---
title: Canonical 事实层
description: 把任意宿主的任意数据形态经 4 层规范化收束到唯一可信的 fact.content。
icon: Binary
---

> **版本**:v2 · W3 末(W2 末 v1 设计 → W3 末实施 + 4 入口统一管道补完)
> **作者**:luoqi
> **状态**:✅ 已交付实施(详见 §六 / §九)
>
> **本文定位**:`docs/algorithm/potential-treatment-recall.md` 的**基础架构补充**。potential-treatment-recall 讲"召回算法",本文讲"算法可信的前提:fact 怎么保证 canonical"。
>
> **形成背景**:W2 末扒 P011 真实数据发现 `raw_payload → content` 链路 7 个真实问题(详见附录 A);W3 落地实施后,跑通 DW 真实 1000 患者 cohort → 63,781 transactions / ~60,000 facts / 0 failed。
>
> **v2 增量**(相对 v1):
> - 加 **Layer A.5 transforms**(6 operator 白名单)— SQL 形态改造下放 yaml,真双源等价
> - 加 **canonical 第 11 category `review`**(承载复查/拆线/暂观)
> - 加 enum_mapping `_default` 兜底键(长尾值统一处理)
> - 加 **4 入口统一管道**(cold / DW 直连 / pull / push 共用 transforms+assembler+parser)— §十 详

---

## 一句话核心结论

> **PAC 的算法 / Persona / 前端**只读一个东西:`patient_facts.content`(由 PAC 标准 schema 强约束)。
>
> 任何宿主的任何数据形态,经过 **3 层规范化(结构 → 语义 → 信号)** 在 `fact.content` 收束,**单一可信源,单一收口**。

---

## 一、三大架构原则

### 原则 1 · 职责清晰(每层只做一类事)

canonical 化分 **4 层**(W3 末加 Layer A.5;算法层在 fact.content 下游消费,不在本文范围):

| 层 | 单一职责 | 不该做的 |
|---|---|---|
| **Layer A.5 transforms**(yaml,per-host,W3 末新增) | 形态改造:JSON 拆行 / 字段派生 / 关键词分流 / 多列推断 / 行过滤 | ❌ 任意 JS 表达式 / eval(只 6 operator 白名单) |
| **Layer A AssemblerEngine**(yaml,per-host) | 字段命名翻译 + 枚举翻译 + 结构映射(host N 张表 ↔ PAC 资源) | ❌ 派生 / 文本抽取 / 复杂业务(那是 A.5 的活) |
| **Layer B Parser + Zod Schema**(per fact_type) | 语义规范化(字段派生 / 单位转换)+ schema 强校验 | ❌ 字段名翻译(那是 A 的活)/ 自由文本抽取 |
| **Layer C Signal Extractor**(LLM + 规则,异步,W5+) | 自由文本 / 多源融合 → 结构化 signals | ❌ 改 fact 主体字段(只增补 normalized_signals[]) |

**口诀**:**形态归 A.5 / 翻译归 A / 校验归 B / 抽取归 C** — 各管一类语义,不重叠不复制。

(A.5 + A 都用 yaml 声明,但 A.5 管"怎么变"、A 管"叫什么",界限是"是否改变行的数量/结构":拆行/路由/过滤 → A.5;只改字段名/值 → A。)

**纪律**:算法层(scenario / Persona / 前端)是 fact 的下游**消费方**,只读不写。

**为什么加 Layer A.5(W3 末)**:原 v1 设计只让 SQL 做形态改造(ARRAY JOIN / JSONExtract / multiIf 关键词),但这违反"双源等价"承诺 — 真换 csv 单表 dump 模式,这些 SQL 操作全部跑不了。Layer A.5 把这些操作下放 yaml 声明式 6 operator,SQL 退化为"等价 csv 单表 dump 的 SELECT",任意切换数据源不动 yaml。

### 原则 2 · 中间过程可审计

每层产物**必须留底**,任何 fact 算错都能反查:

```
host raw          ──→ patient_transactions.raw_payload      (留 host 原文,W2 已有)
transforms 中间表 ──→ (内存中,不持久化;TransformEngine 详细 log)
canonical row     ──→ patient_transactions.canonical_payload (W2 末新增,W3 已实施 ✅)
fact.content      ──→ patient_facts.content                 (强 schema)
normalized_signals→ patient_facts.content.normalized_signals[]  (W5+)
```

✅ **canonical row 留底已落地**(`patient_transactions.canonical_payload` Json? 字段,W3 已上线)。

### 原则 3 · 单一收口(重复多处改集中一处)

每种"东西"只有一个**唯一定义源**:

| 东西 | 单一源 |
|---|---|
| 诊断 / 治疗 enum 字典 | `packages/types/src/canonical-codes.ts` |
| fact.content schema | `apps/pac-service/src/modules/sync/pipeline/fact-content-schemas.ts` |
| 写 fact 的入口 | `FactWriter.writeDraft()`(其他人不许 prisma.patientFact.create) |
| 写 transaction 入口 | `TransactionSynthesizer` |
| 入队 | `QueueProducer` |
| 调 AI | `AiCallRunner` |
| scenario 时间窗配置 | `apps/pac-service/src/modules/plan/engine/scenario-config.ts` |

**违反单一收口** = code review block。

---

## 二、9 道闸保证 `fact.content` 唯一可信(W3 末更新,从 5 道扩到 9 道)

```
host raw rows
   ↓
[闸 5] 6 operator 白名单(transforms yaml)  ← 不允许任意 JS / eval
   ↓
canonical row  (留底 transaction.canonical_payload)
   ↓
[闸 4] enum_mapping 闭集(canonical-codes 字典)+ _default 兜底  ← 长尾自动 drop
   ↓
Parser FactDraft
   ↓
[闸 1] FactContentSchema zod 校验       ← 写库前过,失败抛错不入库
   ↓
[闸 2] FactWriter 唯一写入入口          ← 其他人不许直写 prisma
   ↓
[闸 3] PAC canonical-codes.ts 字典     ← enum 值闭集,新增走 PR
   ↓
patient_facts.content
   ↓
[闸 7] subject_id partial UNIQUE      ← 同 fact 多 active 版本冲突
[闸 8] dedup_by yaml 兜底             ← DW ETL 炸行
[闸 9] event_seq BigInt 水位          ← Persona 重算丢漏
   ↓
[闸 6] CI 防漂移测试 53 个              ← PR 合并前(canonical-fact-layer + transforms + dispatcher)
   ↓
算法 / Persona / 前端 只读 fact.content(不读 raw_payload / canonical_payload)
```

> ⭐ 实际 CI 跑通 53 个测试(W3 末);9 道闸全部落地,**0 个手工 review**。

### 闸 1 · FactContentSchema 强校验

每个 fact_type 一份 zod schema(详见 §五.B 设计):

```typescript
// fact-content-schemas.ts
import { PACDiagnosisCode, PACTreatmentCategory, FDIToothPosition } from '@pac/types';

export const FactContentSchemas = {
  diagnosis_record: z.object({
    code: PACDiagnosisCode,                  // ⭐ 强枚举,字典之外的值进不去
    name: z.string().nullable(),
    tooth_position: FDIToothPosition.nullable(),
    severity: z.enum(['mild', 'moderate', 'severe']).nullable(),
    onset_date: z.string().nullable(),
    source_encounter_external_id: z.string(),
  }),

  treatment_record: z.object({
    category: PACTreatmentCategory,          // ⭐ 强枚举
    subtype: z.string().nullable(),
    tooth_position: FDIToothPosition.nullable(),
    status: z.enum(['planned', 'in_progress', 'completed', 'cancelled']),
    started_at: z.string().nullable(),
    completed_at: z.string().nullable(),
    source_encounter_external_id: z.string(),
    related_diagnosis_subject_id: z.string().nullable(),
  }),

  encounter_record: z.object({
    encounter_external_id: z.string(),
    doctor_id: z.string().nullable(),
    notes: z.string().nullable(),
    // diagnoses / treatments 不嵌套了,由 diagnosis_record / treatment_record 独立承担 ⭐
  }),

  emr_record: z.object({ /* ... */ }),
  image_record: z.object({ /* ... */ }),
  // ...
} as const;
```

