Commit dc813254 by luoqi

feat(recall): v3 优先级补置信度因子(影像AI降权)+ 第3加分项(近1年到诊履约)

回应两个缺口:
1. 置信度(影像价值维度):新 v3 漏了信号置信度 → 影像AI诊断(code_source=image_ai,v3.0标70-90%)
   与医生诊断(std_code/name_map,100%)同权。补:sourceConfidence(医生1.0/影像0.85/建议0.8/未知0.9),
   cluster 取 max(最优来源胜出);confidenceFactor(≥0.9→1.0/≥0.7→0.9/<0.7→0.75)乘进综合分。
   本地:286条影像AI/建议 ×0.9,1169条医生 ×1.0。
2. 信任加分项 3/3:原只做了同类治疗史+1转介+1,补近1年到诊且履约良好+1
   (lifecycle 末诊<365 且 非 special_attention 屡次爽约)。
- 综合 = (急迫×0.4+价值×0.3+意愿×0.3) × 新鲜度 × 置信度。SELECT 加 code_source,merge 算 cluster_confidence。
- 只改分数不改候选集(589 不变)。breakdown 加 confidenceFactor 可解释。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent cd4303ff
......@@ -77,6 +77,11 @@ export interface PriorityInput {
hasTreatmentHistory: boolean;
/// 信任 +1:转介绍达人
isReferralChampion: boolean;
/// 信任 +1:近1年到诊 且 履约良好(非屡次爽约)
recentVisitGood: boolean;
/// 信号置信度(cluster 最优来源):医生诊断 1.0 / 影像AI 0.85 / 建议 0.8 / 未知 0.9
/// v3.0 C.1.1:病历诊断 100% > 影像AI 70-90% > 建议/EMR语义。低置信(影像/建议)降权,防误召冲高分。
confidence: number;
}
export interface PriorityBreakdown {
......@@ -87,8 +92,16 @@ export interface PriorityBreakdown {
intentBehavior: number; // 意愿·主诉行为
trustBase: number; // 意愿·信任基础
freshness: number; // 诊断新鲜度因子 0.4-1.0(老诊断衰减)
base: number; // 三维加权(未乘新鲜度)
raw: number; // 综合 0-10(× 新鲜度后)
confidenceFactor: number; // 置信度因子 0.75-1.0(影像AI/建议 降权)
base: number; // 三维加权(未乘因子)
raw: number; // 综合 0-10(× 新鲜度 × 置信度后)
}
/// 置信度因子:≥0.9→1.0(医生诊断)/ ≥0.7→0.9(影像AI/建议)/ <0.7→0.75。
export function computeConfidenceFactor(confidence: number): number {
if (confidence >= 0.9) return 1.0;
if (confidence >= 0.7) return 0.9;
return 0.75;
}
/**
......@@ -132,10 +145,11 @@ function computeWillingness(input: PriorityInput): {
const rfmAdherence = input.rfmSegment ? (RFM_ADHERENCE[input.rfmSegment] ?? 5) : 5;
// 主诉行为:咨询过该类→8;仅诊断被动→2(召回候选已排除"已预约该项目",故无 10 档)
const intentBehavior = input.consultIntentMatch ? 8 : 2;
// 信任基础:lifecycle + 同类治疗史 +1 + 转介 +1(封顶 10)
// 信任基础:lifecycle + 同类治疗史 +1 + 转介 +1 + 近1年到诊履约良好 +1(封顶 10)
let trustBase = input.lifecycleStage ? (TRUST_BASE[input.lifecycleStage] ?? 4) : 4;
if (input.hasTreatmentHistory) trustBase += 1;
if (input.isReferralChampion) trustBase += 1;
if (input.recentVisitGood) 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;
......@@ -150,9 +164,10 @@ export function calcPriority(input: PriorityInput): PriorityResult {
const value = computeValue(input.primaryCode, input.toothCount);
const { willingness, rfmAdherence, intentBehavior, trustBase } = computeWillingness(input);
const freshness = computeFreshness(input.daysSince, input.windowDays);
const confidenceFactor = computeConfidenceFactor(input.confidence);
const base = urgency * 0.4 + value * 0.3 + willingness * 0.3;
const raw = base * freshness; // 老诊断衰减(止损)
const raw = base * freshness * confidenceFactor; // 老诊断衰减 × 影像AI/建议 降权
const score = Math.max(0, Math.min(100, Math.round(raw * 10)));
return {
......@@ -165,6 +180,7 @@ export function calcPriority(input: PriorityInput): PriorityResult {
intentBehavior,
trustBase,
freshness: Math.round(freshness * 100) / 100,
confidenceFactor,
base: Math.round(base * 100) / 100,
raw: Math.round(raw * 100) / 100,
},
......
......@@ -79,8 +79,10 @@ interface PersonaScoreCtx {
rfmSegment: string | null;
urgencyLevel: 'urgent' | 'high' | 'mid' | 'low' | null;
lifecycleStage: string | null;
recencyDays: number | null; // 末诊天数(lifecycle)— 近1年到诊判定
treatmentHistory: string[]; // treatment_history.types
isReferralChampion: boolean;
frequentNoShow: boolean; // special_attention 屡次爽约 → 履约差
}
/// 召回 primaryCode → treatment_history code(信任迁移"同类治疗史 +1"判定)。
......@@ -326,6 +328,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
${gap.toothOutput} AS tooth,
sig.content->>'extracted_by' AS extracted_by,
sig.content->>'confidence' AS confidence,
sig.content->>'code_source' AS code_source, -- 置信度因子:std_code/name_map=医生 / image_ai / null
sig.clinic_id AS clinic_id,
COALESCE(sig.occurred_at, sig.planned_for) AS signal_occurred_at,
EXTRACT(DAY FROM ${scope.now}::timestamptz - COALESCE(sig.occurred_at, sig.planned_for))::int AS days_since
......@@ -412,6 +415,9 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
const consultMatch = [...(consultCtx.get(patientId) ?? [])].some((c) => expectedCatSet.has(c));
// 信任迁移:同类治疗史
const hasHist = historyCode ? (persona?.treatmentHistory.includes(historyCode) ?? false) : false;
// 信任 +1:近1年到诊(末诊<365)且 履约良好(非屡次爽约)
const recentVisitGood =
persona?.recencyDays != null && persona.recencyDays < 365 && !persona.frequentNoShow;
const toothCount = r.tooth ? r.tooth.split(';').filter(Boolean).length : 0;
const { score, breakdown } = calcPriority({
......@@ -425,6 +431,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
lifecycleStage: persona?.lifecycleStage ?? null,
hasTreatmentHistory: hasHist,
isReferralChampion: persona?.isReferralChampion ?? false,
recentVisitGood,
confidence: r.cluster_confidence ?? sourceConfidence(r),
});
const toothStr = r.tooth ? ` · 牙位 ${r.tooth}` : '';
......@@ -494,20 +502,23 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
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', 'urgency_level', 'lifecycle_stage', 'treatment_history', 'referral_champion')
AND pf.key IN ('rfm', 'urgency_level', 'lifecycle_stage', 'treatment_history', 'referral_champion', 'special_attention')
`;
const ctx = new Map<string, PersonaScoreCtx>();
const get = (id: string) =>
ctx.get(id) ??
({ rfmSegment: null, urgencyLevel: null, lifecycleStage: null, treatmentHistory: [], isReferralChampion: false } as PersonaScoreCtx);
({ rfmSegment: null, urgencyLevel: null, lifecycleStage: null, recencyDays: null, treatmentHistory: [], isReferralChampion: false, frequentNoShow: false } as PersonaScoreCtx);
for (const r of rows) {
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 === 'lifecycle_stage') {
if (typeof d.stage === 'string') cur.lifecycleStage = d.stage;
if (typeof d.recencyDays === 'number') cur.recencyDays = d.recencyDays;
} 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;
else if (r.key === 'special_attention' && Array.isArray(d.types)) cur.frequentNoShow = (d.types as string[]).includes('frequent_no_show');
ctx.set(r.patient_id, cur);
}
return ctx;
......@@ -545,6 +556,7 @@ interface HitRow {
tooth: string | null;
extracted_by: string | null;
confidence: string | null; // pg 转 string,JS Number 转换
code_source: string | null; // std_code/name_map=医生 / image_ai / null — 置信度因子用
clinic_id: string | null; // 触发诊断 fact 的诊所 → plan.targetClinicId
signal_occurred_at: Date;
days_since: number;
......@@ -552,9 +564,20 @@ interface HitRow {
cluster_fact_ids?: string[]; // cluster 内所有 sig 的 fact_id(给 evidence.factIds)
cluster_has_diagnosis?: boolean; // cluster 含至少 1 个 diagnosis_record
cluster_has_recommendation?: boolean; // cluster 含至少 1 个 recommendation_record
cluster_confidence?: number; // cluster 最优来源置信度(医生1.0/影像0.85/建议0.8)
cluster_triggers?: Array<{ type: string; code: string }>; // cluster 内 unique (type, code),给 signals.triggers
}
/// 单 sig 来源置信度:医生诊断(std_code/name_map)1.0 / 影像AI 0.85 / 建议 0.8 / 未知诊断 0.9。
/// v3.0 置信度梯度(病历100% > 影像70-90% > 建议)。cluster 取 max(最优来源胜出)。
function sourceConfidence(row: HitRow): number {
if (row.signal_type === 'recommendation_record') return 0.8;
const src = row.code_source ?? '';
if (src === 'std_code' || src === 'name_map') return 1.0;
if (src === 'image_ai') return 0.85;
return 0.9;
}
/// 牙位字符串 → set,**直接委托共享 toothSet**(单一真理源)。
/// ⚠️ 必须跟 plan-aggregate.service.ts 的 target 匹配口径一致 —— 都用同一个 toothSet。
/// 历史 bug:本函数曾自己用 /^\d+/ 剥成 base 数字("1B"→"1"),而 plan-aggregate 的
......@@ -595,6 +618,7 @@ function mergeRowsByToothOverlap(rows: HitRow[]): HitRow[] {
lead.cluster_fact_ids = wholeMouth.map((x) => x.signal_fact_id);
lead.cluster_has_diagnosis = wholeMouth.some((x) => x.signal_type === 'diagnosis_record');
lead.cluster_has_recommendation = wholeMouth.some((x) => x.signal_type === 'recommendation_record');
lead.cluster_confidence = Math.max(...wholeMouth.map(sourceConfidence));
lead.cluster_triggers = uniqueTriggers(wholeMouth);
merged.push(lead);
}
......@@ -633,6 +657,7 @@ function mergeRowsByToothOverlap(rows: HitRow[]): HitRow[] {
lead.cluster_fact_ids = c.rows.map((x) => x.signal_fact_id);
lead.cluster_has_diagnosis = c.rows.some((x) => x.signal_type === 'diagnosis_record');
lead.cluster_has_recommendation = c.rows.some((x) => x.signal_type === 'recommendation_record');
lead.cluster_confidence = Math.max(...c.rows.map(sourceConfidence));
lead.cluster_triggers = uniqueTriggers(c.rows);
merged.push(lead);
}
......
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