Commit a99b88d7 by luoqi

feat(persona): 家庭构成特征(A.3.1,PatientRelation 反推)

- 复用 PAC PatientRelation 边表(无迁移/无重摄):relations 注入 persona ctx,
  family_structure extractor 反推直系亲属结构。
- 直系 spouse/child·grandchild/father·mother·grandparent;sibling/friend/other 噪音不计。
  有长辈→多代/有子女→多口/有配偶→两口/有非直系边→单身/无边→不打标签(数据缺不臆断)。
- 注册表 spec/enum/label。本地 85/928:多口28/多代21/两口19/单身17。
  ️ 覆盖依赖关系边,样本稀疏+非全量偏低;多代采广义(有长辈即跨代)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 28783dde
import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey } from '@pac/types';
import type {
FeatureExtractor,
FeatureExtractorContext,
PersonaFeatureDraft,
} from './feature.interface';
/**
* family_structure 家庭构成(A.3.1)— 规则层,snapshot
*
* 口径(图):基于直系亲属社交关系反推家庭结构(家庭套餐 / 儿牙营销用)。
* 数据来源:PAC PatientRelation 边表(摄入自 fact_customer_referee_out;relationship 已归一)。
* 直系:spouse(配偶)/ child·grandchild(子女晚辈)/ father·mother·grandparent(长辈);
* sibling(同辈)/ friend / other 视为非直系/推荐人噪音,不计入。
*
* 判定(图 + 可行补全,长辈优先 = 跨代):
* 有长辈 → 多代之家(跨代家庭)
* 否则 有子女 → 多口之家(含单亲)
* 否则 有配偶 → 两口之家
* 否则 有非直系边(sibling/friend/other)→ 单身家庭(有社交数据但无直系)
* 否则 无任何关系边 → 不打标签(数据缺,不臆断为单身)
*
* ⚠️ 数据完整性:依赖关系边覆盖。当前样本仅少数患者有边(且 fact_customer_referee_out
* 混推荐人噪音)→ 覆盖偏低;全量 + 完整社交关系维护后更准。
*/
@Injectable()
export class FamilyStructureFeatureExtractor implements FeatureExtractor {
readonly key = PersonaFeatureKey.FAMILY_STRUCTURE;
private static readonly ELDER = new Set(['father', 'mother', 'grandparent']);
private static readonly YOUNGER = new Set(['child', 'grandchild']);
extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null {
const rels = ctx.relations ?? [];
if (rels.length === 0) return null; // 无任何关系边 → 不臆断
let hasSpouse = false;
let hasChild = false;
let hasElder = false;
for (const r of rels) {
const t = r.relationship;
if (t === 'spouse') hasSpouse = true;
else if (FamilyStructureFeatureExtractor.YOUNGER.has(t)) hasChild = true;
else if (FamilyStructureFeatureExtractor.ELDER.has(t)) hasElder = true;
}
let code: string;
let zh: string;
if (hasElder) {
code = 'multigen';
zh = '多代之家';
} else if (hasChild) {
code = 'nuclear';
zh = '多口之家';
} else if (hasSpouse) {
code = 'couple';
zh = '两口之家';
} else {
code = 'single'; // 有边(sibling/friend/other)但无直系
zh = '单身家庭';
}
return {
key: this.key,
description: zh,
score: null,
data: { structure: code, label: zh, hasSpouse, hasChild, hasElder, relationCount: rels.length },
evidence: { factIds: [] }, // 来自关系边表(非 fact)
};
}
}
...@@ -35,6 +35,9 @@ export interface FeatureExtractorContext { ...@@ -35,6 +35,9 @@ export interface FeatureExtractorContext {
} | null; } | null;
/// 该 patient 的所有 active facts(按 type 索引方便查) /// 该 patient 的所有 active facts(按 type 索引方便查)
factsByType: Map<string, ActiveFact[]>; factsByType: Map<string, ActiveFact[]>;
/// 该 patient 的关系边(PatientRelation;家庭构成等用)。relationship 已归一
/// (spouse/father/mother/grandparent/child/grandchild/sibling/friend/other)。
relations: Array<{ relationship: string }>;
/// today 锚点(测试时可注入固定时间) /// today 锚点(测试时可注入固定时间)
now: Date; now: Date;
/// 群体统计(租户级,统计层特征用 — 如 RFM 的 M 分位阈值 [p20,p40,p60,p80])。 /// 群体统计(租户级,统计层特征用 — 如 RFM 的 M 分位阈值 [p20,p40,p60,p80])。
......
...@@ -6,6 +6,7 @@ import { RfmFeatureExtractor } from './rfm.feature'; ...@@ -6,6 +6,7 @@ import { RfmFeatureExtractor } from './rfm.feature';
import { AgeBracketFeatureExtractor } from './age-bracket.feature'; import { AgeBracketFeatureExtractor } from './age-bracket.feature';
import { GenderFeatureExtractor } from './gender.feature'; import { GenderFeatureExtractor } from './gender.feature';
import { AcquisitionChannelFeatureExtractor } from './acquisition-channel.feature'; import { AcquisitionChannelFeatureExtractor } from './acquisition-channel.feature';
import { FamilyStructureFeatureExtractor } from './family-structure.feature';
/** /**
* FeatureRegistry — 收集所有 PersonaFeature 提取器。 * FeatureRegistry — 收集所有 PersonaFeature 提取器。
...@@ -24,9 +25,10 @@ export class FeatureRegistry { ...@@ -24,9 +25,10 @@ export class FeatureRegistry {
age: AgeBracketFeatureExtractor, age: AgeBracketFeatureExtractor,
gender: GenderFeatureExtractor, gender: GenderFeatureExtractor,
acquisition: AcquisitionChannelFeatureExtractor, acquisition: AcquisitionChannelFeatureExtractor,
family: FamilyStructureFeatureExtractor,
dnc: DoNotContactStatusFeatureExtractor, dnc: DoNotContactStatusFeatureExtractor,
entitlement: EntitlementStatusFeatureExtractor, entitlement: EntitlementStatusFeatureExtractor,
) { ) {
this.extractors = [rfm, age, gender, acquisition, dnc, entitlement]; this.extractors = [rfm, age, gender, acquisition, family, dnc, entitlement];
} }
} }
...@@ -8,6 +8,7 @@ import { RfmFeatureExtractor } from './features/rfm.feature'; ...@@ -8,6 +8,7 @@ import { RfmFeatureExtractor } from './features/rfm.feature';
import { AgeBracketFeatureExtractor } from './features/age-bracket.feature'; import { AgeBracketFeatureExtractor } from './features/age-bracket.feature';
import { GenderFeatureExtractor } from './features/gender.feature'; import { GenderFeatureExtractor } from './features/gender.feature';
import { AcquisitionChannelFeatureExtractor } from './features/acquisition-channel.feature'; import { AcquisitionChannelFeatureExtractor } from './features/acquisition-channel.feature';
import { FamilyStructureFeatureExtractor } from './features/family-structure.feature';
@Module({ @Module({
controllers: [PersonaController], controllers: [PersonaController],
...@@ -19,6 +20,7 @@ import { AcquisitionChannelFeatureExtractor } from './features/acquisition-chann ...@@ -19,6 +20,7 @@ import { AcquisitionChannelFeatureExtractor } from './features/acquisition-chann
AgeBracketFeatureExtractor, AgeBracketFeatureExtractor,
GenderFeatureExtractor, GenderFeatureExtractor,
AcquisitionChannelFeatureExtractor, AcquisitionChannelFeatureExtractor,
FamilyStructureFeatureExtractor,
DoNotContactStatusFeatureExtractor, DoNotContactStatusFeatureExtractor,
EntitlementStatusFeatureExtractor, EntitlementStatusFeatureExtractor,
], ],
......
...@@ -179,6 +179,12 @@ export class PersonaService { ...@@ -179,6 +179,12 @@ export class PersonaService {
factsByType.set(f.type, arr); factsByType.set(f.type, arr);
} }
// 关系边(家庭构成等用)— relatedPatientId 是否解析不影响,只看 relationship 类型
const relations = await this.prisma.patientRelation.findMany({
where: { patientId: patient.id },
select: { relationship: true },
});
const latestTxn = await this.prisma.patientTransaction.findFirst({ const latestTxn = await this.prisma.patientTransaction.findFirst({
where: { hostId: patient.hostId, tenantId: patient.tenantId, patientId: patient.id }, where: { hostId: patient.hostId, tenantId: patient.tenantId, patientId: patient.id },
orderBy: { eventSeq: 'desc' }, orderBy: { eventSeq: 'desc' },
...@@ -208,6 +214,7 @@ export class PersonaService { ...@@ -208,6 +214,7 @@ export class PersonaService {
} }
: null, : null,
factsByType, factsByType,
relations,
now, now,
populationStats: { populationStats: {
monetaryQuantiles: await this.getMonetaryQuantiles( monetaryQuantiles: await this.getMonetaryQuantiles(
......
...@@ -385,6 +385,7 @@ export const PersonaFeatureKey = { ...@@ -385,6 +385,7 @@ export const PersonaFeatureKey = {
AGE_BRACKET: 'age_bracket', // 年龄段(婴幼儿..老年,匹配适龄项目/家庭决策) AGE_BRACKET: 'age_bracket', // 年龄段(婴幼儿..老年,匹配适龄项目/家庭决策)
GENDER: 'gender', // 性别(男性/女性/未知,影响话术与项目推荐) GENDER: 'gender', // 性别(男性/女性/未知,影响话术与项目推荐)
ACQUISITION_CHANNEL: 'acquisition_channel', // 获客渠道(初诊来源,数仓 L2;副表立柱) ACQUISITION_CHANNEL: 'acquisition_channel', // 获客渠道(初诊来源,数仓 L2;副表立柱)
FAMILY_STRUCTURE: 'family_structure', // 家庭构成(单身/两口/多口/多代;PatientRelation 反推)
// v1 候选(规则路径,业务方反馈后逐步上) // v1 候选(规则路径,业务方反馈后逐步上)
ENTITLEMENT_STATUS: 'entitlement_status', // 权益身份(商保直付 / 医保 / 储值 / 私行;事实投影型,史+最近日期) ENTITLEMENT_STATUS: 'entitlement_status', // 权益身份(商保直付 / 医保 / 储值 / 私行;事实投影型,史+最近日期)
......
...@@ -42,6 +42,7 @@ export const PERSONA_FEATURE_META: Record<string, { label: string; tone: Tone }> ...@@ -42,6 +42,7 @@ export const PERSONA_FEATURE_META: Record<string, { label: string; tone: Tone }>
[PersonaFeatureKey.AGE_BRACKET]: { label: '年龄段', tone: 'sky' }, [PersonaFeatureKey.AGE_BRACKET]: { label: '年龄段', tone: 'sky' },
[PersonaFeatureKey.GENDER]: { label: '性别', tone: 'slate' }, [PersonaFeatureKey.GENDER]: { label: '性别', tone: 'slate' },
[PersonaFeatureKey.ACQUISITION_CHANNEL]: { label: '获客渠道', tone: 'teal' }, [PersonaFeatureKey.ACQUISITION_CHANNEL]: { label: '获客渠道', tone: 'teal' },
[PersonaFeatureKey.FAMILY_STRUCTURE]: { label: '家庭构成', tone: 'amber' },
[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' },
......
...@@ -118,4 +118,23 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -118,4 +118,23 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
}, },
// ── A.3.1 家庭构成(业务 CDP 口径;PAC 用 PatientRelation 反推)──
family_structure: {
key: 'family_structure',
nameZh: '家庭构成',
tier: 'rule',
timeSemantics: 'snapshot',
labelValues: ['单身家庭', '两口之家', '多口之家', '多代之家'],
dataSource: '现:PAC PatientRelation 边表(摄入自 fact_customer_referee_out,混推荐人噪音);未来:宿主 CDP「社交关系维护」social_relations',
dataFields: ['social_relations'],
meaning: '基于直系亲属社交关系反推家庭结构,用于家庭套餐推荐与儿牙营销',
algorithm: [
'直系:spouse / child·grandchild / father·mother·grandparent;sibling/friend/other 不计(噪音)。',
'有长辈→多代之家;否则有子女→多口之家;否则有配偶→两口之家;否则有非直系边→单身家庭;无边→不打标签。',
'⚠️ 依赖关系边覆盖,样本稀疏 + 非全量时覆盖偏低。',
].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