新增后台架构前端状态层
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user