# 潜在治疗召回 · 数据流转全景图

> **用途**:业务方评审,讲清楚一条召回计划是如何从"宿主原始数据"一路走到"客服可执行任务"的。
>
> **场景**:**潜在治疗新链召回**(treatment_initiation_recall)— 患者有诊断 / 医生建议,但未启动相应治疗。
>
> **真实例子**:卜晓平,2025-12-21 诊断 K08 缺失牙 17 号,151 天后(2026-05-21)仍未启动种植/桥/义齿 → PAC 算法产 87 分召回 plan。
>
> **架构口诀**(W3 末确立):**形态归 A.5 / 翻译归 A / 校验归 B / 抽取归 C** — 4 层各管一类语义,互不复制。
>   - A.5 transforms(yaml 声明式 6 operator):**形态改造**(拆行/派生/分流/过滤)
>   - A AssemblerEngine(yaml field_mapping/enum_mapping):**命名 + 枚举翻译**
>   - B Parser + zod(per fact_type TS):**语义校验**(强 schema 闭集)
>   - C Signal Extractor(LLM + 规则,W5+):**自由文本抽取**

---

## 前置:DW 数仓分层 & PAC 接哪层

数仓常见 4 层(从原始到聚合):

| 层       | 全称                       | 内容                      | 形态          |
| ------- | ------------------------ | ----------------------- | ----------- |
| **ODS** | Operational Data Store   | 业务库原样同步,最脏最全            | 贴源,字段乱、含技术列 |
| **DWD** | Data Warehouse Detail    | 清洗后的明细事实(去重 / 标准化 / 拉宽) | 一行一事件,干净明细  |
| **DWS** | Data Warehouse Summary   | 按主题轻度汇总(按天/患者聚合)        | 汇总指标        |
| **ADS** | Application Data Service | 面向应用的出口宽表               | 为某消费方定制的成品表 |

- **PAC 需要的是明细层(DWD 级)** —— 召回算法要"某患者某次接诊诊断了 K08 牙位 36",是**事件粒度**,不是按天汇总的指标。汇总层(DWS/ADS 报表口径)丢了牙位 / 时间 / 单条诊断,算不了链。
- **DW 现在给的是 ADS 出口实表**(`dw_group.fact_*_out`,带 `_out` 后缀)—— 但它**做的是"明细拉宽"而非"指标汇总"**:`fact_emr_treatment_out` 把一次接诊的诊断 / 治疗计划 / 影像 / 自由文本合并成一行(含 JSON 数组子字段),粒度仍是**接诊事件级**,等价 DWD 明细 + 适度拉宽。所以 PAC 能直接消化。
- 纪律:跟宿主对接只要**明细 / 事件粒度**的表;给的是汇总指标(DWS)就要不回来牙位和时间,召回算不了。
- **每个分类字段有没有稳定的语义枚举码**?(诊断 / 治疗 / 结算类别)
- 数仓常见坑:只给中文 name(利于显示),没给可计算的语义 key;即便有"码",也可能是每条记录的随机主键 hash,不是类别码。
- 影响:PAC 只能退到"中文 name → enum_mapping 白名单"+ W5+ LLM 兜底,需持续维护、永远有长尾。
- 反馈方向:推动宿主在明细层补稳定语义码。

---

## 总览图

