feat: expose optimizer workflow api
This commit is contained in:
@@ -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<string, string>) {
|
||||
return { params: Promise.resolve(values) };
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
"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}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -245,6 +245,36 @@ export function getArticleJob(dbPath: string | undefined, id: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function updateArticleJob(
|
||||
dbPath: string | undefined,
|
||||
id: string,
|
||||
changes: Partial<Pick<ArticleJob, "brand_template_id" | "status" | "export_paths">>,
|
||||
) {
|
||||
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,
|
||||
|
||||
@@ -75,24 +75,17 @@ export const candidateFactCardSchema = factCardBaseSchema
|
||||
is_ready_for_optimization: card.uncertain_items.length === 0,
|
||||
})) satisfies z.ZodType<CandidateFactCard>;
|
||||
|
||||
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<ConfirmedFactCard>;
|
||||
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<ConfirmedFactCard>;
|
||||
|
||||
export const imageSuggestionSchema = z.object({
|
||||
source: z.string().trim().min(1),
|
||||
|
||||
Reference in New Issue
Block a user