Commit 0edbe90d by luoqi

docs(algorithm): 画像层设计 v2 — 时间语义/Feature Registry/RFM 八象限/圈人群

画像从'被动打分+话术标签'升级为有治理、有时间语义、可圈人群的特征体系:
- 时间语义模型(snapshot/window/lifetime/trend,每特征声明;画像=压缩当前态,历史留fact层,版本流=point-in-time)
- Feature Registry 单一收口(OneModel)+ CI 防漂移
- persona_features 加 typed value JSONB(支持 SQL 圈人群)
- RFM 八象限(统一现有 value+risk:M→value,R+F→lifecycle/risk)+ 召回语义映射
- lifecycle_stage 生命周期分层;treatment_chain 移出画像→episode 视图
- Campaign 圈人群→批量召回;质量监控;分 5 PR 实施

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 4c3f9c58
# 画像层设计 v2 — 时间语义 / Feature Registry / RFM 八象限 / 圈人群
> **版本**:W7 · 2026-06 · 把画像从"被动给召回打分 + 话术摘要标签"升级成"有治理、有时间语义、可圈人群运营"的特征体系。
> **关联**:[`db-design-v2.md`](../db-design-v2.md)(personas/persona_features 表)· [`pac-algorithms-overview.md`](./pac-algorithms-overview.md)(6 因子打分)· `priority-scorer.ts`(消费方)· `canonical-codes.ts`(字典单一源)
>
> **一句话**:画像 = 对全历史的「压缩当前态」,每个特征各自声明时间语义;RFM 八象限统一现有的 value+risk;特征带结构化 value 支持按标签圈人群批量召回。
---
## 〇、为什么重构(现状诊断)
现状画像很「轻」:`persona_feature = key + description + score + evidence + version`,4 个起步特征(value / treatment_chain_status / recall_risk / do_not_contact_status),只被两处消费——召回 6 因子打分(valueBonus/likelihoodBonus)+ 话术输入摘要。
| 相对大厂成熟方案缺的 | 本设计补法 |
|---|---|
| 特征没声明**时间语义**(当前态/窗口/全历史) | §一 时间语义模型,每特征在 Registry 声明 |
| 没有**特征注册表/元数据**(口径散在代码) | §二 Feature Registry(OneModel 单一收口) |
| 几乎没有**统计/模型层**特征 | §四 RFM 八象限 + §五 lifecycle_stage |
| 特征只有散文,**圈不了人群** | §三 schema 加 typed `value` JSONB |
| 用途只「被动打分」 | §六 圈人群 → 批量召回 campaign |
| 质量无监控 | §七 覆盖率/时效/漂移 |
**保留不动**:`key+description+evidence+version+eventWatermark` 这套骨架——等于 Feature Store 的 point-in-time + 血缘 + 版本的轻量版,是对的。**重做的是肉**:特征集、时间语义、治理、用途。
---
## 一、时间语义模型(画像的「时效性」怎么解释)
**画像不是单一时态。三种时间语义同时存在,每个特征各自声明。**
| 时间语义 | 含义 | 牙科例子 | 计算 |
|---|---|---|---|
| `snapshot` 当前态(慢变维) | 属性的「现在值」,覆盖 | 勿扰、归属诊所、在治状态 | 取最新 |
| `window` 窗口聚合 | 限定时间窗内 | 近 24 月就诊次数、近 90 天消费 | 窗口内 agg,窗口长度是参数 |
| `lifetime` 全历史累计 | 跨全部历史 | 累计净消费(LTV)、首诊时间 | 全量 agg |
| `trend` 趋势变化 | 两窗口对比 | 就诊频次变稀(流失前兆) | 窗口相减 |
**两条铁律**:
1. **画像存「压缩特征」,不存历史明细。** 画像回答「**现在**这人是什么样」,是从全历史压缩出来的。全量明细留在 `patient_facts`(事实层)。"看全部历史"是事实层的活,不是画像的活。(对应设计哲学:facts=raw context,persona=压缩 hidden state)
2. **时点回放靠版本流。** 画像本身是当前态;要看「上个月怎么判的」→ 读历史 `personas` 版本(`version + computedAt + eventWatermark` = point-in-time correctness,不穿越未来)。
> **RFM 正是三种时态的融合**:R=当前态(距上次多久)/ F=窗口(近 N 月频次)/ M=全历史(累计消费)。一个模型同时表达「最近、频次、终身价值」。
---
## 二、Feature Registry(特征注册表 — 单一收口)
`canonical-codes.ts` 同级的单一真理源:`packages/types/src/persona-features.ts`(或 `apps/pac-service/.../persona/feature-registry.ts`)。每个 feature_key 登记元数据:
```ts
export const PersonaFeatureRegistry = {
value: {
nameZh: '患者价值', tier: 'statistical',
timeSemantics: 'lifetime', window: null,
valueShape: '{ tier:0..4, monetaryCents, mScore:1..5 }',
dependsOn: ['payment_record', 'refund_record'],
refresh: 'event', owner: 'pac-algo', version: 2,
coverageTarget: 0.95,
},
lifecycle_stage: {
nameZh: '生命周期阶段', tier: 'statistical',
timeSemantics: 'window+trend', window: '24m',
valueShape: "{ stage:'new'|'active'|'silent'|'churned'|'reactivated', recencyDays, ... }",
dependsOn: ['encounter_record', 'treatment_record', 'visit_registration_record'],
refresh: 'event', owner: 'pac-algo', version: 1, coverageTarget: 0.98,
},
rfm: {
nameZh: 'RFM 八象限', tier: 'statistical', timeSemantics: 'mixed',
valueShape: '{ rScore, fScore, mScore, segment, recencyDays, freqCount, monetaryCents }',
dependsOn: ['encounter_record','treatment_record','visit_registration_record','payment_record','refund_record'],
refresh: 'event', owner: 'pac-algo', version: 1, coverageTarget: 0.95,
},
recall_risk: { nameZh:'流失/触达风险', tier:'rule', timeSemantics:'derived', dependsOn:['rfm'], ... },
do_not_contact_status: { nameZh:'勿扰', tier:'rule', timeSemantics:'snapshot', ... },
// LLM 层(W7+):communication_preference / treatment_intent / family_social_relation ...
} as const;
```
**CI 防漂移**:producer 实际产出的 feature key ⊆ Registry;每个 producer 必须声明它产哪个 key(类似 FactWriter 的收口纪律)。
**tier 4 类 producer**(都统一写 persona_features):`rule`(规则)/ `statistical`(SQL 聚合,本轮重点)/ `model`(ML,后续)/ `llm`(自由文本抽,W7+)。
---
## 三、表设计(在现有表上增强)
```prisma
model PersonaFeature {
id String @id @default(uuid()) @db.Uuid
personaId String @map("persona_id") @db.Uuid
key String // Registry 闭集
description String @db.Text // 自然语言(给 LLM/展示)— 已有
score Float? // 排序单值 — 已有
value Json? // ⭐ 新增:结构化 typed 值(给 SQL 圈人)
evidence Json // {factIds,ruleIds,...} — 已有
createdAt DateTime @default(now())
persona Persona @relation(...)
@@unique([personaId, key])
}
```
**`value` 的 shape 由 Registry.valueShape 约定**(application 层 zod 校验,跟 fact.content 同思路)。例:
- `value` 特征:`{ tier: 3, monetaryCents: 4477000, mScore: 5 }`
- `rfm` 特征:`{ rScore:2, fScore:1, mScore:5, segment:'important_retain', recencyDays:540, freqCount:2, monetaryCents:4477000 }`
- `lifecycle_stage`:`{ stage:'churned', recencyDays:540 }`
**圈人群查询索引**(13 万规模够用):
```sql
-- 当前 persona 指针:personas(patient_id) WHERE superseded_at IS NULL
CREATE UNIQUE INDEX persona_current ON personas(platform_id, tenant_id, patient_id) WHERE superseded_at IS NULL;
-- 特征值检索
CREATE INDEX pf_key ON persona_features(key);
CREATE INDEX pf_value_gin ON persona_features USING GIN (value);
```
圈人群 = `persona_features f JOIN personas p ON f.persona_id=p.id AND p.superseded_at IS NULL WHERE f.key='rfm' AND f.value->>'segment'='important_retain'`
**移除 `treatment_chain_status`**:它是 episode 级状态、不是人级属性(一人多链有损)。
- 停止 producer;降级为**详情页 fact 派生视图**(从 treatment_record 直接算 5 阶段,不进画像)。
- 人级有用信号(是否有未闭环治疗在进行)并入 `lifecycle_stage``inTreatment` 子字段。
- 历史行不删(版本流保真),只是新版本不再产该 key。
---
## 四、RFM 八象限(第一个统计层特征)
### R / F / M 口径(牙科)
| | 口径 | 时间语义 | 来源 | 默认阈值(Registry 可调) |
|---|---|---|---|---|
| **R 最近** | 距最后一次临床到诊天数 | snapshot | max(occurred_at) of encounter/treatment/visit_registration | high=≤270d(还在召回节律内)/ low=>270d |
| **F 频次** | 近 24 月去重就诊天数 | window-24m | encounter/visit 按天去重 count | high=≥4 次 / low=<4 次 |
| **M 金额** | 累计净消费(收−退) | lifetime | payment − refund(现有 LTV 口径) | 按租户分位:high=≥ 中位 / low=< 中位 |
> 阈值口径放 Registry,默认值上线后用真实分布(扫描器)校准。R 用临床固定阈(牙科洁牙节律 ~6mo,270d≈ 逾期一轮);M 用**租户内分位**(不同诊所绝对值不可比)。
### 八象限(高/低三维 → 2³)+ 召回语义映射
| segment(key) | R | F | M | 中文 | 召回策略 |
|---|---|---|---|---|---|
| `important_value` | 高 | 高 | 高 | 重要价值 | 在来,**别过度打扰**(已活跃) |
| `important_retain` | 低 | 高 | 高 | 重要保持 | 高价值+曾高频、最近变稀 → **高优召回** |
| `important_develop` | 高 | 低 | 高 | 重要发展 | 高价值低频 → 提频次/复查邀约 |
| `important_winback` | 低 | 低 | 高 | 重要挽留 | 高价值流失 → **最高优召回**(挽回 ROI 最高) |
| `general_value` | 高 | 高 | 低 | 一般价值 | 维护 |
| `general_retain` | 低 | 高 | 低 | 一般保持 | 一般召回 |
| `general_develop` | 高 | 低 | 低 | 一般发展 | 培育 |
| `general_winback` | 低 | 低 | 低 | 一般挽留 | 低优/批量 SMS |
> 关键洞察:**RFM 统一了现有零散的 value+risk**——M→`value`(钱),R+F→`recall_risk`/`lifecycle`(活跃)。八象限直接给「该不该召、多优先」一个可解释的人群标签。
### 落地产出
`rfm` producer(SQL 聚合,确定性、便宜)写 3 个特征:
- `rfm`:完整 `{rScore,fScore,mScore,segment,recencyDays,freqCount,monetaryCents}`
- `value` ← M 派生(`{tier,monetaryCents,mScore}`,**保持 valueBonus 兼容**)
- `lifecycle_stage` ← R+F 派生(§五)
---
## 五、lifecycle_stage(生命周期分层 — R+F 派生)
状态机(就诊节律):
| stage | 判据(默认,可调) | 召回/话术含义 |
|---|---|---|
| `new` 新客 | 首诊 ≤180d 且 总就诊 ≤2 | 建立关系语气 |
| `active` 活跃 | R 高(近 ~270d 有到诊) | 正常维护 |
| `silent` 沉默 | 逾期 1 轮(270d–540d 无到诊) | **召回黄金窗** |
| `churned` 流失 | >540d 无到诊 | 挽回/低优 |
| `reactivated` 回流 | 近期到诊 但 此前长间断 | 强化粘性,别再当流失 |
`value.inTreatment`(并入):有未闭环 treatment_record 在进行 → true(替代旧 treatment_chain_status 的人级语义)。
---
## 六、圈人群 → 批量召回(主动运营)
把画像从「被动打分」升级到「主动圈人」。复用 `plan_reasons.source='campaign'` + `campaignId`(已预留)。
```prisma
model Campaign { // 新增轻量表
id String @id @default(uuid()) @db.Uuid
platformId String; tenantId String
name String // "高价值流失挽回 2026Q3"
segmentQuery Json // {feature:'rfm', filters:{segment:'important_winback'}}
status String // draft|running|done
createdBy String; createdAt DateTime @default(now())
}
```
**流程**:定义 segment 查询 → 预览命中人数 → 批量建 plan(`plan_reasons.source='campaign', campaignId, scenario='campaign_recall'`,priorityScore 用 RFM 段位)→ 进客服池。受同样的合规闸 + 单 active plan 约束 + ⑤b/⑤f 抑制。
**与算法召回的关系**:算法召回 = 临床信号驱动(诊断未治);campaign = 运营/价值驱动(按人群圈)。两者都进同一 plan 的 reasons[],去重由 patient 级单 plan 保证。
---
## 七、质量监控
复用 `persona_recompute_logs` + 一个看板查询:
- **覆盖率**:每个 key 有值的 active 患者占比 vs Registry.coverageTarget。
- **时效**:`max(now - personas.computedAt)`(最旧水位)+ stale 比例。
- **LLM fallback rate**:llm-tier 特征降级占比。
- **分布漂移**:RFM segment / value tier 的周分布对比(PSI),异常即上游/口径漂移信号。
---
## 八、消费方改造
- `priority-scorer.ts`:`valueBonus` 改读 `value.tier`(口径不变,来源换成 RFM-M);`likelihoodBonus` 改读 `lifecycle_stage`/`recall_risk`(RFM-R/F 派生)。八象限可直接给 base bonus 表。
- 话术 ScriptContext:薄画像标签换成 `lifecycle_stage` + `rfm.segment`(定语气),`value.tier`(称呼/价值)。
- 详情页:treatment chain 改 episode 视图;新增 RFM 段位 + 生命周期标签展示。
---
## 九、实施顺序(分 PR,范围=完整八象限+圈人群)
1. **PR1 schema + Registry**:persona_features 加 `value JSONB` + 索引(migration);建 Feature Registry + CI 收口;停产 treatment_chain_status。
2. **PR2 RFM producer**:statistical 层 SQL → `rfm`/`value`/`lifecycle_stage` 三特征落 persona;本地全量校准阈值(扫描器看分布)。
3. **PR3 消费方**:priority-scorer 读新特征(保 valueBonus 兼容);话术/详情页标签。
4. **PR4 圈人群**:Campaign 表 + segment 预览 + 批量建 plan;前端圈人界面(可后置)。
5. **PR5 质量看板**:覆盖率/时效/漂移。
每步本地全量验证(13 万)+ 服务器灰度,口径变更同步本文档。
---
## 附:口径决策记录(2026-06)
- 画像特征加结构化 `value` JSONB(支持 SQL 圈人群)。
- treatment_chain_status 移出画像 → episode 视图;人级信号并入 lifecycle。
- RFM 做完整八象限 + 圈人群 campaign 一起。
- M=全历史累计净消费(牙科大额低频,价值看终身);R 临床固定阈;F/M 阈值用租户分位,上线后扫描器校准。
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment