feat: export optimized article files

This commit is contained in:
Codex
2026-06-21 23:42:37 +08:00
parent 67f19f8550
commit 68310f6e00
3 changed files with 191 additions and 0 deletions
@@ -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,
});
@@ -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"));
});
});
+93
View File
@@ -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 };
}