Files
GEOAgentArticleOptimizer/docs/superpowers/plans/2026-07-16-llm-architecture-observability-tab.md
T
2026-07-16 11:54:35 +08:00

1889 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# LLM 后台架构观测标签页实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在首页增加只读的 `后台架构` 标签,以真实后端事件驱动固定架构图,并让授权用户查看当前或最近一次任务的完整 LLM 请求、完整响应和校验结果。
**Architecture:** `src/lib/llm/client.ts` 产生包含真实 SDK 对象的内部追踪事件,独立追踪收集器把索引写入 D1/SQLite、把完整正文写入现有私有 R2/本地数据目录,并向现有 NDJSON 流发送不含正文的状态事件。前端共享当前任务事件,刷新后通过受保护的只读 API 恢复追踪清单,并按需加载请求或响应正文。
**Tech Stack:** Next.js 16、React 19、TypeScript、Vitest、Playwright、Zod、OpenAI-compatible SDK、Cloudflare Workers、D1、R2、better-sqlite3。
---
## 实施前文件结构
### 新建文件
- `migrations/0004_llm_trace_observability.sql`:生产 D1 追踪表和索引。
- `src/lib/llm/trace-types.ts`:Provider、任务、运行、调用、内部事件、公开流事件和 API 类型。
- `src/lib/llm/trace-repository.ts`:追踪索引仓储接口和运行时选择。
- `src/lib/llm/sqlite-trace-repository.ts`:本地 SQLite 追踪索引实现。
- `src/lib/llm/d1-trace-repository.ts`Cloudflare D1 追踪索引实现。
- `src/lib/llm/trace-payload-store.ts`:本地目录与 R2 的完整 JSON 正文存储。
- `src/lib/llm/trace-recorder.ts`:内部 LLM 事件、工作流事件、持久化、公开事件和保留策略协调器。
- `src/lib/llm/trace-http.ts`:受保护追踪 API 的无缓存 JSON 响应和正文读取助手。
- `src/lib/llm/__tests__/trace-repository.test.ts`SQLite 追踪仓储测试。
- `src/lib/llm/__tests__/d1-trace-repository.test.ts`D1 追踪仓储测试。
- `src/lib/llm/__tests__/trace-payload-store.test.ts`:本地和 R2 正文存储测试。
- `src/lib/llm/__tests__/trace-recorder.test.ts`:事件顺序、业务结果、失败隔离和保留测试。
- `src/app/api/llm-traces/latest/route.ts`:当前或最近终态追踪入口。
- `src/app/api/jobs/[jobId]/llm-trace/route.ts`:任务追踪清单入口。
- `src/app/api/jobs/[jobId]/llm-trace/[callId]/request/route.ts`:完整请求入口。
- `src/app/api/jobs/[jobId]/llm-trace/[callId]/response/route.ts`:完整响应入口。
- `src/app/api/__tests__/llm-traces.test.ts`:追踪 API 鉴权、正文和缓存测试。
- `src/components/architecture/api-client.ts`:浏览器端追踪 API 客户端。
- `src/components/architecture/trace-state.ts`:实时事件归并和固定架构节点派生。
- `src/components/architecture/architecture-flow.tsx`:固定拓扑架构图。
- `src/components/architecture/llm-call-detail.tsx`:请求、响应、校验详情。
- `src/components/architecture/architecture-observer-panel.tsx`:只读观测标签页容器。
- `src/components/architecture/__tests__/api-client.test.ts`:按需正文请求测试。
- `src/components/architecture/__tests__/trace-state.test.ts`:状态归并和业务/技术失败区分测试。
- `tests/e2e/architecture-observer.spec.ts`:标签切换、调用选择和技术详情端到端测试。
### 修改文件
- `src/lib/db/schema.ts`:本地 SQLite 创建追踪表。
- `src/lib/llm/audit.ts`:从共享类型导入 Provider 和任务名。
- `src/lib/llm/client.ts`:记录真实请求、响应、解析、校验和安全错误。
- `src/lib/llm/__tests__/client.test.ts`:请求/响应深度相等和事件顺序测试。
- `src/lib/workflow/fact-extractor.ts`:传递事实提取追踪上下文。
- `src/lib/workflow/article-optimizer.ts`:传递生成草稿追踪上下文。
- `src/lib/workflow/quality-inspector.ts`:传递 QA 和复检轮次追踪上下文。
- `src/lib/workflow/targeted-rewriter.ts`:传递定向修复轮次追踪上下文。
- `src/lib/workflow/streaming-optimizer.ts`:向追踪器转发工作流事件和轮次。
- `src/lib/workflow/stream-events.ts`:公开 LLM 状态事件加入 NDJSON 联合类型。
- `src/lib/workflow/__tests__/stream-events.test.ts`:新事件编码和分块解析测试。
- `src/app/api/jobs/optimize-stream/route.ts`:创建、连接和结束追踪器。
- `src/app/api/__tests__/jobs.test.ts`:真实调用状态事件和失败现场测试。
- `src/app/page.tsx`:第三标签、实时追踪状态和只读组件接入。
- `src/app/globals.css`:架构图、调用轨迹、详情和窄屏布局。
- `tests/e2e/mvp.spec.ts`:把旧的“分析—确认—优化”流程断言更新为当前一键优化界面。
- `tests/e2e/sample-flow/page-flow.ts`:真实 Provider 样例核对追踪接口,不保存客户正文。
### 明确不修改
- `wrangler.jsonc`:复用现有私有 `EXPORT_BUCKET`,不增加绑定。
- `README.md`:本次规格已经完整记录功能,不扩大文档范围。
- 用户当前未提交的样例、导出目录和本地数据。
## Task 1:定义追踪契约和公开流事件
**Files:**
- Create: `src/lib/llm/trace-types.ts`
- Modify: `src/lib/llm/audit.ts`
- Modify: `src/lib/llm/client.ts`
- Modify: `src/lib/workflow/stream-events.ts`
- Test: `src/lib/workflow/__tests__/stream-events.test.ts`
- [ ] **Step 1:先写公开追踪事件的失败测试**
`src/lib/workflow/__tests__/stream-events.test.ts` 增加:
~~~ts
it("encodes LLM call metadata without raw request or response bodies", () => {
const event: OptimizationStreamEvent = {
type: "llm_call_started",
job_id: "job_123",
call_id: "llmcall_1",
sequence: 1,
task: "fact_extractor",
workflow_stage: "fact_card",
rewrite_round: null,
provider: "deepseek",
model: "deepseek-v4-pro",
started_at: "2026-07-16T00:00:00.000Z",
request_available: true,
};
const encoded = encodeOptimizationStreamEvent(event);
expect(JSON.parse(encoded)).toEqual(event);
expect(encoded).not.toContain("messages");
expect(encoded).not.toContain("Authorization");
});
~~~
- [ ] **Step 2:运行测试并确认类型或断言失败**
Run: `npm test -- src/lib/workflow/__tests__/stream-events.test.ts`
Expected: FAIL,因为 `OptimizationStreamEvent` 尚不接受 `llm_call_started`
- [ ] **Step 3:创建共享类型并接入现有类型引用**
`src/lib/llm/trace-types.ts` 必须定义以下完整公共边界:
~~~ts
export type LlmProviderName = "deepseek" | "openai";
export type LlmTaskName =
| "unknown"
| "fact_extractor"
| "article_optimizer"
| "quality_inspector"
| "targeted_rewriter"
| "renwei_copy_optimizer";
export type LlmTraceWorkflowStage =
| "unknown"
| "input"
| "fact_card"
| "draft"
| "qa"
| "rewrite"
| "final";
export type LlmTraceRunStatus =
| "running"
| "completed"
| "failed"
| "interrupted";
export type LlmTraceCallStatus =
| "started"
| "responded"
| "validated"
| "failed";
export type LlmTraceCompleteness = "complete" | "incomplete";
export type LlmBusinessStatus = "pass" | "warn" | "fail";
export type LlmTraceErrorType =
| "provider"
| "json_parse"
| "schema_validation";
export interface LlmTraceContext {
workflow_stage: LlmTraceWorkflowStage;
rewrite_round?: number;
schema_name?: string;
}
export interface LlmTraceRun {
job_id: string;
case_id: string | null;
status: LlmTraceRunStatus;
current_stage: LlmTraceWorkflowStage;
trace_completeness: LlmTraceCompleteness;
error_stage: string | null;
error_summary: string | null;
started_at: string;
finished_at: string | null;
updated_at: string;
}
export interface LlmTraceCall {
call_id: string;
job_id: string;
sequence: number;
task: LlmTaskName;
workflow_stage: LlmTraceWorkflowStage;
rewrite_round: number | null;
provider: LlmProviderName;
model: string;
status: LlmTraceCallStatus;
request_object_key: string | null;
response_object_key: string | null;
token_usage: Record<string, number> | null;
schema_name: string | null;
schema_valid: boolean | null;
validation_issues: string[];
business_status: LlmBusinessStatus | null;
duration_ms: number | null;
started_at: string;
responded_at: string | null;
validated_at: string | null;
failed_at: string | null;
error_type: LlmTraceErrorType | null;
error_summary: string | null;
}
export type LlmTraceCallPublic = Omit<
LlmTraceCall,
"request_object_key" | "response_object_key"
> & {
request_available: boolean;
response_available: boolean;
};
export interface LlmTraceManifest {
run: LlmTraceRun;
calls: LlmTraceCallPublic[];
}
export type LlmClientTraceEvent =
| {
type: "started";
call_id: string;
task: LlmTaskName;
context: LlmTraceContext;
provider: LlmProviderName;
model: string;
request: unknown;
started_at: string;
}
| {
type: "responded";
call_id: string;
response: unknown;
duration_ms: number;
responded_at: string;
}
| {
type: "validated";
call_id: string;
schema_name: string;
schema_valid: boolean;
validation_issues: string[];
validated_at: string;
}
| {
type: "failed";
call_id: string;
error_type: LlmTraceErrorType;
error_summary: string;
duration_ms: number;
failed_at: string;
};
export type LlmClientTraceHandler = (
event: LlmClientTraceEvent,
) => void | Promise<void>;
export type LlmTraceStreamEvent =
| {
type: "llm_call_started";
job_id: string;
call_id: string;
sequence: number;
task: LlmTaskName;
workflow_stage: LlmTraceWorkflowStage;
rewrite_round: number | null;
provider: LlmProviderName;
model: string;
started_at: string;
request_available: boolean;
}
| {
type: "llm_call_responded";
job_id: string;
call_id: string;
duration_ms: number;
token_usage: Record<string, number> | null;
responded_at: string;
response_available: boolean;
}
| {
type: "llm_call_validated";
job_id: string;
call_id: string;
schema_name: string;
schema_valid: boolean;
validation_issues: string[];
validated_at: string;
}
| {
type: "llm_call_failed";
job_id: string;
call_id: string;
error_type: LlmTraceErrorType;
error_summary: string;
failed_at: string;
}
| {
type: "trace_warning";
job_id: string;
trace_completeness: "incomplete";
error_summary: string;
};
~~~
`LlmTaskName``client.ts` 移到该文件;`audit.ts``client.ts` 都从该文件导入。`stream-events.ts` 使用:
~~~ts
import type { LlmTraceStreamEvent } from "../llm/trace-types";
type ExistingOptimizationStreamEvents =
| {
type: "job_created";
job: { id: string };
case?: { id: string; case_type: "article" };
}
| { type: "fact_card_ready"; job_id: string; fact_card: OptimizationFactCard }
| { type: "draft_started"; job_id: string; message: string }
| { type: "draft_ready"; job_id: string; article: OptimizedArticle }
| { type: "qa_started"; job_id: string; message: string }
| { type: "qa_ready"; job_id: string; qa_report: QaReport }
| { type: "rewrite_started"; job_id: string; round: number }
| {
type: "rewrite_ready";
job_id: string;
round: number;
article: OptimizedArticle;
}
| {
type: "final_ready";
job_id: string;
case?: { id: string; case_type: "article" };
result_version?: { id: string; version: number };
optimized_article: OptimizedArticle;
qa_report: QaReport;
export_paths: Record<string, string>;
}
| {
type: "failed";
job_id?: string;
case?: { id: string; case_type: "article" };
result_version?: { id: string; version: number };
stage: OptimizationStreamStage;
error: string;
};
export type OptimizationStreamEvent =
| LlmTraceStreamEvent
| ExistingOptimizationStreamEvents;
~~~
现有业务事件字段和值保持原样,只增加类型别名和新的联合分支。
- [ ] **Step 4:运行流事件测试并确认通过**
Run: `npm test -- src/lib/workflow/__tests__/stream-events.test.ts`
Expected: PASS,且现有分块 NDJSON 测试继续通过。
- [ ] **Step 5:提交契约变更**
~~~bash
git add src/lib/llm/trace-types.ts src/lib/llm/audit.ts src/lib/llm/client.ts src/lib/workflow/stream-events.ts src/lib/workflow/__tests__/stream-events.test.ts
git commit -m "定义LLM追踪事件契约"
~~~
## Task 2:增加 D1 和 SQLite 追踪索引
**Files:**
- Create: `migrations/0004_llm_trace_observability.sql`
- Create: `src/lib/llm/trace-repository.ts`
- Create: `src/lib/llm/sqlite-trace-repository.ts`
- Create: `src/lib/llm/d1-trace-repository.ts`
- Create: `src/lib/llm/__tests__/trace-repository.test.ts`
- Create: `src/lib/llm/__tests__/d1-trace-repository.test.ts`
- Modify: `src/lib/db/schema.ts`
- [ ] **Step 1:写 SQLite 仓储失败测试**
测试必须先创建真实文章任务,再验证运行、调用、最近任务和删除:
~~~ts
it("stores a run and ordered calls, then deletes only trace rows", async () => {
const appRepository = createSqliteRepository(dbPath);
const job = await appRepository.createArticleJob({
source_title: "Title",
source_body: "Body",
image_inputs: [],
publish_platform: "official_site",
user_instructions: "",
});
const repository = createSqliteTraceRepository(dbPath);
await repository.putRun(runFixture(job.id));
await repository.putCall(callFixture(job.id, "llmcall_1", 1));
await repository.putCall(callFixture(job.id, "llmcall_2", 2));
await expect(repository.getLatestRun()).resolves.toMatchObject({
job_id: job.id,
status: "running",
});
await expect(repository.listCalls(job.id)).resolves.toEqual([
expect.objectContaining({ call_id: "llmcall_1", sequence: 1 }),
expect.objectContaining({ call_id: "llmcall_2", sequence: 2 }),
]);
await repository.deleteRun(job.id);
await expect(appRepository.getArticleJob(job.id)).resolves.not.toBeNull();
await expect(repository.getRun(job.id)).resolves.toBeNull();
});
~~~
- [ ] **Step 2:运行测试并确认表或模块不存在**
Run: `npm test -- src/lib/llm/__tests__/trace-repository.test.ts`
Expected: FAIL,模块或 `llm_trace_runs` 表不存在。
- [ ] **Step 3:添加完整迁移和本地 Schema**
`migrations/0004_llm_trace_observability.sql`
~~~sql
CREATE TABLE IF NOT EXISTS llm_trace_runs (
job_id TEXT PRIMARY KEY,
case_id TEXT,
status TEXT NOT NULL,
current_stage TEXT NOT NULL,
trace_completeness TEXT NOT NULL,
error_stage TEXT,
error_summary TEXT,
started_at TEXT NOT NULL,
finished_at TEXT,
updated_at TEXT NOT NULL,
FOREIGN KEY (job_id) REFERENCES article_jobs(id) ON DELETE CASCADE,
FOREIGN KEY (case_id) REFERENCES optimization_cases(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS llm_trace_calls (
call_id TEXT PRIMARY KEY,
job_id TEXT NOT NULL,
sequence INTEGER NOT NULL,
task TEXT NOT NULL,
workflow_stage TEXT NOT NULL,
rewrite_round INTEGER,
provider TEXT NOT NULL,
model TEXT NOT NULL,
status TEXT NOT NULL,
request_object_key TEXT,
response_object_key TEXT,
token_usage TEXT,
schema_name TEXT,
schema_valid INTEGER,
validation_issues TEXT NOT NULL DEFAULT '[]',
business_status TEXT,
duration_ms INTEGER,
started_at TEXT NOT NULL,
responded_at TEXT,
validated_at TEXT,
failed_at TEXT,
error_type TEXT,
error_summary TEXT,
UNIQUE (job_id, sequence),
FOREIGN KEY (job_id) REFERENCES llm_trace_runs(job_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_llm_trace_runs_status_updated
ON llm_trace_runs(status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_llm_trace_calls_job_sequence
ON llm_trace_calls(job_id, sequence);
~~~
把同一表结构加入 `initializeSchema()`,保持本地 SQLite 与 D1 一致。
- [ ] **Step 4:定义仓储接口和运行时选择**
`trace-repository.ts`
~~~ts
export interface LlmTraceRepository {
putRun(run: LlmTraceRun): Promise<void>;
putCall(call: LlmTraceCall): Promise<void>;
getRun(jobId: string): Promise<LlmTraceRun | null>;
getLatestRun(): Promise<LlmTraceRun | null>;
listCalls(jobId: string): Promise<LlmTraceCall[]>;
listTerminalRunsExcept(jobId: string): Promise<LlmTraceRun[]>;
deleteRun(jobId: string): Promise<void>;
}
export function getLlmTraceRepositoryFromRuntime(): LlmTraceRepository {
if (process.env.APP_RUNTIME === "cloudflare") {
const env = getAppCloudflareEnv();
if (!env?.DB) throw new Error("Cloudflare D1 binding DB is required");
return createD1TraceRepository(env.DB);
}
return createSqliteTraceRepository(getDefaultDatabasePath());
}
~~~
使用 Task 1 已定义的 `LlmTraceRun``LlmTraceCall`;其字段与迁移一一对应。
- [ ] **Step 5:实现 SQLite 和 D1 UPSERT**
两个实现都使用完整快照 UPSERT,避免动态拼 SQL。SQLite 的核心语句为:
~~~sql
INSERT INTO llm_trace_calls (
call_id, job_id, sequence, task, workflow_stage, rewrite_round,
provider, model, status, request_object_key, response_object_key,
token_usage, schema_name, schema_valid, validation_issues,
business_status, duration_ms, started_at, responded_at, validated_at,
failed_at, error_type, error_summary
) VALUES (
@call_id, @job_id, @sequence, @task, @workflow_stage, @rewrite_round,
@provider, @model, @status, @request_object_key, @response_object_key,
@token_usage, @schema_name, @schema_valid, @validation_issues,
@business_status, @duration_ms, @started_at, @responded_at, @validated_at,
@failed_at, @error_type, @error_summary
)
ON CONFLICT(call_id) DO UPDATE SET
status = excluded.status,
response_object_key = excluded.response_object_key,
token_usage = excluded.token_usage,
schema_name = excluded.schema_name,
schema_valid = excluded.schema_valid,
validation_issues = excluded.validation_issues,
business_status = excluded.business_status,
duration_ms = excluded.duration_ms,
responded_at = excluded.responded_at,
validated_at = excluded.validated_at,
failed_at = excluded.failed_at,
error_type = excluded.error_type,
error_summary = excluded.error_summary;
~~~
D1 使用相同列顺序的 `prepare().bind().run()`;读取时把 JSON 文本和 `0 | 1 | null` 转回 TypeScript 类型。`getLatestRun()` 的顺序固定为运行中优先,再按 `updated_at DESC`
~~~sql
SELECT * FROM llm_trace_runs
ORDER BY CASE WHEN status = 'running' THEN 0 ELSE 1 END, updated_at DESC
LIMIT 1;
~~~
- [ ] **Step 6:增加 D1 绑定顺序测试并运行两套测试**
D1 测试至少断言 `putRun()` 使用 `insert into llm_trace_runs``putCall()``validation_issues``token_usage` 序列化,`listCalls()``sequence` 读取。
Run: `npm test -- src/lib/llm/__tests__/trace-repository.test.ts src/lib/llm/__tests__/d1-trace-repository.test.ts src/lib/db/__tests__/repository.test.ts`
Expected: PASS。
- [ ] **Step 7:提交索引存储**
~~~bash
git add migrations/0004_llm_trace_observability.sql src/lib/db/schema.ts src/lib/llm/trace-repository.ts src/lib/llm/sqlite-trace-repository.ts src/lib/llm/d1-trace-repository.ts src/lib/llm/__tests__/trace-repository.test.ts src/lib/llm/__tests__/d1-trace-repository.test.ts
git commit -m "新增LLM追踪索引存储"
~~~
## Task 3:实现私有完整正文存储
**Files:**
- Create: `src/lib/llm/trace-payload-store.ts`
- Create: `src/lib/llm/__tests__/trace-payload-store.test.ts`
- [ ] **Step 1:先写本地与 R2 失败测试**
~~~ts
it("round-trips exact JSON locally and deletes one job prefix", async () => {
const store = createLocalTracePayloadStore(tempDir);
const payload = { model: "deepseek-v4-pro", messages: [{ role: "user", content: "原文" }] };
const key = "llm-traces/job_1/llmcall_1/request.json";
await store.putJson(key, payload);
await expect(store.getJson(key)).resolves.toEqual(payload);
await store.deleteJob("job_1");
await expect(store.getJson(key)).resolves.toBeNull();
});
it("stores private JSON in R2 without a public URL", async () => {
const put = vi.fn().mockResolvedValue(undefined);
const get = vi.fn().mockResolvedValue({ json: async () => ({ ok: true }) });
const bucket = { put, get } as unknown as R2Bucket;
const store = createR2TracePayloadStore(bucket);
await store.putJson("llm-traces/job_1/llmcall_1/request.json", { ok: true });
await expect(store.getJson("llm-traces/job_1/llmcall_1/request.json"))
.resolves.toEqual({ ok: true });
expect(put).toHaveBeenCalledWith(
"llm-traces/job_1/llmcall_1/request.json",
JSON.stringify({ ok: true }),
{ httpMetadata: { contentType: "application/json; charset=utf-8" } },
);
});
~~~
- [ ] **Step 2:运行测试并确认模块不存在**
Run: `npm test -- src/lib/llm/__tests__/trace-payload-store.test.ts`
Expected: FAIL,正文存储工厂尚不存在。
- [ ] **Step 3:实现正文存储接口和工厂**
~~~ts
export interface LlmTracePayloadStore {
putJson(key: string, value: unknown): Promise<void>;
getJson(key: string): Promise<unknown | null>;
deleteJob(jobId: string): Promise<void>;
}
export function tracePayloadKey(
jobId: string,
callId: string,
kind: "request" | "response",
) {
return `llm-traces/${jobId}/${callId}/${kind}.json`;
}
export function getLlmTracePayloadStoreFromRuntime(): LlmTracePayloadStore {
if (process.env.APP_RUNTIME === "cloudflare") {
const bucket = getAppCloudflareEnv()?.EXPORT_BUCKET;
if (!bucket) throw new Error("Cloudflare R2 binding EXPORT_BUCKET is required");
return createR2TracePayloadStore(bucket);
}
return createLocalTracePayloadStore(getAppDataDir());
}
~~~
本地实现只能在 `join(dataDir, "llm-traces")` 下使用生成的键,使用 `mkdirSync``writeFileSync``readFileSync``rmSync`。R2 `deleteJob()` 必须循环 `bucket.list({ prefix, cursor })`,收集对象键后调用 `bucket.delete(keys)`,直到 `truncated` 为 false。
- [ ] **Step 4:运行正文存储和现有导出存储测试**
Run: `npm test -- src/lib/llm/__tests__/trace-payload-store.test.ts src/lib/workflow/__tests__/export-store.test.ts`
Expected: PASS;现有 `exports/` 行为不变。
- [ ] **Step 5:提交正文存储**
~~~bash
git add src/lib/llm/trace-payload-store.ts src/lib/llm/__tests__/trace-payload-store.test.ts
git commit -m "新增LLM追踪正文存储"
~~~
## Task 4:实现追踪收集器、业务状态和保留策略
**Files:**
- Create: `src/lib/llm/trace-recorder.ts`
- Create: `src/lib/llm/__tests__/trace-recorder.test.ts`
- [ ] **Step 1:写成功调用、QA 业务失败和保留策略测试**
使用内存 fake 仓储和正文存储,覆盖完整顺序:
~~~ts
it("persists exact bodies before publishing public metadata", async () => {
const published: LlmTraceStreamEvent[] = [];
const recorder = await createLlmTraceRecorder({
jobId: "job_1",
caseId: "case_1",
repository,
payloadStore,
publish: (event) => published.push(event),
});
await recorder.onLlmEvent({
type: "started",
call_id: "llmcall_1",
task: "quality_inspector",
context: { workflow_stage: "qa", schema_name: "llmQaPatchSchema" },
provider: "deepseek",
model: "deepseek-v4-pro",
request: { model: "deepseek-v4-pro", messages: [] },
started_at: "2026-07-16T00:00:00.000Z",
});
expect(payloadStore.values.get(
"llm-traces/job_1/llmcall_1/request.json",
)).toEqual({ model: "deepseek-v4-pro", messages: [] });
expect(published[0]).toMatchObject({
type: "llm_call_started",
request_available: true,
});
expect(JSON.stringify(published)).not.toContain("messages");
});
it("stores QA business failure separately from schema success", async () => {
await recorder.onWorkflowEvent({
type: "qa_ready",
job_id: "job_1",
qa_report: { overall_status: "fail", checks: [] },
});
await expect(repository.listCalls("job_1")).resolves.toEqual([
expect.objectContaining({
task: "quality_inspector",
schema_valid: true,
business_status: "fail",
}),
]);
});
it("keeps running runs and only the newest terminal full trace", async () => {
await recorder.finish({ status: "completed" });
expect(payloadStore.deletedJobs).toEqual(["job_old_completed"]);
expect(repository.deletedRuns).toEqual(["job_old_completed"]);
expect(repository.deletedRuns).not.toContain("job_other_running");
});
~~~
- [ ] **Step 2:运行测试并确认收集器不存在**
Run: `npm test -- src/lib/llm/__tests__/trace-recorder.test.ts`
Expected: FAIL`createLlmTraceRecorder` 尚不存在。
- [ ] **Step 3:实现收集器的公开接口**
~~~ts
export interface LlmTraceRecorder {
onLlmEvent: LlmClientTraceHandler;
onWorkflowEvent(event: OptimizationStreamEvent): Promise<void>;
finish(input: {
status: "completed" | "failed" | "interrupted";
errorStage?: string;
errorSummary?: string;
}): Promise<void>;
}
export function createNoopLlmTraceRecorder(): LlmTraceRecorder {
return {
onLlmEvent: async () => undefined,
onWorkflowEvent: async () => undefined,
finish: async () => undefined,
};
}
export async function createLlmTraceRecorder({
jobId,
caseId,
repository,
payloadStore,
publish,
}: CreateLlmTraceRecorderInput): Promise<LlmTraceRecorder> {
const calls = new Map<string, LlmTraceCall>();
let sequence = 0;
let run = createRunningRun(jobId, caseId);
await repository.putRun(run);
const warn = async (error: unknown) => {
run = { ...run, trace_completeness: "incomplete", updated_at: nowIso() };
await repository.putRun(run).catch(() => undefined);
await publish({
type: "trace_warning",
job_id: jobId,
trace_completeness: "incomplete",
error_summary: safeTraceError(error),
});
};
return {
onLlmEvent: async (event) => {
try {
await applyLlmEvent(event);
} catch (error) {
await warn(error);
}
},
onWorkflowEvent: async (event) => {
try {
await applyWorkflowEvent(event);
} catch (error) {
await warn(error);
}
},
finish: async (input) => {
try {
await finishRun(input);
} catch (error) {
await warn(error);
}
},
};
}
~~~
`applyLlmEvent()` 的硬性顺序:
1. `started`:先写 request.json,再 `putCall()`,最后发布 `llm_call_started`
2. `responded`:先写 response.json,再更新调用,最后发布 `llm_call_responded`
3. `validated`:更新 Schema 字段,再发布 `llm_call_validated`
4. `failed`:更新错误白名单字段,再发布 `llm_call_failed`
若正文写入失败,`onLlmEvent()` 仍尝试写入不带对象键的调用索引并发布 `request_available: false``response_available: false`,随后发布 `trace_warning`;若索引也不可用,则至少发布 `trace_warning`。任何追踪失败都被收集器吞掉,不向 LLM 客户端或业务工作流抛出。
Token 用量只从完整响应的顶层 `usage` 读取为数字字典:
~~~ts
function tokenUsageFromResponse(response: unknown) {
if (!response || typeof response !== "object") return null;
const usage = (response as { usage?: unknown }).usage;
if (!usage || typeof usage !== "object") return null;
return Object.fromEntries(
Object.entries(usage).filter(
(entry): entry is [string, number] => typeof entry[1] === "number",
),
);
}
~~~
- [ ] **Step 4:实现工作流状态和清理**
`onWorkflowEvent()` 必须更新 `current_stage`;收到 `qa_ready` 时,把最近一个 `quality_inspector` 调用的 `business_status` 设置为 `qa_report.overall_status``finish()` 先写终态,再执行:
~~~ts
const expired = await repository.listTerminalRunsExcept(jobId);
for (const oldRun of expired) {
await payloadStore.deleteJob(oldRun.job_id);
await repository.deleteRun(oldRun.job_id);
}
~~~
`listTerminalRunsExcept()` 不返回任何 `running` 记录,因此并发运行任务不会被删除。
- [ ] **Step 5:运行收集器测试**
Run: `npm test -- src/lib/llm/__tests__/trace-recorder.test.ts`
Expected: PASS,包含存储失败时业务回调不抛错、`trace_warning` 被发布的断言。
- [ ] **Step 6:提交追踪协调器**
~~~bash
git add src/lib/llm/trace-recorder.ts src/lib/llm/__tests__/trace-recorder.test.ts
git commit -m "新增LLM任务追踪收集器"
~~~
## Task 5:在 LLM 客户端捕获真实 SDK 请求和响应
**Files:**
- Modify: `src/lib/llm/client.ts`
- Modify: `src/lib/llm/__tests__/client.test.ts`
- [ ] **Step 1:写请求和响应深度相等的失败测试**
~~~ts
it("traces the exact SDK request and full SDK response", async () => {
process.env.LLM_PROVIDER = "deepseek";
process.env.DEEPSEEK_API_KEY = "test-key";
const traced: LlmClientTraceEvent[] = [];
let sdkRequest: unknown;
const sdkResponse = {
id: "chatcmpl_1",
object: "chat.completion",
created: 1784188800,
model: "deepseek-v4-pro",
choices: [{
index: 0,
message: { role: "assistant", content: "{\"value\":\"ok\"}" },
finish_reason: "stop",
}],
usage: { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14 },
};
client.setChatCompletionForTesting(async (request) => {
sdkRequest = request;
return sdkResponse;
});
await client.generateValidatedJson({
schema: z.object({ value: z.string() }),
schemaName: "valueSchema",
prompt: "Return JSON.",
task: "article_optimizer",
traceStage: "draft",
onTraceEvent: (event) => traced.push(event),
});
expect(traced.find((event) => event.type === "started")).toMatchObject({
type: "started",
request: sdkRequest,
});
expect(traced.find((event) => event.type === "responded")).toMatchObject({
type: "responded",
response: sdkResponse,
});
expect(traced.map((event) => event.type)).toEqual([
"started",
"responded",
"validated",
]);
});
~~~
再增加 JSON 解析失败顺序和 Schema 失败顺序测试:
~~~ts
expect(jsonParseEvents.map((event) => event.type)).toEqual([
"started", "responded", "failed",
]);
expect(schemaEvents.map((event) => event.type)).toEqual([
"started", "responded", "validated", "failed",
]);
~~~
- [ ] **Step 2:运行客户端测试并确认失败**
Run: `npm test -- src/lib/llm/__tests__/client.test.ts`
Expected: FAIL`schemaName``traceStage``onTraceEvent` 尚不存在,响应也会在解析后丢失。
- [ ] **Step 3:扩展 GenerateInput 并生成稳定调用 ID**
~~~ts
export interface GenerateInput {
system?: string;
prompt: string;
model?: string;
temperature?: number;
task?: LlmTaskName;
schemaName?: string;
traceStage?: LlmTraceWorkflowStage;
rewriteRound?: number;
onTraceEvent?: LlmClientTraceHandler;
onAuditSummary?: (summary: LlmAuditSummary) => void | Promise<void>;
traceCallId?: string;
}
function callIdFor(input: GenerateInput) {
return input.traceCallId ?? `llmcall_${nanoid(12)}`;
}
async function emitTrace(input: GenerateInput, event: LlmClientTraceEvent) {
try {
await input.onTraceEvent?.(event);
} catch (error) {
console.warn(`[llm:trace-warning] ${safeErrorSummary(error)}`);
}
}
~~~
`generateValidatedJson()` 在调用生成器前创建 `traceCallId`,并把同一个 ID 传给 `generateJsonForValidation()`,保证校验事件属于同一调用。
- [ ] **Step 4:拆开 Provider、JSON 解析和 Schema 校验阶段**
`generateJson()` 必须按以下顺序实现,不能把 `JSON.parse()` 放回 Provider `try/catch`
~~~ts
const request = buildChatCompletionRequest(input, effectiveModel);
await emitTrace(input, {
type: "started",
call_id: callId,
task,
context: {
workflow_stage: input.traceStage ?? "unknown",
rewrite_round: input.rewriteRound,
schema_name: input.schemaName,
},
provider: status.provider,
model: effectiveModel,
request,
started_at: new Date(startedAt).toISOString(),
});
let response: ChatCompletionResult;
try {
response = chatCompletionForTesting
? await chatCompletionForTesting(request)
: await createClient().client.chat.completions.create(request);
} catch (error) {
await emitTrace(input, failedEvent(callId, "provider", error, startedAt));
throw normalizeLlmError(error);
}
await emitTrace(input, {
type: "responded",
call_id: callId,
response,
duration_ms: Date.now() - startedAt,
responded_at: new Date().toISOString(),
});
const content = response.choices[0]?.message.content ?? "{}";
try {
return JSON.parse(content) as T;
} catch (error) {
await emitTrace(input, failedEvent(callId, "json_parse", error, startedAt));
throw new Error(`LLM response is not valid JSON: ${safeErrorSummary(error)}`);
}
~~~
Schema 校验失败时先发送 `validated(schema_valid=false)`,再发送 `failed(error_type=schema_validation)`。安全错误只允许 `name``message`,不得序列化 SDK error、headers、request 或环境变量。
- [ ] **Step 5:保持现有审计和控制台日志兼容**
现有 `[llm:start]``[llm:response]``[llm:validated]``[llm:error]``onAuditSummary` 行为必须保留。现有原始返回控制台日志仍按 `LLM_LOG_RAW_LIMIT` 截断;R2 追踪正文不截断。
- [ ] **Step 6:运行客户端与审计测试**
Run: `npm test -- src/lib/llm/__tests__/client.test.ts src/lib/llm/__tests__/audit.test.ts`
Expected: PASS,且失败测试证明 API Key 不出现在任何事件的 JSON 中。
- [ ] **Step 7:提交客户端追踪**
~~~bash
git add src/lib/llm/client.ts src/lib/llm/__tests__/client.test.ts
git commit -m "记录真实LLM请求与响应"
~~~
## Task 6:把追踪器接入真实流式文章工作流
**Files:**
- Modify: `src/lib/workflow/fact-extractor.ts`
- Modify: `src/lib/workflow/article-optimizer.ts`
- Modify: `src/lib/workflow/quality-inspector.ts`
- Modify: `src/lib/workflow/targeted-rewriter.ts`
- Modify: `src/lib/workflow/streaming-optimizer.ts`
- Modify: `src/app/api/jobs/optimize-stream/route.ts`
- Modify: `src/app/api/__tests__/jobs.test.ts`
- [ ] **Step 1:写流式事件顺序的失败测试**
`jobs.test.ts` 中让 LLM mock 主动调用传入的追踪回调,并断言:
~~~ts
expect(events.map((event) => event.type)).toEqual([
"job_created",
"llm_call_started",
"llm_call_responded",
"llm_call_validated",
"fact_card_ready",
"draft_started",
"llm_call_started",
"llm_call_responded",
"llm_call_validated",
"draft_ready",
"qa_started",
"llm_call_started",
"llm_call_responded",
"llm_call_validated",
"qa_ready",
"final_ready",
]);
~~~
还要断言公开事件 JSON 不包含文章正文、`messages`、完整 `choices` 或访问密钥。
- [ ] **Step 2:运行 API 测试并确认缺少追踪事件**
Run: `npm test -- src/app/api/__tests__/jobs.test.ts`
Expected: FAIL,事件序列仍只有业务阶段事件。
- [ ] **Step 3:给四个工作流节点传递明确追踪上下文**
每个输入接口增加:
~~~ts
onTraceEvent?: GenerateInput["onTraceEvent"];
rewriteRound?: number;
~~~
四个 `generateValidatedJson()` 调用必须分别传入:
~~~ts
// fact-extractor.ts
schemaName: "candidateFactCardSchema",
traceStage: "fact_card",
onTraceEvent: options.onTraceEvent,
// article-optimizer.ts
schemaName: "optimizedArticleSchema",
traceStage: "draft",
onTraceEvent,
// quality-inspector.ts
schemaName: "llmQaPatchSchema",
traceStage: "qa",
rewriteRound: input.rewriteRound,
onTraceEvent: input.onTraceEvent,
// targeted-rewriter.ts
schemaName: "optimizedArticleSchema",
traceStage: "rewrite",
rewriteRound,
onTraceEvent,
~~~
`streaming-optimizer.ts` 初次 QA 使用 `rewriteRound: 0`;每轮修复和复检使用当前 `nextRound`
- [ ] **Step 4:在流式路由创建并连接追踪器**
`ReadableStream.start()` 的业务 `try` 之前,与 `jobId``caseId``stage` 同级声明空追踪器,使失败 `catch` 始终能访问:
~~~ts
let jobId: string | undefined;
let caseId: string | undefined;
let stage: OptimizationStreamStage = "job";
let traceRecorder = createNoopLlmTraceRecorder();
~~~
任务和案例创建完成后,再尝试替换为真实追踪器:
~~~ts
try {
traceRecorder = await createLlmTraceRecorder({
jobId: job.id,
caseId: optimizationCase.id,
repository: getLlmTraceRepositoryFromRuntime(),
payloadStore: getLlmTracePayloadStoreFromRuntime(),
publish: async (event) => send(event),
});
} catch (error) {
send({
type: "trace_warning",
job_id: job.id,
trace_completeness: "incomplete",
error_summary: safeTraceError(error),
});
}
~~~
事实提取和 `runStreamingOptimizationWorkflow()` 都传 `onTraceEvent: traceRecorder.onLlmEvent`。保存事实卡后,把直接发送改为同一事件先入追踪器、再入 NDJSON:
~~~ts
const factCardReadyEvent: OptimizationStreamEvent = {
type: "fact_card_ready",
job_id: job.id,
fact_card: savedFactCard,
};
await traceRecorder.onWorkflowEvent(factCardReadyEvent);
send(factCardReadyEvent);
~~~
工作流的 `onEvent` 改为:
~~~ts
onEvent: async (event) => {
stage = stageForEvent(event, stage);
await traceRecorder.onWorkflowEvent(event);
send(event);
},
~~~
成功路径在 `final_ready` 前调用 `traceRecorder.finish({ status: "completed" })`;失败路径在 `failed` 前调用:
~~~ts
await traceRecorder.finish({
status: "failed",
errorStage: stage,
errorSummary: message,
});
~~~
`safeTraceError()``trace-recorder.ts` 导出,只返回安全化的 `name: message`。追踪器创建或存储异常必须被隔离成 `trace_warning`,不得跳入业务失败分支。
- [ ] **Step 5:运行路由、工作流和流事件测试**
Run: `npm test -- src/app/api/__tests__/jobs.test.ts src/lib/workflow/__tests__/streaming-optimizer.test.ts src/lib/workflow/__tests__/stream-events.test.ts`
Expected: PASS,现有业务事件和结果版本断言保持不变。
- [ ] **Step 6:提交真实工作流接入**
~~~bash
git add src/lib/workflow/fact-extractor.ts src/lib/workflow/article-optimizer.ts src/lib/workflow/quality-inspector.ts src/lib/workflow/targeted-rewriter.ts src/lib/workflow/streaming-optimizer.ts src/app/api/jobs/optimize-stream/route.ts src/app/api/__tests__/jobs.test.ts
git commit -m "接入文章优化LLM追踪"
~~~
## Task 7:增加受保护的追踪读取 API
**Files:**
- Create: `src/lib/llm/trace-http.ts`
- Create: `src/app/api/llm-traces/latest/route.ts`
- Create: `src/app/api/jobs/[jobId]/llm-trace/route.ts`
- Create: `src/app/api/jobs/[jobId]/llm-trace/[callId]/request/route.ts`
- Create: `src/app/api/jobs/[jobId]/llm-trace/[callId]/response/route.ts`
- Create: `src/app/api/__tests__/llm-traces.test.ts`
- [ ] **Step 1:先写鉴权、无缓存和正文测试**
~~~ts
it("rejects trace reads without API access", async () => {
const response = await getLatestTrace(request(null));
expect(response.status).toBe(401);
});
it("returns a no-store manifest without raw bodies", async () => {
const response = await getJobTrace(
request("test-key"),
params({ jobId: "job_1" }),
);
const body = await response.json();
expect(response.headers.get("cache-control")).toBe("no-store");
expect(body.calls[0]).toMatchObject({
call_id: "llmcall_1",
request_available: true,
response_available: true,
});
expect(JSON.stringify(body)).not.toContain("messages");
expect(JSON.stringify(body)).not.toContain("choices");
});
it("returns the exact stored request through the protected payload route", async () => {
const response = await getTraceRequest(
request("test-key"),
params({ jobId: "job_1", callId: "llmcall_1" }),
);
expect(response.headers.get("cache-control")).toBe("no-store");
await expect(response.json()).resolves.toEqual(exactRequestFixture);
});
~~~
- [ ] **Step 2:运行 API 测试并确认路由不存在**
Run: `npm test -- src/app/api/__tests__/llm-traces.test.ts`
Expected: FAIL,读取路由尚不存在。
- [ ] **Step 3:实现共享 HTTP 助手**
~~~ts
export function noStoreJson(body: unknown, status = 200) {
return NextResponse.json(body, {
status,
headers: { "cache-control": "no-store" },
});
}
export async function readTracePayload({
jobId,
callId,
kind,
}: {
jobId: string;
callId: string;
kind: "request" | "response";
}) {
const repository = getLlmTraceRepositoryFromRuntime();
const call = (await repository.listCalls(jobId)).find(
(candidate) => candidate.call_id === callId,
);
if (!call) return noStoreJson({ error: "追踪调用不存在" }, 404);
const key = kind === "request"
? call.request_object_key
: call.response_object_key;
if (!key) {
if (kind === "response" && call.status === "started") {
return noStoreJson({ state: "waiting" }, 202);
}
return noStoreJson({
error: kind === "response" ? "该调用未产生响应" : "追踪请求正文不存在",
}, 404);
}
const payload = await getLlmTracePayloadStoreFromRuntime().getJson(key);
return payload == null
? noStoreJson({ error: "追踪正文不存在" }, 404)
: noStoreJson(payload);
}
~~~
- [ ] **Step 4:实现四个 GET 路由**
每个路由第一段必须完全一致:
~~~ts
const access = requireApiAccess(request);
if (!access.ok) return access.response;
~~~
`latest` 调用 `repository.getLatestRun()`;无记录时返回 `noStoreJson({ run: null, calls: [] })`。有记录时,`latest` 和任务清单路由都调用 `repository.listCalls(run.job_id)`,并通过同一个安全映射函数返回:
~~~ts
export function toPublicTraceCall(call: LlmTraceCall): LlmTraceCallPublic {
const {
request_object_key: requestObjectKey,
response_object_key: responseObjectKey,
...publicFields
} = call;
return {
...publicFields,
request_available: Boolean(requestObjectKey),
response_available: Boolean(responseObjectKey),
};
}
return noStoreJson({ run, calls: calls.map(toPublicTraceCall) });
~~~
`toPublicTraceCall()` 放在 `trace-http.ts`,保证内部 R2 对象键不会进入响应对象。
- [ ] **Step 5:运行追踪 API 和鉴权回归测试**
Run: `npm test -- src/app/api/__tests__/llm-traces.test.ts src/lib/api/__tests__/auth.test.ts`
Expected: PASS401、404、202、200 和 `no-store` 均有断言。
- [ ] **Step 6:提交追踪读取 API**
~~~bash
git add src/lib/llm/trace-http.ts src/app/api/llm-traces/latest/route.ts 'src/app/api/jobs/[jobId]/llm-trace/route.ts' 'src/app/api/jobs/[jobId]/llm-trace/[callId]/request/route.ts' 'src/app/api/jobs/[jobId]/llm-trace/[callId]/response/route.ts' src/app/api/__tests__/llm-traces.test.ts
git commit -m "新增LLM追踪只读接口"
~~~
## Task 8:实现浏览器 API 客户端和架构状态派生
**Files:**
- Create: `src/components/architecture/api-client.ts`
- Create: `src/components/architecture/trace-state.ts`
- Create: `src/components/architecture/__tests__/api-client.test.ts`
- Create: `src/components/architecture/__tests__/trace-state.test.ts`
- [ ] **Step 1:先写按需加载和状态派生失败测试**
~~~ts
it("loads request bodies only when explicitly requested", async () => {
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse(exactRequest)));
await expect(
getTracePayload("job_1", "llmcall_1", "request", "test-key"),
).resolves.toEqual(exactRequest);
expect(fetch).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("distinguishes QA business failure from schema failure", () => {
const nodes = deriveArchitectureNodes(run, [
call({ task: "quality_inspector", schema_valid: true, business_status: "fail" }),
]);
expect(nodes.qa).toMatchObject({
status: "completed",
detail: "检查完成,需要修复",
});
const failedNodes = deriveArchitectureNodes(run, [
call({ task: "quality_inspector", schema_valid: false, status: "failed" }),
]);
expect(failedNodes.qa.status).toBe("failed");
});
~~~
- [ ] **Step 2:运行测试并确认模块不存在**
Run: `npm test -- src/components/architecture/__tests__/api-client.test.ts src/components/architecture/__tests__/trace-state.test.ts`
Expected: FAIL。
- [ ] **Step 3:实现 API 客户端**
~~~ts
function traceHeaders(apiAccessKey: 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,
);
}
~~~
- [ ] **Step 4:实现实时事件归并和固定节点派生**
`trace-state.ts` 导出:
~~~ts
export type ArchitectureNodeStatus =
| "waiting"
| "running"
| "completed"
| "failed";
export interface ArchitectureNodeView {
id: "input" | "fact_card" | "draft" | "qa" | "rewrite" | "final";
label: string;
status: ArchitectureNodeStatus;
detail: string;
}
export function applyLiveTraceEvent(
state: LlmTraceManifest,
event: LlmTraceStreamEvent,
): LlmTraceManifest;
export function deriveArchitectureNodes(
run: LlmTraceRun | null,
calls: LlmTraceCallPublic[],
): Record<ArchitectureNodeView["id"], ArchitectureNodeView>;
~~~
归并必须按 `call_id` 更新而不是追加重复调用;调用清单始终按 `sequence` 排序。`trace_warning` 只把运行记录改为 `incomplete`,不把业务状态改成 `failed`
- [ ] **Step 5:运行前端纯逻辑测试**
Run: `npm test -- src/components/architecture/__tests__/api-client.test.ts src/components/architecture/__tests__/trace-state.test.ts`
Expected: PASS,覆盖重复事件、复检轮次、Provider 失败、Schema 失败和业务 QA 失败。
- [ ] **Step 6:提交前端数据层**
~~~bash
git add src/components/architecture/api-client.ts src/components/architecture/trace-state.ts src/components/architecture/__tests__/api-client.test.ts src/components/architecture/__tests__/trace-state.test.ts
git commit -m "新增后台架构前端状态层"
~~~
## Task 9:构建固定架构图和完整日志详情组件
**Files:**
- Create: `src/components/architecture/architecture-flow.tsx`
- Create: `src/components/architecture/llm-call-detail.tsx`
- Create: `src/components/architecture/architecture-observer-panel.tsx`
- Modify: `src/app/globals.css`
- Test: `src/components/architecture/__tests__/trace-state.test.ts`
- [ ] **Step 1:先写界面格式化失败测试**
把界面格式化保持为纯函数并测试:
~~~ts
it("formats every architecture node and call label in Chinese", () => {
expect(formatNodeStatus("running")).toBe("运行中");
expect(formatNodeStatus("failed")).toBe("失败");
expect(formatCallLabel(call({
sequence: 4,
task: "targeted_rewriter",
rewrite_round: 1,
duration_ms: 22600,
}))).toBe("4 · 定向修复第 1 轮 · 22.6 秒");
});
~~~
- [ ] **Step 2:运行测试并确认格式化函数不存在**
Run: `npm test -- src/components/architecture/__tests__/trace-state.test.ts`
Expected: FAIL。
- [ ] **Step 3:实现固定拓扑组件**
`architecture-flow.tsx` 只接受派生后的节点,不读取 API:
~~~tsx
export function ArchitectureFlow({
nodes,
}: {
nodes: Record<ArchitectureNodeView["id"], ArchitectureNodeView>;
}) {
const order: ArchitectureNodeView["id"][] = [
"input", "fact_card", "draft", "qa", "rewrite", "final",
];
return (
<section className="architecture-flow" aria-label="文章优化后台架构">
{order.map((id) => {
const node = nodes[id];
return (
<article
className={`architecture-node architecture-node-${node.status}`}
data-node={node.id}
key={node.id}
>
<strong>{node.label}</strong>
<span>{formatNodeStatus(node.status)}</span>
<small>{node.detail}</small>
</article>
);
})}
<p className="architecture-loop-label">质量检查未通过时,定向修复后返回复检,最多两轮。</p>
</section>
);
}
~~~
固定节点不能按调用次数新增;复检只改变 QA 节点并在调用轨迹中增加一条调用。
- [ ] **Step 4:实现调用详情按需读取**
`llm-call-detail.tsx` 接受 `jobId``call``apiAccessKey``technicalDetailsEnabled`。只有技术详情开启且用户选择 `请求``响应` 时才调用 `getTracePayload()`
~~~tsx
const [tab, setTab] = useState<"request" | "response" | "validation">(
"validation",
);
const [payload, setPayload] = useState<unknown>(null);
useEffect(() => {
if (!technicalDetailsEnabled || tab === "validation") return;
let current = true;
setPayload(null);
getTracePayload(jobId, call.call_id, tab, apiAccessKey)
.then((value) => current && setPayload(value))
.catch((error) => current && setPayload({ error: errorMessage(error) }));
return () => { current = false; };
}, [apiAccessKey, call.call_id, jobId, tab, technicalDetailsEnabled]);
return (
<section className="panel llm-call-detail" aria-live="polite">
<div className="llm-detail-tabs" role="tablist" aria-label="LLM 调用详情">
{(["request", "response", "validation"] as const).map((value) => (
<button
aria-selected={tab === value}
className={tab === value ? "active-tab" : undefined}
key={value}
onClick={() => setTab(value)}
role="tab"
type="button"
>
{detailTabLabel(value)}
</button>
))}
</div>
<pre className="llm-json-view">
{JSON.stringify(
tab === "validation" ? validationView(call) : payload,
null,
2,
)}
</pre>
</section>
);
~~~
必须使用 `<pre>` 文本渲染,不使用 `dangerouslySetInnerHTML`、Markdown HTML 或代码高亮器。
- [ ] **Step 5:实现只读观察容器**
`architecture-observer-panel.tsx` 的 props 固定为:
~~~ts
interface ArchitectureObserverPanelProps {
apiAccessKey: string;
currentJobId: string | null;
liveEvents: LlmTraceStreamEvent[];
}
~~~
挂载时优先 `getJobTrace(currentJobId)`,没有当前任务时 `getLatestTrace()`;之后用 `applyLiveTraceEvent()` 合并 `liveEvents`。页面必须有:
- “当前任务”或“最近一次任务”明确标签。
- Provider、模型、运行状态和追踪完整性。
- `ArchitectureFlow`
-`sequence` 排列的调用按钮。
- 默认关闭的“技术详情”开关。
- 选中调用的 `LlmCallDetail`
- 无记录、401、加载失败和 `incomplete` 状态。
组件不得出现开始、重试、取消、删除或编辑按钮。
- [ ] **Step 6:增加桌面和窄屏 CSS**
新增类:`.architecture-observer``.architecture-task-strip``.architecture-flow``.architecture-node-*``.architecture-workspace``.llm-call-list``.llm-call-detail``.llm-detail-tabs``.llm-json-view`。桌面 `.architecture-workspace` 使用左侧调用轨迹、右侧详情;在现有 `@media (max-width: 900px)` 中改成单列。JSON 使用 `white-space: pre-wrap``overflow-wrap: anywhere`,不得产生页面级横向滚动。
- [ ] **Step 7:运行纯逻辑测试和 lint**
Run: `npm test -- src/components/architecture/__tests__/trace-state.test.ts && npm run lint`
Expected: PASSESLint 不出现 effect 同步 set-state 或未转义内容错误。
- [ ] **Step 8:提交观测组件**
~~~bash
git add src/components/architecture/architecture-flow.tsx src/components/architecture/llm-call-detail.tsx src/components/architecture/architecture-observer-panel.tsx src/components/architecture/__tests__/trace-state.test.ts src/app/globals.css
git commit -m "新增LLM后台架构观测界面"
~~~
## Task 10:接入第三标签并完成浏览器验收
**Files:**
- Modify: `src/app/page.tsx`
- Create: `tests/e2e/architecture-observer.spec.ts`
- Modify: `tests/e2e/mvp.spec.ts`
- Modify: `tests/e2e/sample-flow/page-flow.ts`
- [ ] **Step 1:写第三标签和切换不中断的 E2E 失败测试**
`architecture-observer.spec.ts` 使用 `page.route()` 模拟分块 NDJSON 和追踪正文接口:
~~~ts
test("后台架构标签跟随当前任务并按需展示真实请求响应", async ({ page }) => {
await mockOptimizationStream(page);
await mockTracePayloads(page);
await page.goto("/");
await page.getByLabel("访问密钥").fill("local-dev-key");
await page.getByLabel("文章内容").fill("示例文章正文");
await page.getByRole("button", { name: "开始优化" }).click();
await page.getByRole("button", { name: "后台架构" }).click();
await expect(page.getByText("当前任务")).toBeVisible();
await expect(page.getByText("article_optimizer")).toBeVisible();
await page.getByRole("button", { name: /article_optimizer/ }).click();
await page.getByRole("checkbox", { name: "技术详情" }).check();
await page.getByRole("tab", { name: "请求" }).click();
await expect(page.locator(".llm-json-view")).toContainText("messages");
await page.getByRole("tab", { name: "响应" }).click();
await expect(page.locator(".llm-json-view")).toContainText("choices");
await expect(page.getByText("终稿已生成")).toBeVisible();
});
~~~
Mock 路由记录请求次数,并断言关闭技术详情时正文接口调用次数为 0。
- [ ] **Step 2:运行 E2E 并确认第三标签不存在**
Run: `npx playwright test tests/e2e/architecture-observer.spec.ts --project=chromium`
Expected: FAIL,找不到 `后台架构` 按钮。
- [ ] **Step 3:在首页接入追踪事件状态**
`page.tsx` 修改为:
~~~tsx
const [activeTab, setActiveTab] = useState<"geo" | "copy" | "architecture">("geo");
const [llmTraceEvents, setLlmTraceEvents] = useState<LlmTraceStreamEvent[]>([]);
function handleStreamEvent(event: OptimizationStreamEvent) {
switch (event.type) {
case "llm_call_started":
case "llm_call_responded":
case "llm_call_validated":
case "llm_call_failed":
case "trace_warning":
setLlmTraceEvents((current) => [...current, event]);
return;
case "job_created":
setJobId(event.job.id);
setLlmTraceEvents([]);
setStreamActivity("任务已创建");
return;
case "fact_card_ready":
setFactCard(event.fact_card);
setStreamActivity("事实卡已就绪");
return;
case "draft_started":
setStreamActivity(event.message);
return;
case "draft_ready":
setDraftArticle(event.article);
setStreamActivity("草稿已生成");
return;
case "qa_started":
setStreamActivity(event.message);
return;
case "qa_ready":
setQaReport(event.qa_report);
setStreamActivity("质量检查完成");
return;
case "rewrite_started":
setStreamActivity(`正在修复第 ${event.round} 轮`);
return;
case "rewrite_ready":
setDraftArticle(event.article);
setStreamActivity(`第 ${event.round} 轮修复完成`);
return;
case "final_ready":
setOptimizedArticle(event.optimized_article);
setDraftArticle(null);
setQaReport(event.qa_report);
setStreamActivity("终稿已生成");
setMessage(
event.qa_report.overall_status === "fail"
? "优化完成,质检发现需要复核的问题。"
: "优化完成。",
);
return;
case "failed":
setStreamActivity("优化失败");
throw new Error(event.error);
}
}
~~~
第三个标签按钮文字固定为 `后台架构`。内容选择使用三个明确分支,不能把 architecture 落入普通文案分支:
~~~tsx
{activeTab === "geo" ? (
<>
<ProgressPanel
action={busyAction}
elapsedSeconds={elapsedSeconds}
lastTiming={lastTiming}
liveProgress={null}
/>
<div className="workflow-grid">
<ArticleInputForm
isSubmitting={busyAction === "optimize"}
value={input}
onChange={setInput}
onSubmit={optimize}
/>
<FactCardEditor
factCard={factCard}
isSaving={busyAction === "optimize"}
onChange={setFactCard}
/>
<OptimizedPreview
activityText={streamActivity}
article={optimizedArticle}
draftArticle={draftArticle}
isStreaming={busyAction === "optimize"}
jobId={jobId}
/>
<QaReportPanel report={qaReport} />
<PerformanceCalibrationPanel
apiAccessKey={apiAccessKey}
jobId={jobId}
key={`${jobId ?? "no-job"}-${optimizedArticle?.revision ?? "no-revision"}`}
optimizedRevision={optimizedArticle?.revision ?? null}
/>
</div>
</>
) : activeTab === "copy" ? (
<RenweiCopyOptimizerPanel apiAccessKey={apiAccessKey} />
) : (
<ArchitectureObserverPanel
apiAccessKey={apiAccessKey}
currentJobId={jobId}
liveEvents={llmTraceEvents}
/>
)}
~~~
在导航中紧跟“普通文案优化”增加 `后台架构` 按钮,并导入 `ArchitectureObserverPanel``LlmTraceStreamEvent`。现有 GEO JSX 原位保留。
- [ ] **Step 4:证明切换标签不取消流读取**
不要把 `readOptimizationStream()` 移进标签组件,也不要在切换标签时 abort fetch。E2E mock 在点击 `后台架构` 后继续发送 `qa_ready``final_ready`,测试必须看到最终状态。
- [ ] **Step 5:更新旧 MVP 回归为当前一键优化流程**
`tests/e2e/mvp.spec.ts` 中已不存在的“标题”“正文”“分析文章”“采纳为核心事实”“确认事实卡”步骤删除,改为填写 `文章内容``图片描述或图片链接``用户要求`,然后直接点击一次 `开始优化`;继续断言 `optimized.md`、质量报告、`optimized.docx``qa_report.json`。这只更新测试以匹配当前产品,不把产品改回旧流程。
- [ ] **Step 6:扩展真实样例验证但不落盘正文**
`tests/e2e/sample-flow/page-flow.ts` 增加:
~~~ts
async function validateLlmTrace({ request, baseURL, apiAccessKey, jobId }: {
request: APIRequestContext;
baseURL: string;
apiAccessKey: string;
jobId: string;
}) {
const headers = { "x-api-key": apiAccessKey };
const manifestResponse = await request.get(
`${baseURL}/api/jobs/${jobId}/llm-trace`,
{ headers },
);
expect(manifestResponse.ok()).toBe(true);
const manifest = await manifestResponse.json() as LlmTraceManifest;
expect(manifest.calls.length).toBeGreaterThan(0);
for (const call of manifest.calls) {
const requestResponse = await request.get(
`${baseURL}/api/jobs/${jobId}/llm-trace/${call.call_id}/request`,
{ headers },
);
expect(requestResponse.ok()).toBe(true);
const requestBody = await requestResponse.json() as { messages?: unknown[] };
expect(Array.isArray(requestBody.messages)).toBe(true);
if (call.response_available) {
const responseResponse = await request.get(
`${baseURL}/api/jobs/${jobId}/llm-trace/${call.call_id}/response`,
{ headers },
);
expect(responseResponse.ok()).toBe(true);
const responseBody = await responseResponse.json() as { choices?: unknown[] };
expect(Array.isArray(responseBody.choices)).toBe(true);
}
}
return manifest.calls.map((call) => call.task);
}
~~~
把返回值写入现有 `SampleResult.llm_tasks`。不得把 request/response 正文写入 `test-results`、截图注释或 summary JSON。
- [ ] **Step 7:运行确定性 E2E 和相关单元测试**
Run: `npx playwright test tests/e2e/architecture-observer.spec.ts tests/e2e/mvp.spec.ts --project=chromium`
Run: `npm test -- src/components/architecture src/lib/workflow/__tests__/stream-events.test.ts`
Expected: 全部 PASS,无控制台错误和横向溢出。
- [ ] **Step 8:提交首页和端到端接入**
~~~bash
git add src/app/page.tsx tests/e2e/architecture-observer.spec.ts tests/e2e/mvp.spec.ts tests/e2e/sample-flow/page-flow.ts
git commit -m "接入后台架构标签页"
~~~
## Task 11:迁移、全量验证和真实 Provider 验收
**Files:**
- Verify only: all files from Tasks 1-10
- [ ] **Step 1:应用本地 D1 迁移并核对表**
Run: `npm run d1:migrate:local`
Expected: `0004_llm_trace_observability.sql` applied successfully;再次运行显示没有待执行迁移。
- [ ] **Step 2:运行完整质量门禁**
Run: `npm run lint`
Expected: PASS0 errors。
Run: `npm test`
Expected: PASS,包含新增 trace repository、payload store、recorder、client、API 和 UI 状态测试。
Run: `npm run build`
Expected: PASSNext.js 生产构建成功。
- [ ] **Step 3:运行完整 Playwright 回归**
Run: `npx playwright test --project=chromium`
Expected: PASSTask 10 已把 `mvp.spec.ts` 更新为当前一键优化流程。
- [ ] **Step 4:运行一次真实 DeepSeek 安全样例**
确保 `.env.local` 只在本地包含 `API_ACCESS_KEY``DEEPSEEK_API_KEY`,然后运行:
Run: `npm run test:e2e:samples -- --limit 1 --timeout-ms 600000`
Expected: PASS,并在生成的 `test-results/geo-sample-flow/<timestamp>/summary.md` 中看到非空 `llm_tasks`。报告不得包含完整请求、完整响应、访问密钥或 Provider 密钥。
- [ ] **Step 5:检查敏感信息和非预期文件**
Run:
~~~bash
git status --short --ignored=matching
rg -n "auth\.token|secretKey|healthsource|Authorization|DEEPSEEK_API_KEY" . --glob '!node_modules/**' --glob '!.next/**' --glob '!.open-next/**' --glob '!deploy/*.toml' --glob '!test-results/**'
~~~
Expected: 没有新提交的真实密钥;`data/``test-results/`、追踪正文和生成构建目录保持忽略。用户原有未提交文件仍保持原状。
- [ ] **Step 6:核对提交边界和最终状态**
Run: `git status --short && git log --oneline -12`
Expected: Tasks 1-10 的功能文件均已包含在对应中文提交中;只剩用户原有的 `README.md``wrangler.jsonc`、样例或导出目录等未提交改动,没有本功能遗漏文件。若质量门禁暴露功能缺陷,返回拥有该文件的任务按“失败测试—最小修正—验证—中文提交”闭环处理,不创建内容不明的兜底提交。
## 完成定义
- 所有任务按 TDD 顺序完成,每个任务都有独立中文提交。
- D1/SQLite 索引和 R2/本地正文存储均有测试。
- SDK 请求对象和保存请求深度相等,SDK 响应对象和保存响应深度相等。
- 公开 NDJSON 和清单 API 不含完整正文。
- 完整正文只能通过受保护且 `no-store` 的按需接口读取。
- 架构图由真实事件驱动,QA 业务失败与技术失败分开表达。
- 切换标签不取消任务,刷新可恢复已持久化状态。
- 只保留运行中任务和最近终态任务的完整正文。
- `npm run lint``npm test``npm run build`、Playwright 回归和一次真实安全样例全部通过。