Commit efd767e5 by luoqi

feat(persona): 急迫等级特征(C.2.1,v1 仅潜在治疗路径)

- urgency_level 单标签(取最大):有潜在待转(8 业务标签 gap)且 末诊>90天→紧急/30-90→高/<30→中。
  末诊口径同 lifecycle(encounter/actual treatment/挂号 max)。
- ️ v1 跳过【已治疗复查路径】(召回未实现复查场景,follow-up)。美学/预防→低(8标签不含,暂不触发)。
- 抽 classifyGapToLabel/ageYearsAt 为共享函数:potential_treatment 出标签 + urgency_level 判待转 共用(单一源)。
- 本地 928:紧急384/高223/中164=771,与 potential_treatment 完全一致。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent e077c6ce
......@@ -16,6 +16,7 @@ import { SpecialAttentionFeatureExtractor } from './special-attention.feature';
import { TreatmentSensitivityFeatureExtractor } from './treatment-sensitivity.feature';
import { ContraindicationFeatureExtractor } from './contraindication.feature';
import { PotentialTreatmentFeatureExtractor } from './potential-treatment.feature';
import { UrgencyLevelFeatureExtractor } from './urgency-level.feature';
/**
* FeatureRegistry — 收集所有 PersonaFeature 提取器。
......@@ -44,9 +45,10 @@ export class FeatureRegistry {
treatmentSensitivity: TreatmentSensitivityFeatureExtractor,
contraindication: ContraindicationFeatureExtractor,
potentialTreatment: PotentialTreatmentFeatureExtractor,
urgencyLevel: UrgencyLevelFeatureExtractor,
dnc: DoNotContactStatusFeatureExtractor,
entitlement: EntitlementStatusFeatureExtractor,
) {
this.extractors = [rfm, age, gender, acquisition, family, referral, lifecycle, treatmentHistory, timePref, discountAnchor, specialAttention, treatmentSensitivity, contraindication, potentialTreatment, dnc, entitlement];
this.extractors = [rfm, age, gender, acquisition, family, referral, lifecycle, treatmentHistory, timePref, discountAnchor, specialAttention, treatmentSensitivity, contraindication, potentialTreatment, urgencyLevel, dnc, entitlement];
}
}
......@@ -27,52 +27,58 @@ import type { PotentialGap } from '../../clinical-gap/potential-treatment.select
* - Step3 主诉意愿加分 = 排序事,消费方自算(score 弃用原则,不进标签)。
* - "非已丢单"(sales_chance)= PAC 未摄入丢单数据 → 省略(注明,follow-up)。
*/
@Injectable()
export class PotentialTreatmentFeatureExtractor implements FeatureExtractor {
readonly key = PersonaFeatureKey.POTENTIAL_TREATMENT;
private static readonly EXTRACTION_NAME_KW = ['残根', '残冠', '无法保留', '不能保留'];
const EXTRACTION_NAME_KW = ['残根', '残冠', '无法保留', '不能保留'];
private static ageYears(birth: Date | null, now: Date): number | null {
if (!birth) return null;
let age = now.getFullYear() - birth.getFullYear();
const m = now.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && now.getDate() < birth.getDate())) age--;
return age >= 0 && age <= 120 ? age : null;
}
/// 周岁(单一源,potential_treatment / urgency_level 共用)
export function ageYearsAt(birth: Date | null, now: Date): number | null {
if (!birth) return null;
let age = now.getFullYear() - birth.getFullYear();
const m = now.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && now.getDate() < birth.getDate())) age--;
return age >= 0 && age <= 120 ? age : null;
}
/// 单个 gap → 业务标签 key(年龄/name 拆分);不命中 8 标签 → null
private classify(g: PotentialGap, age: number | null): { key: string; zh: string } | null {
switch (g.primaryCode) {
case 'K08':
return age !== null && age > 18 ? { key: 'implant', zh: '潜在种植' } : null;
case 'K02':
return { key: 'filling', zh: '潜在补牙' };
case 'K04':
return { key: 'endo', zh: '潜在根管' };
case 'K05':
case 'K06':
return { key: 'perio', zh: '潜在牙周' };
case 'K01':
return { key: 'extraction', zh: '潜在拔牙' };
case 'K03': {
const nm = g.nameZh ?? '';
const isExtract = PotentialTreatmentFeatureExtractor.EXTRACTION_NAME_KW.some((k) => nm.includes(k));
return isExtract ? { key: 'extraction', zh: '潜在拔牙' } : { key: 'restoration', zh: '潜在修复' };
}
case 'K07':
if (age === null) return null; // 年龄未知 → 无法分早矫/正畸
if (age >= 3 && age <= 12) return { key: 'early_ortho', zh: '潜在早矫' };
if (age > 12 && age <= 40) return { key: 'ortho', zh: '潜在正畸' };
return null; // >40 / <3 不召正畸
default:
return null; // K00 / K09 不在业务 8 标签
/// 单个 gap → 8 业务标签 key(年龄/name 拆分);不命中(K00/K09/年龄外)→ null。
/// ⭐ 单一真理源:potential_treatment 出标签 + urgency_level 判"潜在待转" 共用此分类。
export function classifyGapToLabel(
g: PotentialGap,
age: number | null,
): { key: string; zh: string } | null {
switch (g.primaryCode) {
case 'K08':
return age !== null && age > 18 ? { key: 'implant', zh: '潜在种植' } : null;
case 'K02':
return { key: 'filling', zh: '潜在补牙' };
case 'K04':
return { key: 'endo', zh: '潜在根管' };
case 'K05':
case 'K06':
return { key: 'perio', zh: '潜在牙周' };
case 'K01':
return { key: 'extraction', zh: '潜在拔牙' };
case 'K03': {
const nm = g.nameZh ?? '';
const isExtract = EXTRACTION_NAME_KW.some((k) => nm.includes(k));
return isExtract ? { key: 'extraction', zh: '潜在拔牙' } : { key: 'restoration', zh: '潜在修复' };
}
case 'K07':
if (age === null) return null; // 年龄未知 → 无法分早矫/正畸
if (age >= 3 && age <= 12) return { key: 'early_ortho', zh: '潜在早矫' };
if (age > 12 && age <= 40) return { key: 'ortho', zh: '潜在正畸' };
return null; // >40 / <3 不召正畸
default:
return null; // K00 / K09 不在业务 8 标签
}
}
@Injectable()
export class PotentialTreatmentFeatureExtractor implements FeatureExtractor {
readonly key = PersonaFeatureKey.POTENTIAL_TREATMENT;
extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null {
const gaps = ctx.potentialGaps ?? [];
if (gaps.length === 0) return null;
const age = PotentialTreatmentFeatureExtractor.ageYears(ctx.patient.birthDate, ctx.now);
const age = ageYearsAt(ctx.patient.birthDate, ctx.now);
// 按业务标签聚合:teeth 并集 / daysSince 取最大(最早需求)/ confidence 取最大 / 来源
const agg = new Map<
......@@ -80,7 +86,7 @@ export class PotentialTreatmentFeatureExtractor implements FeatureExtractor {
{ zh: string; teeth: Set<string>; daysSince: number; confidence: number; hasDx: boolean; hasRec: boolean }
>();
for (const g of gaps) {
const lbl = this.classify(g, age);
const lbl = classifyGapToLabel(g, age);
if (!lbl) continue;
const cur =
agg.get(lbl.key) ??
......
import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey, FactType, FactKind } from '@pac/types';
import type {
ActiveFact,
FeatureExtractor,
FeatureExtractorContext,
PersonaFeatureDraft,
} from './feature.interface';
import { ageYearsAt, classifyGapToLabel } from './potential-treatment.feature';
/**
* urgency_level 急迫等级(C.2.1)— 规则层,snapshot · 单一标签(取最大)
*
* 标签:紧急 / 高 / 中 / 低。客户当前所有待处理事项中**最高**的急迫程度,决定外呼优先级。
*
* ⚠️ v1 只做【潜在治疗路径】(用户口径:已治疗复查路径召回未实现该场景,先不做)。
* 潜在治疗路径(复用 ctx.potentialGaps = 潜在待转):
* 末诊 > 90 天 → 紧急(有未满足需求且久未回诊,正在流失)
* 末诊 30-90 天 → 高
* 末诊 < 30 天 或 新发现 → 中(刚来过/刚发现,还在跟进中)
* 美学/预防性建议 → 低(当前 8 标签不含美学/预防 → 暂不触发)
* 无潜在治疗 → 不打标签(无待处理事项,此路径无急迫)。
*
* 末诊口径同 lifecycle_stage(单一源):max occurredAt of encounter / actual treatment / 挂号。
* Step 2 取最大:多路径时 MAX;现仅 1 路径。已治疗复查路径 = follow-up(待召回实现复查场景)。
*/
@Injectable()
export class UrgencyLevelFeatureExtractor implements FeatureExtractor {
readonly key = PersonaFeatureKey.URGENCY_LEVEL;
private static readonly DAY = 86400_000;
extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null {
// 潜在待转 = 映射到 8 业务标签的 gap(与 potential_treatment 同一源,口径一致;
// K00/K09/年龄外的 gap 不算"潜在治疗路径"待处理项 → 不计急迫)。
const age = ageYearsAt(ctx.patient.birthDate, ctx.now);
const labelKeys = new Set<string>();
for (const g of ctx.potentialGaps ?? []) {
const lbl = classifyGapToLabel(g, age);
if (lbl) labelKeys.add(lbl.key);
}
if (labelKeys.size === 0) return null; // 无潜在待转(8 标签)→ 此路径无急迫
const gaps = ctx.potentialGaps ?? [];
// 末诊(末次就诊)— 口径同 lifecycle_stage
const get = (t: string) => ctx.factsByType.get(t) ?? [];
const visitFacts: ActiveFact[] = [
...get(FactType.ENCOUNTER_RECORD),
...get(FactType.TREATMENT_RECORD).filter((f) => f.kind === FactKind.ACTUAL),
...get(FactType.VISIT_REGISTRATION_RECORD),
];
let last: number | null = null;
for (const f of visitFacts) {
if (!f.occurredAt) continue;
const t = f.occurredAt.getTime();
if (last === null || t > last) last = t;
}
const now = ctx.now.getTime();
// 无就诊记录(罕见,有诊断必有诊)→ 回退用 gap 最早诊断天数
const lastDays =
last !== null
? Math.floor((now - last) / UrgencyLevelFeatureExtractor.DAY)
: Math.max(...gaps.map((g) => g.daysSince));
let code: string;
let zh: string;
if (lastDays > 90) {
code = 'urgent';
zh = '紧急';
} else if (lastDays >= 30) {
code = 'high';
zh = '高';
} else {
code = 'mid';
zh = '中';
}
const pendingLabels = labelKeys.size;
return {
key: this.key,
description: `${zh} · 末诊${lastDays}天 · ${pendingLabels} 类潜在治疗`,
score: null,
data: { level: code, levelZh: zh, lastVisitDays: lastDays, pendingTypes: pendingLabels, path: 'potential_treatment' },
evidence: { factIds: [] },
};
}
}
......@@ -18,6 +18,7 @@ import { SpecialAttentionFeatureExtractor } from './features/special-attention.f
import { TreatmentSensitivityFeatureExtractor } from './features/treatment-sensitivity.feature';
import { ContraindicationFeatureExtractor } from './features/contraindication.feature';
import { PotentialTreatmentFeatureExtractor } from './features/potential-treatment.feature';
import { UrgencyLevelFeatureExtractor } from './features/urgency-level.feature';
import { ClinicalGapModule } from '../clinical-gap/clinical-gap.module';
@Module({
......@@ -41,6 +42,7 @@ import { ClinicalGapModule } from '../clinical-gap/clinical-gap.module';
TreatmentSensitivityFeatureExtractor,
ContraindicationFeatureExtractor,
PotentialTreatmentFeatureExtractor,
UrgencyLevelFeatureExtractor,
DoNotContactStatusFeatureExtractor,
EntitlementStatusFeatureExtractor,
],
......
......@@ -394,6 +394,7 @@ export const PersonaFeatureKey = {
SPECIAL_ATTENTION: 'special_attention', // 特别关注(屡次爽约/经常迟到/免打扰/不可等候)
TREATMENT_SENSITIVITY: 'treatment_sensitivity', // 治疗敏感(看牙恐惧/晕针/晕血/密闭恐惧;病历关键词)
CONTRAINDICATION: 'contraindication', // 禁忌标签(v1 仅种植年龄≤18;余 Layer C)
URGENCY_LEVEL: 'urgency_level', // 急迫等级(紧急/高/中/低;潜在治疗路径×末诊)
// v1 候选(规则路径,业务方反馈后逐步上)
ENTITLEMENT_STATUS: 'entitlement_status', // 权益身份(商保直付 / 医保 / 储值 / 私行;事实投影型,史+最近日期)
......
......@@ -51,6 +51,7 @@ export const PERSONA_FEATURE_META: Record<string, { label: string; tone: Tone }>
[PersonaFeatureKey.SPECIAL_ATTENTION]: { label: '特别关注', tone: 'rose' },
[PersonaFeatureKey.TREATMENT_SENSITIVITY]: { label: '治疗敏感', tone: 'rose' },
[PersonaFeatureKey.CONTRAINDICATION]: { label: '禁忌标签', tone: 'rose' },
[PersonaFeatureKey.URGENCY_LEVEL]: { label: '急迫等级', tone: 'rose' },
[PersonaFeatureKey.VALUE]: { label: '患者价值', tone: 'indigo' },
[PersonaFeatureKey.TREATMENT_CHAIN_STATUS]: { label: '治疗链状态', tone: 'amber' },
[PersonaFeatureKey.RECALL_RISK]: { label: '流失风险', tone: 'emerald' },
......
......@@ -308,6 +308,25 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
version: 1,
},
// ── C.2.1 急迫等级(取最大;v1 仅潜在治疗路径)──
urgency_level: {
key: 'urgency_level',
nameZh: '急迫等级',
tier: 'rule',
timeSemantics: 'snapshot',
labelValues: ['紧急', '高', '中', '低'],
dataSource: '潜在治疗(ctx.potentialGaps,复用召回 gap 核心)+ 末诊(同 lifecycle 口径)。已治疗复查路径待召回实现该场景',
dataFields: ['is_pending', 'last_visit_time', 'settlement_records', 'sales_chance_records', 'medical_record_tag'],
meaning: '客户当前所有待处理事项中最高的急迫程度,决定外呼/触达优先级。客户维度单一标签',
algorithm: [
'v1 仅潜在治疗路径(已治疗复查路径召回未实现,follow-up):有潜在待转 且',
'末诊>90天→紧急 / 30-90→高 / <30 或新发现→中 / 美学预防→低(8 标签不含,暂不触发)。',
'无潜在治疗→不打标签。Step2 取最大(多路径 MAX,现仅 1 路径)。',
].join('\n'),
owner: 'pac-algo',
version: 1,
},
// ── D.2.4 禁忌标签(v1 仅种植年龄;余 Layer C)──
contraindication: {
key: 'contraindication',
......
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