mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-15 19:19:35 +08:00
✨ feat(ai): 增强上游请求 payload 结构诊断
- 新增 inspect_ai_upstream_logs 的 payload 结构摘要,识别模型、消息角色、工具数量和请求选项 - 补充 includePayloadSummary 参数提示和系统引导 - 补充上游日志探针回归测试
This commit is contained in:
@@ -58,9 +58,59 @@ describe('aiLocalToolExecutor inspect_ai_upstream_logs', () => {
|
||||
expect(result.content).toContain('"status":200');
|
||||
expect(result.content).toContain('"bodyPreview"');
|
||||
expect(result.content).toContain('gpt-5.5');
|
||||
expect(result.content).toContain('"bodySummary"');
|
||||
expect(result.content).toContain('"messageCount":1');
|
||||
expect(result.content).toContain('"toolCount":0');
|
||||
expect(result.content).not.toContain('abcdefghijklmnopqrstuvwxyz');
|
||||
});
|
||||
|
||||
it('summarizes payload shape without exposing prompt content when body preview is disabled', async () => {
|
||||
const readAppLogTail = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
logPath: 'C:/Users/demo/.GoNavi/Logs/gonavi.log',
|
||||
keyword: 'AI 上游请求',
|
||||
requestedLineLimit: 160,
|
||||
lines: [
|
||||
'2026/06/11 13:20:00.000000 [INFO] AI 上游请求开始:requestId=openai-tools-123 provider=openai method=POST endpoint=https://api.example.com/v1/chat/completions body={"model":"gpt-5.5","stream":true,"messages":[{"role":"system","content":"system secret password=abc123"},{"role":"user","content":"user private text"}],"tools":[{"type":"function","function":{"name":"inspect_app_health","description":"inspect app","parameters":{"type":"object","properties":{}}}}],"tool_choice":"auto","response_format":{"type":"json_object"},"api_key":"sk-should-not-leak"}',
|
||||
'2026/06/11 13:20:01.000000 [INFO] AI 上游请求完成:requestId=openai-tools-123 provider=openai endpoint=https://api.example.com/v1/chat/completions status=200 duration=981ms',
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeLocalAIToolCall({
|
||||
toolCall: buildToolCall('inspect_ai_upstream_logs', {
|
||||
includeBody: false,
|
||||
}),
|
||||
connections: [],
|
||||
mcpTools: [],
|
||||
toolContextMap: new Map(),
|
||||
runtime: {
|
||||
getDatabases: vi.fn(),
|
||||
getTables: vi.fn(),
|
||||
readAppLogTail,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(readAppLogTail).toHaveBeenCalledWith(160, 'AI 上游请求');
|
||||
expect(result.content).toContain('"payloadSummaryEnabled":true');
|
||||
expect(result.content).not.toContain('"bodyPreview"');
|
||||
expect(result.content).not.toContain('password=abc123');
|
||||
expect(result.content).not.toContain('user private text');
|
||||
expect(result.content).not.toContain('sk-should-not-leak');
|
||||
expect(result.content).toContain('"bodySummary"');
|
||||
expect(result.content).toContain('"model":"gpt-5.5"');
|
||||
expect(result.content).toContain('"messageCount":2');
|
||||
expect(result.content).toContain('"system":1');
|
||||
expect(result.content).toContain('"user":1');
|
||||
expect(result.content).toContain('"toolCount":1');
|
||||
expect(result.content).toContain('"toolNames":["inspect_app_health"]');
|
||||
expect(result.content).toContain('"hasStream":true');
|
||||
expect(result.content).toContain('"hasToolChoice":true');
|
||||
expect(result.content).toContain('"hasResponseFormat":true');
|
||||
});
|
||||
|
||||
it('returns an actionable empty-state message when no upstream request log is available', async () => {
|
||||
const result = await executeLocalAIToolCall({
|
||||
toolCall: buildToolCall('inspect_ai_upstream_logs', {
|
||||
|
||||
@@ -191,6 +191,7 @@ export async function executeDiagnosticsSnapshotToolCall({
|
||||
includeBody: args.includeBody !== false,
|
||||
includeLines: args.includeLines === true,
|
||||
bodyPreviewLimit: args.bodyPreviewLimit,
|
||||
includePayloadSummary: args.includePayloadSummary !== false,
|
||||
})),
|
||||
success: true,
|
||||
};
|
||||
|
||||
@@ -90,7 +90,7 @@ export const appendDatabaseInspectionGuidanceMessages = (
|
||||
messages,
|
||||
availableToolNames,
|
||||
'inspect_ai_upstream_logs',
|
||||
'如果用户提到“AI 上游请求”“请求入参/请求体”“requestId”“发给模型的 payload”“上游接口报错具体传了什么”,优先调用 inspect_ai_upstream_logs 读取脱敏后的真实请求日志,再结合 inspect_ai_providers 或 inspect_ai_message_flow 继续定位。',
|
||||
'如果用户提到“AI 上游请求”“请求入参/请求体”“requestId”“发给模型的 payload”“工具调用没触发”“上游接口报错具体传了什么”,优先调用 inspect_ai_upstream_logs 读取脱敏后的真实请求日志和 payload 结构摘要,再结合 inspect_ai_providers 或 inspect_ai_message_flow 继续定位。',
|
||||
);
|
||||
appendGuidanceIfToolAvailable(
|
||||
messages,
|
||||
|
||||
@@ -23,10 +23,27 @@ interface AIUpstreamLogEvent {
|
||||
status?: number;
|
||||
duration?: string;
|
||||
bodyPreview?: string;
|
||||
bodySummary?: AIUpstreamPayloadSummary;
|
||||
error?: string;
|
||||
line: string;
|
||||
}
|
||||
|
||||
interface AIUpstreamPayloadSummary {
|
||||
parseable: boolean;
|
||||
parseError?: string;
|
||||
topLevelKeys?: string[];
|
||||
model?: string;
|
||||
messageCount?: number;
|
||||
messageRoleCounts?: Record<string, number>;
|
||||
toolCount?: number;
|
||||
toolNames?: string[];
|
||||
hasStream?: boolean;
|
||||
hasToolChoice?: boolean;
|
||||
hasResponseFormat?: boolean;
|
||||
inputTextCharCount?: number;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
interface AIUpstreamRequestSummary {
|
||||
requestId: string;
|
||||
provider: string;
|
||||
@@ -36,6 +53,7 @@ interface AIUpstreamRequestSummary {
|
||||
status?: number;
|
||||
duration?: string;
|
||||
bodyPreview?: string;
|
||||
bodySummary?: AIUpstreamPayloadSummary;
|
||||
error?: string;
|
||||
eventCount: number;
|
||||
hasBody: boolean;
|
||||
@@ -68,6 +86,146 @@ const truncateText = (value: string, limit: number): string => {
|
||||
return `${value.slice(0, limit)}...[truncated ${value.length - limit} chars]`;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const asRecordArray = (value: unknown): Record<string, unknown>[] =>
|
||||
Array.isArray(value) ? value.filter(isRecord) : [];
|
||||
|
||||
const stringValue = (value: unknown): string | undefined =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
|
||||
const addName = (names: Set<string>, value: unknown) => {
|
||||
const normalized = stringValue(value);
|
||||
if (normalized) {
|
||||
names.add(normalized);
|
||||
}
|
||||
};
|
||||
|
||||
const extractToolNames = (body: Record<string, unknown>): string[] => {
|
||||
const names = new Set<string>();
|
||||
asRecordArray(body.tools).forEach((tool) => {
|
||||
addName(names, tool.name);
|
||||
if (isRecord(tool.function)) {
|
||||
addName(names, tool.function.name);
|
||||
}
|
||||
asRecordArray(tool.functionDeclarations).forEach((declaration) => addName(names, declaration.name));
|
||||
asRecordArray(tool.function_declarations).forEach((declaration) => addName(names, declaration.name));
|
||||
});
|
||||
asRecordArray(body.functions).forEach((fn) => addName(names, fn.name));
|
||||
return Array.from(names).slice(0, 30);
|
||||
};
|
||||
|
||||
const countToolDefinitions = (body: Record<string, unknown>, toolNames: string[]): number => {
|
||||
const tools = asRecordArray(body.tools);
|
||||
const functionDeclarationCount = tools.reduce((total, tool) => {
|
||||
const camelDeclarations = asRecordArray(tool.functionDeclarations).length;
|
||||
const snakeDeclarations = asRecordArray(tool.function_declarations).length;
|
||||
return total + camelDeclarations + snakeDeclarations;
|
||||
}, 0);
|
||||
const genericToolCount = tools.length;
|
||||
const legacyFunctionCount = asRecordArray(body.functions).length;
|
||||
return functionDeclarationCount || legacyFunctionCount || genericToolCount || toolNames.length;
|
||||
};
|
||||
|
||||
const normalizeMessageRole = (message: Record<string, unknown>): string => {
|
||||
const role = stringValue(message.role);
|
||||
if (role) return role;
|
||||
if (isRecord(message.author)) {
|
||||
return stringValue(message.author.role) || 'unknown';
|
||||
}
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
const getMessageLikeItems = (body: Record<string, unknown>): Record<string, unknown>[] => {
|
||||
const messages = asRecordArray(body.messages);
|
||||
if (messages.length > 0) return messages;
|
||||
const contents = asRecordArray(body.contents);
|
||||
if (contents.length > 0) return contents;
|
||||
return [];
|
||||
};
|
||||
|
||||
const addTextLength = (value: unknown): number => {
|
||||
if (typeof value === 'string') return value.length;
|
||||
if (Array.isArray(value)) return value.reduce((total, item) => total + addTextLength(item), 0);
|
||||
if (!isRecord(value)) return 0;
|
||||
return ['content', 'text', 'prompt', 'system', 'input'].reduce(
|
||||
(total, key) => total + addTextLength(value[key]),
|
||||
0,
|
||||
);
|
||||
};
|
||||
|
||||
const estimateInputTextChars = (body: Record<string, unknown>): number => {
|
||||
const messageItems = getMessageLikeItems(body);
|
||||
const messageChars = messageItems.reduce((total, item) => total + addTextLength(item), 0);
|
||||
return messageChars
|
||||
+ addTextLength(body.system)
|
||||
+ addTextLength(body.prompt)
|
||||
+ addTextLength(body.input);
|
||||
};
|
||||
|
||||
const summarizeAIUpstreamPayload = (bodyText: string): AIUpstreamPayloadSummary => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(bodyText);
|
||||
} catch (error: any) {
|
||||
return {
|
||||
parseable: false,
|
||||
parseError: String(error?.message || error || 'invalid JSON'),
|
||||
warnings: ['请求 body 不是完整 JSON,可能已被日志截断,无法生成结构化摘要'],
|
||||
};
|
||||
}
|
||||
|
||||
if (!isRecord(parsed)) {
|
||||
return {
|
||||
parseable: false,
|
||||
parseError: 'request body is not a JSON object',
|
||||
warnings: ['请求 body 不是 JSON object,无法识别模型、消息和工具字段'],
|
||||
};
|
||||
}
|
||||
|
||||
const topLevelKeys = Object.keys(parsed).slice(0, 30);
|
||||
const messages = getMessageLikeItems(parsed);
|
||||
const messageRoleCounts = messages.reduce<Record<string, number>>((acc, message) => {
|
||||
const role = normalizeMessageRole(message);
|
||||
acc[role] = (acc[role] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
if (parsed.system !== undefined) {
|
||||
messageRoleCounts.system = (messageRoleCounts.system || 0) + 1;
|
||||
}
|
||||
const toolNames = extractToolNames(parsed);
|
||||
const toolCount = countToolDefinitions(parsed, toolNames);
|
||||
const messageCount = messages.length + (parsed.system !== undefined ? 1 : 0) + (parsed.prompt !== undefined ? 1 : 0);
|
||||
const inputTextCharCount = estimateInputTextChars(parsed);
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (messageCount === 0) {
|
||||
warnings.push('未识别到 messages、contents、system 或 prompt 字段,请确认上游协议是否符合预期');
|
||||
}
|
||||
if (toolCount === 0) {
|
||||
warnings.push('payload 未携带 tools/functions,模型无法发起工具调用');
|
||||
}
|
||||
if (inputTextCharCount > 60000) {
|
||||
warnings.push('输入文本体量较大,必要时先收窄上下文或减少日志/DDL 内容');
|
||||
}
|
||||
|
||||
return {
|
||||
parseable: true,
|
||||
topLevelKeys,
|
||||
model: stringValue(parsed.model) || stringValue(parsed.modelName),
|
||||
messageCount,
|
||||
messageRoleCounts,
|
||||
toolCount,
|
||||
toolNames,
|
||||
hasStream: parsed.stream === true || isRecord(parsed.stream_options),
|
||||
hasToolChoice: parsed.tool_choice !== undefined || parsed.toolChoice !== undefined || parsed.toolConfig !== undefined,
|
||||
hasResponseFormat: parsed.response_format !== undefined || parsed.responseFormat !== undefined || parsed.generationConfig !== undefined,
|
||||
inputTextCharCount,
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const extractField = (line: string, field: string): string => {
|
||||
const match = line.match(new RegExp(`(?:^|[\\s:])${field}=([^\\s]+)`));
|
||||
return match?.[1] || '';
|
||||
@@ -92,6 +250,7 @@ const resolveEventType = (line: string): AIUpstreamLogEventType => {
|
||||
const parseAIUpstreamLogEvent = (
|
||||
line: string,
|
||||
bodyPreviewLimit: number,
|
||||
includePayloadSummary: boolean,
|
||||
): AIUpstreamLogEvent | null => {
|
||||
if (!line.includes('AI 上游请求')) {
|
||||
return null;
|
||||
@@ -116,6 +275,7 @@ const parseAIUpstreamLogEvent = (
|
||||
status: statusText ? Number(statusText) : undefined,
|
||||
duration,
|
||||
bodyPreview: bodyText ? truncateText(redactAIUpstreamLogPreview(bodyText), bodyPreviewLimit) : undefined,
|
||||
bodySummary: bodyText && includePayloadSummary ? summarizeAIUpstreamPayload(bodyText) : undefined,
|
||||
error: errorText ? truncateText(redactAIUpstreamLogPreview(errorText), 2000) : undefined,
|
||||
line: truncateText(redactAIUpstreamLogPreview(line), 4000),
|
||||
};
|
||||
@@ -178,6 +338,7 @@ const summarizeAIUpstreamRequests = (
|
||||
existing.hasBody = true;
|
||||
if (includeBody) existing.bodyPreview = event.bodyPreview;
|
||||
}
|
||||
if (event.bodySummary) existing.bodySummary = event.bodySummary;
|
||||
if (event.error) existing.error = event.error;
|
||||
requestMap.set(requestKey, existing);
|
||||
});
|
||||
@@ -194,6 +355,7 @@ export const buildAIUpstreamLogSnapshot = (params: {
|
||||
includeBody?: unknown;
|
||||
includeLines?: unknown;
|
||||
bodyPreviewLimit?: unknown;
|
||||
includePayloadSummary?: unknown;
|
||||
}) => {
|
||||
const data = params.readResult?.data && typeof params.readResult.data === 'object'
|
||||
? params.readResult.data as Record<string, unknown>
|
||||
@@ -203,9 +365,10 @@ export const buildAIUpstreamLogSnapshot = (params: {
|
||||
const bodyPreviewLimit = clampNumber(params.bodyPreviewLimit, DEFAULT_AI_UPSTREAM_BODY_LIMIT, 200, MAX_AI_UPSTREAM_BODY_LIMIT);
|
||||
const includeBody = params.includeBody !== false;
|
||||
const includeLines = params.includeLines === true;
|
||||
const includePayloadSummary = params.includePayloadSummary !== false;
|
||||
const lines = normalizeLogLines(data.lines);
|
||||
const parsedEvents = lines
|
||||
.map((line) => parseAIUpstreamLogEvent(line, bodyPreviewLimit))
|
||||
.map((line) => parseAIUpstreamLogEvent(line, bodyPreviewLimit, includePayloadSummary))
|
||||
.filter((event): event is AIUpstreamLogEvent => Boolean(event))
|
||||
.filter((event) => matchesFilter(event, params));
|
||||
const eventBreakdown = {
|
||||
@@ -227,6 +390,7 @@ export const buildAIUpstreamLogSnapshot = (params: {
|
||||
returnedLineCount: lines.length,
|
||||
upstreamEventCount: parsedEvents.length,
|
||||
requestCount: requests.length,
|
||||
payloadSummaryEnabled: includePayloadSummary,
|
||||
eventBreakdown,
|
||||
providers,
|
||||
requestIds,
|
||||
|
||||
@@ -153,14 +153,14 @@ export const BUILTIN_AI_INSPECTION_CORE_TOOL_INFO: AIBuiltinToolInfo[] = [
|
||||
icon: "📡",
|
||||
desc: "查看 AI 上游请求入参与状态",
|
||||
detail:
|
||||
"从 gonavi.log 读取最近的 AI 上游请求开始/完成/失败记录,按 provider、requestId 或关键词过滤,返回请求体 body 预览、endpoint、状态码、耗时和错误摘要。适合用户想核对发给上游模型的真实入参、排查请求参数兼容、确认脱敏日志是否写入时先调用。",
|
||||
params: "provider?, requestId?, keyword?, lineLimit?(默认 160), requestLimit?(默认 12), includeBody?(默认 true), includeLines?(默认 false)",
|
||||
"从 gonavi.log 读取最近的 AI 上游请求开始/完成/失败记录,按 provider、requestId 或关键词过滤,返回请求体 body 预览、payload 结构摘要、endpoint、状态码、耗时和错误摘要。适合用户想核对发给上游模型的真实入参、排查请求参数兼容、确认工具是否随请求下发或脱敏日志是否写入时先调用。",
|
||||
params: "provider?, requestId?, keyword?, lineLimit?(默认 160), requestLimit?(默认 12), includeBody?(默认 true), includePayloadSummary?(默认 true), includeLines?(默认 false)",
|
||||
tool: {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "inspect_ai_upstream_logs",
|
||||
description:
|
||||
"读取 GoNavi 应用日志中的 AI 上游请求记录,返回 requestId、provider、method、endpoint、请求 body 预览、状态码、耗时和错误摘要。适用于用户提到 AI 请求入参、上游请求体、requestId、provider 请求参数、模型接口报错、或需要核对刚才发给上游模型的真实 payload 时,先读取该工具,不要只凭界面响应推断。",
|
||||
"读取 GoNavi 应用日志中的 AI 上游请求记录,返回 requestId、provider、method、endpoint、请求 body 预览、脱敏 payload 结构摘要、状态码、耗时和错误摘要。适用于用户提到 AI 请求入参、上游请求体、requestId、provider 请求参数、工具调用没有触发、模型接口报错、或需要核对刚才发给上游模型的真实 payload 时,先读取该工具,不要只凭界面响应推断。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -170,6 +170,7 @@ export const BUILTIN_AI_INSPECTION_CORE_TOOL_INFO: AIBuiltinToolInfo[] = [
|
||||
lineLimit: { type: "number", description: "可选,最多读取多少行日志尾部,默认 160,最大 300" },
|
||||
requestLimit: { type: "number", description: "可选,最多返回多少个请求摘要,默认 12,最大 40" },
|
||||
includeBody: { type: "boolean", description: "可选,是否返回已脱敏的请求 body 预览,默认 true;只看状态时可设为 false" },
|
||||
includePayloadSummary: { type: "boolean", description: "可选,是否解析请求 body 并返回模型、消息角色分布、工具数量/名称、stream/tool_choice 等结构摘要,默认 true;不返回消息正文或密钥" },
|
||||
includeLines: { type: "boolean", description: "可选,是否附带脱敏后的原始日志行,默认 false;需要引用原文时再开启" },
|
||||
bodyPreviewLimit: { type: "number", description: "可选,单个 body 预览最大字符数,默认 6000,最大 12000" },
|
||||
},
|
||||
|
||||
@@ -99,6 +99,7 @@ describe('aiToolRegistry', () => {
|
||||
expect(info?.desc).toContain('上游请求入参');
|
||||
expect(info?.tool.function.description).toContain('请求 body 预览');
|
||||
expect(info?.tool.function.parameters?.properties?.requestId?.description).toContain('requestId');
|
||||
expect(info?.tool.function.parameters?.properties?.includePayloadSummary?.description).toContain('工具数量');
|
||||
});
|
||||
|
||||
it('registers the ai-tool-catalog inspector as a builtin tool', () => {
|
||||
|
||||
Reference in New Issue
Block a user