新增一键流式优化接口

This commit is contained in:
czj
2026-07-01 13:15:14 +08:00
parent a2183a34af
commit 8a1ceb3b14
4 changed files with 373 additions and 3 deletions
+147
View File
@@ -27,6 +27,7 @@ import {
POST as createPublication,
} from "../jobs/[jobId]/publications/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 recordPerformance } from "../publications/[publicationId]/performance/route";
@@ -84,6 +85,19 @@ interface OptimizeJobResponse {
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", () => {
let tempDir: string;
const originalDataDir = process.env.APP_DATA_DIR;
@@ -187,6 +201,130 @@ describe("job API routes", () => {
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 () => {
const { job } = await createJobFixture();
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) {
return { params: Promise.resolve(values) };
}
+180
View File
@@ -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;
}
+32 -1
View File
@@ -1,6 +1,6 @@
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 { inspectQuality } from "../quality-inspector";
@@ -139,6 +139,37 @@ describe("workflow nodes", () => {
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", () => {
const report = inspectQuality({
article: {
+14 -2
View File
@@ -169,11 +169,23 @@ function inspectRule(
}
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(
ruleId,
hasFullName ? "pass" : "fail",
hasFullName ? factCard.company_full_name : article.body_markdown,
hasFullName ? companyFullName : article.body_markdown,
hasFullName
? "文章中包含事实卡确认的公司全称。"
: "文章缺少事实卡确认的公司全称,或使用了不完整简称。",