`FactWriter.writeDraft()` 写库前必过 schema,违反抛 `SchemaViolationError`,**脏数据进不了库**。

### 闸 2 · FactWriter 唯一入口

```typescript
// 全代码库唯一允许写 fact 的入口
export class FactWriter {
  async writeDraft(...) {
    const schema = FactContentSchemas[draft.type];
    const validated = schema.parse(draft.content);  // 闸 1 校验
    // ... fact version 流逻辑 ...
    await this.prisma.patientFact.create({ data: ... });
  }
}
```

CI 加 lint rule:除 FactWriter 文件,其他文件出现 `prisma.patientFact.create` / `update` = build fail。

### 闸 3 · canonical-codes.ts 字典闭集

```typescript
// packages/types/src/canonical-codes.ts
export const PACDiagnosisCodes = {
  K02: { name_zh: '龋病', icd10_range: 'K02.*' },
  K04: { name_zh: '牙髓 / 根尖周炎', icd10_range: 'K04.*' },
  K05: { name_zh: '牙周炎 / 牙龈炎', icd10_range: 'K05.*' },
  K08: { name_zh: '牙列丢失 / 缺牙', icd10_range: 'K08.*' },
  // 业务码(ICD 不分但 PAC 算法需要)
  IMPLANT_FAILURE: { name_zh: '种植失败', business: true },
  ORTHO_RELAPSE: { name_zh: '正畸反弹', business: true },
  // 推荐码(EMR 文本抽取产物)
  IMPLANT_RECOMMENDED: { name_zh: '建议种植' },
  CROWN_RECOMMENDED: { name_zh: '建议戴冠' },
  // ...
} as const;

export type PACDiagnosisCode = keyof typeof PACDiagnosisCodes;
```

**所有 yaml mapping / parser / scenario / 前端 label 都引用这个文件**,新增码必走 PR + 业务方 + 牙医 review。

### 闸 4 · CI 防漂移

```typescript
// tests/canonical-fact.spec.ts
describe('Canonical Fact Layer', () => {
  test('all mock data passes FactContentSchemas', () => {
    for (const draft of MOCK_FACT_DRAFTS) {
      expect(FactContentSchemas[draft.type].safeParse(draft.content).success).toBe(true);
    }
  });

  test('all yaml enum_mapping targets exist in canonical-codes', () => {
    const yamls = loadAllAssemblerYamls();
    for (const yml of yamls) {
      for (const v of Object.values(yml.enum_mapping ?? {})) {
        expect(Object.keys(PACDiagnosisCodes).includes(v)).toBe(true);
      }
    }
  });
});
```

PR 改 yaml / parser / scenario,CI 自动跑全套验证,漂移立挂。

### 闸 5 · 单向消费(算法不读 raw_payload)

代码 review 红线:
- ❌ scenario SQL 不能 LIKE `raw_payload->>` 任何字段
- ❌ Persona feature 不能直接读 `patient_transactions.raw_payload`
- ❌ AI prompt 不能拼 raw_payload 文本(用 fact.content)
- ✅ 唯一例外:cold-import replay CLI 可以读 raw 重跑(运维通路)

---

## 三、PAC 标准字典 — 集团统一,宿主随意

### 3.1 关键定义澄清

> **"集团统一"** = **PAC 系统统一**(所有宿主接 PAC 看到的都是 PAC canonical 字典)。
>
> **宿主端**可以任意用:ICD-10 / 自定义码 / 中文 / 自由文本 / 实表 / 视图,**字段命名也不要求按 PAC 规范**。Layer A yaml 负责把宿主形态翻译到 PAC 字典。

**这点最重要的工程含义**:跟宿主沟通时**不要求对方按 PAC 标准改字段命名**——那是 yaml 的活。宿主只要"字段全给 + 含义说清 + 提供更新时间列"就够。

### 3.2 PAC 字典设计原则

```
不绑死任何外部标准,但借 ICD-10 命名做骨架
+ 加 PAC 业务码(国际标准没有但算法需要的)
+ 保持小集合(50-200 个码,够用就行)
```

### 3.3 字典内容(初版,候选,W3 跟牙医 review)

#### 诊断码

```typescript
export const PACDiagnosisCodes = {
  // 临床主类(借 ICD-10 K00-K14 大类)
  K02:  { name_zh: '龋病' },
  K03:  { name_zh: '牙体硬组织其他疾病' },
  K04:  { name_zh: '牙髓 / 根尖周炎' },
  K05:  { name_zh: '牙周炎 / 牙龈炎' },
  K06:  { name_zh: '牙龈 / 牙槽骨疾患' },
  K07:  { name_zh: '牙颌面异常' },
  K08:  { name_zh: '牙列丢失 / 缺牙' },
  K09:  { name_zh: '颌骨囊肿' },
  K10:  { name_zh: '颌骨其他疾病' },
  K11:  { name_zh: '唾液腺疾患' },
  K12:  { name_zh: '口腔黏膜疾患' },
  K13:  { name_zh: '唇 / 口腔黏膜其他疾患' },
  K14:  { name_zh: '舌疾患' },

  // PAC 业务码(治疗失败 / 投诉相关)
  IMPLANT_FAILURE:    { name_zh: '种植失败 / 周围炎' },
  ORTHO_RELAPSE:      { name_zh: '正畸反弹' },
  ENDO_FRACTURE:      { name_zh: '根管牙折裂' },

  // 治疗推荐码(EMR 文本抽取产物,临床上很重要)
  IMPLANT_RECOMMENDED:   { name_zh: '建议种植' },
  CROWN_RECOMMENDED:     { name_zh: '建议戴冠' },
  FILLING_RECOMMENDED:   { name_zh: '建议充填' },
  SRP_RECOMMENDED:       { name_zh: '建议牙周基础治疗' },
  EXTRACTION_RECOMMENDED:{ name_zh: '建议拔除' },
  ANNUAL_REVIEW_RECOMMENDED: { name_zh: '建议年度复查' },
  ORTHO_CONSULT_RECOMMENDED: { name_zh: '建议正畸咨询' },
} as const;
```

#### 治疗类别 + 子类(W3 末扩到 11 类)

```typescript
export const PACTreatmentCategories = {
  endodontic:    { name_zh: '牙髓 / 根管' },
  restorative:   { name_zh: '充填 / 嵌体' },
  prosthodontic: { name_zh: '修复 / 冠桥' },
  implant:       { name_zh: '种植' },
  periodontic:   { name_zh: '牙周' },
  orthodontic:   { name_zh: '正畸' },
  surgical:      { name_zh: '外科' },
  pediatric:     { name_zh: '儿牙' },
  preventive:    { name_zh: '预防' },
  cosmetic:      { name_zh: '美学' },
  /// ⭐ W3 末加 — 承载复查/拆线/暂观/无治疗等"流程性事实"
  /// 实测 DW EMR.treat_plan 大量含"常规复查 12k / 拆线 1.5k / 暂观 1.8k / 无治疗 1.5k"等
  /// 这些动作是真实事实(医生做了某节点 / 临床判断本次不动手),不该塞 preventive(污染"已做预防"判定)
  /// scenario 算法**不消费**(不算"已做治疗"),fact 层保真
  review:        { name_zh: '复查 / 流程' },
} as const;
```

