# 潜在治疗召回 — 算法设计 + 数据摄入

> ⚠️ **本文是 W2 末设计稿(历史)**。已实现版本 + 真理源见
> [`potential-treatment-recall-flow.md`](./potential-treatment-recall-flow.md)。
> 关键差异(以 flow 文档为准):
> - 子场景 **4 → 10**(K00–K09 全覆盖),非本文的 A/B/C/D 4 个
> - 入池**只设时间下界 cooldown,不设上界**(超黄金窗仍入池,交 scorer 衰减)
> - 合并逻辑:本文的"per-patient 单 hit" → 实际是 **tooth-overlap union-find**
>   (sub_key = `<sub>@<tooth|whole>`,同患者多牙位 = 多 reason)
> - 数据摄入:本文的 cold-import → 统一 `pnpm sync`(cohort 分批 + 并发 + bulk write)
>
> 本文保留作设计思路存档,**具体参数 / SQL / 命令请勿照本文实施**。

> **版本**:v1.1 · W2 末
> **作者**:luoqi
> **状态**:🎨 设计稿(历史)— 已被 potential-treatment-recall-flow.md 取代
>
> **本文定位**:**特定召回算法**("潜在治疗新链发现")的设计。通用 fact 规范化基础设施见姊妹文档 [`canonical-fact-layer.md`](./canonical-fact-layer.md)——本文不重复讲 fact 怎么变 canonical,聚焦"基于 canonical fact,潜在治疗召回怎么算"。
>
> **背景**:W2 demo(张志远 · 36 牙缺失 5 个月未启动种植)所代表的"潜在治疗新链发现"是 PAC 一期主战场,**业务价值最高**(单条种植机会 ¥1.5-3 万)。
>
> W2 末两条线齐推:
> 1. 审计 PAC mock 链路发现 4 个根本性问题(§一)→ 已在 canonical-fact-layer.md 拍板架构修订
> 2. 摸瑞尔 DW 真实数据发现"惊喜:fact_emr_treatment_out 一张表把诊断/治疗计划/影像/EMR 文本全合并"(§附录 B)→ 本算法可基于真实数据跑

---

## ▎核心原则:fact 是单一真理源

```
       host 原始数据(脏乱多源)
              │
              ▼   adapter + parser + normalizer  ★ 本文重点 ★
              │
   ┌────────────────────────┐
   │  patient_facts(标准)  │ ← PAC 一切下游的唯一输入
   └─────┬────────┬────────┬┘
         ↓        ↓        ↓
       Persona  Plan   AI 话术
```

**纪律**:fact 一旦写入,下游(Persona / PlanEngine / AI prompts)只读 fact,**不再回头解析 raw_payload**。fact 不规范,下游全废。

**推论**:fact.content 的形态必须是**枚举 / 结构化数值 / 归一化字符串**,**绝不能是原始自由文本**(自由文本只保留在 `raw_payload` 留底,不进 content)。

---

## 目录

