# 画像层 v2(实现版)

> **版本**:W7 · 2026-06 · 反映**已实现的 15 个画像标签** + 架构 + 算法审查发现。
> **单一真理源**:每个标签的 标签值/数据源/释义/算法/时间语义 在代码注册表
> [`packages/types/src/persona-feature-specs.ts`](../../packages/types/src/persona-feature-specs.ts)(`PERSONA_FEATURE_SPECS`);
> extractor 在 `apps/pac-service/src/modules/persona/features/*.feature.ts`。本文是**人读总览 + 决策记录**,不复制每字段。
> **关联**:[`db-design-v2.md`](../db-design-v2.md)(personas/persona_features 表)· [`pac-algorithms-overview.md`](./pac-algorithms-overview.md)。
>
> **一句话**:画像 = 对全历史的「压缩当前态」,每标签声明自己的时间语义;13 标签按业务 CDP 标签卡口径实现,口径沉淀在代码注册表;过程中审出并修了多处上游/spec 问题。

---

## 一、画像在三层模型的位置
事实层(`patient_facts`)→ **画像层(`personas`/`persona_features`)** → 召回层(`followup_plans`)。
画像是**对全历史 fact 的压缩**(facts=raw context,persona=hidden state),回答「现在这个人是什么样」。历史明细留 fact 层,**画像不存明细**;要看历史时点画像 → 读 `personas` 版本流(`version + computedAt + eventWatermark` = point-in-time)。

## 二、persona schema(怎么存)
`persona_features` 每行 = 一个标签:
- `key` — 标签 key(注册表闭集)
- `description` — 自然语言(给 LLM / 详情页展示)
- `data` (JSONB) — **结构化值**(机器消费:打分 / 圈人群 / 筛选)。这是"是什么"。
- `evidence` — 证据(factIds/ruleIds…)。这是"为什么"。
- `score` — **已弃用语义**(分值是消费场景的事,不在特征层;消费方从 `data` 自算)。
父 `personas`:`version` 版本流 + `eventWatermark`(算到哪个事件,判 stale)。

## 三、4 种数据范式(标签从哪来)
| 范式 | 说明 | 标签 | 部署成本 |
|---|---|---|---|
| **① 已摄入直接算** | 读现有 fact / 主档 | rfm / lifecycle_stage / treatment_history / time_preference / age_bracket / gender / do_not_contact | 仅 `--force` 重算 |
| **② 副表立柱新摄** | fact_client_out 字段 → `patient_profiles` 新列(enum_mapping 归一,多宿主标准) | acquisition_channel / referral_champion | migration + 重摄 |
| **③ fact.content 新字段** | 结算字段 → `payment_record.content`(JSONB,无迁移) | entitlement_status(卡券名)/ discount_anchor(折扣额) | 重摄 |
| **④ 衍生表 / ctx 加载** | 复用 PAC 已有派生数据,注入 persona ctx | family_structure(PatientRelation)/ special_attention(appointmentsAll) | 仅 `--force` |

## 四、时间语义模型
每标签在注册表声明 `timeSemantics`:`snapshot`(当前态,如年龄/性别)/ `window`(近 N 期,如时间偏好近2年)/ `lifetime`(全史,如治疗史/折扣锚点)/ `trend`/`mixed`(如 RFM:R 当前态、F/M 全史)。
**TZ**:PAC 全 UTC 存储(DW 北京,摄入时转换);算"星期/时段"等本地概念时 `AT TIME ZONE 'Asia/Shanghai'` / +8(试点硬编北京,多宿主应读 host TZ — follow-up)。

## 五、Feature Registry + recompute
- **Registry** = `PERSONA_FEATURE_SPECS`(代码常量,git/PR review),每标签一张"标签卡"(标签值/数据源/数据字段/释义/算法/时间语义/owner)。OneModel 单一收口。
- **extractor** 实现在 `features/*.feature.ts`,由 `FeatureRegistry` 收集,`PersonaService.recompute` 遍历。
- **重算**:`pnpm recompute-persona -- --host=jvs-dw --force`。⚠️ `--force` 必需 —— 算法/特征变更但数据没变时,水位幂等闸会全 noop;`--force` 跳过闸强制重算(部署也用)。

