Commit 50a9c9a5 by luoqi

feat(召回): 病历自由文本自证已治疗 —— 预防/复查类动作的补充举证

王作荣 TS0M002910 牙34:2025-01 戴牙完成,2026-04 复诊重记「牙齿缺少」,当次治疗
只有「常规口腔卫生宣教」(preventive,不解结构缺口) → 误召。而同一份病历的主诉
写着「种植戴牙3个月余按约复查」、现病史写着「3个月前在本院行左下后牙种植戴牙」。
主诉/现病史一直摄在 emr_record 里,只是召回从不读它(召回读过的自由文本只有
exam_findings 一处,且只用于 NO_RESTORATION_GAP_EXAM_PATTERNS 那四个词)。

── 大前提不动 ──
最新诊断永远第一位。 不用「前有治疗、后有诊断」翻案 —— 那是猜疑链(修复体会失败、
会脱落、会重做)。本分支只在【诊断后 · 同牙 · 本次动作是 preventive/review】这个
模糊地带,再审同次病历。治疗类动作不走本表(resolver 已正确处理)。

── 词表独立,不复用治疗名那几张(实测)──
① 覆盖不足:REVIEW_IMPLIES_TREATMENT 要求「治疗词+复查」,真实主诉一半以上不带"复查"
   (「种植戴牙3月余」「右侧后牙种植戴牙后一年」)。
② 时态不同:treat_plan 里的词一定已发生,主诉里可能是诉求(「要求窝沟封闭」)。
🔴 ③ 致命反例就在王志荣 TS0K051842 身上:2024-06 现病史「…要求拔除口内剩余牙齿后
   全口义齿修复」vs 2026-01「全口假牙1年半前于我院修复」—— 字面同、语义反。
否定侧复用 strip 的「建议/推荐/考虑/择期」语义(剥"未发生",跨字段一致)。

── 上线前把 59 条命中全量人工过了一遍,四条判据全是踩出来的 ──
🔴 只认「戴牙」不认裸「种植」:种植是多阶段(一期→二期→取模→戴牙)。裸「种植」放行会
   误销掉最该召的:「种植体拔除后两个月」「种植体及牙冠一起脱落」「三周前行种植手术」
   「拔牙后三个月,种植检查」「转诊种植科」「种植导板设计」「种植前检查」。
🔴 症状词不当完成标记:原先收「脱落/松动」,理由是"不存在的东西不会掉" —— 那只证明
   曾经存在,不证明现在在位;对缺牙修复判定,脱落 = 需要重做 = 该召回。
🔴 同颌闸:陈春洪 TS0K070402 主诉明说「右上后牙种植戴牙」,那次宣教牙位却写了
   16;17;27;41;42;46;47 跨四象限,会把下前牙一起误销。按颌切比按牙位数设上限有依据。
🔴 不扫 disposal:生产实测它夹带 toothPositionBak 的 base64,会制造假命中。
   另补「无法就位/无法佩戴」类否定 —— 修复体做了但不能用,仍该召回。
收紧后 59 → 57 条命中,逐条复核无明确误销(1 条边界:上颌义齿"不贴合",按第 17 例
童然夫同口径视为在位)。

️ 本分支只往 resolved 加牙位,永不新增召回 → 失败方向只有少召,且不报错。
️ 整颌案例单条命中即解 12~15 颗牙(吴庆安/高惠霞/周根娣),判错代价大,需重点盯。
扩展点:TREATED_EVIDENCE_EMR_FIELDS。要加 exam_findings 得先解决段内按颌切分。

