Commit cccb88e8 by luoqi

fix(召回): 残根/残冠的目标类目改外科 —— 与分配的「拔牙治疗」对齐

孙海燕 TS0M012582 牙16;18;28;47 是残根,分配矩阵已正确落在「拔牙治疗」列
(POTENTIAL_LABEL_RULES: K03 + EXTRACTION_NAME_KEYWORDS → extraction),
卡片上的目标标签却是「充填 / 嵌体」—— 残根没法充填。

根因:focusCategory = recCats[0],而 K03 类目序固定为
[restorative, prosthodontic, surgical],同码内不区分「补得回来」(楔状缺损/
牙体缺损)与「补不回来」(残根/残冠)。跟 K00 那次(乳牙早失被打「目标·外科」)
是同一形状的问题。

做法:把 refineCategoriesForDiagnosis 从 K00 专用改成表驱动
(LEAD_CATEGORY_RULES_BY_CODE),新增 K03_LEAD_CATEGORY_RULES。
 词表直接复用 EXTRACTION_NAME_KEYWORDS —— 分配标签与召回目标同源,
不会再出现「进了拔牙列、卡片写充填」这种自相矛盾。

只挪位不增删,排除闸不受影响;K03 无 implant,年龄闸空转不会挪回去。
前端 reason-line / 目标标签共用同一函数,自动跟随。
存量 plan 靠 reason-refresh 的 unchanged 就地刷新拿到新 focusCategory,
不升版本、不丢认领。

