Commit 1a3916a5 by luoqi

fix(召回): 义齿假缺失收紧 —— 限同一份病历,且不整颌铺开

上一版有两处过宽,都会把该召的不召(而少召不报错):

🔴 ① 整颌铺开会盖掉同颌里义齿没覆盖到的缺牙位。
   上颌局部义齿只补了 14;15;16,而 24 也缺着没补 —— 整颌一刀会把 24 一起解掉。
   改为:句子按颌**定范围**,牙位仍取**该条目自己的** tooth_position,不再铺满整颌。
   两例待定个案仍成立:王志荣条目 28 颗 ∩ 下颌 = 他的召回牙位;
   童然夫条目 17 颗 ∩ 上颌 = 他的召回牙位(13;16;17;21~27)。

🔴 ② 作用域从「信号当次或之后的任意病历」收紧到「**信号诊断那一份病历**」。
   同一份里"诊断说缺失、检查说有义齿"是医生同一时刻写下的,用后者补前者没争议;
   跨次就多了一层"这中间会不会变了"的推断 —— 与「最新诊断第一位」这个大前提不符。
   实现:adx.content->>'emr_external_id' = sig.content->>'source_encounter_external_id'。
   (已验:王志荣/童然夫的义齿句都与各自的信号诊断同属一份病历。)

影响面 541 → 141 条 / 140 患者(降 74%,是收紧应有的代价)。