- [一、当前实现的"塌方点"诊断](#一当前实现的塌方点诊断)
- [二、本次目标:潜在治疗召回的完整数据通路](#二本次目标潜在治疗召回的完整数据通路)
- [三、需要的 5 个信号源 + 各自来源](#三需要的-5-个信号源--各自来源)
- [四、Layer C 信号产物 — 独立 fact 优先,内嵌 signals 兜底](#四layer-c-信号产物--独立-fact-优先内嵌-signals-兜底)
- [五、三轨规范化策略](#五三轨规范化策略)
- [六、数据字典:复用 canonical-fact-layer.md](#六数据字典复用-canonical-fact-layermd)
- [七、改造的代码模块](#七改造的代码模块)
- [八、算法 v2:基于独立 fact_type 的入池 + 优先级](#八算法-v2基于-normalized_signals-的入池--优先级)
- [九、实施路径 + 工作量](#九实施路径--工作量)
- [十、验收标准 + 测试方案](#十验收标准--测试方案)
- [附录 A:跟现有文档的对齐](#附录-a跟现有文档的对齐)
- [附录 B:W2 末摸瑞尔 DW 对本算法的影响](#附录-bw2-末摸瑞尔-dw-对本算法的影响)

---

## 一、当前实现的"塌方点"诊断

W2 末审计 6 个 scenario SQL,4 个根本性问题:

| # | 现状 | 真实数据进来会怎样 |
|---|---|---|
| 1 | `pf.content` 期望 `treatment_category='endodontic'` 这种枚举 | 宿主可能给"做了根管治疗"自由文本 / 自定义码"RCT" → SQL 完全失效 |
| 2 | 时间窗 12 个常量散在 4 个 .ts 文件硬编码 | 业务方调参要改代码 + 重新部署 |
| 3 | 信号源只有 `encounter_record.content.diagnoses[]` 1 个 | 漏掉 EMR 自由文本 / 影像 finding / 医嘱 next_step **50%+ 信号** |
| 4 | `emr_record` 完全未利用 | 中国诊所一半诊断细节藏在 EMR 自由文本里 |

详见 W2 W2 设计讨论记录(本文档前置讨论)。

**根因**:fact.content 没有"信号规范化层"。当前 parser 只是机械搬运 canonicalRow,canonical 数据本身已经被 mock 数据"作弊"过。

---

## 二、本次目标:潜在治疗召回的完整数据通路

### 2.1 业务定义

> **潜在治疗召回**(treatment_initiation_recall)= 患者**有治疗需求信号**(诊断 / 医嘱 / 影像)但**没启动对应治疗**。

4 个主子场景:

| 子场景 | 业务含义 | 单条价值 |
|---|---|---|
| **#A 缺失牙 → 种植 / 桥 / 义齿**(K08)| 缺牙诊断后未启动修复 | ¥1.5-3 万 |
| **#B 龋齿 → 充填 / 嵌体**(K02)| 龋齿诊断后未做填充 | ¥800-3000 |
| **#C 牙周诊断 → SRP / 翻瓣**(K05)| 牙周炎诊断未做基础治疗 | ¥2000-5000 |
| **#D 牙髓/根尖周炎 → 根管**(K04)| 牙髓炎/根尖周炎诊断未做根管 | ¥2000-5000 |

(更多子场景按相同框架扩展)

#### 决策记录 · review(复查/拆线/暂观/无治疗)要不要排除召回?

**问题**:患者有诊断新病、又复查/暂观过,还要不要召回(至少别太快)?

**结论(W3 末):暂缓实现,保持现状 + 设计留档。**

- **现状即合理**:review 不算"已做治疗"(scenario 不消费、不进 excludeCats),保持。
  "刚诊断别太快召回"已由**窗口近端 cooldown** 覆盖(K04 <14 天 / K08 <30 天不召)。
- **数据不支持现在建**:1000 cohort 实测——
  - 暂观/观察(显式 defer 信号)仅 19 / 1180 条 review;
  - 全人群"诊断 ∩ 同牙位暂观"仅 5 个患者;**召回池命中 0**。
  - 召回患者都在诊断后早期窗口,复查尚未发生(诊断后复查 = 0)→ 现在建抑制规则**无样本可验证 = 盲写**。
- **设计留档**(数据成熟 / 换 host 后再实现):
  - **暂观/观察**(98% 带牙位,可精确匹配诊断牙)→ 医生显式 defer → **抑制**(压到观察周期后)。
  - **复查/拆线/常规**(多为全口,牙位少)→ 只算"最近一次临床接触"→ **冷静期延时**,不永久排除(缺口仍在)。
- **触发条件**:诊断码覆盖率再提升 + 历史时间拉长(复查在诊断后积累),或接入"暂观"用得多的 host。

### 2.2 数据通路(对齐 canonical-fact-layer.md 3 层)

```
host 原始数据(瑞尔 DW 实例 / 通善 / 5i5ya / Friday 等)
   ├─ 瑞尔 DW(ADS 实表):fact_client_out / fact_appointment_out /
   │  fact_settlement_out / fact_settlement_mode_out /
   │  fact_emr_treatment_out  ← 含 EMR/诊断/治疗计划/影像引用
   ├─ 通善 / 5i5ya(假设):可能各自结构
   └─ ...
            ↓
   ┌─────────────────────────────────────────────────────────┐
   │ Layer A · AssemblerEngine(yaml,per-host)               │
   │   字段命名翻译 + 枚举翻译 + 结构映射(子表嵌套等)        │
   │   brand → tenant_id(瑞尔/瑞泰)                          │
   └────────────────────────────┬───────────────────────────┘
                                ↓
                  canonical row(临时,留底到 transaction.canonical_payload)
                                ↓
   ┌─────────────────────────────────────────────────────────┐
   │ Layer B · Parser + Zod Schema(per fact_type,集团统一)  │
   │   写 fact:                                              │
   │     - encounter_record(只元数据)                        │
   │     - diagnosis_record(独立)                             │
   │     - treatment_record(独立,actual/planned)            │
   │     - emr_record(自由文本保留)                          │
   │     - image_record(metadata + finding)                  │
   │   FactContentSchemas zod 强校验                          │
   └────────────────────────────┬───────────────────────────┘
                                ↓
   ┌─────────────────────────────────────────────────────────┐
   │ Layer C · Signal Extractor(LLM,异步)★ 本文重点 ★      │
   │   读 emr/image fact 的自由文本字段                       │
   │   产出 recommendation_record fact(IMPLANT_RECOMMENDED 等)│
   │   3 路并轨:                                             │
   │     ① rule-based 结构化映射(yaml + 字典码,Layer A 阶段已做)│
   │     ② LLM 抽取(extract_clinical_signals AiCall)         │
   │     ③ 关键词兜底(失败保底)                              │
   └────────────────────────────┬───────────────────────────┘
                                ↓
              ━━━━━ fact.content ⭐ 单一收束点 ⭐ ━━━━━
                                ↓
                  下游消费(算法 / Persona / 前端 / AI)
                  本文 §八 = 召回算法层(scenario / priority-scorer)
```

**Layer C 是本文重点**(LLM 抽自由文本,把宿主病历长啥样跟算法吃啥样解耦)。其他层在 `canonical-fact-layer.md` 详谈。

---

## 三、需要的 5 个信号源 + 各自来源

潜在治疗召回的核心信号(以"缺牙 → 种植"为例):

| # | 信号 | PAC 这边的 fact_type(经 Layer B 拆分后)| 数据形态 | W2 末状态 |
|---|---|---|---|---|
| 1 | **诊断码 / 诊断名 / 牙位** | `diagnosis_record.content.code/tooth_position` | 结构化(PAC 字典 K08 等) | ✅ 主信号 |
| 2 | **EMR 主诉 / 现病史 / 检查所见** | `emr_record.content.diagnosis_text/illness_desc` | 自由文本 | ⚠️ 待 Layer C LLM 抽 |
| 3 | **EMR 治疗计划 / 医嘱** | `emr_record.content.treatment_plan/doctor_advice` | 自由文本 | ⚠️ 待 Layer C |
| 4 | **影像医生 finding / AI output** | `image_record.content.finding`(W5+ 朝厚 AI 接入后变结构化) | 自由文本 | ⚠️ 待 Layer C |
| 5 | **医嘱治疗推荐**(LLM 抽出来) | `recommendation_record.content.code`(IMPLANT_RECOMMENDED 等) | 结构化(PAC 业务码) | ⚠️ Layer C 产物 |

**关键洞察**:
- 信号 1 是基础(结构化诊断)
- 信号 2/3/4 是中国诊所信号的主体(自由文本)
- 信号 5 是 Layer C LLM 从 2/3/4 抽出来的"治疗推荐"独立 fact ⭐(对应 canonical-fact-layer.md §四.4 recommendation_record)

### 3.1 真实 DW 数据形态对照(W2 末摸瑞尔 DW)

瑞尔 DW `fact_emr_treatment_out` 一张表把信号 1-4 **全合并**了:

| PAC 信号 | 瑞尔 DW 字段 | 形态 |
|---|---|---|
| 信号 1 诊断 | `diag` 字段(JSON Array)| `[{toothPosition:'26;27', message:'种植术后', diagUniqueId:'...'}]` |
| 信号 2 EMR 主诉 | `illness_desc` / `pre_illness` | 自由文本 |
| 信号 3 治疗计划 | `treat_plan`(JSON Array,含 `planCode` 标准码) | 半结构化 |
| 信号 3 医嘱 | `doc_order` | 自由文本 |
| 信号 4 影像 | `file_url`(JSON Array,含拍摄时间 + 类型) | 半结构化 |

**这给 Layer A yaml 极大便利**——一张 host 表投影成多个 canonical resource(`emr_record` + `image_record`),或者**一张表对应一个 canonical**(嵌套字段保持原样),Layer B parser 拆出 4 个 fact。详见 canonical-fact-layer.md §3.5 的多租户处理 + §四 fact 重塑。

---

## 四、Layer C 信号产物 — 独立 fact 优先,内嵌 signals 兜底

### 4.1 双轨设计(W2 末跟 canonical-fact-layer.md §四对齐后的最终方案)

Layer C LLM 抽取出来的"治疗推荐 / 建议"等高价值信号,**优先落成独立 fact_type**(对应 canonical-fact-layer.md §四.4 `recommendation_record`):

```
emr_record.content.treatment_plan = "建议单颗种植 36"
                    ↓ Layer C LLM 抽取
                    ↓ extract_clinical_signals AiCall
                    ↓
recommendation_record fact ⭐ 独立(优先)
  content:
    code: "IMPLANT_RECOMMENDED"
    tooth_position: "36"
    source_fact_id: emr_record:emr_P011_exam
    extracted_by: "llm"
    confidence: 0.95
    doctor_suggested_at: "2026-03-19"
    window_days: 90
```

**好处**:scenario SQL 直接 `JOIN recommendation_record WHERE code='IMPLANT_RECOMMENDED'`,索引友好,跟 diagnosis_record / treatment_record 一致。

### 4.2 兜底 — `fact.content.normalized_signals[]` 内嵌

低价值 / 元信号(如 EMR 内的"伴有牙周炎"次要诊断)不值得开独立 fact_type,**挂在原 fact `content.normalized_signals[]` 兜底**:

```typescript
interface NormalizedSignal {
  /** 信号类型 */
  kind: 'diagnosis_secondary' | 'observation' | 'history_note';
  
  /** 标准码(PAC 字典) */
  code: string;                    // 'K05' / 'K07' / 'allergy_history'
  
  /** 牙位(FDI 牙位号,11-48 + 51-85 乳牙)*/
  toothPosition?: string;
  
  /** 信号来源 */
  source: 'emr_text' | 'image_finding' | 'doctor_advice';
  
  /** 抽取方式 + 置信度 */
  extractedBy: 'rule' | 'llm' | 'keyword';
  confidence: number;              // 0.0-1.0
  
  /** 原始证据 */
  evidence?: {
    excerpt?: string;
    agentInvocationId?: string;
  };
}
```

### 4.3 双轨决策表

| 信号类型 | 落点 | 理由 |
|---|---|---|
| 诊断(K02/K04/K05/K07/K08)| `diagnosis_record` 独立 fact | 算法主信号,要 JOIN 查 |
| 治疗已发生(implant/restorative/...)| `treatment_record` 独立 fact | 算法主信号 |
| **治疗推荐(IMPLANT_RECOMMENDED 等)** | **`recommendation_record` 独立 fact** ⭐ | 召回核心信号 |
| 次要诊断(伴随发现) | 原 fact `content.normalized_signals[]` | 算法弱信号,不值得独立 fact |
| EMR 病史 / 过敏 / 用药史 | 原 fact `content.normalized_signals[]` | 同上 |
| 影像 finding(纯描述,无推荐) | 原 fact `content.normalized_signals[]` | 同上 |

**纪律**:决定独立 fact 还是 normalized_signals 的判据 = **scenario SQL 会不会 JOIN 它**。会 JOIN 的开独立 fact,只是用作弱信号增强的进 normalized_signals。

---

## 五、三轨规范化策略

每个信号源根据数据形态选用合适的规范化方式:

```
                  数据形态
                    │
        ┌───────────┴────────────┐
        ▼                        ▼
    结构化码                  自由文本
        │                        │
        ▼                        ▼
    路 ① rule-based         路 ② LLM 抽取
   (yaml 映射)            (extract_clinical_signals AiCall)
        │                        │
        └──────────┬─────────────┘
                   ▼
              路 ③ keyword 兜底
            (前两路失败时启用)
```

### 路 ① · rule-based 结构化映射(成本最低,首选)

适用:host 已有结构化诊断 / 治疗码(ICD-10 / SNOMED / 自定义码)。

**实现**:
- AssemblerEngine 装载 yaml(已存在,需补诊疗码 mapping)
- 落 fact 时直接生成 `normalized_signals[]`

例子(yaml):

```yaml
# apps/pac-service/src/modules/sync/assembler/configs/friday-clinical-codes.yaml
diagnosis_code_mapping:
  # ICD-10 直传
  K02.x:    { canonical_code: K02, category: caries }
  K04.x:    { canonical_code: K04, category: pulpitis }
  K05.x:    { canonical_code: K05, category: periodontitis }
  K08.x:    { canonical_code: K08, category: tooth_loss }
  
  # 宿主自定义码
  诊002:    { canonical_code: K02, category: caries }
  诊004:    { canonical_code: K05, category: periodontitis }
  诊008:    { canonical_code: K08, category: tooth_loss }

treatment_code_mapping:
  RCT:      { category: endodontic, subtype: root_canal }
  CROWN:    { category: prosthodontic, subtype: crown }
  IMPLANT:  { category: implant, subtype: standard }
  SRP:      { category: periodontic, subtype: scaling_root_planing }
  种植:     { category: implant, subtype: standard }     # 中文也能映射
```

**适配点**:Layer A `AssemblerEngine` 已经有 yaml 框架,只需要补 `diagnosis_code_mapping` / `treatment_code_mapping` 两段。

### 路 ② · LLM 抽取(自由文本 → 结构化)

适用:emr_record.diagnosis_text / treatment_plan / image_record.finding(自由文本)。

**实现**:新增 AiCall `extract_clinical_signals`

```typescript
// apps/pac-service/src/modules/ai/calls/extract-clinical-signals/
//   call.ts / prompt.ts / schema.ts / input.types.ts

const SystemPrompt = `你是临床信息抽取助手,从牙科 EMR / 影像 finding 自由文本中,识别"诊断"
和"治疗建议"信号,输出结构化 JSON。

任务:
  1. 识别牙科诊断(对应 ICD-10 K00-K14 范围)
  2. 识别治疗建议(医生显式或隐式推荐的治疗)
  3. 识别牙位(FDI 牙位号 11-48 / 51-85)
  4. 每个信号给一个 confidence(0.0-1.0)

输出格式(zod schema 约束):
{
  signals: [
    {
      kind: 'diagnosis' | 'treatment_plan',
      code: 'K02' | 'K04' | 'K05' | 'K08' | 'implant_recommended' | 'crown_recommended' | ...
      toothPosition: '36',
      confidence: 0.92,
      excerpt: '原文片段(≤30 字)'
    }
  ]
}`;

// 输入(每次一次 EMR 的 text 段,prompt cache 命中率高)
{
  patient_brief: { gender, age },           // 上下文,不含 PII
  text_sections: {
    diagnosis_text: "患者主诉36牙缺失2年...",
    treatment_plan: "建议种植修复...",
    doctor_advice: "6个月内启动"
  }
}
```

**成本估算**(DeepSeek V4 Pro,prompt cache 96%):
- 单次抽取 ~ 1000 prompt tokens + 200 output tokens
- 单次成本:¥0.013 / 次
- 5 家试点全量 EMR ≈ 60 万行 → 一次性 ¥7,800
- 后续增量(每月新增 5 万行)→ 月 ¥650

**调用时机**:
- Cold-import 时批量跑(参考 §九 实施路径)
- 增量 emr_submitted txn 进来时,parser 跑完后异步入队 extract job

### 路 ③ · 关键词兜底

适用:LLM 抽取失败 / 暂未跑 / 边界场景。

```typescript
// apps/pac-service/src/modules/sync/signal-extractor/keyword-rules.ts
const KEYWORD_RULES = {
  K08: ['缺失', '缺牙', '残根', '残冠', '拔除后', '种植位', 'missing tooth'],
  K02: ['龋', '龋齿', '深龋', '中龋', '继发龋', 'caries'],
  K04: ['牙髓炎', '牙髓坏死', '根尖周炎', 'pulpitis'],
  K05: ['牙周炎', '牙周袋', 'BOP+', 'periodontitis'],
  implant_recommended: ['建议种植', '种植修复', '种植牙', '推荐种植'],
  crown_recommended: ['建议戴冠', '冠修复', '全瓷冠', '需要做冠'],
};

// 兜底输出 confidence = 0.5(算法侧低权重对待)
```

**优先级**:① > ② > ③。任一信号在 ① 抽到了,后两路跳过该信号(去重)。

---

## 六、数据字典:复用 canonical-fact-layer.md

字典已在 [`canonical-fact-layer.md §三 PAC 标准字典`](./canonical-fact-layer.md#三pac-标准字典--集团统一宿主随意)定义,本文不重复。

潜在治疗召回**直接使用其中**:
- 诊断码:`K02 / K04 / K05 / K07 / K08`(覆盖龋病 / 牙髓 / 牙周 / 正畸 / 缺牙)
- 治疗类别:`endodontic / restorative / prosthodontic / implant / periodontic / orthodontic / surgical / pediatric`
- 治疗推荐码:`IMPLANT_RECOMMENDED / CROWN_RECOMMENDED / SRP_RECOMMENDED / FILLING_RECOMMENDED / EXTRACTION_RECOMMENDED / ANNUAL_REVIEW_RECOMMENDED / ORTHO_CONSULT_RECOMMENDED`
- 牙位:FDI 标准(11-48 恒牙 + 51-85 乳牙)

字典定义文件:`packages/types/src/canonical-codes.ts`(单一真理源)
维护规则:新增码必走 PR + 牙医 + 业务方 review。

---

## 七、改造的代码模块

### 7.1 新增

```
apps/pac-service/src/modules/signal-extractor/
  signal-extractor.module.ts
  signal-extractor.service.ts          # Layer C 主入口(异步处理)
  rule-extractor.ts                    # 路 ① 结构化映射(其实主要由 Layer A yaml 做)
  llm-extractor.service.ts             # 路 ② LLM 抽取(包装 AiCall)
  keyword-extractor.ts                 # 路 ③ 兜底
  signal-canonical.ts                  # NormalizedSignal 类型 + 引用 canonical-codes

apps/pac-service/src/modules/ai/calls/extract-clinical-signals/
  call.ts                              # AiCall 定义
  prompt.ts                            # system + user prompt 模板
  schema.ts                            # output Zod schema(包含 recommended_treatments[])
  input.types.ts                       # 输入类型

apps/pac-service/src/modules/plan/engine/scenario-config.ts
  # 抽出来的子场景配置(时间窗 / 触发码 / 排除规则)
```

### 7.2 改造

```
apps/pac-service/src/modules/sync/pipeline/parsers/encounter.parser.ts
  # 拆出 diagnosis_record + treatment_record(对齐 canonical-fact-layer.md §四)
  # 不再嵌套 diagnoses[] / treatments[],改成独立 fact

apps/pac-service/src/modules/sync/pipeline/parsers/emr.parser.ts
  # 不再写 normalized_signals(主信号走独立 fact)
  # 写完 emr_record 后入队 signal-extract,异步走 Layer C

apps/pac-service/src/modules/sync/pipeline/parsers/image.parser.ts
  # 同 emr.parser

apps/pac-service/src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts
  # SQL 改成 JOIN diagnosis_record / treatment_record / recommendation_record
  # 时间窗读 scenario-config
```

### 7.3 新增队列

```
apps/pac-service/src/queues/processors/signal-extract.processor.ts
  # 新加 signal-extract 队列(跟现有 persona/plan/asset 队列同级)
  # 每个 emr_record / image_record fact 写入后异步入队
  # processor 跑 LLM 抽取 → 主信号产 recommendation_record 独立 fact
  #                       → 弱信号回填原 fact content.normalized_signals[]
```

### 7.4 测试 / 工具

```
apps/pac-service/src/cli/extract-signals.cli.ts
  # 批量 backfill 现有 fact 的 normalized_signals
  # pnpm extract-signals --host=demo --fact-type=emr_record
```

---

## 八、算法 v2:基于独立 fact_type 的入池 + 优先级

### 8.1 入池(Layer 1)— 布尔过滤

**潜在治疗-缺牙→种植** 的入池条件(改造后):

```sql
SELECT p.id, ...
FROM patients p
JOIN patient_profiles pp ON pp.patient_id = p.id
WHERE
  p.host_id    = ?
  AND p.tenant_id = ?   -- ⭐ brand 映射 (ruier/ruitai),自动隔离

  -- ① 合规硬边界
  AND p.active = true
  AND pp.do_not_contact = false
  AND pp.deceased = false
  AND p.phone IS NOT NULL AND p.phone <> ''
  
  -- ② 临床触发(独立 fact 直接 EXISTS,索引友好)
  AND EXISTS (
    SELECT 1 FROM patient_facts dx
    WHERE dx.patient_id = p.id
      AND dx.type = 'diagnosis_record'
      AND dx.status = 'active'
      AND dx.content->>'code' = 'K08'                    -- ⭐ 直接打字段
      AND dx.occurred_at BETWEEN
            now() - INTERVAL '180 days' AND now() - INTERVAL '30 days'
  )
  
  -- ③ 排除(已启动相关治疗)
  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 = 'active'
      AND tx.content->>'category' = 'implant'            -- ⭐ 已做种植
  )
  
  -- ④ 排除(冷静期硬过滤)
  AND NOT EXISTS (
    SELECT 1 FROM plan_executions pe
    JOIN followup_plans fp ON fp.id = pe.plan_id
    WHERE fp.patient_id = p.id AND pe.created_at > now() - INTERVAL '7 days'
  )

  -- ⑤ 加分:recommendation_record(LLM 抽出来的"建议种植")命中
  --     用 LATERAL JOIN 拉一下,后面优先级算分用,这里不当入池条件
```

⭐ 关键变化(对齐 canonical-fact-layer.md §4.7):
- 用 `dx.type = 'diagnosis_record'` + `dx.content->>'code'`(直接字段),**索引友好,不再 LIKE 自由文本**
- `treatment_record` / `recommendation_record` 都是独立 fact_type,JOIN 干净
- 时间窗用 SQL `INTERVAL`,值从 `scenario-config.ts` 注入
- 7 天冷静期从软扣分改硬过滤
- 多 brand 通过 `tenant_id` WHERE 自动隔离(brand 在 yaml 时已映射)

### 8.2 优先级(Layer 2)— 数值

参考前面 6 因子讨论(本文不重复),整理成最终公式:

```typescript
function calcPriorityScore(input: {
  base: number;              // 子场景基线(60 缺牙 / 55 根管 / ...)
  daysSince: number;
  goldenRange: [number, number];
  persona: Persona | null;
  signalConfidences: number[];  // 各信号源 confidence,LLM 抽的可能 < 1
  recentExecutions: Execution[];
}): { score: number; breakdown: PriorityBreakdown } {
  
  // 1. TimeWindowFactor(看 daysSince 落在 golden 哪段)
  const tw = computeTimeWindowFactor(input.daysSince, input.goldenRange);
  const main = input.base * tw;  // 0-60
  
  // 2. ValueBonus(Persona value feature)
  const value = input.persona?.features.find(f => f.key === 'value');
  const valueBonus =
    (value?.score ?? 0) >= 0.8 ? 20 :
    (value?.score ?? 0) >= 0.5 ? 12 :
    (value?.score ?? 0) >= 0.3 ? 5 : 0;
  
  // 3. LikelihoodBonus(Persona recall_risk + 历史 execution)
  const risk = input.persona?.features.find(f => f.key === 'recall_risk');
  const riskBonus = Math.round((1 - (risk?.score ?? 0.5)) * 6);
  const recentSuccessBonus = input.recentExecutions.some(e =>
    ['success_appointed', 'rescheduled'].includes(e.outcome)
  ) ? 4 : 0;
  const likelihoodBonus = Math.min(10, riskBonus + recentSuccessBonus);
  
  // 4. UrgencyBonus(daysSince 超 临床晋级临界 时加分)
  const urgencyBonus = computeUrgencyBonus(input.daysSince /* + scenario-specific */);
  
  // 5. SignalQualityDiscount(信号 confidence 低时降权)
  // 如果都是 LLM 抽出来的低置信度信号,整体打折
  const minConfidence = Math.min(...input.signalConfidences);
  const confidenceFactor = minConfidence >= 0.9 ? 1.0 : minConfidence >= 0.7 ? 0.9 : 0.75;
  
  const raw = (main + valueBonus + likelihoodBonus + urgencyBonus) * confidenceFactor;
  const score = Math.max(0, Math.min(100, Math.round(raw)));
  
  return {
    score,
    breakdown: {
      main, base: input.base, timeWindowFactor: tw,
      valueBonus, likelihoodBonus, urgencyBonus, confidenceFactor,
    },
  };
}
```

**新增**:`SignalQualityDiscount` — LLM 抽出来的低置信信号别让它跟 ICD 码同等权重,这是公正的。

### 8.3 张志远 v2 算分例子

> 张志远是 PAC 内部 mock 演示样本(不是宿主真实数据)。这里展示算分逻辑;W3 接 DW 真实数据后,瑞尔/瑞泰任一真实患者都按同样算分。

```
patient: 张志远 / 42 / VIP金卡 / LTV ¥48,200
signals(独立 fact):
  diagnosis_record    { code: 'K08', confidence: 1.0, toothPosition: '36',
                        observed_at: 5 个月前(from 真实接诊事件,精确日期)}
  recommendation_record { code: 'IMPLANT_RECOMMENDED', confidence: 0.92,
                        extracted_by: 'llm',
                        source_fact_id: 关联的 emr_record }
daysSince: 156 days (诊断到现在)
persona: { value=0.85, recall_risk=0.2 }
recentExecutions: 无

计算:
  base                = 60(缺牙→种植)
  timeWindowFactor    = 0.85(156 ∈ [90, 180))
  main                = 60 × 0.85 = 51
  valueBonus          = 20(value=0.85 ≥ 0.8)
  likelihoodBonus     = (1-0.2)×6 + 0 = 5
  urgencyBonus        = 5(156 天 > 120 邻牙倾斜临界)
  confidenceFactor    = 0.9(LLM 抽的 0.92 < 0.9 阈值,降权)
  
  score = (51 + 20 + 5 + 5) × 0.9 = 73 ≈ 73
```

UI 详情页:

```
召回优先级 73 / 100
  ├─ 临床基线  60 × 0.85 = 51(缺失牙→种植 · 已过黄金期)
  ├─ 患者价值  +20(VIP 金卡 · LTV ¥48,200)
  ├─ 转化可能  +5 (黏性强 / 流失风险低)
  ├─ 临床紧迫  +5 (诊断 156 天 · 邻牙倾斜风险升高)
  └─ 信号置信  × 0.9(关键信号靠 LLM 从病历文本抽,非结构化码)
```

---

## 九、实施路径 + 工作量

### 9.1 分 4 个 PR 推进,每个独立可 review

| PR | 内容 | 工作量 | 阻塞 |
|---|---|---|---|
| **PR 1** | NormalizedSignal schema + signal-extractor 模块骨架 + rule-extractor + keyword-extractor | 0.5 d | 无 |
| **PR 2** | extract_clinical_signals AiCall + llm-extractor + signal-extract 队列(stub 跟其他 AI 队列一致,需手动触发避免烧 token)| 1 d | PR 1 |
| **PR 3** | encounter.parser / emr.parser / image.parser 改造调 SignalExtractor + scenario-config 抽出来 | 0.5 d | PR 1 |
| **PR 4** | scenario SQL 改成查 normalized_signals + 算法 v2(6 因子 + breakdown 落库)+ 详情页 UI 显示 breakdown | 1 d | PR 1-3 |
| | | **总 3 天** | |

### 9.2 跟 W3 里程碑的对齐

```
本周末            完成 PR 1 + PR 2(信号规范化基础设施)
下周一-周二       完成 PR 3(改造 parser + 跑 mock 数据验证)
下周三            完成 PR 4(算法 v2 + UI breakdown)
下周四            真实(脱敏)数据演示彩排
下周五            W3 末 管委会演示:用真实数据跑完整链路
```

---

## 十、验收标准 + 测试方案

### 10.1 验收标准

| # | 标准 | 验证方法 |
|---|---|---|
| 1 | rule-extractor 能把 ICD K02/K04/K05/K08 + 8 大治疗 category 准确映射 | unit test 覆盖 30+ case |
| 2 | LLM extractor 能从典型 EMR 文本抽出≥85% 召回率,精确率≥90% | 人工标注 50 条 EMR 跑评测 |
| 3 | 算法 v2 在 100 条真实 mock 数据上,分数分布合理(0-100 均匀,无聚集 60-80)| 跑 verify-scenarios CLI 出分布图 |
| 4 | 详情页能展示 breakdown,客服能看懂"为什么这个排第一" | 业务方点评 demo |
| 5 | 性能:cold-import 60 万 EMR LLM 抽取 < 2 小时,成本 < ¥10k | 实测压测 |

### 10.2 评测 corpus

W3 中前准备一组 ground-truth 数据:
- 50 条真实(脱敏)EMR 文本,人工标注"应该抽到哪些 signals"
- 跑自动 evaluator(精确率 / 召回率 / F1)
- 对比 rule-only / LLM-only / 三轨融合 三种配置

写一份 `apps/pac-service/src/cli/eval-signal-extractor.cli.ts` 跑评测。

---

## ▎一句话归纳

> **fact.content 是 PAC 的真理源**;为了它真正"标准",必须在 parser 阶段加一道 **信号规范化层**(rule 优先 + LLM 兜底)。规范化产物是 `normalized_signals[]`,**所有 scenario / 算法只读这个,不读自由文本**。

---

## 附录 A:跟现有文档的对齐

| 关联文档 | 关系 |
|---|---|
| [`canonical-fact-layer.md`](./canonical-fact-layer.md) | ⭐ 基础架构 — 本文基于其 fact 规范化层 + recommendation_record 设计 |
| [`docs/three-layer-model.md`](../three-layer-model.md) | 三层模型大局,fact 层是召回算法的输入 |
| [`docs/db-design-v2.md`](../db-design-v2.md) | fact_type 拆分变更需要回填进去(详见 canonical-fact-layer.md §四) |
| [`docs/scenarios/post_treatment_recall.md`](../scenarios/post_treatment_recall.md) | 疗效保障算法用同一套基础设施(Layer A/B/C + canonical-codes) |
| [`docs/host-integration-clickhouse-view.md`](../host-integration-clickhouse-view.md) | DW 直连接入方案 |
| [`docs/w2-report.md`](../w2-report.md) | W3 demo 里程碑承诺的"真实数据潜在新链召回"靠本文实施 |

---

## 附录 B:W2 末摸瑞尔 DW 对本算法的影响

**真实数据形态发现**(详见 `canonical-fact-layer.md` 附录 B):

| 维度 | DW 现状 | 对本算法影响 |
|---|---|---|
| 数据规模 | 13 万患者 / 5 家试点 / 跨 2 brand(瑞尔+瑞泰)| ✅ 算法跑得起,目标人群够大 |
| 漏治候选池 | `is_dental_loss=1 AND is_plant=0` ≈ 38,439 患者 | ⭐ 验证算法商业价值(¥600-1200 万营收潜力) |
| `fact_emr_treatment_out` | 把 EMR + 诊断(含牙位)+ 治疗计划 + 影像引用合并在一张表 | ⭐ 算法的 5 个信号源**全部能拿到** |
| 诊断时间 | `rq` / `created_date` 字段精确到日 | ✅ 时间窗算法的核心 |
| 牙位 | `diag[i].toothPosition`(JSON Array,FDI 标准) | ✅ 精确到牙位 |
| 治疗计划 | `treat_plan[i].planCode`(标准化码) + `treatName` | ✅ 跟 PAC 字典对接 |
| 影像 | `file_url`(JSON Array,含拍摄时间) | ✅ 可挂 image_record |
| EMR 自由文本 | `illness_desc` / `treat_plan.message` / `doc_order` | ⭐ Layer C LLM 抽取主战场 |
| 多 brand | 瑞尔 / 瑞泰 业务 ID 互相重复 | ⭐ 通过 brand → tenant_id 映射隔离 |

**结论**:之前担心的"数据精度让步 + LLM 兜底"几乎不需要 — 瑞尔 DW 直接给的已经够细粒度。Layer C LLM 抽取只剩"自由文本治疗推荐"这一块要做(`recommendation_record` 主信号源)。

**当前进展(W2 末)**:
- ✅ 摸底完成,数据形态确认
- ⚠️ `fact_emr_treatment_out` 有 ETL 炸行问题(193 万行 → 39 万真实 EMR),已反馈 DW 修
- ⚠️ 还差 1-3 张细粒度表:接诊事件 / 影像独立表 / 投诉 / 转介绍,跟 DW 申请中
- 🎯 W3 末:DW 修完 + 补完 → 真实数据演示