测试:新增 22 条(含 K00 回归 + 标签⟺目标同源断言),全量 1487 条通过;
type-check 17 个错为 main 既有,本次引入 0 个。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 4d9c6f13
/**
* refineCategoriesForDiagnosis —— K03 同码内主类目重排(残根/残冠 → 外科)。
*
* 守的是**「分配的潜在治疗项目」与「卡片上的目标」必须对应**这条口径:
* 分配矩阵把残根患者放进「拔牙治疗」列(POTENTIAL_LABEL_RULES: K03 + 含词 → extraction),
* 卡片的「目标 · X」就不能写「充填 / 嵌体」。两处共用 EXTRACTION_NAME_KEYWORDS 同一份词表。
*
* 跑:
* pnpm test -- refine-categories-k03
*/
import {
refineCategoriesForDiagnosis,
recommendedCategoriesForAge,
DiagnosisTreatmentMap,
EXTRACTION_NAME_KEYWORDS,
classifyCodeToLabel,
} from '@pac/types';
/** K03 在字典里的候选类目(单一真理源,不在测试里硬编码顺序) */
const K03_CATS = DiagnosisTreatmentMap.K03!.categories as readonly string[];
/** 复现 scenario 里 focusCategory 的算法:语义重排 → 年龄重排 → 取首项 */
const focusOf = (code: string, nameZh: string, age: number | null): string | null =>
recommendedCategoriesForAge(refineCategoriesForDiagnosis(code, nameZh, K03_CATS), age)[0] ?? null;
describe('refineCategoriesForDiagnosis · K03', () => {
it('K03 候选类目本身含 surgical(否则重排无处可挪)', () => {
expect(K03_CATS).toContain('surgical');
expect(K03_CATS).toContain('restorative');
});
describe('残根 / 残冠 → 外科打头', () => {
it.each(EXTRACTION_NAME_KEYWORDS)('「%s」→ surgical', (name) => {
expect(refineCategoriesForDiagnosis('K03', name, K03_CATS)[0]).toBe('surgical');
});
it('66 岁残根患者(孙海燕 TS0M012582 牙16;18;28;47)目标 = 外科,不是充填', () => {
expect(focusOf('K03', '残根', 66)).toBe('surgical');
});
it('年龄闸不会把它挪回去(K03 无 implant,recommendedCategoriesForAge 空转)', () => {
for (const age of [8, 18, 40, 66, 88, null]) {
expect(focusOf('K03', '残冠', age)).toBe('surgical');
}
});
});
describe('⛔ 只挪位,不增删', () => {
it('返回集合恒等于入参集合', () => {
const out = refineCategoriesForDiagnosis('K03', '残根', K03_CATS);
expect([...out].sort()).toEqual([...K03_CATS].sort());
});
it('其余相对顺序保持', () => {
const out = refineCategoriesForDiagnosis('K03', '残根', K03_CATS);
const rest = out.filter((c) => c !== 'surgical');
expect(rest).toEqual(K03_CATS.filter((c) => c !== 'surgical'));
});
});
describe('不该动的不动', () => {
it('楔状缺损 / 牙体缺损 → 保持原序(补得回来)', () => {
expect(refineCategoriesForDiagnosis('K03', '牙齿楔状缺损', K03_CATS)).toEqual(K03_CATS);
expect(refineCategoriesForDiagnosis('K03', '牙体缺损', K03_CATS)).toEqual(K03_CATS);
expect(focusOf('K03', '牙齿楔状缺损', 66)).toBe('restorative');
});
it('诊断名为空 → 原样返回(历史数据行为不变)', () => {
expect(refineCategoriesForDiagnosis('K03', null, K03_CATS)).toEqual(K03_CATS);
expect(refineCategoriesForDiagnosis('K03', ' ', K03_CATS)).toEqual(K03_CATS);
});
it('别的码不受影响', () => {
const k08 = DiagnosisTreatmentMap.K08!.categories as readonly string[];
expect(refineCategoriesForDiagnosis('K08', '残根', k08)).toEqual(k08);
expect(refineCategoriesForDiagnosis(null, '残根', K03_CATS)).toEqual(K03_CATS);
});
});
describe('K00 原有行为回归(表驱动改写后不能退化)', () => {
const k00 = DiagnosisTreatmentMap.K00!.categories as readonly string[];
it.each([
['乳牙滞留', 'surgical'],
['乳牙早失', 'orthodontic'],
['萌出障碍', 'orthodontic'],
['先天缺牙', 'prosthodontic'],
['釉质发育不全', 'prosthodontic'],
])('K00「%s」→ %s 打头', (name, lead) => {
expect(refineCategoriesForDiagnosis('K00', name, k00)[0]).toBe(lead);
});
});
describe('⭐ 标签与目标同源(本次修复的核心口径)', () => {
it.each(EXTRACTION_NAME_KEYWORDS)('「%s」:分配标签 extraction ⟺ 目标 surgical', (name) => {
expect(classifyCodeToLabel('K03', name, 66)).toBe('extraction');
expect(focusOf('K03', name, 66)).toBe('surgical');
});
it('非拔除类 K03:标签 restoration ⟺ 目标 restorative', () => {
expect(classifyCodeToLabel('K03', '牙齿楔状缺损', 66)).toBe('restoration');
expect(focusOf('K03', '牙齿楔状缺损', 66)).toBe('restorative');
});
});
});
...@@ -10,6 +10,9 @@ ...@@ -10,6 +10,9 @@
* 详见 `apps/pac-docs/content/docs/architecture/data-ingestion.mdx` §三。 * 详见 `apps/pac-docs/content/docs/architecture/data-ingestion.mdx` §三。
*/ */
import { z } from 'zod'; import { z } from 'zod';
// ⭐ 单一真理源:「该拔不是该补」的诊断词表 —— 分配矩阵的 extraction 标签与
// 召回目标的 surgical 主类目共用同一份词,否则「潜在治疗项目」和「目标」会各说各话。
import { EXTRACTION_NAME_KEYWORDS } from './potential-label-rules';
// ============================================================= // =============================================================
// 诊断码(PACDiagnosisCode)— 借 ICD-10 K00-K14 大类 + 业务码 + 推荐码 // 诊断码(PACDiagnosisCode)— 借 ICD-10 K00-K14 大类 + 业务码 + 推荐码
...@@ -831,12 +834,45 @@ export const K00_LEAD_CATEGORY_RULES: ReadonlyArray<{ ...@@ -831,12 +834,45 @@ export const K00_LEAD_CATEGORY_RULES: ReadonlyArray<{
]; ];
/** /**
* 诊断词细分建议类目(目前只作用于 K00)—— 排在年龄适配**之前**的一道语义重排。 * K03 主类目细分(**同码内重排,不改诊断码**)。
*
* 背景(2026-08 孙海燕 TS0M012582 牙16;18;28;47):K03「牙体硬组织其他疾病」同样是大口袋 ——
* 楔状缺损 / 牙体缺损(补得回来,restorative)与 残根 / 残冠(补不回来,只能拔,surgical)
* 共用一份固定类目序 `[restorative, prosthodontic, surgical]` → focusCategory 恒为
* restorative,给残根患者打「目标 · 充填 / 嵌体」—— 残根是没法充填的。
*
* ⚠️ 词表**复用 [[EXTRACTION_NAME_KEYWORDS]]** —— 那张表已经在驱动分配矩阵的
* `K03 + 含词 → extraction(拔牙治疗)` 标签。两处同源,标签与目标才不会打架:
* 分配把人放进「拔牙治疗」列,卡片却写「目标 · 充填」,客服无从判断该说什么。
* ⚠️ 与 K00 同边界:只挪位,返回集合恒等于入参集合,排除闸不受影响。
*/
export const K03_LEAD_CATEGORY_RULES: ReadonlyArray<{
pattern: RegExp;
lead: string;
why: string;
}> = [
{
pattern: new RegExp(EXTRACTION_NAME_KEYWORDS.join('|')),
lead: 'surgical',
why: '残根 / 残冠补不回来,临床路径是拔除(或根管+桩核冠保留,仍非充填)',
},
];
/// 诊断码 → 同码内主类目重排规则(表驱动;未列的码原样返回)。
const LEAD_CATEGORY_RULES_BY_CODE: Readonly<
Record<string, ReadonlyArray<{ pattern: RegExp; lead: string; why: string }>>
> = {
K00: K00_LEAD_CATEGORY_RULES,
K03: K03_LEAD_CATEGORY_RULES,
};
/**
* 诊断词细分建议类目(K00 / K03,表驱动)—— 排在年龄适配**之前**的一道语义重排。
* *
* 顺序:`refineCategoriesForDiagnosis`(临床语义)→ `recommendedCategoriesForAge`(年龄)。 * 顺序:`refineCategoriesForDiagnosis`(临床语义)→ `recommendedCategoriesForAge`(年龄)。
* 前者定"这个病该往哪个方向治",后者定"这个岁数该先讲哪个"。两道都只挪位。 * 前者定"这个病该往哪个方向治",后者定"这个岁数该先讲哪个"。两道都只挪位。
* *
* @param code 诊断码(非 K00 一律原样返回) * @param code 诊断码(不在 LEAD_CATEGORY_RULES_BY_CODE 里的一律原样返回)
* @param nameZh 诊断原文词(DW diag message,如「乳牙早失」);空 → 原样返回 * @param nameZh 诊断原文词(DW diag message,如「乳牙早失」);空 → 原样返回
* @param categories 该诊断的候选治疗类目 * @param categories 该诊断的候选治疗类目
*/ */
...@@ -845,10 +881,11 @@ export function refineCategoriesForDiagnosis( ...@@ -845,10 +881,11 @@ export function refineCategoriesForDiagnosis(
nameZh: string | null | undefined, nameZh: string | null | undefined,
categories: readonly string[], categories: readonly string[],
): readonly string[] { ): readonly string[] {
if (code !== 'K00') return categories; const rules = code ? LEAD_CATEGORY_RULES_BY_CODE[code] : undefined;
if (!rules) return categories;
const name = (nameZh ?? '').trim(); const name = (nameZh ?? '').trim();
if (!name) return categories; if (!name) return categories;
const hit = K00_LEAD_CATEGORY_RULES.find((r) => r.pattern.test(name)); const hit = rules.find((r) => r.pattern.test(name));
if (!hit || !categories.includes(hit.lead)) return categories; if (!hit || !categories.includes(hit.lead)) return categories;
return [hit.lead, ...categories.filter((c) => c !== hit.lead)]; return [hit.lead, ...categories.filter((c) => c !== hit.lead)];
} }
......
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