# PAC 数据库结构评审

> **状态**:草案 · 待评审
> **目的**:在正式建库前对齐字段口径。

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

PAC(Patient Analyze Center)是疗效保障项目的底层患者分析框架,业务上分三层(对应 PDF §6.4):

```
第一层 患者事实层      第二层 Persona 画像层      第三层 Follow-up Plan 层
─────────────────     ─────────────────────     ───────────────────────
宿主 / DW 给的客观事实  →  PAC 抽象出的高阶特征   →  对外承诺的召回计划
原样保留,作证据来源       特征 + 描述 + 证据 + 版本    优先级 / 时机 / 话术 / Summary
```

数据流向单向:**事实 → 画像 → 计划**;反向回写(执行结果)走独立通道,不污染事实。

## 表结构与三层的对应

| 业务层                      | 表                                                                                                                          | 章节        |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------- | --------- |
| **第一层 — 患者事实**           | `patients` / `patient_fact_events` / `diagnoses` / `treatments` / `appointments` / `charges` / `touchpoints` / `sync_logs` | §3 - §10  |
| **第二层 — Persona 画像**     | `personas` / `persona_features` / `persona_recompute_logs`                                                                 | §11 - §13 |
| **第三层 — Follow-up Plan** | `followup_plans` / `plan_scripts` / `plan_summaries` / `plan_executions` / `plan_generation_logs`                          | §14 - §18 |
|                          |                                                                                                                            |           |

**支撑性基础设施**(三层之外,横向贯穿):

| 类别                 | 表                   | 章节  |
| ------------------ | ------------------- | --- |
| 平台租户               | `platforms`         | §1  |
| 用户 / 角色 / 权限(RBAC) | (不建本地用户表,见说明)       | §2  |
| AI agent 调用账本(共享)  | `agent_invocations` | §19 |

## PAC 核心实现点 → 数据库落地映射

PDF §5(框架核心约束)+ §6.6(数据资产)+ §8(验收口径)给出的 8 条硬约束,逐条对应到本评审的表与字段:

| #   | PAC 核心实现点(PDF)                  | 一句话约束                                                    | 数据库落地                                                                               |
| --- | ------------------------------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| 1   | **事实时间轴**(§6.6 / §4.1)          | 接口 1 进来的患者真实变化原样落档,作唯一真理源                                | `patient_fact_events`(append-only)+ 5 张镜像表(投影)                                      |
| 2   | **实时重算**(§5.1)                  | Plan / Persona 必须基于最新事实重新分析,禁止用静态名单或历史标签                 | `persona_recompute_logs` / `plan_generation_logs` 编排账本 + fact event hook            |
| 3   | **Persona 不拆字段**(§5.2 / 验收 #10) | 特征 + 自然语言描述 + 证据 + 版本;扩展走"新增特征键",不解构成 `*_score` 列        | `persona_features.description` 单列自然语言 + `feature_key` 开放枚举                          |
| 4   | **推断不污染事实**(§5.3)              | Plan 执行回写(客服打了电话)只更新 Plan 自己生命周期,**绝不进事实时间轴** —— 否则 PAC 自身动作会被当成"患者事实"参与下一轮分析,证据链就乱了 | `plan_executions` 独立表,与 `patient_fact_events` 物理分离                                  |
| 5   | **证据链**(§5.5 / 验收 #9)           | 每条对外的 Persona / Plan / 话术都能反查依据(事实 / 规则 / Agent / 版本)    | 推断表的 `evidence` JSONB:`{factEventIds, ruleIds, agentInvocationIds, personaVersion}` |
| 6   | **版本化**(§6.6 / 验收 #9)           | 每次重算 = 新版本,旧版保留可追溯,不就地覆盖                                 | `personas` / `followup_plans` 的 `version` + `superseded_at`(append + supersede)     |
| 7   | **Agent 受控**(§5.4)              | 不直接写宿主、不直接建预约;输出必落地 + 必带输入事实 / 规则 / Prompt / 模型版本;可降级到规则 | `agent_invocations`(全留痕)+ harness 的 `fallback_count` 健康度指标                          |
| 8   | **多租户隔离**(架构方案 §9)              | tenant + clinic 双层强制过滤(PDF 隐含,架构显式化)                     | `platform + tenant` 全表强制基线;`clinic_id` 仅在三类语义下出现:事实表(发生地)、`followup_plans.target_clinic_id`(业务归属)、`plan_executions.executor_clinic_id`(执行地);其他表二级隔离 |


---

## 总体设计原则(本文档全部决策的前提)

1. **按表语义隔离,不是统一三级**。`platform_id + tenant_id` 是所有业务表的强制基线,`clinic_id` 仅在三种语义下出现 —— 滥用 clinic_id 会污染身份语义,见各表说明。
   - **三级隔离**(`platform + tenant + clinic`):事实表(事件物理发生地)、执行表(执行发生地)
   - **二级隔离**(`platform + tenant`):患者实体(跨诊所共享身份)、推断表(personas / followup_plans / 子表 / 患者级任务日志)、跨任务日志(sync_logs)、共享调用日志(agent_invocations)
   - **业务隔离查询**("A 诊所的召回池"):通过 Plan 上的业务字段 `target_clinic_id` 过滤(§14 / §21),"该患者去过哪些诊所"通过 `patient_fact_events.clinic_id` 反查
   - 索引基线:每张业务表 `(platform_id, tenant_id [, clinic_id])` 联合索引覆盖隔离过滤,查询层强制追加;业务热路径(召回池、时间轴翻页)再加二级索引

2. **事实 vs 推断分层**(本评审最重要的纵向口径,方案 §5.1 / §5.2 / §5.3):
   - **事实** = 宿主 / DW 给的结构化记录 → 落 **镜像表 + `patient_fact_events`**(原样保留作证据)
   - **推断** = PAC 基于事实 + 规则 / Agent 算出来的结论 → 落 **`Persona.features` / `FollowupPlan`**
   - 判定看**数据来源**而非字段名。同概念两边都有(医嘱"下次复查"vs PAC"推荐回访"，这字段是事实还是推断)时,业务使用**事实优先,推断兜底**
   - **镜像表绝不存推断结论**(`recommended_*` / `priority_*` / `*_score`);**Persona 不拆"价值描述"为字段**(方案验收口径 #10)
   - **推断不能反向污染事实**:Plan 执行回写(客服打了电话、改了状态)只更新 Plan 自己的生命周期,绝不进 `patient_fact_events` —— 否则 PAC 自己的执行动作会被当成"患者事实"参与下一轮 Persona / Plan 重算,证据链就乱了(对应 PDF §5.3 接口 1 vs 接口 2)

3. **写入模式按数据所有权三分**(不混用):
   - **append-only** — `patient_fact_events`(真相源头,改了违反审计)，解释过程
   - **upsert 当前态** — 镜像表 5 张(真相在 fact_events,镜像是投影,变化轨迹由 `*_updated` / `*_cancelled` fact event 记录)
   - **版本流(append + supersede)** — Persona / FollowupPlan 及其子表(PAC 自己产出的判断,每版独立证据,PDF §5.5 硬要求);`version` 单调递增 + `superseded_at` 标当前态，解释结论
   - **子表无独立版本**:跟随主表 `parentId`,主表 superseded 时子表自然进入历史

4. **证据链与版本化**(方案 §5.5 / 验收口径 #9):Persona / FollowupPlan / PlanScript / PlanSummary 都有 `evidence` 字段 + 关联 `AgentInvocation`,每个对外输出都能反查"为什么这么定"。

5. **Harness 工程观**(跨表的 AI 治理原则):**主流程靠确定性代码,agent 只负责 LLM 本职擅长的事**。
   - **规则（规则即代码）路径**:数值(score / value)、决策(scenario 选择 / 过滤)、时间(`recommendedAt`)、状态机
   - **Agent 路径**:自然语言描述(`description` / `inclusionReason`)、跨文本归纳(话术 / Summary / 偏好推断)
   - **两路共存于 `evidence`**:`{ruleIds, agentInvocationIds, factEventIds}`;**agent 失败必须降级到规则**,绝不阻塞主流程
   - 三张 orchestration 日志(`SyncLog` / `PersonaRecomputeLog` / `PlanGenerationLog`)立柱 `rule_count` / `agent_count` / `fallback_count` —— `fallback_count` 升高即上游 LLM 异常的早期信号

6. **立柱 / JSON / notes 三分**(普适建表判据,不限镜像表):
   - **立柱**:需 SQL 筛选 / 排序 / 索引,或属合规边界(不打扰 / 已故 / 软删除等),必须独立列
   - **JSON 列**:易变 + 平台特异 + 形状不固定(adapter 配置、规则、URL 模板、画像证据);形状由应用层 zod 校验,DB 不强约束
   - **`notes` 自由文本**:仅展示用、无 SQL 检索需求的塞这里,**不为"看起来结构化"而立柱**
   - **字段即契约**:任何已立柱字段(API path、token 字段名、code 数值、行为语义)一旦暴露,无业务原因不破坏向后兼容
   - **JSON 装"程序要用的结构化数据"，`notes` 装"给人看的自由文本"。判别看消费方：代码会 parse 的进 JSON，只展示给人的进 notes。**

7. **金额一律 Int 分,单一真理源**:`amountCents` 等字段以最小货币单位整数表达(`currency` 列 NOT NULL 配套指定),DB / Service / API Pool 2 全链路 Int 分,中间层不换单位。
   - **入口转换**:Pull 在 adapter 里转(`Math.round(host.fee * 100)`);Push 契约要求 Int 分,非整数拒收;通用 adapter 读 `pullConfig.amountUnit` 提示
   - **出口转换**:前端 `formatMoney(cents, currency)` 统一格式化;**API 绝不返回格式化字符串**(防下游再 parse 埋雷)
   - **亚分精度 v1 不支持**:跨境 / 外汇场景出现时一次性 migration 升 `Decimal(18,4)`,不预留

8. **时间一律 UTC 存储,中间层不换时区**(与金额对称的纪律):
   - **存储类型**:时刻类字段用 `DateTime @db.Timestamptz(3)`(Postgres 内部 UTC),**不用裸 `timestamp`**(Prisma 默认值,会丢时区);日期类(`birthDate` 等无时刻概念)用 `@db.Date`
   - **入口转换**:Pull 在 adapter 里按宿主时区(`pullConfig.timezone`,如 `Asia/Shanghai`)解析 → 转 UTC 入库;Push 契约要求 ISO 8601 带时区(`...+08:00` 或 `...Z`),无时区直接拒收
   - **出口格式**:API 一律返 ISO 8601 with offset(`2026-05-01T02:00:00Z`);**绝不返无时区字符串**(`"2026-05-01 10:00:00"` 下游 parse 时补时区是埋雷)
   - **跨日聚合按业务时区**:"今天的回访任务" / "近 30 天" 是诊所所在时区的概念,SQL `AT TIME ZONE <tz>` 后再 `DATE_TRUNC`,不按 UTC 切日
   - **时区配置 v1 放 `platform.pullConfig.timezone`**(IANA 名,默认 `Asia/Shanghai`),与 `amountUnit` 同处,平台级。**真出现同一 platform 下跨时区诊所时**,再建 `clinic` 表把 `timezone` 立柱(届时一次性 migration)

---

## 一、`platforms` 平台租户注册表

### 1.1 当前字段

| 字段                          | 类型           | 必填         | 说明                                          |
| --------------------------- | ------------ | ---------- | ------------------------------------------- |
| `id`                        | UUID(PK)     | ✓          | 主键                                          |
| `platform_id`               | TEXT(UNIQUE) | ✓          | 业务可读标识(`friday`/`5i5y`/`demo`)              |
| `name`                      | TEXT         | ✓          | 平台中文名,后台展示                                  |
| `app_id`                    | TEXT(UNIQUE) | ✓          | 宿主调换票接口的 appId,格式 `pac_<platformId>_<8位随机>` |
| `app_secret_hash`           | TEXT         | ✓          | scrypt 哈希(`salt:hash`),原文从不落库               |
| `pull_config`               | JSONB        | nullable   | 当 PAC 拉这个平台时所需配置,见下表                        |
| `push_secret_hash`          | TEXT         | nullable   | 接受该平台 push 时验签的 HMAC 共享密钥哈希                 |
| `field_mapping`             | JSONB        | ✓(默认 `{}`) | 字段名映射(host 字段名 → PAC canonical 名)           |
| `rules_config`              | JSONB        | ✓(默认 `{}`) | 召回规则(漏诊阈值 / 回收超时 / 优先级权重)                   |
| `action_urls`               | JSONB        | ✓(默认 `{}`) | 动作跳转 URL 模板(VIEW_PATIENT 等)                 |
| `active`                    | BOOL         | ✓(默认 true) | 软停用开关,false 时换票直接 401                       |
| `created_at` / `updated_at` | timestamp    | ✓          | 审计                                          |

**索引**:`UNIQUE(platform_id)`、`UNIQUE(app_id)`(同时是唯一约束 + 等值查找的覆盖索引)

### 1.2 JSON 列内的形状(应用层用 zod 校验)

#### `pull_config` — 仅当 PAC 主动拉该平台时填

```jsonc
{
  "endpoints": {
    // 每个 endpoint 可选,宿主只配自己能提供的部分
    "patients":     "https://friday.example.com/api/patients",
    "diagnoses":    "https://friday.example.com/api/diagnoses",
    "treatments":   "https://friday.example.com/api/treatments",
    "appointments": "https://friday.example.com/api/appointments",
    "charges":      "https://friday.example.com/api/charges",       // 缴费/消费
    "touchpoints":  "https://friday.example.com/api/touchpoints"    // 宿主侧已有的触达记录
  },
  "credentials": {
    // 二选一:
    "loginUrl":  "https://friday.example.com/api/auth/login",  // 服务账号登录
    "username":  "recall-bot",
    "password":  "<encrypted>",
    // OR
    "apiKey":    "<bearer-token>"
  },
  // 通用 adapter 用的单位提示;自定义 adapter 在代码里直接处理,可忽略此字段
  // "fen" | "yuan",缺省 "fen"(即宿主已经按分整数提供)
  "amountUnit": "yuan",
  // 宿主返回时间的时区(IANA 名),adapter 解析无时区字符串时使用,转 UTC 入库
  // **必填**(无默认):接入时必须显式声明,sync engine 启动校验缺失即拒绝
  "timezone": "Asia/Shanghai",
  "intervalMinutes": 5
}
```

**为 null 时**:PAC 不会主动拉这个平台(它要么完全靠 push,要么不贡献数据)。

#### `field_mapping` — host 字段名 → PAC canonical 名

```jsonc
{
  "patient":     { "phone": "mobile", "externalId": "pid" },
  "diagnosis":   { "patientExternalId": "patient_pid" },
  "treatment":   { },
  "appointment": { },
  "charge":      { "amountCents": "fee" },         // 单位由 pullConfig.amountUnit 决定,这里只映射字段名
  "touchpoint":  { "occurredAt": "called_at", "channel": "way" }
}
```

每个 resource 都是可选;留空表示 host 字段名跟 PAC canonical 一致。
当前支持的 6 个 resource:`patient` / `diagnosis` / `treatment` / `appointment` / `charge` / `touchpoint`(对应 6 张镜像表)。

#### `rules_config`（规则不在本次评审内）

```jsonc
{
  "missedThresholdDays": 180,        // 多久未复诊算漏诊
  "excludeCompletedWithinDays": 30,  // 近 N 天完成治疗的不召回
  "priorityWeights": {               // 不同治疗类型的优先级权重
    "implant": 100,
    "rootCanal": 80,
    "filling": 40
  },
  "recycleTimeoutHours": 72,         // 线索超时回收
  "maxContactAttempts": 3            // 最大联系次数(熔断)
}
```

#### `action_urls`

```jsonc
{
  "VIEW_PATIENT":       "https://friday.example.com/patient/{patientId}",
  "CREATE_APPOINTMENT": "https://friday.example.com/appointment/new?patientId={patientId}",
  "VIEW_XRAY":          null  // null = 不支持该动作,前端按钮不渲染
}
```

### 1.3 不加的字段(评审时可讨论)

| 候选字段                              | 不加的理由                                                       |
| --------------------------------- | ----------------------------------------------------------- |
| `region` / `time_zone`            | 暂时所有宿主都国内同时区,有需要再扩                                          |
| `data_retention_days`             | 数据留存策略目前全平台统一,等真有差异再立柱                                      |
| `tier` / `quota`                  | AI 调用配额按 platform 维度限是合理的,但需要专门的 `platform_quotas` 表(每月重置等) |
| `contact_email` / `contact_phone` | 平台联系人,放后台管理界面的 metadata 表更合适,主表保持精简                         |

---

## 二、用户 / 角色 / 权限模型（RBAC）

### 2.1 PAC **不建用户表**

**理由**:
- 用户体系在宿主侧(Friday / 5i5y 自己管),PAC 不重复一份
- 每次换票宿主把 `userId / tenantId / role / clinicIds` 传过来,签进 JWT
- PAC 数据库里只有"哪个 userId 在哪个 plan 上做过什么"(`plan_executions.operator_user_id`),**不存用户档案**
- 后台管理员 / 运维:同样走宿主登录(以 admin role 换票)。PAC 不维护独立的"PAC 自有员工"
- PAC 自身没有"登录界面",必须通过宿主带 code 进来（如走PAC页面）

### 2.2 三个固定角色(代码常量,不入库)

定义在 [`packages/types/src/enums/index.ts`](../packages/types/src/enums/index.ts) 的 `UserRole`:

| Role | 业务定位 | 典型默认权限 |
|---|---|---|
| `staff` | 一线客服(执行回访) | 看自己的线索 / 执行通话 / 拿话术 / 看患者档案 |
| `leader` | 主管 / 班长 | staff 全部 + 看全员线索 / 分配 / 回收 / 看统计 |
| `admin` | 后台管理员 | leader 全部 + 平台注册 / 凭据轮换 |

**为什么固定 3 个**:
- 集团已经定下基本结构,不需要让每个宿主再自定义
- 自定义权限会带来"这个组叫什么"的命名混乱,加大对接成本
- PAC 是组件,不是 IAM

### 2.3 权限模型

定义在同一文件的 `Permission` 常量(12 个):

```
plan:view_own / plan:view_all / plan:assign / plan:recycle / plan:execute
patient:view / persona:view
agent:invoke / fact:ingest
tenant:config:view / tenant:config:update / stats:view / platform:manage
```

**Role → Permission 映射**(`ROLE_PERMISSIONS` 常量):
- `staff`:`plan:view_own` / `plan:execute` / `patient:view` / `persona:view` / `agent:invoke`
- `leader`:staff 全部 + `plan:view_all` / `plan:assign` / `plan:recycle` / `stats:view`
- `admin`:所有(包括 `platform:manage`)

**换票流程**:
1. 宿主调 `POST /pac/v1/auth/token`,body 里 `user.role` 带 `staff/leader/admin`
2. PAC 服务端用 `ROLE_PERMISSIONS[role]` 展开成完整 permission 列表
3. 把 permission 列表签进 JWT payload(client 不能改)
4. 后续所有 API 调用通过 `@RequirePermission('plan:assign')` 装饰器 + `PermissionsGuard` 校验
5. 前端读 token payload 的 `permissions[]`,通过 `<Can perm={...}>` 组件控制 UI 显示

**双层守门**(标准做法):
- 前端按权限隐藏 UI,体验好(不让用户点了再 403)
- 后端在路由层硬验,安全可靠(前端被绕过也挡得住)

### 2.4 现阶段不做自定义

| 维度 | 当前 | v1.5 / v2 演进 |
|---|---|---|
| Role 自定义 | 不支持,固定 3 个 | 加 `roles` 表(platform_id + role_key + permissions[]),宿主管理后台可改 |
| Permission 自定义 | 不支持,代码常量 | 同上,permissions 改成可在 DB 里增删 |
| 用户级权限覆盖 | 不支持 | 加 `user_permissions` 表,允许"某用户额外加 stats:view" |
| 行级权限(ABAC) | 不支持 | 加 policy 表 + 表达式 / Cedar / OPA 接入 |

**v1 决定**:不做以上任何一项。**理由**:加了之后每个宿主接入都要先想"我们要不要自定义",对接成本反升。等真有具体需求再加。

---

## 三、`patients` 患者档案表

### 3.1 当前字段

| 字段                                        | 类型        | 必填             | 说明                                                        |
| ----------------------------------------- | --------- | -------------- | --------------------------------------------------------- |
| `id`                                      | UUID(PK)  | ✓              | 主键                                                        |
| `platform_id` / `tenant_id`               | TEXT      | ✓              | **二级隔离**(患者跨诊所共享身份,见 §3.x)                                 |
| `external_id`                             | TEXT      | ✓              | 宿主系统里的患者 id,回链用                                           |
| `name`                                    | TEXT      | ✓              | 姓名                                                        |
| `phone`                                   | TEXT      | ✓              | 电话                                                        |
| `gender`                                  | TEXT      | nullable       | 自由文本(各宿主取值不一,不强枚举)                                        |
| `birth_date`                              | DATE      | nullable       | 出生日期(算年龄)                                                 |
| `preferences`                             | JSONB     | nullable       | 沟通偏好(语言/时段/渠道),自由 JSON                                    |
| `do_not_contact`                          | BOOL      | ✓(默认 false)    | **不打扰名单**,合规硬要求,一旦 true 永久排除召回池                           |
| `do_not_contact_reason`                   | TEXT      | nullable       | 不打扰原因(complaint / opt_out / treated_elsewhere / ...)      |
| `do_not_contact_at`                       | timestamp | nullable       | 何时打的标                                                     |
| `deceased`                                | BOOL      | ✓(默认 false)    | 已故标记,医疗 SaaS 必须支持                                         |
| `deceased_at`                             | timestamp | nullable       |                                                           |
| `status`                                  | TEXT      | ✓(默认 `active`) | `active` / `archived`(宿主软删除) / `merged`(合并到 mergedIntoId) |
| `merged_into_id`                          | UUID      | nullable       | 当 status=merged 时指向合并目标                                   |
| `created_at` / `updated_at`               | timestamp | ✓              | 审计                                                        |

**索引**:
- `UNIQUE(platform_id, tenant_id, external_id)` — 跨同步 upsert 去重(tenant 级唯一,跨诊所同一物理人共享 external_id)
- `(platform_id, tenant_id)` — 二级隔离基础过滤
- `(phone)` — 按电话查重 / 客服快捷查找
- `(platform_id, tenant_id, status, do_not_contact, deceased)` — **召回池热查询路径**(按诊所过滤走 `followup_plans.target_clinic_id`,不在 patient 表过滤)

### 3.2 设计决策

**`gender` 为什么不强枚举**:
- 各宿主取值不一致(`M`/`F`、`男`/`女`、`male`/`female`、还有 `unknown` / `other`)
- 强枚举会在 ingest 阶段拒收宿主真实数据,不是好的契约
- 业务侧用到性别时再做归一化(generally only at agent prompt assembly time)

### 3.3 不加的字段(评审时可讨论)

| 候选字段 | 不加的理由 |
|---|---|
| **身份证号** | PIPL 高敏字段,host 自己管。PAC 用 `external_id` 回链就够。**任何实现都要 review 是否真有必要** |
| 详细地址 / 街道 | 召回业务用不上,泄漏代价大;诊所定位通过 fact 反查(`SELECT DISTINCT clinic_id FROM patient_fact_events WHERE patient_id=?`) |
| 紧急联系人 / 家属 | 业务深度太具体,host EMR 体系里更合适 |
| 婚姻 / 民族 / 收入 / 职业 | 召回算法不该看这些(歧视风险),合规上也不应留 |
| 既往史 / 过敏史 摘要 | 由 `MedicalSummaryAgent` 即时生成,落到 `PlanSummary` 表(版本化、有审计),不进 patient 主档 |
| `email` / `wechat_id` | 当前业务只走电话回访;真有多渠道触达再加(放 preferences 也可) |
| `vip_level` / `customer_value_score` | 这是 Persona 的特征(value description + numericScore),不是 patient 主档字段 |

### 3.4 患者跨诊所归属:不立关联表,通过 fact 反查

PAC 患者唯一性在 **tenant 级**(`UNIQUE(platform_id, tenant_id, external_id)`),同一物理人在集团内任何诊所就诊都共享同一 patient 主档。**患者 ↔ 诊所是 N:N 关系**,但 v1 **不立关联表**,通过 `patient_fact_events.clinic_id` 反查实现:

| 业务问题 | 反查 SQL |
|---|---|
| 该患者去过哪些诊所 | `SELECT DISTINCT clinic_id FROM patient_fact_events WHERE patient_id=?` |
| 该患者在 A 诊所最近就诊时间 | `SELECT MAX(occurred_at) FROM patient_fact_events WHERE patient_id=? AND clinic_id='C-A'` |
| 该患者首诊诊所 | `SELECT clinic_id FROM patient_fact_events WHERE patient_id=? ORDER BY occurred_at ASC LIMIT 1` |
| 该患者高频诊所 | `GROUP BY clinic_id ORDER BY COUNT(*) DESC LIMIT 1` |

**为什么不立关联表 / 不立 clinic_ids 数组**:
- 召回池"按诊所"过滤已由 `followup_plans.target_clinic_id` 业务字段满足(§14 / §21),不再需要 join membership
- 跨诊所归属是派生信息,真理源在 `patient_fact_events`,加表 = 维护冗余 + 同步逻辑(每次 ingest 要 upsert membership)
- 试点 5 家门诊规模 SQL 反查毫秒级,不是性能瓶颈
- 数组方案违反原则 3"推断不进事实表",并发 append 有 race + 丢失元数据

**Phase 6 之后升级路径**:真出现"该患者去过哪些诊所"高频查询性能问题,加 `patient_clinic_memberships` 物化视图表(零业务影响升级,字段:`patient_id` / `clinic_id` / `first_seen_at` / `last_visit_at` / `visit_count`)。

### 3.5 EMR 在 PAC 的边界(评审时务必对齐)

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

PAC **不持有 EMR**(原则 2),只存"召回算法需要的事实子集",通过 **6 张镜像表**表达:

| PAC 镜像表 | 存什么 | 不存什么 |
|---|---|---|
| `Patient`(本表) | 患者基础档案(姓名、电话、所属诊所、合规标记) | 病史汇总 / 主治意见 |
| `Diagnosis` | 患者被诊断了什么(代码 + 名称 + 时间) | 主诉 / 现病史 / 鉴别诊断 |
| `Treatment` | 患者接受了什么大类治疗(项目 + 状态 + 完成时间) | 器械 / 用药剂量 / 术后细节 |
| `Appointment` | 患者有没有预约 / 到诊(时间 + 状态) | 接诊医生备注 / 病程 |
| `Charge` | 缴费 / 退款金额(用于价值评估) | 发票内容 / 优惠明细 |
| `Touchpoint` | 已发生的触达记录(渠道 + 时间 + 时长) | 通话录音 / 客服自由文本 |

**举个具体对照(同一次复诊):**

| 内容 | 宿主 EMR | PAC |
|---|---|---|
| 主诉 | "右下后牙冷热痛 1 周" | (不存) |
| 诊断 | "36 牙不可复性牙髓炎" + 详细描述 + 鉴别 | `Diagnosis: { name:"不可复性牙髓炎", code:"K04.0", diagnosedAt:... }` |
| 治疗 | 详细操作步骤 + 用药 + 充填材料 | `Treatment: { name:"根管治疗", status:"completed", completedAt:... }` |
| 处方 | 布洛芬 0.4g tid × 3 天 | (不存) |
| 影像 | 4 张根尖片 | (不存) |
| 复诊建议 | "1 周后复诊评估" | (宿主推 Appointment 时落到 PAC) |

**EMR 详细内容怎么消费**(对应原则 2):
1. **客服打电话前看完整病历** → 前端按钮通过 `actionUrls.VIEW_PATIENT` 跳到宿主 EMR 页面
2. **Agent 需要病历文本生成话术 / Summary** → 临时调宿主 API 拉,内容落到 `agent_invocations.input_snapshot`(唯一受控通道,受保留期管控);**绝不写患者主档、绝不进 `patient_fact_events` 或镜像表**
3. **不为业务持久化 EMR 摘要建专表**(原方案中的 `MedicalRecord` 表不立):Agent 输出的结论(如病历摘要)落到 `plan_summaries`(版本化、有证据),原文留在 inputSnapshot 受合规保留期治理

---

## 四、`patient_fact_events` 患者事实时间轴

### 4.1 这张表是干什么的

PAC 的**事实账本** — append-only,只插不改不删。所有"宿主侧发生了什么变化"的变化都先落到这张表,然后投影到镜像表(Patient / Diagnosis / Treatment / Appointment / Charge / Touchpoint)供 SQL 查询。

四个职责:

| 职责 | 怎么用 |
|---|---|
| **真理来源**(single source of truth) | 任何"这个患者发生过什么"的问题,事件流是唯一答案。镜像表都是它的投影 |
| **审计追溯** | 谁在什么时候推/拉了什么数据,查事件表就行,不依赖应用日志 |
| **重算依据** | Persona / Plan 引擎重算时,**输入就是该患者的 fact 事件流** |
| **可灾难恢复**(replay) | 某张镜像表出错 / migration 翻车,**从事件流重建**就行(只要事件没丢) |

**为什么不直接写镜像就够,要再多一张事件表?**
- 镜像只能告诉你"现在状态是什么",不能告诉你"什么时候变成这样的"
- 召回算法常需要"距上次 X 多久"、"近 30 天有没有 Y" — 这是时间序列问题,不是当前态问题
- 合规(PIPL / 医疗审计)要求能回答"谁在 2026-04-15 改了这个患者的电话",镜像覆盖式更新会丢这个
- 镜像表里每一行只反映该具体记录的当前状态;那条记录"何时被改成这样"的历史,在 patient_fact_events 里。一个患者通常对应多条镜像行(多次诊断/多次治疗/多次预约/多笔缴费),这是基数关系,跟"镜像 vs 事件"不矛盾。

### 4.2 当前字段

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | UUID(PK) | ✓ | 主键 |
| `platform_id` / `tenant_id` / `clinic_id` | TEXT | ✓ | 三级隔离 |
| `patient_id` | UUID(FK → patients.id) | ✓ | 关联患者,级联删除 |
| `fact_type` | TEXT | ✓ | 事件类型,canonical 值见 `FactType` 枚举(允许宿主扩展) |
| `source` | TEXT | ✓ | 事件进 PAC 的方向:`pull` / `push` / `manual` |
| `external_ref` | TEXT | nullable | **该事件对应宿主侧具体记录的 id**(治疗 id / 诊断 id / 预约 id / 缴费 id 等);`patient_registered` 这种患者级事件填 patient 的 external_id |
| `payload` | JSONB | ✓ | 事件负载,无 schema,按 fact_type 在应用层用 zod 校验 |
| `occurred_at` | timestamp | ✓ | 事实在**宿主侧**实际发生的时间(由 adapter 从宿主返回的时间字段提取) |
| `ingested_at` | timestamp | ✓(默认 now) | PAC 接收到事件的时间(同步监控用) |

**索引**:
- `UNIQUE(platform_id, tenant_id, fact_type, external_ref)` — **幂等性约束**,相同 (factType, externalRef) 在同一租户内只能存一条;`external_ref` 为 NULL 时 PostgreSQL 视为不同(允许 manual 重复入)
- `(platform_id, tenant_id, clinic_id)` — 多租户基本过滤
- `(patient_id, occurred_at)` — 按患者拉时间轴(`GET /patients/:id/timeline` 走这条)
- `(fact_type)` — 按类型查
- `(ingested_at)` — 同步运维监控

### 4.3 写入时机(三条触发路径)

| 触发 | 入口 | 状态 |
|---|---|---|
| **Pull**(PAC 主动拉) | `SyncService.runPull()` → `PullStrategy.pullPage()` → `FactsService.ingest()` | ✅ 已通(mock-pull / api-key 已实现) |
| **Push**(宿主主动推) | `POST /pac/v1/facts` 控制器 → `FactsService.ingest()` | ✅ 控制器通,**HMAC 验签待加**(用 `pushSecretHash`) |
| **手工**(运维工具) | admin 接口直插一条 fact event | ⬜ v1 不做,需要时再加 |

### 4.4 设计决策

**为什么加 `(platform, tenant, factType, externalRef)` 唯一约束**:
- 幂等性:push 端可以放心重发,pull 边界时间重叠也不会产生重复事件
- 这是 append-only 时间轴的工业标配
- DB 层兜底,不依赖应用层去重逻辑

### 4.5 fact_type 当前 canonical 集合(应用层 enum)

按业务对象分组,允许扩展:

```
# 患者
patient_registered / patient_updated / patient_merged

# 诊断
diagnosis_added / diagnosis_updated

# 治疗
treatment_started / treatment_updated / treatment_paused / treatment_cancelled / treatment_completed

# 预约
appointment_scheduled / appointment_rescheduled / appointment_cancelled / appointment_visited / appointment_no_show

# 缴费
charge_recorded / charge_refunded

# 宿主侧已有触达(防 PAC 重复打)
touchpoint_recorded
```

### 4.6 不加的字段(评审时可讨论)

| 候选字段 | 不加的理由 |
|---|---|
| `processed` / `processed_at` | 事件消费状态属于"消费方"管(镜像投影 / Persona/Plan 重算队列),不该污染时间轴本身 |
| `created_by` / `operator_id` | 事件由系统/同步引擎写,不是用户操作;真要审计来源用 `source` |
| `is_deleted` / `deleted_at` | append-only 表绝不软删,合规上也不允许"假装事件没发生过" |
| `version` 字段(乐观锁) | 时间轴 read-only(insert-only),无并发更新场景 |
| `correlation_id` 关联同次同步产出的事件 | `sync_logs` 表已经记录每次运行的元信息,不必双轨 |
| `payload_version` payload schema 版本 | factType 加后缀(`treatment_completed_v2`)更朴素,不立柱 |

---

## 五、`diagnoses` 诊断镜像表

### 5.1 这张表的定位

诊断镜像表只承担两个语义:
1. **这个患者得过什么病**(Persona 算"治疗链状态" / "潜在客户价值"时引用)
2. **什么时候诊断的**(Plan 排序 / "最近一次诊断距今多久" 这类 feature 用)

**不承担**的语义(评审时容易追问,先排除):
- 该诊断"现在还要不要随访" → 不存 `status`,由 PatientFactEvent + Treatment + Appointment 联合推断
- "下次复查时间" → 不存 `recommended_review_at`,由 `rules_config.priorityWeights` × `diagnosed_at` 计算
- "牙位结构化数据" → 不存 `tooth_codes`,见下面 5.3

一条原则:**诊断的"形状"对召回算法贡献小,就诊节奏(到诊/治疗/缴费时间线)才是核心**。Diagnosis 保持精简,信号靠其他表+事件流给。

### 5.2 当前字段

| 字段                                        | 类型                     | 必填       | 说明                |
| ----------------------------------------- | ---------------------- | -------- | ----------------- |
| `id`                                      | UUID(PK)               | ✓        | 主键                |
| `platform_id` / `tenant_id` / `clinic_id` | TEXT                   | ✓        | 三级隔离              |
| `external_id`                             | TEXT                   | ✓        | 宿主系统里的诊断记录 id     |
| `patient_id`                              | UUID(FK → patients.id) | ✓        | 关联患者              |
| `doctor_id`                               | TEXT                   | nullable | 主诊医生 id(宿主侧 id)   |
| `doctor_name`                             | TEXT                   | nullable | 主诊医生姓名(展示用)       |
| `diagnosis_code`                          | TEXT                   | nullable | 诊断编码(ICD / 宿主自定义) |
| `diagnosis_name`                          | TEXT                   | ✓        | 诊断名称(自由文本)        |
| `diagnosed_at`                            | timestamp              | ✓        | 诊断时间              |
| `notes`                                   | TEXT                   | nullable | 备注                |
| `created_at` / `updated_at`               | timestamp              | ✓        | 审计                |

**约束**:`doctor_id` 和 `doctor_name` 至少要有一个非空(在 ingest 层 zod 校验,不靠 DB CHECK 约束)。

**索引**:
- `UNIQUE(platform_id, tenant_id, external_id)` — upsert 去重
- `(platform_id, tenant_id, clinic_id)` — 多租户基础过滤
- `(patient_id)` — 患者维度查询
- `(diagnosed_at)` — 时间范围查询(最近 N 天诊断)

### 5.3 评审中确认的几条"不加"

| 候选字段 | 不加的理由 |
|---|---|
| `status` / `resolved_at`(诊断生命周期) | 大多数口腔 HIS 没有"关闭诊断"的动作。是否还要随访,由"之后是否有 TREATMENT_COMPLETED" 等事件推断,不在镜像里冗余 |
| `tooth_codes`(FDI 牙位结构化) | 宿主普遍只在 `diagnosis_name` / `notes` 里写自由文本(如"36 牙根尖周炎"),强制结构化字段大概率 90% 租户为空,算法不可靠;真要按牙位匹配,做关键词,不立柱 |
| `recommended_review_at` / `recommended_review_interval_days` | 召回时机是 PAC 算出来的(规则 + 诊断时间),不是宿主能稳定提供的字段。算出来的东西放到 FollowupPlan 上,不冗余到诊断镜像 |
| `severity` / `is_chronic` / `is_primary` / `department` | 召回算法不消费这些维度;真要细分,Persona 的 feature 里加 description,不污染 fact 镜像 |
| 单独的 `Encounter` / `Visit` 表 | "就诊事实"已经由 `Appointment`(到诊)+ `Treatment`(治疗)+ `PatientFactEvent`(时间线)三者联合表达,小型 HIS 自身也没有强 encounter 概念,启发式拼装跨宿主可靠性差。等真有"按次就诊汇总"的报表需求再加 |

---

## 六、`treatments` 治疗镜像表

### 6.1 这张表的定位

治疗镜像表承担三个语义,都直接服务召回算法:
1. **这个患者做过/正在做什么治疗**(Persona 算"治疗链状态" / "客户价值"用)
2. **治疗目前的状态**(进行中/已完成/已取消 — 召回池排除"近期完成"的患者)
3. **医生医嘱里给的下次复查时间**(若宿主以结构化日期形式提供,直接用作召回时机的事实依据)

**不承担**的语义:
- "PAC 推断的回访时机" → 进 `FollowupPlan.recommendedAt`,跟事实字段平行存在,不混
- "治疗链是否已完成"(派生结论)→ 进 `Persona.features` 的 `treatment_chain_status`,不在镜像里
- "牙位结构化数据" → 同 Diagnosis 决议,不立柱

### 6.2 当前字段

| 字段                                        | 类型                     | 必填       | 说明                                             |
| ----------------------------------------- | ---------------------- | -------- | ---------------------------------------------- |
| `id`                                      | UUID(PK)               | ✓        | 主键                                             |
| `platform_id` / `tenant_id` / `clinic_id` | TEXT                   | ✓        | 三级隔离                                           |
| `external_id`                             | TEXT                   | ✓        | 宿主系统里的治疗记录 id                                  |
| `patient_id`                              | UUID(FK → patients.id) | ✓        | 关联患者                                           |
| `diagnosis_id`                            | UUID                   | nullable | 关联诊断(同库内 UUID,可空 — 多数宿主不给关联键)                  |
| `doctor_id`                               | TEXT                   | nullable | 主治医生 id(宿主侧)                                   |
| `doctor_name`                             | TEXT                   | nullable | 主治医生姓名(展示用)                                    |
| `treatment_code`                          | TEXT                   | nullable | 治疗编码(宿主自定义 / 行业编码)                             |
| `treatment_name`                          | TEXT                   | ✓        | 治疗项目名(根管治疗 / 种植 ...)                           |
| `started_at`                              | timestamp              | nullable | **事实**:治疗开始时间("进行中超 N 天" 类查询用)                 |
| `completed_at`                            | timestamp              | nullable | **事实**:完成时间(null = 未完成)                        |
| `next_review_at`                          | timestamp              | nullable | **事实**:医生医嘱里的下次复查时间(仅当宿主结构化提供时落入)              |
| `status`                                  | TEXT                   | ✓        | 宿主原始状态字符串(进行中 / 已完成 / 已取消 / 已暂停 ...)           |
| `notes`                                   | TEXT                   | nullable | 医嘱原文 / 备注;无法解析成 `next_review_at` 的"1 周后复查"也落这里 |
| `created_at` / `updated_at`               | timestamp              | ✓        | 审计                                             |

**约束**:`doctor_id` 和 `doctor_name` 至少有一个非空(ingest 层 zod 校验)。

**索引**:
- `UNIQUE(platform_id, tenant_id, external_id)` — upsert 去重
- `(platform_id, tenant_id, clinic_id)` — 多租户基础过滤
- `(patient_id)` — 患者维度查询

### 6.3 事实 vs 推断在 Treatment 上的具体落点(应用总原则 §3)

| 字段 / 概念 | 来源 | 归宿 |
|---|---|---|
| 治疗开始 / 完成时间(`startedAt` / `completedAt`) | 宿主结构化字段 | 事实 → Treatment 镜像 |
| 医生医嘱里的下次复查日期(`nextReviewAt`) | 宿主结构化字段 | 事实 → Treatment 镜像 |
| 医嘱原文 "1 周后复查"(无法解析成日期) | 宿主自由文本 | 事实 → Treatment.notes |
| PAC 综合规则 + 优先级算出的"推荐回访时间" | PAC 算法 | 推断 → `FollowupPlan.recommendedAt` |
| 治疗链完成状态("整套根管 + 冠修复是否做完") | 大概率 PAC 自己算;若 DW 端已聚合也算事实 | 推断 → `Persona.features.treatment_chain_status`(描述 + 证据);DW 给的事实信号通过 fact event 进时间轴,不进 Treatment 镜像 |
| 召回优先级 / 召回原因 | PAC 算法 | 推断 → `FollowupPlan.priorityScore` / `inclusionReason` |

> 业务使用时:**事实优先,推断兜底**。客服话术里引用"张医生让您 5/22 来复查"用 `Treatment.nextReviewAt`(事实);召回池排序用 `FollowupPlan.recommendedAt`(推断,综合更多信号)。

### 6.4 评审中确认的几条"不加"

| 候选字段 | 不加的理由 |
|---|---|
| `treatment_chain_status` / `is_chain_complete` | 跨多条 Treatment 的派生结论,**单条 Treatment 的字段层面不承载它**。即便 DW 端已经聚合好(对应方案 §4.1 输入范围里的"治疗链完成状态"),也通过 `patient_fact_events`(事件类型 `treatment_chain_completed`)进入,在 Persona 层用 `treatment_chain_status` feature 落地(描述 + numericScore + 证据引用),不污染 Treatment 单行镜像 |
| `is_completed` Boolean | 跟 `completed_at` 冗余(`completed_at IS NOT NULL` 就是已完成),布尔字段反而容易跟 `status` 不一致 |
| `recommended_review_at` / `recommended_review_interval_days` | 这是 PAC 算法的产物,放 `FollowupPlan`,跟 `next_review_at`(事实)区分 |
| `session_number` / `total_sessions` | 宿主普遍不维护"治疗包含几次"这个结构,90% 为空 |
| `amount_cents` | 已在 `Charge` 表,通过 `Charge.treatmentId` 关联,避免冗余 |
| 牙位结构化字段 | 同 Diagnosis,宿主多在 name/notes 自由文本里写 |

---

## 七、`appointments` 预约镜像表

### 7.1 这张表的定位

预约镜像表承担两类事实(对应方案 §4.1 输入范围里"预约事实" + "就诊事实"):

1. **预约事实**:某次预约的计划信息(谁、什么时间、做什么)
2. **就诊事实**:这次预约**有没有真的到诊**(`arrivedAt` 是否非空)

第二条是召回引擎的核心信号:
- "距上次到诊 N 天"→ Persona 算"沉睡风险" / "复查时机"
- "预约了但没到诊"(`scheduledAt < NOW() AND arrivedAt IS NULL`)→ 失约召回
- "到诊后没下次预约" → 复查召回的主路径

**不承担**:
- "这次预约是不是召回目标 / 召回触发的" → 进 `FollowupPlan` / 通过 `PlanExecution.newAppointmentRef` 反向关联,不在镜像里立柱
- 详细主诉 / 现病史 → 走宿主跳转(EMR 边界,总原则 §2)
- 预约状态变化历史(取消时间、改约时间)→ 进 `patient_fact_events`(`appointment_cancelled / rescheduled` 事件,带 `occurredAt`),镜像里 `status` 只反映当前态

### 7.2 当前字段(Round 1 评审定稿后)

| 字段                                        | 类型                     | 必填       | 说明                                  |
| ----------------------------------------- | ---------------------- | -------- | ----------------------------------- |
| `id`                                      | UUID(PK)               | ✓        | 主键                                  |
| `platform_id` / `tenant_id` / `clinic_id` | TEXT                   | ✓        | 三级隔离                                |
| `external_id`                             | TEXT                   | ✓        | 宿主系统里的预约 id                         |
| `patient_id`                              | UUID(FK → patients.id) | ✓        | 关联患者                                |
| `doctor_id`                               | TEXT                   | nullable | 接诊医生 id(宿主侧)                        |
| `doctor_name`                             | TEXT                   | nullable | 接诊医生姓名(展示用)                         |
| `scheduled_at`                            | timestamp              | ✓        | **事实**:预约时间(计划值)                    |
| `arrived_at`                              | timestamp              | nullable | **事实**:实际到诊时间(召回核心信号)               |
| `appointment_type`                        | TEXT                   | nullable | 预约类型(初诊 / 复诊 / 咨询 / 急诊...,宿主自由文本)   |
| `status`                                  | TEXT                   | ✓        | 宿主原始状态字符串(已确认 / 已到诊 / 已取消 / 未到诊...) |
| `notes`                                   | TEXT                   | nullable | 简短预约目的 / 备注("复查根管"/"洗牙")            |
| `created_at` / `updated_at`               | timestamp              | ✓        | 审计                                  |

**约束**:`doctor_id` 与 `doctor_name` 至少一个非空(ingest 层 zod 校验)。

**索引**:
- `UNIQUE(platform_id, tenant_id, external_id)` — upsert 去重
- `(platform_id, tenant_id, clinic_id)` — 多租户基础过滤
- `(patient_id, scheduled_at)` — "该患者最近 N 次预约"
- `(patient_id, arrived_at)` — "该患者最近一次到诊"

### 7.3 事实 vs 推断在 Appointment 上的具体落点(应用总原则 §3)

| 字段 / 概念 | 来源 | 归宿 |
|---|---|---|
| 计划预约时间(`scheduledAt`) | 宿主结构化字段 | 事实 → Appointment 镜像 |
| 实际到诊时间(`arrivedAt`) | 宿主结构化字段(到诊登记产生) | 事实 → Appointment 镜像 |
| 当前预约状态(`status`) | 宿主原始字符串 | 事实 → Appointment 镜像(不强枚举) |
| 取消时间 / 改约时间 | 宿主每次操作产生 | 事实 → `patient_fact_events`(不冗余到镜像) |
| "失约"判定 | PAC 推算(`scheduledAt < NOW() AND arrivedAt IS NULL`) | 推断 → 算法层即时算,不落字段 |
| 召回触发该预约 / 该预约属于召回闭环 | PAC 自己的关联 | 推断 → `PlanExecution.newAppointmentRef` 反向链接 |

### 7.4 评审中确认的几条"不加"

| 候选字段 | 不加的理由 |
|---|---|
| `cancelled_at` / `rescheduled_at` | 事件流 `appointment_cancelled / appointment_rescheduled` 已带 `occurredAt`,不冗余 |
| `treatment_id` 关联 | 预约 ↔ 治疗的关联键宿主基本不给;启发式拼(同患者 + 时间相近)错率高 |
| `priority` / `is_recall_target` | 推断,进 `FollowupPlan`,不进镜像 |
| `chief_complaint`(详细主诉) | EMR 边界(总原则 §2),走宿主跳转;短"目的"塞 `notes` 即可 |
| 牙位结构化字段 | 同 Diagnosis / Treatment 决议 |
| `no_show_reason` / `cancel_reason` | 边际收益低;真要分析失约原因走事件流 payload + `notes` 关键词 |
| `duration` / `end_at` / `chair_number` / `room_id` | 召回算法不消费 |

---

## 八、`charges` 缴费镜像表

### 8.1 这张表的定位

缴费镜像表对应方案 §4.1 输入范围里的 **收费 / 消费事实**,核心用途两条:

1. **Persona "客户价值"特征**:`SUM(amountCents - refundAmountCents)` 算累计净消费
2. **召回算法**:"近 N 天消费过" / "消费频次" 作为筛选 / 排序特征

**不承担**:
- 详细消费明细行项(走宿主跳转,镜像里只存"这次缴费总额")
- 客户价值评分 / VIP 等级 → 推断,进 `Persona.features.value`(描述 + numericScore + 证据)
- 折扣分析 / marketing 向数据 → 跟召回无关,不进镜像

### 8.2 当前字段(Round 1 评审定稿后)

| 字段                                        | 类型                     | 必填          | 说明                                                                      |
| ----------------------------------------- | ---------------------- | ----------- | ----------------------------------------------------------------------- |
| `id`                                      | UUID(PK)               | ✓           | 主键                                                                      |
| `platform_id` / `tenant_id` / `clinic_id` | TEXT                   | ✓           | 三级隔离                                                                    |
| `external_id`                             | TEXT                   | ✓           | 宿主系统里的缴费 / 订单 id                                                        |
| `patient_id`                              | UUID(FK → patients.id) | ✓           | 关联患者                                                                    |
| `treatment_id`                            | UUID                   | nullable    | 关联治疗(若宿主给关联键;多数宿主不给)                                                    |
| `amount_cents`                            | Int                    | ✓           | **事实**:实付金额(分,见总原则 §11)                                                 |
| `refund_amount_cents`                     | Int                    | ✓(默认 0)     | **事实**:已退金额(分);多次部分退款时累加                                                |
| `currency`                                | TEXT                   | ✓(默认 `CNY`) | 币种(为多币种留接口,代价小)                                                         |
| `category`                                | TEXT                   | nullable    | 类别(treatment / consultation / membership / other),宿主自由文本                |
| `status`                                  | TEXT                   | ✓           | 宿主原始状态(`paid` / `refunded` / `partial_refund` / `cancelled` / `unpaid`) |
| `charged_at`                              | timestamp              | ✓           | **事实**:缴费时间(宿主 `paid_at` / `charged_at`)                                |
| `refunded_at`                             | timestamp              | nullable    | **事实**:最近一次退款时间;变化轨迹靠 `charge_refunded` 事件流                             |
| `notes`                                   | TEXT                   | nullable    | 备注 / 简短说明("年度套餐"/"复查折扣"),自由文本                                           |
| `created_at` / `updated_at`               | timestamp              | ✓           | 审计                                                                      |

**索引**:
- `UNIQUE(platform_id, tenant_id, external_id)` — upsert 去重
- `(platform_id, tenant_id, clinic_id)` — 多租户基础过滤
- `(patient_id, charged_at)` — "该患者最近 N 笔缴费"

### 8.3 事实 vs 推断在 Charge 上的具体落点(应用总原则 §3)

| 字段 / 概念                       | 来源       | 归宿                                                                         |
| ----------------------------- | -------- | -------------------------------------------------------------------------- |
| 实付金额 / 退款金额 / 缴费时间            | 宿主结构化字段  | 事实 → Charge 镜像                                                             |
| 退款时间 / 状态变化                   | 宿主每次操作产生 | 事实 → `patient_fact_events`(`charge_recorded` / `charge_refunded`),镜像里只留最新态 |
| 累计净消费(`SUM(amount - refund)`) | PAC 算    | 推断 → 算法即时算,**不立柱**                                                         |
| 客户价值评分 / VIP 等级               | PAC 算法   | 推断 → `Persona.features.value`(描述 + numericScore + 证据)                      |
| 是否折扣敏感 / 价格敏感                 | PAC 算法   | 推断 → Persona feature                                                       |

### 8.4 评审中确认的几条"不加"

| 候选字段 | 不加的理由 |
|---|---|
| `payment_method`(现金/微信/医保/银行卡) | 召回算法不消费支付方式;"识别医保患者" 是 Persona feature 层面的事 |
| `invoice_number` / `receipt_number` | 财务审计向需求,召回不查 |
| `paid_at` 与 `charged_at` 区分 | 小型口腔诊所基本即收即付,两个时间高度重合;真要分,事件流 payload 可承载 |
| `original_amount` / `discount_amount_cents` | 折扣分析是 marketing 向,跟召回无关;`amount_cents` 存"实付"够用 |
| `operator_ref` / `operator_name`(收费员) | 召回不关心收费员是谁 |
| `item_details`(明细行项) | 不是 fact 层面 — 镜像表存"这次缴费总额",明细查宿主 |
| `refund_reason` | 投诉触发的退款应该走 `Patient.doNotContact` + reason 链路,不是 charge 字段 |
| `net_amount_cents`(冗余净额) | `amount - refund` SQL 现算就够;冗余字段易跟两个源字段不一致 |
| `value_score` / `lifetime_value` | 推断,进 `Persona.features.value` |

### 8.6 设计决策(评审时可能被问到)

**为什么退款不另起一行,用 `refundAmountCents` 累加 + `refundedAt`**:
- 同 `external_id` 一条记录表达完整生命周期,SUM 聚合简单
- 多次部分退款时 `refundAmountCents` 累加,`refundedAt` 取最近一次
- 变化轨迹靠 `charge_refunded` 事件流(每次退款一条事件,带 `occurredAt` 和金额)

**`category` 不强枚举**:
- 各宿主分类逻辑差异巨大(套餐 / 疗程 / 会员卡 / 单次...)
- 强枚举会拒收宿主真实数据,违背 ingest 友好原则
- 真要标准化由 Persona 层在算"价值"时归一化

---

## 九、`touchpoints` 触点镜像表

### 9.1 这张表的定位

触点镜像表对应方案 §2.1 / §4.1 里的 **已有回访 / 跟进事实** —— 把宿主侧异构的 **回访任务 / 跟进记录 / 接待任务 / 400 回访记录 / SCRM 跟进 / 销售机会** 等模块,canonical 化成一张薄镜像表。

核心用途三条:
1. **召回排除**:近 N 天宿主已经接触过的患者从召回池剔除(防止 PAC 重复打扰)
2. **One Page Summary**:展示"上次被接触是 X 时间 / Y 渠道 / Z 结果"
3. **跨 PAC + 宿主统一最大联系频次熔断**:`COUNT(direction='outbound')` 是熔断依据

**不承担**:
- "约定下次联系时间"这种**未来计划**(那是 PAC `PlanExecution.scheduledNextAt` 的事,Touchpoint 只记**已经发生**的事实,见 §9.6 对照)
- 触达成本 / 营销转化率(跟召回无关)
- 详细通话录音 / 文字记录(走宿主跳转)

### 9.2 当前字段(Round 1 评审定稿后)

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | UUID(PK) | ✓ | 主键 |
| `platform_id` / `tenant_id` / `clinic_id` | TEXT | ✓ | 三级隔离 |
| `external_id` | TEXT | ✓ | 宿主侧的触点记录 id |
| `patient_id` | UUID(FK → patients.id) | ✓ | 关联患者 |
| `occurred_at` | timestamp | ✓ | **事实**:接触发生时间(宿主侧) |
| `direction` | TEXT | nullable | **事实**:`inbound`(患者主动)/ `outbound`(宿主主动)/ `unknown`;自由文本不强枚举 |
| `channel` | TEXT | nullable | 接触渠道(`phone` / `wecom` / `sms` / `visit` ...,宿主自由文本) |
| `outcome` | TEXT | nullable | 接触结果(成功 / 拒接 / 转语音 ...,宿主自由文本) |
| `duration_seconds` | Int | nullable | 通话时长(秒);非通话渠道留空 |
| `notes` | TEXT | nullable | 摘要 / 备注 |
| `operator_ref` | TEXT | nullable | 宿主侧执行人 id |
| `operator_name` | TEXT | nullable | 执行人姓名(展示用) |
| `task_ref` | TEXT | nullable | 宿主侧"回访任务"id(5i5y / 尘锋等 task object);**一个 task 通常对应多个 touchpoint** |
| `created_at` / `updated_at` | timestamp | ✓ | 审计 |

**约束**:`operator_ref` 与 `operator_name` 至少一个非空(ingest 层 zod 校验)。

**索引**:
- `UNIQUE(platform_id, tenant_id, external_id)` — upsert 去重
- `(platform_id, tenant_id, clinic_id)` — 多租户基础过滤
- `(patient_id, occurred_at)` — "该患者最近 N 次触达"

### 9.3 事实 vs 推断在 Touchpoint 上的具体落点(应用总原则 §3)

| 字段 / 概念 | 来源 | 归宿 |
|---|---|---|
| 接触时间 / 渠道 / 结果 / 时长 / 方向 | 宿主结构化字段 | 事实 → Touchpoint 镜像 |
| `task_ref`(宿主侧任务关联) | 宿主结构化字段 | 事实 → Touchpoint 镜像(用于回链) |
| 触达频次("近 30 天打了几次") | PAC 算 | 推断 → 算法即时算 `COUNT WHERE direction='outbound'`,不立柱 |
| Engagement 评分(基于时长 + 方向) | PAC 算法 | 推断 → `Persona.features.engagement` |
| 客户体验风险(基于失败率 + 拒接率) | PAC 算法 | 推断 → `Persona.features.customer_experience_risk` |
| "约定下次联系时间" | 客服记录的**未来计划** | **不在 Touchpoint** → 走 `PlanExecution.scheduledNextAt`(见 §9.6) |

### 9.4 评审中确认的几条"不加"

| 候选字段 | 不加的理由 |
|---|---|
| `scenario` / `purpose`(回访场景标签) | 跟 PAC 自己的 `FollowupPlan.scenario` 易混淆;宿主回访场景标签信息量低,塞 `notes` 关键词够用 |
| `next_contact_at` / `callback_requested_at` | 这是**未来计划**,跟 `PlanExecution.scheduledNextAt` 严重重叠;按 PDF §5.3 接口 1/2 必须区分,Touchpoint 只记已发生事实,不存约定 |
| `appointment_ref`(本次触达后产生的预约) | 宿主很少在 followup 表挂这种关联;转化追踪靠"该患者后续是否有 Appointment 增加" 启发式 |
| `target_number` / `target_wechat_id` | `patients.phone` 已是主档;特殊号码塞 `notes` |
| `recorded_by` / `source` | 跟 fact event 的 `source` 重叠,镜像里不重复 |
| `has_followup_action` / `is_resolved` | 推断字段,算法即时算 |
| `tags String[]` | 信息量稀疏,塞 `notes` |
| `cost` / 商业触达成本 | 跟召回无关 |

---

## 十、`sync_logs` 事实接入 orchestration 日志

### 10.1 这张表的定位

事实接入的 orchestration 账本。同步引擎每次 pull / 接收 push 落一条记录,跟 `PersonaRecomputeLog`(画像重算)**平行,不混用** —— 各 orchestration 域一张账本,字段贴近自身语义,不强行 union。

**重要边界**:
- SyncLog 记录"事实接入"运行,**只服务 fact ingest 路径**
- 镜像表 upsert 不单独写 SyncLog(它们是 ingest 的副产物,数量在 `upserted` 字段里)
- Persona / Plan / Agent 的 orchestration 各有自己的日志表,不写到这里

### 10.2 当前字段

| 字段                               | 类型        | 必填         | 说明                                                                                                        |
| -------------------------------- | --------- | ---------- | --------------------------------------------------------------------------------------------------------- |
| `id`                             | UUID(PK)  | ✓          | 主键                                                                                                        |
| `platform_id` / `tenant_id`      | TEXT      | ✓          | **两级隔离**(故意不立 clinic_id,见 §10.3)                                                                          |
| `patient_id`                     | UUID      | nullable   | **NULL = 批量 run,非 NULL = 单患者即时 pull**(见 §10.7)。非 NULL 时 cursor_before/cursor_after 必须为 null               |
| `adapter_kind`                   | TEXT      | ✓          | `pull`(PAC 主动拉)/ `push`(宿主主动推);mock 走 pull,不单列                                                            |
| `resource`                       | TEXT      | ✓          | 同步资源(`patients` / `diagnoses` / `treatments` / `appointments` / `charges` / `touchpoints`,或 `*` 表示一次跨多资源) |
| `triggered_by`                   | TEXT      | nullable   | `scheduled:<cronId>` / `manual:<userId>` / `push:<adapter>`;诊断"为什么这条记录在这个时间出现"                            |
| `status`                         | TEXT      | ✓          | `running` / `success` / `partial` / `failed`(各状态语义见 §10.4)                                                |
| `cursor_before` / `cursor_after` | TEXT      | nullable   | 拉取游标(push 路径不适用,留 null)                                                                                   |
| `fetched`                        | Int       | ✓(默认 0)    | 入站记录数:pull 是"从宿主拉到的条数",push 是"本次接收到的条数"                                                                   |
| `upserted`                       | Int       | ✓(默认 0)    | upsert 到本地镜像表的条数                                                                                          |
| `facts_emitted`                  | Int       | ✓(默认 0)    | 写入 fact 时间轴的事件数                                                                                           |
| `failed`                         | Int       | ✓(默认 0)    | 失败条数(`success ⇔ failed=0`,`partial ⇔ failed>0`)                                                           |
| `error_message`                  | TEXT      | nullable   | 失败主因(详细堆栈进应用日志)                                                                                           |
| `started_at` / `ended_at`        | timestamp | started 必填 | 起止时间                                                                                                      |

**索引**:`(platform_id, tenant_id, resource)` / `(started_at)` / `(status)` / `(patient_id, started_at)`(单患者反查)

### 10.3 状态语义(必须严格遵守)

| 状态 | 含义 | `failed` 字段 | `cursor_after` |
|---|---|---|---|
| `running` | 进行中(用于发现卡死任务) | 临时值 | 临时值 |
| `success` | 全部成功 | = 0 | 已推进(若是 pull) |
| `partial` | 部分失败但有进展 | > 0 | 已推进 |
| `failed` | 整体失败,连游标都没推进 | 临时值 | 跟 `cursor_before` 相同或 null |

`partial` 和 `failed` 的关键区别:**cursor 有没有推进**。如果一页里 100 条 9 条失败,cursor 推到下一页,这是 `partial`;如果连第一条都没拉到 / 鉴权挂了,cursor 没动,这是 `failed`。

### 10.4 三种 pull 模式 + 单患者即时 pull 设计

PDF §4.1 把 API Pool 1 的最终能力分成三类(已支持 + 计划中):

| 模式        | 触发                                                                  | 实现状态       | 写 SyncLog 形态                                              |
| --------- | ------------------------------------------------------------------- | ---------- | --------------------------------------------------------- |
| 定时批量      | cron(每 5 分钟) `runScheduledForAll()`                                 | ✓ 已实现      | `patient_id=null`,`resource='*'`,`cursor_before/after` 推进 |
| 手工批量      | admin 调 `POST /sync/trigger`(`runPull(platformId)`)                 | ✓ 已实现      | 同上                                                        |
| **单患者即时** | staff/admin 调 `POST /sync/patient/:id/refresh`(`runPullForPatient`) | ✓ **本轮补齐** | `patient_id` 非空,`cursor_*` 必须 null                        |

---

## 十一、`personas` 患者画像版本头

### 11.1 为什么要单独一层 Persona

Persona 是 PAC 的"特征层"(对应 PDF §5.2 / §6.4 第二层),把 fact 时间轴抽象成**高阶特征**,供多个 Plan / scenario / 话术 / Summary **复用**。

跳过 Persona 直接 fact → Plan 的代价:

| 没有 Persona 层会发生的问题 |
|---|
| 每个 scenario 各自重写"该患者多有钱 / 多忠诚 / 多值得追"的逻辑,平方级膨胀 |
| 特征是临时算出来的,没有版本号,无法回答"你为什么今天这么排?"(PDF §5.5 证据链断裂) |
| One Page Summary 没有结构化数据来源,只能从 fact 现拼,客服 UI 卡顿 |
| 多 scenario 间口径不一致(`post_treatment_recall` 算的"价值" 跟"高价值病例跟进"算的可能不同) |

> 具体例子:"客户价值"特征同时被 `post_treatment_recall` / 未来的"沉睡客户激活" / "高价值病例跟进" 三个 scenario 消费 — Persona 算一次,三处复用,版本号一致,证据可查。

### 11.2 当前字段

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | UUID(PK) | ✓ | 主键 |
| `platform_id` / `tenant_id` | TEXT | ✓ | **二级隔离**(Persona 是患者级,跨诊所共享) |
| `patient_id` | UUID(FK → patients.id) | ✓ | 关联患者 |
| `version` | Int | ✓ | 该患者的 Persona 单调递增版本号 |
| `triggered_by` | TEXT | ✓ | 触发来源:`fact:<eventId>` / `manual:<userId>` / `scheduled:<cronId>` |
| `computed_at` | timestamp | ✓ | 计算完成时间 |
| `superseded_at` | timestamp | nullable | 被新版本取代的时间;NULL = 当前版本 |
| `fact_watermark` | TEXT? | nullable | **建议加** — 重算时所基于的最新 fact event 标识(可以是 event id 或 ingestedAt 时间戳)。**用途**:判断 persona 是否 stale,避免无脑重算 |

**索引**:`UNIQUE(patient_id, version)` / `(platform_id, tenant_id)` / `(patient_id, superseded_at)`

### 11.3 生成时机(设计意图)

按 PDF §5.1"Plan 必须基于最新事实重新分析",Persona 的触发是**事件驱动 + 兜底定时**:

| 触发源 | 时机 | `triggered_by` 取值 |
|---|---|---|
| 新 fact event 落地 | 异步队列触发,fact ingest 不阻塞 | `fact:<eventId>` |
| Admin 手工重算(后台 / debug) | 即时同步 | `manual:<userId>` |
| 定时兜底(漏掉的事件、规则更新后全量) | 每日凌晨 cron | `scheduled:<cronId>` |

**执行模式选择**:

| 模式 | 优点 | 缺点 | v1 是否采用 |
|---|---|---|---|
| 同步内联(ingest 内联算) | Plan 必拿到最新 | 拖慢 ingest;agent 调用耗秒 | ✗ |
| **异步队列**(BullMQ / Pulsar) | 解耦,失败可重试 | 需要队列基建 | ✓ **主路径** |
| 懒计算(plan 引擎要时再算) | 最少计算 | 第一次 plan 请求卡住 | △ 不主动用 |
| 定时全量 | 最简单 | stale 严重 | ✓ 兜底 |

**推荐组合**:fact ingest 完成 emit 事件 → 异步 worker 调 `recompute(patientId)`(主路径)+ 每日定时全量(兜底漏掉)+ admin 接口手工触发(debug)。

### 11.4 不加的字段(评审时可讨论)

| 候选字段 | 不加的理由 |
|---|---|
| `aggregate_score` / `total_score` | 加权综合分应该在召回算法即时算,不固化到 Persona — 加权策略会调,固化字段会误用旧值 |
| `failure_reason` | 失败的重算根本不写一条 Persona 记录(只写成功版),失败信息进 `PersonaRecomputeLog`(§13) |
| `input_fact_count` | 用 `fact_watermark` 推算更精准 |
| 单独 `PersonaSnapshot` 冷归档表 | `superseded_at` 已能识别旧版本,归档策略到时再加 |

---

## 十二、`persona_features` 画像特征条目

### 12.1 这张表的定位

PersonaFeature 是 Persona 的**条目层**,1 个 Persona 含 N 个 feature。**架构强制约束**(PDF §5.2 / 验收口径 #10):

> 每条特征 = 特征键 + 自然语言描述 + 证据引用 + 数值评分(可选)

特征不拆字段、特征键不强枚举,这是 PAC 框架的关键设计承诺,不是疏漏。

### 12.2 当前字段

| 字段                    | 类型                     | 必填       | 说明                                                                                      |
| --------------------- | ---------------------- | -------- | --------------------------------------------------------------------------------------- |
| `id`                  | UUID(PK)               | ✓        | 主键                                                                                      |
| `persona_id`          | UUID(FK → personas.id) | ✓        | 关联画像版本                                                                                  |
| `feature_key`         | TEXT                   | ✓        | 特征键(`value` / `treatment_chain_status` / `engagement` / `communication_preference` ...) |
| `category`            | TEXT                   | nullable | 可选分组(`value` / `risk` / `preference`)                                                   |
| `description`         | TEXT                   | ✓        | **自然语言描述,自包含**。架构硬约束不拆字段                                                                |
| `numeric_score`       | Float                  | nullable | 排序 / 筛选用的可选数值,不承载语义                                                                     |
| `evidence`            | JSONB                  | ✓        | 证据引用 `{ factEventIds, ruleIds, agentInvocationIds }`(详见 §12.4),反查"为什么得出这个结论"               |
| `created_at`          | timestamp              | ✓        | 审计                                                                                      |

**索引**:`UNIQUE(persona_id, feature_key)` / `(feature_key)`

### 12.3 关键设计约束(PDF 硬约束)

**`description` 不拆字段**(PDF §5.2 / 验收口径 #10):
- 自然语言描述自包含,不再拆 `value_score` / `treatment_value` / `relationship_value` / `risk_score` 这种固定子字段
- 否则 PAC 会被宿主反向绑定成一组固定业务字段,失去演进空间
- 后续如需新结构化特征,**新增 feature key**,不拆已有 description

**`numericScore` 不是 description 的替代**:
- 它只用于"排序 / 筛选"(召回池排序、阈值过滤),不承载语义
- 评审常被问:"为什么不直接给我 value=85?" — 因为 85 没有可解释性,需要 description 提供"该患者两年消费 12 万元,种植 + 正畸,预计未来 3 年持续" 这类自然语言上下文

**特征键允许扩展**(PDF §6.4 第二层):
- v1 实现的特征键(初步):`value` / `treatment_chain_status` / `engagement` / `recall_priority`
- 演进方向:沟通偏好 / 客户体验风险 / 投诉风险 / 家庭关系 / 医生关系...
- 不在 schema 里强枚举,featureKey 是开放字符串

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

PDF §5.2 强调 Persona 是"特征 + 描述 + 证据 + 版本",但**没有规定特征必须 agent 生成**。同一张 schema 同时承载两种生成方式:

| 路径 | 优点 | 缺点 | 适合的特征类型 |
|---|---|---|---|
| **规则路径**(SQL / 代码计算) | 确定性、零成本、毫秒级、好审计、可重放 | 表达力有限,description 只能模板填充 | 数值类(`value` 累计消费)、清晰枚举类(`treatment_chain_status` 完成度)、合规边界(`do_not_contact_status`) |
| **Agent 路径**(LLM 调用) | 自然语言生成、跨结构化 + 文本推理、能产出 nuance | 慢、贵、非确定性、需 prompt 版本管理 | 沟通偏好(从 touchpoint notes 文本推断)、客户体验风险(综合投诉/退款/失败触达推理)、社交关系(几乎全文本) |

**evidence 字段同时支持两种来源**(`agentInvocationIds` 用数组形式,允许多 agent 协作):
```jsonc
// 规则生成:
{ "ruleIds": ["rule_value_v1"], "factEventIds": ["evt_xxx", "evt_yyy"] }

// Agent 生成:
{ "agentInvocationIds": ["ainv_zzz"], "factEventIds": ["evt_xxx"], "promptVersion": "v3" }

// 混合(规则给 score,agent 给 description):
{ "ruleIds": ["rule_value_v1"], "agentInvocationIds": ["ainv_zzz"], "factEventIds": [...] }

// 多 agent 协作(将来场景:一个 agent 给 description,一个 agent 给 numericScore):
{ "agentInvocationIds": ["ainv_aaa", "ainv_bbb"], "factEventIds": [...] }
```

**v1 实施分阶段**:
1. **Phase 3 W3-W4**:全部规则路径,description 用模板字符串("该患者累计消费 ¥{x},主要类目 {y}")— 够 demo
2. **Phase 5+**:1-2 个最复杂的特征(`customer_experience_risk` / `communication_preference`)接 agent
3. **Phase 6 试点优化后**:大部分 description 由 agent 生成,数值类仍规则,score 仍稳定

**架构上不需要分两张表** — 同一 `persona_features` 行用 `evidence.agentInvocationIds` 是否为空区分,不破坏 schema。

**Harness 工程观**:agent 是从属工具,主流程由确定性代码主导,有 orchestration 日志(§13)兜底监控。Agent 调用次数、失败回退次数都进 `PersonaRecomputeLog` 的执行账本,作为 harness 健康度指标。

### 12.5 不加的字段

| 候选字段 | 不加的理由 |
|---|---|
| `confidence` | `numericScore` 可以承载置信度;agent 生成时 prompt 约束输出范围 |
| `expires_at` | 整个 Persona 有版本和 `superseded_at`,不需要按特征单独失效 |
| `PersonaFeatureHistory`(每特征独立版本表) | 跟 Persona 整体版本流冲突;每次重算 = 一个新 Persona,这就是历史 |

---

## 十三、`persona_recompute_logs` 重算 orchestration 日志

### 13.1 这张表为什么必要

Persona 重算每次调用一条记录,不论成功失败都落,作为 harness 层的执行账本。跟 `SyncLog`(事实接入)**平行,不混用** —— SyncLog 注释明确"adapterKind: pull/push,resource: patients/diagnoses/...",没有 Persona 重算的位置。

| 需求 | Persona / AgentInvocation 能不能承载 | 是否需要本表 |
|---|---|---|
| 失败的重算要落档(没生成 Persona,但要知道为什么没生成) | Persona 表只记成功版,失败的没人收 | **需要** |
| 一次重算多次 agent 调用要有"父任务"关联 | `AgentInvocation.linkedPersonaId` 只有 Persona 写成功后才能指,跑一半失败时无法关联 | **需要** |
| 跟踪触发延迟(fact 进来 → Persona 算完间隔)、性能 | Persona.computed_at 只有终点,没起点 | **需要** |
| 区分 rule / agent / fallback 路径占比(harness 健康监控) | AgentInvocation 知道 agent 走了几次,但没人统计"规则路径走了几次" | **需要** |

### 13.2 当前字段

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | UUID(PK) | ✓ | 主键 |
| `platform_id` / `tenant_id` | TEXT | ✓ | **二级隔离**(per-patient 重算任务,跨诊所看患者全 fact 流) |
| `patient_id` | UUID | ✓ | 关联患者(本表不强 FK,允许患者删后日志保留以审计) |
| `triggered_by` | TEXT | ✓ | `fact:<eventId>` / `manual:<userId>` / `scheduled:<cronId>` |
| `fact_watermark` | TEXT | nullable | 重算所基于的最新 fact event 标识 |
| `status` | TEXT | ✓ | `running` / `success` / `partial` / `failed` |
| `produced_persona_id` | UUID | nullable | 产出的 Persona id;`failed` 时为 null |
| `feature_count` | Int | ✓(默认 0) | 本次共产出多少 feature |
| `rule_count` | Int | ✓(默认 0) | 由规则路径产出的 feature 数 |
| `agent_count` | Int | ✓(默认 0) | 由 agent 路径成功产出的 feature 数 |
| `fallback_count` | Int | ✓(默认 0) | agent 失败回退到规则的次数(harness 健康度指标 — 升高 = 上游 LLM 出问题) |
| `error_message` | TEXT | nullable | 失败 / 部分失败的主因(详细堆栈进应用日志) |
| `started_at` / `ended_at` | timestamp | started 必填 | 起止时间 |

**索引**:`(patient_id, started_at)` / `(platform_id, tenant_id)` / `(status)`

### 13.3 调用顺序(协议层面定死)

```
1. recompute 开始 → INSERT log status=running
2. 跑规则提取器(rule_count++)
   跑 agent 提取器 → 每次 INSERT 一条 AgentInvocation(linkedPersonaId 暂为 null)
3a. 全部完成 → INSERT 新 Persona + features
              → UPDATE AgentInvocation 回填 linkedPersonaId
              → UPDATE 本表 status=success/partial,producedPersonaId 指向新 Persona
3b. 中途崩溃 → UPDATE 本表 status=failed,errorMessage 落原因
              Persona 不写出;AgentInvocation 仍保留,通过本表反查
```

---

## 十四、`followup_plans` 召回计划版本流

### 14.1 这张表的定位

PAC 三层模型最顶层(PDF §6.4 第三层)。整张表是**推断**层 —— 每行都是 PAC 综合 fact / Persona / 规则 / agent 后的**对外承诺**(给宿主 / 客服)。

召回池主查询路径:

```sql
WHERE status='active' AND assignee_user_id IS NULL
ORDER BY priority_score DESC
LIMIT N
```

单一活跃版本约束:同一 `(patient, scenario)` 同时只有一个 `active`/`assigned` 版本。**通过 partial unique index `one_open_plan_per_scenario` 在 DB 层强制**(本轮新增),不依赖应用层"先 update 旧的再 create 新的"这种 race-prone 写法。

### 14.2 Plan 跟 Persona 的关系:可选增强,不是硬依赖

PDF §5.1 "Plan 必须基于最新事实重新分析" 的"必须"是针对 **fact 时间轴**(硬依赖),不是 Persona(可选增强)。

| 输入 | 必需性 | 没有的代价 |
|---|---|---|
| fact 时间轴 + 镜像表 | **硬依赖** | Plan 完全没法生成 — 不知道患者发生过什么 |
| Persona | **可选增强** | Plan 仍能基于纯规则 + fact 生成,但失去高阶特征加权 |

### 14.3 当前字段

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | UUID(PK) | ✓ | 主键 |
| `platform_id` / `tenant_id` | TEXT | ✓ | **二级隔离**(Plan 是患者级实体,跨诊所共享) |
| `patient_id` | UUID(FK → patients.id) | ✓ | 关联患者 |
| `target_clinic_id` | TEXT | nullable | **业务字段**(归属诊所):期望执行召回的诊所;Plan 生成时锁定不更新;`null` = 集团统一客服池(VIP 中心 / 中央营销 / 流失客户挽回等场景);**不是租户隔离维度**,详见 §21 |
| `persona_id` | UUID? | nullable | **可选增强**(见 §14.2),非过渡态 |
| `scenario` | TEXT | ✓ | 业务场景(`post_treatment_recall` / 后续扩展...,不强枚举) |
| `version` | Int | ✓ | 该 (patient, scenario) 维度的版本号 |
| `goal` | TEXT | nullable | 计划目标(自然语言) |
| `priority_score` | Float | ✓ | 召回池排序用 |
| `inclusion_reason` | TEXT | ✓ | 该患者为什么进入召回(规则模板 v1;Phase 5+ agent 增强) |
| `recommended_at` | timestamp | nullable | PAC 推算的推荐触达时间(注:`Treatment.nextReviewAt` 是医嘱事实,UI 展示时事实优先,推断兜底) |
| `recommended_role` | TEXT | nullable | 推荐执行人角色 |
| `recommended_channel` | TEXT | nullable | 推荐触达渠道 |
| `evidence` | JSONB | ✓ | `{factEventIds, personaVersion, ruleIds, agentInvocationIds}` |
| `status` | TEXT | ✓ | 5 个状态(见 §14.5) |
| `recycle_at` | timestamp | nullable | 自动回收 deadline(assignment 时锁定;**不是聚合,是固定值**) |
| `assignee_user_id` | TEXT | nullable | 当前指派给谁 |
| `assignee_role` | TEXT | nullable | 指派时的角色快照(防止 user 离职 / 角色变更后丢失归属) |
| `assigned_at` | timestamp | nullable | 指派发生时间 |
| `assignment_mode` | TEXT | nullable | `manual` / `auto` |
| `created_at` / `updated_at` | timestamp | ✓ | 审计 |

**索引**:
- `UNIQUE(platform_id, tenant_id, patient_id, scenario, version)` — 版本号去重(严格租户隔离前缀)
- `(platform_id, tenant_id)` — 二级隔离基础过滤
- `(platform_id, tenant_id, target_clinic_id, status, priority_score)` — **召回池主查询路径**(按归属诊所过滤 + 排序)
- `(platform_id, tenant_id, status, priority_score)` — 服务"集团统一池"查询(`target_clinic_id IS NULL`)
- `(scenario, status)` — 按场景看池
- `(assignee_user_id)` — "我的任务"查询
- **partial UNIQUE** `one_open_plan_per_scenario` ON `(platform_id, tenant_id, patient_id, scenario)` WHERE `status IN ('active', 'assigned')` — 强制单活跃版本(患者级,无诊所维度)

### 14.4 规则 vs Agent 在 Plan 上的具体落点(应用总原则 §14)

按 harness 工程观,Plan 这层任务清单 + 实现方式分工:

| 任务 | 路径 | v1 起步 |
|---|---|---|
| scenario 选择 / 召回过滤 | **规则**(SQL WHERE + 配置) | ✓ 已实现 |
| `priority_score` | **规则**(加权公式)— 必须确定性,排序结果要可重放 | ✓ 已实现 |
| `recommended_at` | **规则**(`completedAt + scenario.recallDelayDays`) | ✓ 已实现 |
| `recommended_role` / `recommended_channel` | **规则**(scenario 配置 + patient.preferences) | ✓ 已实现 |
| `inclusion_reason` 自然语言 | **规则模板** v1(`"该患者于 X 完成 Y,符合 Z 规则"`)→ **agent 增强** Phase 5+ | ✓ 模板已实现 |
| `goal` 自然语言 | **规则模板** v1 → **agent 增强** Phase 5+ | ✓ 模板已实现 |
| `evidence` 收集 | **规则**(始终是规则收集 fact / persona / agent ids) | ✓ 已实现 |
| **`PlanScript`(话术)** | **必须 agent**(模板话术僵硬不实用) | ✗ 未实现(等 agent 接入) |
| **`PlanSummary`(One Page Summary / 病历摘要)** | **必须 agent**(跨多源数据归纳成可读叙述) | ✗ 未实现(等 agent 接入) |

**核心观察**:**Plan 主表 99% 字段都是规则化** — 决策、打分、时间、状态机全部确定性。**唯独 `PlanScript` / `PlanSummary` 必须 agent**,因为自然语言生成是 LLM 本职。这正是 harness 工程观的最佳教材 —— 主流程代码,输出层 agent。

evidence 形态:

```jsonc
// 全规则生成(v1):
{
  "factEventIds": ["evt_xxx", "evt_yyy"],
  "personaVersion": 3,
  "personaFeatureKeys": ["value", "treatment_chain_status"],
  "ruleIds": ["scenario_post_treatment_recall_v1", "priority_calc_v2"]
}

// 含 agent(Phase 5+):
{
  "factEventIds": [...],
  "personaVersion": 3,
  "ruleIds": [...],
  "agentInvocationIds": {
    "inclusionReason": "ainv_aaa",
    "script": "ainv_bbb",
    "summary": "ainv_ccc"
  }
}
```

### 14.5 状态机(本轮简化为 5 个)

| 状态 | 含义 |
|---|---|
| `active` | 在召回池等待分配 |
| `assigned` | 已分配给某客服 |
| `completed` | 完成(成功转化或客服结束) |
| `abandoned` | 客服放弃 |
| `superseded` | 被新版本取代 |

**本轮删除两个状态**:
- `executing` — 客服打电话粒度过细,状态频繁切换无意义,**砍**
- `recycled` — 是动作不是状态(系统超时回收 = 把 `assigned` 改回 `active` + 清空 assignee + 清 `recycle_at`),不该是状态值,**砍**

`@pac/types` 的 `PlanStatus` enum 已同步删除这两个值;`plan.service.ts` 的 `recycle()` 行为改为"动作语义"。

### 14.6 设计决策

**为什么 `priority_score` 必须规则化**:
- 召回池排序是高频查询,要可重放(运维事故复盘需要)
- LLM 给分不稳定,同 prompt 不同调用会有差异,排序结果会抖动
- 规则版的加权公式可调参数,试点时容易逐项验证

**为什么 `inclusion_reason` v1 走规则模板,Phase 5+ 才上 agent**:
- v1 试点要先跑通,模板填充够 demo
- 真实使用反馈后才知道客服需要什么风格的 reason
- 规则版作为 agent 失败时的兜底(harness §14)

**为什么 `personaId` 是 nullable**:
- 见 §14.2,Persona 是可选增强不是硬依赖
- 应用层 `scenarios` 实现都按 `persona | null` 设计
- v1 graceful degradation 更稳

---

## 十五、`plan_scripts` 客服参考话术

### 15.1 这张表的定位

每个 plan 下按 `scriptType` 各一份**当前态**话术,客服打电话用。在 §14.4 Plan task 矩阵里已经定死:

> **必须 agent**(harness 总原则 §14:话术是 LLM 本职擅长的事,模板话术僵硬不实用),
> 但 **agent 失败必须能降级**到 scenario 默认模板(`source='template_fallback'`)。

这是 Plan 这层"输出物"的两张表之一(另一张是 PlanSummary,§16 待评审)。

### 15.2 单一最新规则 + Plan 版本协作

`UNIQUE(planId, scriptType)` 保证同一 plan 同类话术**只一条当前态**。Plan 重生成时新 plan 行带新 script 行;旧 plan `status=superseded` 后其 script 自然进入历史(查询时过滤 plan.status 即可)。

```
重新生成 plan
  → 新 FollowupPlan 行 v2,旧 plan v1 status=superseded
  → 新 PlanScript 行 (planId=v2, scriptType='opening')
  → 旧 PlanScript 行 (planId=v1, ...) 仍存,但通过 plan superseded 自然失效
```

PlanScript **自身不需要版本号** — Plan 已经版本化,子表跟随即可。

### 15.3 当前字段

| 字段                                        | 类型                           | 必填            | 说明                                                                                                                                                          |
| ----------------------------------------- | ---------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                                      | UUID(PK)                     | ✓             | 主键                                                                                                                                                          |
| `platform_id` / `tenant_id`               | TEXT                         | ✓             | **二级隔离**(跟 plan 一致,跨诊所共享;按诊所过滤走 plan.target_clinic_id 反查)                                                                                              |
| `plan_id`                                 | UUID(FK → followup_plans.id) | ✓             | 关联 plan;cascade delete                                                                                                                                      |
| `script_type`                             | TEXT                         | ✓             | 话术类型(`opening` / `follow_up` / `objection_handling`,不强枚举)                                                                                                   |
| `content`                                 | TEXT                         | ✓             | 话术正文(应用层 zod 限 4000 字符)                                                                                                                                     |
| `source`                                  | TEXT                         | ✓(默认 `agent`) | **本轮新增** — `agent` / `template_fallback` / `manual`,详见 §15.4                                                                                                |
| `evidence`                                | JSONB                        | nullable      | `{agentInvocationIds, ruleIds, factEventIds}`;source=agent 时 `agentInvocationIds` 非空数组,fallback / manual 时为 null 或仅含 ruleIds(本轮调整,统一推断表 agent 引用形式,见 §15.6) |
| `created_at`                              | timestamp                    | ✓             | 审计                                                                                                                                                          |

**索引**:
- `UNIQUE(plan_id, script_type)` — 同 plan 同 type 单一最新;同时覆盖 `(planId, scriptType)` 查询路径
- `(platform_id, tenant_id)` — 二级隔离基础过滤

### 15.4 `source` 字段的语义(harness §14 落点)

**这是本表本轮最重要的新增字段**。harness 总原则 §14 要求"两路通过 evidence 字段记录来源 + agent 失败必须能降级",PlanScript 落到具体字段:

| `source` | `evidence.agentInvocationIds` | 含义 |
|---|---|---|
| `agent` | 非空数组 | LLM 成功生成。监控指标 `agent_count` |
| `template_fallback` | null / 空 | agent 失败 / 未启用,scenario 默认模板兜底。监控指标 `fallback_count` ↑(harness 健康度告警信号) |
| `manual` | null / 空 | 运维 / 调试手填(灰度阶段或紧急修复) |

**降级路径**(harness 强约束):
```
1. PlanGenerationLog status=running
2. 调 ScriptAgent → 写 AgentInvocation
3a. 成功 → upsert PlanScript(source=agent, evidence={agentInvocationIds:[X]})
3b. 失败 → upsert PlanScript(source=template_fallback, evidence=null, content=scenario.defaultScripts[scriptType])
4. PlanGenerationLog 终态(success / partial)+ fallback_count++
```

**默认模板存哪**:`Platform.rulesConfig.scriptTemplates`(JSON 配置,不立独立表)。

### 15.5 想过但不加的字段

| 候选字段 | 不加的理由 |
|---|---|
| `version`(自表版本) | Plan 已版本化,子表跟随即可,自表版本是冗余 |
| `prompt_version` / `prompt_template` | 已在 `AgentInvocation.promptTemplate` 记录,反查 join 即可 |
| `language` | v1 中文,无多语种需求;真要支持加 `lang` 字段(BCP47) |
| `validity_expires_at` | Plan superseded 时 script 自然失效,不需独立 TTL |
| `style` / `tone` | 个性化风格属于 prompt 输入,在 `AgentInvocation.inputSnapshot` 里 |
| `tenant_default_template_id` | 默认模板存 `Platform.rulesConfig.scriptTemplates`,不立柱关联 |
| `quality_score` / `feedback_count` | 评审反馈是另一套数据,不污染主表 |
| `patient_id` | 见 §15.3,跟 Plan-side 子表一致,通过 plan 反查 |
| `rendered_for_user` 个性化变量替换时间 | 渲染时实时拼,不落库 |

---

## 十六、`plan_summaries` 客服参考材料(One Page Summary 等)

### 16.1 这张表的定位 + 跟 PlanScript 的对偶

PlanSummary 跟 PlanScript 是**完全对偶**的设计 — 都是 Plan 的"输出物",都必须 agent + 可降级。本节只列**差异**,共性参见 §15。

| 维度 | PlanScript(§15) | PlanSummary(本节) |
|---|---|---|
| 用途 | 客服**打电话用**的话术 | 客服**打电话前**的参考材料 |
| 类型字段 | `scriptType` | `summaryType` |
| 类型取值 | opening / follow_up / objection_handling | one_page / medical_record / treatment_chain |
| 典型长度 | 200-500 字(短) | 500-3000 字(中长) |
| `content` 上限 | 4000 字符 | **8000 字符**(病历摘要 / 治疗链可能含 markdown 表格 / 多段落) |
| 必须 agent | ✓ | ✓ |
| 可降级模板 | ✓(`source='template_fallback'`) | ✓ 同样 |
| 单一最新规则 | `UNIQUE(planId, scriptType)` | `UNIQUE(planId, summaryType)` |
| 隔离粒度 | 二级(跟 plan,跨诊所共享) | 二级(同左) |
| `patient_id` | ✗ 不立柱 | ✗ 不立柱 |
| `source` 字段 | ✓(harness §14) | ✓(共享 `OutputSource` enum) |

**所有结构性决策跟 PlanScript 完全一致**,唯一差异是 `content` 上限(8000 vs 4000)和类型取值。

### 16.2 当前字段

| 字段                                        | 类型                           | 必填            | 说明                                                                                                                    |
| ----------------------------------------- | ---------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------- |
| `id`                                      | UUID(PK)                     | ✓             | 主键                                                                                                                    |
| `platform_id` / `tenant_id`               | TEXT                         | ✓             | **二级隔离**(跟 plan 一致,跨诊所共享)                                                                                              |
| `plan_id`                                 | UUID(FK → followup_plans.id) | ✓             | 关联 plan;cascade delete                                                                                                |
| `summary_type`                            | TEXT                         | ✓             | `one_page` / `medical_record` / `treatment_chain`,不强枚举                                                                |
| `content`                                 | TEXT                         | ✓             | 摘要正文(应用层 zod 限 8000 字符)                                                                                               |
| `source`                                  | TEXT                         | ✓(默认 `agent`) | **本轮新增** — `agent` / `template_fallback` / `manual`(共享 `OutputSource` enum)                                           |
| `evidence`                                | JSONB                        | nullable      | `{agentInvocationIds, ruleIds, factEventIds}`;source=agent 时 `agentInvocationIds` 非空数组(本轮调整,统一推断表 agent 引用形式,见 §16.6) |
| `created_at`                              | timestamp                    | ✓             | 审计                                                                                                                    |

**索引**:
- `UNIQUE(plan_id, summary_type)` — 同 plan 同 type 单一最新
- `(platform_id, tenant_id)` — 二级隔离基础过滤

### 16.3 关键设计决策:per-plan 还是 per-patient

One Page Summary 理论上是**患者级信息**(姓名、电话、最近就诊、价值评估),跟具体 plan 无关。当前 schema 是 per-plan,潜在重复成本:

| 方案 | 优点 | 缺点 |
|---|---|---|
| **A. per-plan(当前)** | 简单,跟 plan 生命周期对齐;summary 跟 plan 上下文绑定(可含"为什么本次召回")自然 | 同患者多 scenario 时重复生成 |
| B. per-patient(独立 PatientSummary 表) | 跨 plan 共享,省 token | 独立 invalidation 逻辑跟 Persona 重叠;设计混乱 |

**v1 选 A**。理由:
- 试点 5 门诊,同患者多 plan 的场景不会高频
- 独立 PatientSummary 的 invalidation 跟 Persona 高度重合(Persona 重算 → summary 也要重算),会产生"为什么有 Persona 还要 PatientSummary"的语义混乱
- One Page Summary 在 plan 上下文里渲染时,plan 的 `inclusionReason` / `recommendedAt` 等本来就属于 summary 的一部分,有 plan 上下文不是冗余

如果 Phase 6 试点反馈"summary 重复生成耗时"是真问题,再做 B 方案。

---

## 十七、`plan_executions` 接口 2 执行回写

### 17.1 这张表的定位

接口 2 的核心 — 客服打完电话或操作后,执行结果回写到这里。**Plan-side 唯一写入路径完整实现的表**(`plan.service.submitExecution()` 已经把"写 PlanExecution + 更新 Plan status" 做成完整 transaction)。

### 17.2 跟 PatientFactEvent 的硬边界(总原则 §4)

注释明确:**"绝不写到 patient_fact_events"**(架构 §5.3 / 总原则 §4)。代码层 `FactsService.ingest` 也不接受 plan execution 类型。

跟 §9.6 已经画过对照表:
- `Touchpoint` = 宿主已有事实(In,from pull/push)
- `PlanExecution` = PAC 驱动的执行回写(Out → In)
- 同一次接触可能两张表都有,**这是设计的冗余,不是 bug**

### 17.3 当前字段(Round 3 评审定稿后)

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | UUID(PK) | ✓ | 主键 |
| `platform_id` / `tenant_id` | TEXT | ✓ | 二级隔离基础 |
| `executor_clinic_id` | TEXT | ✓ | **执行客服所在诊所**(执行发生地,跟 plan 主表的归属不同 — plan 是患者级,execution 是诊所级事实)。**字段名明示语义,不与 plan/persona 的"归属冗余"混淆** |
| `plan_id` | UUID(FK → followup_plans.id) | ✓ | 关联 plan;cascade delete |
| `operator_user_id` | TEXT | ✓ | 客服 user id(从 access token 取) |
| `channel` | TEXT | ✓ | 触达渠道(`phone` / `wecom` / `sms` / `other`,不强枚举) |
| `outcome` | TEXT | ✓ | **强枚举** — `abandoned` / `no_answer` / `scheduled_next` / `success_appointed`(驱动 Plan 状态机) |
| `notes` | TEXT | nullable | 客服手填的电话纪要 |
| `abandon_reasons` | String[] | ✓(默认 []) | 放弃原因多选(Postgres 数组) |
| `abandon_other` | TEXT | nullable | 放弃原因 - "其他" 自由文本 |
| `scheduled_next_at` | timestamp | nullable | outcome=scheduled_next 时,下次回访时间 |
| `new_appointment_ref` | TEXT | nullable | outcome=success_appointed 时,关联宿主侧新建预约 id(可空 — 客服可能没填,见 §17.5) |
| `new_appointment_ref_source` | TEXT | nullable | **本轮新增** — `manual` / `pull_inferred` / null(见 §17.5) |
| `started_at` / `ended_at` | timestamp | started 必填 | 通话起止时间 |
| `created_at` | timestamp | ✓ | 审计 |

**索引**(本轮调整后):
- `(platform_id, tenant_id, executor_clinic_id)` — 二级隔离 + 按执行诊所过滤(运维查询"A 诊所近期执行")
- `(plan_id, started_at)` — "该 plan 最近一次执行"(替换原 `(plan_id)` 单列)
- `(operator_user_id, started_at)` — "客服最近 N 次执行"(替换原 `(operator_user_id)` 单列)
- `(outcome, new_appointment_ref, started_at)` — **本轮新增** — 反向归因 cron 用(找 `success_appointed` 但 ref 为空的近期记录)

`patient_id` 不立柱,跟其他 Plan 子表一致(plan_id 反查)。

### 17.4 `outcome` 5 个值跟 Plan 状态机的协作(§14.5 落点)

| `outcome` | Plan.status 变化 | 备注 |
|---|---|---|
| `abandoned` | → `abandoned` | — |
| `success_appointed` | → `completed` | **不依赖 newAppointmentRef 是否填**(详见 §17.5) |
| `no_answer` | 保持 `assigned` | 再次 attempt(走 plan_executions COUNT 现算) |
| `scheduled_next` | 保持 `assigned` | 同时更新 `Plan.recommendedAt = scheduled_next_at` |

`@pac/types` 的 `ExecutionOutcome` 强枚举,跟 `channel` 不一样 — channel 是描述性元数据(自由文本),outcome 是状态驱动器(强枚举),这是有意的不对称。

### 17.5 `newAppointmentRef` 三种归因路径(本轮重要决议)

**问题背景**:理想流程是"客服建预约 → 拿宿主返回 ref → 在 PAC 提交时填 ref",但现实里客服大概率忘填或不填。然而 API Pool 1 已经在 pull `appointments`,新预约最终都会作为 `Appointment` 镜像行进入 PAC。

**三种归因路径**:

| `new_appointment_ref_source` | 触发 | 置信度 |
|---|---|---|
| `manual` | 客服在 PlanExecution 提交时主动填 | 最高(本人声明) |
| `pull_inferred` | 后台 cron 扫"success_appointed 但 ref 为空" → 在 Appointment 镜像找候选 → 启发式匹配回填 | 中(算法推断) |
| `null` | 客服没填,反向归因也没匹配上 | — |

**反向归因 cron 已实现**(`apps/pac-service/src/modules/plan/back-attribution.service.ts`):
- 每 30 分钟跑一次
- 时间窗:执行后 72 小时内
- 候选条件:同 patient + appointment.createdAt 在窗口内 + 非 `cancelled`/`rescheduled` + 同 clinic
- 多候选时取最早一个(更可能是被本次召回直接触发的)

### 17.6 关键设计决策:推断不 mutate 业务状态

**Plan.status 仍以客服声明为准,反向归因只补 ref 不改 status**。

| 方案 | 行为 | 评估 |
|---|---|---|
| **A. 只补 ref,不改 Plan.status**(已采用) | cron 只补 `newAppointmentRef + refSource='pull_inferred'` | 推断不污染业务状态,事实 vs 推断分层清晰 |
| B. 自动转 completed | 客服报 `no_answer` 但 cron 发现患者有新预约 → Plan.status = completed | **不采用** — 患者可能被其他途径产生预约,误归因高估召回率 |

理由:**用推断改业务状态违反总原则 §3 事实 vs 推断分层**。如果运营在仪表盘看到"`no_answer` 但同患者短期内有新预约"的差异,人工决定是否改 status,**不自动**。

**Phase 6 之后可加**:cron 检测到 mismatch 时写一条**告警事件**(供运营 review,不改 status)。

---

## 十八、`plan_generation_logs` Plan 生成 orchestration 日志

### 18.1 这张表的定位

Plan 生成的 orchestration 账本。每次 `PlanEngineService.recomputeForPatient()` 调用一条记录,不论成功失败都落,作为 harness 层(总原则 §14)的执行账本。

### 18.2 三张 orchestration 日志的统一全景(本节重要)

至此 PAC 有**三张平行的 orchestration 日志**,各管各的域,字段贴近自身语义,**不 union 成统一表**:

| 表 | 域 | 章节 | 隔离层级 | 关键字段差异 |
|---|---|---|---|---|
| `sync_logs` | 事实接入(pull / push) | §10 | 二级(run 级跨 clinic) | `cursor_before/after` / `fetched / upserted / facts_emitted` |
| `persona_recompute_logs` | 画像重算 | §13 | 二级 + patient(per-patient 任务跨 clinic 看 fact 流) | `produced_persona_id` / `feature_count` / `fact_watermark` |
| `plan_generation_logs`(本节) | Plan 生成 | §18 | 二级 + patient(同左) | `input_persona_id` / `scenario_run_count` / `plans/scripts/summaries_created` |

**三表共有的 harness 健康度指标**(总原则 §14 落点):

| 字段 | sync_logs | persona_recompute_logs | plan_generation_logs |
|---|---|---|---|
| 状态机 `status` | running / success / partial / failed | 同左 | 同左 |
| `started_at` / `ended_at` | ✓ | ✓ | ✓ |
| `error_message` | ✓ | ✓ | ✓ |
| `agent_count`(agent 成功调用) | — | ✓ | ✓ |
| `fallback_count`(agent 失败回退) | — | ✓ | ✓ |
| `(status)` 索引 | ✓ | ✓ | ✓ |

`fallback_count` 是 PAC 整体 LLM 健康度的**早期告警信号**:跨三个域(画像 + Plan 生成,以及未来扩展)汇总一看就能判断"是上游 LLM 在抖动还是我们 prompt 有问题"。

**为什么不 union 成 `OrchestrationLog`**(三表都强调过):
- 字段差异大:`SyncLog` 有 `cursor / fetched / upserted`;`PersonaRecomputeLog` 有 `feature_count / produced_persona_id`;`PlanGenerationLog` 有 `scenario_run_count / plans_created / scripts_created / summaries_created`
- 强行 union 会变成"半个字段全为 null"的怪表
- 各自查询路径不同,索引设计也不同
- 设计哲学:**每个 orchestration 域一张账本**

### 18.3 当前字段(本轮新增)

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | UUID(PK) | ✓ | 主键 |
| `platform_id` / `tenant_id` | TEXT | ✓ | **二级隔离**(同 PersonaRecomputeLog,Plan 生成是 per-patient,跨诊所看患者全 fact 流) |
| `patient_id` | UUID | ✓ | 关联患者(本表不强 FK,允许患者删后日志保留以审计) |
| `triggered_by` | TEXT | ✓ | `fact:<eventId>` / `persona:<personaId>` / `manual:<userId>` / `scheduled:<cronId>` |
| `input_persona_id` | UUID? | nullable | 输入 Persona 版本(可空 — 无 Persona 也能跑纯规则,见 §14.2) |
| `status` | TEXT | ✓ | `running` / `success` / `partial` / `failed` |
| `scenario_run_count` | Int | ✓(默认 0) | 跑了几个 scenario(无论是否产出 plan) |
| `plans_created` | Int | ✓(默认 0) | 实际写出 plan 数(scenario 决定不召回时为 0) |
| `scripts_created` | Int | ✓(默认 0) | 写出 PlanScript 数 |
| `summaries_created` | Int | ✓(默认 0) | 写出 PlanSummary 数 |
| `agent_count` | Int | ✓(默认 0) | agent 路径成功调用次数 |
| `fallback_count` | Int | ✓(默认 0) | agent 失败回退到模板的次数 |
| `error_message` | TEXT | nullable | 失败主因 |
| `started_at` / `ended_at` | timestamp | started 必填 | 起止时间 |

**索引**:`(patient_id, started_at)` / `(platform_id, tenant_id)` / `(status)`

### 18.4 调用顺序(协议层面定死)

```
1. recomputeForPatient(patientId) → INSERT log status=running, scenarioRunCount=0
2. 读 input Persona(可空,见 §14.2)
3. 对每个 scenario:
   scenarioRunCount++
   if scenario 决定为该患者生成 plan:
     INSERT FollowupPlan
     plansCreated++
     调 ScriptAgent → INSERT AgentInvocation → upsert PlanScript
       成功:scriptsCreated++ + agentCount++
       失败:scriptsCreated++ + fallbackCount++(模板兜底,source='template_fallback')
     调 SummaryAgent → 同上,summariesCreated++ + agent/fallback 计数
4. 全部完成 → UPDATE 本表 status=success/partial,各 count 落终态
5. 中途崩溃 → UPDATE 本表 status=failed,errorMessage 落原因
```

`status` 判定规则:
- `success` ⇔ `plansCreated > 0` AND `fallbackCount = 0` AND 无 scenario 出错
- `partial` ⇔ `plansCreated > 0` AND(`fallbackCount > 0` OR 个别 scenario 出错)
- `failed` ⇔ `plansCreated = 0` AND 主流程崩溃

注:`plansCreated = 0` 但**没崩溃**(所有 scenario 都决定不召回该患者)算 `success`,不是 `failed` —— 这是有效的业务结果。

---

## 十九、`agent_invocations` AI 调用审计表(共享基础设施)

### 19.1 这张表的定位

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

**当前明确用途**:
- Plan 话术(`agentKind=script`)— 已实现流式生成
- Plan 摘要(`agentKind=summary`)— Phase 5 接入

**不排除其他环节扩展**:
- Persona 描述(`agentKind=persona_feature`)— §12.4 双轨设计 Phase 5+ 接入
- 排序辅助(`ranking`)/ Plan 解释(`plan_analysis`)— 将来按需
- 数据清洗 / 反向归因增强 — 将来按需

schema 字段 `agentKind` 是开放字符串(不强枚举),允许扩展;应用层 `AgentKind` enum 给已知取值。

### 19.2 跟三张 orchestration log 的关系(harness §14)

```
sync_logs              ─┐
persona_recompute_logs ─┼─ 调用 ──> agent_invocations
plan_generation_logs   ─┘            (本表)
```

| 维度 | orchestration log(sync/persona/plan) | agent_invocations(本表) |
|---|---|---|
| 角色 | 主流程编排账本 | 从属调用记录 |
| 粒度 | 一次重算 / pull / 生成 = 一行 | 一次 LLM API 调用 = 一行 |
| 数量关系 | 1 | 0 ~ N |
| 主要字段 | scenario_count / plans_created / fallback_count 等 | 输入 snapshot / 输出 / token / 成本 |

**关键边界**:**不立柱反向父链**(`recomputeLogId` / `generationLogId`)。同 §13.4 / §18.5 决议,失败诊断走时间窗 + linkedPatientId + agentKind 反查 orchestration log。

### 19.3 当前字段

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | UUID(PK) | ✓ | 主键 |
| `platform_id` / `tenant_id` | TEXT | ✓ | **二级隔离**(共享调用日志,基于患者全 fact 流,无固定 clinic 上下文) |
| `agent_kind` | TEXT | ✓ | 开放字符串,应用层 `AgentKind` enum 标注已知值 |
| `agent_version` | TEXT | nullable | prompt 模板 / Dify workflow 版本(应用层 best effort 填) |
| `model_name` | TEXT | nullable | **本轮新增** — 模型名(`gpt-4o` / `claude-3.5-sonnet` / `qwen-max-2024-09`),从网关 metadata 提取;跟 agent_version 分开,**审计时两者都需要** |
| `gateway_provider` | TEXT | ✓ | 网关后端(`dify` / `ai-gateway-passthrough` / ...) |
| `workflow_id` | TEXT | nullable | Dify workflow id 或网关侧 workflow 标识 |
| `input_snapshot` | JSONB | ✓ | 完整输入快照(**字面意义 snapshot**,见术语澄清) |
| `prompt_template` | TEXT | nullable | prompt 模板原文(冗余兜底,主要走 agent_version 反查 prompt 仓库) |
| `output` | JSONB | nullable | 结构化输出 |
| `output_text` | TEXT | nullable | 文本输出(流式调用时增量更新) |
| `linked_patient_id` / `linked_persona_id` / `linked_plan_id` | UUID? | nullable | 关联实体(orchestration 完成后回填;失败时为 null) |
| `prompt_tokens` / `completion_tokens` / `total_tokens` | Int | ✓(默认 0) | Token 用量 |
| `cost_cents` | Int | ✓(默认 0) | 估算成本(分;同总原则 §11) |
| `status` | TEXT | ✓ | **本轮简化为 3 个**:`running` / `succeeded` / `failed` |
| `error_message` | TEXT | nullable | 失败错误消息 |
| `started_at` / `ended_at` | timestamp | started 必填 | 起止时间 |

**索引**(本轮调整后):
- `(platform_id, tenant_id)` — 二级隔离基础
- `(agent_kind)` — 按 agent 类型统计
- `(linked_patient_id)` / `(linked_persona_id)` / `(linked_plan_id)` — 三个 linked 维度审计反查(本轮补 `linked_persona_id`,之前漏了)
- `(started_at)` — **本轮新增**,时间范围统计(token / cost / 失败率仪表盘)

---

## 二十、评审需要确认的问题清单(请技术评审组逐项给意见)

### platforms

> **TODO**:本表涉及宿主接入契约(`pull_config` / `field_mapping` / `credentials` 等)。PAC 按本评审定稿的 schema 出对接文档,后续邀请宿主(Friday / 5i5y)开发参与评审,在那一轮收集宿主侧反馈。本轮 db-review 不就宿主对接细节展开。

### 用户 / 角色 / 权限

- [ ] PAC 不建用户表,所有用户身份在宿主侧 — 集团内对齐过吗?
- [ ] 三个固定 role(staff / leader / admin)够不够覆盖一线 + 主管 + 管理员?
- [ ] 不做自定义角色 / 自定义权限,等 v1.5 再加,接入方能接受吗?

### patients

- [ ] **患者唯一性在 tenant 级**(`UNIQUE(platform_id, tenant_id, external_id)`):同一物理人在集团所有诊所共享同一 patient 主档,跨诊所就诊不重复建档。**这要求宿主侧 external_id 跨诊所一致**(对接契约硬要求,见 §3.4),OK 吗?
- [ ] **patient 主档去除 `clinic_id`**:跨诊所归属通过 `patient_fact_events.clinic_id` 反查,**不立 `patient_clinic_memberships` 关联表**(召回池按诊所过滤已由 `followup_plans.target_clinic_id` 业务字段满足);Phase 6 真出现性能问题再加物化视图,OK 吗?
- [ ] **`do_not_contact` 在 tenant 级生效**(跨诊所联动,合规默认),配合 `PatientDncLog` 审计日志透明化("被排除原因"显示给所有诊所客服)避免黑盒感,OK 吗?
- [ ] `gender` 不强枚举,在 ingest 时不归一化,OK 吗?

### patient_fact_events

- [ ] **append-only 不允许 update / delete**(出错只能补修正事件),OK 吗?
- [ ] 唯一约束 `(platform, tenant, fact_type, external_ref)` 用作幂等键 — 强度够吗?是否需要进一步加 `occurred_at` 维度?
- [ ] **投影策略**:事件 → 镜像表(Diagnosis / Treatment / Appointment / Charge / Touchpoint)用同步投影(方案 A)还是异步队列(方案 B)?见 §4.7
- [ ] payload 不强制 schema(应用层 zod 按 fact_type 分别校验),OK 吗?
- [ ] payload 大小是否需要应用层限制(如 64KB)?
- [ ] payload 内 PII 防重存(name / phone / id_card)用文档约定还是 zod deny-list?
- [ ] 保留期限:fact event v1 永久保留,OK 吗?(国内医疗合规一般要求 ≥ 15 年)

### diagnoses

- [ ] Diagnosis 不加 `status` / `tooth_codes` / `recommended_review_at`,召回信号靠 PatientFactEvent + Treatment + Appointment 联合推断,OK 吗?
- [ ] `doctor_id` 改可空 + 新增 `doctor_name`,容许"只有医生姓名没有 id"的小宿主接入,OK 吗?
- [ ] 不引入 `Encounter` / `Visit` 表,就诊时间线靠现有 4 张表 + 事件流,OK 吗?

### treatments

- [ ] **事实 vs 推断口径**(总原则 §3 在 Treatment 的具体落点):`next_review_at`(医嘱)与 `FollowupPlan.recommendedAt`(PAC 推断)平行存在、各自有审计来源,OK 吗?
- [ ] 立柱判据(总原则 §8):按此加 `started_at` / `next_review_at` / `treatment_code` / `notes`,OK 吗?
- [ ] 不在 Treatment 单行加 `treatment_chain_status`,DW 给的"治疗链完成状态"以 fact event + Persona feature 形式落,OK 吗?
- [ ] `next_review_at` 与 `recommendedAt` 同时存在时,召回池排序以 `recommendedAt` 为准、`next_review_at` 仅展示,这个口径 OK 吗?

### appointments

- [ ] `arrived_at`(实际到诊时间)立为镜像核心字段,召回算法以它为准、`status` 字符串仅展示,OK 吗?
- [ ] `appointment_type` 不强枚举(初诊 / 复诊 / 急诊...宿主自由文本),OK 吗?
- [ ] 改约不在镜像里留旧 `scheduled_at`,变化轨迹去 `patient_fact_events` 取(宿主 upsert 同 `external_id` 时直接覆盖),OK 吗?
- [ ] 不立柱 `cancelled_at` / `rescheduled_at` / `is_no_show` 等推断字段,失约判定算法即时算,OK 吗?
- [ ] 索引改为 `(patient_id, scheduled_at)` + `(patient_id, arrived_at)` 替代分开两个单列索引,OK 吗?

### charges

- [ ] 金额一律 `Int` 分(总原则 §11-13),跨宿主单位差异在入口层 adapter 解决,OK 吗?
- [ ] `pullConfig.amountUnit`(`"fen" \| "yuan"`)作为通用 adapter 的单位提示,自定义 adapter 在代码里处理,OK 吗?
- [ ] 退款用 `refundAmountCents` 累加 + `refundedAt` 取最近一次表达,变化轨迹去 `charge_refunded` 事件流取,OK 吗?
- [ ] 不立柱 `net_amount_cents`(`amount - refund` SQL 现算)、`payment_method`、`invoice_number`、`original_amount`、`discount_amount`,OK 吗?
- [ ] 删除 `@@index([status])` 单列索引(选择度低,被 `(p,t,clinic)` 和 `(patient_id, charged_at)` 覆盖),OK 吗?
- [ ] `category` 不强枚举(各宿主分类逻辑差异大),由 Persona 层归一化,OK 吗?

### touchpoints

- [ ] 加 `direction` 字段区分 inbound / outbound,频次熔断只数 outbound,inbound 反向作为 engagement 特征,OK 吗?
- [ ] 加 `duration_seconds`(通话时长)、`task_ref`(宿主回访任务关联),其他候选(`scenario` / `next_contact_at` / `appointment_ref` 等)不加,OK 吗?
- [ ] **Touchpoint vs PlanExecution 严格区分**(§9.6):前者是宿主已发生事实(In),后者是 PAC 驱动的执行回写(Out → In),即使表达同一次接触也保留两条,OK 吗?
- [ ] 同一触达被宿主多模块各记一笔时,按宿主接入文档声明的优先级取一份(回访任务 > SCRM > 400),OK 吗?
- [ ] `outcome` / `channel` 不强枚举(各宿主取值差异大),由 FieldMapper / Persona 层做归一化,OK 吗?
- [ ] inbound 通话需要宿主明确 push 并标 `direction='inbound'`,否则 engagement 特征偏废,这条对接要求加进接入文档,OK 吗?

### sync_logs

- [ ] SyncLog 故意只立 `platform_id` + `tenant_id` 两级隔离(run 级别没有 clinic 概念,镜像表数据仍三级隔离),OK 吗?
- [ ] `partial` vs `failed` 严格按"游标有没有推进"区分,OK 吗?
- [ ] `resource='*'` 表示跨资源 run,本轮不拆细;若 partial 高频再拆每资源一条,OK 吗?
- [ ] `triggered_by` 当前可空(冷启动 backfill 路径未必带),v1 上线 1 个月后渐进收紧为必填,OK 吗?
- [ ] 删 `created_at` 字段(与 `started_at` 实践恒等),OK 吗?
- [ ] `AdapterKind.MOCK` 从 enum 中移除(mock 走 pull 路径),OK 吗?
- [ ] **单患者即时 pull**(§10.7):立 `patient_id?` 字段、强约束 cursor 不推进、30s in-flight + 60s cooldown、宿主 404 不直接改 Patient.status,OK 吗?
- [ ] `PullStrategy.pullPatient` 可选方法 — 当前只 Mock 实现,真宿主 adapter 待 Phase 2 补,过渡期返回 `30804`,OK 吗?
- [ ] 单患者 pull 的 cooldown 阈值 60 秒;后续平台级可覆盖(`pullConfig.singlePatientCooldownSeconds`),OK 吗?

### personas(§11)

- [ ] **Persona 单层设计**:为什么需要单独一层(§11.1)而不是 fact → plan 直连,这套论证集团内对齐过吗?
- [ ] 触发模型:**异步队列(主路径)+ 定时全量(兜底)+ admin 手工(debug)**,v1 主路径必须有队列基建,这个工作量评估过吗?
- [ ] **新加 `Persona.fact_watermark` 字段**(判断 stale),OK 吗?
- [ ] **实施缺口**:Persona 写入路径完全未实现,Phase 3 (W3-W4) 必须补齐 P0 最小集(见 §11.5),工作量评估过吗?

### persona_features(§12)

- [ ] **PDF 硬约束**:`description` 不拆 `value_score / treatment_value / risk_score` 等固定子字段(§12.3 / PDF §5.2 / 验收口径 #10),OK 吗?
- [ ] **规则 vs Agent 双轨并存**:同一 `persona_features` 表用 `evidence.agentInvocationIds` 是否非空区分来源(不立柱单 ID 字段,数组形式为多 agent 协作留口子,跟 PlanScript / PlanSummary 统一);v1 全规则,Phase 5+ 接入 agent,OK 吗?
- [ ] **Agent 失败降级到规则**(不阻塞 Persona 主版本写出),OK 吗?
- [ ] **特征键 v1 范围**:`value` / `treatment_chain_status` / `engagement` / `recall_priority` 四个起步,Phase 6 后再扩,OK 吗?

### persona_recompute_logs(§13)

- [ ] **执行账本** 跟 `SyncLog` 平行(各自一张表,不 union 成 OrchestrationLog),OK 吗?
- [ ] **harness 健康度指标** `rule_count` / `agent_count` / `fallback_count` 立柱在执行账本里,OK 吗?
- [ ] 不加 `AgentInvocation.recomputeLogId` 反向链,失败场景靠时间窗 + patient_id 反查,OK 吗?
- [ ] 90 天滚动保留 + 卡死 running 任务启动时清理,OK 吗?

### followup_plans(§14)

- [ ] **Plan 跟 Persona 关系**(§14.2):Persona 是**可选增强**不是硬依赖,纯规则路径(无 Persona)+ 增强路径(有 Persona)两种工作模式并存,OK 吗?
- [ ] **Harness 工程观应用**(总原则 §14):Plan 主表 99% 字段全规则化(确定性),只有 PlanScript / PlanSummary 必须 agent,OK 吗?
- [ ] **`inclusion_reason` / `goal` v1 走规则模板,Phase 5+ agent 增强**,OK 吗?
- [ ] **状态机简化为 5 个**(删 EXECUTING / RECYCLED),回收语义改为"动作"而非状态,OK 吗?
- [ ] **删 `contact_attempts` / `last_contacted_at` 冗余字段**,熔断查询改 SQL 子查询;v1 试点规模够用,集团规模再加回,OK 吗?
- [ ] **新增 partial unique index `one_open_plan_per_scenario`**(`status IN active|assigned`)DB 层兜底单一活跃版本,OK 吗?
- [ ] **召回池查询索引** `(status, assignee_user_id, priority_score)` 联合索引替换原 `(priority_score)` 单列索引,OK 吗?
- [ ] **实施缺口**:fact event 触发器 / 回收 cron / PlanScript / PlanSummary / PlanGenerationLog 都未实现,Phase 4-5 必须补齐,工作量评估过吗?

### plan_scripts(§15)

- [ ] **二级隔离**(`platform_id + tenant_id`):跟 plan 一致跨诊所共享,不立 `patient_id`(Plan 反查),不立 `clinic_id`(诊所归属在 plan.target_clinic_id 上),OK 吗?
- [ ] **`source` 字段**(§15.4):`agent` / `template_fallback` / `manual` 三值,harness §14 落点;agent 失败必须降级写 `template_fallback` 行,OK 吗?
- [ ] **单一最新规则** `UNIQUE(plan_id, script_type)`:Plan 重生成时 upsert 替换;不在自表做版本,Plan 版本流自然带 OK 吗?
- [ ] **`script_type` 不强枚举**(应用层 zod 给 enum),将来扩 closing / scheduling_confirm 等不锁死 schema,OK 吗?
- [ ] **默认话术模板放 `Platform.rulesConfig.scriptTemplates`**,不立独立表;agent 失败时从 rulesConfig 读 fallback,OK 吗?
- [ ] **多 scriptType 一次 agent 产出**(JSON 输出含 opening + follow_up + objection),token 成本低 + 风格一致,失败时整体回退模板,OK 吗?
- [ ] **触发时机**:Plan 主表写入后异步队列触发 ScriptAgent(staff 打开 plan 时大概率已就绪),OK 吗?
- [ ] **实施缺口**:自动触发 / template_fallback / 多 type 一次产出 / PlanGenerationLog 协作 都未实现,Phase 5 必须补齐,工作量评估过吗?

### plan_summaries(§16)

- [ ] **跟 PlanScript 完全对偶**(§16.1)— 二级隔离 / source / unique / 不立 patient_id / 必须 agent + 可降级 全部一致,OK 吗?
- [ ] **per-plan 而非 per-patient**(§16.3):为避免跟 Persona 的 invalidation 逻辑重叠;Phase 6 试点反馈"重复生成耗时"再做 PatientSummary,OK 吗?
- [ ] **`OutputSource` enum 共享**(§16.4):PlanScript / PlanSummary 共用同一组 source 取值;旧 `ScriptSource` 保留为别名,OK 吗?
- [ ] **`content` 8000 字符上限**(比 PlanScript 4000 更高 — 病历摘要 / markdown 表格),OK 吗?
- [ ] **三种 summaryType 一次产出**(JSON 输出 + 整体回退到模板)还是多次调用?**倾向一次产出**,Phase 5 实测后调整,OK 吗?
- [ ] **summary 跟 script 共享同一次 agent 调用**(prompt 同时输出 scripts + summaries 大 JSON)?**倾向共享**,token 成本 + 风格一致性双优;OK 吗?
- [ ] **实施缺口**:summary 写入路径完全未实现(比 script 还差),Phase 5 必须补齐三种 type 各自的生成 + 模板兜底,工作量评估过吗?

### plan_executions(§17)

- [ ] **`clinic_id` 改名 `executor_clinic_id`**(明示语义 = 执行客服所在诊所,执行发生地;不与 plan/persona 的"归属冗余"混淆),OK 吗?
- [ ] **接口 1/2 硬边界**(总原则 §4):PlanExecution 绝不写到 `patient_fact_events`,跟 Touchpoint(In)严格分离,OK 吗?
- [ ] **`outcome` 强枚举驱动 Plan 状态机**(§17.4 / §14.5):4 个 outcome 直接对应 Plan.status 转移,OK 吗?
- [ ] **`newAppointmentRef` 可空 + 反向归因 cron**(§17.5):宿主"懒得回填 ref"是可接受现实,后台 cron 在 Appointment 镜像启发式匹配回填,OK 吗?
- [ ] **`new_appointment_ref_source` 三值 enum**(`manual` / `pull_inferred` / null):evidence 链清晰区分客服声明 vs 系统归因,OK 吗?
- [ ] **推断不 mutate 业务状态**(§17.6):反向归因只补 ref,**不改 Plan.status**;高估召回率风险由"以客服声明为准"避免,OK 吗?
- [ ] **索引复合化**:`(planId)` / `(operatorUserId)` 单列改为带 `started_at` 的复合索引;新增 `(outcome, newAppointmentRef, started_at)` 服务反向归因 cron,OK 吗?
- [ ] **`abandoned` 不自动联动 `do_not_contact`**(§17.11.2):过度自动化易误伤,加 admin 后台审核工具,OK 吗?
- [ ] **同 plan 重复提交 guard**(§17.11.3):plan 已终态时拒绝再次提交,Phase 5 必须补,工作量评估过吗?
- [ ] **HMAC 验签**(§17.11.1):v1 token 鉴权够用,接入第三方宿主时再加,OK 吗?

### plan_generation_logs(§18)

- [ ] **三张 orchestration 日志平行**(§18.2):`sync_logs` / `persona_recompute_logs` / `plan_generation_logs` 各管各域,**不 union 成 OrchestrationLog**,字段差异大,OK 吗?
- [ ] **harness §14 健康度指标统一**:三个域共用 `agent_count` / `fallback_count`,跨域汇总 `fallback_count` 是 LLM 抖动的早期告警信号,OK 吗?
- [ ] **三个 created 计数立柱**(`plans_created` / `scripts_created` / `summaries_created`):COUNT 现算成本高,立柱换性能 + 仪表盘友好,OK 吗?
- [ ] **`triggered_by` 必填**(不像 SyncLog 可空):新表无历史包袱,v1 起规范化触发来源,OK 吗?
- [ ] **`triggered_by='persona:<personaId>'` chain trigger 标识**:Persona 重算 → 触发 Plan 重算的链式追溯,通过前缀区分,不立反向链字段,OK 吗?
- [ ] **`plans_created=0` 算 success**(scenario 决定不召回是有效业务结果,不是 failed),OK 吗?
- [ ] **实施缺口**:PlanGenerationLog 写入路径完全未实现,Phase 5 必须跟 §14.8 同步补齐,工作量评估过吗?
- [ ] **保留期限**:90 天滚动跟 PersonaRecomputeLog 一致,OK 吗?

### agent_invocations(§19)

- [ ] **共享基础设施定位**(§19.1):`agentKind` 开放字符串支持未来扩展(Persona / Summary / Ranking 等),应用层 enum 给已知值,OK 吗?
- [ ] **跟三张 orchestration log 的关系**(§19.2):被调用的从属表,**不立柱反向父链**;失败诊断走时间窗 + linkedPatientId + agentKind 反查,OK 吗?
- [ ] **`agentVersion` + `modelName` 拆两字段**(§19.5):prompt 侧版本和 LLM 侧版本独立变化,审计时两者都需要,OK 吗?
- [ ] **`AgentKind` enum 加 SUMMARY / PERSONA_FEATURE**;`MEDICAL_SUMMARY` / `ONE_PAGE` 标 deprecated 改用 SUMMARY + summaryType 子分,OK 吗?
- [ ] **状态机简化为 3 个**(`running` / `succeeded` / `failed`,删 STREAM_*),跟其他 orchestration log 统一,OK 吗?
- [ ] **`linked*` 三字段独立立柱**(§19.5),不用 polymorphic 设计;反向链不立 `linkedFeatureId` / `linkedScriptId` 等(粒度过细),OK 吗?
- [ ] **`inputSnapshot` 是字面意义 snapshot**(术语澄清章节),JSONB 不拆字段,OK 吗?
- [ ] **保留期限 90 天**跟其他 orchestration log 一致;`inputSnapshot` 是大字段,归档时优先压缩,OK 吗?
- [ ] **实施缺口**:`agentVersion` 实际填入未实现 / Persona-Summary agent 接入未实现 / 卡死任务清理未实现,Phase 5 必须补,工作量评估过吗?

---

## 二十一、本轮确定改动点(Plan 层)

**结论**:

- **Plan 是 tenant 级隔离实体**(跟 patient 一致),`UNIQUE(platform_id, tenant_id, patient_id, scenario, version)`
- **加两个 clinic 业务字段**(都不是隔离维度):
  - `followup_plans.target_clinic_id`(nullable) — 期望执行召回的诊所(归属);Plan 生成时锁定不更新;`null` = 集团统一客服池(VIP 中心 / 中央营销 / 流失挽回等场景)
  - `plan_executions.executor_clinic_id`(必填) — 实际执行客服所在诊所(执行发生地);跟 target 允许不一致(代办场景)

### 21.1 待讨论:Plan / PlanExecution 是否回写 fact 层

提议建议 Plan 作为 `planned fact` 回写、PlanExecution 作为 `actual fact` 回写。结论:**都不进**。

**根因**:提议 §16 对应表把 `touchpoints` 错误地等同于"宿主触达 + PAC 执行 transaction",**混了接口 1(宿主拉回的事实)和接口 2(PAC 自己回写)**。基于这个错误前提才推出"PlanExecution 该作为 followup_execution_record fact 回写"。

实际上:

- **Plan 回写**:**不算事实**(主语是 PAC,不是患者发生过的事);进了创造循环自反馈(fact → persona → plan → fact → ...),PAC 把自己决策当患者属性
- **PlanExecution 回写**:**不是 PAC 视角的"患者事实"**(主语是 PAC + 客服执行,接口 2 回写)。写入 `plan_executions` 后**反馈到 Plan 层而非 Fact 层**:
  - 驱动 Plan 状态机(active → assigned → completed / abandoned)
  - 累积统计(回访次数 = COUNT plan_executions WHERE plan_id)
  - 触发熔断(累积达 maxContactAttempts → Plan 自动 abandoned)
  - 下次 Plan 重算时读取累积数据 → 决定继续生成 / 调整优先级
  - Persona engagement feature 应用层显式 join 消费(PAC 跟宿主回访模块互斥,没有 touchpoint_record 数据源)

### 21.2 待讨论:Persona 层

**命名 Persona vs Assessment** — 不改。PDF §6.4 钦定术语是 Persona,改名破坏 PDF / 代码 / 文档对齐。

**feature_key 枚举值**:合并 PDF §6.4 + 提议 §14 候选 12 个,v1 起步选哪几个待定:

| 候选 feature_key | 来源 | 实现难度 |
|---|---|---|
| `value`(患者价值) | PDF + 提议 | 低(规则:消费 SUM) |
| `treatment_chain_status`(治疗链状态) | PDF | 低(规则:treatment 状态聚合) |
| `incomplete_treatment`(未完成治疗) | PDF | 低(规则:treatment 状态聚合) |
| `recall_risk`(复查风险) | PDF + 提议 | 中(规则:复查窗口 + 时间) |
| `engagement`(触达活跃度) | (本评审起步) | 低(规则:touchpoint COUNT) |
| `do_not_contact_status`(不打扰状态) | 提议 | 低(规则:patient.doNotContact) |
| `recommended_role`(推荐回访角色) | 提议 | 低(规则:scenario 配置) |
| `referral_relation`(推荐关系) | PDF | 中(规则:推荐人字段) |
| `doctor_relation`(医生关系) | PDF | 中(规则:主治医生频次) |
| `communication_preference`(沟通偏好) | PDF | 高(agent:从 touchpoint notes 推断) |
| `customer_experience_risk`(客户体验风险) | PDF + 提议 | 高(agent:综合投诉/退款/失败触达) |
| `treatment_intent`(治疗意向) | 提议 | 高(agent:综合诊断 + 行为) |
| `family_social_relation`(家庭社会关系) | PDF | 高(agent + 缺数据来源) |

**`scope` 字段** — 不加。建议是为"某诊所视角下的患者评价"留 hook,例如:

> P001 在 A 诊所是 VIP(消费高),在 B 诊所是普通(只去过一次),集团整体看高价值 — 三种视角各算一份 Persona

### 21.3 决议:第一层重构为 Transaction + Fact 双表(评审过完后实施)

**核心改动**:
- **消灭 5 张事实镜像表**(`diagnoses` / `treatments` / `appointments` / `charges` / `touchpoints`)
- **拆出 `patient_transactions`(操作账本)+ `patient_facts`(事实单元)双表**(对齐建议文档 §6 / §10 方向)
- `patients` 主档保留(身份对象,不属事实单元)

**两表语义 + 写入模式**:

| 表 | 语义 | 写入模式 | 关键约束 |
|---|---|---|---|
| `patient_transactions` | 操作账本 — 谁(系统/人)做了什么操作(创建预约 / 改预约 / 患者签到 / 提交收费单 ...) | **append-only** | `UNIQUE(platform, tenant, source_event_id)` 幂等;INSERT only,错了只能补修正 transaction;物理删除走 cascade |
| `patient_facts` | 事实单元 — 患者事实中形成 / 演进的实体(预约计划 / 治疗记录 / 收款记录 / 病历记录 ...);分 `actual`(已发生)/ `planned`(未来安排)。**只装宿主侧事实**,PAC 内部产物(Plan / PlanExecution / AI 输出)**不回写** | **版本流(append + supersede)** | 新版本 = 新 UUID(技术主键);`subject_id`(业务身份)跨版本稳定;`(subject_id, version)` 单调递增;**partial UNIQUE `(subject_id) WHERE fact_status='active'`** 强制单活跃版本;evidence 引用具体 `fact.id` 拿版本快照;**`clinic_id` 立柱但 nullable**(actual / planned 统一字段语义,反例:中央客服 400 接的 `consultation_record`、集团总部群发触达;必填性 per fact_type 在应用层 zod 校验) |

**双表协作**:transaction 解析器(parser)将 transaction 解析为 fact —— 一个 transaction 可能产生 0/1/N 个 fact 状态变化。

**三类 type 严格区分**(对齐建议文档 §2 / §11 / §12,明确业务边界,便于宿主对接):

| type 字段 | 摄入路径 | 含义 | 提议枚举数 |
|---|---|---|---|
| `transaction_type` | **push 主导**(宿主推过来"什么操作发生了") | 动作事实(`appointment_created` / `payment_received` 等) | 14 个起步 |
| `subject_type` | **pull 主导**(PAC 按资源类型拉,如 `GET /appointments`) | 被操作主体类型(`appointment` / `payment` 等) | 10 个起步 |
| `fact_type` | 下游统一(parser 解析后) | 患者事实单元(`appointment_plan` / `payment_record` 等),分 `actual` / `planned` | actual 12 + planned 5 |

**Push 路径**:宿主天然带动作语义(`appointment_changed`)→ `transaction_type` 直接落,`subject_type` 是对象。
**Pull 路径**:宿主 API 按资源拉(`subject_type=appointment`)→ PAC adapter 比较前后态合成 `transaction_type`(`created` / `changed` / `cancelled`)。

所有 type 字段 schema 用 String 不强枚举,应用层 zod enum 标注已知值(跟 channel / outcome / agentKind 风格统一),加新 type 不要 migration。

**v1 起步枚举(待评审确认)**

> 提议原始范围已剔除 PAC 内部动作(Plan / PlanExecution 不回写)和 AI 产物(归 `agent_invocations.output`),保留**只装宿主侧事实**的部分。
>
> **起步策略 — 最小 transaction + parser 衍生 fact**:transaction_type 14 个起步(原版);fact_type 12 个 actual(原 10 + `treatment_record` + `diagnosis_record`)由 adapter parser 从 encounter / emr / charge 类 transaction 衍生。后期真需要精度时可补专门的 transaction_type(treatment_started/completed/cancelled、diagnosis_added、patient_registered/updated 等)。
>
> **触达数据**:PAC 跟宿主回访模块**互斥**(有回访的宿主不接 PAC),无 touchpoint 类 fact;Persona engagement feature 应用层显式 join `plan_executions` 消费。
>
> **来源分类**:**A 原生**(宿主直接提供)/ **B 推导**(PAC adapter 比较前后态合成)/ **C 看接入**(宿主能力差异)
> **PAC 优先级**:🔴 必补(W3-W4 评估必需)/ 🟡 应补(辅助 / 审计)/ 🟢 可缺失(看宿主)

`transaction_type`(14 个):

| transaction_type | 中文 | subject_type | 典型 fact 结果 |
|---|---|---|---|
| `consultation_created`(咨询记录创建) | 咨询记录被创建 | `consultation` | actual: `consultation_record` |
| `appointment_created`(预约创建) | 预约被创建 | `appointment` | planned: `appointment_plan` |
| `appointment_changed`(预约变更) | 预约被变更 | `appointment` | 新 planned + 旧 superseded |
| `appointment_cancelled`(预约取消) | 预约被取消 | `appointment` | planned cancelled |
| `patient_arrived`(到店签到) | 患者到店签到 | `visit_reception` | 参与构建 `patient_received_record` |
| `visit_registered`(挂号) | 挂号发生 | `visit_registration` | actual: `visit_registration_record` |
| `patient_received`(被接诊) | 患者被接诊 | `visit_reception` | actual: `patient_received_record` + planned: `appointment_plan` fulfilled |
| `doctor_encounter_started`(医生接诊) | 医生接诊开始 | `clinical_encounter` | actual: `encounter_record`;**parser 衍生** `diagnosis_record` / `treatment_record` / `treatment_plan` / `review_plan` |
| `charge_order_submitted`(收费单提交) | 收费单被提交 | `charge_order` | planned: `payment_plan` + actual: `charge_order_record`;**parser 衍生** `treatment_record`(从收费项目)|
| `payment_received`(收款) | 收款被确认 | `payment` | actual: `payment_record` + planned: `payment_plan` fulfilled |
| `refund_created`(退费) | 退费被创建 | `refund` | actual: `refund_record` |
| `emr_submitted`(病历提交) | 病历被提交 | `emr_document` | actual: `emr_record`;**parser 衍生** `diagnosis_record` / `treatment_record` |
| `emr_rejected`(病历打回) | 病历被打回 | `emr_document` | actual: `emr_quality_record` |
| `image_uploaded`(影像上传) | 影像被上传 | `image_study` | actual: `image_record` |

`subject_type`(10 个):

`consultation`(咨询)/ `appointment`(预约)/ `visit_registration`(挂号)/ `visit_reception`(接诊登记)/ `clinical_encounter`(医生接诊)/ `emr_document`(病历文档)/ `charge_order`(收费单)/ `payment`(收款)/ `refund`(退费)/ `image_study`(影像资料)

`fact_type` — Actual Fact(12 个,已发生事实):

`consultation_record`(咨询记录)/ `visit_registration_record`(挂号记录)/ `patient_received_record`(患者被接诊记录)/ `encounter_record`(医生接诊记录)/ `diagnosis_record`(诊断记录 — parser 衍生)/ `treatment_record`(治疗记录 ⭐ 召回核心 — parser 衍生)/ `charge_order_record`(收费单提交记录)/ `payment_record`(收款记录)/ `refund_record`(退费记录)/ `emr_record`(病历记录)/ `emr_quality_record`(病历质控记录)/ `image_record`(影像资料记录)

`fact_type` — Planned Fact(4 个,未来安排):

`appointment_plan`(预约计划)/ `treatment_plan`(治疗计划,由 encounter parser 衍生)/ `review_plan`(医嘱复查计划,由 encounter parser 衍生)/ `payment_plan`(待缴费计划)

> **已剔除项**(PAC 数据流单向 → 不回写):`followup_plan_*` / `followup_executed` 4 个 transaction_type、`followup_plan` / `followup_execution` 2 个 subject_type、`followup_plan` / `followup_execution_record` / `image_analysis_result` 3 个 fact_type。
>
> **parser 衍生机制**(关键设计):**一个 transaction 可产多个不同 subject 的 fact**。`treatment_record` / `diagnosis_record` 不需要专门 transaction_type,由 adapter parser 从 `encounter_record` / `emr_record` / `charge_order_record` 等原始 transaction 解析衍生:
> - **`doctor_encounter_started`** → encounter_record + parser 衍生 treatment_record / diagnosis_record / treatment_plan / review_plan
> - **`emr_submitted`** → emr_record + parser 衍生 treatment_record / diagnosis_record
> - **`charge_order_submitted`** → charge_order_record + parser 从收费项目名衍生 treatment_record
>
> **后期可扩展**:若需要精度提升,可补 `treatment_started/completed/cancelled` / `diagnosis_added` / `patient_registered/updated` 等专门 transaction_type。**起步靠 parser 衍生,后期靠 transaction 精确**。

#### `patient_transactions` 字段(append-only)

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | UUID(PK) | ✓ | 主键 |
| `platform_id` / `tenant_id` | TEXT | ✓ | 二级隔离 |
| `patient_id` | UUID(FK → patients.id) | nullable | 关联患者(部分管理操作不关联) |
| `source_system` | TEXT | ✓ | 来源子系统(`friday` / `5i5y` / `400` / `dw` / `manual`) |
| `source_event_id` | TEXT | nullable | 宿主事件 ID 或 adapter 合成 hash;**幂等键** |
| `source_actor_id` | TEXT | nullable | 操作人(宿主 user id) |
| `source_actor_role` | TEXT | nullable | 操作角色 |
| `clinic_id` | TEXT | ✓ | 操作发生地(必填) |
| `transaction_type` | TEXT | ✓ | 动作事实(应用层 zod enum) |
| `subject_type` | TEXT | ✓ | 主体类型(应用层 zod enum) |
| `subject_id` | TEXT | ✓ | 宿主侧主体 ID |
| `operation` | TEXT | ✓ | 动作动词(`created` / `changed` / `cancelled` ...) |
| `before_snapshot` | JSONB | nullable | 操作前态(宿主能给就存) |
| `after_snapshot` | JSONB | ✓ | 操作后态 |
| `raw_payload` | JSONB | ✓ | 原始入参快照 |
| `payload_hash` | TEXT | ✓ | payload 指纹(合成 source_event_id 兜底) |
| `transaction_time` | timestamptz | ✓ | 宿主侧实际发生时间 |
| `received_at` | timestamptz | ✓(default now) | PAC 接收时间 |
| `ingest_id` | UUID | nullable | 关联 sync_log run id |
| `created_at` | timestamptz | ✓ | 审计 |

**索引**:
- partial UNIQUE `(platform_id, tenant_id, source_event_id) WHERE source_event_id IS NOT NULL` — 幂等键
- `(platform_id, tenant_id, clinic_id)` — 三级隔离基础
- `(patient_id, transaction_time)` — 按患者拉时间轴
- `(subject_type, subject_id)` — 按业务主体反查
- `(transaction_type)` — 按类型查
- `(received_at)` — 同步运维

#### `patient_facts` 字段(版本流)

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | UUID(PK) | ✓ | 主键(**每版本独立 UUID**) |
| `platform_id` / `tenant_id` | TEXT | ✓ | 二级隔离 |
| `patient_id` | UUID(FK → patients.id) | ✓ | 关联患者 |
| `subject_id` | TEXT | ✓ | **业务身份,跨版本稳定**(对应宿主侧实体 ID,如 `apt_001`) |
| `fact_kind` | TEXT | ✓ | `actual` \| `planned` |
| `fact_type` | TEXT | ✓ | 应用层 zod enum(actual 11 + planned 4) |
| `fact_status` | TEXT | ✓ | `active` / `superseded` / `cancelled` / `fulfilled` / `expired` / `invalidated` |
| `version` | Int | ✓ | `(subject_id, version)` 单调递增 |
| `clinic_id` | TEXT | nullable | **立柱但 nullable**(per fact_type 应用层校验) |
| `occurred_at` | timestamptz | nullable | actual fact 必填 |
| `planned_for` | timestamptz | nullable | planned fact 推荐填 |
| `valid_from` | timestamptz | nullable | planned 时间窗起 |
| `valid_until` | timestamptz | nullable | planned 时间窗止 |
| `fact_title` | TEXT | nullable | 简短标题(UI 展示) |
| `fact_summary` | TEXT | nullable | 自然语言摘要 |
| `fact_content_json` | JSONB | ✓ | 结构化内容(per fact_type schema 由应用层 zod 校验) |
| `evidence_transaction_ids` | TEXT[] | ✓ | 关联触发的 transaction id 数组 |
| `superseded_by` | UUID | nullable | 指向新版本的 id |
| `created_at` | timestamptz | ✓ | 审计 |
| `updated_at` | timestamptz | ✓ | 仅 supersede 时更新 `fact_status` |

**索引**:
- `UNIQUE(platform_id, tenant_id, subject_id, version)` — 版本去重
- partial UNIQUE `(platform_id, tenant_id, subject_id) WHERE fact_status='active'` — 强制单活跃版本
- `(platform_id, tenant_id, clinic_id)` — 隔离基础(clinic_id IS NULL 时不参与)
- `(patient_id, occurred_at)` — 按患者拉 actual 时间轴
- `(patient_id, planned_for)` — 按患者查 planned
- `(fact_type, fact_status, valid_until)` — 复查 / 待办类查询
- GIN on `evidence_transaction_ids` — 反查"某 transaction 影响了哪些 fact"

#### 跟提议的字段差异(基于"数据流单向"决议剔除)

| 删除字段 | 理由 |
|---|---|
| `source_kind` | patient_facts 只装宿主事实,该字段永远是 `host_transaction`,冗余 |
| `processor_invocation_ids` | AI 产物不进 fact,归 `agent_invocations.output` |
| `evidence_fact_ids`(fact 引用 fact) | 引用关系在 transaction 维度 |
| `confidence`(AI 置信度) | 不属宿主事实 |
| `primary_clinic_id`(actual 性能冗余字段) | nullable 后不需要 |
| 提议的合成幂等键(组合 5 字段) | 用 `source_event_id`(宿主给或合成 hash)单字段更简洁 |

**本轮不立刻动**,评审三层全过完后统一重构,避免分批改造引入不一致。

### 21.4 结论:`evidence` 字段维持 JSON 不立柱

证据链字段(personas / persona_features / followup_plans / plan_scripts / plan_summaries 上的 `evidence`)保持 JSONB,**不拆成 `evidence_fact_event_ids` / `evidence_rule_ids` 等独立列**。理由:

- **没有高频 SQL 筛选 / 排序需求**(原则 7 立柱判据未满足) — 主要消费场景是审计单行读
- **形态会持续扩展**(Phase 5+ 加 promptVersion / scenarioConfigVersion;Phase 6+ 可能加 externalSignalIds 等),立柱每次都要 migration
- **应用层 zod 已校验**类型安全,DB 不强求

真出现"某 fact 影响了哪些 plan"反查热点,按需建 GIN 索引(`USING GIN (evidence)`),不进 v1 默认索引。

### 21.5 衍生能力:影像 AI 识别(展望,不进 v1)

提议引入 `image_uploaded` / `image_analysis_result` 等影像相关 fact_type 是个信号 —— PAC 接收影像事实后,**影像识别可作为衍生能力**:

| 阶段 | 影像参与度 |
|---|---|
| Phase 1 重构后 | 仅接收 `image_record` fact(知道患者有影像检查),**不分析、不复制原文件**,影像 URL 引用宿主侧 |
| Phase 5+ 试点 | 多模态 Agent(GPT-4V / Claude 3 / Qwen-VL)消费影像 URL,输出落到 `Persona.features`(如 `x_ray_findings`)和 `PlanSummary` |
| Phase 6+ | 看试点效果决定是否扩展(大概率不接专业医疗 AI) |

**边界**(跟原则 2"PAC 不持有 EMR,必要时受控借用"一致):

- ✅ PAC 消费影像作为 Persona / Plan 输入
- ❌ PAC 不做主权影像 AI(医疗影像识别责任归专业医疗 AI 服务商或宿主侧)
- ❌ PAC 不复制存储原影像文件(只引用宿主 URL,影像内容进 `agent_invocations.input_snapshot` 临时使用,受保留期管控)
- ❌ Agent 输出不回写 `patient_facts`(归 `agent_invocations.output`,跟数据流单向一致)

**业务定位守土**:PAC 是"患者分析中心",不是影像 PACS / 影像诊断系统。
