新增后台架构前端状态层

This commit is contained in:
czj
2026-07-16 12:31:16 +08:00
parent 8a14ce8722
commit cd661b99c7
4 changed files with 474 additions and 0 deletions
@@ -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");
});
});
@@ -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> = {},
): 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");
});
});
+44
View File
@@ -0,0 +1,44 @@
import type { LlmTraceManifest } from "../../lib/llm/trace-types";
function traceHeaders(apiAccessKey: string): Record<string, string> {
return apiAccessKey ? { "x-api-key": apiAccessKey } : {};
}
async function traceFetch<T>(path: string, apiAccessKey: string): Promise<T> {
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<LlmTraceManifest | { run: null; calls: [] }>(
"/api/llm-traces/latest",
apiAccessKey,
);
}
export function getJobTrace(jobId: string, apiAccessKey: string) {
return traceFetch<LlmTraceManifest>(
`/api/jobs/${encodeURIComponent(jobId)}/llm-trace`,
apiAccessKey,
);
}
export function getTracePayload(
jobId: string,
callId: string,
kind: "request" | "response",
apiAccessKey: string,
) {
return traceFetch<unknown>(
`/api/jobs/${encodeURIComponent(jobId)}/llm-trace/${encodeURIComponent(callId)}/${kind}`,
apiAccessKey,
);
}
+255
View File
@@ -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<Pick<ArchitectureNodeView, "id" | "label">> = [
{ 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<ArchitectureNodeView["id"], ArchitectureNodeView>;
}
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<ArchitectureNodeView["id"], ArchitectureNodeView> {
const nodes = waitingNodes();
if (!run) return nodes;
nodes.input = {
...nodes.input,
status: "completed",
detail: "输入已规范化",
};
const latestByStage = new Map<LlmTraceWorkflowStage, LlmTraceCallPublic>();
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;
}