Commit 37527216 by luoqi

feat(persona): 转介绍达人特征(B.1.2 v1,DW 预聚合)

- 摄入 fact_client_out.recommend_num/recommend_amount → 副表立柱 referral_count/referral_amount_cents
  (元→分;canonical schema + patient.yaml + cold-import/dispatcher 两路 upsert + migration)。
- referral_champion 特征:门槛 recommend_num≥3 且转化额>0;直系家庭≥3→家庭型,否则社交型;不满足→不打标签。
- 本地 928:26 社交型(家庭型需≥3直系家庭,样本稀疏未出)。
- ️ v1 用 DW 预聚合替代逐个被推荐人'均有效转化'(跨患者+需被推荐人已摄入,留 v2)。
- ️ recommend_num 增量刷新滞后(fact_client_out 游标=last_visit_time,无 updated_date):
  推荐人下次到诊/全量重摄才更新;真修=游标改 greatest(last_visit_time,recommend_last_visit_time)(follow-up)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent a99b88d7
......@@ -23,6 +23,9 @@ field_mapping:
# 获客渠道(初诊来源,A.2.1)— 一级经 enum_mapping 归一 PAC 标准;二级原值透传
acquisitionChannel: primary_category
acquisitionSub: sub_category
# 转介绍(B.1.2):推荐人数 / 带来转化总额(元;dispatcher 转分)
referralCount: recommend_num
referralAmount: recommend_amount
# doNotContact / deceased 不映射 — 走 PatientCanonicalSchema default false
# host(瑞尔/瑞泰)初诊一级渠道 → PAC 立柱标准(canonical-codes.PACAcquisitionChannels)
......
-- AlterTable
ALTER TABLE "patient_profiles" ADD COLUMN "referral_amount_cents" INTEGER,
ADD COLUMN "referral_count" INTEGER;
......@@ -256,6 +256,11 @@ model PatientProfile {
/// 二级渠道(host 原值透传,"详见数仓定义";不强约束,展示/下钻用)
acquisitionSub String? @map("acquisition_sub")
/// 转介绍达人(B.1.2) DW 预聚合的推荐活动(随被推荐人到诊更新,增量见 cursor 备注)
/// referralCount = recommend_num(本患者推荐人数);referralAmountCents = recommend_amount(带来转化总额,)
referralCount Int? @map("referral_count")
referralAmountCents Int? @map("referral_amount_cents")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
......
......@@ -32,6 +32,9 @@ export interface FeatureExtractorContext {
/// 获客渠道(A.2.1):一级 PAC 标准枚举 / 二级 host 原值
acquisitionChannel: string | null;
acquisitionSub: string | null;
/// 转介绍(B.1.2):推荐人数 / 带来转化总额(分)
referralCount: number | null;
referralAmountCents: number | null;
} | null;
/// 该 patient 的所有 active facts(按 type 索引方便查)
factsByType: Map<string, ActiveFact[]>;
......
......@@ -7,6 +7,7 @@ import { AgeBracketFeatureExtractor } from './age-bracket.feature';
import { GenderFeatureExtractor } from './gender.feature';
import { AcquisitionChannelFeatureExtractor } from './acquisition-channel.feature';
import { FamilyStructureFeatureExtractor } from './family-structure.feature';
import { ReferralChampionFeatureExtractor } from './referral-champion.feature';
/**
* FeatureRegistry — 收集所有 PersonaFeature 提取器。
......@@ -26,9 +27,10 @@ export class FeatureRegistry {
gender: GenderFeatureExtractor,
acquisition: AcquisitionChannelFeatureExtractor,
family: FamilyStructureFeatureExtractor,
referral: ReferralChampionFeatureExtractor,
dnc: DoNotContactStatusFeatureExtractor,
entitlement: EntitlementStatusFeatureExtractor,
) {
this.extractors = [rfm, age, gender, acquisition, family, dnc, entitlement];
this.extractors = [rfm, age, gender, acquisition, family, referral, dnc, entitlement];
}
}
import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey } from '@pac/types';
import type {
FeatureExtractor,
FeatureExtractorContext,
PersonaFeatureDraft,
} from './feature.interface';
/**
* referral_champion 转介绍达人(B.1.2)— 规则层,snapshot · v1
*
* 口径(图):识别有推荐能力且带来实质转化的客户。家庭型(带家人)/ 社交型(推外人)。
* 有效转化:被推荐人结算排非治疗项后流水 > 200元(瑞尔)/100元(瑞泰)。
* 家庭型:社交关系维护关联 ≥3 人且均有效转化;社交型:非同手机号推荐 ≥3 人且均有效转化。
*
* ⚠️ v1 用 DW 预聚合(fact_client_out):
* referralCount = recommend_num(推荐人数)、referralAmountCents = recommend_amount(带来转化总额)。
* —— 逐个被推荐人"均有效转化"需其本人结算(跨患者 + 需被推荐人已摄入),v1 用
* referralAmountCents>0(确实带来消费)作聚合代理;严格逐人阈值留 v2(全量 + 图谱预聚合)。
* 家庭型/社交型:用 PatientRelation 直系家庭关系数 ≥3 区分(带家人 vs 推外人)。
* 门槛不满足(推荐<3 或无转化额)→ 不打标签。
*/
@Injectable()
export class ReferralChampionFeatureExtractor implements FeatureExtractor {
readonly key = PersonaFeatureKey.REFERRAL_CHAMPION;
private static readonly FAMILY = new Set([
'spouse',
'child',
'grandchild',
'father',
'mother',
'grandparent',
]);
private static readonly MIN_REFERRALS = 3;
extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null {
const count = ctx.profile?.referralCount ?? 0;
const amountCents = ctx.profile?.referralAmountCents ?? 0;
// 门槛:推荐 ≥3 人 且 带来实质转化(聚合额 > 0)
if (count < ReferralChampionFeatureExtractor.MIN_REFERRALS || amountCents <= 0) return null;
const familyCount = (ctx.relations ?? []).filter((r) =>
ReferralChampionFeatureExtractor.FAMILY.has(r.relationship),
).length;
const isFamily = familyCount >= ReferralChampionFeatureExtractor.MIN_REFERRALS;
const code = isFamily ? 'family' : 'social';
const zh = isFamily ? '家庭型' : '社交型';
const yuan = Math.round(amountCents / 100).toLocaleString('zh-CN');
return {
key: this.key,
description: `${zh} · 推荐${count}人 · 带来¥${yuan}`,
score: null,
data: {
type: code,
label: zh,
referralCount: count,
referralAmountCents: amountCents,
familyRelations: familyCount,
},
evidence: { factIds: [] }, // 来自副表立柱(recommend_num/amount)+ 关系边
};
}
}
......@@ -9,6 +9,7 @@ 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';
import { ReferralChampionFeatureExtractor } from './features/referral-champion.feature';
@Module({
controllers: [PersonaController],
......@@ -21,6 +22,7 @@ import { FamilyStructureFeatureExtractor } from './features/family-structure.fea
GenderFeatureExtractor,
AcquisitionChannelFeatureExtractor,
FamilyStructureFeatureExtractor,
ReferralChampionFeatureExtractor,
DoNotContactStatusFeatureExtractor,
EntitlementStatusFeatureExtractor,
],
......
......@@ -211,6 +211,8 @@ export class PersonaService {
notes: patient.profile.notes,
acquisitionChannel: patient.profile.acquisitionChannel,
acquisitionSub: patient.profile.acquisitionSub,
referralCount: patient.profile.referralCount,
referralAmountCents: patient.profile.referralAmountCents,
}
: null,
factsByType,
......
......@@ -819,6 +819,10 @@ export class ColdImportService {
// 获客渠道(A.2.1):一级 PAC 标准枚举(assembler enum_mapping 已归一)/ 二级 host 原值
acquisitionChannel: (c.acquisitionChannel as string | undefined) ?? null,
acquisitionSub: (c.acquisitionSub as string | undefined) ?? null,
// 转介绍(B.1.2):recommend_num / recommend_amount(元→分)
referralCount: c.referralCount != null ? Math.trunc(Number(c.referralCount)) : null,
referralAmountCents:
c.referralAmount != null ? Math.round(Number(c.referralAmount) * 100) : null,
};
try {
......
......@@ -235,6 +235,10 @@ export class PipelineDispatcher {
// 获客渠道(A.2.1):一级 PAC 标准枚举 / 二级 host 原值(assembler 已映射)
acquisitionChannel: (row.acquisitionChannel as string | undefined) ?? null,
acquisitionSub: (row.acquisitionSub as string | undefined) ?? null,
// 转介绍(B.1.2):recommend_num / recommend_amount(元→分)
referralCount: row.referralCount != null ? Math.trunc(Number(row.referralCount)) : null,
referralAmountCents:
row.referralAmount != null ? Math.round(Number(row.referralAmount) * 100) : null,
};
const patient = await this.prisma.patient.upsert({
......
......@@ -386,6 +386,7 @@ export const PersonaFeatureKey = {
GENDER: 'gender', // 性别(男性/女性/未知,影响话术与项目推荐)
ACQUISITION_CHANNEL: 'acquisition_channel', // 获客渠道(初诊来源,数仓 L2;副表立柱)
FAMILY_STRUCTURE: 'family_structure', // 家庭构成(单身/两口/多口/多代;PatientRelation 反推)
REFERRAL_CHAMPION: 'referral_champion', // 转介绍达人(家庭型/社交型;recommend_num+转化)
// v1 候选(规则路径,业务方反馈后逐步上)
ENTITLEMENT_STATUS: 'entitlement_status', // 权益身份(商保直付 / 医保 / 储值 / 私行;事实投影型,史+最近日期)
......
......@@ -43,6 +43,7 @@ export const PERSONA_FEATURE_META: Record<string, { label: string; tone: Tone }>
[PersonaFeatureKey.GENDER]: { label: '性别', tone: 'slate' },
[PersonaFeatureKey.ACQUISITION_CHANNEL]: { label: '获客渠道', tone: 'teal' },
[PersonaFeatureKey.FAMILY_STRUCTURE]: { label: '家庭构成', tone: 'amber' },
[PersonaFeatureKey.REFERRAL_CHAMPION]: { label: '转介绍达人', tone: 'rose' },
[PersonaFeatureKey.VALUE]: { label: '患者价值', tone: 'indigo' },
[PersonaFeatureKey.TREATMENT_CHAIN_STATUS]: { label: '治疗链状态', tone: 'amber' },
[PersonaFeatureKey.RECALL_RISK]: { label: '流失风险', tone: 'emerald' },
......
......@@ -137,4 +137,23 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
owner: 'pac-algo',
version: 1,
},
// ── B.1.2 转介绍达人(业务 CDP 口径;v1 用 DW 预聚合)──
referral_champion: {
key: 'referral_champion',
nameZh: '转介绍达人',
tier: 'rule',
timeSemantics: 'snapshot',
labelValues: ['家庭型', '社交型'],
dataSource: 'DW fact_client_out.recommend_num/recommend_amount(DW 预聚合推荐人数+转化额)+ PatientRelation;未来宿主 CDP social_relations + settlement_records 逐人核',
dataFields: ['recommend_num', 'recommend_amount', 'social_relations'],
meaning: '识别有推荐能力且带来实质转化的客户;家庭型(带家人)推家庭套餐,社交型(推外人)给推荐奖励',
algorithm: [
'门槛:recommend_num ≥3 且 recommend_amount > 0(实质转化,v1 用 DW 聚合额作代理)。',
'家庭型:直系家庭关系 ≥3(带家人);否则 社交型(推外人)。不满足→不打标签。',
'⚠️ v1 用 DW 预聚合(逐个被推荐人"均有效转化"需其结算,跨患者+需被推荐人已摄入,留 v2)。',
].join('\n'),
owner: 'pac-algo',
version: 1,
},
};
......@@ -69,6 +69,9 @@ export const PatientCanonicalSchema = z
acquisitionChannel: z.string().optional().nullable(),
/// 二级渠道(host 原值透传)
acquisitionSub: z.string().optional().nullable(),
/// 转介绍(B.1.2):推荐人数 / 带来转化总额(元,入库转分);DW 预聚合
referralCount: z.coerce.number().int().optional().nullable(),
referralAmount: z.coerce.number().optional().nullable(),
})
.passthrough();
export type PatientCanonical = z.infer<typeof PatientCanonicalSchema>;
......
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