Commit 90ca7f57 by luoqi

feat(persona): 特别关注特征(D.2.3,屡次爽约/经常迟到/免打扰/不可等候)

- special_attention 多标签:屡次爽约(近1年履约率<50%且决定≥3)/经常迟到(到店>预约+15min≥50%
  且≥3)/免打扰(doNotContact)/不可等候(notes/tags/病历关键词)。
- ️ 挖出 2 个真问题并处理:
  ① no_show/cancelled 预约 patient_facts.status≠active/fulfilled,不在 persona ctx → 新增
     ctx.appointmentsAll(单独加载全状态预约,排 superseded)给爽约/迟到用。
  ② arrived_at(in_time)摄入 TZ bug:比 planned_for 一致早 8h → +8h 补偿(根治修摄入,follow-up)。
- 本地 928:经常迟到86/屡次爽约2(免打扰/不可等候样本无数据,全量会出)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 669f4fa6
......@@ -41,6 +41,9 @@ export interface FeatureExtractorContext {
/// 该 patient 的关系边(PatientRelation;家庭构成等用)。relationship 已归一
/// (spouse/father/mother/grandparent/child/grandchild/sibling/friend/other)。
relations: Array<{ relationship: string }>;
/// 全部 appointment_record(各 patient_facts.status,排 superseded)— 特别关注的爽约/迟到用。
/// factsByType 只含 active/fulfilled,no_show/cancelled 预约不在其中,故单独加载。
appointmentsAll: ActiveFact[];
/// today 锚点(测试时可注入固定时间)
now: Date;
/// 群体统计(租户级,统计层特征用 — 如 RFM 的 M 分位阈值 [p20,p40,p60,p80])。
......
......@@ -12,6 +12,7 @@ import { LifecycleStageFeatureExtractor } from './lifecycle-stage.feature';
import { TreatmentHistoryFeatureExtractor } from './treatment-history.feature';
import { TimePreferenceFeatureExtractor } from './time-preference.feature';
import { DiscountAnchorFeatureExtractor } from './discount-anchor.feature';
import { SpecialAttentionFeatureExtractor } from './special-attention.feature';
/**
* FeatureRegistry — 收集所有 PersonaFeature 提取器。
......@@ -36,9 +37,10 @@ export class FeatureRegistry {
treatmentHistory: TreatmentHistoryFeatureExtractor,
timePref: TimePreferenceFeatureExtractor,
discountAnchor: DiscountAnchorFeatureExtractor,
specialAttention: SpecialAttentionFeatureExtractor,
dnc: DoNotContactStatusFeatureExtractor,
entitlement: EntitlementStatusFeatureExtractor,
) {
this.extractors = [rfm, age, gender, acquisition, family, referral, lifecycle, treatmentHistory, timePref, discountAnchor, dnc, entitlement];
this.extractors = [rfm, age, gender, acquisition, family, referral, lifecycle, treatmentHistory, timePref, discountAnchor, specialAttention, dnc, entitlement];
}
}
import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey, FactType } from '@pac/types';
import type {
ActiveFact,
FeatureExtractor,
FeatureExtractorContext,
PersonaFeatureDraft,
} from './feature.interface';
/**
* special_attention 特别关注(D.2.3)— 规则层,snapshot · 多标签
*
* 标签:屡次爽约 / 经常迟到 / 免打扰 / 不可等候。一线服务需特别注意,影响排班与触达。
*
* 算法(近1年):
* 屡次爽约:履约率 = 到诊/(到诊+爽约) < 50% 且 决定数(到诊+爽约)≥3。
* 到诊 = completed/arrived/in_treatment;爽约 = no_show;cancelled/rescheduled/scheduled 不计分母。
* 经常迟到:有到店时间的预约中,迟到(arrived_at > planned_for+15min)占比≥50% 且 ≥3 次。
* 免打扰:profile.doNotContact=true(客户明确标注/客服标记;复用现有合规字段)。
* 不可等候:notes/tags/病历(emr)含 不可等候/时间敏感/赶时间/不能等。
* 全不命中 → 不打标签。
*/
@Injectable()
export class SpecialAttentionFeatureExtractor implements FeatureExtractor {
readonly key = PersonaFeatureKey.SPECIAL_ATTENTION;
private static readonly DAY = 86400_000;
private static readonly LATE_MS = 15 * 60_000;
// ⚠️ arrived_at(in_time)摄入时区 bug:比 planned_for 一致早 8h(实测 -480min±实际早晚)。
// +8h 补偿对齐 planned_for(UTC)。根治应修摄入 in_time 时区(follow-up,同 occurred_at bug)。
private static readonly ARRIVED_TZ_FIX_MS = 8 * 3600_000;
private static readonly ATTENDED = new Set(['completed', 'arrived', 'in_treatment']);
private static readonly WAIT_KW = ['不可等候', '时间敏感', '赶时间', '不能等'];
extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null {
const now = ctx.now.getTime();
const yearAgo = now - 365 * SpecialAttentionFeatureExtractor.DAY;
// 用 appointmentsAll(各状态;factsByType 只 active/fulfilled,no_show 预约不在其中)
const appts = (ctx.appointmentsAll ?? []) as ActiveFact[];
// 近1年预约:履约 / 爽约 / 迟到
let attended = 0;
let noShow = 0;
let arrivalRecs = 0;
let lateRecs = 0;
for (const f of appts) {
const c = (f.content ?? {}) as Record<string, unknown>;
const planned = f.plannedFor ?? null;
if (!planned || planned.getTime() < yearAgo) continue;
const status = String(c.status ?? '');
if (SpecialAttentionFeatureExtractor.ATTENDED.has(status)) attended++;
else if (status === 'no_show') noShow++;
// 迟到:到店时间(+8h 修正摄入 TZ bug)vs 预约时间
const arrRaw = c.arrived_at;
if (arrRaw) {
const arr = new Date(String(arrRaw)).getTime() + SpecialAttentionFeatureExtractor.ARRIVED_TZ_FIX_MS;
if (Number.isFinite(arr)) {
arrivalRecs++;
if (arr - planned.getTime() > SpecialAttentionFeatureExtractor.LATE_MS) lateRecs++;
}
}
}
const decided = attended + noShow;
const codes: string[] = [];
const labels: string[] = [];
const add = (c: string, z: string) => {
codes.push(c);
labels.push(z);
};
if (decided >= 3 && attended / decided < 0.5) add('frequent_no_show', '屡次爽约');
if (arrivalRecs >= 3 && lateRecs / arrivalRecs >= 0.5) add('often_late', '经常迟到');
if (ctx.profile?.doNotContact) add('do_not_disturb', '免打扰');
// 不可等候:notes / tags / 病历自由文本关键词
const emrText = (ctx.factsByType.get(FactType.EMR_RECORD) ?? [])
.map((f) => {
const c = (f.content ?? {}) as Record<string, unknown>;
return [c.illness_desc, c.diagnosis_text, c.treatment_plan, c.doctor_advice, c.notes].join(' ');
})
.join(' ');
const hay = `${ctx.profile?.notes ?? ''} ${(ctx.profile?.tags ?? []).join(' ')} ${emrText}`;
if (SpecialAttentionFeatureExtractor.WAIT_KW.some((k) => hay.includes(k))) add('cannot_wait', '不可等候');
if (codes.length === 0) return null;
return {
key: this.key,
description: labels.join(' / '),
score: null,
data: {
types: codes,
labels,
fulfillRate: decided > 0 ? Math.round((100 * attended) / decided) : null,
apptDecided: decided,
lateRate: arrivalRecs > 0 ? Math.round((100 * lateRecs) / arrivalRecs) : null,
arrivalRecs,
},
evidence: { factIds: [] },
};
}
}
......@@ -14,6 +14,7 @@ import { LifecycleStageFeatureExtractor } from './features/lifecycle-stage.featu
import { TreatmentHistoryFeatureExtractor } from './features/treatment-history.feature';
import { TimePreferenceFeatureExtractor } from './features/time-preference.feature';
import { DiscountAnchorFeatureExtractor } from './features/discount-anchor.feature';
import { SpecialAttentionFeatureExtractor } from './features/special-attention.feature';
@Module({
controllers: [PersonaController],
......@@ -31,6 +32,7 @@ import { DiscountAnchorFeatureExtractor } from './features/discount-anchor.featu
TreatmentHistoryFeatureExtractor,
TimePreferenceFeatureExtractor,
DiscountAnchorFeatureExtractor,
SpecialAttentionFeatureExtractor,
DoNotContactStatusFeatureExtractor,
EntitlementStatusFeatureExtractor,
],
......
......@@ -185,6 +185,21 @@ export class PersonaService {
select: { relationship: true },
});
// 全部 appointment(各状态,排 superseded)— 特别关注爽约/迟到用(no_show 预约 status≠active)
const appointmentsAll = (await this.prisma.patientFact.findMany({
where: {
hostId: patient.hostId,
tenantId: patient.tenantId,
patientId: patient.id,
type: 'appointment_record',
status: { not: 'superseded' },
},
select: {
id: true, subjectId: true, type: true, kind: true, status: true,
occurredAt: true, plannedFor: true, clinicId: true, content: true,
},
})) as ActiveFact[];
const latestTxn = await this.prisma.patientTransaction.findFirst({
where: { hostId: patient.hostId, tenantId: patient.tenantId, patientId: patient.id },
orderBy: { eventSeq: 'desc' },
......@@ -217,6 +232,7 @@ export class PersonaService {
: null,
factsByType,
relations,
appointmentsAll,
now,
populationStats: {
monetaryQuantiles: await this.getMonetaryQuantiles(
......
......@@ -391,6 +391,7 @@ export const PersonaFeatureKey = {
TREATMENT_HISTORY: 'treatment_history', // 治疗史(种植/正畸/修复/牙周;读 treatment category)
TIME_PREFERENCE: 'time_preference', // 时间偏好(工作日/周末/上午/下午/晚间;预约时刻统计)
DISCOUNT_ANCHOR: 'discount_anchor', // 折扣锚点(历史最低折扣率+日期;价格底线参考)
SPECIAL_ATTENTION: 'special_attention', // 特别关注(屡次爽约/经常迟到/免打扰/不可等候)
// v1 候选(规则路径,业务方反馈后逐步上)
ENTITLEMENT_STATUS: 'entitlement_status', // 权益身份(商保直付 / 医保 / 储值 / 私行;事实投影型,史+最近日期)
......
......@@ -48,6 +48,7 @@ export const PERSONA_FEATURE_META: Record<string, { label: string; tone: Tone }>
[PersonaFeatureKey.TREATMENT_HISTORY]: { label: '治疗史', tone: 'amber' },
[PersonaFeatureKey.TIME_PREFERENCE]: { label: '时间偏好', tone: 'sky' },
[PersonaFeatureKey.DISCOUNT_ANCHOR]: { label: '折扣锚点', tone: 'rose' },
[PersonaFeatureKey.SPECIAL_ATTENTION]: { label: '特别关注', tone: 'rose' },
[PersonaFeatureKey.VALUE]: { label: '患者价值', tone: 'indigo' },
[PersonaFeatureKey.TREATMENT_CHAIN_STATUS]: { label: '治疗链状态', tone: 'amber' },
[PersonaFeatureKey.RECALL_RISK]: { label: '流失风险', tone: 'emerald' },
......
......@@ -250,4 +250,23 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
owner: 'pac-algo',
version: 1,
},
// ── D.2.3 特别关注(业务 CDP 口径)──
special_attention: {
key: 'special_attention',
nameZh: '特别关注',
tier: 'rule',
timeSemantics: 'window', // 近1年预约 + 当前标注
labelValues: ['屡次爽约', '经常迟到', '免打扰', '不可等候'],
dataSource: 'PAC appointment_record(status 履约/爽约 + arrived_at 迟到)+ patient_profile.doNotContact/notes/tags + emr_record',
dataFields: ['appointment_records', 'customer_tags'],
meaning: '客户行为特征中需一线服务特别注意的事项,直接影响排班与触达策略',
algorithm: [
'近1年:屡次爽约 履约率<50%且决定数≥3(到诊 completed/arrived;爽约 no_show);',
'经常迟到 到店>预约+15min 占比≥50%且≥3次;免打扰 doNotContact;',
'不可等候 notes/tags/病历含 不可等候/时间敏感/赶时间/不能等。全不命中→不打标签。',
].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