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
...@@ -74,6 +74,24 @@ import { buildGapCore, GAP_FLAGS_BY_PRIMARY, GAP_PRIMARY_GROUPS } from '../../.. ...@@ -74,6 +74,24 @@ import { buildGapCore, GAP_FLAGS_BY_PRIMARY, GAP_PRIMARY_GROUPS } from '../../..
/// 患者级、与具体信号无关;将来复购 scenario 应抽成共享 fragment 复用。 /// 患者级、与具体信号无关;将来复购 scenario 应抽成共享 fragment 复用。
const POST_VISIT_COOLDOWN_DAYS = 14; 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() @Injectable()
export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
readonly key = PlanScenario.TREATMENT_INITIATION_RECALL; readonly key = PlanScenario.TREATMENT_INITIATION_RECALL;
...@@ -379,30 +397,32 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -379,30 +397,32 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// 全口诊断(K05 等空牙位)→ 全部归入 'whole' cluster(1 个 / patient,跟 chain.tooth='*whole' 一致) // 全口诊断(K05 等空牙位)→ 全部归入 'whole' cluster(1 个 / patient,跟 chain.tooth='*whole' 一致)
const mergedHits = mergeRowsByToothOverlap(rows); const mergedHits = mergeRowsByToothOverlap(rows);
// 为算 6 因子,一次性查所有命中 patient 的 persona + recent execution // v3.0 优先级模型:一次性查命中 patient 的 persona 打分上下文 + 咨询意向
const patientIds = [...new Set(mergedHits.map((r) => r.patient_id))]; const patientIds = [...new Set(mergedHits.map((r) => r.patient_id))];
const personaCtx = await this.fetchPersonaContext(patientIds); 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[] = []; const hits: ScenarioHit[] = [];
for (const r of mergedHits) { for (const r of mergedHits) {
const patientId = r.patient_id; const patientId = r.patient_id;
const persona = personaCtx.get(patientId); const persona = personaCtx.get(patientId);
const confidence = r.confidence // 主诉行为:咨询过本 gap 类别(consultation 意向 ∩ expectedCats)
? Number(r.confidence) const consultMatch = [...(consultCtx.get(patientId) ?? [])].some((c) => expectedCatSet.has(c));
: r.signal_type === 'recommendation_record' // 信任迁移:同类治疗史
? 0.8 // 默认 LLM/规则抽出的 0.8 const hasHist = historyCode ? (persona?.treatmentHistory.includes(historyCode) ?? false) : false;
: 1.0; // 结构化诊断 1.0 const toothCount = r.tooth ? r.tooth.split(';').filter(Boolean).length : 0;
const { score, breakdown } = calcPriority({ const { score, breakdown } = calcPriority({
base: cfg.base, urgencyLevel: persona?.urgencyLevel ?? null,
daysSince: r.days_since, primaryCode: cfg.primaryCode,
goldenRange, toothCount,
valueScore: persona?.valueScore ?? null, rfmSegment: persona?.rfmSegment ?? null,
riskScore: persona?.riskScore ?? null, consultIntentMatch: consultMatch,
recentExecutions: execCtx.get(patientId) ?? [], lifecycleStage: persona?.lifecycleStage ?? null,
signalConfidences: [confidence], hasTreatmentHistory: hasHist,
urgencyDayThreshold: rule.urgencyDayThreshold, isReferralChampion: persona?.isReferralChampion ?? false,
}); });
const toothStr = r.tooth ? ` · 牙位 ${r.tooth}` : ''; const toothStr = r.tooth ? ` · 牙位 ${r.tooth}` : '';
...@@ -457,59 +477,54 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -457,59 +477,54 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// 算分需要的辅助数据查询 // 算分需要的辅助数据查询
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
/// 拉每个 patient 当前 active persona 的 valueScore / riskScore。 /// 拉每个 patient 当前 active persona 的打分上下文(v3.0 优先级模型用):
/// W7:统一读 rfm.data(valueTier→valueScore、riskScore→riskScore); /// rfm.segment(RFM依从)/ urgency_level.level(急迫)/ lifecycle_stage.stage(信任)/
/// rfm 缺失时优雅回退旧 value/recall_risk.score(翻转过渡期不抖)。 /// treatment_history.types(信任迁移)/ referral_champion(信任 +1)。
private async fetchPersonaContext( private async fetchPersonaContext(
patientIds: string[], patientIds: string[],
): Promise<Map<string, { valueScore: number | null; riskScore: number | null }>> { ): Promise<Map<string, PersonaScoreCtx>> {
if (patientIds.length === 0) return new Map(); if (patientIds.length === 0) return new Map();
const rows = await this.prisma.$queryRaw< 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 FROM personas p
JOIN persona_features pf ON pf.persona_id = p.id JOIN persona_features pf ON pf.persona_id = p.id
WHERE p.patient_id = ANY(${patientIds}::uuid[]) WHERE p.patient_id = ANY(${patientIds}::uuid[])
AND p.superseded_at IS NULL 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 }>(); const ctx = new Map<string, PersonaScoreCtx>();
// 先收旧值(回退用),rfm 命中则覆盖 const get = (id: string) =>
ctx.get(id) ??
({ rfmSegment: null, urgencyLevel: null, lifecycleStage: null, treatmentHistory: [], isReferralChampion: false } as PersonaScoreCtx);
for (const r of rows) { for (const r of rows) {
const cur = ctx.get(r.patient_id) ?? { valueScore: null, riskScore: null }; const cur = get(r.patient_id);
if (r.key === 'rfm') { const d = (r.data ?? {}) as Record<string, unknown>;
const d = (r.data ?? {}) as { valueTier?: number; riskScore?: number }; if (r.key === 'rfm' && typeof d.segment === 'string') cur.rfmSegment = d.segment;
if (typeof d.valueTier === 'number') cur.valueScore = d.valueTier; else if (r.key === 'urgency_level' && typeof d.level === 'string') cur.urgencyLevel = d.level as PersonaScoreCtx['urgencyLevel'];
if (typeof d.riskScore === 'number') cur.riskScore = d.riskScore; else if (r.key === 'lifecycle_stage' && typeof d.stage === 'string') cur.lifecycleStage = d.stage;
} else if (r.key === 'value' && cur.valueScore === null) cur.valueScore = r.score; else if (r.key === 'treatment_history' && Array.isArray(d.types)) cur.treatmentHistory = d.types as string[];
else if (r.key === 'recall_risk' && cur.riskScore === null) cur.riskScore = r.score; else if (r.key === 'referral_champion') cur.isReferralChampion = true;
ctx.set(r.patient_id, cur); ctx.set(r.patient_id, cur);
} }
return ctx; return ctx;
} }
/// 拉每个 patient 最近 30 天的 plan_executions outcome(用于 likelihoodBonus) /// 拉每个 patient 咨询过的治疗类别(consultation_record.intent_categories)— 主诉行为(意愿)用。
private async fetchRecentExecutions( private async fetchConsultIntents(patientIds: string[]): Promise<Map<string, Set<string>>> {
patientIds: string[],
now: Date,
): Promise<Map<string, Array<{ outcome: string }>>> {
if (patientIds.length === 0) return new Map(); 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; cat: string }>>`
const rows = await this.prisma.$queryRaw< SELECT pf.patient_id, jsonb_array_elements_text(pf.content->'intent_categories') AS cat
Array<{ patient_id: string; outcome: string }> FROM patient_facts pf
>` WHERE pf.patient_id = ANY(${patientIds}::uuid[])
SELECT fp.patient_id, pe.outcome AND pf.type = 'consultation_record' AND pf.status = 'active'
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 ctx = new Map<string, Array<{ outcome: string }>>(); const ctx = new Map<string, Set<string>>();
for (const r of rows) { for (const r of rows) {
const arr = ctx.get(r.patient_id) ?? []; const s = ctx.get(r.patient_id) ?? new Set<string>();
arr.push({ outcome: r.outcome }); if (r.cat) s.add(r.cat);
ctx.set(r.patient_id, arr); ctx.set(r.patient_id, s);
} }
return ctx; 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