# 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) | 八象限 segment;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(初诊来源,一经判定不改) | channel/sub |
| family_structure | 家庭构成 | 亲属关系 | snapshot | 单身/两口/多口/多代 |
| referral_champion | 转介绍达人 | referralCount/Amount | snapshot | 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` | 当前态,随事实变化重算;历史读版本流 | 年龄段 / 潜在治疗(常态 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 选择器两种用法。