feat(ai): 增强上游请求 payload 结构诊断

- 新增 inspect_ai_upstream_logs 的 payload 结构摘要,识别模型、消息角色、工具数量和请求选项

- 补充 includePayloadSummary 参数提示和系统引导

- 补充上游日志探针回归测试
This commit is contained in:
Syngnat
2026-06-11 21:16:39 +08:00
parent 9038fe1bdf
commit 890d693102
6 changed files with 222 additions and 5 deletions

View File

@@ -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', {

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -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,

View File

@@ -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" },
},

View File

@@ -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', () => {