测试 38 例,正反例全部取自生产真实串(含上述七条踩出来的反例)。
97 suites / 1685 tests,tsc 0 error。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 0366b572
...@@ -10,6 +10,12 @@ import { ...@@ -10,6 +10,12 @@ import {
type DxTreatmentRule, type DxTreatmentRule,
REVIEW_IMPLIES_TREATMENT, REVIEW_IMPLIES_TREATMENT,
MISSING_TOOTH_POLISH_EVIDENCE, MISSING_TOOTH_POLISH_EVIDENCE,
TREATED_EVIDENCE_EMR_FIELDS,
TREATED_EVIDENCE_TRIGGER_CATEGORIES,
TREATED_EVIDENCE_RESTORATION_TERMS,
TREATED_EVIDENCE_COMPLETION_RE,
TREATED_EVIDENCE_INTENT_EXCLUDE_RE,
TREATED_EVIDENCE_SINGLE_ARCH_ONLY,
} from '@pac/types'; } from '@pac/types';
/** /**
...@@ -257,6 +263,51 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces { ...@@ -257,6 +263,51 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
${afterDxFor('plx')}` ${afterDxFor('plx')}`
: Prisma.empty; : Prisma.empty;
// (a''''''') 病历自由文本自证已治疗 —— **补充举证路线**(见 [[TREATED_EVIDENCE_RESTORATION_TERMS]])。
// 大前提不动:最新诊断第一位。本表**不**用"前有治疗后有诊断"翻案(那是猜疑链),
// 只在【诊断之后 · 同一颗牙 · 本次动作是预防/复查类】这个模糊地带,再审同一份病历的
// 主诉/现病史/处置,找"已进入治疗"的证据。治疗类动作不走这里(resolver 已正确处理)。
// 王作荣 TS0M002910 牙34:2025-01 戴牙,2026-04 复诊重记「牙齿缺少」,当次治疗只有
// 「常规口腔卫生宣教」(preventive) → 误召;而主诉写着「种植戴牙3个月余按约复查」。
// 判据 = 修复体词 ∧ 完成标记 ∧ ¬未发生标记,且类目须与 resolverCats 有交集。
// ⚠️ 只加 resolved、永不新增召回 → 失败方向只有"少召",且不报错。词表宁紧勿松。
const evidenceTerms = TREATED_EVIDENCE_RESTORATION_TERMS.filter((t) =>
(resolverCats as readonly string[]).includes(t.category),
);
const emrTextSql = Prisma.raw(
TREATED_EVIDENCE_EMR_FIELDS.map((f) => `COALESCE(emx.content->>'${f}', '')`).join(" || ' ' || "),
);
const treatedEvidenceBranch = evidenceTerms.length
? Prisma.sql`
UNION
SELECT tvt AS t
FROM patient_facts tvx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`tvx.content->>'tooth_position'`)}) AS tvt
JOIN patient_facts emx
ON emx.patient_id = tvx.patient_id
AND emx.type = 'emr_record'
AND emx.status IN ('active', 'fulfilled')
AND emx.content->>'emr_external_id' = tvx.content->>'source_encounter_external_id'
WHERE tvx.patient_id = p.id
AND tvx.type = 'treatment_record' AND tvx.kind = 'actual'
AND tvx.status IN ('active', 'fulfilled')
AND COALESCE(NULLIF(trim(tvx.content->>'tooth_position'), ''), '') != ''
AND tvx.content->>'category' = ANY(${[...TREATED_EVIDENCE_TRIGGER_CATEGORIES]}::text[])
AND (${emrTextSql}) ~ ${evidenceTerms.map((t) => t.pattern).join('|')}
AND (${emrTextSql}) ~ ${TREATED_EVIDENCE_COMPLETION_RE}
AND (${emrTextSql}) !~ ${TREATED_EVIDENCE_INTENT_EXCLUDE_RE}
${
TREATED_EVIDENCE_SINGLE_ARCH_ONLY
? Prisma.sql`AND NOT (
EXISTS (SELECT 1 FROM unnest(${toothArrSql(Prisma.sql`tvx.content->>'tooth_position'`)}) au WHERE au ~ '^[12]')
AND
EXISTS (SELECT 1 FROM unnest(${toothArrSql(Prisma.sql`tvx.content->>'tooth_position'`)}) al WHERE al ~ '^[34]')
)`
: Prisma.empty
}
${afterDxFor('tvx')}`
: Prisma.empty;
const resolvedTeethSql = Prisma.sql` const resolvedTeethSql = Prisma.sql`
(SELECT COALESCE(array_agg(DISTINCT t), ARRAY[]::text[]) FROM ( (SELECT COALESCE(array_agg(DISTINCT t), ARRAY[]::text[]) FROM (
-- (a) 治疗家族 resolver(afterDx):同牙诊断后做了 resolverCats 家族里任一治疗 -- (a) 治疗家族 resolver(afterDx):同牙诊断后做了 resolverCats 家族里任一治疗
...@@ -356,6 +407,7 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces { ...@@ -356,6 +407,7 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
AND sig.type = 'diagnosis_record' AND sig.type = 'diagnosis_record'
${reviewImpliesBranch} ${reviewImpliesBranch}
${polishImpliesBranch} ${polishImpliesBranch}
${treatedEvidenceBranch}
${orthoExtractBranch} ${orthoExtractBranch}
${deferDxBranch} ${deferDxBranch}
) u)`; ) u)`;
......
/**
* TREATED_EVIDENCE_* —— 病历自由文本自证已治疗(补充举证路线)。
*
* 判据 = 修复体词 ∧ 完成标记 ∧ ¬未发生标记。三个条件缺一不可,
* 少一个就会把"还没做"判成"已做" → 少召,而少召不报错。
* 下面正反例**全部取自生产真实串**(主诉 / 现病史 / 处置)。
*
* 跑:
* pnpm test -- treated-evidence-from-emr-text
*/
import {
TREATED_EVIDENCE_RESTORATION_TERMS,
TREATED_EVIDENCE_COMPLETION_RE,
TREATED_EVIDENCE_INTENT_EXCLUDE_RE,
TREATED_EVIDENCE_EMR_FIELDS,
TREATED_EVIDENCE_TRIGGER_CATEGORIES,
resolverCategoriesFor,
} from '@pac/types';
const COMPLETION = new RegExp(TREATED_EVIDENCE_COMPLETION_RE);
const INTENT = new RegExp(TREATED_EVIDENCE_INTENT_EXCLUDE_RE);
/** 模拟 SQL 三条件:返回命中的类目集合(空 = 不作数) */
const evidenceCats = (text: string): string[] => {
if (!COMPLETION.test(text)) return [];
if (INTENT.test(text)) return [];
return TREATED_EVIDENCE_RESTORATION_TERMS.filter((t) => new RegExp(t.pattern).test(text)).map(
(t) => t.category,
);
};
describe('正例 —— 生产真实主诉/现病史,必须判为已治疗', () => {
it.each([
['种植戴牙3个月余按约复查', 'prosthodontic'], // 王作荣 TS0M002910 主诉
['3个月前在本院行左下后牙种植戴牙,无不适,今按约复查', 'prosthodontic'], // 同上 现病史
['种植戴牙1年按约复查', 'prosthodontic'], // 陈惠英 TS0K028757
['种植戴牙3月余', 'prosthodontic'], // ⭐ 不含"复查"二字 —— 现有 REVIEW 词表漏的就是这类
['右侧后牙种植戴牙后一年', 'prosthodontic'],
['一年前患者于本院行右上后牙种植戴牙,现按计划复诊', 'prosthodontic'],
['复诊,主诉上颌义齿咬物疼痛,冷热不适近月余', 'prosthodontic'],
['种植戴牙半年余', 'prosthodontic'],
['种植戴牙4月余按约复查', 'prosthodontic'],
['上颌活动义齿不贴合1周余,今来就诊', 'prosthodontic'],
['戴牙后2周复查,自述无特殊不适。', 'prosthodontic'],
])('%s → %s', (text, cat) => {
expect(evidenceCats(text)).toContain(cat);
});
});
describe('反例 —— 必须判为不作数(判错就是少召,不报错)', () => {
it.each([
// ① 诉求,不是已发生
['要求窝沟封闭'],
['患者要求窝沟封闭。(保险收费给孩子正畸用)'],
['今行检查,未行处置,口腔卫生宣教,建议择期种植修复'],
// 🔴 ② 王志荣本人 2024-06-14 现病史 —— 与他 2026 那句字面几乎一样,语义相反
['下颌活动牙齿修复后牙龈反复疼痛,要求拔除口内剩余牙齿后全口义齿修复'],
// ③ 有完成标记但**没有修复体词** —— 光凭"复查"不能算已治疗
['定期复查'],
['定期3个月复查涂氟'],
['按计划复诊'],
['左上后牙拔除术后3月余'], // 拔牙3个月正是该种植,必须照常召回
['右下后牙松动数月。'],
['右下后牙折断1月'], // 童然夫主诉:折断的是天然牙,不是修复体
// ④ 有修复体词但没有完成标记
['要求种植修复'],
['咨询义齿修复方案'],
// 🔴 ⑤ 生产实测踩出来的:种植是多阶段过程,没到「戴牙」都不算修复完成
['右上后牙种植体拔除后两个月'], // BJ0E015462 —— 种植失败取出,最该召回
['右上后牙12年前行种植修复,1个月前种植体及牙冠一起脱落'], // SC15894
['复查种植三周后复查。三周前于我处行左上后牙种植手术,今来复查。'], // SH0Q019876 只做了一期
['拔牙后三个月,种植检查'], // BJ0V001864 正在准备种植
['拔牙3个月后常规复诊。转诊种植科。'], // TS0M004268
['右上后牙自行脱落,前来检查。6个月后种植前检查'], // SH0L009117
// 🔴 ⑥ 修复体做了但不能用 —— 仍该召回
['外院制作右下活动假牙无法就位一周。'], // BJ0F028868
['前牙牙冠脱落数日。患者自述十余年前曾在我院牙冠修复,几日前牙冠脱落,今来诊。'], // BA14863
])('%s → 不作数', (text) => {
expect(evidenceCats(text)).toEqual([]);
});
});
describe('表自身自洽', () => {
it('⭐「计划」不在未发生词里 —— 否则误伤「按计划复诊」', () => {
expect(INTENT.test('按计划复诊')).toBe(false);
expect(TREATED_EVIDENCE_INTENT_EXCLUDE_RE).not.toMatch(/(^|\|)计划(\||$)/);
});
it('触发类目只收预防/复查 —— 治疗类走现有 resolver,不进本表', () => {
expect([...TREATED_EVIDENCE_TRIGGER_CATEGORIES].sort()).toEqual(['preventive', 'review']);
});
it('⛔ 暂不扫检查所见(混合描述) / 处置(夹带 base64)', () => {
expect(TREATED_EVIDENCE_EMR_FIELDS).not.toContain('exam_findings');
expect(TREATED_EVIDENCE_EMR_FIELDS).not.toContain('disposal');
expect([...TREATED_EVIDENCE_EMR_FIELDS]).toEqual(['illness_desc', 'pre_illness']);
});
it('🔴 ⛔ 不认裸「种植」—— 只认「戴牙」(种植是多阶段,一期/二期/取模都不算完成)', () => {
const pats = TREATED_EVIDENCE_RESTORATION_TERMS.map((t) => t.pattern).join('|');
expect(pats).not.toMatch(/(^|\|)种植(\||$)/);
expect(pats).toContain('戴牙');
});
it('🔴 ⛔ 症状词不能当完成标记(脱落=需要重做,该召回)', () => {
expect(TREATED_EVIDENCE_COMPLETION_RE).not.toContain('脱落');
expect(TREATED_EVIDENCE_COMPLETION_RE).not.toContain('松动');
});
it('类目必须落在缺失牙(K08)的 resolver 家族里,否则本表白配', () => {
const cats = resolverCategoriesFor('K08') as readonly string[];
for (const t of TREATED_EVIDENCE_RESTORATION_TERMS) expect(cats).toContain(t.category);
});
it('三个正则都可编译', () => {
expect(() => new RegExp(TREATED_EVIDENCE_COMPLETION_RE)).not.toThrow();
expect(() => new RegExp(TREATED_EVIDENCE_INTENT_EXCLUDE_RE)).not.toThrow();
for (const t of TREATED_EVIDENCE_RESTORATION_TERMS) {
expect(() => new RegExp(t.pattern)).not.toThrow();
}
});
});
...@@ -497,6 +497,95 @@ export const MISSING_TOOTH_POLISH_EVIDENCE = { ...@@ -497,6 +497,95 @@ export const MISSING_TOOTH_POLISH_EVIDENCE = {
why: '牙已缺失,抛光的对象只能是修复体/种植体', why: '牙已缺失,抛光的对象只能是修复体/种植体',
} as const; } as const;
/**
* 「病历自由文本自证已治疗」—— **补充举证路线**,只在本次动作是预防/复查类时才启动。
*
* ── 大前提不动 ──
* 最新诊断永远第一位:诊断了就是机会。⛔ **不用「前有治疗、后有诊断」去翻案** ——
* 那只是一条猜疑链(修复体会失败、会脱落、会需要重做,旧治疗不能证明今天还在位)。
* 本表要做的是把**诊断之后、本次就诊内**的动作判得更有说服力:
* · 本次动作是**治疗类** → 现有 resolver 已正确处理,本表不参与
* · 本次动作是**预防/复查类** → 语义模糊(可能是"修复体做完后的维护",也可能真是没治)
* → 再审同一份病历的自由文本,找"已进入治疗"的证据
* 这就是「因治未治」判定在预防类动作上的补全。
*
* ── 为什么不复用治疗名那几张词表(实测,不是保守)──
* ① 覆盖不足:REVIEW_IMPLIES_TREATMENT 要求「治疗词 + 复查/复诊」,而真实主诉一半以上不带
* 「复查」二字 —— 「种植戴牙3月余」「右侧后牙种植戴牙后一年」「全口假牙1年半前于我院修复」
* 全都漏掉。主诉的语义是"这次来的缘由是某治疗之后多久",时间跨度本身就蕴含那个治疗已完成。
* ② 时态/情态不同:treat_plan 里的词一定是**已发生**,主诉里的词可能是**诉求** ——
* 生产真实串「要求窝沟封闭」「定期复查」(同次治疗名却写着"未行处置,建议择期种植修复")。
* 🔴 ③ 有一条致命反例,就在王志荣 TS0K051842 自己身上:
* 2024-06-14 现病史「下颌活动牙齿修复后牙龈反复疼痛,**要求拔除口内剩余牙齿后全口义齿修复**」
* 2026-01-16 现病史「**全口假牙1年半前于我院修复**,1月来上颌假牙易松动脱落」
* 两句字面都含「义齿/假牙 + 修复」,前者是**将来要做**、后者是**已经做了**,语义相反。
* 直接把治疗名词表扫到现病史上,会在义齿存在之前就判定已修复,**而且不报错**。
* ⛔ 所以正面词必须独立成表;否定侧(见 [[TREATED_EVIDENCE_INTENT_EXCLUDE_RE]])才可以复用
* 「建议/推荐/考虑/择期」那套语义 —— 剥的是"未发生",跨字段一致。
*
* ── 判据:修复体词 ∧ 完成标记 ∧ ¬未发生标记 ──
* 刻意**不要求先后顺序**(两个条件分别匹配整段文本),因为真实语序两种都有:
* 「全口假牙1年半前于我院修复」(标记在词后) / 「9年前外院种植牙冠修复」(标记在词前)。
* 代价是丢了段内局部性,靠 INTENT_EXCLUDE 兜。
*
* ⚠️ 失败方向只有一个:**少召**(只往 resolved 里加牙位,永不新增召回)。而少召不报错,
* 一线只会觉得"系统没提醒过"。所以词表宁紧勿松,上线前要把命中差全量导出人工过一遍。
*
* 消费方:potential-treatment-gap.sql 的 resolvedTeethSql —— 召回与画像共用(口径必须一致)。
*/
/// 动作牙位不得跨上下颌 —— 一副修复体不跨颌。生产实测:陈春洪 TS0K070402 主诉明说
/// 「右上后牙种植戴牙」,那次宣教的牙位却写了 16;17;27;41;42;46;47,跨了四个象限,
/// 会把下前牙 41;42 一起误销。按牙位数设上限是拟合数据,按颌切才有临床依据。
export const TREATED_EVIDENCE_SINGLE_ARCH_ONLY = true;
/// 扫哪些 emr_record 字段(**扩展点**:以后要加 exam_findings 检查所见,改这里即可)。
/// ⛔ 暂不收 exam_findings:它是多牙位混合描述(「上颌固位可,下颌固位稍差」),
/// 整条牙位一起解会误销该召的那一半 —— 那条路要先解决段内按颌切分,不在本表解决。
/// ⛔ 不收 disposal(处置):生产实测它夹带 `toothPositionBak":"7xgAAB+LCAA…"` 这类 base64,
/// 会参与正则匹配并制造假命中(Jing Dong BJ0U007054 就是这么中的)。要收得先剥字段。
export const TREATED_EVIDENCE_EMR_FIELDS = ['illness_desc', 'pre_illness'] as const;
/// 本次动作是这些类目时才启动补充举证(治疗类不走本表 —— resolver 已正确处理)
export const TREATED_EVIDENCE_TRIGGER_CATEGORIES = ['preventive', 'review'] as const;
/// 修复体词 → 它蕴含的治疗类目(消费方须用 resolverCategoriesFor 过滤,跨类目不放行)
export const TREATED_EVIDENCE_RESTORATION_TERMS: ReadonlyArray<{
pattern: string;
category: PACTreatmentCategory;
why: string;
}> = [
// 🔴 ⛔ **只认「戴牙」,不认裸「种植」** —— 种植是多阶段过程(一期手术→二期→取模→戴牙),
// 只有戴牙才等于修复完成。生产实测裸「种植」放行会误销掉最该召的那批:
// 「种植体拔除后两个月」(BJ0E015462) /「种植体及牙冠一起脱落」(SC15894) /
// 「三周前行种植手术,今来复查」(SH0Q019876) /「拔牙后三个月,种植检查」(BJ0V001864) /
// 「转诊种植科」(TS0M004268) /「种植导板设计」(SZ0Y007308) /「种植前检查」(SH0L009117)。
// 「戴牙」归 prosthodontic 而非 implant:装戴修复体本身就是修复动作,种植冠亦然;
// 两者都在 STRUCTURAL_RESOLVER_CATEGORIES 里,对缺牙缺口判定无差别,不必强分。
{
pattern: '戴牙|假牙|义齿|修复体|烤瓷冠|全瓷冠',
category: 'prosthodontic',
why: '戴牙 = 修复完成(一期/二期/取模都不算);义齿/修复体在位才谈得上戴了多久',
},
];
/// 完成标记 —— 时间跨度 / 按约 / 术后 / 修复体的症状。
/// 症状类(脱落/松动/折断/压痛)同样蕴含"东西在那儿":不存在的修复体不会松、不会掉。
/// 🔴 ⛔ **不收症状词**(脱落/松动/折断)。原先收了,理由是"不存在的东西不会掉" ——
/// 那个推理只证明它**曾经**存在,不证明它**现在**在位。而对「缺失牙未修复」这个判定,
/// 脱落恰恰等于需要重做 = 该召回。生产实测误销:「前牙牙冠脱落数日」「金属固定桥松动」。
export const TREATED_EVIDENCE_COMPLETION_RE =
'[0-90-9一二三四五六七八九十两半]+[个]?(天|周|月|年)|按约|按计划|复查|复诊';
/// 未发生标记 —— 命中即整段不作数。语义与 keyword_strip_clauses 一致(剥"还没做"),
/// 另补主诉特有的诉求词(要求/拟/打算/咨询/意向)。⛔「计划」不收 —— 会误伤「按计划复诊」。
/// 分三类:①诉求(还没做) ②种植未完成态 ③修复体已失效(做了但不能用 → 仍该召回)。
/// ②③ 都是生产实测踩出来的,不是预防性堆词。⛔「计划」不收 —— 会误伤「按计划复诊」。
export const TREATED_EVIDENCE_INTENT_EXCLUDE_RE =
'要求|拟行|拟|打算|咨询|意向|建议|推荐|考虑|择期|必要时|如需|未行|未做|未修复' +
'|手术|拔除|拔牙|导板|转诊|种植科|种植前|取出|失败|重新种' +
'|脱落|无法就位|不能就位|未就位|无法佩戴|不能佩戴|无法使用';
export function resolverCategoriesFor(code: string): readonly PACTreatmentCategory[] { export function resolverCategoriesFor(code: string): readonly PACTreatmentCategory[] {
if (STRUCTURAL_DX_CODES.has(code)) return STRUCTURAL_RESOLVER_CATEGORIES; if (STRUCTURAL_DX_CODES.has(code)) return STRUCTURAL_RESOLVER_CATEGORIES;
const rule = lookupDxTreatment(code); 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