From dd6480a8be953c744d31f3b5e9913492825a0867 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 16 Jun 2026 13:39:08 +0800 Subject: [PATCH] feat: expose optimizer workflow api --- src/app/api/__tests__/jobs.test.ts | 132 ++++++++++++++++++ .../jobs/[jobId]/confirm-fact-card/route.ts | 49 +++++++ .../jobs/[jobId]/exports/[fileName]/route.ts | 37 +++++ src/app/api/jobs/[jobId]/optimize/route.ts | 58 ++++++++ src/app/api/jobs/route.ts | 29 ++++ src/lib/db/repositories.ts | 30 ++++ src/lib/domain/validation.ts | 29 ++-- 7 files changed, 346 insertions(+), 18 deletions(-) create mode 100644 src/app/api/__tests__/jobs.test.ts create mode 100644 src/app/api/jobs/[jobId]/confirm-fact-card/route.ts create mode 100644 src/app/api/jobs/[jobId]/exports/[fileName]/route.ts create mode 100644 src/app/api/jobs/[jobId]/optimize/route.ts create mode 100644 src/app/api/jobs/route.ts diff --git a/src/app/api/__tests__/jobs.test.ts b/src/app/api/__tests__/jobs.test.ts new file mode 100644 index 0000000..8e6311d --- /dev/null +++ b/src/app/api/__tests__/jobs.test.ts @@ -0,0 +1,132 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { POST as confirmFactCard } from "../jobs/[jobId]/confirm-fact-card/route"; +import { GET as downloadExport } from "../jobs/[jobId]/exports/[fileName]/route"; +import { POST as optimizeJob } from "../jobs/[jobId]/optimize/route"; +import { POST as createJob } from "../jobs/route"; + +const validFactCard = { + company_full_name: "Example Technology Co., Ltd.", + company_short_names: ["Example Tech"], + brand_names: ["Example"], + product_names: ["Example GEO"], + target_industry: "GEO optimization", + target_audience: "Marketing teams", + experience_years: 8, + core_claims: ["Eight years of GEO optimization experience"], + forbidden_claims: [], + image_topics: ["Product dashboard"], + uncertain_items: [], + is_ready_for_optimization: true, + confirmed_by_user: true, +}; + +describe("job API routes", () => { + let tempDir: string; + const originalDataDir = process.env.APP_DATA_DIR; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "geo-agent-api-")); + process.env.APP_DATA_DIR = tempDir; + }); + + afterEach(() => { + process.env.APP_DATA_DIR = originalDataDir; + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("validates input, creates a job, and returns a candidate fact card", async () => { + const response = await createJob( + request({ + title: "Example Technology Co., Ltd. GEO guide", + body: "Example Technology Co., Ltd. has 8 years of GEO optimization experience.", + image_lines: "Product dashboard", + platform: "official_site", + user_instructions: "Keep factual", + }), + ); + const body = await response.json(); + + expect(response.status).toBe(201); + expect(body.job.id).toMatch(/^job_/); + expect(body.candidateFactCard.company_full_name).toBe( + "Example Technology Co., Ltd.", + ); + }); + + it("rejects unresolved uncertain items when confirming a fact card", async () => { + const { job } = await createJobFixture(); + const response = await confirmFactCard( + request({ + ...validFactCard, + uncertain_items: ["Need company confirmation"], + is_ready_for_optimization: false, + }), + params({ jobId: job.id }), + ); + + expect(response.status).toBe(400); + }); + + it("rejects optimize requests for jobs without confirmed fact cards", async () => { + const { job } = await createJobFixture(); + const response = await optimizeJob(request({}), params({ jobId: job.id })); + + expect(response.status).toBe(409); + }); + + it("returns optimized article and QA report for successful optimization", async () => { + const { job } = await createJobFixture(); + await confirmFactCard(request(validFactCard), params({ jobId: job.id })); + + const response = await optimizeJob(request({}), params({ jobId: job.id })); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.optimizedArticle.title).toContain("GEO optimization"); + expect(body.qaReport.checks).toHaveLength(10); + }); + + it("rejects unknown export filenames", async () => { + const { job } = await createJobFixture(); + const exportDir = join(tempDir, "exports", job.id); + mkdirSync(exportDir, { recursive: true }); + writeFileSync(join(exportDir, "optimized.md"), "# Optimized"); + + const response = await downloadExport( + request({}), + params({ jobId: job.id, fileName: "unknown.txt" }), + ); + + expect(response.status).toBe(404); + }); +}); + +async function createJobFixture() { + const response = await createJob( + request({ + title: "Example Technology Co., Ltd. GEO guide", + body: "Example Technology Co., Ltd. has 8 years of GEO optimization experience.", + image_lines: "Product dashboard", + platform: "official_site", + user_instructions: "", + }), + ); + return response.json() as Promise<{ job: { id: string } }>; +} + +function request(body: unknown) { + return new Request("http://localhost/api/jobs", { + method: "POST", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }); +} + +function params(values: Record) { + return { params: Promise.resolve(values) }; +} diff --git a/src/app/api/jobs/[jobId]/confirm-fact-card/route.ts b/src/app/api/jobs/[jobId]/confirm-fact-card/route.ts new file mode 100644 index 0000000..a0b6f27 --- /dev/null +++ b/src/app/api/jobs/[jobId]/confirm-fact-card/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; + +import { + createBrandTemplate, + getArticleJob, + saveFactCard, + updateArticleJob, +} from "../../../../../lib/db/repositories"; +import { confirmedFactCardSchema } from "../../../../../lib/domain/validation"; + +interface RouteContext { + params: Promise<{ jobId: string }>; +} + +export async function POST(request: Request, context: RouteContext) { + const { jobId } = await context.params; + const job = getArticleJob(undefined, jobId); + if (!job) { + return NextResponse.json({ error: "Job not found" }, { status: 404 }); + } + + try { + const factCard = confirmedFactCardSchema.parse(await request.json()); + const brandTemplate = createBrandTemplate(undefined, { + brand_name: factCard.brand_names[0] ?? factCard.company_short_names[0] ?? factCard.company_full_name, + company_full_name: factCard.company_full_name, + company_short_names: factCard.company_short_names, + product_names: factCard.product_names, + target_industries: [factCard.target_industry], + target_audience: [factCard.target_audience], + verified_claims: factCard.core_claims, + forbidden_claims: factCard.forbidden_claims, + tone_rules: { + official_site: "official brand voice", + media_article: "objective third-party voice", + }, + }); + const savedFactCard = saveFactCard(undefined, jobId, factCard); + updateArticleJob(undefined, jobId, { + brand_template_id: brandTemplate.id, + status: "fact_confirmed", + }); + + return NextResponse.json({ brandTemplate, factCard: savedFactCard }); + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid fact card"; + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/src/app/api/jobs/[jobId]/exports/[fileName]/route.ts b/src/app/api/jobs/[jobId]/exports/[fileName]/route.ts new file mode 100644 index 0000000..278de2b --- /dev/null +++ b/src/app/api/jobs/[jobId]/exports/[fileName]/route.ts @@ -0,0 +1,37 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { NextResponse } from "next/server"; + +import { getAppDataDir } from "../../../../../../lib/db/connection"; + +const CONTENT_TYPES: Record = { + "optimized.md": "text/markdown; charset=utf-8", + "optimized.docx": + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "qa_report.json": "application/json; charset=utf-8", +}; + +interface RouteContext { + params: Promise<{ jobId: string; fileName: string }>; +} + +export async function GET(_request: Request, context: RouteContext) { + const { jobId, fileName } = await context.params; + const contentType = CONTENT_TYPES[fileName]; + if (!contentType) { + return NextResponse.json({ error: "Export file not found" }, { status: 404 }); + } + + const path = join(getAppDataDir(), "exports", jobId, fileName); + if (!existsSync(path)) { + return NextResponse.json({ error: "Export file not found" }, { status: 404 }); + } + + return new Response(readFileSync(path), { + headers: { + "content-type": contentType, + "content-disposition": `attachment; filename="${fileName}"`, + }, + }); +} diff --git a/src/app/api/jobs/[jobId]/optimize/route.ts b/src/app/api/jobs/[jobId]/optimize/route.ts new file mode 100644 index 0000000..69f9ba8 --- /dev/null +++ b/src/app/api/jobs/[jobId]/optimize/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; + +import { + getArticleJob, + getFactCard, + saveOptimizedArticle, + saveQaReport, + updateArticleJob, +} from "../../../../../lib/db/repositories"; +import { runOptimizationWorkflow } from "../../../../../lib/workflow/orchestrator"; + +interface RouteContext { + params: Promise<{ jobId: string }>; +} + +export async function POST(_request: Request, context: RouteContext) { + const { jobId } = await context.params; + const job = getArticleJob(undefined, jobId); + if (!job) { + return NextResponse.json({ error: "Job not found" }, { status: 404 }); + } + + const factCardRecord = getFactCard(undefined, jobId); + if (!factCardRecord) { + return NextResponse.json( + { error: "Confirm the fact card before optimizing" }, + { status: 409 }, + ); + } + + const result = await runOptimizationWorkflow({ + input: { + title: job.source_title, + body: job.source_body, + images: job.image_inputs, + platform: job.publish_platform, + user_instructions: job.user_instructions, + }, + factCard: factCardRecord, + }); + const optimizedArticle = saveOptimizedArticle(undefined, jobId, result.article); + const qaReport = saveQaReport( + undefined, + jobId, + optimizedArticle.revision ?? 1, + result.qaReport, + ); + updateArticleJob(undefined, jobId, { + status: qaReport.overall_status === "fail" ? "qa_failed" : "optimized", + }); + + return NextResponse.json({ + optimizedArticle, + qaReport, + rewriteRounds: result.rewrite_rounds, + stoppedAfterMaxRewrites: result.stopped_after_max_rewrites, + }); +} diff --git a/src/app/api/jobs/route.ts b/src/app/api/jobs/route.ts new file mode 100644 index 0000000..2a82ff7 --- /dev/null +++ b/src/app/api/jobs/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; + +import { createArticleJob } from "../../../lib/db/repositories"; +import { extractCandidateFactCard } from "../../../lib/workflow/fact-extractor"; +import { normalizeInput } from "../../../lib/workflow/input-normalizer"; + +export async function POST(request: Request) { + try { + const payload = await request.json(); + const normalized = normalizeInput(payload); + const job = createArticleJob(undefined, { + 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, + }); + const candidateFactCard = await extractCandidateFactCard(normalized.articleInput); + + return NextResponse.json({ job, candidateFactCard }, { status: 201 }); + } catch (error) { + return jsonError(error, 400); + } +} + +function jsonError(error: unknown, status: number) { + const message = error instanceof Error ? error.message : "Request failed"; + return NextResponse.json({ error: message }, { status }); +} diff --git a/src/lib/db/repositories.ts b/src/lib/db/repositories.ts index f889b92..2882d0f 100644 --- a/src/lib/db/repositories.ts +++ b/src/lib/db/repositories.ts @@ -245,6 +245,36 @@ export function getArticleJob(dbPath: string | undefined, id: string) { }); } +export function updateArticleJob( + dbPath: string | undefined, + id: string, + changes: Partial>, +) { + return withDb(dbPath, (db) => { + const existing = getArticleJob(dbPath, id); + if (!existing) return null; + const updated = { + brand_template_id: changes.brand_template_id ?? existing.brand_template_id, + status: changes.status ?? existing.status, + export_paths: changes.export_paths ?? existing.export_paths, + updated_at: nowIso(), + }; + db.prepare( + `update article_jobs set + brand_template_id = @brand_template_id, + status = @status, + export_paths = @export_paths, + updated_at = @updated_at + where id = @id`, + ).run({ + id, + ...updated, + export_paths: serialize(updated.export_paths), + }); + return getArticleJob(dbPath, id); + }); +} + export function saveFactCard( dbPath: string | undefined, jobId: string, diff --git a/src/lib/domain/validation.ts b/src/lib/domain/validation.ts index 545a99e..cf2bbc5 100644 --- a/src/lib/domain/validation.ts +++ b/src/lib/domain/validation.ts @@ -75,24 +75,17 @@ export const candidateFactCardSchema = factCardBaseSchema is_ready_for_optimization: card.uncertain_items.length === 0, })) satisfies z.ZodType; -export const confirmedFactCardSchema = candidateFactCardSchema - .pipe( - z.object({ - company_full_name: z.string().trim().min(1), - company_short_names: z.array(z.string().trim().min(1)).default([]), - brand_names: z.array(z.string().trim().min(1)).default([]), - product_names: z.array(z.string().trim().min(1)).default([]), - target_industry: z.string().trim().min(1), - target_audience: z.string().trim().min(1), - experience_years: z.number().int().nonnegative().nullable().default(null), - core_claims: z.array(z.string().trim().min(1)).default([]), - forbidden_claims: z.array(z.string().trim().min(1)).default([]), - image_topics: z.array(z.string().trim().min(1)).default([]), - uncertain_items: z.array(z.never()).length(0), - is_ready_for_optimization: z.literal(true), - }), - ) - .and(z.object({ confirmed_by_user: z.literal(true) })) satisfies z.ZodType; +export const confirmedFactCardSchema = factCardBaseSchema + .extend({ + company_full_name: z.string().trim().min(1), + uncertain_items: z.array(z.never()).length(0), + confirmed_by_user: z.literal(true), + is_ready_for_optimization: z.boolean().optional(), + }) + .transform((card) => ({ + ...card, + is_ready_for_optimization: true as const, + })) satisfies z.ZodType; export const imageSuggestionSchema = z.object({ source: z.string().trim().min(1),