feat: implement article optimization workflow
This commit is contained in:
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user