---
title: 三层模型
description: Patient Facts → Persona → Follow-up Plan 单向数据流的 schema 落地版。
icon: Workflow
---

> Patient Facts(事实)→ Persona(画像)→ Follow-up Plan(召回计划)
> 单向数据流 + 版本化推断 + 横切审计账本

---

```mermaid
flowchart TB

%% =========== 外部数据源 ===========
subgraph Sources["🌐 宿主数据源(异构)"]
  direction LR
  H1["5i5ya"]
  H2["Friday"]
  H3["400 中心"]
  H4["DW 数仓"]
end

%% =========== 接入层 ===========
subgraph Ingest["📥 接入层(adapter)"]
  direction LR
  PULL["Pull adapter<br/>━━━━━━━━━━━<br/>周期拉资源<br/>比对前后态合成 transaction"]
  PUSH["Push adapter<br/>━━━━━━━━━━━<br/>实时收事件<br/>HMAC 验签"]
end

%% =========== Layer 1 ===========
subgraph L1["📘 第一层 · 患者事实"]
  direction TB
  PT["<b>patient_transactions</b><br/>━━━━━━━━━━━━━━━━━━<br/>操作账本(append-only)<br/>source_event_id 幂等键<br/>event_seq BigInt 水位<br/>action(23)× subject_type(16)<br/>raw_payload 原文留底"]
  PARSER{{"Parser<br/>解析 + canonical + 衍生"}}
  PF["<b>patient_facts</b><br/>━━━━━━━━━━━━━━━━━━<br/>事实单元(版本流)<br/>subject_id × version<br/>kind: actual / planned<br/>type: 16 类 FactType<br/>status: active/superseded/<br/>cancelled/fulfilled/expired/invalidated<br/>transaction_ids 证据链"]
  PAT["<b>patients</b><br/>━━━━━━━━━━<br/>主档(身份)<br/>tenant 级唯一<br/>mergedInto 自引用<br/>不物删,只软删 / 匿名"]
  PT -->|"1 transaction<br/>→ 0/1/N fact 版本"| PARSER
  PARSER --> PF
end

%% =========== Layer 2 ===========
subgraph L2["🟠 第二层 · Persona 画像"]
  direction TB
  PER["<b>personas</b><br/>━━━━━━━━━━━━━<br/>版本流 supersededAt<br/>eventWatermark = 已响应 event_seq<br/>(features 读 facts active 算)"]
  PFEAT["<b>persona_features</b><br/>━━━━━━━━━━━━━<br/>key + description<br/>+ score? + data? + evidence{factIds...}<br/>33 候选 key · v1 实装 16(全规则)"]
  PER --> PFEAT
end

%% =========== Layer 3 ===========
subgraph L3["🟣 第三层 · 召回计划"]
  direction TB
  FP["<b>followup_plans</b><br/>━━━━━━━━━━━━━━━━━━<br/>版本流 + target_clinic_id<br/>status: active/assigned/<br/>completed/abandoned/superseded<br/>partial UNIQUE(单一活跃)"]
  PS["<b>plan_scripts</b><br/>━━━━━━━━━━━━<br/>per-plan 单行<br/>Markdown 多子段<br/>status: pending/ready/failed<br/>source: agent/template_fallback"]
  PSU["<b>plan_summaries</b><br/>━━━━━━━━━━━━<br/>per-plan × type<br/>详情页 3 卡:recall_brief<br/>/ persona_tags / recall_history"]
  PEX["<b>plan_executions</b><br/>━━━━━━━━━━━━<br/>executor_clinic_id<br/>outcome 强枚举驱动状态机"]
  FP --> PS
  FP --> PSU
  FP -->|"客服执行"| PEX
end

%% =========== AI 调用通路 ===========
subgraph AICall["🤖 AI 调用通路 · AiCall 框架"]
  direction LR
  AIC["AiCall 框架<br/>━━━━━━━━━━━━<br/>PAC 内部(src/modules/ai)<br/>缓存 / 安全闸 / 计费 / 重试<br/>每次调用强制落 agent_invocations"]
  LLM["LLM Provider<br/>━━━━━━━━━━<br/>DeepSeek / Qwen<br/>按 modelId 前缀路由<br/>(国内合规直连)"]
  AIC --> LLM
end

%% =========== 横切 ===========
subgraph Cross["🔧 横切支撑(账本)"]
  direction LR
  AI["agent_invocations<br/>━━━━━━━━━━━━<br/>AI 调用审计账本<br/>每次调用强制落一条<br/>token / cost / replay<br/>modelProvider / modelName / callKey"]
  SL["sync_logs<br/>━━━━━━━━━━<br/>同步运行账本<br/>cursor / reconcile"]
  PRL["persona_recompute_logs<br/>━━━━━━━━━━━━━<br/>画像重算账本<br/>rule/agent/fallback 计数"]
  PGL["plan_generation_logs<br/>━━━━━━━━━━━━<br/>召回生成账本<br/>scenarioRun / assets / agent"]
end

%% =========== 主数据流(实线) ===========
Sources --> Ingest
PULL --> PT
PUSH --> PT
PF -->|"读 active 版本<br/>跑 features 提取器"| PER
PF -->|"主输入"| FP
PER -.->|"可选增强<br/>(enhanced 模式)"| FP

%% =========== 反馈流(虚线,只回到 Plan 层) ===========
PEX -.->|"状态机反馈<br/><b>不回写 facts</b>"| FP

%% =========== AI 调用链(详情页按需 getOrGenerate → AiCall) ===========
PS -.->|"话术 script:stream<br/>(详情页按需)"| AIC
PSU -.->|"摘要 getOrGenerate<br/>(详情页按需)"| AIC
AIC -.->|"调用落账"| AI

%% =========== 编排账本关联(虚线) ===========
Ingest -.-> SL
PER -.-> PRL
FP -.-> PGL

%% =========== 颜色 ===========
style Sources fill:#fff3e0,stroke:#f57c00
style Ingest  fill:#e0f7fa,stroke:#00838f
style L1 fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
style L2 fill:#fff3e0,stroke:#f57c00,stroke-width:2px
style L3 fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
style AICall fill:#e8f5e9,stroke:#388e3c,stroke-width:2px
style Cross fill:#f5f5f5,stroke:#616161
style PT fill:#bbdefb
style PF fill:#bbdefb
style PAT fill:#bbdefb
style PER fill:#ffe0b2
style PFEAT fill:#ffe0b2
style FP fill:#e1bee7
style PS fill:#e1bee7
style PSU fill:#e1bee7
style PEX fill:#e1bee7
style AIC fill:#c8e6c9
style LLM fill:#c8e6c9
```

**图例**:实线 = 主数据流(单向);虚线 = 反馈 / 异步调用 / 编排关联

---

## 各层生成时机 & 策略

### 第一层 · 患者事实

| 子表 | 写入模式 | 触发 |
|---|---|---|
| `patient_transactions` | append-only | Pull cron(周期)/ Push 实时 / Manual 重放 |
| `patient_facts` | 版本流(append + supersede) | ① 摄入时 transaction 落库后立即调 parser 衍生;② **reparse 离线重衍**(见下) |
| `patients` | upsert | 主档拉取 / Push 首见 stub 创建 |

**两种事实生成路径**——同一套 `transforms → assembler → parser` 内核,只是数据入口不同:

| 路径 | 入口 | 连宿主? | 典型场景 |
|---|---|---|---|
| **摄入衍生** | Pull / Push 进来的新 transaction | 是(Pull) | 增量 / 存量首次落库 |
| **Reparse 重衍** | 已存 `transaction.raw_payload`(`reparse` CLI) | **否** | 改了 assembler yaml(enum/keyword/field_mapping/transforms)或 parser 后,按新口径重衍存量 fact |


### 第二层 · Persona 画像

```
事件驱动 ┐
定时兜底 ┼→ PersonaService.recompute(patientId)
人工救火 ┘     (核心函数,共享)
                 ↓
        队列收敛 (BullMQ jobName=patient_id)
                 ↓
     水位幂等:if MAX(event_seq) ≤ persona.eventWatermark → no-op(force 可跳过)
              例外:无 persona(首算)/ 无 txn(写 baseline 版)→ 不 noop,照常跑
                 ↓
     读 patient_facts status∈{active, fulfilled} → 跑 16 个规则 extractor
                 ↓
     写新 persona + features(supersede 旧)
```

