Commit 0b3b7cd0 by luoqi

merge: main(98 个提交) → feat/friday-ai

零冲突。两个重叠文件(host-admin.cli.ts / canonical-codes.ts)自动合并,
已核对无残留标记、本分支新增的码表全在。

合并后重建 @pac/types 与 @pac/pac-client,全量门禁通过:
  friday-ai   374 测试 · tsc ×3 · lint 0 warning · 边界闸 5 条
  pac-service 1943 测试 · tsc

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parents 9d962648 e19135b4
Pipeline #3651 failed in 0 seconds
......@@ -205,3 +205,18 @@ SENTRY_ENVIRONMENT=
SENTRY_TRACES_SAMPLE_RATE=0.1
# release(可选):部署时注入 git SHA
SENTRY_RELEASE=
# ── plan 批量重算性能 ────────────────────────────────────────────────
# 召回子场景并发度。**生产设 4**(2026-08-30 起)。
# 生产全量实测(113 万患者,同一台 RDS,相邻两轮):
# =1 场景段 2h00m / 整轮 2h24m
# =4 场景段 51m56s / 整轮 1h20m → 场景段 ×2.31,省 64 分钟
# 瓶颈是**延迟**(逐次索引探查在等 page 返回)不是磁盘吞吐,所以并发 2 就超线性(×1.50)。
# ⚠️ 判据只能看 `[plan] 阶段耗时` 的**墙钟** —— 并发下单条查询的 sql= 会被争抢拉长,
# 看单条会误判成"变慢了"。详见 treatment-initiation-recall.scenario.ts 里 conc 处注释。
PAC_RECALL_SUBSCENARIO_CONCURRENCY=4
# gap 计算形态:legacy(默认,逐行相关子查询)/ setbased(集合式)。
# ⚠️ 生产**未启用**。集合式已在测试机验证零差异且再省 22~31%,但未经生产验证。
# 见 docs/design/gap-set-based-rewrite-plan.md。
PAC_GAP_VARIANT=
......@@ -17,13 +17,28 @@ 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 治疗痕迹(修复后/修复术后/全瓷桥修复后/备牙后/重新取模后)。
# 排在 implant/endo/ortho/cosmetic 之后 → 种植修复→implant、根管…修复→endo 仍被前面抢走。
# "嵌体修复"等 restorative 已由上方精确 enum 先命中,不受裸"修复"影响。
- { value: prosthodontic, any: [, , 义齿, 修复体, 修复, 备牙, 取模, 桩核, 桩冠, 戴牙, 全瓷, 烤瓷, 重新粘接] }
# 加牙/活托(2026-08-27 过碧霞 TS0B006258 牙12;13;21):活动义齿在原基托上加牙 —— DW 实测
# 裸「加牙」58 条(50 带牙位)全部落 _default 被丢,患者整次修复在系统里不存在 → 误召缺失牙。
# 变体「活动义齿加牙/义齿加牙/取模加牙」已被 义齿/取模 收,只有裸词和「活托X」漏。
# 碰撞已验:「根管治疗加牙周治疗」含"加牙"但被上方 ② endodontic 的「根管」先吃,安全。
# 卡环/基托/重衬/假牙(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 先收。
......@@ -33,11 +48,66 @@ keyword_mapping:
# 材料弱词:放 preventive 后 —— "玻璃离子窝沟封闭" 已被上面 preventive 收;裸"树脂/玻璃离子"→ restorative
- { value: restorative, any: [树脂, 玻璃离子] }
# 牙周去裸"洁牙"(避开自由文本"清洁牙面"误命中);洁牙/全口洁牙等精确词由上方 enum_mapping 兜
- { value: periodontic, any: [洁治, 洗牙, 龈上, 龈下, 刮治, 牙周, 喷砂, 细洁] }
- { value: preventive, any: [涂氟, 防龋, OHI, 口腔卫生宣教] }
- { value: surgical, any: [拔除, 拔牙, 切开, 翻瓣, 切除, 系带, 脓肿, 囊肿, 植骨] }
# SRP = scaling & root planing(龈下刮治+根面平整)的国际通用缩写,正是 perio_no_srp 子场景
# 要认的那个治疗。DW 实测 SRP 648 + 局部SRP 40 + SRP+冲洗上药 76,此前全部落 _default 被丢 ——
# 患者做完牙周基础治疗仍被召「牙周炎未做基础治疗」。含 SRP 的 DW 全量已扫,无非牙周语义。
# 松牙固定 = 牙周炎松动牙夹板固定,牙周治疗动作。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: [拔除, 拔牙, 切开, 翻瓣, 切除, 系带, 脓肿, 囊肿, 植骨, 缝线] }
# 调合/调颌(2026-08-28 陈惠英 TS0K028757 牙34;35;36;37):种植戴牙一年按约复查,当次治疗
# 写「调合,抛光」,牙位与缺牙诊断完全一致。宿主精确表早已判定「调合」「调颌」= prosthodontic
# (treatment_actual.yaml),但只在精确表里 —— 医生把两个词写在一起,精确匹配不上、含词表也没有,
# 整条丢弃 → 那次复查在系统里没有任何治疗 → 判「缺失牙未启动修复」误召。DW 实测 238 条。
#
# ⛔ **必须排在所有其他治疗类目之后**(本表倒数第二行,只在人群词 pediatric 之前)。
# 「调合/调颌」是**咬合微调**,几乎任何术式的收尾都可能带一句,它单独出现时才代表修复动作;
# 和别的操作词同句时,那个操作词才是本次治疗。放前面就会把别人的记录整片抢过来:
# 2026-08-28 测试服实测(初版只挡了 restorative,排在 periodontic 之前)——
# 新写入的 179 条调合/调颌事实里 18 条(10%)判错:
# 「牙周刮治+调合」「根面平整+调合+牙周内上药」「洁治,47调合」→ 应 periodontic(16 条)
# 「涂氟,45调合」→ 应 preventive(1 条);拔牙类 → 应 surgical(1 条)
# 两个方向都坏:①牙周治疗不再解 perio_no_srp 缺口 = 多召牙周;
# ②prosthodontic 在结构 resolver 里,反而去解那些牙位的缺牙缺口 = 少召缺牙(不报错)。
# 排在末尾后仍能接住原始个案:「调合,抛光」「调合抛光」里的「抛光」不在任何含词表,
# 只有「调合」命中 → 照样 prosthodontic(陈惠英@16 / 陶美玉@22 已实测)。
- { value: prosthodontic, any: [调合, 调颌] }
- { value: pediatric, any: [乳牙, 儿童, 年轻恒牙] }
# ⛔ 不收进本表(冲洗 / 换药 / 上药 / 调磨 / 试戴)—— 但**不再丢弃**:
# 它们是「对症处置 ≠ 根治」:阻生牙冠周炎「冲洗上药」之后那颗牙仍然要拔;
# 归 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)。
keyword_strip_clauses: [必要时, 如需, 择期, 建议, 推荐, 考虑, ] # 若…=条件从句(若牙面脱矿,建议充填),非本次实际治疗
......@@ -24,6 +24,22 @@ field_mapping:
# 不是 PAC appointment_type 通用语义"预约目的"(咨询/治疗/检查/拔牙等);
# 且"初诊/复诊"是衍生信号,PAC 自己从 encounter_record 聚合算更可信(单一源 = PAC)。
doctorId: appo_doc_id
# ⭐ 预约医生姓名 —— DW 一直在同一行里给,只是历来没映射(预约事实因此只有 id 没有名字)。
# 列名叫 resource_name(排班资源名)容易让人以为是诊室/椅位,**不是**。行为核实(2026-08-31):
# ① 椅位另有列 appo_chair(仅 3 个取值),诊室/资源另有 resource_id;
# ② resource_name 与 appo_doc_id 的绑定比与 resource_id 更紧
# (本地:(doc_id,name) 140 组 < (resource_id,name) 151 组)——资源名不会有这个方向;
# ③ 拿「已到诊」预约按 (患者,日期) 对当天病历的 doctor_name:
# 生产近 90 天 99,170/101,192 = 98.0% 命中;144,148 条明细里
# 「id 对上而名字不对」和「名字对上而 id 不对」**各 0 条**(本地全量各 1 条,均为资源改派期历史行)。
# → resource_name 就是 appo_doc_id 这个人的姓名,不是恰好像人名的资源名。
# ⚠️ 语义是**约号时约的那位医生**,不是实际接诊医生(剩下 2% 是改派,业务事实非数据问题);
# 要实际接诊医生走病历 doctor_name,两个口径别混。
# ⚠️ 少数行排的**不是人**而是房间/服务/台席("预约"/"学前街手术室"/"正畸咨询"…):
# 生产近 180 天 9,838/1,047,901 = 0.94%。canonical 层原样透传,
# 由 AppointmentParser.isPersonResource 决定写不写 content.doctor_name
# (原值另存 content.resource_name,不丢)。词表与误伤核验记在那个函数上。
doctorName: resource_name
status: appo_status
complaintCategory: appo_complaint_category # 预约科目 / 就诊意向(种植/正畸/…)
complaintText: appo_complaint # 预约主诉自由文本(跟 category 配对)
......
......@@ -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:
......
......@@ -30,6 +30,8 @@
"recompute-persona": "ts-node --transpile-only src/cli/recompute-persona.cli.ts",
"backfill-plan-labels": "ts-node --transpile-only src/cli/backfill-plan-labels.cli.ts",
"recompute-persona:prod": "node --max-old-space-size=8192 dist/cli/recompute-persona.cli.js",
"verify-gap-equivalence": "ts-node --transpile-only src/cli/verify-gap-equivalence.cli.ts",
"verify-gap-equivalence:prod": "node --max-old-space-size=8192 dist/cli/verify-gap-equivalence.cli.js",
"recompute-plans": "ts-node --transpile-only src/cli/recompute-plans.cli.ts",
"recompute-plans:prod": "node --max-old-space-size=8192 dist/cli/recompute-plans.cli.js",
"timeline": "ts-node --transpile-only src/cli/timeline.cli.ts",
......
......@@ -15,6 +15,7 @@ import { Logger } from '@nestjs/common';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { PlanScriptOrchestrator } from '../modules/ai/orchestrators/plan-script.orchestrator';
import { disableSchedulersForCli } from './bootstrap-flags';
interface CliArgs {
planId?: string;
......@@ -81,6 +82,8 @@ async function bootstrap() {
}
const logger = new Logger('ai:gen-script');
// ⚠️ 必须在建应用上下文之前:否则会清掉 service 里正在跑的同步锁(见 bootstrap-flags)
disableSchedulersForCli();
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['warn', 'error', 'log'],
});
......
......@@ -25,6 +25,7 @@ import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { AppModule } from '../app.module';
import { PlanLabelService } from '../modules/plan/plan-label.service';
import { disableSchedulersForCli } from './bootstrap-flags';
async function main(): Promise<void> {
const log = new Logger('backfill-plan-labels');
......@@ -32,6 +33,10 @@ async function main(): Promise<void> {
const all = argv.includes('--all');
const checkOnly = argv.includes('--check');
// ⚠️ 必须在建应用上下文之前:否则会清掉 service 里正在跑的同步锁(见 bootstrap-flags)
disableSchedulersForCli();
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['error', 'warn', 'log'],
});
......
/**
* CLI 启动前的写侧总闸 —— **必须在 `NestFactory.createApplicationContext` 之前调用**。
*
* 🔴 为什么存在(2026-08-30 生产事故):
* 每个 CLI 都会 `createApplicationContext(AppModule)`,于是把**整个应用的 onModuleInit
* 全跑一遍** —— 包括 SyncIncrementalSchedulerService 的僵尸锁回收和 cron 注册。
* 在长驻的 pac-service 容器里 `docker exec` 跑一个 CLI,那个 CLI 就会把 service 里
* **正在跑**的那轮同步的锁当成僵尸清掉,那轮增量当场夭折。
* 实测:08:17:11 跑 recompute-plans → 08:17:13 正常跑着的 08:15 那轮被标 failed。
*
* 所以:凡是**不以调度器为目的**的 CLI,一律先调本函数。
* ⚠️ 例外只有 `sync-incremental.cli`(它本身就是要触发同步)—— 但它也不该回收别人的锁,
* 那一层由 scheduler 的年龄阈值(REAP_MIN_AGE_MS)兜底。
*/
export function disableSchedulersForCli(): void {
process.env.PAC_SCHEDULER_DISABLED = '1';
}
......@@ -15,6 +15,7 @@ import {
ColdImportService,
SyncAlreadyRunningError,
} from '../modules/sync/cold-import/cold-import.service';
import { disableSchedulersForCli } from './bootstrap-flags';
interface CliArgs {
dir?: string;
......@@ -112,6 +113,10 @@ async function bootstrap() {
`${args.since ? `, since=${args.since}${args.months ? `(--months=${args.months})` : ''}` : ''})`,
);
// ⚠️ 必须在建应用上下文之前:否则会清掉 service 里正在跑的同步锁(见 bootstrap-flags)
disableSchedulersForCli();
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['log', 'warn', 'error'],
});
......
......@@ -22,6 +22,7 @@ import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { AppModule } from '../app.module';
import { HostsService } from '../modules/admin/hosts.service';
import { disableSchedulersForCli } from './bootstrap-flags';
interface ParsedArgs {
command: string;
......@@ -92,6 +93,8 @@ async function main() {
}
const logger = new Logger('pac:host');
// ⚠️ 必须在建应用上下文之前:否则会清掉 service 里正在跑的同步锁(见 bootstrap-flags)
disableSchedulersForCli();
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['warn', 'error'],
});
......
......@@ -20,6 +20,7 @@ import { ColdImportService } from '../modules/sync/cold-import/cold-import.servi
import { PersonaService } from '../modules/persona/persona.service';
import { PlanEngineService } from '../modules/plan/engine/plan-engine.service';
import * as path from 'node:path';
import { disableSchedulersForCli } from './bootstrap-flags';
interface Args {
host: string;
......@@ -73,6 +74,8 @@ async function bootstrap() {
}
const logger = new Logger('import-patient');
// ⚠️ 必须在建应用上下文之前:否则会清掉 service 里正在跑的同步锁(见 bootstrap-flags)
disableSchedulersForCli();
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['log', 'warn', 'error'],
});
......
......@@ -16,6 +16,7 @@ import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { disableSchedulersForCli } from './bootstrap-flags';
interface Row {
fileNum: string;
......@@ -48,6 +49,10 @@ async function main(): Promise<void> {
const rows = parseCsv(csvArg);
logger.log(`对照表 ${rows.length} 行(去表头/垃圾后)`);
// ⚠️ 必须在建应用上下文之前:否则会清掉 service 里正在跑的同步锁(见 bootstrap-flags)
disableSchedulersForCli();
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] });
const prisma = app.get(PrismaService);
try {
......
......@@ -17,6 +17,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { RedisService } from '../redis/redis.service';
import { doctorOptionsCacheKey } from '../modules/plan/plan.service';
import { runPool } from '../common/run-pool';
import { disableSchedulersForCli } from './bootstrap-flags';
interface Args {
host: string;
......@@ -60,6 +61,8 @@ async function bootstrap() {
if (args.concurrency > 1 && !process.env.PAC_DB_CONCURRENCY) {
process.env.PAC_DB_CONCURRENCY = String(args.concurrency);
}
// ⚠️ 必须在建应用上下文之前:否则会清掉 service 里正在跑的同步锁(见 bootstrap-flags)
disableSchedulersForCli();
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['log', 'warn', 'error'],
});
......
......@@ -16,6 +16,7 @@ import { Logger } from '@nestjs/common';
import { AppModule } from '../app.module';
import { PlanEngineService } from '../modules/plan/engine/plan-engine.service';
import { PrismaService } from '../prisma/prisma.service';
import { disableSchedulersForCli } from './bootstrap-flags';
interface Args {
host: string;
......@@ -47,6 +48,8 @@ async function bootstrap() {
const n = Math.max(1, Number(process.env.PAC_PLAN_BATCH_CONCURRENCY) || 8);
if (n > 1) process.env.PAC_DB_CONCURRENCY = String(n);
}
// ⚠️ 必须在建应用上下文之前:否则会清掉 service 里正在跑的同步锁(见 bootstrap-flags)
disableSchedulersForCli();
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['log', 'warn', 'error'],
});
......
......@@ -25,6 +25,7 @@ import { createClient } from '@clickhouse/client';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { ColdImportManifestSchema } from '../modules/sync/cold-import/manifest.schema';
import { disableSchedulersForCli } from './bootstrap-flags';
// CLI 是短命进程,不需要 org-tree 启动预热(且会在 app.close() 时跟后台 warmAll 抢连接报噪音)。
process.env.PAC_ORGTREE_WARM_ON_BOOT = 'false';
......@@ -118,6 +119,10 @@ async function main(): Promise<void> {
process.exit(0);
}
// ⚠️ 必须在建应用上下文之前:否则会清掉 service 里正在跑的同步锁(见 bootstrap-flags)
disableSchedulersForCli();
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['warn', 'error'] });
try {
const prisma = app.get(PrismaService);
......
......@@ -38,6 +38,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { ColdImportService } from '../modules/sync/cold-import/cold-import.service';
import { PersonaService } from '../modules/persona/persona.service';
import { PlanEngineService } from '../modules/plan/engine/plan-engine.service';
import { disableSchedulersForCli } from './bootstrap-flags';
interface CliArgs {
dir: string;
......@@ -81,6 +82,10 @@ async function main(): Promise<void> {
return;
}
// ⚠️ 必须在建应用上下文之前:否则会清掉 service 里正在跑的同步锁(见 bootstrap-flags)
disableSchedulersForCli();
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['log', 'warn', 'error'] });
try {
const prisma = app.get(PrismaService);
......
......@@ -34,6 +34,7 @@ import {
} from '@pac/types';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { disableSchedulersForCli } from './bootstrap-flags';
interface Args {
host: string;
......@@ -77,6 +78,8 @@ function mulberry32(seed: number): () => number {
async function main(): Promise<void> {
const logger = new Logger('SeedAssignment');
const args = parseArgs(process.argv.slice(2));
// ⚠️ 必须在建应用上下文之前:否则会清掉 service 里正在跑的同步锁(见 bootstrap-flags)
disableSchedulersForCli();
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['warn', 'error'] });
const prisma = app.get(PrismaService);
......
......@@ -13,9 +13,12 @@ import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { AppModule } from '../app.module';
import { StaleScanService } from '../queues/stale-scan.service';
import { disableSchedulersForCli } from './bootstrap-flags';
async function main(): Promise<void> {
const logger = new Logger('stale-scan-cli');
// ⚠️ 必须在建应用上下文之前:否则会清掉 service 里正在跑的同步锁(见 bootstrap-flags)
disableSchedulersForCli();
const app = await NestFactory.createApplicationContext(AppModule, { logger });
try {
......
......@@ -22,6 +22,7 @@ import { PullOrchestrator } from '../modules/sync/pull/pull.orchestrator';
import { ReconcileOrchestrator } from '../modules/sync/reconcile/reconcile.orchestrator';
import { HmacVerifier } from '../modules/sync/push/hmac-verifier.service';
import { randomUUID, createHash } from 'node:crypto';
import { disableSchedulersForCli } from './bootstrap-flags';
interface CliArgs {
cmd: 'pull-setup' | 'pull' | 'reconcile' | 'push' | 'help';
......@@ -70,6 +71,8 @@ async function bootstrap() {
process.exit(0);
}
const logger = new Logger('sync:test');
// ⚠️ 必须在建应用上下文之前:否则会清掉 service 里正在跑的同步锁(见 bootstrap-flags)
disableSchedulersForCli();
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['warn', 'error'] });
try {
......
......@@ -15,6 +15,7 @@ import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { PatientService } from '../modules/patient/patient.service';
import type { TenantScopeContext } from '../common/decorators/tenant-scope.decorator';
import { disableSchedulersForCli } from './bootstrap-flags';
interface CliArgs {
pid?: string; // host externalId
......@@ -91,6 +92,8 @@ async function bootstrap() {
}
const logger = new Logger('timeline:cli');
// ⚠️ 必须在建应用上下文之前:否则会清掉 service 里正在跑的同步锁(见 bootstrap-flags)
disableSchedulersForCli();
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['warn', 'error'],
});
......
......@@ -6,6 +6,7 @@ import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { ChainComposerService } from '../modules/plan/engine/chain-composer.service';
import { disableSchedulersForCli } from './bootstrap-flags';
async function main() {
const id = process.argv.find((a) => a.startsWith('--id='))?.slice('--id='.length);
......@@ -13,6 +14,8 @@ async function main() {
console.error('Usage: --id=<patientId>');
process.exit(1);
}
// ⚠️ 必须在建应用上下文之前:否则会清掉 service 里正在跑的同步锁(见 bootstrap-flags)
disableSchedulersForCli();
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error'] });
const prisma = app.get(PrismaService);
const composer = app.get(ChainComposerService);
......
/**
* verify-gap-equivalence — gap 计算形态对拍(legacy ↔ setbased)
*
* 为什么必须有这个工具:
* `buildGapCore` 是【召回】与【画像】共用的单一真理源,改错的后果是**静默少召** ——
* 不报错、不炸测试,几个月后才由一线反馈冒出来。而现有 spec(arch-denture /
* polish-implies / restoration-in-place / treated-evidence / review-implies)
* 全是纯 JS 常量与正则断言,**不碰 SQL 行为**,重写后照样全绿 → 对这次改动的保护 ≈ 0。
* 所以正确性只能靠"两版跑同一份数据、逐 (患者×信号×牙位) 差分"来证。
*
* 怎么保证可信:
* ① 两版在**同一个 REPEATABLE READ 事务**里跑 —— 否则并发增量摄入会造出假差异。
* ② SQL 来自 scenario 自己的 `buildScenarioSql()`,不是这里另抄一份 ——
* 另抄就变成"验证我抄得对不对",而不是验证线上行为。
* ③ `--self` 模式让 legacy 跟自己对拍,先证明工具本身可信(必须零差异)。
*
* Usage:
* pnpm verify-gap-equivalence -- --host=jvs-dw # 全部 11 个子场景
* pnpm verify-gap-equivalence -- --host=jvs-dw --sub=impacted_tooth
* pnpm verify-gap-equivalence -- --host=jvs-dw --self # 自对拍(工具自检)
* pnpm verify-gap-equivalence -- --host=jvs-dw --bench # 只测耗时,不差分
*
* 退出码:0 = 零差异;1 = 有差异或出错(可直接进 CI / 部署脚本)。
*/
import { NestFactory } from '@nestjs/core';
import { Prisma } from '@prisma/client';
import { lookupDxTreatment } from '@pac/types';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { TreatmentInitiationRecallScenario } from '../modules/plan/engine/scenarios/treatment-initiation-recall.scenario';
import { PotentialTreatmentSelector } from '../modules/clinical-gap/potential-treatment.selector';
import type { ScenarioScope } from '../modules/plan/engine/scenario.interface';
import type { GapVariant } from '../modules/clinical-gap/potential-treatment-gap.sql';
import { disableSchedulersForCli } from './bootstrap-flags';
interface Args {
host: string;
sub?: string;
self: boolean;
bench: boolean;
samples: number;
/// >0 时改跑画像消费方对拍:抽 N 位患者,逐个跑两版 selectForPatient 比 gap 列表
persona: number;
/// 并发标定:批量路径的子场景并发度(--conc=N),配 --subset=M 只读跑一轮
conc: number;
/// 并发标定的患者子集规模(0=全量)
subset: number;
/// >0 时改跑**交互路径**对拍:抽 N 位患者,按 scope.patientId 单患者跑召回 SQL 两版
/// —— 详情页「刷新」(plan.controller recomputeForPatient)走的就是这条,直接面向用户,
/// 批量快不快是运维的事,这条慢了是用户当场感受得到的。
single: number;
}
function parseArgs(argv: string[]): Args {
const a: Args = { host: 'demo', self: false, bench: false, samples: 20, persona: 0, single: 0, conc: 0, subset: 0 };
for (const s of argv) {
if (s.startsWith('--host=')) a.host = s.slice('--host='.length);
else if (s.startsWith('--sub=')) a.sub = s.slice('--sub='.length);
else if (s.startsWith('--samples=')) a.samples = Number(s.slice('--samples='.length)) || 20;
else if (s === '--self') a.self = true;
else if (s === '--bench') a.bench = true;
else if (s.startsWith('--persona=')) a.persona = Number(s.slice('--persona='.length)) || 0;
else if (s.startsWith('--single=')) a.single = Number(s.slice('--single='.length)) || 0;
else if (s.startsWith('--conc=')) a.conc = Number(s.slice('--conc='.length)) || 0;
else if (s.startsWith('--subset=')) a.subset = Number(s.slice('--subset='.length)) || 0;
}
return a;
}
interface DiffRow {
side: string;
patient_id: string;
signal_fact_id: string;
tooth: string | null;
}
async function bootstrap(): Promise<number> {
const args = parseArgs(process.argv.slice(2));
// 报告一律走 console:Nest 的 logger 级别会被 createApplicationContext 全局压掉,
// 而这个 CLI 的输出就是它的全部产物 —— 不能被日志级别吃掉。
const out = (m: string): void => console.log(m);
const bad_ = (m: string): void => console.error(m);
// ⚠️ 必须在建应用上下文之前:否则会清掉 service 里正在跑的同步锁(见 bootstrap-flags)
disableSchedulersForCli();
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['warn', 'error'],
});
let bad = 0;
try {
const prisma = app.get(PrismaService);
const scenario = app.get(TreatmentInitiationRecallScenario);
const host = await prisma.host.findUnique({ where: { name: args.host } });
if (!host) throw new Error(`Host '${args.host}' not found`);
const tenants = await prisma.patient.findMany({
where: { hostId: host.id },
select: { tenantId: true },
distinct: ['tenantId'],
});
if (!tenants.length) throw new Error('No tenants found for host');
// 🔴 now 固定一次:两版必须拿同一个时间锚,否则 cooldown 边界上的信号会来回抖。
const now = new Date();
// ══ 批量路径并发标定(--conc=N [--subset=M])══
// 只读:每条子场景 SQL 外面套一层 count(*),不写库。
// 目的:量出「子场景并发」在**这台机器**上到底有没有扩展性 ——
// 2026-08-29 曾以「并发3=24.1分 vs 串行23.3分」判其无效并写进代码注释,
// 但 3% 落在 ±25% 的环境噪音里,那次测试什么也没证明(见方案 §11)。
// 判据用**墙钟**,不看各查询耗时之和(并发下单条会被争抢拉长,和不可比)。
if (args.conc > 0) {
let subsetIds: string[] | undefined;
if (args.subset > 0) {
const rows = await prisma.$queryRaw<{ id: string }[]>(Prisma.sql`
SELECT p.id FROM patients p
WHERE p.host_id = ${host.id}::uuid AND p.active = true
ORDER BY p.id LIMIT ${args.subset}`);
subsetIds = rows.map((r) => r.id);
}
const scopeB: ScenarioScope = {
hostId: host.id,
tenantId: tenants[0]!.tenantId,
now,
...(subsetIds ? { patientIds: subsetIds } : {}),
};
const variant: GapVariant = args.self ? 'setbased' : 'legacy';
const jobs = Object.entries(TreatmentInitiationRecallScenario.SUB_SCENARIOS).map(
([subKey, cfg]) => {
const rule = lookupDxTreatment(cfg.primaryCode);
if (!rule) throw new Error(`${subKey} 无 rule`);
return { subKey, sql: scenario.buildScenarioSql(scopeB, cfg.primaryCode, rule, variant) };
},
);
const runOne = async (j: { subKey: string; sql: Prisma.Sql }): Promise<string> => {
const t = Date.now();
const [, rows] = await prisma.$transaction([
prisma.$executeRaw`SET LOCAL work_mem = '256MB'`,
prisma.$queryRaw<{ n: bigint }[]>(Prisma.sql`SELECT count(*)::bigint AS n FROM (${j.sql}) q`),
]);
return `${j.subKey}=${Date.now() - t}ms/${Number(rows[0]?.n ?? 0)}`;
};
out(
` 并发标定 conc=${args.conc} 形态=${variant} 患者域=${subsetIds ? `${subsetIds.length} 位子集` : '全量'}`,
);
const t0 = Date.now();
const results: string[] = [];
for (let i = 0; i < jobs.length; i += args.conc) {
const chunk = await Promise.all(jobs.slice(i, i + args.conc).map(runOne));
results.push(...chunk);
}
const wall = Date.now() - t0;
out(` ${results.join(' ')}`);
out(` 墙钟 = ${wall}ms (${(wall / 1000).toFixed(1)}s) 这是唯一可比的数`);
return 0;
}
// ══ 交互路径对拍(详情页「刷新」:单患者召回)══
// 批量慢是运维问题,这条慢是**用户当场感受得到**的问题 —— 必须单独量。
if (args.single > 0) {
const sample = await prisma.$queryRaw<{ id: string; tenant_id: string }[]>(Prisma.sql`
SELECT p.id, p.tenant_id
FROM patients p
WHERE p.host_id = ${host.id}::uuid AND p.active = true
AND EXISTS (
SELECT 1 FROM patient_facts f
WHERE f.patient_id = p.id AND f.status = 'active'
AND f.type IN ('diagnosis_record','recommendation_record')
)
ORDER BY p.id
LIMIT ${args.single}`);
out(` 交互路径对拍(单患者召回):抽样 ${sample.length} 位`);
const entriesAll = Object.entries(TreatmentInitiationRecallScenario.SUB_SCENARIOS);
const msL: number[] = [];
const msR: number[] = [];
let diffN = 0;
for (const p of sample) {
// 一位患者 = 跑完 11 个子场景(详情页刷新的真实代价)
const scope1: ScenarioScope = {
hostId: host.id,
tenantId: p.tenant_id,
now,
patientId: p.id,
};
for (const [, cfg] of entriesAll) {
const rule = lookupDxTreatment(cfg.primaryCode);
if (!rule) continue;
const lSql = scenario.buildScenarioSql(scope1, cfg.primaryCode, rule, 'legacy');
const rSql = scenario.buildScenarioSql(
scope1,
cfg.primaryCode,
rule,
args.self ? 'legacy' : 'setbased',
);
const runOne = async (sql: Prisma.Sql): Promise<{ ms: number; key: string }> => {
const t = Date.now();
const rows = await prisma.$queryRaw<{ patient_id: string; signal_fact_id: string; tooth: string | null }[]>(
Prisma.sql`SELECT patient_id, signal_fact_id, tooth FROM (${sql}) q ORDER BY 2, 3`,
);
return {
ms: Date.now() - t,
key: rows.map((x) => `${x.signal_fact_id}#${x.tooth ?? ''}`).join('|'),
};
};
const a1 = await runOne(lSql);
const b1 = await runOne(rSql);
msL.push(a1.ms);
msR.push(b1.ms);
if (a1.key !== b1.key) {
diffN++;
if (diffN <= args.samples) {
bad_(` patient=${p.id} sub=${cfg.primaryCode}`);
bad_(` legacy : ${a1.key || '(空)'}`);
bad_(` setbased: ${b1.key || '(空)'}`);
}
}
}
}
const pct = (arr: number[], q: number): number => {
const v = [...arr].sort((x, y) => x - y);
return v[Math.min(v.length - 1, Math.floor(v.length * q))] ?? 0;
};
const sum = (arr: number[]): number => arr.reduce((x, y) => x + y, 0);
out(
` 单查询 legacy p50=${pct(msL, 0.5)}ms p95=${pct(msL, 0.95)}ms | ` +
`另一版 p50=${pct(msR, 0.5)}ms p95=${pct(msR, 0.95)}ms`,
);
out(
` 一次「刷新」(11 个子场景合计) legacy≈${Math.round(sum(msL) / sample.length)}ms | ` +
`另一版≈${Math.round(sum(msR) / sample.length)}ms`,
);
if (diffN === 0) out(` ✅ 交互路径零差异(${sample.length} 位 × 11 子场景)`);
else { bad++; bad_(` ❌ 交互路径 ${diffN} 处不一致`); }
return bad === 0 ? 0 : 1;
}
// ══ 画像消费方对拍(第二个 buildGapCore 消费方)══
// 画像是**逐患者**调用,SQL 形态与召回不同(scope 恒 1 行),必须单独验。
// 只读、不写库:直接调 selectForPatient 两次比返回值。
if (args.persona > 0) {
const selector = app.get(PotentialTreatmentSelector);
// 抽样偏向"有诊断信号的患者",否则大多数抽中的人两版都返回空数组,验了个寂寞。
const sample = await prisma.$queryRaw<{ id: string; tenant_id: string }[]>(Prisma.sql`
SELECT p.id, p.tenant_id
FROM patients p
WHERE p.host_id = ${host.id}::uuid AND p.active = true
AND EXISTS (
SELECT 1 FROM patient_facts f
WHERE f.patient_id = p.id AND f.status = 'active'
AND f.type IN ('diagnosis_record','recommendation_record')
)
ORDER BY p.id
LIMIT ${args.persona}`);
out(`▶ 画像对拍:抽样 ${sample.length} 位患者(有 active 诊断/建议信号)`);
let diffN = 0;
let withGap = 0;
const msLegacy: number[] = [];
const msRight: number[] = [];
for (const p of sample) {
const codes = await prisma.$queryRaw<{ code: string }[]>(Prisma.sql`
SELECT DISTINCT f.content->>'code' AS code FROM patient_facts f
WHERE f.patient_id = ${p.id}::uuid AND f.status = 'active'
AND f.type IN ('diagnosis_record','recommendation_record')
AND f.content->>'code' IS NOT NULL`);
const activeCodes = new Set(codes.map((c) => c.code));
const base = { hostId: host.id, tenantId: p.tenant_id, patientId: p.id, now, activeCodes };
// 🔴 画像是逐患者调用(全量 54.7 万次),单次开销会被放大 54.7 万倍 ——
// 所以这里除了比结果,还必须比**每次调用的耗时分布**(p50/p95)。
const t0 = Date.now();
const l = await selector.selectForPatient({ ...base, variant: 'legacy' });
const t1 = Date.now();
const r = await selector.selectForPatient({
...base,
variant: args.self ? 'legacy' : 'setbased',
});
msLegacy.push(t1 - t0);
msRight.push(Date.now() - t1);
const key = (g: { primaryCode: string; factId: string; tooth: string | null }): string =>
`${g.primaryCode}#${g.factId}#${g.tooth ?? ''}`;
const ls = l.map(key).sort().join('|');
const rs = r.map(key).sort().join('|');
if (l.length) withGap++;
if (ls !== rs) {
diffN++;
if (diffN <= args.samples) {
bad_(` patient=${p.id}`);
bad_(` legacy : ${ls || '(空)'}`);
bad_(` setbased: ${rs || '(空)'}`);
}
}
}
const pct = (a: number[], q: number): number => {
const v = [...a].sort((x, y) => x - y);
return v[Math.min(v.length - 1, Math.floor(v.length * q))] ?? 0;
};
const sum = (a: number[]): number => a.reduce((x, y) => x + y, 0);
out(
` 耗时/患者 legacy p50=${pct(msLegacy, 0.5)}ms p95=${pct(msLegacy, 0.95)}ms 合计=${sum(msLegacy)}ms | ` +
`另一版 p50=${pct(msRight, 0.5)}ms p95=${pct(msRight, 0.95)}ms 合计=${sum(msRight)}ms`,
);
if (diffN === 0) {
out(` ✅ 画像零差异(${sample.length} 位,其中 ${withGap} 位有 gap)`);
} else {
bad++;
bad_(` ❌ 画像 ${diffN}/${sample.length} 位患者不一致`);
}
return bad === 0 ? 0 : 1;
}
const entries = Object.entries(TreatmentInitiationRecallScenario.SUB_SCENARIOS).filter(
([k]) => !args.sub || k === args.sub,
);
if (!entries.length) throw new Error(`--sub=${args.sub} 不是已知子场景`);
for (const t of tenants) {
// --subset=N:把对拍收窄到 N 位患者。生产 113 万患者全量差分要几小时,还会跟
// 2 小时批次抢 RDS;收窄后几分钟就能在**生产真实数据**上拿到零差异证据。
// ⚠️ 收窄降低的是**覆盖**不是可信度:差异一旦出现仍然是真差异。
// 但"子集零差异"≠"全量零差异" —— 报告时必须写清跑的是多少患者。
let subsetIds: string[] | undefined;
if (args.subset > 0) {
const rows = await prisma.$queryRaw<{ id: string }[]>(Prisma.sql`
SELECT p.id FROM patients p
WHERE p.host_id = ${host.id}::uuid AND p.tenant_id = ${t.tenantId} AND p.active = true
AND EXISTS (
SELECT 1 FROM patient_facts f
WHERE f.patient_id = p.id AND f.status = 'active'
AND f.type IN ('diagnosis_record','recommendation_record')
)
ORDER BY p.id LIMIT ${args.subset}`);
subsetIds = rows.map((r) => r.id);
out(` 收窄到 ${subsetIds.length} 位**有 active 诊断/建议信号**的患者(--subset)`);
}
const scope: ScenarioScope = {
hostId: host.id,
tenantId: t.tenantId,
now,
...(subsetIds ? { patientIds: subsetIds } : {}),
};
out(
`▶ host=${args.host} tenant=${t.tenantId} 子场景=${entries.length} 个 ` +
`患者域=${args.subset > 0 ? `${args.subset} 位子集` : '全量'}`,
);
for (const [subKey, cfg] of entries) {
const rule = lookupDxTreatment(cfg.primaryCode);
if (!rule) throw new Error(`${subKey}.primaryCode=${cfg.primaryCode} 不在 DiagnosisTreatmentMap`);
const leftVariant: GapVariant = 'legacy';
const rightVariant: GapVariant = args.self ? 'legacy' : 'setbased';
const left = scenario.buildScenarioSql(scope, cfg.primaryCode, rule, leftVariant);
const right = scenario.buildScenarioSql(scope, cfg.primaryCode, rule, rightVariant);
// ── 计时:两版各单独跑一次(不在同一事务里,避免第二次白蹭第一次的缓存判断失真;
// 对拍另开一个事务)。work_mem 与线上一致。
const timeOne = async (sql: Prisma.Sql): Promise<{ ms: number; rows: number }> => {
const t0 = Date.now();
const [, rows] = await prisma.$transaction([
prisma.$executeRaw`SET LOCAL work_mem = '256MB'`,
prisma.$queryRaw<{ n: bigint }[]>(
Prisma.sql`SELECT count(*)::bigint AS n FROM (${sql}) q`,
),
]);
return { ms: Date.now() - t0, rows: Number(rows[0]?.n ?? 0) };
};
const l = await timeOne(left);
const r = await timeOne(right);
const speed = r.ms > 0 ? (l.ms / r.ms).toFixed(2) : 'n/a';
out(
` ${subKey.padEnd(24)} ${leftVariant}=${String(l.ms).padStart(7)}ms/${l.rows}行 ` +
`${rightVariant}=${String(r.ms).padStart(7)}ms/${r.rows}行 ×${speed}`,
);
if (args.bench) continue;
// ── 差分:两版同一快照,双向 EXCEPT ALL(ALL 保留重复,行数不一致也会暴露)──
const key = Prisma.raw('patient_id, signal_fact_id, tooth');
const diffSql = Prisma.sql`
WITH lg AS (${left}), sb AS (${right}),
d1 AS (SELECT ${key} FROM lg EXCEPT ALL SELECT ${key} FROM sb),
d2 AS (SELECT ${key} FROM sb EXCEPT ALL SELECT ${key} FROM lg)
SELECT 'only_legacy'::text AS side, ${key} FROM d1
UNION ALL
SELECT 'only_setbased'::text AS side, ${key} FROM d2`;
const diffs = await prisma.$transaction(
async (tx) => {
await tx.$executeRaw`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`;
await tx.$executeRaw`SET LOCAL work_mem = '256MB'`;
return tx.$queryRaw<DiffRow[]>(diffSql);
},
{ timeout: 3 * 60 * 60 * 1000, maxWait: 120_000 },
);
if (diffs.length === 0) {
out(` ${subKey.padEnd(24)} ✅ 零差异`);
} else {
bad++;
const onlyL = diffs.filter((d) => d.side === 'only_legacy').length;
const onlyR = diffs.filter((d) => d.side === 'only_setbased').length;
bad_(
` ${subKey.padEnd(24)} ❌ 差异 ${diffs.length} 条(只在 legacy=${onlyL} / 只在 setbased=${onlyR})`,
);
for (const d of diffs.slice(0, args.samples)) {
bad_(` ${d.side} patient=${d.patient_id} sig=${d.signal_fact_id} tooth=${d.tooth ?? 'NULL'}`);
}
}
}
}
} catch (e) {
bad_(e instanceof Error ? (e.stack ?? e.message) : String(e));
bad++;
} finally {
await app.close();
}
if (bad === 0) {
console.log('══ 全部零差异 ══');
return 0;
}
console.error(`══ ${bad} 个子场景不一致 / 出错 ══`);
return 1;
}
bootstrap().then((code) => process.exit(code));
......@@ -8,6 +8,26 @@ import {
RESTORATION_INELIGIBLE_DX_NAMES,
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,
ARCH_DENTURE_PREFILTER_RE,
UPPER_ARCH_FIRST_DIGITS_RE,
LOWER_ARCH_FIRST_DIGITS_RE,
} from '@pac/types';
/**
......@@ -30,6 +50,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 与画像标签都引此。
......@@ -40,6 +64,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: {
......@@ -90,6 +118,58 @@ export interface GapCoreInput {
cfgFlags: GapCfgFlags;
allCodes: readonly string[]; // dxCodes ∪ recCodes
resolverCats: readonly string[]; // resolverCategoriesFor(primaryCode)
/**
* 计算形态。**只换形态,不换口径** —— 两者必须逐 (患者×信号×牙位) 完全一致。
* 'legacy' 逐 (患者×信号) 行跑相关子查询(2026-08 之前唯一形态)
* 'setbased' 先按 (患者,牙位) 预聚合全部 resolved 证据,再做反连接
* 默认 legacy。切换与验证见 docs/design/gap-set-based-rewrite-plan.md,
* 对拍工具 src/cli/verify-gap-equivalence.cli.ts —— **改任何一边都要重跑对拍**。
*/
variant?: GapVariant;
}
export type GapVariant = 'legacy' | 'setbased';
/**
* gap 计算形态开关。默认 legacy(逐行相关子查询);PAC_GAP_VARIANT=setbased 切集合式。
*
* ⚠️ 两种形态**必须产出完全相同的行集** —— 切换前后要跑
* `pnpm verify-gap-equivalence --host=<host>`(逐 患者×信号×牙位 差分,必须零差异)。
* 设成环境开关而不是写死,是为了让同一个进程能在同一份数据上跑两版做对拍,
* 也为了生产上万一发现差异能立刻回退,不用重新发版。
*/
export function gapVariant(): GapVariant {
return process.env.PAC_GAP_VARIANT === 'setbased' ? 'setbased' : 'legacy';
}
/**
* 集合式形态的拼装件。消费方主查询变成:
*
* WITH gap_cand AS MATERIALIZED (
* SELECT <自己的投影>, ${candExtraCols}
* FROM patients p JOIN patient_profiles pp … JOIN patient_facts sig …
* WHERE <自己的闸> ${candWhere}
* )${postCtes}
* SELECT <自己的投影(从 c 取)>, ${toothOutput} AS tooth
* FROM gap_cand c ${remJoin}
* WHERE TRUE ${outerWhere}
*
* 全口码(K05/K07)时 postCtes/remJoin/outerWhere 为空、判定全在 candWhere 里 ——
* 因为全口场景根本不做牙位相减(见方案 §0.1:那条 lateral PG 本来就会自动删掉)。
*/
export interface GapSetBasedPieces {
/// gap_cand 的额外投影列(每项前带逗号)
candExtraCols: Prisma.Sql;
/// gap_cand 的 WHERE add-on(外院已治疗闸;全口码另加两个 NOT EXISTS)
candWhere: Prisma.Sql;
/// gap_cand 之后的 CTE 链(前带逗号),牙位级非空 / 全口码为空
postCtes: Prisma.Sql;
/// 主查询的 gap_rem 连接
remJoin: Prisma.Sql;
/// tooth 输出表达式
toothOutput: Prisma.Sql;
/// 主查询 WHERE add-on(牙位级:剩余非空)
outerWhere: Prisma.Sql;
}
export interface GapCorePieces {
......@@ -107,6 +187,8 @@ export interface GapCorePieces {
toothOutput: Prisma.Sql;
/// ⑤a gap 判定(WHERE add-on):全口 → NOT EXISTS 同类治疗;有牙位 → 剩余非空
gapWhere: Prisma.Sql;
/// 集合式拼装件(variant='setbased' 时非空;legacy 时 undefined)
setBased?: GapSetBasedPieces;
}
/**
......@@ -207,6 +289,180 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
? Prisma.sql`AND COALESCE(sig.content->>'name_zh','') NOT LIKE '%先天%'`
: Prisma.empty;
// (a''''') 「复查/复诊」= 修复体/矫治器在位的**证据**(不是治疗本身)。
// 宋志宏 TS0M001982 牙46;36;37:种植冠在位、按约复查,却被判"缺失牙未启动修复" ——
// 唯一带牙位的证据「种植复查」落在 review 类目,而 review 被刻意排除在 resolver 之外。
// ⛔ 只收 REVIEW_IMPLIES_TREATMENT(治疗词 + 复查/复诊),且**按类目过滤** ——
// 整类 review 放进来会误销:生产带牙位 4.4 万条里「观察」11,165 / 「无治疗」623,
// 那些恰恰是"还没治"。跨类目也不行(牙周复查 ≠ 补了龋)。
// 时间方向同治疗排除(afterDxFor):复查发生在信号之前不算数。
const reviewRules = REVIEW_IMPLIES_TREATMENT.filter((r) =>
(resolverCats as readonly string[]).includes(r.category),
);
const reviewImpliesBranch = reviewRules.length
? Prisma.sql`
UNION
SELECT rvt AS t
FROM patient_facts rvx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`rvx.content->>'tooth_position'`)}) AS rvt
WHERE rvx.patient_id = p.id
AND rvx.type = 'treatment_record' AND rvx.kind = 'actual'
AND rvx.status IN ('active', 'fulfilled')
AND rvx.content->>'category' = 'review'
AND rvx.content->>'subtype' ~ ${reviewRules.map((r) => r.pattern).join('|')}
${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' ~ '^\\['
-- ⚡ 预过滤(纯剪枝,逻辑恒等):上下颌两条 RE 都要求句中出现「义齿|假牙」,
-- 整段文本里一个都没有时任何条目都不可能命中 → 先筛掉整份病历,不进 LATERAL 展开。
-- 2026-08-29 测试库实测:exam_findings 是数组的 emr 事实 1,519,829 份,含此二词的
-- 仅 7,396 份(0.49%)。按患者相关子查询形态(本分支的实际形态)300 患者样本:
-- 832.7ms → 49.7ms,**快 16.7 倍**,结果一致。
-- ⛔ 别拿「全表扫」去验证 —— 那个形态两边都 81 秒看不出差别,照着那个数会误删本行。
-- ⛔ 改 ARCH_DENTURE_UPPER_RE / LOWER_RE 时必须同步核对本词仍是它们的必要条件。
AND adx.content->>'exam_findings' ~ ${ARCH_DENTURE_PREFILTER_RE}
-- 🔴 只认**信号诊断那一份病历**:同一份里医生同时写下"缺失"与"有义齿",用后者补前者
-- 没争议;跨次就多一层"这中间会不会变了"的推断。
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 家族里任一治疗
......@@ -304,6 +560,11 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
AND rdx.content->>'code' = ANY(${[...STRUCTURAL_DX_CODE_LIST]}::text[])
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)`;
......@@ -364,5 +625,401 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
lateralJoin,
toothOutput,
gapWhere,
setBased: input.variant === 'setbased' ? (buildGapSetBased(input) ?? undefined) : undefined,
};
}
// ═══════════════════════════════════════════════════════════════════════════
// 集合式形态(setbased)—— 与上面的 legacy 形态**独立重写一份**
//
// ⚠️ 刻意不与 legacy 共用分支片段。共用意味着"重构时写错的地方两边一起错、对拍互相抵消",
// 那样对拍就失去意义。这里是**独立再推导一遍**,靠 verify-gap-equivalence 逐
// (患者×信号×牙位) 差分来证明两份等价 —— 同 sql/verify-recall.sql 里
// 「vt_codes 重述一份、不一致即暴露」的思路。
// legacy 分支在集合式全量验收通过、生产稳定跑过一轮之后再删。
//
// 核心恒等式: ∃x∈G: t(x) ⋛ a ⟺ max{t(x) : x∈G} ⋛ a
// 分组 G = (patient_id, tooth);组内其余谓词(category/status/正则/牙位纯度)全与 sig 无关,
// 所以能先按组聚出 max(t),再跟每个信号的锚点比大小 —— 一次算完全部患者。
//
// 13 个分支对 sig 的相关性只有三类:
// ① 时间门(10 条) → 聚合键 (pid, tooth),gate = max(时间)
// ② 病历号等值(1 条) → 聚合键 (pid, tooth, enc),enc = 该病历的 emr_external_id
// ③ 无相关(2 条) → gate = 'infinity'(恒过)
// 另有 1 条((c) 建议优先)多一个 sig.type 标量谓词 → ndx 标志位
//
// 🔴 gate 列**永不为 NULL**:时间门分支一律加 `IS NOT NULL`(等价 —— NULL 本来就过不了
// `>= 锚点`),无门分支写死 'infinity'。若让 NULL 表示"无门",全 NULL 组会被 max()
// 聚成 NULL 而当成恒过 → 误销 → 静默少召。这是本次重写最危险的一个坑。
// ═══════════════════════════════════════════════════════════════════════════
/// 集合式分支的统一行形状(顺序即列序,13 个分支 UNION ALL 必须对齐)
/// patient_id | tooth | gate | enc | strict | ndx
const SB_COLS = Prisma.raw('patient_id, tooth, gate, enc, strict, ndx');
/// 无时间门 → 恒过(不能用 NULL,见上面 🔴)
const SB_NO_GATE = Prisma.sql`'infinity'::timestamptz`;
/// 患者收窄:所有分支都必须挂,否则 CTE 会全表扫 patient_facts(生产 34GB heap)
const sbScope = (alias: string): Prisma.Sql =>
Prisma.sql`${Prisma.raw(alias)}.patient_id IN (SELECT patient_id FROM gap_scope)`;
export function buildGapSetBased(input: GapCoreInput): GapSetBasedPieces | null {
const { rule, cfgFlags, allCodes, resolverCats } = input;
// 不变式:excludeIfEverTreated ⟺ wholeMouth(当前只有 K05/K07 两者同时为真)。
// 牙位级场景因此永远是 sig 锚点形态,集合式路径不必处理 latestDxOfCode 那一支。
// 谁给某个牙位级 rule 加了 excludeIfEverTreated,这里必须炸,而不是静默算错。
if (rule.excludeIfEverTreated && !rule.wholeMouth) {
throw new Error(
'buildGapSetBased: excludeIfEverTreated 目前只在 wholeMouth 规则上出现;' +
'牙位级规则要用它,得先给集合式补 latestDxOfCode 分支并重跑对拍。',
);
}
// ══ 全口码(K05/K07):**不进集合式,原样走 legacy** ══
// §0.1 已实测:全口场景 sigToothExpr=NULL → st={},toothOutput/gapWhere 都是
// `CASE WHEN TRUE`,常量折叠后 lat 无人引用 → PG 的 useless-left-join removal
// 把整个 LATERAL 摘掉。也就是说**它们本来就没在跑 resolvedTeeth**,集合式零收益。
//
// 2026-08-30 本地实测,曾试着把它们也套进 gap_cand 统一形态,结果**变慢 2~3 倍**:
// ortho_no_consult 1309ms → 4439ms(×0.29)
// perio_no_srp 1388ms → 3065ms(×0.45)
// MATERIALIZED 挡住了规划器对这两条(判定全是患者级 NOT EXISTS)的原有安排。
// ⛔ 别再为了"形态统一好看"把它们并进来 —— 没收益、纯风险、还慢。
if (rule.wholeMouth) return null;
const sigToothExpr = Prisma.sql`sig.content->>'tooth_position'`; // 全口码已早退,这里必有牙位
// ── gap_cand 的额外列(前缀 gap_ 避免跟消费方自己的投影撞名)──
const candExtraCols = Prisma.sql`,
p.id AS gap_patient_id,
sig.id AS gap_sig_id,
COALESCE(sig.occurred_at, sig.planned_for) AS gap_anchor,
sig.type AS gap_sig_type,
sig.content->>'source_encounter_external_id' AS gap_sig_enc,
COALESCE(${toothArrSql(sigToothExpr, {
dropDeciduous: cfgFlags.excludeDeciduous === true,
dropThirdMolar: cfgFlags.excludeThirdMolar === true,
})}, ARRAY[]::text[]) AS gap_sig_teeth`;
// ── 外院已治疗(回访 result)患者级排除 —— 与 legacy 同口径,放进 gap_cand ──
const externalTreatmentGate = Prisma.sql`AND NOT EXISTS (
SELECT 1 FROM patient_return_visits rv
WHERE rv.patient_id = p.id
AND rv.result ~ ${EXTERNAL_TREATMENT_VISIT_POS_RE}
AND rv.result !~ ${EXTERNAL_TREATMENT_VISIT_NEG_RE}
AND rv.task_date >= COALESCE(sig.occurred_at, sig.planned_for)::date
)`;
const refusalRe = TREATMENT_REFUSAL_SUBTYPE_PATTERNS.join('|');
const refusalImagingRe = TREATMENT_REFUSAL_IMAGING_EXCLUDE_RE;
const refusalSubtypeMatch = (alias: string): Prisma.Sql =>
Prisma.sql`regexp_replace(${Prisma.raw(alias)}.content->>'subtype', ${refusalImagingRe}, '', 'g') ~ ${refusalRe}`;
// ══ 牙位级:13 个分支预聚合 ══
const branches: Prisma.Sql[] = [];
// ① (a) 治疗家族 resolver —— 同牙做了 resolverCats 家族里任一治疗
branches.push(Prisma.sql`
SELECT rtx.patient_id, rtt AS tooth, rtx.occurred_at AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts rtx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`rtx.content->>'tooth_position'`)}) AS rtt
WHERE ${sbScope('rtx')}
AND rtx.type = 'treatment_record' AND rtx.kind = 'actual'
AND rtx.status IN ('active', 'fulfilled')
AND COALESCE(NULLIF(trim(rtx.content->>'tooth_position'), ''), '') != ''
AND rtx.content->>'category' = ANY(${resolverCats}::text[])
AND rtx.occurred_at IS NOT NULL`);
// ② (a'') 桥类修复的牙位盲点 —— 基牙跨度区间内全部牙位计入
branches.push(Prisma.sql`
SELECT btx.patient_id, cov.arch_tooth AS tooth, btx.occurred_at AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts btx
CROSS JOIN LATERAL (
SELECT min(z.idx) AS mi, max(z.idx) AS ma, z.arch
FROM (
SELECT CASE substr(bt,1,1)
WHEN '1' THEN 9 - substr(bt,2,1)::int
WHEN '2' THEN 8 + substr(bt,2,1)::int
WHEN '4' THEN 9 - substr(bt,2,1)::int
WHEN '3' THEN 8 + substr(bt,2,1)::int END AS idx,
CASE WHEN substr(bt,1,1) IN ('1','2') THEN 'U' ELSE 'L' END AS arch
FROM unnest(${toothArrSql(Prisma.sql`btx.content->>'tooth_position'`)}) AS bt
WHERE bt ~ '^[1-4][1-8]$'
) z GROUP BY z.arch HAVING count(*) >= 2
) span
CROSS JOIN LATERAL (
SELECT CASE WHEN span.arch = 'U'
THEN CASE WHEN gi <= 8 THEN '1' || (9 - gi)::text ELSE '2' || (gi - 8)::text END
ELSE CASE WHEN gi <= 8 THEN '4' || (9 - gi)::text ELSE '3' || (gi - 8)::text END
END AS arch_tooth
FROM generate_series(span.mi, span.ma) AS gi
) cov
WHERE ${sbScope('btx')}
AND btx.type = 'treatment_record' AND btx.kind = 'actual'
AND btx.status IN ('active', 'fulfilled')
AND btx.content->>'category' = 'prosthodontic'
AND btx.occurred_at IS NOT NULL`);
// ③ (a''') 检查所见"缺牙但间隙关闭/无修复间隙" → 该牙无修复指征
const noRestorMsgRe = NO_RESTORATION_GAP_EXAM_PATTERNS.join('|');
branches.push(Prisma.sql`
SELECT src.patient_id, nrt AS tooth, src.occurred_at AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM (
SELECT emrx.patient_id, emrx.content->>'exam_findings' AS ef_text, emrx.occurred_at
FROM patient_facts emrx
WHERE ${sbScope('emrx')}
AND emrx.type = 'emr_record' AND emrx.status IN ('active', 'fulfilled')
AND emrx.content->>'exam_findings' ~ '^\\['
AND emrx.content->>'exam_findings' ~ '缺[牙失]'
AND emrx.content->>'exam_findings' ~ ${noRestorMsgRe}
AND emrx.occurred_at IS NOT NULL
) src
CROSS JOIN LATERAL jsonb_array_elements(src.ef_text::jsonb) AS ef
CROSS JOIN unnest(${toothArrSql(Prisma.sql`ef->>'toothPosition'`)}) AS nrt
WHERE (ef->>'message') ~ '缺[牙失]'
AND (ef->>'message') ~ ${noRestorMsgRe}`);
// ④ (a'''') 患者无意愿:该 category 治疗被患者拒绝
branches.push(Prisma.sql`
SELECT rfx.patient_id, rft AS tooth,
COALESCE(rfx.occurred_at, rfx.planned_for) AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts rfx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`rfx.content->>'tooth_position'`)}) AS rft
WHERE ${sbScope('rfx')}
AND rfx.type = 'treatment_record' AND rfx.status IN ('active', 'fulfilled')
AND ${refusalSubtypeMatch('rfx')}
AND rfx.content->>'category' = ANY(${resolverCats}::text[])
AND COALESCE(rfx.occurred_at, rfx.planned_for) IS NOT NULL`);
// ⑤ (b) 同牙位以【最新诊断】为准 —— 🔴 严格 >(其余分支都是 >=)
branches.push(Prisma.sql`
SELECT ldx.patient_id, ldt AS tooth, ldx.occurred_at AS gate,
NULL::text AS enc, TRUE AS strict, FALSE AS ndx
FROM patient_facts ldx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`ldx.content->>'tooth_position'`)}) AS ldt
WHERE ${sbScope('ldx')}
AND ldx.type = 'diagnosis_record' AND ldx.status = 'active'
AND ldx.content->>'code' = ANY(${[...STRUCTURAL_DX_CODE_LIST]}::text[])
AND ldx.occurred_at IS NOT NULL`);
// ⑥ (c) 诊断 vs 建议冲突以建议为准 —— 🔴 只对 sig.type='diagnosis_record' 生效(ndx)
branches.push(Prisma.sql`
SELECT rdx.patient_id, rdt AS tooth,
COALESCE(rdx.occurred_at, rdx.planned_for) AS gate,
NULL::text AS enc, FALSE AS strict, TRUE AS ndx
FROM patient_facts rdx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`rdx.content->>'tooth_position'`)}) AS rdt
WHERE ${sbScope('rdx')}
AND rdx.type = 'recommendation_record' AND rdx.status = 'active'
AND rdx.content->>'code' = ANY(${[...STRUCTURAL_DX_CODE_LIST]}::text[])
AND COALESCE(rdx.occurred_at, rdx.planned_for) IS NOT NULL`);
// ⑦ (a''''') 「复查/复诊」= 修复体在位的证据(按类目过滤,不收整类 review)
const reviewRules = REVIEW_IMPLIES_TREATMENT.filter((r) =>
(resolverCats as readonly string[]).includes(r.category),
);
if (reviewRules.length) {
branches.push(Prisma.sql`
SELECT rvx.patient_id, rvt AS tooth, rvx.occurred_at AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts rvx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`rvx.content->>'tooth_position'`)}) AS rvt
WHERE ${sbScope('rvx')}
AND rvx.type = 'treatment_record' AND rvx.kind = 'actual'
AND rvx.status IN ('active', 'fulfilled')
AND rvx.content->>'category' = 'review'
AND rvx.content->>'subtype' ~ ${reviewRules.map((r) => r.pattern).join('|')}
AND rvx.occurred_at IS NOT NULL`);
}
// ⑧ (a'''''') 缺牙位上的裸「抛光」= 修复体在位(仅 K08 开闸)
if (cfgFlags.polishImpliesRestoration) {
branches.push(Prisma.sql`
SELECT plx.patient_id, plt AS tooth, plx.occurred_at AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts plx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`plx.content->>'tooth_position'`)}) AS plt
WHERE ${sbScope('plx')}
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}
AND plx.occurred_at IS NOT NULL`);
}
// ⑨ (a''''''') 病历自由文本自证已治疗(治疗记录 ⋈ 同次病历;与 sig 无关)
const evidenceTerms = TREATED_EVIDENCE_RESTORATION_TERMS.filter((t) =>
(resolverCats as readonly string[]).includes(t.category),
);
if (cfgFlags.treatedEvidenceFromEmrText && evidenceTerms.length) {
const emrTextSql = Prisma.raw(
TREATED_EVIDENCE_EMR_FIELDS.map((f) => `COALESCE(emx.content->>'${f}', '')`).join(" || ' ' || "),
);
branches.push(Prisma.sql`
SELECT tvx.patient_id, tvt AS tooth, tvx.occurred_at AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
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 ${sbScope('tvx')}
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
}
AND tvx.occurred_at IS NOT NULL`);
}
// ⑩ (a'''''''') 检查所见写着修复体在位(同颌闸 + 失效词一票否决)
if (cfgFlags.restorationInPlaceFromExam) {
branches.push(Prisma.sql`
SELECT rsrc.patient_id, ript AS tooth, rsrc.occurred_at AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM (
SELECT rix.patient_id, rix.content->>'exam_findings' AS ef_text, rix.occurred_at
FROM patient_facts rix
WHERE ${sbScope('rix')}
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}
AND rix.occurred_at IS NOT NULL
) 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]')
)`);
}
// ⑪ (a''''''''') 整颌活动义齿 —— 🔴 唯一走【病历号等值】相关的分支(enc 列)
// legacy: adx.emr_external_id = sig.source_encounter_external_id(无时间门)
// setbased: enc 进聚合键,gate 恒过
if (cfgFlags.archDentureIsRestored) {
branches.push(Prisma.sql`
SELECT adx.patient_id, adt AS tooth, ${SB_NO_GATE} AS gate,
adx.content->>'emr_external_id' AS enc, FALSE AS strict, FALSE AS ndx
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 ${sbScope('adx')}
AND adx.type = 'emr_record' AND adx.status IN ('active', 'fulfilled')
AND adx.content->>'exam_findings' ~ '^\\['
AND adx.content->>'exam_findings' ~ ${ARCH_DENTURE_PREFILTER_RE}
AND adx.content->>'emr_external_id' IS NOT NULL
AND (ade->>'message') !~ ${ARCH_DENTURE_INTENT_EXCLUDE_RE}
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})
)`);
}
// ⑫ §E 正畸减数位 —— 与 sig 无关,gate 恒过
if (cfgFlags.excludeOrthoExtractionSites) {
const exTeeth = toothArrSql(Prisma.sql`ex.content->>'tooth_position'`);
branches.push(Prisma.sql`
SELECT ex.patient_id, eet AS tooth, ${SB_NO_GATE} AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts ex
CROSS JOIN unnest(${exTeeth}) AS eet
WHERE ${sbScope('ex')}
AND ex.type = 'treatment_record' AND ex.kind = 'actual' AND ex.status IN ('active','fulfilled')
AND ex.content->>'category' = 'surgical'
AND eet ~ '^[1-4][45]$'
AND NOT EXISTS (
SELECT 1 FROM unnest(${exTeeth}) AS xt WHERE xt !~ '^[1-4][45]$'
)
AND (${exTeeth} @> ARRAY['14','24']::text[]
OR ${exTeeth} @> ARRAY['34','44']::text[])
AND EXISTS (SELECT 1 FROM patient_facts oc WHERE oc.patient_id = ex.patient_id
AND ((oc.type='diagnosis_record' AND oc.status='active' AND oc.content->>'code'='K07')
OR (oc.type='treatment_record' AND oc.content->>'category'='orthodontic')))`);
}
// ⑬ 「建议拔除」让位同牙编码病种诊断 —— 与 sig 无关,gate 恒过
if (cfgFlags.deferToToothDx) {
branches.push(Prisma.sql`
SELECT dxx.patient_id, ddt AS tooth, ${SB_NO_GATE} AS gate,
NULL::text AS enc, FALSE AS strict, FALSE AS ndx
FROM patient_facts dxx
CROSS JOIN unnest(${toothArrSql(Prisma.sql`dxx.content->>'tooth_position'`)}) AS ddt
WHERE ${sbScope('dxx')}
AND dxx.type = 'diagnosis_record' AND dxx.status = 'active'
AND dxx.content->>'code' = ANY(ARRAY['K00','K01','K02','K03','K04','K06','K08','K09']::text[])`);
}
const branchUnion = Prisma.join(branches, '\n UNION ALL\n');
const postCtes = Prisma.sql`,
gap_scope AS MATERIALIZED (
SELECT DISTINCT gap_patient_id AS patient_id FROM gap_cand
),
gap_resolved AS MATERIALIZED (
-- 按 (患者, 牙位, 病历号, 严格性, 需诊断信号) 聚合出每组的 max(时间门)
SELECT patient_id, tooth, max(gate) AS gate, enc, strict, ndx
FROM ( ${branchUnion} ) sb(${SB_COLS})
GROUP BY patient_id, tooth, enc, strict, ndx
),
gap_rem AS MATERIALIZED (
-- 牙位级反连接。WITH ORDINALITY + ORDER BY ord:保持 legacy 的 unnest 自然顺序,
-- 也保留重复牙位 —— tooth 串会落进 plan_reasons 给客服看,顺序变了就是 diff 噪音。
SELECT c.gap_sig_id AS sig_id,
array_agg(u.x ORDER BY u.ord) AS remaining_teeth
FROM gap_cand c
CROSS JOIN LATERAL unnest(c.gap_sig_teeth) WITH ORDINALITY AS u(x, ord)
WHERE NOT EXISTS (
SELECT 1 FROM gap_resolved r
WHERE r.patient_id = c.gap_patient_id
AND r.tooth = u.x
AND (CASE WHEN r.strict THEN r.gate > c.gap_anchor ELSE r.gate >= c.gap_anchor END)
AND (r.enc IS NULL OR r.enc = c.gap_sig_enc)
AND (NOT r.ndx OR c.gap_sig_type = 'diagnosis_record')
)
GROUP BY c.gap_sig_id
)`;
return {
candExtraCols,
candWhere: externalTreatmentGate,
postCtes,
remJoin: Prisma.sql`LEFT JOIN gap_rem ON gap_rem.sig_id = c.gap_sig_id`,
// 🔴 gap_rem 无行 ≠ 空数组:牙位全被解决的 sig 在 gap_rem 里没有行 → COALESCE 补 {}
toothOutput: Prisma.sql`array_to_string(COALESCE(gap_rem.remaining_teeth, ARRAY[]::text[]), ';')`,
outerWhere: Prisma.sql`AND cardinality(COALESCE(gap_rem.remaining_teeth, ARRAY[]::text[])) > 0`,
};
}
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { lookupDxTreatment, resolverCategoriesFor } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import {
buildGapCore,
GAP_FLAGS_BY_PRIMARY,
GAP_PRIMARY_GROUPS,
gapVariant,
type GapVariant,
} from './potential-treatment-gap.sql';
/**
......@@ -31,8 +34,11 @@ export class PotentialTreatmentSelector {
patientId: string;
now: Date;
activeCodes: Set<string>;
/// 仅对拍工具用:强制 gap 计算形态。生产路径不传,走 gapVariant() 的环境开关。
variant?: GapVariant;
}): Promise<PotentialGap[]> {
const { hostId, tenantId, patientId, now, activeCodes } = opts;
const variant = opts.variant ?? gapVariant();
const out: PotentialGap[] = [];
for (const [primaryCode, group] of Object.entries(GAP_PRIMARY_GROUPS)) {
......@@ -43,21 +49,23 @@ export class PotentialTreatmentSelector {
if (!rule) continue;
const resolverCats = resolverCategoriesFor(primaryCode) as readonly string[];
const cfgFlags = GAP_FLAGS_BY_PRIMARY[primaryCode] ?? {};
const gap = buildGapCore({ rule, cfgFlags, allCodes, resolverCats });
const gap = buildGapCore({ rule, cfgFlags, allCodes, resolverCats, variant });
const rows = await this.prisma.$queryRaw<RawGapRow[]>`
SELECT
// 投影列(两形态共用;tooth 单列,取法不同)
const projection = Prisma.sql`
sig.id AS fact_id,
sig.content->>'code' AS code,
sig.content->>'name_zh' AS name_zh,
sig.type AS signal_type,
${gap.toothOutput} AS tooth,
sig.content->>'confidence' AS confidence,
EXTRACT(DAY FROM ${now}::timestamptz - COALESCE(sig.occurred_at, sig.planned_for))::int AS days_since,
COALESCE(sig.occurred_at, sig.planned_for) AS anchor_at
COALESCE(sig.occurred_at, sig.planned_for) AS anchor_at`;
// ⚠️ 画像是**逐患者**调用(全量 54.7 万次),这里的 scope 恒为 1 个患者 ——
// gap_scope 只有一行,各分支走 (patient_id, type, status) 索引,形态不会退化成全表扫。
const queryBody = (joinAddon: Prisma.Sql, gapAddon: Prisma.Sql): Prisma.Sql => Prisma.sql`
FROM patients p
JOIN patient_facts sig ON sig.patient_id = p.id
${gap.lateralJoin}
${joinAddon}
WHERE p.host_id = ${hostId}::uuid
AND p.tenant_id = ${tenantId}
AND p.id = ${patientId}::uuid
......@@ -68,8 +76,26 @@ export class PotentialTreatmentSelector {
AND COALESCE(sig.occurred_at, sig.planned_for) IS NOT NULL
${gap.restorationIneligibleFrag}
${gap.congenitalFrag}
${gap.gapWhere}
`;
${gapAddon}`;
const sb = gap.setBased;
const sql = sb
? Prisma.sql`
WITH gap_cand AS MATERIALIZED (
SELECT ${projection}${sb.candExtraCols}
${queryBody(Prisma.empty, sb.candWhere)}
)${sb.postCtes}
SELECT c.fact_id, c.code, c.name_zh, c.signal_type, c.confidence, c.days_since, c.anchor_at,
${sb.toothOutput} AS tooth
FROM gap_cand c
${sb.remJoin}
WHERE TRUE ${sb.outerWhere}`
: Prisma.sql`
SELECT ${projection},
${gap.toothOutput} AS tooth
${queryBody(gap.lateralJoin, gap.gapWhere)}`;
const rows = await this.prisma.$queryRaw<RawGapRow[]>(sql);
for (const r of rows) {
out.push({
primaryCode,
......
......@@ -241,6 +241,11 @@ export class PlanEngineService {
);
}
// ⏱ 三段计时(场景 / 预取 / 写入)—— 常态开启,每轮 1 行。
// ⛔ 别删:2026-08-29 生产 plan 段从 12 分钟涨到 159 分钟仍未跑完,而引擎从
// 「▶ Running engine」到最后的统计块之间**什么都不打**,整轮是个黑盒 ——
// 只能靠临时起 pg_stat_activity 采样器反推在哪一段。有这行就不用再猜。
const tSelect = Date.now();
// 1. 各 scenario 跑 selector,汇总 hits
const hitsByPatient = new Map<string, ScenarioHitWithKey[]>();
for (const sc of this.scenarios) {
......@@ -265,9 +270,13 @@ export class PlanEngineService {
// → 逐患复用**同一** upsert 判定(结果等价)→ 写操作**分块并发** → PlanGenerationLog 收集后
// 一次性 createMany(每患仍一行,审计/监控口径不变,只是写法从 12.5万次 → 几次)。
// 注:selectHits 的全表扫不在本优化内(时间规则随时可改,每轮必须全量重选才正确)。
const selectMs = Date.now() - tSelect;
const patientIds = [...hitsByPatient.keys()];
const tPrefetch = Date.now();
const { latestByPatient, snoozedByPatient, personaByPatient, lastVisitClinicByPatient, touchedPlanIds } =
await this.prefetchForBatch(scope, patientIds, now);
const prefetchMs = Date.now() - tPrefetch;
const tWrite = Date.now();
const EMPTY_SNOOZE = new Map<string, Date>();
const logRows: Prisma.PlanGenerationLogCreateManyInput[] = [];
// ⭐ 2026-07-26:写操作从「每 N 个一批 await Promise.all」的**栅栏**改成连续调度的
......@@ -413,6 +422,10 @@ export class PlanEngineService {
this.logger.warn(`[标签] 补齐失败(不阻断生成,夜间刷新会兜住):${e instanceof Error ? e.message : e}`);
}
this.logger.log(
`[plan] 阶段耗时 场景=${selectMs}ms 预取=${prefetchMs}ms 写入=${Date.now() - tWrite}ms ` +
`命中患者=${patientIds.length} 总计=${Date.now() - startedAt.getTime()}ms`,
);
return {
scenariosRun: this.scenarios.length,
patientsHit: hitsByPatient.size,
......@@ -459,16 +472,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 +513,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));
......@@ -894,24 +930,20 @@ export class PlanEngineService {
const snoozedByPatient = new Map<string, Map<string, Date>>();
const personaByPatient = new Map<string, string>();
const lastVisitClinicByPatient = new Map<string, string>();
const CHUNK = 2000;
for (let i = 0; i < patientIds.length; i += CHUNK) {
const ids = patientIds.slice(i, i + CHUNK);
// latest plan(含 reasons)— 每患者最高 version 那条(对齐 upsertPlan 的 orderBy version desc)
const plans = await this.prisma.followupPlan.findMany({
where: { hostId: scope.hostId, tenantId: scope.tenantId, patientId: { in: ids } },
include: { reasons: true },
orderBy: [{ patientId: 'asc' }, { version: 'desc' }],
});
for (const p of plans) {
if (p.patientId && !latestByPatient.has(p.patientId)) latestByPatient.set(p.patientId, p);
}
// snooze 抑制集(对齐 fetchSnoozedSignalKeys:终态 + 冷静期未到期 plan 的 reason → 结案锚点)
const terminal = await this.prisma.followupPlan.findMany({
// ⭐ snooze 抑制集**提到循环外一次查完** —— 它的代价跟「终态且冷静期未到期的计划数」走,
// 跟患者数无关。2026-08-30 生产实测:全库符合条件的只有 **106 行**,
// 而原来按 chunk 查会跑 274 次(54.8 万患者 / 2000),**273 次是在查空**。
// 索引 (status, …) 前导 status,completed/abandoned 是稀有态 → Bitmap 扫 42 buffers / 0.6ms。
//
// 🔴 这不是省常数,是**消掉一个 O(患者数) 项** —— 原写法到 200 万患者会变成 1000 次往返,
// 新写法仍是 1 次。生产是百万级且在涨,这类项必须按规模而不是按当下耗时来判断。
// 口径不变:map 只会被本批患者查到,多取的那些患者条目不影响任何判定。
{
const terminalAll = await this.prisma.followupPlan.findMany({
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
patientId: { in: ids },
status: { in: ['completed', 'abandoned'] },
snoozedUntil: { gt: now },
},
......@@ -919,13 +951,11 @@ export class PlanEngineService {
patientId: true,
updatedAt: true,
reasons: { select: { scenario: true, subKey: true } },
// 结案锚点 = 结案 execution.createdAt(不可变;updatedAt 会被召回反馈等后续写顶后,仅兜底)
executions: { orderBy: { createdAt: 'desc' }, take: 1, select: { createdAt: true } },
},
});
{
const plansByPatient = new Map<string, typeof terminal>();
for (const t of terminal) {
const plansByPatient = new Map<string, typeof terminalAll>();
for (const t of terminalAll) {
if (!t.patientId) continue;
const arr = plansByPatient.get(t.patientId) ?? [];
arr.push(t);
......@@ -935,22 +965,29 @@ export class PlanEngineService {
snoozedByPatient.set(pid, buildSnoozeAnchors(plans));
}
}
const CHUNK = 2000;
for (let i = 0; i < patientIds.length; i += CHUNK) {
const ids = patientIds.slice(i, i + CHUNK);
// ⭐ 三条彼此独立,**并行发** —— 原来是串行,每 chunk 的墙钟 = 三条之和;
// 并行后 = 最慢那条。三条都只读、无共享状态,并行不改任何口径。
// 并发度就是 3(不随患者数涨),不会挤爆连接池(Prisma 默认池 = 核数×2+1)。
const [plans, personas, visits] = await Promise.all([
// latest plan(含 reasons)— 每患者最高 version 那条(对齐 upsertPlan 的 orderBy version desc)
this.prisma.followupPlan.findMany({
where: { hostId: scope.hostId, tenantId: scope.tenantId, patientId: { in: ids } },
include: { reasons: true },
orderBy: [{ patientId: 'asc' }, { version: 'desc' }],
}),
// active persona id(每患最新 active 版本)
const personas = await this.prisma.persona.findMany({
this.prisma.persona.findMany({
where: { patientId: { in: ids }, supersededAt: null },
orderBy: [{ patientId: 'asc' }, { version: 'desc' }],
select: { id: true, patientId: true },
});
for (const pe of personas) {
if (pe.patientId && !personaByPatient.has(pe.patientId)) {
personaByPatient.set(pe.patientId, pe.id);
}
}
}),
// 每患最后一次到诊(encounter/emr)所在诊所 → 跟进归属(见 upsertPlan 说明)。
// DISTINCT ON 取 occurred_at 最新那条;跟"就诊冷静期"用同一 type 口径,语义一致。
const visits = await this.prisma.$queryRaw<
Array<{ patient_id: string; clinic_id: string }>
>`
this.prisma.$queryRaw<Array<{ patient_id: string; clinic_id: string }>>`
SELECT DISTINCT ON (patient_id) patient_id, clinic_id
FROM patient_facts
WHERE host_id = ${scope.hostId}::uuid
......@@ -961,7 +998,16 @@ export class PlanEngineService {
AND clinic_id IS NOT NULL AND clinic_id <> ''
AND superseded_at IS NULL
ORDER BY patient_id, occurred_at DESC
`;
`,
]);
for (const p of plans) {
if (p.patientId && !latestByPatient.has(p.patientId)) latestByPatient.set(p.patientId, p);
}
for (const pe of personas) {
if (pe.patientId && !personaByPatient.has(pe.patientId)) {
personaByPatient.set(pe.patientId, pe.id);
}
}
for (const v of visits) lastVisitClinicByPatient.set(v.patient_id, v.clinic_id);
}
// ⭐ 一次查完「客服碰过哪些单」—— 只问带 assignment_id 的那些(通常是很小的子集),
......
......@@ -10,6 +10,7 @@ import {
treatmentCategoryNameZhFor,
recommendedCategoriesForAge,
refineCategoriesForDiagnosis,
type DxTreatmentRule,
} from '@pac/types';
import { PrismaService } from '../../../../prisma/prisma.service';
import type {
......@@ -20,7 +21,14 @@ import type {
import { calcPriority } from '../priority-scorer';
import { toothSet } from '../../../sync/pipeline/parsers/tooth-position.util';
// ⭐ gap 核心单一真理源(召回 + 潜在治疗画像共用;SQL 逻辑搬此,本文件只组装)
import { buildGapCore, GAP_FLAGS_BY_PRIMARY, GAP_PRIMARY_GROUPS } from '../../../clinical-gap/potential-treatment-gap.sql';
import {
buildGapCore,
GAP_FLAGS_BY_PRIMARY,
GAP_PRIMARY_GROUPS,
gapVariant,
type GapVariant,
} from '../../../clinical-gap/potential-treatment-gap.sql';
/**
* 潜在治疗新链召回(treatment_initiation_recall)— v2.1 重写
......@@ -220,6 +228,29 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// 10 个子场景查询彼此独立(各自 SQL + union-find 合并 + 打分,无共享状态);
// 合并/去重(下游 hitsByPatient Map + max 分 + Set 比对)与顺序无关 → 并行 = 结果逐字节相同。
// PAC_RECALL_SUBSCENARIO_CONCURRENCY=N(>1)开并行(分块 Promise.all);默认串行,行为不变。
//
// ⭐ **这是目前实测最有效的一根杠杆:生产全量 ×2.31。** 生产已设 =4。
//
// 2026-08-30 生产全量实测(113 万患者 / 54.8 万命中,同一台 RDS,相邻两轮):
// 串行(=1): 场景段 7,200,096ms(2h00m) 整轮 8,654,105ms(2h24m)
// 并发(=4): 场景段 3,115,928ms(51m56s) 整轮 4,806,869ms(1h20m)
// → 场景段 ×2.31,整轮 ×1.80,省 64 分钟。**2 小时窗口由此进得去。**
// 先在生产一个 39,148 患者的诊所上交替标定过(串行 57.6/56.9s → 并发4 26.1/26.2/27.9s,
// ×2.18),再由全量复现 ×2.31 —— 子集外推这次成立。
//
// 🔴 **本段注释此前写反了,导致生产一年多没开这个旋钮**,原文:
// 「⛔ 别指望靠这个旋钮提速 —— 并发3=24.1分 vs 串行23.3分,不但没快还略慢。
// 原因:本阶段是共享磁盘 I/O 受限…并行只是抢同一批 page,总读取量一个字节都没少。」
// 两处都错:
// ① **3% 的差距落在噪音里**。2026-08-30 用「两边跑同一条 SQL」的对照组量过,
// 该测试机的环境噪音是 ±25% —— 那次比较什么也没证明,却被当成定论。
// ② **机制归因也错**:真瓶颈是**延迟**(逐次索引探查在等 page 返回,CPU 和磁盘都闲着),
// 不是吞吐。所以并发 2 就能拿到超线性(生产 ×1.50,测试机 ×2.88)。
// 若真是吞吐受限,墙钟应约等于各查询耗时之和;实测墙钟只有求和的 43%。
//
// ⚠️ **读数注意**:并发下**单条**查询的 sql= 会被争抢拉长(生产 caries 23.8→27.7 分,
// perio 12.6→16.9 分),看单条会误以为变慢了。**判据只能是阶段墙钟**,
// 不能看各条耗时之和 —— 上面那次误判有一半就栽在这。
const conc = Math.max(1, Number(process.env.PAC_RECALL_SUBSCENARIO_CONCURRENCY) || 1);
// ⚠️ 不能 hits.push(...subHits):spread 把每个元素当实参压栈,V8 实参上限 ~6.5万;
// host 患者到 ~28 万后单子场景命中可超限 → RangeError: Maximum call stack size exceeded
......@@ -247,35 +278,31 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// 子场景跑 SQL + 算 6 因子分
// ─────────────────────────────────────────────────────────
private async runSubScenario(
/**
* 构造单个子场景的召回 SQL。**抽成独立方法是为了让对拍工具能拿到线上跑的那条 SQL 本身** ——
* verify-gap-equivalence 用同一份代码生成 legacy / setbased 两版再逐行差分,
* 不另写一份查询(另写就变成"验证我抄得对不对",而不是验证线上行为)。
*/
buildScenarioSql(
scope: ScenarioScope,
subKey: string,
cfg: (typeof TreatmentInitiationRecallScenario.SUB_SCENARIOS)[keyof typeof TreatmentInitiationRecallScenario.SUB_SCENARIOS],
): Promise<ScenarioHit[]> {
// 临床窗口 / 类别 / 紧迫临界从 canonical-codes.DiagnosisTreatmentMap 单一真理源读
const rule = lookupDxTreatment(cfg.primaryCode);
if (!rule) {
throw new Error(
`SUB_SCENARIOS[${subKey}].primaryCode=${cfg.primaryCode} 在 DiagnosisTreatmentMap 中找不到 — ` +
`检查 canonical-codes.ts(单一真理源)`,
);
}
const start = rule.cooldownDays;
const goldenRange: [number, number] = [rule.cooldownDays, rule.windowDays];
primaryCode: string,
rule: DxTreatmentRule,
variant: GapVariant,
): Prisma.Sql {
// 码分组 → 单一真理源 GAP_PRIMARY_GROUPS(召回 + 潜在治疗画像共用,不在 SUB_SCENARIOS 内联)
const grp = GAP_PRIMARY_GROUPS[cfg.primaryCode] ?? { dxCodes: [], recCodes: [] };
const grp = GAP_PRIMARY_GROUPS[primaryCode] ?? { dxCodes: [], recCodes: [] };
const dxCodes = grp.dxCodes as readonly string[];
const recCodes = grp.recCodes as readonly string[];
const allCodes = [...dxCodes, ...recCodes];
// §E gap 修正 flag → 单一真理源 GAP_FLAGS_BY_PRIMARY(召回 + 潜在治疗画像共用)
const cfgFlags = GAP_FLAGS_BY_PRIMARY[cfg.primaryCode] ?? {};
const cfgFlags = GAP_FLAGS_BY_PRIMARY[primaryCode] ?? {};
// ⭐ 两个口径分开(单一真理源 canonical-codes):
// expectedCats = rule.categories(窄,主治疗)→ 展示"未启动 X" + 触发预期 + ⑤d 主诉匹配
// resolverCats = resolverCategoriesFor(宽,治疗家族)→ ⑤a "已解决" 判定
// 结构码(K02/K03/K08…)= 任何局部结构治疗都算(充填/根管/冠桥/种植/外科/美学/儿牙);
// 牙周/正畸(K05/K06/K07)沿用各自 categories。见 canonical-codes.resolverCategoriesFor。
const expectedCats = rule.categories as readonly string[];
const resolverCats = resolverCategoriesFor(cfg.primaryCode) as readonly string[];
const resolverCats = resolverCategoriesFor(primaryCode) as readonly string[];
// 收窄(可空,两种粒度):
// - scope.patientId 单患者(详情页"刷新"):O(全租户) → O(1)
......@@ -299,7 +326,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// ⭐ gap 核心(sig 牙位 / resolved / remaining + ⑤a 判定 + 废用牙/先天剔除)抽到共享模块
// potential-treatment-gap.sql —— 召回与潜在治疗画像【单一真理源】,SQL 逻辑零改动只搬家。
// 召回在此基础上再加时间门(④ cooldown / ⑤b 预约 / ⑤d entered / ⑤f 到诊)+ 6 因子打分。
const gap = buildGapCore({ rule, cfgFlags, allCodes, resolverCats });
const gap = buildGapCore({ rule, cfgFlags, allCodes, resolverCats, variant });
// ╔═════════════════════════════════════════════════════════════════════╗
// ║ 召回 SQL 完整解读(initiation = 潜在治疗新链召回) ║
......@@ -366,8 +393,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// ║ 输出:每个命中 (patient × sig) 一行,后段 byPatient Map 去重 ║
// ║ 只留 daysSince 最大那条(最早诊断 = 最有召回价值) ║
// ╚═════════════════════════════════════════════════════════════════════╝
const rows: HitRow[] = await this.prisma.$queryRaw`
SELECT
// ── 投影列(legacy / setbased 两形态共用;tooth 单列,两边取法不同)──
const projection = Prisma.sql`
p.id AS patient_id,
p.external_id AS patient_external_id,
-- 建议治疗的年龄适配用(只影响"建议做什么",不参与召回筛选/排除;无生日 → NULL 走默认)
......@@ -378,19 +405,39 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
-- 诊断原文词(K00 主类目细分用:滞留→外科 / 早失→正畸 / 先天缺→修复…)。
-- 纯投影,不进任何 WHERE —— 返回行集与加它之前逐行相同。
sig.content->>'name_zh' AS signal_name_zh,
-- ⭐ 牙位级相减:有牙位信号 → 剩余未治牙位;全口信号 → 原样 NULL(gap 核心,共享模块)
${gap.toothOutput} AS tooth,
sig.content->>'extracted_by' AS extracted_by,
sig.content->>'confidence' AS confidence,
sig.content->>'code_source' AS code_source, -- 置信度因子:std_code/name_map=医生 / image_ai / null
sig.clinic_id AS clinic_id,
COALESCE(sig.occurred_at, sig.planned_for) AS signal_occurred_at,
EXTRACT(DAY FROM ${scope.now}::timestamptz - COALESCE(sig.occurred_at, sig.planned_for))::int AS days_since
EXTRACT(DAY FROM ${scope.now}::timestamptz - COALESCE(sig.occurred_at, sig.planned_for))::int AS days_since`;
// setbased 外层从 gap_cand 取同名列(顺序无关,消费方按列名映射)
const outerProjection = Prisma.raw(
[
'patient_id',
'patient_external_id',
'patient_age',
'signal_fact_id',
'signal_type',
'signal_code',
'signal_name_zh',
'extracted_by',
'confidence',
'code_source',
'clinic_id',
'signal_occurred_at',
'days_since',
]
.map((c) => `c.${c}`)
.join(', '),
);
// ── FROM + 全部闸(①隔离 ②合规 ③信号 ④cooldown ⑤b/⑤f/⑤g)——
// joinAddon = legacy 的 gap lateral(setbased 为空);gapAddon = gap 判定
const queryBody = (joinAddon: Prisma.Sql, gapAddon: Prisma.Sql): Prisma.Sql => Prisma.sql`
FROM patients p
JOIN patient_profiles pp ON pp.patient_id = p.id
JOIN patient_facts sig ON sig.patient_id = p.id
-- ⭐ 按牙相减(sig 牙位 / 已解决 / 剩余未治)— gap 核心,共享模块单一真理源
${gap.lateralJoin}
${joinAddon}
WHERE p.host_id = ${scope.hostId}::uuid -- ① 隔离闸
AND p.tenant_id = ${scope.tenantId} -- ① 隔离闸
${patientFilter} -- 单刷 / 子集收窄(可空)
......@@ -405,12 +452,12 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
AND sig.type IN ('diagnosis_record', 'recommendation_record') -- ③ 信号类型
AND sig.content->>'code' = ANY(${allCodes}::text[]) -- ③ 信号 code 命中
AND COALESCE(sig.occurred_at, sig.planned_for) IS NOT NULL -- ④ 时间不为空
AND COALESCE(sig.occurred_at, sig.planned_for) <= ${this.daysAgo(scope.now, start)}::timestamptz -- ④ 过 cooldown
AND COALESCE(sig.occurred_at, sig.planned_for) <= ${this.daysAgo(scope.now, rule.cooldownDays)}::timestamptz -- ④ 过 cooldown
-- ④' 废用牙/无功能牙剔除 + ④' §E 先天缺失剔除 + ⑤a 牙位级 gap 判定(全口 NOT EXISTS / 有牙位剩余非空)
-- —— 全部抽到 gap 核心(共享模块),召回与潜在治疗画像口径一致
${gap.restorationIneligibleFrag}
${gap.congenitalFrag}
${gap.gapWhere}
${gapAddon}
-- (⑤c 同牙位拔除 已折进 resolved_teeth 的 surgical 分支 — 拔了的牙从 remaining 减掉)
AND NOT EXISTS ( -- ⑤b 排除:患者已有未来预约
-- 召回目的 = 让客服建预约。患者已经有未来预约 → 客服不需要再 push,医生到诊现场处理即可
......@@ -455,8 +502,138 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
SELECT 1 FROM patient_return_visits rv
WHERE rv.patient_id = p.id
AND rv.task_date > ${scope.now}::date
)
)`;
const sb = gap.setBased;
return sb
? // ══ 集合式:候选 → 患者域 → resolved 预聚合 → 牙位反连接 ══
// 见 docs/design/gap-set-based-rewrite-plan.md §4。gap_cand 必须 MATERIALIZED:
// 它被下游扫三次(gap_scope / gap_rem / 主查询),内联会让 ①②③④ 闸重算三遍。
Prisma.sql`
WITH gap_cand AS MATERIALIZED (
SELECT ${projection}${sb.candExtraCols}
${queryBody(Prisma.empty, sb.candWhere)}
)${sb.postCtes}
SELECT ${outerProjection},
${sb.toothOutput} AS tooth
FROM gap_cand c
${sb.remJoin}
WHERE TRUE ${sb.outerWhere}
`
: // ══ 逐行相关子查询(legacy)══
Prisma.sql`
SELECT ${projection},
-- ⭐ 牙位级相减:有牙位信号 → 剩余未治牙位;全口信号 → 原样 NULL(gap 核心,共享模块)
${gap.toothOutput} AS tooth
${queryBody(gap.lateralJoin, gap.gapWhere)}
`;
}
private async runSubScenario(
scope: ScenarioScope,
subKey: string,
cfg: (typeof TreatmentInitiationRecallScenario.SUB_SCENARIOS)[keyof typeof TreatmentInitiationRecallScenario.SUB_SCENARIOS],
): Promise<ScenarioHit[]> {
// 临床窗口 / 类别 / 紧迫临界从 canonical-codes.DiagnosisTreatmentMap 单一真理源读
const rule = lookupDxTreatment(cfg.primaryCode);
if (!rule) {
throw new Error(
`SUB_SCENARIOS[${subKey}].primaryCode=${cfg.primaryCode} 在 DiagnosisTreatmentMap 中找不到 — ` +
`检查 canonical-codes.ts(单一真理源)`,
);
}
const start = rule.cooldownDays;
const goldenRange: [number, number] = [rule.cooldownDays, rule.windowDays];
const expectedCats = rule.categories as readonly string[];
const scenarioSql = this.buildScenarioSql(scope, cfg.primaryCode, rule, gapVariant());
// ══════════════════════════════════════════════════════════════════
// 📊 2026-08-29 全量重算耗时归因(测试服 585K 患者 / 87,661 命中,空闲机)
//
// 用下面这个 dump 开关 + pg_stat_activity 采样测出来的,结论与直觉相反,记在这里
// 免得后来人重走弯路:
//
// ① **11 个子场景里只有 2 个慢**,其余 9 个全部秒回:
// K05 牙周 9 分 13 秒
// K01 阻生牙 13 分钟+
// 且**与结果集大小无关** —— K02 龋齿理由数最多(49,382)却秒回。
//
// ② **11 条子场景 SQL 逐字节相同**,只有绑定参数(诊断码 / resolver 类目)不同。
// 所以慢是数据分布 + 执行计划的事,不是 SQL 写法的事。
//
// ③ 真实热点(EXPLAIN ANALYZE,K01 单条 301 秒):
// Append(resolvedTeeth 的 UNION) loops=117,256 × 1.71ms ≈ 201 秒
// → **68% 的时间花在「每候选行跑一次」的 gap 相关子查询上**
// 5.8 万个 (患者×信号) 候选,每行跑两个子查询。
//
// ⛔ 已实测否定的方案(别再试)/ 已被推翻的旧结论:
// · ~~子场景并发~~ —— ⚠️ **这条不是否定项,是被推翻的错误结论,见上面 conc 处的注释**。
// 原记「并发3=24.1分 vs 串行23.3 —— 无效,瓶颈是共享磁盘 I/O」;
// 3% 落在 ±25% 的环境噪音里,不成立。2026-08-30 生产全量实测 **×2.31**,已启用。
// · 给信号码建部分索引(((content->>'code')) WHERE type IN (...) AND status='active'):
// EXPLAIN 估算成本 1,837,125 → 139,816(13×),信号扫描 122 万行 → 5,593 行,
// **但墙钟 24.5 分钟 vs 23.3 —— 无效**。教训:别拿 EXPLAIN 的估算成本当依据,
// 成本模型改善不等于实际耗时改善;要 EXPLAIN (ANALYZE) 看真实的 actual time。
//
// ✅ 已解决:**开子场景并发**(生产 =4)。生产全量 场景段 2h00m → 51m56s(×2.31),
// 整轮 2h24m → 1h20m,2 小时窗口进得去。代价是一行环境变量。
// 上面 ③ 说的「68% 花在逐行 gap 子查询上」仍然成立 —— 只是那部分现在被并行摊掉了,
// 不再是墙钟瓶颈。
//
// 🔭 仍然可做、但已非必需:把 resolvedTeethSql 改成**集合式**
// (一次算出全部患者的 resolved 牙位再做连接)。
// 2026-08-30 已在 test 分支实现并验证:测试机 585K 上 11 个子场景逐
// (患者×信号×牙位) **零差异**,场景段再省 22~31%。但它是召回与画像共用的
// 单一真理源,1600 行改动 + 一套对拍工具,且**尚未在生产验过**。
// 并发已把窗口解决掉之后,性价比要重新权衡 —— 见
// docs/design/gap-set-based-rewrite-plan.md。
// ══════════════════════════════════════════════════════════════════
// ⚡ 调试开关:PAC_RECALL_DUMP_SQL=1 时把本子场景的完整 SQL 打出来。
// 为什么需要:pg_stat_activity.query 被 track_activity_query_size(默认 1024 字节)截断,
// 而本查询远超这个长度 —— 线上抓到"某条子场景查询跑了 6.7 分钟"却拿不到全文做 EXPLAIN,
// 2026-08-29 排查 plan 段耗时就卡在这一步。开关默认关,零开销。
// 用法:PAC_RECALL_DUMP_SQL=1 pnpm recompute-plans:prod -- --host=jvs-dw
// 然后把打出来的 SQL 喂给 EXPLAIN (ANALYZE, BUFFERS)。
if (process.env.PAC_RECALL_DUMP_SQL === '1') {
this.logger.log(
`[dump-sql] subKey=${subKey} primaryCode=${cfg.primaryCode} len=${scenarioSql.sql.length}\n` +
`${scenarioSql.sql}\n` +
`[dump-sql-params] ${JSON.stringify(scenarioSql.values)}`,
);
}
// ⏱ 分段计时 —— SQL 与 Node 后处理**分开算**,这是本组日志最关键的一格:
// 2026-08-29 排查时最大的困难,就是知道 plan 段慢、却分不清慢在 SQL 还是慢在 Node,
// 只能靠采样 pg_stat_activity 反推。有了这两个数,看一眼日志就知道该往哪查。
const sqlStart = Date.now();
// ⚡ 本查询单独抬高 work_mem —— 2026-08-29 **生产**六轮实测(endo_no_rct,72,769 行):
// 配置 耗时 相对基线
// 无索引 + 4MB (冷) 18:43
// 无索引 + 4MB (暖) 17:33 ← 基线;冷暖只差 6%,缓存不是主因
// 无索引 + 256MB (暖) 11:37 −34%
// 无索引 + 256MB (暖,复现) 12:12 −30% ← 两次复现一致
// 有索引 + 4MB (暖) 16:42 −5% ← 落在噪声里,索引已撤
// 有索引 + 128MB (暖) 14:47 −16% ← 128MB 只吃到一半收益
//
// ⭐ 取 256MB 而不是 128MB:收益对取值**很敏感**,128→256 还差一倍。
// 原本按"溢出只有 4 批、内存用量 8MB"推断 128MB 够用,实测否定了这个推断。
// 原因:默认 4MB 太小,EXPLAIN ANALYZE 里看到哈希溢出成 4 批
// (Buckets:131072 Batches:4 Memory Usage:8032kB)+ 临时文件读写 11,865 页。
//
// ⛔ 必须用事务级 SET LOCAL,**不能全局调**:work_mem 是「每个排序/哈希节点」的上限,
// 不是每连接。生产 RDS 只有约 7GB 内存(shared_buffers 1.83GB 反推),全局设大值
// 遇上几十个并发连接同时排序会把内存吃穿。SET LOCAL 出了事务自动还原,
// Web API 的连接完全不受影响;且场景查询是串行的,同一时刻只有一条在跑,峰值可控。
//
// ⛔ 别再去加「信号码部分索引」——(content->>'code') 上的部分索引在测试机和生产
// 都实测过,生产上 −5% 落在噪声里(同配置两次测量本身差 6%),不值得引入
// 一个 Prisma 管不到的索引。详见 [[GAP_FLAGS_BY_PRIMARY]] 附近的耗时归因注释。
const [, rows] = await this.prisma.$transaction([
this.prisma.$executeRaw`SET LOCAL work_mem = '256MB'`,
this.prisma.$queryRaw<HitRow[]>(scenarioSql),
]);
const sqlMs = Date.now() - sqlStart;
const postStart = Date.now();
// ⭐ 同 patient 同 sub_scenario 的多 sig 按 tooth-overlap 合并(union-find)
// 跟 chain-composer 的 bucket 合并口径一致 → reason 与 chain 1:1 对齐
......@@ -580,6 +757,14 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
priorityBreakdown: breakdown,
});
}
// ⏱ 每个子场景一行 —— 常态开启(每轮 11 行,可忽略)。
// ⛔ 别删:没有它,一轮跑两小时也只知道总体慢,既不知道是哪个子场景、
// 也不知道是 SQL 还是后处理慢。2026-08-29 生产 plan 段从 12 分钟涨到 159 分钟
// 仍未跑完,是临时加采样器才定位到子场景粒度的 —— 那种事不该再来一次。
this.logger.log(
`[recall] sub=${subKey} code=${cfg.primaryCode} gap=${gapVariant()} ` +
`sql=${sqlMs}ms rows=${rows.length} post=${Date.now() - postStart}ms hits=${hits.length}`,
);
return hits;
}
......
......@@ -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 进得来**。删掉行集就变了 ——
* 而多出来的那些人不会报错,只会悄悄出现在格子里。
......
......@@ -117,6 +117,7 @@ export class MockPullStrategy implements PullStrategy {
occurredAt: baseTime.toISOString(),
status: 'scheduled',
doctorId: `mock-d${(i % 3) + 1}`,
doctorName: `模拟医生${(i % 3) + 1}`,
treatmentCategory: '复查',
});
}
......
......@@ -185,10 +185,22 @@ export class ColdImportService {
// 2a. dryRun:报 scope(患者数 + 各资源 txn 数),不写、不分批装配(便宜)。
if (opts.dryRun) {
// ⛔ 同样分块 —— 早先这里也是整份清单塞 in,>3.2 万患者时 dry-run 直接崩,
// 而 --patients-file 的文档恰恰说「按受影响患者收窄是最有效的提速手段(可达 250 倍)」:
// 最需要先 dry-run 探一探的大清单场景,正好是它唯一不工作的场景。
const DRY_CHUNK = 3000;
for (const cfg of reparseableCfgs) {
const n = await this.prisma.patientTransaction.count({
where: { hostId: host.id, subjectType: cfg.emits!.subjectType, ...(opts.patientIds?.length ? { patientId: { in: opts.patientIds } } : {}) },
let n = 0;
const chunks: Array<string[] | null> = opts.patientIds?.length
? Array.from({ length: Math.ceil(opts.patientIds.length / DRY_CHUNK) }, (_, i) =>
opts.patientIds!.slice(i * DRY_CHUNK, (i + 1) * DRY_CHUNK),
)
: [null];
for (const chunk of chunks) {
n += await this.prisma.patientTransaction.count({
where: { hostId: host.id, subjectType: cfg.emits!.subjectType, ...(chunk ? { patientId: { in: chunk } } : {}) },
});
}
this.logger.log(`reparse[dry]: ${cfg.canonical}(${cfg.emits!.subjectType}) txns=${n} 实跑按版本流 supersede 变更的、跳过不变的`);
}
this.logger.log(`reparse[dry]: 范围 ${scopePatientIds.length} 患者;去掉 --dry-run 实跑(非破坏)`);
......@@ -276,16 +288,34 @@ export class ColdImportService {
}
// 3. 受影响 patientId = 本次真正被 supersede(内容变了)的 fact 的 distinct patient → 只重算这些。
//
// ⛔ 必须**分块**查 —— `patientId: { in: [...] }` 直接塞完整清单会撞 PG 的
// 32767 bind 变量上限。2026-08-29 生产实测:18 万患者的 reparse 跑满 61/61 批、
// 写完全部事实之后,**倒在这最后一步**:
// `Assertion violation: too many bind variables ... received 32769`
// 6.6 小时的活全干完了,只因收尾统计炸掉而 exit 1 —— 最难受的一种失败。
// (同族的另一处在 dryRun 分支的 count,见下方注释。)
// 分块大小取 BATCH 同款 3000:每块 3001 个变量,离上限很远。
const CHANGED_CHUNK = 3000;
const affectedSet = new Set<string>();
const scanChunks: Array<string[] | null> = opts.patientIds?.length
? Array.from({ length: Math.ceil(opts.patientIds.length / CHANGED_CHUNK) }, (_, i) =>
opts.patientIds!.slice(i * CHANGED_CHUNK, (i + 1) * CHANGED_CHUNK),
)
: [null]; // 不限定患者 → 一次全查(where 里没有 in,无变量上限问题)
for (const chunk of scanChunks) {
const changed = await this.prisma.patientFact.findMany({
where: {
hostId: host.id,
supersededAt: { gte: runStart },
...(opts.patientIds?.length ? { patientId: { in: opts.patientIds } } : {}),
...(chunk ? { patientId: { in: chunk } } : {}),
},
select: { patientId: true },
distinct: ['patientId'],
});
const affectedPatientIds = changed.map((a) => a.patientId).filter((x): x is string => !!x);
for (const c of changed) if (c.patientId) affectedSet.add(c.patientId);
}
const affectedPatientIds = [...affectedSet];
return { perResource, affectedPatientIds, dryRunDiffs };
}
......@@ -2476,16 +2506,24 @@ export function traceRawSourceTable(primaryTable: string, transforms: ReadonlyAr
input?: string;
output?: string;
inputs?: string[];
outputs?: Array<{ output?: string }>;
/// ⚠️ route_by_pattern 的字段名是 `routes`(见 transforms.schema.ts RouteByPatternOpSchema),
/// 不是 `outputs` —— 早先这里写成 outputs,恒为 undefined,导致**凡链路经过 route 的资源
/// 回溯都停在中间表**(_treatment_actual_raw_emr 等)。后果:reparse 把 rawPayload 灌进中间表,
/// 随即被 transform 链用空结果覆盖 → 治疗类 reparse 恒 0 变更**且报成功**(exit 0)。
/// diagnosis 没踩到只因它的链 split→derive→derive 不过 route。
routes?: Array<{ output?: string }>;
};
if (t.kind === 'union' && t.output && Array.isArray(t.inputs)) {
unionInputs.set(t.output, t.inputs);
continue;
}
if (t.output && t.input) byOutput.set(t.output, t.input);
// ⚠️ 跳过**原地 derive**(output === input,如 `_treat_plan_raw → _treat_plan_raw` 补 treat_name):
// 它不改变这张表的来源。登记进去会让 byOutput 指向自己 → resolve 撞环保护当场返回,
// 回溯同样停在中间表(与 routes 那条是**两个独立 bug**,只修一个仍然失效)。
if (t.output && t.input && t.output !== t.input) byOutput.set(t.output, t.input);
// route_by_pattern 多 output:每个 output 都回到同一 input
if (Array.isArray(t.outputs) && t.input) {
for (const o of t.outputs) if (o?.output) byOutput.set(o.output, t.input);
if (Array.isArray(t.routes) && t.input) {
for (const o of t.routes) if (o?.output) byOutput.set(o.output, t.input);
}
}
const resolve = (tbl: string, seen: Set<string>): string => {
......
......@@ -262,6 +262,14 @@ const AppointmentRecordContent = z
arrived_at: isoDateString.nullable().optional().default(null),
appointment_type: nullableString(),
doctor_id: nullableString(),
// 约号时约的医生姓名(源自 jvs-dw resource_name;核实记录见 appointment.yaml)。
// ⚠️ 口径 = **约的**医生,不是实际接诊医生(改派时不同,实际接诊看 emr_record.doctor_name)。
// ⚠️ 已过「资源是不是人」这道闸(AppointmentParser.isPersonResource):排房间/服务/台席的
// 那 0.94% 在这里是 null,原值仍在 resource_name。所以本字段可以直接拼「X医生」。
doctor_name: nullableString(),
// 排班资源原名(未过滤)。多数等于 doctor_name;不是人时("预约"/"学前街手术室"/"正畸咨询")
// doctor_name 为 null 而本字段保留原值 —— 既不丢 host 快照,也便于审计上面那道闸。
resource_name: nullableString(),
// 预约科目/就诊意向(常规/正畸/种植/修复/拔牙/牙周…),host appo_complaint_category
complaint_category: nullableString(),
// 预约主诉自由文本(跟 complaint_category 配对:分类 + 原文)— Layer C 源
......
......@@ -44,6 +44,12 @@ export class AppointmentParser implements Parser {
const arrivedAt = c.arrivedAt ? new Date(c.arrivedAt as string) : null;
const appointmentType = (c.appointmentType as string | undefined) ?? null;
const doctorId = (c.doctorId as string | undefined) ?? null;
// 排班资源名(host 行内快照,DW resource_name)。约 99% 是医生本人,详见下方 isPersonResource。
const resourceName = (c.doctorName as string | undefined)?.trim() || null;
// 约号时约的医生姓名。见 appointment.yaml 里的核实记录。
// ⚠️ 不是实际接诊医生 —— 改派时两者不同,实际接诊以病历 doctor_name 为准。
const doctorName =
resourceName && AppointmentParser.isPersonResource(resourceName) ? resourceName : null;
return [
{
......@@ -77,6 +83,8 @@ export class AppointmentParser implements Parser {
arrived_at: arrivedAt ? arrivedAt.toISOString() : null,
appointment_type: appointmentType,
doctor_id: doctorId,
doctor_name: doctorName,
resource_name: resourceName,
complaint_category: (c.complaintCategory as string | undefined) ?? null,
complaint_text: (c.complaintText as string | undefined) ?? null,
duration_minutes:
......@@ -93,6 +101,42 @@ export class AppointmentParser implements Parser {
];
}
/**
* 排班资源是不是**一个人**。
*
* DW 的 `resource_name`(→ canonical doctorName)是「排班资源」的名字,绝大多数资源就是医生本人,
* 但少数排的是房间 / 服务 / 台席。生产近 180 天 1,047,901 条预约里实测 9,838 条(**0.94%**)不是人:
* 预约(5,696)· 学前街手术室(1,218)· 种植手术(天使)(396)· 会诊室(351)· 显微镜管理(328)
* 舒适治疗(罗院/徐院/梁博)(738)· 种植室(欢乐)(260)· 方寸诊所客服(243)· 正畸咨询(205)
* 种植手术(金融街)(158)· 华侨城/三星手术室(141)· 公共诊室(49+19)· 种植手术(30)
* 华贸诊所B诊区客服(4)· 主诉初诊(2)
* 不拦的话,时间轴上每 106 条预约就有 1 条写着「预约医生」「学前街手术室医生」,
* 更糟的是详情页「主治医生」的最高频兜底可能解析成「学前街手术室」。
*
* 词表是**从上面这份实测清单反推**出来的(九个词覆盖全部 19 个取值),不是凭感觉列的。
* 误伤核验(生产 2026-08-31):临床事实里 1,314 个 distinct doctor_name 过一遍本规则,
* 命中 **1** 个 —— 「公共诊室」,它本身就是漏进病历的房间名,不是医生。**真人零误伤**。
*
* ⚠️ 被拦下的原值不丢:仍完整写在 content.resource_name(以及 raw_payload)里,
* `WHERE resource_name IS NOT NULL AND doctor_name IS NULL` 就能审计本规则拦了什么。
* ⚠️ 新诊所接入 / host 改排班命名后要复查这份词表 —— 用上面那条审计 SQL 看有没有新形态漏网。
*/
private static readonly NON_PERSON_TOKENS = [
'预约',
'手术',
'室',
'治疗',
'咨询',
'客服',
'管理',
'诊区',
'初诊',
];
static isPersonResource(name: string): boolean {
return !AppointmentParser.NON_PERSON_TOKENS.some((t) => name.includes(t));
}
// 已发生事实(actual)的 host 状态:到诊 / 接诊 / 结算 + walk-in 变更(已到店)
private static readonly ACTUAL_STATUSES = new Set([
'arrived',
......
......@@ -37,10 +37,34 @@ import { schedulerDisabled } from './scheduler-switch';
*
* 跑失败:cursor 不前进 → 下次自动 catchup;log ERROR 不抛。
*/
/**
* 僵尸锁的最小年龄。比「一轮同步的正常耗时」留足余量 —— 生产实测单轮摄入 28~52 分钟,
* 取 3 小时:真崩溃留下的锁必然远超此值,而正常在跑的绝不会。
*/
const REAP_MIN_AGE_MS = 3 * 60 * 60 * 1000;
@Injectable()
export class SyncIncrementalSchedulerService implements OnModuleInit {
private readonly logger = new Logger(SyncIncrementalSchedulerService.name);
/**
* 本进程内「该 host 正在跑」的闸 —— **防套圈**。
*
* 🔴 2026-08-29 生产事故:plan 段耗时涨到 2 小时以上后,cron(每 2 小时)照常触发下一轮,
* 两轮的 plan 段并发抢同一批 I/O → 两轮都更慢 → 更容易被再下一轮套圈 → 雪崩。
* 实测 08-28 20:15 起连续多轮 plan 段一次都没跑完,直到 08-29 上午仍有两轮在并行。
*
* ⚠️ 为什么现有的锁挡不住:
* ① NestJS 的 CronJob **默认不防重入** —— 上一次回调还在 await,下一次照样进;
* ② `sync_logs` 的 partial UNIQUE(host_id) WHERE status='running' 只覆盖**摄入段**,
* 摄入一结束锁就放了,而 persona / plan 段还在跑,恰恰是最慢的部分。
* 所以必须在**回调入口**挡,不能靠库里的锁。
*
* 跳过而不是排队:摄入是游标增量,跳过这轮的数据下轮自然 catchup;
* plan 是时间驱动的全量,跳一轮只是晚 2 小时评估,远好过雪崩。
*/
private readonly runningHosts = new Set<string>();
constructor(
private readonly prisma: PrismaService,
private readonly coldImport: ColdImportService,
......@@ -126,11 +150,21 @@ export class SyncIncrementalSchedulerService implements OnModuleInit {
/**
* 回收僵尸同步锁 —— 把 startedAt 早于本进程启动的 running sync_log 标 failed。
*
* 为什么这样判据安全:sync 只在两处跑 —— 本 service 进程的 cron 回调,或一次性 CLI
* (`sync-incremental.cli` / `cold-import.cli`,跑完即退)。两者的进程都不可能比本进程
* 启动得更早还活着。所以 `startedAt < PROCESS_STARTED_AT` 的 running 行 = 上一个已死进程的残留。
* 反过来,本进程启动后新建的 running 行(startedAt >= PROCESS_STARTED_AT)绝不碰 —— 那可能是
* 正在跑的真锁(例如运维手动触发的 CLI 与本进程并存),误清会把在跑的同步" orphan"掉。
* 🔴 2026-08-30 生产事故:本判据**曾经是错的**,理由写反了方向。
* 原注释说「sync 只在 service 进程的 cron 或一次性 CLI 里跑,两者的进程都不可能比本进程
* 启动得更早还活着」——【但长驻的 pac-service 恰恰就是「启动得更早还活着」的那个】。
* 任何 CLI(recompute-plans / recompute-persona / reparse …)都会
* `createApplicationContext(AppModule)`,于是也跑一遍本 onModuleInit;
* 此时 CLI 进程的 PROCESS_STARTED_AT = 现在,而 service 里**正在跑**的那轮 sync
* startedAt 更早 → 被当成僵尸锁清掉 → 那一轮增量当场夭折。
* 实测:08:17:11 在生产容器里跑 recompute-plans,08:17:13(Nest 启动 2 秒后)
* 08:15 那轮正常同步就被标 failed。前 19 轮全 success,只死了撞上的这一轮。
* 数据没丢(cursor_after=null,下轮按同一水位 catchup),但白丢一轮、晚 2 小时落库。
*
* 现在的判据:`startedAt < 本进程启动` **且** `startedAt < now - REAP_MIN_AGE_MS`。
* 后半条是真正的防线 —— 真僵尸锁是上个进程崩溃留下的,必然已经躺了很久;
* 而被误伤的那种,是"刚起没多久还在正常跑"的。用年龄区分,不靠进程身份猜。
* 另外 CLI 侧统一设 PAC_SCHEDULER_DISABLED=1(见 src/cli/bootstrap-flags.ts),双保险。
*
* 幂等:被标 failed 只是让并发锁释放;数据侧不受影响(游标没推进,下次增量靠 48h 回看窗补齐)。
*
......@@ -139,14 +173,17 @@ export class SyncIncrementalSchedulerService implements OnModuleInit {
*/
private async reapStaleRunningLocks(processStartedAt: Date = PROCESS_STARTED_AT): Promise<void> {
try {
// 双条件取更早的那个界:既要早于本进程启动,又要已经躺够 REAP_MIN_AGE_MS。
const ageCutoff = new Date(Date.now() - REAP_MIN_AGE_MS);
const cutoff = ageCutoff < processStartedAt ? ageCutoff : processStartedAt;
const stale = await this.prisma.syncLog.findMany({
where: { status: SyncStatus.RUNNING, startedAt: { lt: processStartedAt } },
where: { status: SyncStatus.RUNNING, startedAt: { lt: cutoff } },
select: { id: true, hostId: true, startedAt: true, triggeredBy: true },
});
if (stale.length === 0) return;
const { count } = await this.prisma.syncLog.updateMany({
where: { status: SyncStatus.RUNNING, startedAt: { lt: processStartedAt } },
where: { status: SyncStatus.RUNNING, startedAt: { lt: cutoff } },
data: {
status: SyncStatus.FAILED,
endedAt: new Date(),
......@@ -168,6 +205,15 @@ export class SyncIncrementalSchedulerService implements OnModuleInit {
/// 单 host 跑一轮(cron 回调用,吞异常不影响该 host 下次 / 别的 host)
private async runHostSafe(host: string): Promise<void> {
// ⛔ 上一轮还没跑完就跳过本轮 —— 见 runningHosts 的注释(防套圈雪崩)
if (this.runningHosts.has(host)) {
this.logger.warn(
`sync-incremental: host=${host} **跳过本轮** —— 上一轮仍在运行(防套圈)。` +
`连续出现说明单轮已撑不下 cron 间隔,需要查 plan 段耗时。`,
);
return;
}
this.runningHosts.add(host);
try {
await this.runOne(path.join(this.dataDir(), host));
} catch (err) {
......@@ -176,6 +222,9 @@ export class SyncIncrementalSchedulerService implements OnModuleInit {
} else {
this.logger.error(`sync-incremental: host=${host} failed: ${(err as Error).message}`);
}
} finally {
// ⚠️ 必须在 finally —— 抛异常时不释放会把该 host 永久锁死到进程重启
this.runningHosts.delete(host);
}
}
......
/**
* 预约医生姓名(jvs-dw resource_name → content.doctor_name)。
*
* 背景:预约事实历来只有 doctor_id 没有姓名,前端/话术拿到的是裸 ID。
* DW 其实一直在同一行里给了姓名,列名叫 `resource_name`(排班资源名)——名字有误导性,
* 核实过程与判据记在 data/jvs-dw/assemblers/appointment.yaml 的注释里。
*
* 本文件锁三件事:
* ① yaml 确实把 resource_name 映到 canonical doctorName(改名/删映射会红);
* ② parser 把它写进 content.doctor_name,空白归一成 null;
* ③ 口径不漂移 —— doctor_name 与 doctor_id 是**同一个人**的两面,别一个来自预约、
* 另一个来自别处。
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import * as yaml from 'js-yaml';
import { Action } from '@pac/types';
import { AppointmentParser } from '../src/modules/sync/pipeline/parsers/appointment.parser';
import type { ParserContext } from '../src/modules/sync/pipeline/parsers/parser.interface';
const YAML_PATH = join(
__dirname,
'../data/jvs-dw/assemblers/appointment.yaml',
);
describe('appointment.yaml | resource_name → doctorName', () => {
const cfg = yaml.load(readFileSync(YAML_PATH, 'utf-8')) as {
field_mapping: Record<string, string>;
};
test('doctorName 映射到 host 列 resource_name', () => {
expect(cfg.field_mapping.doctorName).toBe('resource_name');
});
// ⚠️ 这两列是**不同的人**,历史上极易混:
// appo_doc_id = 约的医生(id) ←→ resource_name 是它的姓名
// director_id = 另一个角色(仅 34% 有值,与 appo_doc_id 几乎从不相同)
// create_name / appo_handler = 呼叫中心约号人(值形如 "400-叶玉娇")
test('doctorId 仍取 appo_doc_id,没有被 director/handler 之类顶替', () => {
expect(cfg.field_mapping.doctorId).toBe('appo_doc_id');
});
});
describe('AppointmentParser | content.doctor_name', () => {
const parser = new AppointmentParser();
const ctx = (row: Record<string, unknown>): ParserContext => ({
transaction: {
id: 'tx-1',
hostId: 'host-1',
tenantId: 'tenant-1',
patientId: 'p-1',
action: Action.APPOINTMENT_CREATED,
subjectType: 'appointment',
subjectId: 'appt-1',
occurredAt: new Date('2026-08-01T02:00:00Z'),
clinicId: 'c-1',
},
canonicalRow: {
externalId: 'appt-1',
patientExternalId: 'p-1',
clinicId: 'c-1',
scheduledAt: '2026-08-01T02:00:00Z',
status: 'scheduled',
doctorId: '5624',
...row,
},
});
const contentOf = (row: Record<string, unknown>) =>
parser.parse(ctx(row))[0]!.content as Record<string, unknown>;
test('有姓名 → 写进 content.doctor_name,与 doctor_id 成对', () => {
const c = contentOf({ doctorName: '赵茜' });
expect(c.doctor_name).toBe('赵茜');
expect(c.doctor_id).toBe('5624');
});
test('两侧空白被裁掉(host 常见尾随空格)', () => {
expect(contentOf({ doctorName: ' 李闻 ' }).doctor_name).toBe('李闻');
});
test.each([
['缺字段', {}],
['空串', { doctorName: '' }],
['纯空白', { doctorName: ' ' }],
])('%s → null(不写空串,免得下游把 "" 当姓名渲染成「医生」)', (_label, row) => {
expect(contentOf(row).doctor_name).toBeNull();
});
test('resource_name 始终保留原值(host 快照不丢)', () => {
expect(contentOf({ doctorName: '赵茜' }).resource_name).toBe('赵茜');
expect(contentOf({ doctorName: '学前街手术室' }).resource_name).toBe('学前街手术室');
});
});
/**
* 「排班资源是不是人」这道闸。
*
* 生产近 180 天 0.94% 的预约排的是房间/服务/台席而非医生 —— 不拦的话时间轴上每 106 条
* 就有一条写着「预约医生」「学前街手术室医生」。词表是从那份实测清单反推的,不是凭感觉列的,
* 所以下面逐个锁住:清单变了(新诊所换了命名)这里就该红。
*/
describe('AppointmentParser.isPersonResource | 非人资源闸', () => {
const parser = new AppointmentParser();
const doctorNameOf = (nm: string) =>
(parser.parse({
transaction: {
id: 'tx-1', hostId: 'h', tenantId: 't', patientId: 'p',
action: Action.APPOINTMENT_CREATED, subjectType: 'appointment', subjectId: 'a',
occurredAt: new Date('2026-08-01T02:00:00Z'), clinicId: 'c',
},
canonicalRow: {
externalId: 'a', patientExternalId: 'p', clinicId: 'c',
scheduledAt: '2026-08-01T02:00:00Z', status: 'scheduled', doctorName: nm,
},
})[0]!.content as Record<string, unknown>).doctor_name;
// 生产实测出现过的 19 个非人取值,逐个锁
test.each([
'预约',
'学前街手术室',
'种植手术(天使)',
'会诊室',
'显微镜管理',
'舒适治疗(罗院)',
'种植室(欢乐)',
'方寸诊所客服',
'正畸咨询',
'种植手术(金融街)',
'华侨城手术室',
'公共诊室',
'三星手术室',
'顺义瑞捷公共诊室',
'华贸诊所B诊区客服',
'主诉初诊',
])('%s → 不是人,doctor_name 留空', (nm) => {
expect(AppointmentParser.isPersonResource(nm)).toBe(false);
expect(doctorNameOf(nm)).toBeNull();
});
// 真人零误伤 —— 含生产里那些容易被规则误伤的形态:
// 带消歧后缀的(吕晓玉(Y))、四字名(孙汪心悦)、外籍全名(SABELLI MARIA LUZ)
test.each([
'赵茜',
'李闻',
'刘柳',
'吕晓玉(Y)',
'姚志远(Z)',
'孙汪心悦',
'SABELLI MARIA LUZ',
'TAN CHYI YANN',
'薛玫(L)',
])('%s → 是人,doctor_name 照写', (nm) => {
expect(AppointmentParser.isPersonResource(nm)).toBe(true);
expect(doctorNameOf(nm)).toBe(nm);
});
});
/**
* 「活动义齿 = 假缺失」按颌判定。
*
* 戴活动义齿的患者诊断栏**永远**写「缺失牙」—— 天然牙确实没了,诊断没错;
* 但那个位置已被义齿盖住,「缺失牙未**启动**修复」不成立(治疗做的就是义齿)。
* 活动义齿是整颌跨度的修复体 → 判出"这一颌有义齿",那一颌的缺牙位全部解除。
*
* 🔴 本条**天然绕开混合句** —— 王志荣/童然夫的检查所见牙位横跨上下颌,
* 按条目判的那条(RESTORATION_IN_PLACE_*)整条跳过,按颌判直接可用。
*
* 跑:
* pnpm test -- arch-denture-false-missing
*/
import {
ARCH_DENTURE_UPPER_RE,
ARCH_DENTURE_LOWER_RE,
ARCH_DENTURE_INTENT_EXCLUDE_RE,
ARCH_DENTURE_PREFILTER_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 PRE = new RegExp(ARCH_DENTURE_PREFILTER_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']);
});
});
/**
* ⚡ SQL 侧的预过滤(potential-treatment-gap.sql:archDentureBranch)在展开 JSON 数组之前
* 先用 ARCH_DENTURE_PREFILTER_RE 把整份 exam_findings 筛一道。那是纯剪枝,**前提是
* 该词必须始终是上下颌两条 RE 的必要条件** —— 一旦不是,预过滤就会静默丢掉真命中
* (少召,不报错)。这组用例就是锁这个蕴含关系的。
*
* 实测依据(2026-08-29 测试库):exam_findings 是数组的 emr 事实 1,519,829 份,
* 含「义齿|假牙」的仅 7,396 份(0.49%),预过滤剪掉 99.5% 的无用展开。
*/
describe('⚡ 预过滤词必须是两条 RE 的必要条件(改 RE 时这组会先炸)', () => {
const 会命中的句子 = [
'上下颌吸附性义齿修复,下颌固位可,上颌固位稍差',
'牙缺失,口内活动义齿修复,上颌义齿卡环紧,不易取戴,下颌义齿无法完全就位',
'上颌活动义齿在位',
'下半口假牙尚可',
'全口义齿修复',
'上下全口假牙使用中',
'上颌见活动义齿,基托边缘密合',
];
it.each(会命中的句子)('命中 RE 的句子必然含预过滤词:%s', (msg) => {
expect(UP.test(msg) || LOW.test(msg)).toBe(true); // 前提:这些确实命中
expect(PRE.test(msg)).toBe(true); // 结论:那就一定含预过滤词
});
it('⛔ 反向:不含预过滤词的文本,两条 RE 都不可能命中(否则预过滤会丢真命中)', () => {
for (const msg of [
'上颌见残根,下颌牙列完整',
'全口牙石(+),牙龈红肿',
'上颌种植体冠在位,边缘密合', // 修复体在位但非义齿 → 归按条目那条分支管
'下颌固定桥完好',
]) {
expect(PRE.test(msg)).toBe(false);
expect(UP.test(msg)).toBe(false);
expect(LOW.test(msg)).toBe(false);
}
});
});
/**
* CLI 启动不得清掉正在跑的同步锁(2026-08-30 生产事故回归测试)
*
* 事故:在生产容器里 `docker exec` 跑 recompute-plans,CLI 会
* `createApplicationContext(AppModule)` → 跑一遍 SyncIncrementalScheduler.onModuleInit
* → reapStaleRunningLocks 把 service 里**正在跑**的那轮同步当僵尸锁清掉,那轮增量夭折。
* 实测 08:17:11 起 CLI,08:17:13 正常跑着的 08:15 那轮被标 failed(前 19 轮全 success)。
*
* 两道防线,这里各锁一条。
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
const CLI_DIR = path.resolve(__dirname, '../src/cli');
/// 唯一豁免:它本身就是要触发同步(靠 scheduler 的年龄阈值兜底)
const EXEMPT = new Set(['sync-incremental.cli.ts']);
describe('CLI 调度器总闸', () => {
const cliFiles = fs
.readdirSync(CLI_DIR)
.filter((f) => f.endsWith('.cli.ts'))
.filter((f) => fs.readFileSync(path.join(CLI_DIR, f), 'utf8').includes('createApplicationContext'));
it('存在会启动完整应用上下文的 CLI(否则本 spec 形同虚设)', () => {
expect(cliFiles.length).toBeGreaterThan(5);
});
it.each(cliFiles.filter((f) => !EXEMPT.has(f)))(
'%s 在建上下文之前调用 disableSchedulersForCli()',
(file) => {
const src = fs.readFileSync(path.join(CLI_DIR, file), 'utf8');
const guard = src.indexOf('disableSchedulersForCli()');
const ctx = src.indexOf('NestFactory.createApplicationContext');
expect(guard).toBeGreaterThanOrEqual(0);
// 顺序也要对:晚于建上下文就没意义了(onModuleInit 已经跑完)
expect(guard).toBeLessThan(ctx);
},
);
it('僵尸锁回收带年龄阈值 —— 不能只靠"比本进程早"这一个判据', () => {
const sched = fs.readFileSync(
path.resolve(__dirname, '../src/queues/sync-incremental.scheduler.ts'),
'utf8',
);
expect(sched).toContain('REAP_MIN_AGE_MS');
// 阈值必须显著大于一轮同步的正常耗时(生产实测 28~52 分钟)
const m = sched.match(/const REAP_MIN_AGE_MS = ([^;]+);/);
expect(m).not.toBeNull();
// eslint-disable-next-line no-eval
const ms = eval(m![1]!) as number;
expect(ms).toBeGreaterThanOrEqual(2 * 60 * 60 * 1000);
});
});
/**
* gap 集合式形态的**结构对拍**(纯 SQL 文本层,不连库)
*
* 定位:数据层的等价性由 `pnpm verify-gap-equivalence`(逐 患者×信号×牙位 差分)证明,
* 本 spec 只守一件单元测试能守住的事 —— **两种形态的分支集合不许走散**。
* 典型事故:后来人给 legacy 加了第 14 条 resolved 分支,忘了同步 setbased →
* 线上悄悄少销一类证据 → 静默多召 / 少召。那种漏法 tsc 和现有 spec 全都发现不了,
* 但分支计数会当场炸。
*
* ⚠️ 本 spec 断言的是"两边都改了",不是"改对了"。改完仍必须跑 verify-gap-equivalence。
*/
import { lookupDxTreatment, resolverCategoriesFor } from '@pac/types';
import {
buildGapCore,
GAP_FLAGS_BY_PRIMARY,
GAP_PRIMARY_GROUPS,
} from '../src/modules/clinical-gap/potential-treatment-gap.sql';
const PRIMARY_CODES = Object.keys(GAP_PRIMARY_GROUPS);
const WHOLE_MOUTH = ['K05', 'K07'];
function core(primaryCode: string, variant: 'legacy' | 'setbased') {
const rule = lookupDxTreatment(primaryCode);
if (!rule) throw new Error(`no rule for ${primaryCode}`);
const grp = GAP_PRIMARY_GROUPS[primaryCode];
return buildGapCore({
rule,
cfgFlags: GAP_FLAGS_BY_PRIMARY[primaryCode] ?? {},
allCodes: [...grp.dxCodes, ...grp.recCodes],
resolverCats: resolverCategoriesFor(primaryCode) as readonly string[],
variant,
});
}
const count = (hay: string, needle: RegExp): number => (hay.match(needle) ?? []).length;
describe('gap 集合式 ↔ 逐行形态:结构对拍', () => {
it('variant 默认 legacy;只有显式 setbased 才产出集合式拼装件', () => {
const g = core('K08', 'legacy');
expect(g.setBased).toBeUndefined();
expect(core('K08', 'setbased').setBased).toBeDefined();
});
describe.each(PRIMARY_CODES)('%s', (code) => {
const isWhole = WHOLE_MOUTH.includes(code);
it('全口码不进集合式(原样走 legacy),牙位码必须有 resolved 预聚合链', () => {
const g = core(code, 'setbased');
if (isWhole) {
// 全口场景 legacy 的 lateral 本来就会被 PG 的 useless-left-join removal 摘掉 →
// 集合式零收益;实测硬套进来还慢 2~3 倍(见 buildGapSetBased 里的早退注释)。
expect(g.setBased).toBeUndefined();
} else {
const sb = g.setBased!;
expect(sb.postCtes.sql).toContain('gap_scope');
expect(sb.postCtes.sql).toContain('gap_resolved');
expect(sb.postCtes.sql).toContain('gap_rem');
expect(sb.remJoin.sql).toContain('LEFT JOIN gap_rem');
}
});
if (!WHOLE_MOUTH.includes(code)) {
it('两种形态的 resolved 分支条数必须一致(加分支只改一边 = 静默错召)', () => {
const legacy = core(code, 'legacy').lateralJoin.sql;
const setbased = core(code, 'setbased').setBased!.postCtes.sql;
// legacy 分支用裸 UNION 分隔;setbased 用 UNION ALL(先聚合后去重,不需要 UNION 的排序去重)
const legacyBranches = count(legacy, /\bUNION\b(?!\s+ALL)/g) + 1;
const setBranches = count(setbased, /\bUNION ALL\b/g) + 1;
expect(setBranches).toBe(legacyBranches);
});
it('每个分支都挂了患者收窄(漏一个就全表扫 34GB patient_facts)', () => {
const sql = core(code, 'setbased').setBased!.postCtes.sql;
const branches = sql
.slice(sql.indexOf('FROM ('), sql.indexOf(') sb('))
.split(/\bUNION ALL\b/);
expect(branches.length).toBeGreaterThan(1);
for (const b of branches) {
expect(b).toContain('IN (SELECT patient_id FROM gap_scope)');
}
});
it('gate 列永不为 NULL —— 时间门分支带 IS NOT NULL,无门分支写死 infinity', () => {
const sql = core(code, 'setbased').setBased!.postCtes.sql;
const branches = sql
.slice(sql.indexOf('FROM ('), sql.indexOf(') sb('))
.split(/\bUNION ALL\b/);
for (const b of branches) {
const ungated = b.includes(`'infinity'::timestamptz AS gate`);
const gated = /IS NOT NULL/.test(b);
// 二者必居其一:否则全 NULL 组会被 max() 聚成 NULL、当成"无门恒过"→ 误销 → 静默少召
expect(ungated || gated).toBe(true);
}
});
it('严格 > 只出现在「更晚结构诊断」一条分支上', () => {
const sql = core(code, 'setbased').setBased!.postCtes.sql;
expect(count(sql, /TRUE AS strict/g)).toBe(1);
expect(sql).toContain('CASE WHEN r.strict THEN r.gate > c.gap_anchor ELSE r.gate >= c.gap_anchor END');
});
it('牙位顺序与重复原样保留(tooth 串会落进 plan_reasons 给客服看)', () => {
const sql = core(code, 'setbased').setBased!.postCtes.sql;
expect(sql).toContain('WITH ORDINALITY');
expect(sql).toContain('array_agg(u.x ORDER BY u.ord)');
});
it('gap_rem 无行要补空数组,不能留 NULL', () => {
const sb = core(code, 'setbased').setBased!;
expect(sb.toothOutput.sql).toContain("COALESCE(gap_rem.remaining_teeth, ARRAY[]::text[])");
expect(sb.outerWhere.sql).toContain("COALESCE(gap_rem.remaining_teeth, ARRAY[]::text[])");
});
}
});
it('病历号等值相关(整颌活动义齿)只在 K08 出现,且走 enc 列而非时间门', () => {
const k08 = core('K08', 'setbased').setBased!.postCtes.sql;
expect(k08).toContain("adx.content->>'emr_external_id' AS enc");
expect(k08).toContain("r.enc IS NULL OR r.enc = c.gap_sig_enc");
const k02 = core('K02', 'setbased').setBased!.postCtes.sql;
expect(k02).not.toContain("AS enc,\n FALSE AS strict");
expect(count(k02, /adx\./g)).toBe(0);
});
it('「建议优先于诊断」分支只对 diagnosis_record 信号生效(ndx 标志)', () => {
const sql = core('K08', 'setbased').setBased!.postCtes.sql;
expect(count(sql, /TRUE AS ndx/g)).toBe(1);
expect(sql).toContain("NOT r.ndx OR c.gap_sig_type = 'diagnosis_record'");
});
it('牙位级规则若被加上 excludeIfEverTreated,集合式必须直接炸而不是静默算错', () => {
const rule = { ...lookupDxTreatment('K08')!, excludeIfEverTreated: true };
expect(() =>
buildGapCore({
rule,
cfgFlags: GAP_FLAGS_BY_PRIMARY.K08,
allCodes: ['K08'],
resolverCats: resolverCategoriesFor('K08') as readonly string[],
variant: 'setbased',
}),
).toThrow(/excludeIfEverTreated/);
});
});
/**
* 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);
});
});
// ═══════════════════════════════════════════════════════════════
// 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');
});
// 🔴 2026-08-28 测试服 reparse 实测到的回归 —— 初版只挡了 restorative,规则排在 periodontic
// 之前,新写的 179 条调合/调颌事实里 18 条(10%)被抢:牙周 16 / 预防 1 / 外科 1。
// 两个方向都坏:①牙周治疗不再解 perio_no_srp 缺口 = 多召牙周;
// ②prosthodontic 在结构 resolver 里,反而去解那些牙位的缺牙缺口 = 少召缺牙(不报错)。
// 修法:把该规则挪到本表倒数第二行(只在人群词 pediatric 之前)。
it('🔴 ⛔ 也必须排在 periodontic / preventive / surgical 之后 —— 别人的记录不能被抢', () => {
// 下面全是测试服实测被错判成 prosthodontic 的真实串
expect(cat('牙周刮治+调合')).toBe('periodontic');
expect(cat('根面平整+调合+牙周内上药')).toBe('periodontic');
expect(cat('洁治,47调合')).toBe('periodontic');
expect(cat('牙周刮治+调合,酌情牙周手术')).toBe('periodontic');
expect(cat('复查,洁牙,调颌')).toBe('periodontic');
expect(cat('涂氟,45调合')).toBe('preventive');
});
it('⭐ 挪到末尾后,原始个案照样接得住(「抛光」不在任何含词表,只有「调合」命中)', () => {
// 陈惠英@16 / 陶美玉@22 —— 这两条是加这条规则的初衷,不能因为挪位置而失效
expect(cat('调合,抛光')).toBe('prosthodontic');
expect(cat('调合抛光')).toBe('prosthodontic');
expect(cat('局部调颌')).toBe('prosthodontic');
expect(cat('切端调合')).toBe('prosthodontic');
expect(cat('基托缓冲')).toBe('prosthodontic'); // 义齿维护族,走上面那行
});
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);
});
});
......@@ -47,12 +47,27 @@ function flatten(frag: Prisma.Sql | SqlLike | unknown): string {
/// 返回 [] 让调用方走空结果短路,不需要再 mock 后续查询。
function makeSqlCapturingPrisma(): { prisma: PrismaService; sql: () => string } {
const captured: string[] = [];
const queryRaw = (strings: TemplateStringsArray, ...values: unknown[]) => {
const parts = Array.isArray(strings?.raw) ? strings.raw : [String(strings)];
const queryRaw = (strings: TemplateStringsArray | { sql?: string }, ...values: unknown[]) => {
// 两种调用形态都要认:
// ① 标签模板 `$queryRaw`...`` → strings 是 TemplateStringsArray
// ② 传 Prisma.sql 对象 `$queryRaw(sql)` → strings 是 Sql,已摊平好的 .sql 直接用
// (2026-08-29 起主查询走 ② —— 先建 Prisma.sql 对象才能在 PAC_RECALL_DUMP_SQL=1 时
// 打出完整 SQL 做 EXPLAIN;pg_stat_activity 会把它截断在 1024 字节。)
if (typeof (strings as { sql?: string })?.sql === 'string') {
captured.push((strings as { sql: string }).sql);
return Promise.resolve([]);
}
const t = strings as TemplateStringsArray;
const parts = Array.isArray(t?.raw) ? t.raw : [String(strings)];
captured.push(parts.map((s, i) => s + (i < values.length ? flatten(values[i]) : '')).join(''));
return Promise.resolve([]);
};
const prisma = { $queryRaw: queryRaw } as unknown as PrismaService;
// 主查询走 `$transaction([SET LOCAL work_mem, $queryRaw(sql)])`(2026-08-29 起,
// 为抬高 work_mem;见 scenario 里那段实测注释)。桩要同时认这三个:
// $queryRaw 负责捕获 SQL,$executeRaw 吞掉 SET LOCAL,$transaction 把数组按序解析。
const noop = () => Promise.resolve(0);
const tx = (arr: unknown[]) => Promise.all(arr as Promise<unknown>[]);
const prisma = { $queryRaw: queryRaw, $executeRaw: noop, $transaction: tx } as unknown as PrismaService;
return { prisma, sql: () => captured.join('\n/* --- next query --- */\n') };
}
......
/**
* reconcileDiagnosisCode — 宿主 std_code 错标 / 截码失真的纠偏规则。
*
* 两条规则各有一个**反向闸**,测试重点全在闸上 —— 闸失效不会报错,
* 只会让一批患者悄悄换场景(K08 缺牙 base 60 ↔ K03 牙体损伤 base 35),
* 表现为矩阵某一列莫名多/少几百格。
*
* 跑:
* pnpm test -- reconcile-diagnosis-code
*/
import { reconcileDiagnosisCode } from '@pac/types';
describe('reconcileDiagnosisCode', () => {
describe('K00 → K08(后天缺失被错标成发育障碍)', () => {
it.each(['牙齿缺少', '牙缺失', '缺牙', '后天性牙齿缺失'])('%s → K08', (name) => {
expect(reconcileDiagnosisCode('K00', name)).toBe('K08');
});
it('带「先天」→ 保持 K00(真发育障碍)', () => {
expect(reconcileDiagnosisCode('K00', '先天性牙齿缺失')).toBe('K00');
expect(reconcileDiagnosisCode('K00', '先天缺牙')).toBe('K00');
});
it('其它 K00 不动', () => {
expect(reconcileDiagnosisCode('K00', '乳牙滞留')).toBe('K00');
expect(reconcileDiagnosisCode('K00', '多生牙')).toBe('K00');
});
});
describe('K08 → K03(K08.3 牙根残留被截成 K08 缺牙)', () => {
// 生产实测:名字恰为这几个的 932 条应翻 K03
it.each(['残根', '残冠', '残根残冠', '残根/残冠'])('%s → K03', (name) => {
expect(reconcileDiagnosisCode('K08', name)).toBe('K03');
});
it('无法保留 / 不能保留 → K03', () => {
expect(reconcileDiagnosisCode('K08', '无法保留')).toBe('K03');
expect(reconcileDiagnosisCode('K08', '患牙不能保留')).toBe('K03');
});
// ⛔ 反向闸:复合诊断(生产 325 条)两个病挤在一个 name,翻 K03 会丢掉缺牙那一半
it.each([
'牙列缺损,残根',
'缺失,残根',
'残根,牙列缺损',
'牙缺失;#44残根',
'牙缺失, 残根残冠',
'16,12,26缺失,17,11,21,22,23,25,27残根',
])('复合诊断「%s」→ 保持 K08', (name) => {
expect(reconcileDiagnosisCode('K08', name)).toBe('K08');
});
it('纯缺牙类 K08 不动', () => {
expect(reconcileDiagnosisCode('K08', '后天性牙齿缺失')).toBe('K08');
expect(reconcileDiagnosisCode('K08', '牙列缺损')).toBe('K08');
expect(reconcileDiagnosisCode('K08', '牙齿缺少')).toBe('K08');
});
it('残根本来就是 K03 的不受影响(医生没填 std_code 的多数派)', () => {
expect(reconcileDiagnosisCode('K03', '残根')).toBe('K03');
expect(reconcileDiagnosisCode('K03', '残冠')).toBe('K03');
});
});
describe('边界', () => {
it('code 为 null → null', () => {
expect(reconcileDiagnosisCode(null, '残根')).toBeNull();
});
it('name 为 null → 原样返回,不误翻', () => {
expect(reconcileDiagnosisCode('K08', null)).toBe('K08');
expect(reconcileDiagnosisCode('K00', null)).toBe('K00');
});
it('无关码不动', () => {
expect(reconcileDiagnosisCode('K05', '慢性牙周炎')).toBe('K05');
expect(reconcileDiagnosisCode('K02', '龋病')).toBe('K02');
});
});
});
/**
* refineCategoriesForDiagnosis —— K03 同码内主类目重排(残根/残冠 → 外科)。
*
* 守的是**「分配的潜在治疗项目」与「卡片上的目标」必须对应**这条口径:
* 分配矩阵把残根患者放进「拔牙治疗」列(POTENTIAL_LABEL_RULES: K03 + 含词 → extraction),
* 卡片的「目标 · X」就不能写「充填 / 嵌体」。两处共用 EXTRACTION_NAME_KEYWORDS 同一份词表。
*
* 跑:
* pnpm test -- refine-categories-k03
*/
import {
refineCategoriesForDiagnosis,
recommendedCategoriesForAge,
DiagnosisTreatmentMap,
EXTRACTION_NAME_KEYWORDS,
classifyCodeToLabel,
} from '@pac/types';
/** K03 在字典里的候选类目(单一真理源,不在测试里硬编码顺序) */
const K03_CATS = DiagnosisTreatmentMap.K03!.categories as readonly string[];
/** 复现 scenario 里 focusCategory 的算法:语义重排 → 年龄重排 → 取首项 */
const focusOf = (code: string, nameZh: string, age: number | null): string | null =>
recommendedCategoriesForAge(refineCategoriesForDiagnosis(code, nameZh, K03_CATS), age)[0] ?? null;
describe('refineCategoriesForDiagnosis · K03', () => {
it('K03 候选类目本身含 surgical(否则重排无处可挪)', () => {
expect(K03_CATS).toContain('surgical');
expect(K03_CATS).toContain('restorative');
});
describe('残根 / 残冠 → 外科打头', () => {
it.each(EXTRACTION_NAME_KEYWORDS)('「%s」→ surgical', (name) => {
expect(refineCategoriesForDiagnosis('K03', name, K03_CATS)[0]).toBe('surgical');
});
it('66 岁残根患者(孙海燕 TS0M012582 牙16;18;28;47)目标 = 外科,不是充填', () => {
expect(focusOf('K03', '残根', 66)).toBe('surgical');
});
it('年龄闸不会把它挪回去(K03 无 implant,recommendedCategoriesForAge 空转)', () => {
for (const age of [8, 18, 40, 66, 88, null]) {
expect(focusOf('K03', '残冠', age)).toBe('surgical');
}
});
});
describe('⛔ 只挪位,不增删', () => {
it('返回集合恒等于入参集合', () => {
const out = refineCategoriesForDiagnosis('K03', '残根', K03_CATS);
expect([...out].sort()).toEqual([...K03_CATS].sort());
});
it('其余相对顺序保持', () => {
const out = refineCategoriesForDiagnosis('K03', '残根', K03_CATS);
const rest = out.filter((c) => c !== 'surgical');
expect(rest).toEqual(K03_CATS.filter((c) => c !== 'surgical'));
});
});
describe('不该动的不动', () => {
it('楔状缺损 / 牙体缺损 → 保持原序(补得回来)', () => {
expect(refineCategoriesForDiagnosis('K03', '牙齿楔状缺损', K03_CATS)).toEqual(K03_CATS);
expect(refineCategoriesForDiagnosis('K03', '牙体缺损', K03_CATS)).toEqual(K03_CATS);
expect(focusOf('K03', '牙齿楔状缺损', 66)).toBe('restorative');
});
it('诊断名为空 → 原样返回(历史数据行为不变)', () => {
expect(refineCategoriesForDiagnosis('K03', null, K03_CATS)).toEqual(K03_CATS);
expect(refineCategoriesForDiagnosis('K03', ' ', K03_CATS)).toEqual(K03_CATS);
});
it('别的码不受影响', () => {
const k08 = DiagnosisTreatmentMap.K08!.categories as readonly string[];
expect(refineCategoriesForDiagnosis('K08', '残根', k08)).toEqual(k08);
expect(refineCategoriesForDiagnosis(null, '残根', K03_CATS)).toEqual(K03_CATS);
});
});
describe('K00 原有行为回归(表驱动改写后不能退化)', () => {
const k00 = DiagnosisTreatmentMap.K00!.categories as readonly string[];
it.each([
['乳牙滞留', 'surgical'],
['乳牙早失', 'orthodontic'],
['萌出障碍', 'orthodontic'],
['先天缺牙', 'prosthodontic'],
['釉质发育不全', 'prosthodontic'],
])('K00「%s」→ %s 打头', (name, lead) => {
expect(refineCategoriesForDiagnosis('K00', name, k00)[0]).toBe(lead);
});
});
describe('⭐ 标签与目标同源(本次修复的核心口径)', () => {
it.each(EXTRACTION_NAME_KEYWORDS)('「%s」:分配标签 extraction ⟺ 目标 surgical', (name) => {
expect(classifyCodeToLabel('K03', name, 66)).toBe('extraction');
expect(focusOf('K03', name, 66)).toBe('surgical');
});
it('非拔除类 K03:标签 restoration ⟺ 目标 restorative', () => {
expect(classifyCodeToLabel('K03', '牙齿楔状缺损', 66)).toBe('restoration');
expect(focusOf('K03', '牙齿楔状缺损', 66)).toBe('restorative');
});
});
});
/**
* 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();
}
});
});
/**
* REVIEW_IMPLIES_TREATMENT —— 「复查/复诊」作为修复体在位的**证据**。
*
* 守两条闸,任何一条松掉都会静默误销召回(少召不报错,一线只会觉得"系统没提醒过"):
* ① 词形闸:必须「治疗词 + 复查/复诊」——「正畸复诊」(在做)vs「正畸会诊」(还在谈)只差一字
* ② 类目闸:必须与 resolverCategoriesFor 交集 ——「牙周复查」不能解 K02 龋齿
*
* 正则用 PG 语义书写,这里用 JS RegExp 近似校验词形(两者对本表用到的语法等价)。
*
* 跑:
* pnpm test -- review-implies-treatment
*/
import { REVIEW_IMPLIES_TREATMENT, resolverCategoriesFor } from '@pac/types';
/** 某个 subtype 命中哪些规则 */
const rulesFor = (subtype: string) =>
REVIEW_IMPLIES_TREATMENT.filter((r) => new RegExp(r.pattern).test(subtype));
/** 模拟消费方:该诊断码下,这个 subtype 会不会解除缺口 */
const resolves = (code: string, subtype: string): boolean => {
const cats = resolverCategoriesFor(code) as readonly string[];
return rulesFor(subtype).some((r) => cats.includes(r.category));
};
describe('REVIEW_IMPLIES_TREATMENT · 词形闸', () => {
it.each([
['种植复查', 'implant'],
['牙周复查', 'periodontic'],
['正畸复诊', 'orthodontic'],
['正畸复查', 'orthodontic'],
['保持器复诊', 'orthodontic'],
])('生产实有词「%s」→ %s', (subtype, cat) => {
expect(rulesFor(subtype).map((r) => r.category)).toContain(cat);
});
// ⛔ 这些是生产带牙位 review 里的大头,收进来就是误销
it.each([
'观察', '暂观', '观察,必要时拔除', '暂观,必要时拔除', '随访观察', '观察随访', '随诊观察',
'无治疗', '初诊检查', '检查', '方案沟通', '沟通治疗方案', '听方案', '取资料', '缴费',
'转诊', '请全科医生会诊', '治疗中',
])('「%s」不命中任何规则', (subtype) => {
expect(rulesFor(subtype)).toHaveLength(0);
});
// ⛔ 泛指复查也不收 —— 它不指明治疗对象(宽口径 391 条里的争议区)
it.each(['复查', '常规复查', '定期复查'])('泛指「%s」不命中', (subtype) => {
expect(rulesFor(subtype)).toHaveLength(0);
});
// ⛔ 一字之差,语义相反
it.each(['正畸会诊', '正畸咨询', '正畸检查'])('「%s」不命中(还在谈,没在做)', (subtype) => {
expect(rulesFor(subtype)).toHaveLength(0);
});
});
describe('REVIEW_IMPLIES_TREATMENT · 类目闸', () => {
it('K08 缺失牙 + 种植复查 → 解除(宋志宏 TS0M001982 牙46;36;37)', () => {
expect(resolves('K08', '种植复查')).toBe(true);
});
it('K04 根尖周炎 + 种植复查 → 解除(种了牙就没牙髓了)', () => {
expect(resolves('K04', '种植复查')).toBe(true);
});
// ⛔ 生产实测的 9 条跨类目错配,逐条锁死
it.each([
['K02', '牙周复查', '洗牙不补龋'],
['K03', '牙周复查', '洗牙不修牙体'],
['K08', '牙周复查', '洗牙不修缺牙'],
['K06', '种植复查', 'K06 只认牙周/外科'],
['K01', '正畸复查', 'K01 走结构家族,不含正畸'],
])('%s + %s → 不解除(%s)', (code, subtype) => {
expect(resolves(code, subtype)).toBe(false);
});
});
describe('REVIEW_IMPLIES_TREATMENT · 表自身自洽', () => {
it('每条 pattern 都能编译成正则', () => {
for (const r of REVIEW_IMPLIES_TREATMENT) {
expect(() => new RegExp(r.pattern)).not.toThrow();
}
});
it('每条都写了 why(改表的人得说明临床依据)', () => {
for (const r of REVIEW_IMPLIES_TREATMENT) {
expect(r.why.length).toBeGreaterThan(4);
}
});
it('每条 pattern 都强制要求出现 复查/复诊', () => {
for (const r of REVIEW_IMPLIES_TREATMENT) {
expect(r.pattern).toMatch(/复查\|复诊/);
}
});
});
import { SyncIncrementalSchedulerService } from '../src/queues/sync-incremental.scheduler';
/**
* cron 回调防重入(runHostSafe 的 runningHosts 闸)回归。
*
* 🔴 2026-08-29 生产事故:plan 段耗时涨过 2 小时后,cron(每 2 小时)照常触发下一轮,
* 两轮的 plan 段并发抢同一批 I/O → 都更慢 → 更易被再下一轮套圈 → 雪崩。
* 实测 08-28 20:15 起连续多轮 plan 段一次都没跑完,到 08-29 上午仍有两轮在并行。
*
* 为什么现有的锁挡不住(这两条是本用例存在的理由):
* ① NestJS CronJob **默认不防重入** —— 上一次回调还在 await,下一次照样进;
* ② `sync_logs` 的 partial UNIQUE(host_id) WHERE status='running' 只覆盖**摄入段**,
* 摄入一结束锁就放了,而最慢的 persona / plan 段还在跑。
*
* 跑:
* pnpm test -- scheduler-reentrancy-guard
*/
/** 造一个只关心 runOne 编排的实例;runOne 用可控的 promise 替换 */
function makeService() {
const svc = new SyncIncrementalSchedulerService(
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
);
const calls: string[] = [];
let release!: () => void;
const gate = new Promise<void>((r) => { release = r; });
(svc as unknown as { runOne: (dir: string) => Promise<void> }).runOne = async (dir: string) => {
calls.push(dir);
await gate; // 卡住不返回 = 模拟"上一轮还在跑"
};
const run = (host: string) =>
(svc as unknown as { runHostSafe: (h: string) => Promise<void> }).runHostSafe(host);
return { svc, calls, run, release };
}
describe('runHostSafe 防重入', () => {
test('🔴 上一轮未结束时,同 host 的下一轮被跳过(不并发进 runOne)', async () => {
const { calls, run, release } = makeService();
const first = run('jvs-dw'); // 卡在 gate 上
await run('jvs-dw'); // 第二次应立即返回
await run('jvs-dw'); // 第三次同样
expect(calls).toHaveLength(1); // ⛔ 只有第一轮真正进了 runOne
release();
await first;
});
test('上一轮结束后,下一轮正常放行', async () => {
const { calls, run, release } = makeService();
const first = run('jvs-dw');
release();
await first;
await run('jvs-dw');
expect(calls).toHaveLength(2);
});
test('不同 host 互不阻塞(闸是按 host 的,不是全局)', async () => {
const { calls, run, release } = makeService();
const a = run('jvs-dw');
const b = run('friday');
// runOne 收到的是 path.join(dataDir, host) 的完整路径,按后缀断言
expect(calls.map((d) => d.split('/').pop())).toEqual(['jvs-dw', 'friday']);
release();
await Promise.all([a, b]);
});
test('⛔ runOne 抛异常也必须释放闸 —— 否则该 host 被永久锁死到进程重启', async () => {
const svc = new SyncIncrementalSchedulerService(
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
);
let n = 0;
(svc as unknown as { runOne: (dir: string) => Promise<void> }).runOne = async () => {
n++;
throw new Error('boom');
};
const run = (h: string) =>
(svc as unknown as { runHostSafe: (h: string) => Promise<void> }).runHostSafe(h);
await run('jvs-dw'); // runHostSafe 吞异常
await run('jvs-dw'); // 闸已释放 → 应能再进
expect(n).toBe(2);
});
});
/**
* traceRawSourceTable —— reparse 的源表回溯。
*
* 回溯错了不会报错:rawPayload 被灌进**中间表**,随即被 transform 链用空结果覆盖 →
* reparse 恒 0 变更、exit 0、日志写"重衍完成"。2026-08-27 实测治疗类三个资源全中,
* 意味着任何治疗字典修补都无法回填存量,而且没人会发现。
*
* 跑:
* pnpm test -- trace-raw-source-table
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as yaml from 'js-yaml';
import { traceRawSourceTable } from '../src/modules/sync/cold-import/cold-import.service';
const MANIFEST = path.join(__dirname, '../data/jvs-dw/manifest.yaml');
const manifest = yaml.load(fs.readFileSync(MANIFEST, 'utf8')) as { transforms?: unknown[] };
const TF = manifest.transforms ?? [];
const trace = (t: string) => traceRawSourceTable(t, TF);
describe('traceRawSourceTable · 真实 manifest', () => {
// ⛔ 这四个必须都回溯到真正的 DW 原始表,任何一个停在中间表 = reparse 静默失效
it.each([
'treatment_actual_rows',
'treatment_planned_rows',
'treatment_review_rows',
'diagnosis_rows',
])('%s → fact_emr_treatment_out', (tbl) => {
expect(trace(tbl)).toBe('fact_emr_treatment_out');
});
it('⛔ 不能停在 route 的输出表上(本次 bug 的指纹)', () => {
for (const tbl of ['treatment_actual_rows', 'treatment_planned_rows', 'treatment_review_rows']) {
expect(trace(tbl)).not.toMatch(/^_/); // 下划线开头 = manifest 里的中间表
}
});
it('原始表回溯到自身(reparse 据此判定"非 transform 产出"并跳过)', () => {
expect(trace('fact_emr_treatment_out')).toBe('fact_emr_treatment_out');
});
});
describe('traceRawSourceTable · 合成用例', () => {
it('route_by_pattern 用的是 routes 字段,不是 outputs', () => {
const tf = [
{ kind: 'split_json_array', input: 'raw_src', output: '_mid' },
{ kind: 'route_by_pattern', input: '_mid', field: 'x', routes: [{ output: '_a' }, { output: '_b' }] },
{ kind: 'derive', input: '_a', output: 'final_rows' },
];
expect(traceRawSourceTable('final_rows', tf)).toBe('raw_src');
});
it('union 各分支回溯不一致 → 停在 union 输出(既有语义不变)', () => {
const tf = [
{ kind: 'derive', input: 'raw_a', output: 't1' },
{ kind: 'union', inputs: ['t1', 'raw_b'], output: 'merged' },
];
expect(traceRawSourceTable('merged', tf)).toBe('merged');
});
});
......@@ -17,7 +17,10 @@ const transforms = [
{ kind: 'filter', input: 'patient_settlement_spec', output: '_refund_item_raw', where: {} },
{ kind: 'lookup', input: '_refund_item_raw', output: 'refund_item_rows', from: 'patient_settlement', select: {} },
// route_by_pattern 多输出回同一 input
{ kind: 'route_by_pattern', input: '_treat_raw', outputs: [{ output: '_actual_raw' }, { output: '_rec_raw' }] },
// ⚠️ 2026-08-27 修正:字段名是 `routes`(见 transforms.schema.ts RouteByPatternOpSchema),
// 本 fixture 原先写 `outputs` —— 跟当时的实现犯了同一个错,于是测试一直绿、生产一直坏
// (治疗类三个资源 reparse 恒 0 变更且报成功)。fixture 必须照**契约**写,不能照实现写。
{ kind: 'route_by_pattern', input: '_treat_raw', routes: [{ output: '_actual_raw' }, { output: '_rec_raw' }] },
];
describe('traceRawSourceTable', () => {
......
/**
* 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();
}
});
});
......@@ -102,6 +102,31 @@ services:
environment:
NODE_ENV: production
PORT: 3101
# 🔴 **主进程堆上限** —— ⛔ 别删,删了每 ~4 小时崩一次(2026-08-26 生产实证)。
#
# 症状:`FATAL ERROR: Reached heap limit — JavaScript heap out of memory`,
# 容器 exitCode=139 自动重启。08-23~08-26 崩了 **11 次**,期间**一次完整的
# 全量 plans 都没跑成过**(日志里 `Result(ruier-grp)` 出现 0 次)。
# 根因:每轮增量同步结束会在**主进程内**跑一次 `runAllForHost` 全量召回。
# 补摄把患者灌到 111 万、召回池 54 万后,`selectHits` 一次出 96 万条命中,
# 连同 prefetch 的 latest/snoozed/persona 全在堆里 —— 而主进程启动命令
# (`node --enable-source-maps dist/main.js`)**没带 max-old-space-size**,
# 取 Node 默认 **4144 MB**,装不下。
# ⚠️ 容器**没有内存上限**(HostConfig.Memory=0),宿主 15.36G 也一直有 7-11G 空闲 ——
# ⛔ 别往"容器内存不够"上找,撑爆的是 V8 自己的堆,与宿主余量无关。
# ⚠️ 同一容器里 CLI 那条路(cold-import / recompute-plans)一直是 `-max-old-space-size=8192`,
# **两条路配置不一致**,崩的一直是没配的这条。这里补齐。
#
# ⚠️ 宿主内存账:15.36G 总量,本进程 8G 上限 + CLI 8G 上限 = 16G > 总量。
# 实测两者不会同时顶到上限(补摄期间增量被 sync 并发锁挡下,实测 12 小时零崩溃),
# 且 max-old-space 是**天花板不是预留**(实测峰值:服务 4G / CLI 4.9G)。
# ⛔ 但**别手工并发跑** `recompute-plans --host=<全量>`(它也吃 8G)——
# 那条 CLI **不走 sync 锁**,与服务自己那轮全量撞上时才真有可能把宿主吃满。
#
# ⚠️ 这是**止血不是根治**:池子还在涨,8G 迟早也会到顶。
# 根治是「增量之后别跑全量」—— `recompute-plans` 已经有 `--clinics` 收窄能力,
# 同样的思路搬到 SyncIncrementalSchedulerService 那一处即可。
NODE_OPTIONS: --max-old-space-size=8192
# 覆盖 .env 的 localhost URL → 走 docker 内部网络
DATABASE_URL: postgresql://${POSTGRES_USER:-pac}:${POSTGRES_PASSWORD:-pac}@postgres:5432/${POSTGRES_DB:-pac}?schema=public
REDIS_URL: redis://redis:6379
......
# resolvedTeeth 集合式重写 · 方案
> 目标:把 `buildGapCore` 的 `resolvedTeethSql` 从**逐 (患者×信号) 行相关子查询**改成**集合式预聚合 + 连接**,
> 让 plan 全量重算能装进 2 小时增量窗口。
> 现状锚点:生产全轮 场景段 3h11m / 总 3h41m(work_mem=256MB 之后);测试机全轮 23.3 分钟。
---
## 0. 开工前先复核的两件事(2026-08-30 本地实测,直接改变了方案边界)
### 0.1 ⛔ 全口场景(K05/K07)的 lateral **PostgreSQL 已经自动删掉了** —— 集合式重写对它们零收益
`wholeMouth: true``sigToothExpr = NULL::text``st = {}`;
`toothOutput``CASE WHEN TRUE THEN NULL`,`gapWhere``CASE WHEN TRUE THEN NOT EXISTS(...)` ——
两处常量折叠后 `lat` 无人引用,PG 的 useless-left-join removal 直接摘掉整个 LATERAL。
本地 pac-postgres 用**贴真形态**(lateral 内引用 `sig`、外层挂 ⑤ 闸)EXPLAIN ANALYZE 验证:
```
Nested Loop
-> Parallel Bitmap Heap Scan on patient_facts sig
-> Memoize -> Index Scan on patients p
Filter: (active AND (NOT (SubPlan 1))) ← 只剩患者级 NOT EXISTS
计划里没有任何 lateral / Append 节点
```
**推论**:测试机 23.3 分钟里 **K05 牙周 9m13s** 花的不是 resolvedTeeth 的钱,是
「1.22M 信号行扫描 + 5 个患者级 NOT EXISTS 闸」的钱。本方案**帮不上 K05/K07**,
它们要单独立项(反连接化 / 候选预筛),不在本文范围。
> 收益上限因此要下修:测试机两条慢查询 K05(9m13s,不受益)+ K01(13m,受益),
> 本方案吃掉的是 K01 那一半里的 68%。**全轮预期改善 ≈ 35~40%,不足以单独装进 2 小时。**
> Phase 0 必须先量生产上「牙位级子场景合计耗时占比」,别砍掉 68% 的一个小分母。
### 0.2 本地单分支对拍只快 1.26× —— 收益必须先量,不能先写
30K 患者本地库、K01、只取 (a) 治疗家族一个分支:
| 形态 | 墙钟 |
|---|---|
| A 现状(逐行相关子查询) | 1484 ms |
| B 集合式(cand → scope → res 预聚合 → 反连接) | 1178 ms |
同一分支 UNION 三份 → 1693 ms,**边际分支代价只有 ~105ms**,说明本地瓶颈根本不在分支数上。
本地库太小、全热、去重比只有 1.55×(15,195 候选行 / 9,789 患者),**不能外推到生产**
但足以定一条纪律:**先建对拍与基准,单子场景试点达标再铺开** ——
这条分支上已经有两个「理论漂亮、实测为零」的前科(子场景并发、信号码部分索引)。
---
## 1. 适用面
| 子场景 | primaryCode | wholeMouth | 本方案是否受益 |
|---|---|---|---|
| missing_tooth | K08 | ✗ | ✅(分支最多,12 条) |
| caries_no_filling | K02 | ✗ | ✅ |
| hard_tissue_damage | K03 | ✗ | ✅ |
| endo_no_rct | K04 | ✗ | ✅ |
| impacted_tooth | K01 | ✗ | ✅(生产/测试最慢那条) |
| jaw_cyst | K09 | ✗ | ✅ |
| development_eruption | K00 | ✗ | ✅ |
| gum_alveolar_lesion | K06 | ✗ | ✅ |
| extraction_recommended | EXTRACTION_RECOMMENDED | ✗ | ✅ |
| perio_no_srp | K05 | ✓ | ❌ 见 §0.1 |
| ortho_no_consult | K07 | ✓ | ❌ 见 §0.1 |
**关键不变式(要写成断言)**:`excludeIfEverTreated ⟺ wholeMouth`(当前只有 K05/K07 两者同时为真)。
所以 **9 个牙位级场景里 `afterDxFor` 永远是 sig 锚点形态**,集合式路径不必处理 `latestDxOfCode` 那一支。
未来谁给某个牙位级 rule 加 `excludeIfEverTreated`,断言必须炸。
---
## 2. 核心恒等式
时间门全部是 `∃ 行 x : t(x) ⋛ 锚点` 的形状,而
```
∃ x ∈ G : t(x) >= a ⟺ max{ t(x) : x ∈ G } >= a
∃ x ∈ G : t(x) > a ⟺ max{ t(x) : x ∈ G } > a
```
分组 G = `(patient_id, tooth)`,组内其余谓词(category / status / 正则 / 牙位纯度…)**全部与 sig 无关**,
所以可以先按组聚合 `max(t)`,再跟每个信号的锚点比大小 —— 一次算完全部患者。
---
## 3. 13 个分支的相关性分类(这是重写的全部依据)
| # | 代码标记 | 别名 | 相关类型 | 聚合键 | 门 |
|---|---|---|---|---|---|
| 1 | (a) 治疗家族 resolver | rtx | 时间 | (pid, tooth) | `max(occurred_at) >= anchor` |
| 2 | (a'') 桥区间填洞 | btx | 时间 | (pid, arch_tooth) | `max(occurred_at) >= anchor` |
| 3 | (a''') 缺牙间隙关闭 | emrx | 时间 | (pid, tooth) | `max(occurred_at) >= anchor` |
| 4 | (a'''') 患者拒绝 | rfx | 时间 | (pid, tooth) | `max(COALESCE(occ,planned)) >= anchor` |
| 5 | (b) 更晚结构诊断 | ldx | 时间(**严格 >**) | (pid, tooth) | `max(occurred_at) > anchor` |
| 6 | (c) 建议优先于诊断 | rdx | 时间 **+ sig.type** | (pid, tooth) | `max(COALESCE) >= anchor``sig.type='diagnosis_record'` |
| 7 | (a''''') 复查即治疗 | rvx | 时间 | (pid, tooth) | `max >= anchor` |
| 8 | (a'''''') 抛光即修复 | plx | 时间 | (pid, tooth) | `max >= anchor` |
| 9 | (a''''''') 病历文本自证 | tvx ⋈ emx | 时间 | (pid, tooth) | `max >= anchor` |
| 10 | (a'''''''') 检查所见修复在位 | rix | 时间 | (pid, tooth) | `max >= anchor` |
| 11 | (a''''''''') 整颌活动义齿 | adx | **等值(病历号)** | (pid, emr_ext_id, tooth) | `emr_ext_id = sig.source_encounter_external_id` |
| 12 | §E 正畸减数位 | ex | **无** | (pid, tooth) | 恒真 |
| 13 | 建议拔除让位病种 | dxx | **无** | (pid, tooth) | 恒真 |
只有三类:**时间门 / 病历号等值 / 无相关**。第 6 条多一个 `sig.type` 标量谓词,提到连接条件即可。
分支 9 的 `tvx ⋈ emx` 与分支 11 的 `adx ⋈ sig` 要分清:前者是**源内部**的连接(与 sig 无关,照常预聚合),
后者才是**与 sig 的等值相关**(进聚合键)。
---
## 4. 目标 SQL 形态
```sql
WITH cand AS MATERIALIZED ( -- 候选 (患者×信号),已过 ①隔离 ②合规 ③信号 ④cooldown
SELECT p.id AS patient_id, sig.id AS sig_id,
COALESCE(sig.occurred_at, sig.planned_for) AS anchor,
sig.type AS sig_type,
sig.content->>'source_encounter_external_id' AS sig_enc,
<toothArrSql(sigToothExpr, 乳牙/智齿剔除)> AS sig_teeth,
...投影列(code / name_zh / clinic_id / days_since )
FROM patients p JOIN patient_profiles pp ON JOIN patient_facts sig ON
WHERE <①②③④ + patientFilter + restorationIneligible + congenital>
),
scope AS MATERIALIZED (SELECT DISTINCT patient_id FROM cand),
res AS MATERIALIZED ( -- 13 个分支 UNION ALL,统一四元组
-- (patient_id, tooth, gate, enc, strict, needs_dx_sig)
-- gate = 'infinity'::timestamptz 表示"无时间门"(恒过);时间门分支保证 gate NOT NULL
SELECT patient_id, tooth, max(gate) AS gate, enc, strict, needs_dx_sig
FROM ( <branch1> UNION ALL <branch2> UNION ALL <branch13> ) b
GROUP BY patient_id, tooth, enc, strict, needs_dx_sig
),
rem AS ( -- 牙位级反连接
SELECT c.sig_id, array_agg(u.x ORDER BY u.ord) AS remaining_teeth
FROM cand c
CROSS JOIN LATERAL unnest(c.sig_teeth) WITH ORDINALITY AS u(x, ord)
WHERE NOT EXISTS (
SELECT 1 FROM res r
WHERE r.patient_id = c.patient_id
AND r.tooth = u.x
AND (CASE WHEN r.strict THEN r.gate > c.anchor ELSE r.gate >= c.anchor END)
AND (r.enc IS NULL OR r.enc = c.sig_enc)
AND (NOT r.needs_dx_sig OR c.sig_type = 'diagnosis_record')
)
GROUP BY c.sig_id
)
SELECT , array_to_string(COALESCE(rem.remaining_teeth, ARRAY[]::text[]), ';') AS tooth
FROM cand c LEFT JOIN rem ON rem.sig_id = c.sig_id
WHERE cardinality(COALESCE(rem.remaining_teeth, ARRAY[]::text[])) > 0
AND <b 未来预约 / f 到诊冷静 / g 未来回访 —— 召回独有,画像不加>
```
要点:
- 每个分支源表 **`JOIN scope s ON s.patient_id = x.patient_id`** —— 没有这一句,CTE 会全表扫 34GB heap。
- `toothArrSql` 从「每候选行算一次」降到「每源事实行算一次」,这是省钱的第二处。
- 画像单患者路径 `scope` = 1 行 → 全部走 `patient_facts_patient_id_type_status_idx`,形态不退化。
---
## 5. 五个必须踩准的坑(每一个都通向静默少召)
1. **NULL 时间不能靠 max() 自然消化**`max()` 忽略 NULL,全 NULL 组聚成 NULL;
若拿 NULL 表示「无时间门」,这种组会被当成恒过 → 误销 → 少召。
⇒ 时间门分支内 `WHERE occurred_at IS NOT NULL`(等价:NULL 本来就过不了 `>= anchor`),
无时间门分支显式写 `'infinity'::timestamptz`**`gate` 列永不为 NULL。**
2. **分支 5 是严格 `>`**,其余是 `>=`。合并成一列后必须带 `strict` 标志,不能"统一成 >= 反正差不多"。
3. **`remaining_teeth` 的顺序与重复要原样保留**。现状是 `unnest(st)` 的自然顺序、不去重;
`tooth` 字符串会落进 plan_reasons 给客服看,顺序变了就是 diff 噪音。⇒ `WITH ORDINALITY` + `ORDER BY ord`
4. **`rem` 无行 ≠ `{}`**。全部牙位被解决的 sig 在 `rem` 里没有行,LEFT JOIN 得 NULL;
`cardinality(NULL) > 0` 是 NULL(等价于假,行为一致),但 `toothOutput` 会从 `''` 变 NULL。⇒ 一律 `COALESCE(…, '{}')`
5. **CTE 没有统计信息**`WITH … AS MATERIALIZED` 的行数估计是瞎猜,可能选错连接方式。
先例:`apps/pac-service/sql/verify-recall.sql` 用的是**临时表 + CREATE INDEX + ANALYZE**,
注释里写明「13 万患者秒级(旧逐行相关子查询版会 O(n²) 卡死)」。
⇒ 若 CTE 形态在测试机上计划走歪,退回临时表形态(代价:一次事务内多两条 DDL)。
---
## 6. 接口与代码量
| 文件 | 改动 | 量 |
|---|---|---|
| `clinical-gap/potential-treatment-gap.sql.ts` | `resolvedTeethSql`(107 行)→ 13 个分支 CTE 生成器 + `res`/`rem` 拼装;`GapCorePieces` 新增 `withCtes` / `candProjection` / `remainingExpr`;`buildGapCore` 新增入参 `scope: Prisma.Sql``variant: 'legacy'|'setbased'` | +190 / −110 |
| `plan/engine/scenarios/treatment-initiation-recall.scenario.ts` | 主查询包 `WITH`,⑤ 闸留在外层 | +20 |
| `clinical-gap/potential-treatment.selector.ts` | 同上,`scope` = 单患者 | +18 |
| `cli/recompute-plans.cli.ts` | `--only=<subKey>` 试点开关 | +8 |
| **新** `sql/verify-gap-equivalence.sql`(或 CLI) | 对拍工具 —— **真正的工作量** | ~250 |
| `tests/gap-setbased-shape.spec.ts`(新) | 锁「13 分支都进了 res」「wholeMouth 不生成 lateral」「gate 列无 NULL 路径」 | ~120 |
`variant`**临时脚手架**:对拍工具靠它在同一份主查询里跑新旧两版。全量验收通过后连同 legacy 分支一起删。
### 现有测试保护不了这次重写(已核)
`arch-denture-false-missing` / `polish-implies-restoration` / `restoration-in-place-from-exam` /
`treated-evidence-from-emr-text` / `review-implies-treatment` 五个 spec **全部是纯 JS 常量与正则断言**,
不碰 SQL 行为 —— 重写后它们照样全绿。**101 套 / 1790 个测试对本次改动的保护 ≈ 0。**
这就是为什么对拍工具不是"顺便加的验证",而是本项目的主体。
---
## 7. 分期与验收门
### Phase 0 · 对拍 + 基准(不改任何逻辑,独立可交付)
- 产出 `verify-gap-equivalence`:对每个 primaryCode 跑两版,落 `(patient_id, sig_id, tooth)` 三元组,FULL OUTER JOIN 差分。
- **必须同一快照**:两版在**同一个事务**里跑(`REPEATABLE READ`),否则并发增量摄入会造出假差异。
- 顺带量清楚:生产 11 个子场景各自 `sql=ms` 占比 → 确认牙位级到底占多少(见 §0.1 的分母问题)。
- **门:对 legacy 自己跑两遍必须零差异**(先证明工具本身可信),且拿到生产逐子场景耗时分布。
### Phase 1 · 单子场景试点(K01)
- 只改 K01 路径走 setbased,测试机跑。
- **门(两条都要过)**:① 全量对拍**零差异**(不是"差异很少");② `sql=ms` 至少快 **3×**
- **不到 3× 就停**,只留 Phase 0 的对拍工具。风险与收益不成比例。
### Phase 2 · 9 个牙位级场景全量
- **门**:9 个场景生产全量对拍零差异 + 全轮墙钟对比 + 命中患者数逐场景对比(参照上次「−0.43% / −2.5%」那套核法)。
### Phase 3 · 画像消费方
- 画像是**逐患者**调用(547K 次),任何单次开销都会被放大。
- **门**:单患者 p95 不劣化 >10%;`recompute-persona --force` 全量墙钟不劣化;
`persona_features``detail[].teeth` 与旧版逐患者比对零差异。
### Phase 4 · 清理
-`variant` 脚手架与 legacy 分支;更新 scenario 里那段耗时归因注释;
写一条 memory(集合式重写的结论 + 对拍工具位置)。
**工期**:核心改写 1 天;对拍工具 1 天;试点与全量验证 2~3 天(全量跑批本身 3~6 小时/轮,是墙钟大头)。
---
## 8. 明确不做
- **不动 `gapWhere` 的 wholeMouth 分支**(§0.1:没收益,纯风险)。
- **不动打分 / ⑤b ⑤f ⑤g 闸 / GAP_FLAGS / 词表正则** —— 本次只换计算形态,口径一个字节不改。
- **不把 11 条子场景合并成一条 SQL**。诱人(公共分支只算一次),但对拍粒度会崩,
`resolverCats` 逐场景不同。等集合式站稳、对拍工具成熟后再单独评估。
- **不上 `CREATE INDEX`**。同分支已实测:EXPLAIN 成本降 13× 而墙钟 24.5 vs 23.3 分钟,无效。
## 9. K05/K07 另立一项(本文不解决)
测试机 9m13s 的钱在「信号扫描 + 患者级 NOT EXISTS」。已知线索:
`NOT EXISTS` 被裹在 `CASE WHEN <wholeMouthFlag> THEN … END` 里 → PG 不做 sublink 上拉,
只能走 per-row SubPlan(本地实测:裸写 `NOT EXISTS` 会变成 Anti Join,`CASE` 包住则是 SubPlan)。
本地两者差距只有 13%(库小全热),生产是否放大**未验**
在 TS 侧按 `rule.wholeMouth` 分叉生成 SQL(而不是靠 SQL 的 CASE)是零风险的第一步,值得单独试。
---
## 10. 实施记录 · 本地验收(2026-08-30)
分支 `perf/gap-set-based`。默认仍走 legacy(`PAC_GAP_VARIANT` 未设),集合式靠环境变量切换。
### 与方案的两处偏离(都已改回)
- 曾把全口码(K05/K07)也统一进 `gap_cand` 形态 —— **实测慢 2~3 倍**
(ortho 1309→4439ms ×0.29;perio 1388→3065ms ×0.45),已按 §8 改回原样走 legacy。
`buildGapSetBased``rule.wholeMouth` 直接 `return null`
### 本地验收结果(30K 患者库)
| 项 | 结果 |
|---|---|
| 单元测试 | 102 套 / 1859 测试全绿(含新增 `gap-setbased-parity.spec.ts` 69 例) |
| SQL 层对拍 | 11 个子场景**逐 (患者×信号×牙位) 零差异**,行数逐个相同 |
| 端到端对拍 | `plan_reasons` 39,182 条**行级 diff = 0**(含 priority_score) |
| 幂等 | 两版互相复跑都 `plansCreated=0 plansClosed=0` |
| 场景段耗时 | legacy 34.2s → setbased 29.2s(×1.17) |
⚠️ 本地库小且全热、去重比低,**×1.17 不作为收益判据** —— 以测试机 585K 患者的实测为准。
### 对拍工具自检
`--self`(legacy 对 legacy)先跑过,零差异 —— 证明工具本身不会把相同当不同。
## 11. 测试机验收协议(2026-08-30 夜)
脚本 `~/gapverify.sh`(测试机),产物落 `~/gapbench/`
| 步 | 内容 | 门 |
|---|---|---|
| 0 | 工具自检:`--self --sub=jaw_cyst`(legacy 对 legacy) | 必须零差异,否则后面的"零差异"不可信 |
| 1 | 画像消费方:`--persona=2000` | 零差异 + 单患者 p95 不劣化 >10% |
| 2 | 召回 11 子场景:逐 (患者×信号×牙位) 双向 EXCEPT ALL | **零差异**(不是"差异很少") |
| 3 | 交替全量重算 A(legacy) → B(setbased) → C(legacy) → D(setbased) | 行级 diff 全 0;取 C vs D(都是热态第二轮)比墙钟 |
**为什么要交替四轮**:第一轮把页读进缓存,第二轮才代表稳态。只跑 legacy→setbased
会让 setbased 白蹭前一轮的缓存,把收益量虚高 —— 这条分支上已经有两次"理论漂亮实测为零"
的前科,不能再拿有偏的数字下结论。
### ⛔ 对拍工具打出的 ×N **不能当收益判据**
工具总是先跑 legacy 再跑 setbased,第二条白蹭第一条预热出来的缓存。
2026-08-30 测试机自检实测(`--self`,同一条 legacy SQL 跑两遍):
```
jaw_cyst legacy=36,797ms legacy=4,861ms ×7.57
```
**缓存效应 7.6 倍,比任何真实形态差异大一个数量级。** 所以:
- `verify-gap-equivalence` 的 ×N 只用来看"有没有离谱地变慢",不用来量收益
- 收益只认交替四轮里的 **C(legacy,第二轮) vs D(setbased,第二轮)** —— 两边都是热态
**噪音源**:测试机 `PAC_STALE_SCAN_CRON=0 2 * * *`(画像 stale 扫描)会落在验收中段。
C/D 相邻,长时间后台负载对两者影响相近,不单边偏袒;若数字明显抖动,重跑 C/D 一对。
`PAC_INCREMENTAL_CRON=15 8 * * *` 在窗口之外。
### 分支覆盖自检(本地 30K 库,2026-08-30)
「零差异」只有在**每条分支都真的产出过行**时才有意义 —— 一条从没触发的分支两边都返回空,
差分当然为零,但什么也没证明。本地各分支的源行数:
| 分支 | 源行数 | | 分支 | 源行数 |
|---|---|---|---|---|
| ① 治疗家族 | 56,552 | | ⑧ 裸抛光 | 66 |
| ② 桥类修复 | 8,681 | | ⑨ 病历文本自证 | 4,157 |
| ③ 缺牙间隙关闭 | 117 | | ⑩ 检查所见修复在位 | 4,628 |
| ④ 患者拒绝 | 375 | | ⑪ 整颌活动义齿 | 584 |
| ⑤ 更晚结构诊断 | 153,509 | | ⑫ 正畸减数位(外科拔牙) | 8,344 |
| ⑥ 建议记录 | 15,440 | | ⑬ 让位编码病种 | 77,083 |
| ⑦ 复查即治疗 | 1,805 | | | |
13 条全部有料,本地对拍不是空转。测试机数据量 ~20 倍,覆盖只会更足。
### 测试机结果 · 步 0~1(2026-08-30 01:05~)
| 步 | 结果 |
|---|---|
| 0 工具自检 | ✅ 零差异(并测出缓存效应 ×7.57,见上) |
| 1 画像对拍 | ✅ **零差异**(2000 位,1290 位有 gap);耗时/患者 legacy p50=13ms p95=47ms 合计 34.7s → setbased p50=9ms p95=27ms 合计 22.1s |
画像的门是「p95 不劣化 >10%」—— 通过。
⚠️ 但每位患者也是先 legacy 后 setbased,**顺序同样偏袒 setbased**,
所以只能下「不劣化」的结论,**不能说快 1.57 倍**。要量画像的真实收益,得改成交替顺序再跑。
### 测试机结果 · 步 2:召回 11 子场景对拍(2026-08-30 01:07~)
**✅ 11/11 零差异,行数逐个相同**(585K 患者,逐 患者×信号×牙位 双向 EXCEPT ALL)。
| 子场景 | 行数 | legacy | setbased | 比值* |
|---|---|---|---|---|
| missing_tooth | 14,970 | 230.4s | 151.7s | ×1.52 |
| caries_no_filling | 45,339 | 183.3s | 71.3s | ×2.57 |
| hard_tissue_damage | 31,758 | 102.8s | 47.3s | ×2.17 |
| endo_no_rct | 10,165 | 84.2s | 39.3s | ×2.14 |
| impacted_tooth | 38,003 | 71.0s | 39.1s | ×1.82 |
| perio_no_srp *(全口对照)* | 15,751 | 41.7s | 39.1s | ×1.07 |
| ortho_no_consult *(全口对照)* | 21,674 | 29.1s | 13.2s | ×2.20 |
| development_eruption | 2,882 | 19.4s | 13.9s | ×1.39 |
| extraction_recommended | 1,235 | 15.2s | 15.8s | ×0.96 |
| gum_alveolar_lesion | 875 | 7.3s | 9.2s | ×0.79 |
| jaw_cyst | 284 | 5.2s | 6.8s | ×0.77 |
\***这一列不是收益**。两个全口子场景两边跑的是**同一条 SQL**(setbased 对 wholeMouth 早退),
它们的 ×1.07 与 ×2.20 就是纯缓存噪音的量级 —— 噪音跨度比要测的信号还大。
这一列只用来看「有没有离谱地变慢」。收益看步 3 的 C vs D。
**新发现:小场景一致变慢。** ≤3000 行的四条(extraction ×0.96 / gum ×0.79 / jaw_cyst ×0.77,
本地 extraction ×0.60)都在带缓存优势的情况下仍然更慢 —— 集合式的 CTE 物化是**固定开销**,
结果集太小就摊不掉。绝对值都在 2 秒以内,对总墙钟无影响,不值得为它加"按规模切形态"的分叉
(那会让单一真理源裂成两条路径,得不偿失)。
### 测试机结果 · 步 3:交替四轮全量重算(2026-08-30 01:45~03:14)
| 轮 | 形态 | 场景段 | 整轮墙钟 | 命中患者 | reason 行数 |
|---|---|---|---|---|---|
| A | legacy | 670s | 1,584s | 87,807 | 323,781 |
| B | setbased | **1,334s** | 1,675s | 87,811 | 323,791 |
| C | legacy | 794s | 1,181s | 87,813 | 323,794 |
| D | setbased | **545s** | 923s | 87,814 | 323,796 |
#### ⛔ B 轮是离群值 —— 我据它下过一个错误结论,记在这里免得后来人重犯
B 轮场景段 1,334s(比 legacy 慢一倍),两个**跑同一条 SQL** 的全口子场景也慢了 7~12 倍
(ortho 38→444s / perio 105→797s)。我当场写下「集合式把内存打穿、溢盘、连没改的查询一起拖垮」,
还配了机器状态佐证(14G 内存吃光 + 9G swap + `temp_bytes=187GB`)。
**全错。** D 轮复现不出来 —— 545s,四轮里最快。真相:
- B 轮跑在 **02:11–02:39**,整段压在测试机 `PAC_STALE_SCAN_CRON=0 2 * * *`(画像 stale 扫描)上。
A 轮尾巴也蹭到 11 分钟,所以 A 也偏慢。C/D 在扫描结束后跑,才是干净的。
- `temp_bytes=187GB`**统计累计值,不是今晚产生的**。实测每轮增量:
C(legacy)4 个临时文件 / D(setbased)4 个,总增量 62MB —— **根本没有溢盘**
- 那个"内存打穿"的因果链是我照着一个数字编出来的,没有任何一步是量出来的。
**教训**:交替四轮的设计救了这次。只跑 A→B 就下结论的话,会把一个正确的优化否掉,
而且带着一套听起来很像回事的错误归因。⛔ 一个离群点 + 一套讲得通的机制 ≠ 结论;
**结论要能复现**。同 [[verify-before-prohibiting]]。
#### 真实收益:C(legacy) vs D(setbased),相邻两轮、同等条件
| 子场景 | C legacy | D setbased | 倍数 |
|---|---|---|---|
| impacted_tooth | 269.6s | 119.6s | **×2.25** |
| caries_no_filling | 358.2s | 171.5s | **×2.09** |
| hard_tissue_damage | 209.7s | 132.3s | ×1.59 |
| endo_no_rct | 243.2s | 169.7s | ×1.43 |
| development_eruption | 58.9s | 43.9s | ×1.34 |
| ortho_no_consult *(全口对照,同一条 SQL)* | 74.7s | 59.1s | *×1.26* |
| missing_tooth | 286.6s | 245.5s | ×1.17 |
| extraction_recommended | 51.0s | 45.6s | ×1.12 |
| gum_alveolar_lesion | 40.2s | 38.7s | ×1.04 |
| perio_no_srp *(全口对照,同一条 SQL)* | 128.2s | 130.1s | *×0.99* |
| jaw_cyst | 16.2s | 18.2s | ×0.89 |
两个对照组(逐字节相同的 SQL)给出 ×1.26 / ×0.99 → **环境噪音 ±25%**
超出噪音的真实收益集中在 impacted / caries / hard / endo 四条。
- **场景段 794s → 545s(×1.46,−31%)**
- **整轮 1,181s → 923s(×1.28,−22%)**
#### 端到端正确性
reason 行级 diff(323,796 条):A↔B 10 行 / B↔C 3 行 / C↔D 2 行 / A↔D 15 行。
差异**全部是新增(`>`),无一删除**,成簇按患者出现,且包含 `perio_no_srp@whole`
这种全口场景的行(两版跑同一条 SQL)—— 是患者跨过 ⑤f 到诊冷静期重新进池的**时间漂移**,
与形态无关。命中患者数 87,807 → 87,811 → 87,813 → 87,814 单调递增也印证这点。
**零删除 = setbased 从未丢掉 legacy 有的任何一行**,静默少召这个最怕的方向没有发生。
#### 结论
| 门 | 结果 |
|---|---|
| 正确性(SQL 层,固定 now 同一快照) | ✅ 11/11 零差异,行数逐个相同 |
| 正确性(画像消费方) | ✅ 2000 位零差异,p95 不劣化 |
| 正确性(端到端) | ✅ 差异仅时间漂移,零删除 |
| 收益 | ⚠️ 场景段 −31% / 整轮 −22%,**低于方案 §7 定的 ≥3× 试点门** |
方案 §0.1 预估「全轮改善 35~40%,不足以单独装进 2 小时」——**实测 22~31%,预估方向对、幅度偏乐观**
生产场景段 3h11m 按此推算 → 约 2h11m,整轮 3h41m → 约 2h50m,**仍然超 2 小时**
要进窗口还得叠加 §9(全口码那条独立线)或别的手段。
## 12. 测试机切到集合式(2026-08-30 03:2x)
`apps/pac-service/.env``PAC_GAP_VARIANT=setbased`,重启 pac-service。
**生产未动,仍是 legacy**(该变量在生产 .env 里不存在 → `gapVariant()` 返回 legacy)。
回退:把该行改回 `legacy`(或删掉),`bash deploy/deploy-prod.sh --no-pull`**不需要改代码、不需要发版。**
### ⛔ 切换时踩的坑:别手搓 docker compose
我为了省一次构建,手动跑了
`docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --no-deps --force-recreate pac-service`,
结果:
1. **compose 文件组合错了** —— deploy-prod.sh 用的是 `-f docker-compose.prod.yml`(+ managed override),
我多带了一个 `docker-compose.yml`,容器名从 `pac-pac-service-1` 变成 `pac-service`,端口冲突起不来
2. 删掉重来后又撞上**已知的 DB 口令坑**([[pac-prod-compose-db-cred-landmine]]):
手动 compose 不 source `.env`,`DATABASE_URL` 里的口令回落 → **P1000 认证失败 → crash-loop**
3. 测试服务挂了约 5 分钟,靠 `bash deploy/deploy-prod.sh --no-pull` 恢复(三项验证全过,数据完好)
deploy-prod.sh 的文件头注释本来就写着"不信 compose 的 diff 启发式"、整套逻辑就是为了避开这些。
**改环境变量也要走部署脚本** —— 它多花的那几分钟构建时间,买的是不出这种事。
## 13. 交互路径(详情页「刷新」)—— 用户当场感受得到的那条
`plan.controller``recomputeForPatient`**HTTP 端点**,走的就是本套召回 SQL 的
单患者路径(`scope.patientId`)。之前只验了批量和画像,漏了它。
批量慢是运维问题,**这条慢是体验问题**
测试机(585K 患者,200 位样本 × 11 子场景 = 2,200 次比对):
| | legacy | setbased |
|---|---|---|
| 单子场景查询 p50 | 5ms | 4ms |
| 单子场景查询 p95 | 20ms | 17ms |
| **一次「刷新」(11 条合计)** | **89ms** | **75ms** |
| 结果 | — | ✅ **零差异** |
本地(30K)同向:一次刷新 103ms → 71ms,零差异。
结论:**交互路径不劣化**。⚠️ 每条查询仍是先 legacy 后 setbased,顺序偏袒后者,
所以只下「不劣化」的结论,不宣称快 15%。
## 14. 生产与测试机的配置差异(推算收益时必须带上)
| | 生产(friday) | 测试机 |
|---|---|---|
| `PAC_RECALL_SUBSCENARIO_CONCURRENCY` | **未设 → 默认 1(串行)** | **4** |
| `PAC_GAP_VARIANT` | 未设 → legacy | setbased(2026-08-30 起) |
| `PAC_PLAN_BATCH_CONCURRENCY` | 未设 → 默认 8 | 8 |
⚠️ **−31% 是在并发 4 上量的,生产是串行。** 倍数能否原样搬过去**没有生产实测**
两个旁证指向同一区间:按「逐查询耗时求和」(更接近串行形态)算,C→D 是 1736.5s → 1174.2s = **−32%**;
整轮墙钟 −22%。所以 **−20~32% 是一个有依据的区间,不是一个点估计**
上生产前应先跑**只读对拍**(`verify-gap-equivalence --host=jvs-dw`,不写库、不改配置),
确认生产数据分布上同样零差异 —— 生产与测试机的数据快照不是同一份。
---
## 15. 🔴 生产实测:集合式在**全量规模上更慢**,已回退(2026-08-30 21:32)
**结论:集合式正确但不可用。生产保持 legacy + 并发4。**
### 时间线
| 时刻 | 动作 |
|---|---|
| 18:21~19:14 | 生产 **10 万患者子集**对拍:11/11 **零差异**,行数逐个相同 |
| 19:17 | 按门开启 `PAC_GAP_VARIANT=setbased` |
| 21:07~21:30 | 20:15 轮第 1 批出结果 → **判据失败** |
| 21:32 | 回退,生产恢复 legacy |
### 数据
| 子场景 | legacy(16:15) | setbased(20:15) | |
|---|---|---|---|
| missing_tooth | 8m13s | **30m42s** | **慢 3.7×** |
| endo_no_rct | 8m12s | 18m21s | 慢 2.2× |
| ortho_no_consult *(全口,两版同一条 SQL)* | 8m14s | 8m24s | *噪音* |
| perio_no_srp *(全口,两版同一条 SQL)* | 17m03s | 15m23s | *噪音* |
| **第 1 批墙钟** | **17m03s** | **30m42s** | **+80%** |
命中数全部仍在漂移带内(missing_tooth 79,874 / endo 72,640)——**正确性没有问题**
### 归因:集合式的代价跟「患者域」走,legacy 跟「候选行数」走
- `gap_scope` 要把**全域患者**的 resolved 牙位预聚合一遍;域越大,这份固定成本越贵
- legacy 的逐行相关子查询只在**候选行**上付费
所以规模一变,结论就翻转:
| 患者域 | missing_tooth |
|---|---|
| 本地 30K | 集合式快 1.46× |
| 测试机 585K | 集合式快 1.46×(相邻两轮 C/D) |
| 生产 10 万子集 | 集合式快 1.53× |
| **生产 113 万全量** | **集合式慢 3.7×** |
### ⛔ 教训:子集验证掩盖的不只是覆盖,还有**性能的规模拐点**
方案里写过「子集零差异 ≠ 全量零差异」,但那句话只想着**正确性覆盖**
真正被子集掩盖的是**性能**:集合式在 10 万上快 1.53×、在 113 万上慢 3.7×,
**方向都反了**
- 子集测正确性:有效(差异一旦出现就是真差异)
- 子集测性能:**无效**,除非能证明代价与规模线性 —— 集合式恰恰不是
- 测试机 585K 的收益也不能外推到生产 113 万,**两者隔着这个拐点**
### 保留了什么
- 代码留在 main,默认 legacy,不产生任何行为
- 对拍工具(`verify-gap-equivalence`)是本轮最有价值的产出,与形态无关:
`--self` / `--persona` / `--single` / `--conc` / `--subset` 五种模式,
后续任何 gap 口径改动都该先过它
- 集合式若要复活,前提是解决「代价随患者域增长」这一条 ——
例如按诊所/批次切分 scope,让每次预聚合只覆盖一小片
### 现状
生产 = **legacy + `PAC_RECALL_SUBSCENARIO_CONCURRENCY=4`**,场景段 50m25s、整轮 1h17m。
2 小时窗口的余量仍然偏紧(今日 14:15、18:15 两次跳轮),下一步应看**摄入 34 分钟**
**plan 预取 20 分钟**,而不是继续在场景段上使劲。
# 增量摄入的回看窗:为什么是 48 小时,能不能缩
> 结论先行:**不能缩,而且根因不在我们这边**。真正的解法是让 DW 提供「入仓时间」列。
> 测量日期 2026-08-31,生产 jvs-dw(113 万患者)。全程只读。
## 1. 现象
每轮增量(2 小时一轮)的账:
```
fetched = 248,112 行 其中 dup = 208,556(84%)是重复
实际只写 transactions = 16,448 / facts = 12,854
耗时 31~35 分钟,占整轮(约 1h57m)的 27%
```
直觉是「84% 白拉,把回看窗从 48h 缩到 24h 就能砍一半」。**测下来这个直觉是错的。**
## 2. 根因:游标跟的量与数据到达顺序无关
- 游标跟的是 **`updated_date`** —— **源 HIS 系统**记录被改动的时刻,≈ 事件发生时间
- 数据何时能被 PAC 查到,取决于 **DW 自己的 ETL**,与 `updated_date` 无关
于是必然存在「`updated_date` 比游标旧、但此刻才到达」的行 —— 游标无论怎么设都追不上。
**回看窗不是冗余,是对上游不可控延迟的唯一防线。**
最干净的证据(退费记录):
```
created_date 08-23 13:05
updated_date 08-23 13:13 ← 源系统 8 分钟内当场写完
received_at 08-25 12:39 ← PAC 47 小时后才看到
```
中间那 47 小时 `updated_date` 一动没动。
## 3. 落库滞后实测(7 天,281,760 条**增量**记录)
| 分位 | 滞后 |
|---|---|
| p50 | 2.39h |
| p95 | 5.80h |
| p99.9 | 15.39h |
| max | **47.43h** |
| 阈值 | 超过的行数 |
|---|---|
| >12h | 4,389 |
| >24h | **31** |
| >36h | **19** |
| >48h | **0** |
**按表拆开后,两类行为截然不同:**
| 表 | 条数 | max 滞后 | >24h |
|---|---|---|---|
| **refund** | 150 | **47.4h** | **27** |
| treatment | 48,880 | 42.2h | 4 |
| image | 48,346 | 19.8h | 0 |
| emr / diagnosis / recommendation | 60,748 | 15.5~15.6h | 0 |
| appointment / encounter / payment | 123,402 | 14.9~15.0h | 0 |
**召回真正依赖的临床表(diagnosis / emr / recommendation / encounter)7 天内最大滞后 15.5h,超 24h 一条没有。**
顶到 48h 边界的是 **refund**,而召回逻辑不看退费。
## 4. ⛔ 为什么仍然不能全局缩窗
1. 缩到 24h 会丢 **31 条**、缩到 36h 会丢 **19 条**。游标一旦推过去就是**永久漏拉**,
下一轮补不回来(见 [[incremental-ingest-history-gap]])。
2. **max = 47.43h 恰好顶在 48h 边界、>48h 为 0** —— 这是**截断的指纹**,不是"刚好够"。
真实分布的尾巴可能更长,我们只是观察不到。
> 🔴 **这个测量的方向是单边的**:它能证明「48h 绰绰有余」,**不能**证明「48h 不够」。
> 而现在数据显示边界被顶到了,连"绰绰有余"都谈不上。别拿单边结论去支撑双边决策。
## 4b. 证据链:确认是 DW 侧没有,不是我们没去问
**决定性判据**(只用 PAC 自己的数据,可复现):一行出现在第 N 轮,而它的 `updated_date`
在第 N−1、N−2… 轮的窗口里**就已经被覆盖** → 证明 DW 当时确实没有这行。
以那条 47.4 小时的退费为例(`updated_date=08-23 13:13`,`received_at=08-25 12:39`):
```
08-23 14:15 sync fetched=231,515 窗口下界 08-21 14:15 ✅ 覆盖 → 没拉到
08-23 16:15 sync fetched=235,101 ✅ → 没拉到
…(共 17 轮增量,全部 success)…
08-24 20:09 FULL fetched=4,914,444 ← 全表重读,不看游标 → 仍没拉到
08-25 02:22 FULL fetched=4,255,188 ← 再次全表重读 → 仍没拉到
08-25 12:15 sync fetched=213,108 窗口下界 08-23 12:15 → **拉到了**
```
**19 次查询(含 2 次把 DW 整个重读一遍)都看不见,第 20 次看见了。**
最后那轮的窗口下界是 08-23 12:15,记录是 13:13 —— **只剩 58 分钟余量**,再晚一轮就永久丢。
### 游标的真实语义(容易读错)
```ts
// cold-import.service.ts
// ⭐ cursor_after = run_start ISO(关键!不是 max(updated_date))
const ignoreCursor = options.incremental === false; // full: 忽略游标,全表重读
```
游标推进用的是**墙钟(本轮启动时刻)**,不是数据里的 `max(updated_date)`
每轮查询下界 = `上轮 run_start − 48h`
⚠️ 原注释的理由「run_start 之后 DW 任何写入,下次增量 `WHERE > run_start` 都能捞回」
**有漏洞**:过滤的是 `updated_date`,不是"DW 何时写的"。若 DW 写入时该行
`updated_date` 已早于 `(游标 − 48h)`,下次也捞不回来 —— 这正是漏拉的机制。
## 4c. 「DW 承诺 2 小时更新」与实测的差距
若承诺成立,滞后应封顶 ~4h(2h 刷新 + 2h 等下一轮)。实测(7 天 / 28.2 万条):
| 表 | 条数 | >6h | >12h | >24h |
|---|---|---|---|---|
| **refund** | 150 | **18.7%** | **18.7%** | **27** |
| **recommendation** | 2,631 | **18.9%** | 4.1% | 0 |
| image | 48,348 | 7.8% | 1.3% | 0 |
| diagnosis | 32,763 | 7.5% | 2.1% | 0 |
| treatment | 48,884 | 6.9% | 2.1% | **4** |
| emr | 25,362 | 6.2% | 1.7% | 0 |
| encounter | 30,490 | 2.5% | 1.8% | 0 |
| payment | 8,548 | 2.5% | 0.2% | 0 |
| appointment | 84,380 | 2.3% | 1.0% | 0 |
**中位数守住了(p50 2.39h),尾巴没守住。****分表差异极大**
(appointment 2.3% vs recommendation 18.9% vs refund 18.7%)——
指向 DW 内部不同表走不同的 ETL 链路/调度,不是整体延迟。
`recommendation` 是召回的第二信号源,18.9% 超 6 小时,直接影响召回时效。
## 4d. 🔴 数据时效与丢失边界是**同一个数**
```
PAC 能拿到的数据:最坏 T+2(47.43h 实测)
丢失边界 :48h
```
不是"DW 最慢两天",而是**比 T+2 更晚的根本进不来**。所以:
- **能看见的迟到**:0~48 小时
- **看不见的迟到**:>48 小时 —— 从每轮窗口掉出去,**永远不会知道它存在过**
### 漏掉的数据不会自动修复
`full:` 全量补摄忽略游标、全表重读,机制上能捞回所有迟到数据。
**但生产的全量补摄是人工临时跑的,没有定时任务**:
```
08-21 ×4 08-22 ×2 08-24~08-26 ×5(批量补摄期间)
最近一次:08-26 07:08
```
**08-26 之后漏掉的至今还漏着。**
### 两个未采纳的兜底(记录备查)
| 方案 | 作用 | 代价 |
|---|---|---|
| **定期全量补摄**(如每周一次凌晨) | 把"可能永久漏"变成"最多漏一周" | 单次拉取 300~500 万行,需挑窗口 |
| **跟 DW 对账**:要某历史时段的行数与 PAC 库比对 | **唯一能回答"到底漏了多少"的办法**,其余都是推断 | 只读,需 DW 配合提供计数 |
## 5. 根治办法(不在我们这边):请 DW 提供入仓时间列
**DW 当前一个入仓时间字段都没有。** 十张表的时间列只有 `created_date` / `updated_date`,
全是源 HIS 的;没有 `etl_date` / `load_time` / `dw_insert_time`,也没有分区列
(`rq` 是业务日期,只到天,已排除)。
若 DW 增加一列由其 ETL 写入、只增不改的时间戳:
| | 现在 | 有入仓时间之后 |
|---|---|---|
| 游标语义 | 事件时间,与到达顺序无关 | **在到达顺序上单调** |
| 漏拉风险 | 靠窗口够大来赌 | **结构上不可能** |
| 每轮 fetched | 248,112 行(84% 重复) | 约 16,000 行 |
| 摄入耗时 | 31~35 分钟 | 预计 5~10 分钟 |
## 6. 我们这边的缓解手段(未采纳)
**按表分设回看窗**:临床表 24h、退费/支付 48h+。拉取量约降一半,摄入 31m → ~20m。
**没做,理由**:
- 引入「每张表一个窗口」的配置复杂度,且必须持续盯着 DW 各表的延迟特性有没有变
- `treatment` 有 4 条 >24h(最大 42.2h),它是判「已治疗」的关键表,漏了会**误召**,
需要单独定窗或先查清成因
- 在根治方案可能拿得到的情况下,性价比不高
## 7. ⛔ 取样陷阱(第一版测错了)
第一版按 `received_at > now() - 7 days` 一刀切,得出 p50 滞后 **43,889 小时(5 年)**
max 14.7 年的荒谬结果。原因:窗口内混进了 08-24~08-26 的 **5 次 `full:` 全量补摄**,
那些行的 `updated_date` 是几年前的历史数据。
**在既有增量又有补摄的系统里,按时间窗取样是不成立的**,必须按事件来源限定
(`sync_logs.triggered_by LIKE 'sync:%'`)。修正后样本从 689 万降到 28 万,结论才成立。
## 8. 复现方式(只读,约 9 秒)
```sql
SELECT count(*), percentile_cont(0.999) WITHIN GROUP (ORDER BY lag_h), max(lag_h),
count(*) FILTER (WHERE lag_h > 24)
FROM (
SELECT EXTRACT(EPOCH FROM (t.received_at - (t.raw_payload->>'updated_date')::timestamptz))/3600 AS lag_h
FROM patient_transactions t JOIN sync_logs s ON s.id = t.sync_log_id
WHERE s.triggered_by LIKE 'sync:%' AND s.started_at > now() - interval '7 days'
AND t.raw_payload ? 'updated_date'
) x WHERE lag_h >= 0;
```
# 误召个案
| # | 患者 | 病历号 | 诊所 | 信号日期 | 医生 | 牙位 | 诊断信号 | 程序判定 | 临床真实 | 定性 | 处理 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | 宋志宏 | TS0M001982 | 瑞泰学前街 | 2026-04-07 | 孙红胜 | 46;36;37 | 缺失牙 | 有缺失牙诊断,无对应修复治疗(复查不算治疗),故召回 | 已完成种植修复,本次为定期复查 | 最新诊断仍记为缺失牙;复查不算治疗 | 已修复:种植复查视为修复体在位,同类目缺口解除 |
| 2 | 王丽芳 | TS0M013040 | 瑞泰学前街 | 2026-02-06 | 段路路 | 42 | 残根 | 判成缺失牙,推荐**种植治疗**,分配也落在种植列 | 是残根,医生计划残根拔除术,尚未执行 | 残根的编码被并进了缺失牙 | 已修复:改判为**拔牙治疗** |
| 3 | 孙海燕 | TS0M012582 | 瑞泰学前街 | 2026-01-10 | 段路路 | 27;45 | 缺失牙 | 有缺失牙诊断,无对应修复治疗,故召回 | **确实缺牙未修复**,医生给了种植和固定桥两套方案,一套都没执行 | **召回正确**,是客服误标为已治疗 | 无需修复算法;同患者 16;18;28;47 残根误判已随第 2 例一并修复 |
| 4 | 过碧霞 | TS0B006258 | 瑞泰学前街 | 2026-04-07 | 刘海燕 | 12;13、21 | 缺失牙、残冠 | 有诊断,无对应治疗,故召回 | 当天即做「加牙」,三天后戴上颌活动义齿 | 「加牙」不在治疗名词表里,整条治疗记录没入库,系统看不到这次治疗 | 已修复:补上「加牙」等词 |
| 5 | 杨九妹 | TS0M013899 | 瑞泰学前街 | 2026-03-27 | 蒋亚萍 | 上颌 11-27 共 14 颗 | 缺失牙 | 有缺失牙诊断,无对应修复治疗,故召回 | 医生开了上下颌两副活动义齿,**只做了下颌**(收费「半口×1、义齿 8 颗」,戴牙处置写「下颌」);上颌无任何治疗与收费 | **召回正确**,是客服误标为已治疗 —— 患者确实刚做完义齿,但做的是下颌 | 无需修复算法 |
| 6 | 王志荣 | TS0K051842 | 瑞泰学前街 | 2026-01-16 | 刘海燕 | 下颌 31-47 共 14 颗 | 缺失牙 | 有缺失牙诊断,无对应修复治疗,故召回 | 全口义齿 2024-09-13 已在本院做好;本次只是上颌松了做重衬,检查所见写着「上下颌吸附性义齿修复,**下颌固位可**」 | 复诊时医生例行重记了一次全口缺牙诊断,系统以最新诊断为准,两年前做的修复被视为过期 | 已修复:同一份病历的检查所见按颌判出义齿存在(「上下颌吸附性义齿修复」),该颌的缺牙位即视为已被义齿覆盖 |
| 7 | 曹解初 | TS0M007481 | 瑞泰学前街 | 2026-04-15 | 王杏松 | 24 颗(全口除第二磨牙) | 缺失牙 | 有缺失牙诊断,无对应修复治疗(复查不算治疗),故召回 | 全口种植已戴牙 5 个月,本次为定期复查,检查所见「种植体冠在位」 | 同第 1 例;更极端的是**种植手术记录在病历系统里根本不存在**,复查是唯一能证明已修复的记录 | 已修复:种植复查视为修复体在位 |
| 8 | 陈菊芬 | TS0K052459 | 瑞泰学前街 | 2026-03-06 | 王杏松 | 35;36;37;44;45;46 | 缺失牙 | 有缺失牙诊断,无对应修复治疗(复查不算治疗),故召回 | 种植修复早已完成,此后五次种植复查,最近一次牙位与召回完全重合 | 同第 1 例。客服另备注「6 月在总院才复诊过」—— 但**总院是集团的回访中心,不看诊**。患者是被总院回访了,不是去总院看了病 | 已修复:种植复查视为修复体在位 |
| 9 | 周燕芬 | TS0M013273 | 瑞泰学前街 | 2026-02-26 | 孙红胜 | 上颌 11-27 共 14 颗 | 缺失牙 | 有缺失牙诊断,无对应修复治疗,故召回 | **上颌活动义齿在位且良好** —— 检查所见「见活动义齿,基托边缘密合,伸展范围良好,固位力良好,无压痛」;本次来看的是下前牙 | 同第 6 例:义齿是以前做的,系统里没有对应治疗记录;复诊时重记了一次缺牙诊断,按「有诊断、无治疗」判定 | 已修复:检查所见写着修复体在位时,视为该牙已有修复 |
| 10 | 张佳飞 | TS0M012229 | 瑞泰学前街 | 2025-12-22 | 蒋亚萍 | 25 | 缺失牙 | 有缺失牙诊断,无对应修复治疗,故召回 | **25 号牙好好的** —— 医生检查所见写「25 牙龈轻度萎缩」;真正缺的是 24,且「口内未见缺牙间隙」,间隙已自然合拢,无需修复 | 缺失牙位来自影像 AI 读片,牙位判偏一位 | 待定 |
| 11 | 李钟瑜 | TS0M006971 | 瑞泰学前街 | 2026-02-12 | 王静 | 31 | 缺失牙 | 有缺失牙诊断,无对应修复治疗,故召回 | **31 拔除后已做活动义齿并戴牙**,本次是拿义齿来抛光 —— 主诉「下颌活动义齿粗糙要求调磨」,检查所见「隐形义齿完好」 | 本次同期唯一的治疗名是「抛光」,而**抛光归在预防、不算治疗** | 已修复:牙已拔除,缺失牙位上的抛光对象只能是修复体,视为修复体在位 |
| 12 | 殷和平 | TS0M012233 | 瑞泰学前街 | 2025-12-22 | 蒋亚萍 | 17;27 | 缺失牙 | 有缺失牙诊断,无对应修复治疗,故召回 | 初诊全口检查查出六组问题、开了整套计划,17;27 开的是「种植修复或活动义齿修复」,**一次没做**;患者只做了自己来看的 45 号牙根管,此后七个多月未再就诊 | **召回正确**,是客服误标为已治疗 —— 她做完的是根管,不是缺牙修复 | 无需修复算法(病史记有放疗史,属种植禁忌,是否推进由诊所判断) |
| 13 | 薛希明 | TS0K018752 | 瑞泰学前街 | 2026-05-21 | 蒋亚萍 | 14;15;16;17;25;26;27 | 缺失牙 | 有缺失牙诊断,无对应修复治疗,故召回 | **活动义齿一年多前已戴**(覆盖全部召回牙位);本次是主诉「上颌假牙松2周」来做**卡环加力**,检查所见「活动义齿修复,义齿卡环松,余无不适」 | 复诊重记了一次缺牙诊断,把一年多前的戴牙挡在时间口径外;而当次的「卡环加力」不在治疗名词表里,整条治疗没入库,系统看这天只有诊断没有治疗 | 已修复:补上「卡环、基托、重衬、假牙」等义齿维护词 |
| 14 | 陆雪 | TS0K090243 | 瑞泰学前街 | 2025-09-01 | 王杏松 | 36;37;46;47、17、41 | 缺失牙、残根 | 有诊断,无对应治疗,故召回 | 只来过一次,做的是检查加拍片;医生开了「种植修复 46;47;36;37」「残根拔除术 17」「转牙周科」,**一项都没做**,当次治疗只有口腔卫生宣教 | **召回正确**,是客服误标为已治疗。另:41 号牙那条是**影像 AI 误判** —— 同一次就诊医生诊断 41 有牙周病(牙还在且在治它),AI 却判它缺失 | 无需修复算法;影像 AI 牙位待定(同第 10 例) |
| 15 | 茅威 | TS0M010629 | 瑞泰学前街 | 2025-09-27 | 段路路 | 23、63 | 缺失牙、乳牙滞留(均为影像 AI) | 有诊断,无对应治疗,故召回 | 医生检查所见写着「**全口牙列完整,未见缺牙**」,23 就在牙位列表里;滞留的那颗乳牙**当天已拔除** | **影像 AI 识别不准** —— 23 并不缺失,滞留乳牙也已处理 | 待定(同第 10、14 例) |
| 16 | 陈惠英 | TS0K028757 | 瑞泰学前街 | 2026-05-20 | 蒋亚萍 | 34;35;36;37 | 缺失牙 | 有缺失牙诊断,无对应修复治疗,故召回 | **四颗种植一年多前已全部戴牙**(中间 37 植体失败还重做过一次);本次是主诉「种植戴牙1年按约复查」来复查,检查所见「冠边缘密合,牙龈未见异常」,当次做的是**调合、抛光** | 复诊重记了一次缺牙诊断,把两年的种植治疗史挡在时间口径外;而当次的「调合,抛光」写在一起,治疗名词表只认单个词、不认组合,整条治疗没入库 | 已修复:补上「调合、调颌」组合词 |
| 17 | 童然夫 | TS0M013276 | 瑞泰学前街 | 2026-02-26 | 刘海燕 | 13;16;17;21;22;23;24;25;26;27 | 缺失牙 | 有缺失牙诊断,无对应修复治疗,故召回 | **上下颌本来都有活动义齿** —— 检查所见「牙缺失,**口内活动义齿修复**,上颌义齿卡环紧,不易取戴,下颌义齿无法完全就位」;下颌那副不能用了、医生当次重做,上颌那副在位,只是卡环紧要调整 | 同第 6、9 例:**算法按「有诊断、无治疗」判定没错,临床事实是上颌义齿已在位**,只写在检查所见里 | 已修复:同第 6 例,按颌判出上颌有义齿(「上颌义齿卡环紧」),上颌缺牙位视为已被义齿覆盖 |
| 18 | 王作荣 | TS0M002910 | 瑞泰学前街 | 2026-04-13 | 蒋亚萍 | 34 | 缺失牙 | 有缺失牙诊断,无对应修复治疗,故召回 | **种植修复一年多前已完成戴牙**;本次是主诉「**种植戴牙3个月余按约复查**」来复查,检查所见「冠边缘密合,牙龈未见异常」,当次只做了口腔卫生宣教 | 复诊重记了一次缺牙诊断,把整条种植治疗史挡在时间口径外;当次唯一的动作是**宣教(预防类,不算治疗)**,而「已治疗」的凭证只写在主诉和现病史里,系统原本不看这两个字段 | 已修复:本次动作是预防/复查类时,改为再看主诉与现病史 |
| 19 | 丁一南 | TS0K033117 | 瑞泰学前街 | 2026-01-05 | 陈洋洋 | 全口 21 颗 | 缺失牙 | 有缺失牙诊断,无对应修复治疗(复查不算治疗),故召回 | **全口种植修复 2024 年做到 2025 年 9 月已全部完成**;本次是主诉「右下后牙种植戴冠术后3月余,现常规复查」,检查所见 21 颗「可见全瓷冠修复,边缘密合邻接良好,咬合合适」,当次做的就是**种植复查** | 同第 1 例:最新诊断仍记为缺失牙;复查不算治疗 | 已修复:种植复查视为修复体在位 |
| 20 | 王祖妹 | TS0B006805 | 瑞泰学前街 | 2026-04-25 | 蒋亚萍 | 46 | 缺失牙 | 有缺失牙诊断,无对应修复治疗,故召回 | **种植 4 年前已做好戴牙**;本次是主诉「**种植戴牙4年余按约复查**」专程来复查,现病史「4年前在本院种植戴牙,无不适,今按约复诊」,检查所见「冠边缘密合」;当次只做了转洁牙中心洗牙 | 那副种植修复做在**系统接入之前**,库里只有本次这一天的记录,治疗事实根本不存在;「已治疗」的凭证只写在主诉和现病史里 | 已修复:本次动作解不开缺口时(含洗牙、宣教、复查),改为再看主诉与现病史 |
| 21 | 季炎萍 | TS0B010543 | 瑞泰学前街 | 2026-03-19 | 王杏松 | 下颌 31~47 共 14 颗 | 缺失牙 | 有缺失牙诊断,无对应修复治疗,故召回,并推**种植** | **下颌活动义齿已戴用数年**;本次是主诉「下颌活动义齿戴牙数年余」因压痛来修整,检查所见「义齿密合良好,边缘过长」,处置写着「调磨过长边缘,抛光」 | 「调磨」不在治疗名词表里,整条治疗没入库 —— **她库里一条治疗事实都没有**,系统看成从没治过。客服反馈的是「治疗项不准·种植」,根子在这里 | 已修复:调磨等姑息处置改为记入治疗史(仍不算已治疗);并按主诉自证修复体在位 |
| 22 | 陶美玉 | TS0K070812 | 瑞泰学前街 | 2026-03-26 | 王杏松 | 下颌 31~47 共 14 颗 | 缺失牙 | 有缺失牙诊断,无对应修复治疗(复查不算治疗),故召回,并推**种植** | **下颌种植覆盖义齿已戴 2 年**;本次是主诉「种植复查」专程来复查,现病史「患者下颌种植戴牙2年后复查」,检查所见「下颌覆盖牙齿在位,无松动,就位可,边缘贴合;杆卡无松动」,当次做的是种植复查加调合抛光 | 同第 1 例:最新诊断仍记为缺失牙;复查不算治疗 | 已修复:种植复查视为修复体在位;处置里的「调合」也已补词 |
......@@ -10,6 +10,9 @@
* 详见 `apps/pac-docs/content/docs/architecture/data-ingestion.mdx` §三。
*/
import { z } from 'zod';
// ⭐ 单一真理源:「该拔不是该补」的诊断词表 —— 分配矩阵的 extraction 标签与
// 召回目标的 surgical 主类目共用同一份词,否则「潜在治疗项目」和「目标」会各说各话。
import { EXTRACTION_NAME_KEYWORDS } from './potential-label-rules';
// =============================================================
// 诊断码(PACDiagnosisCode)— 借 ICD-10 K00-K14 大类 + 业务码 + 推荐码
......@@ -303,10 +306,25 @@ export const EXTERNAL_TREATMENT_VISIT_NEG_RE =
/// "牙齿缺少/缺失/缺牙"也错标成 K00(全库 1044 例)→ 冒出"萌出异常"误召(关平 BJ0U007377 牙25:
/// 残根拔除后缺失,宿主标 K00 → 误召萌出处置)。区分符=「先天」:有先天 → 真 K00(发育障碍),
/// 无先天的缺牙义 → 后天缺失 = K08。萌出/多生牙等其它 K00 不动。
///
/// 残根/残冠纠偏(2026-08 王丽芳 TS0M013040 牙42):源 ICD 用 **K08.3 牙根残留**(医学上没标错),
/// 但 std_code 截前 3 位后跟 K08.1 后天缺失挤进同一个 K08 → 目标文案变成"邀约启动缺失牙修复
/// (种植/桥/义齿)",而临床路径是**拔除**或根管+桩核冠。K03 的 categories 含 surgical、
/// 目标文案本就写着"残根/残冠 → 充填/嵌体/拔除修复",且 POTENTIAL_LABEL_RULES 的
/// `K03 + EXTRACTION_NAME_KEYWORDS → extraction(拔牙治疗)` 正等着这批名字 —— 归 K03 才对得上。
/// 口径一致性:医生**没填** std_code 时按中文名映射本来就落 K03(全库 97,970 条);
/// 填了 K08.3xx 的这 932 条反而归错,本规则把少数派拉回多数派。
/// ⛔ 区分符=「缺失/缺牙/缺损」:复合诊断("牙列缺损,残根"/"牙缺失;#44残根",全库 325 条)
/// 两个病挤在一个 name 里,翻 K03 会丢掉缺牙那一半 → 保持 K08(基线分更高,且 K08 的已解决
/// 判定是结构家族宽集、含拔除,患者去拔了照样解除)。那是源头把多诊断挤成一行的问题,不在此修。
/// 源码不丢:transaction.rawPayload 原样留着 stdCode(K08.300x002 / K08.302 / K08.300),随时可倒查。
export function reconcileDiagnosisCode(code: string | null, nameZh: string | null): string | null {
if (code === 'K00' && nameZh && /缺(牙|失|少)/.test(nameZh) && !/先天/.test(nameZh)) {
return 'K08';
}
if (code === 'K08' && nameZh && /残根|残冠|无法保留|不能保留/.test(nameZh) && !/缺失|缺牙|缺损/.test(nameZh)) {
return 'K03';
}
return code;
}
......@@ -403,6 +421,351 @@ const STRUCTURAL_DX_CODES = new Set<string>([
*
* 单一真理源:召回 scenario 的 ⑤a 排除闸 + 牙位事实 oracle 对账 共用此函数,口径不漂移。
*/
/**
* 「复查/复诊」→ 它蕴含的治疗类目(**证据,不是治疗**)。
*
* ── 为什么需要 ──
* 宋志宏 TS0M001982 牙46;36;37 三颗种植冠在位、正在按约复查,却被判「缺失牙未启动修复」。
* 他那次就诊里能证明修复体存在的四处表述(诊断「种植术后」/ 本次治疗「种植复查」/
* 检查所见「种植冠无松动」/ 主诉「种植戴牙术后1年余」)PAC 一处也没接住 ——
* `review` 类目被**刻意**排除在 [[STRUCTURAL_RESOLVER_CATEGORIES]] 之外(见其上方注释)。
* 那个排除是对的:review 是杂物抽屉,生产带牙位的 4.4 万条里「观察」11,165、
* 「观察,必要时拔除」928、「无治疗」623 —— 这些恰恰说明**还没治**,整类放进 resolver 会误销。
*
* ── 判据:复查的**对象**必须先存在 ──
* 「种植复查」蕴含种植体在位,「保持器复诊」蕴含正畸做过 —— 这是逻辑蕴含,不是统计推断。
* 生产验证:带牙位的「种植复查」覆盖 3,893 个牙位,其中 3,434(88.2%)在**同一颗牙**上
* 找得到 implant/prosthodontic 的 actual 治疗;患者级 95.2%。剩下 11.8% 正是本规则要救的
* (外院种的 / 摄入窗口之前 / 治疗记录漏牙位)。
*
* ⛔ **必须同时匹配「治疗词 + 复查/复诊」**:「正畸复诊」(在做正畸)与「正畸会诊」(还在谈)
* 只差一个字,语义相反;生产各 35 / 103 条。光匹配「正畸」会把会诊咨询一起网进来。
* ⛔ **必须按类目匹配**,不能一律放行:生产实测 19 条候选里 9 条是跨类目错配 ——
* 「牙周复查」去解 K02 龋齿(洗牙不补龋,4 条)、「种植复查」去解 K06 牙龈疾患(1 条)、
* 「正畸复查」去解 K01 阻生牙(1 条)。消费方须用 [[resolverCategoriesFor]] 过滤本表。
* ⛔ 「观察 / 暂观 / 无治疗 / 初诊检查 / 方案沟通」等**刻意不收** —— 它们是"还没治"的证据。
*
* 消费方:potential-treatment-gap.sql 的 resolvedTeethSql(牙位级)。
* 生产实测无一条落在 K05/K07 全口场景,故全口路径(gapWhere 的 NOT EXISTS)不接本表。
*/
export const REVIEW_IMPLIES_TREATMENT: ReadonlyArray<{
/// PG 正则(用于 `subtype ~ pattern`)
pattern: string;
/// 命中即视为该类目治疗已完成
category: PACTreatmentCategory;
why: string;
}> = [
{ pattern: '种植[^,,;;]*(复查|复诊)', category: 'implant', why: '种植体不存在就没有种植复查' },
{ pattern: '(修复|冠|桥|义齿|戴牙)[^,,;;]*(复查|复诊)', category: 'prosthodontic', why: '修复体在位才谈得上复查' },
{ pattern: '(正畸|保持器)[^,,;;]*(复查|复诊)', category: 'orthodontic', why: '矫治/保持阶段 ≠ 正畸会诊咨询' },
{ pattern: '牙周[^,,;;]*(复查|复诊)', category: 'periodontic', why: '牙周复查蕴含基础治疗做过' },
];
/**
* 「缺失牙位上的抛光」= 修复体/种植体在位的**证据**(不是治疗本身)。
*
* ── 判据:那颗牙已经不在了,抛的只能是修复体 ──
* 李钟瑜 TS0M006971 牙31:2025-03-27 拔除 → 2025-06-26 活动义齿戴牙 → 2026-02-12 复诊
* 重记「牙齿缺失 31」+「抛光 31」。真实的修复证据(戴牙)早于新诊断,被时间门挡在外面;
* 唯一落在信号当日的证据是那条抛光,而它归 `preventive` —— 不在结构家族 resolver 里 → 误召。
* 31 号牙 2025 年就拔了,**牙不存在,抛光的对象只能是那副义齿** —— 逻辑蕴含,不是统计推断。
*
* 生产验证:裸抛光落在 K08 缺失牙位上共 101 个(患者×牙位),其中 94 个(93.1%)在**同一颗牙**
* 找得到 prosthodontic/implant 的 actual 治疗,患者级 98.0%。剩下 7 个正是本规则要救的
* (外院修复 / 摄入窗口之前 / 修复记录漏牙位 / 像李钟瑜这样被重记诊断挡在时间门外)。
*
* ⛔ **只收"光秃秃一个抛光"**(容尾标点)。生产 36 万条带牙位的含抛光记录里,绝大多数是
* ①「全口龈上洁治,抛光」等洁治流程(periodontic,牙位动辄 28 颗)——针对天然牙,放行会把
* 洗过牙的患者所有缺牙位一次性解光;②「树脂充填…修整抛光」「试戴全瓷冠…抛光」等**别的治疗
* 里的一个步骤** —— 它们本来就带 restorative/prosthodontic 类目,本来就解得开,无须本表。
* 真正落单的只有裸「抛光」→ preventive 这一支(带牙位 2,405 条)。
* ⛔ 「抛光,涂氟 / 抛光+涂氟」**刻意不收** —— 涂氟是全牙列预防处置,针对天然牙,不指向修复体。
* ⛔ **牙位数上限**:裸抛光里仍有 56 条挂了 7~32 颗牙,那是洁治语境漏进裸词的,按"全口"丢弃;
* 针对某颗牙的操作不会一次写满一口牙。
* ⛔ **只对缺失牙(K08)开**:牙还在的时候抛光就是抛天然牙(除渍),什么也不蕴含。
* 由 [[GAP_FLAGS_BY_PRIMARY]] 的 polishImpliesRestoration 闸控制,不是全场景通用。
*
* 消费方:potential-treatment-gap.sql 的 resolvedTeethSql(牙位级)。
*/
export const MISSING_TOOTH_POLISH_EVIDENCE = {
/// PG 正则:归一后恰为「抛光」(容前后空白与尾标点),不含任何其它术语
subtypePattern: '^\\s*抛光\\s*[。.,,;;、]?\\s*$',
/// 只在这个类目下认(别的类目里的抛光都是某个治疗的步骤,本来就解得开)
category: 'preventive' as PACTreatmentCategory,
/// 牙位数上限 —— 超过即视为洁治/全口语境,丢弃
maxTeeth: 6,
why: '牙已缺失,抛光的对象只能是修复体/种植体',
} as const;
/**
* 「病历自由文本自证已治疗」—— **补充举证路线**,只在本次动作是预防/复查类时才启动。
*
* ── 大前提不动 ──
* 最新诊断永远第一位:诊断了就是机会。⛔ **不用「前有治疗、后有诊断」去翻案** ——
* 那只是一条猜疑链(修复体会失败、会脱落、会需要重做,旧治疗不能证明今天还在位)。
* 本表要做的是把**诊断之后、本次就诊内**的动作判得更有说服力:
* · 本次动作是**治疗类** → 现有 resolver 已正确处理,本表不参与
* · 本次动作是**预防/复查类** → 语义模糊(可能是"修复体做完后的维护",也可能真是没治)
* → 再审同一份病历的自由文本,找"已进入治疗"的证据
* 这就是「因治未治」判定在预防类动作上的补全。
*
* ── 为什么不复用治疗名那几张词表(实测,不是保守)──
* ① 覆盖不足:REVIEW_IMPLIES_TREATMENT 要求「治疗词 + 复查/复诊」,而真实主诉一半以上不带
* 「复查」二字 —— 「种植戴牙3月余」「右侧后牙种植戴牙后一年」「全口假牙1年半前于我院修复」
* 全都漏掉。主诉的语义是"这次来的缘由是某治疗之后多久",时间跨度本身就蕴含那个治疗已完成。
* ② 时态/情态不同:treat_plan 里的词一定是**已发生**,主诉里的词可能是**诉求** ——
* 生产真实串「要求窝沟封闭」「定期复查」(同次治疗名却写着"未行处置,建议择期种植修复")。
* 🔴 ③ 有一条致命反例,就在王志荣 TS0K051842 自己身上:
* 2024-06-14 现病史「下颌活动牙齿修复后牙龈反复疼痛,**要求拔除口内剩余牙齿后全口义齿修复**」
* 2026-01-16 现病史「**全口假牙1年半前于我院修复**,1月来上颌假牙易松动脱落」
* 两句字面都含「义齿/假牙 + 修复」,前者是**将来要做**、后者是**已经做了**,语义相反。
* 直接把治疗名词表扫到现病史上,会在义齿存在之前就判定已修复,**而且不报错**。
* ⛔ 所以正面词必须独立成表;否定侧(见 [[TREATED_EVIDENCE_INTENT_EXCLUDE_RE]])才可以复用
* 「建议/推荐/考虑/择期」那套语义 —— 剥的是"未发生",跨字段一致。
*
* ── 判据:修复体词 ∧ 完成标记 ∧ ¬未发生标记 ──
* 刻意**不要求先后顺序**(两个条件分别匹配整段文本),因为真实语序两种都有:
* 「全口假牙1年半前于我院修复」(标记在词后) / 「9年前外院种植牙冠修复」(标记在词前)。
* 代价是丢了段内局部性,靠 INTENT_EXCLUDE 兜。
*
* ⚠️ 失败方向只有一个:**少召**(只往 resolved 里加牙位,永不新增召回)。而少召不报错,
* 一线只会觉得"系统没提醒过"。所以词表宁紧勿松,上线前要把命中差全量导出人工过一遍。
*
* 消费方:potential-treatment-gap.sql 的 resolvedTeethSql —— 召回与画像共用(口径必须一致)。
*/
/// 动作牙位不得跨上下颌 —— 一副修复体不跨颌。生产实测:陈春洪 TS0K070402 主诉明说
/// 「右上后牙种植戴牙」,那次宣教的牙位却写了 16;17;27;41;42;46;47,跨了四个象限,
/// 会把下前牙 41;42 一起误销。按牙位数设上限是拟合数据,按颌切才有临床依据。
export const TREATED_EVIDENCE_SINGLE_ARCH_ONLY = true;
/// 扫哪些 emr_record 字段(**扩展点**:以后要加 exam_findings 检查所见,改这里即可)。
/// ⛔ 暂不收 exam_findings:它是多牙位混合描述(「上颌固位可,下颌固位稍差」),
/// 整条牙位一起解会误销该召的那一半 —— 那条路要先解决段内按颌切分,不在本表解决。
/// ⛔ 不收 disposal(处置):生产实测它夹带 `toothPositionBak":"7xgAAB+LCAA…"` 这类 base64,
/// 会参与正则匹配并制造假命中(Jing Dong BJ0U007054 就是这么中的)。要收得先剥字段。
export const TREATED_EVIDENCE_EMR_FIELDS = ['illness_desc', 'pre_illness'] as const;
/// 启动条件不是硬清单,而是「本次动作**解不开这个场景的缺口**」——
/// 即 category ∉ 该场景的 resolverCats。硬写 ['preventive','review'] 会漏:
/// 王祖妹 TS0B006805 牙46 那次的动作是「转洁牙中心全口牙洁治」(periodontic),
/// 主诉写着「种植戴牙4年余按约复查」,却因类目不在清单里而不触发。
/// 牙周/洗牙对缺牙缺口同样是"什么也没解决",判据应当一致。
/// (生产实测放宽后只多 3 条命中,形状与王祖妹一致,全部正确。)
export const treatedEvidenceTriggersFor = (
resolverCats: readonly string[],
): readonly PACTreatmentCategory[] =>
(Object.keys(PACTreatmentCategories) as PACTreatmentCategory[]).filter(
(c) => !resolverCats.includes(c),
);
/// ⛔ 天然批量的类目:牙周洁治/正畸的牙位是**全口批量**写的(一条记录挂 28 颗),
/// 不表达"针对这颗牙",拿它当牙位锚会大面积误销 → 这两类额外加牙位上限。
/// 预防/复查类不设上限:那些牙位是医生针对性写的,且全口种植修复复查确实会写满一颌
/// (吴庆安 12 颗 / 高惠霞 14 颗 / 周根娣 15 颗,实测都是对的)。
export const TREATED_EVIDENCE_BATCH_CATEGORIES = ['periodontic', 'orthodontic'] as const;
export const TREATED_EVIDENCE_BATCH_MAX_TEETH = 8;
/// 修复体词 → 它蕴含的治疗类目(消费方须用 resolverCategoriesFor 过滤,跨类目不放行)
export const TREATED_EVIDENCE_RESTORATION_TERMS: ReadonlyArray<{
pattern: string;
category: PACTreatmentCategory;
why: string;
}> = [
// 🔴 ⛔ **只认「戴牙」,不认裸「种植」** —— 种植是多阶段过程(一期手术→二期→取模→戴牙),
// 只有戴牙才等于修复完成。生产实测裸「种植」放行会误销掉最该召的那批:
// 「种植体拔除后两个月」(BJ0E015462) /「种植体及牙冠一起脱落」(SC15894) /
// 「三周前行种植手术,今来复查」(SH0Q019876) /「拔牙后三个月,种植检查」(BJ0V001864) /
// 「转诊种植科」(TS0M004268) /「种植导板设计」(SZ0Y007308) /「种植前检查」(SH0L009117)。
// 「戴牙」归 prosthodontic 而非 implant:装戴修复体本身就是修复动作,种植冠亦然;
// 两者都在 STRUCTURAL_RESOLVER_CATEGORIES 里,对缺牙缺口判定无差别,不必强分。
{
pattern: '戴牙|假牙|义齿|修复体|烤瓷冠|全瓷冠',
category: 'prosthodontic',
why: '戴牙 = 修复完成(一期/二期/取模都不算);义齿/修复体在位才谈得上戴了多久',
},
];
/// 完成标记 —— 时间跨度 / 按约 / 术后 / 修复体的症状。
/// 症状类(脱落/松动/折断/压痛)同样蕴含"东西在那儿":不存在的修复体不会松、不会掉。
/// 🔴 ⛔ **不收症状词**(脱落/松动/折断)。原先收了,理由是"不存在的东西不会掉" ——
/// 那个推理只证明它**曾经**存在,不证明它**现在**在位。而对「缺失牙未修复」这个判定,
/// 脱落恰恰等于需要重做 = 该召回。生产实测误销:「前牙牙冠脱落数日」「金属固定桥松动」。
/// 量词含「数/多/几」:真实主诉大量写「戴牙**数年余**」「多年」「几个月」而非确切数字
/// (季炎萍 TS0B010543 主诉「下颌活动义齿戴牙数年余」——不认就漏)。
export const TREATED_EVIDENCE_COMPLETION_RE =
'[0-90-9一二三四五六七八九十两半数多几]+[个]?(天|周|月|年)|按约|按计划|复查|复诊';
/// 未发生标记 —— 命中即整段不作数。语义与 keyword_strip_clauses 一致(剥"还没做"),
/// 另补主诉特有的诉求词(要求/拟/打算/咨询/意向)。⛔「计划」不收 —— 会误伤「按计划复诊」。
/// 分三类:①诉求(还没做) ②种植未完成态 ③修复体已失效(做了但不能用 → 仍该召回)。
/// ②③ 都是生产实测踩出来的,不是预防性堆词。⛔「计划」不收 —— 会误伤「按计划复诊」。
export const TREATED_EVIDENCE_INTENT_EXCLUDE_RE =
'要求|拟行|拟|打算|咨询|意向|建议|推荐|考虑|择期|必要时|如需|未行|未做|未修复' +
'|手术|拔除|拔牙|导板|转诊|种植科|种植前|取出|失败|重新种' +
'|脱落|无法就位|不能就位|未就位|无法佩戴|不能佩戴|无法使用';
/**
* 「检查所见写着修复体在位」—— 缺牙缺口的**第三条举证路线**(前两条:治疗名 / 主诉现病史)。
*
* ── 为什么需要第三条 ──
* 前两条都要求患者**为那副修复体而来**:治疗名要当次动了它,主诉要当次是为它复查。
* 但一大类患者是**为别的牙来的**,那副义齿只出现在医生的全口检查所见里:
* 周燕芬 TS0M013273:那次来做**下前牙**冠修复,上颌 11~27 的记录是
* 「见活动义齿,基托边缘密合,伸展范围良好,固位力良好,无压痛」—— 上颌义齿明明在位,
* 却因为"这次不是为它来的"而两条路都接不住 → 判「缺失牙未启动修复」误召。
*
* ── 🔴 同颌闸是这条路线成立的前提 ──
* 检查所见一条记录常挂一整排牙位,句子里可能上下颌状态相反:
* 王志荣 TS0K051842:「上下颌吸附性义齿修复,**下颌固位可**,**上颌固位稍差**,义齿易脱落」
* 童然夫 TS0M013276:「口内活动义齿修复,**上颌**义齿卡环紧,**下颌**义齿无法完全就位」
* 整条牙位一起解会误销该召的那一半。所以**牙位跨上下颌的条目一律跳过**。
* 生产实测这个代价很小:提到义齿的 81,812 条检查所见里,**71,832 条(87.8%)牙位本就单颌**,
* 跨颌的只有 9,980 条(12.2%),其中文中点名了颌、理论上可切分的仅 676 条(上下颌都点名 150 条)。
* ⛔ 即"先做按颌切分再动这条路"是把 12% 当成了全部 —— 88% 根本不需要切分。
* (王志荣/童然夫恰好落在那 12% 里,是巧合,不代表切分值得做:整个生产只值 150 条。)
*
* ── 判据 ──
* 修复体名词 ∧ 在位状态词 ∧ ¬失效词,且该条目牙位不跨颌。
* ⛔ 失效词是这条路线的命门:修复体**做了但不能用**仍然该召回 ——
* 「无法就位」(王茜 BJ0F028868 外院假牙)/「脱落」/「折断」都表示需要重做,不是已修复。
*
* ⚠️ 只往 resolved 加牙位,永不新增召回 → 失败方向只有少召,且不报错。
* 消费方:potential-treatment-gap.sql 的 resolvedTeethSql(召回与画像共用)。
*/
/// 修复体名词 —— 出现即说明"有个修复体在讨论"。
/// ⛔ 不收裸「冠」:天然牙冠也叫冠(「牙冠完好」说的是真牙)。代价是「冠边缘密合」这类
/// 省略了定语的串接不住 —— 那类患者通常另有治疗名/主诉证据(陶美玉 TS0K070812 即是),
/// 不值得为它放宽到裸「冠」。
/// 「杆卡」= 种植覆盖义齿的固位杆,只可能属于修复体,无歧义。
export const RESTORATION_IN_PLACE_TERMS_RE =
'义齿|假牙|修复体|全冠|烤瓷冠|全瓷冠|种植冠|固定桥|杆卡';
/// 在位/状态良好 —— 医生描述这副修复体现在是好的
export const RESTORATION_IN_PLACE_STATE_RE = '密合|良好|在位|无松动|无压痛|固位可|完好|无异常';
/// 🔴 判失效词**之前**先抹掉的"否定式好话" —— 否则子串匹配分不开正反。
///
/// 中文否定前缀在这段文本里出现了三种方向,堆词堆不完:
/// ① 好词被否定:「密合」← 「**不**密合 / **欠**密合 / 密合度**差**」
/// ② 存在被否定:「义齿」← 「**无**义齿修复」(徐磊 BJ0U001139)
/// ③ 坏词被否定:「松动」← 「**无**松动 / **未见明显**松动 / 松动(-)」
/// ①② 的否定形式有限,逐个列进 FAIL 即可;③ 反过来 —— 坏词才是主形,好话是它的否定,
/// 列进 FAIL 会把好的一起否掉。所以对 ③ 采用本表:**先抹掉好话,再判坏词**。
/// 这跟 refusal 判定里「先 regexp_replace 抹掉'拒绝拍片'再判是否拒绝治疗」是同一手法。
export const RESTORATION_IN_PLACE_NEG_STRIP_RE = [
'无明显松动', '未见明显松动', '未见松动', '无松动', '不松动', '未松动',
'松动(-)', '松动\\(-\\)', '松动(—)', '松(-)', '松\\(-\\)', '不松',
].join('|');
/// 🔴 失效/未完成 —— 命中即不作数。做了但不能用,仍然该召回(需要重做的修复体不是"已修复")。
///
/// ⛔ **否定前缀必须逐个列全**:状态词是子串匹配,「密合」会匹配「**不**密合」「**欠**密合」
/// 「密合度**差**」「密合性**不佳**」—— 生产实测第一版就这么误销了一大批:
/// 赵河 BA27761「固定桥修复,边缘欠密合」/ 陈德家 BA40818「密合度差,继发龋坏」/
/// 翟健民 BJ0C021369「修复体松动,边缘欠密合,部分崩瓷」/ 李露 BJ0D049016「松动I,边缘不密合」。
/// 这跟「清洁牙面 vs 洁牙」是同一类坑(中文否定前缀让子串匹配失效),只能靠 FAIL 一票否决兜。
/// ⛔ 「松动」同理:STATE 收的是「无松动」,但「松动I」「修复体松动」「冠松动」是坏的 ——
/// PG ARE 不支持后顾断言,只能把坏形式逐个列出来。
export const RESTORATION_IN_PLACE_FAIL_RE = [
// 🔴 修复体**根本不存在**的否定说法 —— 与「不密合」同类的否定前缀坑,第二次踩到:
// 徐磊 BJ0U001139「缺失,**无义齿修复**,46,47均向近中移位」—— 含「义齿」却是说没有。
'无义齿', '无修复体', '未行义齿', '未行修复', '未见修复',
// 装不上 / 用不了
'无法就位', '不能就位', '未就位', '无法佩戴', '不能佩戴', '无法使用', '不可摘除', '漏风',
// 密合类的否定形式(子串匹配防不住,必须列全)
// ⚠️ 「密合」的否定形式**逐个列举列不完**(实测四轮才收敛:不密合/欠密合/密合度差/
// 密合性不佳/密合度欠佳/密合较差/密合度不佳/密合差…)。改用模式:密合 + ≤3 字 + 贬义词,
// 且中间不许跨标点(否则「边缘密合,龈缘红肿…咬合差」会误否)。
// ⛔ 「密合度尚可 / 尚密合 / 较密合」是可接受状态,刻意不收进贬义词。
// 「固位」同理(周锡英 TS0B001674「覆盖义齿修复,固位不良」)—— 用同一套模式,
// ⛔ 注意 STATE 里收的是「固位可 / 固位良好 / 固位稳定」,贬义只由本模式判。
'不密合', '欠密合', '不贴合', '密合[^,,。;;]{0,3}(差|不佳|欠佳|不良)',
'固位[^,,。;;]{0,3}(差|不佳|欠佳|不良)', '形态不良',
// 破坏 / 松动的坏形式
'脱落', '折断', '破损', '崩瓷', '绷瓷', '裂',
// ⚠️ 括号与 + 必须转义(PG ARE 与 JS 同):'松动(+' 里的 ( 会开组、+ 无物可重复 → 正则编译失败
'修复体松动', '冠松动', '桥松动', '义齿松动', '松动I', '松动Ⅰ', '松动Ⅱ', '松动Ⅲ',
'松动\\(\\+', '松动(\\+', '松动度',
// 基牙松动度的各种写法(王秋枫「11Ⅰ°松」/ 侯永生「松动二度」/ 康振英「牙松动2度」/
// 吴实「有明显松动」)。⚠️ 收裸「松动」是**安全的** —— 好话形式已由
// [[RESTORATION_IN_PLACE_NEG_STRIP_RE]] 在判定前抹掉。
'°松', '度松', '松动',
// 还有问题要处理
'继发龋', '缝隙', '可探入',
// 还没做
'未修复', '待修复', '建议', '要求', '拟', '考虑', '择期',
].join('|');
/**
* 「活动义齿 = 假缺失」—— 按**颌**判定,是缺牙误召里最大的一类。
*
* ── 问题 ──
* 戴活动义齿的患者,诊断栏**永远**写「缺失牙」—— 天然牙确实没了,这个诊断是对的。
* 但那个位置已经被义齿盖住,是**假缺失**:召回说的「缺失牙未**启动**修复」根本不成立,
* 治疗早就做了(而且做的就是义齿)。义齿松了/紧了/该重衬是**维护**,不是没做过。
*
* ── 判据:句子按颌定范围,牙位仍由条目自己给 ──
* 「上颌…义齿」→ 该条目牙位里的**上颌部分**解除;「下颌…义齿」→ 下颌部分;「上下颌/全口」→ 两边。
* 不需要逐段判状态(义齿存在就够),但**不整颌铺开** ——
* 🔴 整颌铺开会盖掉同颌里义齿没覆盖到的缺牙位:上颌局部义齿只补了 14;15;16,
* 而 24 也缺着没补,整颌一刀会把 24 一起解掉 = 该召的不召。用条目自己的牙位兜住。
* 🔴 且只认**信号诊断那一份病历**里的检查所见:同一份里"诊断说缺失、检查说有义齿"是
* 医生同一时刻写的,用检查补诊断没争议;跨次就多了一层"这中间会不会变了"的推断。
* 这比 [[RESTORATION_IN_PLACE_TERMS_RE]] 那条(按条目牙位 + 同颌闸)覆盖面大,
* 且**天然绕开了混合句问题** —— 王志荣 TS0K051842「上下颌吸附性义齿修复,下颌固位可,
* 上颌固位稍差」/ 童然夫 TS0M013276「上颌义齿卡环紧,下颌义齿无法完全就位」这两条
* 检查所见牙位横跨上下颌,同颌闸整条跳过,而按颌判定直接可用。
*
* ⚠️ 「≤10 字」的距离限制是关键:要求颌词紧挨着义齿词,否则
* 「上颌见残根……患者要求下次做义齿」这类会被误连。
* ⛔ 未发生词一票否决:「建议上颌活动义齿修复」是还没做。
*
* ⚠️ 只往 resolved 加牙位,永不新增召回 → 失败方向只有少召,且不报错。
* 消费方:potential-treatment-gap.sql 的 resolvedTeethSql(召回与画像共用)。
*/
export const ARCH_DENTURE_UPPER_RE =
'(上颌|上半口)[^。;;]{0,10}(义齿|假牙)|(上下颌|全口|上下全口)[^。;;]{0,10}(义齿|假牙)';
export const ARCH_DENTURE_LOWER_RE =
'(下颌|下半口)[^。;;]{0,10}(义齿|假牙)|(上下颌|全口|上下全口)[^。;;]{0,10}(义齿|假牙)';
/**
* 预过滤词 —— **纯性能剪枝,逻辑上恒等,不改任何判定结果**。
*
* 上面两条 RE 都要求句子里出现「义齿」或「假牙」(两个分支各自的第二个捕获组都是
* `(义齿|假牙)`),所以整段 exam_findings 文本里若一个都没有,任何条目都不可能命中。
* 于是可以在**展开 JSON 数组之前**先用它把整份病历筛掉。
*
* 🔴 为什么必须加(2026-08-29 测试库实测):
* exam_findings 是数组的 emr 事实 1,519,829 份,其中含「义齿|假牙」的只有 7,396 份
* —— **0.49%**。不加预过滤时这条分支要对 152 万份病历做 `jsonb_array_elements`
* 横向展开,再逐条目 unnest 牙位(全口条目一条炸 28~32 行)。
* ⚡ 按患者相关子查询(**实际使用的形态**,先走 patient_id 索引再过滤),300 患者样本:
* 无预过滤 832.7ms → 有预过滤 49.7ms,**快 16.7 倍**,结果一致(均 5 行)。
*
* ⛔ 别用「全表扫」的写法去验证本优化 —— 那个形态下两边都是 81 秒、看不出差别
* (顺序扫描 + detoast 压倒一切,少展开几百万行无关紧要),照着那个数会误以为它没用
* 而把它删掉。2026-08-29 我自己就先测错了这一次。批量路径(runAllForHost)接近全表扫
* 形态,收益不明显;真正吃到 16.7 倍的是**单患者路径**(详情页刷新 /
* recomputeForPatient / reparse 后的定向重算)。
*
* 兄弟分支 [[RESTORATION_IN_PLACE_TERMS_RE]] 那条本来就先在整段文本上过滤了
* (见 potential-treatment-gap.sql 的 rsrc 子查询),这条是漏了,不是有意为之。
*
* ⛔ 改上面两条 RE 时必须同步检查本词:**本词必须始终是那两条 RE 的必要条件**,
* 否则预过滤会开始丢真命中,而且是静默少召。tests/arch-denture-false-missing.spec.ts
* 有一条用例专门锁这个蕴含关系。
*/
export const ARCH_DENTURE_PREFILTER_RE = '义齿|假牙';
/// 未发生 —— 命中即不作数(「建议上颌活动义齿修复」是还没做)
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);
......@@ -900,12 +1263,45 @@ export const K00_LEAD_CATEGORY_RULES: ReadonlyArray<{
];
/**
* 诊断词细分建议类目(目前只作用于 K00)—— 排在年龄适配**之前**的一道语义重排。
* K03 主类目细分(**同码内重排,不改诊断码**)。
*
* 背景(2026-08 孙海燕 TS0M012582 牙16;18;28;47):K03「牙体硬组织其他疾病」同样是大口袋 ——
* 楔状缺损 / 牙体缺损(补得回来,restorative)与 残根 / 残冠(补不回来,只能拔,surgical)
* 共用一份固定类目序 `[restorative, prosthodontic, surgical]` → focusCategory 恒为
* restorative,给残根患者打「目标 · 充填 / 嵌体」—— 残根是没法充填的。
*
* ⚠️ 词表**复用 [[EXTRACTION_NAME_KEYWORDS]]** —— 那张表已经在驱动分配矩阵的
* `K03 + 含词 → extraction(拔牙治疗)` 标签。两处同源,标签与目标才不会打架:
* 分配把人放进「拔牙治疗」列,卡片却写「目标 · 充填」,客服无从判断该说什么。
* ⚠️ 与 K00 同边界:只挪位,返回集合恒等于入参集合,排除闸不受影响。
*/
export const K03_LEAD_CATEGORY_RULES: ReadonlyArray<{
pattern: RegExp;
lead: string;
why: string;
}> = [
{
pattern: new RegExp(EXTRACTION_NAME_KEYWORDS.join('|')),
lead: 'surgical',
why: '残根 / 残冠补不回来,临床路径是拔除(或根管+桩核冠保留,仍非充填)',
},
];
/// 诊断码 → 同码内主类目重排规则(表驱动;未列的码原样返回)。
const LEAD_CATEGORY_RULES_BY_CODE: Readonly<
Record<string, ReadonlyArray<{ pattern: RegExp; lead: string; why: string }>>
> = {
K00: K00_LEAD_CATEGORY_RULES,
K03: K03_LEAD_CATEGORY_RULES,
};
/**
* 诊断词细分建议类目(K00 / K03,表驱动)—— 排在年龄适配**之前**的一道语义重排。
*
* 顺序:`refineCategoriesForDiagnosis`(临床语义)→ `recommendedCategoriesForAge`(年龄)。
* 前者定"这个病该往哪个方向治",后者定"这个岁数该先讲哪个"。两道都只挪位。
*
* @param code 诊断码(非 K00 一律原样返回)
* @param code 诊断码(不在 LEAD_CATEGORY_RULES_BY_CODE 里的一律原样返回)
* @param nameZh 诊断原文词(DW diag message,如「乳牙早失」);空 → 原样返回
* @param categories 该诊断的候选治疗类目
*/
......@@ -914,10 +1310,11 @@ export function refineCategoriesForDiagnosis(
nameZh: string | null | undefined,
categories: readonly string[],
): readonly string[] {
if (code !== 'K00') return categories;
const rules = code ? LEAD_CATEGORY_RULES_BY_CODE[code] : undefined;
if (!rules) return categories;
const name = (nameZh ?? '').trim();
if (!name) return categories;
const hit = K00_LEAD_CATEGORY_RULES.find((r) => r.pattern.test(name));
const hit = rules.find((r) => r.pattern.test(name));
if (!hit || !categories.includes(hit.lead)) return categories;
return [hit.lead, ...categories.filter((c) => c !== hit.lead)];
}
......
......@@ -151,6 +151,10 @@ export const AppointmentCanonicalSchema = z
]),
appointmentType: z.string().optional().nullable(),
doctorId: z.string().optional().nullable(),
/// 排班资源名 = 约号时约的那位医生的姓名(host 行内快照,jvs-dw resource_name)。
/// ⚠️ 不等于实际接诊医生 —— 改派时以病历为准。
/// ⚠️ 约 1% 排的不是人而是房间/服务,canonical 层原样透传,由 parser 判定后再落 doctor_name。
doctorName: z.string().optional().nullable(),
complaintCategory: z.string().optional().nullable(),
durationMinutes: z.coerce.number().optional().nullable(),
arrivedAt: optionalIsoDateTime,
......
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