# Unified Optimization Case Library Implementation Plan > **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:** Build the unified long-term optimization case library for article optimization and human-tone copy optimization, including auto-save, result versions, failed cases, case list/detail pages, and publication performance learning records. **Architecture:** Add a unified case layer above the current article job tables, then connect both article and human-copy flows to that layer. Keep existing article job/export behavior working, but make every new optimization produce a case, input record, result version, and LLM audit summary. Use result versions as the long-term anchor for publication records, performance snapshots, and re-runs. **Tech Stack:** Next.js App Router, TypeScript, Zod, Vitest, Playwright, SQLite via better-sqlite3, Cloudflare D1, existing R2/local export store. --- ## Source Documents - `CONTEXT.md` - `docs/adr/0001-unified-optimization-case-library.md` - `docs/superpowers/specs/2026-07-08-long-term-optimization-case-storage-design.md` ## Implementation Principles - New business language uses `优化案例`, `文章优化案例`, `人味文案优化案例`, `结果版本`, `LLM 审计摘要`, and `效果学习库`. - Preserve the technical route `/api/copy/renwei-optimize`, but all user-facing copy says `人味文案优化`. - Do not store full prompt/response in long-term tables. - Auto-save begins only after request validation succeeds. Empty or malformed input returns a validation error without creating a case. - Failed LLM runs after validation create a failed case and a failed result version. - Re-running from a case creates a new result version and never overwrites an old version. - Archive hides cases from the default list. It does not hard-delete records. - New migration serves new records first. Historical article jobs can remain job-only until a user opens or re-runs them. ## File Structure Create: - `src/lib/cases/types.ts`: case, case input, result version, list filters, and publication result-version types. - `src/lib/cases/validation.ts`: Zod schemas for case filters, metadata patching, result-version publication input, and human-copy publish target. - `src/lib/cases/summaries.ts`: pure helpers for source excerpts, default titles, result summaries, and process summaries. - `src/lib/llm/audit.ts`: LLM audit summary type and hash helper. - `src/lib/llm/__tests__/audit.test.ts`: verifies audit hashes and prompt/response exclusion. - `src/lib/cases/__tests__/validation.test.ts`: domain validation tests for cases. - `src/lib/cases/__tests__/summaries.test.ts`: pure summary helper tests. - `migrations/0003_unified_optimization_cases.sql`: D1 migration for case tables and result-version references. - `src/app/api/cases/route.ts`: list cases. - `src/app/api/cases/[caseId]/route.ts`: read and patch a case. - `src/app/api/cases/[caseId]/archive/route.ts`: archive a case. - `src/app/api/cases/[caseId]/restore/route.ts`: restore a case. - `src/app/api/cases/[caseId]/rerun/route.ts`: create a new result version from stored input. - `src/app/api/cases/[caseId]/versions/[versionId]/publications/route.ts`: create and list result-version publications. - `src/app/cases/page.tsx`: case list route. - `src/app/cases/[caseId]/page.tsx`: case detail route. - `src/components/cases/case-list.tsx`: list table and filters. - `src/components/cases/case-detail.tsx`: shared detail shell. - `src/components/cases/article-case-detail.tsx`: article-only detail modules. - `src/components/cases/human-copy-case-detail.tsx`: human-copy-only detail modules. - `src/components/cases/case-publication-panel.tsx`: publication and manual performance entry for result versions. - `src/components/cases/case-result-version-list.tsx`: result-version selector/list. - `src/app/api/__tests__/cases.test.ts`: case API tests. - `tests/e2e/cases.spec.ts`: browser coverage for list/detail and human-copy storage. Modify: - `src/lib/domain/types.ts`: add `publish_target` to `CopyOptimizationRequest`; keep existing article types stable. - `src/lib/domain/validation.ts`: validate `publish_target` for human-copy requests. - `src/lib/calibration/types.ts`: allow publication/scoring records to bind to `result_version_id` while keeping article `job_id`/`revision` compatibility. - `src/lib/calibration/validation.ts`: validate result-version publication input. - `src/lib/calibration/scoring.ts`: add a human-copy rubric and scoring function. - `src/lib/db/schema.ts`: initialize new case tables for local SQLite. - `src/lib/db/repository.ts`: extend `AppRepository` with case, version, publication, and result-version scoring methods. - `src/lib/db/repositories.ts`: implement new SQLite repository functions. - `src/lib/db/sqlite-repository.ts`: expose new SQLite repository functions. - `src/lib/db/d1-repository.ts`: implement matching D1 repository functions. - `src/lib/db/__tests__/repositories.test.ts`: assert local schema and low-level SQLite persistence. - `src/lib/db/__tests__/repository.test.ts`: assert async repository behavior. - `src/app/api/jobs/route.ts`: create article case during job creation. - `src/app/api/jobs/optimize-stream/route.ts`: auto-save streaming article cases, success versions, and failed versions. - `src/app/api/jobs/[jobId]/optimize/route.ts`: auto-save non-streaming article result versions. - `src/app/api/jobs/[jobId]/publications/route.ts`: keep job API working and backfill result-version references when possible. - `src/app/api/jobs/[jobId]/calibration/score/route.ts`: save article scoring with result-version reference. - `src/app/api/publications/[publicationId]/performance/route.ts`: support result-version publications and keep article fallback. - `src/app/api/copy/renwei-optimize/route.ts`: auto-save human-copy cases and return case/version metadata. - `src/lib/workflow/fact-extractor.ts`: pass LLM audit callbacks through. - `src/lib/workflow/article-optimizer.ts`: pass LLM audit callbacks through. - `src/lib/workflow/quality-inspector.ts`: pass LLM audit callbacks through. - `src/lib/workflow/targeted-rewriter.ts`: pass LLM audit callbacks through. - `src/lib/workflow/streaming-optimizer.ts`: collect process summary and audit summaries. - `src/lib/workflow/orchestrator.ts`: collect process summary and audit summaries. - `src/lib/workflow/renwei-copy-optimizer.ts`: collect audit summaries. - `src/lib/workflow/stream-events.ts`: include optional case metadata in the `job_created` and `final_ready` events. - `src/components/renwei-copy-optimizer-panel.tsx`: add publish target input and show saved case link. - `src/components/performance-calibration-panel.tsx`: keep old job page usable while case detail becomes the primary long-term entry. - `src/app/page.tsx`: add top-level navigation to `案例库`. - `src/app/globals.css`: style case list/detail layouts. - `src/app/api/__tests__/copy-renwei.test.ts`: update human-copy API tests. - `src/app/api/__tests__/jobs.test.ts`: update article API tests. - `tests/e2e/renwei-copy.spec.ts`: assert publish target is sent and case link appears. ## Database Model Use these table shapes in `migrations/0003_unified_optimization_cases.sql` and mirror them in `src/lib/db/schema.ts`. ```sql CREATE TABLE IF NOT EXISTS optimization_cases ( id TEXT PRIMARY KEY, case_type TEXT NOT NULL, title TEXT NOT NULL, summary TEXT NOT NULL, status TEXT NOT NULL, customer_name TEXT NOT NULL DEFAULT '', brand_name TEXT NOT NULL DEFAULT '', project_tags TEXT NOT NULL DEFAULT '[]', notes TEXT NOT NULL DEFAULT '', publish_target TEXT NOT NULL DEFAULT '', source_excerpt TEXT NOT NULL DEFAULT '', result_excerpt TEXT NOT NULL DEFAULT '', latest_result_version_id TEXT, latest_version_number INTEGER, last_error_stage TEXT, last_error_summary TEXT, archived_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS case_inputs ( case_id TEXT PRIMARY KEY, case_type TEXT NOT NULL, article_job_id TEXT, payload TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (case_id) REFERENCES optimization_cases(id) ON DELETE CASCADE, FOREIGN KEY (article_job_id) REFERENCES article_jobs(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS optimization_result_versions ( id TEXT PRIMARY KEY, case_id TEXT NOT NULL, case_type TEXT NOT NULL, version INTEGER NOT NULL, status TEXT NOT NULL, article_job_id TEXT, article_revision INTEGER, result_summary TEXT NOT NULL, payload TEXT NOT NULL, process_summary TEXT NOT NULL, llm_audit_summary TEXT NOT NULL, error_stage TEXT, error_summary TEXT, created_at TEXT NOT NULL, UNIQUE (case_id, version), FOREIGN KEY (case_id) REFERENCES optimization_cases(id) ON DELETE CASCADE, FOREIGN KEY (article_job_id) REFERENCES article_jobs(id) ON DELETE SET NULL ); CREATE INDEX IF NOT EXISTS idx_optimization_cases_updated_at ON optimization_cases(updated_at); CREATE INDEX IF NOT EXISTS idx_optimization_cases_case_type ON optimization_cases(case_type); CREATE INDEX IF NOT EXISTS idx_optimization_cases_status ON optimization_cases(status); CREATE INDEX IF NOT EXISTS idx_optimization_result_versions_case ON optimization_result_versions(case_id, version DESC); ALTER TABLE article_jobs ADD COLUMN case_id TEXT; ``` For publication and scoring compatibility, use a table rebuild in the migration because `publication_records.job_id` and `revision` are currently required. Preserve existing rows. ```sql CREATE TABLE IF NOT EXISTS publication_records_next ( id TEXT PRIMARY KEY, result_version_id TEXT, job_id TEXT, revision INTEGER, publish_target TEXT NOT NULL, url TEXT NOT NULL, published_at TEXT NOT NULL, status TEXT NOT NULL, notes TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (result_version_id) REFERENCES optimization_result_versions(id) ON DELETE CASCADE ); INSERT INTO publication_records_next ( id, result_version_id, job_id, revision, publish_target, url, published_at, status, notes, created_at, updated_at ) SELECT id, NULL, job_id, revision, platform, url, published_at, status, notes, created_at, updated_at FROM publication_records; DROP TABLE publication_records; ALTER TABLE publication_records_next RENAME TO publication_records; CREATE INDEX IF NOT EXISTS idx_publication_records_result_version ON publication_records(result_version_id); CREATE INDEX IF NOT EXISTS idx_publication_records_job_revision ON publication_records(job_id, revision); ``` For scoring compatibility, keep article scoring by `job_id`/`revision` and add result-version fields. ```sql ALTER TABLE scoring_runs ADD COLUMN result_version_id TEXT; ALTER TABLE scoring_runs ADD COLUMN case_type TEXT NOT NULL DEFAULT 'article'; ``` ## Task 1: Case Domain Types And Validation **Files:** - Create: `src/lib/cases/types.ts` - Create: `src/lib/cases/validation.ts` - Create: `src/lib/cases/__tests__/validation.test.ts` - Modify: `src/lib/domain/types.ts` - Modify: `src/lib/domain/validation.ts` - Modify: `src/lib/domain/__tests__/validation.test.ts` - [ ] **Step 1: Write failing case validation tests** Add this test file: ```ts import { describe, expect, it } from "vitest"; import { caseListFiltersSchema, caseMetadataPatchSchema, caseTypeSchema, resultVersionStatusSchema, } from "../validation"; describe("case validation", () => { it("accepts the supported case types and result-version statuses", () => { expect(caseTypeSchema.parse("article")).toBe("article"); expect(caseTypeSchema.parse("human_copy")).toBe("human_copy"); expect(resultVersionStatusSchema.parse("optimized")).toBe("optimized"); expect(resultVersionStatusSchema.parse("failed")).toBe("failed"); }); it("keeps optional ownership metadata empty and editable", () => { expect(caseMetadataPatchSchema.parse({})).toEqual({}); expect( caseMetadataPatchSchema.parse({ customer_name: "伟思德鲁", brand_name: "IPMS", project_tags: ["推荐榜单", "2026"], notes: "客户偏好保留专业语气。", }), ).toEqual({ customer_name: "伟思德鲁", brand_name: "IPMS", project_tags: ["推荐榜单", "2026"], notes: "客户偏好保留专业语气。", }); }); it("parses default list filters without archived cases", () => { expect(caseListFiltersSchema.parse({})).toEqual({ include_archived: false, }); expect( caseListFiltersSchema.parse({ case_type: "human_copy", status: "optimized", project_tag: "朋友圈", q: "自然表达", include_archived: "true", }), ).toMatchObject({ case_type: "human_copy", status: "optimized", project_tag: "朋友圈", q: "自然表达", include_archived: true, }); }); }); ``` - [ ] **Step 2: Write failing human-copy publish-target validation test** Add this assertion to `src/lib/domain/__tests__/validation.test.ts`: ```ts it("validates human-copy publish target", () => { expect( copyOptimizationRequestSchema.parse({ source_text: "这是一段普通文案。", goal: "", intensity: "light", user_instructions: "", publish_target: "朋友圈", }), ).toMatchObject({ goal: "保留原意,减少 AI 味", publish_target: "朋友圈", }); }); ``` - [ ] **Step 3: Run tests and verify they fail** Run: ```bash npm test -- src/lib/cases/__tests__/validation.test.ts src/lib/domain/__tests__/validation.test.ts ``` Expected: fail because `src/lib/cases/validation.ts` does not exist and `publish_target` is not in `CopyOptimizationRequest`. - [ ] **Step 4: Add case types** Create `src/lib/cases/types.ts`: ```ts import type { CopyOptimizationRequest, CopyOptimizationResult, OptimizedArticle, QaReport, } from "../domain/types"; import type { LlmAuditSummary } from "../llm/audit"; export type OptimizationCaseType = "article" | "human_copy"; export type OptimizationCaseStatus = | "running" | "optimized" | "failed" | "archived"; export type ResultVersionStatus = "optimized" | "failed"; export interface ProcessSummaryStep { stage: string; started_at: string; ended_at: string; duration_ms: number; status: "success" | "failed"; error_summary?: string; rewrite_round?: number; produced_result_version: boolean; } export interface OptimizationCase { id: string; case_type: OptimizationCaseType; title: string; summary: string; status: OptimizationCaseStatus; customer_name: string; brand_name: string; project_tags: string[]; notes: string; publish_target: string; source_excerpt: string; result_excerpt: string; latest_result_version_id: string | null; latest_version_number: number | null; last_error_stage: string | null; last_error_summary: string | null; archived_at: string | null; created_at: string; updated_at: string; } export interface CaseInput { case_id: string; case_type: OptimizationCaseType; article_job_id: string | null; payload: ArticleCaseInputPayload | HumanCopyCaseInputPayload; created_at: string; updated_at: string; } export interface ArticleCaseInputPayload { source_title: string; source_body: string; image_inputs: Array<{ type: "description" | "link"; content: string }>; publish_platform: string; user_instructions: string; fact_card?: unknown; } export interface HumanCopyCaseInputPayload extends CopyOptimizationRequest {} export interface ArticleResultVersionPayload { article: OptimizedArticle; qa_report: QaReport; export_paths: Record; } export interface HumanCopyResultVersionPayload extends CopyOptimizationResult {} export interface OptimizationResultVersion { id: string; case_id: string; case_type: OptimizationCaseType; version: number; status: ResultVersionStatus; article_job_id: string | null; article_revision: number | null; result_summary: string; payload: ArticleResultVersionPayload | HumanCopyResultVersionPayload | null; process_summary: ProcessSummaryStep[]; llm_audit_summary: LlmAuditSummary[]; error_stage: string | null; error_summary: string | null; created_at: string; } export interface OptimizationCaseDetail { case: OptimizationCase; input: CaseInput | null; versions: OptimizationResultVersion[]; } export interface CaseListFilters { q?: string; case_type?: OptimizationCaseType; status?: OptimizationCaseStatus; publish_target?: string; project_tag?: string; created_from?: string; created_to?: string; include_archived: boolean; } export interface CaseMetadataPatch { title?: string; customer_name?: string; brand_name?: string; project_tags?: string[]; notes?: string; } ``` - [ ] **Step 5: Add case validation schemas** Create `src/lib/cases/validation.ts`: ```ts import { z } from "zod"; import type { CaseListFilters, CaseMetadataPatch, OptimizationCaseStatus, OptimizationCaseType, ResultVersionStatus, } from "./types"; export const caseTypeSchema = z.enum([ "article", "human_copy", ]) satisfies z.ZodType; export const caseStatusSchema = z.enum([ "running", "optimized", "failed", "archived", ]) satisfies z.ZodType; export const resultVersionStatusSchema = z.enum([ "optimized", "failed", ]) satisfies z.ZodType; const optionalTrimmedText = z .preprocess((value) => (value == null ? undefined : value), z.string().trim()) .optional(); function booleanQuery(value: unknown) { if (value === true || value === "true") return true; if (value === false || value === "false") return false; return value; } export const caseMetadataPatchSchema = z.object({ title: optionalTrimmedText, customer_name: optionalTrimmedText, brand_name: optionalTrimmedText, project_tags: z.array(z.string().trim().min(1)).optional(), notes: optionalTrimmedText, }) satisfies z.ZodType; export const caseListFiltersSchema = z.object({ q: optionalTrimmedText, case_type: caseTypeSchema.optional(), status: caseStatusSchema.optional(), publish_target: optionalTrimmedText, project_tag: optionalTrimmedText, created_from: optionalTrimmedText, created_to: optionalTrimmedText, include_archived: z.preprocess(booleanQuery, z.boolean().default(false)), }) satisfies z.ZodType; export const resultVersionPublicationInputSchema = z.object({ publish_target: z.string().trim().min(1), url: z.string().trim().url(), published_at: z.string().datetime(), notes: z.string().trim().default(""), }); ``` - [ ] **Step 6: Add `publish_target` to human-copy request types and validation** Modify `src/lib/domain/types.ts`: ```ts export interface CopyOptimizationRequest { source_text: string; goal: string; intensity: CopyOptimizationIntensity; user_instructions: string; publish_target: string; } ``` Modify `copyOptimizationRequestSchema` in `src/lib/domain/validation.ts`: ```ts export const copyOptimizationRequestSchema = z.object({ source_text: z.string().trim().min(1), goal: z .preprocess((value) => { if (typeof value !== "string") return value; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : "保留原意,减少 AI 味"; }, z.string().trim().min(1)) .default("保留原意,减少 AI 味"), intensity: copyOptimizationIntensitySchema.default("light"), user_instructions: z.string().trim().default(""), publish_target: z .preprocess((value) => { if (typeof value !== "string") return value; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : "未指定"; }, z.string().trim().min(1)) .default("未指定"), }) satisfies z.ZodType; ``` - [ ] **Step 7: Run tests and commit** Run: ```bash npm test -- src/lib/cases/__tests__/validation.test.ts src/lib/domain/__tests__/validation.test.ts ``` Expected: pass. Commit: ```bash git add src/lib/cases src/lib/domain/types.ts src/lib/domain/validation.ts src/lib/domain/__tests__/validation.test.ts git commit -m "新增优化案例领域模型" ``` ## Task 2: Summary Helpers And LLM Audit Boundary **Files:** - Create: `src/lib/cases/summaries.ts` - Create: `src/lib/cases/__tests__/summaries.test.ts` - Create: `src/lib/llm/audit.ts` - Create: `src/lib/llm/__tests__/audit.test.ts` - Modify: `src/lib/llm/client.ts` - Modify: `src/lib/llm/__tests__/client.test.ts` - [ ] **Step 1: Write failing summary helper tests** Create `src/lib/cases/__tests__/summaries.test.ts`: ```ts import { describe, expect, it } from "vitest"; import { buildArticleCaseSummary, buildHumanCopyCaseSummary, createProcessStep, excerpt, } from "../summaries"; describe("case summaries", () => { it("creates compact source excerpts", () => { expect(excerpt("第一段。\n\n第二段内容很长".repeat(20), 20)).toHaveLength(21); expect(excerpt(" 一段文案 ", 20)).toBe("一段文案"); }); it("builds article case title, summary, and publish target", () => { expect( buildArticleCaseSummary({ source_title: "IPMS 推荐机构文章", source_body: "正文内容", publish_platform: "media_article", }), ).toEqual({ title: "IPMS 推荐机构文章", summary: "正文内容", publish_target: "media_article", source_excerpt: "正文内容", }); }); it("builds human-copy case summary from source and publish target", () => { expect( buildHumanCopyCaseSummary({ source_text: "帮客户解释智能体授课的价值。", goal: "自然一点", intensity: "light", user_instructions: "", publish_target: "朋友圈", }), ).toMatchObject({ title: "人味文案优化:朋友圈", publish_target: "朋友圈", source_excerpt: "帮客户解释智能体授课的价值。", }); }); it("creates a process summary step without draft text", () => { expect( createProcessStep({ stage: "draft", startedAt: 100, endedAt: 250, status: "success", producedResultVersion: false, }), ).toEqual({ stage: "draft", started_at: expect.any(String), ended_at: expect.any(String), duration_ms: 150, status: "success", produced_result_version: false, }); }); }); ``` - [ ] **Step 2: Write failing LLM audit tests** Create `src/lib/llm/__tests__/audit.test.ts`: ```ts import { describe, expect, it } from "vitest"; import { createLlmAuditSummary, hashContent } from "../audit"; describe("LLM audit summary", () => { it("hashes content deterministically", async () => { await expect(hashContent("abc")).resolves.toBe( "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", ); }); it("does not retain raw prompt or response", async () => { const summary = await createLlmAuditSummary({ provider: "deepseek", model: "deepseek-v4-pro", task: "renwei_copy_optimizer", duration_ms: 12, schema_valid: true, prompt: "完整 prompt 不应长期保存", output: "完整 response 不应长期保存", error_summary: null, }); expect(summary).toMatchObject({ provider: "deepseek", model: "deepseek-v4-pro", task: "renwei_copy_optimizer", duration_ms: 12, schema_valid: true, error_summary: null, }); expect(JSON.stringify(summary)).not.toContain("完整 prompt"); expect(JSON.stringify(summary)).not.toContain("完整 response"); expect(summary.input_hash).toHaveLength(64); expect(summary.output_hash).toHaveLength(64); }); }); ``` - [ ] **Step 3: Run tests and verify they fail** Run: ```bash npm test -- src/lib/cases/__tests__/summaries.test.ts src/lib/llm/__tests__/audit.test.ts ``` Expected: fail because helper modules do not exist. - [ ] **Step 4: Implement summary helpers** Create `src/lib/cases/summaries.ts`: ```ts import type { ArticleCaseInputPayload, HumanCopyCaseInputPayload, ProcessSummaryStep, } from "./types"; export function excerpt(value: string, maxLength = 120) { const compact = value.replace(/\s+/g, " ").trim(); return compact.length > maxLength ? `${compact.slice(0, maxLength)}…` : compact; } export function buildArticleCaseSummary(input: Pick< ArticleCaseInputPayload, "source_title" | "source_body" | "publish_platform" >) { const sourceExcerpt = excerpt(input.source_body); return { title: input.source_title.trim() || excerpt(input.source_body, 32), summary: sourceExcerpt, publish_target: input.publish_platform, source_excerpt: sourceExcerpt, }; } export function buildHumanCopyCaseSummary(input: HumanCopyCaseInputPayload) { const publishTarget = input.publish_target.trim() || "未指定"; const sourceExcerpt = excerpt(input.source_text); return { title: `人味文案优化:${publishTarget}`, summary: sourceExcerpt, publish_target: publishTarget, source_excerpt: sourceExcerpt, }; } export function createProcessStep({ stage, startedAt, endedAt, status, errorSummary, rewriteRound, producedResultVersion, }: { stage: string; startedAt: number; endedAt: number; status: ProcessSummaryStep["status"]; errorSummary?: string; rewriteRound?: number; producedResultVersion: boolean; }): ProcessSummaryStep { return { stage, started_at: new Date(startedAt).toISOString(), ended_at: new Date(endedAt).toISOString(), duration_ms: Math.max(0, endedAt - startedAt), status, ...(errorSummary ? { error_summary: errorSummary } : {}), ...(rewriteRound ? { rewrite_round: rewriteRound } : {}), produced_result_version: producedResultVersion, }; } ``` - [ ] **Step 5: Implement LLM audit helpers** Create `src/lib/llm/audit.ts`: ```ts import type { LlmProviderStatus, LlmTaskName } from "./client"; export interface LlmAuditSummary { provider: LlmProviderStatus["provider"]; model: string; task: LlmTaskName; duration_ms: number; schema_valid: boolean; error_summary: string | null; input_hash: string; output_hash: string | null; } export async function hashContent(value: string) { const data = new TextEncoder().encode(value); const digest = await crypto.subtle.digest("SHA-256", data); return Array.from(new Uint8Array(digest)) .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); } export async function createLlmAuditSummary({ provider, model, task, duration_ms, schema_valid, prompt, output, error_summary, }: { provider: LlmProviderStatus["provider"]; model: string; task: LlmTaskName; duration_ms: number; schema_valid: boolean; prompt: string; output: string | null; error_summary: string | null; }): Promise { return { provider, model, task, duration_ms, schema_valid, error_summary, input_hash: await hashContent(prompt), output_hash: output == null ? null : await hashContent(output), }; } ``` - [ ] **Step 6: Add audit callback support to LLM client** Modify `GenerateInput` in `src/lib/llm/client.ts`: ```ts import { createLlmAuditSummary, type LlmAuditSummary } from "./audit"; export interface GenerateInput { system?: string; prompt: string; model?: string; temperature?: number; task?: LlmTaskName; onAuditSummary?: (summary: LlmAuditSummary) => void | Promise; } ``` In `generateValidatedJson`, after `const startedAt = Date.now();`, add: ```ts const effectiveModel = input.model ?? status.model; const emitAudit = async ({ schemaValid, output, errorSummary, }: { schemaValid: boolean; output: unknown | null; errorSummary: string | null; }) => { if (!input.onAuditSummary) return; await input.onAuditSummary( await createLlmAuditSummary({ provider: status.provider, model: effectiveModel, task, duration_ms: Date.now() - startedAt, schema_valid: schemaValid, prompt: input.prompt, output: output == null ? null : stringifyForLog(output), error_summary: errorSummary, }), ); }; ``` Call it on parse success: ```ts await emitAudit({ schemaValid: true, output: parsed.data, errorSummary: null, }); ``` Call it before throwing validation errors: ```ts const summary = summarizeZodError(parsed.error); await emitAudit({ schemaValid: false, output: generated, errorSummary: summary, }); throw new LlmValidationError( `LLM response failed schema validation: ${summary}`, task, ); ``` Call it in provider errors before rethrowing: ```ts await emitAudit({ schemaValid: false, output: null, errorSummary: message, }); ``` - [ ] **Step 7: Update LLM client tests** Add to `src/lib/llm/__tests__/client.test.ts`: ```ts it("emits an audit summary without raw prompt or response", async () => { const audits: unknown[] = []; setGenerateJsonForValidation(async () => ({ value: "ok" })); const result = await generateValidatedJson({ schema: z.object({ value: z.string() }), prompt: "raw prompt", task: "unknown", onAuditSummary: (summary) => audits.push(summary), }); expect(result).toEqual({ value: "ok" }); expect(JSON.stringify(audits)).not.toContain("raw prompt"); expect(JSON.stringify(audits)).not.toContain("ok"); expect(audits).toEqual([ expect.objectContaining({ task: "unknown", schema_valid: true, input_hash: expect.any(String), output_hash: expect.any(String), }), ]); }); ``` - [ ] **Step 8: Run tests and commit** Run: ```bash npm test -- src/lib/cases/__tests__/summaries.test.ts src/lib/llm/__tests__/audit.test.ts src/lib/llm/__tests__/client.test.ts ``` Expected: pass. Commit: ```bash git add src/lib/cases src/lib/llm git commit -m "新增LLM审计摘要边界" ``` ## Task 3: Database Migration And Repository Contract **Files:** - Create: `migrations/0003_unified_optimization_cases.sql` - Modify: `src/lib/db/schema.ts` - Modify: `src/lib/db/repository.ts` - Modify: `src/lib/db/repositories.ts` - Modify: `src/lib/db/sqlite-repository.ts` - Modify: `src/lib/db/d1-repository.ts` - Modify: `src/lib/calibration/types.ts` - Modify: `src/lib/calibration/validation.ts` - Modify: `src/lib/db/__tests__/repositories.test.ts` - Modify: `src/lib/db/__tests__/repository.test.ts` - Modify: `src/lib/db/__tests__/d1-repository.test.ts` - [ ] **Step 1: Write failing SQLite schema test** Update the table expectation in `src/lib/db/__tests__/repositories.test.ts`: ```ts expect(tables).toEqual([ "article_jobs", "brand_templates", "calibration_events", "case_inputs", "fact_cards", "optimization_cases", "optimization_result_versions", "optimized_articles", "performance_snapshots", "publication_records", "qa_reports", "rubric_versions", "scoring_runs", ]); ``` Add a column assertion: ```ts it("adds case references to article jobs and result-version publications", () => { const db = createDatabase(dbPath); const articleJobColumns = db .prepare("pragma table_info(article_jobs)") .all() .map((row) => (row as { name: string }).name); const publicationColumns = db .prepare("pragma table_info(publication_records)") .all() .map((row) => (row as { name: string }).name); db.close(); expect(articleJobColumns).toContain("case_id"); expect(publicationColumns).toContain("result_version_id"); expect(publicationColumns).toContain("publish_target"); }); ``` - [ ] **Step 2: Write failing repository tests** Add to `src/lib/db/__tests__/repository.test.ts`: ```ts test("creates, lists, updates, archives, and restores optimization cases", async () => { const repository = createSqliteRepository(dbPath); const created = await repository.createOptimizationCase({ case_type: "human_copy", title: "人味文案优化:朋友圈", summary: "原文摘要", publish_target: "朋友圈", source_excerpt: "原文摘要", }); await repository.saveCaseInput({ case_id: created.id, case_type: "human_copy", article_job_id: null, payload: { source_text: "原文摘要", goal: "自然一点", intensity: "light", user_instructions: "", publish_target: "朋友圈", }, }); await expect(repository.listOptimizationCases({ include_archived: false })) .resolves.toEqual([expect.objectContaining({ id: created.id })]); await expect( repository.updateOptimizationCaseMetadata(created.id, { customer_name: "客户A", brand_name: "品牌B", project_tags: ["朋友圈"], notes: "保留口语。", }), ).resolves.toMatchObject({ customer_name: "客户A", project_tags: ["朋友圈"], }); await repository.archiveOptimizationCase(created.id); await expect(repository.listOptimizationCases({ include_archived: false })) .resolves.toHaveLength(0); await repository.restoreOptimizationCase(created.id); await expect(repository.getOptimizationCaseDetail(created.id)) .resolves.toMatchObject({ case: { id: created.id, status: "running" }, input: expect.objectContaining({ case_id: created.id }), }); }); test("creates multiple result versions and binds publication to a version", async () => { const repository = createSqliteRepository(dbPath); const optimizationCase = await repository.createOptimizationCase({ case_type: "human_copy", title: "人味文案优化:私域", summary: "原文", publish_target: "私域", source_excerpt: "原文", }); const first = await repository.createOptimizationResultVersion({ case_id: optimizationCase.id, case_type: "human_copy", status: "optimized", article_job_id: null, article_revision: null, result_summary: "第一版", payload: { optimized_text: "第一版文案", change_notes: [], ai_taste_checks: [], warnings: [], }, process_summary: [], llm_audit_summary: [], error_stage: null, error_summary: null, }); const second = await repository.createOptimizationResultVersion({ case_id: optimizationCase.id, case_type: "human_copy", status: "optimized", article_job_id: null, article_revision: null, result_summary: "第二版", payload: { optimized_text: "第二版文案", change_notes: [], ai_taste_checks: [], warnings: [], }, process_summary: [], llm_audit_summary: [], error_stage: null, error_summary: null, }); expect(first.version).toBe(1); expect(second.version).toBe(2); const publication = await repository.createPublicationRecord({ result_version_id: second.id, job_id: null, revision: null, publish_target: "私域", url: "https://example.com/private", published_at: "2026-07-08T12:00:00.000Z", status: "published", notes: "客户私域发布", }); await expect(repository.listPublicationRecordsForResultVersion(second.id)) .resolves.toEqual([expect.objectContaining({ id: publication.id })]); }); ``` - [ ] **Step 3: Run tests and verify they fail** Run: ```bash npm test -- src/lib/db/__tests__/repositories.test.ts src/lib/db/__tests__/repository.test.ts ``` Expected: fail because tables, columns, and repository methods do not exist. - [ ] **Step 4: Add the migration and local schema** Create `migrations/0003_unified_optimization_cases.sql` using the SQL from the `Database Model` section. In `src/lib/db/schema.ts`, add the same table definitions and indexes inside `initializeSchema`. Because `initializeSchema` is used for empty local SQLite databases, define `publication_records` directly with the new result-version-compatible shape there: ```sql create table if not exists publication_records ( id text primary key, result_version_id text, job_id text, revision integer, publish_target text not null, url text not null, published_at text not null, status text not null, notes text not null, created_at text not null, updated_at text not null, foreign key (result_version_id) references optimization_result_versions(id) on delete cascade ); ``` - [ ] **Step 5: Extend calibration types** Modify `PublicationRecord` in `src/lib/calibration/types.ts`: ```ts export interface PublicationRecord { id: string; result_version_id: string | null; job_id: string | null; revision: number | null; publish_target: string; url: string; published_at: string; status: "draft" | "published" | "archived"; notes: string; created_at: string; updated_at: string; } ``` Modify `ScoringRun`: ```ts export interface ScoringRun { id: string; result_version_id?: string | null; case_type?: "article" | "human_copy"; job_id: string | null; revision: number | null; rubric_version_id: string; dimension_scores: Record; composite_score: number; rationale: string; created_at: string; } ``` Update `publicationInputSchema` in `src/lib/calibration/validation.ts` to transform old `platform` into `publish_target`: ```ts export const publicationInputSchema = z.object({ platform: publishPlatformSchema.optional(), publish_target: z.string().trim().min(1).optional(), url: z.string().trim().url(), published_at: z.string().datetime(), notes: optionalTextSchema.default(""), }).transform((input) => ({ publish_target: input.publish_target ?? input.platform ?? "未指定", url: input.url, published_at: input.published_at, notes: input.notes, })); ``` - [ ] **Step 6: Extend `AppRepository`** Add these methods to `src/lib/db/repository.ts`: ```ts createOptimizationCase(input: { case_type: OptimizationCaseType; title: string; summary: string; publish_target: string; source_excerpt: string; }): Promise; saveCaseInput(input: Omit): Promise; listOptimizationCases(filters: CaseListFilters): Promise; getOptimizationCaseDetail(caseId: string): Promise; updateOptimizationCaseMetadata( caseId: string, changes: CaseMetadataPatch, ): Promise; archiveOptimizationCase(caseId: string): Promise; restoreOptimizationCase(caseId: string): Promise; markOptimizationCaseFailed( caseId: string, input: { error_stage: string; error_summary: string }, ): Promise; createOptimizationResultVersion(input: Omit< OptimizationResultVersion, "id" | "version" | "created_at" >): Promise; getOptimizationResultVersion(versionId: string): Promise; findResultVersionForArticleRevision( jobId: string, revision: number, ): Promise; listPublicationRecordsForResultVersion( resultVersionId: string, ): Promise; ``` Update `NewArticleJob` in `src/lib/db/repositories.ts`: ```ts export interface NewArticleJob { brand_template_id?: string | null; case_id?: string | null; source_title: string; source_body: string; image_inputs: ImageInput[]; publish_platform: PublishPlatform; user_instructions: string; } ``` Update `ArticleJob` with `case_id: string | null`. - [ ] **Step 7: Implement SQLite repository functions** In `src/lib/db/repositories.ts`, add row interfaces and converters: ```ts interface OptimizationCaseRow { id: string; case_type: OptimizationCaseType; title: string; summary: string; status: OptimizationCaseStatus; customer_name: string; brand_name: string; project_tags: string; notes: string; publish_target: string; source_excerpt: string; result_excerpt: string; latest_result_version_id: string | null; latest_version_number: number | null; last_error_stage: string | null; last_error_summary: string | null; archived_at: string | null; created_at: string; updated_at: string; } interface CaseInputRow { case_id: string; case_type: OptimizationCaseType; article_job_id: string | null; payload: string; created_at: string; updated_at: string; } interface OptimizationResultVersionRow { id: string; case_id: string; case_type: OptimizationCaseType; version: number; status: ResultVersionStatus; article_job_id: string | null; article_revision: number | null; result_summary: string; payload: string; process_summary: string; llm_audit_summary: string; error_stage: string | null; error_summary: string | null; created_at: string; } ``` Use these conversion helpers: ```ts function toOptimizationCase(row: OptimizationCaseRow): OptimizationCase { return { ...row, project_tags: parseJson(row.project_tags), }; } function toCaseInput(row: CaseInputRow): CaseInput { return { ...row, payload: parseJson(row.payload), }; } function toOptimizationResultVersion( row: OptimizationResultVersionRow, ): OptimizationResultVersion { return { ...row, payload: row.payload ? parseJson(row.payload) : null, process_summary: parseJson( row.process_summary, ), llm_audit_summary: parseJson( row.llm_audit_summary, ), }; } ``` Implement result-version numbering with a max query: ```ts const nextVersion = ((db .prepare( "select max(version) as version from optimization_result_versions where case_id = ?", ) .get(input.case_id) as { version: number | null }).version ?? 0) + 1; ``` After inserting an optimized or failed version, update `optimization_cases`: ```sql update optimization_cases set status = @status, result_excerpt = @result_excerpt, latest_result_version_id = @latest_result_version_id, latest_version_number = @latest_version_number, last_error_stage = @last_error_stage, last_error_summary = @last_error_summary, updated_at = @updated_at where id = @case_id ``` - [ ] **Step 8: Wire SQLite and D1 repositories** Expose all new functions from `createSqliteRepository` in `src/lib/db/sqlite-repository.ts`. Implement the same SQL in `src/lib/db/d1-repository.ts` using the existing D1 pattern: ```ts await db.prepare(sql).bind(valueA, valueB, valueC).run(); const result = await db.prepare(sql).bind(valueA).all(); ``` Keep converter output identical to SQLite output. - [ ] **Step 9: Update publication repository functions** Change `createPublicationRecord` inserts to use: ```ts record.result_version_id, record.job_id, record.revision, record.publish_target, record.url, record.published_at, record.status, record.notes, record.created_at, record.updated_at ``` Change `listPublicationRecords(jobId)` to keep the existing job API: ```sql select * from publication_records where job_id = ? order by published_at desc ``` Add `listPublicationRecordsForResultVersion(resultVersionId)`: ```sql select * from publication_records where result_version_id = ? order by published_at desc ``` - [ ] **Step 10: Run repository tests and commit** Run: ```bash npm test -- src/lib/db/__tests__/repositories.test.ts src/lib/db/__tests__/repository.test.ts src/lib/db/__tests__/d1-repository.test.ts ``` Expected: pass. Commit: ```bash git add migrations/0003_unified_optimization_cases.sql src/lib/db src/lib/calibration git commit -m "新增统一案例存储仓储" ``` ## Task 4: Article Optimization Auto-Save **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/lib/workflow/orchestrator.ts` - Modify: `src/lib/workflow/stream-events.ts` - Modify: `src/app/api/jobs/route.ts` - Modify: `src/app/api/jobs/optimize-stream/route.ts` - Modify: `src/app/api/jobs/[jobId]/optimize/route.ts` - Modify: `src/app/api/__tests__/jobs.test.ts` - [ ] **Step 1: Write failing article API tests** Add to `src/app/api/__tests__/jobs.test.ts`: ```ts it("auto-saves stream article optimization as a case and result version", async () => { llmMocks.generateValidatedJson .mockResolvedValueOnce(validCandidateFactCard) .mockResolvedValueOnce({ title: "流式优化标题", summary: "流式优化摘要。", body_markdown: "## 服务能力\nExample Technology Co., Ltd. 提供 GEO optimization 服务。", image_suggestions: [], changed_sections: ["title", "body"], requires_user_confirmation: [], }) .mockResolvedValueOnce({ checks: [] }); const response = await optimizeStream( request({ body: "Example Technology Co., Ltd. has 8 years of GEO optimization experience.", platform: "official_site", }), ); const events = await streamEvents(response); const finalEvent = events.find((event) => event.type === "final_ready") as | (StreamEventResponse & { case?: { id: string; case_type: string }; result_version?: { id: string; version: number }; }) | undefined; expect(finalEvent?.case?.case_type).toBe("article"); expect(finalEvent?.result_version?.version).toBe(1); const listResponse = await listCases(request({})); const listBody = (await listResponse.json()) as { cases: Array<{ id: string; case_type: string; status: string }>; }; expect(listBody.cases).toEqual([ expect.objectContaining({ id: finalEvent?.case?.id, case_type: "article", status: "optimized", }), ]); }); it("auto-saves stream article LLM failure as a failed case", async () => { llmMocks.generateValidatedJson .mockResolvedValueOnce(validCandidateFactCard) .mockRejectedValueOnce(new Error("LLM provider error: timeout")); const response = await optimizeStream( request({ body: "Example Technology Co., Ltd. has 8 years of GEO optimization experience.", platform: "official_site", }), ); const events = await streamEvents(response); const failedEvent = events[events.length - 1] as StreamEventResponse & { case?: { id: string }; }; expect(failedEvent.type).toBe("failed"); expect(failedEvent.case?.id).toMatch(/^case_/); const detailResponse = await getCase( request({}), params<{ caseId: string }>({ caseId: failedEvent.case.id }), ); const detailBody = (await detailResponse.json()) as { case: { status: string; last_error_stage: string }; versions: Array<{ status: string; error_summary: string }>; }; expect(detailBody.case.status).toBe("failed"); expect(detailBody.case.last_error_stage).toBe("draft"); expect(detailBody.versions).toEqual([ expect.objectContaining({ status: "failed", error_summary: "LLM provider error: timeout", }), ]); }); ``` Import the new route handlers at the top of the test file: ```ts import { GET as listCases } from "../cases/route"; import { GET as getCase } from "../cases/[caseId]/route"; ``` - [ ] **Step 2: Run tests and verify they fail** Run: ```bash npm test -- src/app/api/__tests__/jobs.test.ts ``` Expected: fail because case APIs and route payloads are not implemented. - [ ] **Step 3: Add audit callback plumbing to workflow nodes** Change each workflow function to accept `onAuditSummary?: GenerateInput["onAuditSummary"]` and pass it to `generateValidatedJson`. Example for `src/lib/workflow/article-optimizer.ts`: ```ts import type { GenerateInput } from "../llm/client"; export interface OptimizeArticleInput { input: ArticleInput; factCard: OptimizationFactCard; onAuditSummary?: GenerateInput["onAuditSummary"]; } export async function optimizeArticle({ input, factCard, onAuditSummary, }: OptimizeArticleInput): Promise { const llmArticle = await generateValidatedJson({ schema: optimizedArticleSchema, system: ARTICLE_OPTIMIZER_SYSTEM_PROMPT, prompt: buildArticleOptimizerPrompt(input, factCard), temperature: 0.2, task: "article_optimizer", onAuditSummary, }); return optimizedArticleSchema.parse({ ...llmArticle, image_suggestions: [], }); } ``` Apply the same pattern to fact extraction, quality inspection, targeted rewrite, and human-copy optimization. - [ ] **Step 4: Collect process and audit summary in article workflows** Update `runStreamingOptimizationWorkflow` return value: ```ts return { article, qaReport, rewriteRounds, stoppedAfterMaxRewrites: qaReport.overall_status === "fail" && rewriteRounds >= 2, processSummary, llmAuditSummary, }; ``` Use a local audit array: ```ts const llmAuditSummary: LlmAuditSummary[] = []; const onAuditSummary = (summary: LlmAuditSummary) => { llmAuditSummary.push(summary); }; ``` Wrap each major stage with start/end timestamps and `createProcessStep`. Do not include article draft text in `processSummary`. For `runOptimizationWorkflow`, use the same output shape and preserve the existing `timing` object. - [ ] **Step 5: Extend stream events with case metadata** Modify `src/lib/workflow/stream-events.ts`: ```ts export type OptimizationStreamEvent = | { 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; } | { type: "failed"; job_id?: string; case?: { id: string; case_type: "article" }; result_version?: { id: string; version: number }; stage: OptimizationStreamStage; error: string; }; ``` - [ ] **Step 6: Create article case in `/api/jobs`** In `src/app/api/jobs/route.ts`, after `normalizeInput(payload)`, create a case before creating the article job: ```ts const caseSummary = buildArticleCaseSummary({ source_title: normalized.articleInput.title, source_body: normalized.articleInput.body, publish_platform: normalized.articleInput.platform, }); const optimizationCase = await repository.createOptimizationCase({ case_type: "article", ...caseSummary, }); const job = await repository.createArticleJob({ case_id: optimizationCase.id, source_title: normalized.articleInput.title, source_body: normalized.articleInput.body, image_inputs: normalized.articleInput.images, publish_platform: normalized.articleInput.platform, user_instructions: normalized.articleInput.user_instructions, }); await repository.saveCaseInput({ case_id: optimizationCase.id, case_type: "article", article_job_id: job.id, payload: { source_title: normalized.articleInput.title, source_body: normalized.articleInput.body, image_inputs: normalized.articleInput.images, publish_platform: normalized.articleInput.platform, user_instructions: normalized.articleInput.user_instructions, }, }); ``` On fact extraction failure, call `createOptimizationResultVersion` with: ```ts { case_id: optimizationCase.id, case_type: "article", status: "failed", article_job_id: job.id, article_revision: null, result_summary: "", payload: null, process_summary: [ createProcessStep({ stage: "fact_card", startedAt: factStartedAt, endedAt: Date.now(), status: "failed", errorSummary: message, producedResultVersion: false, }), ], llm_audit_summary: audits, error_stage: "fact_card", error_summary: message, } ``` - [ ] **Step 7: Create and update article case in `/api/jobs/optimize-stream`** In the stream route, create `optimizationCase` before `createArticleJob`, save the case input, and include case metadata in `job_created`. On success after exports: ```ts const resultVersion = await repository.createOptimizationResultVersion({ case_id: optimizationCase.id, case_type: "article", status: "optimized", article_job_id: job.id, article_revision: optimizedArticle.revision ?? 1, result_summary: optimizedArticle.summary, payload: { article: optimizedArticle, qa_report: qaReport, export_paths: exportPaths, }, process_summary: result.processSummary, llm_audit_summary: result.llmAuditSummary, error_stage: null, error_summary: null, }); ``` Include `case` and `result_version` in `final_ready`. On failure after case creation: ```ts const failedVersion = await repository.createOptimizationResultVersion({ case_id: optimizationCase.id, case_type: "article", status: "failed", article_job_id: jobId ?? null, article_revision: null, result_summary: "", payload: null, process_summary: processSummary, llm_audit_summary: llmAuditSummary, error_stage: stage, error_summary: message, }); ``` Include `case` and `result_version` in `failed`. - [ ] **Step 8: Backfill article cases in non-streaming optimize route** In `src/app/api/jobs/[jobId]/optimize/route.ts`, if `job.case_id` is null, create an article case and save case input before running optimization. On success, create an optimized result version. On failure, create a failed result version with stage `"optimize"`. - [ ] **Step 9: Run article API tests and commit** Run: ```bash npm test -- src/app/api/__tests__/jobs.test.ts src/lib/workflow/__tests__/stream-events.test.ts src/lib/workflow/__tests__/orchestrator.test.ts ``` Expected: pass. Commit: ```bash git add src/lib/workflow src/app/api/jobs src/app/api/__tests__/jobs.test.ts git commit -m "接入文章优化案例自动保存" ``` ## Task 5: Human-Copy Auto-Save **Files:** - Modify: `src/lib/workflow/renwei-copy-optimizer.ts` - Modify: `src/app/api/copy/renwei-optimize/route.ts` - Modify: `src/components/renwei-copy-optimizer-panel.tsx` - Modify: `src/app/api/__tests__/copy-renwei.test.ts` - Modify: `tests/e2e/renwei-copy.spec.ts` - [ ] **Step 1: Write failing human-copy API tests** Update the structured result test in `src/app/api/__tests__/copy-renwei.test.ts`: ```ts const response = await optimizeCopy( request({ source_text: "我把这段文案顺顺。", goal: "", intensity: "light", user_instructions: "保留口语。", publish_target: "朋友圈", }), ); const body = (await response.json()) as { case: { id: string; case_type: string }; result_version: { id: string; version: number }; result: { optimized_text: string; change_notes: unknown[] }; }; expect(response.status).toBe(200); expect(body.case.case_type).toBe("human_copy"); expect(body.result_version.version).toBe(1); expect(body.result.optimized_text).toBe("我把这段文案顺了一下。"); ``` Add a failed-case test: ```ts it("saves LLM failures as failed human-copy cases", async () => { llmMocks.generateValidatedJson.mockRejectedValueOnce( new Error("LLM response failed schema validation: optimized_text"), ); const response = await optimizeCopy( request({ source_text: "这是一段普通文案。", intensity: "light", publish_target: "私域", }), ); const body = (await response.json()) as { error: string; case?: { id: string; case_type: string }; result_version?: { id: string; version: number }; }; expect(response.status).toBe(502); expect(body.error).toBe( "LLM response failed schema validation: optimized_text", ); expect(body.case?.case_type).toBe("human_copy"); expect(body.result_version?.version).toBe(1); }); ``` - [ ] **Step 2: Run tests and verify they fail** Run: ```bash npm test -- src/app/api/__tests__/copy-renwei.test.ts ``` Expected: fail because the route returns only `{ result }`. - [ ] **Step 3: Pass audit callback through human-copy optimizer** Modify `src/lib/workflow/renwei-copy-optimizer.ts`: ```ts import type { GenerateInput } from "../llm/client"; export async function optimizeRenweiCopy( input: CopyOptimizationRequest, options: { onAuditSummary?: GenerateInput["onAuditSummary"] } = {}, ): Promise { return generateValidatedJson({ schema: copyOptimizationResultSchema, system: RENWEI_COPY_OPTIMIZER_SYSTEM_PROMPT, prompt: buildRenweiCopyOptimizationPrompt(input), temperature: 0.2, task: "renwei_copy_optimizer", onAuditSummary: options.onAuditSummary, }); } ``` - [ ] **Step 4: Save human-copy cases in the route** In `src/app/api/copy/renwei-optimize/route.ts`, after request parsing: ```ts const repository = getRepositoryFromRuntime(); const caseSummary = buildHumanCopyCaseSummary(payload); const optimizationCase = await repository.createOptimizationCase({ case_type: "human_copy", ...caseSummary, }); await repository.saveCaseInput({ case_id: optimizationCase.id, case_type: "human_copy", article_job_id: null, payload, }); const llmAuditSummary: LlmAuditSummary[] = []; const startedAt = Date.now(); ``` On success: ```ts const result = await optimizeRenweiCopy(payload, { onAuditSummary: (summary) => llmAuditSummary.push(summary), }); const resultVersion = await repository.createOptimizationResultVersion({ case_id: optimizationCase.id, case_type: "human_copy", status: "optimized", article_job_id: null, article_revision: null, result_summary: excerpt(result.optimized_text), payload: result, process_summary: [ createProcessStep({ stage: "human_copy_optimize", startedAt, endedAt: Date.now(), status: "success", producedResultVersion: true, }), ], llm_audit_summary: llmAuditSummary, error_stage: null, error_summary: null, }); return NextResponse.json({ case: { id: optimizationCase.id, case_type: "human_copy" }, result_version: { id: resultVersion.id, version: resultVersion.version }, result, }); ``` On LLM failure: ```ts const resultVersion = await repository.createOptimizationResultVersion({ case_id: optimizationCase.id, case_type: "human_copy", status: "failed", article_job_id: null, article_revision: null, result_summary: "", payload: null, process_summary: [ createProcessStep({ stage: "human_copy_optimize", startedAt, endedAt: Date.now(), status: "failed", errorSummary: message, producedResultVersion: false, }), ], llm_audit_summary: llmAuditSummary, error_stage: "human_copy_optimize", error_summary: message, }); return NextResponse.json( { error: message, case: { id: optimizationCase.id, case_type: "human_copy" }, result_version: { id: resultVersion.id, version: resultVersion.version }, }, { status: getErrorStatus(error) }, ); ``` - [ ] **Step 5: Add publish target and saved-case link to the panel** In `src/components/renwei-copy-optimizer-panel.tsx`, add state: ```ts const [publishTarget, setPublishTarget] = useState("朋友圈"); const [caseId, setCaseId] = useState(null); ``` Add a label to the form: ```tsx ``` Send it in the request body: ```ts publish_target: publishTarget, ``` Update response type: ```ts interface CopyOptimizeResponse { case?: { id: string; case_type: "human_copy" }; result_version?: { id: string; version: number }; result?: CopyOptimizationResult; error?: string; } ``` After success: ```ts setCaseId(body.case?.id ?? null); setMessage("文案优化完成,已保存到案例库。"); ``` Render a case link near the result: ```tsx {caseId ? ( 查看案例详情 ) : null} ``` - [ ] **Step 6: Update E2E route mock** In `tests/e2e/renwei-copy.spec.ts`, assert request payload: ```ts await page.route("**/api/copy/renwei-optimize", async (route) => { const payload = route.request().postDataJSON() as { publish_target?: string }; expect(payload.publish_target).toBe("朋友圈"); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ case: { id: "case_copy_1", case_type: "human_copy" }, result_version: { id: "ver_copy_1", version: 1 }, result: { optimized_text: "我观察到大家越来越难进入心流了。", change_notes: [ { original: "我观察到大家越来越难进入心流", revised: "我观察到大家越来越难进入心流了。", reason: "补足句尾语气,让句子自然收住。", confidence: "confident", revertible: false, }, ], ai_taste_checks: [ { rule_id: "promotion_tone", status: "pass", evidence: "没有新增宣传腔。", suggestion: "", }, ], warnings: [], }, }), }); }); ``` Fill publish target: ```ts await page.getByLabel("发布目标").fill("朋友圈"); await expect(page.getByRole("link", { name: "查看案例详情" })).toHaveAttribute( "href", "/cases/case_copy_1", ); ``` - [ ] **Step 7: Run tests and commit** Run: ```bash npm test -- src/app/api/__tests__/copy-renwei.test.ts npx playwright test tests/e2e/renwei-copy.spec.ts ``` Expected: pass. Commit: ```bash git add src/lib/workflow/renwei-copy-optimizer.ts src/app/api/copy src/components/renwei-copy-optimizer-panel.tsx src/app/api/__tests__/copy-renwei.test.ts tests/e2e/renwei-copy.spec.ts git commit -m "接入人味文案案例自动保存" ``` ## Task 6: Case APIs, Archive, Restore, And Rerun **Files:** - Create: `src/app/api/cases/route.ts` - Create: `src/app/api/cases/[caseId]/route.ts` - Create: `src/app/api/cases/[caseId]/archive/route.ts` - Create: `src/app/api/cases/[caseId]/restore/route.ts` - Create: `src/app/api/cases/[caseId]/rerun/route.ts` - Create: `src/app/api/cases/[caseId]/versions/[versionId]/publications/route.ts` - Modify: `src/app/api/publications/[publicationId]/performance/route.ts` - Modify: `src/app/api/__tests__/cases.test.ts` - [ ] **Step 1: Write failing case API tests** Create `src/app/api/__tests__/cases.test.ts` with these covered behaviors: ```ts import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createSqliteRepository } from "../../../lib/db/sqlite-repository"; import { GET as listCases } from "../cases/route"; import { GET as getCase, PATCH as patchCase } from "../cases/[caseId]/route"; import { POST as archiveCase } from "../cases/[caseId]/archive/route"; import { POST as restoreCase } from "../cases/[caseId]/restore/route"; import { POST as rerunCase } from "../cases/[caseId]/rerun/route"; import { GET as listVersionPublications, POST as createVersionPublication, } from "../cases/[caseId]/versions/[versionId]/publications/route"; const llmMocks = vi.hoisted(() => ({ generateValidatedJson: vi.fn(), })); vi.mock("../../../lib/llm/client", async () => { const actual = await vi.importActual( "../../../lib/llm/client", ); return { ...actual, generateValidatedJson: llmMocks.generateValidatedJson, }; }); describe("case APIs", () => { let tempDir: string; const originalDataDir = process.env.APP_DATA_DIR; const originalApiKey = process.env.API_ACCESS_KEY; const originalAuthDisabled = process.env.API_AUTH_DISABLED; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "geo-cases-api-")); process.env.APP_DATA_DIR = tempDir; process.env.API_ACCESS_KEY = "test-key"; process.env.API_AUTH_DISABLED = "false"; }); afterEach(() => { process.env.APP_DATA_DIR = originalDataDir; process.env.API_ACCESS_KEY = originalApiKey; process.env.API_AUTH_DISABLED = originalAuthDisabled; llmMocks.generateValidatedJson.mockReset(); rmSync(tempDir, { recursive: true, force: true }); }); it("lists, reads, patches, archives, and restores cases", async () => { const repository = createSqliteRepository(); const created = await repository.createOptimizationCase({ case_type: "human_copy", title: "人味文案优化:朋友圈", summary: "原文", publish_target: "朋友圈", source_excerpt: "原文", }); const patchResponse = await patchCase( request({ customer_name: "客户A", brand_name: "品牌B", project_tags: ["朋友圈"], notes: "保留口语。", }), params({ caseId: created.id }), ); expect(patchResponse.status).toBe(200); const archiveResponse = await archiveCase( request({}), params({ caseId: created.id }), ); expect(archiveResponse.status).toBe(200); const listResponse = await listCases(request({})); const listBody = (await listResponse.json()) as { cases: unknown[] }; expect(listBody.cases).toHaveLength(0); const restoreResponse = await restoreCase( request({}), params({ caseId: created.id }), ); expect(restoreResponse.status).toBe(200); const detailResponse = await getCase(request({}), params({ caseId: created.id })); const detailBody = (await detailResponse.json()) as { case: { customer_name: string; project_tags: string[] }; }; expect(detailBody.case.customer_name).toBe("客户A"); expect(detailBody.case.project_tags).toEqual(["朋友圈"]); }); it("creates publication records for result versions", async () => { const repository = createSqliteRepository(); const created = await repository.createOptimizationCase({ case_type: "human_copy", title: "人味文案优化:私域", summary: "原文", publish_target: "私域", source_excerpt: "原文", }); const version = await repository.createOptimizationResultVersion({ case_id: created.id, case_type: "human_copy", status: "optimized", article_job_id: null, article_revision: null, result_summary: "优化后文案", payload: { optimized_text: "优化后文案", change_notes: [], ai_taste_checks: [], warnings: [], }, process_summary: [], llm_audit_summary: [], error_stage: null, error_summary: null, }); const createResponse = await createVersionPublication( request({ publish_target: "私域", url: "https://example.com/private", published_at: "2026-07-08T12:00:00.000Z", notes: "客户发布", }), params({ caseId: created.id, versionId: version.id }), ); expect(createResponse.status).toBe(201); const listResponse = await listVersionPublications( request({}), params({ caseId: created.id, versionId: version.id }), ); const listBody = (await listResponse.json()) as { publications: Array<{ publish_target: string }>; }; expect(listBody.publications).toEqual([ expect.objectContaining({ publish_target: "私域" }), ]); }); it("reruns a human-copy case into a new result version", async () => { const repository = createSqliteRepository(); const created = await repository.createOptimizationCase({ case_type: "human_copy", title: "人味文案优化:朋友圈", summary: "原文", publish_target: "朋友圈", source_excerpt: "原文", }); await repository.saveCaseInput({ case_id: created.id, case_type: "human_copy", article_job_id: null, payload: { source_text: "原文", goal: "自然一点", intensity: "light", user_instructions: "", publish_target: "朋友圈", }, }); await repository.createOptimizationResultVersion({ case_id: created.id, case_type: "human_copy", status: "optimized", article_job_id: null, article_revision: null, result_summary: "第一版", payload: { optimized_text: "第一版", change_notes: [], ai_taste_checks: [], warnings: [], }, process_summary: [], llm_audit_summary: [], error_stage: null, error_summary: null, }); llmMocks.generateValidatedJson.mockResolvedValueOnce({ optimized_text: "第二版", change_notes: [], ai_taste_checks: [], warnings: [], }); const response = await rerunCase(request({}), params({ caseId: created.id })); const body = (await response.json()) as { result_version: { version: number }; result: { optimized_text: string }; }; expect(response.status).toBe(201); expect(body.result_version.version).toBe(2); expect(body.result.optimized_text).toBe("第二版"); }); }); function request(body: unknown, options: { apiKey?: string | null } = {}) { const headers: Record = { "content-type": "application/json" }; const apiKey = options.apiKey === undefined ? "test-key" : options.apiKey; if (apiKey) headers["x-api-key"] = apiKey; return new Request("http://localhost/api/cases", { method: "POST", body: JSON.stringify(body), headers, }); } function params>(values: T) { return { params: Promise.resolve(values) }; } ``` - [ ] **Step 2: Run tests and verify they fail** Run: ```bash npm test -- src/app/api/__tests__/cases.test.ts ``` Expected: fail because case route files do not exist. - [ ] **Step 3: Implement list/read/patch routes** `src/app/api/cases/route.ts`: ```ts import { NextResponse } from "next/server"; import { requireApiAccess } from "../../../lib/api/auth"; import { caseListFiltersSchema } from "../../../lib/cases/validation"; import { getRepositoryFromRuntime } from "../../../lib/db/repository"; export async function GET(request: Request) { const access = requireApiAccess(request); if (!access.ok) return access.response; const url = new URL(request.url); const filters = caseListFiltersSchema.parse( Object.fromEntries(url.searchParams.entries()), ); const repository = getRepositoryFromRuntime(); return NextResponse.json({ cases: await repository.listOptimizationCases(filters), }); } ``` `src/app/api/cases/[caseId]/route.ts`: ```ts import { NextResponse } from "next/server"; import { requireApiAccess } from "../../../../lib/api/auth"; import { caseMetadataPatchSchema } from "../../../../lib/cases/validation"; import { getRepositoryFromRuntime } from "../../../../lib/db/repository"; interface RouteContext { params: Promise<{ caseId: string }>; } export async function GET(request: Request, context: RouteContext) { const access = requireApiAccess(request); if (!access.ok) return access.response; const { caseId } = await context.params; const repository = getRepositoryFromRuntime(); const detail = await repository.getOptimizationCaseDetail(caseId); if (!detail) { return NextResponse.json({ error: "Case not found" }, { status: 404 }); } return NextResponse.json(detail); } export async function PATCH(request: Request, context: RouteContext) { const access = requireApiAccess(request); if (!access.ok) return access.response; const { caseId } = await context.params; const repository = getRepositoryFromRuntime(); const changes = caseMetadataPatchSchema.parse(await request.json()); const optimizationCase = await repository.updateOptimizationCaseMetadata( caseId, changes, ); if (!optimizationCase) { return NextResponse.json({ error: "Case not found" }, { status: 404 }); } return NextResponse.json({ case: optimizationCase }); } ``` - [ ] **Step 4: Implement archive and restore routes** Both routes require API access, load repository, call the matching repository method, return `404` when null, and return `{ case: optimizationCase }` on success. Archive route body: ```ts const optimizationCase = await repository.archiveOptimizationCase(caseId); ``` Restore route body: ```ts const optimizationCase = await repository.restoreOptimizationCase(caseId); ``` - [ ] **Step 5: Implement publication routes** `POST /api/cases/:caseId/versions/:versionId/publications`: ```ts const version = await repository.getOptimizationResultVersion(versionId); if (!version || version.case_id !== caseId) { return NextResponse.json({ error: "Result version not found" }, { status: 404 }); } const input = resultVersionPublicationInputSchema.parse(await request.json()); const publication = await repository.createPublicationRecord({ result_version_id: version.id, job_id: version.article_job_id, revision: version.article_revision, publish_target: input.publish_target, url: input.url, published_at: input.published_at, status: "published", notes: input.notes, }); return NextResponse.json({ publication }, { status: 201 }); ``` `GET` loads the version, verifies `case_id`, then returns `repository.listPublicationRecordsForResultVersion(version.id)`. - [ ] **Step 6: Implement human-copy rerun** In `src/app/api/cases/[caseId]/rerun/route.ts`, first support `human_copy`: ```ts const detail = await repository.getOptimizationCaseDetail(caseId); if (!detail?.input) { return NextResponse.json({ error: "Case not found" }, { status: 404 }); } if (detail.case.case_type !== "human_copy") { return NextResponse.json( { error: "Article case rerun uses the article workflow" }, { status: 409 }, ); } ``` Run `optimizeRenweiCopy` with `detail.input.payload`, collect audit summary, and create a new optimized result version. On LLM failure, create a failed result version and return the case/version metadata with the error and status code from `getErrorStatus`. - [ ] **Step 7: Implement article rerun** After human-copy rerun passes, extend the same route for `article`: - Load stored article input and optional fact card. - Create a new article job with the same input and `case_id`. - Save or extract fact card. - Run `runOptimizationWorkflow`. - Save optimized article, QA, exports, and a new result version. - Return `{ case, result_version, optimizedArticle, qaReport, exportPaths }` with status `201`. Use the same failure behavior as the stream route. - [ ] **Step 8: Update performance route for result-version publications** In `src/app/api/publications/[publicationId]/performance/route.ts`, after loading the publication: ```ts const scoringRun = publication.result_version_id ? await repository.getLatestScoringRunForResultVersion(publication.result_version_id) : publication.job_id && publication.revision ? await repository.getLatestScoringRun(publication.job_id, publication.revision) : null; ``` If no scoring run exists for a human-copy publication, still save the manual snapshot and return: ```ts return NextResponse.json({ snapshot, calibrationEvent: null }, { status: 201 }); ``` This keeps effect-learning data available before human-copy calibration is generated. - [ ] **Step 9: Run tests and commit** Run: ```bash npm test -- src/app/api/__tests__/cases.test.ts src/app/api/__tests__/jobs.test.ts src/app/api/__tests__/copy-renwei.test.ts ``` Expected: pass. Commit: ```bash git add src/app/api/cases src/app/api/publications src/app/api/__tests__/cases.test.ts git commit -m "新增优化案例API" ``` ## Task 7: Case List And Detail UI **Files:** - Create: `src/app/cases/page.tsx` - Create: `src/app/cases/[caseId]/page.tsx` - Create: `src/components/cases/case-list.tsx` - Create: `src/components/cases/case-detail.tsx` - Create: `src/components/cases/article-case-detail.tsx` - Create: `src/components/cases/human-copy-case-detail.tsx` - Create: `src/components/cases/case-result-version-list.tsx` - Create: `src/components/cases/case-publication-panel.tsx` - Modify: `src/app/page.tsx` - Modify: `src/app/globals.css` - Create: `tests/e2e/cases.spec.ts` - [ ] **Step 1: Write failing E2E tests** Create `tests/e2e/cases.spec.ts`: ```ts import { expect, test } from "@playwright/test"; test("案例库列表和人味文案详情可查看", async ({ page }) => { await page.route("**/api/cases", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ cases: [ { id: "case_copy_1", case_type: "human_copy", title: "人味文案优化:朋友圈", summary: "原始文案摘要", status: "optimized", customer_name: "客户A", brand_name: "品牌B", project_tags: ["朋友圈"], notes: "", publish_target: "朋友圈", source_excerpt: "原始文案摘要", result_excerpt: "优化后文案摘要", latest_result_version_id: "ver_copy_1", latest_version_number: 1, last_error_stage: null, last_error_summary: null, archived_at: null, created_at: "2026-07-08T10:00:00.000Z", updated_at: "2026-07-08T10:05:00.000Z", }, ], }), }); }); await page.route("**/api/cases/case_copy_1", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ case: { id: "case_copy_1", case_type: "human_copy", title: "人味文案优化:朋友圈", summary: "原始文案摘要", status: "optimized", customer_name: "客户A", brand_name: "品牌B", project_tags: ["朋友圈"], notes: "", publish_target: "朋友圈", source_excerpt: "原始文案摘要", result_excerpt: "优化后文案摘要", latest_result_version_id: "ver_copy_1", latest_version_number: 1, last_error_stage: null, last_error_summary: null, archived_at: null, created_at: "2026-07-08T10:00:00.000Z", updated_at: "2026-07-08T10:05:00.000Z", }, input: { case_id: "case_copy_1", case_type: "human_copy", article_job_id: null, payload: { source_text: "原始文案", goal: "自然一点", intensity: "light", user_instructions: "", publish_target: "朋友圈", }, created_at: "2026-07-08T10:00:00.000Z", updated_at: "2026-07-08T10:00:00.000Z", }, versions: [ { id: "ver_copy_1", case_id: "case_copy_1", case_type: "human_copy", version: 1, status: "optimized", article_job_id: null, article_revision: null, result_summary: "优化后文案摘要", payload: { optimized_text: "优化后文案", change_notes: [ { original: "原始文案", revised: "优化后文案", reason: "减少书面腔。", confidence: "confident", revertible: false, }, ], ai_taste_checks: [ { rule_id: "promotion_tone", status: "pass", evidence: "没有新增宣传腔。", suggestion: "", }, ], warnings: [], }, process_summary: [], llm_audit_summary: [], error_stage: null, error_summary: null, created_at: "2026-07-08T10:05:00.000Z", }, ], }), }); }); await page.goto("/cases"); await expect(page.getByRole("heading", { name: "案例库" })).toBeVisible(); await expect(page.getByText("人味文案优化:朋友圈")).toBeVisible(); await page.getByRole("link", { name: "人味文案优化:朋友圈" }).click(); await expect(page.getByText("优化后文案")).toBeVisible(); await expect(page.getByText("AI 味检查")).toBeVisible(); await expect(page.getByText("发布表现")).toBeVisible(); }); ``` - [ ] **Step 2: Run E2E test and verify it fails** Run: ```bash npx playwright test tests/e2e/cases.spec.ts ``` Expected: fail because `/cases` route does not exist. - [ ] **Step 3: Add case list route and component** `src/app/cases/page.tsx`: ```tsx import { CaseList } from "../../components/cases/case-list"; export default function CasesPage() { return ; } ``` `src/components/cases/case-list.tsx`: ```tsx "use client"; import { useEffect, useMemo, useState } from "react"; import type { OptimizationCase } from "../../lib/cases/types"; interface CaseListResponse { cases?: OptimizationCase[]; error?: string; } export function CaseList() { const [cases, setCases] = useState([]); const [caseType, setCaseType] = useState(""); const [status, setStatus] = useState(""); const [query, setQuery] = useState(""); const [message, setMessage] = useState(""); const search = useMemo(() => { const params = new URLSearchParams(); if (caseType) params.set("case_type", caseType); if (status) params.set("status", status); if (query) params.set("q", query); return params.toString(); }, [caseType, status, query]); useEffect(() => { fetch(`/api/cases${search ? `?${search}` : ""}`) .then((response) => response.json()) .then((body: CaseListResponse) => { setCases(body.cases ?? []); setMessage(body.error ?? ""); }) .catch((error) => setMessage(error instanceof Error ? error.message : "案例加载失败")); }, [search]); return (

案例库

{message ?

{message}

: null}
返回优化台
setQuery(event.target.value)} />
标题/摘要 类型 客户/品牌 项目标签 发布目标 状态 最近更新
{cases.map((item) => ( {item.title} {item.summary} {item.case_type === "article" ? "文章优化" : "人味文案"} {[item.customer_name, item.brand_name].filter(Boolean).join(" / ") || "未填写"} {item.project_tags.length > 0 ? item.project_tags.join("、") : "未标记"} {item.publish_target || "未指定"} {item.status} {new Date(item.updated_at).toLocaleString("zh-CN")} ))}
); } ``` - [ ] **Step 4: Add detail route and shared shell** `src/app/cases/[caseId]/page.tsx`: ```tsx import { CaseDetail } from "../../../components/cases/case-detail"; export default async function CaseDetailPage({ params, }: { params: Promise<{ caseId: string }>; }) { const { caseId } = await params; return ; } ``` `CaseDetail` fetches `/api/cases/${caseId}`, shows shared header fields, result-version list, and switches on `detail.case.case_type` to render article or human-copy modules. For failed cases, render failure stage and error summary before type-specific modules. - [ ] **Step 5: Add article and human-copy modules** `human-copy-case-detail.tsx` renders: - 原始文案. - 优化目标. - 修改强度. - 补充要求. - 发布目标. - 优化后文案. - 修改说明. - AI 味检查. - warnings. `article-case-detail.tsx` renders: - 原文标题和正文. - 图片输入. - 目标平台和补充要求. - 事实卡. - 当前版本优化稿. - QA 报告. - 导出链接. - [ ] **Step 6: Add publication panel** `case-publication-panel.tsx` accepts `caseId`, `versionId`, `publishTarget`, and `apiAccessKey`. It posts to: ```ts `/api/cases/${caseId}/versions/${versionId}/publications` ``` Then posts manual performance to: ```ts `/api/publications/${publicationId}/performance` ``` Render an empty state when there are no publications: ```tsx

