Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
51300d36
Commit
51300d36
authored
Aug 29, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
merge: 缺牙误召四条举证路线 + 姑息处置入 review + 抑制全有或全无 → main
parents
62d9b71a
1a3916a5
Hide whitespace changes
Inline
Side-by-side
Showing
13 changed files
with
1361 additions
and
20 deletions
+1361
-20
apps/pac-service/data/_shared/dict/treatment-category-actual-rules.yaml
+55
-11
apps/pac-service/data/jvs-dw/manifest.yaml
+21
-0
apps/pac-service/src/modules/clinical-gap/potential-treatment-gap.sql.ts
+172
-0
apps/pac-service/src/modules/plan/engine/plan-engine.service.ts
+30
-7
apps/pac-service/src/modules/plan/reason-temperature.sql.ts
+6
-2
apps/pac-service/tests/arch-denture-false-missing.spec.ts
+127
-0
apps/pac-service/tests/keyword-case-and-terms.spec.ts
+123
-0
apps/pac-service/tests/palliative-route-to-review.spec.ts
+89
-0
apps/pac-service/tests/plan-engine-batch.spec.ts
+69
-0
apps/pac-service/tests/polish-implies-restoration.spec.ts
+105
-0
apps/pac-service/tests/restoration-in-place-from-exam.spec.ts
+133
-0
apps/pac-service/tests/treated-evidence-from-emr-text.spec.ts
+155
-0
packages/types/src/canonical-codes.ts
+276
-0
No files found.
apps/pac-service/data/_shared/dict/treatment-category-actual-rules.yaml
View file @
51300d36
...
...
@@ -17,7 +17,10 @@ keyword_mapping:
# ⚠️ "二期" 必须限定到「种植二期」:裸"二期"会误吞"二期隐形矫正"(正畸)等 → 误判 implant。
# 种植语境的二期都带"种植"(种植二期);ortho 的"二期隐形矫正/二期矫正"靠下面 orthodontic 兜住。
-
{
value
:
implant
,
any
:
[
种植
,
即拔即种
,
植体
,
种植二期
]
}
-
{
value
:
endodontic
,
any
:
[
根管
,
RCT
,
牙髓
,
开髓
,
根备
,
根充
,
盖髓
,
摘髓
,
根尖
]
}
# 扩根 = 根管扩大预备的口语写法,与已收的「根备」同一动作。DW 实测 604 EMR(扩根 474 /
# 扩根换药 39 / 扩根封药 19 …)全部落 _default 被丢 —— 患者根管做到一半在系统里等于没做过。
# 碰撞已验:「扩弓」是正畸但不含「扩根」;含扩根的 DW 全量无非牙髓语义。
-
{
value
:
endodontic
,
any
:
[
根管
,
RCT
,
牙髓
,
开髓
,
根备
,
根充
,
盖髓
,
摘髓
,
根尖
,
扩根
]
}
-
{
value
:
orthodontic
,
any
:
[
正畸
,
矫治
,
矫正
,
托槽
,
保持器
,
粘附件
,
隐适美
,
隐形矫
,
扩弓
,
错颌
,
错合
]
}
-
{
value
:
cosmetic
,
any
:
[
贴面
,
漂白
,
美白
]
}
# 修复/备牙/取模:收 C.10 治疗痕迹(修复后/修复术后/全瓷桥修复后/备牙后/重新取模后)。
...
...
@@ -27,7 +30,15 @@ keyword_mapping:
# 裸「加牙」58 条(50 带牙位)全部落 _default 被丢,患者整次修复在系统里不存在 → 误召缺失牙。
# 变体「活动义齿加牙/义齿加牙/取模加牙」已被 义齿/取模 收,只有裸词和「活托X」漏。
# 碰撞已验:「根管治疗加牙周治疗」含"加牙"但被上方 ② 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:
# "去腐,备洞,...树脂充填,窝沟封闭"(dispose 散文)是补牙(restorative),不能因含"窝沟封闭"
# 被误判 preventive → 非 resolver → 误召(马思煦@46)。注:"开髓去腐"等根管语境已被上方 endodontic 先收。
...
...
@@ -36,25 +47,58 @@ keyword_mapping:
-
{
value
:
preventive
,
any
:
[
窝沟封闭
,
封闭剂
,
点隙封闭
]
}
# 材料弱词:放 preventive 后 —— "玻璃离子窝沟封闭" 已被上面 preventive 收;裸"树脂/玻璃离子"→ restorative
-
{
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 兜
# SRP = scaling & root planing(龈下刮治+根面平整)的国际通用缩写,正是 perio_no_srp 子场景
# 要认的那个治疗。DW 实测 SRP 648 + 局部SRP 40 + SRP+冲洗上药 76,此前全部落 _default 被丢 ——
# 患者做完牙周基础治疗仍被召「牙周炎未做基础治疗」。含 SRP 的 DW 全量已扫,无非牙周语义。
-
{
value
:
periodontic
,
any
:
[
洁治
,
洗牙
,
龈上
,
龈下
,
刮治
,
牙周
,
喷砂
,
细洁
,
SRP
]
}
-
{
value
:
preventive
,
any
:
[
涂氟
,
防龋
,
OHI
,
口腔卫生宣教
]
}
# 松牙固定 = 牙周炎松动牙夹板固定,牙周治疗动作。DW 实测 269 EMR 被丢。
-
{
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 条正常落库),但措辞变体
# 「拆除缝线」699 条(665 带牙位)漏了 —— 同一临床动作。收「缝线」一并覆盖变体。
# 碰撞已验:「拆除」不含「拔除/切除」,不会被本行前面的词误命中。
-
{
value
:
surgical
,
any
:
[
拔除
,
拔牙
,
切开
,
翻瓣
,
切除
,
系带
,
脓肿
,
囊肿
,
植骨
,
缝线
]
}
-
{
value
:
pediatric
,
any
:
[
乳牙
,
儿童
,
年轻恒牙
]
}
# ⛔ 刻意不收(2026-08-27 评估过,别"顺手补全"):
# 冲洗 / 换药 / 上药(~3,500 条)—— **对症处置 ≠ 根治**。阻生牙冠周炎「冲洗上药」之后那颗牙
# 仍然要拔;归 surgical 会把 K01 阻生牙缺口消掉 = 该召的不召(少召不报错,最难发现)。
# 调磨(817)/ 试戴(213)—— 姑息调整 / 修复流程中途,都不代表问题已解决。
# ⚠️ 根因:本表的 category 同时被两处消费 —— ①治疗史完整性 ②resolver 判"缺口已解"。
# 对这几个词我们想要①不想要②,而现在只有一个旋钮,只能二选一(现选"都不要"=丢弃)。
# 要两全需在 category 之外再加一维"是否根治",那是更大的改动,不在本表解决。
# ⛔ 不收进本表(冲洗 / 换药 / 上药 / 调磨 / 试戴)—— 但**不再丢弃**:
# 它们是「对症处置 ≠ 根治」:阻生牙冠周炎「冲洗上药」之后那颗牙仍然要拔;
# 归 surgical 会把 K01 阻生牙缺口消掉 = 该召的不召(少召不报错,最难发现)。
# 所以不能进本表的任何真治疗类目。
#
# 🔴 2026-08-28 重评并纠正了此处原来的根因分析。原文写着:
# 「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 语义:这些词开头的从句 = 本次没做(条件/未来/建议)→ 切段丢弃后再匹配。
# 例 "充填,必要时根管治疗" → 丢"必要时根管治疗" → "充填" → restorative(不误判成 endodontic)。
...
...
apps/pac-service/data/jvs-dw/manifest.yaml
View file @
51300d36
...
...
@@ -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(临床真实)
-
output
:
_treatment_actual_raw_emr
when
:
...
...
apps/pac-service/src/modules/clinical-gap/potential-treatment-gap.sql.ts
View file @
51300d36
...
...
@@ -9,6 +9,24 @@ import {
STRUCTURAL_DX_CODE_LIST
,
type
DxTreatmentRule
,
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'
;
/**
...
...
@@ -31,6 +49,10 @@ export interface GapCfgFlags {
excludeOrthoExtractionSites
?:
boolean
;
// §E 正畸减数位(外科拔除 + 正畸语境)折进 resolved
excludeCongenitalName
?:
boolean
;
// §E name 含"先天" → 正畸统筹,不自动召修复
deferToToothDx
?:
boolean
;
// 「建议拔除」场景专用:有同牙编码病种诊断 → 让位给该病种场景(避免双召)
polishImpliesRestoration
?:
boolean
;
// 缺失牙专用:缺牙位上的裸「抛光」= 修复体在位的证据(牙都没了,抛的是义齿)
treatedEvidenceFromEmrText
?:
boolean
;
// 本次动作是预防/复查类时,再审同次病历主诉/现病史找"已治疗"证据
archDentureIsRestored
?:
boolean
;
// 检查所见判出某颌有活动义齿 → 该颌缺牙位是"假缺失"
restorationInPlaceFromExam
?:
boolean
;
// 检查所见(单颌条目)写着修复体在位 → 该牙无新修复需求
}
/// §E gap 修正 flag 的单一真理源(按 primaryCode 查)。召回 SUB_SCENARIOS 与画像标签都引此。
...
...
@@ -41,6 +63,10 @@ export const GAP_FLAGS_BY_PRIMARY: Record<string, GapCfgFlags> = {
excludeThirdMolar
:
true
,
excludeOrthoExtractionSites
:
true
,
excludeCongenitalName
:
true
,
polishImpliesRestoration
:
true
,
treatedEvidenceFromEmrText
:
true
,
archDentureIsRestored
:
true
,
restorationInPlaceFromExam
:
true
,
},
// 「建议拔除」独立场景:有同牙编码病种诊断 → 让位给病种场景,不重复召(残根=K03、阻生=K01…)
EXTRACTION_RECOMMENDED
:
{
...
...
@@ -232,6 +258,148 @@ 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
;
// (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
`
(SELECT COALESCE(array_agg(DISTINCT t), ARRAY[]::text[]) FROM (
-- (a) 治疗家族 resolver(afterDx):同牙诊断后做了 resolverCats 家族里任一治疗
...
...
@@ -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 sig.type = 'diagnosis_record'
${
reviewImpliesBranch
}
${
polishImpliesBranch
}
${
treatedEvidenceBranch
}
${
restorationInPlaceBranch
}
${
archDentureBranch
}
${
orthoExtractBranch
}
${
deferDxBranch
}
) u)`
;
...
...
apps/pac-service/src/modules/plan/engine/plan-engine.service.ts
View file @
51300d36
...
...
@@ -459,16 +459,36 @@ export class PlanEngineService {
// 带到新版)→ 客服不丢单、不被抢,只是召回理由刷新到最新;理由没变则只就地刷分(下面 unchanged 分支)。
// 缺口全消失(0 命中)→ 即使 assigned 也关闭(见 closeStaleActivePlan)。
// ⭐
信号级抑制(召回闭环核心)— 抑制粒度 = 召回算法粒度 (scenario, subKey)
。
//
只压"已结案那条召回覆盖的诊断";患者新长的、不在已结案理由里的诊断照常召回(不受冷静期影响)。
//
做法:取该患者所有"终态(completed/abandoned)+ 冷静期未到期"plan 的 reason (scenario, subKey)
//
并集作抑制集,逐 hit 过滤掉命中抑制集的信号;剩下的(全新诊断)继续走下面生成逻辑。
//
修复的 bug:旧版"整患者抑制" → 成功转化 K08 后,snooze 期内新长的 K02 也被压住不召(误伤新缺口)。
// ⭐
抑制(召回闭环核心)—— 判定粒度 = (scenario, subKey),生效粒度 = **整个 plan**
。
//
取该患者所有"终态(completed/abandoned)+ 冷静期未到期"plan 的 reason (scenario, subKey)
//
并集作抑制集,再看本轮 hit 里**有没有任何一条**逃得出去(不在集里 / 结案后新发):
//
· 一条都逃不出去 → 整个 plan 不生成
//
· 有任意一条逃得出去 → 本轮全部 hit 都进 plan
// 多次结案也正确:查的是全部未到期终态 plan 的并集(不止 latest 一条)。
// 历史:旧版是"整患者抑制"(成功转化 K08 后,snooze 期内新长的 K02 也被压住 → 误伤新缺口);
// 中间版改成逐 hit 过滤,治好了误伤,却引入了"部分抑制"(见下方 2026-08-28 注释)。
const
snoozedAnchors
=
prefetched
?
prefetched
.
snoozedAnchors
:
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
?
hits
:
hits
.
filter
((
h
)
=>
{
...
...
@@ -480,10 +500,13 @@ export class PlanEngineService {
const latest = h.latestSignalOccurredAt;
return latest != null && latest > anchor;
});
if (
usable
Hits.length === 0) {
if (
escaped
Hits.length === 0) {
// 当前所有活信号都在冷静期内(= 刚结案那批,且无结案后新发)→ 不生成新 plan
return 'suppressed';
}
// ⛔ 这里**故意**用 hits 而不是 escapedHits —— 见上方「全有或全无」。
// 改回 escapedHits 就退回部分抑制,而且不会报错,只会让卡片再次跟病历对不上。
const usableHits = hits;
const newPriorityScore = Math.max(...usableHits.map((h) => h.priorityScore));
...
...
apps/pac-service/src/modules/plan/reason-temperature.sql.ts
View file @
51300d36
...
...
@@ -142,8 +142,12 @@ export function temperatureBucketCaseSql(
* 关联条件变成恒真且 SQL 不报错)—— 那种场景用下方的 `labelExistsSql` / `labelTemperatureExistsSql`。
*
* ⚠️ `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` **必须留着**,即便锚点已经换成末诊、不再用它定档:
* 它是「这条证据有日期」的守卫,决定**哪些 plan 进得来**。删掉行集就变了 ——
* 而多出来的那些人不会报错,只会悄悄出现在格子里。
...
...
apps/pac-service/tests/arch-denture-false-missing.spec.ts
0 → 100644
View file @
51300d36
/**
* 「活动义齿 = 假缺失」按颌判定。
*
* 戴活动义齿的患者诊断栏**永远**写「缺失牙」—— 天然牙确实没了,诊断没错;
* 但那个位置已被义齿盖住,「缺失牙未**启动**修复」不成立(治疗做的就是义齿)。
* 活动义齿是整颌跨度的修复体 → 判出"这一颌有义齿",那一颌的缺牙位全部解除。
*
* 🔴 本条**天然绕开混合句** —— 王志荣/童然夫的检查所见牙位横跨上下颌,
* 按条目判的那条(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'
]);
});
});
apps/pac-service/tests/keyword-case-and-terms.spec.ts
View file @
51300d36
...
...
@@ -94,3 +94,126 @@ describe('既有裁决顺序不被新词打乱', () => {
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'
);
});
});
apps/pac-service/tests/palliative-route-to-review.spec.ts
0 → 100644
View file @
51300d36
/**
* 姑息处置词(调磨/试戴/冲洗/换药/上药)→ 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
);
});
});
apps/pac-service/tests/plan-engine-batch.spec.ts
View file @
51300d36
...
...
@@ -460,6 +460,75 @@ describe('runAllForHost 批量路径 — 5 种结局等价', () => {
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
()
=>
{
const
{
prisma
,
plans
}
=
makeStore
({
plans
:
[
...
...
apps/pac-service/tests/polish-implies-restoration.spec.ts
0 → 100644
View file @
51300d36
/**
* 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
);
});
});
apps/pac-service/tests/restoration-in-place-from-exam.spec.ts
0 → 100644
View file @
51300d36
/**
* 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
();
}
});
});
apps/pac-service/tests/treated-evidence-from-emr-text.spec.ts
0 → 100644
View file @
51300d36
/**
* 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
();
}
});
});
packages/types/src/canonical-codes.ts
View file @
51300d36
...
...
@@ -461,6 +461,282 @@ 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
;
/**
* 「病历自由文本自证已治疗」—— **补充举证路线**,只在本次动作是预防/复查类时才启动。
*
* ── 大前提不动 ──
* 最新诊断永远第一位:诊断了就是机会。⛔ **不用「前有治疗、后有诊断」去翻案** ——
* 那只是一条猜疑链(修复体会失败、会脱落、会需要重做,旧治疗不能证明今天还在位)。
* 本表要做的是把**诊断之后、本次就诊内**的动作判得更有说服力:
* · 本次动作是**治疗类** → 现有 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
[]
{
if
(
STRUCTURAL_DX_CODES
.
has
(
code
))
return
STRUCTURAL_RESOLVER_CATEGORIES
;
const
rule
=
lookupDxTreatment
(
code
);
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment