From cd661b99c789a67d8ff622b9a58d8583d09c446d Mon Sep 17 00:00:00 2001 From: czj <13261895355@163.com> Date: Thu, 16 Jul 2026 12:31:16 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=90=8E=E5=8F=B0=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E5=89=8D=E7=AB=AF=E7=8A=B6=E6=80=81=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../architecture/__tests__/api-client.test.ts | 41 +++ .../__tests__/trace-state.test.ts | 134 +++++++++ src/components/architecture/api-client.ts | 44 +++ src/components/architecture/trace-state.ts | 255 ++++++++++++++++++ 4 files changed, 474 insertions(+) create mode 100644 src/components/architecture/__tests__/api-client.test.ts create mode 100644 src/components/architecture/__tests__/trace-state.test.ts create mode 100644 src/components/architecture/api-client.ts create mode 100644 src/components/architecture/trace-state.ts diff --git a/src/components/architecture/__tests__/api-client.test.ts b/src/components/architecture/__tests__/api-client.test.ts new file mode 100644 index 0000000..6a30697 --- /dev/null +++ b/src/components/architecture/__tests__/api-client.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { getTracePayload } from "../api-client"; + +describe("architecture trace API client", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("loads request bodies only when explicitly requested", async () => { + const exactRequest = { + model: "deepseek-v4-pro", + messages: [{ role: "user", content: "完整原文" }], + }; + const fetchMock = vi.fn(async () => Response.json(exactRequest)); + vi.stubGlobal("fetch", fetchMock); + + await expect( + getTracePayload("job_1", "llmcall_1", "request", "test-key"), + ).resolves.toEqual(exactRequest); + expect(fetchMock).toHaveBeenCalledWith( + "/api/jobs/job_1/llm-trace/llmcall_1/request", + expect.objectContaining({ + credentials: "same-origin", + headers: { "x-api-key": "test-key" }, + cache: "no-store", + }), + ); + }); + + it("surfaces protected API errors without returning partial data", async () => { + vi.stubGlobal("fetch", vi.fn(async () => Response.json( + { error: "Unauthorized" }, + { status: 401 }, + ))); + + await expect( + getTracePayload("job_1", "llmcall_1", "response", "wrong"), + ).rejects.toThrow("Unauthorized"); + }); +}); diff --git a/src/components/architecture/__tests__/trace-state.test.ts b/src/components/architecture/__tests__/trace-state.test.ts new file mode 100644 index 0000000..f6f81ce --- /dev/null +++ b/src/components/architecture/__tests__/trace-state.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; + +import type { + LlmTraceCallPublic, + LlmTraceManifest, + LlmTraceRun, +} from "../../../lib/llm/trace-types"; +import { + applyLiveTraceEvent, + deriveArchitectureNodes, +} from "../trace-state"; + +const run: LlmTraceRun = { + job_id: "job_1", + case_id: "case_1", + status: "running", + current_stage: "qa", + trace_completeness: "complete", + error_stage: null, + error_summary: null, + started_at: "2026-07-16T00:00:00.000Z", + finished_at: null, + updated_at: "2026-07-16T00:00:01.000Z", +}; + +function call( + overrides: Partial = {}, +): LlmTraceCallPublic { + return { + call_id: "llmcall_1", + job_id: "job_1", + sequence: 1, + task: "quality_inspector", + workflow_stage: "qa", + rewrite_round: 0, + provider: "deepseek", + model: "deepseek-v4-pro", + status: "validated", + token_usage: { total_tokens: 14 }, + schema_name: "llmQaPatchSchema", + schema_valid: true, + validation_issues: [], + business_status: "pass", + duration_ms: 1200, + started_at: "2026-07-16T00:00:00.000Z", + responded_at: "2026-07-16T00:00:01.000Z", + validated_at: "2026-07-16T00:00:01.200Z", + failed_at: null, + error_type: null, + error_summary: null, + request_available: true, + response_available: true, + ...overrides, + }; +} + +describe("architecture trace state", () => { + it("distinguishes QA business failure from schema failure", () => { + const nodes = deriveArchitectureNodes(run, [ + call({ schema_valid: true, business_status: "fail" }), + ]); + expect(nodes.qa).toMatchObject({ + status: "completed", + detail: "检查完成,需要修复", + }); + + const failedNodes = deriveArchitectureNodes(run, [ + call({ schema_valid: false, status: "failed" }), + ]); + expect(failedNodes.qa.status).toBe("failed"); + }); + + it("merges repeated live events by call id and preserves sequence order", () => { + const initial: LlmTraceManifest = { run, calls: [] }; + const started = applyLiveTraceEvent(initial, { + type: "llm_call_started", + job_id: "job_1", + call_id: "llmcall_2", + sequence: 2, + task: "targeted_rewriter", + workflow_stage: "rewrite", + rewrite_round: 1, + provider: "deepseek", + model: "deepseek-v4-pro", + started_at: "2026-07-16T00:00:02.000Z", + request_available: true, + }); + const responded = applyLiveTraceEvent(started, { + type: "llm_call_responded", + job_id: "job_1", + call_id: "llmcall_2", + duration_ms: 1500, + token_usage: { total_tokens: 20 }, + responded_at: "2026-07-16T00:00:03.500Z", + response_available: true, + }); + const duplicate = applyLiveTraceEvent(responded, { + type: "llm_call_responded", + job_id: "job_1", + call_id: "llmcall_2", + duration_ms: 1500, + token_usage: { total_tokens: 20 }, + responded_at: "2026-07-16T00:00:03.500Z", + response_available: true, + }); + + expect(duplicate.calls).toHaveLength(1); + expect(duplicate.calls[0]).toMatchObject({ + call_id: "llmcall_2", + status: "responded", + response_available: true, + token_usage: { total_tokens: 20 }, + }); + expect(duplicate.run.current_stage).toBe("rewrite"); + }); + + it("marks only trace completeness when a trace warning arrives", () => { + const manifest = applyLiveTraceEvent( + { run, calls: [call()] }, + { + type: "trace_warning", + job_id: "job_1", + trace_completeness: "incomplete", + error_summary: "Error: payload unavailable", + }, + ); + + expect(manifest.run).toMatchObject({ + status: "running", + trace_completeness: "incomplete", + }); + expect(manifest.calls[0].status).toBe("validated"); + }); +}); diff --git a/src/components/architecture/api-client.ts b/src/components/architecture/api-client.ts new file mode 100644 index 0000000..43ace62 --- /dev/null +++ b/src/components/architecture/api-client.ts @@ -0,0 +1,44 @@ +import type { LlmTraceManifest } from "../../lib/llm/trace-types"; + +function traceHeaders(apiAccessKey: string): Record { + return apiAccessKey ? { "x-api-key": apiAccessKey } : {}; +} + +async function traceFetch(path: string, apiAccessKey: string): Promise { + const response = await fetch(path, { + credentials: "same-origin", + headers: traceHeaders(apiAccessKey), + cache: "no-store", + }); + const body = await response.json().catch(() => ({})) as T & { + error?: string; + }; + if (!response.ok) throw new Error(body.error ?? "读取后台追踪失败"); + return body; +} + +export function getLatestTrace(apiAccessKey: string) { + return traceFetch( + "/api/llm-traces/latest", + apiAccessKey, + ); +} + +export function getJobTrace(jobId: string, apiAccessKey: string) { + return traceFetch( + `/api/jobs/${encodeURIComponent(jobId)}/llm-trace`, + apiAccessKey, + ); +} + +export function getTracePayload( + jobId: string, + callId: string, + kind: "request" | "response", + apiAccessKey: string, +) { + return traceFetch( + `/api/jobs/${encodeURIComponent(jobId)}/llm-trace/${encodeURIComponent(callId)}/${kind}`, + apiAccessKey, + ); +} diff --git a/src/components/architecture/trace-state.ts b/src/components/architecture/trace-state.ts new file mode 100644 index 0000000..a692416 --- /dev/null +++ b/src/components/architecture/trace-state.ts @@ -0,0 +1,255 @@ +import type { + LlmTraceCallPublic, + LlmTraceManifest, + LlmTraceRun, + LlmTraceStreamEvent, + LlmTraceWorkflowStage, +} from "../../lib/llm/trace-types"; + +export type ArchitectureNodeStatus = + | "waiting" + | "running" + | "completed" + | "failed"; + +export interface ArchitectureNodeView { + id: "input" | "fact_card" | "draft" | "qa" | "rewrite" | "final"; + label: string; + status: ArchitectureNodeStatus; + detail: string; +} + +const nodeDefinitions: Array> = [ + { id: "input", label: "输入归一化" }, + { id: "fact_card", label: "事实提取" }, + { id: "draft", label: "生成草稿" }, + { id: "qa", label: "质量检查" }, + { id: "rewrite", label: "定向修复" }, + { id: "final", label: "保存与导出" }, +]; + +function waitingNodes() { + return Object.fromEntries(nodeDefinitions.map((node) => [ + node.id, + { ...node, status: "waiting", detail: "等待执行" }, + ])) as Record; +} + +function updateCall( + calls: LlmTraceCallPublic[], + callId: string, + update: (call: LlmTraceCallPublic) => LlmTraceCallPublic, +) { + return calls + .map((call) => call.call_id === callId ? update(call) : call) + .sort((left, right) => left.sequence - right.sequence); +} + +export function applyLiveTraceEvent( + state: LlmTraceManifest, + event: LlmTraceStreamEvent, +): LlmTraceManifest { + if (event.job_id !== state.run.job_id) return state; + + if (event.type === "trace_warning") { + return { + ...state, + run: { + ...state.run, + trace_completeness: "incomplete", + updated_at: new Date().toISOString(), + }, + }; + } + + if (event.type === "llm_call_started") { + const call: LlmTraceCallPublic = { + call_id: event.call_id, + job_id: event.job_id, + sequence: event.sequence, + task: event.task, + workflow_stage: event.workflow_stage, + rewrite_round: event.rewrite_round, + provider: event.provider, + model: event.model, + status: "started", + token_usage: null, + schema_name: null, + schema_valid: null, + validation_issues: [], + business_status: null, + duration_ms: null, + started_at: event.started_at, + responded_at: null, + validated_at: null, + failed_at: null, + error_type: null, + error_summary: null, + request_available: event.request_available, + response_available: false, + }; + const withoutExisting = state.calls.filter( + (existing) => existing.call_id !== event.call_id, + ); + return { + run: { + ...state.run, + current_stage: event.workflow_stage, + updated_at: event.started_at, + }, + calls: [...withoutExisting, call].sort( + (left, right) => left.sequence - right.sequence, + ), + }; + } + + if (event.type === "llm_call_responded") { + return { + ...state, + run: { ...state.run, updated_at: event.responded_at }, + calls: updateCall(state.calls, event.call_id, (call) => ({ + ...call, + status: "responded", + duration_ms: event.duration_ms, + token_usage: event.token_usage, + responded_at: event.responded_at, + response_available: event.response_available, + })), + }; + } + + if (event.type === "llm_call_validated") { + return { + ...state, + run: { ...state.run, updated_at: event.validated_at }, + calls: updateCall(state.calls, event.call_id, (call) => ({ + ...call, + status: "validated", + schema_name: event.schema_name, + schema_valid: event.schema_valid, + validation_issues: event.validation_issues, + validated_at: event.validated_at, + })), + }; + } + + return { + ...state, + run: { + ...state.run, + status: "failed", + error_stage: state.run.current_stage, + error_summary: event.error_summary, + updated_at: event.failed_at, + }, + calls: updateCall(state.calls, event.call_id, (call) => ({ + ...call, + status: "failed", + failed_at: event.failed_at, + error_type: event.error_type, + error_summary: event.error_summary, + })), + }; +} + +function callDetail(call: LlmTraceCallPublic) { + if (call.status === "failed" || call.schema_valid === false) { + if (call.error_type === "provider") return "模型服务调用失败"; + if (call.error_type === "json_parse") return "响应 JSON 解析失败"; + return "Schema 校验失败"; + } + if (call.status === "started" || call.status === "responded") { + return "LLM 调用进行中"; + } + if (call.task === "quality_inspector" && call.business_status === "fail") { + return "检查完成,需要修复"; + } + if (call.task === "quality_inspector" && call.business_status === "warn") { + return "检查完成,存在警告"; + } + return "LLM 调用完成"; +} + +function statusForCall(call: LlmTraceCallPublic): ArchitectureNodeStatus { + if (call.status === "failed" || call.schema_valid === false) return "failed"; + if (call.status === "started" || call.status === "responded") return "running"; + return "completed"; +} + +function isNodeStage( + stage: LlmTraceWorkflowStage, +): stage is ArchitectureNodeView["id"] { + return nodeDefinitions.some((node) => node.id === stage); +} + +export function deriveArchitectureNodes( + run: LlmTraceRun | null, + calls: LlmTraceCallPublic[], +): Record { + const nodes = waitingNodes(); + if (!run) return nodes; + + nodes.input = { + ...nodes.input, + status: "completed", + detail: "输入已规范化", + }; + + const latestByStage = new Map(); + for (const call of [...calls].sort((left, right) => left.sequence - right.sequence)) { + latestByStage.set(call.workflow_stage, call); + } + for (const [stage, call] of latestByStage) { + if (!isNodeStage(stage)) continue; + nodes[stage] = { + ...nodes[stage], + status: statusForCall(call), + detail: callDetail(call), + }; + } + + if (isNodeStage(run.current_stage) && nodes[run.current_stage].status === "waiting") { + nodes[run.current_stage] = { + ...nodes[run.current_stage], + status: run.status === "failed" ? "failed" : "running", + detail: run.status === "failed" ? "任务在此阶段失败" : "后台正在执行", + }; + } + + const progression: ArchitectureNodeView["id"][] = [ + "input", + "fact_card", + "draft", + "qa", + "rewrite", + "final", + ]; + const currentIndex = progression.indexOf( + run.current_stage as ArchitectureNodeView["id"], + ); + if (currentIndex > 0) { + for (const stage of progression.slice(0, currentIndex)) { + if (stage === "rewrite" && !latestByStage.has("rewrite")) continue; + if (nodes[stage].status === "waiting") { + nodes[stage] = { + ...nodes[stage], + status: "completed", + detail: "阶段已完成", + }; + } + } + } + + if (run.status === "completed") { + nodes.final = { + ...nodes.final, + status: "completed", + detail: "终稿与导出已保存", + }; + if (nodes.rewrite.status === "waiting") { + nodes.rewrite.detail = "本次未触发修复"; + } + } + + return nodes; +}