Commit ec55b5be by luoqi

feat(recall): 优先级算法重做为 v3.0 三维模型(急迫×0.4 + 价值×0.3 + 意愿×0.3)

替换旧 6 因子加权启发式(只用 value+risk 2 特征)为业务《画像字典 v3.0》优先级模型:
- 急迫性 = urgency_level(紧急10/高7/中4/低1)
- 价值性 = 治疗类型+牙数(种植 单颗8/多颗9/半口10 · 正畸7 · 根管6 · 修复5-6 · 牙周5 · 拔3 · 补2)
- 意愿度 = RFM依从×0.375 + 主诉行为×0.375 + 信任基础×0.25
  · RFM依从 = rfm 八象限分群
  · 主诉行为 = 咨询过该类(consultation 意向命中)→8 / 仅诊断→2(触达活跃维度 PAC 无数据,去掉按比例归一)
  · 信任基础 = lifecycle + 同类治疗史+1 + 转介达人+1
- scorer 用上 6 个画像特征(rfm/urgency/lifecycle/treatment_history/referral + consultation 意向),旧版只用 2 个。
- ️ 只改 priorityScore(排序),不改召回候选集(gap 选择独立)→ verify-recall FP/FN 不受影响。
- 本地 928:候选 589(≈旧588 不变),分数 23-94 avg66.6;价值序 种植78>牙周/根管/正畸73>拔/补67>发育62。
- 局限(v3.0 同,记 follow-up):权重人拍(可调);意愿≈倾向性非增益(uplift,需试点对照组);
  confidence<0.6 降权 + 触达维度 待数据/二次筛选实现。breakdown 保留可解释。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent cca5ef37
/**
* Priority Scorer — 6 因子召回优先级算分(v2.1)
* Priority Scorer — 客户优先级排序模型(v3.0 业务口径)
*
* 对齐 docs/algorithm/potential-treatment-recall.md §8.2 算法 v2
* 对齐《客户画像标签字典 v3.0》第三部分"客户优先级排序模型"
*
* 公式:
* raw = (base × timeWindowFactor + valueBonus + likelihoodBonus + urgencyBonus) × confidenceFactor
* score = clamp(round(raw), 0, 100)
* 综合 = 急迫性×0.4 + 价值性×0.3 + 意愿度×0.3 (三维各 0-10 → ×10 映射 0-100)
* 急迫性 = urgency_level(紧急10/高7/中4/低1/无0)— 纯客观"病多急",不看人
* 价值性 = 潜在治疗类型 + 牙数 → 预估收入(纯客观,不看人有没有钱)
* 意愿度 = RFM依从×0.375 + 主诉行为×0.375 + 信任基础×0.25
* (v3.0 原含"触达活跃×0.2";PAC 无企微/小程序/呼叫/推送数据源 → 去掉,余三项按比例归一)
*
* 6 因子语义:
* 1. ClinicalBase — 子场景临床基线(缺牙 60 / 龋齿 45 / 牙周 50 / ...)
* 2. TimeWindowFactor — 黄金窗内 1.0,过早 0.6,过晚衰减到 0.4
* 3. ValueBonus — patient LTV(Persona value 特征)
* 4. LikelihoodBonus — 转化可能性(recall_risk 低 + 近期回访成功)
* 5. UrgencyBonus — 临床紧迫(诊断超 X 天 → 邻牙倾斜 / 复发风险升)
* 6. SignalQualityDiscount — LLM 抽出来的低置信信号降权
* ⚠️ 改 scorer 只改 priorityScore(排序),不改召回候选集(gap 选择独立)→ verify-recall 不受影响。
* ⚠️ 已知科学局限(v3.0 同):权重人拍(业务 baseline,可调);意愿度≈倾向性非增益(uplift),
* 会偏好"本来就会来的人"——要做对需试点留随机对照组(follow-up)。
*
* 详情页 breakdown 用 calcPriority(...).breakdown 落库到 plan_reasons.evidence,
* UI 展示客服"为什么这个 73 分"的可解释性来源。
* breakdown 落 plan_reasons,详情页展示客服"为什么这个分"。
*/
// ── 价值性:潜在治疗类型 → 预估收入分(0-10,v3.0 表)──
// 种植按牙数分档(单颗8/多颗9/半口+10);修复按牙数(<3=5/≥3=6);其余定值。
// 按召回 primaryCode 映射(单一真理源在此,跟 v3.0 价值表对齐)。
const VALUE_BASE_BY_CODE: Record<string, number> = {
K08: 8, // 种植(单颗 base;多颗/半口在 computeValue 提分)
K07: 7, // 正畸 / 早矫
K04: 6, // 根管(+冠)
K03: 5, // 牙体修复(冠/贴面;≥3颗提到 6)
K09: 4, // 颌骨囊肿(必处理手术)
K05: 5, // 牙周(全口序列)
K06: 3, // 牙龈/牙槽嵴
K01: 3, // 阻生牙拔除(低值,但可能是种植入口)
K00: 3, // 发育/萌出
K02: 2, // 龋齿充填
};
const URGENCY_SCORE: Record<string, number> = { urgent: 10, high: 7, mid: 4, low: 1 };
// key 对齐 rfm.feature 八象限 segment code
const RFM_ADHERENCE: Record<string, number> = {
important_value: 10, // 重要价值
important_retain: 8, // 重要保持
important_develop: 8, // 重要发展
important_winback: 5, // 重要挽留
general_value: 6, // 一般价值
general_retain: 5, // 一般保持
general_develop: 5, // 一般发展
low_active: 2, // 低活跃
};
const TRUST_BASE: Record<string, number> = {
mature: 9, // 成熟客
growth: 7, // 成长客
new: 4, // 新客
reactivate: 3, // 待激活
prospect: 2, // 潜客
dormant: 1, // 沉睡客
churned: 0, // 流失客
};
export interface PriorityInput {
/// 子场景临床基线(各 scenario 自己提供 60 / 55 / 50 等)
base: number;
/// 自诊断 / 推荐至今的天数
daysSince: number;
/// 黄金窗:[start, end](天数);daysSince 落 [start, end] = factor 1.0
goldenRange: [number, number];
/// Persona feature 的 value.score(0-4 档,见 ValueFeatureExtractor),null = 无 persona
valueScore: number | null;
/// Persona feature 的 recall_risk.score(0-3 档),null = 无 persona
riskScore: number | null;
/// 该 patient 近 30 天的 plan_executions outcome 列表(可空)
recentExecutions: Array<{ outcome: string }>;
/// 各信号源 confidence(0-1);LLM 抽的 < 1,结构化诊断 = 1
signalConfidences: number[];
/// 子场景定义的"临床紧迫"临界天数(超过加 urgencyBonus)
urgencyDayThreshold?: number;
/// C.2.1 急迫等级(urgency_level 特征)
urgencyLevel: 'urgent' | 'high' | 'mid' | 'low' | null;
/// 召回子场景 primaryCode(K08/K07/...)— 决定价值
primaryCode: string;
/// 剩余未治牙位数(种植/修复分档用;全口码为 0)
toothCount: number;
/// rfm 分群 code(important_value/...)
rfmSegment: string | null;
/// 主诉行为:患者咨询过该类治疗(consultation 意向命中本 gap 类别)
consultIntentMatch: boolean;
/// 信任:lifecycle 阶段 code
lifecycleStage: string | null;
/// 信任 +1:同类治疗史(跨科室信任迁移)
hasTreatmentHistory: boolean;
/// 信任 +1:转介绍达人
isReferralChampion: boolean;
}
export interface PriorityBreakdown {
/// 客服 UI 直接显示:"临床基线 60 × 0.85 = 51"
clinicalBase: number;
timeWindowFactor: number;
main: number; // base × timeWindowFactor
valueBonus: number;
likelihoodBonus: number;
urgencyBonus: number;
confidenceFactor: number; // 1.0 / 0.9 / 0.75 等
raw: number; // 未 clamp 未 round 的真实数
urgency: number; // 急迫性 0-10
value: number; // 价值性 0-10
willingness: number; // 意愿度 0-10
rfmAdherence: number; // 意愿·RFM依从
intentBehavior: number; // 意愿·主诉行为
trustBase: number; // 意愿·信任基础
raw: number; // 综合 0-10(未 ×10)
}
export interface PriorityResult {
score: number;
score: number; // 0-100
breakdown: PriorityBreakdown;
}
function computeValue(primaryCode: string, toothCount: number): number {
const base = VALUE_BASE_BY_CODE[primaryCode] ?? 3;
if (primaryCode === 'K08') {
// 种植:单颗8 / 多颗(2-5)9 / 半口+(≥6)10
if (toothCount >= 6) return 10;
if (toothCount >= 2) return 9;
return 8;
}
if (primaryCode === 'K03' && toothCount >= 3) return 6; // 修复 ≥3颗
return base;
}
function computeWillingness(input: PriorityInput): {
willingness: number;
rfmAdherence: number;
intentBehavior: number;
trustBase: number;
} {
// RFM 依从
const rfmAdherence = input.rfmSegment ? (RFM_ADHERENCE[input.rfmSegment] ?? 5) : 5;
// 主诉行为:咨询过该类→8;仅诊断被动→2(召回候选已排除"已预约该项目",故无 10 档)
const intentBehavior = input.consultIntentMatch ? 8 : 2;
// 信任基础:lifecycle + 同类治疗史 +1 + 转介 +1(封顶 10)
let trustBase = input.lifecycleStage ? (TRUST_BASE[input.lifecycleStage] ?? 4) : 4;
if (input.hasTreatmentHistory) trustBase += 1;
if (input.isReferralChampion) trustBase += 1;
trustBase = Math.min(10, trustBase);
// 权重(触达活跃 0.2 去掉,RFM 0.3 / 主诉 0.3 / 信任 0.2 归一到 0.375/0.375/0.25)
const willingness = rfmAdherence * 0.375 + intentBehavior * 0.375 + trustBase * 0.25;
return { willingness, rfmAdherence, intentBehavior, trustBase };
}
/**
* 主算分入口 — 6 因子组合。
*
* 调用方:Scenario plugin 算出 ScenarioHit 时调本函数,score 落 hit.priorityScore,
* breakdown 序列化到 plan_reasons.evidence(JSONB)便于 UI 详情页显示。
* 主算分入口 — v3.0 三维模型。score 落 hit.priorityScore,breakdown 落 plan_reasons。
*/
export function calcPriority(input: PriorityInput): PriorityResult {
// ─ 1. TimeWindowFactor ─
const timeWindowFactor = computeTimeWindowFactor(input.daysSince, input.goldenRange);
const main = input.base * timeWindowFactor;
// ─ 2. ValueBonus(value.score: 0=新客 / 1=普通 / 2=银卡 / 3=金卡 / 4=钻卡)─
const valueBonus = computeValueBonus(input.valueScore);
// ─ 3. LikelihoodBonus(risk 越低,触达成功率越高;近期 success 加分)─
const likelihoodBonus = computeLikelihoodBonus(
input.riskScore,
input.recentExecutions,
);
const urgency = input.urgencyLevel ? (URGENCY_SCORE[input.urgencyLevel] ?? 0) : 0;
const value = computeValue(input.primaryCode, input.toothCount);
const { willingness, rfmAdherence, intentBehavior, trustBase } = computeWillingness(input);
// ─ 4. UrgencyBonus(超过临床晋级临界即加固定分)─
const urgencyBonus = computeUrgencyBonus(
input.daysSince,
input.urgencyDayThreshold,
);
// ─ 5. SignalQualityDiscount(最低 confidence 决定整体置信因子)─
const confidenceFactor = computeConfidenceFactor(input.signalConfidences);
const raw =
(main + valueBonus + likelihoodBonus + urgencyBonus) * confidenceFactor;
const score = Math.max(0, Math.min(100, Math.round(raw)));
const raw = urgency * 0.4 + value * 0.3 + willingness * 0.3;
const score = Math.max(0, Math.min(100, Math.round(raw * 10)));
return {
score,
breakdown: {
clinicalBase: input.base,
timeWindowFactor,
main,
valueBonus,
likelihoodBonus,
urgencyBonus,
confidenceFactor,
raw,
urgency,
value,
willingness: Math.round(willingness * 100) / 100,
rfmAdherence,
intentBehavior,
trustBase,
raw: Math.round(raw * 100) / 100,
},
};
}
// ─────────────────────────────────────────────────────────
// 因子计算函数(集中此处,业务方调阈值改这里)
// ─────────────────────────────────────────────────────────
/**
* TimeWindow 因子曲线:
* 过早(< start):线性 0.6 → 1.0
* 黄金窗(start ≤ d ≤ end):1.0
* 过晚(> end):线性 1.0 → 0.4(在 2×end 处衰减完)
*/
export function computeTimeWindowFactor(
daysSince: number,
range: [number, number],
): number {
const [start, end] = range;
if (daysSince < 0) return 0.4; // 数据脏的兜底
if (daysSince < start) {
// 过早:线性从 0.6 升到 1.0
const t = daysSince / Math.max(1, start);
return 0.6 + 0.4 * t;
}
if (daysSince <= end) return 1.0;
// 过晚:从 end 起,2×end 时降到 0.4
const overdue = daysSince - end;
const decayWindow = end; // 2×end - end = end
const t = Math.min(1, overdue / decayWindow);
return 1.0 - 0.6 * t;
}
export function computeValueBonus(valueScore: number | null): number {
const s = valueScore ?? 0;
if (s >= 4) return 20; // 钻卡
if (s >= 3) return 15; // 金卡
if (s >= 2) return 10; // 银卡
if (s >= 1) return 5; // 普通付费
return 0;
}
export function computeLikelihoodBonus(
riskScore: number | null,
recentExecutions: Array<{ outcome: string }>,
): number {
// recall_risk:0=none / 1=low / 2=medium / 3=high — 越高触达可能性越低
const risk = riskScore ?? 1;
const riskBonus = Math.max(0, Math.round((3 - risk) * 2)); // 0~6
// 仅"真实正向进展"加权:成功转化 / 改约(都已敲定新时间)。
// 注:'considering'(考虑中)已移除 — 软意向不该把患者顶到列表最前天天打;
// 且 considering 现在走 snoozedUntil 冷静期,不应再额外加权(否则与抑制窗自相矛盾)。
const recentSuccessBonus = recentExecutions.some((e) =>
['success_appointed', 'rescheduled'].includes(e.outcome),
)
? 4
: 0;
return Math.min(10, riskBonus + recentSuccessBonus);
}
export function computeUrgencyBonus(
daysSince: number,
threshold: number | undefined,
): number {
if (!threshold) return 0;
if (daysSince <= threshold) return 0;
// 超过临界,固定加 5 分(简化;真要分级再扩)
return 5;
}
export function computeConfidenceFactor(confidences: number[]): number {
if (confidences.length === 0) return 1.0;
const min = Math.min(...confidences);
if (min >= 0.9) return 1.0;
if (min >= 0.7) return 0.9;
return 0.75;
}
......@@ -74,6 +74,24 @@ import { buildGapCore, GAP_FLAGS_BY_PRIMARY, GAP_PRIMARY_GROUPS } from '../../..
/// 患者级、与具体信号无关;将来复购 scenario 应抽成共享 fragment 复用。
const POST_VISIT_COOLDOWN_DAYS = 14;
/// v3.0 打分上下文(每患者 active persona 投影)
interface PersonaScoreCtx {
rfmSegment: string | null;
urgencyLevel: 'urgent' | 'high' | 'mid' | 'low' | null;
lifecycleStage: string | null;
treatmentHistory: string[]; // treatment_history.types
isReferralChampion: boolean;
}
/// 召回 primaryCode → treatment_history code(信任迁移"同类治疗史 +1"判定)。
/// 仅 4 个主治疗有历史标签;基础治疗(龋/根管/拔)无 → 不加分。
const HISTORY_CODE_BY_PRIMARY: Record<string, string> = {
K08: 'implant_history',
K07: 'ortho_history',
K03: 'prostho_history',
K05: 'perio_history',
};
@Injectable()
export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
readonly key = PlanScenario.TREATMENT_INITIATION_RECALL;
......@@ -379,30 +397,32 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// 全口诊断(K05 等空牙位)→ 全部归入 'whole' cluster(1 个 / patient,跟 chain.tooth='*whole' 一致)
const mergedHits = mergeRowsByToothOverlap(rows);
// 为算 6 因子,一次性查所有命中 patient 的 persona + recent execution
// v3.0 优先级模型:一次性查命中 patient 的 persona 打分上下文 + 咨询意向
const patientIds = [...new Set(mergedHits.map((r) => r.patient_id))];
const personaCtx = await this.fetchPersonaContext(patientIds);
const execCtx = await this.fetchRecentExecutions(patientIds, scope.now);
const consultCtx = await this.fetchConsultIntents(patientIds);
const expectedCatSet = new Set(expectedCats);
const historyCode = HISTORY_CODE_BY_PRIMARY[cfg.primaryCode];
const hits: ScenarioHit[] = [];
for (const r of mergedHits) {
const patientId = r.patient_id;
const persona = personaCtx.get(patientId);
const confidence = r.confidence
? Number(r.confidence)
: r.signal_type === 'recommendation_record'
? 0.8 // 默认 LLM/规则抽出的 0.8
: 1.0; // 结构化诊断 1.0
// 主诉行为:咨询过本 gap 类别(consultation 意向 ∩ expectedCats)
const consultMatch = [...(consultCtx.get(patientId) ?? [])].some((c) => expectedCatSet.has(c));
// 信任迁移:同类治疗史
const hasHist = historyCode ? (persona?.treatmentHistory.includes(historyCode) ?? false) : false;
const toothCount = r.tooth ? r.tooth.split(';').filter(Boolean).length : 0;
const { score, breakdown } = calcPriority({
base: cfg.base,
daysSince: r.days_since,
goldenRange,
valueScore: persona?.valueScore ?? null,
riskScore: persona?.riskScore ?? null,
recentExecutions: execCtx.get(patientId) ?? [],
signalConfidences: [confidence],
urgencyDayThreshold: rule.urgencyDayThreshold,
urgencyLevel: persona?.urgencyLevel ?? null,
primaryCode: cfg.primaryCode,
toothCount,
rfmSegment: persona?.rfmSegment ?? null,
consultIntentMatch: consultMatch,
lifecycleStage: persona?.lifecycleStage ?? null,
hasTreatmentHistory: hasHist,
isReferralChampion: persona?.isReferralChampion ?? false,
});
const toothStr = r.tooth ? ` · 牙位 ${r.tooth}` : '';
......@@ -457,59 +477,54 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// 算分需要的辅助数据查询
// ─────────────────────────────────────────────────────────
/// 拉每个 patient 当前 active persona 的 valueScore / riskScore。
/// W7:统一读 rfm.data(valueTier→valueScore、riskScore→riskScore);
/// rfm 缺失时优雅回退旧 value/recall_risk.score(翻转过渡期不抖)。
/// 拉每个 patient 当前 active persona 的打分上下文(v3.0 优先级模型用):
/// rfm.segment(RFM依从)/ urgency_level.level(急迫)/ lifecycle_stage.stage(信任)/
/// treatment_history.types(信任迁移)/ referral_champion(信任 +1)。
private async fetchPersonaContext(
patientIds: string[],
): Promise<Map<string, { valueScore: number | null; riskScore: number | null }>> {
): Promise<Map<string, PersonaScoreCtx>> {
if (patientIds.length === 0) return new Map();
const rows = await this.prisma.$queryRaw<
Array<{ patient_id: string; key: string; score: number | null; data: unknown }>
Array<{ patient_id: string; key: string; data: unknown }>
>`
SELECT p.patient_id, pf.key, pf.score, pf.data
SELECT p.patient_id, pf.key, pf.data
FROM personas p
JOIN persona_features pf ON pf.persona_id = p.id
WHERE p.patient_id = ANY(${patientIds}::uuid[])
AND p.superseded_at IS NULL
AND pf.key IN ('rfm', 'value', 'recall_risk')
AND pf.key IN ('rfm', 'urgency_level', 'lifecycle_stage', 'treatment_history', 'referral_champion')
`;
const ctx = new Map<string, { valueScore: number | null; riskScore: number | null }>();
// 先收旧值(回退用),rfm 命中则覆盖
const ctx = new Map<string, PersonaScoreCtx>();
const get = (id: string) =>
ctx.get(id) ??
({ rfmSegment: null, urgencyLevel: null, lifecycleStage: null, treatmentHistory: [], isReferralChampion: false } as PersonaScoreCtx);
for (const r of rows) {
const cur = ctx.get(r.patient_id) ?? { valueScore: null, riskScore: null };
if (r.key === 'rfm') {
const d = (r.data ?? {}) as { valueTier?: number; riskScore?: number };
if (typeof d.valueTier === 'number') cur.valueScore = d.valueTier;
if (typeof d.riskScore === 'number') cur.riskScore = d.riskScore;
} else if (r.key === 'value' && cur.valueScore === null) cur.valueScore = r.score;
else if (r.key === 'recall_risk' && cur.riskScore === null) cur.riskScore = r.score;
const cur = get(r.patient_id);
const d = (r.data ?? {}) as Record<string, unknown>;
if (r.key === 'rfm' && typeof d.segment === 'string') cur.rfmSegment = d.segment;
else if (r.key === 'urgency_level' && typeof d.level === 'string') cur.urgencyLevel = d.level as PersonaScoreCtx['urgencyLevel'];
else if (r.key === 'lifecycle_stage' && typeof d.stage === 'string') cur.lifecycleStage = d.stage;
else if (r.key === 'treatment_history' && Array.isArray(d.types)) cur.treatmentHistory = d.types as string[];
else if (r.key === 'referral_champion') cur.isReferralChampion = true;
ctx.set(r.patient_id, cur);
}
return ctx;
}
/// 拉每个 patient 最近 30 天的 plan_executions outcome(用于 likelihoodBonus)
private async fetchRecentExecutions(
patientIds: string[],
now: Date,
): Promise<Map<string, Array<{ outcome: string }>>> {
/// 拉每个 patient 咨询过的治疗类别(consultation_record.intent_categories)— 主诉行为(意愿)用。
private async fetchConsultIntents(patientIds: string[]): Promise<Map<string, Set<string>>> {
if (patientIds.length === 0) return new Map();
const cutoff = new Date(now.getTime() - 30 * 86400_000);
const rows = await this.prisma.$queryRaw<
Array<{ patient_id: string; outcome: string }>
>`
SELECT fp.patient_id, pe.outcome
FROM plan_executions pe
JOIN followup_plans fp ON fp.id = pe.plan_id
WHERE fp.patient_id = ANY(${patientIds}::uuid[])
AND pe.created_at >= ${cutoff}::timestamptz
const rows = await this.prisma.$queryRaw<Array<{ patient_id: string; cat: string }>>`
SELECT pf.patient_id, jsonb_array_elements_text(pf.content->'intent_categories') AS cat
FROM patient_facts pf
WHERE pf.patient_id = ANY(${patientIds}::uuid[])
AND pf.type = 'consultation_record' AND pf.status = 'active'
`;
const ctx = new Map<string, Array<{ outcome: string }>>();
const ctx = new Map<string, Set<string>>();
for (const r of rows) {
const arr = ctx.get(r.patient_id) ?? [];
arr.push({ outcome: r.outcome });
ctx.set(r.patient_id, arr);
const s = ctx.get(r.patient_id) ?? new Set<string>();
if (r.cat) s.add(r.cat);
ctx.set(r.patient_id, s);
}
return ctx;
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment