Commit d8285684 by luoqi

feat(persona): 权益身份特征(商保/医保)+ 专属客服摄入

吸收 CDP 画像字典 v3.0「权益身份」+「专属客服」,落 PAC 三层:

权益身份(entitlement_status persona feature,事实投影型):
- 商业保险强时效(雇主团险换工作即失效,DW 无保单有效期)→ 不断言"当前在保",
  产「史 + 最近日期」:everCommercial + insurers[] + lastInsuranceAt + monthsSinceLast,
  时效判断留给读取方(UI 按日期变措辞 / scorer 按 monthsSinceLast 套窗口)
- 判定 channel='insurance' OR content.insurance_name 非空(拆单支付里保险非主导也能捕获)
- 保司名 57 脏名在 feature 层 canonicalInsurer 归一(别名折叠 + 排除测试数据)
- 零 DB 迁移:description 人读串 / score=monthsSinceLast / evidence.data 放结构化明细

fact 层:payment_record 加 insurance_name(payment.yaml 映射 + parser + zod schema)
专属客服(current_task_director):路由属性非画像,并入 patients.preferences.dedicatedCs
  (mergePatientPreferences 共享 helper,cold-import + dispatcher 两处 upsert,零迁移)

本地端到端验证(患者 6857):payment.insurance_name 写入 / entitlement="商保客户·平安
健康险·最近2026-03(2个月前)"(PINGAN+PingHealth 归一为一家)/ dedicatedCs={832,康慧捧}

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 40393fbe
......@@ -16,4 +16,8 @@ field_mapping:
birthDate: birthday
medicalRecordNumber: file_num # 病历号(辅助标识,客服沟通用)
notes: follow_content
# 专属客服(当前任务负责人)— 路由/指派属性,非画像特征。upsert 时并入 patients.preferences.dedicatedCs。
# 时效:"当前"会换 → 存当前值(upsert 覆盖);用于详情页展示 + 推送人匹配。
dedicatedCsName: current_task_director
dedicatedCsId: current_task_director_id
# doNotContact / deceased 不映射 — 走 PatientCanonicalSchema default false
......@@ -30,6 +30,10 @@ field_mapping:
# - 跟 design.md 患者价值定义对齐:LTV = 患者带来的业务量,不是现金流
amount: receivable_this # FieldMapper.normalize 按 amount_unit=yuan 转 cents
method: payment_channel # transforms.pick_first_nonzero 推断的主导支付通道
# 商业保险公司名(settlement_mode_out.insurance_name)— 喂 persona「权益身份」特征。
# 保司名脏(57 个别名),归一在 entitlement-status.feature 的 canonicalInsurer 里做;
# 此处原样带入 fact.content.insurance_name(单一收口),非空即"商保结算"强信号。
insuranceName: insurance_name
doctorId: doctor_id # 收费医生(医患关系信号)
encounterExternalId: registration_id # 关联接诊(语义准:registration_id = 接诊号)
# orderExternalId 不映射:host 没真正的"医嘱单 id"概念,留 null 等其他 host(charge_order)填
import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey } from '@pac/types';
import type {
ActiveFact,
FeatureExtractor,
FeatureExtractorContext,
PersonaFeatureDraft,
} from './feature.interface';
/**
* entitlement_status 权益身份(商保 / 医保 …)
*
* ⭐ 本特征是「事实投影型」,跟 value / recall_risk(计算分档型)性质不同:
* - 不引入业务阈值判断,只把"用过哪类保险结算"的事实 rollup 出来
* - 商业保险**强时效**(雇主团险换工作即失效;实测 61% 患者最近一次商保 >2 年前)
* 且 DW 无保单有效期字段 → **不断言"当前在保"**,只产「史 + 最近日期」,
* 时效判断留给读取方(UI 按 lastInsuranceAt 变措辞,scorer 按 monthsSinceLast 套窗口)
*
* 数据来源(payment_record fact,单一收口 fact.content):
* - 商保:channel='insurance' 或 content.insurance_name 非空(阶段2 re-ingest 后才有保司名)
* - 医保:channel='medical_insurance'(社保普惠,**不当 VIP**,仅记录)
*
* 落库(零迁移):description 人读串 / score=monthsSinceLast 给排序 /
* evidence.data 放结构化明细(insurers / lastInsuranceAt …)给 scorer 与 UI 结构化消费。
*
* 未命中(既无商保也无医保结算)→ 返回 null,不打标签。
*/
@Injectable()
export class EntitlementStatusFeatureExtractor implements FeatureExtractor {
readonly key = PersonaFeatureKey.ENTITLEMENT_STATUS;
/** 保司名归一(57 个脏名 → canonical;别名折叠 + 排除测试数据)。
* 阶段1(未 re-ingest)content.insurance_name 为空 → 列表自然为空,不影响"是否商保"判定。 */
private static canonicalInsurer(raw: unknown): string | null {
const s = String(raw ?? '').trim();
if (!s) return null;
if (/测试|test|xxx|宣南书馆|乐雅健康科技|乐牙/i.test(s)) return null; // 测试/非保险脏数据
const ALIAS: [RegExp, string][] = [
[/万欣和|MSH/i, '万欣和'],
[/招商信诺|信诺|CIGNA/i, '招商信诺'],
[/平安/i, '平安健康险'],
[/中意/i, '中意人寿'],
[/太保安联|太平洋|CPIC/i, '太保安联'],
[/安态|AETNA/i, '安态'],
[/柏盛/i, '柏盛健康'],
[/保柏|BUPA/i, '保柏'],
[/友邦|AIA/i, '友邦'],
[/中间带|Medilink/i, '中间带'],
[/安联|ALLIANZ/i, '安联'],
[/吉倍吉|GBG/i, '吉倍吉'],
[/工银安盛/i, '工银安盛'],
[/复星|FOSUN/i, '复星联合'],
[/方胜|FESCO/i, '方胜'],
[/江泰/i, '江泰救援'],
[/休荪|HSC/i, '休荪'],
[/安顾|ERV/i, '安顾援助'],
[/泰康/i, '泰康养老'],
];
for (const [re, name] of ALIAS) if (re.test(s)) return name;
const zh = s.split('/')[0]!.trim();
return zh || null;
}
private monthsSince(from: Date, now: Date): number {
return Math.max(0, Math.floor((now.getTime() - from.getTime()) / (1000 * 60 * 60 * 24 * 30.4375)));
}
private isCommercial(c: Record<string, unknown>): boolean {
return c.channel === 'insurance' || !!String(c.insurance_name ?? '').trim();
}
extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null {
const payments = ctx.factsByType.get('payment_record') ?? [];
const commercialFactIds: string[] = [];
const insurerSet = new Set<string>();
let lastCommercialAt: Date | null = null;
let lastMedicalAt: Date | null = null;
const medicalFactIds: string[] = [];
for (const f of payments as ActiveFact[]) {
const c = (f.content ?? {}) as Record<string, unknown>;
const at = f.occurredAt ?? null;
if (this.isCommercial(c)) {
commercialFactIds.push(f.id);
const ins = EntitlementStatusFeatureExtractor.canonicalInsurer(c.insurance_name);
if (ins) insurerSet.add(ins);
if (at && (!lastCommercialAt || at > lastCommercialAt)) lastCommercialAt = at;
} else if (c.channel === 'medical_insurance') {
medicalFactIds.push(f.id);
if (at && (!lastMedicalAt || at > lastMedicalAt)) lastMedicalAt = at;
}
}
const hasCommercial = commercialFactIds.length > 0;
const hasMedical = medicalFactIds.length > 0;
if (!hasCommercial && !hasMedical) return null; // 未命中,不打标签
const insurers = [...insurerSet].sort();
const fmtYm = (d: Date | null) => (d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` : null);
const monthsCommercial = lastCommercialAt ? this.monthsSince(lastCommercialAt, ctx.now) : null;
const monthsMedical = lastMedicalAt ? this.monthsSince(lastMedicalAt, ctx.now) : null;
// description:人读自包含;商保优先,把"最近日期"显式写出来(时效判断留给人/scorer)
const parts: string[] = [];
if (hasCommercial) {
const who = insurers.length ? ` · ${insurers.join('、')}` : '';
const when = fmtYm(lastCommercialAt);
parts.push(`商保客户${who}${when ? ` · 最近 ${when}${monthsCommercial}个月前)` : ''}`);
}
if (hasMedical) {
const when = fmtYm(lastMedicalAt);
parts.push(`医保结算${when ? ` · 最近 ${when}` : ''}`);
}
return {
key: this.key,
description: parts.join(';'),
// score = 商保最近月数(越小越新);仅医保时用医保月数兜底。旗标型,非梯度,scorer 读 data 更精确。
score: monthsCommercial ?? monthsMedical ?? null,
evidence: {
factIds: [...commercialFactIds, ...medicalFactIds],
data: {
commercialInsured: hasCommercial,
commercialInsurers: insurers, // 阶段2 re-ingest 后才有保司名
lastCommercialInsuranceAt: lastCommercialAt ? lastCommercialAt.toISOString() : null,
monthsSinceLastCommercial: monthsCommercial,
medicalInsured: hasMedical,
lastMedicalInsuranceAt: lastMedicalAt ? lastMedicalAt.toISOString() : null,
},
},
};
}
}
......@@ -57,6 +57,9 @@ export interface PersonaFeatureDraft {
evidence: {
factIds: string[];
agentInvocationIds?: string[];
/// 结构化 payload(零迁移路线):description 是人读串、score 是排序数,
/// 需要被 scorer / UI 结构化消费的明细放这里(如权益身份 insurers / lastInsuranceAt)。
data?: Record<string, unknown>;
};
}
......
......@@ -4,11 +4,13 @@ import { ValueFeatureExtractor } from './value.feature';
import { TreatmentChainStatusFeatureExtractor } from './treatment-chain-status.feature';
import { RecallRiskFeatureExtractor } from './recall-risk.feature';
import { DoNotContactStatusFeatureExtractor } from './do-not-contact-status.feature';
import { EntitlementStatusFeatureExtractor } from './entitlement-status.feature';
/**
* FeatureRegistry — 收集所有规则路径的 PersonaFeature 提取器。
*
* v1 起步 4 个:value / treatment_chain_status / recall_risk / do_not_contact_status。
* + entitlement_status 权益身份(商保/医保,事实投影型,史+最近日期)。
* 单个 extractor 抛错不影响其余 — PersonaService 整体记 partial(orchestration log)。
*/
@Injectable()
......@@ -20,7 +22,8 @@ export class FeatureRegistry {
chain: TreatmentChainStatusFeatureExtractor,
risk: RecallRiskFeatureExtractor,
dnc: DoNotContactStatusFeatureExtractor,
entitlement: EntitlementStatusFeatureExtractor,
) {
this.extractors = [value, chain, risk, dnc];
this.extractors = [value, chain, risk, dnc, entitlement];
}
}
......@@ -6,6 +6,7 @@ import { ValueFeatureExtractor } from './features/value.feature';
import { TreatmentChainStatusFeatureExtractor } from './features/treatment-chain-status.feature';
import { RecallRiskFeatureExtractor } from './features/recall-risk.feature';
import { DoNotContactStatusFeatureExtractor } from './features/do-not-contact-status.feature';
import { EntitlementStatusFeatureExtractor } from './features/entitlement-status.feature';
@Module({
controllers: [PersonaController],
......@@ -16,6 +17,7 @@ import { DoNotContactStatusFeatureExtractor } from './features/do-not-contact-st
TreatmentChainStatusFeatureExtractor,
RecallRiskFeatureExtractor,
DoNotContactStatusFeatureExtractor,
EntitlementStatusFeatureExtractor,
],
exports: [PersonaService],
})
......
......@@ -28,6 +28,7 @@ import {
} from './clickhouse-source.service';
import { TransformEngine } from '../transforms/transform-engine';
import { buildTenantResolver, type TenantResolver } from './tenant-resolver';
import { mergePatientPreferences } from '../pipeline/patient-preferences.util';
/**
* ColdImportService(v2 — AssemblerEngine 驱动)
......@@ -729,10 +730,8 @@ export class ColdImportService {
birthDate: c.birthDate ? new Date(c.birthDate as string) : null,
medicalRecordNumber:
(c.medicalRecordNumber as string | undefined) ?? null,
preferences:
c.preferences && typeof c.preferences === 'object'
? (c.preferences as Prisma.InputJsonValue)
: undefined,
// 专属客服(current_task_director)并入 preferences.dedicatedCs(零迁移,非画像特征)
preferences: mergePatientPreferences(c),
// status='archived'/'merged' → active=false;'active' / 缺省 → true
active: ((c.status as string | undefined) ?? 'active') === 'active',
};
......
......@@ -331,6 +331,8 @@ const PaymentRecordContent = z
payment_external_id: z.string().min(1),
amount_cents: z.number().int().nonnegative(),
channel: nullableString(),
/// 商业保险公司名(可空)— 非空 = 商保结算;喂 persona「权益身份」。保司名脏,归一在 feature 层做。
insurance_name: nullableString(),
/// 收费医生 id(host 侧)— 医患关系信号(患者主要花钱给哪个医生)
doctor_id: nullableString(),
/// 关联接诊 id(反查"这次收款关联哪次接诊")
......
......@@ -44,6 +44,9 @@ export class PaymentParser implements Parser {
const method = (c.method as string | undefined) ?? null;
const doctorId = c.doctorId ? String(c.doctorId) : null; // host Int64,0/null → null
const currency = (c.currency as string | undefined) ?? 'CNY';
// 商保公司名(可空):非空 = 商保结算;喂 persona「权益身份」。归一在 feature 层做。
const insRaw = (c.insuranceName as string | undefined) ?? null;
const insuranceName = insRaw && insRaw.trim() ? insRaw.trim() : null;
return [
{
......@@ -59,6 +62,7 @@ export class PaymentParser implements Parser {
payment_external_id: externalId,
amount_cents: amount,
channel: method,
insurance_name: insuranceName,
doctor_id: doctorId,
encounter_external_id: encounterExternalId,
related_order_external_id: orderExternalId,
......
import type { Prisma } from '@prisma/client';
/**
* 把 canonical patient row 的派生属性并入 patients.preferences(Json)——零 schema 迁移。
*
* 当前承载:
* - dedicatedCs 专属客服(current_task_director):路由/指派属性,非画像特征。
* "当前"会换 → upsert 覆盖(存当前值)。用于详情页展示 + 推送人匹配。
*
* 返回 undefined 表示"无可写偏好"(让 prisma upsert 不动该列)。
*/
export function mergePatientPreferences(
c: Record<string, unknown>,
): Prisma.InputJsonValue | undefined {
const base =
c.preferences && typeof c.preferences === 'object'
? ({ ...(c.preferences as Record<string, unknown>) } as Record<string, unknown>)
: ({} as Record<string, unknown>);
const csName = ((c.dedicatedCsName as string | undefined) ?? '').trim() || null;
const rawId = c.dedicatedCsId == null ? '' : String(c.dedicatedCsId).trim();
const csId = rawId && rawId !== '0' ? rawId : null;
if (csName || csId) {
base.dedicatedCs = { id: csId, name: csName };
}
return Object.keys(base).length ? (base as Prisma.InputJsonValue) : undefined;
}
......@@ -6,6 +6,7 @@ import { QueueProducer } from '../../../queues/queue-producer.service';
import { TransactionSynthesizer } from './transaction-synthesizer';
import { ParserPipeline } from './parser-pipeline.service';
import type { EmitsConfig } from '../assembler/assembler.schema';
import { mergePatientPreferences } from './patient-preferences.util';
/**
* PipelineDispatcher — "canonical tables → transaction + fact" 的共享内核。
......@@ -222,7 +223,8 @@ export class PipelineDispatcher {
phone: (row.phone as string | undefined) ?? null,
gender: (row.gender as string | undefined) ?? null,
birthDate: row.birthDate ? new Date(row.birthDate as string) : null,
preferences: row.preferences ? (row.preferences as Prisma.InputJsonValue) : undefined,
// 专属客服(current_task_director)并入 preferences.dedicatedCs(零迁移)
preferences: mergePatientPreferences(row),
active: ((row.status as string | undefined) ?? 'active') === 'active',
};
const profileData = {
......
......@@ -110,4 +110,14 @@ const ALGORITHMS: Record<string, AlgoMeta> = {
],
note: '不打扰时召回引擎硬拦截,不生成 / 已生成的不派单。电话缺失算"待补充电话",不算不打扰',
},
[PersonaFeatureKey.ENTITLEMENT_STATUS]: {
title: '权益身份',
subtitle: '商保 / 医保结算史(事实投影,史+最近日期)',
rules: [
{ label: '商保客户', body: '历史用商业保险结算过(channel=保险 或 有保司名),展示保司 + 最近一次日期' },
{ label: '医保结算', body: '用过社保医保结算(普惠,非高端 VIP 信号)' },
{ label: '不打标签', body: '无任何保险结算记录' },
],
note: '商保强时效(雇主团险换工作即失效,DW 无保单有效期)→ 只陈述"曾经+最近日期",不断言"当前在保";新鲜度由人/打分器按最近日期判断',
},
};
......@@ -380,6 +380,7 @@ export const PersonaFeatureKey = {
DO_NOT_CONTACT_STATUS: 'do_not_contact_status', // 不打扰状态(合规硬约束)
// v1 候选(规则路径,业务方反馈后逐步上)
ENTITLEMENT_STATUS: 'entitlement_status', // 权益身份(商保直付 / 医保 / 储值 / 私行;事实投影型,史+最近日期)
INCOMPLETE_TREATMENT: 'incomplete_treatment', // 未完成治疗(治疗链有缺口)
RECOMMENDED_ROLE: 'recommended_role', // 推荐执行角色(staff/leader/...)
REFERRAL_RELATION: 'referral_relation', // 转介绍关系(老带新链)
......
......@@ -42,6 +42,7 @@ export const PERSONA_FEATURE_META: Record<string, { label: string; tone: Tone }>
[PersonaFeatureKey.TREATMENT_CHAIN_STATUS]: { label: '治疗链状态', tone: 'amber' },
[PersonaFeatureKey.RECALL_RISK]: { label: '流失风险', tone: 'emerald' },
[PersonaFeatureKey.DO_NOT_CONTACT_STATUS]: { label: '不打扰状态', tone: 'slate' },
[PersonaFeatureKey.ENTITLEMENT_STATUS]: { label: '权益身份', tone: 'indigo' },
[PersonaFeatureKey.INCOMPLETE_TREATMENT]: { label: '未完成治疗', tone: 'amber' },
[PersonaFeatureKey.RECOMMENDED_ROLE]: { label: '推荐角色', tone: 'teal' },
[PersonaFeatureKey.REFERRAL_RELATION]: { label: '转介绍关系', tone: 'sky' },
......
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