> **全规则**:`feature.registry.ts` 注册 16 个 extractor 全是规则路径 —— 价值分群(rfm)、年龄段(age_bracket)、性别(gender)、获客渠道(acquisition_channel)、家庭构成(family_structure)、转介绍达人(referral_champion)、生命周期(lifecycle_stage)、治疗史(treatment_history)、时间偏好(time_preference)、折扣锚点(discount_anchor)、特别关注(special_attention)、治疗敏感(treatment_sensitivity)、禁忌标签(contraindication)、潜在治疗(potential_treatment)、急迫等级(urgency_level)、权益身份(entitlement_status)。

| 触发源 | triggeredBy | 时机 | 实装 |
|---|---|---|---|
| txn 事件驱动 | `txn:<...>` | sync 写入 transaction 后即 enqueue(`pipeline-dispatcher`) | ✅ 主路径,秒级 |
| 手工 | `manual:<...>` | `StaleScanService.runNow()` / `recompute-persona` CLI(`--force`) | ✅ 运维 |
| 定时兜底 | `scheduled:<cronId>` | `stale-scan` cron 扫 `watermark < max(event_seq)` 的漏算患者 | ✅ 默认关(`PAC_STALE_SCAN_CRON`),建议每日 |

### 第三层 · 召回计划

计划重算**跟在画像重算之后**(事件驱动主路径),另有手工端点;定时全扫代码就绪但默认关。

```
主路径(事件驱动,单患者)
  sync 写 transaction → persona-recompute
    → 成功(success/partial)即 enqueue plan-recompute
      → PlanEngine.recomputeForPatient(patientId)   // 只算该患者,秒级跟上

手工 HTTP(无 body,从路径参数取):
  POST /pac/v1/plans/:id/recompute                  // 按 plan 反查 patient 重算
  POST /pac/v1/plans/recompute/by-patient/:patientId

定时全扫(批量,默认关):
  PlanEngine.runAllForHost({ hostId, tenantId })    // 挂在增量同步 scheduler 尾,按 tenant 全扫
  cron = env PAC_INCREMENTAL_CRON;未设 → '0 0 31 12 *'(never)→ 当前不跑
```

**重算时对已有 plan 的处置**(`recomputeForPatient` / `runAllForHost` 同一套决策,返回 `created/superseded/unchanged/suppressed/closed/none`):

```
0 信号级抑制:过滤掉命中"冷静期未到"集合的 hit('suppressed')
    抑制集 = 该患者所有终态(completed/abandoned)且 snoozedUntil 未到期 plan 的 reason
            (scenario, subKey) 并集;按信号粒度压,不是整患者压
    → 刚结案的 K08 在冷静期内不复召,但新长的 K02 照常召回
    → 过滤后 usableHits 全空 → 不生成('suppressed')

1 命中且 (scenario, subKey) 集合不变 → 仅刷新 priorityScore,不升版本('unchanged')
2 命中且集合变了                     → supersede 旧版 + 建新版('superseded');
                                       旧版若 assigned → 新版继承 assignee/assignedAt/
                                       recycleAt/contactAttempts(不丢单、不被抢)
3 零命中(缺口全消失)               → closeStaleActivePlan 关 plan,active / assigned 都关('closed')
* 每写出一个 plan                    → enqueue plan-asset-generate(话术/摘要;见下)
```

> assigned 的 plan 照常重算并在升版时继承分配(`assignee/contactAttempts` 带到新版),不丢单也不被抢。

### Plan Assets(话术 / 摘要)

话术和摘要是 AI 产物,**当前按需懒生成**,不在重算时自动批量产(省 token)。

```
自动路径(目前 stub):plan-recompute → enqueue plan-asset-generate
   worker = no-AI stub,只 log "would generate",不调 LLM
   (置 env PLAN_ASSET_AUTO_GENERATE=true 才启用自动生成)

真实生成 = 客服打开 plan 详情页时按需触发(getOrGenerate:已有直接取,没有才调 AI):
   参考话术    GET  /pac/v1/plans/:id/script:stream(SSE 流式)/ POST :id/script:regenerate
   本次召回简报 GET  /pac/v1/plans/:id/recall-brief
   画像标签    GET  /pac/v1/plans/:id/persona-summary
   回访历史    GET  /pac/v1/plans/:id/recall-summary
```

**当前实际产出的产物**:

