Files
GEOAgentArticleOptimizer/docs/superpowers/plans/2026-07-08-unified-optimization-case-library.md
2026-07-08 13:06:15 +08:00

94 KiB

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.

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.

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.

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:

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:

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:

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:

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<string, string>;
}

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:

import { z } from "zod";

import type {
  CaseListFilters,
  CaseMetadataPatch,
  OptimizationCaseStatus,
  OptimizationCaseType,
  ResultVersionStatus,
} from "./types";

export const caseTypeSchema = z.enum([
  "article",
  "human_copy",
]) satisfies z.ZodType<OptimizationCaseType>;

export const caseStatusSchema = z.enum([
  "running",
  "optimized",
  "failed",
  "archived",
]) satisfies z.ZodType<OptimizationCaseStatus>;

export const resultVersionStatusSchema = z.enum([
  "optimized",
  "failed",
]) satisfies z.ZodType<ResultVersionStatus>;

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<CaseMetadataPatch>;

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<CaseListFilters>;

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:

export interface CopyOptimizationRequest {
  source_text: string;
  goal: string;
  intensity: CopyOptimizationIntensity;
  user_instructions: string;
  publish_target: string;
}

Modify copyOptimizationRequestSchema in src/lib/domain/validation.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<CopyOptimizationRequest>;
  • Step 7: Run tests and commit

Run:

npm test -- src/lib/cases/__tests__/validation.test.ts src/lib/domain/__tests__/validation.test.ts

Expected: pass.

Commit:

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:

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:

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:

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:

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:

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<LlmAuditSummary> {
  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:

import { createLlmAuditSummary, type LlmAuditSummary } from "./audit";

export interface GenerateInput {
  system?: string;
  prompt: string;
  model?: string;
  temperature?: number;
  task?: LlmTaskName;
  onAuditSummary?: (summary: LlmAuditSummary) => void | Promise<void>;
}

In generateValidatedJson, after const startedAt = Date.now();, add:

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:

await emitAudit({
  schemaValid: true,
  output: parsed.data,
  errorSummary: null,
});

Call it before throwing validation errors:

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:

await emitAudit({
  schemaValid: false,
  output: null,
  errorSummary: message,
});
  • Step 7: Update LLM client tests

Add to src/lib/llm/__tests__/client.test.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:

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:

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:

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:

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:

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:

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:

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:

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:

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<string, number>;
  composite_score: number;
  rationale: string;
  created_at: string;
}

Update publicationInputSchema in src/lib/calibration/validation.ts to transform old platform into publish_target:

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:

createOptimizationCase(input: {
  case_type: OptimizationCaseType;
  title: string;
  summary: string;
  publish_target: string;
  source_excerpt: string;
}): Promise<OptimizationCase>;
saveCaseInput(input: Omit<CaseInput, "created_at" | "updated_at">): Promise<CaseInput>;
listOptimizationCases(filters: CaseListFilters): Promise<OptimizationCase[]>;
getOptimizationCaseDetail(caseId: string): Promise<OptimizationCaseDetail | null>;
updateOptimizationCaseMetadata(
  caseId: string,
  changes: CaseMetadataPatch,
): Promise<OptimizationCase | null>;
archiveOptimizationCase(caseId: string): Promise<OptimizationCase | null>;
restoreOptimizationCase(caseId: string): Promise<OptimizationCase | null>;
markOptimizationCaseFailed(
  caseId: string,
  input: { error_stage: string; error_summary: string },
): Promise<OptimizationCase | null>;
createOptimizationResultVersion(input: Omit<
  OptimizationResultVersion,
  "id" | "version" | "created_at"
>): Promise<OptimizationResultVersion>;
getOptimizationResultVersion(versionId: string): Promise<OptimizationResultVersion | null>;
findResultVersionForArticleRevision(
  jobId: string,
  revision: number,
): Promise<OptimizationResultVersion | null>;
listPublicationRecordsForResultVersion(
  resultVersionId: string,
): Promise<PublicationRecord[]>;

