---
title: 画像算法
description: 把全历史事实压缩成 16 个可解释画像标签 —— 数据源、时间语义、版本流与刷新策略。
icon: Users
---

> **真理源**:标签定义(标签值 / 数据源 / 算法 / 时间语义)在 `packages/types/src/persona-feature-specs.ts`(`PERSONA_FEATURE_SPECS`,每标签一张"标签卡");extractor 在 `apps/pac-service/src/modules/persona/features/*.feature.ts`,由 `feature.registry.ts` 注册、`persona.service.ts` 遍历。本页是人读总览,逐字段以代码为准。画像在三层里的触发 / 水位 / 版本流见 [三层模型](/docs/architecture/three-layer-model)。

---

## 定位

事实层(`patient_facts`)→ **画像层(`personas` / `persona_features`)** → 召回层(`followup_plans`)。

画像 = **对全历史 fact 的压缩当前态**(facts = raw context,persona = hidden state),回答"现在这个人是什么样"。明细留 fact 层,**画像不存明细**;看历史时点画像 → 读 `personas` 版本流(`version + computedAt + eventWatermark` = point-in-time)。单向流:画像只读事实层、只被计划层读,执行回写不进画像。

## 怎么存

`persona_features` 每行 = 一个标签:

- `key` —— 闭集标签键(`PersonaFeatureKey`)
- `description` —— 自然语言(给 LLM / 详情页)
- `data`(JSONB)—— **结构化值**(机器消费:打分 / 圈人群 / 筛选)= "是什么"
- `evidence` —— 证据(`{ factIds, ruleIds }`)= "为什么";引 fact 即可两跳回溯宿主原文
- `score` —— 列保留但**当前不用**(分值是消费场景的事,消费方从 `data` 自算)

父 `personas`:`version` 版本流 + `eventWatermark`(算到哪个 `event_seq`,判 stale)。

## 16 个标签(全确定性,无 LLM;15 规则 + 1 统计)

| key | 中文 | 数据源 | 时间语义 | data 要点 |
|---|---|---|---|---|
| `rfm` | 价值分群 | payment / recharge / refund / encounter | mixed | 8 分群;M 按**租户内分位** |
| `lifecycle_stage` | 生命周期 | 末诊时间 + 就诊频次 + 消费斜率 | mixed | 7 档 + `daysFromLastVisit` |
| `age_bracket` | 年龄段 | `patient.birthDate` | snapshot | 9 档(婴幼儿…老年) |
| `gender` | 性别 | `patient.gender` | snapshot | 男 / 女 / 未知 |
| `acquisition_channel` | 获客渠道 | `patient_profiles`(副表立柱) | snapshot(static) | channel / sub |
| `family_structure` | 家庭构成 | `PatientRelation` 关系边 | snapshot | 单身 / 两口 / 多口 / 多代 |
| `referral_champion` | 转介绍达人 | `recommend_num` / `amount`(副表) | lifetime | family / social |
| `entitlement_status` | 权益身份 | 结算卡券名 + 充值 | lifetime | `labels[]`(保险 / 私行 / 储值 / 儿牙 / 医保) |
| `treatment_history` | 治疗史 | `treatment_record.category` | lifetime | `labels[]`(种植 / 正畸 / 修复 / 牙周) |
| `potential_treatment` | 潜在治疗 | diagnosis / recommendation gap(共用召回 gap 核心) | snapshot | 8 标签(种植 / 正畸 / 早矫 / 修复 / 牙周 / 根管 / 拔牙 / 补牙) |
| `urgency_level` | 急迫等级 | 潜在治疗 gap × 末诊 | snapshot | 紧急 / 高 / 中 / 低(取最大) |
| `contraindication` | 禁忌标签 | `birthDate`(v1 仅种植年龄 ≤18) | snapshot(`validUntil` 到龄解除) | `labels[]` |
| `time_preference` | 时间偏好 | 近 2 年预约 `planned_for`(北京) | window(2y) | `labels[]` + 分布 |
| `discount_anchor` | 折扣锚点 | payment 应收 / 折扣额 | lifetime | 最低折扣率 + 日期 / 项目 |
| `special_attention` | 特别关注 | 全状态预约 + profile | window(1y) | `labels[]`(屡次爽约 / 经常迟到 / 免打扰 / 不可等候)+ `noShowRate` |
| `treatment_sensitivity` | 治疗敏感 | EMR 既往史关键词 | lifetime | `labels[]`(恐惧 / 晕针 / 晕血 / 密闭恐惧) |

每行落库:`key + description(自包含中文)+ data(结构化 JSON)+ evidence{factIds}`。

> 多标签(允许并列):`treatment_history` / `time_preference` / `entitlement_status` / `special_attention` / `treatment_sensitivity` / `contraindication` / `potential_treatment`。

