Commit 5210f3ea by luoqi

feat(persona): 时间偏好特征(D.1.2)

- time_preference 多标签:工作日/周末/上午/下午/晚间偏好。近2年预约,北京 TZ。
-  数据排查:occurred_at 钟点被搞乱(时区/解析,16-23 乱分布),planned_for +8 才是
  干净营业钟形(8-18 峰 9-15)→ 用 planned_for。无新摄入/无迁移。
- 阈值照 spec:工作日≥60%/周末≥50%/各时段≥50%,记录≥2;无命中→不打标签。
- 注册表 spec/enum/label。本地 601 患者:下午368/工作日305/上午289/周末289。
- ️ follow-up:appointment occurred_at 钟点不可靠(latent bug);TZ 硬编北京,多宿主应读 host TZ。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 35170610
...@@ -10,6 +10,7 @@ import { FamilyStructureFeatureExtractor } from './family-structure.feature'; ...@@ -10,6 +10,7 @@ import { FamilyStructureFeatureExtractor } from './family-structure.feature';
import { ReferralChampionFeatureExtractor } from './referral-champion.feature'; import { ReferralChampionFeatureExtractor } from './referral-champion.feature';
import { LifecycleStageFeatureExtractor } from './lifecycle-stage.feature'; import { LifecycleStageFeatureExtractor } from './lifecycle-stage.feature';
import { TreatmentHistoryFeatureExtractor } from './treatment-history.feature'; import { TreatmentHistoryFeatureExtractor } from './treatment-history.feature';
import { TimePreferenceFeatureExtractor } from './time-preference.feature';
/** /**
* FeatureRegistry — 收集所有 PersonaFeature 提取器。 * FeatureRegistry — 收集所有 PersonaFeature 提取器。
...@@ -32,9 +33,10 @@ export class FeatureRegistry { ...@@ -32,9 +33,10 @@ export class FeatureRegistry {
referral: ReferralChampionFeatureExtractor, referral: ReferralChampionFeatureExtractor,
lifecycle: LifecycleStageFeatureExtractor, lifecycle: LifecycleStageFeatureExtractor,
treatmentHistory: TreatmentHistoryFeatureExtractor, treatmentHistory: TreatmentHistoryFeatureExtractor,
timePref: TimePreferenceFeatureExtractor,
dnc: DoNotContactStatusFeatureExtractor, dnc: DoNotContactStatusFeatureExtractor,
entitlement: EntitlementStatusFeatureExtractor, entitlement: EntitlementStatusFeatureExtractor,
) { ) {
this.extractors = [rfm, age, gender, acquisition, family, referral, lifecycle, treatmentHistory, dnc, entitlement]; this.extractors = [rfm, age, gender, acquisition, family, referral, lifecycle, treatmentHistory, timePref, dnc, entitlement];
} }
} }
import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey, FactType } from '@pac/types';
import type {
ActiveFact,
FeatureExtractor,
FeatureExtractorContext,
PersonaFeatureDraft,
} from './feature.interface';
/**
* time_preference 时间偏好(D.1.2)— 规则层,snapshot · 多标签(允许并列)
*
* 标签:工作日偏好 / 周末偏好 / 上午偏好 / 下午偏好 / 晚间偏好。习惯就诊时间,优化推送与排班。
*
* 数据源:appointment_record.planned_for(= 预约槽时刻;北京 TZ 下是干净营业钟形 8-18)。
* ⚠️ occurred_at 小时不可靠(时区/解析乱),用 planned_for;近2年(730d)。
* ⚠️ TZ 硬编北京(+8)— 试点单宿主;多宿主应读 host 时区(follow-up)。
*
* 算法(图):近2年记录提取星期+钟点(记录数≥2):
* 工作日占比≥60%→工作日偏好;周末≥50%→周末偏好;
* 上午(8-12)≥50%→上午;下午(12-18)≥50%→下午;晚间(18-21)≥50%→晚间。
* 无命中或记录<2→不打标签。
*/
@Injectable()
export class TimePreferenceFeatureExtractor implements FeatureExtractor {
readonly key = PersonaFeatureKey.TIME_PREFERENCE;
private static readonly TZ_OFFSET_MS = 8 * 3600_000; // 北京 UTC+8
private static readonly WINDOW_MS = 730 * 86400_000;
extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null {
const appts = (ctx.factsByType.get(FactType.APPOINTMENT_RECORD) ?? []) as ActiveFact[];
const since = ctx.now.getTime() - TimePreferenceFeatureExtractor.WINDOW_MS;
let total = 0;
let weekday = 0;
let weekend = 0;
let morning = 0;
let afternoon = 0;
let evening = 0;
let periodTotal = 0; // 落在 8-21 的记录(算时段占比的分母)
for (const f of appts) {
const at = f.plannedFor ?? f.occurredAt;
if (!at || at.getTime() < since) continue;
total++;
const bj = new Date(at.getTime() + TimePreferenceFeatureExtractor.TZ_OFFSET_MS);
const dow = bj.getUTCDay(); // 0=周日..6=周六
if (dow === 0 || dow === 6) weekend++;
else weekday++;
const h = bj.getUTCHours();
if (h >= 8 && h < 12) {
morning++;
periodTotal++;
} else if (h >= 12 && h < 18) {
afternoon++;
periodTotal++;
} else if (h >= 18 && h < 21) {
evening++;
periodTotal++;
}
}
if (total < 2) return null;
const codes: string[] = [];
const labels: string[] = [];
const add = (c: string, z: string) => {
codes.push(c);
labels.push(z);
};
if (weekday / total >= 0.6) add('weekday', '工作日偏好');
if (weekend / total >= 0.5) add('weekend', '周末偏好');
if (periodTotal >= 2) {
if (morning / periodTotal >= 0.5) add('morning', '上午偏好');
if (afternoon / periodTotal >= 0.5) add('afternoon', '下午偏好');
if (evening / periodTotal >= 0.5) add('evening', '晚间偏好');
}
if (codes.length === 0) return null;
const pct = (n: number, d: number) => (d > 0 ? Math.round((100 * n) / d) : 0);
return {
key: this.key,
description: labels.join(' / '),
score: null,
data: {
types: codes,
labels,
recordCount: total,
weekdayPct: pct(weekday, total),
weekendPct: pct(weekend, total),
morningPct: pct(morning, periodTotal),
afternoonPct: pct(afternoon, periodTotal),
eveningPct: pct(evening, periodTotal),
},
evidence: { factIds: [] },
};
}
}
...@@ -12,6 +12,7 @@ import { FamilyStructureFeatureExtractor } from './features/family-structure.fea ...@@ -12,6 +12,7 @@ import { FamilyStructureFeatureExtractor } from './features/family-structure.fea
import { ReferralChampionFeatureExtractor } from './features/referral-champion.feature'; import { ReferralChampionFeatureExtractor } from './features/referral-champion.feature';
import { LifecycleStageFeatureExtractor } from './features/lifecycle-stage.feature'; import { LifecycleStageFeatureExtractor } from './features/lifecycle-stage.feature';
import { TreatmentHistoryFeatureExtractor } from './features/treatment-history.feature'; import { TreatmentHistoryFeatureExtractor } from './features/treatment-history.feature';
import { TimePreferenceFeatureExtractor } from './features/time-preference.feature';
@Module({ @Module({
controllers: [PersonaController], controllers: [PersonaController],
...@@ -27,6 +28,7 @@ import { TreatmentHistoryFeatureExtractor } from './features/treatment-history.f ...@@ -27,6 +28,7 @@ import { TreatmentHistoryFeatureExtractor } from './features/treatment-history.f
ReferralChampionFeatureExtractor, ReferralChampionFeatureExtractor,
LifecycleStageFeatureExtractor, LifecycleStageFeatureExtractor,
TreatmentHistoryFeatureExtractor, TreatmentHistoryFeatureExtractor,
TimePreferenceFeatureExtractor,
DoNotContactStatusFeatureExtractor, DoNotContactStatusFeatureExtractor,
EntitlementStatusFeatureExtractor, EntitlementStatusFeatureExtractor,
], ],
......
...@@ -389,6 +389,7 @@ export const PersonaFeatureKey = { ...@@ -389,6 +389,7 @@ export const PersonaFeatureKey = {
REFERRAL_CHAMPION: 'referral_champion', // 转介绍达人(家庭型/社交型;recommend_num+转化) REFERRAL_CHAMPION: 'referral_champion', // 转介绍达人(家庭型/社交型;recommend_num+转化)
LIFECYCLE_STAGE: 'lifecycle_stage', // 生命周期(潜客..流失客;时间+消费规则) LIFECYCLE_STAGE: 'lifecycle_stage', // 生命周期(潜客..流失客;时间+消费规则)
TREATMENT_HISTORY: 'treatment_history', // 治疗史(种植/正畸/修复/牙周;读 treatment category) TREATMENT_HISTORY: 'treatment_history', // 治疗史(种植/正畸/修复/牙周;读 treatment category)
TIME_PREFERENCE: 'time_preference', // 时间偏好(工作日/周末/上午/下午/晚间;预约时刻统计)
// v1 候选(规则路径,业务方反馈后逐步上) // v1 候选(规则路径,业务方反馈后逐步上)
ENTITLEMENT_STATUS: 'entitlement_status', // 权益身份(商保直付 / 医保 / 储值 / 私行;事实投影型,史+最近日期) ENTITLEMENT_STATUS: 'entitlement_status', // 权益身份(商保直付 / 医保 / 储值 / 私行;事实投影型,史+最近日期)
......
...@@ -46,6 +46,7 @@ export const PERSONA_FEATURE_META: Record<string, { label: string; tone: Tone }> ...@@ -46,6 +46,7 @@ export const PERSONA_FEATURE_META: Record<string, { label: string; tone: Tone }>
[PersonaFeatureKey.REFERRAL_CHAMPION]: { label: '转介绍达人', tone: 'rose' }, [PersonaFeatureKey.REFERRAL_CHAMPION]: { label: '转介绍达人', tone: 'rose' },
[PersonaFeatureKey.LIFECYCLE_STAGE]: { label: '生命周期', tone: 'teal' }, [PersonaFeatureKey.LIFECYCLE_STAGE]: { label: '生命周期', tone: 'teal' },
[PersonaFeatureKey.TREATMENT_HISTORY]: { label: '治疗史', tone: 'amber' }, [PersonaFeatureKey.TREATMENT_HISTORY]: { label: '治疗史', tone: 'amber' },
[PersonaFeatureKey.TIME_PREFERENCE]: { label: '时间偏好', tone: 'sky' },
[PersonaFeatureKey.VALUE]: { label: '患者价值', tone: 'indigo' }, [PersonaFeatureKey.VALUE]: { label: '患者价值', tone: 'indigo' },
[PersonaFeatureKey.TREATMENT_CHAIN_STATUS]: { label: '治疗链状态', tone: 'amber' }, [PersonaFeatureKey.TREATMENT_CHAIN_STATUS]: { label: '治疗链状态', tone: 'amber' },
[PersonaFeatureKey.RECALL_RISK]: { label: '流失风险', tone: 'emerald' }, [PersonaFeatureKey.RECALL_RISK]: { label: '流失风险', tone: 'emerald' },
......
...@@ -213,4 +213,23 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -213,4 +213,23 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
}, },
// ── D.1.2 时间偏好(业务 CDP 口径)──
time_preference: {
key: 'time_preference',
nameZh: '时间偏好',
tier: 'rule',
timeSemantics: 'window', // 近2年预约
labelValues: ['工作日偏好', '周末偏好', '上午偏好', '下午偏好', '晚间偏好'],
dataSource: 'PAC appointment_record.planned_for(预约槽时刻;北京 TZ 下干净营业钟形)。occurred_at 钟点不可靠,用 planned_for',
dataFields: ['appointment_records(日期+时段)', 'settlement_records(日期)'],
meaning: '客户习惯就诊时间(星期+时段),用于优化推送时间与预约排班;允许并列',
algorithm: [
'近2年预约(记录≥2),北京时区:工作日占比≥60%→工作日偏好;周末≥50%→周末偏好;',
'上午8-12≥50%→上午;下午12-18≥50%→下午;晚间18-21≥50%→晚间。无命中/记录<2→不打标签。',
'⚠️ TZ 硬编北京(+8),多宿主应读 host 时区(follow-up)。',
].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