#### 牙位(FDI 标准)

```typescript
// FDI 牙位:11-48(恒牙)+ 51-85(乳牙)
export const FDIToothPositions = ['11','12','13','...','48','51','52','...','85'] as const;
```

#### 其他

- ExecutionOutcome(已有)
- DoNotContactReason(已有)
- 其他按需扩展

### 3.4 维护流程

```
新增码:PR
   ├─ 提议人:产品 / 业务方 / 牙医 / PAC 工程
   ├─ Reviewer:至少 1 牙医 + 1 业务方 + 1 PAC 工程
   ├─ 必须填:含义 / 临床触发场景 / 算法用法 / 中文名
   └─ 合并后:CI 自动检查相关 yaml / parser / scenario 引用

废弃码:仅标 deprecated(不删,历史数据要)
```

### 3.5 宿主内部多租户 / 多品牌的隔离(W2 末从瑞尔 DW 学到)

接入瑞尔集团 DW 时发现:**一个 host 内部可能存在多 brand / 多租户**,所有业务 ID(`patient_id` / `appointment_id` / `doctor_id` 等)**只在 brand 内唯一,跨 brand 会重复**。

实际数据:

```
单 host(瑞尔集团 DW)内 2 个 brand:
  瑞尔  →  3 家诊所(杭州大厦 / 杭州高德 / 北京朝阳公园)
  瑞泰  →  2 家诊所(通善学前街 / 上海世纪公园)

跨 brand ID 重复实例:
  patient_id = 1310616
    + brand=瑞尔  → 田雨嘉(9 岁儿童 / 杭州大厦)
    + brand=瑞泰  → 陈振志(44 岁成人 / 上海世纪公园)
  完全不同的两个人共享同一 patient_id
```

**PAC 落地:brand 映射到 `tenant_id`**(直接利用 PAC 现有的 `(host_id, tenant_id, external_id)` 三段隔离):

```yaml
# Layer A yaml(瑞尔 DW 接入)
brand_to_tenant:
  "瑞尔": ruier
  "瑞泰": ruitai
```

落地后:
- `patients` 表 `unique(host_id, tenant_id, external_id)` 自动防冲突
- 算法 SQL 永远 `WHERE host_id=? AND tenant_id=?`,两个 brand 完全隔离
- 客服 / 主管 / dashboard 通过 JWT.tenantId 限定,看不到对方品牌数据
- 跨品牌身份合并(未来"瑞尔的张三 = 瑞泰的张三")走 `Patient.mergedIntoId` 自引用

**纪律**:接入新宿主时,如果发现"业务 ID 跨某维度重复",**那个维度就是 tenant 维度,塞 `tenant_id`**,不要在 fact 主键里拼前缀。

---

## 四、fact 结构重塑 — 从第一性原理推

### 4.1 问题:当前 fact_type 设计对吗?

**现状**:
- `encounter_record` 内嵌 `diagnoses[]` + `treatments[]` + `next_steps[]`
- `treatment_plan`(planned)独立存在

**问题**:
1. 诊断 / 治疗是临床独立实体,捆在 encounter 里**违背独立语义**
2. 治疗周期长(种植 3-6 个月),跨多次接诊,**嵌在第一次 encounter 里追踪不到状态变化**
3. SQL 查"该患者所有 K08 诊断" → 遍历所有 encounter 的 JSONB 数组,**索引不友好**
4. 修改一个诊断 → 整个 encounter 进新版本,**粒度太粗**
5. 业务上同一颗牙跨年度多次诊断,**追踪是核心需求**

### 4.2 第一性原理推导

**原理 1 · fact 是"患者世界发生了什么"**
- 诊断 = "患者被诊断有 X 病"(独立事件,有自己的生命周期)
- 治疗 = "患者被做了 Y 治疗"(独立事件,跨多次接诊)
- 接诊 = "患者来了一次诊所"(只是容器 / 时间锚点)

这 **3 件事是不同语义粒度**,不该捆在一起。

**原理 2 · PAC 算法的查询路径都是"按诊断 / 治疗"维度查**
- "这个患者有 K08 吗?" → 查诊断
- "做过种植吗?" → 查治疗
- "最近一次接诊里有啥?" → EMR 视角,不是召回视角

**原理 3 · 通用性 / 宿主多样性**
- 宿主 A:接诊 + 诊断子表 + 治疗子表(典型结构化系统)
- 宿主 B:只有"病历"表,诊断/治疗都在自由文本(小诊所)
- 宿主 C:治疗有独立生命周期表(种植中心)
- 三种形态,**PAC 都应该看到统一 fact**

**原理 4 · 版本流粒度**
- 治疗"in_progress → completed" 是治疗自己的状态,**应该治疗 fact 自己进新版本**,不该带动 encounter
- 诊断的 severity / 进展 也独立演化

**原理 5 · 证据链清晰**
- 一条 plan 的证据 = [diagnosis_record 36 K08] + [emr_record 建议种植] + [image_record CBCT 符合]
- 独立 fact 互相引用比"挖嵌套 JSONB"清晰

### 4.3 决策:**拆**

```diff
- encounter_record (内嵌 diagnoses[]/treatments[]/next_steps[])
+ encounter_record (只装接诊元数据:doctor_id, notes, location, started_at)
+ diagnosis_record (独立, kind=actual, content={code,name,tooth_position,severity,...})
+ treatment_record (独立, kind=actual/planned, content={category,subtype,status,...})
  treatment_plan   ⛔ 废弃 — 合并进 treatment_record(用 kind=planned 替代)
  review_plan      ⛔ 废弃 — 合并进 treatment_record(category=review)
+ recommendation_record (独立, kind=planned, content={code,tooth_position,doctor_suggested,window_days,...})
```

### 4.4 新 fact_type 全集(精简版,14 个)

| fact_type | kind | 业务含义 | 主要字段 |
|---|---|---|---|
| **encounter_record** | actual | 接诊事件(只元数据) | encounter_external_id, doctor_id, notes |
| **diagnosis_record** ⭐ 新 | actual | 诊断(独立) | code(PAC 字典), tooth_position, severity, source_encounter |
| **treatment_record** ⭐ 新合并 | actual / planned | 治疗(独立,含计划/已做) | category, subtype, status, planned_for, started_at, completed_at |
| **recommendation_record** ⭐ 新 | planned | 医生建议(EMR / 影像抽出来的"建议种植"等) | code, tooth_position, doctor_suggested_at, window_days |
| **emr_record** | actual | 病历(主诉 / 现病史 / 治疗计划 自由文本) | diagnosis_text, treatment_plan, doctor_advice |
| **image_record** | actual | 影像(metadata + finding) | modality, finding, tooth_positions, encounter_external_id |
| **appointment_record** | actual | 预约 | scheduled_at, status, type |
| **appointment_plan** | planned | 预约计划 | planned_for, type |
| **order_record** | actual | 收费单 | items[], amount_cents |
| **payment_record** | actual | 收款 | amount_cents, channel |
| **refund_record** | actual | 退费 | amount_cents, reason |
| **recharge_record** | actual | 充值 / 储值卡 | amount_cents, card_type |
| **complaint_record** | actual | 投诉 | content, severity |
| **referral_record** | actual | 转介绍 | referrer_patient_id, channel |
| **consultation_record** | actual | 咨询(400) | content, source_channel |
| **visit_registration_record** | actual | 挂号 | doctor_id, registered_at |

