接入人味文案案例自动保存

This commit is contained in:
czj
2026-07-08 13:33:45 +08:00
parent 79913ef7f7
commit e867f4f07e
6 changed files with 198 additions and 18 deletions
+31
View File
@@ -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"),
+92 -10
View File
@@ -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) {
+20 -1
View File
@@ -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<CopyOptimizationIntensity>("light");
const [userInstructions, setUserInstructions] = useState("");
const [publishTarget, setPublishTarget] = useState("朋友圈");
const [result, setResult] = useState<CopyOptimizationResult | null>(null);
const [caseId, setCaseId] = useState<string | null>(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)}
/>
</label>
<label>
<span></span>
<input
value={publishTarget}
onChange={(event) => setPublishTarget(event.target.value)}
/>
</label>
{message ? <p className="status-text">{message}</p> : null}
</form>
@@ -129,6 +143,11 @@ export function RenweiCopyOptimizerPanel({
</div>
{result ? (
<>
{caseId ? (
<a className="text-link" href={`/cases/${caseId}`}>
</a>
) : null}
<pre className="copy-result">{result.optimized_text}</pre>
<section className="stack">
<h3></h3>
+43 -6
View File
@@ -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}`);
}
+3 -1
View File
@@ -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<CopyOptimizationResult> {
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,
});
}
+9
View File
@@ -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();
});