Commit a37e654e by luoqi

merge: 残根目标类目 + 复查证据分支 → test

parents 0a79ffa2 0d458785
Pipeline #3600 failed in 0 seconds
......@@ -8,6 +8,7 @@ import {
RESTORATION_INELIGIBLE_DX_NAMES,
STRUCTURAL_DX_CODE_LIST,
type DxTreatmentRule,
REVIEW_IMPLIES_TREATMENT,
} from '@pac/types';
/**
......@@ -207,6 +208,30 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
? Prisma.sql`AND COALESCE(sig.content->>'name_zh','') NOT LIKE '%先天%'`
: Prisma.empty;
// (a''''') 「复查/复诊」= 修复体/矫治器在位的**证据**(不是治疗本身)。
// 宋志宏 TS0M001982 牙46;36;37:种植冠在位、按约复查,却被判"缺失牙未启动修复" ——
// 唯一带牙位的证据「种植复查」落在 review 类目,而 review 被刻意排除在 resolver 之外。
// ⛔ 只收 REVIEW_IMPLIES_TREATMENT(治疗词 + 复查/复诊),且**按类目过滤** ——
// 整类 review 放进来会误销:生产带牙位 4.4 万条里「观察」11,165 / 「无治疗」623,
// 那些恰恰是"还没治"。跨类目也不行(牙周复查 ≠ 补了龋)。
// 时间方向同治疗排除(afterDxFor):复查发生在信号之前不算数。
const reviewRules = REVIEW_IMPLIES_TREATMENT.filter((r) =>
(resolverCats as readonly string[]).includes(r.category),
);
const reviewImpliesBranch = reviewRules.length
? Prisma.sql`
UNION
SELECT rvt AS t
FROM patient_facts rvx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`rvx.content->>'tooth_position'`)}) AS rvt
WHERE rvx.patient_id = p.id
AND rvx.type = 'treatment_record' AND rvx.kind = 'actual'
AND rvx.status IN ('active', 'fulfilled')
AND rvx.content->>'category' = 'review'
AND rvx.content->>'subtype' ~ ${reviewRules.map((r) => r.pattern).join('|')}
${afterDxFor('rvx')}`
: Prisma.empty;
const resolvedTeethSql = Prisma.sql`
(SELECT COALESCE(array_agg(DISTINCT t), ARRAY[]::text[]) FROM (
-- (a) 治疗家族 resolver(afterDx):同牙诊断后做了 resolverCats 家族里任一治疗
......@@ -304,6 +329,7 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
AND rdx.content->>'code' = ANY(${[...STRUCTURAL_DX_CODE_LIST]}::text[])
AND COALESCE(rdx.occurred_at, rdx.planned_for) >= COALESCE(sig.occurred_at, sig.planned_for)
AND sig.type = 'diagnosis_record'
${reviewImpliesBranch}
${orthoExtractBranch}
${deferDxBranch}
) u)`;
......
/**
* 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');
});
});
});
/**
* REVIEW_IMPLIES_TREATMENT —— 「复查/复诊」作为修复体在位的**证据**。
*
* 守两条闸,任何一条松掉都会静默误销召回(少召不报错,一线只会觉得"系统没提醒过"):
* ① 词形闸:必须「治疗词 + 复查/复诊」——「正畸复诊」(在做)vs「正畸会诊」(还在谈)只差一字
* ② 类目闸:必须与 resolverCategoriesFor 交集 ——「牙周复查」不能解 K02 龋齿
*
* 正则用 PG 语义书写,这里用 JS RegExp 近似校验词形(两者对本表用到的语法等价)。
*
* 跑:
* pnpm test -- review-implies-treatment
*/
import { REVIEW_IMPLIES_TREATMENT, resolverCategoriesFor } from '@pac/types';
/** 某个 subtype 命中哪些规则 */
const rulesFor = (subtype: string) =>
REVIEW_IMPLIES_TREATMENT.filter((r) => new RegExp(r.pattern).test(subtype));
/** 模拟消费方:该诊断码下,这个 subtype 会不会解除缺口 */
const resolves = (code: string, subtype: string): boolean => {
const cats = resolverCategoriesFor(code) as readonly string[];
return rulesFor(subtype).some((r) => cats.includes(r.category));
};
describe('REVIEW_IMPLIES_TREATMENT · 词形闸', () => {
it.each([
['种植复查', 'implant'],
['牙周复查', 'periodontic'],
['正畸复诊', 'orthodontic'],
['正畸复查', 'orthodontic'],
['保持器复诊', 'orthodontic'],
])('生产实有词「%s」→ %s', (subtype, cat) => {
expect(rulesFor(subtype).map((r) => r.category)).toContain(cat);
});
// ⛔ 这些是生产带牙位 review 里的大头,收进来就是误销
it.each([
'观察', '暂观', '观察,必要时拔除', '暂观,必要时拔除', '随访观察', '观察随访', '随诊观察',
'无治疗', '初诊检查', '检查', '方案沟通', '沟通治疗方案', '听方案', '取资料', '缴费',
'转诊', '请全科医生会诊', '治疗中',
])('「%s」不命中任何规则', (subtype) => {
expect(rulesFor(subtype)).toHaveLength(0);
});
// ⛔ 泛指复查也不收 —— 它不指明治疗对象(宽口径 391 条里的争议区)
it.each(['复查', '常规复查', '定期复查'])('泛指「%s」不命中', (subtype) => {
expect(rulesFor(subtype)).toHaveLength(0);
});
// ⛔ 一字之差,语义相反
it.each(['正畸会诊', '正畸咨询', '正畸检查'])('「%s」不命中(还在谈,没在做)', (subtype) => {
expect(rulesFor(subtype)).toHaveLength(0);
});
});
describe('REVIEW_IMPLIES_TREATMENT · 类目闸', () => {
it('K08 缺失牙 + 种植复查 → 解除(宋志宏 TS0M001982 牙46;36;37)', () => {
expect(resolves('K08', '种植复查')).toBe(true);
});
it('K04 根尖周炎 + 种植复查 → 解除(种了牙就没牙髓了)', () => {
expect(resolves('K04', '种植复查')).toBe(true);
});
// ⛔ 生产实测的 9 条跨类目错配,逐条锁死
it.each([
['K02', '牙周复查', '洗牙不补龋'],
['K03', '牙周复查', '洗牙不修牙体'],
['K08', '牙周复查', '洗牙不修缺牙'],
['K06', '种植复查', 'K06 只认牙周/外科'],
['K01', '正畸复查', 'K01 走结构家族,不含正畸'],
])('%s + %s → 不解除(%s)', (code, subtype) => {
expect(resolves(code, subtype)).toBe(false);
});
});
describe('REVIEW_IMPLIES_TREATMENT · 表自身自洽', () => {
it('每条 pattern 都能编译成正则', () => {
for (const r of REVIEW_IMPLIES_TREATMENT) {
expect(() => new RegExp(r.pattern)).not.toThrow();
}
});
it('每条都写了 why(改表的人得说明临床依据)', () => {
for (const r of REVIEW_IMPLIES_TREATMENT) {
expect(r.why.length).toBeGreaterThan(4);
}
});
it('每条 pattern 都强制要求出现 复查/复诊', () => {
for (const r of REVIEW_IMPLIES_TREATMENT) {
expect(r.pattern).toMatch(/复查\|复诊/);
}
});
});
......@@ -10,6 +10,9 @@
* 详见 `apps/pac-docs/content/docs/architecture/data-ingestion.mdx` §三。
*/
import { z } from 'zod';
// ⭐ 单一真理源:「该拔不是该补」的诊断词表 —— 分配矩阵的 extraction 标签与
// 召回目标的 surgical 主类目共用同一份词,否则「潜在治疗项目」和「目标」会各说各话。
import { EXTRACTION_NAME_KEYWORDS } from './potential-label-rules';
// =============================================================
// 诊断码(PACDiagnosisCode)— 借 ICD-10 K00-K14 大类 + 业务码 + 推荐码
......@@ -418,6 +421,46 @@ const STRUCTURAL_DX_CODES = new Set<string>([
*
* 单一真理源:召回 scenario 的 ⑤a 排除闸 + 牙位事实 oracle 对账 共用此函数,口径不漂移。
*/
/**
* 「复查/复诊」→ 它蕴含的治疗类目(**证据,不是治疗**)。
*
* ── 为什么需要 ──
* 宋志宏 TS0M001982 牙46;36;37 三颗种植冠在位、正在按约复查,却被判「缺失牙未启动修复」。
* 他那次就诊里能证明修复体存在的四处表述(诊断「种植术后」/ 本次治疗「种植复查」/
* 检查所见「种植冠无松动」/ 主诉「种植戴牙术后1年余」)PAC 一处也没接住 ——
* `review` 类目被**刻意**排除在 [[STRUCTURAL_RESOLVER_CATEGORIES]] 之外(见其上方注释)。
* 那个排除是对的:review 是杂物抽屉,生产带牙位的 4.4 万条里「观察」11,165、
* 「观察,必要时拔除」928、「无治疗」623 —— 这些恰恰说明**还没治**,整类放进 resolver 会误销。
*
* ── 判据:复查的**对象**必须先存在 ──
* 「种植复查」蕴含种植体在位,「保持器复诊」蕴含正畸做过 —— 这是逻辑蕴含,不是统计推断。
* 生产验证:带牙位的「种植复查」覆盖 3,893 个牙位,其中 3,434(88.2%)在**同一颗牙**上
* 找得到 implant/prosthodontic 的 actual 治疗;患者级 95.2%。剩下 11.8% 正是本规则要救的
* (外院种的 / 摄入窗口之前 / 治疗记录漏牙位)。
*
* ⛔ **必须同时匹配「治疗词 + 复查/复诊」**:「正畸复诊」(在做正畸)与「正畸会诊」(还在谈)
* 只差一个字,语义相反;生产各 35 / 103 条。光匹配「正畸」会把会诊咨询一起网进来。
* ⛔ **必须按类目匹配**,不能一律放行:生产实测 19 条候选里 9 条是跨类目错配 ——
* 「牙周复查」去解 K02 龋齿(洗牙不补龋,4 条)、「种植复查」去解 K06 牙龈疾患(1 条)、
* 「正畸复查」去解 K01 阻生牙(1 条)。消费方须用 [[resolverCategoriesFor]] 过滤本表。
* ⛔ 「观察 / 暂观 / 无治疗 / 初诊检查 / 方案沟通」等**刻意不收** —— 它们是"还没治"的证据。
*
* 消费方:potential-treatment-gap.sql 的 resolvedTeethSql(牙位级)。
* 生产实测无一条落在 K05/K07 全口场景,故全口路径(gapWhere 的 NOT EXISTS)不接本表。
*/
export const REVIEW_IMPLIES_TREATMENT: ReadonlyArray<{
/// PG 正则(用于 `subtype ~ pattern`)
pattern: string;
/// 命中即视为该类目治疗已完成
category: PACTreatmentCategory;
why: string;
}> = [
{ pattern: '种植[^,,;;]*(复查|复诊)', category: 'implant', why: '种植体不存在就没有种植复查' },
{ pattern: '(修复|冠|桥|义齿|戴牙)[^,,;;]*(复查|复诊)', category: 'prosthodontic', why: '修复体在位才谈得上复查' },
{ pattern: '(正畸|保持器)[^,,;;]*(复查|复诊)', category: 'orthodontic', why: '矫治/保持阶段 ≠ 正畸会诊咨询' },
{ pattern: '牙周[^,,;;]*(复查|复诊)', category: 'periodontic', why: '牙周复查蕴含基础治疗做过' },
];
export function resolverCategoriesFor(code: string): readonly PACTreatmentCategory[] {
if (STRUCTURAL_DX_CODES.has(code)) return STRUCTURAL_RESOLVER_CATEGORIES;
const rule = lookupDxTreatment(code);
......@@ -831,12 +874,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`(年龄)。
* 前者定"这个病该往哪个方向治",后者定"这个岁数该先讲哪个"。两道都只挪位。
*
* @param code 诊断码(非 K00 一律原样返回)
* @param code 诊断码(不在 LEAD_CATEGORY_RULES_BY_CODE 里的一律原样返回)
* @param nameZh 诊断原文词(DW diag message,如「乳牙早失」);空 → 原样返回
* @param categories 该诊断的候选治疗类目
*/
......@@ -845,10 +921,11 @@ export function refineCategoriesForDiagnosis(
nameZh: string | null | undefined,
categories: 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();
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;
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