## 六、16 标签总览(本地 928 样本)
| 标签 | 类 | 标签值 | 数据源 | 时间语义 | 覆盖 |
|---|---|---|---|---|---|
| `rfm` 价值分群 | B | 8 分群(重要价值..低活跃) | 付款/就诊 fact | mixed | 928 |
| `lifecycle_stage` 生命周期 | B | 潜客/新客/成长/成熟/待激活/沉睡/流失 | 付款/就诊/预约 fact(自算) | window+trend | 928 |
| `treatment_history` 治疗史 | C | 种植/正畸/修复/牙周(多标签) | treatment_record.category | lifetime | 773 |
| `potential_treatment` 潜在治疗 | C | 种植/正畸/早矫/修复/牙周/根管/拔牙/补牙(多) | **复用召回 gap 核心**(clinical-gap) | snapshot | 771 |
| `time_preference` 时间偏好 | D | 工作日/周末/上午/下午/晚间(多) | appointment.planned_for(北京) | window(2y) | 601 |
| `discount_anchor` 折扣锚点 | D | 最低折扣率+日期/项目 | payment.content(应收/折扣额) | lifetime | 338 |
| `special_attention` 特别关注 | D | 屡次爽约/经常迟到/免打扰/不可等候(多) | appointmentsAll + profile + emr | window(1y) | 88 |
| `acquisition_channel` 获客渠道 | A | 走入/口碑/集团营销/地区/电商/自媒体… | fact_client_out.primary_category(副表立柱) | snapshot | 928 |
| `entitlement_status` 权益身份 | B | 高端保险/银行私行/储值/儿牙/医保(多) | payment 卡券名关键词 + 充值 | lifetime | 274 |
| `age_bracket` 年龄段 | A | 婴幼儿..老年(9 档) | patient.birthDate | snapshot | 928 |
| `gender` 性别 | A | 男/女/未知 | patient.gender | snapshot | 928 |
| `family_structure` 家庭构成 | A | 单身/两口/多口/多代 | PatientRelation 直系 | snapshot | 85 |
| `referral_champion` 转介绍达人 | B | 家庭型/社交型 | recommend_num/amount(副表)+ 关系 | snapshot | 26 |
| `treatment_sensitivity` 治疗敏感 | D | 看牙恐惧/晕针/晕血/密闭恐惧(多) | EMR 既往史关键词(精炼排假阳) | lifetime | 2(全量~177) |
| `contraindication` 禁忌标签 | D | 种植/正畸/麻醉/手术禁忌(v1 仅种植年龄≤18) | patient.birthDate(余 Layer C) | snapshot | 167 |
| `do_not_contact_status` 勿扰 | — | 可触达/勿扰 | profile + 投诉 | snapshot | 928 |

> 多标签(允许并列):treatment_history / time_preference / entitlement_status / special_attention / treatment_sensitivity / contraindication / potential_treatment。
> 稀疏(依赖数据完整性,全量更准):family_structure / referral_champion(关系边稀疏)、special_attention 免打扰/不可等候、treatment_sensitivity(本地样本无数据,全量出)。
> **未完整实现(留 Layer C / LLM)**:contraindication 除"种植年龄"外的禁忌(糖尿病/过敏/抗凝/妊娠…需读既往史+处理否定+判控制状态)。

