Commit 418d47d9 by luoqi

docs(algorithm): 对齐 potential-treatment-recall-flow 到代码实现

§L3 召回 SQL 重写到当前实现:
- 子场景 4 → 10(K00–K09 全覆盖),表补全 base/dx/rec/cooldown/window/urgent/exclude
  (真理源 SUB_SCENARIOS + DiagnosisTreatmentMap@canonical-codes)
- 入池 SQL 修正:
  · 只设时间下界 cooldown,️ 去掉上界(W3 末:缺口不自愈,超 window 仍入池交 scorer 衰减)
  · COALESCE(occurred_at, planned_for)(诊断 vs 推荐)
  · 排除升级为牙位级 overlap(W4)+ 时间方向 tx.occurred_at >= sig + ⑤b拔除 ⑤c未来预约排除
- 合并逻辑:旧"取 days_since 最大丢其余" → tooth-overlap union-find
  · sub_key = '<sub>@<union(tooth)|whole>';同 patient 同 sub 不同牙位 = 多 reason 行
  · cluster_triggers/factIds 全量;来源标签 dx+rec → "(诊断+医生建议)"
- 总览图 mermaid "4 子规则" → 10
- 实现状态:scenario 3→10 子规则;DW 增量 cursor 🟡→(统一 sync 重构)
parent ddeb2661
...@@ -93,7 +93,7 @@ end ...@@ -93,7 +93,7 @@ end
%% ───── L3 · 召回计划 ───── %% ───── L3 · 召回计划 ─────
subgraph L3["🟣 L3 · 召回计划层"] subgraph L3["🟣 L3 · 召回计划层"]
SCEN["Scenario · treatment_initiation_recall<br/>━━━━━━━━━━━━━━━━━━━━<br/>4 子规则(SQL EXISTS / NOT EXISTS):<br/>#A K08 缺牙 → implant 未做<br/>#B K02 龋齿 → restorative 未做<br/>#C K05 牙周 → periodontic 未做<br/>#D K04 牙髓/根尖周 → endodontic 未做<br/>(review 类不算已做)"] SCEN["Scenario · treatment_initiation_recall<br/>━━━━━━━━━━━━━━━━━━━━<br/>10 子规则 K00–K09(SQL NOT EXISTS 排已做):<br/>K08 缺牙→implant · K07 正畸 · K04 根管<br/>K05 牙周 · K02 龋齿 · K03 牙体 · K06 牙龈<br/>K01 阻生 · K09 颌骨囊肿 · K00 萌出<br/>(review 不算已做;按 tooth-overlap union-find 合并)"]
SCORER["6 因子 Priority Scorer<br/>━━━━━━━━━━━━━━━━━━<br/>①ClinicalBase ②TimeWindowFactor<br/>③ValueBonus ④LikelihoodBonus<br/>⑤UrgencyBonus ⑥SignalQualityDiscount<br/>score = clamp((①×② + ③+④+⑤) × ⑥, 0-100)"] SCORER["6 因子 Priority Scorer<br/>━━━━━━━━━━━━━━━━━━<br/>①ClinicalBase ②TimeWindowFactor<br/>③ValueBonus ④LikelihoodBonus<br/>⑤UrgencyBonus ⑥SignalQualityDiscount<br/>score = clamp((①×② + ③+④+⑤) × ⑥, 0-100)"]
PLAN["followup_plans + plan_reasons<br/>━━━━━━━━━━━━━━━━━━<br/>plan = patient 级触达单元<br/>(同 patient 多 sub-rule 合并 1 plan)<br/>breakdown(6 因子 JSONB)"] PLAN["followup_plans + plan_reasons<br/>━━━━━━━━━━━━━━━━━━<br/>plan = patient 级触达单元<br/>(同 patient 多 sub-rule 合并 1 plan)<br/>breakdown(6 因子 JSONB)"]
SCEN -->|hit| SCORER SCEN -->|hit| SCORER
...@@ -386,65 +386,93 @@ SQL 只允许:`SELECT col1, col2 FROM single_table WHERE filter`。 ...@@ -386,65 +386,93 @@ SQL 只允许:`SELECT col1, col2 FROM single_table WHERE filter`。
> **真理源**:`apps/pac-service/src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts`。 > **真理源**:`apps/pac-service/src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts`。
> 本节是该文件 SQL 的 1:1 摘录,供业务方核对入池/排除逻辑。**SQL 永远只查 `patient_facts.content`,不碰 raw_payload。** > 本节是该文件 SQL 的 1:1 摘录,供业务方核对入池/排除逻辑。**SQL 永远只查 `patient_facts.content`,不碰 raw_payload。**
### 子场景配置(集中此处便于业务方调参) ### 子场景配置(W3 末扩至 K00–K09 全 10 子场景)
| 子场景 | 基线 base | 黄金窗(天) | 紧迫临界(天) | 触发诊断码 | 触发建议码 | 排除已做治疗 category | > 真理源:`SUB_SCENARIOS`(scenario 文件)+ `DiagnosisTreatmentMap`(`packages/types/src/canonical-codes.ts`)。
| ------------------ | ------- | ------ | ---------- | ----- | --------------------- | --------------------------- | > `base` 在 scenario 文件;窗口/排除 category 在 canonical-codes 字典,按 `primaryCode` 关联。
| **#A 缺失牙未启动修复** | 60 | 30–180 | >120(邻牙倾斜) | `K08` | `IMPLANT_RECOMMENDED` | `implant` / `prosthodontic` |
| **#B 龋齿未做充填** | 45 | 14–90 | >60 | `K02` | `FILLING_RECOMMENDED` | `restorative` |
| **#C 牙周炎未做基础治疗** | 50 | 30–120 | >90 | `K05` | `SRP_RECOMMENDED` | `periodontic` |
| **#D 牙髓/根尖周炎未做根管** | 52 | 14–90 | >45 | `K04` | —(暂无推荐码) | `endodontic` |
每个子场景跑同一段 SQL 模板,只换 `dxCodes / recCodes / excludeCats / goldenRange` 参数。 | 子场景 sub_key | base | dx 码 | 建议码 | cooldown(入池下界) | window(黄金窗上界) | urgent 临界 | 排除 category |
| --------------------- | ---- | ----- | -------------------------------------- | -------------- | ------------- | --------- | ----------- |
| `missing_tooth` | 60 | K08 | IMPLANT_RECOMMENDED | 30 | 180 | 120 | implant / prosthodontic |
| `ortho_no_consult` | 55 | K07 | ORTHO_CONSULT_RECOMMENDED | 30 | 365 | 180 | orthodontic |
| `endo_no_rct` | 52 | K04 | RCT_RECOMMENDED | 14 | 60 | 45 | endodontic |
| `perio_no_srp` | 50 | K05 | SRP_RECOMMENDED | 30 | 120 | 90 | periodontic(全口) |
| `jaw_cyst` | 50 | K09 | JAW_CYST_REMOVAL_RECOMMENDED | 14 | 90 | 60 | surgical |
| `caries_no_filling` | 45 | K02 | FILLING_RECOMMENDED | 14 | 90 | 60 | restorative |
| `hard_tissue_damage` | 35 | K03 | HARD_TISSUE_REPAIR_RECOMMENDED / CROWN | 14 | 90 | 60 | restorative / prosthodontic / surgical |
| `gum_alveolar_lesion` | 35 | K06 | GUM_TREATMENT_RECOMMENDED | 14 | 120 | 60 | periodontic / surgical |
| `impacted_tooth` | 30 | K01 | EXTRACTION_RECOMMENDED | 14 | 180 | 90 | surgical |
| `development_eruption`| 25 | K00 | ERUPTION_INTERVENTION_RECOMMENDED | 30 | 365 | 180 | surgical / prosthodontic / implant / orthodontic |
> **不漏码原则**:host 数据出现的任何 K-code 都进召回池;base 分级 + 窗口让低优先的自然衰减排后。
> 每个子场景跑同一段 SQL 模板,只换 `allCodes(=dxCodes+recCodes) / excludeCats / cooldown` 参数;
> `goal` 字段(scenario 配置)直供 AI 话术 plan.goal。
### 入池 SQL(每个子场景一次,$queryRaw 拼数组参数) ### 入池 SQL(每个子场景一次,$queryRaw 拼数组参数)
```sql ```sql
SELECT SELECT
p.id AS patient_id, p.id, p.external_id,
p.external_id AS patient_external_id, sig.id AS signal_fact_id,
sig.id AS signal_fact_id, sig.type AS signal_type, -- diagnosis_record | recommendation_record
sig.type AS signal_type, -- diagnosis_record | recommendation_record sig.content->>'code' AS signal_code,
sig.content->>'code' AS signal_code, sig.content->>'tooth_position' AS tooth,
sig.content->>'tooth_position' AS tooth, sig.content->>'confidence' AS confidence,
sig.content->>'confidence' AS confidence, sig.clinic_id,
sig.occurred_at AS signal_occurred_at, COALESCE(sig.occurred_at, sig.planned_for) AS signal_occurred_at,
EXTRACT(DAY FROM :now::timestamptz - sig.occurred_at)::int AS days_since EXTRACT(DAY FROM :now::timestamptz - COALESCE(sig.occurred_at, sig.planned_for))::int AS days_since
FROM patients p FROM patients p
JOIN patient_profiles pp ON pp.patient_id = p.id JOIN patient_profiles pp ON pp.patient_id = p.id
JOIN patient_facts sig ON sig.patient_id = p.id JOIN patient_facts sig ON sig.patient_id = p.id
WHERE p.host_id = :hostId::uuid WHERE p.host_id = :hostId::uuid AND p.tenant_id = :tenantId -- ① 隔离(brand→tenant)
AND p.tenant_id = :tenantId -- ⭐ brand→tenant 映射,两品牌天然隔离 -- ② 合规硬边界
-- ① 合规硬边界 AND p.active = true AND pp.do_not_contact = false AND pp.deceased = false
AND p.active = true -- ③ 临床触发信号(诊断 或 医生建议,任一命中)
AND pp.do_not_contact = false
AND pp.deceased = false
-- ② 临床触发信号(诊断 或 医生建议,任一命中)
AND sig.status = 'active' AND sig.status = 'active'
AND sig.type IN ('diagnosis_record', 'recommendation_record') AND sig.type IN ('diagnosis_record', 'recommendation_record')
AND sig.content->>'code' = ANY(:allCodes::text[]) -- 如 {K08, IMPLANT_RECOMMENDED} AND sig.content->>'code' = ANY(:allCodes::text[])
AND sig.occurred_at IS NOT NULL -- ④ 只设时间下界(冷静期),⚠️ 不设上界
-- ③ 黄金时间窗(诊断日落在 [now-end, now-start]) AND COALESCE(sig.occurred_at, sig.planned_for) IS NOT NULL
AND sig.occurred_at BETWEEN AND COALESCE(sig.occurred_at, sig.planned_for) <= :now - (:cooldown||' days')::interval
:now - (:end || ' days')::interval -- 窗口远端 -- ⑤a 排除:诊断之后启动的同类 actual 治疗(牙位级 overlap)
AND :now - (:start || ' days')::interval -- 窗口近端(刚诊断完冷静期内不打扰)
-- ④ 排除:已启动相应治疗(actual,含已完成 fulfilled)
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM patient_facts tx SELECT 1 FROM patient_facts tx
WHERE tx.patient_id = p.id WHERE tx.patient_id = p.id
AND tx.type = 'treatment_record' AND tx.type = 'treatment_record' AND tx.kind = 'actual'
AND tx.kind = 'actual' AND tx.status IN ('active', 'fulfilled') -- 完成的 actual 是 fulfilled
AND tx.status IN ('active', 'fulfilled') -- ⚠️ 必须含 fulfilled,见下
AND tx.content->>'category' = ANY(:excludeCats::text[]) AND tx.content->>'category' = ANY(:excludeCats::text[])
); AND tx.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for) -- ⭐ 诊断之后
AND ( <tooth overlap: 信号无牙位/actual全口 category;双方有牙位 牙位数组 overlap> )
)
-- ⑤b 排除:同牙位已有拔除(拔了就终结) ⑤c 排除:已有未来同类预约(已在来的路上)
``` ```
**关键纪律(代码注释已固化)**: **关键纪律(代码注释已固化)**:
1. **触发信号双源**:`diagnosis_record`(结构化诊断,confidence=1.0)或 `recommendation_record`(LLM/规则抽出的"建议种植",默认 confidence=0.8)。`review` category 的 treatment_record **不算已做治疗**,不进排除。 1. **触发信号双源**:`diagnosis_record`(结构化诊断,confidence=1.0)或 `recommendation_record`(LLM/规则抽,默认 confidence=0.8)。`COALESCE(occurred_at, planned_for)` — 诊断用 occurred_at,推荐用 planned_for。`review` category 不算已做治疗。
2. **排除条件 `status IN ('active','fulfilled')`** — actual treatment 完成后 status 落 `fulfilled` 而非 `active`。早期只写 `active` 会漏掉已完成治疗,把"已做种植/修复"的患者错误召回为"缺失牙未启动"(此 bug 在 W3 末修复)。 2. **只设下界,不设上界**(W3 末改):入池只过滤 `<= now - cooldown`**上界(黄金窗 window)废了** — 缺牙拖 1 年比拖 3 个月更该召;超 window 仍入池,交 scorer 的 `timeWindowFactor` 衰减。旧 bug:入池上界 = window → 缺牙 >180 天直接踢出池,scorer"过晚"分支成死代码。
3. **时间窗近端非 0**:刚诊断完(< start 天)在冷静期内不打扰,避免医生当次已安排却被立即召回。 3. **排除 `status IN ('active','fulfilled')`** — actual 完成后 status=`fulfilled``active`;漏 fulfilled 会把已做治疗者错召为未启动(W3 末修)。
4. **同 patient 多命中信号**:取 `days_since` 最大(最早诊断)那条作为主 hit,其余并入证据。 4. **排除是牙位级 overlap**(W4 末):信号+actual 双方有牙位 → tooth 数组 overlap 才排;信号无牙位(K05 全口)→ category 级;actual 全口(全口洁治)→ 视为覆盖该 category 全牙位。另加 ⑤b 同牙位拔除排除 + ⑤c 未来预约排除。
5. **时间方向** `tx.occurred_at >= sig.occurred_at`:只算"诊断之后才启动"的治疗,历史旧治疗(可能另一颗牙)不算。
### 入池后:同 patient 同 sub_scenario → tooth-overlap union-find 合并
> 旧版(W3):同 patient 取 `days_since` 最大那条作主 hit,其余丢弃 → 多牙信号被吃掉。
> 现版(W4 末):**按牙位重叠做 union-find 合并**,跟 chain-composer 的 bucket 同口径。
```
同 patient 同 sub_scenario 的多条 sig 行:
- 牙位集合有交集 → 并入同一 cluster(union-find)
- 全口诊断(K05 等空牙位)→ 全归 'whole' cluster(1 个/patient)
每个 cluster → 1 个 plan_reason:
- sub_key = '<sub_scenario>@<union(tooth) | whole>' (如 caries_no_filling@36 / impacted_tooth@18;28;38;48 / perio_no_srp@whole)
- daysSince = cluster 内最大(最早诊断,最有召回价值)
- evidence.factIds = cluster 内全部 sig fact_id(审计可追溯)
- signals.triggers = cluster 内去重 (type, code) 全量
- 来源标签 = cluster 含 dx+rec 两类 → "(诊断+医生建议)";单类保留单一来源
```
`plan_reasons` 表 UNIQUE `(plan, scenario, sub_key)`,所以**同 patient 同 sub_scenario 不同牙位组 = 多条 reason 行**(36 需充填 + 46 需充填 = 2 行,都进库),不再被合并吃掉。
### 入池后:6 因子算分(priority-scorer,纯 TS) ### 入池后:6 因子算分(priority-scorer,纯 TS)
...@@ -518,12 +546,12 @@ scenario 跑完 #A/#B/#C 各自产 hit;同一 patient 命中多个子规则时,* ...@@ -518,12 +546,12 @@ scenario 跑完 #A/#B/#C 各自产 hit;同一 patient 命中多个子规则时,*
| Parser pipeline + FactContentSchemas zod | ✅ | | Parser pipeline + FactContentSchemas zod | ✅ |
| Persona 4 起步特征 | ✅ | | Persona 4 起步特征 | ✅ |
| 6 因子 Priority Scorer + breakdown | ✅ | | 6 因子 Priority Scorer + breakdown | ✅ |
| Scenario `treatment_initiation_recall`(3 子规则)| ✅ | | Scenario `treatment_initiation_recall`(10 子规则 K00–K09 + tooth-overlap union-find)| ✅ |
| 详情页 UI(WhyCard + FactsTimeline + ScriptStream)| ✅ | | 详情页 UI(WhyCard + FactsTimeline + ScriptStream)| ✅ |
| 4 入口统一管道(dispatcher emitsResolver)| ✅ P0 补完 | | 4 入口统一管道(dispatcher emitsResolver)| ✅ P0 补完 |
| Mock Pull 端到端 | ✅ 自带 emitsPerResource | | Mock Pull 端到端 | ✅ 自带 emitsPerResource |
| Push 形态 A(canonical payload)| ✅ event 自带 emits | | Push 形态 A(canonical payload)| ✅ event 自带 emits |
| Push 形态 B(host raw payload)| 🟡 框架预留,需 host 时实装 | | Push 形态 B(host raw payload)| 🟡 框架预留,需 host 时实装 |
| Real HTTP Pull(api-key / login)| 🟡 框架预留,W5+ 接真 host | | Real HTTP Pull(api-key / login)| 🟡 框架预留,W5+ 接真 host |
| DW 直连增量 cursor | 🟡 框架预留(cold-import 已支持 SQL,加 cursor 即可)| | DW 直连增量 cursor | ✅ 统一 sync(存量增量同一套)+ cohort 分批 + 并发 + bulk write(2026-05-28 重构)|
| Layer C 自由文本抽取(LLM)| 🔵 W5+ 计划 | | Layer C 自由文本抽取(LLM)| 🔵 W5+ 计划 |
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