# PAC 数据库设计 v2(合并整理版)

> **版本:** v2.1(W2 末更新)
> **目的:** 统一收敛 PAC 数据库设计 — 替代已删除的 W1 评审过程稿(db-review / db-suggest-after-review / db-review-confirm),作为后续唯一权威参考。
> **状态:** ✅ v2.0 schema 已落地(13 model + 4 MIGRATION);🟡 v2.1 增量未跑 migration(等开 PR 1 一起)
> **真理源:** [`apps/pac-service/prisma/schema.prisma`](../apps/pac-service/prisma/schema.prisma) — 本文档与之保持同步,如有出入以 schema 为准。
>
> **v2.1 增量(W2 末决议,详见 [`docs/algorithm/canonical-fact-layer.md`](algorithm/canonical-fact-layer.md))**:
> - **fact_type 重塑**:`encounter_record` 退化为只装元数据;新增独立 `diagnosis_record` / `treatment_record`(合并旧 treatment_plan/review_plan)/ `recommendation_record`(LLM 抽取产物)
> - **PatientTransaction 加列 `canonicalPayload`**:AssemblerEngine yaml 翻译后的中间态留底(审计 + replay)
> - **多 brand 隔离**:brand 通过 yaml 映射到 `tenant_id`(瑞尔 → ruier / 瑞泰 → ruitai),fact schema 不变
> - **`fact.content` zod 强校验**:`FactContentSchemas` 单一定义,写库前过(防 parser 漂移)
> - **PAC canonical-codes 字典集中化**:`packages/types/src/canonical-codes.ts` 单一真理源
>
> **v2.0 合并说明**(原 3 份文档保留不变,本文为优化合并版):
> - 内容来源:已合并 W1 三份评审过程稿(主体设计 + 评审建议 + 最终响应单),原稿已删
> - 删除过时章节:6 张已删除镜像表(diagnoses / treatments / appointments / charges / touchpoints / patient_fact_events)、`rulesConfig` 字段、`adapterKind` / `assigneeRole` 等已删字段
> - 收敛重复表述:三份文档中重复的"接口 1 / 接口 2"约束、隔离纪律等
> - 加上交叉引用:[architecture-v2.md](architecture-v2.md) / [design-v2.md](design-v2.md) / [three-layer-model.md](three-layer-model.md) / [host-integration.md](host-integration.md) / [schema.prisma](../apps/pac-service/prisma/schema.prisma)

---

## 目录

