Commit 19793597 by luoqi

feat(persona): 年龄段特征(A.1.1,snapshot)

- age_bracket extractor:从 patient.birthDate 算周岁 → 9 档(婴幼儿..老年),照图区间;
  3/55 重叠按'下界含、归下一档'+ ≥55→老年消歧。snapshot 时间语义(历史读版本流)。
- 注册表 spec(标签卡)+ enum/label;数据来源可切(现 birthDate 自算,宿主给 client_age 后切)。
- birthDate 缺失/年龄越界(<0/>120 脏数据)→ 不打标签。
- 本地 928 验证:分布合理、边界消歧正确、覆盖率 100%。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 1b086e7b
import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey } from '@pac/types';
import type {
FeatureExtractor,
FeatureExtractorContext,
PersonaFeatureDraft,
} from './feature.interface';
/**
* age_bracket 年龄段(A.1.1)— 规则层,snapshot 时间语义(当下从 birthDate 算)
*
* 口径采用业务整理好的 CDP 标签定义:
* 数据字段:client_age(Int32)— 现从 PAC patient.birthDate 自算;宿主 CDP 给 client_age 后切宿主值
* 标签释义:按口腔治疗需求特征精细划分年龄层,匹配适龄项目与家庭决策模式
* 区间规则(图):0-3 婴幼儿 / 3-6 学龄前 / 7-11 替牙期 / 12-17 青少年 / 18-25 青年 /
* 26-30 中青年 / 31-45 中年 / 46-55 中老年 / ≥55 老年
* ⚠️ 图区间在 3 / 55 两点重叠:按"下界含、上界归下一档"消歧,且 ≥55→老年(终值规则)优先:
* → 婴幼儿 0-2 / 学龄前 3-6 / 替牙期 7-11 / 青少年 12-17 / 青年 18-25 / 中青年 26-30 /
* 中年 31-45 / 中老年 46-54 / 老年 ≥55
*
* snapshot:每次重算按当下年龄取值(年龄随时间增长,看历史年龄读 persona 版本流)。
* birthDate 缺失 / 年龄越界(<0 或 >120,脏数据)→ 不打标签(返回 null)。
*/
@Injectable()
export class AgeBracketFeatureExtractor implements FeatureExtractor {
readonly key = PersonaFeatureKey.AGE_BRACKET;
// 下界含,顺序匹配第一个 age < 下一档下界的档(老年为兜底)
private static readonly BRACKETS: Array<{ min: number; code: string; zh: string }> = [
{ min: 0, code: 'infant', zh: '婴幼儿' }, // 0-2
{ min: 3, code: 'preschool', zh: '学龄前' }, // 3-6
{ min: 7, code: 'mixed_dentition', zh: '替牙期' }, // 7-11
{ min: 12, code: 'adolescent', zh: '青少年' }, // 12-17
{ min: 18, code: 'youth', zh: '青年' }, // 18-25
{ min: 26, code: 'young_adult', zh: '中青年' }, // 26-30
{ min: 31, code: 'middle_aged', zh: '中年' }, // 31-45
{ min: 46, code: 'pre_senior', zh: '中老年' }, // 46-54
{ min: 55, code: 'senior', zh: '老年' }, // ≥55
];
/** 周岁:年差,生日未到则减 1 */
private static ageYears(birth: Date, now: Date): number {
let age = now.getFullYear() - birth.getFullYear();
const m = now.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && now.getDate() < birth.getDate())) age--;
return age;
}
extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null {
const birth = ctx.patient.birthDate;
if (!birth) return null; // 无出生日期 → 不打标签
const age = AgeBracketFeatureExtractor.ageYears(birth, ctx.now);
if (age < 0 || age > 120) return null; // 脏数据
// 取最后一个 min ≤ age 的档(BRACKETS 升序)
let bracket = AgeBracketFeatureExtractor.BRACKETS[0]!;
for (const b of AgeBracketFeatureExtractor.BRACKETS) {
if (age >= b.min) bracket = b;
else break;
}
return {
key: this.key,
description: `${bracket.zh} · ${age}岁`,
score: null, // 弃用语义,场景从 data 取
data: {
bracket: bracket.code, // 圈人群/筛选用稳定 code
label: bracket.zh,
ageYears: age,
},
// 年龄来自 patient 主档 birthDate(非 fact),无 fact 证据
evidence: { factIds: [] },
};
}
}
...@@ -3,6 +3,7 @@ import type { FeatureExtractor } from './feature.interface'; ...@@ -3,6 +3,7 @@ import type { FeatureExtractor } from './feature.interface';
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';
import { AgeBracketFeatureExtractor } from './age-bracket.feature';
/** /**
* FeatureRegistry — 收集所有 PersonaFeature 提取器。 * FeatureRegistry — 收集所有 PersonaFeature 提取器。
...@@ -18,9 +19,10 @@ export class FeatureRegistry { ...@@ -18,9 +19,10 @@ export class FeatureRegistry {
constructor( constructor(
rfm: RfmFeatureExtractor, rfm: RfmFeatureExtractor,
age: AgeBracketFeatureExtractor,
dnc: DoNotContactStatusFeatureExtractor, dnc: DoNotContactStatusFeatureExtractor,
entitlement: EntitlementStatusFeatureExtractor, entitlement: EntitlementStatusFeatureExtractor,
) { ) {
this.extractors = [rfm, dnc, entitlement]; this.extractors = [rfm, age, dnc, entitlement];
} }
} }
...@@ -5,6 +5,7 @@ import { FeatureRegistry } from './features/feature.registry'; ...@@ -5,6 +5,7 @@ import { FeatureRegistry } from './features/feature.registry';
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';
import { AgeBracketFeatureExtractor } from './features/age-bracket.feature';
@Module({ @Module({
controllers: [PersonaController], controllers: [PersonaController],
...@@ -13,6 +14,7 @@ import { RfmFeatureExtractor } from './features/rfm.feature'; ...@@ -13,6 +14,7 @@ import { RfmFeatureExtractor } from './features/rfm.feature';
FeatureRegistry, FeatureRegistry,
// W7:rfm 统一了旧 value/recall_risk/treatment_chain_status,三个旧 extractor 已摘除。 // W7:rfm 统一了旧 value/recall_risk/treatment_chain_status,三个旧 extractor 已摘除。
RfmFeatureExtractor, RfmFeatureExtractor,
AgeBracketFeatureExtractor,
DoNotContactStatusFeatureExtractor, DoNotContactStatusFeatureExtractor,
EntitlementStatusFeatureExtractor, EntitlementStatusFeatureExtractor,
], ],
......
...@@ -381,6 +381,8 @@ export const PersonaFeatureKey = { ...@@ -381,6 +381,8 @@ export const PersonaFeatureKey = {
// 统计层(RFM 八象限 — 融合 R 最近/F 频次/M 金额三种时间语义;统一旧 value+recall_risk) // 统计层(RFM 八象限 — 融合 R 最近/F 频次/M 金额三种时间语义;统一旧 value+recall_risk)
RFM: 'rfm', // 价值分群(RFM 八象限 + 生命周期;data 带 segment 供圈人群) RFM: 'rfm', // 价值分群(RFM 八象限 + 生命周期;data 带 segment 供圈人群)
// 规则层 · snapshot(从 birthDate 当下算)
AGE_BRACKET: 'age_bracket', // 年龄段(婴幼儿..老年,匹配适龄项目/家庭决策)
// v1 候选(规则路径,业务方反馈后逐步上) // v1 候选(规则路径,业务方反馈后逐步上)
ENTITLEMENT_STATUS: 'entitlement_status', // 权益身份(商保直付 / 医保 / 储值 / 私行;事实投影型,史+最近日期) ENTITLEMENT_STATUS: 'entitlement_status', // 权益身份(商保直付 / 医保 / 储值 / 私行;事实投影型,史+最近日期)
......
...@@ -39,6 +39,7 @@ export const planScenarioLabel = (key: string): string => ...@@ -39,6 +39,7 @@ export const planScenarioLabel = (key: string): string =>
export const PERSONA_FEATURE_META: Record<string, { label: string; tone: Tone }> = { export const PERSONA_FEATURE_META: Record<string, { label: string; tone: Tone }> = {
[PersonaFeatureKey.RFM]: { label: '价值分群', tone: 'indigo' }, [PersonaFeatureKey.RFM]: { label: '价值分群', tone: 'indigo' },
[PersonaFeatureKey.AGE_BRACKET]: { 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' },
......
...@@ -59,4 +59,33 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -59,4 +59,33 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
}, },
// ── A.1.1 年龄段(业务 CDP 口径)──
age_bracket: {
key: 'age_bracket',
nameZh: '年龄段',
tier: 'rule',
timeSemantics: 'snapshot', // 当下年龄(随时间增长,历史读版本流)
labelValues: [
'婴幼儿(0-3)',
'学龄前(3-6)',
'替牙期(7-11)',
'青少年(12-17)',
'青年(18-25)',
'中青年(26-30)',
'中年(31-45)',
'中老年(46-55)',
'老年(55+)',
],
dataSource: '现:PAC patient.birthDate 自算;未来:宿主 CDP「客户综合分析报表」client_age 直接取',
dataFields: ['client_age'],
meaning: '按口腔治疗需求特征精细划分年龄层,匹配适龄项目与家庭决策模式',
algorithm: [
'client_age 直接取值(PAC 现从 birthDate 算周岁);区间(下界含,3/55 重叠归下一档,≥55→老年):',
'0-2→婴幼儿 / 3-6→学龄前 / 7-11→替牙期 / 12-17→青少年 / 18-25→青年 /',
'26-30→中青年 / 31-45→中年 / 46-54→中老年 / ≥55→老年',
].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