From 68310f6e006a145a339cc03c7905d78a08acf3f2 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 16 Jun 2026 13:48:21 +0800 Subject: [PATCH] feat: export optimized article files --- src/app/api/jobs/[jobId]/optimize/route.ts | 11 +++ src/lib/workflow/__tests__/exporter.test.ts | 87 +++++++++++++++++++ src/lib/workflow/exporter.ts | 93 +++++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 src/lib/workflow/__tests__/exporter.test.ts create mode 100644 src/lib/workflow/exporter.ts diff --git a/src/app/api/jobs/[jobId]/optimize/route.ts b/src/app/api/jobs/[jobId]/optimize/route.ts index 69f9ba8..50bbc93 100644 --- a/src/app/api/jobs/[jobId]/optimize/route.ts +++ b/src/app/api/jobs/[jobId]/optimize/route.ts @@ -7,6 +7,7 @@ import { saveQaReport, updateArticleJob, } from "../../../../../lib/db/repositories"; +import { writeJobExports } from "../../../../../lib/workflow/exporter"; import { runOptimizationWorkflow } from "../../../../../lib/workflow/orchestrator"; interface RouteContext { @@ -45,13 +46,23 @@ export async function POST(_request: Request, context: RouteContext) { optimizedArticle.revision ?? 1, result.qaReport, ); + const exportPaths = + qaReport.overall_status === "fail" + ? {} + : await writeJobExports({ + jobId, + article: optimizedArticle, + qaReport, + }); updateArticleJob(undefined, jobId, { status: qaReport.overall_status === "fail" ? "qa_failed" : "optimized", + export_paths: exportPaths, }); return NextResponse.json({ optimizedArticle, qaReport, + exportPaths, rewriteRounds: result.rewrite_rounds, stoppedAfterMaxRewrites: result.stopped_after_max_rewrites, }); diff --git a/src/lib/workflow/__tests__/exporter.test.ts b/src/lib/workflow/__tests__/exporter.test.ts new file mode 100644 index 0000000..d21e1ea --- /dev/null +++ b/src/lib/workflow/__tests__/exporter.test.ts @@ -0,0 +1,87 @@ +import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + renderOptimizedDocx, + renderOptimizedMarkdown, + renderQaReportJson, + writeJobExports, +} from "../exporter"; + +const article = { + title: "Example GEO Guide", + summary: "A factual GEO guide.", + body_markdown: "## Body\nConfirmed content.", + image_suggestions: [{ source: "image_1", suggestion: "Use dashboard." }], + changed_sections: ["title"], + requires_user_confirmation: [], +}; + +const qaReport = { + overall_status: "pass" as const, + checks: [ + { + rule_id: "title_quality" as const, + status: "pass" as const, + evidence: "Readable title.", + reason: "Title is clear.", + suggested_fix: "", + target_agent: null, + }, + ], +}; + +describe("exporter", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "geo-agent-export-")); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("renders markdown with optimized article fields", () => { + const markdown = renderOptimizedMarkdown(article); + + expect(markdown).toContain("# Example GEO Guide"); + expect(markdown).toContain("A factual GEO guide."); + expect(markdown).toContain("## Body"); + expect(markdown).toContain("image_1: Use dashboard."); + }); + + it("serializes the QA report as JSON", () => { + const json = renderQaReportJson(qaReport); + + expect(JSON.parse(json)).toMatchObject({ + overall_status: "pass", + checks: [{ rule_id: "title_quality" }], + }); + }); + + it("creates a non-empty Word document buffer", async () => { + const buffer = await renderOptimizedDocx(article); + + expect(buffer.byteLength).toBeGreaterThan(0); + }); + + it("writes markdown, Word, and JSON exports under a job directory", async () => { + const paths = await writeJobExports({ + dataDir: tempDir, + jobId: "job_xxx", + article, + qaReport, + }); + + expect(readFileSync(paths.markdown, "utf8")).toContain("# Example GEO Guide"); + expect(statSync(paths.docx).size).toBeGreaterThan(0); + expect(JSON.parse(readFileSync(paths.qaJson, "utf8")).overall_status).toBe( + "pass", + ); + expect(paths.markdown).toContain(join("exports", "job_xxx", "optimized.md")); + }); +}); diff --git a/src/lib/workflow/exporter.ts b/src/lib/workflow/exporter.ts new file mode 100644 index 0000000..e47404e --- /dev/null +++ b/src/lib/workflow/exporter.ts @@ -0,0 +1,93 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { Document, Packer, Paragraph, TextRun } from "docx"; + +import { getAppDataDir } from "../db/connection"; +import type { OptimizedArticle, QaReport } from "../domain/types"; + +export interface WriteJobExportsInput { + dataDir?: string; + jobId: string; + article: OptimizedArticle; + qaReport: QaReport; +} + +export function renderOptimizedMarkdown(article: OptimizedArticle) { + const imageSuggestions = + article.image_suggestions.length > 0 + ? article.image_suggestions + .map((item) => `- ${item.source}: ${item.suggestion}`) + .join("\n") + : "- No image suggestions."; + const confirmations = + article.requires_user_confirmation.length > 0 + ? [ + "", + "## Requires User Confirmation", + ...article.requires_user_confirmation.map((item) => `- ${item}`), + ].join("\n") + : ""; + + return [ + `# ${article.title}`, + "", + article.summary, + "", + article.body_markdown, + "", + "## Image Suggestions", + imageSuggestions, + confirmations, + "", + ].join("\n"); +} + +export function renderQaReportJson(report: QaReport) { + return `${JSON.stringify(report, null, 2)}\n`; +} + +export async function renderOptimizedDocx(article: OptimizedArticle) { + const document = new Document({ + sections: [ + { + children: [ + new Paragraph({ + children: [new TextRun({ text: article.title, bold: true, size: 32 })], + }), + new Paragraph(article.summary), + ...article.body_markdown + .split(/\r?\n/) + .filter(Boolean) + .map((line) => new Paragraph(line.replace(/^#+\s*/, ""))), + new Paragraph("Image Suggestions"), + ...article.image_suggestions.map( + (item) => new Paragraph(`${item.source}: ${item.suggestion}`), + ), + ], + }, + ], + }); + + return Packer.toBuffer(document); +} + +export async function writeJobExports({ + dataDir = getAppDataDir(), + jobId, + article, + qaReport, +}: WriteJobExportsInput) { + const exportDir = join(dataDir, "exports", jobId); + mkdirSync(exportDir, { recursive: true }); + + const markdown = join(exportDir, "optimized.md"); + const docx = join(exportDir, "optimized.docx"); + const qaJson = join(exportDir, "qa_report.json"); + + writeFileSync(markdown, renderOptimizedMarkdown(article), "utf8"); + writeFileSync(qaJson, renderQaReportJson(qaReport), "utf8"); + writeFileSync(docx, await renderOptimizedDocx(article)); + + return { markdown, docx, qaJson }; +}