Commit 539dbcf2 by luoqi

refactor(persona): 删 do_not_contact_status 特征(免打扰并入 special_attention)+ lifecycle 潜客加咨询 gate

- 删 do_not_contact_status extractor(免打扰已在 special_attention/CDP D.2.3;合规闸召回读 profile 原始列,
  不依赖本特征)。enum 标弃用保留(前端 hover/label 无害)。已故/投诉信号后续补(投诉数据本就缺)。
- lifecycle 潜客(B.1.4):零就诊 → 加 gate「有预约 OR 有咨询」(用上新摄的 consultation_record);
  零就诊且无触点 → 兜底新客(不落 lastDays 分支避免 Infinity 误判流失)。
- 本地 928:现役 16 特征(dnc 0),lifecycle 成熟614/新客207/成长104/待激活2/流失1
  (样本无零就诊 → 无潜客,全量会出)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent fdbe3424
import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey } from '@pac/types';
import type {
FeatureExtractor,
FeatureExtractorContext,
PersonaFeatureDraft,
} from './feature.interface';
/**
* do_not_contact_status 不打扰状态(合规硬约束)
*
* 信号(任一命中即 DNC):
* - patient_profiles.doNotContact = true(host 主档标记 / 客服手动)
* - patient_profiles.deceased = true(已故)
* - 存在 active 或 fulfilled complaint_record(投诉过的合规避雷)
*
* 任一命中 → DNC=true,Plan 引擎必须拦截。
*
* **注意:phone 缺失不算 DNC** — phone 缺失是数据完整性问题(host 接入未覆盖),
* 不是合规拒触达。description 会注明"待补充电话"提示客服补,但 score 不升。
*/
@Injectable()
export class DoNotContactStatusFeatureExtractor implements FeatureExtractor {
readonly key = PersonaFeatureKey.DO_NOT_CONTACT_STATUS;
extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null {
const reasons: string[] = []; // DNC 硬命中原因(算分用)
const warnings: string[] = []; // 数据完整性提示(不算 DNC,客服可见)
const factIds: string[] = [];
if (ctx.profile?.doNotContact) {
reasons.push(
`主档标记 do_not_contact${ctx.profile.doNotContactReason ? '(' + ctx.profile.doNotContactReason + ')' : ''}`,
);
}
if (ctx.profile?.deceased) reasons.push('已故');
const complaints = ctx.factsByType.get('complaint_record') ?? [];
for (const f of complaints) {
factIds.push(f.id);
if (f.status === 'active' || f.status === 'fulfilled') {
const c = f.content as Record<string, unknown>;
const cat = (c.category as string | undefined) ?? '?';
reasons.push(`投诉记录(${cat})`);
break;
}
}
// phone 缺失:仅提示,不计 DNC(避免 host 不给 phone 时全员误标)
if (!ctx.patient.phone) warnings.push('待补充电话');
const dnc = reasons.length > 0;
let description: string;
if (dnc) {
description = `[DNC] 不可触达:${reasons.join(' / ')}`;
if (warnings.length > 0) description += ` · ${warnings.join(' / ')}`;
} else if (warnings.length > 0) {
description = `[contactable] 可触达(${warnings.join(' / ')})`;
} else {
description = '[contactable] 可触达';
}
return {
key: this.key,
description,
score: dnc ? 1 : 0,
evidence: { factIds },
};
}
}
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { FeatureExtractor } from './feature.interface'; import type { FeatureExtractor } from './feature.interface';
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';
import { AgeBracketFeatureExtractor } from './age-bracket.feature'; import { AgeBracketFeatureExtractor } from './age-bracket.feature';
...@@ -23,7 +22,8 @@ import { UrgencyLevelFeatureExtractor } from './urgency-level.feature'; ...@@ -23,7 +22,8 @@ import { UrgencyLevelFeatureExtractor } from './urgency-level.feature';
* *
* W7 重构:统计层 rfm(RFM 8 分群)统一了旧 value(=M)+ recall_risk(=R+缺口)+ * W7 重构:统计层 rfm(RFM 8 分群)统一了旧 value(=M)+ recall_risk(=R+缺口)+
* treatment_chain_status(降级为详情页 episode 视图,不进画像)→ 三个旧 extractor 已摘除。 * treatment_chain_status(降级为详情页 episode 视图,不进画像)→ 三个旧 extractor 已摘除。
* 现役:rfm(统计层)/ do_not_contact_status(合规)/ entitlement_status(权益,事实投影)。 * W7 末:do_not_contact_status 摘除 — 免打扰并入 special_attention(CDP D.2.3);
* 合规闸召回读 patient_profiles 原始列(不依赖本特征);已故/投诉信号后续补(投诉数据缺)。
* 单个 extractor 抛错不影响其余 — PersonaService 整体记 partial。 * 单个 extractor 抛错不影响其余 — PersonaService 整体记 partial。
*/ */
@Injectable() @Injectable()
...@@ -46,9 +46,8 @@ export class FeatureRegistry { ...@@ -46,9 +46,8 @@ export class FeatureRegistry {
contraindication: ContraindicationFeatureExtractor, contraindication: ContraindicationFeatureExtractor,
potentialTreatment: PotentialTreatmentFeatureExtractor, potentialTreatment: PotentialTreatmentFeatureExtractor,
urgencyLevel: UrgencyLevelFeatureExtractor, urgencyLevel: UrgencyLevelFeatureExtractor,
dnc: DoNotContactStatusFeatureExtractor,
entitlement: EntitlementStatusFeatureExtractor, entitlement: EntitlementStatusFeatureExtractor,
) { ) {
this.extractors = [rfm, age, gender, acquisition, family, referral, lifecycle, treatmentHistory, timePref, discountAnchor, specialAttention, treatmentSensitivity, contraindication, potentialTreatment, urgencyLevel, dnc, entitlement]; this.extractors = [rfm, age, gender, acquisition, family, referral, lifecycle, treatmentHistory, timePref, discountAnchor, specialAttention, treatmentSensitivity, contraindication, potentialTreatment, urgencyLevel, entitlement];
} }
} }
...@@ -74,6 +74,8 @@ export class LifecycleStageFeatureExtractor implements FeatureExtractor { ...@@ -74,6 +74,8 @@ export class LifecycleStageFeatureExtractor implements FeatureExtractor {
} }
const totalVisits = days.size; const totalVisits = days.size;
const hasAppt = get(FactType.APPOINTMENT_RECORD).length > 0; const hasAppt = get(FactType.APPOINTMENT_RECORD).length > 0;
// 咨询记录(consultation_record,W7末摄入)— 潜客判定的另一触点(spec:零就诊 + 有预约/咨询)
const hasConsult = get(FactType.CONSULTATION_RECORD).length > 0;
const firstDays = first !== null ? Math.floor((now - first) / DAY) : Infinity; const firstDays = first !== null ? Math.floor((now - first) / DAY) : Infinity;
const lastDays = last !== null ? Math.floor((now - last) / DAY) : Infinity; const lastDays = last !== null ? Math.floor((now - last) / DAY) : Infinity;
...@@ -84,9 +86,15 @@ export class LifecycleStageFeatureExtractor implements FeatureExtractor { ...@@ -84,9 +86,15 @@ export class LifecycleStageFeatureExtractor implements FeatureExtractor {
let code: string; let code: string;
let zh: string; let zh: string;
if (totalVisits === 0) { if (totalVisits === 0) {
// 从未到诊:有预约 → 潜客;否则也算潜客(有档无诊) // 从未到诊(spec B.1.4):有预约 OR 有咨询 → 潜客(咨询/约过但没来);
// 都没有(有档无诊无触点)→ 兜底新客(不落 lastDays 分支,避免 Infinity 误判流失)。
if (hasAppt || hasConsult) {
code = 'prospect'; code = 'prospect';
zh = '潜客'; zh = '潜客';
} else {
code = 'new';
zh = '新客';
}
} else if (firstDays <= 180 && totalVisits <= 3) { } else if (firstDays <= 180 && totalVisits <= 3) {
code = 'new'; code = 'new';
zh = '新客'; zh = '新客';
......
...@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common'; ...@@ -2,7 +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 { 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';
import { AgeBracketFeatureExtractor } from './features/age-bracket.feature'; import { AgeBracketFeatureExtractor } from './features/age-bracket.feature';
...@@ -43,7 +42,6 @@ import { ClinicalGapModule } from '../clinical-gap/clinical-gap.module'; ...@@ -43,7 +42,6 @@ import { ClinicalGapModule } from '../clinical-gap/clinical-gap.module';
ContraindicationFeatureExtractor, ContraindicationFeatureExtractor,
PotentialTreatmentFeatureExtractor, PotentialTreatmentFeatureExtractor,
UrgencyLevelFeatureExtractor, UrgencyLevelFeatureExtractor,
DoNotContactStatusFeatureExtractor,
EntitlementStatusFeatureExtractor, EntitlementStatusFeatureExtractor,
], ],
exports: [PersonaService], exports: [PersonaService],
......
...@@ -377,7 +377,7 @@ export const PersonaFeatureKey = { ...@@ -377,7 +377,7 @@ export const PersonaFeatureKey = {
VALUE: 'value', // 患者价值(LTV / VIP 等级) VALUE: 'value', // 患者价值(LTV / VIP 等级)
TREATMENT_CHAIN_STATUS: 'treatment_chain_status', // 治疗链状态(进行中 / 已闭环 / 有缺口) TREATMENT_CHAIN_STATUS: 'treatment_chain_status', // 治疗链状态(进行中 / 已闭环 / 有缺口)
RECALL_RISK: 'recall_risk', // 流失/复发风险 RECALL_RISK: 'recall_risk', // 流失/复发风险
DO_NOT_CONTACT_STATUS: 'do_not_contact_status', // 不打扰状态(合规硬约束) DO_NOT_CONTACT_STATUS: 'do_not_contact_status', // [弃用 W7末] 免打扰并入 special_attention;合规闸召回读 profile 原始列。已故/投诉信号后续补
// 统计层(RFM 八象限 — 融合 R 最近/F 频次/M 金额三种时间语义;统一旧 value+recall_risk) // 统计层(RFM 八象限 — 融合 R 最近/F 频次/M 金额三种时间语义;统一旧 value+recall_risk)
RFM: 'rfm', // 价值分群(RFM 八象限 + 生命周期;data 带 segment 供圈人群) RFM: 'rfm', // 价值分群(RFM 八象限 + 生命周期;data 带 segment 供圈人群)
......
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