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 {
} | null;
/// 该 patient 的所有 active facts(按 type 索引方便查)
factsByType: Map<string, ActiveFact[]>;
/// 该 patient 的关系边(PatientRelation;家庭构成等用)。relationship 已归一
/// (spouse/father/mother/grandparent/child/grandchild/sibling/friend/other)。
relations: Array<{ relationship: string }>;
/// today 锚点(测试时可注入固定时间)
now: Date;
/// 群体统计(租户级,统计层特征用 — 如 RFM 的 M 分位阈值 [p20,p40,p60,p80])。
......
......@@ -6,6 +6,7 @@ import { RfmFeatureExtractor } from './rfm.feature';
import { AgeBracketFeatureExtractor } from './age-bracket.feature';
import { GenderFeatureExtractor } from './gender.feature';
import { AcquisitionChannelFeatureExtractor } from './acquisition-channel.feature';
import { FamilyStructureFeatureExtractor } from './family-structure.feature';
/**
* FeatureRegistry — 收集所有 PersonaFeature 提取器。
......@@ -24,9 +25,10 @@ export class FeatureRegistry {
age: AgeBracketFeatureExtractor,
gender: GenderFeatureExtractor,
acquisition: AcquisitionChannelFeatureExtractor,
family: FamilyStructureFeatureExtractor,
dnc: DoNotContactStatusFeatureExtractor,
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';
import { AgeBracketFeatureExtractor } from './features/age-bracket.feature';
import { GenderFeatureExtractor } from './features/gender.feature';
import { AcquisitionChannelFeatureExtractor } from './features/acquisition-channel.feature';
import { FamilyStructureFeatureExtractor } from './features/family-structure.feature';
@Module({
controllers: [PersonaController],
......@@ -19,6 +20,7 @@ import { AcquisitionChannelFeatureExtractor } from './features/acquisition-chann
AgeBracketFeatureExtractor,
GenderFeatureExtractor,
AcquisitionChannelFeatureExtractor,
FamilyStructureFeatureExtractor,
DoNotContactStatusFeatureExtractor,
EntitlementStatusFeatureExtractor,
],
......
......@@ -179,6 +179,12 @@ export class PersonaService {
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({
where: { hostId: patient.hostId, tenantId: patient.tenantId, patientId: patient.id },
orderBy: { eventSeq: 'desc' },
......@@ -208,6 +214,7 @@ export class PersonaService {
}
: null,
factsByType,
relations,
now,
populationStats: {
monetaryQuantiles: await this.getMonetaryQuantiles(
......
......@@ -385,6 +385,7 @@ export const PersonaFeatureKey = {
AGE_BRACKET: 'age_bracket', // 年龄段(婴幼儿..老年,匹配适龄项目/家庭决策)
GENDER: 'gender', // 性别(男性/女性/未知,影响话术与项目推荐)
ACQUISITION_CHANNEL: 'acquisition_channel', // 获客渠道(初诊来源,数仓 L2;副表立柱)
FAMILY_STRUCTURE: 'family_structure', // 家庭构成(单身/两口/多口/多代;PatientRelation 反推)
// v1 候选(规则路径,业务方反馈后逐步上)
ENTITLEMENT_STATUS: 'entitlement_status', // 权益身份(商保直付 / 医保 / 储值 / 私行;事实投影型,史+最近日期)
......
......@@ -42,6 +42,7 @@ export const PERSONA_FEATURE_META: Record<string, { label: string; tone: Tone }>
[PersonaFeatureKey.AGE_BRACKET]: { label: '年龄段', tone: 'sky' },
[PersonaFeatureKey.GENDER]: { label: '性别', tone: 'slate' },
[PersonaFeatureKey.ACQUISITION_CHANNEL]: { label: '获客渠道', tone: 'teal' },
[PersonaFeatureKey.FAMILY_STRUCTURE]: { label: '家庭构成', tone: 'amber' },
[PersonaFeatureKey.VALUE]: { label: '患者价值', tone: 'indigo' },
[PersonaFeatureKey.TREATMENT_CHAIN_STATUS]: { label: '治疗链状态', tone: 'amber' },
[PersonaFeatureKey.RECALL_RISK]: { label: '流失风险', tone: 'emerald' },
......
......@@ -118,4 +118,23 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
owner: 'pac-algo',
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