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
......@@ -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:
......
......@@ -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 (usableHits.length === 0) {
if (escapedHits.length === 0) {
// 当前所有活信号都在冷静期内(= 刚结案那批,且无结案后新发)→ 不生成新 plan
return 'suppressed';
}
// ⛔ 这里**故意**用 hits 而不是 escapedHits —— 见上方「全有或全无」。
// 改回 escapedHits 就退回部分抑制,而且不会报错,只会让卡片再次跟病历对不上。
const usableHits = hits;
const newPriorityScore = Math.max(...usableHits.map((h) => h.priorityScore));
......
......@@ -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 进得来**。删掉行集就变了 ——
* 而多出来的那些人不会报错,只会悄悄出现在格子里。
......
/**
* 「活动义齿 = 假缺失」按颌判定。
*
* 戴活动义齿的患者诊断栏**永远**写「缺失牙」—— 天然牙确实没了,诊断没错;
* 但那个位置已被义齿盖住,「缺失牙未**启动**修复」不成立(治疗做的就是义齿)。
* 活动义齿是整颌跨度的修复体 → 判出"这一颌有义齿",那一颌的缺牙位全部解除。
*
* 🔴 本条**天然绕开混合句** —— 王志荣/童然夫的检查所见牙位横跨上下颌,
* 按条目判的那条(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('既有裁决顺序不被新词打乱', () => {
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 种结局等价', () => {
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: [
......
/**
* 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();
}
});
});
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