Commit e0a12c44 by luoqi

fix(召回): 缺失牙位上的裸「抛光」= 修复体在位的证据 —— 牙都拔了,抛的是义齿

李钟瑜 TS0M006971 牙31:
  2025-03-27 拔除 31
  2025-06-26 K08 牙缺失 31 → 活动义齿戴牙 31   ← 真实修复证据
  2026-02-12 复诊重记「牙齿缺失 31」+「抛光 31」← 新信号,把上面的戴牙挡在时间门外
当日唯一能过时间门的证据是那条抛光,而它归 preventive —— 不在结构家族 resolver
里 → 判「缺失牙未启动修复」,误召。

31 号牙 2025 年就拔了,牙不存在,抛光的对象只能是那副义齿。这是逻辑蕴含,不是
统计推断;生产实测也印证:裸抛光落在 K08 缺牙位上共 101 个(患者×牙位),94 个
(93.1%)在同一颗牙找得到 prosthodontic/implant 的 actual 治疗,患者级 98.0%。
(当年 REVIEW_IMPLIES_TREATMENT 上线的同口径是 88.2% / 95.2%。)

三条闸,少一条就会静默误销:
 ① 词形:只收光秃秃一个「抛光」(容尾标点)。生产 36 万条带牙位的含抛光记录里,
    绝大多数是「全口龈上洁治,抛光」这类洗牙流程(periodontic,牙位动辄 28 颗)——
    针对天然牙,放行会把洗过牙的患者所有缺牙位一次性解光;其次是「树脂充填…修整
    抛光」「试戴全瓷冠…抛光」这类别的治疗里的一个步骤,它们本来就带 restorative/
    prosthodontic,本来就解得开。「义齿抛光」同理已是 prosthodontic。真正落单的
    只有裸「抛光」→ preventive 这一支(带牙位 2,405 条)。
    「抛光,涂氟」刻意不收 —— 涂氟是全牙列预防,针对天然牙。
 ② 牙位数 ≤ 6:裸抛光里仍有 56 条挂了 7~32 颗牙,那是洁治语境漏进裸词的。
 ③ 只对 K08 开闸(GAP_FLAGS_BY_PRIMARY.polishImpliesRestoration):牙还在时抛光
    就是抛天然牙除渍,什么也不蕴含。