- [一、业务定位:PAC 三层模型](#一业务定位pac-三层模型)
- [二、设计原则(8 条)](#二设计原则8-条)
- [三、表清单总览(13 个 model)](#三表清单总览13-个-model)
- [四、命名 / 类型 / 索引规约](#四命名--类型--索引规约)
- [五、Platform 表(接入宿主注册)](#五platform-表接入宿主注册)
- [六、Patient 表(身份对象)](#六patient-表身份对象)
- [七、PatientTransaction 表(操作账本)](#七patienttransaction-表操作账本)
- [八、PatientFact 表(事实单元版本流)](#八patientfact-表事实单元版本流)
- [九、Persona / PersonaFeature / PersonaRecomputeLog 三表](#九persona--personafeature--personarecomputelog-三表)(下半)
- [十、FollowupPlan / PlanScript / PlanSummary / PlanExecution / PlanGenerationLog 五表](#十plan-五表)(下半)
- [十一、AgentInvocation / SyncLog 横切两表](#十一横切支撑两表)(下半)
- [十二、4 项 MIGRATION raw SQL](#十二4-项-migration-raw-sql)(下半)
- [十三、决议清单(决策记录)](#十三决议清单决策记录)(下半)
- [十四、演进路径](#十四演进路径)(下半)

---

## 一、业务定位:PAC 三层模型

PAC 三层模型(详见 [three-layer-model.md](three-layer-model.md)):

```
第一层 患者事实层      第二层 患者画像层       第三层 召回计划层
─────────────────     ─────────────────      ─────────────────
patient_transactions   personas                followup_plans
patient_facts          persona_features        plan_scripts / plan_summaries
patients(主档)                                plan_executions
                       ↑ event-driven         ↑ scenario-driven
                       + manual + scheduled    + manual
                                                ↓ 异步生成 assets
                                                AI Gateway → Dify
```

| 层 | 业务问题 | 表 | 写入模式 |
|---|---|---|---|
| 第一层 — Patient + Transaction + Fact | "患者世界发生了什么 / 现在是什么样" | `patients` / `patient_transactions` / `patient_facts` | upsert / append-only / 版本流 |
| 第二层 — Persona | "PAC 怎么评价这个患者" | `personas` + `persona_features` | 版本流 |
| 第三层 — FollowupPlan | "PAC 让客服打谁、怎么打、什么时候打" | `followup_plans` + `plan_scripts` / `plan_summaries` / `plan_executions` | 版本流 + append-only(execution) |
| 横切 | "AI 调用 / 同步运行 / 重算编排 账本" | `agent_invocations` / `sync_logs` / `persona_recompute_logs` / `plan_generation_logs` | append-only / 状态更新 |

PAC 核心实现点(对应 PDF 验收口径):

| # | 核心实现点 | 对应表 / 字段 |
|---|---|---|
| 1 | 同时支持 pull / push 两种事实接入 | `Platform.pullConfig` + `Platform.pushSecretHashes[]` |
| 2 | 患者事实时间轴(append-only)| `patient_transactions` |
| 3 | 实时分析触发(event-driven)| `patient_transactions.eventSeq` BigInt 单调水位 |
| 4 | Persona 版本化(描述 + 证据 + 版本)| `personas` + `persona_features` 双表 |
| 5 | Plan 版本化(每个对外承诺都能反查依据) | `followup_plans.evidence` + `version` + `supersededAt` |
| 6 | 接口 1 / 接口 2 严格区分(执行反馈不进事实层) | `plan_executions` 仅反馈 Plan 状态机,不回写 fact |
| 7 | Agent Pool(LLM 调用审计 + 输入快照 + 版本)| `agent_invocations` 完整闭环字段 |
| 8 | 多租户 / 多门诊隔离 | 硬隔离 platform+tenant + 立柱业务字段 clinic_id 分级 |

---

## 二、设计原则(8 条)

### 原则 1:按表语义隔离,不是统一三级

**`platform_id + tenant_id` 是所有业务表的强制基线,`clinic_id` 仅在三种语义下出现** — 滥用 `clinic_id` 会污染身份语义。

| 维度 | 适用表 | 业务语义 |
|---|---|---|
| **硬隔离**(`platform + tenant`) | 所有业务表的基线 | 跨 tenant 不允许越界 |
| **立柱 clinic_id**(业务字段,非硬隔离) | `patient_transactions`(操作发生地)/ `plan_executions`(执行发生地) | 同 tenant 内跨 clinic 可见性由应用层 RBAC 决定 |
| **立柱 target_clinic_id**(业务字段,nullable) | `followup_plans` | 业务归属诊所;null = 集团统一客服池 |
| **不立 clinic_id** | 患者身份 / 推断层(persona / persona_features / plan)/ 跨任务日志 / AI 调用账本 | 跨诊所共享身份 |

跨诊所查询:
- "该患者去过哪些诊所"靠 `patient_transactions.clinic_id` 反查
- "某诊所的召回池"靠 `followup_plans.target_clinic_id` 业务过滤

索引基线:每张业务表 `(platform_id, tenant_id [, clinic_id])` 联合索引覆盖隔离过滤,查询层强制追加。

### 原则 2:事实 vs 推断分层

| 层 | 来源 | 落地 |
|---|---|---|
| **事实**(host 给的结构化记录)| 宿主侧 | `patient_transactions`(原文留底)+ `patient_facts`(canonical 衍生) |
| **推断**(PAC 基于事实算的结论)| PAC 内部 | `persona_features` + `followup_plans` |

判定看**数据来源**而非字段名。同概念两边都有时(医嘱"下次复查" vs PAC"推荐回访"),业务使用**事实优先,推断兜底**。

**镜像表绝不存推断结论**(`recommended_*` / `priority_*` / `*_score` 不进事实层)。**Persona 不拆"价值描述"为字段**。

**推断不能反向污染事实**:Plan 执行回写(`plan_executions`)只更新 Plan 自己的生命周期,**绝不进 `patient_facts` 或 `patient_transactions`** — 否则 PAC 自己的执行动作会被当成"患者事实"参与下一轮 Persona / Plan 重算,证据链就乱了(对应 PDF §5.3 接口 1 vs 接口 2)。

### 原则 3:写入模式按数据所有权三分

| 模式 | 适用表 | 解释 |
|---|---|---|
| **append-only**(只插不改不删) | `patient_transactions`(操作账本)/ `plan_executions`(执行回写)/ `agent_invocations`(AI 调用账本)/ 3 张 orchestration log | 真相源头 / 审计,改了违反纪律 |
| **upsert 当前态** | `patients` 主档 | 跟随宿主侧 update |
| **版本流(append + supersede)** | `patient_facts` / `personas` + features / `followup_plans` + scripts / summaries | PAC 自己产出的判断,每版独立证据,PDF §5.5 硬要求;`version` 单调递增 + `supersededAt` 标当前态 |

**子表跟随父表版本** — `persona_features` 跟 `personas` parent 版本,`plan_scripts` / `plan_summaries` 跟 `followup_plans` parent 版本。父表 supersede 时子表自然进入历史。

### 原则 4:证据链与版本化

PAC 输出的每一个 Persona / FollowupPlan / PlanScript / PlanSummary 都有 `evidence` 字段 + 关联 `agent_invocations`,**每个对外输出都能反查"为什么这么定"**:

```
persona_features.evidence = { factIds, agentInvocationIds, ruleIds, promptVersion }
followup_plans.evidence = { factIds, personaVersion, agentInvocationIds, ruleIds }
plan_scripts.agentInvocationId / plan_summaries.agentInvocationId
patient_facts.transactionIds → 反查 patient_transactions.id → 原始 rawPayload
```

### 原则 5:Harness 工程观(AI 治理)

**主流程靠确定性代码,agent 只负责 LLM 本职擅长的事**。

| 路径 | 用途 | 失败行为 |
|---|---|---|
| **规则路径**(SQL / 代码计算)| 数值(score / value)、决策(scenario 选择 / 过滤)、时间(`recommendedAt`)、状态机 | 不允许失败 |
| **Agent 路径**(LLM 调用,经 AI Gateway → Dify)| 自然语言描述(`description` / `inclusionReason`)、跨文本归纳(话术 / Summary / 偏好推断)| **必须降级到规则**,绝不阻塞主流程 |

两路共存于 `evidence`:`{ ruleIds, agentInvocationIds, factIds }`;agent 失败时 `plan_scripts.source='template_fallback'`。

3 张 orchestration log(`sync_logs` / `persona_recompute_logs` / `plan_generation_logs`)立柱 `rule_count` / `agent_count` / `fallback_count` —— `fallbackCount` 升高即上游 LLM 异常的早期信号。

### 原则 6:立柱 / JSON / notes 三分

| 形态 | 判据 | 例子 |
|---|---|---|
| **立柱**(独立列) | 需 SQL 筛选 / 排序 / 索引,或属合规边界 | `phone` / `do_not_contact` / `status` / `priorityScore` |
| **JSON 列** | 易变 + 平台特异 + 形状不固定;形状由应用层 zod 校验 | `pullConfig` / `fieldMapping` / `evidence` / `content`(fact)|
| **`notes` 自由文本** | 仅展示用、无 SQL 检索需求 | `plan_executions.notes` / `agent_invocations.errorMessage` |

**字段即契约** — 任何已立柱字段(API path / token 字段名 / code 数值 / 行为语义),一旦暴露,无业务原因不破坏向后兼容。

**JSON 装"程序要用的结构化数据",`notes` 装"给人看的自由文本"。判别看消费方:代码会 parse 的进 JSON,只展示给人的进 notes。**

### 原则 7:金额一律 Int 分,单一真理源

`amountCents` 等字段以最小货币单位整数表达(`currency` 列 NOT NULL 配套指定),DB / Service / API 全链路 Int 分,中间层不换单位。

- **入口转换**:Pull adapter 读 `pullConfig.amountUnit`(`fen` / `yuan` 必填)做归一化;Push 契约要求 Int 分
- **出口转换**:前端 `formatMoney(cents, currency)` 统一格式化;**API 绝不返回格式化字符串**
- **亚分精度 v1 不支持**:跨境 / 外汇场景出现时一次性 migration 升 `Decimal(18,4)`,不预留

### 原则 8:时间一律 UTC 存储

所有时刻字段用 `DateTime @db.Timestamptz(3)`(Postgres 内部 UTC,毫秒精度),**不用裸 `timestamp`**(Prisma 默认值,会丢时区);日期类(`birthDate` 等无时刻概念)用 `@db.Date`。

- **入口**:Pull adapter 按宿主时区(`pullConfig.timezone`,IANA 名,必填)解析 → 转 UTC 入库;Push 契约要求 ISO 8601 带时区,无时区拒收
- **出口**:API 一律返 ISO 8601 with offset(`2026-05-01T02:00:00Z`)
- **跨日聚合**按业务时区:"今天的回访任务" / "近 30 天" 是诊所所在时区的概念,SQL `AT TIME ZONE <tz>` 后再 `DATE_TRUNC`

---

## 三、表清单总览(13 个 model)

| # | 表名 | 层 | 写入模式 | 隔离 | 父子关系 |
|---|---|---|---|---|---|
| 1 | `platforms` | 顶层 | upsert | n/a(自身 = platform 注册)| 13 表的 FK 父表 |
| 2 | `patients` | L1 | upsert | platform + tenant | 父表(self-merge 关系)|
| 3 | `patient_transactions` | L1 | append-only | platform + tenant + clinic_id 立柱 | 关联 patient(nullable)+ syncLog |
| 4 | `patient_facts` | L1 | 版本流 | platform + tenant + patient | 关联 patient + 反查 transaction_ids |
| 5 | `personas` | L2 | 版本流 | platform + tenant + patient | 父表 |
| 6 | `persona_features` | L2 | 版本流(随父)| 通过 persona FK | 子表 |
| 7 | `persona_recompute_logs` | 横切 L2 | append-only | platform + tenant | log 账本 |
| 8 | `followup_plans` | L3 | 版本流 + target_clinic_id 立柱 + patient 级 single-open | platform + tenant + patient | 父表(scenario 移到 plan_reasons)|
| 8a | `plan_reasons` | L3 | scenario 维度子表(plan 1:N reason)| platform + tenant + plan | follow-up plan 子表 |
| 9 | `plan_scripts` | L3 | 版本流(随父)+ 异步 status 机 | 通过 plan FK | 子表(每 plan 1 行) |
| 10 | `plan_summaries` | L3 | 同上 | 通过 plan FK | 子表(每 plan × 3 type) |
| 11 | `plan_executions` | L3 | append-only + executor_clinic_id 立柱 | platform + tenant + executor_clinic | 子表(反馈父 plan 状态机)|
| 12 | `plan_generation_logs` | 横切 L3 | append-only | platform + tenant | log 账本 |
| 13 | `agent_invocations` | 横切 | append-only | platform + tenant | AI 调用账本 |
| 14 | `sync_logs` | 横切 L1 | append-only | platform + tenant | 同步运行账本 |

**(实际 14 个表,Platform 算 1 个;PAC 内**不立 tenants / clinics / users 表**,这三个 id 都是 host 提供的 TEXT)**

---

## 四、命名 / 类型 / 索引规约

### 4.1 命名

- **表名**:snake_case 复数(`patient_transactions`)
- **Prisma 字段**:camelCase(`patientId`)→ DB 列 snake_case(`patient_id`,通过 `@map`)
- **PK**:统一 `id` UUID(`@id @default(uuid()) @db.Uuid`)
- **FK 字段**:`<entityName>Id` + `@db.Uuid`(如 `patientId`、`platformId`)
- **去前缀原则**:同表内字段不重复表名前缀
  - 例:patient_facts 用 `kind` / `type` / `status` / `title` / `summary` / `content`,**不**用 `factKind` / `factType` ...
  - 例外:跨表保命字段保留前缀(`platformId` / `patientId` / `subjectId` 等 FK 性质)
- **小写枚举值**:`agent_invocations.kind` 取值 `script` / `summary`(不是 `SCRIPT` / `SUMMARY`)

### 4.2 类型

| 业务字段 | Prisma 类型 |
|---|---|
| 主键 / FK | `String @db.Uuid` |
| 时刻 | `DateTime @db.Timestamptz(3)` |
| 日期(无时刻) | `DateTime @db.Date` |
| 单调水位 / 序列 | `BigInt @default(autoincrement())`(如 `eventSeq`)|
| 金额 | `Int`(分整数)|
| 自由 JSON | `Json` |
| 数组 | `String[]`(普通)或 `String[] @db.Uuid`(UUID 数组)|
| 长文本 | `String @db.Text` |
| 枚举(应用层管理)| `String` + 应用层 zod enum 校验,DB 不强枚举(允许扩展) |

### 4.3 索引规约

- **隔离基础**:每张业务表 `[platformId, tenantId]` 或 `[platformId, tenantId, clinicId]` 联合
- **热查询路径**:命中路径单独立索引(召回池排序 / 时间轴翻页 / etc.)
- **partial UNIQUE**(Prisma 表达不了,raw SQL migration):
  - `patient_transactions.source_event_id` 幂等(WHERE NOT NULL)
  - `patient_facts.subject_id` 单活跃版本(WHERE status='active')
  - `followup_plans.(patient_id)` 单未结案 plan(WHERE status IN active/assigned) — v2 patient 级,scenario 维度移到 plan_reasons 子表
- **GIN**(数组 / JSONB):`patient_facts.transaction_ids` 反查"某 transaction 影响了哪些 fact"

---

## 五、Platform 表(接入宿主注册)

### 5.1 字段

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | UUID PK | ✓ | 所有子表 FK 到此 |
| `name` | String unique | ✓ | 英文 slug(friday / 5i5ya / dw / mock),人读用 |
| `appId` | String unique | ✓ | 宿主调换票接口的 appId |
| `appSecretHash` | String | ✓ | scrypt 哈希(`salt:hash`),原文从不回读 |
| `pullConfig` | JSONB | nullable | PAC 调宿主出站配置(endpoints + credentials + amountUnit + timezone) |
| `pushSecretHashes` | String[] | ✓(默认空) | HMAC 共享密钥哈希数组,**支持平滑轮换**(索引 0 是 current) |
| `fieldMapping` | JSONB | ✓(默认 `{}`) | host 字段命名 → PAC canonical(三层结构) |
| `actionUrls` | JSONB | ✓(默认 `{}`) | deep-link 模板(VIEW_PATIENT 等) |
| `active` | Boolean | ✓(true)| 软停用开关 |
| `createdAt` / `updatedAt` | Timestamptz | ✓ | 审计 |

### 5.2 删除的字段(v2 较老 v1)

| 删的字段 | 删的原因 |
|---|---|
| `platformId` slug | 用 `name` 取代,子表关联走 `id` UUID FK |
| `pushSecretHash`(单值) | 改 `pushSecretHashes[]` 数组(平滑轮换) |
| `rulesConfig` | 集团统一规则,scenario 改为应用层常量,DB 不存 |

### 5.3 JSON 字段形态(详见 [host-integration.md §3.1 / §7](host-integration.md))

`pullConfig` 见 [host-integration.md §3.1](host-integration.md#3-pull-路径pac-主动拉资源);`fieldMapping` 三层结构(`patient` / `transaction` / `subjects.*`)见 [host-integration.md §7](host-integration.md#7-field-mapping-配置)。

### 5.4 关键纪律

- `appSecret` / `pushSecret` plaintext 一次性返回,PAC 永不回读(失落只能轮换)
- `pushSecretHashes` 数组形式:遍历匹配,任一命中即通过验签;运维定期清理过期 key
- **PAC 不维护** tenants / clinics / users 表 — 边界:这些主数据在宿主侧

---

## 六、Patient 表(身份对象)

### 6.1 业务定位

患者主档 — 跨诊所共享身份对象。**同一物理人在集团内任何诊所共享同一行**(`UNIQUE(platform_id, tenant_id, external_id)`)。

### 6.2 字段

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | UUID PK | ✓ | 主键 |
| `platformId` | UUID FK → platforms.id | ✓ | 硬隔离基线 |
| `tenantId` | String | ✓ | 宿主侧 tenant id(来自 token) |
| `externalId` | String | ✓ | 宿主侧 patient id,回链 |
| `name` | String? | nullable | **stub 期可空** — push 首见可能只有 patientRef |
| `phone` | String? | nullable | **stub 期可空** — 缺失自动排除召回池 |
| `gender` | String? | nullable | 自由文本,各宿主取值不一不强枚举 |
| `birthDate` | Date | nullable | YYYY-MM-DD |
| `preferences` | JSONB | nullable | 沟通偏好(自由形态)|
| `doNotContact` | Boolean | ✓(false)| 合规硬要求,true → 永久排除召回 |
| `doNotContactReason` | String? | nullable | 应用层 zod enum |
| `doNotContactAt` | Timestamptz | nullable | 打标时间 |
| `deceased` | Boolean | ✓(false)| 已故标记 |
| `deceasedAt` | Timestamptz | nullable | |
| `status` | String | ✓(active)| `active` / `archived` / `merged` |
| `mergedIntoId` | UUID? FK → patients.id(自引用) | nullable | status=merged 时指向合并目标 |
| `tags` | String[] | ✓([]) | ⭐ 产品收集 — 宿主多源标签数组(如"VIP 卡客户"/"难沟通"),Persona engine 输入信号 |
| `primaryContactType` | String? | nullable | ⭐ 产品收集 — 主要联系人身份:`self` / `spouse` / `parent` / `child` / `guardian` / `other`(儿童/老年触达流程不同) |
| `createdAt` / `updatedAt` | Timestamptz | ✓ | 审计 |

### 6.3 索引

| 索引 | 用途 |
|---|---|
| UNIQUE `(platformId, tenantId, externalId)` | 跨同步 upsert 去重 |
| `(platformId, tenantId)` | 隔离基础过滤 |
| `(phone)` | 按电话快速查找 |
| `(platformId, tenantId, status, doNotContact, deceased)` | **召回池热查询路径** |

### 6.4 召回池硬过滤(主查询路径)

```sql
WHERE status='active'
  AND do_not_contact=false
  AND deceased=false
  AND phone IS NOT NULL    -- stub 患者自动排除
```

### 6.5 关键设计决策

- **不立 `clinic_id`** — 跨诊所共享身份;"该患者去过哪些诊所"通过 `patient_transactions.clinic_id` 反查
- **不物理删除** — 合规走 `status='archived'` 软删 + name/phone 匿名化;DB 层 cascade 用 `Restrict` / `SetNull` 兜底防误删
- **Merge 穿透查询** — `status='merged'` 时历史 transactions / facts 不迁移,应用层用递归 CTE 穿透读 root + mergedFrom 子树
- **stub 患者** — push 首见时只有 patientRef + 部分字段 → name/phone nullable,后续 pull 补全;`phone IS NULL` 自动排除召回池

### 6.6 不加的字段(评审讨论沉淀)

| 候选字段 | 不加的理由 |
|---|---|
| 身份证号 | PIPL 高敏字段,host 自己管,external_id 回链够用 |
| 详细地址 / 街道 | 召回业务用不上,泄漏代价大 |
| 紧急联系人 / 家属 | 业务深度太具体,host EMR 更合适 |
| 婚姻 / 民族 / 收入 / 职业 | 召回算法不该看(歧视风险 + 合规) |
| 既往史 / 过敏史摘要 | 由 SummaryAgent 即时生成,落到 `plan_summaries`,不进 patient 主档 |
| `email` / `wechat_id` | v1 走电话回访,真有多渠道触达再加 |
| `vip_level` / `customer_value_score` | 这是 Persona 的特征(value description + score),不是 patient 主档字段 |
| `clinic_id` / `primary_clinic_id` | 跨诊所共享身份,通过 transactions 反查;target_clinic_id 在 Plan 表满足 |
| `clinic_ids[]` 数组(membership) | 违反原则 2"推断不进事实表",并发 race + 丢失元数据 |

### 6.7 EMR 在 PAC 的边界

宿主 EMR 通常含:主诉 / 现病史 / 体格检查 / 诊断 / 处方 / 影像 / 病程 / 知情同意书等。

**PAC 不持有 EMR**(原则 2),只存"召回算法需要的事实子集" — 通过 `patient_transactions` 的 `rawPayload` + parser 衍生 `patient_facts.content`(canonical 化的事实片段)。

**EMR 详细内容消费路径**:
1. 客服打电话前看完整病历 → 前端按钮通过 `actionUrls.VIEW_PATIENT` deep-link 跳到宿主 EMR 页面
2. Agent 需要病历文本生成话术 / Summary → 临时调宿主 API 拉,落到 `agent_invocations.input_snapshot`(唯一受控通道,受保留期管控)
3. **不为业务持久化 EMR 摘要建专表** — Agent 输出的结论(如病历摘要)落到 `plan_summaries`(版本化、有证据),原文留在 inputSnapshot

---

## 七、PatientTransaction 表(操作账本)

### 7.1 业务定位

**操作账本(append-only)** — 回答"在哪个宿主、哪个 tenant、哪个诊所、对哪个主体、做了什么动作"。原始证据存在 `rawPayload`(宿主原文),canonical 化和事实视图在 `patient_facts` 层做。

### 7.2 字段

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | UUID PK | ✓ | 主键 |
| `eventSeq` | BigInt autoincrement | ✓ | **单调水位**(Postgres 序列维护)— 供 Persona.eventWatermark / 同步游标消费 |
| `platformId` | UUID FK | ✓ | 硬隔离 |
| `tenantId` | String | ✓ | |
| `patientId` | UUID? FK → patients | nullable | 纯管理动作或 stub 阶段可留 null;onDelete=SetNull |
| `sourceEventId` | String? | nullable | 宿主原生事件 ID 或 adapter 合成 hash,**幂等键**(partial UNIQUE WHERE NOT NULL) |
| `clinicId` | String | ✓ | 操作发生诊所(立柱业务字段,非硬隔离) |
| `action` | String | ✓ | 动作类型,14 个起步(应用层 zod enum) |
| `subjectType` | String | ✓ | 被操作主体类型,10 个起步 |
| `subjectId` | String | ✓ | 宿主侧业务对象 id(同一可被多次操作) |
| `rawPayload` | JSONB | ✓ | 宿主原生 100% 原文(adapter 收到啥存啥) |
| `canonicalPayload` | JSONB? | nullable | ⭐ **W2 末新增** — AssemblerEngine yaml 翻译后的 canonical row 留底(审计 + replay 用)|
| `payloadHash` | String | ✓ | sha256 指纹,幂等兜底 + 完整性校验 |
| `occurredAt` | Timestamptz | ✓ | 宿主侧实际发生时间(adapter 按 pullConfig.timezone 转 UTC) |
| `receivedAt` | Timestamptz | ✓(default now) | PAC 接收时间 |
| `syncLogId` | UUID? FK → sync_logs | nullable | 关联同步运行 |
| `createdAt` | Timestamptz | ✓ | 审计 |

### 7.3 起步枚举 — 14 起步 + 4 产品收集 = 18 action;10 起步 + 3 产品收集 = 13 subjectType

**Action**(18,形式 `英文_key`(中文)):
- `consultation_created`(咨询创建)
- `appointment_created`(预约创建)/ `appointment_changed`(预约变更)/ `appointment_cancelled`(预约取消)
- `appointment_fulfilled`(患者到诊,预约履约)/ `visit_registered`(挂号)/ `reception_completed`(前台接待完成)
- `doctor_encounter_started`(医生接诊开始)
- `order_submitted`(收费单/医嘱提交)/ `payment_received`(收款)/ `refund_created`(退费)
- `emr_submitted`(病历提交)/ `emr_rejected`(病历打回)
- `image_uploaded`(影像上传)
- ── 产品收集(回访中心信息字段.docx)──
- `recharge_received`(充值入账,储值办卡)⭐ 产品收集
- `complaint_filed`(投诉提交)⭐ 产品收集
- `complaint_resolved`(投诉处理结案)⭐ 产品收集
- `referral_recorded`(转介绍记录,老带新)⭐ 产品收集

**SubjectType**(13,形式 `英文_key`(中文)):
- `consultation`(咨询)/ `appointment`(预约)
- `visit_registration`(挂号)/ `visit_reception`(接待登记)
- `clinical_encounter`(医生接诊)/ `emr_document`(病历文档)
- `charge_order`(收费单)/ `payment`(收款)/ `refund`(退费)
- `image_study`(影像资料)
- ── 产品收集 ──
- `recharge`(充值/储值/福利卡办理)⭐ 产品收集
- `complaint`(投诉)⭐ 产品收集
- `referral`(转介绍记录)⭐ 产品收集

详见 [host-integration.md §4.4](host-integration.md#44-14-个-action--10-个-subjecttype)。

### 7.4 索引

| 索引 | 用途 |
|---|---|
| `(platformId, tenantId, clinicId)` | 立柱业务字段联合索引(同时覆盖前缀查询) |
| `(patientId, occurredAt)` | 按患者拉时间轴(parser / 审计反查路径) |
| `(subjectType, subjectId)` | 按业务主体反查(同一 apt_001 被几次改过) |
| `(action)` | 按动作类型分析 / 仪表盘 |
| `(receivedAt)` | 同步运维 / 监控 |
| `(eventSeq)` | 水位游标查询(persona 重算"自上次以来新增的 transaction") |
| **MIGRATION** partial UNIQUE `(platformId, tenantId, sourceEventId) WHERE NOT NULL` | 幂等键 |

### 7.5 关键纪律(v2 重要决议)

- **append-only**:只插不改不删,错了只能补一条修正 transaction
- **`rawPayload`(host 原文)+ `canonicalPayload`(yaml 翻译后中间态)双留底** ⭐ W2 末新增 — fact 算错时可两跳回溯;yaml 改后可局部 replay parser 不必重跑 sync
- **`sourceEventId` 幂等键** 是 PAC ↔ host 同步可靠性的基石(七层防线第 1 层)
- **`eventSeq` 单调水位** 用作 Persona.eventWatermark / 同步游标(BigInt 防 32 位溢出)
- **`syncLogId` FK** 配合 sync_logs.transactions 反向关系,反查"哪批拉进来的"
- **接口 2 的 PlanExecution 绝不写到这里** — 数据流单向(详见 plan_executions 章节)

### 7.6 评审过程中删除的字段(决议沉淀)

| 删的字段 | 原因 |
|---|---|
| `beforeSnapshot` / `afterSnapshot` | fact 版本流已是天然 before/after,canonical 由 parser 算到 fact 层 |
| `sourceSystem` | `platformId` UUID FK 已标识宿主,sub-module 是 metadata 可塞 rawPayload |
| `operation` | `action` 已含动作动词(`appointment_created`)|
| `sourceActorId` / `sourceActorRole` | 主流程不读,审计 raw_payload 自带 — 减字段降复杂度 |
| `ingestId`(改名 `syncLogId`)| 命名跟 SyncLog model 对齐 |

---

## 八、PatientFact 表(事实单元版本流)

### 8.1 业务定位

**事实单元(版本流)** — 回答"患者事实中存在什么"。由 transaction 经 parser 解析衍生;**一个 transaction 可产 0 / 1 / N 个独立 fact**(每个独立语义粒度一个 fact_type)。

例:`doctor_encounter_started` 一次接诊 → 同时产出 `encounter_record`(元数据)+ N 条 `diagnosis_record`(每个诊断一条)+ M 条 `treatment_record`(每项治疗一条)+ 若干 `recommendation_record`(医生建议)。详见 [`docs/algorithm/canonical-fact-layer.md` §四 fact 结构重塑](algorithm/canonical-fact-layer.md#四fact-结构重塑--从第一性原理推)。

### 8.2 写入模式 — 版本流

- 新版本 = 新 UUID(技术主键),`subjectId`(业务身份)跨版本稳定
- `(platform, tenant, subject_id, version)` 单调递增
- partial UNIQUE `(subject_id) WHERE status='active'` 强制单活跃版本(MIGRATION)
- supersededAt 标 supersede 时间(跟 personas / followup_plans 统一)

### 8.3 字段

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | UUID PK | ✓ | 主键(每版本独立)|
| `platformId` | UUID FK | ✓ | 硬隔离 |
| `tenantId` | String | ✓ | |
| `patientId` | UUID FK → patients | ✓ | onDelete=Restrict(版本流是审计核心,不随 patient 物删消失) |
| `subjectId` | String | ✓ | **业务身份,跨版本稳定**(对应宿主侧实体 ID) |
| `kind` | String | ✓ | `actual`(已发生) / `planned`(未来安排) |
| `type` | String | ✓ | 16 个起步(actual 12 + planned 4) |
| `status` | String | ✓ | active / superseded / cancelled / fulfilled / expired / invalidated |
| `version` | Int | ✓ | 单调递增 |
| `clinicId` | String? | nullable | 业务诊所,**立柱但 nullable**(actual / planned 字段统一) |
| `occurredAt` | Timestamptz? | nullable | actual 应填 |
| `plannedFor` | Timestamptz? | nullable | planned 推荐填 |
| `validFrom` | Timestamptz? | nullable | planned 时间窗起 |
| `validUntil` | Timestamptz? | nullable | planned 时间窗止 |
| `title` | String? | nullable | UI 展示用短标题 |
| `summary` | String? @db.Text | nullable | 自然语言摘要(供 LLM 拼 prompt) |
| `content` | JSONB | ✓ | 结构化内容(per type schema 由应用层 zod 校验)— **规则引擎读这里** |
| `transactionIds` | String[] @db.Uuid | ✓ | 关联触发本 fact 版本的 transaction.id 数组(证据链)|
| `createdAt` / `updatedAt` | Timestamptz | ✓ | |
| `supersededAt` | Timestamptz? | nullable | 被新版本取代时间;status='active' 时为 null |

### 8.4 起步枚举 — **v2.1(W2 末重塑后)13 actual + 2 planned + 3 产品收集 = 18 fact_type**

> ⭐ v2.1 重大重塑(详见 [`docs/algorithm/canonical-fact-layer.md` §四](algorithm/canonical-fact-layer.md#四fact-结构重塑--从第一性原理推)):
> - `encounter_record` 退化为只装元数据,**不再嵌套** diagnoses/treatments/next_steps
> - 新增独立 `diagnosis_record` / `treatment_record` / `recommendation_record`
> - 废弃 `treatment_plan` / `review_plan` / `payment_plan`(合并进 `treatment_record` 用 kind=planned + category 区分)

**Actual**(已发生事实,形式 `英文_key`(中文)):

| fact_type | 说明 |
|---|---|
| `consultation_record` | 咨询记录 |
| `visit_registration_record` | 挂号记录 |
| `visit_reception_record` | 接待记录(原 patient_received_record) |
| `encounter_record` ⚡ | **接诊事件 — 只元数据**(doctor_id / notes / 时间);诊断治疗拆出独立 fact |
| `diagnosis_record` ⭐ 新 | 诊断 — content: { code(PAC 字典), tooth_position, source_encounter_external_id, severity? } |
| `treatment_record` ⭐ 新 | 治疗 — actual=已做,合并 v2.0 的 treatment_plan/review_plan 为 planned 状态 |
| `appointment_record` | 预约 — kind=planned 未到诊 / actual 已到诊 |
| `order_record` | 医嘱记录(原 charge_order_record) |
| `payment_record` | 收款记录 |
| `refund_record` | 退费记录 |
| `emr_record` | 病历自由文本 — content 含 illness_desc/diagnosis_text/treatment_plan/doctor_advice |
| `image_record` | 影像 metadata + 医生 finding |

**Planned**(未来安排):

| fact_type | 说明 |
|---|---|
| `treatment_record` (kind=planned) | 计划治疗(合并旧 treatment_plan / review_plan,用 category=review 区分复查) |
| `recommendation_record` ⭐ 新 | 医生建议 — LLM 从 EMR 自由文本 / 影像 finding 抽出来,content: { code(IMPLANT_RECOMMENDED 等), tooth_position, extracted_by:'llm', confidence, window_days } |

**产品收集**(回访中心信息字段.docx):

| fact_type | 说明 |
|---|---|
| `recharge_record` | 充值记录,储值办卡 ⭐ 产品收集 |
| `complaint_record` | 投诉记录 ⭐ 产品收集 |
| `referral_record` | 转介绍记录 ⭐ 产品收集 |

**已废弃(v2.0 → v2.1 移除)**:
- `treatment_plan` / `review_plan` / `payment_plan` → 合并进 `treatment_record` 用 kind=planned + content.category 区分

### 8.5 索引

| 索引 | 用途 |
|---|---|
| UNIQUE `(platformId, tenantId, subjectId, version)` | 版本去重 |
| `(platformId, tenantId)` | 隔离基础 |
| `(patientId, status)` | 拉某患者当前 active 视图(persona / plan 重算主路径)|
| `(patientId, occurredAt)` | actual 时间轴 |
| `(patientId, plannedFor)` | planned 待办 / 即将复查 |
| `(type, status, validUntil)` | 复查 / 待办类查询("近期未完成的 planned + 时间窗过期"扫描)|
| **MIGRATION** partial UNIQUE `(subject_id) WHERE status='active'` | 强制单活跃版本 |
| **MIGRATION** GIN `(transaction_ids)` | 反查"某 transaction 影响了哪些 fact" |

### 8.6 关键纪律

- **只装宿主侧事实** — PAC 内部产物(Plan / PlanExecution / AI 输出)不回写 fact
- **AI 解析结果归 `agent_invocations.output`** — 不进 fact 层
- **并发约束** — Schema 层 unique 只兜底,并发性能由应用层 + 队列层保证(BullMQ jobName=subject_id 串行化;事务内 SELECT FOR UPDATE)
- **transactionIds 数组** — 引用 transaction.id 不是 fact.id;两跳可回到原始 rawPayload

### 8.7 评审决议

#### v1 → v2.0(从 6 镜像表 → 双表,W1 末)

| 决议 | 原 | 新 |
|---|---|---|
| 消灭镜像表 | `diagnoses` / `treatments` / `appointments` / `charges` / `touchpoints` 5 张 + `patient_fact_events` | **删全部 6 张**,改为 `patient_transactions` + `patient_facts` 双表 |
| 命名前缀 | `factKind` / `factType` / `factStatus` / `factTitle` / `factSummary` / `factContentJson` | **`kind` / `type` / `status` / `title` / `summary` / `content`** 去前缀 |
| 引用 | `evidenceTransactionIds` | `transactionIds` 去前缀 |
| 版本控制 | `supersededBy` UUID 指针 | **删 `supersededBy`,留 `supersededAt`** 跟 Persona / Plan 字段一致 |

#### v2.0 → v2.1(独立 fact 拆分,W2 末)⭐

详细推导见 [`docs/algorithm/canonical-fact-layer.md` §四](algorithm/canonical-fact-layer.md#四fact-结构重塑--从第一性原理推),核心 5 条:

| 决议 | v2.0 | v2.1(W2 末)|
|---|---|---|
| encounter_record content | 内嵌 `diagnoses[]` / `treatments[]` / `next_steps[]` | **只装元数据**(doctor_id / notes),诊断治疗拆出独立 fact |
| diagnosis 独立性 | 嵌在 encounter | **独立 `diagnosis_record`**(content: code / tooth_position / observed_at / severity) |
| treatment 独立性 | 嵌在 encounter(actual);独立 treatment_plan(planned) | **独立 `treatment_record`**,actual / planned 用 kind 字段区分,合并旧 plan |
| treatment_plan / review_plan / payment_plan | 3 个独立 fact_type | **废弃**,合并进 `treatment_record` 用 `kind=planned + category` 区分 |
| LLM 抽取产物 | (没设计) | **新增 `recommendation_record`**(LLM 从自由文本抽出的"建议种植"等,独立 fact_type)|
| `canonicalPayload` 字段 | 没有(canonical 是临时变量) | **PatientTransaction 加列**留底 canonical(审计 / replay) |
| `fact.content` schema 校验 | parser 写啥就是啥 | 引入 `FactContentSchemas` zod 强校验,写库前过 |
| 字典管理 | enum 散在 yaml + scenario SQL | `packages/types/src/canonical-codes.ts` 单一真理源 |
| 多 brand 隔离 | 默认单 tenant | **brand → tenant_id 映射**(瑞尔 → ruier / 瑞泰 → ruitai) |

---

## 九、Persona / PersonaFeature / PersonaRecomputeLog 三表

### 9.1 业务定位

**Persona** 是 PAC 的"特征层" — 把 fact 时间轴抽象成**高阶特征**,供多个 Plan / scenario / 话术 / Summary 复用。

**跳过 Persona 直接 fact → Plan 的代价**:
- 每个 scenario 各自重写"该患者多有钱 / 多忠诚 / 多值得追"的逻辑,平方级膨胀
- 特征是临时算出来的,没有版本号,无法回答"你为什么今天这么排?"(证据链断裂)
- One Page Summary 没有结构化数据来源,只能从 fact 现拼,客服 UI 卡顿
- 多 scenario 间口径不一致

> 例:"客户价值"特征同时被 `treatment_aftercare_recall`(主战场)/ `treatment_initiation_recall`(启治召回)/ 未来 scenario 消费 — Persona 算一次,处处复用,版本号一致,证据可查。

### 9.2 `personas` 表

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | UUID PK | ✓ | |
| `platformId` | UUID FK | ✓ | 硬隔离基础 |
| `tenantId` | String | ✓ | |
| `patientId` | UUID FK → patients | ✓ | onDelete=Restrict |
| `version` | Int | ✓ | 该 patient 单调递增版本号 |
| `triggeredBy` | String | ✓ | `txn:<id>` / `manual:<userId>` / `scheduled:<cronId>` |
| `computedAt` | Timestamptz | ✓ | 计算完成时间 |
| `supersededAt` | Timestamptz? | nullable | null = 当前版本 |
| `eventWatermark` | BigInt? | nullable | 重算时已响应到的 `patient_transactions.event_seq`,判断 stale 用 |

**索引**:UNIQUE `(patientId, version)` / `(platformId, tenantId)` / `(patientId, supersededAt)`

### 9.3 触发机制(三种来源 + 队列收敛)

| 触发源 | triggeredBy | 时机 | 价值 |
|---|---|---|---|
| **txn 事件驱动** | `txn:<transactionId>` | 新 transaction 落库即触发 | ⭐⭐⭐ 主路径(秒级响应) |
| **手工** | `manual:<userId>` | admin / API 调试 | ⭐⭐ 运维必需 |
| **定时兜底** | `scheduled:<cronId>` | 每日扫漏 | ⭐⭐ 可靠性 |

**冲突收敛**:三种 trigger 都 enqueue 到同一个 BullMQ 队列(`jobName = patient_id`),同一 patient 永远只一个 worker 在跑。核心 `PersonaService.recompute(patientId, triggeredBy)` 函数共享 — 重算时**用 eventWatermark 做幂等检查**:`if MAX(transactions.event_seq) ≤ persona.eventWatermark → no-op`。

### 9.4 `persona_features` 表

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | UUID PK | ✓ | |
| `personaId` | UUID FK → personas | ✓ | onDelete=Cascade |
| `key` | String | ✓ | 特征键(去 `feature_` 前缀) |
| `description` | String @db.Text | ✓ | **自然语言描述,自包含。不要拆字段。** |
| `score` | Float? | nullable | 可选数值评分(排序/筛选)|
| `evidence` | JSONB | ✓ | `{ factIds, agentInvocationIds, ruleIds, promptVersion }` |
| `createdAt` | Timestamptz | ✓ | |

**索引**:UNIQUE `(personaId, key)` / `(key)`

### 9.5 起步候选 feature_key — 13 起步 + 5 产品收集 = 18

形式:`英文_key`(中文含义)

```
── v1 起步实施(规则路径,4 个)──
  value(患者价值 / LTV / VIP 等级)
  treatment_chain_status(治疗链状态:进行中 / 已闭环 / 有缺口)
  recall_risk(流失/复发风险)
  do_not_contact_status(不打扰状态,合规硬约束)

── v1 候选(规则路径,4 个,业务方反馈后逐步上)──
  incomplete_treatment(未完成治疗 / 治疗链缺口)
  recommended_role(推荐执行角色:staff / leader / ...)
  referral_relation(转介绍关系,老带新链)
  doctor_relation(医患关系,主治医生绑定)

── W5+ 候选(agent 路径,4 个,LLM 接入后)──
  communication_preference(沟通偏好:微信 / 电话 / 拒接)
  customer_experience_risk(客户体验风险,投诉前兆)
  treatment_intent(治疗意向)
  family_social_relation(家庭/社交关系)

── v1 不实现(无 touchpoint 数据源)──
  engagement(触达活跃度,Phase 5+ host 真实触达数据接入再做)

── 产品收集(回访中心信息字段.docx)──
  patient_segment(患者基础人群细分:儿童/中年男/老年女...)        ⭐ 产品收集
  complaint_history(投诉历史,合规营销避雷)                       ⭐ 产品收集
  potential_treatment(待治疗,AI 影像识别 / 病历推断)              ⭐ 产品收集
  cooperation_attitude(配合态度:好说话 / 难沟通 / 拒接)          ⭐ 产品收集
  primary_contact_relation(主要联系人身份:本人/配偶/父母/子女)   ⭐ 产品收集
```

### 9.6 规则路径 vs Agent 路径(双轨并存)

| 路径 | 优点 | 缺点 | 适合特征类型 |
|---|---|---|---|
| **规则路径**(SQL / 代码) | 确定性 / 零成本 / 毫秒级 / 好审计 / 可重放 | 表达力有限,description 只能模板填充 | 数值类(`value`) / 清晰枚举类(`treatment_chain_status`) / 合规边界(`do_not_contact_status`) |
| **Agent 路径**(LLM via AI Gateway → Dify) | 自然语言生成 / 跨结构化 + 文本推理 / 能产出 nuance | 慢 / 贵 / 非确定性 / 需 prompt 版本管理 | `customer_experience_risk` / `communication_preference` / `family_social_relation`(几乎全文本)|

evidence 字段同时支持两种来源:`evidence.ruleIds` 跟 `evidence.agentInvocationIds` 互补。**v1 全部规则路径** — agent 路径留 W5+。

### 9.7 关键设计约束(PDF / 验收口径)

- **`description` 不拆字段**(PDF §5.2):自然语言描述自包含,不再拆 `value_score` / `treatment_value` / `relationship_value` / `risk_score` 这种固定子字段
- **`score` 不是 description 的替代** — 只用于"排序 / 筛选",不承载语义
- **特征键允许扩展** — `key` 是开放字符串,新增特征不需要 migration

### 9.8 `persona_recompute_logs` 表

每次 `PersonaService.recompute()` 调用一条记录,不论成功失败都落 —— harness 层执行账本。跟 `sync_logs` / `plan_generation_logs` 三表平行,各 orchestration 域一张账本,字段贴近自身语义。

| 字段 | 类型 | 说明 |
|---|---|---|
| `id` / `platformId` / `tenantId` / `patientId` | 标识 + 隔离 | |
| `triggeredBy` | String | 触发来源(同 personas.triggeredBy) |
| `eventWatermark` | BigInt? | 重算时已响应到的 transaction.event_seq |
| `status` | String | `running` / `success` / `partial` / `failed` |
| `producedPersonaId` | UUID? | 产出的 Persona id;failed 时 null |
| `featureCount` / `ruleCount` / `agentCount` / `fallbackCount` | Int | harness 健康度计数 |
| `errorMessage` | String? @db.Text | 失败信息 |
| `startedAt` / `endedAt` | Timestamptz | |

**索引**:`(patientId, startedAt)` / `(platformId, tenantId)` / `(status)`

**调用顺序**:
1. 重算开始 → 写一条 status=running
2. 跑规则提取器(`ruleCount++`);跑 agent 提取器 → 每次写一条 `AgentInvocation`(此时 `linkedPersonaId` 暂为 null)
3. 全部完成 → 写新 Persona + features → 回填 `AgentInvocation.linkedPersonaId` + 更新本表 status=success/partial,`producedPersonaId` 指向新 Persona
4. 中途崩溃 → 仅更新本表 status=failed,`errorMessage` 落原因;Persona 不写出;`AgentInvocation` 仍保留(`linkedPersonaId` 永远 null,通过本表反查)

---

## 十、FollowupPlan / Plan 子表(五表)

### 10.1 业务定位

**FollowupPlan = 召回计划(版本流)** — 整张表是**推断**层,每行都是 PAC 综合 fact / Persona / 规则 / agent 后的对外承诺。

**第一性原理**(v2 重要变更):**plan = patient 级触达决策**(一通电话/wechat),粒度是 patient 不是 scenario。同一 patient 命中多个 scenario(根管未戴冠 + 影像复查 + 流失挽回)→ 都进**同一条 plan** 的 reasons[] 子表。

- 一个 patient × 任意时刻最多一条 active/assigned plan
- scenario 维度 / 自身分 / 理由文本 / 证据 fact 移到 `plan_reasons` 子表(见 10.7)
- plan.priorityScore = MAX over plan_reasons.priority_score(冗余写入,客服列表排序专用)
- 熔断/触达频控按 patient 算(contactAttempts),不分摊到各 reason
- 客服 UI 一张卡片 = 一个患者(展示该 plan 所有 reasons)

### 10.2 `followup_plans` 表

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | UUID PK | ✓ | |
| `platformId` | UUID FK | ✓ | 硬隔离基础 |
| `tenantId` | String | ✓ | |
| `patientId` | UUID FK | ✓ | onDelete=Restrict |
| `personaId` | UUID? FK → personas | nullable | 两种工作模式:有 Persona 走 enhanced;无 Persona 走纯规则 |
| `targetClinicId` | String? | nullable | **业务字段**(创建锁定不更新);null = 集团统一客服池 |
| `version` | Int | ✓ | 该 patient 维度单调递增(supersede 时 +1)|
| `priorityScore` | Float | ✓ | **MAX over plan_reasons.priority_score** — 客服列表排序冗余写入字段 |
| `recommendedAt` | Timestamptz? | nullable | 推荐触达时间 |
| `recommendedRole` | String? | nullable | staff / leader / doctor_assistant ... |
| `recommendedChannel` | String? | nullable | phone / wecom / sms ... |
| `contactAttempts` | Int | ✓ default 0 | **patient 级**触达频控 + 熔断计数 |
| `status` | String | ✓ | active / assigned / completed / abandoned / superseded |
| `recycleAt` | Timestamptz? | nullable | 自动回收 deadline(assignment 时锁定) |
| `assigneeUserId` | String? | nullable | 当前指派给谁(宿主侧 user id) |
| `assignedAt` | Timestamptz? | nullable | |
| `createdAt` / `updatedAt` / `supersededAt` | Timestamptz | | 版本流字段(对齐 Persona / Fact)|

⚠️ **v1 字段已删/移动**:
- `scenario` → 移到 `plan_reasons.scenario`(一个 plan 可以含多个 scenario reason)
- `inclusionReason` → 移到 `plan_reasons.reason`
- `evidence`(JSONB)→ 移到 `plan_reasons.evidence_fact_ids`(UUID[])

### 10.3 索引

| 索引 | 用途 |
|---|---|
| UNIQUE `(platformId, tenantId, patientId, version)` | 版本去重(scenario 维度已移到子表)|
| `(platformId, tenantId)` | 隔离基础 |
| `(platformId, tenantId, targetClinicId)` | 按诊所过滤召回池(target 维度 — null 走集团池) |
| `(assigneeUserId)` | "我负责的" 查询 |
| `(status, assigneeUserId, priorityScore)` | **召回池主查询路径** |
| **MIGRATION** partial UNIQUE `(platformId, tenantId, patientId) WHERE status IN ('active','assigned')` | **patient 级**单未结案 plan(v1 含 scenario,v2 去掉)|

### 10.7 `plan_reasons` 子表(scenario 维度)

**职责**:plan 是 patient-级触达单元,reasons 是该 plan 命中的"召回理由清单"(scenario 维度)。

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | UUID PK | ✓ | |
| `planId` | UUID FK → followup_plans | ✓ | onDelete=Cascade |
| `scenario` | String | ✓ | PAC v1 两条主战场:`treatment_aftercare_recall`(治疗链已完成疗效保障)/ `treatment_initiation_recall`(启治召回,W3+ 启用);应用层 zod enum 校验 |
| `priorityScore` | Float | ✓ | 该 reason 自身得分(子场景基线 + value/risk/recency bonus 加权)|
| `reason` | String @db.Text | ✓ | 自然语言"为什么命中" — **子场景细节也在这里表达**(如"#1 根管未戴冠 — 患者于 ... 未见 crown")|
| `evidence` | UUID[] @map("evidence_fact_ids") | ✓ default []  | 证据:`patient_facts.id` 数组 |
| `lifecycle` | String | ✓(one_shot)| ⭐ 产品收集 — `one_shot`(短效:成因消失即关) / `recurring`(长效:周期性触发) |
| `source` | String | ✓(algorithm)| ⭐ 产品收集 — `algorithm`(scenario engine) / `rule`(规则触发) / `manual`(客服手添) / `campaign`(运营批量) |
| `sourceActorId` | String? | nullable | ⭐ 产品收集 — manual/campaign 时的操作人 user_id |
| `campaignId` | String? | nullable | ⭐ 产品收集 — campaign 时的批次 ID |
| `closedReason` | String? | nullable | ⭐ 产品收集 — `patient_treated` / `patient_refused` / `external_treatment` / `merged` / `superseded` / `expired` / `manual_closed` |
| `closedAt` | Timestamptz? | nullable | ⭐ 产品收集 — reason 关闭时间 |
| `createdAt` / `updatedAt` | Timestamptz | | 标准时间戳 |

**索引**:
| 索引 | 用途 |
|---|---|
| UNIQUE `(planId, scenario)` | 同 plan 内每个 scenario 最多一条 reason(避免重复 inclusion)|
| `(scenario)` | 反查"哪些 plan 因 X scenario 命中" |
| `(campaignId)` | ⭐ 产品收集 — 反查某批次活动涉及哪些 plan |
| **MIGRATION** GIN `(evidence_fact_ids)` | 反查"某 fact 影响了哪些 plan" |

**纪律**:
- 不再有 `subScenario` 字段 — 子场景细节(#1 根管未戴冠 / #3 种植年度复查等)在 `reason` 文本里
- 多 scenario engine 各自跑、各自 INSERT 到 plan_reasons,通过 UNIQUE 保证不重复
- 应用层每次 INSERT/UPDATE plan_reasons 后,同步更新 followup_plans.priorityScore = MAX(冗余字段保读性能)

### 10.4 召回池主查询路径

```sql
WHERE status='active' AND assignee_user_id IS NULL
ORDER BY priority_score DESC
-- 按诊所过滤追加:target_clinic_id IN (...) OR target_clinic_id IS NULL
```

### 10.5 评审决议(从 v1 到 v2)

| 决议 | 原 | 新 |
|---|---|---|
| 隔离维度 | 三级(platform + tenant + clinic) | **二级**(platform + tenant)+ targetClinicId 立柱业务字段 |
| `goal` 字段 | 自然语言计划目标 | **删** — 跟 inclusionReason 重叠,scenario 文案应用层管 |
| `assignmentMode` | manual / auto 区分 | **删** — 业务流程不消费,"how" 类描述字段 |
| `assigneeRole` snapshot | 角色快照防止 user 离职 | **删** — role 是 PAC 固定 3 个常量,不是 host 主数据,历史 role 失去就失去 |
| 版本流字段 | 只用 status='superseded' | **加 `supersededAt`**(跟 Persona / Fact 统一) |
| 删 plan 字段 | 见上 3 个 | 字段精简,减心智负担 |

### 10.6 `plan_scripts` 表(单字段 Markdown)

**v2 重大设计**:**每个 plan 一份完整话术**(开场 / 跟进 / 异议处理等子段在 `content` 内用 Markdown 分节)— 一次 agent 调用产出全部子段,**保持风格一致 + 节省 token**。

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | UUID PK | ✓ | |
| `platformId` | UUID FK | ✓ | |
| `tenantId` | String | ✓ | |
| `planId` | UUID FK → followup_plans, **@unique** | ✓ | 每 plan 1 行 |
| `content` | String? @db.Text(8000)| nullable | Markdown 多子段;pending 时为 null |
| `status` | String | ✓(pending)| pending / ready / failed |
| `source` | String? | nullable | agent / template_fallback / manual;status=ready/failed 时有意义 |
| `agentInvocationId` | UUID? FK | nullable | source=agent 时非空 |
| `createdAt` / `updatedAt` | Timestamptz | ✓ | |

### 10.7 `plan_summaries` 表(per-plan × 3 type)

跟 PlanScript 对偶,但保留 type 多行 — `one_page` / `medical_record` / `treatment_chain` 是**不同产品**(不同 prompt / 形态 / UI 卡片)。

**生成策略**:**一次 agent 调用产 3 个 type 内容**,写库时拆 3 行存,共享同一个 `agentInvocationId` — 省 token 也保证语境一致。

字段同 `plan_scripts`,多一个 `type` 字段,UNIQUE `(planId, type)` 保证同类一行。

### 10.8 plan_scripts / plan_summaries 异步状态机

```
Plan 写入 → 同步插占位行 (status='pending', content=null, source=null)
   → enqueue ScriptAgent + SummaryAgent (BullMQ)
       ↓ 异步消化
   worker → POST AI Gateway → Dify workflow → LLM
       ↓
   成功 → status='ready', source='agent', content=...
   agent 失败 → 模板兜底 → status='ready', source='template_fallback'
   兜底也挂 → status='failed' (罕见)
```

### 10.9 `plan_executions` 表(执行回写)

**v2 关键决议**:**v1 数据流单向** — 本表只反馈到 Plan 层(状态机 / 累积统计 / 熔断),**不回写 patient_facts / patient_transactions**(架构债待定)。

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | UUID PK | ✓ | |
| `platformId` | UUID FK | ✓ | |
| `tenantId` | String | ✓ | |
| `executorClinicId` | String | ✓ | **执行发生地**(必填) — 跟 followup_plans.targetClinicId(业务归属)允许不一致(代办场景:A 归属 B 代办) |
| `planId` | UUID FK | ✓ | |
| `operatorUserId` | String | ✓ | 客服 user id |
| `channel` | String | ✓ | phone / wecom / sms / other |
| `outcome` | String | ✓(强枚举驱动 Plan 状态机)| 4 起步 + 8 产品收集 = 12 类(详见 10.10)|
| `notes` | String? @db.Text | nullable | 通话纪要 |
| `invalidReason` | String? | nullable | ⭐ 产品收集 — outcome=marked_invalid 时的无效原因 |
| `abandonReasons` | String[] | ✓(空数组)| 放弃原因多选 |
| `abandonOther` | String? | nullable | 放弃原因 - "其他" 自由文本 |
| `scheduledNextAt` | Timestamptz? | nullable | outcome=scheduled_next 时下次回访时间 |
| `createdAt` | Timestamptz | ✓ | 提交时间 ≈ 通话结束时间 |

### 10.10 outcome 强枚举驱动 Plan 状态机 — 4 起步 + 8 产品收集 = 12

形式 `英文_key`(中文)→ Plan.status 变化:

| outcome | 中文含义 | Plan.status 变化 |
|---|---|---|
| `abandoned` | 已联系,客服放弃 | → `abandoned` |
| `no_answer` | 未接通,未回复 | 维持 `active` 等下次 |
| `scheduled_next` | 已联系,约定下次回访 | 维持 `assigned` |
| `success_appointed` | 已联系,成功转化为新预约 | → `completed` |
| `rescheduled` ⭐ 产品收集 | 已联系,改约 | 维持 `assigned` |
| `considering` ⭐ 产品收集 | 已联系,考虑中(近期再跟进)| 维持 `assigned` |
| `declined_recent` ⭐ 产品收集 | 已联系,近期不考虑 | 维持 `active` 等冷静期 |
| `needs_doctor` ⭐ 产品收集 | 已联系,需要找医生 | 维持 `assigned` |
| `external_treatment` ⭐ 产品收集 | 已联系,已在外院治疗 | → `completed` + reason close |
| `refused` ⭐ 产品收集 | 已联系,明确拒绝 | → `abandoned` |
| `pending_info` ⭐ 产品收集 | 已联系,需进一步确认 | 维持 `assigned` |
| `sms_sent` ⭐ 产品收集 | 电话未接,已发短信跟进 | 维持 `active` |
| `proactive_sms` ⭐ 产品收集 | 主动短信沟通(未电话)| 维持 `active` |
| `marked_invalid` ⭐ 产品收集 | 标记为无效 | → `abandoned`(需 invalidReason)|

### 10.11 评审决议(plan_executions v1 → v2)

| 决议 | 原 | 新 |
|---|---|---|
| `clinicId` | 三级隔离 | **`executorClinicId`** 立柱业务字段 |
| `startedAt` / `endedAt` | 客服手填通话时长 | **删** — 客服手填不准,设备通话系统才有,不在 PAC 范围 |
| `newAppointmentRef` / `newAppointmentRefSource` | 关联宿主侧新预约 ID | **删** — 增加宿主回传契约;审计走应用层 join transaction `action='appointment_created'` |

### 10.12 `plan_generation_logs` 表

每次 `PlanEngineService.recomputeForPatient()` 调用一条记录,跟 `sync_logs` / `persona_recompute_logs` 平行。

| 字段 | 类型 | 说明 |
|---|---|---|
| `id` / `platformId` / `tenantId` / `patientId` | 标识 + 隔离 | |
| `triggeredBy` | String | v1 起步 `scheduled:<cronId>` / `manual:<userId>`,W5+ 加 event-driven `txn:<id>` / `persona:<id>` |
| `inputPersonaId` | UUID? | 输入 Persona 版本(可空,纯规则路径)|
| `status` | String | running / success / partial / failed |
| `scenarioRunCount` | Int | 跑了几个 scenario |
| `plansCreated` | Int | 本次写出多少 plan |
| `assetsCreated` | Int | **本次产出 plan 子资产数**(PlanScript + PlanSummary 合计) |
| `agentCount` / `fallbackCount` | Int | harness 健康度 |
| `errorMessage` | String? @db.Text | |
| `startedAt` / `endedAt` | Timestamptz | |

`scriptsCreated` + `summariesCreated` 合并为 `assetsCreated`(PlanScript 单行后两字段无单独区分价值)。

---

## 十一、横切支撑两表

### 11.1 `agent_invocations` 表(AI 调用账本)

**共享基础设施** — 每个 agent 调用都强制落一条,供合规审计 + 反查"这个输出哪来的"。**所有 agent 调用必走 AI Gateway → Dify**。

| 字段 | 类型 | 说明 |
|---|---|---|
| `id` / `platformId` / `tenantId` | 标识 + 隔离 | |
| `kind` | String | 小写 enum:`script` / `summary` / `persona_feature` / `ranking` / `plan_analysis` |
| `agentVersion` | String? | prompt 模板版本 / Dify workflow 版本(audit trail) |
| `modelName` | String? | LLM 模型名(gpt-4o / claude-3.5-sonnet / ...),从网关 metadata 提取 |
| `gatewayProvider` | String | 网关后端实际是谁(dify / ai-gateway-passthrough) |
| `workflowId` | String? | 网关侧 workflow 标识 |
| `inputSnapshot` | JSONB | **瘦身版** — 只存"agent 独占内容":factIds 引用 + personaVersion 引用 + emrSnippets + imageMetadata + scenarioContext + promptVariables;**不重复 fact / persona 主体数据** |
| `promptTemplate` | String? @db.Text | 冗余兜底,从 agentVersion 反查更可靠 |
| `output` | JSONB? | 结构化输出 |
| `outputText` | String? @db.Text | 文本输出(流式调用增量更新) |
| `linkedPatientId` / `linkedPersonaId` / `linkedPlanId` | UUID? | 松引用(orchestration 完成后回填) |
| `promptTokens` / `completionTokens` / `totalTokens` / `costCents` | Int | 用量 + 估算成本 |
| `status` | String | running / succeeded / failed |
| `errorMessage` | String? @db.Text | |
| `startedAt` / `endedAt` / `updatedAt` | Timestamptz | 流式更新用 |

**inputSnapshot 瘦身原则**:fact 数据已经在 patient_facts 版本流持久化,inputSnapshot 只引用 `factIdsAtCall` + `personaVersionAtCall`;**独占内容**(emr_snippets / image_metadata / scenarioContext / promptVariables)才完整存。

**保留期治理**:`inputSnapshot` 可能含 EMR / 影像 metadata 等敏感数据 + 大体积内容,运维侧需要保留期清理策略(成功调用 N 天后清 inputSnapshot 仅保留元数据;失败的保留更久供 debug)。Schema 不强制,应用层执行。

**索引**:`(platformId, tenantId)` / `(kind)` / `(linkedPatientId)` / `(linkedPersonaId)` / `(linkedPlanId)` / `(startedAt)` — 时间范围统计

### 11.2 `sync_logs` 表(同步运行账本)

同步引擎每次 pull / 接收 push 都落一条记录。

| 字段 | 类型 | 说明 |
|---|---|---|
| `id` / `platformId` / `tenantId` | 标识 + 隔离 | |
| `patientId` | UUID? | 单患者 pull 时填,批量 run 留 null |
| `direction` | String | `pull` / `push`(替代老 `adapterKind`)|
| `resource` | String | patients / appointments / clinical_encounters / ...(对齐 subject_type 维度 + patients) |
| `triggeredBy` | String? | scheduled:<cronId> / manual:<userId> / push:<adapter> / **reconcile:<YYYY-MM-DD>**(第 4 层防线) |
| `status` | String | running / success / partial / failed |
| `cursorBefore` / `cursorAfter` | String? | 游标(push 路径不适用)|
| `fetched` | Int | 入站记录数 |
| `transactionsWritten` | Int | 写入 patient_transactions 的条数(去重后)|
| `factsEmitted` | Int | parser 衍生写入 patient_facts 的版本数 |
| `duplicates` | Int | 幂等去重命中数;**校验恒等式 fetched = transactionsWritten + duplicates + failed** |
| `failed` | Int | 失败条数 |
| `errorMessage` | String? @db.Text | |
| `startedAt` / `endedAt` | Timestamptz | |

**关键决议**:
- **故意只到 platform + tenant 不立 clinic_id** — 一次批量 pull 把整个 tenant 所有 clinic 的数据一并拉来,run 级别没有 clinic 概念。落到 `patient_transactions.clinic_id` 才是 per-record 业务字段。
- **单患者 pull 路径绝不写 cursor_after** — 避免污染批量水位,靠 DB 唯一约束兜底重复
- **`reconcile:<date>` triggeredBy** — 第 4 层防线(对账)复用 sync_logs,只是 triggered_by 标记

**索引**:`(platformId, tenantId, resource)` / `(startedAt)` / `(status)` / `(patientId, startedAt)` 单患者反查

---

## 十二、4 项 MIGRATION raw SQL

Prisma schema 表达不了的 4 个约束,在 [`prisma/migrations/<initial>/migration.sql`](../apps/pac-service/prisma/migrations/) 末尾用 raw SQL 补齐:

```sql
-- 1. patient_transactions 幂等键:partial UNIQUE on source_event_id WHERE NOT NULL
CREATE UNIQUE INDEX "patient_transactions_idempotency"
  ON "patient_transactions" ("platform_id", "tenant_id", "source_event_id")
  WHERE "source_event_id" IS NOT NULL;

-- 2. patient_facts 单一活跃版本:partial UNIQUE on subject_id WHERE status='active'
CREATE UNIQUE INDEX "patient_facts_single_active_version"
  ON "patient_facts" ("platform_id", "tenant_id", "subject_id")
  WHERE "status" = 'active';

-- 3. patient_facts 证据链反查:GIN on transaction_ids 数组
CREATE INDEX "patient_facts_transaction_ids_gin"
  ON "patient_facts" USING GIN ("transaction_ids");

-- 4. followup_plans 单一活跃计划:partial UNIQUE per (patient, scenario)
CREATE UNIQUE INDEX "followup_plans_one_open_per_scenario"
  ON "followup_plans" ("platform_id", "tenant_id", "patient_id", "scenario")
  WHERE "status" IN ('active', 'assigned');
```

详见 [`apps/pac-service/prisma/migrations/20260512091027_initial_pac_schema/migration.sql`](../apps/pac-service/prisma/migrations/20260512091027_initial_pac_schema/migration.sql)。

---

## 十三、决议清单(决策记录)

> 完整 60+ 决议项 closure 检查(原 W1 响应单已并入此处),给出**关键 8 大决议**及其落地状态。

### D1:第一层重构为 Transaction + Fact 双表 ✅ 已落地

- 消灭 5 张事实镜像表(`diagnoses` / `treatments` / `appointments` / `charges` / `touchpoints`)+ `patient_fact_events`
- 新建 `patient_transactions`(操作账本,append-only)+ `patient_facts`(事实单元,版本流)
- 命名:用 `patient_*` 前缀(不用提议的 `customer_*`),跟现有 PAC 风格统一

### D2:三类 type 严格区分 ✅ 已落地

| type 字段 | 摄入路径 | 含义 | 起步枚举数 |
|---|---|---|---|
| `action`(replace `transaction_type`)| push 主导 | 动作事实 | **14 个** |
| `subjectType` | pull 主导 | 被操作主体类型 | **10 个** |
| `factType`(`patient_facts.type`)| 下游统一 | 患者事实单元 | **actual 12 + planned 4 = 16 个** |

### D3:Fact 分 Actual / Planned ✅ 已落地

`patient_facts.kind` 字段区分(同表),不拆两张表。

### D4:版本流写入模式 ✅ 已落地

- 新版本 = 新 UUID(技术主键),`subject_id` 跨版本稳定
- partial UNIQUE `(subject_id) WHERE status='active'` 强制单活跃
- evidence 引用 `transactionIds` 数组

### D5:Plan 隔离 + target_clinic_id 字段 ✅ 已落地

- 隔离改为二级(platform + tenant)
- `target_clinic_id` 业务字段(nullable,创建锁定,null = 集团统一客服池)
- `plan_executions.executor_clinic_id` 立柱业务字段

### D6:数据流单向(执行反馈不回写 fact) ✅ 已落地

- Plan 回写不算事实(主语是 PAC,不是患者发生过的事)
- PlanExecution 回写**不进** patient_facts / patient_transactions,只反馈 Plan 状态机
- AI 解析结果(影像 AI / Persona description / 等)归 `agent_invocations.output`,**不回写 fact**

### D7:Push 密钥支持平滑轮换 ✅ 已落地

- `Platform.pushSecretHashes` 改为 `String[]` 数组(从单值 `pushSecretHash` 升级)
- 索引 0 是 current,后置为 grace period 内仍接受的旧 key
- HMAC 验签遍历数组,任一命中即通过

### D8:Cascade Delete 改 Restrict / SetNull ✅ 已落地

- `PatientTransaction.patient`:`Cascade` → `SetNull`(审计账本不随物删消失,patientId 已 nullable)
- `PatientFact.patient` / `Persona.patient` / `FollowupPlan.patient`:`Cascade` → `Restrict`(强制应用层走软删 / 匿名化)
- PAC **不支持患者物理删除**,PIPL 删除权走"匿名化"(name=null / phone=null / externalId 保留)

### 字段级删除汇总

| 删的字段 | 表 | 原因 |
|---|---|---|
| `beforeSnapshot` / `afterSnapshot` | patient_transactions | fact 版本流已是天然 before/after |
| `sourceSystem` | patient_transactions | platformId UUID FK 已标识 |
| `operation` | patient_transactions | `action` 已含动作 |
| `sourceActorId` / `sourceActorRole` | patient_transactions | rawPayload 自带审计 |
| `supersededBy` | patient_facts | 留 `supersededAt` 跟 Persona / Plan 统一 |
| `category` | persona_features | 跟 key 一对一映射,应用层常量更合适 |
| `clinicId` | personas / persona_features / followup_plans / plan_scripts / plan_summaries | 二级隔离不需要 |
| `goal` | followup_plans | 跟 inclusionReason 重叠 |
| `assignmentMode` | followup_plans | 业务流程不消费 |
| `assigneeRole` | followup_plans | role 是 PAC 框架常量不是 host 主数据 |
| `startedAt` / `endedAt` | plan_executions | 客服手填不准,设备才有 |
| `newAppointmentRef` / `newAppointmentRefSource` | plan_executions | 需要宿主额外回传契约 |
| `Platform.platformId` slug | platforms | 子表 FK 改 UUID,用 name 替代 |
| `Platform.pushSecretHash` 单值 | platforms | 改 `pushSecretHashes` 数组 |
| `Platform.rulesConfig` | platforms | scenario 改为应用层常量 |

### 字段级重命名汇总

| 老 | 新 | 表 |
|---|---|---|
| `transactionType` / `transaction_type` | **`action`** | patient_transactions |
| `transactionTime` / `transaction_time` | **`occurredAt`** / `occurred_at` | patient_transactions(跟 patient_facts 对齐) |
| `factKind` / `fact_kind` | **`kind`** | patient_facts |
| `factType` / `fact_type` | **`type`** | patient_facts |
| `factStatus` / `fact_status` | **`status`** | patient_facts |
| `factTitle` / `fact_title` | **`title`** | patient_facts |
| `factSummary` / `fact_summary` | **`summary`** | patient_facts |
| `factContentJson` / `fact_content_json` | **`content`** | patient_facts |
| `evidenceTransactionIds` | **`transactionIds`** | patient_facts |
| `featureKey` / `feature_key` | **`key`** | persona_features |
| `numericScore` / `numeric_score` | **`score`** | persona_features |
| `factWatermark` | **`eventWatermark`** | personas / persona_recompute_logs(对齐 transaction.eventSeq 语义) |
| `clinicId` | **`executorClinicId`** | plan_executions |
| `agentKind` / `agent_kind` | **`kind`** | agent_invocations |
| `adapterKind` | **`direction`** | sync_logs |
| `ingestId` | **`syncLogId`** | patient_transactions(FK 名跟 SyncLog model 对齐) |

---

## 十四、演进路径

### 14.1 已完成 ✅(W1)

- 13 个 model + 4 项 MIGRATION 全部落地
- Initial migration 应用到 PostgreSQL(`pac` 数据库,13 表 + 索引齐全)
- packages/types 对齐 schema(enums + zod schemas 全部重写)
- PAC backend 编译通过 + 启动 health 端点正常
- 8 个 service stub 化(留 W2-W4 重写)

### 14.2 W2-W4 实施

- **W2**:Mock 数据集 + adapter pipeline(sync 模块真实实现 + parser 衍生 fact)
- **W3**:Persona engine 重建(eventWatermark 触发 + 4 起步 features)+ 画像页面 v1
- **W4 ⭐**:PlanEngine + scenario 应用层常量 + assets 异步生成 → 管委会评估节点 1

### 14.3 W5-W6 真实接入

- 第一个真实 host 接入(可能是 mock 升级为真实)
- adapter 实现(api-key-pull / http-login-pull 真实版)
- 客服工作台 v3

### 14.4 W6+ 可扩展方向

| 候选 | 触发条件 |
|---|---|
| 漏诊召回 scenario(选项 B-2)| 接朝厚影像 AI 后 |
| 影像 AI 集成 — `image_analysis_result` fact type | 同上,且业务方拍板做漏诊 |
| 第二个 scenario(沉睡客户激活 / 复查提醒)| 试点反馈后 |
| Persona engagement feature | host 触达数据接入后 |
| PAC outbound webhook 字段 + 实现 | 真有秒级 push 需求(host dashboard / DW 同步)|
| `failed_transactions` quarantine 专表 | 接入宿主多 / 数据质量下降需要专门 quarantine 时 |
| `data_quality_metrics` 漂移监控表 | 应用层 metric 收敛进 schema |
| Persona 多 scope 评价(诊所视角)| 集团出现"加盟诊所自看自评"需求 |
| `patient_clinic_memberships` 物化视图 | 跨诊所归属高频查询出现性能瓶颈(数百诊所级) |
| 用户 / 角色专表 | PAC 真要独立 IAM(目前无规划) |

### 14.5 不会做的演进(决议封存)

| 不做 | 理由 |
|---|---|
| Persona 改名 Assessment | PDF §6.4 钦定术语,改名破坏 PDF / 代码 / 文档对齐 |
| `patient_identity_refs` 拆表 | v1 接入契约假设 external_id 唯一 |
| `patient_profile` 拆表 | v1 patients 字段可控 ~10 个 |
| 把 `description` 拆字段 | 架构硬约束(原则 2 + PDF §5.2) |
| `evidence` 字段立柱 | JSON 形态可演进,无高频 SQL 筛选需求 |
| `confidence` 字段(AI 置信度)| 不属宿主事实 |
| `primary_clinic_id` 性能冗余 | clinicId nullable 后不需要 |

---

## 附录:历史文档归档

> W1 评审过程稿 `db-review.md` / `db-suggest-after-review.md` / `db-review-confirm.md` 已**删除**
> (3 份共 ~3.5k 行 ping-pong 过程记录,决议已全部沉淀进本文档 §13 + `schema.prisma`,无独立价值)。

**真理源优先级**(冲突时以下排序):
1. **`apps/pac-service/prisma/schema.prisma`** — 唯一权威源
2. 本文档 [db-design-v2.md](db-design-v2.md) — 设计意图 + 决策记录
3. [host-integration.md](host-integration.md) — 对外契约
4. [architecture-v2.md](architecture-v2.md) / [design-v2.md](design-v2.md) — 上层设计

