Files
GEOAgentArticleOptimizer/src/lib/workflow/quality-inspector.ts
T
2026-07-16 12:19:16 +08:00

314 lines
9.9 KiB
TypeScript

import type {
CheckStatus,
OptimizationFactCard,
ImageInput,
OptimizedArticle,
PublishPlatform,
QaCheck,
QaReport,
QualityRuleId,
} from "../domain/types";
import { qaCheckSchema, qaReportSchema } from "../domain/validation";
import { generateValidatedJson, type GenerateInput } from "../llm/client";
import {
QUALITY_INSPECTOR_SYSTEM_PROMPT,
buildQualityInspectorPrompt,
} from "../llm/prompts";
import { z } from "zod";
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",
];
const HARD_FAILURE_RULES = new Set<QualityRuleId>([
"company_name_integrity",
"claim_consistency",
]);
const llmQaPatchSchema = z.object({
checks: z.array(qaCheckSchema).default([]),
});
export interface InspectQualityInput {
article: OptimizedArticle;
factCard: OptimizationFactCard;
platform: PublishPlatform;
sourceImages: ImageInput[];
rewriteRound?: number;
onTraceEvent?: GenerateInput["onTraceEvent"];
onAuditSummary?: GenerateInput["onAuditSummary"];
}
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 });
}
export async function inspectQualityWithLlm(
input: InspectQualityInput,
): Promise<QaReport> {
const deterministicReport = inspectQuality(input);
const llmPatch = await generateValidatedJson({
schema: llmQaPatchSchema,
system: QUALITY_INSPECTOR_SYSTEM_PROMPT,
prompt: buildQualityInspectorPrompt({
article: input.article,
factCard: input.factCard,
platform: input.platform,
deterministicChecks: deterministicReport.checks,
}),
temperature: 0.1,
task: "quality_inspector",
schemaName: "llmQaPatchSchema",
traceStage: "qa",
rewriteRound: input.rewriteRound,
onTraceEvent: input.onTraceEvent,
onAuditSummary: input.onAuditSummary,
});
const patchedChecks = deterministicReport.checks.map((deterministicCheck) => {
const llmCheck = llmPatch.checks.find(
(check) => check.rule_id === deterministicCheck.rule_id,
);
if (!llmCheck) {
return deterministicCheck;
}
if (deterministicCheck.status === "fail") {
return deterministicCheck;
}
const status =
llmCheck.status === "fail" && !HARD_FAILURE_RULES.has(deterministicCheck.rule_id)
? "warn"
: llmCheck.status;
return {
...deterministicCheck,
status,
evidence: llmCheck.evidence,
reason: llmCheck.reason,
suggested_fix: llmCheck.suggested_fix,
target_agent: llmCheck.target_agent,
};
});
const overall_status: CheckStatus = patchedChecks.some(
(check) => check.status === "fail",
)
? "fail"
: patchedChecks.some((check) => check.status === "warn")
? "warn"
: "pass";
return qaReportSchema.parse({ overall_status, checks: patchedChecks });
}
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 targetIndustry = factCard.target_industry.trim().toLowerCase();
const aligned = targetIndustry.length === 0 || lower.includes(targetIndustry);
return check(
ruleId,
aligned ? "pass" : "warn",
aligned ? factCard.target_industry : article.summary,
aligned
? "文章内容与事实卡确认的目标行业一致。"
: "文章可能没有充分体现事实卡确认的目标行业。",
"围绕事实卡确认的目标行业重写相关段落。",
aligned ? null : "body",
);
}
if (ruleId === "image_text_match") {
return check(
ruleId,
"pass",
sourceImages.length > 0 ? "当前版本暂不评估图片内容。" : "当前版本未启用图片分析。",
"当前版本仅优化文本,图片匹配检查暂不参与质量门禁。",
"后续启用图片工作流后再补充图文匹配检查。",
null,
);
}
if (ruleId === "voice_consistency") {
const thirdPartyOfficial = platform === "official_site" && /\bthey\b|\btheir\b/i.test(combined);
return check(
ruleId,
thirdPartyOfficial ? "warn" : "pass",
thirdPartyOfficial ? "发现第三方代词。" : "表达视角符合平台要求。",
thirdPartyOfficial
? "官网文章应避免疏离的第三方叙述口吻。"
: "未发现明显的平台口吻不一致问题。",
"改写为更适合官网的品牌表达。",
thirdPartyOfficial ? "body" : null,
);
}
if (ruleId === "platform_fit") {
return check(
ruleId,
article.summary.toLowerCase().includes(platform.replace(/_/g, " "))
? "pass"
: "warn",
article.summary,
"平台适配基于生成摘要与文章结构判断。",
"复核文章是否符合目标平台的表达方式和结构。",
null,
);
}
if (ruleId === "company_name_integrity") {
const companyFullName = factCard.company_full_name.trim();
if (companyFullName.length === 0) {
return check(
ruleId,
"warn",
"事实卡尚未提供公司全称。",
"无法执行公司全称一致性硬性检查,因为事实卡中的公司全称仍待确认。",
"补充公司全称,或确认当前文案可以使用简称。",
"fact_card",
);
}
const hasFullName = combined.includes(companyFullName);
return check(
ruleId,
hasFullName ? "pass" : "fail",
hasFullName ? companyFullName : article.body_markdown,
hasFullName
? "文章中包含事实卡确认的公司全称。"
: "文章缺少事实卡确认的公司全称,或使用了不完整简称。",
"首次出现公司时使用事实卡确认的公司全称。",
hasFullName ? null : "body",
);
}
if (ruleId === "title_quality") {
const badTitle = /!!!|\?\?|keyword keyword/i.test(article.title);
return check(
ruleId,
badTitle ? "fail" : "pass",
article.title,
badTitle ? "标题存在语义不顺或标点堆砌问题。" : "标题表达自然清晰。",
"重写标题,提升语义清晰度和语法质量。",
badTitle ? "title" : null,
);
}
if (ruleId === "body_quality") {
const longSentence = combined.split(/[.!?。]/).some((part) => part.length > 220);
return check(
ruleId,
longSentence ? "warn" : "pass",
longSentence ? "发现过长句子。" : "正文结构可读。",
longSentence
? "部分句子过长,影响阅读和理解。"
: "未发现严重正文语法问题。",
"拆分长句,并明确指代关系。",
longSentence ? "body" : null,
);
}
if (ruleId === "hallucination_risk") {
const unsupportedNumber = findUnsupportedNumbers(combined, factCard).length > 0;
return check(
ruleId,
unsupportedNumber || article.requires_user_confirmation.length > 0 ? "warn" : "pass",
unsupportedNumber
? findUnsupportedNumbers(combined, factCard).join(", ")
: "未发现未确认的数字类事实主张。",
unsupportedNumber
? "发现需要人工复核的数字类事实主张。"
: "事实主张可以追溯到已确认事实卡。",
"建议人工复核,必要时删除未确认主张,或补充到事实卡后再确认。",
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(", ") : "未发现冲突事实。",
conflicts.length > 0
? "经验年限与事实卡确认内容冲突。"
: "重复出现的事实主张保持一致。",
"将年限、产品和服务表述统一为事实卡确认内容。",
conflicts.length > 0 ? "body" : null,
);
}
return check(
ruleId,
/\b(?:sensitive|forbidden)\b/i.test(combined) ? "warn" : "pass",
"已完成语境敏感词检查。",
"涉及敏感或禁用表达时,需要结合上下文判断。",
"人工复核相关表述,避免机械删除导致语义错误。",
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: OptimizationFactCard) {
const allowed = new Set(
[
factCard.experience_years,
...extractNumbersFromFactCard(factCard),
]
.filter((value): value is number | string =>
typeof value === "number" || typeof value === "string",
)
.map(String),
);
return [...text.matchAll(/\b\d{1,4}\b/g)]
.map((match) => match[0])
.filter((number) => !allowed.has(number));
}
function extractNumbersFromFactCard(factCard: OptimizationFactCard) {
return [
factCard.company_full_name,
...factCard.company_short_names,
...factCard.brand_names,
...factCard.product_names,
factCard.target_industry,
factCard.target_audience,
...factCard.core_claims,
...factCard.forbidden_claims,
...factCard.image_topics,
].flatMap((value) => [...value.matchAll(/\b\d{1,4}\b/g)].map((match) => match[0]));
}