Commit 22b2c95f by luoqi

fix(priority): 「没主动问过」是错的 —— 查不到 ≠ 患者没做过

上一版把 intentBehavior=2 写成「没主动问过」。生产核对下来这话 99.9% 的情况是错的,
22 万个 2 分患者拆开是:

    真的从没咨询过              230 人   0.1%
    咨询过但意向没解析出来   176,578 人    80%
    咨询过、问的是别的项目    43,911 人    20%

而且 80% 那批不是解析 bug —— 237 万条 consultation_record 里 214.8 万条源端
intent/content **全是 null**(DW 没给),再改 parser 也挖不出来。

客服是照着这句开口的(「您之前没问过种植吧?」),说错等于当场翻车。这跟我在画像那边
批评过的「权益身份承诺了数据里没有的保司和日期」是同一个错误。

改为只陈述查到了什么,不替患者下结论:
    8 分 → 「问过这个项目」   (意向命中本次治疗类别,确凿)
    2 分 → 「没查到咨询意向」 (我们没查到,不是他没问)

顺带在 scorer 记下这条数据现实:调权重的人要知道 2 分不代表患者被动,这一维现状更接近
"有意向信号则加分"而非"无意向则扣分",把 2 当负面证据会系统性压低大批患者。

新增 tests/priority-explain-wording.spec.ts 守住红线:低分档文案里不许出现
没主动问过 / 没问过 / 未咨询 / 被动 这类断言患者行为的措辞。
(测试放 pac-service 是因为 pac-web 没有测试环境,而该模块是纯函数零 React 依赖。)

