Commit 2f659db5 by luoqi

merge: 缺牙误召四条举证路线 + 姑息处置入 review + 抑制全有或全无 → test

代码分支 fix/treated-evidence-from-emr-text(已含 fix/polish-on-missing-tooth-implies-restoration)。

摄入期(需 reparse --subject-type=treatment):
  67fe9d19 补义齿维护族(卡环/基托/重衬/假牙)+ 扩根/松牙固定/用氟/洁牙
  0366b572 补含词「调合/调颌」
  6ee7b8e9 姑息处置(调磨/试戴/冲洗/换药/上药)route 到 review,不再整条丢弃

查询期(plan 重算即生效,均只往 resolvedTeeth 加牙位,失败方向为少召):
  e0a12c44 缺失牙位上的裸「抛光」= 修复体在位
  50a9c9a5+362f484e+79e63437 主诉/现病史自证已治疗(仅开缺失牙场景)
  96765e1d 检查所见按条目举证修复体在位
  aac4e249+1a3916a5 活动义齿「假缺失」按颌判定(限同一份病历,不整颌铺开)
  1284675e 抑制改为全有或全无 —— 处置是计划级的
parents ebc90fb7 1a3916a5
...@@ -17,7 +17,10 @@ keyword_mapping: ...@@ -17,7 +17,10 @@ keyword_mapping:
# ⚠️ "二期" 必须限定到「种植二期」:裸"二期"会误吞"二期隐形矫正"(正畸)等 → 误判 implant。 # ⚠️ "二期" 必须限定到「种植二期」:裸"二期"会误吞"二期隐形矫正"(正畸)等 → 误判 implant。
# 种植语境的二期都带"种植"(种植二期);ortho 的"二期隐形矫正/二期矫正"靠下面 orthodontic 兜住。 # 种植语境的二期都带"种植"(种植二期);ortho 的"二期隐形矫正/二期矫正"靠下面 orthodontic 兜住。
- { value: implant, any: [种植, 即拔即种, 植体, 种植二期] } - { value: implant, any: [种植, 即拔即种, 植体, 种植二期] }
- { value: endodontic, any: [根管, RCT, 牙髓, 开髓, 根备, 根充, 盖髓, 摘髓, 根尖] } # 扩根 = 根管扩大预备的口语写法,与已收的「根备」同一动作。DW 实测 604 EMR(扩根 474 /
# 扩根换药 39 / 扩根封药 19 …)全部落 _default 被丢 —— 患者根管做到一半在系统里等于没做过。
# 碰撞已验:「扩弓」是正畸但不含「扩根」;含扩根的 DW 全量无非牙髓语义。
- { value: endodontic, any: [根管, RCT, 牙髓, 开髓, 根备, 根充, 盖髓, 摘髓, 根尖, 扩根] }
- { value: orthodontic, any: [正畸, 矫治, 矫正, 托槽, 保持器, 粘附件, 隐适美, 隐形矫, 扩弓, 错颌, 错合] } - { value: orthodontic, any: [正畸, 矫治, 矫正, 托槽, 保持器, 粘附件, 隐适美, 隐形矫, 扩弓, 错颌, 错合] }
- { value: cosmetic, any: [贴面, 漂白, 美白] } - { value: cosmetic, any: [贴面, 漂白, 美白] }
# 修复/备牙/取模:收 C.10 治疗痕迹(修复后/修复术后/全瓷桥修复后/备牙后/重新取模后)。 # 修复/备牙/取模:收 C.10 治疗痕迹(修复后/修复术后/全瓷桥修复后/备牙后/重新取模后)。
...@@ -27,7 +30,15 @@ keyword_mapping: ...@@ -27,7 +30,15 @@ keyword_mapping:
# 裸「加牙」58 条(50 带牙位)全部落 _default 被丢,患者整次修复在系统里不存在 → 误召缺失牙。 # 裸「加牙」58 条(50 带牙位)全部落 _default 被丢,患者整次修复在系统里不存在 → 误召缺失牙。
# 变体「活动义齿加牙/义齿加牙/取模加牙」已被 义齿/取模 收,只有裸词和「活托X」漏。 # 变体「活动义齿加牙/义齿加牙/取模加牙」已被 义齿/取模 收,只有裸词和「活托X」漏。
# 碰撞已验:「根管治疗加牙周治疗」含"加牙"但被上方 ② endodontic 的「根管」先吃,安全。 # 碰撞已验:「根管治疗加牙周治疗」含"加牙"但被上方 ② endodontic 的「根管」先吃,安全。
- { value: prosthodontic, any: [, , 义齿, 修复体, 修复, 备牙, 取模, 桩核, 桩冠, 戴牙, 全瓷, 烤瓷, 重新粘接, 加牙, 活托] } # 卡环/基托/重衬/假牙(2026-08-28 薛希明 TS0K018752 牙14;15;16;17;25;26;27):活动义齿 2025-01
# 已戴,2026-05 复诊主诉「上颌假牙松2周」,当次治疗写「卡环加力」,牙位与缺牙诊断完全一致 ——
# 该词落 _default 整条被丢,那次就诊在系统里没有任何治疗 → 判「缺失牙未启动修复」误召。
# 这四个词临床上**只属于活动义齿**:卡环(卡子)/基托(托板)/重衬/假牙(口语)。
# DW 全量回放:478 EMR / 216 词形被丢(基托选磨 49、卡环加力 40、重衬 26、紧卡环 21…)。
# 碰撞已验:① 活动矫治器也有卡环/基托,但 orthodontic 规则排在本行**之前**,含
# 正畸/矫治/矫正/托槽/保持器的一律先被判走;② 扫了 DW 全部不含正畸词的卡环/基托记录,
# 无一条是正畸语义,全是义齿维护。
- { value: prosthodontic, any: [, , 义齿, 修复体, 修复, 备牙, 取模, 桩核, 桩冠, 戴牙, 全瓷, 烤瓷, 重新粘接, 加牙, 活托, 卡环, 基托, 重衬, 假牙] }
# ⭐ 明确充填动作(充填/去腐/备洞/嵌体/垫底/补牙)压过 preventive: # ⭐ 明确充填动作(充填/去腐/备洞/嵌体/垫底/补牙)压过 preventive:
# "去腐,备洞,...树脂充填,窝沟封闭"(dispose 散文)是补牙(restorative),不能因含"窝沟封闭" # "去腐,备洞,...树脂充填,窝沟封闭"(dispose 散文)是补牙(restorative),不能因含"窝沟封闭"
# 被误判 preventive → 非 resolver → 误召(马思煦@46)。注:"开髓去腐"等根管语境已被上方 endodontic 先收。 # 被误判 preventive → 非 resolver → 误召(马思煦@46)。注:"开髓去腐"等根管语境已被上方 endodontic 先收。
...@@ -36,25 +47,58 @@ keyword_mapping: ...@@ -36,25 +47,58 @@ keyword_mapping:
- { value: preventive, any: [窝沟封闭, 封闭剂, 点隙封闭] } - { value: preventive, any: [窝沟封闭, 封闭剂, 点隙封闭] }
# 材料弱词:放 preventive 后 —— "玻璃离子窝沟封闭" 已被上面 preventive 收;裸"树脂/玻璃离子"→ restorative # 材料弱词:放 preventive 后 —— "玻璃离子窝沟封闭" 已被上面 preventive 收;裸"树脂/玻璃离子"→ restorative
- { value: restorative, any: [树脂, 玻璃离子] } - { value: restorative, any: [树脂, 玻璃离子] }
# 调合/调颌(2026-08-28 陈惠英 TS0K028757 牙34;35;36;37):种植戴牙一年按约复查,当次治疗
# 写「调合,抛光」,牙位与缺牙诊断完全一致。宿主精确表早已判定「调合」「调颌」= prosthodontic
# (treatment_actual.yaml),但只在精确表里 —— 医生把两个词写在一起,精确匹配不上、含词表也没有,
# 整条丢弃 → 那次复查在系统里没有任何治疗 → 判「缺失牙未启动修复」误召。DW 实测 238 条。
# ⛔ **必须排在 restorative 之后**:「去腐净,Z250充填,调合抛光」这类补牙散文含「调合」,
# 放到上面的 prosthodontic 行会把上千条充填记录改判成修复(两者都在结构 resolver 里,
# 召回判定不变,但治疗史和标签会错)。放这里让「充填/树脂」先赢。
# ⛔ 「调磨」不收 —— 见文末 ⛔ 块(姑息调整,不代表问题已解决);调合=咬合调整,是宿主已判定的修复动作。
- { value: prosthodontic, any: [调合, 调颌] }
# 牙周去裸"洁牙"(避开自由文本"清洁牙面"误命中);洁牙/全口洁牙等精确词由上方 enum_mapping 兜 # 牙周去裸"洁牙"(避开自由文本"清洁牙面"误命中);洁牙/全口洁牙等精确词由上方 enum_mapping 兜
# SRP = scaling & root planing(龈下刮治+根面平整)的国际通用缩写,正是 perio_no_srp 子场景 # SRP = scaling & root planing(龈下刮治+根面平整)的国际通用缩写,正是 perio_no_srp 子场景
# 要认的那个治疗。DW 实测 SRP 648 + 局部SRP 40 + SRP+冲洗上药 76,此前全部落 _default 被丢 —— # 要认的那个治疗。DW 实测 SRP 648 + 局部SRP 40 + SRP+冲洗上药 76,此前全部落 _default 被丢 ——
# 患者做完牙周基础治疗仍被召「牙周炎未做基础治疗」。含 SRP 的 DW 全量已扫,无非牙周语义。 # 患者做完牙周基础治疗仍被召「牙周炎未做基础治疗」。含 SRP 的 DW 全量已扫,无非牙周语义。
- { value: periodontic, any: [洁治, 洗牙, 龈上, 龈下, 刮治, 牙周, 喷砂, 细洁, SRP] } # 松牙固定 = 牙周炎松动牙夹板固定,牙周治疗动作。DW 实测 269 EMR 被丢。
- { value: preventive, any: [涂氟, 防龋, OHI, 口腔卫生宣教] } - { value: periodontic, any: [洁治, 洗牙, 龈上, 龈下, 刮治, 牙周, 喷砂, 细洁, SRP, 松牙固定] }
# 裸「洁牙」单开一条并挂 none —— 上一行**不能**直接加它:actual 侧收的是 dispose 散文,
# 「清洁牙面 / 清洁牙齿 / 清洁牙间隙」都含「洁牙」二字(332 EMR),那是充填/正畸流程里的
# 清洁动作,不是洗牙。none 挂在独立规则上,原规则的语义不受影响(混合句「清洁牙面…龈下刮治」
# 仍由上一行正常命中;DW 全量实测这类 0 条,单开一条是为了将来也不会被误挡)。
# 收益:DW 全量回放 5,894 EMR(全口超声洁牙 1,359 / 超声洁牙 1,059 / 超声波洁牙 475 …)——
# 此前患者洗过牙仍被召「牙周炎未做基础治疗」。planned 侧本就收裸「洁牙」(计划名无散文)。
- { value: periodontic, any: [洁牙], none: [清洁牙] }
# 用氟:与已收的「涂氟」同一动作的另一种写法。DW 实测 4,470 EMR 被丢(局部用氟 4,436)。
# 注:preventive 不在结构家族 resolver 里,本词只补治疗史完整性,不改召回判定。
- { value: preventive, any: [涂氟, 用氟, 防龋, OHI, 口腔卫生宣教] }
# 缝线:「拆线」已在宿主精确表(拆线→surgical,37,253 条正常落库),但措辞变体 # 缝线:「拆线」已在宿主精确表(拆线→surgical,37,253 条正常落库),但措辞变体
# 「拆除缝线」699 条(665 带牙位)漏了 —— 同一临床动作。收「缝线」一并覆盖变体。 # 「拆除缝线」699 条(665 带牙位)漏了 —— 同一临床动作。收「缝线」一并覆盖变体。
# 碰撞已验:「拆除」不含「拔除/切除」,不会被本行前面的词误命中。 # 碰撞已验:「拆除」不含「拔除/切除」,不会被本行前面的词误命中。
- { value: surgical, any: [拔除, 拔牙, 切开, 翻瓣, 切除, 系带, 脓肿, 囊肿, 植骨, 缝线] } - { value: surgical, any: [拔除, 拔牙, 切开, 翻瓣, 切除, 系带, 脓肿, 囊肿, 植骨, 缝线] }
- { value: pediatric, any: [乳牙, 儿童, 年轻恒牙] } - { value: pediatric, any: [乳牙, 儿童, 年轻恒牙] }
# ⛔ 刻意不收(2026-08-27 评估过,别"顺手补全"): # ⛔ 不收进本表(冲洗 / 换药 / 上药 / 调磨 / 试戴)—— 但**不再丢弃**:
# 冲洗 / 换药 / 上药(~3,500 条)—— **对症处置 ≠ 根治**。阻生牙冠周炎「冲洗上药」之后那颗牙 # 它们是「对症处置 ≠ 根治」:阻生牙冠周炎「冲洗上药」之后那颗牙仍然要拔;
# 仍然要拔;归 surgical 会把 K01 阻生牙缺口消掉 = 该召的不召(少召不报错,最难发现)。 # 归 surgical 会把 K01 阻生牙缺口消掉 = 该召的不召(少召不报错,最难发现)。
# 调磨(817)/ 试戴(213)—— 姑息调整 / 修复流程中途,都不代表问题已解决。 # 所以不能进本表的任何真治疗类目。
# ⚠️ 根因:本表的 category 同时被两处消费 —— ①治疗史完整性 ②resolver 判"缺口已解"。 #
# 对这几个词我们想要①不想要②,而现在只有一个旋钮,只能二选一(现选"都不要"=丢弃)。 # 🔴 2026-08-28 重评并纠正了此处原来的根因分析。原文写着:
# 要两全需在 category 之外再加一维"是否根治",那是更大的改动,不在本表解决。 # 「category 同时被①治疗史完整性 ②resolver 判缺口已解 两处消费,只有一个旋钮,
# 只能二选一(现选都不要=丢弃)。要两全需在 category 之外再加一维"是否根治"」
# —— **前提不成立**。第二个旋钮一直都在,就是 `review` 类目,而且它就是为这件事设计的:
# PACTreatmentCategories.review 注释:「医生做了某个流程节点 / 临床判断本次不动手」
# 「不该塞 preventive(会污染已做预防判定),也不该丢弃(丢失事实)」;
# 结构家族注释:「刻意排除 periodontic / orthodontic / preventive / **review**」;
# 实测 review 出现在 **0 个** resolver 家族里。
# 即 review 精确地给①不给② —— 冠周炎冲洗上药之后仍照常召拔牙,但治疗史里看得到。
#
# 现状:这五个词由 manifest 的 route_by_pattern 分流到 _treatment_review_raw
# (category=review),不进本含词表。DW 全量 treat_plan 全是这五词的 EMR 4,336 份。
# 触发个案:季炎萍 TS0B010543 —— treat_plan 只有「调磨」被丢 → 她库里零条治疗事实。
# ⛔ 别把它们补进本表:进来就等于归了某个真治疗类目 = 会解缺口。
# 也别把它们并进 manifest 的 &review_terms —— 那个 anchor 被 dispose 闸门复用。
# 两条约束都由 tests/palliative-route-to-review.spec.ts 锁住。
# actual 语义:这些词开头的从句 = 本次没做(条件/未来/建议)→ 切段丢弃后再匹配。 # actual 语义:这些词开头的从句 = 本次没做(条件/未来/建议)→ 切段丢弃后再匹配。
# 例 "充填,必要时根管治疗" → 丢"必要时根管治疗" → "充填" → restorative(不误判成 endodontic)。 # 例 "充填,必要时根管治疗" → 丢"必要时根管治疗" → "充填" → restorative(不误判成 endodontic)。
......
...@@ -572,6 +572,27 @@ transforms: ...@@ -572,6 +572,27 @@ transforms:
- 拒绝拍片 - 拒绝拍片
- 治疗中 - 治疗中
- 已交付纸质病历 - 已交付纸质病历
# ── 姑息处置 → 同样落 _treatment_review_raw(category=review)──
# 2026-08-28 重评「⛔ 刻意不收」那几个词(见 treatment-category-actual-rules.yaml 文末)。
# 那个块的根因写着「category 被治疗史与 resolver 两处消费,只有一个旋钮,只能二选一
# (现选都不要=丢弃)」—— **前提不成立**:第二个旋钮一直都在,就是 review 类目,
# 而且它就是为这件事设计的(见 PACTreatmentCategories.review 注释:「医生做了某个流程
# 节点/临床判断本次不动手」「不该塞 preventive,也不该丢弃」),且 review 出现在
# **0 个** resolver 家族里(结构家族注释明写「刻意排除 …preventive / review」)。
# 所以 review 精确地给①治疗史完整性、不给②解缺口 —— 冠周炎冲洗上药之后仍照常召拔牙。
# 触发个案:季炎萍 TS0B010543 牙41~37,下颌活动义齿戴数年,本次「调磨」过长边缘,
# treat_plan 只有「调磨」被丢 → 她库里**零条治疗事实**,系统看成"从没治过"。
# 影响面:DW 全量 treat_plan 全是这五词的 EMR 4,336 份 → 进治疗史,不解任何缺口。
#
# ⛔ **单独一条 route,不并进上面的 &review_terms** —— 那个 anchor 被 C.9 dispose 闸门
# 复用(blank_or_all_in),并进去会同时打开闸门:那 4,336 份处置 100% 非空,实测
# TOP400 处置里约 216 条会落进结构 resolver 家族,且含「冠周冲洗,派力奥上药」
# →prosthodontic(含"冠"字)、「去暂封…玻璃离子暂封」→restorative 这类误判,
# 会误销缺口。dispose 闸门维持现状(不开=不回退),要开另行评估。
# 两个消费方语义本就不同:route 问"哪些名字不是真治疗",闸门问"何时可安全抽处置"。
- output: _treatment_review_raw
when:
equals: [调磨, 试戴, 冲洗, 换药, 上药]
# 真治疗动作 → treatment_actual_rows ⭐ kind=actual(临床真实) # 真治疗动作 → treatment_actual_rows ⭐ kind=actual(临床真实)
- output: _treatment_actual_raw_emr - output: _treatment_actual_raw_emr
when: when:
......
...@@ -9,6 +9,24 @@ import { ...@@ -9,6 +9,24 @@ import {
STRUCTURAL_DX_CODE_LIST, STRUCTURAL_DX_CODE_LIST,
type DxTreatmentRule, type DxTreatmentRule,
REVIEW_IMPLIES_TREATMENT, REVIEW_IMPLIES_TREATMENT,
MISSING_TOOTH_POLISH_EVIDENCE,
TREATED_EVIDENCE_EMR_FIELDS,
treatedEvidenceTriggersFor,
TREATED_EVIDENCE_BATCH_CATEGORIES,
TREATED_EVIDENCE_BATCH_MAX_TEETH,
TREATED_EVIDENCE_RESTORATION_TERMS,
TREATED_EVIDENCE_COMPLETION_RE,
TREATED_EVIDENCE_INTENT_EXCLUDE_RE,
TREATED_EVIDENCE_SINGLE_ARCH_ONLY,
RESTORATION_IN_PLACE_TERMS_RE,
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_FIRST_DIGITS_RE,
LOWER_ARCH_FIRST_DIGITS_RE,
} from '@pac/types'; } from '@pac/types';
/** /**
...@@ -31,6 +49,10 @@ export interface GapCfgFlags { ...@@ -31,6 +49,10 @@ export interface GapCfgFlags {
excludeOrthoExtractionSites?: boolean; // §E 正畸减数位(外科拔除 + 正畸语境)折进 resolved excludeOrthoExtractionSites?: boolean; // §E 正畸减数位(外科拔除 + 正畸语境)折进 resolved
excludeCongenitalName?: boolean; // §E name 含"先天" → 正畸统筹,不自动召修复 excludeCongenitalName?: boolean; // §E name 含"先天" → 正畸统筹,不自动召修复
deferToToothDx?: boolean; // 「建议拔除」场景专用:有同牙编码病种诊断 → 让位给该病种场景(避免双召) deferToToothDx?: boolean; // 「建议拔除」场景专用:有同牙编码病种诊断 → 让位给该病种场景(避免双召)
polishImpliesRestoration?: boolean; // 缺失牙专用:缺牙位上的裸「抛光」= 修复体在位的证据(牙都没了,抛的是义齿)
treatedEvidenceFromEmrText?: boolean; // 本次动作是预防/复查类时,再审同次病历主诉/现病史找"已治疗"证据
archDentureIsRestored?: boolean; // 检查所见判出某颌有活动义齿 → 该颌缺牙位是"假缺失"
restorationInPlaceFromExam?: boolean; // 检查所见(单颌条目)写着修复体在位 → 该牙无新修复需求
} }
/// §E gap 修正 flag 的单一真理源(按 primaryCode 查)。召回 SUB_SCENARIOS 与画像标签都引此。 /// §E gap 修正 flag 的单一真理源(按 primaryCode 查)。召回 SUB_SCENARIOS 与画像标签都引此。
...@@ -41,6 +63,10 @@ export const GAP_FLAGS_BY_PRIMARY: Record<string, GapCfgFlags> = { ...@@ -41,6 +63,10 @@ export const GAP_FLAGS_BY_PRIMARY: Record<string, GapCfgFlags> = {
excludeThirdMolar: true, excludeThirdMolar: true,
excludeOrthoExtractionSites: true, excludeOrthoExtractionSites: true,
excludeCongenitalName: true, excludeCongenitalName: true,
polishImpliesRestoration: true,
treatedEvidenceFromEmrText: true,
archDentureIsRestored: true,
restorationInPlaceFromExam: true,
}, },
// 「建议拔除」独立场景:有同牙编码病种诊断 → 让位给病种场景,不重复召(残根=K03、阻生=K01…) // 「建议拔除」独立场景:有同牙编码病种诊断 → 让位给病种场景,不重复召(残根=K03、阻生=K01…)
EXTRACTION_RECOMMENDED: { EXTRACTION_RECOMMENDED: {
...@@ -232,6 +258,148 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces { ...@@ -232,6 +258,148 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
${afterDxFor('rvx')}` ${afterDxFor('rvx')}`
: Prisma.empty; : 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;
// (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(" || ' ' || "),
);
// ⛔ **按场景开闸,不默认全开**。机制本身与场景无关(判据是牙位级的),但词表是拿
// 缺失牙的 59 条命中逐条人工核出来的 —— 「戴牙」蕴含"这颗牙的修复做完了",对 K08 成立;
// 对 K02 龋齿/K04 根尖炎是否同样成立**没有验过**(那些场景理由量 18 万+,导不出来复核)。
// 少召不报错,没验过就不开。要给别的场景开,先把该场景的命中全量导出人工过一遍。
const treatedEvidenceBranch = cfgFlags.treatedEvidenceFromEmrText && 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(${[
...treatedEvidenceTriggersFor(resolverCats),
]}::text[])
AND (
tvx.content->>'category' <> ALL(${[...TREATED_EVIDENCE_BATCH_CATEGORIES]}::text[])
OR COALESCE(array_length(${toothArrSql(Prisma.sql`tvx.content->>'tooth_position'`)}, 1), 0)
<= ${TREATED_EVIDENCE_BATCH_MAX_TEETH}
)
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;
// (a'''''''') 检查所见写着修复体在位 —— 第三条举证路线(见 [[RESTORATION_IN_PLACE_TERMS_RE]])。
// 前两条(治疗名 / 主诉现病史)都要求患者**为那副修复体而来**;这条接的是"为别的牙来、
// 医生顺带记了全口状况"的那一类。周燕芬 TS0M013273 那次来做下前牙冠修复,上颌 11~27
// 记着「见活动义齿,基托边缘密合,伸展范围良好,固位力良好,无压痛」→ 上颌义齿明明在位。
// 🔴 同颌闸是前提:一条记录常挂一整排牙位,句子里上下颌状态可能相反(王志荣「下颌固位可,
// 上颌固位稍差」/ 童然夫「上颌卡环紧,下颌无法就位」)→ 跨颌条目一律跳过。
// 代价很小:生产 81,812 条提到义齿的检查所见里 87.8% 牙位本就单颌。
// ⛔ 失效词一票否决:做了但不能用(无法就位/脱落/折断)仍然该召回。
const restorationInPlaceBranch = cfgFlags.restorationInPlaceFromExam
? Prisma.sql`
UNION
SELECT ript AS t
FROM (
SELECT rix.content->>'exam_findings' AS ef_text, rix.occurred_at, rix.planned_for
FROM patient_facts rix
WHERE rix.patient_id = p.id
AND rix.type = 'emr_record' AND rix.status IN ('active', 'fulfilled')
AND rix.content->>'exam_findings' ~ '^\\['
AND rix.content->>'exam_findings' ~ ${RESTORATION_IN_PLACE_TERMS_RE}
${afterDxFor('rix')}
) rsrc
CROSS JOIN LATERAL jsonb_array_elements(rsrc.ef_text::jsonb) AS rief
CROSS JOIN unnest(${toothArrSql(Prisma.sql`rief->>'toothPosition'`)}) AS ript
WHERE (rief->>'message') ~ ${RESTORATION_IN_PLACE_TERMS_RE}
AND (rief->>'message') ~ ${RESTORATION_IN_PLACE_STATE_RE}
-- 🔴 先抹掉"否定式好话"(无松动/松动(-)…)再判失效词,否则裸「松动」会把好的一起否掉
AND regexp_replace(rief->>'message', ${RESTORATION_IN_PLACE_NEG_STRIP_RE}, '', 'g')
!~ ${RESTORATION_IN_PLACE_FAIL_RE}
-- 🔴 同颌闸:该条目牙位跨上下颌 → 整条跳过(句内可能上下颌状态相反)
AND NOT (
EXISTS (SELECT 1 FROM unnest(${toothArrSql(Prisma.sql`rief->>'toothPosition'`)}) ru WHERE ru ~ '^[12]')
AND
EXISTS (SELECT 1 FROM unnest(${toothArrSql(Prisma.sql`rief->>'toothPosition'`)}) rl WHERE rl ~ '^[34]')
)`
: Prisma.empty;
// (a''''''''') 「活动义齿 = 假缺失」按**颌**判定(见 [[ARCH_DENTURE_UPPER_RE]])。
// 戴活动义齿的患者诊断栏永远写「缺失牙」—— 天然牙确实没了,诊断没错;但那个位置已被义齿
// 盖住,「未**启动**修复」不成立(治疗做的就是义齿)。义齿松/紧/该重衬是维护,不是没做过。
// 活动义齿是整颌跨度的修复体 → 判出"这一颌有义齿",那一颌的缺牙位全部解除,
// 不需要条目牙位跟召回牙位对上,也天然绕开混合句(王志荣/童然夫那种跨颌描述)。
const archDentureBranch = cfgFlags.archDentureIsRestored
? Prisma.sql`
UNION
SELECT adt AS t
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->>'emr_external_id' = sig.content->>'source_encounter_external_id'
AND (ade->>'message') !~ ${ARCH_DENTURE_INTENT_EXCLUDE_RE}
-- 🔴 句子按颌定范围,牙位仍取该条目自己的 —— **不整颌铺开**,否则会盖掉同颌里
-- 义齿没覆盖到的缺牙位(上颌局部义齿只补 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` 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 家族里任一治疗
...@@ -330,6 +498,10 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces { ...@@ -330,6 +498,10 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
AND COALESCE(rdx.occurred_at, rdx.planned_for) >= COALESCE(sig.occurred_at, sig.planned_for) AND COALESCE(rdx.occurred_at, rdx.planned_for) >= COALESCE(sig.occurred_at, sig.planned_for)
AND sig.type = 'diagnosis_record' AND sig.type = 'diagnosis_record'
${reviewImpliesBranch} ${reviewImpliesBranch}
${polishImpliesBranch}
${treatedEvidenceBranch}
${restorationInPlaceBranch}
${archDentureBranch}
${orthoExtractBranch} ${orthoExtractBranch}
${deferDxBranch} ${deferDxBranch}
) u)`; ) u)`;
......
...@@ -459,16 +459,36 @@ export class PlanEngineService { ...@@ -459,16 +459,36 @@ export class PlanEngineService {
// 带到新版)→ 客服不丢单、不被抢,只是召回理由刷新到最新;理由没变则只就地刷分(下面 unchanged 分支)。 // 带到新版)→ 客服不丢单、不被抢,只是召回理由刷新到最新;理由没变则只就地刷分(下面 unchanged 分支)。
// 缺口全消失(0 命中)→ 即使 assigned 也关闭(见 closeStaleActivePlan)。 // 缺口全消失(0 命中)→ 即使 assigned 也关闭(见 closeStaleActivePlan)。
// ⭐ 信号级抑制(召回闭环核心)— 抑制粒度 = 召回算法粒度 (scenario, subKey) // ⭐ 抑制(召回闭环核心)—— 判定粒度 = (scenario, subKey),生效粒度 = **整个 plan**
// 只压"已结案那条召回覆盖的诊断";患者新长的、不在已结案理由里的诊断照常召回(不受冷静期影响)。 // 取该患者所有"终态(completed/abandoned)+ 冷静期未到期"plan 的 reason (scenario, subKey)
// 做法:取该患者所有"终态(completed/abandoned)+ 冷静期未到期"plan 的 reason (scenario, subKey) // 并集作抑制集,再看本轮 hit 里**有没有任何一条**逃得出去(不在集里 / 结案后新发):
// 并集作抑制集,逐 hit 过滤掉命中抑制集的信号;剩下的(全新诊断)继续走下面生成逻辑。 // · 一条都逃不出去 → 整个 plan 不生成
// 修复的 bug:旧版"整患者抑制" → 成功转化 K08 后,snooze 期内新长的 K02 也被压住不召(误伤新缺口)。 // · 有任意一条逃得出去 → 本轮全部 hit 都进 plan
// 多次结案也正确:查的是全部未到期终态 plan 的并集(不止 latest 一条)。 // 多次结案也正确:查的是全部未到期终态 plan 的并集(不止 latest 一条)。
// 历史:旧版是"整患者抑制"(成功转化 K08 后,snooze 期内新长的 K02 也被压住 → 误伤新缺口);
// 中间版改成逐 hit 过滤,治好了误伤,却引入了"部分抑制"(见下方 2026-08-28 注释)。
const snoozedAnchors = prefetched const snoozedAnchors = prefetched
? prefetched.snoozedAnchors ? prefetched.snoozedAnchors
: await this.fetchSnoozedSignalKeys(scope, patientId, scope.now); : await this.fetchSnoozedSignalKeys(scope, patientId, scope.now);
const usableHits = // ⭐ 2026-08-28 改为**全有或全无**(业务口径:抑制粒度必须跟处置粒度一致)。
// ── 为什么 ──
// 处置是**计划级**的:客服点一次「已完成治疗」,压的是那个 plan 名下全部 reason
// (schema PlanExecution.inaccurateTreatments 注释:客服勾"只有种植不准"也不会只放过
// 根管那条 —— 业务 2026-07-30 明确)。抑制却是**逐 hit** 过滤的,两个粒度对不上,
// 于是出现「部分抑制」:一部分理由回来了、另一部分还压着。
// 陆雪 TS0K090243 就是这么坏的:08-23 客服标「已完成治疗」压掉 4 条(缺牙种植 /
// 牙周 / 影像AI缺牙 / 残根),三天后我们的残根→K03 改判把 subKey 从 missing_tooth@17
// 换成 hard_tissue_damage@17,它不在抑制集里 → 只有它一条复活。结果卡片上:
// 病历三类应治未治、画像标签也写着三类,召回理由却只剩一条 —— 读的人只能以为算法漏了。
// 而那次「已完成治疗」本身还是错的(该患者信号后只做过 OHI),等于把两条正确召回
// 永久埋掉(treated 的 suppressDays = 36500 天)。
// ── 新规则 ──
// 缺口开没开由**事实**说了算,抑制只是人为覆盖,不改写事实:
// · 一条都没逃逸 → 整个 plan 不生成(一起抑制)
// · 有任意一条逃逸 → 该患者本轮**全部** hit 都进 plan(一起生效)
// 如实反映:理由在,它牵涉的那些理由同样在。真治好了的缺口会被 gap 计算自己消掉,
// 根本轮不到抑制来盖 —— 抑制盖住的从来只是①数据没摄到 或②人标错了,这两种都不该永久。
const escapedHits =
snoozedAnchors.size === 0 snoozedAnchors.size === 0
? hits ? hits
: hits.filter((h) => { : hits.filter((h) => {
...@@ -480,10 +500,13 @@ export class PlanEngineService { ...@@ -480,10 +500,13 @@ export class PlanEngineService {
const latest = h.latestSignalOccurredAt; const latest = h.latestSignalOccurredAt;
return latest != null && latest > anchor; return latest != null && latest > anchor;
}); });
if (usableHits.length === 0) { if (escapedHits.length === 0) {
// 当前所有活信号都在冷静期内(= 刚结案那批,且无结案后新发)→ 不生成新 plan // 当前所有活信号都在冷静期内(= 刚结案那批,且无结案后新发)→ 不生成新 plan
return 'suppressed'; return 'suppressed';
} }
// ⛔ 这里**故意**用 hits 而不是 escapedHits —— 见上方「全有或全无」。
// 改回 escapedHits 就退回部分抑制,而且不会报错,只会让卡片再次跟病历对不上。
const usableHits = hits;
const newPriorityScore = Math.max(...usableHits.map((h) => h.priorityScore)); const newPriorityScore = Math.max(...usableHits.map((h) => h.priorityScore));
......
...@@ -142,8 +142,12 @@ export function temperatureBucketCaseSql( ...@@ -142,8 +142,12 @@ export function temperatureBucketCaseSql(
* 关联条件变成恒真且 SQL 不报错)—— 那种场景用下方的 `labelExistsSql` / `labelTemperatureExistsSql`。 * 关联条件变成恒真且 SQL 不报错)—— 那种场景用下方的 `labelExistsSql` / `labelTemperatureExistsSql`。
* *
* ⚠️ `f.status = 'active'`:治完的诊断是 `fulfilled` 不是删除。拿它当证据 = * ⚠️ `f.status = 'active'`:治完的诊断是 `fulfilled` 不是删除。拿它当证据 =
* 对着一个已经做完的诊断说"您还没做"。当前数据 5,034 条证据全是 active, * 对着一个已经做完的诊断说"您还没做"。这条过滤是**不变量守卫** —— 治疗落库后
* 这条过滤是**不变量守卫** —— 治疗落库后引擎还没重算的窗口期里,它就是唯一防线。 * 引擎还没重算的窗口期里,它就是唯一防线。
* 2026-08-28 生产实测(全量,不再是早期 5,034 条的小样本):在跑计划的 1,092,580 条
* active 理由 + 1,265 条 assigned + 41 条 completed,**无一条**取不到 active 证据;
* 只有 abandoned 里 6/282 取不到。即引擎重算追得上事实换代,守卫没有在挡真数据。
* ⚠️ 这个数掉下来 = 重算落后于摄入,格位会静默变空(不报错),值得当信号看。
* 🔴 `anc.at IS NOT NULL` **必须留着**,即便锚点已经换成末诊、不再用它定档: * 🔴 `anc.at IS NOT NULL` **必须留着**,即便锚点已经换成末诊、不再用它定档:
* 它是「这条证据有日期」的守卫,决定**哪些 plan 进得来**。删掉行集就变了 —— * 它是「这条证据有日期」的守卫,决定**哪些 plan 进得来**。删掉行集就变了 ——
* 而多出来的那些人不会报错,只会悄悄出现在格子里。 * 而多出来的那些人不会报错,只会悄悄出现在格子里。
......
/**
* 「活动义齿 = 假缺失」按颌判定。
*
* 戴活动义齿的患者诊断栏**永远**写「缺失牙」—— 天然牙确实没了,诊断没错;
* 但那个位置已被义齿盖住,「缺失牙未**启动**修复」不成立(治疗做的就是义齿)。
* 活动义齿是整颌跨度的修复体 → 判出"这一颌有义齿",那一颌的缺牙位全部解除。
*
* 🔴 本条**天然绕开混合句** —— 王志荣/童然夫的检查所见牙位横跨上下颌,
* 按条目判的那条(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_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';
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('🔴 牙位由条目自己给,不整颌铺开', () => {
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('🔴 只提上颌 → 下颌牙位不得被解(义齿没覆盖到的那一颌)', () => {
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)开闸', () => {
const on = Object.entries(GAP_FLAGS_BY_PRIMARY)
.filter(([, f]) => f.archDentureIsRestored === true)
.map(([code]) => code);
expect(on).toEqual(['K08']);
});
});
...@@ -94,3 +94,126 @@ describe('既有裁决顺序不被新词打乱', () => { ...@@ -94,3 +94,126 @@ describe('既有裁决顺序不被新词打乱', () => {
expect(cat(s)).toBe(want); expect(cat(s)).toBe(want);
}); });
}); });
// ═══════════════════════════════════════════════════════════════
// 2026-08-28 补:义齿维护族 + 扩根 / 松牙固定 / 用氟 / 洁牙
// 缘起 薛希明 TS0K018752 —— 活动义齿早已戴上,复诊「卡环加力」落 _default 整条丢弃,
// 那次就诊在系统里没有任何治疗 → 判「缺失牙未启动修复」误召。
// 每个新词锁一条**碰撞**用例:新词最大的风险不是没命中,是抢了前面该赢的。
// ═══════════════════════════════════════════════════════════════
describe('义齿维护族 → prosthodontic(卡环/基托/重衬/假牙)', () => {
it.each([
'卡环加力', // 薛希明本例
'紧卡环',
'调整卡环',
'加卡环',
'基托选磨',
'舌侧基托选磨',
'基托缓冲',
'基托修理',
'重衬',
'下颌重衬',
'调假牙',
'戴活动假牙',
'调磨假牙',
])('%s', (s) => {
expect(cat(s)).toBe('prosthodontic');
});
it('⭐ 碰撞:活动矫治器也有卡环/基托 —— orthodontic 排在前面,必须先赢', () => {
expect(cat('矫治器卡环调整')).toBe('orthodontic');
expect(cat('正畸基托调磨')).toBe('orthodontic');
expect(cat('保持器重衬')).toBe('orthodontic');
});
it('⭐ 碰撞:种植/根管语境仍归各自类目(prosthodontic 排在它们之后)', () => {
expect(cat('种植覆盖义齿卡环加力')).toBe('implant');
expect(cat('根管治疗后基托修理')).toBe('endodontic');
});
});
describe('扩根 → endodontic', () => {
it.each(['扩根', '扩根换药', '扩根封药', '继续扩根', '定长扩根'])('%s', (s) => {
expect(cat(s)).toBe('endodontic');
});
it('⭐ 碰撞:正畸「扩弓」不含「扩根」,不被误吃', () => {
expect(cat('扩弓')).toBe('orthodontic');
});
});
describe('松牙固定 → periodontic', () => {
it.each(['松牙固定', '松牙固定术', '下前牙松牙固定'])('%s', (s) => {
expect(cat(s)).toBe('periodontic');
});
});
describe('用氟 → preventive', () => {
it.each(['局部用氟', '定期局部用氟'])('%s', (s) => {
expect(cat(s)).toBe('preventive');
});
});
describe('洁牙 → periodontic(独立规则 + none: 清洁牙)', () => {
it.each(['洁牙', '全口超声洁牙', '超声洁牙', '超声波洁牙', '洁牙检查'])('%s', (s) => {
expect(cat(s)).toBe('periodontic');
});
it('🔴 「清洁牙面 / 清洁牙齿」含「洁牙」二字但不是洗牙 —— 必须仍然丢弃', () => {
// actual 侧收的是 dispose 散文,这三串在生产共 332 条,是充填/正畸流程里的清洁动作
expect(cat('清洁牙面')).toBeUndefined();
expect(cat('清洁牙齿')).toBeUndefined();
expect(cat('清洁牙间隙')).toBeUndefined();
expect(cat('清洁牙齿 交付新牙套')).toBeUndefined();
});
it('🔴 none 挂在独立规则上,不能误挡混合句 —— 上一行的牙周词仍须命中', () => {
expect(cat('清洁牙面,龈下刮治')).toBe('periodontic');
expect(cat('清洁牙面后行龈上洁治')).toBe('periodontic');
});
it('⭐ 碰撞:含充填动作的仍归 restorative(restorative 排在 periodontic 之前)', () => {
expect(cat('洁牙后树脂充填')).toBe('restorative');
});
});
describe('调合 / 调颌 → prosthodontic(2026-08-28 陈惠英 TS0K028757)', () => {
it.each(['调合,抛光', '调合抛光', '调颌,抛光', '调颌抛光', '调合 抛光', '调合'])('%s', (s) => {
expect(cat(s)).toBe('prosthodontic');
});
it('🔴 ⛔ 必须排在 restorative 之后 —— 补牙散文含「调合」不能被改判成修复', () => {
// 生产真实串,各上千条;它们本来就是 restorative,加规则前后必须一致
expect(cat('去腐净,GIC垫,Z250充填,调合抛光。')).toBe('restorative');
expect(cat('去龋备洞,粘结,Z350充,调合抛光。')).toBe('restorative');
expect(cat('上橡皮障,去腐净达牙本质深层,赛光垫底,3MZ350树脂充填,调牙合,抛光。')).toBe(
'restorative',
);
});
it('⭐ 碰撞:种植 / 正畸语境仍由前面的规则先赢', () => {
expect(cat('种植体调合')).toBe('implant');
expect(cat('正畸调合')).toBe('orthodontic');
});
it('⛔ 「调磨」仍然不收(姑息调整,见词表文末 ⛔ 块)', () => {
expect(cat('调磨')).toBeUndefined();
});
});
// ═══════════════════════════════════════════════════════════════
// 2026-08-28 重评「⛔ 刻意不收」:调磨/试戴/冲洗/换药/上药 改为**路由到 review**
// (manifest route_by_pattern),不再整条丢弃。它们仍然**不进本含词表** ——
// review 由 route 分流产生,词表里没有任何映射到 review 的条目。
// 这里锁住"不要顺手把它们补进含词表"(补进去 = 归了某个真治疗类目 = 会解缺口)。
// ═══════════════════════════════════════════════════════════════
describe('⛔ 姑息处置词不得出现在含词表里(它们走 route → review)', () => {
it.each(['调磨', '试戴', '冲洗', '换药', '上药'])('%s 仍不被含词表接管', (s) => {
expect(cat(s)).toBeUndefined();
});
it('⭐ 但「调合」是例外 —— 宿主精确表早已判定它是修复动作,不是姑息调整', () => {
expect(cat('调合')).toBe('prosthodontic');
});
});
/**
* 姑息处置词(调磨/试戴/冲洗/换药/上药)→ route 到 review,不再整条丢弃。
*
* ── 为什么改 ──
* 原「⛔ 刻意不收」块的根因写着「category 被治疗史与 resolver 两处消费,只有一个旋钮,
* 只能二选一(现选都不要=丢弃)」—— 前提不成立:第二个旋钮就是 review 类目,
* 且它就是为这件事设计的(PACTreatmentCategories.review:「医生做了某个流程节点/
* 临床判断本次不动手」「不该塞 preventive,也不该丢弃」),review 在 0 个 resolver 家族里。
* 触发个案:季炎萍 TS0B010543 —— treat_plan 只有「调磨」被丢 → 库里零条治疗事实。
*
* ── 🔴 本测试锁两件事,任一被"顺手优化"掉都会静默出事 ──
* ① 这五个词必须落 _treatment_review_raw(既不被丢,也不进真治疗表)
* ② **它们不得进 &review_terms 那个 anchor** —— 那张表被 C.9 dispose 闸门复用,
* 并进去会同时打开闸门。实测那 4,336 份 EMR 处置 100% 非空,TOP400 里约 216 条
* 会落进结构 resolver 家族,含「冠周冲洗,派力奥上药」→prosthodontic(含"冠"字)、
* 「去暂封…玻璃离子暂封」→restorative 这类误判 → 误销缺口。
*
* 跑:
* pnpm test -- palliative-route-to-review
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as yaml from 'js-yaml';
import { runRouteByPattern } from '../src/modules/sync/transforms/operators/route-by-pattern.op';
const PALLIATIVE = ['调磨', '试戴', '冲洗', '换药', '上药'] as const;
const manifest = yaml.load(
fs.readFileSync(path.join(__dirname, '../data/jvs-dw/manifest.yaml'), 'utf-8'),
) as { transforms: Array<Record<string, unknown>> };
const treatPlanRoute = manifest.transforms.find(
(t) => t.kind === 'route_by_pattern' && t.input === '_treat_plan_raw',
) as { routes: Array<{ output: string; when: Record<string, unknown> }> };
const disposeGate = manifest.transforms.find(
(t) => t.kind === 'filter' && t.output === '_emr_dispose_gate',
) as { where: { treat_plan: { blank_or_all_in: string[] } } };
const route = (names: string[]): Record<string, string[]> => {
const { outputs } = runRouteByPattern(
treatPlanRoute as never,
names.map((n) => ({ treat_name: n })) as never,
);
return Object.fromEntries(
Object.entries(outputs).map(([k, rows]) => [
k,
(rows as Array<{ treat_name: string }>).map((r) => r.treat_name),
]),
);
};
describe('① 姑息处置 → _treatment_review_raw', () => {
it('五个词全部落 review 表', () => {
const out = route([...PALLIATIVE]);
expect(out['_treatment_review_raw']).toEqual([...PALLIATIVE]);
expect(out['_treatment_actual_raw_emr'] ?? []).toEqual([]);
});
it('⛔ 真治疗不受影响,仍走 actual', () => {
const out = route(['树脂充填', '调合', '抛光', '种植戴牙']);
expect(out['_treatment_actual_raw_emr']).toEqual(['树脂充填', '调合', '抛光', '种植戴牙']);
expect(out['_treatment_review_raw'] ?? []).toEqual([]);
});
it('原有复查词行为不变(同落 review 表)', () => {
expect(route(['常规复查'])['_treatment_review_raw']).toContain('常规复查');
});
it('「建议…」仍走 recommendation,不被本条 route 抢走', () => {
expect(route(['建议种植修复'])['_recommendation_raw']).toEqual(['建议种植修复']);
});
});
describe('② 🔴 dispose 闸门必须维持原状(anchor 不得合并)', () => {
it.each([...PALLIATIVE])('%s 不得出现在 dispose 闸门词表里', (w) => {
expect(disposeGate.where.treat_plan.blank_or_all_in).not.toContain(w);
});
it('闸门词表仍是原来那 34 个复查/流程词', () => {
expect(disposeGate.where.treat_plan.blank_or_all_in).toHaveLength(34);
expect(disposeGate.where.treat_plan.blank_or_all_in).toContain('常规复查');
});
it('⭐ 两条 route 指向同一个 review 表 —— 这是拆 anchor 的手段,不是笔误', () => {
const toReview = treatPlanRoute.routes.filter((r) => r.output === '_treatment_review_raw');
expect(toReview).toHaveLength(2);
});
});
...@@ -460,6 +460,75 @@ describe('runAllForHost 批量路径 — 5 种结局等价', () => { ...@@ -460,6 +460,75 @@ describe('runAllForHost 批量路径 — 5 种结局等价', () => {
expect(plans.filter((p) => p.patientId === 'pat-d' && p.status === 'active')).toHaveLength(0); expect(plans.filter((p) => p.patientId === 'pat-d' && p.status === 'active')).toHaveLength(0);
}); });
// ═══ 2026-08-28 抑制改为「全有或全无」(陆雪 TS0K090243)═══
// 处置是计划级的(客服点一次「已完成治疗」压掉该 plan 全部 reason),抑制却曾是逐 hit 的,
// 于是出现"部分抑制":一部分理由复活、另一部分还压着 → 卡片跟病历对不上。
test('⭐ 部分逃逸 → 被压的理由**一起复活**(不是只回来逃逸那条)', async () => {
const { prisma, plans } = makeStore({
plans: [
{
id: 'p-term-partial',
patientId: 'pat-partial',
version: 1,
status: 'abandoned',
snoozedUntil: new Date('2026-12-01T00:00:00Z'),
// 客服一次「已完成治疗」压掉这三条
reasons: [
{ scenario: SCEN, subKey: 'missing_tooth@17' },
{ scenario: SCEN, subKey: 'missing_tooth@36;37;46;47' },
{ scenario: SCEN, subKey: 'perio_no_srp@whole' },
],
},
],
});
const res = await engine(
prisma,
makeScenario([
// 同一条诊断被改判 → subKey 变了,不在抑制集里 → 逃逸
hit('pat-partial', 'hard_tissue_damage@17', 60),
// 这两条仍在抑制集里 —— 旧口径会被过滤掉,新口径必须跟着一起回来
hit('pat-partial', 'missing_tooth@36;37;46;47', 40),
hit('pat-partial', 'perio_no_srp@whole', 30),
]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
expect(res.plansSuppressed).toBe(0);
// 已有 v1(终态)→ 计数走"升版本"而非"新建",这是既有计数口径,不是本次改动引入的
expect(res.plansSuperseded).toBe(1);
const created = plans.find((p) => p.patientId === 'pat-partial' && p.status === 'active');
expect(created!.reasons.map((r) => r.subKey).sort()).toEqual(
['hard_tissue_damage@17', 'missing_tooth@36;37;46;47', 'perio_no_srp@whole'].sort(),
);
// 顶层分数取全部 hit 的最大值(不是只看逃逸那条)
expect(created!.priorityScore).toBe(60);
});
test('⭐ 一条都逃不出去 → 整个 plan 不生成(一起抑制,口径不变)', async () => {
const { prisma, plans } = makeStore({
plans: [
{
id: 'p-term-all',
patientId: 'pat-all',
version: 1,
status: 'abandoned',
snoozedUntil: new Date('2026-12-01T00:00:00Z'),
reasons: [
{ scenario: SCEN, subKey: 'missing_tooth@17' },
{ scenario: SCEN, subKey: 'perio_no_srp@whole' },
],
},
],
});
const res = await engine(
prisma,
makeScenario([hit('pat-all', 'missing_tooth@17'), hit('pat-all', 'perio_no_srp@whole')]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
expect(res.plansSuppressed).toBe(1);
expect(res.plansCreated).toBe(0);
expect(plans.filter((p) => p.patientId === 'pat-all' && p.status === 'active')).toHaveLength(0);
});
test('stale-close:有 active 但本轮 0 命中 → plansClosed=1', async () => { test('stale-close:有 active 但本轮 0 命中 → plansClosed=1', async () => {
const { prisma, plans } = makeStore({ const { prisma, plans } = makeStore({
plans: [ plans: [
......
/**
* 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);
});
});
/**
* RESTORATION_IN_PLACE_* —— 检查所见写着修复体在位(缺牙缺口的第三条举证路线)。
*
* 前两条(治疗名 / 主诉现病史)都要求患者**为那副修复体而来**;这条接的是
* "为别的牙来、医生顺带记了全口状况"的那一类 —— 周燕芬 TS0M013273 即是。
*
* 判据 = 修复体名词 ∧ 在位状态词 ∧ ¬失效词,且条目牙位不跨颌。
* 🔴 失效词是命门:修复体**做了但不能用**仍然该召回。
*
* 跑:
* pnpm test -- restoration-in-place-from-exam
*/
import {
RESTORATION_IN_PLACE_TERMS_RE,
RESTORATION_IN_PLACE_STATE_RE,
RESTORATION_IN_PLACE_FAIL_RE,
RESTORATION_IN_PLACE_NEG_STRIP_RE,
} from '@pac/types';
import { GAP_FLAGS_BY_PRIMARY } from '../src/modules/clinical-gap/potential-treatment-gap.sql';
const TERMS = new RegExp(RESTORATION_IN_PLACE_TERMS_RE);
const STATE = new RegExp(RESTORATION_IN_PLACE_STATE_RE);
const FAIL = new RegExp(RESTORATION_IN_PLACE_FAIL_RE);
const NEG = new RegExp(RESTORATION_IN_PLACE_NEG_STRIP_RE, 'g');
/** 模拟三条件(不含同颌闸 —— 那是牙位层面的,由 SQL 负责) */
const inPlace = (msg: string): boolean =>
TERMS.test(msg) && STATE.test(msg) && !FAIL.test(msg.replace(NEG, ''));
describe('正例 —— 生产真实检查所见,判为修复体在位', () => {
it.each([
// 🔴 周燕芬 TS0M013273 上颌 11~27(单颌条目)—— 本条路线的触发个案
'见活动义齿,基托边缘密合,伸展范围良好,固位力良好,无压痛',
'下颌覆盖牙齿在位,无松动,就位可,边缘贴合;杆卡无松动,牙龈无红肿',
'可见全瓷冠修复,边缘密合邻接良好,咬合合适,牙龈及黏膜未见明显异常',
'见全冠修复体,叩(-),无松动,牙龈未见明显异常',
'义齿密合良好,牙龈无红肿',
// ⛔ 「固位良好/固位稳定」不得被「固位…不良」那条贬义模式误否
'铸造活动义齿,固位稳定,咬合关系良好。', // 邢五一 BJ0A078837
'缺失,粘膜无异常,牙槽嵴有明显吸收。活动义齿固位良好。', // 孙玉娟 GZ0A031993
// 🔴 「无松动」类是好话,抹掉后不得被裸「松动」否掉
'金属烤瓷冠修复桥体,边缘密合,未见明显松动,冷叩诊无不适', // 陈铭燕 GZ0A016429
'烤瓷冠,边缘密合,探痛(—),冷热(—),叩(—)松动(—)', // 马琦 SC10794
'活动义齿在位,牙龈粘膜未见明显异常,37牙体缺损至平龈,叩(-),未见明显松动', // 李太花
'46口内未探及,45-47烤瓷固定桥修复,边缘密合度一般,无明显松动。', // 丁亚茹
// ⛔ 「尚可/尚密合/较密合」是可接受状态,不得被贬义模式误否
'已行固定桥修复,边缘密合度尚可,冷不敏,叩(-),不松动', // 陈晓晖 BJ0D033690
'冠桥修复体,边缘较密合,叩诊无不适', // 李科伟 BD13007
'全瓷冠桥修复,近中单端悬臂,25#缺失,冠边缘位于龈上尚密合,叩-,龈-,松-', // 周斯男 SH0L005714
// ⛔ 贬义词跨标点不得误否:「边缘密合」后面另起一句说别的差
'种植冠修复,边缘密合,牙龈无红肿,口腔卫生差'
])('%s', (msg) => {
expect(inPlace(msg)).toBe(true);
});
});
describe('🔴 反例 —— 做了但不能用 / 还没做,必须仍然召回', () => {
it.each([
// 王茜 BJ0F028868:外院假牙做好了却装不上
'外院制作右下活动假牙无法就位一周',
'活动义齿基托折断,无法佩戴',
'烤瓷冠脱落,牙体缺损,边缘尚密合', // 有"密合"但也有"脱落" → 失效词一票否决
'义齿破损,固位力良好但影响进食',
'牙列缺损,建议活动义齿修复,患者考虑中',
'缺牙区黏膜良好,要求义齿修复',
'上颌活动义齿不贴合,需重衬', // 童然夫/周根娣同类:不贴合 = 需处理
// 🔴 生产实测第一版误销的那批 —— 「密合」子串会匹配它的否定形式
'固定桥修复,边缘欠密合,龈缘红肿', // 赵河 BA27761
'锤造冠固定桥修复,密合度差,继发龋坏', // 陈德家 BA40818
'固定桥修复,边缘密合度欠佳,牙龈(-)叩(-)松动(-)', // 张华 BJ0A054066
'固定桥修复,边缘密合性不佳,46近中可探入', // 王竞男 BJ0A090886
'金属烤瓷冠桥修复长桥,修复体松动。边缘欠密合,部分崩瓷,咬合欠佳', // 翟健民 BJ0C021369
'种植修复体,松动I,边缘不密合', // 李露 BJ0D049016
'缺失,活动义齿修复,不可摘除,不密合', // 王秀梅 BJ0D030624
'胶连活动义齿修复,义齿与软组织不密合,客人自觉舌侧基托厚,漏风,影响发音', // 朱少宇 BJ0A081328
'固定桥,崩瓷,边缘不密合,叩(-),不松,龈水肿', // 余健 BJ0D024381
// 🔴 否定前缀第二次踩到:「无义齿修复」含「义齿」却是说没有
'缺失,无义齿修复,46,47均向近中移位,46烤瓷冠修复,边缘密合,无异常', // 徐磊 BJ0U001139
// 基牙松动度的另两种写法
'烤瓷冠修复体,边缘较密合,11Ⅰ°松,21Ⅱ°松,22Ⅰ-Ⅱ°松', // 王秋枫 BJ0D053568
'可见全冠,边缘密合,松动二度,叩(+)', // 侯永生 BJ0R008817
'瓷修复体存,边缘密合,牙松动2度,PD:8mm', // 康振英 GZ0A019990
'固定桥完好,边缘密合较差,叩痛(-),有明显松动', // 吴实 SH0T009924
// 🔴 第四轮:「密合」的否定形式列不完,改用模式匹配
'单端固定桥;44-47烤瓷牙+树脂基托;牙冠形态不良,密合度不佳,局部牙龈红肿', // 张顺宝 SH0K018368
'金属全冠修复体,边缘密合差,卡探针', // 姜华 ZJ0B010604
'覆盖义齿修复,固位不良,port基台稳定无松动,牙龈未见异常', // 周锡英 TS0B001674
])('%s → 不作数', (msg) => {
expect(inPlace(msg)).toBe(false);
});
it('没有修复体名词,只是牙龈好 → 不作数', () => {
expect(inPlace('牙龈未见明显异常,无压痛')).toBe(false);
});
it('有修复体但没说状态 → 不作数', () => {
expect(inPlace('见活动义齿')).toBe(false);
});
});
describe('表自身自洽', () => {
it('⛔ 不收裸「冠」—— 天然牙冠也叫冠', () => {
expect(RESTORATION_IN_PLACE_TERMS_RE).not.toMatch(/(^|\|)(\||$)/);
expect(inPlace('牙冠完好,无松动')).toBe(false);
// 代价:省略定语的「冠边缘密合」接不住。那类患者通常另有治疗名/主诉证据
// (陶美玉 TS0K070812 就被「种植复查」和处置「调合」两条路各接住一次),
// 不值得为它放宽到裸「冠」。
expect(inPlace('冠边缘密合,牙龈未见异常')).toBe(false);
});
it('🔴 失效词必须含"无法就位/脱落/折断" —— 做了但不能用仍该召回', () => {
for (const w of ['无法就位', '脱落', '折断', '不贴合', '不密合', '欠密合', '崩瓷']) {
expect(RESTORATION_IN_PLACE_FAIL_RE).toContain(w);
}
});
it('🔴 只对缺失牙(K08)开闸 —— 判据是拿 K08 的检查所见核出来的', () => {
const on = Object.entries(GAP_FLAGS_BY_PRIMARY)
.filter(([, f]) => f.restorationInPlaceFromExam === true)
.map(([code]) => code);
expect(on).toEqual(['K08']);
});
it('三个正则都可编译', () => {
for (const re of [
RESTORATION_IN_PLACE_TERMS_RE,
RESTORATION_IN_PLACE_STATE_RE,
RESTORATION_IN_PLACE_FAIL_RE,
]) {
expect(() => new RegExp(re)).not.toThrow();
}
});
});
/**
* TREATED_EVIDENCE_* —— 病历自由文本自证已治疗(补充举证路线)。
*
* 判据 = 修复体词 ∧ 完成标记 ∧ ¬未发生标记。三个条件缺一不可,
* 少一个就会把"还没做"判成"已做" → 少召,而少召不报错。
* 下面正反例**全部取自生产真实串**(主诉 / 现病史 / 处置)。
*
* 跑:
* pnpm test -- treated-evidence-from-emr-text
*/
import { GAP_FLAGS_BY_PRIMARY } from '../src/modules/clinical-gap/potential-treatment-gap.sql';
import {
TREATED_EVIDENCE_RESTORATION_TERMS,
TREATED_EVIDENCE_COMPLETION_RE,
TREATED_EVIDENCE_INTENT_EXCLUDE_RE,
TREATED_EVIDENCE_EMR_FIELDS,
treatedEvidenceTriggersFor,
TREATED_EVIDENCE_BATCH_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'],
// ⭐ 量词含「数/多/几」—— 季炎萍 TS0B010543,不认就漏
['下颌活动义齿戴牙数年余', 'prosthodontic'],
['患者下颌活动义齿戴牙数年余,有压痛,今来就诊', 'prosthodontic'],
['全口义齿戴用多年', '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('⭐ 触发条件 = 该动作解不开本场景的缺口(不是硬清单)', () => {
const k08 = resolverCategoriesFor('K08') as readonly string[];
const trig = treatedEvidenceTriggersFor(k08);
// 治疗类走现有 resolver,不进本表
for (const c of ['implant', 'prosthodontic', 'restorative', 'endodontic', 'surgical']) {
expect(trig).not.toContain(c);
}
// 🔴 牙周必须在内 —— 王祖妹 TS0B006805 那次动作是「转洁牙中心全口牙洁治」(periodontic),
// 硬写 ['preventive','review'] 会漏掉她
for (const c of ['preventive', 'review', 'periodontic', 'orthodontic']) {
expect(trig).toContain(c);
}
});
it('⛔ 牙周/正畸是全口批量记录,额外加牙位上限', () => {
expect([...TREATED_EVIDENCE_BATCH_CATEGORIES].sort()).toEqual(['orthodontic', 'periodontic']);
});
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).toContain('数');
expect(new RegExp(TREATED_EVIDENCE_COMPLETION_RE).test('戴牙数年余')).toBe(true);
expect(new RegExp(TREATED_EVIDENCE_COMPLETION_RE).test('戴用多年')).toBe(true);
});
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('🔴 ⛔ 只对缺失牙(K08)开闸 —— 词表是拿 K08 的命中人工核出来的,别的场景没验过', () => {
const on = Object.entries(GAP_FLAGS_BY_PRIMARY)
.filter(([, f]) => f.treatedEvidenceFromEmrText === true)
.map(([code]) => code);
expect(on).toEqual(['K08']);
});
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();
}
});
});
...@@ -461,6 +461,282 @@ export const REVIEW_IMPLIES_TREATMENT: ReadonlyArray<{ ...@@ -461,6 +461,282 @@ export const REVIEW_IMPLIES_TREATMENT: ReadonlyArray<{
{ pattern: '牙周[^,,;;]*(复查|复诊)', category: 'periodontic', why: '牙周复查蕴含基础治疗做过' }, { 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;
/**
* 「病历自由文本自证已治疗」—— **补充举证路线**,只在本次动作是预防/复查类时才启动。
*
* ── 大前提不动 ──
* 最新诊断永远第一位:诊断了就是机会。⛔ **不用「前有治疗、后有诊断」去翻案** ——
* 那只是一条猜疑链(修复体会失败、会脱落、会需要重做,旧治疗不能证明今天还在位)。
* 本表要做的是把**诊断之后、本次就诊内**的动作判得更有说服力:
* · 本次动作是**治疗类** → 现有 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;
/// 启动条件不是硬清单,而是「本次动作**解不开这个场景的缺口**」——
/// 即 category ∉ 该场景的 resolverCats。硬写 ['preventive','review'] 会漏:
/// 王祖妹 TS0B006805 牙46 那次的动作是「转洁牙中心全口牙洁治」(periodontic),
/// 主诉写着「种植戴牙4年余按约复查」,却因类目不在清单里而不触发。
/// 牙周/洗牙对缺牙缺口同样是"什么也没解决",判据应当一致。
/// (生产实测放宽后只多 3 条命中,形状与王祖妹一致,全部正确。)
export const treatedEvidenceTriggersFor = (
resolverCats: readonly string[],
): readonly PACTreatmentCategory[] =>
(Object.keys(PACTreatmentCategories) as PACTreatmentCategory[]).filter(
(c) => !resolverCats.includes(c),
);
/// ⛔ 天然批量的类目:牙周洁治/正畸的牙位是**全口批量**写的(一条记录挂 28 颗),
/// 不表达"针对这颗牙",拿它当牙位锚会大面积误销 → 这两类额外加牙位上限。
/// 预防/复查类不设上限:那些牙位是医生针对性写的,且全口种植修复复查确实会写满一颌
/// (吴庆安 12 颗 / 高惠霞 14 颗 / 周根娣 15 颗,实测都是对的)。
export const TREATED_EVIDENCE_BATCH_CATEGORIES = ['periodontic', 'orthodontic'] as const;
export const TREATED_EVIDENCE_BATCH_MAX_TEETH = 8;
/// 修复体词 → 它蕴含的治疗类目(消费方须用 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: '戴牙 = 修复完成(一期/二期/取模都不算);义齿/修复体在位才谈得上戴了多久',
},
];
/// 完成标记 —— 时间跨度 / 按约 / 术后 / 修复体的症状。
/// 症状类(脱落/松动/折断/压痛)同样蕴含"东西在那儿":不存在的修复体不会松、不会掉。
/// 🔴 ⛔ **不收症状词**(脱落/松动/折断)。原先收了,理由是"不存在的东西不会掉" ——
/// 那个推理只证明它**曾经**存在,不证明它**现在**在位。而对「缺失牙未修复」这个判定,
/// 脱落恰恰等于需要重做 = 该召回。生产实测误销:「前牙牙冠脱落数日」「金属固定桥松动」。
/// 量词含「数/多/几」:真实主诉大量写「戴牙**数年余**」「多年」「几个月」而非确切数字
/// (季炎萍 TS0B010543 主诉「下颌活动义齿戴牙数年余」——不认就漏)。
export const TREATED_EVIDENCE_COMPLETION_RE =
'[0-90-9一二三四五六七八九十两半数多几]+[个]?(天|周|月|年)|按约|按计划|复查|复诊';
/// 未发生标记 —— 命中即整段不作数。语义与 keyword_strip_clauses 一致(剥"还没做"),
/// 另补主诉特有的诉求词(要求/拟/打算/咨询/意向)。⛔「计划」不收 —— 会误伤「按计划复诊」。
/// 分三类:①诉求(还没做) ②种植未完成态 ③修复体已失效(做了但不能用 → 仍该召回)。
/// ②③ 都是生产实测踩出来的,不是预防性堆词。⛔「计划」不收 —— 会误伤「按计划复诊」。
export const TREATED_EVIDENCE_INTENT_EXCLUDE_RE =
'要求|拟行|拟|打算|咨询|意向|建议|推荐|考虑|择期|必要时|如需|未行|未做|未修复' +
'|手术|拔除|拔牙|导板|转诊|种植科|种植前|取出|失败|重新种' +
'|脱落|无法就位|不能就位|未就位|无法佩戴|不能佩戴|无法使用';
/**
* 「检查所见写着修复体在位」—— 缺牙缺口的**第三条举证路线**(前两条:治疗名 / 主诉现病史)。
*
* ── 为什么需要第三条 ──
* 前两条都要求患者**为那副修复体而来**:治疗名要当次动了它,主诉要当次是为它复查。
* 但一大类患者是**为别的牙来的**,那副义齿只出现在医生的全口检查所见里:
* 周燕芬 TS0M013273:那次来做**下前牙**冠修复,上颌 11~27 的记录是
* 「见活动义齿,基托边缘密合,伸展范围良好,固位力良好,无压痛」—— 上颌义齿明明在位,
* 却因为"这次不是为它来的"而两条路都接不住 → 判「缺失牙未启动修复」误召。
*
* ── 🔴 同颌闸是这条路线成立的前提 ──
* 检查所见一条记录常挂一整排牙位,句子里可能上下颌状态相反:
* 王志荣 TS0K051842:「上下颌吸附性义齿修复,**下颌固位可**,**上颌固位稍差**,义齿易脱落」
* 童然夫 TS0M013276:「口内活动义齿修复,**上颌**义齿卡环紧,**下颌**义齿无法完全就位」
* 整条牙位一起解会误销该召的那一半。所以**牙位跨上下颌的条目一律跳过**。
* 生产实测这个代价很小:提到义齿的 81,812 条检查所见里,**71,832 条(87.8%)牙位本就单颌**,
* 跨颌的只有 9,980 条(12.2%),其中文中点名了颌、理论上可切分的仅 676 条(上下颌都点名 150 条)。
* ⛔ 即"先做按颌切分再动这条路"是把 12% 当成了全部 —— 88% 根本不需要切分。
* (王志荣/童然夫恰好落在那 12% 里,是巧合,不代表切分值得做:整个生产只值 150 条。)
*
* ── 判据 ──
* 修复体名词 ∧ 在位状态词 ∧ ¬失效词,且该条目牙位不跨颌。
* ⛔ 失效词是这条路线的命门:修复体**做了但不能用**仍然该召回 ——
* 「无法就位」(王茜 BJ0F028868 外院假牙)/「脱落」/「折断」都表示需要重做,不是已修复。
*
* ⚠️ 只往 resolved 加牙位,永不新增召回 → 失败方向只有少召,且不报错。
* 消费方:potential-treatment-gap.sql 的 resolvedTeethSql(召回与画像共用)。
*/
/// 修复体名词 —— 出现即说明"有个修复体在讨论"。
/// ⛔ 不收裸「冠」:天然牙冠也叫冠(「牙冠完好」说的是真牙)。代价是「冠边缘密合」这类
/// 省略了定语的串接不住 —— 那类患者通常另有治疗名/主诉证据(陶美玉 TS0K070812 即是),
/// 不值得为它放宽到裸「冠」。
/// 「杆卡」= 种植覆盖义齿的固位杆,只可能属于修复体,无歧义。
export const RESTORATION_IN_PLACE_TERMS_RE =
'义齿|假牙|修复体|全冠|烤瓷冠|全瓷冠|种植冠|固定桥|杆卡';
/// 在位/状态良好 —— 医生描述这副修复体现在是好的
export const RESTORATION_IN_PLACE_STATE_RE = '密合|良好|在位|无松动|无压痛|固位可|完好|无异常';
/// 🔴 判失效词**之前**先抹掉的"否定式好话" —— 否则子串匹配分不开正反。
///
/// 中文否定前缀在这段文本里出现了三种方向,堆词堆不完:
/// ① 好词被否定:「密合」← 「**不**密合 / **欠**密合 / 密合度**差**」
/// ② 存在被否定:「义齿」← 「**无**义齿修复」(徐磊 BJ0U001139)
/// ③ 坏词被否定:「松动」← 「**无**松动 / **未见明显**松动 / 松动(-)」
/// ①② 的否定形式有限,逐个列进 FAIL 即可;③ 反过来 —— 坏词才是主形,好话是它的否定,
/// 列进 FAIL 会把好的一起否掉。所以对 ③ 采用本表:**先抹掉好话,再判坏词**。
/// 这跟 refusal 判定里「先 regexp_replace 抹掉'拒绝拍片'再判是否拒绝治疗」是同一手法。
export const RESTORATION_IN_PLACE_NEG_STRIP_RE = [
'无明显松动', '未见明显松动', '未见松动', '无松动', '不松动', '未松动',
'松动(-)', '松动\\(-\\)', '松动(—)', '松(-)', '松\\(-\\)', '不松',
].join('|');
/// 🔴 失效/未完成 —— 命中即不作数。做了但不能用,仍然该召回(需要重做的修复体不是"已修复")。
///
/// ⛔ **否定前缀必须逐个列全**:状态词是子串匹配,「密合」会匹配「**不**密合」「**欠**密合」
/// 「密合度**差**」「密合性**不佳**」—— 生产实测第一版就这么误销了一大批:
/// 赵河 BA27761「固定桥修复,边缘欠密合」/ 陈德家 BA40818「密合度差,继发龋坏」/
/// 翟健民 BJ0C021369「修复体松动,边缘欠密合,部分崩瓷」/ 李露 BJ0D049016「松动I,边缘不密合」。
/// 这跟「清洁牙面 vs 洁牙」是同一类坑(中文否定前缀让子串匹配失效),只能靠 FAIL 一票否决兜。
/// ⛔ 「松动」同理:STATE 收的是「无松动」,但「松动I」「修复体松动」「冠松动」是坏的 ——
/// PG ARE 不支持后顾断言,只能把坏形式逐个列出来。
export const RESTORATION_IN_PLACE_FAIL_RE = [
// 🔴 修复体**根本不存在**的否定说法 —— 与「不密合」同类的否定前缀坑,第二次踩到:
// 徐磊 BJ0U001139「缺失,**无义齿修复**,46,47均向近中移位」—— 含「义齿」却是说没有。
'无义齿', '无修复体', '未行义齿', '未行修复', '未见修复',
// 装不上 / 用不了
'无法就位', '不能就位', '未就位', '无法佩戴', '不能佩戴', '无法使用', '不可摘除', '漏风',
// 密合类的否定形式(子串匹配防不住,必须列全)
// ⚠️ 「密合」的否定形式**逐个列举列不完**(实测四轮才收敛:不密合/欠密合/密合度差/
// 密合性不佳/密合度欠佳/密合较差/密合度不佳/密合差…)。改用模式:密合 + ≤3 字 + 贬义词,
// 且中间不许跨标点(否则「边缘密合,龈缘红肿…咬合差」会误否)。
// ⛔ 「密合度尚可 / 尚密合 / 较密合」是可接受状态,刻意不收进贬义词。
// 「固位」同理(周锡英 TS0B001674「覆盖义齿修复,固位不良」)—— 用同一套模式,
// ⛔ 注意 STATE 里收的是「固位可 / 固位良好 / 固位稳定」,贬义只由本模式判。
'不密合', '欠密合', '不贴合', '密合[^,,。;;]{0,3}(差|不佳|欠佳|不良)',
'固位[^,,。;;]{0,3}(差|不佳|欠佳|不良)', '形态不良',
// 破坏 / 松动的坏形式
'脱落', '折断', '破损', '崩瓷', '绷瓷', '裂',
// ⚠️ 括号与 + 必须转义(PG ARE 与 JS 同):'松动(+' 里的 ( 会开组、+ 无物可重复 → 正则编译失败
'修复体松动', '冠松动', '桥松动', '义齿松动', '松动I', '松动Ⅰ', '松动Ⅱ', '松动Ⅲ',
'松动\\(\\+', '松动(\\+', '松动度',
// 基牙松动度的各种写法(王秋枫「11Ⅰ°松」/ 侯永生「松动二度」/ 康振英「牙松动2度」/
// 吴实「有明显松动」)。⚠️ 收裸「松动」是**安全的** —— 好话形式已由
// [[RESTORATION_IN_PLACE_NEG_STRIP_RE]] 在判定前抹掉。
'°松', '度松', '松动',
// 还有问题要处理
'继发龋', '缝隙', '可探入',
// 还没做
'未修复', '待修复', '建议', '要求', '拟', '考虑', '择期',
].join('|');
/**
* 「活动义齿 = 假缺失」—— 按**颌**判定,是缺牙误召里最大的一类。
*
* ── 问题 ──
* 戴活动义齿的患者,诊断栏**永远**写「缺失牙」—— 天然牙确实没了,这个诊断是对的。
* 但那个位置已经被义齿盖住,是**假缺失**:召回说的「缺失牙未**启动**修复」根本不成立,
* 治疗早就做了(而且做的就是义齿)。义齿松了/紧了/该重衬是**维护**,不是没做过。
*
* ── 判据:句子按颌定范围,牙位仍由条目自己给 ──
* 「上颌…义齿」→ 该条目牙位里的**上颌部分**解除;「下颌…义齿」→ 下颌部分;「上下颌/全口」→ 两边。
* 不需要逐段判状态(义齿存在就够),但**不整颌铺开** ——
* 🔴 整颌铺开会盖掉同颌里义齿没覆盖到的缺牙位:上颌局部义齿只补了 14;15;16,
* 而 24 也缺着没补,整颌一刀会把 24 一起解掉 = 该召的不召。用条目自己的牙位兜住。
* 🔴 且只认**信号诊断那一份病历**里的检查所见:同一份里"诊断说缺失、检查说有义齿"是
* 医生同一时刻写的,用检查补诊断没争议;跨次就多了一层"这中间会不会变了"的推断。
* 这比 [[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:上颌 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[] { 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