feat: implement article optimization workflow
This commit is contained in:
@@ -6,7 +6,7 @@ import type {
|
|||||||
OptimizedArticle,
|
OptimizedArticle,
|
||||||
PublishPlatform,
|
PublishPlatform,
|
||||||
QaReport,
|
QaReport,
|
||||||
} from "@/lib/domain/types";
|
} from "../domain/types";
|
||||||
|
|
||||||
import { createDatabase, getDefaultDatabasePath } from "./connection";
|
import { createDatabase, getDefaultDatabasePath } from "./connection";
|
||||||
import { initializeSchema } from "./schema";
|
import { initializeSchema } from "./schema";
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import OpenAI from "openai";
|
||||||
|
|
||||||
|
export interface GenerateInput {
|
||||||
|
system?: string;
|
||||||
|
prompt: string;
|
||||||
|
model?: string;
|
||||||
|
temperature?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LlmProviderStatus {
|
||||||
|
provider: "deepseek" | "openai";
|
||||||
|
configured: boolean;
|
||||||
|
model: string;
|
||||||
|
baseURL?: string;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProvider() {
|
||||||
|
return (process.env.LLM_PROVIDER || "deepseek").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLlmProviderStatus(): LlmProviderStatus {
|
||||||
|
const provider = getProvider();
|
||||||
|
|
||||||
|
if (provider === "openai") {
|
||||||
|
return {
|
||||||
|
provider: "openai",
|
||||||
|
configured: Boolean(process.env.OPENAI_API_KEY),
|
||||||
|
model: process.env.OPENAI_MODEL || "gpt-4.1-mini",
|
||||||
|
reason: process.env.OPENAI_API_KEY ? undefined : "OPENAI_API_KEY is missing",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: "deepseek",
|
||||||
|
configured: Boolean(process.env.DEEPSEEK_API_KEY),
|
||||||
|
model: process.env.DEEPSEEK_MODEL || "deepseek-v4-pro",
|
||||||
|
baseURL: process.env.DEEPSEEK_BASE_URL || "https://api.deepseek.com",
|
||||||
|
reason: process.env.DEEPSEEK_API_KEY
|
||||||
|
? undefined
|
||||||
|
: "DEEPSEEK_API_KEY is missing",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLlmConfigured() {
|
||||||
|
return getLlmProviderStatus().configured;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createClient() {
|
||||||
|
const status = getLlmProviderStatus();
|
||||||
|
|
||||||
|
if (!status.configured) {
|
||||||
|
throw new Error(status.reason ?? "LLM provider is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.provider === "openai") {
|
||||||
|
return {
|
||||||
|
client: new OpenAI({ apiKey: process.env.OPENAI_API_KEY }),
|
||||||
|
model: status.model,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
client: new OpenAI({
|
||||||
|
apiKey: process.env.DEEPSEEK_API_KEY,
|
||||||
|
baseURL: status.baseURL,
|
||||||
|
}),
|
||||||
|
model: status.model,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateText(input: GenerateInput) {
|
||||||
|
try {
|
||||||
|
const { client, model } = createClient();
|
||||||
|
const response = await client.chat.completions.create({
|
||||||
|
model: input.model ?? model,
|
||||||
|
temperature: input.temperature ?? 0.2,
|
||||||
|
messages: [
|
||||||
|
...(input.system ? [{ role: "system" as const, content: input.system }] : []),
|
||||||
|
{ role: "user" as const, content: input.prompt },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.choices[0]?.message.content ?? "";
|
||||||
|
} catch (error) {
|
||||||
|
throw normalizeLlmError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateJson<T>(input: GenerateInput): Promise<T> {
|
||||||
|
try {
|
||||||
|
const { client, model } = createClient();
|
||||||
|
const response = await client.chat.completions.create({
|
||||||
|
model: input.model ?? model,
|
||||||
|
temperature: input.temperature ?? 0.1,
|
||||||
|
response_format: { type: "json_object" },
|
||||||
|
messages: [
|
||||||
|
...(input.system ? [{ role: "system" as const, content: input.system }] : []),
|
||||||
|
{ role: "user" as const, content: input.prompt },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const content = response.choices[0]?.message.content ?? "{}";
|
||||||
|
return JSON.parse(content) as T;
|
||||||
|
} catch (error) {
|
||||||
|
throw normalizeLlmError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLlmError(error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown LLM error";
|
||||||
|
return new Error(`LLM provider error: ${message}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export const JSON_ONLY_PROMPT =
|
||||||
|
"Return valid JSON only. Do not include markdown fences or commentary.";
|
||||||
|
|
||||||
|
export const ARTICLE_OPTIMIZER_SYSTEM_PROMPT = [
|
||||||
|
"You optimize GEO-related articles under a confirmed fact card.",
|
||||||
|
"Never invent numbers, cases, qualifications, company names, products, or years.",
|
||||||
|
JSON_ONLY_PROMPT,
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
export const QUALITY_INSPECTOR_SYSTEM_PROMPT = [
|
||||||
|
"Evaluate article quality against the confirmed fact card and target platform.",
|
||||||
|
"Return one structured check per required quality gate.",
|
||||||
|
JSON_ONLY_PROMPT,
|
||||||
|
].join(" ");
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { optimizeArticle } from "../article-optimizer";
|
||||||
|
import { extractCandidateFactCard } from "../fact-extractor";
|
||||||
|
import { normalizeInput } from "../input-normalizer";
|
||||||
|
import { inspectQuality } from "../quality-inspector";
|
||||||
|
import { runOptimizationWorkflow } from "../orchestrator";
|
||||||
|
import { rewriteFailedSections } from "../targeted-rewriter";
|
||||||
|
|
||||||
|
const confirmedFactCard = {
|
||||||
|
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: ["industry first"],
|
||||||
|
image_topics: ["product dashboard"],
|
||||||
|
uncertain_items: [],
|
||||||
|
is_ready_for_optimization: true,
|
||||||
|
confirmed_by_user: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
describe("workflow nodes", () => {
|
||||||
|
it("normalizes input whitespace and image lines", () => {
|
||||||
|
const normalized = normalizeInput({
|
||||||
|
title: " A GEO Article ",
|
||||||
|
body: "\nFirst paragraph.\n\nSecond paragraph. ",
|
||||||
|
image_lines:
|
||||||
|
" Product dashboard screenshot \n https://example.com/image.png \n\n",
|
||||||
|
platform: "official_site",
|
||||||
|
user_instructions: " Keep factual. ",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalized.article_draft.title).toBe("A GEO Article");
|
||||||
|
expect(normalized.article_draft.body).toBe(
|
||||||
|
"First paragraph.\n\nSecond paragraph.",
|
||||||
|
);
|
||||||
|
expect(normalized.image_assets).toEqual([
|
||||||
|
{ type: "description", content: "Product dashboard screenshot" },
|
||||||
|
{ type: "link", content: "https://example.com/image.png" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places missing or conflicting company facts into uncertain items", async () => {
|
||||||
|
const card = await extractCandidateFactCard({
|
||||||
|
title: "Example announces GEO product",
|
||||||
|
body: "Example has 8 years of experience. Example has 12 years of service. The article discusses GEO optimization.",
|
||||||
|
images: [],
|
||||||
|
platform: "media_article",
|
||||||
|
user_instructions: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(card.company_full_name).toBe("");
|
||||||
|
expect(card.uncertain_items).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.stringContaining("company full name"),
|
||||||
|
expect.stringContaining("Conflicting experience years"),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(card.is_ready_for_optimization).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add claims outside the confirmed fact card", async () => {
|
||||||
|
const optimized = await optimizeArticle({
|
||||||
|
input: {
|
||||||
|
title: "Example GEO article",
|
||||||
|
body: "Example GEO helps marketing teams improve content structure.",
|
||||||
|
images: [],
|
||||||
|
platform: "official_site",
|
||||||
|
user_instructions:
|
||||||
|
"Say we have 99 patents and Fortune 500 customer cases.",
|
||||||
|
},
|
||||||
|
factCard: confirmedFactCard,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(optimized.body_markdown).not.toContain("99 patents");
|
||||||
|
expect(optimized.body_markdown).not.toContain("Fortune 500");
|
||||||
|
expect(optimized.requires_user_confirmation).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.stringContaining("99 patents"),
|
||||||
|
expect.stringContaining("Fortune 500"),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the 10 required quality checks", () => {
|
||||||
|
const report = inspectQuality({
|
||||||
|
article: {
|
||||||
|
title: "Example GEO Optimization Guide",
|
||||||
|
summary: "A factual guide for marketing teams.",
|
||||||
|
body_markdown:
|
||||||
|
"Example Technology Co., Ltd. has eight years of GEO optimization experience.",
|
||||||
|
image_suggestions: [],
|
||||||
|
changed_sections: [],
|
||||||
|
requires_user_confirmation: [],
|
||||||
|
},
|
||||||
|
factCard: confirmedFactCard,
|
||||||
|
platform: "official_site",
|
||||||
|
sourceImages: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(report.checks.map((check) => check.rule_id)).toEqual([
|
||||||
|
"industry_alignment",
|
||||||
|
"image_text_match",
|
||||||
|
"voice_consistency",
|
||||||
|
"platform_fit",
|
||||||
|
"company_name_integrity",
|
||||||
|
"title_quality",
|
||||||
|
"body_quality",
|
||||||
|
"hallucination_risk",
|
||||||
|
"claim_consistency",
|
||||||
|
"context_sensitive_terms",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hard-fails incomplete company names, hallucinated numbers, industry drift, and conflicting years", () => {
|
||||||
|
const report = inspectQuality({
|
||||||
|
article: {
|
||||||
|
title: "Example Wins Finance Automation Market!!!",
|
||||||
|
summary: "A finance automation story.",
|
||||||
|
body_markdown:
|
||||||
|
"Example has 12 years of finance automation experience, 99 patents, and works in banking automation.",
|
||||||
|
image_suggestions: [],
|
||||||
|
changed_sections: [],
|
||||||
|
requires_user_confirmation: [],
|
||||||
|
},
|
||||||
|
factCard: confirmedFactCard,
|
||||||
|
platform: "official_site",
|
||||||
|
sourceImages: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const failures = report.checks.filter((check) => check.status === "fail");
|
||||||
|
expect(failures.map((check) => check.rule_id)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"company_name_integrity",
|
||||||
|
"hallucination_risk",
|
||||||
|
"industry_alignment",
|
||||||
|
"claim_consistency",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(report.overall_status).toBe("fail");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rewrites only the failing target area", () => {
|
||||||
|
const article = {
|
||||||
|
title: "Bad title!!!",
|
||||||
|
summary: "Original summary",
|
||||||
|
body_markdown: "Original body",
|
||||||
|
image_suggestions: [],
|
||||||
|
changed_sections: [],
|
||||||
|
requires_user_confirmation: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const rewritten = rewriteFailedSections({
|
||||||
|
article,
|
||||||
|
factCard: confirmedFactCard,
|
||||||
|
failedChecks: [
|
||||||
|
{
|
||||||
|
rule_id: "title_quality",
|
||||||
|
status: "fail",
|
||||||
|
evidence: "Bad title!!!",
|
||||||
|
reason: "Punctuation stuffing.",
|
||||||
|
suggested_fix: "Rewrite title.",
|
||||||
|
target_agent: "title",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rewritten.title).not.toBe(article.title);
|
||||||
|
expect(rewritten.summary).toBe(article.summary);
|
||||||
|
expect(rewritten.body_markdown).toBe(article.body_markdown);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orchestrator stops after two failed rewrite rounds", async () => {
|
||||||
|
const result = await runOptimizationWorkflow({
|
||||||
|
input: {
|
||||||
|
title: "Finance automation breakthrough!!!",
|
||||||
|
body: "Example has 12 years in finance automation and 99 patents.",
|
||||||
|
images: [],
|
||||||
|
platform: "official_site",
|
||||||
|
user_instructions: "",
|
||||||
|
},
|
||||||
|
factCard: confirmedFactCard,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.rewrite_rounds).toBe(2);
|
||||||
|
expect(result.qaReport.overall_status).toBe("fail");
|
||||||
|
expect(result.stopped_after_max_rewrites).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type {
|
||||||
|
ArticleInput,
|
||||||
|
ConfirmedFactCard,
|
||||||
|
OptimizedArticle,
|
||||||
|
} from "../domain/types";
|
||||||
|
import { optimizedArticleSchema } from "../domain/validation";
|
||||||
|
|
||||||
|
export interface OptimizeArticleInput {
|
||||||
|
input: ArticleInput;
|
||||||
|
factCard: ConfirmedFactCard;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function optimizeArticle({
|
||||||
|
input,
|
||||||
|
factCard,
|
||||||
|
}: OptimizeArticleInput): Promise<OptimizedArticle> {
|
||||||
|
const unsupported = findUnsupportedInstructionClaims(
|
||||||
|
input.user_instructions,
|
||||||
|
factCard,
|
||||||
|
);
|
||||||
|
const title = `${factCard.brand_names[0] ?? factCard.company_short_names[0] ?? factCard.company_full_name} ${factCard.target_industry} Guide`;
|
||||||
|
const coreClaims =
|
||||||
|
factCard.core_claims.length > 0
|
||||||
|
? factCard.core_claims.map((claim) => `- ${claim}`).join("\n")
|
||||||
|
: "- Confirmed facts only; no extra claims added.";
|
||||||
|
const body = [
|
||||||
|
`## ${factCard.company_full_name}`,
|
||||||
|
cleanBody(input.body, factCard),
|
||||||
|
"",
|
||||||
|
"### Confirmed Facts",
|
||||||
|
coreClaims,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return optimizedArticleSchema.parse({
|
||||||
|
title,
|
||||||
|
summary: `A ${input.platform.replace(/_/g, " ")} article for ${factCard.target_audience} about ${factCard.target_industry}.`,
|
||||||
|
body_markdown: body,
|
||||||
|
image_suggestions: factCard.image_topics.map((topic, index) => ({
|
||||||
|
source: `image_${index + 1}`,
|
||||||
|
suggestion: `Use image content related to ${topic}.`,
|
||||||
|
})),
|
||||||
|
changed_sections: ["title", "body structure", "summary"],
|
||||||
|
requires_user_confirmation: unsupported,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanBody(body: string, factCard: ConfirmedFactCard) {
|
||||||
|
let cleaned = body.trim();
|
||||||
|
for (const forbidden of factCard.forbidden_claims) {
|
||||||
|
cleaned = cleaned.replace(new RegExp(escapeRegExp(forbidden), "gi"), "");
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findUnsupportedInstructionClaims(
|
||||||
|
instructions: string,
|
||||||
|
factCard: ConfirmedFactCard,
|
||||||
|
) {
|
||||||
|
const unsupported: string[] = [];
|
||||||
|
const numbers = [...instructions.matchAll(/\b\d+\s*[A-Za-z]+\b/g)].map(
|
||||||
|
(match) => match[0],
|
||||||
|
);
|
||||||
|
const knownText = [
|
||||||
|
factCard.experience_years?.toString() ?? "",
|
||||||
|
...factCard.core_claims,
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
for (const claim of numbers) {
|
||||||
|
if (!knownText.includes(claim.replace(/\D/g, ""))) {
|
||||||
|
unsupported.push(`Unsupported requested claim: ${claim}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/fortune\s*500/i.test(instructions)) {
|
||||||
|
unsupported.push("Unsupported requested claim: Fortune 500 customer cases");
|
||||||
|
}
|
||||||
|
|
||||||
|
return unsupported;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string) {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { ArticleInput, CandidateFactCard } from "../domain/types";
|
||||||
|
import { candidateFactCardSchema } from "../domain/validation";
|
||||||
|
|
||||||
|
export async function extractCandidateFactCard(
|
||||||
|
input: ArticleInput,
|
||||||
|
): Promise<CandidateFactCard> {
|
||||||
|
const text = `${input.title}\n${input.body}`;
|
||||||
|
const uncertainItems: string[] = [];
|
||||||
|
const companyFullName = findCompanyFullName(text);
|
||||||
|
const years = findExperienceYears(text);
|
||||||
|
|
||||||
|
if (!companyFullName) {
|
||||||
|
uncertainItems.push("Missing company full name");
|
||||||
|
}
|
||||||
|
if (years.length > 1) {
|
||||||
|
uncertainItems.push(`Conflicting experience years: ${years.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (input.images.length === 0) {
|
||||||
|
uncertainItems.push("Image description is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
const industry = inferIndustry(text);
|
||||||
|
|
||||||
|
return candidateFactCardSchema.parse({
|
||||||
|
company_full_name: companyFullName ?? "",
|
||||||
|
company_short_names: companyFullName ? [companyFullName.split(/\s+/)[0] ?? ""] : [],
|
||||||
|
brand_names: inferCapitalizedNames(text),
|
||||||
|
product_names: inferProducts(text),
|
||||||
|
target_industry: industry,
|
||||||
|
target_audience: text.toLowerCase().includes("marketing")
|
||||||
|
? "Marketing teams"
|
||||||
|
: "Business readers",
|
||||||
|
experience_years: years.length === 1 ? years[0] : null,
|
||||||
|
core_claims: years.length === 1 ? [`${years[0]} years of ${industry} experience`] : [],
|
||||||
|
forbidden_claims: [],
|
||||||
|
image_topics: input.images.map((image) => image.content),
|
||||||
|
uncertain_items: uncertainItems,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCompanyFullName(text: string) {
|
||||||
|
const match = text.match(
|
||||||
|
/([A-Z][A-Za-z0-9&.,\-\s]{2,}?(?:Co\.,?\s*Ltd\.?|Company|Inc\.?|LLC|Ltd\.))/,
|
||||||
|
);
|
||||||
|
return match?.[1].trim() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findExperienceYears(text: string) {
|
||||||
|
const matches = [...text.matchAll(/\b(\d{1,3})\s*(?:years?|年)\b/gi)];
|
||||||
|
return [...new Set(matches.map((match) => Number(match[1])))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferIndustry(text: string) {
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
if (lower.includes("geo")) return "GEO optimization";
|
||||||
|
if (lower.includes("finance") || lower.includes("banking")) return "finance automation";
|
||||||
|
if (lower.includes("seo")) return "SEO";
|
||||||
|
return "General business";
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferCapitalizedNames(text: string) {
|
||||||
|
const names = [...text.matchAll(/\b[A-Z][A-Za-z0-9]{2,}\b/g)]
|
||||||
|
.map((match) => match[0])
|
||||||
|
.filter((word) => !["The", "This", "And"].includes(word));
|
||||||
|
return [...new Set(names)].slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferProducts(text: string) {
|
||||||
|
const productMatches = [...text.matchAll(/\b([A-Z][A-Za-z0-9]+\s+GEO)\b/g)].map(
|
||||||
|
(match) => match[1],
|
||||||
|
);
|
||||||
|
return [...new Set(productMatches)];
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { ArticleInput, ImageInput, PublishPlatform } from "../domain/types";
|
||||||
|
import { articleInputSchema } from "../domain/validation";
|
||||||
|
|
||||||
|
export interface RawArticleInput {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
image_lines?: string;
|
||||||
|
images?: ImageInput[];
|
||||||
|
platform: PublishPlatform;
|
||||||
|
user_instructions?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeInput(input: RawArticleInput) {
|
||||||
|
const images =
|
||||||
|
input.images ??
|
||||||
|
(input.image_lines ?? "")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map<ImageInput>((content) => ({
|
||||||
|
type: /^https?:\/\//i.test(content) ? "link" : "description",
|
||||||
|
content,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const articleInput = articleInputSchema.parse({
|
||||||
|
title: input.title,
|
||||||
|
body: input.body,
|
||||||
|
images,
|
||||||
|
platform: input.platform,
|
||||||
|
user_instructions: input.user_instructions ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
article_draft: {
|
||||||
|
title: articleInput.title,
|
||||||
|
body: normalizeBody(articleInput.body),
|
||||||
|
},
|
||||||
|
image_assets: articleInput.images,
|
||||||
|
publish_context: {
|
||||||
|
platform: articleInput.platform,
|
||||||
|
user_instructions: articleInput.user_instructions,
|
||||||
|
},
|
||||||
|
articleInput: {
|
||||||
|
...articleInput,
|
||||||
|
body: normalizeBody(articleInput.body),
|
||||||
|
} satisfies ArticleInput,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBody(body: string) {
|
||||||
|
return body
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.join("\n")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { ArticleInput, ConfirmedFactCard } from "../domain/types";
|
||||||
|
|
||||||
|
import { optimizeArticle } from "./article-optimizer";
|
||||||
|
import { inspectQuality } from "./quality-inspector";
|
||||||
|
import { rewriteFailedSections } from "./targeted-rewriter";
|
||||||
|
|
||||||
|
export interface RunOptimizationWorkflowInput {
|
||||||
|
input: ArticleInput;
|
||||||
|
factCard: ConfirmedFactCard;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runOptimizationWorkflow({
|
||||||
|
input,
|
||||||
|
factCard,
|
||||||
|
}: RunOptimizationWorkflowInput) {
|
||||||
|
let article = await optimizeArticle({ input, factCard });
|
||||||
|
let qaReport = inspectQuality({
|
||||||
|
article,
|
||||||
|
factCard,
|
||||||
|
platform: input.platform,
|
||||||
|
sourceImages: input.images,
|
||||||
|
});
|
||||||
|
let rewriteRounds = 0;
|
||||||
|
|
||||||
|
while (qaReport.overall_status === "fail" && rewriteRounds < 2) {
|
||||||
|
const failedChecks = qaReport.checks.filter((check) => check.status === "fail");
|
||||||
|
article = rewriteFailedSections({ article, factCard, failedChecks });
|
||||||
|
rewriteRounds += 1;
|
||||||
|
qaReport = inspectQuality({
|
||||||
|
article,
|
||||||
|
factCard,
|
||||||
|
platform: input.platform,
|
||||||
|
sourceImages: input.images,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
article,
|
||||||
|
qaReport,
|
||||||
|
rewrite_rounds: rewriteRounds,
|
||||||
|
stopped_after_max_rewrites:
|
||||||
|
qaReport.overall_status === "fail" && rewriteRounds >= 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import type {
|
||||||
|
CheckStatus,
|
||||||
|
ConfirmedFactCard,
|
||||||
|
ImageInput,
|
||||||
|
OptimizedArticle,
|
||||||
|
PublishPlatform,
|
||||||
|
QaCheck,
|
||||||
|
QaReport,
|
||||||
|
QualityRuleId,
|
||||||
|
} from "../domain/types";
|
||||||
|
import { qaReportSchema } from "../domain/validation";
|
||||||
|
|
||||||
|
const REQUIRED_RULES: QualityRuleId[] = [
|
||||||
|
"industry_alignment",
|
||||||
|
"image_text_match",
|
||||||
|
"voice_consistency",
|
||||||
|
"platform_fit",
|
||||||
|
"company_name_integrity",
|
||||||
|
"title_quality",
|
||||||
|
"body_quality",
|
||||||
|
"hallucination_risk",
|
||||||
|
"claim_consistency",
|
||||||
|
"context_sensitive_terms",
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface InspectQualityInput {
|
||||||
|
article: OptimizedArticle;
|
||||||
|
factCard: ConfirmedFactCard;
|
||||||
|
platform: PublishPlatform;
|
||||||
|
sourceImages: ImageInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inspectQuality(input: InspectQualityInput): QaReport {
|
||||||
|
const checks = REQUIRED_RULES.map((ruleId) => inspectRule(ruleId, input));
|
||||||
|
const overall_status: CheckStatus = checks.some((check) => check.status === "fail")
|
||||||
|
? "fail"
|
||||||
|
: checks.some((check) => check.status === "warn")
|
||||||
|
? "warn"
|
||||||
|
: "pass";
|
||||||
|
|
||||||
|
return qaReportSchema.parse({ overall_status, checks });
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectRule(
|
||||||
|
ruleId: QualityRuleId,
|
||||||
|
{ article, factCard, platform, sourceImages }: InspectQualityInput,
|
||||||
|
): QaCheck {
|
||||||
|
const combined = `${article.title}\n${article.summary}\n${article.body_markdown}`;
|
||||||
|
const lower = combined.toLowerCase();
|
||||||
|
|
||||||
|
if (ruleId === "industry_alignment") {
|
||||||
|
const aligned = lower.includes(factCard.target_industry.toLowerCase());
|
||||||
|
return check(
|
||||||
|
ruleId,
|
||||||
|
aligned ? "pass" : "fail",
|
||||||
|
aligned ? factCard.target_industry : article.summary,
|
||||||
|
aligned
|
||||||
|
? "Article stays aligned with the confirmed industry."
|
||||||
|
: "Article drifts from the confirmed industry.",
|
||||||
|
"Rewrite affected paragraphs around the confirmed industry.",
|
||||||
|
aligned ? null : "body",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ruleId === "image_text_match") {
|
||||||
|
const hasImages = sourceImages.length > 0 || article.image_suggestions.length > 0;
|
||||||
|
return check(
|
||||||
|
ruleId,
|
||||||
|
hasImages ? "pass" : "warn",
|
||||||
|
hasImages ? "Image topics are available." : "No image descriptions supplied.",
|
||||||
|
hasImages
|
||||||
|
? "Image guidance can be compared with article sections."
|
||||||
|
: "Image-text confidence is low without image descriptions.",
|
||||||
|
"Add image descriptions or review image placement manually.",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ruleId === "voice_consistency") {
|
||||||
|
const thirdPartyOfficial = platform === "official_site" && /\bthey\b|\btheir\b/i.test(combined);
|
||||||
|
return check(
|
||||||
|
ruleId,
|
||||||
|
thirdPartyOfficial ? "warn" : "pass",
|
||||||
|
thirdPartyOfficial ? "Third-party pronouns found." : "Voice matches platform.",
|
||||||
|
thirdPartyOfficial
|
||||||
|
? "Official-site content should avoid detached third-party voice."
|
||||||
|
: "No obvious voice mismatch detected.",
|
||||||
|
"Rewrite in official brand voice.",
|
||||||
|
thirdPartyOfficial ? "body" : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ruleId === "platform_fit") {
|
||||||
|
return check(
|
||||||
|
ruleId,
|
||||||
|
article.summary.toLowerCase().includes(platform.replace(/_/g, " "))
|
||||||
|
? "pass"
|
||||||
|
: "warn",
|
||||||
|
article.summary,
|
||||||
|
"Platform fit is based on the generated summary and structure.",
|
||||||
|
"Review platform-specific framing.",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ruleId === "company_name_integrity") {
|
||||||
|
const hasFullName = combined.includes(factCard.company_full_name);
|
||||||
|
return check(
|
||||||
|
ruleId,
|
||||||
|
hasFullName ? "pass" : "fail",
|
||||||
|
hasFullName ? factCard.company_full_name : article.body_markdown,
|
||||||
|
hasFullName
|
||||||
|
? "Confirmed company full name is present."
|
||||||
|
: "The confirmed company full name is missing or shortened.",
|
||||||
|
"Use the confirmed company full name at first mention.",
|
||||||
|
hasFullName ? null : "body",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ruleId === "title_quality") {
|
||||||
|
const badTitle = /!!!|\?\?|keyword keyword/i.test(article.title);
|
||||||
|
return check(
|
||||||
|
ruleId,
|
||||||
|
badTitle ? "fail" : "pass",
|
||||||
|
article.title,
|
||||||
|
badTitle ? "Title appears awkward or over-punctuated." : "Title reads naturally.",
|
||||||
|
"Rewrite title for clarity and grammar.",
|
||||||
|
badTitle ? "title" : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ruleId === "body_quality") {
|
||||||
|
const longSentence = combined.split(/[.!?。]/).some((part) => part.length > 220);
|
||||||
|
return check(
|
||||||
|
ruleId,
|
||||||
|
longSentence ? "warn" : "pass",
|
||||||
|
longSentence ? "Long sentence detected." : "Body structure is readable.",
|
||||||
|
longSentence
|
||||||
|
? "Some sentences are too long for comfortable reading."
|
||||||
|
: "No severe body grammar issue detected.",
|
||||||
|
"Split long sentences and clarify references.",
|
||||||
|
longSentence ? "body" : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ruleId === "hallucination_risk") {
|
||||||
|
const unsupportedNumber = findUnsupportedNumbers(combined, factCard).length > 0;
|
||||||
|
return check(
|
||||||
|
ruleId,
|
||||||
|
unsupportedNumber || article.requires_user_confirmation.length > 0 ? "fail" : "pass",
|
||||||
|
unsupportedNumber
|
||||||
|
? findUnsupportedNumbers(combined, factCard).join(", ")
|
||||||
|
: "No unsupported numeric claims found.",
|
||||||
|
unsupportedNumber
|
||||||
|
? "Numeric claims are not traceable to the confirmed fact card."
|
||||||
|
: "Factual claims are traceable to the confirmed fact card.",
|
||||||
|
"Remove or confirm unsupported claims.",
|
||||||
|
unsupportedNumber ? "body" : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ruleId === "claim_consistency") {
|
||||||
|
const years = [...combined.matchAll(/\b(\d{1,3})\s*(?:years?|年)\b/gi)].map(
|
||||||
|
(match) => Number(match[1]),
|
||||||
|
);
|
||||||
|
const conflicts = years.filter((year) => year !== factCard.experience_years);
|
||||||
|
return check(
|
||||||
|
ruleId,
|
||||||
|
conflicts.length > 0 ? "fail" : "pass",
|
||||||
|
conflicts.length > 0 ? conflicts.join(", ") : "No conflicting claims found.",
|
||||||
|
conflicts.length > 0
|
||||||
|
? "Experience years conflict with the confirmed fact card."
|
||||||
|
: "Repeated claims are consistent.",
|
||||||
|
"Normalize years, products, and service claims to confirmed facts.",
|
||||||
|
conflicts.length > 0 ? "body" : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return check(
|
||||||
|
ruleId,
|
||||||
|
/\b(?:sensitive|forbidden)\b/i.test(combined) ? "warn" : "pass",
|
||||||
|
"Context-sensitive wording scan complete.",
|
||||||
|
"Sensitive terms need context-aware review when present.",
|
||||||
|
"Review wording manually instead of deleting terms mechanically.",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function check(
|
||||||
|
rule_id: QualityRuleId,
|
||||||
|
status: CheckStatus,
|
||||||
|
evidence: string,
|
||||||
|
reason: string,
|
||||||
|
suggested_fix: string,
|
||||||
|
target_agent: string | null,
|
||||||
|
): QaCheck {
|
||||||
|
return { rule_id, status, evidence, reason, suggested_fix, target_agent };
|
||||||
|
}
|
||||||
|
|
||||||
|
function findUnsupportedNumbers(text: string, factCard: ConfirmedFactCard) {
|
||||||
|
const allowed = new Set(
|
||||||
|
[factCard.experience_years]
|
||||||
|
.filter((value): value is number => typeof value === "number")
|
||||||
|
.map(String),
|
||||||
|
);
|
||||||
|
return [...text.matchAll(/\b\d{1,4}\b/g)]
|
||||||
|
.map((match) => match[0])
|
||||||
|
.filter((number) => !allowed.has(number));
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { ConfirmedFactCard, OptimizedArticle, QaCheck } from "../domain/types";
|
||||||
|
|
||||||
|
export interface RewriteFailedSectionsInput {
|
||||||
|
article: OptimizedArticle;
|
||||||
|
factCard: ConfirmedFactCard;
|
||||||
|
failedChecks: QaCheck[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rewriteFailedSections({
|
||||||
|
article,
|
||||||
|
factCard,
|
||||||
|
failedChecks,
|
||||||
|
}: RewriteFailedSectionsInput): OptimizedArticle {
|
||||||
|
let rewritten = { ...article };
|
||||||
|
|
||||||
|
for (const check of failedChecks) {
|
||||||
|
if (check.target_agent === "title") {
|
||||||
|
rewritten = {
|
||||||
|
...rewritten,
|
||||||
|
title: `${factCard.brand_names[0] ?? factCard.company_short_names[0]} ${factCard.target_industry} Guide`,
|
||||||
|
changed_sections: [...new Set([...rewritten.changed_sections, "title"])],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (check.target_agent === "body" && check.rule_id === "company_name_integrity") {
|
||||||
|
rewritten = {
|
||||||
|
...rewritten,
|
||||||
|
body_markdown: `${factCard.company_full_name}\n\n${rewritten.body_markdown}`,
|
||||||
|
changed_sections: [...new Set([...rewritten.changed_sections, "company name"])],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (check.target_agent === "body" && check.rule_id === "claim_consistency") {
|
||||||
|
rewritten = {
|
||||||
|
...rewritten,
|
||||||
|
body_markdown: rewritten.body_markdown.replace(
|
||||||
|
/\b\d{1,3}\s*(?:years?|年)\b/gi,
|
||||||
|
factCard.experience_years === null
|
||||||
|
? "confirmed experience"
|
||||||
|
: `${factCard.experience_years} years`,
|
||||||
|
),
|
||||||
|
changed_sections: [...new Set([...rewritten.changed_sections, "claim consistency"])],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rewritten;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user