```mermaid
%%{init: {'theme':'neutral','flowchart':{'curve':'basis'}}}%%
flowchart TB

%% ───── L0 · 数据源(4 入口统一) ─────
subgraph L0["🌐 L0 · 数据源(4 入口统一,W3 末确立)"]
  direction LR
  COLD["①Cold-import<br/>━━━━━━━━━━<br/>CSV dump<br/>历史一次性灌"]
  DW["②DW 直连增量<br/>━━━━━━━━━━<br/>SQL + cursor 水位<br/>(W5+,框架预留)"]
  PULL["③Pull HTTP<br/>━━━━━━━━━━<br/>API<br/>(W5+)"]
  PUSH["④Push Webhook<br/>━━━━━━━━━━<br/>host 主动推 event<br/>action/subjectType 自带"]
end

%% ───── Layer A.5 · transforms(yaml 声明式形态改造) ─────
subgraph LA5["🔧 Layer A.5 · transforms(yaml 声明式,6 operator 白名单)"]
  direction LR
  T1["project<br/>选列/改名"]
  T2["split_json_array<br/>JSON 数组拆行"]
  T3["derive<br/>substring/concat"]
  T4["route_by_pattern<br/>关键词分流"]
  T5["pick_first_nonzero<br/>多列推断单列"]
  T6["filter<br/>多字段行过滤"]
end

%% ───── Layer A · AssemblerEngine(yaml 翻译) ─────
LA["🔄 Layer A · AssemblerEngine<br/>━━━━━━━━━━━━━━━━━━<br/>field_mapping(命名翻译)<br/>enum_mapping(枚举闭集 + _default 兜底)<br/>emits(action/subjectType/occurredAtField)<br/>dedup_by(炸行兜底)"]

%% ───── L1 · 患者事实层 ─────
subgraph L1["📘 L1 · 患者事实层"]
  direction TB
  TX["patient_transactions<br/>━━━━━━━━━━━━<br/>append-only 账本<br/>raw_payload + canonical_payload 双留底<br/>source_event_id 幂等键"]
  PARSER{{"🅑 Layer B · Parser + zod 强校验<br/>━━━━━━━━━━<br/>per fact_type 17 个 parser<br/>语义规范化 + 单位归一<br/>FactContentSchemas 闭集<br/>违反 schema 抛错不入库"}}
  FACTS["patient_facts<br/>━━━━━━━━━━━━<br/>事实单元版本流<br/>━━━━━━━━━━━━<br/>diagnosis_record  K08/牙位<br/>treatment_record  actual/planned<br/>· 11 category(含 review)<br/>recommendation_record  建议XX<br/>emr_record    自由文本 ⚠️<br/>image_record  影像 finding ⚠️<br/>encounter_record  接诊元数据<br/>appointment_record  预约状态<br/>payment_record  amount_cents"]
  TX -->|1 transaction<br/>→ 0/1/N fact 版本| PARSER
  PARSER --> FACTS
end

%% ───── Layer C · 自由文本结构化(LLM + 规则,异步,W5+) ─────
subgraph LC["🅒 Layer C · 自由文本结构化(异步,W5+ 计划)"]
  direction TB
  LCIN["输入:fact 里**结构化不了**的自由文本<br/>━━━━━━━━━━━━<br/>emr_record.illness_desc / treatment_plan / doctor_advice<br/>image_record.finding<br/>(yaml / 规则翻译不动的那部分)"]
  LCEX{{"三轨抽取(优先级 ①>②>③)<br/>━━━━━━━━━━━━<br/>① rule 结构化映射(已有码直传)<br/>② LLM 抽取(extract_clinical_signals · DeepSeek)<br/>③ keyword 兜底(LLM 失败 / 未跑)"}}
  LCOUT["产出(回 L1,不改原 fact 主体)<br/>━━━━━━━━━━━━<br/>· recommendation_record 独立 fact(强信号)<br/>· 原 fact.content.normalized_signals[](弱信号)<br/>· 带 extracted_by + confidence(算法降权用)"]
  LCIN --> LCEX
  LCEX --> LCOUT
end

%% ───── L2 · Persona ─────
subgraph L2["🟠 L2 · Persona 画像层"]
  PERSONA["personas<br/>━━━━━━━━━━<br/>版本流 + eventWatermark 幂等水位"]
  FEAT["persona_features<br/>━━━━━━━━━━<br/>v1 起步 4 特征:<br/>value(钻/金/银/普通/新客)<br/>treatment_chain_status<br/>recall_risk<br/>do_not_contact_status"]
  PERSONA --> FEAT
end

%% ───── L3 · 召回计划 ─────
subgraph L3["🟣 L3 · 召回计划层"]
  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)"]
  PLAN["followup_plans + plan_reasons<br/>━━━━━━━━━━━━━━━━━━<br/>plan = patient 级触达单元<br/>(同 patient 多 sub-rule 合并 1 plan)<br/>breakdown(6 因子 JSONB)"]
  SCEN -->|hit| SCORER
  SCORER -->|score+breakdown| PLAN
end

%% ───── UI ─────
subgraph UI["👤 客服 UI · /plans"]
  LIST["召回池列表<br/>按 priority_score 排序"]
  DETAIL["详情页<br/>━━━━━━━━━━<br/>WhyCard:6 因子拆解<br/>FactsTimeline:fact 时间轴<br/>ScriptStream:话术(DeepSeek)"]
end

%% ───── 横切 ─────
subgraph CROSS["🔧 横切支撑"]
  direction LR
  SL["sync_logs<br/>同步运行"]
  PRL["persona_recompute_logs<br/>画像重算"]
  PGL["plan_generation_logs<br/>召回生成"]
end

%% ───── 主流(实线) ─────
COLD ==> LA5
DW ==> LA5
PULL ==> LA5
PUSH -.->|绕过 transforms<br/>action 自带| LA
LA5 ==> LA
LA ==> TX
FACTS -.->|自由文本<br/>结构化不了的部分| LCIN
LCOUT -.->|回写 recommendation_record<br/>/ normalized_signals| FACTS
FACTS -->|触发| PERSONA
FEAT -->|输入| SCEN
FACTS -->|主信号| SCEN
PLAN ==> LIST
LIST -->|点击| DETAIL

%% ───── 编排关联(虚线) ─────
L0 -.-> SL
PERSONA -.-> PRL
PLAN -.-> PGL

%% ───── 样式 ─────
style L0  fill:#fff3e0,stroke:#f57c00,stroke-width:2px
style LA5 fill:#fffde7,stroke:#fbc02d,stroke-width:2px
style LA  fill:#e0f7fa,stroke:#00838f,stroke-width:2px
style L1  fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
style LC  fill:#ede7f6,stroke:#5e35b1,stroke-width:2px,stroke-dasharray: 5 5
style L2  fill:#fff3e0,stroke:#f57c00,stroke-width:2px
style L3  fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
style UI  fill:#e8f5e9,stroke:#388e3c,stroke-width:2px
style CROSS fill:#f5f5f5,stroke:#616161
```

---

## 数据流转实例(DW 真实一行 → N 个 fact)

> 取自瑞泰患者**于燕**(patient_id=3641549)2026-04-22 那次接诊,DW `fact_emr_treatment_out` 的**一行**。
> 直观感受"摄入到底在干什么":**一行宽表 → 拆 / 译 / 校验 → 多个独立 fact**。

**① L0 · DW 原始一行**(关键列;`diag` / `treat_plan` 是 JSON 数组子字段):
```jsonc
{
  "id": "…emr_id…", "patient_id": "3641549", "brand": "瑞泰",
  "organization_id": "c18cadf2…", "rq": "2026-04-22",
  "diag": [
    {"toothPosition":"16;17;25;26;47;36", "message":"残根"},                        // ← 无 stdCode
    {"toothPosition":"27;35",            "message":"牙周炎"},                        // ← 无 stdCode
    {"stdCode":"K08.103","toothPosition":"44;45;46;34;37","message":"后天性牙齿缺失"},
    {"stdCode":"K05.300","toothPosition":"41;31",         "message":"慢性牙周炎"},
    {"stdCode":"K04.500","toothPosition":"14;15",         "message":"慢性根尖牙周炎"},
    {"stdCode":"K07.303","toothPosition":"11;12;21;22",   "message":"牙体缺损"}
  ],
  "treat_plan": [
    {"toothPosition":"27","treatName":"松动恒牙拔除术","planCode":"6e3a…"}
  ],
  "illness_desc":"…自由文本…", "doc_order":"…医嘱自由文本…"
}
```

**② Layer A.5 transforms(形态改造,拆/派生/分流/过滤)**:
```
split_json_array  diag       → 6 行诊断子记录
filter            std_code not_empty  → 丢 2 行无码(残根 / 牙周炎)→ 剩 4 行
derive            substring(stdCode,0,3)  → K08.103→K08 / K05.300→K05 / K04.500→K04 / K07.303→K07
split_json_array  treat_plan → 1 行
route_by_pattern  treatName="松动恒牙拔除术"  → 非"建议/推荐"、非复查关键词 → 落 treatment_rows(planned)
project           主行保留接诊元数据 + 自由文本 → encounter_rows / emr_rows
```

**③ Layer A AssemblerEngine(命名 + 枚举翻译)**:host 字段名 → canonical;`K0x` ∈ 字典闭集直通;`organization_id`→`clinicId`;`rq`→`occurredAt`;brand→tenant_id(瑞泰 UUID)。

**④ L1 · 落库**(1 transaction/资源 + parser zod 校验 → fact 版本流):
| fact_type | 条数 | 内容(content)|
|---|---|---|
| `encounter_record` | 1 | 接诊元数据(doctor / 时间) |
| `diagnosis_record` | **4** | K08(44;45;46;34;37)/ K05(41;31)/ K04(14;15)/ K07(11;12;21;22) |
| `treatment_record`(planned) | 1 | category=surgical(拔除),牙位 27 |
| `emr_record` | 1 | illness_desc / doc_order 自由文本(留给 Layer C 抽取) |

**结果:DW 一行 → 7 个独立 fact**。下游算法只读这些 `fact.content`(如 `diagnosis_record.code='K08'`),不再碰原始宽表。
> 注:无 stdCode 的"残根 / 牙周炎"被 filter 丢弃(不靠中文 message 猜码);K07 入 fact 但无对应召回子规则,不产链。

---

## 4 入口统一管道(W3 末确立)

PAC 的 ingestion 是一条管道,4 个入口接不同 stage,后续完全共用:

| 入口 | Stage 0 取数 | Stage 1 transforms | Stage 2 Assembler | Stage 3 Synthesizer emits | Stage 4 Parser |
|---|---|---|---|---|---|
| **Cold-import**(SQL/CSV) | 文件/SQL 朴素 SELECT | ✅ 跑 | ✅ 跑(yaml emits) | yaml | ✅ 共用 |
| **DW 直连增量**(W5+) | SQL + cursor | ✅ 跑 | ✅ 跑 | yaml | ✅ 共用 |
| **Pull HTTP**(W5+ 5i5ya) | HTTP GET | ✅ 跑 | ✅ 跑 | yaml | ✅ 共用 |
| **Push Webhook**(W5+) | host 直推 event | ❌ 跳过 | 视形态可选 | **event 自带** | ✅ 共用 |

