Commit a4b75100 by luoqi

fix(priority): 三维只念画像标签,不从别处补第二套口径

三维的输入本来就取自画像(急迫等级 / 价值分群 / 生命周期 / 潜在治疗,见 scorer 入参),
但我为了"讲得细"又从 reason.signals 和 consultation_record 各推了一段话 —— 那是第二套口径,
和「画像标签」抽屉必然对不齐。两处已经翻车:

  · 从 signals.triggers 反推出「种植 · 缺 6 颗」。同一屏上 79 岁患者的召回目标写的是
    「活动义齿为主」,画像自己写的是「潜在种植」—— 三处三个说法。
  · 把 intentBehavior=2 说成「没主动问过」(上个 commit 已修措辞,这次直接移除):
    它来自 consultation_record,根本不是画像标签。

改成照抄画像:

    急迫性 10 × 0.4     急迫等级 紧急
    价值性 10 × 0.3     潜在治疗 潜在种植 / 潜在牙周
    意愿度 2.63 × 0.3   价值分群 重要挽留 · 生命周期 流失客

与该患者「画像标签」抽屉里的取值逐字一致。画像没标就说「画像未标 X」,不编。

为此 scenario 的 persona 上下文多取一个 potential_treatment,由 scorer 原样透传进
breakdown(仅展示、不参与打分)—— 前端拿画像的原文,而不是自己拿 K 码 + 牙位再算一遍。
中文名一律走 personaTagLabel(圈人字典),本文件不留映射表。hover 不再需要 signals 入参。

守卫测试相应收紧:三维文案里出现「缺 N 颗」「咨询」等非画像来源的词就红。

️ 顺带查出一处**存量对不齐**(本次未改,需业务定):按年龄排治疗只落到了召回目标,
没落到画像 —— classifyGapToLabel 里 K08 仍只卡 age>18。生产 2,539 条 plan 目标写着
「活动义齿为主」而画像·潜在治疗仍标「潜在种植」。改它会同时改变「潜在种植」的圈人口径。

