feat: implement article optimization workflow

This commit is contained in:
Codex
2026-06-21 23:42:37 +08:00
parent cb56ff9ee7
commit 75bcab33c6
10 changed files with 834 additions and 1 deletions
+209
View File
@@ -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));
}