**纪律**:**Stage 3-4 后所有路径汇合,`fact.content` 是 PAC 唯一真理源**。后续 Persona/Plan/AI 永远只读 fact,不读 raw_payload。

---

## 各层做了什么(评审用速读)

| 层 | 输入 | 处理 | 输出 | 关键设计 |
|---|---|---|---|---|
| **L0 数据源** | — | 4 入口取 raw rows(SQL/CSV/HTTP/event)| `Record<tableName, Row[]>` | 4 入口形态一致,下游无感知 |
| **Layer A.5 transforms** | raw tables | yaml 声明式形态改造(6 operator 白名单)| 整理后的 tables(JSON 已拆行 / 多列推断完成 / 关键词分流到 3 路) | **无副作用纯函数**,撞墙必须加新 operator + PR |
| **Layer A AssemblerEngine** | 整理后 tables | field_mapping + enum_mapping(`_default` 兜底)+ dedup_by + emits 推断 | canonical rows + emits | **集团统一引擎**,字典闭集 |
| **Layer B Parser + zod** | canonical rows | per fact_type 语义规范化 + 单位归一 + FactContentSchemas 强校验 | `patient_facts`(版本流)| 字段漂移即抛错,脏数据进不了库 |
| **Layer C 自由文本结构化**(W5+) | fact 里结构化不了的自由文本(emr/image)| 三轨抽取:① rule ② LLM(DeepSeek)③ keyword 兜底 | `recommendation_record` 独立 fact + 原 fact `normalized_signals[]` | ⭐ **不只是 recommendation** — 凡 yaml/规则结构化不了的,都靠 LLM 转结构化;**LLM 失败有 keyword 兜底**,绝不阻塞;产物带 confidence,算法侧 SignalQualityDiscount 降权 |
| **L1 患者事实** | canonical rows + emits | TransactionSynthesizer 写账本 + 触发 Layer B/C | `patient_transactions`(双留底)+ `patient_facts`(版本流)| **fact 是 PAC 唯一真理源**,下游只读 |
| **L2 Persona 画像** | active facts | 4 个 rule-based 特征(纯 TS 算法)| `personas` + `persona_features`(eventWatermark 幂等) | "特征 + 描述 + 证据 + 版本",不字段化拆 |
| **L3 召回计划** | facts + personas | scenario SQL 入池 + 6 因子算分 + 同 patient 多 sub-rule 合并 | `followup_plans` + `plan_reasons`(含 breakdown JSONB)| **plan = patient 级触达单元**,UNIQUE 保证 1 patient 1 active plan |
| **UI · 客服工作台** | plan-aggregate API | 列表排序 + 详情可解释性 | 客服可点开"为什么 87 分" | 6 因子 breakdown 完全可解释,无黑盒 |

---

## L2 患者画像(Persona)算法

> **入口**:`PersonaService.recompute(patientId)`,跑一组 `FeatureExtractor`(**纯 TS 规则,v1 无 LLM**),每个产 0/1 个 feature,写成 `personas` + `persona_features` 一个版本。
> **单向数据流**:只读 `patient_facts` active 版本,绝不回写。

### 编排流程

```
recompute(patientId, source)
  ① 水位幂等闸:max(transaction.eventSeq) ≤ persona.eventWatermark → noop(省全量提取)
  ② 拉该 patient 所有 active facts → 按 type 索引成 factsByType
  ③ 顺序跑 4 个 extractor(单个失败不影响其他,整体 status=partial)
  ④ supersede 旧 persona + 写新版本(version+1,记 eventWatermark 水位)
  ⑤ 落 persona_recompute_logs 账本(success / partial / failed / noop)
```

**纪律**:`description` 是**自包含自然语言**(客服直接看懂);`score` 只做排序辅助,不承载语义;`evidence.factIds` 必填(反查 fact → transaction → 原文)。

### v1 起步 4 特征(全规则路径)