Update NewArticleJob in src/lib/db/repositories.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:

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:

function toOptimizationCase(row: OptimizationCaseRow): OptimizationCase {
  return {
    ...row,
    project_tags: parseJson<string[]>(row.project_tags),
  };
}

function toCaseInput(row: CaseInputRow): CaseInput {
  return {
    ...row,
    payload: parseJson<CaseInput["payload"]>(row.payload),
  };
}

function toOptimizationResultVersion(
  row: OptimizationResultVersionRow,
): OptimizationResultVersion {
  return {
    ...row,
    payload: row.payload ? parseJson<OptimizationResultVersion["payload"]>(row.payload) : null,
    process_summary: parseJson<OptimizationResultVersion["process_summary"]>(
      row.process_summary,
    ),
    llm_audit_summary: parseJson<OptimizationResultVersion["llm_audit_summary"]>(
      row.llm_audit_summary,
    ),
  };
}

Implement result-version numbering with a max query:

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:

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:

await db.prepare(sql).bind(valueA, valueB, valueC).run();
const result = await db.prepare(sql).bind(valueA).all<RowType>();

Keep converter output identical to SQLite output.

  • Step 9: Update publication repository functions

Change createPublicationRecord inserts to use:

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:

select * from publication_records
where job_id = ?
order by published_at desc

Add listPublicationRecordsForResultVersion(resultVersionId):

select * from publication_records
where result_version_id = ?
order by published_at desc
  • Step 10: Run repository tests and commit

Run:

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:

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:

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:

import { GET as listCases } from "../cases/route";
import { GET as getCase } from "../cases/[caseId]/route";
  • Step 2: Run tests and verify they fail