还没有发布记录,可以先登记发布链接和手动表现。

``` - [ ] **Step 7: Add navigation and styles** In `src/app/page.tsx`, add a link in the topbar: ```tsx 案例库 ``` Add CSS for `.case-toolbar`, `.case-table`, `.case-row`, `.case-detail-grid`, `.case-meta-grid`, `.text-link`, and mobile stacking. Keep the layout dense and work-focused. - [ ] **Step 8: Run E2E and commit** Run: ```bash npx playwright test tests/e2e/cases.spec.ts tests/e2e/renwei-copy.spec.ts ``` Expected: pass. Commit: ```bash git add src/app/cases src/components/cases src/app/page.tsx src/app/globals.css tests/e2e/cases.spec.ts tests/e2e/renwei-copy.spec.ts git commit -m "新增案例库列表和详情页" ``` ## Task 8: Type-Specific Scoring And Performance Learning **Files:** - Modify: `src/lib/calibration/scoring.ts` - Modify: `src/lib/calibration/types.ts` - Modify: `src/lib/calibration/validation.ts` - Modify: `src/lib/calibration/__tests__/scoring.test.ts` - Modify: `src/app/api/jobs/[jobId]/calibration/score/route.ts` - Modify: `src/app/api/publications/[publicationId]/performance/route.ts` - Modify: `src/app/api/cases/[caseId]/rerun/route.ts` - [ ] **Step 1: Write failing human-copy scoring test** Add to `src/lib/calibration/__tests__/scoring.test.ts`: ```ts it("scores human-copy result with a separate rubric", () => { const scoringRun = scoreHumanCopyResult({ resultVersionId: "ver_1", result: { optimized_text: "我把这段文案顺了一下。", change_notes: [ { original: "我把这段文案顺顺。", revised: "我把这段文案顺了一下。", reason: "修正重复表达。", confidence: "confident", revertible: false, }, ], ai_taste_checks: [ { rule_id: "promotion_tone", status: "pass", evidence: "没有新增宣传腔。", suggestion: "", }, ], warnings: [], }, }); expect(scoringRun).toMatchObject({ result_version_id: "ver_1", case_type: "human_copy", rubric_version_id: "rubric_human_copy_v1", composite_score: expect.any(Number), }); expect(scoringRun.composite_score).toBeGreaterThan(0); }); ``` - [ ] **Step 2: Run scoring test and verify it fails** Run: ```bash npm test -- src/lib/calibration/__tests__/scoring.test.ts ``` Expected: fail because human-copy scoring does not exist. - [ ] **Step 3: Add human-copy rubric and scoring** In `src/lib/calibration/scoring.ts`, add: ```ts export const HUMAN_COPY_RUBRIC_V1: RubricVersion = { id: "rubric_human_copy_v1", version: "v1", name: "人味文案优化评分口径", dimensions: [ { id: "restraint", label: "改动克制", weight: 0.2, description: "减少机械润色,不把短文案扩写成宣传稿。", }, { id: "meaning_fidelity", label: "原意保真", weight: 0.25, description: "保留原文意图、事实和表达边界。", }, { id: "natural_tone", label: "语气自然度", weight: 0.25, description: "读起来像真人表达,少套路句和格式痕迹。", }, { id: "goal_fit", label: "目标匹配", weight: 0.15, description: "符合优化目标和发布场景。", }, { id: "ai_taste_risk", label: "AI味风险", weight: 0.15, description: "宣传腔、套话、聊天痕迹和填充词风险低。", }, ], formula: "weighted_average_0_to_10", is_active: true, created_at: "2026-07-08T00:00:00.000Z", }; ``` Add scoring: ```ts export function scoreHumanCopyResult({ resultVersionId, result, }: { resultVersionId: string; result: CopyOptimizationResult; }): ScoringRun { const warningsPenalty = Math.min(result.warnings.length, 3) * 0.5; const aiTasteWarnings = result.ai_taste_checks.filter( (check) => check.status === "warn", ).length; const scores = { restraint: result.optimized_text.length > 280 ? 3 : 5, meaning_fidelity: result.change_notes.some((note) => note.confidence === "uncertain") ? 3.5 : 5, natural_tone: Math.max(2, 5 - aiTasteWarnings * 0.75), goal_fit: 4.5, ai_taste_risk: Math.max(1, 5 - aiTasteWarnings - warningsPenalty), }; return { id: `score_${nanoid(10)}`, result_version_id: resultVersionId, case_type: "human_copy", job_id: null, revision: null, rubric_version_id: HUMAN_COPY_RUBRIC_V1.id, dimension_scores: scores, composite_score: weightedAverage0To10(HUMAN_COPY_RUBRIC_V1, scores), rationale: "基于改动克制、原意保真、自然度、目标匹配和AI味风险生成评分。", created_at: new Date().toISOString(), }; } ``` - [ ] **Step 4: Save scoring for successful human-copy versions** When human-copy route and rerun route create an optimized result version, also: ```ts await repository.saveRubricVersion(HUMAN_COPY_RUBRIC_V1); await repository.saveScoringRun( scoreHumanCopyResult({ resultVersionId: resultVersion.id, result, }), ); ``` When article routes create an optimized result version, keep `score` as an explicit action through the existing calibration route, but save `result_version_id` when scoring: ```ts const version = await repository.findResultVersionForArticleRevision( jobId, article.revision ?? 1, ); const scoringRun = scoreOptimizedArticle({ jobId, article, qaReport, resultVersionId: version?.id ?? null, }); ``` - [ ] **Step 5: Update performance learning response** For result-version publications, return the saved snapshot plus calibration when scoring exists. For human-copy publications, use the human-copy scoring run created in Step 4. - [ ] **Step 6: Run calibration tests and commit** Run: ```bash npm test -- src/lib/calibration/__tests__/scoring.test.ts src/app/api/__tests__/cases.test.ts src/app/api/__tests__/copy-renwei.test.ts ``` Expected: pass. Commit: ```bash git add src/lib/calibration src/app/api git commit -m "新增人味文案评分和效果学习" ``` ## Task 9: Final Verification And Documentation Sync **Files:** - Modify if needed: `docs/superpowers/specs/2026-07-08-long-term-optimization-case-storage-design.md` - Modify if needed: `CONTEXT.md` - [ ] **Step 1: Run focused test suites** Run: ```bash npm test -- src/lib/cases src/lib/llm src/lib/db src/app/api src/lib/calibration ``` Expected: pass. - [ ] **Step 2: Run full unit suite** Run: ```bash npm test ``` Expected: pass. - [ ] **Step 3: Run E2E coverage** Run: ```bash npx playwright test tests/e2e/renwei-copy.spec.ts tests/e2e/cases.spec.ts tests/e2e/sample-flow.spec.ts ``` Expected: pass. - [ ] **Step 4: Run lint and build** Run: ```bash npm run lint npm run build ``` Expected: both pass. - [ ] **Step 5: Run migration smoke check locally** Run: ```bash npm run d1:migrate:local ``` Expected: migration applies successfully to the local D1 database. - [ ] **Step 6: Public repository safety scan** Run: ```bash git status --short --ignored=matching rg -n "auth\\.token|secretKey|healthsource" . --glob '!node_modules/**' --glob '!.next/**' --glob '!.open-next/**' --glob '!deploy/*.toml' ``` Expected: no real credentials or generated build artifacts are staged. - [ ] **Step 7: Documentation consistency check** Search for removed or invalid terms: ```bash rg -n "仁微|敏感稿|完整 prompt|完整 response|硬删除" CONTEXT.md docs/superpowers/specs/2026-07-08-long-term-optimization-case-storage-design.md docs/adr/0001-unified-optimization-case-library.md src tests ``` Expected: - `仁微` appears only in `_Avoid_` context or old technical route/file names such as `renwei`. - `敏感稿` does not appear. - `完整 prompt` and `完整 response` appear only in design/spec language that says they are not stored by default. - `硬删除` appears only in the out-of-scope statement. - [ ] **Step 8: Commit final sync** If documentation or small cleanup changed: ```bash git add CONTEXT.md docs/superpowers/specs/2026-07-08-long-term-optimization-case-storage-design.md docs/adr/0001-unified-optimization-case-library.md src tests migrations git commit -m "完成统一案例库实施" ``` If Step 8 has no changes, do not create an empty commit. ## Execution Notes - Prefer one commit per task. Each commit message must be Chinese. - Use the current `origin` remote when pushing. Do not use the old GitHub remote. - Before Cloudflare staging deployment, run `npm run lint`, `npm test`, and `npm run build`. - Apply D1 migration before deploying an environment that writes cases. - The old job-based publication APIs must keep working until the case detail page fully replaces the old calibration panel. ## Self-Review - Spec coverage: this plan covers unified case storage, auto-save, failed cases, result versions, human-copy storage, case list/detail, archive/restore, re-run, publication records, manual performance snapshots, LLM audit summaries, and type-specific scoring. - Prompt/response boundary: only hashes and metadata are persisted in `llm_audit_summary`; no route stores raw prompt/response as long-term data. - Sensitive-draft design: no task introduces sensitive draft handling. - Historical migration: new writes are covered first; historical article jobs remain compatible and can be backfilled when opened or re-run.