| 特征 key                     | 中文         | 输入 fact                                                | 算法                                            | 输出档位(score)                                                      |
| -------------------------- | ---------- | ------------------------------------------------------ | --------------------------------------------- | ---------------------------------------------------------------- |
| **value**                  | 患者价值 / LTV | payment + recharge − refund                            | 累计净消费金额分档                                     | 钻卡 ≥¥3w(4) / 金卡 ≥¥1w(3) / 银卡 ≥¥3k(2) / 普通 ≥¥500(1) / 新客(0)       |
| **treatment_chain_status** | 治疗链状态      | diagnosis + treatment(actual/planned) + recommendation | 诊断码 → 期望治疗 category 配对,看有无对应 actual treatment | gap 有缺口(3) / closed 闭环(2) / in_progress 在管(1) / no_chain(0)      |
| **recall_risk**            | 召回风险 / 流失  | 上述 + encounter 的 occurredAt                            | ① 距上次临床事件天数 ② 诊断超黄金窗未治疗 = 缺口                  | high ≥540d 且缺口(3) / medium ≥360d 或缺口(2) / low ≥180d(1) / none(0) |
| **do_not_contact_status**  | 不打扰(合规硬约束) | profile.doNotContact / deceased + complaint_record     | 任一命中即 DNC,Plan 引擎必拦截                          | DNC(1) / contactable(0)                                          |

**消费方**:L2 的 `value` / `recall_risk` 两个 score 被 L3 的 6 因子算分(③ ValueBonus / ④ LikelihoodBonus)直接读取。

> ⚠️ **一致性提示**:"诊断码 → 期望治疗类别"映射目前散落 4 处各写一份(persona 的 treatment-chain / recall-risk、chain-composer、scenario 子规则),属 canonical-fact-layer.md「单一收口」原则的待收敛项 — 候选抽到 `canonical-codes.ts` 做单一真理源。

---

## 关键技术决策(W3 末)

### 决策 1 · SQL 退化为"等价 csv 单表 dump"

SQL 只允许:`SELECT col1, col2 FROM single_table WHERE filter`。
**禁止**:ARRAY JOIN / JSONExtract / multiIf / substring / 跨表 JOIN(那是形态改造,归 transforms)。

**好处**:直连 DW / csv dump / HTTP pull 给的数据**完全等价**,host 任意切换数据交付方式,PAC 这边一行不动。

### 决策 2 · 形态改造统一 yaml(6 operator 闭集)

| operator | 干什么 | 例子 |
|---|---|---|
| `project` | 选列/改名 | `keep: [id, patient_id]`、`rename: { id: emr_id }` |
| `split_json_array` | JSON 数组字段拆行 | EMR.diag JSON → N 行诊断子记录 |
| `derive` | 字段派生(substring/concat/lower/upper/default)| `K05.300 → K05` |
| `route_by_pattern` | 按字段值分流到多输出表 | treatName → recommendation / review / treatment 三路 |
| `pick_first_nonzero` | 多个数值列挑第一个 > 0 对应 label | 17 个 pay_* 列推 payment_channel |
| `filter` | 多字段 AND 行过滤(等价 WHERE)| `in_time: { not_empty: true }` |

**纪律**:不允许任意 JS 表达式 / eval。撞墙必须加 operator(走 PR + review)。

### 决策 3 · canonical-codes 加第 11 category `review`

**问题**:DW EMR treat_plan 大量含"常规复查/拆线/暂观/无治疗"等流程性动作 — 是真实事实,但**不是治疗动作**。
**方案**:加 `review` category 承载这类事实,scenario 算法不消费(不算"已做治疗")。
- ✅ fact 层保真(2714 行 review fact 入库)
- ❌ 不污染 preventive(避免"建议种植"被误判为"已做预防")

### 决策 4 · enum_mapping `_default` 兜底键

`AssemblerEngine.applyEnum` 查表顺序:`fieldMap[key]` → `fieldMap._default` → 原值。
**用法**:settlement_type 全枚举 ~45 个 → PAC category,长尾未识别 → `_default: ""` 让 parser 跳过(silently warn drop)。

---

## 真实数据案例(评审"故事化"用)

> **卜晓平 · 男 · 1985** · 杭州大厦诊所
> **故事**:2025-12-21 接诊,医生 EMR 诊断 **K08 缺失牙 17 号**,医嘱建议种植修复。**151 天过去**,患者无任何 implant / prosthodontic 治疗记录,无新预约。
>
> ## PAC 算法决策
>
> | 阶段 | 输出 |
> |---|---|
> | **L0/A.5/A**(数据源 → transforms → assembler) | DW EMR 单行 → transforms 拆出 1 个 encounter + 1 个 diagnosis(K08/17)+ 1 个 emr_record + 1 个 treatment_record(review:常规复查)→ assembler 翻译入 canonical |
> | **L1 fact**(parser + zod 强校验) | `encounter_record` × 1 + `diagnosis_record:K08:17` × 1 + `emr_record` × 1 + `treatment_record:review` × 1 |
> | **L2 Persona** | value=钻卡(LTV ¥57k+)/ treatment_chain_status=gap(K08 无 implant)/ recall_risk=low |
> | **L3 Scenario `treatment_initiation_recall`** | 命中子规则 `missing_tooth`(K08 ∈ 30-180d 窗 + 无 implant actual treatment)|
> | **6 因子算分** | base 60 × tw 1.0 + value+20 + likelihood+2 + urgency+5(>120d 邻牙倾斜)= **87 分** |
> | **Plan 落库** | priorityScore=87 / goal="邀约启动缺失牙修复(种植/桥/义齿)" / recommendedRole=staff / recommendedChannel=phone |
> | **客服 UI** | 召回池排第一;详情页 WhyCard 自动渲染 6 因子拆解 + FactsTimeline 时间轴 |

