# PAC 画像(Persona)实现速览

> 设计背景见 [persona-design-v2.md](./persona-design-v2.md)。
> 更新:2026-06-11

## 定位

三层模型第二层:`patient_facts`(事实)→ **`personas` + `persona_features`**(画像)→ `followup_plans`(召回)。
单向流:画像只读事实层,只被计划层读;执行回写不进画像。

## 代码地图

| 部件             | 路径                                                                                   |
| -------------- | ------------------------------------------------------------------------------------ |
| 引擎(编排/水位/版本)   | `apps/pac-service/src/modules/persona/persona.service.ts`                            |
| 16 个标签提取器      | `apps/pac-service/src/modules/persona/features/*.feature.ts` + `feature.registry.ts` |
| 标签字典(单一真理源)    | `packages/types/src/persona-feature-specs.ts`                                        |
| 圈人筛选字典(14 维子集) | `packages/types/src/persona-tag-filters.ts`                                          |
| 表结构            | `prisma/schema.prisma` → `personas` / `persona_features` / `persona_recompute_logs`  |
| CLI            | `cli/recompute-persona.cli.ts`(单人/全量/force)、`cli/stale-scan.cli.ts`(兜底)              |

## 16 个标签(全部规则路径,无 LLM)

| key                   | 中文    | 数据源                               | 时间语义                                   | data 结构要点                                          |
| --------------------- | ----- | --------------------------------- | -------------------------------------- | -------------------------------------------------- |
| rfm                   | 价值分群  | payment/recharge/refund/encounter | mixed(R=snapshot,F/M=lifetime)         | M 用**租户级分位数**(缓存 30min)                            |
| lifecycle_stage       | 生命周期  | 末诊时间 + 就诊频次                       | mixed(末诊 window + 近1年消费 vs 历史年均 trend) | stage 7 档 + daysFromLastVisit                      |
| age_bracket           | 年龄段   | birthDate                         | snapshot                               | 9 档(婴幼儿~老年)                                        |
| gender                | 性别    | patient.gender                    | snapshot                               | —                                                  |
| acquisition_channel   | 获客渠道  | profile(取值随宿主)                    | snapshot(实为 static:不变历史定格)             | channel/sub                                        |
| family_structure      | 家庭构成  | 亲属关系                              | snapshot                               | 单身/两口/多口/多代                                        |
| referral_champion     | 转介绍达人 | referralCount/Amount              | lifetime(累计计数器只增)                      | family / social                                    |
| entitlement_status    | 权益身份  | 结算卡券名                             | lifetime                               | labels[](保险/私行/储值/儿牙/医保)                           |
| treatment_history     | 治疗史   | treatment_record.category         | lifetime                               | labels[](种植/正畸/修复/牙周)                              |
| potential_treatment   | 潜在治疗  | diagnosis/recommendation gap      | snapshot(常态 gap,随新诊断/治疗重算)             | **复用召回 clinical-gap 选择器,但去掉时间冷静门**(画像=常态属性,召回才讲时机) |
| urgency_level         | 急迫等级  | 潜在治疗 gaps + 末诊                    | snapshot                               | critical/high/medium/low                           |
| contraindication      | 禁忌    | birthDate(v1 仅年龄禁忌)               | snapshot(validUntil=到龄自动解除)            | labels[]                                           |
| time_preference       | 时间偏好  | 近 2 年预约分布                         | window(2y)                             | labels[] + distribution                            |
| discount_anchor       | 折扣锚点  | payment.discount_cents            | lifetime                               | 最低折扣率 + 日期                                         |
| special_attention     | 特别关注  | 爽约率 + profile 免打扰                 | window(1y)+ 当前标注                       | labels[] + noShowRate                              |
| treatment_sensitivity | 治疗敏感  | 病历备注关键词匹配                         | lifetime                               | labels[](恐惧/晕针/晕血/密闭)                              |

每个 feature 落库:`key + description(自包含中文)+ data(结构化 JSON)+ evidence{factIds}`。
`score` 列预留,目前全空。

## 时间语义模型

