diff --git a/src/app/api/__tests__/jobs.test.ts b/src/app/api/__tests__/jobs.test.ts index f6f024e..945847b 100644 --- a/src/app/api/__tests__/jobs.test.ts +++ b/src/app/api/__tests__/jobs.test.ts @@ -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; + 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>(values: T) { return { params: Promise.resolve(values) }; } diff --git a/src/app/api/jobs/optimize-stream/route.ts b/src/app/api/jobs/optimize-stream/route.ts new file mode 100644 index 0000000..68a1604 --- /dev/null +++ b/src/app/api/jobs/optimize-stream/route.ts @@ -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; + try { + normalized = normalizeInput(payload); + } catch (error) { + return jsonError(error, getErrorStatus(error)); + } + + const stream = new ReadableStream({ + 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).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; +} diff --git a/src/lib/workflow/__tests__/workflow.test.ts b/src/lib/workflow/__tests__/workflow.test.ts index 3b2061e..d82e686 100644 --- a/src/lib/workflow/__tests__/workflow.test.ts +++ b/src/lib/workflow/__tests__/workflow.test.ts @@ -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: { diff --git a/src/lib/workflow/quality-inspector.ts b/src/lib/workflow/quality-inspector.ts index eb6b23d..5a2eaa6 100644 --- a/src/lib/workflow/quality-inspector.ts +++ b/src/lib/workflow/quality-inspector.ts @@ -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 ? "文章中包含事实卡确认的公司全称。" : "文章缺少事实卡确认的公司全称,或使用了不完整简称。",