# 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` 列预留,目前全空。

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

```
① 事件驱动(主路径,日常全靠它)
   每天 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 选择器两种用法。
5. **租户内相对分群**:RFM 的 M 维用租户分位数而非全局阈值,小诊所与大集团各自成曲线。

## 当前边界(规划中)

- Layer C LLM 信号抽取未上(治疗敏感等靠关键词);score 全空(无强度排序);禁忌 v1 仅年龄;全量重算并发靠 CLI 手控。