## 七、算法审查发现 + 口径决策(这轮核心 —— 不是照搬 spec)
1. **RFM**:采业务图 B.1.1 口径(M 按**租户分位** p20/40/60/80,非绝对¥;8 段**决策树**非几何八象限)。补全 spec 缺口:`F<3` 也归 develop(近期单次新客不误落"低活跃")。RFM 与生命周期解耦(RFM 管价值,lifecycle 管阶段)。
2. **lifecycle 顺序 bug**:spec 原顺序「沉睡(末诊>540)」在「流失(末诊>730)」前 → 流失永不触发(被沉睡遮蔽)。**修:流失提前**。另:源表无 `net_receipts_total/total_visit_times` → PAC 自算(同 RFM)。
3. **discount_anchor**:DW 无 `original_amount`;`discount_*_rate` **实为折扣金额非比率**(应收3−dept0.45=实收2.55)→ 折扣率=1−Σ折扣/应收。修 spec naive 点:① 免费洁牙/检查促销(0折)霸占锚点 → 只看原价≥¥500+ratio>0;② 保险方付款非诊所折扣 → 排除 `discount_insurance_rate`。
4. **time_preference**:`occurred_at` 钟点被搞乱(摄入 TZ/解析),`planned_for`+8 才是干净营业钟形 → 用 planned_for。
5. **special_attention**:① `no_show`/`cancelled` 预约 `patient_facts.status≠active/fulfilled` → 不在 persona ctx → 新增 `ctx.appointmentsAll`(全状态单独加载);② `arrived_at`(in_time)摄入 TZ bug 一致早 8h → +8h 补偿。
6. **referral_champion**:逐个被推荐人「均有效转化」需跨患者 + 全量 → v1 用 DW 预聚合 `recommend_num/recommend_amount`(数仓已替我们算)。
7. **acquisition_channel**:DW 实际一级渠道比图多(走入最大、集团销售渠道、瑞尔员工);按数仓实际值,enum_mapping 归一 PAC 立柱标准。
8. **score 弃用**:特征"马上量化"是伪命题,分值是消费场景的事 → 特征只产 `data`,场景自算分。
9. **treatment_chain_status 移除**:它是 episode 级状态非人级属性 → 移出画像,降级为详情页 episode 视图(人级在治信号并入 lifecycle)。
10. **treatment_sensitivity 关键词精炼**:先扫全量验证再定口径,排假阳——裸"紧张"=颏肌紧张(正畸)、裸"见血"=可见血凝块(临床)、裸"张口受限"=物理开口受限,均剔除;看牙恐惧用"恐惧/害怕看牙/牙科焦虑"。轻微否定风险(否认晕针,量级小)记 follow-up。
12. **潜在治疗复用召回 gap 核心(单一源 + 分层不倒置)**:召回挖的就是"诊断了但没做对应治疗"=潜在治疗。抽 gap 核心(`clinical-gap/potential-treatment-gap.sql` 的 buildGapCore:牙位相减/⑤a/§E/废用牙)到中立模块,召回(Layer 3,+时间门+打分)与潜在治疗画像(Layer 2,经 PotentialTreatmentSelector,去时间门)共用 → 真单一源、口径一致(圈人群=召回候选超集)。画像**不调召回**(避免 Layer 2→3 倒置)。✅ 召回字节等价两验(EXCEPT 双向 0 diff)。8 标签按年龄/name 拆:种植 K08>18、早矫/正畸 K07 按 3-12/>12≤40、修复 vs 拔牙 按 K03 name(残根残冠→拔)。⚠️ 发现:召回 missing_tooth 缺"种植 age>18"门(画像/禁忌都有)→ 召回可能召 <18 恒牙缺失种植,与禁忌矛盾(follow-up)。
11. **contraindication 不上规则版(医疗安全)**:既往史**否定泛滥**(含"过敏"97% 是"否认过敏",naive 匹配=把无过敏标成麻醉禁忌的事故)+ **量化条件无数据**(HbA1c 全量仅10条、血压自由文本)+ 需控制状态/急性期判断 → 除"种植年龄≤18"(无否定/量化问题)外**整体留 Layer C(LLM 抽取)**。年龄禁忌带 validUntil(满19岁)自动解除。

## 八、follow-up / 未实现
- **appointment 时间 TZ 根治**:`occurred_at` 乱 + `arrived_at` 早 8h(+8h 是 band-aid)→ 修 `in_time` 摄入时区。
- **recommend_num 增量游标**:fact_client_out 无 updated_date,游标=`last_visit_time` → 推荐人不来诊则 recommend_num 更新滞后;改 `greatest(last_visit_time, recommend_last_visit_time)`。
- **转介绍 v2**:逐人有效转化(跨患者图谱 + 全量)。
- **潜在治疗**:① 非已丢单(sales_chance)PAC 未摄入 → 现未排丢单;② 召回 missing_tooth 补"种植 age>18"门(与禁忌/潜在治疗对齐);③ Step3 主诉意愿加分(排序)留消费方。
- **gap 核心收敛**:召回与潜在治疗已共用 clinical-gap;治疗链 chain-composer 的同类牙位逻辑后续也可并入。
- **Layer C(LLM 抽取)层标签** ⭐:禁忌标签(糖尿病控制/过敏/抗凝/妊娠… 需读既往史+否定处理+控制状态+时效窗)、沟通偏好/治疗意向/家庭社交关系等。contraindication / treatment_sensitivity 已留 data 多标签位,Layer C 就绪后并入。
- **treatment_sensitivity 否定**:晕针等加否定感知(否认晕针),量级小,随 Layer C 一起规整。
- **discount_anchor 口径**:近免费(>90%off)comp/全保 是否算锚点(现 ≥¥500+ratio>0,未加 floor);¥500 阈值可调。
- **TZ 多宿主**:硬编北京 → 读 host 时区。
- **圈人群 campaign**:未实现;`data` 带 segment + GIN 可查,schema 已 ready(`plan_reasons.campaignId` 预留)。
- **LLM 层标签**:沟通偏好/治疗意向/家庭社交关系等(从自由文本抽,走 AiCall),预留。
- **清理**:`score` 列后续 migration 删;`persona-design-v2.md`(本文)随标签增改维护。

## 九、部署清单(累积)
- **2 migration**:`patient_profiles` 加 acquisition_channel/sub + referral_count/amount_cents。
- **需重摄**(`sync --full`):acquisition/referral(主档)+ entitlement(payment 卡券名)+ discount(payment 折扣额)。
- **重算**:`recompute-persona:prod --force` → `recompute-plans:prod`。
- 其余范式①④标签仅需 `--force` 重算。