| 产物 | SummaryType | 详情页卡片 | 编排器 · provider |
|---|---|---|---|
| 参考话术 | (PlanScript,无 type) | 参考话术(Markdown 多子段) | `plan-script` · DeepSeek |
| 摘要 | `recall_brief` | 本次召回简报(患者是谁 / 解决什么 / 邀约做什么) | `recall-brief` · Qwen |
| 摘要 | `persona_tags` | 画像标签(画像标签 + 取值一句话) | `persona-summary` · Qwen |
| 摘要 | `recall_history` | 回访历史(plan_executions 结构化数据归纳) | `recall-summary` · Qwen |


**AiCall 框架**:所有 LLM 调用收口，单一执行器。
- 流程:inputHash + prompt cache → 起 invocation(running)→ `generateObject`(model + zod schema + system + prompt)→ 安全闸校验 → 落 invocation(succeeded/failed/cached)+ 写缓存;JSON 解析失败重试 ≤2 次
- **多 provider 按 `modelId` 前缀路由**(`ai-provider.service.ts`):`deepseek-*`(话术)/ `qwen*`(三类摘要);切 provider 不动调用方
- 每次调用经 `invocation-recorder` 两段式落 `agent_invocations`(start=running → end 回填 `output` + token 明细 + `costYuan` + `ttftMs`/`latencyMs`),是 audit / replay / 成本核算唯一真理源
- 失败兜底:安全闸不过 / LLM 失败 → 模板兜底(`source='template_fallback'`);兜底也挂 → `status='failed'`(罕见)

### PlanExecution 回写

```
客服工作台 → POST /pac/v1/plans/{id}/executions (单次提交)
   body: { channel, outcome, notes?, abandonReasons?, scheduledNextAt?, ... }
                 ↓
   写一行 plan_executions
                 ↓
   按 EXECUTION_OUTCOME_META[outcome].drivesStatus 驱动 Plan.status(单一真理源):
     completed  → 结案(success_appointed 等)
     abandoned  → 关闭(refused / external_treatment / declined_recent 等)
     keep       → 状态不变,等下次(no_answer / quick_hangup / scheduled_next 等)
                 ↓
   按 suppressDays 写 snoozedUntil 抑制窗(终态抑制 / 冷静期;到期自愈)
                 ↓
   触发熔断检查(contactAttempts 累积达上限 → 自动 abandoned,冷静期 30 天)
```

> `outcome` 闭集共 14 个(表单可见 7 个,其余为 `hiddenInForm` 历史值,仍可翻译旧记录)。前后端共用 `EXECUTION_OUTCOME_META`(group / label / `drivesStatus` / `suppressDays`),改一处处处生效。

---

## 横切支撑

| 表 | 职责 | 平行关系 |
|---|---|---|
| `agent_invocations` | 每次 AI 调用落账,token / cost / replay / 影像 AI 结果落点 | 跨所有层共享 |
| `sync_logs` | Pull / Push 运行账本,cursor 断点续传 / 对账 trigger | 接入层 |
| `persona_recompute_logs` | 重算执行账本,rule 计数 → harness 健康度(agent/fallback 计数位 v1 恒 0) | Layer 2 |
| `plan_generation_logs` | 召回生成账本,scenarioRun / assetsCreated / agent 计数 | Layer 3 |

三张 orchestration log **平行不混用**,每个 domain 一张账本,字段贴近自身语义。

---

## 七层防线(系统间通信可靠性)

| # | 防线 | schema 落地 |
|---|---|---|
| 1 | 幂等 | `patient_transactions.source_event_id` partial UNIQUE |
| 2 | 水位续传 | `sync_logs.cursor_before/after` |
| 3 | 原始报文留底 | `patient_transactions.raw_payload` |
| 4 | Reconcile 对账 | `sync_logs.triggered_by='reconcile:<date>'` |
| 5 | Quarantine | (v1 暂缓,SyncLog.failed + errorMessage 兜底) |
| 6 | Drift 监控 | `ContractDriftError`(字段契约失配 → `SyncLog.failed`)+ `sync_logs.metadata.suspectFields`(整列缺席预警)+ mapping-miss 面板(枚举长尾)。无正式 metric |
| 7 | 推断 stale | `personas.event_watermark` |

---

## 一句话归纳

**三层模型 = 把"宿主散乱事件" 升级成"集团统一画像" 再升级成"客服可执行的召回计划"**。
每层各自承担(账本留底 / 综合评价 / 行动指令),数据流单向 + 证据可追溯 + 版本化推断。