Run:

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:

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<OptimizedArticle> {
  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:

return {
  article,
  qaReport,
  rewriteRounds,
  stoppedAfterMaxRewrites:
    qaReport.overall_status === "fail" && rewriteRounds >= 2,
  processSummary,
  llmAuditSummary,
};

Use a local audit array:

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:

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<string, string>;
    }
  | {
      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:

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:

{
  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:

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:

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:

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:

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:

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:

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:

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:

import type { GenerateInput } from "../llm/client";

export async function optimizeRenweiCopy(
  input: CopyOptimizationRequest,
  options: { onAuditSummary?: GenerateInput["onAuditSummary"] } = {},
): Promise<CopyOptimizationResult> {
  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:

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:

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:

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:

const [publishTarget, setPublishTarget] = useState("朋友圈");
const [caseId, setCaseId] = useState<string | null>(null);

Add a label to the form:

<label>
  <span>发布目标</span>
  <input
    value={publishTarget}
    onChange={(event) => setPublishTarget(event.target.value)}
  />
</label>

Send it in the request body:

publish_target: publishTarget,

Update response type:

interface CopyOptimizeResponse {
  case?: { id: string; case_type: "human_copy" };
  result_version?: { id: string; version: number };
  result?: CopyOptimizationResult;
  error?: string;
}

After success:

setCaseId(body.case?.id ?? null);
setMessage("文案优化完成,已保存到案例库。");

Render a case link near the result:

{caseId ? (
  <a className="text-link" href={`/cases/${caseId}`}>
    查看案例详情
  </a>
) : null}
  • Step 6: Update E2E route mock

In tests/e2e/renwei-copy.spec.ts, assert request payload:

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:

await page.getByLabel("发布目标").fill("朋友圈");
await expect(page.getByRole("link", { name: "查看案例详情" })).toHaveAttribute(
  "href",
  "/cases/case_copy_1",
);
  • Step 7: Run tests and commit

Run:

npm test -- src/app/api/__tests__/copy-renwei.test.ts
npx playwright test tests/e2e/renwei-copy.spec.ts

Expected: pass.

Commit:

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:

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<typeof import("../../../lib/llm/client")>(
    "../../../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<string, string> = { "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<T extends Record<string, string>>(values: T) {
  return { params: Promise.resolve(values) };
}
  • Step 2: Run tests and verify they fail

Run:

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:

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:

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:

const optimizationCase = await repository.archiveOptimizationCase(caseId);

Restore route body:

const optimizationCase = await repository.restoreOptimizationCase(caseId);
  • Step 5: Implement publication routes

POST /api/cases/:caseId/versions/:versionId/publications:

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:

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:

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:

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:

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:

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:

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:

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:

import { CaseList } from "../../components/cases/case-list";

export default function CasesPage() {
  return <CaseList />;
}

src/components/cases/case-list.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<OptimizationCase[]>([]);
  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 (
    <main className="app-shell">
      <header className="topbar">
        <div>
          <h1>案例库</h1>
          {message ? <p>{message}</p> : null}
        </div>
        <a className="text-link" href="/">
          返回优化台
        </a>
      </header>
      <section className="case-toolbar" aria-label="案例筛选">
        <input
          aria-label="关键词"
          placeholder="搜索标题、客户、品牌、备注"
          value={query}
          onChange={(event) => setQuery(event.target.value)}
        />
        <select
          aria-label="案例类型"
          value={caseType}
          onChange={(event) => setCaseType(event.target.value)}
        >
          <option value="">全部类型</option>
          <option value="article">文章优化</option>
          <option value="human_copy">人味文案</option>
        </select>
        <select
          aria-label="状态"
          value={status}
          onChange={(event) => setStatus(event.target.value)}
        >
          <option value="">全部状态</option>
          <option value="running">运行中</option>
          <option value="optimized">已优化</option>
          <option value="failed">失败</option>
        </select>
      </section>
      <section className="case-table" aria-label="案例列表">
        <div className="case-row case-row-heading">
          <span>标题/摘要</span>
          <span>类型</span>
          <span>客户/品牌</span>
          <span>项目标签</span>
          <span>发布目标</span>
          <span>状态</span>
          <span>最近更新</span>
        </div>
        {cases.map((item) => (
          <a className="case-row" href={`/cases/${item.id}`} key={item.id}>
            <span>
              <strong>{item.title}</strong>
              <small>{item.summary}</small>
            </span>
            <span>{item.case_type === "article" ? "文章优化" : "人味文案"}</span>
            <span>{[item.customer_name, item.brand_name].filter(Boolean).join(" / ") || "未填写"}</span>
            <span>{item.project_tags.length > 0 ? item.project_tags.join("、") : "未标记"}</span>
            <span>{item.publish_target || "未指定"}</span>
            <span>{item.status}</span>
            <span>{new Date(item.updated_at).toLocaleString("zh-CN")}</span>
          </a>
        ))}
      </section>
    </main>
  );
}
  • Step 4: Add detail route and shared shell

src/app/cases/[caseId]/page.tsx:

import { CaseDetail } from "../../../components/cases/case-detail";

export default async function CaseDetailPage({
  params,
}: {
  params: Promise<{ caseId: string }>;
}) {
  const { caseId } = await params;
  return <CaseDetail caseId={caseId} />;
}

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:

`/api/cases/${caseId}/versions/${versionId}/publications`

Then posts manual performance to:

`/api/publications/${publicationId}/performance`

Render an empty state when there are no publications:

<p className="empty-panel">还没有发布记录,可以先登记发布链接和手动表现。</p>
  • Step 7: Add navigation and styles

In src/app/page.tsx, add a link in the topbar:

<a className="text-link" href="/cases">
  案例库
</a>

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:

npx playwright test tests/e2e/cases.spec.ts tests/e2e/renwei-copy.spec.ts

Expected: pass.

Commit:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

npm test

Expected: pass.

  • Step 3: Run E2E coverage

Run:

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:

npm run lint
npm run build

Expected: both pass.

  • Step 5: Run migration smoke check locally

Run:

npm run d1:migrate:local

Expected: migration applies successfully to the local D1 database.

  • Step 6: Public repository safety scan

Run:

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:

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:

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.