**全程纯规则,无 LLM**(W5+ 接 LLM 后,只是把 emr_record 自由文本里的"建议种植"抽成独立 `recommendation_record`,算法不变)。

---

## PAC 标准码字典(集团统一闭集)

> **单一真理源**:`packages/types/src/canonical-codes.ts`。所有 `fact.content` 里的 enum 值必须 ∈ 本字典。
> 宿主侧任意码(`A01` / `诊002` / `龋齿`)由 Layer A yaml `enum_mapping` 翻译进来,**集团内算法 / dashboard / 报表口径完全一致**。
> 只到大类粒度(不细分 K02.1 等亚分类),保持小集合够用即可;新增码走 PR(1 牙医 + 1 业务方 + 1 PAC 工程 review)。

### 诊断码(`diagnosis_record.content.code`)— 借 ICD-10 K00–K14 大类

| 码                                         | 中文                                                                                | 召回用途                    |
| ----------------------------------------- | --------------------------------------------------------------------------------- | ----------------------- |
| `K02`                                     | 龋病                                                                                | ⭐ #B 龋齿→充填的触发码          |
| `K04`                                     | 牙髓 / 根尖周炎⭐                                                                        | ⭐ #D 根管治疗链信号            |
| `K05`                                     | 牙周炎 / 牙龈炎                                                                         | ⭐ #C 牙周→SRP 的触发码        |
| `K08`                                     | 牙列丢失 / 缺牙                                                                         | ⭐ #A 缺牙→种植的触发码(最高价值)    |
| `K00`/`K01`/`K03`/`K06`/`K07`/`K09`–`K14` | 发育萌出障碍 / 阻生埋伏牙 / 牙体硬组织其他 / 牙龈牙槽骨 / 牙颌面异常 / 颌骨囊肿 / 颌骨其他 / 唾液腺 / 口腔黏膜 / 唇黏膜其他 / 舌疾患 | 事实保真,当前 scenario 暂不直接消费 |

**PAC 业务码**(ICD 不分类但算法需要):

| 码 | 中文 | 用途 |
|---|---|---|
| `IMPLANT_FAILURE` | 种植失败 / 周围炎 | 种植维护 / 投诉风险信号 |
| `ORTHO_RELAPSE` | 正畸反弹 | 正畸复诊召回信号 |
| `ENDO_FRACTURE` | 根管牙折裂 | 修复 / 拔除升级信号 |

### 推荐 / 建议码(`recommendation_record.content.code`)— EMR/影像文本抽取产物

> Layer C(LLM / 规则)从医生自由文本里抽出的"治疗建议",是召回的**强信号**(医生已明示该做某治疗,但患者未启动)。
> 注:代码层与诊断码共用同一 enum `PACDiagnosisCode`,语义上是独立的"建议"语族。

| 码 | 中文 | 召回用途 |
|---|---|---|
| `IMPLANT_RECOMMENDED` | 建议种植 | ⭐ #A 缺牙召回的双触发源之一 |
| `CROWN_RECOMMENDED` | 建议戴冠 | 根管后未戴冠召回信号 |
| `FILLING_RECOMMENDED` | 建议充填 | ⭐ #B 龋齿召回双触发源之一 |
| `SRP_RECOMMENDED` | 建议牙周基础治疗 | ⭐ #C 牙周召回双触发源之一 |
| `EXTRACTION_RECOMMENDED` | 建议拔除 | 拔牙→后续修复链信号 |
| `ANNUAL_REVIEW_RECOMMENDED` | 建议年度复查 | 种植 / 牙周维护期召回信号 |
| `ORTHO_CONSULT_RECOMMENDED` | 建议正畸咨询 | 正畸意向召回信号 |

### 治疗码(`treatment_record.content.category`)— 大类粒度(11 类)

> PAC 不存细治疗码,只存大类 `category`。召回算法用它判断"该患者是否已启动相应治疗"(排除条件)。

