Commit 1966d351 by luoqi

fix(plan): 召回池排序键恢复精度 + 「应治未治」改文案为「潜在治疗」

本地这一轮的两处展示层修复(同一批文件有重叠,合成一个提交)。

━━ 一、排序键精度(真 bug)━━━━━━━━━━━━━━━━━━━━━━━━

用户截图:召回池按优先级降序,看到的却是「3.12 → 3.12 → 3.10 → 3.10 → 3.10 → 3.12 → …」。

根因是两个数不同源:
  排序:ORDER BY priority_score DESC, created_at ASC
        而 score = Math.round(raw * 10) —— 0-100 的**整数**
  展示:(raw ?? score/10).toFixed(2) —— 取 breakdown.priority.raw,0-10 的**两位小数**
排序精度比展示精度粗 10 倍 → 同一整数档里塞着一堆不同展示分,档内只能按 created_at
兜底排。测试服实测:priority_score=31 一档有 21 种展示分(3.20~2.59);
全池 126,073 张工单只有 93 个排序档位,却有 849 种展示分。

- priority-scorer:不再取整。raw 先定到展示精度(两位小数),score = 该值 × 10,
  breakdown.raw 复用同一变量 → **score === raw × 10** 成为不变量。
  列本来就是 Float(schema 未动);量纲仍 0-100,queueStats 的 70/40 分档、
  前端进度条 score/100 均不受影响。
- PriorityBar(列表页 + 患者轨):展示改用 score/10,删掉 raw 入参。
  原来「优先读 breakdown.raw、否则 score/10」是双源,正是分叉起点;两处都加了注释。
- 迁移 20260729060000:一次性回填存量。plan-engine 的 unchanged 分支本来就会就地改
  priorityScore,下一轮全量重算(每 2h)能自愈 —— 但在那之前页面所有分会显得"变整"
  (2.74 → 2.70),像精度倒退。用 numeric 运算避免 3.12*10=31.200000000000003 的
  浮点噪声;带「值确实不同」判断,幂等。

━━ 二、文案:应治未治 → 潜在治疗 ━━━━━━━━━━━━━━━━━━━━

只改**展示层**,共 8 处:场景 chip(labels.ts)、话术段落标题(服务端 SECTION_META +
深度档兜底标题 + 前端流式/空态两处 label)、列表与详情的「其余 N 项…」、助手建议问句、mock。

️ SECTION_HEAD_TO_ID 那个 markdown 解析键**一个字没动**,仍是「告知应治未治」——
它匹配的是已落库话术正文里 AI 写下的 `## 告知应治未治`,以及 prompt 当前仍在输出的标题。
改它 = 存量话术全部解析不出 informMissed 段,打开就空白。已在该常量上写了注释说明。
prompt 侧的 {应治未治项} 等约 60 处占位符同样未动(动了要 bump 版本 + 全量重生成)。

━━ 验证(全部本地)━━━━━━━━━━━━━━━━━━━━━━━━━━━━

- 新增 priority-score-precision.spec:锁 score===raw×10;断言「按 score 排序」与
  「按展示值排序」结果一致且单调不增;并在邻近分里自动找出一对「旧口径判等、展示不同」
  的样本,证明新口径分得开。
- recall-suppression.spec 那条 toBe(45) 锁的是被丢掉的精度,改为 raw=4.49 / score=44.9。
- 本地重算 4 条 friday plan:score 31 → 31.2 / 31,与 breakdown.raw 1:1。
- 迁移本地试跑 13,473 reason + 7,923 plan;复跑 0 行(幂等);
  「排序键 ≠ 展示值×10」的行数 0;页面分数从 2.70 恢复 2.74。
- 文案:特意找一条**正文仍是老标题**的存量话术挂到当前 plan 上验证 —— chip 显示
  「潜在治疗」、段落标题「告知潜在治疗」、且段落内容完整渲染(老正文照样解析得出)。
- 654 tests / 42 suites 全过;@pac/service 与 @pac/web typecheck 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 4bd77113
-- 数据回填 —— 把排序键 priority_score 从「0-100 整数」还原成与展示同精度的「0-100 一位小数」
--
-- 【症状】召回池按优先级降序排,客服看到的却是「3.12 → 3.12 → 3.10 → 3.10 → 3.10 → 3.12 → …」,
-- 像根本没排序。2026-07-29 用户截图报的。
--
-- 【根因】排序键 followup_plans.priority_score 由 priority-scorer 写入,当时是
-- score = Math.round(raw * 10) → 0-100 的**整数**
-- 而列表展示的是 breakdown.priority.raw 的**两位小数**(0-10)。
-- 排序精度比展示精度粗 10 倍 → 同一个整数档里塞着一堆不同的展示分,档内只能按
-- created_at 兜底排,于是看起来乱序。
-- 测试服实测:priority_score=31 一档里有 21 种展示分(3.20~2.59);
-- 全池 126,073 张工单只有 93 个排序档位,却有 849 种展示分。
--
-- 【代码侧已修】priority-scorer 改为 score = round(raw,2) * 10 —— 与展示 1:1。
-- 列本来就是 Float(schema 未变),量纲仍是 0-100(queueStats 的 70/40 分档、
-- 前端进度条 score/100 都不受影响)。
--
-- 【为什么还要这条回填】存量行的 score 仍是旧整数。plan-engine 的 unchanged 分支确实会
-- 就地改 priorityScore(`if (latest.priorityScore !== newPriorityScore) patch...`),
-- 所以下一轮全量重算(每 2 小时)能自愈 —— 但在那之前,页面上所有分都会显得"变整"
-- (如 2.74 → 2.70),看着像精度倒退。这条把它一次性补齐,消除过渡期。
--
-- 【口径】breakdown.priority.raw 就是当初算出来的两位小数原值,乘 10 即得新排序键。
-- 用 numeric 运算(不是 float)避免 3.12*10=31.200000000000003 这种噪声。
-- 没有 raw 的老行(早期 reason 没落 breakdown)保持原值不动,等重算自然覆盖。
--
-- 【幂等】WHERE 带「值确实不同」判断,重复执行为空跑。
-- 【代价】生产 ~206k plan / ~25 万 reason,两条 UPDATE,秒级。
-- 1) reason 级:排序键 = breakdown.priority.raw × 10
UPDATE "plan_reasons" r
SET "priority_score" = round((r."breakdown" -> 'priority' ->> 'raw')::numeric * 10, 1)::double precision
WHERE r."breakdown" -> 'priority' ->> 'raw' IS NOT NULL
AND r."priority_score"
IS DISTINCT FROM round((r."breakdown" -> 'priority' ->> 'raw')::numeric * 10, 1)::double precision;
-- 2) plan 级:= MAX over 它的 reason(与 plan-engine 的 newPriorityScore 同口径)
UPDATE "followup_plans" p
SET "priority_score" = m."mx"
FROM (SELECT "plan_id", MAX("priority_score") AS "mx" FROM "plan_reasons" GROUP BY "plan_id") m
WHERE m."plan_id" = p."id"
AND p."priority_score" IS DISTINCT FROM m."mx";
......@@ -76,7 +76,7 @@ export function draftOutputToDeep(out: DraftPlanScriptOutput): DeepDraft {
tone: out.tone,
sections: [
{ title: t?.opening ?? '开场白', markdown: out.opening },
{ title: t?.informMissed ?? '告知应治未治', markdown: out.informMissed },
{ title: t?.informMissed ?? '告知潜在治疗', markdown: out.informMissed },
{ title: t?.reviewAdvice ?? '复查建议', markdown: out.reviewAdvice },
{ title: t?.closing ?? '结束回访语', markdown: out.closing },
],
......
......@@ -604,6 +604,15 @@ function serializeScript(s: {
};
}
/**
* markdown H2 标题 → 段 id 的**解析键**。
*
* ⚠️ 这里必须保持「告知应治未治」,别跟下面 SECTION_META 的展示名一起改成「告知潜在治疗」——
* 它匹配的是**已经落库的话术正文**(plan_scripts.markdown 里 AI 写下的 `## 告知应治未治`),
* 以及 prompt 当前仍在输出的标题。改这里 = 存量话术全部解析不出 informMissed 段,
* 打开就是空白。展示名换词只需改 SECTION_META,解析键跟着 prompt 走。
* 哪天真要改 prompt 输出的标题,这里得**同时认新旧两个键**再切。
*/
const SECTION_HEAD_TO_ID: Record<string, 'opening' | 'informMissed' | 'reviewAdvice' | 'closing'> = {
开场白: 'opening',
告知应治未治: 'informMissed',
......@@ -615,7 +624,7 @@ const SECTION_META: Record<
{ label: string; durationHint: string }
> = {
opening: { label: '开场白', durationHint: '' },
informMissed: { label: '告知应治未治', durationHint: '' },
informMissed: { label: '告知潜在治疗', durationHint: '' },
reviewAdvice: { label: '复查建议', durationHint: '' },
closing: { label: '结束回访语', durationHint: '' },
};
......
......@@ -167,7 +167,21 @@ export function calcPriority(input: PriorityInput): PriorityResult {
const base = urgency * 0.4 + value * 0.3 + willingness * 0.3;
const raw = base * freshness * confidenceFactor; // 老诊断衰减 × 影像AI/建议 降权
const score = Math.max(0, Math.min(100, Math.round(raw * 10)));
// ⭐ score 与 breakdown.raw **同源同精度**,别再取整。
//
// 原来是 `Math.round(raw * 10)` —— 排序键退化成 0-100 的整数,而列表展示的是
// raw 的两位小数(0-10),**排序精度比展示精度粗 10 倍**。后果:同一个整数档里
// 塞着一堆不同的展示分,档内只能按 created_at 兜底排,客服看到的就是
// 「3.12 → 3.10 → 3.10 → 3.12」这种乱序。
// 2026-07-29 测试服实测:priority_score=31 一档里有 21 种展示分(3.20~2.59);
// 全池 126,073 张工单只有 93 个排序档位、却有 849 种展示分。
//
// 现在:raw 先按展示精度定死两位小数,score = 该值 × 10 → 两者严格 1:1,
// 「排序键 === 展示值 × 10」,档内不再有隐藏差异。列仍是 Float(schema 本来就是),
// 量纲仍是 0-100(queueStats 的 70/40 分档、前端进度条 score/100 全部不受影响)。
const rawDisplay = Math.round(raw * 100) / 100; // 0-10,两位小数 = 列表展示的那个数
const score = Math.max(0, Math.min(100, Math.round(rawDisplay * 1000) / 100));
return {
score,
......@@ -181,7 +195,7 @@ export function calcPriority(input: PriorityInput): PriorityResult {
freshness: Math.round(freshness * 100) / 100,
confidenceFactor,
base: Math.round(base * 100) / 100,
raw: Math.round(raw * 100) / 100,
raw: rawDisplay, // 与 score 同一个数(score = rawDisplay × 10),别各算各的
},
};
}
/**
* 优先级排序键的精度 —— score 必须与列表展示的 raw 同源同精度
*
* 2026-07-29 线上现象:召回池按优先级降序排,客服看到的却是
* 「3.12 → 3.12 → 3.10 → 3.10 → 3.10 → 3.12 → …」,像没排序。
*
* 根因:排序键 `followup_plans.priority_score` 当时是 `Math.round(raw * 10)` —— 0-100 的**整数**,
* 而列表展示的是 `breakdown.priority.raw` 的**两位小数**(0-10)。排序精度比展示精度粗 10 倍,
* 同一个整数档里塞着一堆不同的展示分,档内只能按 created_at 兜底排。
* 测试服实测:priority_score=31 一档里有 21 种展示分(3.20~2.59);
* 全池 126,073 张工单只有 93 个排序档位,却有 849 种展示分。
*
* 不变量:**score === breakdown.raw × 10**。守住它,「按 score 排序」就等价于
* 「按客服看到的那个数排序」,不会再出现档内乱序。
*/
import { calcPriority, type PriorityInput } from '../src/modules/plan/engine/priority-scorer';
const base: PriorityInput = {
urgencyLevel: 'mid',
daysSince: 200,
windowDays: 180,
primaryCode: 'K08',
toothCount: 1,
rfmSegment: 'general_develop',
consultIntentMatch: false,
lifecycleStage: 'reactivate',
hasTreatmentHistory: false,
isReferralChampion: false,
recentVisitGood: false,
confidence: 1,
};
/** 造一批只在"新鲜度"上有细微差别的输入 —— 正是线上同一档内的那种邻近分 */
const NEIGHBOURS: PriorityInput[] = Array.from({ length: 60 }, (_, i) => ({
...base,
daysSince: 150 + i, // 逐天递增:相邻两档的 raw 只差千分位,正是被旧口径压平的那种
}));
describe('优先级分:排序键与展示值同源', () => {
test('不变量:score === raw × 10(逐例)', () => {
for (const input of NEIGHBOURS) {
const { score, breakdown } = calcPriority(input);
// 浮点乘法有噪声,比到分厘即可(展示只到两位小数)
expect(score).toBeCloseTo(breakdown.raw * 10, 6);
}
});
test('按 score 排序 === 按展示值排序(线上那个乱序的正解)', () => {
const rows = NEIGHBOURS.map((input) => {
const { score, breakdown } = calcPriority(input);
return { score, disp: (score / 10).toFixed(2), raw: breakdown.raw };
});
const byScore = [...rows].sort((a, b) => b.score - a.score).map((r) => r.disp);
const byDisp = [...rows].sort((a, b) => Number(b.disp) - Number(a.disp)).map((r) => r.disp);
expect(byScore).toEqual(byDisp);
// 且展示序列本身单调不增 —— 客服眼里不会再出现「3.10 之后又冒出 3.12」
const nums = byScore.map(Number);
for (let i = 1; i < nums.length; i++) expect(nums[i]!).toBeLessThanOrEqual(nums[i - 1]!);
});
test('回归:被旧口径压平的那些分,现在能分开', () => {
const scored = NEIGHBOURS.map(calcPriority);
// 找一对「旧实现会判定相等、展示上本来就不同」的 —— 线上乱序就是这种对造成的
const pair = scored.flatMap((a, i) =>
scored.slice(i + 1).map((b) => ({ a, b })),
).find(({ a, b }) =>
Math.round(a.score) === Math.round(b.score) && a.breakdown.raw !== b.breakdown.raw,
);
expect(pair).toBeDefined(); // 邻近分里必然存在这种对,否则这个 bug 本来就不会发生
expect(pair!.a.score).not.toBe(pair!.b.score); // 新口径必须分得开(旧实现这里相等)
});
test('量纲不变:仍是 0-100,queueStats 的 70/40 分档口径不受影响', () => {
const top = calcPriority({
...base,
urgencyLevel: 'urgent',
primaryCode: 'K08',
toothCount: 3,
rfmSegment: 'important_value',
consultIntentMatch: true,
lifecycleStage: 'mature',
hasTreatmentHistory: true,
isReferralChampion: true,
recentVisitGood: true,
daysSince: 0,
confidence: 1,
});
expect(top.score).toBeGreaterThan(70);
expect(top.score).toBeLessThanOrEqual(100);
const bottom = calcPriority({ ...base, urgencyLevel: null, daysSince: 3000, confidence: 0.5 });
expect(bottom.score).toBeGreaterThanOrEqual(0);
expect(bottom.score).toBeLessThan(40);
});
});
......@@ -132,7 +132,11 @@ describe('priority-scorer v3 | 确定性因子', () => {
expect(r.breakdown.value).toBe(2);
expect(r.breakdown.willingness).toBe(3.63);
expect(r.breakdown.base).toBe(4.49);
expect(r.score).toBe(45); // round(4.4875 × 10)
// score 与展示值同源:raw 先定到两位小数(4.4875 → 4.49),score = 该值 × 10。
// 2026-07-29 前这里是 round(4.4875 × 10) = 45 —— 排序键被取整,比展示精度粗 10 倍,
// 召回池档内乱序就是这么来的(见 priority-score-precision.spec.ts)。
expect(r.breakdown.raw).toBe(4.49);
expect(r.score).toBe(44.9);
});
test('种植(K08)价值按牙数分档:单颗8 / 多颗9 / 半口+10', () => {
......
......@@ -46,7 +46,7 @@ const VOICE_INPUT_ENABLED = false;
const EXAMPLES = [
'今日推荐:挑几个该优先跟进的患者,并说明理由',
'查一下患者孙柯的信息、就诊事实和画像',
'现在有哪些应治未治、值得召回的患者?',
'现在有哪些潜在治疗、值得召回的患者?',
];
// ── 工具中文元信息:助手用的是内部英文工具名,界面不展示工具卡,只把"正在做什么"
......
......@@ -16,7 +16,7 @@ const EMPTY_SCRIPT = {
generatedAt: null,
sections: [
{ id: 'opening', label: '开场白', durationHint: '', markdown: '' },
{ id: 'informMissed', label: '告知应治未治', durationHint: '', markdown: '' },
{ id: 'informMissed', label: '告知潜在治疗', durationHint: '', markdown: '' },
{ id: 'reviewAdvice', label: '复查建议', durationHint: '', markdown: '' },
{ id: 'closing', label: '结束回访语', durationHint: '', markdown: '' },
],
......
......@@ -349,7 +349,7 @@ export const mockPlan = {
{
id: 'rsn_1',
scenario: PlanScenario.TREATMENT_INITIATION_RECALL,
scenarioLabel: '应治未治',
scenarioLabel: '潜在治疗',
priorityScore: 92,
lifecycle: 'one_shot',
source: 'algorithm',
......@@ -459,7 +459,7 @@ export const mockScript = {
},
{
id: 'informMissed',
label: '告知应治未治',
label: '告知潜在治疗',
durationHint: '1 分钟',
markdown: `• 上次来检查的时候,李医生注意到您左下大牙有缺失牙的情况
• 缺牙的地方如果不处理,旁边的牙齿可能会慢慢歪掉,时间一长吃东西也不太舒服
......
......@@ -1500,7 +1500,7 @@ function RecallReasonLine({ visibleReasons }: { visibleReasons: PlanReason[] })
</HoverCardTrigger>
<HoverCardContent align="start" sideOffset={6} className="w-80 p-3">
<p className="text-[11px] font-medium text-slate-500 mb-1.5">
其余 {visibleReasons.length - 1}应治未治
其余 {visibleReasons.length - 1}潜在治疗
</p>
<ul className="space-y-1.5 text-[12px] text-slate-700 leading-relaxed">
{visibleReasons.slice(1).map((r) => (
......
......@@ -71,7 +71,7 @@ export interface UseScriptStream {
// 2026-06 重构:4 模块 — opening / informMissed / reviewAdvice / closing
const LABEL_FALLBACK: Record<FixedSectionId, string> = {
opening: '开场白',
informMissed: '告知应治未治',
informMissed: '告知潜在治疗',
reviewAdvice: '复查建议',
closing: '结束回访语',
};
......
......@@ -528,13 +528,14 @@ function OutcomeTag({ outcome }: { outcome: string }) {
}
// ── 优先级(与列表页 PriorityBar 同款:五格条 + 10 分制)────────
function PriorityBar({ score, raw }: { score: number; raw?: number }) {
function PriorityBar({ score }: { score: number }) {
const pct = Math.max(0, Math.min(1, score / 100));
const filled = Math.max(1, Math.round(pct * 5));
const colors = ['bg-emerald-400', 'bg-emerald-500', 'bg-amber-400', 'bg-amber-500', 'bg-rose-500'];
const labelTone =
pct >= 0.6 ? (pct >= 0.8 ? 'text-rose-700' : 'text-amber-700') : pct >= 0.2 ? 'text-emerald-700' : 'text-slate-500';
const disp = (raw ?? score / 10).toFixed(2);
// 同列表页:展示值 = 排序键 / 10,别再读 breakdown.raw(见 plans-list-app 注释)
const disp = (score / 10).toFixed(2);
return (
<span className="inline-flex flex-none items-center gap-1" title={`优先级 ${disp} / 10`}>
<span className="inline-flex items-center gap-0.5">
......@@ -595,7 +596,7 @@ function PatientRow({
<span className="ml-auto flex-none">
<PriorityHover score={p.priorityScore} breakdown={breakdown}>
<span className="cursor-help">
<PriorityBar score={p.priorityScore} raw={breakdown?.raw} />
<PriorityBar score={p.priorityScore} />
</span>
</PriorityHover>
</span>
......
......@@ -810,10 +810,7 @@ function PatientPlanCard({
breakdown={(p.reasons[0]?.breakdown as { priority?: PriorityBreakdown } | null | undefined)?.priority}
>
<span className="inline-flex items-center gap-1 cursor-help group/score">
<PriorityBar
score={p.priorityScore}
raw={(p.reasons[0]?.breakdown as { priority?: PriorityBreakdown } | null | undefined)?.priority?.raw}
/>
<PriorityBar score={p.priorityScore} />
<svg className="w-3 h-3 text-slate-400 opacity-0 group-hover/score:opacity-100 transition-opacity" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" strokeLinecap="round" />
......@@ -845,7 +842,7 @@ function PatientPlanCard({
</HoverCardTrigger>
<HoverCardContent align="start" sideOffset={6} className="w-80 p-3">
<p className="mb-1.5 text-[11px] font-medium text-slate-500">
其余 {p.reasons.length - 1}应治未治
其余 {p.reasons.length - 1}潜在治疗
</p>
<ul className="space-y-1.5 text-[12px] leading-relaxed text-slate-700">
{p.reasons.slice(1).map((r) => (
......@@ -914,7 +911,7 @@ function PatientPlanCard({
// 原子组件
// ─────────────────────────────────────────────────────────────────
function PriorityBar({ score, raw }: { score: number; raw?: number }) {
function PriorityBar({ score }: { score: number }) {
const pct = Math.max(0, Math.min(1, score / 100));
const filled = Math.max(1, Math.round(pct * 5));
const colors = ['bg-emerald-400', 'bg-emerald-500', 'bg-amber-400', 'bg-amber-500', 'bg-rose-500'];
......@@ -924,8 +921,11 @@ function PriorityBar({ score, raw }: { score: number; raw?: number }) {
: pct >= 0.4 ? 'text-amber-700'
: pct >= 0.2 ? 'text-emerald-700'
: 'text-slate-500';
// 10 分制(保留两位小数):优先 breakdown.raw,无则 score/10
const disp = (raw ?? score / 10).toFixed(2);
// 10 分制(两位小数)= 排序键 score / 10。
// ⚠️ 不要改回读 breakdown.raw:那会让「显示的数」和「排序用的数」分成两条源,
// 2026-07-29 就是这么出的乱序 bug(排序用 0-100 整数、显示用 0-10 两位小数)。
// 现在 score 由 priority-scorer 保证 = raw × 10,除以 10 即还原展示值,单一真理源。
const disp = (score / 10).toFixed(2);
return (
<span
className="inline-flex flex-none items-center gap-1"
......
......@@ -25,7 +25,7 @@ export type Tone =
export const PLAN_SCENARIO_LABEL: Record<string, string> = {
[PlanScenario.TREATMENT_AFTERCARE_RECALL]: '疗效保障',
[PlanScenario.TREATMENT_INITIATION_RECALL]: '应治未治',
[PlanScenario.TREATMENT_INITIATION_RECALL]: '潜在治疗',
treatment_chain_disruption_recall: '链中断', // 待业务拍板,暂未入 enum
missed_diagnosis_recall: '漏诊召回', // W6+ 影像 AI
};
......
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