feat: export optimized article files
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
|||||||
saveQaReport,
|
saveQaReport,
|
||||||
updateArticleJob,
|
updateArticleJob,
|
||||||
} from "../../../../../lib/db/repositories";
|
} from "../../../../../lib/db/repositories";
|
||||||
|
import { writeJobExports } from "../../../../../lib/workflow/exporter";
|
||||||
import { runOptimizationWorkflow } from "../../../../../lib/workflow/orchestrator";
|
import { runOptimizationWorkflow } from "../../../../../lib/workflow/orchestrator";
|
||||||
|
|
||||||
interface RouteContext {
|
interface RouteContext {
|
||||||
@@ -45,13 +46,23 @@ export async function POST(_request: Request, context: RouteContext) {
|
|||||||
optimizedArticle.revision ?? 1,
|
optimizedArticle.revision ?? 1,
|
||||||
result.qaReport,
|
result.qaReport,
|
||||||
);
|
);
|
||||||
|
const exportPaths =
|
||||||
|
qaReport.overall_status === "fail"
|
||||||
|
? {}
|
||||||
|
: await writeJobExports({
|
||||||
|
jobId,
|
||||||
|
article: optimizedArticle,
|
||||||
|
qaReport,
|
||||||
|
});
|
||||||
updateArticleJob(undefined, jobId, {
|
updateArticleJob(undefined, jobId, {
|
||||||
status: qaReport.overall_status === "fail" ? "qa_failed" : "optimized",
|
status: qaReport.overall_status === "fail" ? "qa_failed" : "optimized",
|
||||||
|
export_paths: exportPaths,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
optimizedArticle,
|
optimizedArticle,
|
||||||
qaReport,
|
qaReport,
|
||||||
|
exportPaths,
|
||||||
rewriteRounds: result.rewrite_rounds,
|
rewriteRounds: result.rewrite_rounds,
|
||||||
stoppedAfterMaxRewrites: result.stopped_after_max_rewrites,
|
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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user