482 tests green · pac-web build 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 4477a8c2
...@@ -148,7 +148,11 @@ function computeWillingness(input: PriorityInput): { ...@@ -148,7 +148,11 @@ function computeWillingness(input: PriorityInput): {
} { } {
// RFM 依从 // RFM 依从
const rfmAdherence = input.rfmSegment ? (RFM_ADHERENCE[input.rfmSegment] ?? 5) : 5; const rfmAdherence = input.rfmSegment ? (RFM_ADHERENCE[input.rfmSegment] ?? 5) : 5;
// 主诉行为:咨询过该类→8;仅诊断被动→2(召回候选已排除"已预约该项目",故无 10 档) // 主诉行为:咨询过该类→8;否则→2(召回候选已排除"已预约该项目",故无 10 档)。
// ⚠️ 调权重的人要知道:2 分**不代表患者被动**,而是"查不到意向"。生产实测 237 万条
// consultation_record 里只有 9.7% 解析出非空 intent_categories —— 其余 214.8 万条源端
// intent/content 全为 null(DW 没给,非解析 bug)。所以这一维现状更接近
// "有意向信号则加分",而不是"无意向则扣分";把 2 当成负面证据会系统性压低大批患者。
const intentBehavior = input.consultIntentMatch ? 8 : 2; const intentBehavior = input.consultIntentMatch ? 8 : 2;
// 信任基础:lifecycle + 同类治疗史 +1 + 转介 +1 + 近1年到诊履约良好 +1(封顶 10) // 信任基础:lifecycle + 同类治疗史 +1 + 转介 +1 + 近1年到诊履约良好 +1(封顶 10)
let trustBase = input.lifecycleStage ? (TRUST_BASE[input.lifecycleStage] ?? 4) : 4; let trustBase = input.lifecycleStage ? (TRUST_BASE[input.lifecycleStage] ?? 4) : 4;
......
import {
explainConfidence,
explainPercentile,
explainUrgency,
explainValue,
explainWillingness,
type PriorityBreakdown,
} from '../../pac-web/src/components/priority-explain';
/**
* 优先级 hover 的逐患者文案回归。
*
* ⚠️ 测试放在 pac-service 是因为 pac-web 没有测试环境,而这个模块是**纯函数、零 React 依赖**
* (PriorityBreakdown 类型也因此从 priority-hover.tsx 挪了出来)。
*
* ── 为什么值得测 ──
* 这些字是客服照着跟患者开口用的,写错比不写更糟。已经踩过一次:
* intentBehavior=2 曾被写成「没主动问过」,而生产实测 22 万个 2 分患者里
* 真的从没咨询过的只有 230 人(0.1%)
* 咨询过但源端 intent/content 全为 null,解析不出意向的 176,578 人(80%)
* 咨询过、有意向但问的是别的项目 43,911 人(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',
...over,
});
describe('⭐ 红线:数据缺失不能说成"患者没做过"', () => {
test('查不到咨询意向 → 只陈述查不到,不替患者下结论', () => {
const text = explainWillingness(bd({ intentBehavior: 2 }));
expect(text).toContain('没查到咨询意向');
// 这些措辞都是在断言患者的行为,而我们只知道"记录里没有"
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('⭐ 价值性带牙数分档(种植的分值就是按牙数抬的,只报名字等于没说)', () => {
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('signals 缺失时退回分档数字,不编造治疗类型', () => {
expect(explainValue(bd({ value: 7 }), null)).toBe('预估收入档 7/10');
});
test('意愿度 = RFM 分群 · 咨询意向 · 生命周期(中文名取自圈人字典)', () => {
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('置信度对应信号来源', () => {
expect(explainConfidence(bd({ confidenceFactor: 1 }))).toBe('来自医生诊断');
expect(explainConfidence(bd({ confidenceFactor: 0.8 }))).toBe('来自医生建议');
expect(explainConfidence(bd({ confidenceFactor: 0.5 }))).toContain('影像');
});
});
describe('池内位置 —— 回答"这分算高还是低"', () => {
test('缺失时不渲染(池太小 / 老响应)', () => {
expect(explainPercentile(null)).toBeNull();
expect(explainPercentile(undefined)).toBeNull();
});
test('⭐ 100 分位不能说成「高于池内 100% 的患者」(他自己也在池里)', () => {
expect(explainPercentile(100)).not.toContain('100%');
expect(explainPercentile(100)).toContain('前 1%');
});
test('各档都给得出话', () => {
for (const p of [0, 15, 45, 75, 92, 99]) {
expect(explainPercentile(p)?.length ?? 0).toBeGreaterThan(0);
}
});
});
...@@ -10,7 +10,7 @@ import { diagnosisCodeNameZh, personaTagLabel } from '@pac/types'; ...@@ -10,7 +10,7 @@ import { diagnosisCodeNameZh, personaTagLabel } from '@pac/types';
* 取值来源尽量**从已有数据推**,不额外存: * 取值来源尽量**从已有数据推**,不额外存:
* 急迫档位 ← urgency 分值(10/7/4/1 一一对应,无歧义) * 急迫档位 ← urgency 分值(10/7/4/1 一一对应,无歧义)
* 治疗类型 ← reason.signals.triggers[].code;牙数 ← signals.toothPosition * 治疗类型 ← reason.signals.triggers[].code;牙数 ← signals.toothPosition
* 主诉命中 ← intentBehavior(8=咨询过 / 2=没有,二值) * 主诉命中 ← intentBehavior(8=咨询记录里问过本项目;2=**没查到**,不等于没问过)
* 只有 RFM 分群和生命周期反推不出来(8 分既可能是重要保持也可能是重要发展), * 只有 RFM 分群和生命周期反推不出来(8 分既可能是重要保持也可能是重要发展),
* 由 scorer 在 breakdown 里带上。 * 由 scorer 在 breakdown 里带上。
*/ */
...@@ -79,9 +79,13 @@ export function explainWillingness(breakdown: PriorityBreakdown): string { ...@@ -79,9 +79,13 @@ export function explainWillingness(breakdown: PriorityBreakdown): string {
// 中文名走圈人字典(单一真理源),不在这儿另抄一张表 // 中文名走圈人字典(单一真理源),不在这儿另抄一张表
const rfm = personaTagLabel('rfm', breakdown.rfmSegment); const rfm = personaTagLabel('rfm', breakdown.rfmSegment);
if (rfm) parts.push(rfm); if (rfm) parts.push(rfm);
// intentBehavior 是二值(8 咨询过 / 2 没有)—— 「主动问过」是最强的意愿信号,一定要说 // intentBehavior 是二值,但**低分那档不能说成"没问过"**。生产实测:22 万个 2 分患者里
// 真的从没咨询过的只有 230 人(0.1%),80% 是咨询过但源数据 intent/content 全为 null
// (214.8 万条如此,不是解析 bug —— DW 压根没给),20% 是问的别的项目。
// 写「没主动问过」等于让客服拿着错事实去开口(「您之前没问过种植吧?」)。
// 只陈述我们查到了什么,不替患者下结论。
if (breakdown.intentBehavior !== undefined) { if (breakdown.intentBehavior !== undefined) {
parts.push(breakdown.intentBehavior >= 8 ? '主动咨询过' : '没主动问过'); parts.push(breakdown.intentBehavior >= 8 ? '问过这个项目' : '没查到咨询意向');
} }
const stage = personaTagLabel('lifecycle_stage', breakdown.lifecycleStage); const stage = personaTagLabel('lifecycle_stage', breakdown.lifecycleStage);
if (stage) parts.push(stage); if (stage) parts.push(stage);
......
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