**核心变化**:
- ✅ diagnosis / treatment 从 encounter 嵌套提升为独立 fact
- ✅ treatment_plan / review_plan 合并进 treatment_record(用 kind=planned + category 区分)
- ✅ 新增 recommendation_record 承接"医生建议"信号(EMR 文本 / 影像 finding 抽取产物)

### 4.5 关联怎么挂?

```
encounter_record (subject_id = "encounter_record:enc_001")
   ↑
   │ via patient_facts.transactionIds[]  (反查 transaction → encounter)
   │ via content.source_encounter_external_id  (正查 diagnosis → encounter)
   ↓
diagnosis_record (subject_id = "diagnosis_record:enc_001_dx_1")
treatment_record (subject_id = "treatment_record:tx_001")
emr_record       (subject_id = "emr_record:emr_001", content.encounter_external_id="enc_001")
image_record     (subject_id = "image_record:img_001", content.encounter_external_id="enc_001")
```

通过 `source_encounter_external_id` 字段反查,**不嵌套不冗余**。

### 4.6 P011 患者在新模型下长什么样

> P011 是 PAC 内部 `data/mock-demo/` 里的演示样本(36 牙缺失场景),不是宿主真实数据。这里用它说明 fact 新结构;W3 切真实 DW 数据后,所有 patient_id 会换成真实值,但 schema 形态完全一致。

```
patient: P011, 36 牙缺失场景

fact 1: encounter_record:enc_P011_exam
  { encounter_external_id: "enc_P011_exam", doctor_id: "doc_wang",
    notes: "36 牙缺失,建议种植修复,患者考虑中" }

fact 2: diagnosis_record:enc_P011_exam_dx_1   ⭐ 独立
  { code: "K08", name: "牙缺失", tooth_position: "36",
    source_encounter_external_id: "enc_P011_exam" }

fact 3: emr_record:emr_P011_exam
  { diagnosis_text: "36 牙缺失,牙槽骨条件良好",
    treatment_plan: "建议单颗种植 36",
    doctor_advice: "2-3 月内启动种植治疗" }

fact 4: image_record:img_P011_cbct_36
  { modality: "cbct", finding: "36 区骨高度 12mm,符合种植条件" }

fact 5: recommendation_record:emr_P011_exam_rec_1  ⭐ 新,Layer C 抽取
  { code: "IMPLANT_RECOMMENDED", tooth_position: "36",
    extracted_from: "emr_record:emr_P011_exam",
    extracted_by: "llm", confidence: 0.95,
    window_days: 90, doctor_suggested_at: "2026-03-19" }

(无 treatment_record:K08 尚未治疗)
```

### 4.7 SQL 查询(新模型)— 漂亮 + 索引友好

```sql
-- "找所有有 K08 诊断但没启动 implant 治疗的患者(过去 180 天)"
SELECT p.id
FROM patients p
JOIN patient_profiles pp ON pp.patient_id = p.id
WHERE p.active AND NOT pp.do_not_contact AND NOT pp.deceased
  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() - '180d' AND now() - '30d'
  )
  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.content->>'category' = 'implant'              -- ⭐ 直接打
  );

-- 加分项:跨 diagnosis 和 recommendation 联合
  OR EXISTS (
    SELECT 1 FROM patient_facts rec
    WHERE rec.patient_id = p.id
      AND rec.type = 'recommendation_record'
      AND rec.content->>'code' = 'IMPLANT_RECOMMENDED'     -- ⭐ EMR 抽出来的强信号
      AND rec.content->>'tooth_position' = dx.content->>'tooth_position'
  )
```

**比当前 `EXISTS (SELECT 1 FROM jsonb_array_elements(...))` 干净 10 倍**。

### 4.8 拆分代价

| 代价 | 评估 |
|---|---|
| fact 行数增加 | 一次接诊原 1 行 → 1 + N + M 行(N=诊断数,M=治疗数);P011 这种从 1 → 1+1=2 行;高产患者从 1 → 1+5+5=11 行。**接受**,fact 表行数本就不是瓶颈 |
| Parser 复杂度 | encounter.parser 拆成 encounter / diagnosis / treatment 3 个 parser,**符合 SRP** |
| 历史数据迁移 | 一次性 script:扫旧 encounter_record,拆 diagnoses[] / treatments[] 出来生成新 fact;1 d 工作量 |
| API 影响 | 详情页查"该 plan 的证据链" → join 多 fact,加几行 SQL |

**总代价可控,长期收益 >> 一次性投入**。

### 4.9 跟 data-model 文档的冲突

文档 §八 当前定义:
```
- encounter_record(content 含 diagnoses[]/treatments[]/next_steps[] 衍生)
- treatment_plan(planned)
- review_plan(planned)
```

新模型:
```
- encounter_record(只元数据)
- diagnosis_record(actual,新)
- treatment_record(actual/planned,合并旧 treatment_plan + review_plan)
- recommendation_record(planned,新)
```

**做法**:更新 [data-model](/docs/architecture/data-model) §八 + 加 schema migration(新增 fact_type enum 值,无需改表结构,因为 type 是 String)。

---

## 五、4 层规范化架构(W3 末加 Layer A.5)

复盘前面所有讨论,最终架构:

