新增一键流式优化接口
This commit is contained in:
@@ -27,6 +27,7 @@ import {
|
|||||||
POST as createPublication,
|
POST as createPublication,
|
||||||
} from "../jobs/[jobId]/publications/route";
|
} from "../jobs/[jobId]/publications/route";
|
||||||
import { GET as getJobProgress } from "../jobs/[jobId]/progress/route";
|
import { GET as getJobProgress } from "../jobs/[jobId]/progress/route";
|
||||||
|
import { POST as optimizeStream } from "../jobs/optimize-stream/route";
|
||||||
import { POST as createJob } from "../jobs/route";
|
import { POST as createJob } from "../jobs/route";
|
||||||
import { POST as recordPerformance } from "../publications/[publicationId]/performance/route";
|
import { POST as recordPerformance } from "../publications/[publicationId]/performance/route";
|
||||||
|
|
||||||
@@ -84,6 +85,19 @@ interface OptimizeJobResponse {
|
|||||||
timing: TimingResponse;
|
timing: TimingResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StreamEventResponse {
|
||||||
|
type: string;
|
||||||
|
job_id?: string;
|
||||||
|
job?: { id: string };
|
||||||
|
fact_card?: { company_full_name: string; confirmed_by_user?: boolean };
|
||||||
|
article?: { title: string; body_markdown?: string };
|
||||||
|
optimized_article?: { title: string };
|
||||||
|
qa_report?: { overall_status?: string };
|
||||||
|
export_paths?: Record<string, string>;
|
||||||
|
stage?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
describe("job API routes", () => {
|
describe("job API routes", () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
const originalDataDir = process.env.APP_DATA_DIR;
|
const originalDataDir = process.env.APP_DATA_DIR;
|
||||||
@@ -187,6 +201,130 @@ describe("job API routes", () => {
|
|||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("streams a one-click optimization from body-only input", async () => {
|
||||||
|
llmMocks.generateValidatedJson
|
||||||
|
.mockResolvedValueOnce(validCandidateFactCard)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
title: "流式优化标题",
|
||||||
|
summary: "流式优化摘要。",
|
||||||
|
body_markdown:
|
||||||
|
"## 服务能力\nExample Technology Co., Ltd. 提供 GEO optimization 服务。",
|
||||||
|
image_suggestions: [],
|
||||||
|
changed_sections: ["title", "body"],
|
||||||
|
requires_user_confirmation: [],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ checks: [] });
|
||||||
|
|
||||||
|
const response = await optimizeStream(
|
||||||
|
request({
|
||||||
|
body: "Example Technology Co., Ltd. has 8 years of GEO optimization experience.",
|
||||||
|
image_lines: "",
|
||||||
|
platform: "official_site",
|
||||||
|
user_instructions: "",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const events = await streamEvents(response);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(events.map((event) => event.type)).toEqual([
|
||||||
|
"job_created",
|
||||||
|
"fact_card_ready",
|
||||||
|
"draft_started",
|
||||||
|
"draft_ready",
|
||||||
|
"qa_started",
|
||||||
|
"qa_ready",
|
||||||
|
"final_ready",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
events.find((event) => event.type === "fact_card_ready")?.fact_card,
|
||||||
|
).toEqual(expect.objectContaining({ confirmed_by_user: false }));
|
||||||
|
expect(
|
||||||
|
events.find((event) => event.type === "final_ready")?.optimized_article
|
||||||
|
?.title,
|
||||||
|
).toBe("流式优化标题");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses an edited fact card without extracting a new one", async () => {
|
||||||
|
llmMocks.generateValidatedJson
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
title: "使用编辑事实卡的标题",
|
||||||
|
summary: "使用编辑事实卡的摘要。",
|
||||||
|
body_markdown: "## 服务能力\n示例科技提供GEO内容优化服务。",
|
||||||
|
image_suggestions: [],
|
||||||
|
changed_sections: ["title"],
|
||||||
|
requires_user_confirmation: [],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ checks: [] });
|
||||||
|
|
||||||
|
const response = await optimizeStream(
|
||||||
|
request({
|
||||||
|
body: "示例科技提供GEO内容优化服务。",
|
||||||
|
platform: "official_site",
|
||||||
|
fact_card: {
|
||||||
|
...validCandidateFactCard,
|
||||||
|
company_full_name: "",
|
||||||
|
uncertain_items: ["公司全称需要确认"],
|
||||||
|
confirmed_by_user: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const events = await streamEvents(response);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(events.map((event) => event.type)).toContain("fact_card_ready");
|
||||||
|
expect(
|
||||||
|
events.find((event) => event.type === "fact_card_ready")?.fact_card
|
||||||
|
?.company_full_name,
|
||||||
|
).toBe("");
|
||||||
|
expect(llmMocks.generateValidatedJson).toHaveBeenCalledTimes(2);
|
||||||
|
expect(llmMocks.generateValidatedJson).not.toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ task: "fact_extractor" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a Chinese validation error for empty stream input bodies", async () => {
|
||||||
|
const response = await optimizeStream(
|
||||||
|
request({
|
||||||
|
title: "",
|
||||||
|
body: " ",
|
||||||
|
platform: "official_site",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const body = (await response.json()) as { error: string };
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(body.error).toBe("请输入需要优化的文章内容");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streams failed events when the LLM fails after the job is created", async () => {
|
||||||
|
llmMocks.generateValidatedJson
|
||||||
|
.mockResolvedValueOnce(validCandidateFactCard)
|
||||||
|
.mockRejectedValueOnce(new Error("LLM provider error: timeout"));
|
||||||
|
|
||||||
|
const response = await optimizeStream(
|
||||||
|
request({
|
||||||
|
body: "Example Technology Co., Ltd. has 8 years of GEO optimization experience.",
|
||||||
|
platform: "official_site",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const events = await streamEvents(response);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(events.map((event) => event.type)).toEqual([
|
||||||
|
"job_created",
|
||||||
|
"fact_card_ready",
|
||||||
|
"draft_started",
|
||||||
|
"failed",
|
||||||
|
]);
|
||||||
|
expect(events[events.length - 1]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "failed",
|
||||||
|
stage: "draft",
|
||||||
|
error: "LLM provider error: timeout",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects unresolved uncertain items when confirming a fact card", async () => {
|
it("rejects unresolved uncertain items when confirming a fact card", async () => {
|
||||||
const { job } = await createJobFixture();
|
const { job } = await createJobFixture();
|
||||||
const response = await confirmFactCard(
|
const response = await confirmFactCard(
|
||||||
@@ -539,6 +677,15 @@ function request(body: unknown, options: { apiKey?: string | null } = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function streamEvents(response: Response) {
|
||||||
|
const text = await response.text();
|
||||||
|
return text
|
||||||
|
.split(/\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => JSON.parse(line) as StreamEventResponse);
|
||||||
|
}
|
||||||
|
|
||||||
function params<T extends Record<string, string>>(values: T) {
|
function params<T extends Record<string, string>>(values: T) {
|
||||||
return { params: Promise.resolve(values) };
|
return { params: Promise.resolve(values) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { requireApiAccess } from "../../../../lib/api/auth";
|
||||||
|
import { getRepositoryFromRuntime } from "../../../../lib/db/repository";
|
||||||
|
import { optimizationFactCardSchema } from "../../../../lib/domain/validation";
|
||||||
|
import { LlmValidationError } from "../../../../lib/llm/client";
|
||||||
|
import { getExportStoreFromRuntime } from "../../../../lib/workflow/export-store";
|
||||||
|
import { extractCandidateFactCard } from "../../../../lib/workflow/fact-extractor";
|
||||||
|
import {
|
||||||
|
normalizeInput,
|
||||||
|
type RawArticleInput,
|
||||||
|
} from "../../../../lib/workflow/input-normalizer";
|
||||||
|
import {
|
||||||
|
encodeOptimizationStreamEvent,
|
||||||
|
type OptimizationStreamEvent,
|
||||||
|
type OptimizationStreamStage,
|
||||||
|
} from "../../../../lib/workflow/stream-events";
|
||||||
|
import { runStreamingOptimizationWorkflow } from "../../../../lib/workflow/streaming-optimizer";
|
||||||
|
|
||||||
|
interface OptimizeStreamPayload extends RawArticleInput {
|
||||||
|
fact_card?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const access = requireApiAccess(request);
|
||||||
|
if (!access.ok) {
|
||||||
|
return access.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: OptimizeStreamPayload;
|
||||||
|
try {
|
||||||
|
payload = (await request.json()) as OptimizeStreamPayload;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "请求体不是合法 JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasOptimizableBody(payload)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "请输入需要优化的文章内容" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized: ReturnType<typeof normalizeInput>;
|
||||||
|
try {
|
||||||
|
normalized = normalizeInput(payload);
|
||||||
|
} catch (error) {
|
||||||
|
return jsonError(error, getErrorStatus(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
async start(controller) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const send = (event: OptimizationStreamEvent) => {
|
||||||
|
controller.enqueue(encoder.encode(encodeOptimizationStreamEvent(event)));
|
||||||
|
};
|
||||||
|
|
||||||
|
let jobId: string | undefined;
|
||||||
|
let stage: OptimizationStreamStage = "job";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const repository = getRepositoryFromRuntime();
|
||||||
|
const job = await repository.createArticleJob({
|
||||||
|
source_title: normalized.articleInput.title,
|
||||||
|
source_body: normalized.articleInput.body,
|
||||||
|
image_inputs: normalized.articleInput.images,
|
||||||
|
publish_platform: normalized.articleInput.platform,
|
||||||
|
user_instructions: normalized.articleInput.user_instructions,
|
||||||
|
});
|
||||||
|
jobId = job.id;
|
||||||
|
send({ type: "job_created", job: { id: job.id } });
|
||||||
|
|
||||||
|
stage = "fact_card";
|
||||||
|
const factCard = optimizationFactCardSchema.parse(
|
||||||
|
payload.fact_card ??
|
||||||
|
(await extractCandidateFactCard(normalized.articleInput)),
|
||||||
|
);
|
||||||
|
const savedFactCard = await repository.saveFactCard(job.id, factCard);
|
||||||
|
send({
|
||||||
|
type: "fact_card_ready",
|
||||||
|
job_id: job.id,
|
||||||
|
fact_card: savedFactCard,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runStreamingOptimizationWorkflow({
|
||||||
|
jobId: job.id,
|
||||||
|
input: normalized.articleInput,
|
||||||
|
factCard: savedFactCard,
|
||||||
|
onEvent: (event) => {
|
||||||
|
stage = stageForEvent(event, stage);
|
||||||
|
send(event);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
stage = "final";
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
send({
|
||||||
|
type: "final_ready",
|
||||||
|
job_id: job.id,
|
||||||
|
optimized_article: optimizedArticle,
|
||||||
|
qa_report: qaReport,
|
||||||
|
export_paths: exportPaths,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
send({
|
||||||
|
type: "failed",
|
||||||
|
job_id: jobId,
|
||||||
|
stage,
|
||||||
|
error: error instanceof Error ? error.message : "优化失败",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
controller.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/x-ndjson; charset=utf-8",
|
||||||
|
"cache-control": "no-cache, no-transform",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOptimizableBody(payload: unknown): payload is OptimizeStreamPayload {
|
||||||
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (payload as Partial<OptimizeStreamPayload>).body;
|
||||||
|
return typeof body === "string" && body.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageForEvent(
|
||||||
|
event: OptimizationStreamEvent,
|
||||||
|
fallback: OptimizationStreamStage,
|
||||||
|
): OptimizationStreamStage {
|
||||||
|
switch (event.type) {
|
||||||
|
case "draft_started":
|
||||||
|
case "draft_ready":
|
||||||
|
return "draft";
|
||||||
|
case "qa_started":
|
||||||
|
case "qa_ready":
|
||||||
|
return "qa";
|
||||||
|
case "rewrite_started":
|
||||||
|
case "rewrite_ready":
|
||||||
|
return "rewrite";
|
||||||
|
default:
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonError(error: unknown, status: number) {
|
||||||
|
const message = error instanceof Error ? error.message : "Request failed";
|
||||||
|
return NextResponse.json({ error: message }, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import type { ConfirmedFactCard } from "../../domain/types";
|
import type { ConfirmedFactCard, OptimizationFactCard } from "../../domain/types";
|
||||||
import { normalizeInput } from "../input-normalizer";
|
import { normalizeInput } from "../input-normalizer";
|
||||||
import { inspectQuality } from "../quality-inspector";
|
import { inspectQuality } from "../quality-inspector";
|
||||||
|
|
||||||
@@ -139,6 +139,37 @@ describe("workflow nodes", () => {
|
|||||||
expect(report.overall_status).toBe("fail");
|
expect(report.overall_status).toBe("fail");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("warns without blocking QA when the editable fact card has no company full name", () => {
|
||||||
|
const factCard: OptimizationFactCard = {
|
||||||
|
...confirmedFactCard,
|
||||||
|
company_full_name: "",
|
||||||
|
uncertain_items: ["公司全称需要确认"],
|
||||||
|
is_ready_for_optimization: false,
|
||||||
|
confirmed_by_user: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const report = inspectQuality({
|
||||||
|
article: {
|
||||||
|
title: "示例科技 GEO 内容优化方案",
|
||||||
|
summary: "示例科技提供GEO内容优化服务。",
|
||||||
|
body_markdown: "## 服务能力\n示例科技提供GEO内容优化服务。",
|
||||||
|
image_suggestions: [],
|
||||||
|
changed_sections: [],
|
||||||
|
requires_user_confirmation: [],
|
||||||
|
},
|
||||||
|
factCard,
|
||||||
|
platform: "official_site",
|
||||||
|
sourceImages: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const companyCheck = report.checks.find(
|
||||||
|
(check) => check.rule_id === "company_name_integrity",
|
||||||
|
);
|
||||||
|
expect(companyCheck?.status).toBe("warn");
|
||||||
|
expect(companyCheck?.evidence).toContain("公司全称");
|
||||||
|
expect(report.overall_status).toBe("warn");
|
||||||
|
});
|
||||||
|
|
||||||
it("does not flag numbers already present in confirmed fact card claims", () => {
|
it("does not flag numbers already present in confirmed fact card claims", () => {
|
||||||
const report = inspectQuality({
|
const report = inspectQuality({
|
||||||
article: {
|
article: {
|
||||||
|
|||||||
@@ -169,11 +169,23 @@ function inspectRule(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (ruleId === "company_name_integrity") {
|
if (ruleId === "company_name_integrity") {
|
||||||
const hasFullName = combined.includes(factCard.company_full_name);
|
const companyFullName = factCard.company_full_name.trim();
|
||||||
|
if (companyFullName.length === 0) {
|
||||||
|
return check(
|
||||||
|
ruleId,
|
||||||
|
"warn",
|
||||||
|
"事实卡尚未提供公司全称。",
|
||||||
|
"无法执行公司全称一致性硬性检查,因为事实卡中的公司全称仍待确认。",
|
||||||
|
"补充公司全称,或确认当前文案可以使用简称。",
|
||||||
|
"fact_card",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFullName = combined.includes(companyFullName);
|
||||||
return check(
|
return check(
|
||||||
ruleId,
|
ruleId,
|
||||||
hasFullName ? "pass" : "fail",
|
hasFullName ? "pass" : "fail",
|
||||||
hasFullName ? factCard.company_full_name : article.body_markdown,
|
hasFullName ? companyFullName : article.body_markdown,
|
||||||
hasFullName
|
hasFullName
|
||||||
? "文章中包含事实卡确认的公司全称。"
|
? "文章中包含事实卡确认的公司全称。"
|
||||||
: "文章缺少事实卡确认的公司全称,或使用了不完整简称。",
|
: "文章缺少事实卡确认的公司全称,或使用了不完整简称。",
|
||||||
|
|||||||
Reference in New Issue
Block a user