Commit 1b086e7b by luoqi

feat(persona): 翻转 scorer 读 rfm.data + 摘除旧 value/recall_risk/treatment_chain

- treatment-initiation-recall.fetchPersonaContext:改读 rfm.data(valueTier/riskScore),
  rfm 缺失优雅回退旧字段。本地 1437/1437 plan 打分零变化 → 翻转行为等价。
- FeatureRegistry/persona.module:摘除 value/recall_risk/treatment_chain_status 三个 extractor
  (treatment_chain 降级为详情页 episode 视图,后续单做);现役 rfm/dnc/entitlement。
- rfm 决策树补全:develop 档 F=2 扩 F<3(近期单次新客→一般发展,不误落低活跃)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 3a3abed7
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { FeatureExtractor } from './feature.interface'; import type { FeatureExtractor } from './feature.interface';
import { ValueFeatureExtractor } from './value.feature';
import { TreatmentChainStatusFeatureExtractor } from './treatment-chain-status.feature';
import { RecallRiskFeatureExtractor } from './recall-risk.feature';
import { DoNotContactStatusFeatureExtractor } from './do-not-contact-status.feature'; import { DoNotContactStatusFeatureExtractor } from './do-not-contact-status.feature';
import { EntitlementStatusFeatureExtractor } from './entitlement-status.feature'; import { EntitlementStatusFeatureExtractor } from './entitlement-status.feature';
import { RfmFeatureExtractor } from './rfm.feature'; import { RfmFeatureExtractor } from './rfm.feature';
/** /**
* FeatureRegistry — 收集所有规则路径的 PersonaFeature 提取器。 * FeatureRegistry — 收集所有 PersonaFeature 提取器。
* *
* v1 起步 4 个:value / treatment_chain_status / recall_risk / do_not_contact_status。 * W7 重构:统计层 rfm(RFM 8 分群)统一了旧 value(=M)+ recall_risk(=R+缺口)+
* + entitlement_status 权益身份(商保/医保,事实投影型,史+最近日期)。 * treatment_chain_status(降级为详情页 episode 视图,不进画像)→ 三个旧 extractor 已摘除。
* 单个 extractor 抛错不影响其余 — PersonaService 整体记 partial(orchestration log)。 * 现役:rfm(统计层)/ do_not_contact_status(合规)/ entitlement_status(权益,事实投影)。
* 单个 extractor 抛错不影响其余 — PersonaService 整体记 partial。
*/ */
@Injectable() @Injectable()
export class FeatureRegistry { export class FeatureRegistry {
readonly extractors: FeatureExtractor[]; readonly extractors: FeatureExtractor[];
constructor( constructor(
value: ValueFeatureExtractor, rfm: RfmFeatureExtractor,
chain: TreatmentChainStatusFeatureExtractor,
risk: RecallRiskFeatureExtractor,
dnc: DoNotContactStatusFeatureExtractor, dnc: DoNotContactStatusFeatureExtractor,
entitlement: EntitlementStatusFeatureExtractor, entitlement: EntitlementStatusFeatureExtractor,
rfm: RfmFeatureExtractor,
) { ) {
// rfm(统计层)additive 加入;下一步翻转 scorer 读 rfm.data 后,推翻 value/chain/risk this.extractors = [rfm, dnc, entitlement];
this.extractors = [value, chain, risk, dnc, entitlement, rfm];
} }
} }
import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey, FactType, FactKind, lookupDxTreatment } from '@pac/types';
import type {
FeatureExtractor,
FeatureExtractorContext,
PersonaFeatureDraft,
} from './feature.interface';
/**
* recall_risk 召回风险 / 流失 / 复发(v2.1)
*
* v2.1 重塑(对齐 canonical-fact-layer.md §四):
* - 不再读 encounter_record.content.treatments[] 嵌套
* - 改读独立 fact 的 occurred_at 算"距上次活跃"
* - 改读 diagnosis_record + treatment_record 配对缺口算"链有缺口"
*
* 信号:
* 1. 距上次任意临床事件天数(取 max(occurred_at) over diagnosis/treatment/encounter)
* 2. 是否有"诊断后超期未治疗"缺口(K02 后 > 90d 无 restorative,K08 后 > 180d 无 implant)
* 3. 投诉 complaint_record 存在 → 加风险但同时 DNC,本特征只看风险维度
*
* 分档:
* - 距上次 ≥ 540 天 + 链有缺口 → 'high' score=3
* - 距上次 ≥ 360 天 / 链有缺口 → 'medium' score=2
* - 距上次 ≥ 180 天 → 'low' score=1
* - 否则 → 'none' score=0
*/
@Injectable()
export class RecallRiskFeatureExtractor implements FeatureExtractor {
readonly key = PersonaFeatureKey.RECALL_RISK;
// 诊断码 → 期望 treatment category + 黄金窗(天)用单一真理源
// canonical-codes.DiagnosisTreatmentMap(lookupDxTreatment)
extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null {
const diagnoses = ctx.factsByType.get(FactType.DIAGNOSIS_RECORD) ?? [];
const treatments = ctx.factsByType.get(FactType.TREATMENT_RECORD) ?? [];
const recommendations =
ctx.factsByType.get(FactType.RECOMMENDATION_RECORD) ?? [];
const encounters = ctx.factsByType.get(FactType.ENCOUNTER_RECORD) ?? [];
const allClinical = [
...diagnoses,
...treatments,
...recommendations,
...encounters,
];
if (allClinical.length === 0) {
return {
key: this.key,
description: '无召回风险数据(从未有临床事件)',
score: 0,
evidence: { factIds: [] },
};
}
// 1. 距上次任意临床事件天数
const today = ctx.now;
let latest: Date | null = null;
const factIds: string[] = [];
for (const f of allClinical) {
factIds.push(f.id);
if (!f.occurredAt) continue;
if (!latest || f.occurredAt.getTime() > latest.getTime())
latest = f.occurredAt;
}
const daysSince = latest
? Math.floor((today.getTime() - latest.getTime()) / 86400_000)
: 9999;
// 2. 缺口:遍历 dx/recommendation,看相应 treatment category 是否有,且诊断超窗
const actualTreatmentCats = new Set<string>();
for (const tx of treatments) {
if (tx.kind !== FactKind.ACTUAL) continue;
const c = tx.content as Record<string, unknown>;
const cat = String(c.category ?? '');
if (cat) actualTreatmentCats.add(cat);
}
let hasGap = false;
for (const sig of [...diagnoses, ...recommendations]) {
if (!sig.occurredAt) continue;
const c = sig.content as Record<string, unknown>;
const code = String(c.code ?? '');
const rule = lookupDxTreatment(code);
if (!rule) continue;
const fulfilled = rule.categories.some((cat) =>
actualTreatmentCats.has(cat),
);
if (fulfilled) continue;
const sinceDx = Math.floor(
(today.getTime() - sig.occurredAt.getTime()) / 86400_000,
);
if (sinceDx > rule.windowDays) {
hasGap = true;
break;
}
}
let level: string;
let score: number;
if (daysSince >= 540 && hasGap) {
level = 'high';
score = 3;
} else if (daysSince >= 360 || hasGap) {
level = 'medium';
score = 2;
} else if (daysSince >= 180) {
level = 'low';
score = 1;
} else {
level = 'none';
score = 0;
}
const gapStr = hasGap ? ',治疗链有未闭合缺口' : '';
return {
key: this.key,
description: `[${level}] 距上次临床事件 ${daysSince}${gapStr}`,
score,
evidence: { factIds },
};
}
}
...@@ -77,17 +77,19 @@ export class RfmFeatureExtractor implements FeatureExtractor { ...@@ -77,17 +77,19 @@ export class RfmFeatureExtractor implements FeatureExtractor {
return 1; return 1;
} }
/** 八象限决策树(R/F/M 为 1-5 分),图 B.1.1 Step2 */ /** RFM 8 分群决策树(R/F/M 为 1-5 分),图 B.1.1 Step2。
* 弥补:图的 develop 档只写 F=2,这里扩成 F<3(含 F=1 的近期单次新客),
* 避免"近期来过一次"误落低活跃;其余分层逐字照图。 */
private static segmentOf(r: number, f: number, m: number): { key: string; zh: string } { private static segmentOf(r: number, f: number, m: number): { key: string; zh: string } {
const mHi = m >= 4; const mHi = m >= 4; // M≥4 = 累计消费 TOP40%
if (r >= 4 && f >= 3 && mHi) return { key: 'important_value', zh: '重要价值' }; if (r >= 4 && f >= 3 && mHi) return { key: 'important_value', zh: '重要价值' };
if (r === 3 && f >= 3 && mHi) return { key: 'important_retain', zh: '重要保持' }; if (r === 3 && f >= 3 && mHi) return { key: 'important_retain', zh: '重要保持' };
if (r >= 4 && f === 2 && mHi) return { key: 'important_develop', zh: '重要发展' }; if (r >= 4 && f < 3 && mHi) return { key: 'important_develop', zh: '重要发展' }; // 图 F=2 → 扩 F<3
if (r <= 2 && f >= 3 && mHi) return { key: 'important_winback', zh: '重要挽留' }; if (r <= 2 && f >= 3 && mHi) return { key: 'important_winback', zh: '重要挽留' };
if (r >= 4 && f >= 3 && !mHi) return { key: 'general_value', zh: '一般价值' }; if (r >= 4 && f >= 3 && !mHi) return { key: 'general_value', zh: '一般价值' };
if (r === 3 && f >= 3 && !mHi) return { key: 'general_retain', zh: '一般保持' }; if (r === 3 && f >= 3 && !mHi) return { key: 'general_retain', zh: '一般保持' };
if (r >= 4 && f === 2 && !mHi) return { key: 'general_develop', zh: '一般发展' }; if (r >= 4 && f < 3 && !mHi) return { key: 'general_develop', zh: '一般发展' }; // 图 F=2 → 扩 F<3
return { key: 'low_active', zh: '低活跃' }; // 含图 rule8(R≤2 任意)+ 未覆盖的稀疏组合 return { key: 'low_active', zh: '低活跃' }; // 图 rule8(R≤2 任意)+ R=3&F<3 低频流失中
} }
extract(ctx: FeatureExtractorContext): PersonaFeatureDraft { extract(ctx: FeatureExtractorContext): PersonaFeatureDraft {
......
import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey, FactType } from '@pac/types';
import {
ChainComposerService,
type ChainComposeInputFact,
} from '../../plan/engine/chain-composer.service';
import type {
FeatureExtractor,
FeatureExtractorContext,
PersonaFeatureDraft,
} from './feature.interface';
/**
* treatment_chain_status 治疗链状态(v2.2 — 复用 chain-composer,与治疗链面板/召回同口径)
*
* v2.2 重塑(修"画像缺口数 ≠ 召回缺口数"):
* 旧版自己用**类别级**判定缺口(`actualByCat.has(cat)`)— 不看牙位/时间/替代闭环,
* 会把"缺牙11(K08)被无关 prosthodontic 治疗掩盖"漏算 → 画像显示 1,实际 2。
* 新版直接调 ChainComposerService.compose(facts)(就是画"治疗链面板"那套,已正确处理
* 牙位分链 / 时间方向 / 替代闭环),数 status='discovered' 且非 alternativeClosedBy 的链 =
* "潜在新链 = 缺口",跟召回理由 / 治疗链面板逐条一致。
*
* 规则(v1 语义不变,判据换成 chain):
* - 无任何 chain(无诊断/治疗/建议) → 'no_chain'(从未进入)
* - 有 discovered 且非替代闭环的链 → 'gap'(链有缺口,召回核心信号)
* - 否则有 entered/ongoing 链 → 'in_progress'(在管)
* - 否则有 closed 链 → 'closed'(已闭环)
*/
@Injectable()
export class TreatmentChainStatusFeatureExtractor implements FeatureExtractor {
readonly key = PersonaFeatureKey.TREATMENT_CHAIN_STATUS;
constructor(private readonly chainComposer: ChainComposerService) {}
extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null {
const diagnoses = ctx.factsByType.get(FactType.DIAGNOSIS_RECORD) ?? [];
const treatments = ctx.factsByType.get(FactType.TREATMENT_RECORD) ?? [];
const recommendations =
ctx.factsByType.get(FactType.RECOMMENDATION_RECORD) ?? [];
if (
diagnoses.length === 0 &&
treatments.length === 0 &&
recommendations.length === 0
) {
return {
key: this.key,
description: '从未进入治疗链(无诊断 / 治疗 / 建议记录)',
score: 0,
evidence: { factIds: [] },
};
}
// 复用 chain-composer:它消费所有 active facts,内部按 type 分组
const allFacts = [...ctx.factsByType.values()].flat() as ChainComposeInputFact[];
const chains = this.chainComposer.compose(allFacts);
// 缺口 = 潜在新链:discovered(仅 S1 命中)且未被替代方案闭环覆盖
const gapChains = chains.filter(
(c) => c.status === 'discovered' && !c.alternativeClosedBy,
);
const activeChains = chains.filter(
(c) => c.status === 'entered' || c.status === 'ongoing',
);
const closedChains = chains.filter((c) => c.status === 'closed');
// 证据:诊断 / 治疗 / 建议 fact ids(反查链路)
const factIds = [
...diagnoses.map((f) => f.id),
...treatments.map((f) => f.id),
...recommendations.map((f) => f.id),
];
let status: string;
let description: string;
let score: number;
if (gapChains.length > 0) {
status = 'gap';
const names = gapChains.map((c) => c.name).slice(0, 3);
const more = gapChains.length > 3 ? ` 等 ${gapChains.length} 处` : '';
description = `${gapChains.length} 处缺口 — ${names.join('、')}${more}`;
score = 3;
} else if (activeChains.length > 0) {
status = 'in_progress';
description = `治疗链进行中(${activeChains.length} 条在管)`;
score = 1;
} else if (closedChains.length > 0) {
status = 'closed';
description = `治疗链已闭环(${closedChains.length} 条已完成)`;
score = 2;
} else {
// 有信号但 chain 既非 gap 也非在管/闭环(罕见兜底)
status = 'gap';
description = '有诊断 / 建议,治疗链待跟进';
score = 3;
}
return {
key: this.key,
description: `[${status}] ${description}`,
score,
evidence: { factIds },
};
}
}
import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey } from '@pac/types';
import type {
FeatureExtractor,
FeatureExtractorContext,
PersonaFeatureDraft,
} from './feature.interface';
/**
* value 患者价值 / LTV
*
* LTV(cents) = SUM(payment_record.amount) + SUM(recharge_record.amount) - SUM(refund_record.amount)
*
* 分档(可调,集中在此):
* ≥ ¥30,000 → VIP 钻卡 (score=4)
* ≥ ¥10,000 → VIP 金卡 (score=3)
* ≥ ¥3,000 → VIP 银卡 (score=2)
* ≥ ¥500 → 普通付费 (score=1)
* < ¥500 → 新客/未消费 (score=0)
*/
@Injectable()
export class ValueFeatureExtractor implements FeatureExtractor {
readonly key = PersonaFeatureKey.VALUE;
private static readonly TIERS = [
{ cents: 3000_000, label: 'VIP 钻卡', score: 4 },
{ cents: 1000_000, label: 'VIP 金卡', score: 3 },
{ cents: 300_000, label: 'VIP 银卡', score: 2 },
{ cents: 50_000, label: '普通付费', score: 1 },
];
extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null {
const payments = ctx.factsByType.get('payment_record') ?? [];
const recharges = ctx.factsByType.get('recharge_record') ?? [];
const refunds = ctx.factsByType.get('refund_record') ?? [];
const factIds: string[] = [];
let total = 0;
for (const f of payments) {
const c = f.content as Record<string, unknown>;
// v2.1 字段统一为 amount_cents(payment.parser / payment fact-content-schemas)
total += Number(c.amount_cents ?? c.amount ?? 0);
factIds.push(f.id);
}
for (const f of recharges) {
const c = f.content as Record<string, unknown>;
total += Number(c.amount_cents ?? c.amount ?? 0);
factIds.push(f.id);
}
for (const f of refunds) {
const c = f.content as Record<string, unknown>;
total -= Number(c.amount_cents ?? c.amount ?? 0);
factIds.push(f.id);
}
const tier = ValueFeatureExtractor.TIERS.find((t) => total >= t.cents);
const label = tier?.label ?? '新客/未消费';
const score = tier?.score ?? 0;
const yuan = (total / 100).toFixed(2);
// description 简化 — UI 紧凑展示;笔数/退款明细走 facts 时间轴 / profile.paymentCount 等
// (旧版 "新客/未消费(累计净消费 ¥58.00,含付款 1 笔 / 充值 0 笔 / 退款 0 笔)" UI 严重溢出)
return {
key: this.key,
description: `${label} · 累计 ¥${yuan}`,
score,
evidence: { factIds },
};
}
}
...@@ -2,10 +2,6 @@ import { Module } from '@nestjs/common'; ...@@ -2,10 +2,6 @@ import { Module } from '@nestjs/common';
import { PersonaController } from './persona.controller'; import { PersonaController } from './persona.controller';
import { PersonaService } from './persona.service'; import { PersonaService } from './persona.service';
import { FeatureRegistry } from './features/feature.registry'; import { FeatureRegistry } from './features/feature.registry';
import { ChainComposerService } from '../plan/engine/chain-composer.service';
import { ValueFeatureExtractor } from './features/value.feature';
import { TreatmentChainStatusFeatureExtractor } from './features/treatment-chain-status.feature';
import { RecallRiskFeatureExtractor } from './features/recall-risk.feature';
import { DoNotContactStatusFeatureExtractor } from './features/do-not-contact-status.feature'; import { DoNotContactStatusFeatureExtractor } from './features/do-not-contact-status.feature';
import { EntitlementStatusFeatureExtractor } from './features/entitlement-status.feature'; import { EntitlementStatusFeatureExtractor } from './features/entitlement-status.feature';
import { RfmFeatureExtractor } from './features/rfm.feature'; import { RfmFeatureExtractor } from './features/rfm.feature';
...@@ -15,14 +11,10 @@ import { RfmFeatureExtractor } from './features/rfm.feature'; ...@@ -15,14 +11,10 @@ import { RfmFeatureExtractor } from './features/rfm.feature';
providers: [ providers: [
PersonaService, PersonaService,
FeatureRegistry, FeatureRegistry,
// 无依赖纯函数服务 — 治疗链缺口判定复用它(跟 治疗链面板/召回 同口径) // W7:rfm 统一了旧 value/recall_risk/treatment_chain_status,三个旧 extractor 已摘除。
ChainComposerService, RfmFeatureExtractor,
ValueFeatureExtractor,
TreatmentChainStatusFeatureExtractor,
RecallRiskFeatureExtractor,
DoNotContactStatusFeatureExtractor, DoNotContactStatusFeatureExtractor,
EntitlementStatusFeatureExtractor, EntitlementStatusFeatureExtractor,
RfmFeatureExtractor,
], ],
exports: [PersonaService], exports: [PersonaService],
}) })
......
...@@ -657,26 +657,33 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -657,26 +657,33 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// 算分需要的辅助数据查询 // 算分需要的辅助数据查询
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
/// 拉每个 patient 当前 active persona 的 value / recall_risk 两个 score /// 拉每个 patient 当前 active persona 的 valueScore / riskScore。
/// W7:统一读 rfm.data(valueTier→valueScore、riskScore→riskScore);
/// rfm 缺失时优雅回退旧 value/recall_risk.score(翻转过渡期不抖)。
private async fetchPersonaContext( private async fetchPersonaContext(
patientIds: string[], patientIds: string[],
): Promise<Map<string, { valueScore: number | null; riskScore: number | null }>> { ): Promise<Map<string, { valueScore: number | null; riskScore: number | null }>> {
if (patientIds.length === 0) return new Map(); if (patientIds.length === 0) return new Map();
const rows = await this.prisma.$queryRaw< const rows = await this.prisma.$queryRaw<
Array<{ patient_id: string; key: string; score: number | null }> Array<{ patient_id: string; key: string; score: number | null; data: unknown }>
>` >`
SELECT p.patient_id, pf.key, pf.score SELECT p.patient_id, pf.key, pf.score, pf.data
FROM personas p FROM personas p
JOIN persona_features pf ON pf.persona_id = p.id JOIN persona_features pf ON pf.persona_id = p.id
WHERE p.patient_id = ANY(${patientIds}::uuid[]) WHERE p.patient_id = ANY(${patientIds}::uuid[])
AND p.superseded_at IS NULL AND p.superseded_at IS NULL
AND pf.key IN ('value', 'recall_risk') AND pf.key IN ('rfm', 'value', 'recall_risk')
`; `;
const ctx = new Map<string, { valueScore: number | null; riskScore: number | null }>(); const ctx = new Map<string, { valueScore: number | null; riskScore: number | null }>();
// 先收旧值(回退用),rfm 命中则覆盖
for (const r of rows) { for (const r of rows) {
const cur = ctx.get(r.patient_id) ?? { valueScore: null, riskScore: null }; const cur = ctx.get(r.patient_id) ?? { valueScore: null, riskScore: null };
if (r.key === 'value') cur.valueScore = r.score; if (r.key === 'rfm') {
else if (r.key === 'recall_risk') cur.riskScore = r.score; const d = (r.data ?? {}) as { valueTier?: number; riskScore?: number };
if (typeof d.valueTier === 'number') cur.valueScore = d.valueTier;
if (typeof d.riskScore === 'number') cur.riskScore = d.riskScore;
} else if (r.key === 'value' && cur.valueScore === null) cur.valueScore = r.score;
else if (r.key === 'recall_risk' && cur.riskScore === null) cur.riskScore = r.score;
ctx.set(r.patient_id, cur); ctx.set(r.patient_id, cur);
} }
return ctx; return ctx;
......
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