3245 lines
94 KiB
Markdown
3245 lines
94 KiB
Markdown
# Unified Optimization Case Library Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Build the unified long-term optimization case library for article optimization and human-tone copy optimization, including auto-save, result versions, failed cases, case list/detail pages, and publication performance learning records.
|
|
|
|
**Architecture:** Add a unified case layer above the current article job tables, then connect both article and human-copy flows to that layer. Keep existing article job/export behavior working, but make every new optimization produce a case, input record, result version, and LLM audit summary. Use result versions as the long-term anchor for publication records, performance snapshots, and re-runs.
|
|
|
|
**Tech Stack:** Next.js App Router, TypeScript, Zod, Vitest, Playwright, SQLite via better-sqlite3, Cloudflare D1, existing R2/local export store.
|
|
|
|
---
|
|
|
|
## Source Documents
|
|
|
|
- `CONTEXT.md`
|
|
- `docs/adr/0001-unified-optimization-case-library.md`
|
|
- `docs/superpowers/specs/2026-07-08-long-term-optimization-case-storage-design.md`
|
|
|
|
## Implementation Principles
|
|
|
|
- New business language uses `优化案例`, `文章优化案例`, `人味文案优化案例`, `结果版本`, `LLM 审计摘要`, and `效果学习库`.
|
|
- Preserve the technical route `/api/copy/renwei-optimize`, but all user-facing copy says `人味文案优化`.
|
|
- Do not store full prompt/response in long-term tables.
|
|
- Auto-save begins only after request validation succeeds. Empty or malformed input returns a validation error without creating a case.
|
|
- Failed LLM runs after validation create a failed case and a failed result version.
|
|
- Re-running from a case creates a new result version and never overwrites an old version.
|
|
- Archive hides cases from the default list. It does not hard-delete records.
|
|
- New migration serves new records first. Historical article jobs can remain job-only until a user opens or re-runs them.
|
|
|
|
## File Structure
|
|
|
|
Create:
|
|
|
|
- `src/lib/cases/types.ts`: case, case input, result version, list filters, and publication result-version types.
|
|
- `src/lib/cases/validation.ts`: Zod schemas for case filters, metadata patching, result-version publication input, and human-copy publish target.
|
|
- `src/lib/cases/summaries.ts`: pure helpers for source excerpts, default titles, result summaries, and process summaries.
|
|
- `src/lib/llm/audit.ts`: LLM audit summary type and hash helper.
|
|
- `src/lib/llm/__tests__/audit.test.ts`: verifies audit hashes and prompt/response exclusion.
|
|
- `src/lib/cases/__tests__/validation.test.ts`: domain validation tests for cases.
|
|
- `src/lib/cases/__tests__/summaries.test.ts`: pure summary helper tests.
|
|
- `migrations/0003_unified_optimization_cases.sql`: D1 migration for case tables and result-version references.
|
|
- `src/app/api/cases/route.ts`: list cases.
|
|
- `src/app/api/cases/[caseId]/route.ts`: read and patch a case.
|
|
- `src/app/api/cases/[caseId]/archive/route.ts`: archive a case.
|
|
- `src/app/api/cases/[caseId]/restore/route.ts`: restore a case.
|
|
- `src/app/api/cases/[caseId]/rerun/route.ts`: create a new result version from stored input.
|
|
- `src/app/api/cases/[caseId]/versions/[versionId]/publications/route.ts`: create and list result-version publications.
|
|
- `src/app/cases/page.tsx`: case list route.
|
|
- `src/app/cases/[caseId]/page.tsx`: case detail route.
|
|
- `src/components/cases/case-list.tsx`: list table and filters.
|
|
- `src/components/cases/case-detail.tsx`: shared detail shell.
|
|
- `src/components/cases/article-case-detail.tsx`: article-only detail modules.
|
|
- `src/components/cases/human-copy-case-detail.tsx`: human-copy-only detail modules.
|
|
- `src/components/cases/case-publication-panel.tsx`: publication and manual performance entry for result versions.
|
|
- `src/components/cases/case-result-version-list.tsx`: result-version selector/list.
|
|
- `src/app/api/__tests__/cases.test.ts`: case API tests.
|
|
- `tests/e2e/cases.spec.ts`: browser coverage for list/detail and human-copy storage.
|
|
|
|
Modify:
|
|
|
|
- `src/lib/domain/types.ts`: add `publish_target` to `CopyOptimizationRequest`; keep existing article types stable.
|
|
- `src/lib/domain/validation.ts`: validate `publish_target` for human-copy requests.
|
|
- `src/lib/calibration/types.ts`: allow publication/scoring records to bind to `result_version_id` while keeping article `job_id`/`revision` compatibility.
|
|
- `src/lib/calibration/validation.ts`: validate result-version publication input.
|
|
- `src/lib/calibration/scoring.ts`: add a human-copy rubric and scoring function.
|
|
- `src/lib/db/schema.ts`: initialize new case tables for local SQLite.
|
|
- `src/lib/db/repository.ts`: extend `AppRepository` with case, version, publication, and result-version scoring methods.
|
|
- `src/lib/db/repositories.ts`: implement new SQLite repository functions.
|
|
- `src/lib/db/sqlite-repository.ts`: expose new SQLite repository functions.
|
|
- `src/lib/db/d1-repository.ts`: implement matching D1 repository functions.
|
|
- `src/lib/db/__tests__/repositories.test.ts`: assert local schema and low-level SQLite persistence.
|
|
- `src/lib/db/__tests__/repository.test.ts`: assert async repository behavior.
|
|
- `src/app/api/jobs/route.ts`: create article case during job creation.
|
|
- `src/app/api/jobs/optimize-stream/route.ts`: auto-save streaming article cases, success versions, and failed versions.
|
|
- `src/app/api/jobs/[jobId]/optimize/route.ts`: auto-save non-streaming article result versions.
|
|
- `src/app/api/jobs/[jobId]/publications/route.ts`: keep job API working and backfill result-version references when possible.
|
|
- `src/app/api/jobs/[jobId]/calibration/score/route.ts`: save article scoring with result-version reference.
|
|
- `src/app/api/publications/[publicationId]/performance/route.ts`: support result-version publications and keep article fallback.
|
|
- `src/app/api/copy/renwei-optimize/route.ts`: auto-save human-copy cases and return case/version metadata.
|
|
- `src/lib/workflow/fact-extractor.ts`: pass LLM audit callbacks through.
|
|
- `src/lib/workflow/article-optimizer.ts`: pass LLM audit callbacks through.
|
|
- `src/lib/workflow/quality-inspector.ts`: pass LLM audit callbacks through.
|
|
- `src/lib/workflow/targeted-rewriter.ts`: pass LLM audit callbacks through.
|
|
- `src/lib/workflow/streaming-optimizer.ts`: collect process summary and audit summaries.
|
|
- `src/lib/workflow/orchestrator.ts`: collect process summary and audit summaries.
|
|
- `src/lib/workflow/renwei-copy-optimizer.ts`: collect audit summaries.
|
|
- `src/lib/workflow/stream-events.ts`: include optional case metadata in the `job_created` and `final_ready` events.
|
|
- `src/components/renwei-copy-optimizer-panel.tsx`: add publish target input and show saved case link.
|
|
- `src/components/performance-calibration-panel.tsx`: keep old job page usable while case detail becomes the primary long-term entry.
|
|
- `src/app/page.tsx`: add top-level navigation to `案例库`.
|
|
- `src/app/globals.css`: style case list/detail layouts.
|
|
- `src/app/api/__tests__/copy-renwei.test.ts`: update human-copy API tests.
|
|
- `src/app/api/__tests__/jobs.test.ts`: update article API tests.
|
|
- `tests/e2e/renwei-copy.spec.ts`: assert publish target is sent and case link appears.
|
|
|
|
## Database Model
|
|
|
|
Use these table shapes in `migrations/0003_unified_optimization_cases.sql` and mirror them in `src/lib/db/schema.ts`.
|
|
|
|
```sql
|
|
CREATE TABLE IF NOT EXISTS optimization_cases (
|
|
id TEXT PRIMARY KEY,
|
|
case_type TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
summary TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
customer_name TEXT NOT NULL DEFAULT '',
|
|
brand_name TEXT NOT NULL DEFAULT '',
|
|
project_tags TEXT NOT NULL DEFAULT '[]',
|
|
notes TEXT NOT NULL DEFAULT '',
|
|
publish_target TEXT NOT NULL DEFAULT '',
|
|
source_excerpt TEXT NOT NULL DEFAULT '',
|
|
result_excerpt TEXT NOT NULL DEFAULT '',
|
|
latest_result_version_id TEXT,
|
|
latest_version_number INTEGER,
|
|
last_error_stage TEXT,
|
|
last_error_summary TEXT,
|
|
archived_at TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS case_inputs (
|
|
case_id TEXT PRIMARY KEY,
|
|
case_type TEXT NOT NULL,
|
|
article_job_id TEXT,
|
|
payload TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
FOREIGN KEY (case_id) REFERENCES optimization_cases(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (article_job_id) REFERENCES article_jobs(id) ON DELETE SET NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS optimization_result_versions (
|
|
id TEXT PRIMARY KEY,
|
|
case_id TEXT NOT NULL,
|
|
case_type TEXT NOT NULL,
|
|
version INTEGER NOT NULL,
|
|
status TEXT NOT NULL,
|
|
article_job_id TEXT,
|
|
article_revision INTEGER,
|
|
result_summary TEXT NOT NULL,
|
|
payload TEXT NOT NULL,
|
|
process_summary TEXT NOT NULL,
|
|
llm_audit_summary TEXT NOT NULL,
|
|
error_stage TEXT,
|
|
error_summary TEXT,
|
|
created_at TEXT NOT NULL,
|
|
UNIQUE (case_id, version),
|
|
FOREIGN KEY (case_id) REFERENCES optimization_cases(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (article_job_id) REFERENCES article_jobs(id) ON DELETE SET NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_optimization_cases_updated_at
|
|
ON optimization_cases(updated_at);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_optimization_cases_case_type
|
|
ON optimization_cases(case_type);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_optimization_cases_status
|
|
ON optimization_cases(status);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_optimization_result_versions_case
|
|
ON optimization_result_versions(case_id, version DESC);
|
|
|
|
ALTER TABLE article_jobs ADD COLUMN case_id TEXT;
|
|
```
|
|
|
|
For publication and scoring compatibility, use a table rebuild in the migration because `publication_records.job_id` and `revision` are currently required. Preserve existing rows.
|
|
|
|
```sql
|
|
CREATE TABLE IF NOT EXISTS publication_records_next (
|
|
id TEXT PRIMARY KEY,
|
|
result_version_id TEXT,
|
|
job_id TEXT,
|
|
revision INTEGER,
|
|
publish_target TEXT NOT NULL,
|
|
url TEXT NOT NULL,
|
|
published_at TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
notes TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
FOREIGN KEY (result_version_id)
|
|
REFERENCES optimization_result_versions(id) ON DELETE CASCADE
|
|
);
|
|
|
|
INSERT INTO publication_records_next (
|
|
id, result_version_id, job_id, revision, publish_target, url,
|
|
published_at, status, notes, created_at, updated_at
|
|
)
|
|
SELECT
|
|
id, NULL, job_id, revision, platform, url,
|
|
published_at, status, notes, created_at, updated_at
|
|
FROM publication_records;
|
|
|
|
DROP TABLE publication_records;
|
|
ALTER TABLE publication_records_next RENAME TO publication_records;
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_publication_records_result_version
|
|
ON publication_records(result_version_id);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_publication_records_job_revision
|
|
ON publication_records(job_id, revision);
|
|
```
|
|
|
|
For scoring compatibility, keep article scoring by `job_id`/`revision` and add result-version fields.
|
|
|
|
```sql
|
|
ALTER TABLE scoring_runs ADD COLUMN result_version_id TEXT;
|
|
ALTER TABLE scoring_runs ADD COLUMN case_type TEXT NOT NULL DEFAULT 'article';
|
|
```
|
|
|
|
## Task 1: Case Domain Types And Validation
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/lib/cases/types.ts`
|
|
- Create: `src/lib/cases/validation.ts`
|
|
- Create: `src/lib/cases/__tests__/validation.test.ts`
|
|
- Modify: `src/lib/domain/types.ts`
|
|
- Modify: `src/lib/domain/validation.ts`
|
|
- Modify: `src/lib/domain/__tests__/validation.test.ts`
|
|
|
|
- [ ] **Step 1: Write failing case validation tests**
|
|
|
|
Add this test file:
|
|
|
|
```ts
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
caseListFiltersSchema,
|
|
caseMetadataPatchSchema,
|
|
caseTypeSchema,
|
|
resultVersionStatusSchema,
|
|
} from "../validation";
|
|
|
|
describe("case validation", () => {
|
|
it("accepts the supported case types and result-version statuses", () => {
|
|
expect(caseTypeSchema.parse("article")).toBe("article");
|
|
expect(caseTypeSchema.parse("human_copy")).toBe("human_copy");
|
|
expect(resultVersionStatusSchema.parse("optimized")).toBe("optimized");
|
|
expect(resultVersionStatusSchema.parse("failed")).toBe("failed");
|
|
});
|
|
|
|
it("keeps optional ownership metadata empty and editable", () => {
|
|
expect(caseMetadataPatchSchema.parse({})).toEqual({});
|
|
expect(
|
|
caseMetadataPatchSchema.parse({
|
|
customer_name: "伟思德鲁",
|
|
brand_name: "IPMS",
|
|
project_tags: ["推荐榜单", "2026"],
|
|
notes: "客户偏好保留专业语气。",
|
|
}),
|
|
).toEqual({
|
|
customer_name: "伟思德鲁",
|
|
brand_name: "IPMS",
|
|
project_tags: ["推荐榜单", "2026"],
|
|
notes: "客户偏好保留专业语气。",
|
|
});
|
|
});
|
|
|
|
it("parses default list filters without archived cases", () => {
|
|
expect(caseListFiltersSchema.parse({})).toEqual({
|
|
include_archived: false,
|
|
});
|
|
expect(
|
|
caseListFiltersSchema.parse({
|
|
case_type: "human_copy",
|
|
status: "optimized",
|
|
project_tag: "朋友圈",
|
|
q: "自然表达",
|
|
include_archived: "true",
|
|
}),
|
|
).toMatchObject({
|
|
case_type: "human_copy",
|
|
status: "optimized",
|
|
project_tag: "朋友圈",
|
|
q: "自然表达",
|
|
include_archived: true,
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Write failing human-copy publish-target validation test**
|
|
|
|
Add this assertion to `src/lib/domain/__tests__/validation.test.ts`:
|
|
|
|
```ts
|
|
it("validates human-copy publish target", () => {
|
|
expect(
|
|
copyOptimizationRequestSchema.parse({
|
|
source_text: "这是一段普通文案。",
|
|
goal: "",
|
|
intensity: "light",
|
|
user_instructions: "",
|
|
publish_target: "朋友圈",
|
|
}),
|
|
).toMatchObject({
|
|
goal: "保留原意,减少 AI 味",
|
|
publish_target: "朋友圈",
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: Run tests and verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/cases/__tests__/validation.test.ts src/lib/domain/__tests__/validation.test.ts
|
|
```
|
|
|
|
Expected: fail because `src/lib/cases/validation.ts` does not exist and `publish_target` is not in `CopyOptimizationRequest`.
|
|
|
|
- [ ] **Step 4: Add case types**
|
|
|
|
Create `src/lib/cases/types.ts`:
|
|
|
|
```ts
|
|
import type {
|
|
CopyOptimizationRequest,
|
|
CopyOptimizationResult,
|
|
OptimizedArticle,
|
|
QaReport,
|
|
} from "../domain/types";
|
|
import type { LlmAuditSummary } from "../llm/audit";
|
|
|
|
export type OptimizationCaseType = "article" | "human_copy";
|
|
export type OptimizationCaseStatus =
|
|
| "running"
|
|
| "optimized"
|
|
| "failed"
|
|
| "archived";
|
|
export type ResultVersionStatus = "optimized" | "failed";
|
|
|
|
export interface ProcessSummaryStep {
|
|
stage: string;
|
|
started_at: string;
|
|
ended_at: string;
|
|
duration_ms: number;
|
|
status: "success" | "failed";
|
|
error_summary?: string;
|
|
rewrite_round?: number;
|
|
produced_result_version: boolean;
|
|
}
|
|
|
|
export interface OptimizationCase {
|
|
id: string;
|
|
case_type: OptimizationCaseType;
|
|
title: string;
|
|
summary: string;
|
|
status: OptimizationCaseStatus;
|
|
customer_name: string;
|
|
brand_name: string;
|
|
project_tags: string[];
|
|
notes: string;
|
|
publish_target: string;
|
|
source_excerpt: string;
|
|
result_excerpt: string;
|
|
latest_result_version_id: string | null;
|
|
latest_version_number: number | null;
|
|
last_error_stage: string | null;
|
|
last_error_summary: string | null;
|
|
archived_at: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface CaseInput {
|
|
case_id: string;
|
|
case_type: OptimizationCaseType;
|
|
article_job_id: string | null;
|
|
payload: ArticleCaseInputPayload | HumanCopyCaseInputPayload;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface ArticleCaseInputPayload {
|
|
source_title: string;
|
|
source_body: string;
|
|
image_inputs: Array<{ type: "description" | "link"; content: string }>;
|
|
publish_platform: string;
|
|
user_instructions: string;
|
|
fact_card?: unknown;
|
|
}
|
|
|
|
export interface HumanCopyCaseInputPayload extends CopyOptimizationRequest {}
|
|
|
|
export interface ArticleResultVersionPayload {
|
|
article: OptimizedArticle;
|
|
qa_report: QaReport;
|
|
export_paths: Record<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`:
|
|
|
|
```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`:
|
|
|
|
```ts
|
|
export interface CopyOptimizationRequest {
|
|
source_text: string;
|
|
goal: string;
|
|
intensity: CopyOptimizationIntensity;
|
|
user_instructions: string;
|
|
publish_target: string;
|
|
}
|
|
```
|
|
|
|
Modify `copyOptimizationRequestSchema` in `src/lib/domain/validation.ts`:
|
|
|
|
```ts
|
|
export const copyOptimizationRequestSchema = z.object({
|
|
source_text: z.string().trim().min(1),
|
|
goal: z
|
|
.preprocess((value) => {
|
|
if (typeof value !== "string") return value;
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : "保留原意,减少 AI 味";
|
|
}, z.string().trim().min(1))
|
|
.default("保留原意,减少 AI 味"),
|
|
intensity: copyOptimizationIntensitySchema.default("light"),
|
|
user_instructions: z.string().trim().default(""),
|
|
publish_target: z
|
|
.preprocess((value) => {
|
|
if (typeof value !== "string") return value;
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : "未指定";
|
|
}, z.string().trim().min(1))
|
|
.default("未指定"),
|
|
}) satisfies z.ZodType<CopyOptimizationRequest>;
|
|
```
|
|
|
|
- [ ] **Step 7: Run tests and commit**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/cases/__tests__/validation.test.ts src/lib/domain/__tests__/validation.test.ts
|
|
```
|
|
|
|
Expected: pass.
|
|
|
|
Commit:
|
|
|
|
```bash
|
|
git add src/lib/cases src/lib/domain/types.ts src/lib/domain/validation.ts src/lib/domain/__tests__/validation.test.ts
|
|
git commit -m "新增优化案例领域模型"
|
|
```
|
|
|
|
## Task 2: Summary Helpers And LLM Audit Boundary
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/lib/cases/summaries.ts`
|
|
- Create: `src/lib/cases/__tests__/summaries.test.ts`
|
|
- Create: `src/lib/llm/audit.ts`
|
|
- Create: `src/lib/llm/__tests__/audit.test.ts`
|
|
- Modify: `src/lib/llm/client.ts`
|
|
- Modify: `src/lib/llm/__tests__/client.test.ts`
|
|
|
|
- [ ] **Step 1: Write failing summary helper tests**
|
|
|
|
Create `src/lib/cases/__tests__/summaries.test.ts`:
|
|
|
|
```ts
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
buildArticleCaseSummary,
|
|
buildHumanCopyCaseSummary,
|
|
createProcessStep,
|
|
excerpt,
|
|
} from "../summaries";
|
|
|
|
describe("case summaries", () => {
|
|
it("creates compact source excerpts", () => {
|
|
expect(excerpt("第一段。\n\n第二段内容很长".repeat(20), 20)).toHaveLength(21);
|
|
expect(excerpt(" 一段文案 ", 20)).toBe("一段文案");
|
|
});
|
|
|
|
it("builds article case title, summary, and publish target", () => {
|
|
expect(
|
|
buildArticleCaseSummary({
|
|
source_title: "IPMS 推荐机构文章",
|
|
source_body: "正文内容",
|
|
publish_platform: "media_article",
|
|
}),
|
|
).toEqual({
|
|
title: "IPMS 推荐机构文章",
|
|
summary: "正文内容",
|
|
publish_target: "media_article",
|
|
source_excerpt: "正文内容",
|
|
});
|
|
});
|
|
|
|
it("builds human-copy case summary from source and publish target", () => {
|
|
expect(
|
|
buildHumanCopyCaseSummary({
|
|
source_text: "帮客户解释智能体授课的价值。",
|
|
goal: "自然一点",
|
|
intensity: "light",
|
|
user_instructions: "",
|
|
publish_target: "朋友圈",
|
|
}),
|
|
).toMatchObject({
|
|
title: "人味文案优化:朋友圈",
|
|
publish_target: "朋友圈",
|
|
source_excerpt: "帮客户解释智能体授课的价值。",
|
|
});
|
|
});
|
|
|
|
it("creates a process summary step without draft text", () => {
|
|
expect(
|
|
createProcessStep({
|
|
stage: "draft",
|
|
startedAt: 100,
|
|
endedAt: 250,
|
|
status: "success",
|
|
producedResultVersion: false,
|
|
}),
|
|
).toEqual({
|
|
stage: "draft",
|
|
started_at: expect.any(String),
|
|
ended_at: expect.any(String),
|
|
duration_ms: 150,
|
|
status: "success",
|
|
produced_result_version: false,
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Write failing LLM audit tests**
|
|
|
|
Create `src/lib/llm/__tests__/audit.test.ts`:
|
|
|
|
```ts
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { createLlmAuditSummary, hashContent } from "../audit";
|
|
|
|
describe("LLM audit summary", () => {
|
|
it("hashes content deterministically", async () => {
|
|
await expect(hashContent("abc")).resolves.toBe(
|
|
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
|
);
|
|
});
|
|
|
|
it("does not retain raw prompt or response", async () => {
|
|
const summary = await createLlmAuditSummary({
|
|
provider: "deepseek",
|
|
model: "deepseek-v4-pro",
|
|
task: "renwei_copy_optimizer",
|
|
duration_ms: 12,
|
|
schema_valid: true,
|
|
prompt: "完整 prompt 不应长期保存",
|
|
output: "完整 response 不应长期保存",
|
|
error_summary: null,
|
|
});
|
|
|
|
expect(summary).toMatchObject({
|
|
provider: "deepseek",
|
|
model: "deepseek-v4-pro",
|
|
task: "renwei_copy_optimizer",
|
|
duration_ms: 12,
|
|
schema_valid: true,
|
|
error_summary: null,
|
|
});
|
|
expect(JSON.stringify(summary)).not.toContain("完整 prompt");
|
|
expect(JSON.stringify(summary)).not.toContain("完整 response");
|
|
expect(summary.input_hash).toHaveLength(64);
|
|
expect(summary.output_hash).toHaveLength(64);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: Run tests and verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/cases/__tests__/summaries.test.ts src/lib/llm/__tests__/audit.test.ts
|
|
```
|
|
|
|
Expected: fail because helper modules do not exist.
|
|
|
|
- [ ] **Step 4: Implement summary helpers**
|
|
|
|
Create `src/lib/cases/summaries.ts`:
|
|
|
|
```ts
|
|
import type {
|
|
ArticleCaseInputPayload,
|
|
HumanCopyCaseInputPayload,
|
|
ProcessSummaryStep,
|
|
} from "./types";
|
|
|
|
export function excerpt(value: string, maxLength = 120) {
|
|
const compact = value.replace(/\s+/g, " ").trim();
|
|
return compact.length > maxLength
|
|
? `${compact.slice(0, maxLength)}…`
|
|
: compact;
|
|
}
|
|
|
|
export function buildArticleCaseSummary(input: Pick<
|
|
ArticleCaseInputPayload,
|
|
"source_title" | "source_body" | "publish_platform"
|
|
>) {
|
|
const sourceExcerpt = excerpt(input.source_body);
|
|
return {
|
|
title: input.source_title.trim() || excerpt(input.source_body, 32),
|
|
summary: sourceExcerpt,
|
|
publish_target: input.publish_platform,
|
|
source_excerpt: sourceExcerpt,
|
|
};
|
|
}
|
|
|
|
export function buildHumanCopyCaseSummary(input: HumanCopyCaseInputPayload) {
|
|
const publishTarget = input.publish_target.trim() || "未指定";
|
|
const sourceExcerpt = excerpt(input.source_text);
|
|
return {
|
|
title: `人味文案优化:${publishTarget}`,
|
|
summary: sourceExcerpt,
|
|
publish_target: publishTarget,
|
|
source_excerpt: sourceExcerpt,
|
|
};
|
|
}
|
|
|
|
export function createProcessStep({
|
|
stage,
|
|
startedAt,
|
|
endedAt,
|
|
status,
|
|
errorSummary,
|
|
rewriteRound,
|
|
producedResultVersion,
|
|
}: {
|
|
stage: string;
|
|
startedAt: number;
|
|
endedAt: number;
|
|
status: ProcessSummaryStep["status"];
|
|
errorSummary?: string;
|
|
rewriteRound?: number;
|
|
producedResultVersion: boolean;
|
|
}): ProcessSummaryStep {
|
|
return {
|
|
stage,
|
|
started_at: new Date(startedAt).toISOString(),
|
|
ended_at: new Date(endedAt).toISOString(),
|
|
duration_ms: Math.max(0, endedAt - startedAt),
|
|
status,
|
|
...(errorSummary ? { error_summary: errorSummary } : {}),
|
|
...(rewriteRound ? { rewrite_round: rewriteRound } : {}),
|
|
produced_result_version: producedResultVersion,
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Implement LLM audit helpers**
|
|
|
|
Create `src/lib/llm/audit.ts`:
|
|
|
|
```ts
|
|
import type { LlmProviderStatus, LlmTaskName } from "./client";
|
|
|
|
export interface LlmAuditSummary {
|
|
provider: LlmProviderStatus["provider"];
|
|
model: string;
|
|
task: LlmTaskName;
|
|
duration_ms: number;
|
|
schema_valid: boolean;
|
|
error_summary: string | null;
|
|
input_hash: string;
|
|
output_hash: string | null;
|
|
}
|
|
|
|
export async function hashContent(value: string) {
|
|
const data = new TextEncoder().encode(value);
|
|
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
return Array.from(new Uint8Array(digest))
|
|
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
.join("");
|
|
}
|
|
|
|
export async function createLlmAuditSummary({
|
|
provider,
|
|
model,
|
|
task,
|
|
duration_ms,
|
|
schema_valid,
|
|
prompt,
|
|
output,
|
|
error_summary,
|
|
}: {
|
|
provider: LlmProviderStatus["provider"];
|
|
model: string;
|
|
task: LlmTaskName;
|
|
duration_ms: number;
|
|
schema_valid: boolean;
|
|
prompt: string;
|
|
output: string | null;
|
|
error_summary: string | null;
|
|
}): Promise<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`:
|
|
|
|
```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:
|
|
|
|
```ts
|
|
const effectiveModel = input.model ?? status.model;
|
|
const emitAudit = async ({
|
|
schemaValid,
|
|
output,
|
|
errorSummary,
|
|
}: {
|
|
schemaValid: boolean;
|
|
output: unknown | null;
|
|
errorSummary: string | null;
|
|
}) => {
|
|
if (!input.onAuditSummary) return;
|
|
await input.onAuditSummary(
|
|
await createLlmAuditSummary({
|
|
provider: status.provider,
|
|
model: effectiveModel,
|
|
task,
|
|
duration_ms: Date.now() - startedAt,
|
|
schema_valid: schemaValid,
|
|
prompt: input.prompt,
|
|
output: output == null ? null : stringifyForLog(output),
|
|
error_summary: errorSummary,
|
|
}),
|
|
);
|
|
};
|
|
```
|
|
|
|
Call it on parse success:
|
|
|
|
```ts
|
|
await emitAudit({
|
|
schemaValid: true,
|
|
output: parsed.data,
|
|
errorSummary: null,
|
|
});
|
|
```
|
|
|
|
Call it before throwing validation errors:
|
|
|
|
```ts
|
|
const summary = summarizeZodError(parsed.error);
|
|
await emitAudit({
|
|
schemaValid: false,
|
|
output: generated,
|
|
errorSummary: summary,
|
|
});
|
|
throw new LlmValidationError(
|
|
`LLM response failed schema validation: ${summary}`,
|
|
task,
|
|
);
|
|
```
|
|
|
|
Call it in provider errors before rethrowing:
|
|
|
|
```ts
|
|
await emitAudit({
|
|
schemaValid: false,
|
|
output: null,
|
|
errorSummary: message,
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 7: Update LLM client tests**
|
|
|
|
Add to `src/lib/llm/__tests__/client.test.ts`:
|
|
|
|
```ts
|
|
it("emits an audit summary without raw prompt or response", async () => {
|
|
const audits: unknown[] = [];
|
|
setGenerateJsonForValidation(async () => ({ value: "ok" }));
|
|
|
|
const result = await generateValidatedJson({
|
|
schema: z.object({ value: z.string() }),
|
|
prompt: "raw prompt",
|
|
task: "unknown",
|
|
onAuditSummary: (summary) => audits.push(summary),
|
|
});
|
|
|
|
expect(result).toEqual({ value: "ok" });
|
|
expect(JSON.stringify(audits)).not.toContain("raw prompt");
|
|
expect(JSON.stringify(audits)).not.toContain("ok");
|
|
expect(audits).toEqual([
|
|
expect.objectContaining({
|
|
task: "unknown",
|
|
schema_valid: true,
|
|
input_hash: expect.any(String),
|
|
output_hash: expect.any(String),
|
|
}),
|
|
]);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 8: Run tests and commit**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/cases/__tests__/summaries.test.ts src/lib/llm/__tests__/audit.test.ts src/lib/llm/__tests__/client.test.ts
|
|
```
|
|
|
|
Expected: pass.
|
|
|
|
Commit:
|
|
|
|
```bash
|
|
git add src/lib/cases src/lib/llm
|
|
git commit -m "新增LLM审计摘要边界"
|
|
```
|
|
|
|
## Task 3: Database Migration And Repository Contract
|
|
|
|
**Files:**
|
|
|
|
- Create: `migrations/0003_unified_optimization_cases.sql`
|
|
- Modify: `src/lib/db/schema.ts`
|
|
- Modify: `src/lib/db/repository.ts`
|
|
- Modify: `src/lib/db/repositories.ts`
|
|
- Modify: `src/lib/db/sqlite-repository.ts`
|
|
- Modify: `src/lib/db/d1-repository.ts`
|
|
- Modify: `src/lib/calibration/types.ts`
|
|
- Modify: `src/lib/calibration/validation.ts`
|
|
- Modify: `src/lib/db/__tests__/repositories.test.ts`
|
|
- Modify: `src/lib/db/__tests__/repository.test.ts`
|
|
- Modify: `src/lib/db/__tests__/d1-repository.test.ts`
|
|
|
|
- [ ] **Step 1: Write failing SQLite schema test**
|
|
|
|
Update the table expectation in `src/lib/db/__tests__/repositories.test.ts`:
|
|
|
|
```ts
|
|
expect(tables).toEqual([
|
|
"article_jobs",
|
|
"brand_templates",
|
|
"calibration_events",
|
|
"case_inputs",
|
|
"fact_cards",
|
|
"optimization_cases",
|
|
"optimization_result_versions",
|
|
"optimized_articles",
|
|
"performance_snapshots",
|
|
"publication_records",
|
|
"qa_reports",
|
|
"rubric_versions",
|
|
"scoring_runs",
|
|
]);
|
|
```
|
|
|
|
Add a column assertion:
|
|
|
|
```ts
|
|
it("adds case references to article jobs and result-version publications", () => {
|
|
const db = createDatabase(dbPath);
|
|
const articleJobColumns = db
|
|
.prepare("pragma table_info(article_jobs)")
|
|
.all()
|
|
.map((row) => (row as { name: string }).name);
|
|
const publicationColumns = db
|
|
.prepare("pragma table_info(publication_records)")
|
|
.all()
|
|
.map((row) => (row as { name: string }).name);
|
|
db.close();
|
|
|
|
expect(articleJobColumns).toContain("case_id");
|
|
expect(publicationColumns).toContain("result_version_id");
|
|
expect(publicationColumns).toContain("publish_target");
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Write failing repository tests**
|
|
|
|
Add to `src/lib/db/__tests__/repository.test.ts`:
|
|
|
|
```ts
|
|
test("creates, lists, updates, archives, and restores optimization cases", async () => {
|
|
const repository = createSqliteRepository(dbPath);
|
|
|
|
const created = await repository.createOptimizationCase({
|
|
case_type: "human_copy",
|
|
title: "人味文案优化:朋友圈",
|
|
summary: "原文摘要",
|
|
publish_target: "朋友圈",
|
|
source_excerpt: "原文摘要",
|
|
});
|
|
|
|
await repository.saveCaseInput({
|
|
case_id: created.id,
|
|
case_type: "human_copy",
|
|
article_job_id: null,
|
|
payload: {
|
|
source_text: "原文摘要",
|
|
goal: "自然一点",
|
|
intensity: "light",
|
|
user_instructions: "",
|
|
publish_target: "朋友圈",
|
|
},
|
|
});
|
|
|
|
await expect(repository.listOptimizationCases({ include_archived: false }))
|
|
.resolves.toEqual([expect.objectContaining({ id: created.id })]);
|
|
|
|
await expect(
|
|
repository.updateOptimizationCaseMetadata(created.id, {
|
|
customer_name: "客户A",
|
|
brand_name: "品牌B",
|
|
project_tags: ["朋友圈"],
|
|
notes: "保留口语。",
|
|
}),
|
|
).resolves.toMatchObject({
|
|
customer_name: "客户A",
|
|
project_tags: ["朋友圈"],
|
|
});
|
|
|
|
await repository.archiveOptimizationCase(created.id);
|
|
await expect(repository.listOptimizationCases({ include_archived: false }))
|
|
.resolves.toHaveLength(0);
|
|
|
|
await repository.restoreOptimizationCase(created.id);
|
|
await expect(repository.getOptimizationCaseDetail(created.id))
|
|
.resolves.toMatchObject({
|
|
case: { id: created.id, status: "running" },
|
|
input: expect.objectContaining({ case_id: created.id }),
|
|
});
|
|
});
|
|
|
|
test("creates multiple result versions and binds publication to a version", async () => {
|
|
const repository = createSqliteRepository(dbPath);
|
|
const optimizationCase = await repository.createOptimizationCase({
|
|
case_type: "human_copy",
|
|
title: "人味文案优化:私域",
|
|
summary: "原文",
|
|
publish_target: "私域",
|
|
source_excerpt: "原文",
|
|
});
|
|
|
|
const first = await repository.createOptimizationResultVersion({
|
|
case_id: optimizationCase.id,
|
|
case_type: "human_copy",
|
|
status: "optimized",
|
|
article_job_id: null,
|
|
article_revision: null,
|
|
result_summary: "第一版",
|
|
payload: {
|
|
optimized_text: "第一版文案",
|
|
change_notes: [],
|
|
ai_taste_checks: [],
|
|
warnings: [],
|
|
},
|
|
process_summary: [],
|
|
llm_audit_summary: [],
|
|
error_stage: null,
|
|
error_summary: null,
|
|
});
|
|
const second = await repository.createOptimizationResultVersion({
|
|
case_id: optimizationCase.id,
|
|
case_type: "human_copy",
|
|
status: "optimized",
|
|
article_job_id: null,
|
|
article_revision: null,
|
|
result_summary: "第二版",
|
|
payload: {
|
|
optimized_text: "第二版文案",
|
|
change_notes: [],
|
|
ai_taste_checks: [],
|
|
warnings: [],
|
|
},
|
|
process_summary: [],
|
|
llm_audit_summary: [],
|
|
error_stage: null,
|
|
error_summary: null,
|
|
});
|
|
|
|
expect(first.version).toBe(1);
|
|
expect(second.version).toBe(2);
|
|
|
|
const publication = await repository.createPublicationRecord({
|
|
result_version_id: second.id,
|
|
job_id: null,
|
|
revision: null,
|
|
publish_target: "私域",
|
|
url: "https://example.com/private",
|
|
published_at: "2026-07-08T12:00:00.000Z",
|
|
status: "published",
|
|
notes: "客户私域发布",
|
|
});
|
|
|
|
await expect(repository.listPublicationRecordsForResultVersion(second.id))
|
|
.resolves.toEqual([expect.objectContaining({ id: publication.id })]);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: Run tests and verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/db/__tests__/repositories.test.ts src/lib/db/__tests__/repository.test.ts
|
|
```
|
|
|
|
Expected: fail because tables, columns, and repository methods do not exist.
|
|
|
|
- [ ] **Step 4: Add the migration and local schema**
|
|
|
|
Create `migrations/0003_unified_optimization_cases.sql` using the SQL from the `Database Model` section. In `src/lib/db/schema.ts`, add the same table definitions and indexes inside `initializeSchema`.
|
|
|
|
Because `initializeSchema` is used for empty local SQLite databases, define `publication_records` directly with the new result-version-compatible shape there:
|
|
|
|
```sql
|
|
create table if not exists publication_records (
|
|
id text primary key,
|
|
result_version_id text,
|
|
job_id text,
|
|
revision integer,
|
|
publish_target text not null,
|
|
url text not null,
|
|
published_at text not null,
|
|
status text not null,
|
|
notes text not null,
|
|
created_at text not null,
|
|
updated_at text not null,
|
|
foreign key (result_version_id)
|
|
references optimization_result_versions(id) on delete cascade
|
|
);
|
|
```
|
|
|
|
- [ ] **Step 5: Extend calibration types**
|
|
|
|
Modify `PublicationRecord` in `src/lib/calibration/types.ts`:
|
|
|
|
```ts
|
|
export interface PublicationRecord {
|
|
id: string;
|
|
result_version_id: string | null;
|
|
job_id: string | null;
|
|
revision: number | null;
|
|
publish_target: string;
|
|
url: string;
|
|
published_at: string;
|
|
status: "draft" | "published" | "archived";
|
|
notes: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
```
|
|
|
|
Modify `ScoringRun`:
|
|
|
|
```ts
|
|
export interface ScoringRun {
|
|
id: string;
|
|
result_version_id?: string | null;
|
|
case_type?: "article" | "human_copy";
|
|
job_id: string | null;
|
|
revision: number | null;
|
|
rubric_version_id: string;
|
|
dimension_scores: Record<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`:
|
|
|
|
```ts
|
|
export const publicationInputSchema = z.object({
|
|
platform: publishPlatformSchema.optional(),
|
|
publish_target: z.string().trim().min(1).optional(),
|
|
url: z.string().trim().url(),
|
|
published_at: z.string().datetime(),
|
|
notes: optionalTextSchema.default(""),
|
|
}).transform((input) => ({
|
|
publish_target: input.publish_target ?? input.platform ?? "未指定",
|
|
url: input.url,
|
|
published_at: input.published_at,
|
|
notes: input.notes,
|
|
}));
|
|
```
|
|
|
|
- [ ] **Step 6: Extend `AppRepository`**
|
|
|
|
Add these methods to `src/lib/db/repository.ts`:
|
|
|
|
```ts
|
|
createOptimizationCase(input: {
|
|
case_type: OptimizationCaseType;
|
|
title: string;
|
|
summary: string;
|
|
publish_target: string;
|
|
source_excerpt: string;
|
|
}): Promise<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`:
|
|
|
|
```ts
|
|
export interface NewArticleJob {
|
|
brand_template_id?: string | null;
|
|
case_id?: string | null;
|
|
source_title: string;
|
|
source_body: string;
|
|
image_inputs: ImageInput[];
|
|
publish_platform: PublishPlatform;
|
|
user_instructions: string;
|
|
}
|
|
```
|
|
|
|
Update `ArticleJob` with `case_id: string | null`.
|
|
|
|
- [ ] **Step 7: Implement SQLite repository functions**
|
|
|
|
In `src/lib/db/repositories.ts`, add row interfaces and converters:
|
|
|
|
```ts
|
|
interface OptimizationCaseRow {
|
|
id: string;
|
|
case_type: OptimizationCaseType;
|
|
title: string;
|
|
summary: string;
|
|
status: OptimizationCaseStatus;
|
|
customer_name: string;
|
|
brand_name: string;
|
|
project_tags: string;
|
|
notes: string;
|
|
publish_target: string;
|
|
source_excerpt: string;
|
|
result_excerpt: string;
|
|
latest_result_version_id: string | null;
|
|
latest_version_number: number | null;
|
|
last_error_stage: string | null;
|
|
last_error_summary: string | null;
|
|
archived_at: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface CaseInputRow {
|
|
case_id: string;
|
|
case_type: OptimizationCaseType;
|
|
article_job_id: string | null;
|
|
payload: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface OptimizationResultVersionRow {
|
|
id: string;
|
|
case_id: string;
|
|
case_type: OptimizationCaseType;
|
|
version: number;
|
|
status: ResultVersionStatus;
|
|
article_job_id: string | null;
|
|
article_revision: number | null;
|
|
result_summary: string;
|
|
payload: string;
|
|
process_summary: string;
|
|
llm_audit_summary: string;
|
|
error_stage: string | null;
|
|
error_summary: string | null;
|
|
created_at: string;
|
|
}
|
|
```
|
|
|
|
Use these conversion helpers:
|
|
|
|
```ts
|
|
function toOptimizationCase(row: OptimizationCaseRow): OptimizationCase {
|
|
return {
|
|
...row,
|
|
project_tags: parseJson<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:
|
|
|
|
```ts
|
|
const nextVersion =
|
|
((db
|
|
.prepare(
|
|
"select max(version) as version from optimization_result_versions where case_id = ?",
|
|
)
|
|
.get(input.case_id) as { version: number | null }).version ?? 0) + 1;
|
|
```
|
|
|
|
After inserting an optimized or failed version, update `optimization_cases`:
|
|
|
|
```sql
|
|
update optimization_cases set
|
|
status = @status,
|
|
result_excerpt = @result_excerpt,
|
|
latest_result_version_id = @latest_result_version_id,
|
|
latest_version_number = @latest_version_number,
|
|
last_error_stage = @last_error_stage,
|
|
last_error_summary = @last_error_summary,
|
|
updated_at = @updated_at
|
|
where id = @case_id
|
|
```
|
|
|
|
- [ ] **Step 8: Wire SQLite and D1 repositories**
|
|
|
|
Expose all new functions from `createSqliteRepository` in `src/lib/db/sqlite-repository.ts`.
|
|
|
|
Implement the same SQL in `src/lib/db/d1-repository.ts` using the existing D1 pattern:
|
|
|
|
```ts
|
|
await db.prepare(sql).bind(valueA, valueB, valueC).run();
|
|
const result = await db.prepare(sql).bind(valueA).all<RowType>();
|
|
```
|
|
|
|
Keep converter output identical to SQLite output.
|
|
|
|
- [ ] **Step 9: Update publication repository functions**
|
|
|
|
Change `createPublicationRecord` inserts to use:
|
|
|
|
```ts
|
|
record.result_version_id,
|
|
record.job_id,
|
|
record.revision,
|
|
record.publish_target,
|
|
record.url,
|
|
record.published_at,
|
|
record.status,
|
|
record.notes,
|
|
record.created_at,
|
|
record.updated_at
|
|
```
|
|
|
|
Change `listPublicationRecords(jobId)` to keep the existing job API:
|
|
|
|
```sql
|
|
select * from publication_records
|
|
where job_id = ?
|
|
order by published_at desc
|
|
```
|
|
|
|
Add `listPublicationRecordsForResultVersion(resultVersionId)`:
|
|
|
|
```sql
|
|
select * from publication_records
|
|
where result_version_id = ?
|
|
order by published_at desc
|
|
```
|
|
|
|
- [ ] **Step 10: Run repository tests and commit**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/db/__tests__/repositories.test.ts src/lib/db/__tests__/repository.test.ts src/lib/db/__tests__/d1-repository.test.ts
|
|
```
|
|
|
|
Expected: pass.
|
|
|
|
Commit:
|
|
|
|
```bash
|
|
git add migrations/0003_unified_optimization_cases.sql src/lib/db src/lib/calibration
|
|
git commit -m "新增统一案例存储仓储"
|
|
```
|
|
|
|
## Task 4: Article Optimization Auto-Save
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/lib/workflow/fact-extractor.ts`
|
|
- Modify: `src/lib/workflow/article-optimizer.ts`
|
|
- Modify: `src/lib/workflow/quality-inspector.ts`
|
|
- Modify: `src/lib/workflow/targeted-rewriter.ts`
|
|
- Modify: `src/lib/workflow/streaming-optimizer.ts`
|
|
- Modify: `src/lib/workflow/orchestrator.ts`
|
|
- Modify: `src/lib/workflow/stream-events.ts`
|
|
- Modify: `src/app/api/jobs/route.ts`
|
|
- Modify: `src/app/api/jobs/optimize-stream/route.ts`
|
|
- Modify: `src/app/api/jobs/[jobId]/optimize/route.ts`
|
|
- Modify: `src/app/api/__tests__/jobs.test.ts`
|
|
|
|
- [ ] **Step 1: Write failing article API tests**
|
|
|
|
Add to `src/app/api/__tests__/jobs.test.ts`:
|
|
|
|
```ts
|
|
it("auto-saves stream article optimization as a case and result version", async () => {
|
|
llmMocks.generateValidatedJson
|
|
.mockResolvedValueOnce(validCandidateFactCard)
|
|
.mockResolvedValueOnce({
|
|
title: "流式优化标题",
|
|
summary: "流式优化摘要。",
|
|
body_markdown:
|
|
"## 服务能力\nExample Technology Co., Ltd. 提供 GEO optimization 服务。",
|
|
image_suggestions: [],
|
|
changed_sections: ["title", "body"],
|
|
requires_user_confirmation: [],
|
|
})
|
|
.mockResolvedValueOnce({ checks: [] });
|
|
|
|
const response = await optimizeStream(
|
|
request({
|
|
body: "Example Technology Co., Ltd. has 8 years of GEO optimization experience.",
|
|
platform: "official_site",
|
|
}),
|
|
);
|
|
const events = await streamEvents(response);
|
|
const finalEvent = events.find((event) => event.type === "final_ready") as
|
|
| (StreamEventResponse & {
|
|
case?: { id: string; case_type: string };
|
|
result_version?: { id: string; version: number };
|
|
})
|
|
| undefined;
|
|
|
|
expect(finalEvent?.case?.case_type).toBe("article");
|
|
expect(finalEvent?.result_version?.version).toBe(1);
|
|
|
|
const listResponse = await listCases(request({}));
|
|
const listBody = (await listResponse.json()) as {
|
|
cases: Array<{ id: string; case_type: string; status: string }>;
|
|
};
|
|
|
|
expect(listBody.cases).toEqual([
|
|
expect.objectContaining({
|
|
id: finalEvent?.case?.id,
|
|
case_type: "article",
|
|
status: "optimized",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("auto-saves stream article LLM failure as a failed case", async () => {
|
|
llmMocks.generateValidatedJson
|
|
.mockResolvedValueOnce(validCandidateFactCard)
|
|
.mockRejectedValueOnce(new Error("LLM provider error: timeout"));
|
|
|
|
const response = await optimizeStream(
|
|
request({
|
|
body: "Example Technology Co., Ltd. has 8 years of GEO optimization experience.",
|
|
platform: "official_site",
|
|
}),
|
|
);
|
|
const events = await streamEvents(response);
|
|
const failedEvent = events[events.length - 1] as StreamEventResponse & {
|
|
case?: { id: string };
|
|
};
|
|
|
|
expect(failedEvent.type).toBe("failed");
|
|
expect(failedEvent.case?.id).toMatch(/^case_/);
|
|
|
|
const detailResponse = await getCase(
|
|
request({}),
|
|
params<{ caseId: string }>({ caseId: failedEvent.case.id }),
|
|
);
|
|
const detailBody = (await detailResponse.json()) as {
|
|
case: { status: string; last_error_stage: string };
|
|
versions: Array<{ status: string; error_summary: string }>;
|
|
};
|
|
|
|
expect(detailBody.case.status).toBe("failed");
|
|
expect(detailBody.case.last_error_stage).toBe("draft");
|
|
expect(detailBody.versions).toEqual([
|
|
expect.objectContaining({
|
|
status: "failed",
|
|
error_summary: "LLM provider error: timeout",
|
|
}),
|
|
]);
|
|
});
|
|
```
|
|
|
|
Import the new route handlers at the top of the test file:
|
|
|
|
```ts
|
|
import { GET as listCases } from "../cases/route";
|
|
import { GET as getCase } from "../cases/[caseId]/route";
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests and verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/app/api/__tests__/jobs.test.ts
|
|
```
|
|
|
|
Expected: fail because case APIs and route payloads are not implemented.
|
|
|
|
- [ ] **Step 3: Add audit callback plumbing to workflow nodes**
|
|
|
|
Change each workflow function to accept `onAuditSummary?: GenerateInput["onAuditSummary"]` and pass it to `generateValidatedJson`.
|
|
|
|
Example for `src/lib/workflow/article-optimizer.ts`:
|
|
|
|
```ts
|
|
import type { GenerateInput } from "../llm/client";
|
|
|
|
export interface OptimizeArticleInput {
|
|
input: ArticleInput;
|
|
factCard: OptimizationFactCard;
|
|
onAuditSummary?: GenerateInput["onAuditSummary"];
|
|
}
|
|
|
|
export async function optimizeArticle({
|
|
input,
|
|
factCard,
|
|
onAuditSummary,
|
|
}: OptimizeArticleInput): Promise<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:
|
|
|
|
```ts
|
|
return {
|
|
article,
|
|
qaReport,
|
|
rewriteRounds,
|
|
stoppedAfterMaxRewrites:
|
|
qaReport.overall_status === "fail" && rewriteRounds >= 2,
|
|
processSummary,
|
|
llmAuditSummary,
|
|
};
|
|
```
|
|
|
|
Use a local audit array:
|
|
|
|
```ts
|
|
const llmAuditSummary: LlmAuditSummary[] = [];
|
|
const onAuditSummary = (summary: LlmAuditSummary) => {
|
|
llmAuditSummary.push(summary);
|
|
};
|
|
```
|
|
|
|
Wrap each major stage with start/end timestamps and `createProcessStep`. Do not include article draft text in `processSummary`.
|
|
|
|
For `runOptimizationWorkflow`, use the same output shape and preserve the existing `timing` object.
|
|
|
|
- [ ] **Step 5: Extend stream events with case metadata**
|
|
|
|
Modify `src/lib/workflow/stream-events.ts`:
|
|
|
|
```ts
|
|
export type OptimizationStreamEvent =
|
|
| {
|
|
type: "job_created";
|
|
job: { id: string };
|
|
case?: { id: string; case_type: "article" };
|
|
}
|
|
| {
|
|
type: "fact_card_ready";
|
|
job_id: string;
|
|
fact_card: OptimizationFactCard;
|
|
}
|
|
| { type: "draft_started"; job_id: string; message: string }
|
|
| { type: "draft_ready"; job_id: string; article: OptimizedArticle }
|
|
| { type: "qa_started"; job_id: string; message: string }
|
|
| { type: "qa_ready"; job_id: string; qa_report: QaReport }
|
|
| { type: "rewrite_started"; job_id: string; round: number }
|
|
| {
|
|
type: "rewrite_ready";
|
|
job_id: string;
|
|
round: number;
|
|
article: OptimizedArticle;
|
|
}
|
|
| {
|
|
type: "final_ready";
|
|
job_id: string;
|
|
case?: { id: string; case_type: "article" };
|
|
result_version?: { id: string; version: number };
|
|
optimized_article: OptimizedArticle;
|
|
qa_report: QaReport;
|
|
export_paths: Record<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:
|
|
|
|
```ts
|
|
const caseSummary = buildArticleCaseSummary({
|
|
source_title: normalized.articleInput.title,
|
|
source_body: normalized.articleInput.body,
|
|
publish_platform: normalized.articleInput.platform,
|
|
});
|
|
const optimizationCase = await repository.createOptimizationCase({
|
|
case_type: "article",
|
|
...caseSummary,
|
|
});
|
|
const job = await repository.createArticleJob({
|
|
case_id: optimizationCase.id,
|
|
source_title: normalized.articleInput.title,
|
|
source_body: normalized.articleInput.body,
|
|
image_inputs: normalized.articleInput.images,
|
|
publish_platform: normalized.articleInput.platform,
|
|
user_instructions: normalized.articleInput.user_instructions,
|
|
});
|
|
await repository.saveCaseInput({
|
|
case_id: optimizationCase.id,
|
|
case_type: "article",
|
|
article_job_id: job.id,
|
|
payload: {
|
|
source_title: normalized.articleInput.title,
|
|
source_body: normalized.articleInput.body,
|
|
image_inputs: normalized.articleInput.images,
|
|
publish_platform: normalized.articleInput.platform,
|
|
user_instructions: normalized.articleInput.user_instructions,
|
|
},
|
|
});
|
|
```
|
|
|
|
On fact extraction failure, call `createOptimizationResultVersion` with:
|
|
|
|
```ts
|
|
{
|
|
case_id: optimizationCase.id,
|
|
case_type: "article",
|
|
status: "failed",
|
|
article_job_id: job.id,
|
|
article_revision: null,
|
|
result_summary: "",
|
|
payload: null,
|
|
process_summary: [
|
|
createProcessStep({
|
|
stage: "fact_card",
|
|
startedAt: factStartedAt,
|
|
endedAt: Date.now(),
|
|
status: "failed",
|
|
errorSummary: message,
|
|
producedResultVersion: false,
|
|
}),
|
|
],
|
|
llm_audit_summary: audits,
|
|
error_stage: "fact_card",
|
|
error_summary: message,
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 7: Create and update article case in `/api/jobs/optimize-stream`**
|
|
|
|
In the stream route, create `optimizationCase` before `createArticleJob`, save the case input, and include case metadata in `job_created`.
|
|
|
|
On success after exports:
|
|
|
|
```ts
|
|
const resultVersion = await repository.createOptimizationResultVersion({
|
|
case_id: optimizationCase.id,
|
|
case_type: "article",
|
|
status: "optimized",
|
|
article_job_id: job.id,
|
|
article_revision: optimizedArticle.revision ?? 1,
|
|
result_summary: optimizedArticle.summary,
|
|
payload: {
|
|
article: optimizedArticle,
|
|
qa_report: qaReport,
|
|
export_paths: exportPaths,
|
|
},
|
|
process_summary: result.processSummary,
|
|
llm_audit_summary: result.llmAuditSummary,
|
|
error_stage: null,
|
|
error_summary: null,
|
|
});
|
|
```
|
|
|
|
Include `case` and `result_version` in `final_ready`.
|
|
|
|
On failure after case creation:
|
|
|
|
```ts
|
|
const failedVersion = await repository.createOptimizationResultVersion({
|
|
case_id: optimizationCase.id,
|
|
case_type: "article",
|
|
status: "failed",
|
|
article_job_id: jobId ?? null,
|
|
article_revision: null,
|
|
result_summary: "",
|
|
payload: null,
|
|
process_summary: processSummary,
|
|
llm_audit_summary: llmAuditSummary,
|
|
error_stage: stage,
|
|
error_summary: message,
|
|
});
|
|
```
|
|
|
|
Include `case` and `result_version` in `failed`.
|
|
|
|
- [ ] **Step 8: Backfill article cases in non-streaming optimize route**
|
|
|
|
In `src/app/api/jobs/[jobId]/optimize/route.ts`, if `job.case_id` is null, create an article case and save case input before running optimization. On success, create an optimized result version. On failure, create a failed result version with stage `"optimize"`.
|
|
|
|
- [ ] **Step 9: Run article API tests and commit**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/app/api/__tests__/jobs.test.ts src/lib/workflow/__tests__/stream-events.test.ts src/lib/workflow/__tests__/orchestrator.test.ts
|
|
```
|
|
|
|
Expected: pass.
|
|
|
|
Commit:
|
|
|
|
```bash
|
|
git add src/lib/workflow src/app/api/jobs src/app/api/__tests__/jobs.test.ts
|
|
git commit -m "接入文章优化案例自动保存"
|
|
```
|
|
|
|
## Task 5: Human-Copy Auto-Save
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/lib/workflow/renwei-copy-optimizer.ts`
|
|
- Modify: `src/app/api/copy/renwei-optimize/route.ts`
|
|
- Modify: `src/components/renwei-copy-optimizer-panel.tsx`
|
|
- Modify: `src/app/api/__tests__/copy-renwei.test.ts`
|
|
- Modify: `tests/e2e/renwei-copy.spec.ts`
|
|
|
|
- [ ] **Step 1: Write failing human-copy API tests**
|
|
|
|
Update the structured result test in `src/app/api/__tests__/copy-renwei.test.ts`:
|
|
|
|
```ts
|
|
const response = await optimizeCopy(
|
|
request({
|
|
source_text: "我把这段文案顺顺。",
|
|
goal: "",
|
|
intensity: "light",
|
|
user_instructions: "保留口语。",
|
|
publish_target: "朋友圈",
|
|
}),
|
|
);
|
|
const body = (await response.json()) as {
|
|
case: { id: string; case_type: string };
|
|
result_version: { id: string; version: number };
|
|
result: { optimized_text: string; change_notes: unknown[] };
|
|
};
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(body.case.case_type).toBe("human_copy");
|
|
expect(body.result_version.version).toBe(1);
|
|
expect(body.result.optimized_text).toBe("我把这段文案顺了一下。");
|
|
```
|
|
|
|
Add a failed-case test:
|
|
|
|
```ts
|
|
it("saves LLM failures as failed human-copy cases", async () => {
|
|
llmMocks.generateValidatedJson.mockRejectedValueOnce(
|
|
new Error("LLM response failed schema validation: optimized_text"),
|
|
);
|
|
|
|
const response = await optimizeCopy(
|
|
request({
|
|
source_text: "这是一段普通文案。",
|
|
intensity: "light",
|
|
publish_target: "私域",
|
|
}),
|
|
);
|
|
const body = (await response.json()) as {
|
|
error: string;
|
|
case?: { id: string; case_type: string };
|
|
result_version?: { id: string; version: number };
|
|
};
|
|
|
|
expect(response.status).toBe(502);
|
|
expect(body.error).toBe(
|
|
"LLM response failed schema validation: optimized_text",
|
|
);
|
|
expect(body.case?.case_type).toBe("human_copy");
|
|
expect(body.result_version?.version).toBe(1);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests and verify they fail**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/app/api/__tests__/copy-renwei.test.ts
|
|
```
|
|
|
|
Expected: fail because the route returns only `{ result }`.
|
|
|
|
- [ ] **Step 3: Pass audit callback through human-copy optimizer**
|
|
|
|
Modify `src/lib/workflow/renwei-copy-optimizer.ts`:
|
|
|
|
```ts
|
|
import type { GenerateInput } from "../llm/client";
|
|
|
|
export async function optimizeRenweiCopy(
|
|
input: CopyOptimizationRequest,
|
|
options: { onAuditSummary?: GenerateInput["onAuditSummary"] } = {},
|
|
): Promise<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:
|
|
|
|
```ts
|
|
const repository = getRepositoryFromRuntime();
|
|
const caseSummary = buildHumanCopyCaseSummary(payload);
|
|
const optimizationCase = await repository.createOptimizationCase({
|
|
case_type: "human_copy",
|
|
...caseSummary,
|
|
});
|
|
await repository.saveCaseInput({
|
|
case_id: optimizationCase.id,
|
|
case_type: "human_copy",
|
|
article_job_id: null,
|
|
payload,
|
|
});
|
|
const llmAuditSummary: LlmAuditSummary[] = [];
|
|
const startedAt = Date.now();
|
|
```
|
|
|
|
On success:
|
|
|
|
```ts
|
|
const result = await optimizeRenweiCopy(payload, {
|
|
onAuditSummary: (summary) => llmAuditSummary.push(summary),
|
|
});
|
|
const resultVersion = await repository.createOptimizationResultVersion({
|
|
case_id: optimizationCase.id,
|
|
case_type: "human_copy",
|
|
status: "optimized",
|
|
article_job_id: null,
|
|
article_revision: null,
|
|
result_summary: excerpt(result.optimized_text),
|
|
payload: result,
|
|
process_summary: [
|
|
createProcessStep({
|
|
stage: "human_copy_optimize",
|
|
startedAt,
|
|
endedAt: Date.now(),
|
|
status: "success",
|
|
producedResultVersion: true,
|
|
}),
|
|
],
|
|
llm_audit_summary: llmAuditSummary,
|
|
error_stage: null,
|
|
error_summary: null,
|
|
});
|
|
return NextResponse.json({
|
|
case: { id: optimizationCase.id, case_type: "human_copy" },
|
|
result_version: { id: resultVersion.id, version: resultVersion.version },
|
|
result,
|
|
});
|
|
```
|
|
|
|
On LLM failure:
|
|
|
|
```ts
|
|
const resultVersion = await repository.createOptimizationResultVersion({
|
|
case_id: optimizationCase.id,
|
|
case_type: "human_copy",
|
|
status: "failed",
|
|
article_job_id: null,
|
|
article_revision: null,
|
|
result_summary: "",
|
|
payload: null,
|
|
process_summary: [
|
|
createProcessStep({
|
|
stage: "human_copy_optimize",
|
|
startedAt,
|
|
endedAt: Date.now(),
|
|
status: "failed",
|
|
errorSummary: message,
|
|
producedResultVersion: false,
|
|
}),
|
|
],
|
|
llm_audit_summary: llmAuditSummary,
|
|
error_stage: "human_copy_optimize",
|
|
error_summary: message,
|
|
});
|
|
return NextResponse.json(
|
|
{
|
|
error: message,
|
|
case: { id: optimizationCase.id, case_type: "human_copy" },
|
|
result_version: { id: resultVersion.id, version: resultVersion.version },
|
|
},
|
|
{ status: getErrorStatus(error) },
|
|
);
|
|
```
|
|
|
|
- [ ] **Step 5: Add publish target and saved-case link to the panel**
|
|
|
|
In `src/components/renwei-copy-optimizer-panel.tsx`, add state:
|
|
|
|
```ts
|
|
const [publishTarget, setPublishTarget] = useState("朋友圈");
|
|
const [caseId, setCaseId] = useState<string | null>(null);
|
|
```
|
|
|
|
Add a label to the form:
|
|
|
|
```tsx
|
|
<label>
|
|
<span>发布目标</span>
|
|
<input
|
|
value={publishTarget}
|
|
onChange={(event) => setPublishTarget(event.target.value)}
|
|
/>
|
|
</label>
|
|
```
|
|
|
|
Send it in the request body:
|
|
|
|
```ts
|
|
publish_target: publishTarget,
|
|
```
|
|
|
|
Update response type:
|
|
|
|
```ts
|
|
interface CopyOptimizeResponse {
|
|
case?: { id: string; case_type: "human_copy" };
|
|
result_version?: { id: string; version: number };
|
|
result?: CopyOptimizationResult;
|
|
error?: string;
|
|
}
|
|
```
|
|
|
|
After success:
|
|
|
|
```ts
|
|
setCaseId(body.case?.id ?? null);
|
|
setMessage("文案优化完成,已保存到案例库。");
|
|
```
|
|
|
|
Render a case link near the result:
|
|
|
|
```tsx
|
|
{caseId ? (
|
|
<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:
|
|
|
|
```ts
|
|
await page.route("**/api/copy/renwei-optimize", async (route) => {
|
|
const payload = route.request().postDataJSON() as { publish_target?: string };
|
|
expect(payload.publish_target).toBe("朋友圈");
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
case: { id: "case_copy_1", case_type: "human_copy" },
|
|
result_version: { id: "ver_copy_1", version: 1 },
|
|
result: {
|
|
optimized_text: "我观察到大家越来越难进入心流了。",
|
|
change_notes: [
|
|
{
|
|
original: "我观察到大家越来越难进入心流",
|
|
revised: "我观察到大家越来越难进入心流了。",
|
|
reason: "补足句尾语气,让句子自然收住。",
|
|
confidence: "confident",
|
|
revertible: false,
|
|
},
|
|
],
|
|
ai_taste_checks: [
|
|
{
|
|
rule_id: "promotion_tone",
|
|
status: "pass",
|
|
evidence: "没有新增宣传腔。",
|
|
suggestion: "",
|
|
},
|
|
],
|
|
warnings: [],
|
|
},
|
|
}),
|
|
});
|
|
});
|
|
```
|
|
|
|
Fill publish target:
|
|
|
|
```ts
|
|
await page.getByLabel("发布目标").fill("朋友圈");
|
|
await expect(page.getByRole("link", { name: "查看案例详情" })).toHaveAttribute(
|
|
"href",
|
|
"/cases/case_copy_1",
|
|
);
|
|
```
|
|
|
|
- [ ] **Step 7: Run tests and commit**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/app/api/__tests__/copy-renwei.test.ts
|
|
npx playwright test tests/e2e/renwei-copy.spec.ts
|
|
```
|
|
|
|
Expected: pass.
|
|
|
|
Commit:
|
|
|
|
```bash
|
|
git add src/lib/workflow/renwei-copy-optimizer.ts src/app/api/copy src/components/renwei-copy-optimizer-panel.tsx src/app/api/__tests__/copy-renwei.test.ts tests/e2e/renwei-copy.spec.ts
|
|
git commit -m "接入人味文案案例自动保存"
|
|
```
|
|
|
|
## Task 6: Case APIs, Archive, Restore, And Rerun
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/app/api/cases/route.ts`
|
|
- Create: `src/app/api/cases/[caseId]/route.ts`
|
|
- Create: `src/app/api/cases/[caseId]/archive/route.ts`
|
|
- Create: `src/app/api/cases/[caseId]/restore/route.ts`
|
|
- Create: `src/app/api/cases/[caseId]/rerun/route.ts`
|
|
- Create: `src/app/api/cases/[caseId]/versions/[versionId]/publications/route.ts`
|
|
- Modify: `src/app/api/publications/[publicationId]/performance/route.ts`
|
|
- Modify: `src/app/api/__tests__/cases.test.ts`
|
|
|
|
- [ ] **Step 1: Write failing case API tests**
|
|
|
|
Create `src/app/api/__tests__/cases.test.ts` with these covered behaviors:
|
|
|
|
```ts
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { createSqliteRepository } from "../../../lib/db/sqlite-repository";
|
|
import { GET as listCases } from "../cases/route";
|
|
import { GET as getCase, PATCH as patchCase } from "../cases/[caseId]/route";
|
|
import { POST as archiveCase } from "../cases/[caseId]/archive/route";
|
|
import { POST as restoreCase } from "../cases/[caseId]/restore/route";
|
|
import { POST as rerunCase } from "../cases/[caseId]/rerun/route";
|
|
import {
|
|
GET as listVersionPublications,
|
|
POST as createVersionPublication,
|
|
} from "../cases/[caseId]/versions/[versionId]/publications/route";
|
|
|
|
const llmMocks = vi.hoisted(() => ({
|
|
generateValidatedJson: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../../../lib/llm/client", async () => {
|
|
const actual = await vi.importActual<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:
|
|
|
|
```bash
|
|
npm test -- src/app/api/__tests__/cases.test.ts
|
|
```
|
|
|
|
Expected: fail because case route files do not exist.
|
|
|
|
- [ ] **Step 3: Implement list/read/patch routes**
|
|
|
|
`src/app/api/cases/route.ts`:
|
|
|
|
```ts
|
|
import { NextResponse } from "next/server";
|
|
|
|
import { requireApiAccess } from "../../../lib/api/auth";
|
|
import { caseListFiltersSchema } from "../../../lib/cases/validation";
|
|
import { getRepositoryFromRuntime } from "../../../lib/db/repository";
|
|
|
|
export async function GET(request: Request) {
|
|
const access = requireApiAccess(request);
|
|
if (!access.ok) return access.response;
|
|
|
|
const url = new URL(request.url);
|
|
const filters = caseListFiltersSchema.parse(
|
|
Object.fromEntries(url.searchParams.entries()),
|
|
);
|
|
const repository = getRepositoryFromRuntime();
|
|
return NextResponse.json({
|
|
cases: await repository.listOptimizationCases(filters),
|
|
});
|
|
}
|
|
```
|
|
|
|
`src/app/api/cases/[caseId]/route.ts`:
|
|
|
|
```ts
|
|
import { NextResponse } from "next/server";
|
|
|
|
import { requireApiAccess } from "../../../../lib/api/auth";
|
|
import { caseMetadataPatchSchema } from "../../../../lib/cases/validation";
|
|
import { getRepositoryFromRuntime } from "../../../../lib/db/repository";
|
|
|
|
interface RouteContext {
|
|
params: Promise<{ caseId: string }>;
|
|
}
|
|
|
|
export async function GET(request: Request, context: RouteContext) {
|
|
const access = requireApiAccess(request);
|
|
if (!access.ok) return access.response;
|
|
|
|
const { caseId } = await context.params;
|
|
const repository = getRepositoryFromRuntime();
|
|
const detail = await repository.getOptimizationCaseDetail(caseId);
|
|
if (!detail) {
|
|
return NextResponse.json({ error: "Case not found" }, { status: 404 });
|
|
}
|
|
return NextResponse.json(detail);
|
|
}
|
|
|
|
export async function PATCH(request: Request, context: RouteContext) {
|
|
const access = requireApiAccess(request);
|
|
if (!access.ok) return access.response;
|
|
|
|
const { caseId } = await context.params;
|
|
const repository = getRepositoryFromRuntime();
|
|
const changes = caseMetadataPatchSchema.parse(await request.json());
|
|
const optimizationCase = await repository.updateOptimizationCaseMetadata(
|
|
caseId,
|
|
changes,
|
|
);
|
|
if (!optimizationCase) {
|
|
return NextResponse.json({ error: "Case not found" }, { status: 404 });
|
|
}
|
|
return NextResponse.json({ case: optimizationCase });
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Implement archive and restore routes**
|
|
|
|
Both routes require API access, load repository, call the matching repository method, return `404` when null, and return `{ case: optimizationCase }` on success.
|
|
|
|
Archive route body:
|
|
|
|
```ts
|
|
const optimizationCase = await repository.archiveOptimizationCase(caseId);
|
|
```
|
|
|
|
Restore route body:
|
|
|
|
```ts
|
|
const optimizationCase = await repository.restoreOptimizationCase(caseId);
|
|
```
|
|
|
|
- [ ] **Step 5: Implement publication routes**
|
|
|
|
`POST /api/cases/:caseId/versions/:versionId/publications`:
|
|
|
|
```ts
|
|
const version = await repository.getOptimizationResultVersion(versionId);
|
|
if (!version || version.case_id !== caseId) {
|
|
return NextResponse.json({ error: "Result version not found" }, { status: 404 });
|
|
}
|
|
const input = resultVersionPublicationInputSchema.parse(await request.json());
|
|
const publication = await repository.createPublicationRecord({
|
|
result_version_id: version.id,
|
|
job_id: version.article_job_id,
|
|
revision: version.article_revision,
|
|
publish_target: input.publish_target,
|
|
url: input.url,
|
|
published_at: input.published_at,
|
|
status: "published",
|
|
notes: input.notes,
|
|
});
|
|
return NextResponse.json({ publication }, { status: 201 });
|
|
```
|
|
|
|
`GET` loads the version, verifies `case_id`, then returns `repository.listPublicationRecordsForResultVersion(version.id)`.
|
|
|
|
- [ ] **Step 6: Implement human-copy rerun**
|
|
|
|
In `src/app/api/cases/[caseId]/rerun/route.ts`, first support `human_copy`:
|
|
|
|
```ts
|
|
const detail = await repository.getOptimizationCaseDetail(caseId);
|
|
if (!detail?.input) {
|
|
return NextResponse.json({ error: "Case not found" }, { status: 404 });
|
|
}
|
|
if (detail.case.case_type !== "human_copy") {
|
|
return NextResponse.json(
|
|
{ error: "Article case rerun uses the article workflow" },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
```
|
|
|
|
Run `optimizeRenweiCopy` with `detail.input.payload`, collect audit summary, and create a new optimized result version. On LLM failure, create a failed result version and return the case/version metadata with the error and status code from `getErrorStatus`.
|
|
|
|
- [ ] **Step 7: Implement article rerun**
|
|
|
|
After human-copy rerun passes, extend the same route for `article`:
|
|
|
|
- Load stored article input and optional fact card.
|
|
- Create a new article job with the same input and `case_id`.
|
|
- Save or extract fact card.
|
|
- Run `runOptimizationWorkflow`.
|
|
- Save optimized article, QA, exports, and a new result version.
|
|
- Return `{ case, result_version, optimizedArticle, qaReport, exportPaths }` with status `201`.
|
|
|
|
Use the same failure behavior as the stream route.
|
|
|
|
- [ ] **Step 8: Update performance route for result-version publications**
|
|
|
|
In `src/app/api/publications/[publicationId]/performance/route.ts`, after loading the publication:
|
|
|
|
```ts
|
|
const scoringRun =
|
|
publication.result_version_id
|
|
? await repository.getLatestScoringRunForResultVersion(publication.result_version_id)
|
|
: publication.job_id && publication.revision
|
|
? await repository.getLatestScoringRun(publication.job_id, publication.revision)
|
|
: null;
|
|
```
|
|
|
|
If no scoring run exists for a human-copy publication, still save the manual snapshot and return:
|
|
|
|
```ts
|
|
return NextResponse.json({ snapshot, calibrationEvent: null }, { status: 201 });
|
|
```
|
|
|
|
This keeps effect-learning data available before human-copy calibration is generated.
|
|
|
|
- [ ] **Step 9: Run tests and commit**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/app/api/__tests__/cases.test.ts src/app/api/__tests__/jobs.test.ts src/app/api/__tests__/copy-renwei.test.ts
|
|
```
|
|
|
|
Expected: pass.
|
|
|
|
Commit:
|
|
|
|
```bash
|
|
git add src/app/api/cases src/app/api/publications src/app/api/__tests__/cases.test.ts
|
|
git commit -m "新增优化案例API"
|
|
```
|
|
|
|
## Task 7: Case List And Detail UI
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/app/cases/page.tsx`
|
|
- Create: `src/app/cases/[caseId]/page.tsx`
|
|
- Create: `src/components/cases/case-list.tsx`
|
|
- Create: `src/components/cases/case-detail.tsx`
|
|
- Create: `src/components/cases/article-case-detail.tsx`
|
|
- Create: `src/components/cases/human-copy-case-detail.tsx`
|
|
- Create: `src/components/cases/case-result-version-list.tsx`
|
|
- Create: `src/components/cases/case-publication-panel.tsx`
|
|
- Modify: `src/app/page.tsx`
|
|
- Modify: `src/app/globals.css`
|
|
- Create: `tests/e2e/cases.spec.ts`
|
|
|
|
- [ ] **Step 1: Write failing E2E tests**
|
|
|
|
Create `tests/e2e/cases.spec.ts`:
|
|
|
|
```ts
|
|
import { expect, test } from "@playwright/test";
|
|
|
|
test("案例库列表和人味文案详情可查看", async ({ page }) => {
|
|
await page.route("**/api/cases", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
cases: [
|
|
{
|
|
id: "case_copy_1",
|
|
case_type: "human_copy",
|
|
title: "人味文案优化:朋友圈",
|
|
summary: "原始文案摘要",
|
|
status: "optimized",
|
|
customer_name: "客户A",
|
|
brand_name: "品牌B",
|
|
project_tags: ["朋友圈"],
|
|
notes: "",
|
|
publish_target: "朋友圈",
|
|
source_excerpt: "原始文案摘要",
|
|
result_excerpt: "优化后文案摘要",
|
|
latest_result_version_id: "ver_copy_1",
|
|
latest_version_number: 1,
|
|
last_error_stage: null,
|
|
last_error_summary: null,
|
|
archived_at: null,
|
|
created_at: "2026-07-08T10:00:00.000Z",
|
|
updated_at: "2026-07-08T10:05:00.000Z",
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
});
|
|
await page.route("**/api/cases/case_copy_1", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
case: {
|
|
id: "case_copy_1",
|
|
case_type: "human_copy",
|
|
title: "人味文案优化:朋友圈",
|
|
summary: "原始文案摘要",
|
|
status: "optimized",
|
|
customer_name: "客户A",
|
|
brand_name: "品牌B",
|
|
project_tags: ["朋友圈"],
|
|
notes: "",
|
|
publish_target: "朋友圈",
|
|
source_excerpt: "原始文案摘要",
|
|
result_excerpt: "优化后文案摘要",
|
|
latest_result_version_id: "ver_copy_1",
|
|
latest_version_number: 1,
|
|
last_error_stage: null,
|
|
last_error_summary: null,
|
|
archived_at: null,
|
|
created_at: "2026-07-08T10:00:00.000Z",
|
|
updated_at: "2026-07-08T10:05:00.000Z",
|
|
},
|
|
input: {
|
|
case_id: "case_copy_1",
|
|
case_type: "human_copy",
|
|
article_job_id: null,
|
|
payload: {
|
|
source_text: "原始文案",
|
|
goal: "自然一点",
|
|
intensity: "light",
|
|
user_instructions: "",
|
|
publish_target: "朋友圈",
|
|
},
|
|
created_at: "2026-07-08T10:00:00.000Z",
|
|
updated_at: "2026-07-08T10:00:00.000Z",
|
|
},
|
|
versions: [
|
|
{
|
|
id: "ver_copy_1",
|
|
case_id: "case_copy_1",
|
|
case_type: "human_copy",
|
|
version: 1,
|
|
status: "optimized",
|
|
article_job_id: null,
|
|
article_revision: null,
|
|
result_summary: "优化后文案摘要",
|
|
payload: {
|
|
optimized_text: "优化后文案",
|
|
change_notes: [
|
|
{
|
|
original: "原始文案",
|
|
revised: "优化后文案",
|
|
reason: "减少书面腔。",
|
|
confidence: "confident",
|
|
revertible: false,
|
|
},
|
|
],
|
|
ai_taste_checks: [
|
|
{
|
|
rule_id: "promotion_tone",
|
|
status: "pass",
|
|
evidence: "没有新增宣传腔。",
|
|
suggestion: "",
|
|
},
|
|
],
|
|
warnings: [],
|
|
},
|
|
process_summary: [],
|
|
llm_audit_summary: [],
|
|
error_stage: null,
|
|
error_summary: null,
|
|
created_at: "2026-07-08T10:05:00.000Z",
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
});
|
|
|
|
await page.goto("/cases");
|
|
await expect(page.getByRole("heading", { name: "案例库" })).toBeVisible();
|
|
await expect(page.getByText("人味文案优化:朋友圈")).toBeVisible();
|
|
await page.getByRole("link", { name: "人味文案优化:朋友圈" }).click();
|
|
await expect(page.getByText("优化后文案")).toBeVisible();
|
|
await expect(page.getByText("AI 味检查")).toBeVisible();
|
|
await expect(page.getByText("发布表现")).toBeVisible();
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run E2E test and verify it fails**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npx playwright test tests/e2e/cases.spec.ts
|
|
```
|
|
|
|
Expected: fail because `/cases` route does not exist.
|
|
|
|
- [ ] **Step 3: Add case list route and component**
|
|
|
|
`src/app/cases/page.tsx`:
|
|
|
|
```tsx
|
|
import { CaseList } from "../../components/cases/case-list";
|
|
|
|
export default function CasesPage() {
|
|
return <CaseList />;
|
|
}
|
|
```
|
|
|
|
`src/components/cases/case-list.tsx`:
|
|
|
|
```tsx
|
|
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
|
|
import type { OptimizationCase } from "../../lib/cases/types";
|
|
|
|
interface CaseListResponse {
|
|
cases?: OptimizationCase[];
|
|
error?: string;
|
|
}
|
|
|
|
export function CaseList() {
|
|
const [cases, setCases] = useState<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`:
|
|
|
|
```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:
|
|
|
|
```ts
|
|
`/api/cases/${caseId}/versions/${versionId}/publications`
|
|
```
|
|
|
|
Then posts manual performance to:
|
|
|
|
```ts
|
|
`/api/publications/${publicationId}/performance`
|
|
```
|
|
|
|
Render an empty state when there are no publications:
|
|
|
|
```tsx
|
|
<p className="empty-panel">还没有发布记录,可以先登记发布链接和手动表现。</p>
|
|
```
|
|
|
|
- [ ] **Step 7: Add navigation and styles**
|
|
|
|
In `src/app/page.tsx`, add a link in the topbar:
|
|
|
|
```tsx
|
|
<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:
|
|
|
|
```bash
|
|
npx playwright test tests/e2e/cases.spec.ts tests/e2e/renwei-copy.spec.ts
|
|
```
|
|
|
|
Expected: pass.
|
|
|
|
Commit:
|
|
|
|
```bash
|
|
git add src/app/cases src/components/cases src/app/page.tsx src/app/globals.css tests/e2e/cases.spec.ts tests/e2e/renwei-copy.spec.ts
|
|
git commit -m "新增案例库列表和详情页"
|
|
```
|
|
|
|
## Task 8: Type-Specific Scoring And Performance Learning
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/lib/calibration/scoring.ts`
|
|
- Modify: `src/lib/calibration/types.ts`
|
|
- Modify: `src/lib/calibration/validation.ts`
|
|
- Modify: `src/lib/calibration/__tests__/scoring.test.ts`
|
|
- Modify: `src/app/api/jobs/[jobId]/calibration/score/route.ts`
|
|
- Modify: `src/app/api/publications/[publicationId]/performance/route.ts`
|
|
- Modify: `src/app/api/cases/[caseId]/rerun/route.ts`
|
|
|
|
- [ ] **Step 1: Write failing human-copy scoring test**
|
|
|
|
Add to `src/lib/calibration/__tests__/scoring.test.ts`:
|
|
|
|
```ts
|
|
it("scores human-copy result with a separate rubric", () => {
|
|
const scoringRun = scoreHumanCopyResult({
|
|
resultVersionId: "ver_1",
|
|
result: {
|
|
optimized_text: "我把这段文案顺了一下。",
|
|
change_notes: [
|
|
{
|
|
original: "我把这段文案顺顺。",
|
|
revised: "我把这段文案顺了一下。",
|
|
reason: "修正重复表达。",
|
|
confidence: "confident",
|
|
revertible: false,
|
|
},
|
|
],
|
|
ai_taste_checks: [
|
|
{
|
|
rule_id: "promotion_tone",
|
|
status: "pass",
|
|
evidence: "没有新增宣传腔。",
|
|
suggestion: "",
|
|
},
|
|
],
|
|
warnings: [],
|
|
},
|
|
});
|
|
|
|
expect(scoringRun).toMatchObject({
|
|
result_version_id: "ver_1",
|
|
case_type: "human_copy",
|
|
rubric_version_id: "rubric_human_copy_v1",
|
|
composite_score: expect.any(Number),
|
|
});
|
|
expect(scoringRun.composite_score).toBeGreaterThan(0);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run scoring test and verify it fails**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/calibration/__tests__/scoring.test.ts
|
|
```
|
|
|
|
Expected: fail because human-copy scoring does not exist.
|
|
|
|
- [ ] **Step 3: Add human-copy rubric and scoring**
|
|
|
|
In `src/lib/calibration/scoring.ts`, add:
|
|
|
|
```ts
|
|
export const HUMAN_COPY_RUBRIC_V1: RubricVersion = {
|
|
id: "rubric_human_copy_v1",
|
|
version: "v1",
|
|
name: "人味文案优化评分口径",
|
|
dimensions: [
|
|
{
|
|
id: "restraint",
|
|
label: "改动克制",
|
|
weight: 0.2,
|
|
description: "减少机械润色,不把短文案扩写成宣传稿。",
|
|
},
|
|
{
|
|
id: "meaning_fidelity",
|
|
label: "原意保真",
|
|
weight: 0.25,
|
|
description: "保留原文意图、事实和表达边界。",
|
|
},
|
|
{
|
|
id: "natural_tone",
|
|
label: "语气自然度",
|
|
weight: 0.25,
|
|
description: "读起来像真人表达,少套路句和格式痕迹。",
|
|
},
|
|
{
|
|
id: "goal_fit",
|
|
label: "目标匹配",
|
|
weight: 0.15,
|
|
description: "符合优化目标和发布场景。",
|
|
},
|
|
{
|
|
id: "ai_taste_risk",
|
|
label: "AI味风险",
|
|
weight: 0.15,
|
|
description: "宣传腔、套话、聊天痕迹和填充词风险低。",
|
|
},
|
|
],
|
|
formula: "weighted_average_0_to_10",
|
|
is_active: true,
|
|
created_at: "2026-07-08T00:00:00.000Z",
|
|
};
|
|
```
|
|
|
|
Add scoring:
|
|
|
|
```ts
|
|
export function scoreHumanCopyResult({
|
|
resultVersionId,
|
|
result,
|
|
}: {
|
|
resultVersionId: string;
|
|
result: CopyOptimizationResult;
|
|
}): ScoringRun {
|
|
const warningsPenalty = Math.min(result.warnings.length, 3) * 0.5;
|
|
const aiTasteWarnings = result.ai_taste_checks.filter(
|
|
(check) => check.status === "warn",
|
|
).length;
|
|
const scores = {
|
|
restraint: result.optimized_text.length > 280 ? 3 : 5,
|
|
meaning_fidelity: result.change_notes.some((note) => note.confidence === "uncertain")
|
|
? 3.5
|
|
: 5,
|
|
natural_tone: Math.max(2, 5 - aiTasteWarnings * 0.75),
|
|
goal_fit: 4.5,
|
|
ai_taste_risk: Math.max(1, 5 - aiTasteWarnings - warningsPenalty),
|
|
};
|
|
return {
|
|
id: `score_${nanoid(10)}`,
|
|
result_version_id: resultVersionId,
|
|
case_type: "human_copy",
|
|
job_id: null,
|
|
revision: null,
|
|
rubric_version_id: HUMAN_COPY_RUBRIC_V1.id,
|
|
dimension_scores: scores,
|
|
composite_score: weightedAverage0To10(HUMAN_COPY_RUBRIC_V1, scores),
|
|
rationale: "基于改动克制、原意保真、自然度、目标匹配和AI味风险生成评分。",
|
|
created_at: new Date().toISOString(),
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Save scoring for successful human-copy versions**
|
|
|
|
When human-copy route and rerun route create an optimized result version, also:
|
|
|
|
```ts
|
|
await repository.saveRubricVersion(HUMAN_COPY_RUBRIC_V1);
|
|
await repository.saveScoringRun(
|
|
scoreHumanCopyResult({
|
|
resultVersionId: resultVersion.id,
|
|
result,
|
|
}),
|
|
);
|
|
```
|
|
|
|
When article routes create an optimized result version, keep `score` as an explicit action through the existing calibration route, but save `result_version_id` when scoring:
|
|
|
|
```ts
|
|
const version = await repository.findResultVersionForArticleRevision(
|
|
jobId,
|
|
article.revision ?? 1,
|
|
);
|
|
const scoringRun = scoreOptimizedArticle({
|
|
jobId,
|
|
article,
|
|
qaReport,
|
|
resultVersionId: version?.id ?? null,
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 5: Update performance learning response**
|
|
|
|
For result-version publications, return the saved snapshot plus calibration when scoring exists. For human-copy publications, use the human-copy scoring run created in Step 4.
|
|
|
|
- [ ] **Step 6: Run calibration tests and commit**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/calibration/__tests__/scoring.test.ts src/app/api/__tests__/cases.test.ts src/app/api/__tests__/copy-renwei.test.ts
|
|
```
|
|
|
|
Expected: pass.
|
|
|
|
Commit:
|
|
|
|
```bash
|
|
git add src/lib/calibration src/app/api
|
|
git commit -m "新增人味文案评分和效果学习"
|
|
```
|
|
|
|
## Task 9: Final Verification And Documentation Sync
|
|
|
|
**Files:**
|
|
|
|
- Modify if needed: `docs/superpowers/specs/2026-07-08-long-term-optimization-case-storage-design.md`
|
|
- Modify if needed: `CONTEXT.md`
|
|
|
|
- [ ] **Step 1: Run focused test suites**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test -- src/lib/cases src/lib/llm src/lib/db src/app/api src/lib/calibration
|
|
```
|
|
|
|
Expected: pass.
|
|
|
|
- [ ] **Step 2: Run full unit suite**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm test
|
|
```
|
|
|
|
Expected: pass.
|
|
|
|
- [ ] **Step 3: Run E2E coverage**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npx playwright test tests/e2e/renwei-copy.spec.ts tests/e2e/cases.spec.ts tests/e2e/sample-flow.spec.ts
|
|
```
|
|
|
|
Expected: pass.
|
|
|
|
- [ ] **Step 4: Run lint and build**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run lint
|
|
npm run build
|
|
```
|
|
|
|
Expected: both pass.
|
|
|
|
- [ ] **Step 5: Run migration smoke check locally**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run d1:migrate:local
|
|
```
|
|
|
|
Expected: migration applies successfully to the local D1 database.
|
|
|
|
- [ ] **Step 6: Public repository safety scan**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
git status --short --ignored=matching
|
|
rg -n "auth\\.token|secretKey|healthsource" . --glob '!node_modules/**' --glob '!.next/**' --glob '!.open-next/**' --glob '!deploy/*.toml'
|
|
```
|
|
|
|
Expected: no real credentials or generated build artifacts are staged.
|
|
|
|
- [ ] **Step 7: Documentation consistency check**
|
|
|
|
Search for removed or invalid terms:
|
|
|
|
```bash
|
|
rg -n "仁微|敏感稿|完整 prompt|完整 response|硬删除" CONTEXT.md docs/superpowers/specs/2026-07-08-long-term-optimization-case-storage-design.md docs/adr/0001-unified-optimization-case-library.md src tests
|
|
```
|
|
|
|
Expected:
|
|
|
|
- `仁微` appears only in `_Avoid_` context or old technical route/file names such as `renwei`.
|
|
- `敏感稿` does not appear.
|
|
- `完整 prompt` and `完整 response` appear only in design/spec language that says they are not stored by default.
|
|
- `硬删除` appears only in the out-of-scope statement.
|
|
|
|
- [ ] **Step 8: Commit final sync**
|
|
|
|
If documentation or small cleanup changed:
|
|
|
|
```bash
|
|
git add CONTEXT.md docs/superpowers/specs/2026-07-08-long-term-optimization-case-storage-design.md docs/adr/0001-unified-optimization-case-library.md src tests migrations
|
|
git commit -m "完成统一案例库实施"
|
|
```
|
|
|
|
If Step 8 has no changes, do not create an empty commit.
|
|
|
|
## Execution Notes
|
|
|
|
- Prefer one commit per task. Each commit message must be Chinese.
|
|
- Use the current `origin` remote when pushing. Do not use the old GitHub remote.
|
|
- Before Cloudflare staging deployment, run `npm run lint`, `npm test`, and `npm run build`.
|
|
- Apply D1 migration before deploying an environment that writes cases.
|
|
- The old job-based publication APIs must keep working until the case detail page fully replaces the old calibration panel.
|
|
|
|
## Self-Review
|
|
|
|
- Spec coverage: this plan covers unified case storage, auto-save, failed cases, result versions, human-copy storage, case list/detail, archive/restore, re-run, publication records, manual performance snapshots, LLM audit summaries, and type-specific scoring.
|
|
- Prompt/response boundary: only hashes and metadata are persisted in `llm_audit_summary`; no route stores raw prompt/response as long-term data.
|
|
- Sensitive-draft design: no task introduces sensitive draft handling.
|
|
- Historical migration: new writes are covered first; historical article jobs remain compatible and can be backfilled when opened or re-run.
|