## 时间语义

每标签在标签卡声明 `timeSemantics`;判别看**值的演化方向**(都在重算时刻评估,这点不是分界):

| 取值 | 含义 | 例 |
|---|---|---|
| `lifetime` | 单调累计,**只增不减** | 治疗史 / 权益 / 折扣锚点 / 治疗敏感 / 转介绍达人 |
| `snapshot` | 可逆当前态,**可增可减** | 年龄段 / 潜在治疗 gap / 禁忌(到龄解除)/ 家庭构成 |
| `window` | 近 N 期滚动窗 | 时间偏好 2y / 特别关注 1y |
| `trend` | 趋势对比(当前只作 mixed 成分) | 生命周期"近 1 年消费 vs 历史年均" |
| `mixed` | 复合 | RFM(R snapshot + F/M lifetime)/ 生命周期(末诊 window + 消费斜率 trend) |

评估基准 = 重算时刻;历史某天的画像查当天未被 supersede 的版本。本地时间概念(星期 / 时段)按北京时区算(PAC 全 UTC 存储,试点硬编北京,多宿主应读 host 时区)。

## 几个非显然口径

- **RFM**:M 按**租户内分位** p20/40/60/80(非绝对 ¥),8 段**决策树**(非几何八象限);RFM(管价值)与 `lifecycle_stage`(管阶段)解耦。
- **折扣锚点**:折扣率 = `1 − Σ折扣 / 应收`;只取原价 ≥¥500 且 ratio>0(滤掉免费洁牙 / 检查促销霸榜);排除保险方付款(非诊所折扣)。
- **时间偏好**:用 `planned_for`(干净营业钟形)按北京时区算星期 / 时段。
- **特别关注**:`no_show` / `cancelled` 预约不是 active fact → 单独全状态加载 `appointmentsAll` 算爽约率 / 迟到。
- **禁忌标签**:v1 仅"种植年龄 ≤18"(无否定 / 量化歧义,带 `validUntil` 满 19 岁自动解除);糖尿病 / 过敏 / 抗凝 / 妊娠等需读既往史 + 否定处理 + 控制状态判断 → **整体留 Layer C,不上规则版**(医疗安全)。
- **治疗敏感**:关键词精炼排假阳(裸"紧张"=颏肌紧张、裸"见血"=血凝块、裸"张口受限"=物理受限,均剔除)。

## 潜在治疗 = 复用召回 gap 核心

`potential_treatment` 标签和召回挖的是**同一个 gap**("诊断了 / 建议了但没做对应治疗")。两者共用 `clinical-gap/potential-treatment-gap.sql` 的 `buildGapCore`(牙位相减 / resolved 判定 / 误召剔除),**口径逐字节一致**(画像圈到的人群 = 召回候选超集)。

区别:**画像是常态属性**(去掉时间冷静门),**召回才讲时机**(cooldown / 排除 / 打分)。**画像不调召回**(避免 Layer 2→3 倒置),只共用中立 gap 模块。8 业务标签按年龄 / name 拆分(种植 K08>18、早矫 / 正畸 K07 按年龄、修复 vs 拔牙 按 K03 name)。`urgency_level` 由潜在治疗 gap × 末诊推出。gap 细节见 [召回算法](/docs/algorithms/recall)。

## 刷新策略(三触发)

```
① 事件驱动(主路径)= 增量 sync 写 facts → 逐人入 BullMQ(jobId=persona_<patientId>,同人去重串行)
     → PersonaService.recompute(triggeredBy='txn:…')→ 成功后级联 plan 重算
② 手动 = recompute-persona CLI(--force 跳水位 —— 算法/特征变更但数据没变时必需)
③ 兜底 = stale-scan 扫 eventWatermark < 最新 event_seq 的画像补算(cron,默认关)
```

## 幂等与版本流

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

## 关键纪律

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

## 未上线(Layer C / follow-up)

- **Layer C(LLM 抽取)标签**:完整禁忌(糖尿病控制 / 过敏 / 抗凝 / 妊娠)、沟通偏好、治疗意向、家庭社交关系 —— `data` 已留多标签位,Layer C 就绪后并入。
- **治疗意向**:咨询主体已摄入(`consultation_record` 含患者意向),`treatment_intent` 特征未做(咨询想做 X 但没做 = 高意愿,与 `potential_treatment` 互补)。
- **急迫等级**:v1 仅"潜在治疗"路径;"已治疗复查超期"路径待召回的复查 scenario 落地后补。
- **圈人群 campaign**:`data` 带 segment + GIN 可查、schema 已 ready(`plan_reasons.campaignId` 预留),功能未实现。
