308 lines
9.7 KiB
TypeScript
308 lines
9.7 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[];
|
|
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",
|
|
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]));
|
|
}
|