时间方向沿用 afterDxFor,与治疗排除同口径。
测试 96 suites / 1602 tests 全绿(新增 1 suite / 38 tests);tsc 0 error。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 62d9b71a
......@@ -9,6 +9,7 @@ import {
STRUCTURAL_DX_CODE_LIST,
type DxTreatmentRule,
REVIEW_IMPLIES_TREATMENT,
MISSING_TOOTH_POLISH_EVIDENCE,
} from '@pac/types';
/**
......@@ -31,6 +32,7 @@ export interface GapCfgFlags {
excludeOrthoExtractionSites?: boolean; // §E 正畸减数位(外科拔除 + 正畸语境)折进 resolved
excludeCongenitalName?: boolean; // §E name 含"先天" → 正畸统筹,不自动召修复
deferToToothDx?: boolean; // 「建议拔除」场景专用:有同牙编码病种诊断 → 让位给该病种场景(避免双召)
polishImpliesRestoration?: boolean; // 缺失牙专用:缺牙位上的裸「抛光」= 修复体在位的证据(牙都没了,抛的是义齿)
}
/// §E gap 修正 flag 的单一真理源(按 primaryCode 查)。召回 SUB_SCENARIOS 与画像标签都引此。
......@@ -41,6 +43,7 @@ export const GAP_FLAGS_BY_PRIMARY: Record<string, GapCfgFlags> = {
excludeThirdMolar: true,
excludeOrthoExtractionSites: true,
excludeCongenitalName: true,
polishImpliesRestoration: true,
},
// 「建议拔除」独立场景:有同牙编码病种诊断 → 让位给病种场景,不重复召(残根=K03、阻生=K01…)
EXTRACTION_RECOMMENDED: {
......@@ -232,6 +235,28 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
${afterDxFor('rvx')}`
: Prisma.empty;
// (a'''''') 缺失牙位上的裸「抛光」= 修复体/种植体在位的**证据**(见 [[MISSING_TOOTH_POLISH_EVIDENCE]])。
// 李钟瑜 TS0M006971 牙31:2025 拔除 → 2025-06 活动义齿戴牙 → 2026-02 复诊重记「牙齿缺失 31」,
// 真实修复证据早于新诊断被时间门挡下,当日唯一证据「抛光 31」却归 preventive → 误召。
// 牙都拔了,抛的只能是那副义齿。生产实测同牙修复/种植吻合 93.1%。
// ⛔ 只收裸词(洁治流程/充填冠修复里的"修整抛光"分别由 periodontic/本类目自解),
// 且牙位数 ≤ maxTeeth(超过 = 洁治语境漏进裸词),且**仅 K08 开闸**(牙还在时抛光=抛天然牙)。
const polishImpliesBranch = cfgFlags.polishImpliesRestoration
? Prisma.sql`
UNION
SELECT plt AS t
FROM patient_facts plx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`plx.content->>'tooth_position'`)}) AS plt
WHERE plx.patient_id = p.id
AND plx.type = 'treatment_record' AND plx.kind = 'actual'
AND plx.status IN ('active', 'fulfilled')
AND plx.content->>'category' = ${MISSING_TOOTH_POLISH_EVIDENCE.category}
AND plx.content->>'subtype' ~ ${MISSING_TOOTH_POLISH_EVIDENCE.subtypePattern}
AND COALESCE(array_length(${toothArrSql(Prisma.sql`plx.content->>'tooth_position'`)}, 1), 0)
BETWEEN 1 AND ${MISSING_TOOTH_POLISH_EVIDENCE.maxTeeth}
${afterDxFor('plx')}`
: Prisma.empty;
const resolvedTeethSql = Prisma.sql`
(SELECT COALESCE(array_agg(DISTINCT t), ARRAY[]::text[]) FROM (
-- (a) 治疗家族 resolver(afterDx):同牙诊断后做了 resolverCats 家族里任一治疗
......@@ -330,6 +355,7 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
AND COALESCE(rdx.occurred_at, rdx.planned_for) >= COALESCE(sig.occurred_at, sig.planned_for)
AND sig.type = 'diagnosis_record'
${reviewImpliesBranch}
${polishImpliesBranch}
${orthoExtractBranch}
${deferDxBranch}
) u)`;
......
/**
* MISSING_TOOTH_POLISH_EVIDENCE —— 「缺失牙位上的抛光」作为修复体在位的**证据**。
*
* 蕴含成立的前提是那颗牙**已经不在了**:牙都拔了,抛的只能是义齿/种植冠。
* 所以守三条闸,任何一条松掉都会静默误销召回(少召不报错,一线只会觉得"系统没提醒过"):
* ① 词形闸:只收光秃秃一个「抛光」——「全口龈上洁治,抛光」是洗牙(针对天然牙),
* 「树脂充填…修整抛光」是充填的一个步骤(本来就带 restorative,本来就解得开)
* ② 牙位闸:牙位数 ≤ maxTeeth —— 挂满一口牙的裸抛光是洁治语境漏进来的
* ③ 场景闸:只对缺失牙(K08)开 —— 牙还在时抛光就是抛天然牙除渍,什么也不蕴含
*
* 正则用 PG 语义书写(\s 在 PG ARE 与 JS 等价),这里用 JS RegExp 校验词形。
*
* 跑:
* pnpm test -- polish-implies-restoration
*/
import { MISSING_TOOTH_POLISH_EVIDENCE } from '@pac/types';
import { GAP_FLAGS_BY_PRIMARY } from '../src/modules/clinical-gap/potential-treatment-gap.sql';
const RE = new RegExp(MISSING_TOOTH_POLISH_EVIDENCE.subtypePattern);
const hits = (subtype: string) => RE.test(subtype);
describe('MISSING_TOOTH_POLISH_EVIDENCE · ① 词形闸(收)', () => {
it.each([
['抛光'],
['抛光。'],
['抛光.'],
['抛光,'],
['抛光,'],
['抛光;'],
['抛光、'],
[' 抛光 '],
['\n抛光\n'],
])('收:%s', (subtype) => {
expect(hits(subtype)).toBe(true);
});
});
describe('MISSING_TOOTH_POLISH_EVIDENCE · ① 词形闸(不收)', () => {
it.each([
// 洁治流程 —— 针对天然牙,放行会把洗过牙的患者所有缺牙位一次性解光
['全口龈上洁治,抛光'],
['全口龈上洁治,抛光。'],
['全口洁治,抛光'],
['龈上洁治+抛光'],
['全口超声洁治,抛光。'],
['洁牙,抛光,口腔卫生宣教'],
// 涂氟 —— 全牙列预防处置,针对天然牙,不指向修复体
['抛光,涂氟'],
['抛光涂氟'],
['抛光+涂氟'],
['抛光 涂氟'],
['全口抛光涂氟'],
// 别的治疗里的一个步骤 —— 本来就带对的类目,本来就解得开
['去净腐质,酒消毒牙面,酸蚀,冲洗干燥,粘接剂涂布,树脂充填,修整抛光,嘱充填后注意事项。'],
['去腐净,GIC垫,Z250充填,调合抛光。'],
['试戴全瓷冠,精确就位,调整邻面接触点及咬合至合适,抛光,富士I玻璃离子粘固。'],
['去除愈合基台,上氧化锆基台一体冠,中心螺丝加力至35N/cm,暂封,调合抛光'],
// 义齿类 —— 已经是 prosthodontic,不该也不需要从本表走
['义齿抛光'],
['活动义齿抛光'],
['上颌种植义齿调合抛光。'],
['义齿组织面调磨抛光'],
// 全口语境的裸词变体
['全口抛光'],
['全口牙列抛光,清洁牙面,轻干燥,全牙列涂布氟保护漆。'],
// 空 / 无关
[''],
[' '],
['调磨'],
])('不收:%s', (subtype) => {
expect(hits(subtype)).toBe(false);
});
});
describe('MISSING_TOOTH_POLISH_EVIDENCE · ② 牙位闸', () => {
it('maxTeeth 必须是个小数字 —— 针对某颗牙的操作不会写满一口牙', () => {
expect(MISSING_TOOTH_POLISH_EVIDENCE.maxTeeth).toBeGreaterThanOrEqual(1);
expect(MISSING_TOOTH_POLISH_EVIDENCE.maxTeeth).toBeLessThanOrEqual(8);
});
});
describe('MISSING_TOOTH_POLISH_EVIDENCE · ③ 场景闸', () => {
it('只对缺失牙 K08 开闸', () => {
const on = Object.entries(GAP_FLAGS_BY_PRIMARY)
.filter(([, f]) => f.polishImpliesRestoration === true)
.map(([code]) => code);
expect(on).toEqual(['K08']);
});
});
describe('MISSING_TOOTH_POLISH_EVIDENCE · 表自身自洽', () => {
it('只认 preventive —— 别的类目里的抛光都是某治疗的步骤,本来就解得开', () => {
expect(MISSING_TOOTH_POLISH_EVIDENCE.category).toBe('preventive');
});
it('正则可编译且锚定首尾(避免退化成"含抛光即可")', () => {
expect(MISSING_TOOTH_POLISH_EVIDENCE.subtypePattern.startsWith('^')).toBe(true);
expect(MISSING_TOOTH_POLISH_EVIDENCE.subtypePattern.endsWith('$')).toBe(true);
expect(() => new RegExp(MISSING_TOOTH_POLISH_EVIDENCE.subtypePattern)).not.toThrow();
});
it('why 说清蕴含的依据', () => {
expect(MISSING_TOOTH_POLISH_EVIDENCE.why.length).toBeGreaterThan(6);
});
});
......@@ -461,6 +461,42 @@ export const REVIEW_IMPLIES_TREATMENT: ReadonlyArray<{
{ pattern: '牙周[^,,;;]*(复查|复诊)', category: 'periodontic', why: '牙周复查蕴含基础治疗做过' },
];
/**
* 「缺失牙位上的抛光」= 修复体/种植体在位的**证据**(不是治疗本身)。
*
* ── 判据:那颗牙已经不在了,抛的只能是修复体 ──
* 李钟瑜 TS0M006971 牙31:2025-03-27 拔除 → 2025-06-26 活动义齿戴牙 → 2026-02-12 复诊
* 重记「牙齿缺失 31」+「抛光 31」。真实的修复证据(戴牙)早于新诊断,被时间门挡在外面;
* 唯一落在信号当日的证据是那条抛光,而它归 `preventive` —— 不在结构家族 resolver 里 → 误召。
* 31 号牙 2025 年就拔了,**牙不存在,抛光的对象只能是那副义齿** —— 逻辑蕴含,不是统计推断。
*
* 生产验证:裸抛光落在 K08 缺失牙位上共 101 个(患者×牙位),其中 94 个(93.1%)在**同一颗牙**
* 找得到 prosthodontic/implant 的 actual 治疗,患者级 98.0%。剩下 7 个正是本规则要救的
* (外院修复 / 摄入窗口之前 / 修复记录漏牙位 / 像李钟瑜这样被重记诊断挡在时间门外)。
*
* ⛔ **只收"光秃秃一个抛光"**(容尾标点)。生产 36 万条带牙位的含抛光记录里,绝大多数是
* ①「全口龈上洁治,抛光」等洁治流程(periodontic,牙位动辄 28 颗)——针对天然牙,放行会把
* 洗过牙的患者所有缺牙位一次性解光;②「树脂充填…修整抛光」「试戴全瓷冠…抛光」等**别的治疗
* 里的一个步骤** —— 它们本来就带 restorative/prosthodontic 类目,本来就解得开,无须本表。
* 真正落单的只有裸「抛光」→ preventive 这一支(带牙位 2,405 条)。
* ⛔ 「抛光,涂氟 / 抛光+涂氟」**刻意不收** —— 涂氟是全牙列预防处置,针对天然牙,不指向修复体。
* ⛔ **牙位数上限**:裸抛光里仍有 56 条挂了 7~32 颗牙,那是洁治语境漏进裸词的,按"全口"丢弃;
* 针对某颗牙的操作不会一次写满一口牙。
* ⛔ **只对缺失牙(K08)开**:牙还在的时候抛光就是抛天然牙(除渍),什么也不蕴含。
* 由 [[GAP_FLAGS_BY_PRIMARY]] 的 polishImpliesRestoration 闸控制,不是全场景通用。
*
* 消费方:potential-treatment-gap.sql 的 resolvedTeethSql(牙位级)。
*/
export const MISSING_TOOTH_POLISH_EVIDENCE = {
/// PG 正则:归一后恰为「抛光」(容前后空白与尾标点),不含任何其它术语
subtypePattern: '^\\s*抛光\\s*[。.,,;;、]?\\s*$',
/// 只在这个类目下认(别的类目里的抛光都是某个治疗的步骤,本来就解得开)
category: 'preventive' as PACTreatmentCategory,
/// 牙位数上限 —— 超过即视为洁治/全口语境,丢弃
maxTeeth: 6,
why: '牙已缺失,抛光的对象只能是修复体/种植体',
} as const;
export function resolverCategoriesFor(code: string): readonly PACTreatmentCategory[] {
if (STRUCTURAL_DX_CODES.has(code)) return STRUCTURAL_RESOLVER_CATEGORIES;
const rule = lookupDxTreatment(code);
......
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