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.mddocs/adr/0001-unified-optimization-case-library.mddocs/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: addpublish_targettoCopyOptimizationRequest; keep existing article types stable.src/lib/domain/validation.ts: validatepublish_targetfor human-copy requests.src/lib/calibration/types.ts: allow publication/scoring records to bind toresult_version_idwhile keeping articlejob_id/revisioncompatibility.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: extendAppRepositorywith 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 thejob_createdandfinal_readyevents.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_targetto 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 status201.
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 asrenwei. -
敏感稿does not appear. -
完整 promptand完整 responseappear 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
originremote when pushing. Do not use the old GitHub remote. - Before Cloudflare staging deployment, run
npm run lint,npm test, andnpm 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.