From 8b10969c49bb20ffc21da101fc3d9dc36a41dce5 Mon Sep 17 00:00:00 2001 From: czj <13261895355@163.com> Date: Wed, 8 Jul 2026 13:06:15 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=BB=9F=E4=B8=80=E6=A1=88?= =?UTF-8?q?=E4=BE=8B=E5=BA=93=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...07-08-unified-optimization-case-library.md | 3244 +++++++++++++++++ 1 file changed, 3244 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-08-unified-optimization-case-library.md diff --git a/docs/superpowers/plans/2026-07-08-unified-optimization-case-library.md b/docs/superpowers/plans/2026-07-08-unified-optimization-case-library.md new file mode 100644 index 0000000..c2f5204 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-unified-optimization-case-library.md @@ -0,0 +1,3244 @@ +# 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.