新增一键流式优化接口
This commit is contained in:
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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 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: {
|
||||
|
||||
@@ -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
|
||||
? "文章中包含事实卡确认的公司全称。"
|
||||
: "文章缺少事实卡确认的公司全称,或使用了不完整简称。",
|
||||
|
||||
Reference in New Issue
Block a user