Commit aac4e249 by luoqi

feat(召回): 「活动义齿 = 假缺失」按颌判定 —— 缺牙误召里最大的一类

戴活动义齿的患者,诊断栏**永远**写「缺失牙」—— 天然牙确实没了,这个诊断是对的。
但那个位置已被义齿盖住,是**假缺失**:召回说的「缺失牙未**启动**修复」根本不成立,
治疗早就做了,而且做的就是义齿。义齿松了/紧了/该重衬是**维护**,不是没做过。

── 判据:按颌,不按牙位 ──
活动义齿是**整颌跨度**的修复体 —— 判出"这一颌有义齿",那一颌的缺牙位就都被它盖住。
  「上颌…义齿」→ 上排全部解除;「下颌…义齿」→ 下排;「上下颌/全口…义齿」→ 两排。
不需要逐段判状态,也不需要条目牙位跟召回牙位对上。

🔴 这条**天然绕开了混合句问题**,而混合句正是前一条(RESTORATION_IN_PLACE_*,按条目
牙位 + 同颌闸)卡住两例待定个案的原因:
  王志荣 TS0K051842「上下颌吸附性义齿修复,下颌固位可,上颌固位稍差」(28 颗跨颌)
  童然夫 TS0M013276「上颌义齿卡环紧,下颌义齿无法完全就位」(17 颗跨颌)
两条检查所见牙位都横跨上下颌 → 同颌闸整条跳过;而句子本身按颌写清了,按颌判直接可用。
此前评估"要先做按颌切分"需要两个新机制(颌作用域粘连 + 存在即在位),按颌判定都不需要。

── 影响面 ──
在跑计划命中 541 条 / 511 患者(前一条按条目判的是 386 条,两者互补:
按条目那条接的是句中不提颌、但条目牙位单颌的写法,如周燕芬「见活动义齿,基托边缘密合」)。
导出复核:命中句几乎全是真「那一颌有义齿」——「上颌活动义齿修复」「上下颌全口义齿,
咬合接触均匀」「上下颌种植临时义齿存」等。状态不好的(卡环折断/固位欠佳/压痛/易脱落)
**按口径同样算已治疗** —— 义齿存在,「未启动」就不成立。

️ 「≤10 字」距离限制是关键:要求颌词紧挨义齿词,否则「上颌见残根……下次考虑做义齿」
会被误连(已写成测试)。 未发生词一票否决:「建议上颌活动义齿修复」是还没做。
️ 同前三条路线:只往 resolved 加牙位、永不新增召回 → 失败方向只有少召且不报错。
 只对缺失牙(K08)开闸。

