新增优化案例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) };
|
||||
}
|
||||
Reference in New Issue
Block a user