| 码               | 中文      | 召回用途                                              |
| --------------- | ------- | ------------------------------------------------- |
| `implant`       | 种植      | ⭐ #A 缺牙的排除 category(已种植则不召回)                      |
| `prosthodontic` | 修复 / 冠桥 | ⭐ #A 缺牙的排除 category(义齿/桥也算已修复)                    |
| `restorative`   | 充填 / 嵌体 | ⭐ #B 龋齿的排除 category                               |
| `periodontic`   | 牙周      | ⭐ #C 牙周的排除 category                               |
| `endodontic`    | 牙髓 / 根管 | ⭐ #D 根管治疗链                                        |
| `orthodontic`   | 正畸      | 正畸链                                               |
| `surgical`      | 外科      | 拔牙等外科                                             |
| `pediatric`     | 儿牙      | 儿童治疗                                              |
| `preventive`    | 预防      | 洁牙 / 涂氟等                                          |
| `cosmetic`      | 美学      | 美白 / 贴面                                           |
| `review`        | 复查 / 流程 | ⚠️ 拆线/暂观/无治疗等流程性事实;**算法不算"已做治疗"**,fact 层保真但不进排除条件 |

**治疗状态**(`treatment_record.content.status`):`planned` 计划中 / `in_progress` 进行中 / `completed` 已完成 / `cancelled` 已取消 / `failed` 治疗失败。
召回排除条件按 `kind='actual' AND status IN ('active','fulfilled')` 判定"已启动治疗"。

---

## L3 召回 SQL(scenario 入池 + 6 因子算分)

> **真理源**:`apps/pac-service/src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts`。
> 本节是该文件 SQL 的 1:1 摘录,供业务方核对入池/排除逻辑。**SQL 永远只查 `patient_facts.content`,不碰 raw_payload。**

### 子场景配置(W3 末扩至 K00–K09 全 10 子场景)

> 真理源:`SUB_SCENARIOS`(scenario 文件)+ `DiagnosisTreatmentMap`(`packages/types/src/canonical-codes.ts`)。
> `base` 在 scenario 文件;窗口/排除 category 在 canonical-codes 字典,按 `primaryCode` 关联。

| 子场景 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
SELECT
  p.id, p.external_id,
  sig.id  AS signal_fact_id,
  sig.type AS signal_type,             -- diagnosis_record | recommendation_record
  sig.content->>'code'           AS signal_code,
  sig.content->>'tooth_position' AS tooth,
  sig.content->>'confidence'     AS confidence,
  sig.clinic_id,
  COALESCE(sig.occurred_at, sig.planned_for) AS signal_occurred_at,
  EXTRACT(DAY FROM :now::timestamptz - COALESCE(sig.occurred_at, sig.planned_for))::int AS days_since
FROM patients p
JOIN patient_profiles pp ON pp.patient_id = p.id
JOIN patient_facts     sig ON sig.patient_id = p.id
WHERE p.host_id = :hostId::uuid AND p.tenant_id = :tenantId   -- ① 隔离(brand→tenant)
  -- ② 合规硬边界
  AND p.active = true AND pp.do_not_contact = false AND pp.deceased = false
  -- ③ 临床触发信号(诊断 或 医生建议,任一命中)
  AND sig.status = 'active'
  AND sig.type IN ('diagnosis_record', 'recommendation_record')
  AND sig.content->>'code' = ANY(:allCodes::text[])
  -- ④ 只设时间下界(冷静期),⚠️ 不设上界
  AND COALESCE(sig.occurred_at, sig.planned_for) IS NOT NULL
  AND COALESCE(sig.occurred_at, sig.planned_for) <= :now - (:cooldown||' days')::interval
  -- ⑤a 排除:诊断之后启动的同类 actual 治疗(牙位级 overlap)
  AND NOT EXISTS (
    SELECT 1 FROM patient_facts tx
    WHERE tx.patient_id = p.id
      AND tx.type = 'treatment_record' AND tx.kind = 'actual'
      AND tx.status IN ('active', 'fulfilled')          -- 完成的 actual 是 fulfilled
      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)。`COALESCE(occurred_at, planned_for)` — 诊断用 occurred_at,推荐用 planned_for。`review` category 不算已做治疗。
2. **只设下界,不设上界**(W3 末改):入池只过滤 `<= now - cooldown`。**上界(黄金窗 window)废了** — 缺牙拖 1 年比拖 3 个月更该召;超 window 仍入池,交 scorer 的 `timeWindowFactor` 衰减。旧 bug:入池上界 = window → 缺牙 >180 天直接踢出池,scorer"过晚"分支成死代码。
3. **排除 `status IN ('active','fulfilled')`** — actual 完成后 status=`fulfilled` 非 `active`;漏 fulfilled 会把已做治疗者错召为未启动(W3 末修)。
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 行,都进库),不再被合并吃掉。

### 入池后:v3.0 三维算分(priority-scorer,纯 TS;W7 重做)

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

```
综合(0-10) = 急迫性×0.4 + 价值性×0.3 + 意愿度×0.3
score(0-100) = clamp( 综合 × 新鲜度 × 置信度 × 10 , 0, 100 )
  意愿度 = RFM依从×0.375 + 主诉行为×0.375 + 信任基础×0.25
```