```
┌──────────────────────────────────────────────────────────────────┐
│ host 原始数据(无限多样性) — 4 入口取数(§十)                   │
│   - 宿主 A:标准 5 张表 + 结构化码                                │
│   - 宿主 B:1 张病历表,诊断/治疗全自由文本                       │
│   - 宿主 C:大宽表 + 分隔符串                                     │
│   - DW 视图:聚合 / 已 ETL(瑞尔 jvs-dw 实际形态)                │
└────────────────────────────────┬─────────────────────────────────┘
                                 ↓
┌──────────────────────────────────────────────────────────────────┐
│ Layer A.5 · transforms(yaml 驱动,W3 末新增)                    │
│   职责:形态改造 — 6 operator 白名单(纯函数,无副作用)          │
│   解决:host 原始形态(嵌套 JSON / 多列推断 / 关键词分流)→ 整理 │
│   能力(6 operator):                                             │
│     ① project           选列 / 改名                              │
│     ② split_json_array  JSON 数组拆行(EMR.diag 1 行→N 行)      │
│     ③ derive            substring / concat / lower / default     │
│     ④ route_by_pattern  按字段值分流到多输出表(建议/复查/治疗) │
│     ⑤ pick_first_nonzero 多个数值列推单列(17 个 pay_*→channel) │
│     ⑥ filter            多字段 AND 行过滤(in_time non-null)    │
│   输出:整理后的 raw tables(row 数组,字段命名仍 host 原生)      │
│   纪律:**不允许任意 JS 表达式 / eval**;撞墙必加 operator + PR  │
└────────────────────────────────┬─────────────────────────────────┘
                                 ↓
┌──────────────────────────────────────────────────────────────────┐
│ Layer A · AssemblerEngine(yaml 驱动,per-host)                  │
│   职责:结构映射 + 字段命名 + 枚举翻译                            │
│   解决:整理好的表 → PAC 18 类 fact_type 固定结构                 │
│   能力:                                                          │
│     ✓ 一张表 → 一个 canonical resource(primary.table 索引)      │
│     ✓ field_mapping(字段命名)                                   │
│     ✓ enum_mapping(自定义码 → PAC 字典 + `_default` 兜底)       │
│     ✓ join_arrays(1:N 子表嵌入 canonical 数组,极少用)           │
│     ✓ dedup_by(炸行兜底)                                        │
│     ✓ emits(action / subjectType / occurredAtField)             │
│   输出:canonical row(留底到 patient_transactions.canonical_payload)│
│   纪律:per-host yaml,**不写业务逻辑 / 不调 LLM**(那是 A.5/C 的活)│
└────────────────────────────────┬─────────────────────────────────┘
                                 ↓
                    canonical row(留底到 transaction.canonical_payload)
                                 ↓
┌──────────────────────────────────────────────────────────────────┐
│ Layer B · Parser + FactContentSchema(per fact_type,集团统一)   │
│   职责:语义规范化 + 派生 + schema 强校验                         │
│   能力:                                                          │
│     ✓ 字段最终命名(camelCase → snake_case 等)                  │
│     ✓ 单位归一化(yuan → cents)                                  │
│     ✓ 派生字段(treatments[i].expectedNextStep → next_steps[])  │
│     ✓ 引用 canonical-codes.ts 字典(枚举闭集)                   │
│     ✓ FactWriter 入库前 zod schema 校验                          │
│   输出:fact.content(强 schema 化,可信)                         │
│   纪律:per fact_type,**不读 host 自由文本**                     │
└────────────────────────────────┬─────────────────────────────────┘
                                 ↓
┌──────────────────────────────────────────────────────────────────┐
│ Layer C · Signal Extractor(异步,LLM + 规则,per fact_type)     │
│   职责:从 fact.content 的自由文本里抽结构化信号                  │
│   能力:                                                          │
│     ✓ extract_clinical_signals AiCall(LLM 抽)                  │
│     ✓ 关键词兜底                                                 │
│     ✓ 产 recommendation_record 等新 fact                         │
│     ✓ 或回写 fact.content.normalized_signals[]                  │
│   输出:fact.content(增强后)+ recommendation_record(派生 fact) │
│   纪律:per fact_type,**不改 fact 主体字段,只增补 signals 字段** │
└────────────────────────────────┬─────────────────────────────────┘
                                 ↓
              ━━━━━ fact.content ⭐ 单一收束点 ⭐ ━━━━━
                                 ↓
                  下游消费(算法 / Persona / 前端 / AI)
                  • 永远只读 fact.content
                  • 不碰 raw_payload / canonical_payload / host 原表
                  • SQL 永远 SELECT FROM patient_facts
```

### 5.2 字段级翻译对照表(P011 缺牙演示样本 → 召回的完整字段路径)

下面是真实字段 4 段翻译的完整对照,**任何一处错位就会导致召回算法失效**:

| 业务概念 | host raw 字段 | canonical 字段(临时) | fact.content 字段(持久化) | 算法 SQL 查询路径 |
|---|---|---|---|---|
| 接诊 ID | `tb_encounter.encounter_no` | `externalId` | `encounter_record.content.encounter_external_id` + fact `subject_id` 后缀 | `pf.subject_id = 'encounter_record:enc_P011_exam'` |
| 患者 ID | `tb_encounter.patient_pid` | `patientExternalId` | (反查 `patients.external_id`)→ fact 表 `patient_id` UUID | `pf.patient_id = <UUID>` |
| 诊所 | `tb_encounter.clinic_code` | `clinicId` | fact 表 `clinic_id` 列 + 各 content 不重复存 | `pf.clinic_id = 'c_hz_dasha'` |
| 医生 | `tb_encounter.doctor_code` | `doctorId` | `encounter_record.content.doctor_id` | `content->>'doctor_id'` |
| 诊断码 | `tb_dx.dx_code = 'K08.1'` | `diagnoses[i].code = 'K08.1'` | `diagnosis_record.content.code = 'K08'` ⭐ 字典闭集 | `content->>'code' = 'K08'` |
| 诊断牙位 | `tb_dx.tooth_no = '36'` | `diagnoses[i].toothPosition = '36'` | `diagnosis_record.content.tooth_position = '36'` | `content->>'tooth_position' = '36'` |
| 治疗类型 | `tb_tx.tx_type = 'C01'` | `treatments[i].category = 'implant'` ⭐ enum_mapping | `treatment_record.content.category = 'implant'` | `content->>'category' = 'implant'` |
| 治疗状态 | `tb_tx.tx_status = '1'` | `treatments[i].status = 'completed'` ⭐ enum_mapping | `treatment_record.content.status = 'completed'` | `content->>'status' = 'completed'` |
| 病历自由文本 | `tb_emr.treatment_plan = '建议单颗种植 36'` | `treatmentPlan = '建议单颗种植 36'` | `emr_record.content.treatment_plan` | **算法不直接查**,Layer C 抽 → recommendation_record |
| 医生建议(LLM 抽出) | (从 EMR 文本派生) | (Layer C 产物) | `recommendation_record.content.code = 'IMPLANT_RECOMMENDED'` ⭐ PAC 业务码 | `content->>'code' = 'IMPLANT_RECOMMENDED'` |
| 影像 finding | `tb_image.finding = '36 区骨高度 12mm'` | `finding = '36 区骨高度 12mm'` | `image_record.content.finding` | **算法不直接查**,可作为 LLM 抽取输入 |

**关键纪律**:
1. **算法 SQL 永远查 `fact.content`,不查 raw_payload / canonical_payload**
2. **每个字段的"最终命名"由 `fact-content-schemas.ts` 强制约束**,parser 改字段名 = zod 校验挂掉
3. **enum 值必须 ∈ canonical-codes.ts 闭集**(`K08`/`implant`/`IMPLANT_RECOMMENDED`),自由扩展走 PR

---

## 六、实施回顾(W2 末规划 → W3 末落地)

### 6.1 实施清单(W2 末规划项,全部完成 ✅)

| 维度 | W2 末规划 | W3 末实际产出 |
|---|---|---|
| canonical row 留底 | `transaction.canonical_payload` 字段 | ✅ `patient_transactions.canonical_payload` Json? |
| fact.content schema 校验 | FactContentSchemas + zod 前置 | ✅ 17 个 fact_type schema + FactWriter 唯一入口 |
| PAC 字典集中度 | canonical-codes.ts 单一源 | ✅ K00-K14 + 业务码 + 推荐码 + 11 治疗 category(含 review) |
| fact_type 设计 | 拆出 diagnosis/treatment/recommendation 独立 | ✅ encounter 退化只元数据,3 个独立 fact_type 落地 |
| recommendation_record | 新增 | ✅ Layer A.5 route_by_pattern 抽"建议X" → 55 行入库 |
| 算法读 fact | scenario JOIN 独立 fact_type | ✅ 10 子规则 K00–K09 全覆盖 + tooth-overlap union-find 合并 |
| 多品牌 / 多租户隔离 | yaml `brand_to_tenant` 映射 | ✅ 瑞尔 → tenant=ruier;瑞泰另一份 manifest |
| CI 漂移防护 | schema + yaml + canonical-codes 联立测试 | ✅ 53 测试通过(transforms 49 + canonical-fact 19 - 重叠) |
| **Layer A.5 transforms**(W3 新加) | — | ✅ 6 operator 白名单 + 49 单元测试 |
| **review category**(W3 新加) | — | ✅ 第 11 个,2,714 行 fact 入库 |
| **enum_mapping `_default` 兜底**(W3 新加) | — | ✅ AssemblerEngine.applyEnum 支持 |
| **4 入口统一管道**(W3 新加) | — | ✅ dispatcher emitsResolver(§十) |

### 6.2 仍 deferred 项(P2,不阻塞 W4)