测试补 4 例锁住"不越界":条目跨颌时只解句中点名那一颌、只提上颌时下颌牙位不得被解、
不得铺到条目之外。100 suites / 1777 tests,tsc 0 error。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent aac4e249
......@@ -25,8 +25,8 @@ import {
ARCH_DENTURE_UPPER_RE,
ARCH_DENTURE_LOWER_RE,
ARCH_DENTURE_INTENT_EXCLUDE_RE,
UPPER_ARCH_FDI,
LOWER_ARCH_FDI,
UPPER_ARCH_FIRST_DIGITS_RE,
LOWER_ARCH_FIRST_DIGITS_RE,
} from '@pac/types';
/**
......@@ -381,22 +381,23 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
? Prisma.sql`
UNION
SELECT adt AS t
FROM (
SELECT bool_or((ade->>'message') ~ ${ARCH_DENTURE_UPPER_RE}) AS has_up,
bool_or((ade->>'message') ~ ${ARCH_DENTURE_LOWER_RE}) AS has_low
FROM patient_facts adx
CROSS JOIN LATERAL jsonb_array_elements((adx.content->>'exam_findings')::jsonb) AS ade
CROSS JOIN unnest(${toothArrSql(Prisma.sql`ade->>'toothPosition'`)}) AS adt
WHERE adx.patient_id = p.id
AND adx.type = 'emr_record' AND adx.status IN ('active', 'fulfilled')
AND adx.content->>'exam_findings' ~ '^\\['
AND adx.content->>'exam_findings' ~ '义齿|假牙'
-- 🔴 只认**信号诊断那一份病历**:同一份里医生同时写下"缺失"与"有义齿",用后者补前者
-- 没争议;跨次就多一层"这中间会不会变了"的推断。
AND adx.content->>'emr_external_id' = sig.content->>'source_encounter_external_id'
AND (ade->>'message') !~ ${ARCH_DENTURE_INTENT_EXCLUDE_RE}
${afterDxFor('adx')}
) adarch
CROSS JOIN LATERAL unnest(
(CASE WHEN adarch.has_up THEN ${[...UPPER_ARCH_FDI]}::text[] ELSE ARRAY[]::text[] END)
|| (CASE WHEN adarch.has_low THEN ${[...LOWER_ARCH_FDI]}::text[] ELSE ARRAY[]::text[] END)
) AS adt`
-- 🔴 句子按颌定范围,牙位仍取该条目自己的 —— **不整颌铺开**,否则会盖掉同颌里
-- 义齿没覆盖到的缺牙位(上颌局部义齿只补 14;15;16,而 24 也缺着 → 24 被误销)。
AND (
((ade->>'message') ~ ${ARCH_DENTURE_UPPER_RE} AND adt ~ ${UPPER_ARCH_FIRST_DIGITS_RE})
OR
((ade->>'message') ~ ${ARCH_DENTURE_LOWER_RE} AND adt ~ ${LOWER_ARCH_FIRST_DIGITS_RE})
)`
: Prisma.empty;
const resolvedTeethSql = Prisma.sql`
......
......@@ -15,8 +15,8 @@ import {
ARCH_DENTURE_UPPER_RE,
ARCH_DENTURE_LOWER_RE,
ARCH_DENTURE_INTENT_EXCLUDE_RE,
UPPER_ARCH_FDI,
LOWER_ARCH_FDI,
UPPER_ARCH_FIRST_DIGITS_RE,
LOWER_ARCH_FIRST_DIGITS_RE,
} from '@pac/types';
import { GAP_FLAGS_BY_PRIMARY } from '../src/modules/clinical-gap/potential-treatment-gap.sql';
......@@ -80,18 +80,42 @@ describe('⛔ 不作数', () => {
});
});
describe('表自身自洽', () => {
it('上下颌牙位表各 16 颗且不相交', () => {
expect(UPPER_ARCH_FDI).toHaveLength(16);
expect(LOWER_ARCH_FDI).toHaveLength(16);
expect(UPPER_ARCH_FDI.filter((t) => (LOWER_ARCH_FDI as readonly string[]).includes(t))).toEqual(
[],
describe('🔴 牙位由条目自己给,不整颌铺开', () => {
const UPD = new RegExp(UPPER_ARCH_FIRST_DIGITS_RE);
const LOWD = new RegExp(LOWER_ARCH_FIRST_DIGITS_RE);
/** 模拟 SQL:条目牙位 ∩ 句中点名有义齿的那一颌 */
const resolved = (msg: string, teeth: string[]): string[] => {
if (INTENT.test(msg)) return [];
return teeth.filter((t) => (UP.test(msg) && UPD.test(t)) || (LOW.test(msg) && LOWD.test(t)));
};
it('童然夫:条目 17 颗跨颌,上颌句 → 只解上颌那 10 颗', () => {
const teeth = '13;16;17;21;22;23;24;25;26;27;41;42;45;46;47;31;32'.split(';');
expect(resolved('牙缺失,口内活动义齿修复,上颌义齿卡环紧,不易取戴', teeth)).toEqual(
['13', '16', '17', '21', '22', '23', '24', '25', '26', '27'],
);
});
it('上颌牙位首位是 1/2,下颌是 3/4', () => {
for (const t of UPPER_ARCH_FDI) expect(t[0]).toMatch(/[12]/);
for (const t of LOWER_ARCH_FDI) expect(t[0]).toMatch(/[34]/);
it('🔴 只提上颌 → 下颌牙位不得被解(义齿没覆盖到的那一颌)', () => {
expect(resolved('上颌活动义齿修复', ['14', '15', '16', '36', '37'])).toEqual(['14', '15', '16']);
});
it('🔴 ⛔ 不得铺到条目之外 —— 同颌里义齿没补的缺牙位必须仍能召回', () => {
// 上颌局部义齿只补了 14;15;16;24 也缺着但不在这条记录里 → 24 不该被解
expect(resolved('上颌活动义齿修复', ['14', '15', '16'])).not.toContain('24');
});
it('上下颌句 → 两边都解,但仍限条目内', () => {
expect(resolved('上下颌活动义齿修复', ['15', '16', '36'])).toEqual(['15', '16', '36']);
});
});
describe('表自身自洽', () => {
it('颌 → 牙位首位:上颌 1/2,下颌 3/4', () => {
expect(new RegExp(UPPER_ARCH_FIRST_DIGITS_RE).test('16')).toBe(true);
expect(new RegExp(UPPER_ARCH_FIRST_DIGITS_RE).test('36')).toBe(false);
expect(new RegExp(LOWER_ARCH_FIRST_DIGITS_RE).test('46')).toBe(true);
expect(new RegExp(LOWER_ARCH_FIRST_DIGITS_RE).test('26')).toBe(false);
});
it('🔴 只对缺失牙(K08)开闸', () => {
......
......@@ -706,10 +706,13 @@ export const RESTORATION_IN_PLACE_FAIL_RE = [
* 但那个位置已经被义齿盖住,是**假缺失**:召回说的「缺失牙未**启动**修复」根本不成立,
* 治疗早就做了(而且做的就是义齿)。义齿松了/紧了/该重衬是**维护**,不是没做过。
*
* ── 判据:按颌,不按牙位 ──
* 活动义齿是**整颌跨度**的修复体 —— 判出"这一颌有义齿",那一颌的缺牙位就都被它盖住了。
* 所以不需要逐段判状态,也不需要条目牙位跟召回牙位对上:
* 「上颌…义齿」→ 上排全部缺牙位解除;「下颌…义齿」→ 下排;「上下颌/全口…义齿」→ 两排。
* ── 判据:句子按颌定范围,牙位仍由条目自己给 ──
* 「上颌…义齿」→ 该条目牙位里的**上颌部分**解除;「下颌…义齿」→ 下颌部分;「上下颌/全口」→ 两边。
* 不需要逐段判状态(义齿存在就够),但**不整颌铺开** ——
* 🔴 整颌铺开会盖掉同颌里义齿没覆盖到的缺牙位:上颌局部义齿只补了 14;15;16,
* 而 24 也缺着没补,整颌一刀会把 24 一起解掉 = 该召的不召。用条目自己的牙位兜住。
* 🔴 且只认**信号诊断那一份病历**里的检查所见:同一份里"诊断说缺失、检查说有义齿"是
* 医生同一时刻写的,用检查补诊断没争议;跨次就多了一层"这中间会不会变了"的推断。
* 这比 [[RESTORATION_IN_PLACE_TERMS_RE]] 那条(按条目牙位 + 同颌闸)覆盖面大,
* 且**天然绕开了混合句问题** —— 王志荣 TS0K051842「上下颌吸附性义齿修复,下颌固位可,
* 上颌固位稍差」/ 童然夫 TS0M013276「上颌义齿卡环紧,下颌义齿无法完全就位」这两条
......@@ -730,13 +733,9 @@ export const ARCH_DENTURE_LOWER_RE =
/// 未发生 —— 命中即不作数(「建议上颌活动义齿修复」是还没做)
export const ARCH_DENTURE_INTENT_EXCLUDE_RE = '建议|推荐|拟|打算|要求|考虑|择期|待|计划做|未行|未做';
/// 恒牙 FDI 牙位:上颌 / 下颌。与 sig_teeth 取交集后,实际范围由信号侧的排除闸(乳牙/智齿等)决定。
export const UPPER_ARCH_FDI = [
'18','17','16','15','14','13','12','11','21','22','23','24','25','26','27','28',
] as const;
export const LOWER_ARCH_FDI = [
'48','47','46','45','44','43','42','41','31','32','33','34','35','36','37','38',
] as const;
/// 颌 → 牙位首位数字(FDI:上颌 1x/2x,下颌 3x/4x)。用于把条目牙位按颌切开。
export const UPPER_ARCH_FIRST_DIGITS_RE = '^[12]';
export const LOWER_ARCH_FIRST_DIGITS_RE = '^[34]';
export function resolverCategoriesFor(code: string): readonly PACTreatmentCategory[] {
if (STRUCTURAL_DX_CODES.has(code)) return STRUCTURAL_RESOLVER_CATEGORIES;
......
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