diff --git a/src/lib/workflow/__tests__/streaming-optimizer.test.ts b/src/lib/workflow/__tests__/streaming-optimizer.test.ts new file mode 100644 index 0000000..e13f25e --- /dev/null +++ b/src/lib/workflow/__tests__/streaming-optimizer.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { OptimizationStreamEvent } from "../stream-events"; +import { runStreamingOptimizationWorkflow } from "../streaming-optimizer"; + +const workflowMocks = vi.hoisted(() => ({ + optimizeArticle: vi.fn(), + inspectQualityWithLlm: vi.fn(), + rewriteFailedSections: vi.fn(), +})); + +vi.mock("../article-optimizer", () => ({ + optimizeArticle: workflowMocks.optimizeArticle, +})); + +vi.mock("../quality-inspector", () => ({ + inspectQualityWithLlm: workflowMocks.inspectQualityWithLlm, +})); + +vi.mock("../targeted-rewriter", () => ({ + rewriteFailedSections: workflowMocks.rewriteFailedSections, +})); + +const input = { + title: "", + body: "示例科技提供GEO内容优化服务。", + images: [], + platform: "official_site" as const, + user_instructions: "", +}; + +const factCard = { + company_full_name: "", + company_short_names: ["示例科技"], + brand_names: [], + product_names: ["GEO内容优化平台"], + target_industry: "GEO内容优化", + target_audience: "市场团队", + experience_years: null, + core_claims: ["提供GEO内容优化服务"], + forbidden_claims: [], + image_topics: [], + uncertain_items: ["公司全称需要确认"], + is_ready_for_optimization: false, + confirmed_by_user: false, +}; + +const draftArticle = { + title: "示例科技 GEO 内容优化方案", + summary: "面向市场团队的GEO内容优化说明。", + body_markdown: "## 服务能力\n示例科技提供GEO内容优化服务。", + image_suggestions: [], + changed_sections: ["标题", "正文"], + requires_user_confirmation: [], +}; + +const failCheck = { + rule_id: "body_quality" as const, + status: "fail" as const, + evidence: "句子不够顺。", + reason: "正文需要润色。", + suggested_fix: "润色正文。", + target_agent: "body", +}; + +describe("runStreamingOptimizationWorkflow", () => { + it("emits draft and final QA events when no rewrite is needed", async () => { + workflowMocks.optimizeArticle.mockResolvedValueOnce(draftArticle); + workflowMocks.inspectQualityWithLlm.mockResolvedValueOnce({ + overall_status: "pass", + checks: [], + }); + const events: OptimizationStreamEvent[] = []; + + const result = await runStreamingOptimizationWorkflow({ + jobId: "job_stream", + input, + factCard, + onEvent: (event) => events.push(event), + }); + + expect(events.map((event) => event.type)).toEqual([ + "draft_started", + "draft_ready", + "qa_started", + "qa_ready", + ]); + expect(result.article.title).toBe("示例科技 GEO 内容优化方案"); + expect(result.qaReport.overall_status).toBe("pass"); + expect(result.rewriteRounds).toBe(0); + }); + + it("emits rewrite events when QA fails", async () => { + workflowMocks.optimizeArticle.mockResolvedValueOnce(draftArticle); + workflowMocks.inspectQualityWithLlm + .mockResolvedValueOnce({ overall_status: "fail", checks: [failCheck] }) + .mockResolvedValueOnce({ overall_status: "pass", checks: [] }); + workflowMocks.rewriteFailedSections.mockResolvedValueOnce({ + ...draftArticle, + body_markdown: "## 服务能力\n示例科技提供清晰的GEO内容优化服务。", + }); + const events: OptimizationStreamEvent[] = []; + + const result = await runStreamingOptimizationWorkflow({ + jobId: "job_stream", + input, + factCard, + onEvent: (event) => events.push(event), + }); + + expect(events.map((event) => event.type)).toEqual([ + "draft_started", + "draft_ready", + "qa_started", + "qa_ready", + "rewrite_started", + "rewrite_ready", + "qa_started", + "qa_ready", + ]); + expect(result.article.body_markdown).toContain("清晰的GEO内容优化服务"); + expect(result.rewriteRounds).toBe(1); + }); +}); diff --git a/src/lib/workflow/streaming-optimizer.ts b/src/lib/workflow/streaming-optimizer.ts new file mode 100644 index 0000000..f1e69bc --- /dev/null +++ b/src/lib/workflow/streaming-optimizer.ts @@ -0,0 +1,81 @@ +import type { ArticleInput, OptimizationFactCard } from "../domain/types"; + +import { optimizeArticle } from "./article-optimizer"; +import { inspectQualityWithLlm } from "./quality-inspector"; +import type { OptimizationStreamEvent } from "./stream-events"; +import { rewriteFailedSections } from "./targeted-rewriter"; + +export interface RunStreamingOptimizationWorkflowInput { + jobId: string; + input: ArticleInput; + factCard: OptimizationFactCard; + onEvent: (event: OptimizationStreamEvent) => void | Promise; +} + +export async function runStreamingOptimizationWorkflow({ + jobId, + input, + factCard, + onEvent, +}: RunStreamingOptimizationWorkflowInput) { + await onEvent({ + type: "draft_started", + job_id: jobId, + message: "正在生成优化草稿", + }); + let article = await optimizeArticle({ input, factCard }); + await onEvent({ type: "draft_ready", job_id: jobId, article }); + + await onEvent({ + type: "qa_started", + job_id: jobId, + message: "正在检查质量", + }); + let qaReport = await inspectQualityWithLlm({ + article, + factCard, + platform: input.platform, + sourceImages: input.images, + }); + await onEvent({ type: "qa_ready", job_id: jobId, qa_report: qaReport }); + + let rewriteRounds = 0; + while (qaReport.overall_status === "fail" && rewriteRounds < 2) { + const nextRound = rewriteRounds + 1; + const failedChecks = qaReport.checks.filter((check) => check.status === "fail"); + await onEvent({ + type: "rewrite_started", + job_id: jobId, + round: nextRound, + }); + article = await rewriteFailedSections({ article, factCard, failedChecks }); + rewriteRounds = nextRound; + await onEvent({ + type: "rewrite_ready", + job_id: jobId, + round: nextRound, + article, + }); + + await onEvent({ + type: "qa_started", + job_id: jobId, + message: `正在复检第 ${nextRound} 轮修复`, + }); + qaReport = await inspectQualityWithLlm({ + article, + factCard, + platform: input.platform, + sourceImages: input.images, + }); + await onEvent({ type: "qa_ready", job_id: jobId, qa_report: qaReport }); + } + + return { + article, + qaReport, + rewriteRounds, + stoppedAfterMaxRewrites: + qaReport.overall_status === "fail" && rewriteRounds >= 2, + }; +}