| 项 | 状态 | 触发时机 |
|---|---|---|
| raw_payload 子表嵌套(yaml raw_groupArray) | 🟡 不急 | DW 仍单表 dump 就不需要 |
| replay CLI | 🟡 不急 | 真正 yaml 改大版时再做 |
| Layer C LLM 自由文本抽取 | 🔵 W5+ | 接 host EMR 自由文本时启动 |
| Real HTTP Pull(api-key / login) | 🔵 W5+ | 接 5i5ya / Friday 时实装 |
| Push 形态 B(host raw payload) | 🟡 按需 | host 不愿做 canonical 翻译时 |

---

## 七、给宿主的"接入边界"对话

### 7.1 跟宿主沟通的核心

> "**PAC 不要求你改 schema / 改命名 / 改自定义码 / 选 view 还是实表**。
>
> 你怎么存就怎么给我,只要你能告诉我:
>   1. 字段命名是什么(我们做 yaml 翻译)
>   2. 自定义码的含义(我们做 enum 翻译)
>   3. 自由文本的语言风格(我们用 LLM 抽信号)
>   4. 每张表有"更新时间"列(单调递增,PAC 增量同步用,这是 ETL 工程基本要求)
>
> 我们在 PAC 内部统一成集团标准,**你的代码 / DB 一行都不用动**。"

### 7.2 宿主端按形态分类应对

| 宿主形态 | 接入方式 |
|---|---|
| 标准多表 + 结构化码(理想宿主) | yaml field_mapping + enum_mapping,1 d 接入 |
| 自定义码(`诊002`) | 在 enum_mapping 加翻译(`诊002 → K02`),2 d 接入 |
| 一张大宽表 + 分隔符串 | Layer A.5 `derive` 拆 + `split_json_array` 拆行,1-2 d |
| 一张病历表 + 全自由文本 | Layer A.5 `route_by_pattern` 关键词分流 + Layer C LLM 抽取,3-5 d(W5+) |
| **嵌套 JSON 数组字段**(W2 末 DW 实例) | Layer A.5 `split_json_array` 拆行,半天接入(jvs-dw 实测) |
| **ADS 出口层实表**(W2 瑞尔 DW 实际形态) | yaml 对实表写 mapping,性能反而比 view 好。需要确认数据形态(炸行 / 命名 / 子表嵌套) |
| **多 brand / 租户合并在同 host** | brand 映射到 `tenant_id`(详见 §3.5),业务 ID 跨 brand 重复天然不冲突 |

**宿主侧的工作总是"提供数据 + 解释含义",不是"按 PAC 改 schema"**。

### 7.2bis 实战避坑(W2 瑞尔 DW 接入,W3 接入新宿主时复用)

跟宿主对接时**必问**(避免反复返工):

1. **业务 ID 唯一性边界**:`patient_id` / `appointment_id` 等在什么粒度内唯一?(host 内 / brand 内 / clinic 内?)
2. **每张表都有"更新时间"列**吗?ETL 写入时刷新?(PAC 增量同步靠这列)
3. **表是 view 还是物理实表**?ETL 频率(实时 / 小时 / T+1)?
4. **子表是嵌套在主表的 Array 字段,还是 JOIN 炸行**?(炸行就 dedup 兜底 + 反馈 ETL 改)
5. **PII 字段**(姓名 / 手机 / 投诉黑名单)有没有,合规约束(保留期 / 加密)?
6. **数据规模 + 时间跨度**:能跑出多少候选患者?多少历史数据?
7. **每个分类字段有没有稳定的语义枚举码**?(诊断 / 治疗 / 结算类别)
   - 数仓常见坑:**只给中文 name(利于显示),没给可计算的语义 key**;即便有"码",也可能是每条记录的随机主键 hash(防重用),不是类别码。
   - 影响:PAC 只能退到"中文 name → enum_mapping 白名单"+ W5+ LLM 兜底,白名单需持续维护、永远有长尾。
   - 反馈方向:推动宿主在明细层补**稳定语义码**(诊断规范填 ICD;治疗归一到集团项目字典),从源头解决。
8. **写入是 upsert 当前态,还是 append 事件流?状态变更留不留痕?**(W3 末瑞尔 DW 预约表实战)
   - 瑞尔 DW 预约表是**混合**:① 同一 `appointment_id` 的状态流转(预约→到诊→结算)是**就地 upsert**——只剩终态,中间态丢失;② **改约**则是**新建一条新 id + 把旧 id 标"已改约"**——改约历史以"已改约"行留痕。
   - 影响:`created/arrived/cancelled` 等 in-place 流转,**cold-import 只看终态**(动作流要靠增量 pull 多次比对前后态,或 host push / append 事件日志);改约历史可见但**"已改约"≠"有效预约"**,务必映射成独立状态(rescheduled),否则虚高有效预约(瑞尔已改约占 44.7%)。
   - 反馈方向:行为分析(爽约率/改约频率/漏斗)需 **append 事件流**;当前态表只够"看现状"。
9. **时间字段的粒度与拆分**(W3 末瑞尔预约时间实战)
   - 坑:约定"日期"与"时分"可能分列存(瑞尔 `appo_time` 只到天 Date,真正时分在 `appo_datetime_str` "13:00:00-14:00:00" + `appo_time_period` 时长);只取日期会让同日多次改约"看着重复"。
   - 处理:transforms 拼出精确 Timestamptz(日期 + 起始时分);区分"约定时刻"(scheduled)与"实际发生时刻"(arrived=签到 in_time),两者落 `plannedFor` / `occurredAt`。
   - 顺带:`appo_complaint_category`(种植/正畸/…)这类"预约科目"是**就诊意向强信号**,值得标准化进 fact,别只当展示字段丢掉。
10. **"已做治疗"的凭据表与牙位粒度**(W3 末潜在召回 SQL 审计)
    - 坑:同一概念(治疗)在 host 有**两个口径**——"治疗计划"(`treat_plan`,含牙位,但≠已做)和"已结算收费"(`fact_settlement_out`,花钱才算做,但**按收费项记账,无牙位列**)。
    - 影响:潜在治疗召回的"排除已做治疗"必须信结算(actual),而结算无牙位 → **牙位级排除做不了,只能 patient 级**(同 patient 做过同类治疗即排,会粗化:A 牙做过种植会挡掉 B 牙缺口)。
    - 缓解:排除条件加**时间方向**(`treatment.occurred_at >= 诊断时间`),只排"诊断后才启动"的治疗,避免历史旧治疗误排新诊断(实测 K08 误伤 33%)。
    - 反馈方向:推动 DW 在结算明细补 `tooth_position`,或用 `treat_plan.planCode` 关联结算行,才能升级到牙位级排除。
11. **时间窗别一杆当两用:入池边界 vs 优先级权重要分开**(W3 末同审计)
    - 坑:把"黄金窗"(算优先级用,如缺牙 30-180 天)直接当**入池硬边界**(SQL `BETWEEN`),会把超上界的患者直接踢出池 —— 缺牙拖 1 年比拖 3 个月更该召,却因"超 180 天"漏掉(实测超窗 3 个全 `>360` 天,窗内仅 2 个)。
    - 处理:入池**不设硬上界**,黄金窗只喂给打分器算**新鲜度因子**(W7 v3:过窗衰减到 0.4;旧称 timeWindowFactor);超窗患者照常入池、分数低、排后面。下界保留(刚诊断 `<start` 天还在考虑期,不急召)。
