---
title: 召回算法
description: 应治未治召回(treatment_initiation_recall)—— 有诊断/医生建议但未启动对应治疗的患者,如何筛选、合并、打分成召回任务。
icon: Filter
---

> **场景**:**应治未治召回**(`treatment_initiation_recall`)—— 患者有治疗需求信号(诊断 / 医生建议)却没启动对应治疗。**这是当前唯一在引擎注册运行的召回 scenario**。
>
> **真理源**:`apps/pac-service/src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts`(入池 + 合并)、`priority-scorer.ts`(算分)、`packages/types/src/canonical-codes.ts`(`DiagnosisTreatmentMap` 窗口参数)。fact 怎么来的见 [数据摄入](/docs/architecture/data-ingestion);本页只讲"基于标准化 fact,召回怎么算"。

---

## 业务定义

> 患者**有治疗需求信号但没启动对应治疗** —— 诊断了 K08 缺失牙却没做任何种植/桥/义齿,诊断了 K04 牙髓炎却没做根管,等等。召回的目标是**邀约患者到诊启动治疗**。

算法只读结构化 `fact.content`,认医生白纸黑字写下的诊断与建议,不猜自由文本。

**触发信号(两类,任一命中)**:

| 信号 | fact | 置信度 | 现状 |
|---|---|---|---|
| 结构化诊断 | `diagnosis_record.content.code`(K00–K09)+ `tooth_position` | 1.0 | ✅ 主信号 |
| 医生建议 | `recommendation_record.content.code`(`IMPLANT_RECOMMENDED` 等) | 0.8 | ✅ 当前由规则/关键词路由(transforms `route_by_pattern`)产生;LLM 抽取(Layer C)未上线 |

---

## 子场景(11 个)

每个子场景 = 一个病种(诊断码)+ 对应建议码 +「已做就排除」的治疗 category。`base` 在 scenario 文件,窗口参数(`cooldownDays` / `windowDays` / `urgencyDayThreshold`)在 `DiagnosisTreatmentMap`,按 `primaryCode` 关联。

| sub_key | 病种码 | base | cooldown(入池下界,天) | window(黄金窗,天) | urgent 临界(天) | 排除 category(已做即不召) |
|---|---|---|---|---|---|---|
| `missing_tooth` | K08 | 60 | 30 | 180 | 120 | implant / prosthodontic |
| `ortho_no_consult` | K07 | 55 | 30 | 365 | 180 | orthodontic |
| `endo_no_rct` | K04 | 52 | 14 | 60 | 45 | endodontic |
| `perio_no_srp` | K05 | 50 | 30 | 120 | 90 | periodontic（全口） |
| `jaw_cyst` | K09 | 50 | 14 | 90 | 60 | surgical |
| `caries_no_filling` | K02 | 45 | 14 | 90 | 60 | restorative |
| `hard_tissue_damage` | K03 | 35 | 14 | 90 | 60 | restorative / prosthodontic / surgical |
| `gum_alveolar_lesion` | K06 | 35 | 14 | 120 | 60 | periodontic / surgical |
| `impacted_tooth` | K01 | 30 | 14 | 180 | 90 | surgical |
| `extraction_recommended` | EXTRACTION_RECOMMENDED | 30 | 14 | 90 | 60 | surgical(跨病种,只认建议码) |
| `development_eruption` | K00 | 25 | 30 | 365 | 180 | surgical / prosthodontic / implant / orthodontic |

> **不漏码原则**:host 数据出现的任何病种都进召回池,靠 `base` 分级 + 窗口让低优先的自然衰减排后。`goal` 字段(scenario 配置)直供话术 `plan.goal`。

---

## 入池(每个子场景一段 SQL,只换 `allCodes` / `excludeCats` / `cooldown` 参数)

```
① 隔离        host_id + tenant_id
② 合规硬过滤   patients.active=true AND do_not_contact=false AND deceased=false
              AND host_follow_up_active IS NOT TRUE   ← 宿主正在跟进的不召回(见下「宿主跟进闸」)
③ 触发信号    fact.status='active' AND type ∈ {diagnosis_record, recommendation_record}
              AND content->>'code' = ANY(allCodes)
④ 冷静期下界   COALESCE(occurred_at, planned_for) <= now - cooldownDays
              ⚠️ 只设下界,不设上界 —— 缺口不自愈,拖越久越该召;超窗交 scorer 新鲜度衰减
⑤ 三道排除(NOT EXISTS):
   a. 已无缺口              该牙位已「解决」(resolved)—— 牙位级判定见下节
   b. 已有未来预约           appointment_record active 且 planned_for > now(已在来的路上,不按科目)
   c. 近期已到诊             14 天内有 encounter/emr(刚见过面,别催;⑤d 预约科目排除 W7 已移除)
```

**关键纪律(代码已固化):**
- **触发时间** = `COALESCE(occurred_at, planned_for)` —— 诊断用 `occurred_at`,建议(planned)用 `planned_for`。
- **排除按牙位级 overlap**:诊断 + actual 双方有牙位 → 牙位数组有交集才排;诊断无牙位(K05 全口)→ category 级;actual 全口(全口洁治)→ 视为覆盖该 category 全牙位。
- **时间方向**:只算"诊断之后才启动"的治疗,历史旧治疗(可能另一颗牙)不算已做。
- **排除用 `status IN ('active','fulfilled')`**:actual 完成后状态是 `fulfilled` 非 `active`,漏了会把已治者误召。

---

## 宿主跟进闸(`host_follow_up_active`)

**要解决的问题**:宿主自己已经在跟进的患者,PAC 不该再发起召回 —— 否则两边同时联系同一个人,
患者体验差,也显得两个系统各说各话。

**位置**:`patient_profiles.host_follow_up_active`,与 `do_not_contact` / `deceased` 并列在②合规硬过滤,
但**性质不同**:

| | 合规闸(`do_not_contact`/`deceased`)| 宿主跟进闸(`host_follow_up_active`)|
|---|---|---|
| 性质 | 法务 / 风险,**永久** | 协作分工,**临时** |
| 解除 | 需人工介入 | 宿主停止跟进 → 下次推主档即回池 |
| 来源 | 患者意愿 / 事实 | 宿主业务系统的跟进状态 |

**判定用 `IS NOT TRUE` 而非 `= false`** —— 该列可空,`NULL` 表示**宿主未提供该信号**
(如 jvs-dw 暂未接入),此时不启用该闸,不能因为 `NULL != false` 把这些患者全挡在池外。

**信号由宿主给,PAC 不猜**:各宿主"正在跟进"的定义不同(FRIDAY 是复杂病例阶段,
其他宿主可能是工单/任务),且宿主的私有枚举 PAC 无从翻译。宿主在推患者主档时
inline 一列自己的跟进凭据,PAC 归一成本字段。FRIDAY 侧的具体口径见
[FRIDAY 推送载荷 · customer_basic_info](/docs/integration/friday-push-payload)。

---

## 「已启动 / 无需召」的牙位级判定(resolved)

排除不是简单"做过同类治疗就不召",而是**逐牙算"剩余未治牙位"**:`remaining = 信号牙位 − resolved`,`remaining` 非空才召(全口病退化到 patient/category 级)。判定逻辑在 `clinical-gap/potential-treatment-gap.sql`,**召回与「潜在治疗」画像标签共用同一份**,口径逐字节一致(圈到的人群 = 召回候选)。

**两个 category 口径(别混)**:
- `expectedCats`(窄,主治疗)—— 展示"未启动 X" + 主诉意向匹配
- `resolverCats`(宽,治疗家族)—— 判"已解决"。结构码(K02/K03/K08…)= **任何局部结构治疗**都算(充填/根管/冠桥/种植/外科/美学/儿牙);牙周/正畸沿用各自 category

**一颗牙算「已解决」的来源**(任一即从 gap 扣掉):
- 同牙诊断**之后**做了 `resolverCats` 家族的 actual 治疗
- **固定桥盲点**:同牙弓 ≥2 颗 prosthodontic → 基牙跨度区间内全部牙位计入(桥只写基牙牙位、与缺失牙位不重叠的误召修正)
- 检查所见"缺牙但**间隙关闭 / 无修复间隙**" → 无修复指征
- 患者**拒绝**该类治疗(treatment subtype 含拒绝/不愿)
- 同牙位以**最新结构诊断**为准(更晚的诊断 → 旧诊断对该牙失效)
- 诊断 vs 建议冲突,以**医生建议**为准(治疗决定 > 诊断)

**K08 缺失牙的误召剔除**(影像 AI / name 映射常把废用牙误当缺牙):乳牙(FDI 51-85)、智齿位(18/28/38/48)、正畸**减数位**(对称前磨牙 14&24 / 34&44 拔除且处正畸语境)、name 含"先天"(交正畸统筹)、废用牙 / 无功能牙 name —— 一律不召种植 / 修复。

**全口病(K05 牙周 / K07 正畸,`wholeMouth`)**:无牙位 → patient/category 级;且 `excludeIfEverTreated` —— **曾做过该类治疗或明确拒绝即排除**,时间锚点取"最新诊断之后"。

**「建议拔除」让位(`deferToToothDx`)**:`EXTRACTION_RECOMMENDED` 跨病种(残根/阻生/松动牙都可能拔),若同牙已有编码病种诊断(K00–K09)→ 让位给该病种场景,不重复召;松动牙无 code → 由建议拔除场景正常召。

**外院已治疗**:回访记录(`patient_return_visits.result`)命中"已在外院治疗"且晚于诊断 → 患者级排除。

**置信度来源标注**:`code_source` = 医生码(std_code / name_map)1.0 / 影像 AI 0.5 / 医生建议 0.8;影像 AI 诊断在话术里单独标"(影像 AI)"(影像所见 ≠ 医生确诊,更谨慎)。

---

## 合并:同 patient 同子场景 → 按牙位 union-find 聚类

同一患者同一子场景命中多条信号时,**按牙位重叠做 union-find 聚类**(跟治疗进度 bucket 同口径),每个 cluster 产 1 条 `plan_reason`:

```
牙位集合有交集 → 并入同一 cluster；全口诊断(空牙位)→ 全归 'whole' cluster
每个 cluster → 1 个 plan_reason:
  sub_key   = '<子场景>@<牙位排序拼接 | whole>'   (如 caries_no_filling@36 / perio_no_srp@whole)
  daysSince = cluster 内最大(最早诊断,最有召回价值)
  evidence.factIds = cluster 内全部信号 fact_id（可追溯）
  signals.triggers = cluster 内去重 (type, code)
```

`plan_reasons` 唯一键 `(plan, scenario, sub_key)`,所以**同患者同子场景不同牙位组 = 多条 reason**(36 需充填 + 46 需充填 = 2 行,都进库),不会互相吃掉。

---

## 算分(`priority-scorer.ts`,纯 TS)

SQL 出 hit 后,逐患者拉打分上下文(persona 的 rfm / urgency_level / lifecycle / treatment_history / referral / special_attention + consultation 意向),送 `calcPriority()`:

```
base(0-10) = 急迫性 × 0.4 + 价值性 × 0.3 + 意愿度 × 0.3
raw(0-10)  = base × 新鲜度 × 置信度
score(0-100) = clamp( round(raw × 10), 0, 100 )

  意愿度 = RFM依从 × 0.375 + 主诉行为 × 0.375 + 信任基础 × 0.25
```

| 因子 | 范围 | 来源与口径 |
|---|---|---|
| **急迫性** | 0-10 | persona `urgency_level`:urgent 10 / high 7 / mid 4 / low 1 |
| **价值性** | 0-10 | 按病种基线 + 牙数加成(如 K08 单颗 8 / 多颗 9 / 半口+ 10) |
| **意愿度** | 0-10 | RFM依从(`rfm` 分群,important_value 10 … low_active 2)+ 主诉行为(咨询过该类 8 / 被动诊断 2)+ 信任基础(生命周期 + 治疗史/转介绍达人/近年履约 各 +1,封顶 10) |
| **新鲜度** | 0.4-1.0 | `daysSince ≤ window` → 1.0;超窗线性衰减,到 2×window 触底 0.4(老诊断衰减止损,不踢出池) |
| **置信度** | 0.5-1.0 | `clamp(confidence, 0.5, 1)`:医生诊断 1.0 / 医生建议 0.8 / 影像 AI 0.5 |

每条 reason 把上述因子明细落到 `plan_reasons.breakdown`(`{ urgency, value, willingness, rfmAdherence, intentBehavior, trustBase, freshness, confidenceFactor, base, raw }`),供详情页逐项渲染"为什么这个分"。

> **示例**:K08 缺牙多颗 + 咨询过种植 + RFM 重要价值 + 成熟客有种植史,诊断在黄金窗内 → 急迫 10×0.4 + 价值 9×0.3 + 意愿 9×0.3 = 8.5;× 新鲜度 1.0 × 置信度 1.0 → **85 分**。

> ⚠️ **局限**:意愿度本质是倾向性(propensity),偏好"本来就会来的人";要做对"该不该打"需测增益(uplift),靠试点随机对照组,见 [召回验证](/docs/algorithms/verification)。

---

## 形成召回计划(patient 级)

scenario 跑完各子场景产 hit;同一患者命中多个子场景时,**合并进同一条 `followup_plans`**(patient 级触达单元),各子场景作为 `plan_reasons[]` 行:

- `plan.priorityScore = MAX(各 reason.priorityScore)`(客服列表排序)
- 每条 reason 带 `breakdown` / `signals` / `evidence` / `goal`,详情页 WhyCard 渲染因子拆解
- 一个患者 = 一通电话 = 一张召回卡片

> 重算时对已有 plan 的处置(unchanged / 升版继承分配 / 零命中关闭 / 信号级抑制),见 [三层模型 · 第三层](/docs/architecture/three-layer-model)。

---

## 一句话归纳

> 召回算法只读结构化 `fact.content`,认医生写下的诊断与建议:**合规 + 冷静期 + 已做就不召**三道闸筛出候选 → 按牙位 union-find 合并成召回理由 → 三维(急迫/价值/意愿)× 新鲜度 × 置信度算出可解释优先级 → 同患者多理由并成一张召回卡。正确性验证见 [召回验证](/docs/algorithms/verification)。