测试 20 例,含两例待定个案的原句 + 生产真实句 + 距离限制的反例。
100 suites / 1773 tests,tsc 0 error。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 96765e1d
......@@ -22,6 +22,11 @@ import {
RESTORATION_IN_PLACE_STATE_RE,
RESTORATION_IN_PLACE_FAIL_RE,
RESTORATION_IN_PLACE_NEG_STRIP_RE,
ARCH_DENTURE_UPPER_RE,
ARCH_DENTURE_LOWER_RE,
ARCH_DENTURE_INTENT_EXCLUDE_RE,
UPPER_ARCH_FDI,
LOWER_ARCH_FDI,
} from '@pac/types';
/**
......@@ -46,6 +51,7 @@ export interface GapCfgFlags {
deferToToothDx?: boolean; // 「建议拔除」场景专用:有同牙编码病种诊断 → 让位给该病种场景(避免双召)
polishImpliesRestoration?: boolean; // 缺失牙专用:缺牙位上的裸「抛光」= 修复体在位的证据(牙都没了,抛的是义齿)
treatedEvidenceFromEmrText?: boolean; // 本次动作是预防/复查类时,再审同次病历主诉/现病史找"已治疗"证据
archDentureIsRestored?: boolean; // 检查所见判出某颌有活动义齿 → 该颌缺牙位是"假缺失"
restorationInPlaceFromExam?: boolean; // 检查所见(单颌条目)写着修复体在位 → 该牙无新修复需求
}
......@@ -59,6 +65,7 @@ export const GAP_FLAGS_BY_PRIMARY: Record<string, GapCfgFlags> = {
excludeCongenitalName: true,
polishImpliesRestoration: true,
treatedEvidenceFromEmrText: true,
archDentureIsRestored: true,
restorationInPlaceFromExam: true,
},
// 「建议拔除」独立场景:有同牙编码病种诊断 → 让位给病种场景,不重复召(残根=K03、阻生=K01…)
......@@ -365,6 +372,33 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
)`
: Prisma.empty;
// (a''''''''') 「活动义齿 = 假缺失」按**颌**判定(见 [[ARCH_DENTURE_UPPER_RE]])。
// 戴活动义齿的患者诊断栏永远写「缺失牙」—— 天然牙确实没了,诊断没错;但那个位置已被义齿
// 盖住,「未**启动**修复」不成立(治疗做的就是义齿)。义齿松/紧/该重衬是维护,不是没做过。
// 活动义齿是整颌跨度的修复体 → 判出"这一颌有义齿",那一颌的缺牙位全部解除,
// 不需要条目牙位跟召回牙位对上,也天然绕开混合句(王志荣/童然夫那种跨颌描述)。
const archDentureBranch = cfgFlags.archDentureIsRestored
? 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
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 (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`
: Prisma.empty;
const resolvedTeethSql = Prisma.sql`
(SELECT COALESCE(array_agg(DISTINCT t), ARRAY[]::text[]) FROM (
-- (a) 治疗家族 resolver(afterDx):同牙诊断后做了 resolverCats 家族里任一治疗
......@@ -466,6 +500,7 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
${polishImpliesBranch}
${treatedEvidenceBranch}
${restorationInPlaceBranch}
${archDentureBranch}
${orthoExtractBranch}
${deferDxBranch}
) u)`;
......
/**
* 「活动义齿 = 假缺失」按颌判定。
*
* 戴活动义齿的患者诊断栏**永远**写「缺失牙」—— 天然牙确实没了,诊断没错;
* 但那个位置已被义齿盖住,「缺失牙未**启动**修复」不成立(治疗做的就是义齿)。
* 活动义齿是整颌跨度的修复体 → 判出"这一颌有义齿",那一颌的缺牙位全部解除。
*
* 🔴 本条**天然绕开混合句** —— 王志荣/童然夫的检查所见牙位横跨上下颌,
* 按条目判的那条(RESTORATION_IN_PLACE_*)整条跳过,按颌判直接可用。
*
* 跑:
* pnpm test -- arch-denture-false-missing
*/
import {
ARCH_DENTURE_UPPER_RE,
ARCH_DENTURE_LOWER_RE,
ARCH_DENTURE_INTENT_EXCLUDE_RE,
UPPER_ARCH_FDI,
LOWER_ARCH_FDI,
} from '@pac/types';
import { GAP_FLAGS_BY_PRIMARY } from '../src/modules/clinical-gap/potential-treatment-gap.sql';
const UP = new RegExp(ARCH_DENTURE_UPPER_RE);
const LOW = new RegExp(ARCH_DENTURE_LOWER_RE);
const INTENT = new RegExp(ARCH_DENTURE_INTENT_EXCLUDE_RE);
/** 模拟:该句判出哪几颌有义齿(未发生词一票否决) */
const arches = (msg: string): string[] => {
if (INTENT.test(msg)) return [];
const out: string[] = [];
if (UP.test(msg)) out.push('上');
if (LOW.test(msg)) out.push('下');
return out;
};
describe('🔴 两条待定个案 —— 检查所见跨上下颌,按条目判会整条跳过,按颌判可用', () => {
it('王志荣 TS0K051842:上下颌都有义齿(召回下颌 → 解)', () => {
expect(
arches('上下颌吸附性义齿修复,下颌固位可,上颌固位稍差,说话、喝水时义齿易脱落,牙槽嵴黏膜萎缩,无疼痛不适'),
).toEqual(['上', '下']);
});
it('童然夫 TS0M013276:上下颌都有义齿(召回上颌 → 解)', () => {
expect(
arches('牙缺失,口内活动义齿修复,上颌义齿卡环紧,不易取戴,下颌义齿无法完全就位(三个多月未佩戴)'),
).toEqual(['上', '下']);
});
});
describe('按颌判定 —— 生产真实句子', () => {
it.each([
['缺失,上颌活动义齿修复', ['上']],
['缺失,下颌活动义齿修复', ['下']],
['上颌义齿卡环折断', ['上']], // ⭐ 状态不好仍算已治疗:义齿存在 = 修复启动过
['下颌义齿压痛', ['下']],
['上颌义齿固位欠佳', ['上']],
['上下颌活动义齿修复', ['上', '下']],
['上下颌全口义齿,咬合接触均匀。牙龈色粉,无溃疡。', ['上', '下']],
['U全口义齿固位不良,咬合关系欠佳', ['上', '下']],
['上下颌种植临时义齿存,牙龈未见异常', ['上', '下']],
['右侧下颌义齿舌侧粘膜有压痕', ['下']],
])('%s → %s', (msg, expected) => {
expect(arches(msg)).toEqual(expected);
});
});
describe('⛔ 不作数', () => {
it.each([
'建议上颌活动义齿修复', // 还没做
'患者要求下颌义齿修复',
'拟行上下颌全口义齿修复',
'缺牙区粘膜无异常,牙槽嵴有吸收', // 压根没提义齿
])('%s', (msg) => {
expect(arches(msg)).toEqual([]);
});
it('🔴 颌词与义齿词距离过远不得相连', () => {
// 「上颌」讲的是残根,「义齿」是下次的打算 —— 中间隔了十几个字
expect(arches('上颌见残根,牙龈红肿,牙槽嵴吸收明显,下次考虑做义齿')).toEqual([]);
});
});
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(
[],
);
});
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('🔴 只对缺失牙(K08)开闸', () => {
const on = Object.entries(GAP_FLAGS_BY_PRIMARY)
.filter(([, f]) => f.archDentureIsRestored === true)
.map(([code]) => code);
expect(on).toEqual(['K08']);
});
});
......@@ -698,6 +698,46 @@ export const RESTORATION_IN_PLACE_FAIL_RE = [
'未修复', '待修复', '建议', '要求', '拟', '考虑', '择期',
].join('|');
/**
* 「活动义齿 = 假缺失」—— 按**颌**判定,是缺牙误召里最大的一类。
*
* ── 问题 ──
* 戴活动义齿的患者,诊断栏**永远**写「缺失牙」—— 天然牙确实没了,这个诊断是对的。
* 但那个位置已经被义齿盖住,是**假缺失**:召回说的「缺失牙未**启动**修复」根本不成立,
* 治疗早就做了(而且做的就是义齿)。义齿松了/紧了/该重衬是**维护**,不是没做过。
*
* ── 判据:按颌,不按牙位 ──
* 活动义齿是**整颌跨度**的修复体 —— 判出"这一颌有义齿",那一颌的缺牙位就都被它盖住了。
* 所以不需要逐段判状态,也不需要条目牙位跟召回牙位对上:
* 「上颌…义齿」→ 上排全部缺牙位解除;「下颌…义齿」→ 下排;「上下颌/全口…义齿」→ 两排。
* 这比 [[RESTORATION_IN_PLACE_TERMS_RE]] 那条(按条目牙位 + 同颌闸)覆盖面大,
* 且**天然绕开了混合句问题** —— 王志荣 TS0K051842「上下颌吸附性义齿修复,下颌固位可,
* 上颌固位稍差」/ 童然夫 TS0M013276「上颌义齿卡环紧,下颌义齿无法完全就位」这两条
* 检查所见牙位横跨上下颌,同颌闸整条跳过,而按颌判定直接可用。
*
* ⚠️ 「≤10 字」的距离限制是关键:要求颌词紧挨着义齿词,否则
* 「上颌见残根……患者要求下次做义齿」这类会被误连。
* ⛔ 未发生词一票否决:「建议上颌活动义齿修复」是还没做。
*
* ⚠️ 只往 resolved 加牙位,永不新增召回 → 失败方向只有少召,且不报错。
* 消费方:potential-treatment-gap.sql 的 resolvedTeethSql(召回与画像共用)。
*/
export const ARCH_DENTURE_UPPER_RE =
'(上颌|上半口)[^。;;]{0,10}(义齿|假牙)|(上下颌|全口|上下全口)[^。;;]{0,10}(义齿|假牙)';
export const ARCH_DENTURE_LOWER_RE =
'(下颌|下半口)[^。;;]{0,10}(义齿|假牙)|(上下颌|全口|上下全口)[^。;;]{0,10}(义齿|假牙)';
/// 未发生 —— 命中即不作数(「建议上颌活动义齿修复」是还没做)
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;
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