12. **fact 标准主体由 PAC 定,别被单一宿主牵着走 + 摄入断链体检**(W3 末审计 emr/影像)
    - 原则:`fact.content` schema = PAC 自己定的临床通用语义全集(所有宿主的统一投影目标)。
      某宿主映射不上某字段 → 留 null,**绝不因"这个 host 没有 / 拆走了"从标准删字段**。
      例:emr_record 必须保留 `diagnosis_text` / `treatment_plan`(小诊所病历常是自由文本),
      哪怕瑞尔 DW 把诊断 / 治疗拆成了结构化数组(这俩字段对瑞尔留 null)。
    - 判断依据 = **语义普适性(跨宿主通用),不是来源**:host 的设计若普适就大方采纳为标准
      (如瑞尔预约 8 态多为通用就诊生命周期 scheduled/arrived/completed/cancelled/no_show/rescheduled,
      配得上 PAC 标准);PAC 自拍的若不普适也要砍。来源是 host 还是 PAC 不重要。
    - 真正的反模式:为贴合某 host 而**删通用字段**(如删 diagnosis_text)或**把 host 私有字段塞进标准**。
      正确是逐项问"是否跨宿主普适" —— 这是 PAC 团队(懂业务 + 临床)的持续判断活,非机械规则。
    - **摄入断链体检**(三处任一断 → 主体静默变空壳,无报错):
      ① SQL 没 SELECT 该列;② yaml `field_mapping` 没映射(parser 读 canonical 名 → undefined → null);
      ③ assembler 没注册进 `manifest.assemblers`(整个主体不进 dispatcher)。
    - 实测踩坑:emr_record 803 条自由文本**全 null**(②yaml 漏映射 + parser 字段名对不上);
      image_record **0 条**(①file_url 无 transform + ③无 image.yaml 注册)。修复后 emr 文本全有、影像 756 条。
    - 体检方法:逐 fact_type 抽查 `content` 各字段非空率;非空率为 0 的字段顺管道三处查断点。
13. **字段名 + 注释只能参考,真实含义靠数据推**(W3 末 billing_date 实战)
    - 坑:host 字段名/comment 经常误导,顾名思义会选错字段。
    - 实战:settlement 4 个时间字段 `rq` / `settlement_time` / `billing_date` / `created_date`,
      字面看 `billing_date` 像"开单日"(以为偏行政),`rq` 像"日期"(以为主用)。PAC 起初选 rq → 治疗时间 vs 诊断时间口径不一致(rq 是结算操作日,可能延后 1-64 天)。
    - 用数据推真实含义:
      - billing_date **99.96%** 等于同 encounter 的 emr.rq(接诊日)→ **真实含义 = 治疗发生当天**
      - 同 encounter 的多行 settlement 共享同一 billing_date(distinct=1)→ 一次接诊一次开单(治疗事件级)
      - rq 跟 billing_date **97% 同天**,但偶有延后 → 是结算操作日
      - 结论:PAC 治疗时间应该用 `billing_date`(跟诊断时间同口径 = 接诊日)。
    - 推导方法(可复用):① 字段类型 + 取值范围 + null 率;② 同行多字段对比(rq vs billing_date 差异分布);
      ③ 跨表 join 验证语义(settlement.billing_date vs emr.rq 是否相等);④ 多行同 key 聚合(同 encounter 多行 distinct);
      ⑤ 拒绝从命名猜结论。
    - 文档化:每个时间/枚举字段在 yaml field_mapping 注释处记录"数据推论 + 充实度 + 选择理由",
      避免下任接手又被命名误导。

### 7.3 PAC 字典 vs 宿主码的关系

```
宿主码(host-specific,无所谓)             PAC 字典(集团统一,严格)
   K02.1 ┐                                K02
   K02.5 ┤── yaml enum_mapping ──→        K02
   诊002  ┤                                K02
   "龋齿" ┘                                K02

   tx_type=A01 ┐
   tx_type="种植" ┤── enum_mapping ──→     implant
   IMPLANT-S    ┘
```

**宿主可以随意,PAC 字典是闭集**——保证集团范围内**算法 / dashboard / 报表口径完全一致**。

---

## 八、决策清单(W2 末 D1-D13 + W3 末 D14-D19 新增)

| # | 决策 | 状态 |
|---|---|---|
| D1 | `packages/types/src/canonical-codes.ts` 字典文件,集团统一 enum 真理源 | ✅ |
| D2 | `fact-content-schemas.ts` + 写库前 zod 强校验 | ✅ |
| D3 | `patient_transactions.canonical_payload` 字段持久化中间态 | ✅ |
| D4 | 拆分 fact_type:diagnosis_record / treatment_record / recommendation_record 独立 | ✅ |
| D5 | encounter_record 退化为"只装元数据",诊断/治疗不再嵌套 | ✅ |
| D6 | treatment_plan + review_plan 合并进 treatment_record(用 kind=planned + category 区分) | ✅ |
| D7 | scenario / Persona / 前端 / AI prompt 全部只读 fact.content,**绝不读 raw_payload** | ✅ |
| D8 | 宿主侧不要求改 schema / 改命名 / 用 view 还是实表,PAC 内部 yaml + LLM 自己摆平 | ✅ |
| D9 | canonical-codes.ts 新增码必走 PR + 牙医 + 业务方 review | ✅ |
| D10 | **宿主内 brand / 多租户**(如瑞尔/瑞泰)映射到 PAC `tenant_id`,不在 fact 主键拼前缀 | ✅ |
| D11 | raw_payload 改成"主+子嵌套"(yaml 加 raw_groupArray)— 后续 | 🟡 |
| D12 | replay CLI(局部重算) | 🟡 |
| D13 | CI 防漂移测试 | ✅ |
| **D14**(W3) | **加 Layer A.5 transforms 层**:形态改造从 SQL 下放 yaml 6 operator 白名单 | ✅ |
| **D15**(W3) | **canonical-codes 加第 11 category `review`**:承载复查/拆线/暂观/无治疗 | ✅ |
| **D16**(W3) | **enum_mapping `_default` 兜底键**:长尾值统一映射,parser 跳过 | ✅ |
| **D17**(W3) | **SQL 朴素化**:SQL 退化为"等价 csv 单表 dump",形态改造全归 transforms | ✅ |
| **D18**(W3) | **4 入口统一管道**:cold/DW/pull/push 共用 transforms + assembler + parser,dispatcher 接 `emitsResolver`(§十) | ✅ |
| **D19**(W3) | **fact 标准主体由 PAC 自己定,不被任何单一宿主形态裁剪** | ✅ |

---

## 九、W3 末实际产出

### 9.1 实施清单(已交付)

| # | 产出 | 量 |
|---|---|---|
| 1 | canonical-codes 字典(K00-K14 + 业务码 + 推荐码 + 11 治疗 category 含 review) | 30+ codes |
| 2 | fact-content-schemas(17 fact_type × zod 强校验) | 17 schema |
| 3 | FactWriter 唯一入口 + canonical_payload 双留底 | 1 入口 |
| 4 | fact 拆分:encounter / diagnosis / treatment / recommendation 独立 | 4 新 fact_type |
| 5 | scenario 10 子规则 K00–K09 跑通独立 fact_type | 10 子规则 |
| 6 | brand → tenant_id 多品牌隔离(瑞尔/瑞泰) | 2 brand 实测 |
| 7 | **Layer A.5 transforms 6 operator + 49 单元测试** | 6 operator |
| 8 | **enum_mapping `_default` 兜底键** | AssemblerEngine 改 |
| 9 | **4 入口统一管道**(dispatcher emitsResolver) | §十 |
| 10 | CI 防漂移测试 | 53 个测试通过 |
| 11 | DW 真实 cohort 端到端跑通 | 1000 患者 / 63,781 txn / 0 failed |
| 12 | 卜晓平 plan 87 分(真实数据 K08 缺牙 151 天) | 召回率 3.2% |

