From 75bcab33c60989b4ce9aeb6c532bfffbdcea0cf0 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 16 Jun 2026 13:34:02 +0800 Subject: [PATCH] feat: implement article optimization workflow --- src/lib/db/repositories.ts | 2 +- src/lib/llm/client.ts | 112 +++++++++++ src/lib/llm/prompts.ts | 14 ++ src/lib/workflow/__tests__/workflow.test.ts | 193 ++++++++++++++++++ src/lib/workflow/article-optimizer.ts | 83 ++++++++ src/lib/workflow/fact-extractor.ts | 73 +++++++ src/lib/workflow/input-normalizer.ts | 57 ++++++ src/lib/workflow/orchestrator.ts | 44 +++++ src/lib/workflow/quality-inspector.ts | 209 ++++++++++++++++++++ src/lib/workflow/targeted-rewriter.ts | 48 +++++ 10 files changed, 834 insertions(+), 1 deletion(-) create mode 100644 src/lib/llm/client.ts create mode 100644 src/lib/llm/prompts.ts create mode 100644 src/lib/workflow/__tests__/workflow.test.ts create mode 100644 src/lib/workflow/article-optimizer.ts create mode 100644 src/lib/workflow/fact-extractor.ts create mode 100644 src/lib/workflow/input-normalizer.ts create mode 100644 src/lib/workflow/orchestrator.ts create mode 100644 src/lib/workflow/quality-inspector.ts create mode 100644 src/lib/workflow/targeted-rewriter.ts diff --git a/src/lib/db/repositories.ts b/src/lib/db/repositories.ts index 6c64eab..f889b92 100644 --- a/src/lib/db/repositories.ts +++ b/src/lib/db/repositories.ts @@ -6,7 +6,7 @@ import type { OptimizedArticle, PublishPlatform, QaReport, -} from "@/lib/domain/types"; +} from "../domain/types"; import { createDatabase, getDefaultDatabasePath } from "./connection"; import { initializeSchema } from "./schema"; diff --git a/src/lib/llm/client.ts b/src/lib/llm/client.ts new file mode 100644 index 0000000..d6fc523 --- /dev/null +++ b/src/lib/llm/client.ts @@ -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(input: GenerateInput): Promise { + 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}`); +} diff --git a/src/lib/llm/prompts.ts b/src/lib/llm/prompts.ts new file mode 100644 index 0000000..77ace34 --- /dev/null +++ b/src/lib/llm/prompts.ts @@ -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(" "); diff --git a/src/lib/workflow/__tests__/workflow.test.ts b/src/lib/workflow/__tests__/workflow.test.ts new file mode 100644 index 0000000..6605cad --- /dev/null +++ b/src/lib/workflow/__tests__/workflow.test.ts @@ -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); + }); +}); diff --git a/src/lib/workflow/article-optimizer.ts b/src/lib/workflow/article-optimizer.ts new file mode 100644 index 0000000..3b7cbf7 --- /dev/null +++ b/src/lib/workflow/article-optimizer.ts @@ -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 { + 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, "\\$&"); +} diff --git a/src/lib/workflow/fact-extractor.ts b/src/lib/workflow/fact-extractor.ts new file mode 100644 index 0000000..f38510e --- /dev/null +++ b/src/lib/workflow/fact-extractor.ts @@ -0,0 +1,73 @@ +import type { ArticleInput, CandidateFactCard } from "../domain/types"; +import { candidateFactCardSchema } from "../domain/validation"; + +export async function extractCandidateFactCard( + input: ArticleInput, +): Promise { + 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)]; +} diff --git a/src/lib/workflow/input-normalizer.ts b/src/lib/workflow/input-normalizer.ts new file mode 100644 index 0000000..328334f --- /dev/null +++ b/src/lib/workflow/input-normalizer.ts @@ -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((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(); +} diff --git a/src/lib/workflow/orchestrator.ts b/src/lib/workflow/orchestrator.ts new file mode 100644 index 0000000..936b1da --- /dev/null +++ b/src/lib/workflow/orchestrator.ts @@ -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, + }; +} diff --git a/src/lib/workflow/quality-inspector.ts b/src/lib/workflow/quality-inspector.ts new file mode 100644 index 0000000..06ba461 --- /dev/null +++ b/src/lib/workflow/quality-inspector.ts @@ -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)); +} diff --git a/src/lib/workflow/targeted-rewriter.ts b/src/lib/workflow/targeted-rewriter.ts new file mode 100644 index 0000000..942fb08 --- /dev/null +++ b/src/lib/workflow/targeted-rewriter.ts @@ -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; +}