481 tests green · pac-web build 通过 · 本地真实数据逐条比对过画像抽屉。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 22b2c95f
......@@ -79,6 +79,10 @@ export interface PriorityInput {
isReferralChampion: boolean;
/// 信任 +1:近1年到诊 且 履约良好(非屡次爽约)
recentVisitGood: boolean;
/// 画像·潜在治疗的标签原文 —— **仅透传给展示**,不参与打分。
/// 详情页价值性那一维要陈述画像自己的说法;若前端另从 reason.signals 反推治疗类型,
/// 就会和「画像标签」抽屉两套口径,当场对不齐。
potentialLabels?: string[];
/// 信号置信度(cluster 最优来源):医生诊断 1.0 / 影像AI 0.85 / 建议 0.8 / 未知 0.9
/// v3.0 C.1.1:病历诊断 100% > 影像AI 70-90% > 建议/EMR语义。低置信(影像/建议)降权,防误召冲高分。
confidence: number;
......@@ -101,6 +105,7 @@ export interface PriorityBreakdown {
/// 牙数、主诉命中)都能从已有数字或 reason.signals 推出,不重复存。
rfmSegment: string | null;
lifecycleStage: string | null;
potentialLabels: string[];
}
/// 置信度因子 = 来源置信度直用(诊断1.0 / 建议0.8 / 影像AI0.5),下限 0.5 防过罚。
......@@ -194,6 +199,7 @@ export function calcPriority(input: PriorityInput): PriorityResult {
raw: Math.round(raw * 100) / 100,
rfmSegment: input.rfmSegment,
lifecycleStage: input.lifecycleStage,
potentialLabels: input.potentialLabels ?? [],
},
};
}
......@@ -87,6 +87,8 @@ interface PersonaScoreCtx {
treatmentHistory: string[]; // treatment_history.types
isReferralChampion: boolean;
frequentNoShow: boolean; // special_attention 屡次爽约 → 履约差
/// 画像·潜在治疗的标签原文(如 ['潜在种植','潜在修复'])—— 仅供展示,不参与打分
potentialLabels: string[];
}
/// 召回 primaryCode → treatment_history code(信任迁移"同类治疗史 +1"判定)。
......@@ -448,6 +450,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
hasTreatmentHistory: hasHist,
isReferralChampion: persona?.isReferralChampion ?? false,
recentVisitGood,
potentialLabels: persona?.potentialLabels ?? [],
confidence: r.cluster_confidence ?? sourceConfidence(r),
});
......@@ -544,12 +547,12 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
JOIN persona_features pf ON pf.persona_id = p.id
WHERE p.patient_id = ANY(${patientIds}::uuid[])
AND p.superseded_at IS NULL
AND pf.key IN ('rfm', 'urgency_level', 'lifecycle_stage', 'treatment_history', 'referral_champion', 'special_attention')
AND pf.key IN ('rfm', 'urgency_level', 'lifecycle_stage', 'treatment_history', 'referral_champion', 'special_attention', 'potential_treatment')
`;
const ctx = new Map<string, PersonaScoreCtx>();
const get = (id: string) =>
ctx.get(id) ??
({ rfmSegment: null, urgencyLevel: null, lifecycleStage: null, recencyDays: null, treatmentHistory: [], isReferralChampion: false, frequentNoShow: false } as PersonaScoreCtx);
({ rfmSegment: null, urgencyLevel: null, lifecycleStage: null, recencyDays: null, treatmentHistory: [], isReferralChampion: false, frequentNoShow: false, potentialLabels: [] } as PersonaScoreCtx);
for (const r of rows) {
const cur = get(r.patient_id);
const d = (r.data ?? {}) as Record<string, unknown>;
......@@ -561,6 +564,9 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
} else if (r.key === 'treatment_history' && Array.isArray(d.types)) cur.treatmentHistory = d.types as string[];
else if (r.key === 'referral_champion') cur.isReferralChampion = true;
else if (r.key === 'special_attention' && Array.isArray(d.types)) cur.frequentNoShow = (d.types as string[]).includes('frequent_no_show');
// 只为「详情页把分数讲成人话」而取:价值性那一维要陈述画像自己的说法,不另外从
// reason.signals 反推治疗类型 —— 两套口径会当场对不齐(见 priority-explain.ts)。
else if (r.key === 'potential_treatment' && Array.isArray(d.labels)) cur.potentialLabels = d.labels as string[];
ctx.set(r.patient_id, cur);
}
return ctx;
......
......@@ -14,72 +14,78 @@ import {
* (PriorityBreakdown 类型也因此从 priority-hover.tsx 挪了出来)。
*
* ── 为什么值得测 ──
* 这些字是客服照着跟患者开口用的,写错比不写更糟。已经踩过一次:
* intentBehavior=2 曾被写成「没主动问过」,而生产实测 22 万个 2 分患者里
* 真的从没咨询过的只有 230 人(0.1%)
* 咨询过但源端 intent/content 全为 null,解析不出意向的 176,578 人(80%)
* 咨询过、有意向但问的是别的项目 43,911 人(20%)
* ——「没主动问过」在 99.9% 的情况下是错的,客服拿去讲会当场翻车。
* 这些字是客服照着跟患者开口用的,而且必须和「画像标签」抽屉说同一句话。踩过两次:
*
* ① 从 reason.signals 反推出「种植 · 缺 6 颗」。可同一屏上,79 岁患者的召回目标写的是
* 「活动义齿为主」,画像自己写的是「潜在种植」—— 三处三个说法(生产 2,539 条这种)。
* ② 把 intentBehavior=2 说成「没主动问过」。它来自 consultation_record 不是画像,
* 而且生产 22 万个 2 分患者里真的没咨询过的只有 230 人(0.1%)—— 80% 是源端
* intent/content 全为 null 查不出来,20% 是问了别的项目。这话 99.9% 是错的。
*
* 结论就是本文件第一组断言:**只念画像标签的取值,别处一律不补**。
*/
const bd = (over: Partial<PriorityBreakdown> = {}): PriorityBreakdown => ({
urgency: 10,
value: 10,
willingness: 2.63,
intentBehavior: 2,
freshness: 1,
confidenceFactor: 1,
rfmSegment: 'important_winback',
lifecycleStage: 'churned',
potentialLabels: ['潜在种植'],
...over,
});
describe('⭐ 红线:数据缺失不能说成"患者没做过"', () => {
test('查不到咨询意向 → 只陈述查不到,不替患者下结论', () => {
const text = explainWillingness(bd({ intentBehavior: 2 }));
expect(text).toContain('没查到咨询意向');
// 这些措辞都是在断言患者的行为,而我们只知道"记录里没有"
for (const forbidden of ['没主动问过', '没问过', '没咨询', '未咨询', '被动']) {
describe('⭐ 红线:只念画像,不从别处补第二套口径', () => {
test('三维文案里不出现非画像来源的说法', () => {
const text = [
explainUrgency(bd()),
explainValue(bd({ potentialLabels: ['潜在种植'] })),
explainWillingness(bd()),
].join(' ');
// 这些都曾被从 reason.signals / consultation_record 推出来过,和画像抽屉对不齐:
// 「缺 N 颗」「种植」来自 signals;「没主动问过」来自 consultation_record
for (const forbidden of ['缺 ', '没主动问过', '没问过', '未咨询', '被动', '咨询']) {
expect(text).not.toContain(forbidden);
}
});
test('查到了才说问过 —— 8 分要求意向命中本次治疗类别,是确凿的', () => {
expect(explainWillingness(bd({ intentBehavior: 8 }))).toContain('问过这个项目');
});
});
describe('三维文案讲的是这个患者', () => {
test('急迫性按档位翻译', () => {
expect(explainUrgency(bd({ urgency: 10 }))).toBe('紧急 · 90 天以上没来');
expect(explainUrgency(bd({ urgency: 7 }))).toBe('高 · 30-90 天没来');
expect(explainUrgency(bd({ urgency: 0 }))).toBe('无待做项目');
test('价值性念画像·潜在治疗的原文', () => {
expect(explainValue(bd({ potentialLabels: ['潜在种植', '潜在修复'] }))).toBe(
'潜在治疗 潜在种植 / 潜在修复',
);
});
test('⭐ 价值性带牙数分档(种植的分值就是按牙数抬的,只报名字等于没说)', () => {
const s = (teeth: string) => ({ triggers: [{ code: 'K08' }], toothPosition: teeth });
expect(explainValue(bd(), s('16'))).toBe('种植 · 缺 1 颗');
expect(explainValue(bd(), s('16;17;27'))).toBe('种植 · 缺 3 颗');
expect(explainValue(bd(), s('11;12;13;14;15;16;17;21'))).toBe('种植 · 缺 6 颗以上');
test('画像没标就说没标,不编', () => {
expect(explainValue(bd({ potentialLabels: [] }))).toBe('画像未标潜在治疗');
expect(explainUrgency(bd({ urgency: 0 }))).toBe('画像未标急迫等级');
expect(explainWillingness(bd({ rfmSegment: null, lifecycleStage: null }))).toBe('画像未标价值分群');
});
});
test('signals 缺失时退回分档数字,不编造治疗类型', () => {
expect(explainValue(bd({ value: 7 }), null)).toBe('预估收入档 7/10');
describe('三维念的是画像标签取值', () => {
test('急迫性 = 画像·急迫等级(中文取自圈人字典)', () => {
expect(explainUrgency(bd({ urgency: 10 }))).toBe('急迫等级 紧急');
expect(explainUrgency(bd({ urgency: 7 }))).toBe('急迫等级 高');
expect(explainUrgency(bd({ urgency: 4 }))).toBe('急迫等级 中');
});
test('意愿度 = RFM 分群 · 咨询意向 · 生命周期(中文名取自圈人字典)', () => {
test('意愿度 = 画像·价值分群 + 生命周期', () => {
expect(explainWillingness(bd({ rfmSegment: 'important_winback', lifecycleStage: 'churned' }))).toBe(
'重要挽留 · 没查到咨询意向 · 流失客',
'价值分群 重要挽留 · 生命周期 流失客',
);
});
test('老 plan 没有 rfmSegment / lifecycleStage → 降级但不出 undefined', () => {
const text = explainWillingness(bd({ rfmSegment: null, lifecycleStage: null }));
expect(text).not.toContain('undefined');
expect(text.length).toBeGreaterThan(0);
test('老 plan 缺字段时不出 undefined', () => {
for (const t of [explainUrgency(bd({ urgency: undefined })), explainValue(bd({ potentialLabels: null })),
explainWillingness(bd({ rfmSegment: null, lifecycleStage: null }))]) {
expect(t).not.toContain('undefined');
expect(t.length).toBeGreaterThan(0);
}
});
test('置信度对应信号来源', () => {
test('置信度对应信号来源(这一维本就不是画像,是信号来源)', () => {
expect(explainConfidence(bd({ confidenceFactor: 1 }))).toBe('来自医生诊断');
expect(explainConfidence(bd({ confidenceFactor: 0.8 }))).toBe('来自医生建议');
expect(explainConfidence(bd({ confidenceFactor: 0.5 }))).toContain('影像');
......
......@@ -1058,7 +1058,6 @@ function TopBar({
score={plan.priorityScore}
breakdown={(plan.reasons[0]?.breakdown as { priority?: PriorityBreakdown } | null | undefined)?.priority}
percentile={plan.priorityPercentile}
signals={plan.reasons[0]?.signals}
>
<span className="hidden md:inline-flex cursor-help flex-none">
<PriorityBar score={plan.priorityScore} label="优先级" />
......
......@@ -597,7 +597,6 @@ function PatientRow({
score={p.priorityScore}
breakdown={breakdown}
percentile={p.priorityPercentile}
signals={p.reasons[0]?.signals}
>
<span className="cursor-help">
<PriorityBar score={p.priorityScore} raw={breakdown?.raw} />
......
......@@ -809,7 +809,6 @@ function PatientPlanCard({
score={p.priorityScore}
breakdown={(p.reasons[0]?.breakdown as { priority?: PriorityBreakdown } | null | undefined)?.priority}
percentile={p.priorityPercentile}
signals={p.reasons[0]?.signals}
>
<span className="inline-flex items-center gap-1 cursor-help group/score">
<PriorityBar
......
import { diagnosisCodeNameZh, personaTagLabel } from '@pac/types';
import { personaTagLabel } from '@pac/types';
/**
* 把优先级三维的**数字**翻成**这个患者的情况**。
* 把优先级三维翻成**这个患者的画像取值**。
*
* 原先 hover 每行右边写的是口径定义(「病情多急(末诊/超期)」「治疗类型 + 牙数预估收入」),
* 换谁看都一样 —— 看完仍不知道这位患者为什么是 10 分。这里改成讲他自己的事:
* 「紧急 · 90 天以上没来」「种植 · 缺 6 颗以上」「重要价值 · 主动咨询过 · 成熟客」。
* ── 一条铁律:只念画像,不从别处补 ──
* 三维的输入本来就取自画像标签(急迫等级 / 价值分群 / 生命周期 / 潜在治疗,见 scorer 入参),
* 所以这里就照抄画像的说法。**不要**为了"讲得更细"去 reason.signals、consultation_record
* 之类的地方另外推一段话 —— 那是第二套口径,和「画像标签」抽屉一定会对不齐。
*
* 取值来源尽量**从已有数据推**,不额外存:
* 急迫档位 ← urgency 分值(10/7/4/1 一一对应,无歧义)
* 治疗类型 ← reason.signals.triggers[].code;牙数 ← signals.toothPosition
* 主诉命中 ← intentBehavior(8=咨询记录里问过本项目;2=**没查到**,不等于没问过)
* 只有 RFM 分群和生命周期反推不出来(8 分既可能是重要保持也可能是重要发展),
* 由 scorer 在 breakdown 里带上。
* 已经吃过两次亏:
* · 从 signals.triggers 反推出「种植 · 缺 6 颗」,而同屏的召回目标对 79 岁患者写的是
* 「活动义齿为主」(生产 2,539 条这种);画像自己说的是「潜在种植」——
* 三处三个说法。现在统一念画像那一个。
* · 把 intentBehavior=2 说成「没主动问过」,而它其实来自 consultation_record 且
* 99.9% 是"查不到"。既然它不是画像标签,索性不出现在这里。
*
* 中文名一律走 personaTagLabel(圈人字典),不在本文件另抄映射表。
*/
export interface PriorityBreakdown {
......@@ -26,9 +29,11 @@ export interface PriorityBreakdown {
confidenceFactor?: number; // 置信度因子(诊断1.0/建议0.8/影像0.5)
base?: number; // 三维加权(未乘因子)
raw?: number; // 综合 ×因子后
/// 讲人话用(不参与计算):数字反推不出来的两项,由 scorer 带出。老 plan 可能没有。
/// 以下三项**仅供展示**,由 scorer 从画像原样带出(不参与计算)。老 plan 可能没有。
rfmSegment?: string | null;
lifecycleStage?: string | null;
/// 画像·潜在治疗的标签原文,如 ['潜在种植','潜在修复']
potentialLabels?: string[] | null;
}
/** reason.signals 里本模块用到的部分(其余字段不关心) */
......@@ -37,59 +42,29 @@ export interface PriorityExplainSignals {
toothPosition?: string | null;
}
const URGENCY_ZH: Record<number, string> = {
10: '紧急 · 90 天以上没来',
7: '高 · 30-90 天没来',
4: '中 · 近期来过或刚发现',
1: '低',
0: '无待做项目',
};
/** 牙位串 `16;17;27` → 颗数;全口病种为空 → 0 */
function toothCount(toothPosition?: string | null): number {
if (!toothPosition) return 0;
return toothPosition.split(/[;,]+/).filter((s) => s.trim()).length;
}
/// 急迫分值 → 画像·急迫等级的 code(scorer 的 URGENCY_SCORE 反向表,一一对应无歧义)
const URGENCY_CODE: Record<number, string> = { 10: 'urgent', 7: 'high', 4: 'mid', 1: 'low' };
export function explainUrgency(breakdown: PriorityBreakdown): string {
return URGENCY_ZH[Math.round(breakdown.urgency ?? 0)] ?? '';
const code = URGENCY_CODE[Math.round(breakdown.urgency ?? 0)];
const zh = code ? personaTagLabel('urgency_level', code) : '';
return zh ? `急迫等级 ${zh}` : '画像未标急迫等级';
}
/** 价值性 = 治疗类型(+ 种植/修复看牙数分档) */
export function explainValue(
breakdown: PriorityBreakdown,
signals?: PriorityExplainSignals | null,
): string {
const code = signals?.triggers?.find((t) => t?.code)?.code ?? '';
const name = code ? diagnosisCodeNameZh(code) : '';
const n = toothCount(signals?.toothPosition);
// 种植/修复的分值就是按牙数抬的,说清楚哪一档比只报名字有用
if (code === 'K08') {
const tier = n >= 6 ? '缺 6 颗以上' : n >= 2 ? `缺 ${n} 颗` : '缺 1 颗';
return `种植 · ${tier}`;
}
if (code === 'K03' && n >= 3) return `${name || '牙体修复'} · ${n} 颗`;
if (name) return n > 0 ? `${name} · ${n} 颗` : name;
return `预估收入档 ${(breakdown.value ?? 0).toFixed(0)}/10`;
/** 价值性 —— 念画像·潜在治疗。不从 reason.signals 反推治疗类型(见文件头) */
export function explainValue(breakdown: PriorityBreakdown): string {
const labels = breakdown.potentialLabels ?? [];
return labels.length ? `潜在治疗 ${labels.join(' / ')}` : '画像未标潜在治疗';
}
/** 意愿度 = RFM 分群 · 有没有主动咨询过 · 生命周期 */
/** 意愿度 —— 念画像·价值分群 + 生命周期。主诉意向来自 consultation_record,不是画像,不出现在这里 */
export function explainWillingness(breakdown: PriorityBreakdown): string {
const parts: string[] = [];
// 中文名走圈人字典(单一真理源),不在这儿另抄一张表
const rfm = personaTagLabel('rfm', breakdown.rfmSegment);
if (rfm) parts.push(rfm);
// intentBehavior 是二值,但**低分那档不能说成"没问过"**。生产实测:22 万个 2 分患者里
// 真的从没咨询过的只有 230 人(0.1%),80% 是咨询过但源数据 intent/content 全为 null
// (214.8 万条如此,不是解析 bug —— DW 压根没给),20% 是问的别的项目。
// 写「没主动问过」等于让客服拿着错事实去开口(「您之前没问过种植吧?」)。
// 只陈述我们查到了什么,不替患者下结论。
if (breakdown.intentBehavior !== undefined) {
parts.push(breakdown.intentBehavior >= 8 ? '问过这个项目' : '没查到咨询意向');
}
if (rfm) parts.push(`价值分群 ${rfm}`);
const stage = personaTagLabel('lifecycle_stage', breakdown.lifecycleStage);
if (stage) parts.push(stage);
return parts.length ? parts.join(' · ') : `RFM ${breakdown.rfmAdherence ?? '-'}`;
if (stage) parts.push(`生命周期 ${stage}`);
return parts.length ? parts.join(' · ') : '画像未标价值分群';
}
/** 新鲜度 —— 只在打了折时才值得占一行 */
......
......@@ -13,7 +13,6 @@ import {
explainUrgency,
explainValue,
explainWillingness,
type PriorityExplainSignals,
} from './priority-explain';
/**
......@@ -31,15 +30,12 @@ export function PriorityHover({
score,
breakdown,
percentile,
signals,
children,
}: {
score: number;
breakdown?: PriorityBreakdown | null;
/// 池内百分位(后端 PriorityDistributionService);缺失 → 不渲染位置行
percentile?: number | null;
/// primary reason 的 signals —— 用来把价值性讲成「种植 · 缺 3 颗」而不是「预估收入档 9/10」
signals?: PriorityExplainSignals | null;
children: React.ReactNode;
}) {
return (
......@@ -51,7 +47,6 @@ export function PriorityHover({
score={score}
breakdown={breakdown}
percentile={percentile}
signals={signals}
/>
</HoverCardContent>
</HoverCard>
......@@ -62,12 +57,10 @@ function PriorityBreakdownTable({
score,
breakdown,
percentile,
signals,
}: {
score: number;
breakdown?: PriorityBreakdown | null;
percentile?: number | null;
signals?: PriorityExplainSignals | null;
}) {
// 10 分制(保留两位小数):优先用 breakdown.raw(真 2 位精度),无则 score/10
const disp = (breakdown?.raw ?? score / 10).toFixed(2);
......@@ -98,10 +91,10 @@ function PriorityBreakdownTable({
<Header disp={disp} percentile={percentile} />
<table className="w-full tabular-nums">
<tbody>
{/* hint 一律讲**这个患者**的情况,不讲口径定义 —— 定义换谁看都一样,看完还是不知道
他为什么是 10 分。翻译逻辑在 priority-explain.ts。 */}
{/* hint 一律念**画像标签的取值**(而不是口径定义,也不从 signals 另推一套说法),
这样和「画像标签」抽屉里显示的永远是同一句。见 priority-explain.ts 文件头。 */}
<Row label="急迫性" value={`${urgency.toFixed(0)} × 0.4`} hint={explainUrgency(breakdown)} />
<Row label="价值性" value={`${value.toFixed(0)} × 0.3`} hint={explainValue(breakdown, signals)} />
<Row label="价值性" value={`${value.toFixed(0)} × 0.3`} hint={explainValue(breakdown)} />
<Row
label="意愿度"
value={`${willing.toFixed(1)} × 0.3`}
......
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