# 召回正确性验证 + 两个修复(§D/§E)

> **版本**:W6 · 2026-06 · 对 `treatment_initiation_recall` 召回算法做的一轮交叉验证 + 修复留档。
> **关联**:[`potential-treatment-recall-flow.md`](./potential-treatment-recall-flow.md)(算法本体)· `apps/pac-service/sql/verify-recall.sql`(扫描器)· `apps/pac-service/src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts`(代码)
>
> **一句话**:全量 13 万患者交叉验证,召回 **FP ≈ 0.02%、真漏召 ≈ 0**;过程中量化 + 修了两个真实边角(§D 全口复发漏召、§E K08 空位误召)。

---

## 一、验证方法(多路径交叉)

**核心思路**:独立于 scenario 代码,用 `plan_reasons.signals` 反查 `patient_facts` 比对"该召没召 / 不该召却召"。扫描器(`sql/verify-recall.sql`)里**重述一份** `code→{resolver/cooldown/⑤d科目/全口/乳牙}` 配置,跟代码各写一份 → 不一致即暴露(差分测试)。

- **粒度 = 牙位级**:每颗(患者×牙)摊开,2×2 交叉 `已治(resolved) × 已召(recalled)`:
  - 未治→召 ✓ / 已治→不召 ✓ / **已治→召 = FP** / **未治→不召 = FN 候选**
- **FN 必须逐层剥合法排除**才看"真漏召"——纯 SQL 不建模排除会把合法不召虚报成 FN。合法层:
  ⑤b 未来预约 / ⑤f 近期到诊(14d)/ ⑤d 预约科目 / 废用牙·乳牙名单 / §E 剔除 / 同牙更晚诊断取代。
- **FP 时间基准用 LATEST 诊断**(非最早):否则"同牙复发(旧诊断治了、新诊断未治)"会被误判 FP。

### 性能教训
扫描器**必须集合式**:把治疗/召回/诊断牙位各 `unnest` 一次进**索引临时表 + ANALYZE**,再 EXISTS 索引查找。
逐行相关子查询(`EXISTS + CROSS JOIN LATERAL unnest`)在 13 万患者上 O(n²),实测跑 1h21min 没完;集合式 **39 秒**。

### 跑法
```bash
# 本地
docker exec -i pac-postgres   psql -U pac -d pac -f - < apps/pac-service/sql/verify-recall.sql
# 服务器
docker exec -i pac-postgres-1 psql -U pac -d pac -f - < apps/pac-service/sql/verify-recall.sql
```
每次摄入 / 改算法后跑一遍当回归。章节:§A 规模 / §B FP 硬闸(应0)/ §C 牙位交叉表+FP / §C3 FN 逐层归桶 / §D 全口复发 / §E K08 正畸语境。

---

## 二、全量结论(2026-06,§D+§E 修复后)

443,996 诊断牙 / 86,308 患者(过 cooldown + 合规):

| 项 | 数 | 占比 |
|---|---|---|
| ✓ 未治→召 | 209,603 | |
| ✓ 已治→不召 | 185,192 | |
| ✗ FP 已治却召 | 104 | **0.023%** |
| FN 真·无法解释 | 2 | **0.0005%** |
| FP 硬闸(DNC/已故/未来预约还召) | 0 | ✅ |

FN 候选 49,097 逐层归桶:未来预约 14,394 / 近期到诊 2,000 / 预约科目 8,165 / 废用牙·乳牙 10,132 / **§E剔除 663** / 同牙更晚诊断取代 13,741 / **真·无法解释 2**。

> **FP 104 多为"继发龋/不良充填物"复诊**(旧填料坏了又诊断龋)——其实是**对**的召回,被扫描器按"已治"误判,非真 FP。真 FP 更少。

---

## 三、修复 §D — 全口码治疗后复发重召

**问题**:K05 牙周 / K07 正畸是 `excludeIfEverTreated`(全口长疗程),原实现"**曾做过同类治疗就永久排除、忽略时间**" → 把"治疗后复发、又被诊断"的患者永久压掉(真漏召)。

**改法**(`scenario.ts`,两段时间方向 fragment + `latestDxOfCode` 子查询):
> 不再"曾做过即排除",改为"**治疗须晚于【该患者该病的最新诊断/建议】才算已处理**"。
> 最新诊断在末次治疗**之后**(= 复发未治)→ 重新进召回;维护中患者(末次治疗 ≥ 最新诊断)继续排除。
> 用"最新诊断"(非最早/非 sig 自身)规避"复诊反复重记诊断"的误召;活跃患者仍由 cooldown/⑤b/⑤f 兜住。

**效果(全量)**:K05 复发重召 **848** + K07 **464** = **1,312 个患者捞回**。

---

## 四、修复 §E — K08 缺牙召回剔除"空位但非真缺牙"

**问题**:K08 缺牙召回(→种植/修复)的触发,全量里 **76% 来自 name_map、19% 来自 image_ai,仅 4% 是真 ICD 编码**。影像 AI 从片子看"位置空着"就标 K08,分不清:
- **正畸减数拔的牙**(葛欣恬 15;25;35;45,缝隙正畸关闭)— 不该种
- **智齿位 18/28/38/48** — 不该种
- **先天缺失** — 正畸统筹开/关隙,不自动召修复
- 真·后天缺失大牙 — 该种 ✅

**改法**(仅 `missing_tooth` 子场景加 3 条剔除):
- `excludeThirdMolar`:牙位 `^[1-4]8$`(18/28/38/48)不召
- `excludeOrthoExtractionSites`:该牙有外科拔除(任意时间)+ 患者正畸语境(K07/正畸治疗)→ 折进 resolvedTeeth 减掉(忽略时间方向:拔在 K08 诊断前也算)
- `excludeCongenitalName`:`name_zh` 含"先天" → 不召

**效果(全量)**:剔除 663 颗;§E 正畸语境上界 2,340 → 2,251。**保持精确**(3573063 `38;46`→`46`:智齿剔、真磨牙留)。

---

## 五、口径决策(重要,别忘)

1. **影像 AI / name_map 的错误 = 上游 garbage-in,不在召回逻辑补偿**。
   例:周子建 image_ai 标 K08 `15;25;35;45`,实际正畸拔的是 `14;24;34;44`(AI 牙位错位一颗)→ §E 精确匹配正确地连不上、不剔。这类归"影像问题",该在影像 AI / 摄入层修,**不在召回里加猜对齐的启发式**(否则越堆越多 magic + 掩盖真 bug)。后续走 LLM 读原文对账那条线。
2. **已指派锁**:`recompute-plans` **跳过 `status=assigned` 的 plan**(不打断客服在跟的单)。所以已指派患者的 §D/§E 修复**下次 plan 周转才生效**(葛欣恬即此,非算法问题)。
3. **resolver 两套口径**:`rule.categories`(窄,展示"未启动X"+触发)vs `resolverCategoriesFor`(宽,判"已解决"=结构治疗全集)。
4. **FP 看 LATEST 诊断、FN 看全排除层** —— 纯 SQL 不建模会虚高,必须逐层剥。

---

## 六、已知剩余 / 后续

- **§E ortho 上界 2,251 含真缺牙**(正畸患者也可能真缺大牙该种),未再细分 —— 精确剔除已覆盖明确子集,放宽(前磨牙簇一刀切)有 FN 风险,暂不做。
- **上游 garbage-in**(image_ai 牙位错位 / name_map 误映射如"牙折裂→K02")—— 留给 LLM 读真实 EMR 对账(Path 3)。
- **真·无法解释 2 颗**(K02@12 / K03@12)—— 量级可忽略,需要时单查。
