接入人味文案案例自动保存
This commit is contained in:
@@ -87,13 +87,18 @@ describe("renwei copy optimization API route", () => {
|
|||||||
goal: "",
|
goal: "",
|
||||||
intensity: "light",
|
intensity: "light",
|
||||||
user_instructions: "保留口语。",
|
user_instructions: "保留口语。",
|
||||||
|
publish_target: "朋友圈",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const body = (await response.json()) as {
|
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[] };
|
result: { optimized_text: string; change_notes: unknown[] };
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
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.optimized_text).toBe("我把这段文案顺了一下。");
|
||||||
expect(body.result.change_notes).toHaveLength(1);
|
expect(body.result.change_notes).toHaveLength(1);
|
||||||
expect(llmMocks.generateValidatedJson).toHaveBeenCalledWith(
|
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 () => {
|
it("surfaces LLM failures as a 502", async () => {
|
||||||
llmMocks.generateValidatedJson.mockRejectedValueOnce(
|
llmMocks.generateValidatedJson.mockRejectedValueOnce(
|
||||||
new Error("LLM response failed schema validation: optimized_text"),
|
new Error("LLM response failed schema validation: optimized_text"),
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { requireApiAccess } from "../../../../lib/api/auth";
|
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 { copyOptimizationRequestSchema } from "../../../../lib/domain/validation";
|
||||||
|
import type { LlmAuditSummary } from "../../../../lib/llm/audit";
|
||||||
import { LlmValidationError } from "../../../../lib/llm/client";
|
import { LlmValidationError } from "../../../../lib/llm/client";
|
||||||
import { optimizeRenweiCopy } from "../../../../lib/workflow/renwei-copy-optimizer";
|
import { optimizeRenweiCopy } from "../../../../lib/workflow/renwei-copy-optimizer";
|
||||||
|
|
||||||
@@ -14,22 +17,101 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = copyOptimizationRequestSchema.parse(await request.json());
|
const payload = copyOptimizationRequestSchema.parse(await request.json());
|
||||||
const result = await optimizeRenweiCopy(payload);
|
const repository = getRepositoryFromRuntime();
|
||||||
return NextResponse.json({ result });
|
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) {
|
} catch (error) {
|
||||||
return jsonError(error, getErrorStatus(error));
|
return jsonError(error, getErrorStatus(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function jsonError(error: unknown, status: number) {
|
function errorMessage(error: unknown) {
|
||||||
const message =
|
return error instanceof z.ZodError
|
||||||
error instanceof z.ZodError
|
|
||||||
? "请输入需要优化的文案"
|
? "请输入需要优化的文案"
|
||||||
: error instanceof Error
|
: error instanceof Error
|
||||||
? error.message
|
? 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) {
|
function getErrorStatus(error: unknown) {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ interface RenweiCopyOptimizerPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CopyOptimizeResponse {
|
interface CopyOptimizeResponse {
|
||||||
|
case?: { id: string; case_type: "human_copy" };
|
||||||
|
result_version?: { id: string; version: number };
|
||||||
result?: CopyOptimizationResult;
|
result?: CopyOptimizationResult;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
@@ -33,7 +35,9 @@ export function RenweiCopyOptimizerPanel({
|
|||||||
const [intensity, setIntensity] =
|
const [intensity, setIntensity] =
|
||||||
useState<CopyOptimizationIntensity>("light");
|
useState<CopyOptimizationIntensity>("light");
|
||||||
const [userInstructions, setUserInstructions] = useState("");
|
const [userInstructions, setUserInstructions] = useState("");
|
||||||
|
const [publishTarget, setPublishTarget] = useState("朋友圈");
|
||||||
const [result, setResult] = useState<CopyOptimizationResult | null>(null);
|
const [result, setResult] = useState<CopyOptimizationResult | null>(null);
|
||||||
|
const [caseId, setCaseId] = useState<string | null>(null);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
@@ -42,6 +46,7 @@ export function RenweiCopyOptimizerPanel({
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setMessage("");
|
setMessage("");
|
||||||
setResult(null);
|
setResult(null);
|
||||||
|
setCaseId(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/copy/renwei-optimize", {
|
const response = await fetch("/api/copy/renwei-optimize", {
|
||||||
@@ -52,6 +57,7 @@ export function RenweiCopyOptimizerPanel({
|
|||||||
goal,
|
goal,
|
||||||
intensity,
|
intensity,
|
||||||
user_instructions: userInstructions,
|
user_instructions: userInstructions,
|
||||||
|
publish_target: publishTarget,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const body = (await response.json()) as CopyOptimizeResponse;
|
const body = (await response.json()) as CopyOptimizeResponse;
|
||||||
@@ -59,7 +65,8 @@ export function RenweiCopyOptimizerPanel({
|
|||||||
throw new Error(body.error ?? "文案优化失败");
|
throw new Error(body.error ?? "文案优化失败");
|
||||||
}
|
}
|
||||||
setResult(body.result);
|
setResult(body.result);
|
||||||
setMessage("文案优化完成。");
|
setCaseId(body.case?.id ?? null);
|
||||||
|
setMessage("文案优化完成,已保存到案例库。");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setMessage(error instanceof Error ? error.message : "文案优化失败");
|
setMessage(error instanceof Error ? error.message : "文案优化失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -117,6 +124,13 @@ export function RenweiCopyOptimizerPanel({
|
|||||||
onChange={(event) => setUserInstructions(event.target.value)}
|
onChange={(event) => setUserInstructions(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>发布目标</span>
|
||||||
|
<input
|
||||||
|
value={publishTarget}
|
||||||
|
onChange={(event) => setPublishTarget(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
{message ? <p className="status-text">{message}</p> : null}
|
{message ? <p className="status-text">{message}</p> : null}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -129,6 +143,11 @@ export function RenweiCopyOptimizerPanel({
|
|||||||
</div>
|
</div>
|
||||||
{result ? (
|
{result ? (
|
||||||
<>
|
<>
|
||||||
|
{caseId ? (
|
||||||
|
<a className="text-link" href={`/cases/${caseId}`}>
|
||||||
|
查看案例详情
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
<pre className="copy-result">{result.optimized_text}</pre>
|
<pre className="copy-result">{result.optimized_text}</pre>
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
<h3>改动说明</h3>
|
<h3>改动说明</h3>
|
||||||
|
|||||||
+43
-6
@@ -187,9 +187,6 @@ export function initializeSchema(db: Database.Database) {
|
|||||||
create index if not exists idx_scoring_runs_job_revision
|
create index if not exists idx_scoring_runs_job_revision
|
||||||
on scoring_runs(job_id, 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
|
create index if not exists idx_optimization_cases_updated_at
|
||||||
on 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
|
create index if not exists idx_publication_records_job_revision
|
||||||
on publication_records(job_id, 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
|
create index if not exists idx_performance_snapshots_publication
|
||||||
on performance_snapshots(publication_id);
|
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,
|
CopyOptimizationResult,
|
||||||
} from "../domain/types";
|
} from "../domain/types";
|
||||||
import { copyOptimizationResultSchema } from "../domain/validation";
|
import { copyOptimizationResultSchema } from "../domain/validation";
|
||||||
import { generateValidatedJson } from "../llm/client";
|
import { generateValidatedJson, type GenerateInput } from "../llm/client";
|
||||||
import {
|
import {
|
||||||
RENWEI_COPY_OPTIMIZER_SYSTEM_PROMPT,
|
RENWEI_COPY_OPTIMIZER_SYSTEM_PROMPT,
|
||||||
buildRenweiCopyOptimizationPrompt,
|
buildRenweiCopyOptimizationPrompt,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
|
|
||||||
export async function optimizeRenweiCopy(
|
export async function optimizeRenweiCopy(
|
||||||
input: CopyOptimizationRequest,
|
input: CopyOptimizationRequest,
|
||||||
|
options: { onAuditSummary?: GenerateInput["onAuditSummary"] } = {},
|
||||||
): Promise<CopyOptimizationResult> {
|
): Promise<CopyOptimizationResult> {
|
||||||
return generateValidatedJson({
|
return generateValidatedJson({
|
||||||
schema: copyOptimizationResultSchema,
|
schema: copyOptimizationResultSchema,
|
||||||
@@ -18,5 +19,6 @@ export async function optimizeRenweiCopy(
|
|||||||
prompt: buildRenweiCopyOptimizationPrompt(input),
|
prompt: buildRenweiCopyOptimizationPrompt(input),
|
||||||
temperature: 0.2,
|
temperature: 0.2,
|
||||||
task: "renwei_copy_optimizer",
|
task: "renwei_copy_optimizer",
|
||||||
|
onAuditSummary: options.onAuditSummary,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import { expect, test } from "@playwright/test";
|
|||||||
|
|
||||||
test("普通文案优化标签页可以生成文案结果", async ({ page }) => {
|
test("普通文案优化标签页可以生成文案结果", async ({ page }) => {
|
||||||
await page.route("**/api/copy/renwei-optimize", async (route) => {
|
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({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
case: { id: "case_copy_1", case_type: "human_copy" },
|
||||||
|
result_version: { id: "ver_copy_1", version: 1 },
|
||||||
result: {
|
result: {
|
||||||
optimized_text: "我观察到大家越来越难进入心流了。",
|
optimized_text: "我观察到大家越来越难进入心流了。",
|
||||||
change_notes: [
|
change_notes: [
|
||||||
@@ -42,12 +46,17 @@ test("普通文案优化标签页可以生成文案结果", async ({ page }) =>
|
|||||||
|
|
||||||
await page.getByLabel("访问密钥").fill("local-dev-key");
|
await page.getByLabel("访问密钥").fill("local-dev-key");
|
||||||
await page.getByLabel("原始文案").fill("我观察到大家越来越难进入心流");
|
await page.getByLabel("原始文案").fill("我观察到大家越来越难进入心流");
|
||||||
|
await page.getByLabel("发布目标").fill("朋友圈");
|
||||||
await page.getByRole("button", { name: "优化文案" }).click();
|
await page.getByRole("button", { name: "优化文案" }).click();
|
||||||
|
|
||||||
await expect(page.getByText("优化后文案")).toBeVisible();
|
await expect(page.getByText("优化后文案")).toBeVisible();
|
||||||
await expect(page.locator(".copy-result")).toContainText(
|
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("改动说明")).toBeVisible();
|
||||||
await expect(page.getByText("AI 味检查")).toBeVisible();
|
await expect(page.getByText("AI 味检查")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user