Commit 57838efe by luoqi

feat(persona): 生命周期特征(B.1.4)+ 修 spec 顺序 bug

- lifecycle_stage 7 阶段(潜客/新客/成长/成熟/待激活/沉睡/流失)。PAC 自算:
  net_receipts_total/total_visit_times 源表(fact_client_out)没有 → 从 payment/就诊 fact 算
  (同 RFM 口径);潜客用 appointment fact。无新摄入/无迁移。
- ️ 修正 spec 顺序 bug:原'沉睡>540'在'流失>730'前→流失永不触发;流失提前到沉睡前。
  本地验证:末诊1913天/23次患者正确判流失(原顺序会误判沉睡)。
- 移除 rfm.data 里的简化 lifecycle(避免与本特征重复;生命周期归本特征单一来源)。
- 注册表 spec/enum/label。本地:成熟614/新客207/成长104/待激活2/流失1。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 6689b612
......@@ -8,6 +8,7 @@ import { GenderFeatureExtractor } from './gender.feature';
import { AcquisitionChannelFeatureExtractor } from './acquisition-channel.feature';
import { FamilyStructureFeatureExtractor } from './family-structure.feature';
import { ReferralChampionFeatureExtractor } from './referral-champion.feature';
import { LifecycleStageFeatureExtractor } from './lifecycle-stage.feature';
/**
* FeatureRegistry — 收集所有 PersonaFeature 提取器。
......@@ -28,9 +29,10 @@ export class FeatureRegistry {
acquisition: AcquisitionChannelFeatureExtractor,
family: FamilyStructureFeatureExtractor,
referral: ReferralChampionFeatureExtractor,
lifecycle: LifecycleStageFeatureExtractor,
dnc: DoNotContactStatusFeatureExtractor,
entitlement: EntitlementStatusFeatureExtractor,
) {
this.extractors = [rfm, age, gender, acquisition, family, referral, dnc, entitlement];
this.extractors = [rfm, age, gender, acquisition, family, referral, lifecycle, dnc, entitlement];
}
}
import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey, FactType, FactKind } from '@pac/types';
import type {
ActiveFact,
FeatureExtractor,
FeatureExtractorContext,
PersonaFeatureDraft,
} from './feature.interface';
/**
* lifecycle_stage 生命周期(B.1.4)— 规则层,snapshot
*
* 标签:潜客/新客/成长客/成熟客/待激活/沉睡客/流失客。决定运营策略;成长vs成熟用消费斜率区分。
*
* 数据字段(spec 用 fact_client_out 的 first/last_visit_time/total_visit_times/net_receipts_this/_total),
* 但源表无 net_receipts_total/total_visit_times → PAC 自算(同 RFM 口径):
* 总消费 = Σpayment+recharge−refund(lifetime);近1年消费 = 近365天同口径;
* 就诊次数 = 去重就诊天数(encounter/actual treatment/挂号);首/末诊 = 就诊事件 min/max。
*
* 判定(⚠️ 修正 spec 顺序 bug:原"沉睡>540"在"流失>730"前 → 流失永不触发;此处流失提前):
* total=0 且有预约 → 潜客
* 首诊≤180天 且 就诊≤3 → 新客
* 末诊≤540 且 年均就诊≥0.5 且 近1年消费 > 历史年均×1.5 → 成长客
* 末诊≤540 且 年均就诊≥0.5 且 近1年消费 ≤ 历史年均×1.5 → 成熟客
* 末诊 180–540 且 就诊≥2 → 待激活
* 末诊>730 → 流失客 (提到沉睡前)
* 末诊>540 且 就诊≥2 → 沉睡客 (= 540–730)
* 否则 → 新客(兜底)
*/
@Injectable()
export class LifecycleStageFeatureExtractor implements FeatureExtractor {
readonly key = PersonaFeatureKey.LIFECYCLE_STAGE;
private static readonly DAY = 86400_000;
private sumCents(facts: ActiveFact[], sign: number, since: number | null): number {
let s = 0;
for (const f of facts) {
if (since !== null && (!f.occurredAt || f.occurredAt.getTime() < since)) continue;
s += sign * Number((f.content as Record<string, unknown>).amount_cents ?? 0);
}
return s;
}
extract(ctx: FeatureExtractorContext): PersonaFeatureDraft {
const get = (t: string) => ctx.factsByType.get(t) ?? [];
const now = ctx.now.getTime();
const DAY = LifecycleStageFeatureExtractor.DAY;
// 消费:lifetime / 近1年
const pays = get('payment_record');
const recharges = get('recharge_record');
const refunds = get('refund_record');
const netTotal =
this.sumCents(pays, 1, null) + this.sumCents(recharges, 1, null) - this.sumCents(refunds, 1, null);
const yearAgo = now - 365 * DAY;
const netThis =
this.sumCents(pays, 1, yearAgo) + this.sumCents(recharges, 1, yearAgo) - this.sumCents(refunds, 1, yearAgo);
// 就诊:去重天数 + 首/末
const visitFacts: ActiveFact[] = [
...get(FactType.ENCOUNTER_RECORD),
...get(FactType.TREATMENT_RECORD).filter((f) => f.kind === FactKind.ACTUAL),
...get(FactType.VISIT_REGISTRATION_RECORD),
];
const days = new Set<string>();
let first: number | null = null;
let last: number | null = null;
for (const f of visitFacts) {
if (!f.occurredAt) continue;
const t = f.occurredAt.getTime();
days.add(f.occurredAt.toISOString().slice(0, 10));
if (first === null || t < first) first = t;
if (last === null || t > last) last = t;
}
const totalVisits = days.size;
const hasAppt = get(FactType.APPOINTMENT_RECORD).length > 0;
const firstDays = first !== null ? Math.floor((now - first) / DAY) : Infinity;
const lastDays = last !== null ? Math.floor((now - last) / DAY) : Infinity;
const firstYears = Math.max((first !== null ? now - first : DAY) / (365.25 * DAY), 1 / 365.25);
const visitsPerYear = totalVisits / firstYears;
const histYearlySpend = netTotal / firstYears;
let code: string;
let zh: string;
if (totalVisits === 0) {
// 从未到诊:有预约 → 潜客;否则也算潜客(有档无诊)
code = 'prospect';
zh = '潜客';
} else if (firstDays <= 180 && totalVisits <= 3) {
code = 'new';
zh = '新客';
} else if (lastDays <= 540 && visitsPerYear >= 0.5 && netThis > histYearlySpend * 1.5) {
code = 'growth';
zh = '成长客';
} else if (lastDays <= 540 && visitsPerYear >= 0.5) {
code = 'mature';
zh = '成熟客';
} else if (lastDays > 180 && lastDays <= 540 && totalVisits >= 2) {
code = 'reactivate';
zh = '待激活';
} else if (lastDays > 730) {
code = 'churned';
zh = '流失客';
} else if (lastDays > 540 && totalVisits >= 2) {
code = 'dormant';
zh = '沉睡客';
} else {
code = 'new';
zh = '新客'; // 兜底
}
const lastStr = last !== null ? `末诊${lastDays}天` : (hasAppt ? '仅预约未诊' : '无就诊');
return {
key: this.key,
description: `${zh} · ${lastStr} · 就诊${totalVisits}次`,
score: null,
data: {
stage: code,
label: zh,
firstSeenDays: first !== null ? firstDays : null,
recencyDays: last !== null ? lastDays : null,
totalVisits,
visitsPerYear: Math.round(visitsPerYear * 10) / 10,
netTotalCents: netTotal,
netThisCents: netThis,
hasAppt,
},
evidence: { factIds: [] },
};
}
}
......@@ -137,14 +137,7 @@ export class RfmFeatureExtractor implements FeatureExtractor {
const rScore = RfmFeatureExtractor.rScore(recencyDays);
const fScore = RfmFeatureExtractor.fScore(freqCount);
const seg = RfmFeatureExtractor.segmentOf(rScore, fScore, mScore);
// ── lifecycle 生命周期(临床节律,R 派生;reactivated 回流后续加)──
let lifecycle: string;
if (recencyDays > 730) lifecycle = 'churned';
else if (recencyDays > 365) lifecycle = 'silent';
else if (firstDays <= 180) lifecycle = 'new';
else lifecycle = 'active';
const lifecycleZh = { churned: '流失', silent: '沉默', new: '新客', active: '活跃' }[lifecycle]!;
// 生命周期已拆为独立特征 lifecycle_stage(B.1.4);此处不再产 lifecycle。
// ── riskScore 0-3(=旧 recall_risk:临床触点 recency + 治疗缺口),喂 likelihoodBonus ──
const clinicalFactIds: string[] = [];
......@@ -186,12 +179,11 @@ export class RfmFeatureExtractor implements FeatureExtractor {
return {
key: this.key,
description: `${seg.zh} · ${lifecycleZh} · ${recencyStr} · 就诊${freqCount}次 · 累计¥${yuan} · R${rScore}F${fScore}M${mScore}`,
description: `${seg.zh} · ${recencyStr} · 就诊${freqCount}次 · 累计¥${yuan} · R${rScore}F${fScore}M${mScore}`,
// score 列已弃用语义(场景从 data 自算分);此处留空
score: null,
data: {
segment: seg.key, // 八象限 → 圈人群
lifecycle,
rScore,
fScore,
mScore,
......
......@@ -10,6 +10,7 @@ import { GenderFeatureExtractor } from './features/gender.feature';
import { AcquisitionChannelFeatureExtractor } from './features/acquisition-channel.feature';
import { FamilyStructureFeatureExtractor } from './features/family-structure.feature';
import { ReferralChampionFeatureExtractor } from './features/referral-champion.feature';
import { LifecycleStageFeatureExtractor } from './features/lifecycle-stage.feature';
@Module({
controllers: [PersonaController],
......@@ -23,6 +24,7 @@ import { ReferralChampionFeatureExtractor } from './features/referral-champion.f
AcquisitionChannelFeatureExtractor,
FamilyStructureFeatureExtractor,
ReferralChampionFeatureExtractor,
LifecycleStageFeatureExtractor,
DoNotContactStatusFeatureExtractor,
EntitlementStatusFeatureExtractor,
],
......
......@@ -387,6 +387,7 @@ export const PersonaFeatureKey = {
ACQUISITION_CHANNEL: 'acquisition_channel', // 获客渠道(初诊来源,数仓 L2;副表立柱)
FAMILY_STRUCTURE: 'family_structure', // 家庭构成(单身/两口/多口/多代;PatientRelation 反推)
REFERRAL_CHAMPION: 'referral_champion', // 转介绍达人(家庭型/社交型;recommend_num+转化)
LIFECYCLE_STAGE: 'lifecycle_stage', // 生命周期(潜客..流失客;时间+消费规则)
// v1 候选(规则路径,业务方反馈后逐步上)
ENTITLEMENT_STATUS: 'entitlement_status', // 权益身份(商保直付 / 医保 / 储值 / 私行;事实投影型,史+最近日期)
......
......@@ -44,6 +44,7 @@ export const PERSONA_FEATURE_META: Record<string, { label: string; tone: Tone }>
[PersonaFeatureKey.ACQUISITION_CHANNEL]: { label: '获客渠道', tone: 'teal' },
[PersonaFeatureKey.FAMILY_STRUCTURE]: { label: '家庭构成', tone: 'amber' },
[PersonaFeatureKey.REFERRAL_CHAMPION]: { label: '转介绍达人', tone: 'rose' },
[PersonaFeatureKey.LIFECYCLE_STAGE]: { label: '生命周期', tone: 'teal' },
[PersonaFeatureKey.VALUE]: { label: '患者价值', tone: 'indigo' },
[PersonaFeatureKey.TREATMENT_CHAIN_STATUS]: { label: '治疗链状态', tone: 'amber' },
[PersonaFeatureKey.RECALL_RISK]: { label: '流失风险', tone: 'emerald' },
......
......@@ -175,4 +175,24 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
owner: 'pac-algo',
version: 2,
},
// ── B.1.4 生命周期(业务 CDP 口径;PAC 自算 + 修正顺序 bug)──
lifecycle_stage: {
key: 'lifecycle_stage',
nameZh: '生命周期',
tier: 'rule',
timeSemantics: 'window+trend', // 末诊 recency + 消费斜率(近1年 vs 历史年均)
labelValues: ['潜客', '新客', '成长客', '成熟客', '待激活', '沉睡客', '流失客'],
dataSource: '现:PAC 自算(payment/recharge/refund + 就诊 fact + appointment);源表 fact_client_out 无 net_receipts_total/total_visit_times → PAC 算;宿主 CDP 报表给出后可切',
dataFields: ['first_visit_time', 'last_visit_time', 'total_visit_times', 'net_receipts_this', 'net_receipts_total'],
meaning: '客户在生命周期中的位置,决定运营策略匹配;成长客 vs 成熟客用消费斜率(近1年>历史年均×1.5)区分',
algorithm: [
'total=0 有预约→潜客;首诊≤180&就诊≤3→新客;',
'末诊≤540&年均≥0.5&近1年>历史年均×1.5→成长客;同上但≤1.5→成熟客;',
'末诊180-540&就诊≥2→待激活;末诊>730→流失客;末诊540-730&就诊≥2→沉睡客;否则→新客兜底。',
'⚠️ 修正 spec 顺序 bug:原"沉睡>540"在"流失>730"前 → 流失永不触发;此处流失提前。',
].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