接入人味文案案例自动保存
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user