| 维度/因子 | 来源 | 实例(种植多颗,咨询过,重要价值,成熟客)|
|---|---|---|
| 急迫性 ×0.4 | urgency_level | 10(紧急)|
| 价值性 ×0.3 | 治疗类型+牙数 | 9(种植 4 颗)|
| 意愿度 ×0.3 | RFM依从+主诉行为+信任基础 | 9.0(重要价值10 / 咨询过8 / 成熟9+种植史1)|
| 新鲜度 ×(0.4-1) | days_since vs 病种黄金窗 | ×1.0(窗内;老诊断衰减止损)|
| 置信度 ×(0.75-1) | code_source(医生/影像AI/建议)| ×1.0(医生诊断;影像AI ×0.9)|
| **score** | | **94** |

`breakdown`(三维 + 子项 + 新鲜度/置信度)落 `plan_reasons` JSONB,详情页 WhyCard 完全可解释。
旧 6 因子加权启发式(base×时间窗 + value/likelihood/urgency 加分 × confidence)已替换。

### 同 patient 多子规则 → 合并一条 plan

scenario 跑完 #A/#B/#C 各自产 hit;同一 patient 命中多个子规则时,**合并进同一条 `followup_plans`**(patient 级触达单元),各子规则作为 `plan_reasons[]` 行,`plan.priorityScore = MAX(各 reason.priorityScore)`。这保证"一个患者 = 一通电话 = 一张召回卡片"。

---

## 完备性 / 可靠性的几道闸(底色)

| # | 闸 | 防什么 |
|---|---|---|
| 1 | `source_event_id` partial UNIQUE | 同 transaction 重复入库 |
| 2 | `patient_facts.subjectId active UNIQUE` | 同 fact 多 active 版本冲突 |
| 3 | `FactContentSchemas` zod 强校验 | parser 字段漂移让脏数据入 fact |
| 4 | `canonical-codes.ts` 闭集 + `_default` 兜底 | yaml 翻译目标在闭集外;长尾自动 drop |
| 5 | `dedup_by` yaml 兜底 | DW ETL 炸行(EMR 5x / settlement 5x)|
| 6 | `event_seq` BigInt 水位 | Persona 重算丢漏事件 |
| 7 | 6 operator 白名单 | yaml 越权改 fact 主体(只允许声明式纯函数) |
| 8 | `breakdown` JSONB 落库 | 算法决策无解释性(可视性=可信任) |
| 9 | CI 防漂移测试 53 个 | 加 fact_type 忘加 zod / yaml enum 漂移 / operator 边界回归 |

---

## 数据规模(实测 W3 末)

| 数据 | 规模 |
|---|---|
| jvs-dw 5 试点诊所 | 14 万患者(瑞尔 8.4 万 + 瑞泰 4.6 万) |
| 一期 cohort 试运行 | **1,000 患者**(最近就诊优先,可调) |
| DW raw 行数(过滤后) | EMR 9k / 预约 18.8k / 结算 42.8k / 支付 5.2k |
| Layer A.5 transforms 产出 | diagnosis 560 / treatment(planned)6,788 / treatment(review)2,714 / treatment(actual)11,924 / recommendation 55 |
| 入库 transactions | **63,781**(0 failed) |
| 入库 facts | **~60,000**(0 failed,review category 2,714 行真实入库)|
| 召回率 | ~3.2%(W3 末跑通,完整数字待新一轮 plan recompute)|
| Top patient | 卜晓平 87 分(K08 缺牙 151 天) |

---

## W3 末实现状态

| 模块 | 状态 |
|---|---|
| Cold-import(SQL 直连 / CSV)| ✅ 跑通,0 fact failed |
| Layer A.5 transforms(6 operator)| ✅ 实现 + 49 测试通过 |
| Layer A AssemblerEngine(`_default` 兜底)| ✅ |
| Parser pipeline + FactContentSchemas zod | ✅ |
| Persona 4 起步特征 | ✅ |
| 6 因子 Priority Scorer + breakdown | ✅ |
| Scenario `treatment_initiation_recall`(10 子规则 K00–K09 + tooth-overlap union-find)| ✅ |
| 详情页 UI(WhyCard + FactsTimeline + ScriptStream)| ✅ |
| 4 入口统一管道(dispatcher emitsResolver)| ✅ P0 补完 |
| Mock Pull 端到端 | ✅ 自带 emitsPerResource |
| Push 形态 A(canonical payload)| ✅ event 自带 emits |
| Push 形态 B(host raw payload)| 🟡 框架预留,需 host 时实装 |
| Real HTTP Pull(api-key / login)| 🟡 框架预留,W5+ 接真 host |
| DW 直连增量 cursor | ✅ 统一 sync(存量增量同一套)+ cohort 分批 + 并发 + bulk write(2026-05-28 重构)|
| Layer C 自由文本抽取(LLM)| 🔵 W5+ 计划 |
