diff --git a/src/app/api/__tests__/copy-renwei.test.ts b/src/app/api/__tests__/copy-renwei.test.ts index b846ee5..c027f5b 100644 --- a/src/app/api/__tests__/copy-renwei.test.ts +++ b/src/app/api/__tests__/copy-renwei.test.ts @@ -87,13 +87,18 @@ describe("renwei copy optimization API route", () => { 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("我把这段文案顺了一下。"); expect(body.result.change_notes).toHaveLength(1); expect(llmMocks.generateValidatedJson).toHaveBeenCalledWith( @@ -103,6 +108,32 @@ describe("renwei copy optimization API route", () => { ); }); + 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); + }); + it("surfaces LLM failures as a 502", async () => { llmMocks.generateValidatedJson.mockRejectedValueOnce( new Error("LLM response failed schema validation: optimized_text"), diff --git a/src/app/api/copy/renwei-optimize/route.ts b/src/app/api/copy/renwei-optimize/route.ts index 2e33192..649409a 100644 --- a/src/app/api/copy/renwei-optimize/route.ts +++ b/src/app/api/copy/renwei-optimize/route.ts @@ -2,7 +2,10 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { requireApiAccess } from "../../../../lib/api/auth"; +import { buildHumanCopyCaseSummary, createProcessStep, excerpt } from "../../../../lib/cases/summaries"; +import { getRepositoryFromRuntime } from "../../../../lib/db/repository"; import { copyOptimizationRequestSchema } from "../../../../lib/domain/validation"; +import type { LlmAuditSummary } from "../../../../lib/llm/audit"; import { LlmValidationError } from "../../../../lib/llm/client"; import { optimizeRenweiCopy } from "../../../../lib/workflow/renwei-copy-optimizer"; @@ -14,22 +17,101 @@ export async function POST(request: Request) { try { const payload = copyOptimizationRequestSchema.parse(await request.json()); - const result = await optimizeRenweiCopy(payload); - return NextResponse.json({ result }); + 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 startedAt = Date.now(); + const llmAuditSummary: LlmAuditSummary[] = []; + try { + 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, + }); + } catch (error) { + const message = errorMessage(error); + 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) }, + ); + } } catch (error) { return jsonError(error, getErrorStatus(error)); } } -function jsonError(error: unknown, status: number) { - const message = - error instanceof z.ZodError - ? "请输入需要优化的文案" - : error instanceof Error - ? error.message - : "文案优化失败"; +function errorMessage(error: unknown) { + return error instanceof z.ZodError + ? "请输入需要优化的文案" + : error instanceof Error + ? error.message + : "文案优化失败"; +} - return NextResponse.json({ error: message }, { status }); +function jsonError(error: unknown, status: number) { + return NextResponse.json({ error: errorMessage(error) }, { status }); } function getErrorStatus(error: unknown) { diff --git a/src/components/renwei-copy-optimizer-panel.tsx b/src/components/renwei-copy-optimizer-panel.tsx index 9e3a66a..d427c84 100644 --- a/src/components/renwei-copy-optimizer-panel.tsx +++ b/src/components/renwei-copy-optimizer-panel.tsx @@ -12,6 +12,8 @@ interface RenweiCopyOptimizerPanelProps { } interface CopyOptimizeResponse { + case?: { id: string; case_type: "human_copy" }; + result_version?: { id: string; version: number }; result?: CopyOptimizationResult; error?: string; } @@ -33,7 +35,9 @@ export function RenweiCopyOptimizerPanel({ const [intensity, setIntensity] = useState("light"); const [userInstructions, setUserInstructions] = useState(""); + const [publishTarget, setPublishTarget] = useState("朋友圈"); const [result, setResult] = useState(null); + const [caseId, setCaseId] = useState(null); const [message, setMessage] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); @@ -42,6 +46,7 @@ export function RenweiCopyOptimizerPanel({ setIsSubmitting(true); setMessage(""); setResult(null); + setCaseId(null); try { const response = await fetch("/api/copy/renwei-optimize", { @@ -52,6 +57,7 @@ export function RenweiCopyOptimizerPanel({ goal, intensity, user_instructions: userInstructions, + publish_target: publishTarget, }), }); const body = (await response.json()) as CopyOptimizeResponse; @@ -59,7 +65,8 @@ export function RenweiCopyOptimizerPanel({ throw new Error(body.error ?? "文案优化失败"); } setResult(body.result); - setMessage("文案优化完成。"); + setCaseId(body.case?.id ?? null); + setMessage("文案优化完成,已保存到案例库。"); } catch (error) { setMessage(error instanceof Error ? error.message : "文案优化失败"); } finally { @@ -117,6 +124,13 @@ export function RenweiCopyOptimizerPanel({ onChange={(event) => setUserInstructions(event.target.value)} /> + {message ?

{message}

: null} @@ -129,6 +143,11 @@ export function RenweiCopyOptimizerPanel({ {result ? ( <> + {caseId ? ( + + 查看案例详情 + + ) : null}
{result.optimized_text}

改动说明

diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 47b4d9f..107f431 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -187,9 +187,6 @@ export function initializeSchema(db: Database.Database) { create index if not exists idx_scoring_runs_job_revision on scoring_runs(job_id, revision); - create index if not exists idx_scoring_runs_result_version - on scoring_runs(result_version_id); - create index if not exists idx_optimization_cases_updated_at on optimization_cases(updated_at); @@ -205,10 +202,50 @@ export function initializeSchema(db: Database.Database) { create index if not exists idx_publication_records_job_revision on publication_records(job_id, revision); - create index if not exists idx_publication_records_result_version - on publication_records(result_version_id); - create index if not exists idx_performance_snapshots_publication on performance_snapshots(publication_id); `); + + ensureColumn(db, "article_jobs", "case_id", "text"); + ensureColumn(db, "scoring_runs", "result_version_id", "text"); + ensureColumn(db, "scoring_runs", "case_type", "text not null default 'article'"); + ensureColumn(db, "publication_records", "result_version_id", "text"); + ensureColumn( + db, + "publication_records", + "publish_target", + "text not null default ''", + ); + + const publicationColumns = getTableColumns(db, "publication_records"); + if (publicationColumns.includes("platform")) { + db.exec( + "update publication_records set publish_target = platform where publish_target = ''", + ); + } + + db.exec(` + create index if not exists idx_scoring_runs_result_version + on scoring_runs(result_version_id); + + create index if not exists idx_publication_records_result_version + on publication_records(result_version_id); + `); +} + +function getTableColumns(db: Database.Database, tableName: string) { + return db + .prepare(`pragma table_info(${tableName})`) + .all() + .map((row) => (row as { name: string }).name); +} + +function ensureColumn( + db: Database.Database, + tableName: string, + columnName: string, + definition: string, +) { + if (getTableColumns(db, tableName).includes(columnName)) return; + db.exec(`alter table ${tableName} add column ${columnName} ${definition}`); } diff --git a/src/lib/workflow/renwei-copy-optimizer.ts b/src/lib/workflow/renwei-copy-optimizer.ts index a5d6be0..91b50f8 100644 --- a/src/lib/workflow/renwei-copy-optimizer.ts +++ b/src/lib/workflow/renwei-copy-optimizer.ts @@ -3,7 +3,7 @@ import type { CopyOptimizationResult, } from "../domain/types"; import { copyOptimizationResultSchema } from "../domain/validation"; -import { generateValidatedJson } from "../llm/client"; +import { generateValidatedJson, type GenerateInput } from "../llm/client"; import { RENWEI_COPY_OPTIMIZER_SYSTEM_PROMPT, buildRenweiCopyOptimizationPrompt, @@ -11,6 +11,7 @@ import { export async function optimizeRenweiCopy( input: CopyOptimizationRequest, + options: { onAuditSummary?: GenerateInput["onAuditSummary"] } = {}, ): Promise { return generateValidatedJson({ schema: copyOptimizationResultSchema, @@ -18,5 +19,6 @@ export async function optimizeRenweiCopy( prompt: buildRenweiCopyOptimizationPrompt(input), temperature: 0.2, task: "renwei_copy_optimizer", + onAuditSummary: options.onAuditSummary, }); } diff --git a/tests/e2e/renwei-copy.spec.ts b/tests/e2e/renwei-copy.spec.ts index 9e8fee5..2792d84 100644 --- a/tests/e2e/renwei-copy.spec.ts +++ b/tests/e2e/renwei-copy.spec.ts @@ -2,10 +2,14 @@ import { expect, test } from "@playwright/test"; test("普通文案优化标签页可以生成文案结果", async ({ page }) => { 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: [ @@ -42,12 +46,17 @@ test("普通文案优化标签页可以生成文案结果", async ({ page }) => await page.getByLabel("访问密钥").fill("local-dev-key"); await page.getByLabel("原始文案").fill("我观察到大家越来越难进入心流"); + await page.getByLabel("发布目标").fill("朋友圈"); await page.getByRole("button", { name: "优化文案" }).click(); await expect(page.getByText("优化后文案")).toBeVisible(); await expect(page.locator(".copy-result")).toContainText( "我观察到大家越来越难进入心流了。", ); + await expect(page.getByRole("link", { name: "查看案例详情" })).toHaveAttribute( + "href", + "/cases/case_copy_1", + ); await expect(page.getByText("改动说明")).toBeVisible(); await expect(page.getByText("AI 味检查")).toBeVisible(); });