新增发布表现评分服务
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user