新增优化案例API
This commit is contained in:
@@ -0,0 +1,375 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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";
|
||||||
|
import { POST as recordPerformance } from "../publications/[publicationId]/performance/route";
|
||||||
|
|
||||||
|
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("records manual performance for a result-version publication without scoring", 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 publication = await repository.createPublicationRecord({
|
||||||
|
result_version_id: version.id,
|
||||||
|
job_id: null,
|
||||||
|
revision: null,
|
||||||
|
publish_target: "私域",
|
||||||
|
url: "https://example.com/private",
|
||||||
|
published_at: "2026-07-08T12:00:00.000Z",
|
||||||
|
status: "published",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await recordPerformance(
|
||||||
|
request({
|
||||||
|
window_label: "T+7d",
|
||||||
|
views: "1200",
|
||||||
|
feedback_summary: "客户反馈更自然",
|
||||||
|
}),
|
||||||
|
params({ publicationId: publication.id }),
|
||||||
|
);
|
||||||
|
const body = (await response.json()) as {
|
||||||
|
snapshot?: { metrics: { views: number } };
|
||||||
|
calibrationEvent: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(body.snapshot?.metrics.views).toBe(1200);
|
||||||
|
expect(body.calibrationEvent).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reruns an article case into a new result version and article job", async () => {
|
||||||
|
const repository = createSqliteRepository();
|
||||||
|
const created = await repository.createOptimizationCase({
|
||||||
|
case_type: "article",
|
||||||
|
title: "GEO 指南",
|
||||||
|
summary: "原文摘要",
|
||||||
|
publish_target: "official_site",
|
||||||
|
source_excerpt: "原文摘要",
|
||||||
|
});
|
||||||
|
const sourceJob = await repository.createArticleJob({
|
||||||
|
case_id: created.id,
|
||||||
|
source_title: "GEO 指南",
|
||||||
|
source_body: "Example Technology Co., Ltd. has 8 years of GEO experience.",
|
||||||
|
image_inputs: [{ type: "description", content: "dashboard" }],
|
||||||
|
publish_platform: "official_site",
|
||||||
|
user_instructions: "保持事实准确",
|
||||||
|
});
|
||||||
|
await repository.saveFactCard(sourceJob.id, confirmedFactCard);
|
||||||
|
await repository.saveCaseInput({
|
||||||
|
case_id: created.id,
|
||||||
|
case_type: "article",
|
||||||
|
article_job_id: sourceJob.id,
|
||||||
|
payload: {
|
||||||
|
source_title: "GEO 指南",
|
||||||
|
source_body: "Example Technology Co., Ltd. has 8 years of GEO experience.",
|
||||||
|
image_inputs: [{ type: "description", content: "dashboard" }],
|
||||||
|
publish_platform: "official_site",
|
||||||
|
user_instructions: "保持事实准确",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await repository.createOptimizationResultVersion({
|
||||||
|
case_id: created.id,
|
||||||
|
case_type: "article",
|
||||||
|
status: "optimized",
|
||||||
|
article_job_id: null,
|
||||||
|
article_revision: null,
|
||||||
|
result_summary: "第一版",
|
||||||
|
payload: null,
|
||||||
|
process_summary: [],
|
||||||
|
llm_audit_summary: [],
|
||||||
|
error_stage: null,
|
||||||
|
error_summary: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
llmMocks.generateValidatedJson
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
title: "第二版 GEO 指南",
|
||||||
|
summary: "第二版 official site GEO optimization 摘要",
|
||||||
|
body_markdown:
|
||||||
|
"## 第二版\nExample Technology Co., Ltd. has 8 years of GEO optimization experience.",
|
||||||
|
image_suggestions: [],
|
||||||
|
changed_sections: ["标题"],
|
||||||
|
requires_user_confirmation: [],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
overall_status: "pass",
|
||||||
|
checks: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await rerunCase(request({}), params({ caseId: created.id }));
|
||||||
|
const body = (await response.json()) as {
|
||||||
|
result_version: { version: number };
|
||||||
|
optimizedArticle: { title: string; job_id: string };
|
||||||
|
qaReport: { overall_status: string };
|
||||||
|
exportPaths: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(body.result_version.version).toBe(2);
|
||||||
|
expect(body.optimizedArticle.title).toBe("第二版 GEO 指南");
|
||||||
|
expect(body.qaReport.overall_status).toBe("pass");
|
||||||
|
expect(body.exportPaths.markdown).toContain(body.optimizedArticle.job_id);
|
||||||
|
await expect(repository.getArticleJob(body.optimizedArticle.job_id)).resolves.toMatchObject({
|
||||||
|
case_id: created.id,
|
||||||
|
status: "optimized",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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("第二版");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmedFactCard = {
|
||||||
|
company_full_name: "Example Technology Co., Ltd.",
|
||||||
|
company_short_names: ["Example Tech"],
|
||||||
|
brand_names: ["Example"],
|
||||||
|
product_names: ["Example GEO"],
|
||||||
|
target_industry: "GEO optimization",
|
||||||
|
target_audience: "Marketing teams",
|
||||||
|
experience_years: 8,
|
||||||
|
core_claims: ["8 years of GEO optimization experience"],
|
||||||
|
forbidden_claims: ["industry first"],
|
||||||
|
image_topics: ["dashboard"],
|
||||||
|
uncertain_items: [],
|
||||||
|
is_ready_for_optimization: true,
|
||||||
|
confirmed_by_user: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
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) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { requireApiAccess } from "../../../../../lib/api/auth";
|
||||||
|
import { getRepositoryFromRuntime } from "../../../../../lib/db/repository";
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{ caseId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request, context: RouteContext) {
|
||||||
|
const access = requireApiAccess(request);
|
||||||
|
if (!access.ok) {
|
||||||
|
return access.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { caseId } = await context.params;
|
||||||
|
const repository = getRepositoryFromRuntime();
|
||||||
|
const optimizationCase = await repository.archiveOptimizationCase(caseId);
|
||||||
|
if (!optimizationCase) {
|
||||||
|
return NextResponse.json({ error: "Case not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ case: optimizationCase });
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { requireApiAccess } from "../../../../../lib/api/auth";
|
||||||
|
import type {
|
||||||
|
ArticleCaseInputPayload,
|
||||||
|
OptimizationCaseDetail,
|
||||||
|
ProcessSummaryStep,
|
||||||
|
} from "../../../../../lib/cases/types";
|
||||||
|
import { createProcessStep, excerpt } from "../../../../../lib/cases/summaries";
|
||||||
|
import {
|
||||||
|
getRepositoryFromRuntime,
|
||||||
|
type AppRepository,
|
||||||
|
} from "../../../../../lib/db/repository";
|
||||||
|
import {
|
||||||
|
articleInputSchema,
|
||||||
|
copyOptimizationRequestSchema,
|
||||||
|
optimizationFactCardSchema,
|
||||||
|
} from "../../../../../lib/domain/validation";
|
||||||
|
import type { LlmAuditSummary } from "../../../../../lib/llm/audit";
|
||||||
|
import { LlmValidationError } from "../../../../../lib/llm/client";
|
||||||
|
import { getExportStoreFromRuntime } from "../../../../../lib/workflow/export-store";
|
||||||
|
import { extractCandidateFactCard } from "../../../../../lib/workflow/fact-extractor";
|
||||||
|
import { runOptimizationWorkflow } from "../../../../../lib/workflow/orchestrator";
|
||||||
|
import { optimizeRenweiCopy } from "../../../../../lib/workflow/renwei-copy-optimizer";
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{ caseId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(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?.input) {
|
||||||
|
return NextResponse.json({ error: "Case not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
const caseDetail = { ...detail, input: detail.input } satisfies CaseDetailWithInput;
|
||||||
|
|
||||||
|
if (detail.case.case_type === "article") {
|
||||||
|
return rerunArticleCase(repository, caseDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.case.case_type === "human_copy") {
|
||||||
|
return rerunHumanCopyCase(repository, caseDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: "Unsupported case type" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
type CaseDetailWithInput = OptimizationCaseDetail & {
|
||||||
|
input: NonNullable<OptimizationCaseDetail["input"]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function rerunHumanCopyCase(
|
||||||
|
repository: AppRepository,
|
||||||
|
detail: CaseDetailWithInput,
|
||||||
|
) {
|
||||||
|
const payload = copyOptimizationRequestSchema.parse(detail.input.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: detail.case.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: detail.case.id, case_type: "human_copy" },
|
||||||
|
result_version: { id: resultVersion.id, version: resultVersion.version },
|
||||||
|
result,
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = errorMessage(error);
|
||||||
|
const resultVersion = await repository.createOptimizationResultVersion({
|
||||||
|
case_id: detail.case.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: detail.case.id, case_type: "human_copy" },
|
||||||
|
result_version: { id: resultVersion.id, version: resultVersion.version },
|
||||||
|
},
|
||||||
|
{ status: getErrorStatus(error) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
return error instanceof z.ZodError
|
||||||
|
? "请输入需要优化的文案"
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "文案优化失败";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorStatus(error: unknown) {
|
||||||
|
if (error instanceof LlmValidationError) return 502;
|
||||||
|
if (error instanceof Error && /^LLM\b|provider/i.test(error.message)) return 502;
|
||||||
|
return 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rerunArticleCase(
|
||||||
|
repository: AppRepository,
|
||||||
|
detail: CaseDetailWithInput,
|
||||||
|
) {
|
||||||
|
const payload = detail.input.payload as ArticleCaseInputPayload;
|
||||||
|
const articleInput = articleInputSchema.parse({
|
||||||
|
title: payload.source_title,
|
||||||
|
body: payload.source_body,
|
||||||
|
images: payload.image_inputs,
|
||||||
|
platform: payload.publish_platform,
|
||||||
|
user_instructions: payload.user_instructions,
|
||||||
|
});
|
||||||
|
const llmAuditSummary: LlmAuditSummary[] = [];
|
||||||
|
const processSummary: ProcessSummaryStep[] = [];
|
||||||
|
const requestStartedAt = Date.now();
|
||||||
|
let jobId: string | null = null;
|
||||||
|
let stage = "job";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const job = await repository.createArticleJob({
|
||||||
|
case_id: detail.case.id,
|
||||||
|
source_title: articleInput.title,
|
||||||
|
source_body: articleInput.body,
|
||||||
|
image_inputs: articleInput.images,
|
||||||
|
publish_platform: articleInput.platform,
|
||||||
|
user_instructions: articleInput.user_instructions,
|
||||||
|
});
|
||||||
|
jobId = job.id;
|
||||||
|
|
||||||
|
stage = "fact_card";
|
||||||
|
const factCardStartedAt = Date.now();
|
||||||
|
const existingFactCard = detail.input.article_job_id
|
||||||
|
? await repository.getFactCard(detail.input.article_job_id)
|
||||||
|
: null;
|
||||||
|
const factCard = optimizationFactCardSchema.parse(
|
||||||
|
payload.fact_card ??
|
||||||
|
existingFactCard ??
|
||||||
|
(await extractCandidateFactCard(articleInput, {
|
||||||
|
onAuditSummary: (summary) => llmAuditSummary.push(summary),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
processSummary.push(
|
||||||
|
createProcessStep({
|
||||||
|
stage: "fact_card",
|
||||||
|
startedAt: factCardStartedAt,
|
||||||
|
endedAt: Date.now(),
|
||||||
|
status: "success",
|
||||||
|
producedResultVersion: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const savedFactCard = await repository.saveFactCard(job.id, factCard);
|
||||||
|
|
||||||
|
stage = "optimize";
|
||||||
|
const result = await runOptimizationWorkflow({
|
||||||
|
input: articleInput,
|
||||||
|
factCard: savedFactCard,
|
||||||
|
onAuditSummary: (summary) => llmAuditSummary.push(summary),
|
||||||
|
});
|
||||||
|
const optimizedArticle = await repository.saveOptimizedArticle(
|
||||||
|
job.id,
|
||||||
|
result.article,
|
||||||
|
);
|
||||||
|
const qaReport = await repository.saveQaReport(
|
||||||
|
job.id,
|
||||||
|
optimizedArticle.revision ?? 1,
|
||||||
|
result.qaReport,
|
||||||
|
);
|
||||||
|
const exportStore = getExportStoreFromRuntime();
|
||||||
|
const exportPaths = await exportStore.writeJobExports({
|
||||||
|
jobId: job.id,
|
||||||
|
article: optimizedArticle,
|
||||||
|
qaReport,
|
||||||
|
});
|
||||||
|
await repository.updateArticleJob(job.id, {
|
||||||
|
status: "optimized",
|
||||||
|
export_paths: exportPaths,
|
||||||
|
});
|
||||||
|
const resultVersion = await repository.createOptimizationResultVersion({
|
||||||
|
case_id: detail.case.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: processSummary,
|
||||||
|
llm_audit_summary: llmAuditSummary,
|
||||||
|
error_stage: null,
|
||||||
|
error_summary: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
case: { id: detail.case.id, case_type: "article" },
|
||||||
|
result_version: { id: resultVersion.id, version: resultVersion.version },
|
||||||
|
optimizedArticle,
|
||||||
|
qaReport,
|
||||||
|
exportPaths,
|
||||||
|
rewriteRounds: result.rewrite_rounds,
|
||||||
|
stoppedAfterMaxRewrites: result.stopped_after_max_rewrites,
|
||||||
|
timing: result.timing,
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "文章优化失败";
|
||||||
|
const resultVersion = await repository.createOptimizationResultVersion({
|
||||||
|
case_id: detail.case.id,
|
||||||
|
case_type: "article",
|
||||||
|
status: "failed",
|
||||||
|
article_job_id: jobId,
|
||||||
|
article_revision: null,
|
||||||
|
result_summary: "",
|
||||||
|
payload: null,
|
||||||
|
process_summary: [
|
||||||
|
...processSummary,
|
||||||
|
createProcessStep({
|
||||||
|
stage,
|
||||||
|
startedAt: requestStartedAt,
|
||||||
|
endedAt: Date.now(),
|
||||||
|
status: "failed",
|
||||||
|
errorSummary: message,
|
||||||
|
producedResultVersion: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
llm_audit_summary: llmAuditSummary,
|
||||||
|
error_stage: stage,
|
||||||
|
error_summary: message,
|
||||||
|
});
|
||||||
|
if (jobId) {
|
||||||
|
await repository.updateArticleJob(jobId, { status: "failed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: message,
|
||||||
|
case: { id: detail.case.id, case_type: "article" },
|
||||||
|
result_version: { id: resultVersion.id, version: resultVersion.version },
|
||||||
|
},
|
||||||
|
{ status: getErrorStatus(error) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { requireApiAccess } from "../../../../../lib/api/auth";
|
||||||
|
import { getRepositoryFromRuntime } from "../../../../../lib/db/repository";
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{ caseId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request, context: RouteContext) {
|
||||||
|
const access = requireApiAccess(request);
|
||||||
|
if (!access.ok) {
|
||||||
|
return access.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { caseId } = await context.params;
|
||||||
|
const repository = getRepositoryFromRuntime();
|
||||||
|
const optimizationCase = await repository.restoreOptimizationCase(caseId);
|
||||||
|
if (!optimizationCase) {
|
||||||
|
return NextResponse.json({ error: "Case not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ case: optimizationCase });
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { requireApiAccess } from "../../../../../../../lib/api/auth";
|
||||||
|
import { resultVersionPublicationInputSchema } from "../../../../../../../lib/cases/validation";
|
||||||
|
import { getRepositoryFromRuntime } from "../../../../../../../lib/db/repository";
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{ caseId: string; versionId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request, context: RouteContext) {
|
||||||
|
const access = requireApiAccess(request);
|
||||||
|
if (!access.ok) {
|
||||||
|
return access.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { caseId, versionId } = await context.params;
|
||||||
|
const repository = getRepositoryFromRuntime();
|
||||||
|
const version = await repository.getOptimizationResultVersion(versionId);
|
||||||
|
if (!version || version.case_id !== caseId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Result version not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
publications: await repository.listPublicationRecordsForResultVersion(
|
||||||
|
version.id,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request, context: RouteContext) {
|
||||||
|
const access = requireApiAccess(request);
|
||||||
|
if (!access.ok) {
|
||||||
|
return access.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { caseId, versionId } = await context.params;
|
||||||
|
const repository = getRepositoryFromRuntime();
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -22,12 +22,22 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
return NextResponse.json({ error: "Publication not found" }, { status: 404 });
|
return NextResponse.json({ error: "Publication not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const scoringRun = await repository.getLatestScoringRun(
|
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.job_id,
|
||||||
publication.revision,
|
publication.revision,
|
||||||
);
|
)
|
||||||
const qaReport = await repository.getLatestQaReport(publication.job_id);
|
: null;
|
||||||
if (!scoringRun || !qaReport) {
|
const qaReport = publication.job_id
|
||||||
|
? await repository.getLatestQaReport(publication.job_id)
|
||||||
|
: null;
|
||||||
|
const manualInput = await request.json();
|
||||||
|
|
||||||
|
if (!publication.result_version_id && (!scoringRun || !qaReport)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Score the optimized revision before recording performance" },
|
{ error: "Score the optimized revision before recording performance" },
|
||||||
{ status: 409 },
|
{ status: 409 },
|
||||||
@@ -40,9 +50,15 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
await adapter.fetch({
|
await adapter.fetch({
|
||||||
publication,
|
publication,
|
||||||
window_label: "manual",
|
window_label: "manual",
|
||||||
manualInput: await request.json(),
|
manualInput,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
if (!scoringRun || !qaReport) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ snapshot, calibrationEvent: null },
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
}
|
||||||
const calibrationEvent = await repository.saveCalibrationEvent(
|
const calibrationEvent = await repository.saveCalibrationEvent(
|
||||||
createCalibrationEvent({ scoringRun, qaReport, snapshot }),
|
createCalibrationEvent({ scoringRun, qaReport, snapshot }),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user