# 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 | 八象限 segment;M 用**租户级分位数**(缓存 30min) |
| lifecycle_stage | 生命周期 | 末诊时间 + 就诊频次 | stage 7 档 + daysFromLastVisit |
| age_bracket | 年龄段 | birthDate | 9 档(婴幼儿~老年) |
| gender | 性别 | patient.gender | — |
| acquisition_channel | 获客渠道 | profile(取值随宿主) | channel/sub |
| family_structure | 家庭构成 | 亲属关系 | 单身/两口/多口/多代 |
| referral_champion | 转介绍达人 | referralCount/Amount | family / social |
| entitlement_status | 权益身份 | 结算卡券名 | labels[](保险/私行/储值/儿牙/医保) |
| treatment_history | 治疗史 | treatment_record.category | labels[](种植/正畸/修复/牙周) |
| potential_treatment | 潜在治疗 | diagnosis/recommendation gap | **复用召回 clinical-gap 选择器,但去掉时间冷静门**(画像=常态属性,召回才讲时机) |
| urgency_level | 急迫等级 | 潜在治疗 gaps + 末诊 | critical/high/medium/low |
| contraindication | 禁忌 | birthDate(v1 仅年龄禁忌) | labels[] |
| time_preference | 时间偏好 | 近 2 年预约分布 | labels[] + distribution |
| discount_anchor | 折扣锚点 | payment.discount_cents | 最低折扣率 + 日期 |
| special_attention | 特别关注 | 爽约率 + profile 免打扰 | labels[] + noShowRate |
| treatment_sensitivity | 治疗敏感 | 病历备注关键词匹配 | labels[](恐惧/晕针/晕血/密闭) |

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

## 时间语义

**总基准**:所有标签以**重算时刻**为"现在"评估(非事件发生时点);历史某天的画像靠版本流还原(查当天未被 supersede 的版本)。

四类时间口径:

| 口径 | 含义 | 标签 |
|---|---|---|
| **静态/当前快照** | 与时间无关,或按"现在"取当前态 | 性别 · 获客渠道(首次,不变)· 年龄段(birthDate→今)· 家庭构成(当前关系边)· 禁忌(当前年龄,`validUntil`=到龄自动解除日) |
| **全史累计** | lifetime 不设窗 | RFM 的 F(累计就诊)/M(累计净消费)· 转介绍达人 · 权益身份 · 治疗史 · 折扣锚点(全史最低折扣+发生日)· 治疗敏感(全史病历备注) |
| **滚动窗口** | 只看近 N 天,随时间自动进出 | 时间偏好 **近 2 年**(730d,用 planned_for)· 特别关注 **近 1 年**(爽约/迟到率)· 生命周期的"近 1 年消费 vs 历史年均"(成长/成熟分界) |
| **距今天数切档** | 末诊距今多少天落档 | RFM 的 R(≤540=5 / ≤730=4 / ≤1095=3 / ≤1460=2 / >1460=1)· 生命周期(首诊≤180 新客;末诊 180–540 待激活;>540 沉睡;>730 流失)· RFM 风险分(无临床 ≥180d 起算) |

特例:**潜在治疗/急迫等级**是"当前未闭合 gap"语义——诊断后未启动治疗就一直在(`daysSince` 记录悬置天数,只增不减),直到治疗事实进来才消失;**不设上界**(缺牙 3 年仍是潜在种植),与召回侧的冷静期/黄金窗口径不同(见上表"画像≠召回")。

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

```
① 事件驱动(主路径,日常全靠它)
   每天 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 选择器两种用法。