Commit 3a3abed7 by luoqi

feat(persona): RFM 采用业务 CDP 口径(图 B.1.1)+ M 分位 + 注册表 spec

- rfm.feature 改为业务整理好的 RFM 定义:R/F 分段照图、M 按租户分位(p20/40/60/80)、
  8 段决策树(重要价值..低活跃)。R/F/M = last_visit_time/visit_times/net_receipts_total(lifetime)。
- M 分位需群体计算:PersonaService 算+缓存租户分位阈值(30min TTL),注入 ctx.populationStats;
  缺失降级绝对¥档。valueTier(绝对¥)/riskScore 保留 → 仍 100% 兼容旧 value/recall_risk。
- 新增 persona-feature-specs.ts:标签注册表(标签值/数据来源/数据字段/释义/算法/时间语义),
  代码存、来源可切(现 PAC 自算,宿主 CDP 报表给出后切宿主值)。score 列弃用语义。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 6fafb50e
......@@ -34,6 +34,12 @@ export interface FeatureExtractorContext {
factsByType: Map<string, ActiveFact[]>;
/// today 锚点(测试时可注入固定时间)
now: Date;
/// 群体统计(租户级,统计层特征用 — 如 RFM 的 M 分位阈值 [p20,p40,p60,p80])。
/// 单一真理源由 PersonaService 计算 + 缓存后注入;缺失时统计层特征降级到绝对阈值。
populationStats?: {
/// 累计净消费(cents)分位阈值 [p20,p40,p60,p80](租户内,M 打分用)
monetaryQuantiles: number[];
} | null;
}
export interface ActiveFact {
......
......@@ -28,11 +28,44 @@ import type {
export class PersonaService {
private readonly logger = new Logger(PersonaService.name);
/// 租户级 M(累计净消费)分位阈值缓存:key=host:tenant → { at, q:[p20,p40,p60,p80] }。
/// RFM 的 M 打分要群体分位(图 B.1.1),但 PAC 按患者重算 → 分位阈值缓存复用(分布慢变);
/// 批量重算每轮首个患者算一次,后续命中缓存;TTL 兜底单刷场景。
private readonly mQuantileCache = new Map<string, { at: number; q: number[] }>();
private static readonly M_QUANTILE_TTL_MS = 30 * 60 * 1000;
constructor(
private readonly prisma: PrismaService,
private readonly registry: FeatureRegistry,
) {}
/** 租户内"累计净消费/患者"的 [p20,p40,p60,p80](cents)。缓存 30min;失败/无数据 → []。 */
private async getMonetaryQuantiles(hostId: string, tenantId: string, nowMs: number): Promise<number[]> {
const key = `${hostId}:${tenantId}`;
const hit = this.mQuantileCache.get(key);
if (hit && nowMs - hit.at < PersonaService.M_QUANTILE_TTL_MS) return hit.q;
try {
const rows = await this.prisma.$queryRaw<Array<{ q: number[] }>>`
WITH spend AS (
SELECT patient_id, SUM(
CASE type WHEN 'payment_record' THEN (content->>'amount_cents')::bigint
WHEN 'recharge_record' THEN (content->>'amount_cents')::bigint
WHEN 'refund_record' THEN -(content->>'amount_cents')::bigint END) AS net
FROM patient_facts
WHERE host_id = ${hostId}::uuid AND tenant_id = ${tenantId}
AND status IN ('active','fulfilled')
AND type IN ('payment_record','recharge_record','refund_record')
GROUP BY patient_id)
SELECT percentile_cont(ARRAY[0.2,0.4,0.6,0.8]) WITHIN GROUP (ORDER BY net)::float8[] AS q FROM spend`;
const q = (rows[0]?.q ?? []).map((x) => Number(x));
this.mQuantileCache.set(key, { at: nowMs, q });
return q;
} catch (err) {
this.logger.warn(`getMonetaryQuantiles failed (${key}): ${err instanceof Error ? err.message : err}`);
return [];
}
}
/**
* 重算单个 patient 的 Persona,产新版本并 supersede 旧 active。
*
......@@ -174,6 +207,13 @@ export class PersonaService {
: null,
factsByType,
now,
populationStats: {
monetaryQuantiles: await this.getMonetaryQuantiles(
patient.hostId,
patient.tenantId,
now.getTime(),
),
},
};
const drafts: PersonaFeatureDraft[] = [];
......
......@@ -3,3 +3,4 @@ export * from './schemas';
export * from './utils';
export * from './labels';
export * from './canonical-codes';
export * from './persona-feature-specs';
/**
* Persona Feature Registry(标签注册表)— 单一真理源(代码,git/PR review)
*
* 画像的核心是「标签化」。每个标签按业务整理好的「标签卡」形式定义(对齐 CDP 标签中台 / OneModel):
* 标签值 / 数据来源 / 数据字段 / 标签释义 / 计算算法 + 时间语义 / 层级 / owner / version。
*
* - 存储:**代码常量**(本文件),不落库;DB(persona_features)只存每患者每标签的实例值。
* - 时间语义:每个标签自己声明(snapshot 当前态 / window 窗口 / lifetime 全史 / trend 趋势 / mixed)。
* - 收口:producer 产出的 key 必须在此登记(CI 防漂移,见 persona-design-v2.md §二)。
* - 来源可切:同一标签现可从 PAC 事实层自算,宿主 CDP 报表给出后切宿主值,口径(算法)不变。
*/
export type FeatureTier = 'rule' | 'statistical' | 'model' | 'llm';
export type FeatureTimeSemantics = 'snapshot' | 'window' | 'lifetime' | 'trend' | 'mixed';
export interface PersonaFeatureSpec {
key: string; // PersonaFeatureKey
nameZh: string; // 标签名
tier: FeatureTier; // 层级:规则 / 统计 / 模型 / LLM
timeSemantics: FeatureTimeSemantics; // 时间语义
labelValues?: string[]; // 标签值(枚举型标签)
dataSource: string; // 数据来源(现状 + 未来切换)
dataFields: string[]; // 数据字段
meaning: string; // 标签释义
algorithm: string; // 计算算法(口径,人读;真理实现在 extractor)
owner: string;
version: number;
}
export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
// ── B.1.1 RFM 层级(业务整理好的 CDP 口径,直接采用)──
rfm: {
key: 'rfm',
nameZh: '价值分群(RFM)',
tier: 'statistical',
timeSemantics: 'mixed', // R=snapshot(最近) F/M=lifetime
labelValues: [
'重要价值客户',
'重要保持客户',
'重要发展客户',
'重要挽留客户',
'一般价值客户',
'一般保持客户',
'一般发展客户',
'低活跃客户',
],
dataSource:
'现:PAC 事实层自算(encounter/treatment(actual)/挂号 + payment/recharge/refund);未来:宿主 CDP「客户综合分析报表」直接给 R/F/M',
dataFields: ['last_visit_time', 'visit_times', 'net_receipts_total'],
meaning: '基于最近就诊时间(R)、就诊频次(F)、累计消费(M)三维度的客户价值分层',
algorithm: [
'Step1 R/F/M 得分(1-5):',
' R ≤540天=5 / 541-730=4 / 731-1095=3 / 1096-1460=2 / >1460=1',
' F ≥5次=5 / 3-4次=4 / 2次=2 / 1次=1',
' M 累计消费分位:TOP20%=5 / 20-40%=4 / 40-60%=3 / 60-80%=2 / BOTTOM20%=1(租户内分位)',
'Step2 分层:',
' R≥4 F≥3 M≥4→重要价值 / R=3 F≥3 M≥4→重要保持 / R≥4 F=2 M≥4→重要发展 / R≤2 F≥3 M≥4→重要挽留',
' R≥4 F≥3 M<4→一般价值 / R=3 F≥3 M<4→一般保持 / R≥4 F=2 M<4→一般发展 / 其余(含 R≤2 任意)→低活跃',
].join('\n'),
owner: 'pac-algo',
version: 1,
},
};
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