Commit 66ed2e46 by luoqi

fix(摄入): 含词匹配改大小写不敏感 + 补 SRP/加牙/活托/缝线 四词

过碧霞 TS0B006258 牙12;13;21:2026-04-07(信号当天)有一条 treat_plan
「加牙 @12;13;21」,牙位齐全、时间过闸、类目本该是 prosthodontic ——
但「加牙」不在任何字典里,落 _default 后**整条治疗事实不落库**,
于是 missing_tooth@12;13 和 hard_tissue_damage@21 两条召回都留了下来。
客服标「已治疗」是对的:04-05 取模 → 04-07 加牙 → 04-10 上颌活动义齿佩戴。

① 大小写:classifyByKeyword 用 text.includes(kw) 原样比较,全链路无一处转小写
   (op: lowercase 算子存在但所有 yaml 未用)。生产实测 `RCT` 3,122 条正常落库、
   `rct` 725 条整条丢弃。改为双侧 toLowerCase;中文恒等,零副作用。
    只放宽含词层,不动精确 enum_mapping(key 可能有意区分大小写,撞了不报错)。

② 补词(共享层,临床通用非宿主怪癖),落位与碰撞逐个验过:
   SRP  → periodontic  (648+局部SRP 40+SRP+冲洗上药 76;正是 perio_no_srp 要认的治疗)
   加牙 → prosthodontic(裸词 58/50 带牙位;「根管治疗加牙周治疗」被 endodontic 先吃)
   活托 → prosthodontic(活动托牙简称)
   缝线 → surgical     (「拆线」已在精确表 37,253 条;变体「拆除缝线」699 漏)

 冲洗/换药/上药/调磨/试戴**刻意不收**并在 yaml 写明理由:对症处置≠根治,
   收了会消掉 K01 阻生牙等真缺口(少召不报错)。根因是 category 同时被
   "治疗史完整性"和"resolver 判缺口已解"两处消费,只有一个旋钮 —— 不在本表解决。

口径更正:早先从 source_event_id 反推的漏配清单作废 —— 宿主改病历会产生新流水
(幂等键含 updated_date),SRP 实测 1,135 流水 / 729 去重 emr / DW 648 行,
系统性高估 1.56 倍,且未与 normalize 对齐。本次四词均以 DW 归一后口径为准。

测试:新增 30 条(大小写/四词/碰撞/刻意不收/既有顺序回归),全量 1556 条通过;
type-check 17 个错为 main 既有,本次引入 0 个。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 0d458785
...@@ -23,7 +23,11 @@ keyword_mapping: ...@@ -23,7 +23,11 @@ keyword_mapping:
# 修复/备牙/取模:收 C.10 治疗痕迹(修复后/修复术后/全瓷桥修复后/备牙后/重新取模后)。 # 修复/备牙/取模:收 C.10 治疗痕迹(修复后/修复术后/全瓷桥修复后/备牙后/重新取模后)。
# 排在 implant/endo/ortho/cosmetic 之后 → 种植修复→implant、根管…修复→endo 仍被前面抢走。 # 排在 implant/endo/ortho/cosmetic 之后 → 种植修复→implant、根管…修复→endo 仍被前面抢走。
# "嵌体修复"等 restorative 已由上方精确 enum 先命中,不受裸"修复"影响。 # "嵌体修复"等 restorative 已由上方精确 enum 先命中,不受裸"修复"影响。
- { value: prosthodontic, any: [, , 义齿, 修复体, 修复, 备牙, 取模, 桩核, 桩冠, 戴牙, 全瓷, 烤瓷, 重新粘接] } # 加牙/活托(2026-08-27 过碧霞 TS0B006258 牙12;13;21):活动义齿在原基托上加牙 —— DW 实测
# 裸「加牙」58 条(50 带牙位)全部落 _default 被丢,患者整次修复在系统里不存在 → 误召缺失牙。
# 变体「活动义齿加牙/义齿加牙/取模加牙」已被 义齿/取模 收,只有裸词和「活托X」漏。
# 碰撞已验:「根管治疗加牙周治疗」含"加牙"但被上方 ② endodontic 的「根管」先吃,安全。
- { value: prosthodontic, any: [, , 义齿, 修复体, 修复, 备牙, 取模, 桩核, 桩冠, 戴牙, 全瓷, 烤瓷, 重新粘接, 加牙, 活托] }
# ⭐ 明确充填动作(充填/去腐/备洞/嵌体/垫底/补牙)压过 preventive: # ⭐ 明确充填动作(充填/去腐/备洞/嵌体/垫底/补牙)压过 preventive:
# "去腐,备洞,...树脂充填,窝沟封闭"(dispose 散文)是补牙(restorative),不能因含"窝沟封闭" # "去腐,备洞,...树脂充填,窝沟封闭"(dispose 散文)是补牙(restorative),不能因含"窝沟封闭"
# 被误判 preventive → 非 resolver → 误召(马思煦@46)。注:"开髓去腐"等根管语境已被上方 endodontic 先收。 # 被误判 preventive → 非 resolver → 误召(马思煦@46)。注:"开髓去腐"等根管语境已被上方 endodontic 先收。
...@@ -33,11 +37,25 @@ keyword_mapping: ...@@ -33,11 +37,25 @@ keyword_mapping:
# 材料弱词:放 preventive 后 —— "玻璃离子窝沟封闭" 已被上面 preventive 收;裸"树脂/玻璃离子"→ restorative # 材料弱词:放 preventive 后 —— "玻璃离子窝沟封闭" 已被上面 preventive 收;裸"树脂/玻璃离子"→ restorative
- { value: restorative, any: [树脂, 玻璃离子] } - { value: restorative, any: [树脂, 玻璃离子] }
# 牙周去裸"洁牙"(避开自由文本"清洁牙面"误命中);洁牙/全口洁牙等精确词由上方 enum_mapping 兜 # 牙周去裸"洁牙"(避开自由文本"清洁牙面"误命中);洁牙/全口洁牙等精确词由上方 enum_mapping 兜
- { value: periodontic, any: [洁治, 洗牙, 龈上, 龈下, 刮治, 牙周, 喷砂, 细洁] } # 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, 口腔卫生宣教] } - { value: preventive, any: [涂氟, 防龋, OHI, 口腔卫生宣教] }
- { value: surgical, any: [拔除, 拔牙, 切开, 翻瓣, 切除, 系带, 脓肿, 囊肿, 植骨] } # 缝线:「拆线」已在宿主精确表(拆线→surgical,37,253 条正常落库),但措辞变体
# 「拆除缝线」699 条(665 带牙位)漏了 —— 同一临床动作。收「缝线」一并覆盖变体。
# 碰撞已验:「拆除」不含「拔除/切除」,不会被本行前面的词误命中。
- { value: surgical, any: [拔除, 拔牙, 切开, 翻瓣, 切除, 系带, 脓肿, 囊肿, 植骨, 缝线] }
- { value: pediatric, any: [乳牙, 儿童, 年轻恒牙] } - { value: pediatric, any: [乳牙, 儿童, 年轻恒牙] }
# ⛔ 刻意不收(2026-08-27 评估过,别"顺手补全"):
# 冲洗 / 换药 / 上药(~3,500 条)—— **对症处置 ≠ 根治**。阻生牙冠周炎「冲洗上药」之后那颗牙
# 仍然要拔;归 surgical 会把 K01 阻生牙缺口消掉 = 该召的不召(少召不报错,最难发现)。
# 调磨(817)/ 试戴(213)—— 姑息调整 / 修复流程中途,都不代表问题已解决。
# ⚠️ 根因:本表的 category 同时被两处消费 —— ①治疗史完整性 ②resolver 判"缺口已解"。
# 对这几个词我们想要①不想要②,而现在只有一个旋钮,只能二选一(现选"都不要"=丢弃)。
# 要两全需在 category 之外再加一维"是否根治",那是更大的改动,不在本表解决。
# actual 语义:这些词开头的从句 = 本次没做(条件/未来/建议)→ 切段丢弃后再匹配。 # actual 语义:这些词开头的从句 = 本次没做(条件/未来/建议)→ 切段丢弃后再匹配。
# 例 "充填,必要时根管治疗" → 丢"必要时根管治疗" → "充填" → restorative(不误判成 endodontic)。 # 例 "充填,必要时根管治疗" → 丢"必要时根管治疗" → "充填" → restorative(不误判成 endodontic)。
keyword_strip_clauses: [必要时, 如需, 择期, 建议, 推荐, 考虑, ] # 若…=条件从句(若牙面脱矿,建议充填),非本次实际治疗 keyword_strip_clauses: [必要时, 如需, 择期, 建议, 推荐, 考虑, ] # 若…=条件从句(若牙面脱矿,建议充填),非本次实际治疗
/**
* classifyByKeyword 大小写不敏感 + 2026-08-27 补的四个词(SRP / 加牙 / 活托 / 缝线)。
*
* 规则表按序裁决(首个命中即用),所以新词最大的风险不是"没命中",而是**抢了前面该赢的**。
* 下面每个新词都锁一条碰撞用例(生产 DW 里真实存在的串)。
*
* 跑:
* pnpm test -- keyword-case-and-terms
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as yaml from 'js-yaml';
import { classifyByKeyword } from '../src/modules/sync/assembler/assembler-engine';
import type { KeywordRule } from '../src/modules/sync/assembler/assembler.schema';
/// 直接读生产字典,不在测试里抄一份(抄了必漂)
const DICT = path.join(__dirname, '../data/_shared/dict/treatment-category-actual-rules.yaml');
const doc = yaml.load(fs.readFileSync(DICT, 'utf8')) as {
keyword_mapping: { category: KeywordRule[] };
keyword_strip_clauses?: string[];
};
const RULES = doc.keyword_mapping.category;
const STRIP = doc.keyword_strip_clauses;
const cat = (s: string) => classifyByKeyword(s, RULES, STRIP);
describe('大小写不敏感', () => {
it('RCT 大小写混写都归 endodontic(生产:RCT 3,122 落库 / rct 725 曾全丢)', () => {
for (const s of ['RCT', 'rct', 'Rct', 'rCT']) expect(cat(s)).toBe('endodontic');
});
it('复合串也不敏感', () => {
expect(cat('rct+冠修复')).toBe('endodontic'); // 根管优先于冠
expect(cat('局部srp')).toBe('periodontic');
});
it('中文不受影响(toLowerCase 对中文恒等)', () => {
expect(cat('根管治疗')).toBe('endodontic');
expect(cat('全口龈上洁治')).toBe('periodontic');
});
});
describe('SRP → periodontic', () => {
it.each(['SRP', '局部SRP', 'SRP+冲洗上药'])('%s', (s) => {
expect(cat(s)).toBe('periodontic');
});
});
describe('加牙 / 活托 → prosthodontic', () => {
it.each(['加牙', '活托加牙', '加牙后初戴', '活托加牙后初戴'])('%s', (s) => {
expect(cat(s)).toBe('prosthodontic');
});
// ⛔ 碰撞:「加牙」是「加牙周治疗」的子串,必须让根管先赢
it('「根管治疗加牙周治疗或酌情拔除」→ endodontic,不被加牙抢走', () => {
expect(cat('根管治疗加牙周治疗或酌情拔除')).toBe('endodontic');
});
it('已被旧词命中的变体行为不变', () => {
expect(cat('活动义齿加牙')).toBe('prosthodontic'); // 义齿
expect(cat('取模加牙')).toBe('prosthodontic'); // 取模
});
});
describe('缝线 → surgical', () => {
it.each(['拆除缝线', '拆除缝线,局部冲洗'])('%s', (s) => {
expect(cat(s)).toBe('surgical');
});
it('种植拆线仍归 implant(种植优先)', () => {
expect(cat('种植拆线')).toBe('implant');
});
});
describe('⛔ 刻意不收的词仍然不命中(收了会误销召回)', () => {
it.each(['冲洗', '换药', '上药', '调磨', '试戴', '观察', '定期观察'])('%s → undefined', (s) => {
expect(cat(s)).toBeUndefined();
});
it('「冲洗上药」不归 surgical —— 阻生牙冠周炎处置后那颗牙仍要拔', () => {
expect(cat('冲洗上药')).toBeUndefined();
});
});
describe('既有裁决顺序不被新词打乱', () => {
it.each([
['种植二期', 'implant'],
['根管治疗(磨牙)', 'endodontic'],
['二期隐形矫正', 'orthodontic'],
['冠修复(非美学区)', 'prosthodontic'],
['非美学区树脂充填', 'restorative'],
['玻璃离子窝沟封闭', 'preventive'],
['牙拔除术(松动乳牙)', 'surgical'],
])('%s → %s', (s, want) => {
expect(cat(s)).toBe(want);
});
});
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