画像 = 对全历史的**压缩当前态**;每标签在注册表(`persona-feature-specs.ts`)声明 `timeSemantics`,逐标签取值见上表。

**snapshot vs lifetime 判别准则**(都在重算时刻评估,这点不是分界;分界看**值的演化方向**):
- `lifetime` = 单调累计,**只增不减**(做过种植→治疗史永远有;拿过 9 折→锚点永远 ≤9 折);
- `snapshot` = 可逆的当前态,**可增可减**(gap 治了就消失、禁忌到龄解除、家庭构成会变)。

| 取值 | 含义 | 例 |
|---|---|---|
| `snapshot` | 当前态,随事实变化重算;历史读版本流 | 年龄段 / 潜在治疗(常态 gap)/ 禁忌(validUntil 到龄解除) |
| `window` | 近 N 期滚动窗 | 时间偏好 2y / 特别关注 1y |
| `lifetime` | 全史累计,不设窗 | 治疗史 / 权益 / 折扣锚点 / 治疗敏感 |
| `trend` | 趋势对比(当前只作 mixed 的成分) | 生命周期"近1年消费 vs 历史年均" |
| `mixed` | 复合 | RFM(R=snapshot,F/M=lifetime)/ 生命周期 |

补充:
- **评估基准** = 重算时刻;历史某天的画像查当天未被 supersede 的版本(版本流)。
- **TZ**:PAC 全 UTC 存储(DW 北京时间摄入时转换);算"星期/时段"等本地概念时 +8(试点硬编北京,多宿主应读 host TZ — follow-up)。
- 关键切档值:R 档 540/730/1095/1460 天;生命周期 首诊≤180 新客、末诊 180–540 待激活、>540 沉睡、>730 流失。

## 刷新策略(三条触发路径)

```
① 事件驱动(主路径,日常全靠它)
   每天 02:30 增量 sync → 写 facts,收集 touchedPatients
   → 逐人入 BullMQ(jobId=host:tenant:patientId,同批同人自动去重)
   → PersonaService.recompute(triggeredBy='txn:<id>')

② 手动(算法变更/排查)
   pnpm recompute-persona -- --pid=xxx | --host=jvs-dw  (--force 跳水位)

③ 兜底
   stale-scan 全扫 eventWatermark < 最新 eventSeq 的画像补算
```

**自动串联**:persona 新版本落库后,同事务内触发该患者 Plan 重算。
日常节奏:DW 凌晨刷 → 02:30 增量 → 画像重算 → 计划重算 → 客服早上看到新鲜画像+新任务。

## 幂等与版本化(两道闸)

| 机制 | 实现 |
|---|---|
| 水位幂等 | 每版记 `eventWatermark = max(transactions.eventSeq)`;重算先比水位,无新事件 → **no-op**(落 recompute_log status='noop');`force` 破门 |
| 版本流 | `(patientId, version)` 单调递增;新版落库回填旧版 `supersededAt`;features 随父版本整套重建 |
| 当前版语义 | **`supersededAt IS NULL`** — 前端/筛选 SQL/Plan 引擎统一读这个 |
| 审计 | 历史版本全保留;`persona_recompute_logs` 记每次运行(running→success/partial/failed/noop) |

## 消费方

- 详情页标签卡:`pac-web .../plan-detail/persona-display.ts` + hover 展开
- 左栏画像圈人:`patient-picker-rail.tsx` → `plan.service.ts` JSON 路径 SQL(**同维 OR,跨维 AND**,只匹配当前版)
- Plan 优先级:意愿度因子读 rfm/lifecycle
- 助手 MCP:`get_patient_overview` 带画像
- 运营策略

## 关键

1. **规则可解释**:16 标签全规则产出,带 evidence(factIds)可回溯到原始事实两跳到宿主原文。
2. **水位幂等**:事件序号水位,重复触发零成本 no-op,重算账本可审计。
3. **版本流不可变**:画像不 update,只 append 新版;任意时点画像可还原。
4. **画像≠召回**:潜在治疗在画像里是常态属性(无时间门),在召回里才有冷静期/排除逻辑——同一 gap 选择器两种用法。