223 lines
6.7 KiB
TypeScript
223 lines
6.7 KiB
TypeScript
import { nanoid } from "nanoid";
|
|
|
|
import type { OptimizedArticle, QaReport } from "../domain/types";
|
|
import type {
|
|
CalibrationContext,
|
|
CalibrationDirection,
|
|
CalibrationEvent,
|
|
RubricVersion,
|
|
ScoringRun,
|
|
} from "./types";
|
|
|
|
export const GEO_RUBRIC_V1: RubricVersion = {
|
|
id: "rubric_geo_v1",
|
|
version: "v1",
|
|
name: "GEO article performance rubric",
|
|
formula: "weighted_average_0_to_10",
|
|
is_active: true,
|
|
created_at: "2026-06-24T00:00:00.000Z",
|
|
dimensions: [
|
|
{
|
|
id: "fact_integrity",
|
|
label: "事实一致性",
|
|
weight: 2,
|
|
description: "事实、公司名、产品名和经验年限是否遵守事实卡。",
|
|
},
|
|
{
|
|
id: "platform_fit",
|
|
label: "平台适配",
|
|
weight: 1.5,
|
|
description: "表达是否匹配目标发布平台。",
|
|
},
|
|
{
|
|
id: "search_intent_fit",
|
|
label: "搜索意图匹配",
|
|
weight: 1.5,
|
|
description: "是否回答GEO/Search背后的用户问题。",
|
|
},
|
|
{
|
|
id: "answer_density",
|
|
label: "答案密度",
|
|
weight: 1.5,
|
|
description: "是否提供具体信息而不是泛泛宣传。",
|
|
},
|
|
{
|
|
id: "trust_signal_quality",
|
|
label: "信任信号质量",
|
|
weight: 1.5,
|
|
description: "可信依据是否具体、克制且可复核。",
|
|
},
|
|
{
|
|
id: "readability",
|
|
label: "可读性",
|
|
weight: 1,
|
|
description: "标题、摘要、正文是否清晰易读。",
|
|
},
|
|
],
|
|
};
|
|
|
|
interface ScoreOptimizedArticleInput {
|
|
jobId: string;
|
|
article: OptimizedArticle;
|
|
qaReport: QaReport;
|
|
}
|
|
|
|
export function scoreOptimizedArticle({
|
|
jobId,
|
|
article,
|
|
qaReport,
|
|
}: ScoreOptimizedArticleInput): ScoringRun {
|
|
const combined = `${article.title}\n${article.summary}\n${article.body_markdown}`;
|
|
const dimensionScores = {
|
|
fact_integrity: scoreFactIntegrity(qaReport),
|
|
platform_fit: scoreRuleGroup(qaReport, ["platform_fit", "voice_consistency"]),
|
|
search_intent_fit: hasGeoIntent(combined) ? 4 : 2,
|
|
answer_density: scoreAnswerDensity(combined),
|
|
trust_signal_quality: scoreTrustSignals(combined, qaReport),
|
|
readability: scoreReadability(article),
|
|
};
|
|
|
|
return {
|
|
id: `score_${nanoid(10)}`,
|
|
job_id: jobId,
|
|
revision: article.revision ?? 1,
|
|
rubric_version_id: GEO_RUBRIC_V1.id,
|
|
dimension_scores: dimensionScores,
|
|
composite_score: weightedComposite(dimensionScores),
|
|
rationale: buildRationale(dimensionScores),
|
|
created_at: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
function scoreFactIntegrity(report: QaReport) {
|
|
const hardRules = ["company_name_integrity", "claim_consistency", "hallucination_risk"];
|
|
const statuses = report.checks
|
|
.filter((check) => hardRules.includes(check.rule_id))
|
|
.map((check) => check.status);
|
|
if (statuses.includes("fail")) return 1;
|
|
if (statuses.includes("warn")) return 4;
|
|
return 5;
|
|
}
|
|
|
|
function scoreRuleGroup(report: QaReport, ruleIds: string[]) {
|
|
const statuses = report.checks
|
|
.filter((check) => ruleIds.includes(check.rule_id))
|
|
.map((check) => check.status);
|
|
if (statuses.includes("fail")) return 2;
|
|
if (statuses.includes("warn")) return 3;
|
|
return 4;
|
|
}
|
|
|
|
function hasGeoIntent(text: string) {
|
|
return /GEO|AI搜索|生成式引擎|搜索|可见性|问答|推荐/i.test(text);
|
|
}
|
|
|
|
function scoreAnswerDensity(text: string) {
|
|
const headings = (text.match(/^##\s+/gm) ?? []).length;
|
|
const concreteSignals = (
|
|
text.match(/服务|流程|方案|能力|团队|行业|客户|案例/g) ?? []
|
|
).length;
|
|
if (headings >= 2 && concreteSignals >= 8) return 5;
|
|
if (headings >= 1 && concreteSignals >= 4) return 4;
|
|
if (concreteSignals >= 2) return 3;
|
|
return 2;
|
|
}
|
|
|
|
function scoreTrustSignals(text: string, report: QaReport) {
|
|
const hallucination = report.checks.find(
|
|
(check) => check.rule_id === "hallucination_risk",
|
|
);
|
|
if (hallucination?.status === "fail") return 1;
|
|
const trustSignals = (text.match(/依据|经验|资质|案例|事实卡|复核|客户/g) ?? [])
|
|
.length;
|
|
if (hallucination?.status === "warn") return trustSignals >= 2 ? 3 : 2;
|
|
return trustSignals >= 2 ? 4 : 3;
|
|
}
|
|
|
|
function scoreReadability(article: OptimizedArticle) {
|
|
const hasLongSentence = `${article.summary}\n${article.body_markdown}`
|
|
.split(/[。!?.!?]/)
|
|
.some((sentence) => sentence.length > 180);
|
|
if (article.title.length > 42 || hasLongSentence) return 3;
|
|
return 4;
|
|
}
|
|
|
|
function weightedComposite(scores: Record<string, number>) {
|
|
const totalWeight = GEO_RUBRIC_V1.dimensions.reduce(
|
|
(sum, dimension) => sum + dimension.weight,
|
|
0,
|
|
);
|
|
const weighted = GEO_RUBRIC_V1.dimensions.reduce(
|
|
(sum, dimension) => sum + (scores[dimension.id] ?? 0) * dimension.weight,
|
|
0,
|
|
);
|
|
return Math.round((weighted / totalWeight) * 2 * 10) / 10;
|
|
}
|
|
|
|
function buildRationale(scores: Record<string, number>) {
|
|
return `事实一致性 ${scores.fact_integrity}/5,平台适配 ${scores.platform_fit}/5,答案密度 ${scores.answer_density}/5,信任信号 ${scores.trust_signal_quality}/5。`;
|
|
}
|
|
|
|
export function createCalibrationEvent({
|
|
scoringRun,
|
|
qaReport,
|
|
snapshot,
|
|
}: CalibrationContext): CalibrationEvent {
|
|
const direction = inferDirection(scoringRun.composite_score, snapshot.metrics);
|
|
const observations = buildObservations(
|
|
direction,
|
|
scoringRun,
|
|
qaReport,
|
|
snapshot.feedback_summary,
|
|
);
|
|
|
|
return {
|
|
id: `cal_${nanoid(10)}`,
|
|
publication_id: snapshot.publication_id,
|
|
scoring_run_id: scoringRun.id,
|
|
performance_snapshot_id: snapshot.id,
|
|
direction,
|
|
observations,
|
|
recommended_action:
|
|
"先积累至少 5 篇同类样本,再评估是否调整 GEO rubric 权重。",
|
|
created_at: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
function inferDirection(
|
|
composite: number,
|
|
metrics: { views?: number; clicks?: number; inquiries?: number },
|
|
): CalibrationDirection {
|
|
if (!metrics.views && !metrics.clicks && !metrics.inquiries) {
|
|
return "needs_more_data";
|
|
}
|
|
if ((metrics.inquiries ?? 0) >= 3 || (metrics.clicks ?? 0) >= 50) {
|
|
return "better_than_expected";
|
|
}
|
|
if ((metrics.views ?? 0) < 100 && composite >= 7) {
|
|
return "worse_than_expected";
|
|
}
|
|
return "as_expected";
|
|
}
|
|
|
|
function buildObservations(
|
|
direction: CalibrationDirection,
|
|
scoringRun: ScoringRun,
|
|
qaReport: QaReport,
|
|
feedbackSummary: string,
|
|
) {
|
|
const observations = [
|
|
`综合评分 ${scoringRun.composite_score}/10,真实表现方向为 ${direction}。`,
|
|
];
|
|
if (qaReport.overall_status !== "pass") {
|
|
observations.push(`QA 状态为 ${qaReport.overall_status},需要和表现数据一起复盘。`);
|
|
}
|
|
if (feedbackSummary) {
|
|
observations.push(`反馈摘要:${feedbackSummary}`);
|
|
}
|
|
if ((scoringRun.dimension_scores.trust_signal_quality ?? 0) <= 3) {
|
|
observations.push("信任信号质量偏低,后续观察是否影响询盘。");
|
|
}
|
|
return observations;
|
|
}
|