### 9.2 跑通的 4 入口

| 入口 | 状态 | 验证方式 |
|---|---|---|
| Cold-import(SQL/CSV) | ✅ jvs-dw 跑通 0 failed | 真 DW + 1000 cohort |
| DW 直连增量 | ✅ 统一 pnpm sync(存量增量同一套 importDirectory) | cohort 分批 + 并发 + bulk write + run_start cursor |
| Mock Pull | ✅ 跑通 + 自带 emitsPerResource | adapter |
| Real HTTP Pull | 🔵 W5+ | 接 5i5ya 时实装 |
| Push 形态 A | ✅ event 自带 emits | dispatchTables resolver |
| Push 形态 B | 🟡 按需 | host 需求触发 |

---

## 十、4 入口统一管道(W3 末新加,对齐 potential-treatment-recall-flow.md)

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

```
Stage 0  数据获取(产 raw rows)
         ↓
Stage 1  Layer A.5 transforms(yaml 6 operator,可选)
         ↓
Stage 2  Layer A AssemblerEngine(yaml field/enum/emits)
         ↓
Stage 3  TransactionSynthesizer(写 transaction + emits 三件套)
         ↓
Stage 4  ParserPipeline(per fact_type zod + FactWriter)
```

### 10.1 4 入口切入点对照

| 入口 | Stage 0 | Stage 1 | Stage 2 | Stage 3 emits | Stage 4 |
|---|---|---|---|---|---|
| **Cold-import** | 文件/SQL 朴素 SELECT | ✅ 跑 | ✅ 跑 | yaml | ✅ 共用 |
| **DW 直连增量** | SQL + cursor 水位 | ✅ 跑 | ✅ 跑 | yaml | ✅ 共用 |
| **Pull HTTP** | host endpoints GET | ✅ 跑 | ✅ 跑 | yaml | ✅ 共用 |
| **Push 形态 A** | host 直推 event(canonical payload) | ❌ 跳过 | ❌ 跳过 | **event 自带 action/subjectType** | ✅ 共用 |
| **Push 形态 B** | host 直推 event(raw payload) | ❌ 跳过 | ✅ 跑(per-host push assembler) | event 自带 | ✅ 共用 |

### 10.2 实现纪律

- `PipelineDispatcher.dispatchTables` 加 **`emitsResolver: (resource, row) => EmitsConfig | undefined`** — caller 注入 emits 元数据
- yaml 路径:resolver 按 resource 名查 yaml 注册表(同 resource 内所有行共用 emits)
- push 路径:resolver per-row 读 `row._action` / `row._subjectType`(同 batch 不同 action OK)
- patient 主档:resolver 返回 undefined → 走 upsertPatient 跳过 transaction
- `RawPullPage` 加 `emitsPerResource?: Record<string, EmitsConfig>` — adapter 直接返回 canonical 时填(mock pull / shortcut adapter)

### 10.3 数据源真等价(yaml 单方案保证)

SQL 退化为"等价 csv 单表 dump 的 SELECT":
- ✅ 允许:`SELECT col1, col2 FROM single_table WHERE simple_filter`
- ❌ 禁止:ARRAY JOIN / JSONExtract / multiIf / substring / 跨表 JOIN

切 csv 模式:host 用同 WHERE 跑 SQL 把 csv dump 给 PAC → manifest 改 `csv:` 替代 `sql:` → transforms / assemblers / parsers **一行不动**。

详见 [potential-treatment-recall-flow](/docs/algorithms/scenarios) 的"4 入口统一管道"。

---

## 一句话归纳

> **PAC 的核心资产是 `fact.content` 一个对象**:经过 4 层规范化(形态 / 翻译 / 语义 / 文本)、由 zod schema 强约束、引用 PAC canonical-codes 字典闭集、**集团内全宿主统一**。**4 入口接不同 stage,Stage 3-4 后所有路径汇合**。
>
> 算法 / Persona / 前端 / AI 永远只读这一个对象,**不读 raw,不读 canonical 中间态,不读 host 原表**。任何破坏这条原则的代码,review 不过。

---

## 附录 A · W2 末扒 PAC mock 链路的 7 个发现(P011 演示样本)

(详见 W2 末 review 讨论;沉淀在这里供后续追溯。P011 是 PAC 内部 mock 数据,不是宿主真实数据。)

1. `raw_payload` 只存主表,子表(诊断 / 治疗)数据丢失 → 审计 / replay 困难
2. 字段名 3 套(host / canonical / content),3 处翻译规则散落
3. `encounter_record` 内嵌 `diagnoses[]` / `treatments[]` / `next_steps[]` 违背独立语义
4. `emr_record` / `image_record` fact 已落库但 scenario 完全不查 → P011 这种"EMR 显式建议种植"信号 100% 浪费
5. `next_steps[]` 派生只从 `treatments[]` → P011 没治疗就没 `next_steps`,临床意图丢失
6. canonical row 不留底 → 算错无法回放看中间产物
7. content 无 zod 校验 → parser 字段名漂移会静默失效

## 附录 B · W2 末从瑞尔 DW 实战学到的 4 件事

(对应 §3.5,这里把对话/查询过程的关键发现补全)

1. **DW 给的是 ADS 出口层实表,不是 view**(`dw_group.fact_*_out` 命名,带 _out 后缀)
   - DW 内部有更细的 DWD 明细层,但当前对 PAC 隔离没暴露
   - PAC 这边能消化(yaml 直接对实表写 mapping),性能反而比 view 好
2. **业务 ID 跨 brand 重复**(详见 §3.5)— `patient_id` / `appointment_id` / `registration_id` 全部跨 brand 重复
   - 必须用 `(business_id, brand)` 复合键 JOIN
   - PAC 把 brand 映射到 `tenant_id`,后续算法零感知
3. **聚合表炸行风险**:`fact_emr_treatment_out` 因为 JOIN 影像子表没 groupArray,**5 倍行数膨胀**(193 万行 → 39 万真实 EMR)
   - 已反馈 DW 修复
   - PAC 这边 yaml 加 `dedup_by: id` 兜底
4. **数据齐全度超预期**:DW 后来补的 `fact_emr_treatment_out` 把诊断 / 治疗计划 / 影像 metadata / 自由文本主诉**全部合并到一张表**
   - 诊断含牙位(`diag[i].toothPosition`,FDI 标准)
   - 治疗计划含标准码(`treat_plan[i].planCode`)
   - 影像引用(`file_url` JSON Array,含拍摄时间 + 类型)
   - EMR 自由文本(`illness_desc` / `pre_illness` / `doc_order`)
   - PAC fact 重塑(§四)的所有 fact_type 都有数据源

---

## 关联文档

| 文档 | 关系 |
|---|---|
| [召回场景](/docs/algorithms/scenarios) | 召回算法侧设计(基于本文 fact 规范化层) |
| [three-layer-model](/docs/architecture/three-layer-model) | 三层模型大局,fact 层是本文聚焦 |
| [data-model](/docs/architecture/data-model) | DB schema 真理源,本文带来的 fact_type 变化要回填进去 |
| [host-integration](/docs/integration/overview) | 宿主接入手册,本文 §七"接入边界对话"补充 |
| [channel-clickhouse](/docs/integration/channel-clickhouse) | DW 直连方案,跟本文 Layer A AssemblerEngine 是上下游 |
