1. 31 Aug, 2026 2 commits
    • docs(sync): 增量回看窗分析 —— 不能缩,根因是游标跟的量与到达顺序无关 · e503e8ae
      生产只读实测(7 天 / 281,760 条增量记录):
        滞后 p50 2.39h / p99.9 15.39h / max 47.43h;>24h 31 条、>48h 0 条
        按表拆:refund max 47.4h(>24h 有 27 条)、临床表 max 15.5h(>24h 全 0)
      
      根因:游标跟 updated_date(源 HIS 改动时刻,≈事件时间),而数据到达取决于 DW 自己的
      ETL —— 两者无关。所以必然有"updated_date 比游标旧、此刻才到达"的行,游标追不上。
      退费那几条是铁证:源系统 13:05 建、13:13 改完,PAC 47 小时后才看到。
      
       不能缩:缩 24h 丢 31 条、36h 丢 19 条,且游标推过去是**永久**漏拉。
         且 max=47.43h 恰好顶在 48h 边界、>48h 为 0 —— 这是**截断的指纹**,
         真实尾巴可能更长。本测量方向单边:能证"够用"不能证"不够"。
      
      根治不在我们这边:DW 十张表一个入仓时间字段都没有(rq 是业务日期已排除)。
      若 DW 加一列 ETL 写入的时间戳,游标即可在到达顺序上单调 → 漏拉结构上不可能,
      fetched 从 24.8 万降到约 1.6 万,摄入 31m → 预计 5~10m。
      
       另记一个取样陷阱:第一版按 received_at 时间窗取样,混进 5 次 full: 全量补摄的
      历史数据,得出"滞后 5 年"的荒谬结果。既有增量又有补摄的系统,取样必须按事件来源限定。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  2. 30 Aug, 2026 13 commits
    • merge: 预取消 O(患者数) 项 + 三查询并行 → main · 48ca36cb
      测试机验证(87,885 命中患者 / 44 chunk):
        预取 288,003ms / 247,250ms(旧基线) → 183,810ms(新)  −26~31%
        命中患者 87,885,在 87,849~87,853 漂移带内 —— 口径没变
      本地(19,476 患者 / 10 chunk):预取 8,383 → 4,899ms;plan_reasons 行级 diff=0
      
      推算生产:预取 20m09s → 省 5~6 分钟,整轮 2h02m → 约 1h56m,回到 2 小时线内。
      luoqi committed
    • perf(plan): 预取消掉一个 O(患者数) 项 + 三条查询并行 · 66c54b46
      ① snooze 抑制集提到 chunk 循环外一次查完。
         它的代价跟「终态且冷静期未到期的计划数」走,跟患者数无关 ——
         2026-08-30 生产实测全库符合条件只有 **106 行**,而按 chunk 查要跑 274 次
         (54.8 万患者 / 2000),**273 次在查空**。
         🔴 这不是省常数,是消掉一个 O(患者数) 项:到 200 万患者原写法是 1000 次往返,
            新写法仍是 1 次。生产百万级且在涨,这类项要按规模判断而不是按当下耗时。
         索引 (status,…) 前导 status,completed/abandoned 是稀有态 → Bitmap 扫 42 buffers/0.6ms。
      
      ② 余下三条(latest plan / persona / 末次到诊诊所)彼此独立,改 Promise.all 并行。
         每 chunk 墙钟从「三条之和」降到「最慢那条」。并发度恒为 3,不随患者数涨,
         不会挤爆 Prisma 池(默认 核数×2+1)。
      
      口径零变化(三条都是只读、无共享状态;snooze map 只会被本批患者查到)。
      本地实测:预取 8,383ms → 4,899ms;**plan_reasons 逐行 diff = 0**(39,225 行)。
      ️ 预取是纯性能路径,单测覆盖不到,所以靠真实数据端到端行级对拍来验。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(gap): 生产实测集合式全量更慢,已回退 —— 子集掩盖了性能的规模拐点 · dc760962
      生产 10 万患者子集对拍 11/11 零差异 → 开启 → 20:15 轮第 1 批判据失败 → 回退。
        missing_tooth 8m13s → 30m42s(慢 3.7×)
        endo_no_rct   8m12s → 18m21s(慢 2.2×)
        第 1 批墙钟   17m03s → 30m42s(+80%)
      命中数仍在漂移带内 —— **正确性没问题,是性能不达标**。
      临时文件零增长、swap 为 0 —— 也不是内存问题(这次没再编错误归因)。
      
      归因:集合式代价跟**患者域**走(gap_scope 要预聚合全域),legacy 跟**候选行数**走。
      规模一变结论就翻转:30K/585K/10万子集 都快 1.5×,113 万全量慢 3.7×。
      
       教训:方案里写过「子集零差异 ≠ 全量零差异」,但只想着正确性覆盖。
         真正被掩盖的是**性能**,而且方向都反了。子集测正确性有效,测性能无效 ——
         除非能先证明代价与规模线性,而集合式恰恰不是。
      
      生产维持 legacy + 并发4(场景段 50m25s / 整轮 1h17m)。代码留 main,默认关闭。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • test(gap): 对拍差分支持 --subset=N —— 让生产真实数据上的零差异证据变得便宜 · 9a042f45
      生产 113 万患者全量差分要几小时,还会跟 2 小时批次抢 RDS;收窄到几十万后
      几分钟就能在**生产真实数据**上拿到证据。抽样偏向"有 active 诊断/建议信号"的患者,
      否则大多数抽中的人两版都返回空,验了个寂寞。
      
      ️ 收窄降低的是**覆盖**不是可信度:差异一旦出现仍是真差异;但「子集零差异」
         ≠「全量零差异」,所以报告行里带上患者域,逼自己写清跑的是多少人。
      
      本地 3000 位子集实测:11/11 零差异。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • perf(gap): resolvedTeeth 集合式形态 + 对拍工具(默认 legacy,生产不启用) · 1bde4764
      把 buildGapCore 的 resolvedTeethSql 从**逐 (患者×信号) 行相关子查询**改成
      **集合式预聚合 + 反连接**。核心恒等式 ∃x∈G: t(x) ⋛ a ⟺ max{t(x)} ⋛ a,
      分组 G=(患者,牙位)。13 个分支对 sig 的相关性只有三类:
      时间门(10)/ 病历号等值(1)/ 无相关(2),外加「建议优先」一条的 sig.type 标量谓词。
      
      ️ 集合式是**独立重写一份**,刻意不与 legacy 共用片段 —— 共用则重构写错的地方两边
         一起错、对拍互相抵消。等价性靠 verify-gap-equivalence 逐行差分来证。
      全口码(K05/K07)不进集合式:其 lateral PG 本来就会摘掉(零收益),硬套还慢 2~3 倍。
      
      **默认 legacy**,靠 PAC_GAP_VARIANT=setbased 显式开启。生产不设该变量 → 行为零变化。
      
      验证(测试机 585K 患者 / 本地 30K):
        · SQL 层 11/11 子场景逐 (患者×信号×牙位) **零差异**,行数逐个相同
        · 画像消费方 2000 位患者零差异,单患者 p95 47→27ms 不劣化
        · 端到端 plan_reasons 行级 diff=0;测试机 32 万条差异仅时间漂移、**零删除**
        · 交互路径(详情页刷新)200 位 × 11 子场景零差异,一次刷新 89→75ms
        · 13 条分支源行全部非空 —— 零差异不是空转
      收益(测试机相邻两轮,并发4):场景段 794s → 545s(×1.46)。**生产未验**。
      
      新增 src/cli/verify-gap-equivalence.cli.ts:两版同一 REPEATABLE READ 快照双向
      EXCEPT ALL 差分;--self 自对拍先证工具可信;--persona/--single/--conc 覆盖
      画像、交互、并发标定四条路径。
      新增 tests/gap-setbased-parity.spec.ts:结构对拍,守「两种形态的分支集合不许走散」
      (加分支只改一边 = 静默错召,tsc 和现有 spec 都发现不了)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(gap): 精简误召个案表 —— 只留结论和证据,删推理过程 · 61ba24de
      删掉四类冗余:
      · 「该计划共 N 条理由,另 X 条判得对」的旁注(#8/#10/#14/#15/#17)
      · 算法机制的重复解释(#9「算法逻辑没错,是事实没进结构化数据」等)
      · 「该修复上线晚于本单建单,故当时未生效」这类时序补注(#19/#22)
      · 举证细节的收尾展开(#8 总院回访那段收成一句)
      
      个案表是给人看结论的,不是复述推理的地方。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • perf(recall): 启用子场景并发 —— 生产全量 ×2.31,2 小时窗口进得去 · 0c391bd1
      生产全量实测(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 分钟
      先在生产一个 39,148 患者的诊所交替标定(×2.18),再由全量复现(×2.31)。
      
      🔴 推翻原有的「 别指望靠这个旋钮提速」结论 —— 它让生产一年多没开这根杠杆。
      原依据是「并发3=24.1分 vs 串行23.3分」,两处都错:
      ① 3% 差距落在 ±25% 的环境噪音里(用「两边跑同一条 SQL」的对照组量过),那次比较
         什么也没证明;
      ② 机制归因也错:真瓶颈是**延迟**(逐次索引探查等 page,CPU 与磁盘都闲着)不是吞吐,
         所以并发 2 就超线性(生产 ×1.50 / 测试机 ×2.88)。若真是吞吐受限,墙钟应约等于
         各查询耗时之和;实测墙钟只有求和的 43%。
      
      同时把「集合式重写是唯一出路」那段改成现状:并发已解决窗口问题,集合式转为可选。
      .env.example 补文档,含「判据只能看阶段墙钟、不能看单条 sql=」的读数陷阱。
      
      本提交只改注释与 .env.example,不改任何运行逻辑(生产开关已于 2026-08-30 09:16 生效)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • merge: CLI 启动误杀正在跑的同步锁 → main · 6f0915f7
      生产事故修复(2026-08-30):在 pac-service 容器里 docker exec 跑任何 CLI,
      都会 createApplicationContext(AppModule) → 跑一遍 SyncIncrementalScheduler.onModuleInit
      → reapStaleRunningLocks 把 service 里**正在跑**的那轮同步当僵尸锁清掉。
      实测 08:17:11 起 recompute-plans → 08:17:13 正常跑着的 08:15 那轮被标 failed
      (前 19 轮全 success)。数据未丢(cursor_after=null,下轮同水位 catchup),但白丢一轮。
      
      两道防线:年龄阈值 REAP_MIN_AGE_MS=3h + 16 个 CLI 建上下文前设 PAC_SCHEDULER_DISABLED=1。
      不带任何开关,上线即生效。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(sync): CLI 启动会清掉正在跑的同步锁 —— 生产实测夭折一轮增量 · 824554a9
      🔴 事故(2026-08-30 生产):在 pac-service 容器里 docker exec 跑 recompute-plans,
         08:17:11 起进程 → 08:17:13 正在跑的 08:15 那轮同步被标 failed。前 19 轮全 success,
         只死了撞上的这一轮。数据未丢(cursor_after=null,下轮同水位 catchup),但白丢一轮。
      
      根因:每个 CLI 都 createApplicationContext(AppModule) → 跑一遍
      SyncIncrementalScheduler.onModuleInit → reapStaleRunningLocks。
      原判据是「startedAt < 本进程启动 = 僵尸锁」,注释里写着"两者的进程都不可能比本进程
      启动得更早还活着"—— 但**长驻的 pac-service 恰恰就是那个更早启动还活着的进程**。
      理由写反了方向,而且只在"回收逻辑跑在长驻服务里"时才成立。
      
      两道防线:
      ① scheduler 加年龄阈值 REAP_MIN_AGE_MS=3h —— 真僵尸锁必然躺很久,正在跑的不会。
         用年龄区分,不靠猜进程身份。(生产单轮摄入实测 28~52 分钟)
      ② 新增 src/cli/bootstrap-flags.ts,16 个 CLI 在建上下文**之前**设
         PAC_SCHEDULER_DISABLED=1(该总闸本就会跳过回收,只是没人用)。
         豁免 sync-incremental.cli(它就是要触发同步),由 ① 兜底。
      
      回归测试 tests/cli-scheduler-guard.spec.ts:遍历所有会建上下文的 CLI,
      断言调用存在**且位置早于** createApplicationContext;并锁住年龄阈值 ≥2h。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  3. 29 Aug, 2026 14 commits
    • perf(召回): 场景查询事务级 work_mem=256MB —— 生产实测降 30~34% · 7d20ca2b
      2026-08-29 生产六轮实测(endo_no_rct,72,769 行,全部在生产 RDS 上跑):
        配置                      耗时     相对基线
        无索引 + 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:收益对取值很敏感,128→256 还差一倍。原本按「溢出只有 4 批、
        内存用量 8MB」推断 128MB 够用,实测否定了这个推断。
      
       事务级 SET LOCAL,不能全局调:work_mem 是每个排序/哈希节点的上限,不是每连接。
        生产 RDS 约 7GB 内存,全局设大值遇上并发排序会吃穿。SET LOCAL 出事务自动还原,
        Web API 连接不受影响;场景查询串行,同一时刻只有一条,峰值可控。
      
       别再加信号码部分索引:测试机与生产都测过,生产 −5% 落在噪声里
        (同配置两次测量本身差 6%),不值得引入一个 Prisma 管不到的索引。
      
      tests/recall-future-return-visit-gate.spec.ts 的 SQL 捕获桩补 $transaction/$executeRaw。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • merge: plan 三层耗时日志 + 义齿按颌预过滤 → main · d6b0fa9d
      补的是常态可观测性:2026-08-29 生产 plan 段从 12 分钟涨到 159 分钟仍未跑完,
      而引擎中间什么都不打,只能临时起 pg_stat_activity 采样器反推。
      现在每个子场景一行(SQL 与 Node 后处理分开计时)+ 每轮阶段耗时一行。
      
      同时带上义齿按颌分支的预过滤(逻辑恒等,单患者路径实测 16.7×)——
      它针对的正是本次新增的两条 exam_findings 分支,是生产回归的头号嫌疑。
      luoqi committed
    • feat(可观测): plan 引擎补三层耗时日志 —— 一轮跑两小时不该是黑盒 · bac4c234
      2026-08-29 生产 plan 段从 12 分钟涨到 159 分钟仍未跑完,而引擎从「 Running engine」
      到最后的统计块之间**什么都不打**,整轮是个黑盒 —— 只能临时起 pg_stat_activity 采样器
      反推,才勉强定位到子场景粒度。这组日志就是为了不再重复那个过程。
      
      ① 每个子场景一行(常态开启,每轮 11 行):
         [recall] sub=missing_tooth code=K08 sql=1234ms rows=567 post=89ms hits=42
          SQL 与 Node 后处理**分开计时** —— 今天最大的困难就是知道慢、却分不清
           慢在 SQL 还是慢在 Node。
      
      ② runAllForHost 三段(常态开启,每轮 1 行):
         [plan] 阶段耗时 场景=Xms 预取=Yms 写入=Zms 命中患者=N 总计=Wms
         今天一度误以为瓶颈在预取,有这行就不会跑偏。
      
      ③ PAC_RECALL_DUMP_SQL=1(默认关)保留,需要拿 SQL 原文做 EXPLAIN 时才开。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(召回): 全量重算耗时归因 —— 68% 在逐行 gap 子查询,两个方案已实测否定 · d4b5264f
      2026-08-29 用 dump 开关 + pg_stat_activity 采样 + EXPLAIN ANALYZE 测出来的,
      结论与直觉相反,写进代码免得后来人重走弯路:
      
      ① 11 个子场景只有 2 个慢(K05 牙周 9m13s / K01 阻生牙 13m+),其余 9 个秒回;
         且与结果集大小无关 —— K02 理由数最多(49,382)却秒回。
      ② 11 条 SQL 逐字节相同,只有绑定参数不同 → 是数据分布 + 执行计划,不是 SQL 写法。
      ③ 真实热点:Append(resolvedTeeth 的 UNION) loops=117,256 × 1.71ms ≈ 201 秒,
         占 K01 单条 301 秒的 68% —— 每候选行跑一次的 gap 相关子查询。
      
       已实测否定,别再试:
       · 子场景并发=3:24.1 分钟 vs 串行 23.3。瓶颈是共享磁盘 I/O,并行只抢同一批 page。
       · 信号码部分索引:EXPLAIN 估算成本降 13×(122 万行 → 5,593 行),
         墙钟 24.5 vs 23.3 —— 无效。教训:别拿估算成本当依据,要看 EXPLAIN ANALYZE 的实际时间。
         (该索引的迁移已撤回,不上生产。)
      
       真正的方向(独立项目,未做):resolvedTeethSql 从逐行相关子查询改集合式。
         它是召回与画像共用的单一真理源,重写必须配等价性验证,否则是拿静默少召赌运气。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(排查): 召回场景 SQL 的 dump 开关 —— pg_stat_activity 会截断,拿不到全文 · 38e59329
      PAC_RECALL_DUMP_SQL=1 时把每个子场景的完整 SQL + 绑定值打到日志。默认关,零开销。
      
      为什么需要:2026-08-29 排查 plan 段耗时,采样抓到某条子场景查询单次跑 6.7 分钟,
      但 pg_stat_activity.query 被 track_activity_query_size(默认 1024 字节)截断 ——
      拿不到全文就没法 EXPLAIN,排查卡死在这一步。调 track_activity_query_size 要重启 PG,
      生产上不划算;做成开关更可控。
      
      实现上主查询从「$queryRaw 标签模板」改为「先建 Prisma.sql 对象再 $queryRaw(obj)」,
      两者等价,但对象有 .sql / .values 可检视。
      tests/recall-future-return-visit-gate.spec.ts 的 SQL 捕获桩同步兼容两种调用形态。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • merge: cron 防重入 + reparse bind 溢出修复 + 并发否定结论 → main · d46a6cc5
      修的是 2026-08-29 生产的两个真实故障:
        ① plan 段被拖过 2 小时 cron 间隔后套圈,雪崩自持(源头消失也不自愈)
        ② 18 万患者 reparse 跑满 61/61 批后倒在收尾统计的 bind 变量溢出
      luoqi committed
    • docs(召回): 记录「子场景并发提速」的否定实测 —— I/O 受限,并行无效 · c4c1e3bd
      2026-08-29 测试服实测(585K 患者 / 87,661 命中,空闲机):
        串行(默认 1):24.6 / 21.7 / 23.5 分钟(三次)
        并发 3:      24.1 分钟 —— 不但没快,还略慢
      
      采样显示全程 DataFileRead:本阶段是共享磁盘 I/O 受限,不是查询延迟受限,
      并行只让几条查询抢同一批 page,总读取量不变。要提速得减少读取量(索引/收窄扫描),
      不是提高并行度。把这个负结果写进注释,免得后来人再拧一次这个旋钮。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(调度): cron 回调防重入 + reparse 两处 bind 变量溢出 · 834a0c3e
      ## 一、cron 防重入(sync-incremental.scheduler.ts)
      
      🔴 2026-08-29 生产事故:plan 段耗时涨过 2 小时后,cron(每 2 小时)照常触发下一轮,
      两轮 plan 段并发抢同一批 I/O → 都更慢 → 更易被再下一轮套圈 → 雪崩。
      实测 08-28 20:15 起连续多轮 plan 段一次都没跑完;到 08-29 11:19(即触发原因
      reparse 结束 1.4 小时后)仍有两轮在并行互拖 —— 雪崩是**自持**的,源头消失也不会自愈。
      
      现有的锁都挡不住,这是本修复存在的理由:
        ① NestJS CronJob **默认不防重入** —— 上一次回调还在 await,下一次照样进;
        ② sync_logs 的 partial UNIQUE(host_id) WHERE status='running' 只覆盖**摄入段**,
           摄入一结束锁就放了,而最慢的 persona / plan 段还在跑。
      所以必须在回调入口用进程内 Set 挡。跳过而非排队:摄入是游标增量,下轮自然 catchup;
      plan 是时间驱动全量,跳一轮只是晚 2 小时评估,远好过雪崩。
      释放放在 finally —— 抛异常时不释放会把该 host 锁死到进程重启。
      
      tests/scheduler-reentrancy-guard.spec.ts 锁四条:并发跳过 / 结束后放行 /
      按 host 而非全局 / 抛异常也释放。
      
      ## 二、reparse 两处 bind 变量溢出(cold-import.service.ts)
      
      `patientId: { in: [...] }` 直接塞完整患者清单会撞 PG 的 32767 上限。
      
      实跑路径(第 3 步统计受影响患者)—— 2026-08-29 生产实测:18 万患者的 reparse
      跑满 61/61 批、写完全部事实(superseded=9,062)之后**倒在最后一步**:
        Assertion violation: too many bind variables ... received 32769
      6.6 小时的活全干完,只因收尾统计炸掉而 exit 1。
      
      dry-run 路径同病:>3.2 万患者直接崩,而 --patients-file 的文档恰恰说
      「按受影响患者收窄是最有效的提速手段(可达 250 倍)」—— 最需要先 dry-run 探路的
      大清单场景,正好是它唯一不工作的场景。
      
      两处都按 3000 分块(与 PAC_REPARSE_BATCH 同款),每块 3001 个变量,离上限很远。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • perf(召回): 义齿按颌分支补预过滤 —— 单患者路径快 16.7 倍 · 611548a1
      archDentureBranch 在展开 exam_findings 的 JSON 数组之前没有任何文本级过滤,
      而兄弟分支 RESTORATION_IN_PLACE 那条本来就先在整段文本上过滤了(rsrc 子查询)。
      这是漏了,不是有意为之。
      
      ARCH_DENTURE_UPPER_RE / LOWER_RE 两条都要求句中出现「义齿|假牙」,所以整段
      exam_findings 里一个都没有时,任何条目都不可能命中 —— 预过滤逻辑上恒等,只剪枝。
      
      2026-08-29 测试库实测:
        exam_findings 是数组的 emr 事实 1,519,829 份,含「义齿|假牙」的仅 7,396(0.49%)
        按患者相关子查询(实际形态,300 患者样本):832.7ms → 49.7ms,快 16.7 倍,结果一致
      
       注释里写死了一条反向提醒:别用「全表扫」形态验证本优化 —— 那个形态两边都是
      81 秒(顺序扫描 + detoast 压倒一切),看不出差别,照着那个数会误以为没用而删掉。
      我自己 2026-08-29 就先测错了这一次。批量路径接近全表扫形态,收益不明显;
      吃到 16.7 倍的是单患者路径(详情页刷新 / recomputeForPatient / reparse 后定向重算)。
      
      tests/arch-denture-false-missing.spec.ts 新增一组用例锁「预过滤词必须始终是
      两条 RE 的必要条件」的蕴含关系 —— 一旦不成立,预过滤会静默丢真命中(少召不报错)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  4. 28 Aug, 2026 11 commits
    • fix(摄入): 「调合/调颌」挪到含词表末尾 —— 排在 periodontic 之前会抢别人的记录 · 0089fe31
      0366b572 加这条规则时只挡了 restorative(补牙散文含「调合」),放在了 periodontic
      之前 —— 漏掉了牙周/预防/外科。「调合/调颌」是咬合微调,几乎任何术式的收尾都可能
      带一句,它**单独出现**时才代表修复动作;和别的操作词同句时,那个操作词才是本次治疗。
      
      2026-08-28 测试服 reparse 实测:新写入的 179 条调合/调颌事实里 18 条(10%)判错
        「牙周刮治+调合」「根面平整+调合+牙周内上药」「洁治,47调合」→ 应 periodontic(16)
        「涂氟,45调合」→ 应 preventive(1);拔牙类 → 应 surgical(1)
      
      两个方向都坏:
        ① 牙周治疗不再解 perio_no_srp 缺口 = 多召牙周;
        ② prosthodontic 在结构 resolver 里,反而去解那些牙位的缺牙缺口 = 少召缺牙(不报错)。
      
      修法:挪到本表倒数第二行(只在人群词 pediatric 之前)。原始个案照样接得住 ——
      「调合,抛光」「调合抛光」里的「抛光」不在任何含词表,只有「调合」命中,仍判
      prosthodontic(陈惠英@16 / 陶美玉@22)。
      
      tests/keyword-case-and-terms.spec.ts 锁住两侧:实测误判串必须归回原类目,
      原始个案必须仍是 prosthodontic。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • merge: 缺牙误召四条举证路线 + 姑息处置入 review + 抑制全有或全无 → test · 2f659db5
      代码分支 fix/treated-evidence-from-emr-text(已含 fix/polish-on-missing-tooth-implies-restoration)。
      
      摄入期(需 reparse --subject-type=treatment):
        67fe9d19 补义齿维护族(卡环/基托/重衬/假牙)+ 扩根/松牙固定/用氟/洁牙
        0366b572 补含词「调合/调颌」
        6ee7b8e9 姑息处置(调磨/试戴/冲洗/换药/上药)route 到 review,不再整条丢弃
      
      查询期(plan 重算即生效,均只往 resolvedTeeth 加牙位,失败方向为少召):
        e0a12c44 缺失牙位上的裸「抛光」= 修复体在位
        50a9c9a5+362f484e+79e63437 主诉/现病史自证已治疗(仅开缺失牙场景)
        96765e1d 检查所见按条目举证修复体在位
        aac4e249+1a3916a5 活动义齿「假缺失」按颌判定(限同一份病历,不整颌铺开)
        1284675e 抑制改为全有或全无 —— 处置是计划级的
      luoqi committed
    • docs(召回): 第 6 例王志荣、第 17 例童然夫改为已修复(义齿按颌判定) · 9a922c9d
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(召回): 义齿假缺失收紧 —— 限同一份病历,且不整颌铺开 · 1a3916a5
      上一版有两处过宽,都会把该召的不召(而少召不报错):
      
      🔴 ① 整颌铺开会盖掉同颌里义齿没覆盖到的缺牙位。
         上颌局部义齿只补了 14;15;16,而 24 也缺着没补 —— 整颌一刀会把 24 一起解掉。
         改为:句子按颌**定范围**,牙位仍取**该条目自己的** tooth_position,不再铺满整颌。
         两例待定个案仍成立:王志荣条目 28 颗 ∩ 下颌 = 他的召回牙位;
         童然夫条目 17 颗 ∩ 上颌 = 他的召回牙位(13;16;17;21~27)。
      
      🔴 ② 作用域从「信号当次或之后的任意病历」收紧到「**信号诊断那一份病历**」。
         同一份里"诊断说缺失、检查说有义齿"是医生同一时刻写下的,用后者补前者没争议;
         跨次就多了一层"这中间会不会变了"的推断 —— 与「最新诊断第一位」这个大前提不符。
         实现:adx.content->>'emr_external_id' = sig.content->>'source_encounter_external_id'。
         (已验:王志荣/童然夫的义齿句都与各自的信号诊断同属一份病历。)
      
      影响面 541 → 141 条 / 140 患者(降 74%,是收紧应有的代价)。
      
      测试补 4 例锁住"不越界":条目跨颌时只解句中点名那一颌、只提上颌时下颌牙位不得被解、
      不得铺到条目之外。100 suites / 1777 tests,tsc 0 error。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(召回): 「活动义齿 = 假缺失」按颌判定 —— 缺牙误召里最大的一类 · aac4e249
      戴活动义齿的患者,诊断栏**永远**写「缺失牙」—— 天然牙确实没了,这个诊断是对的。
      但那个位置已被义齿盖住,是**假缺失**:召回说的「缺失牙未**启动**修复」根本不成立,
      治疗早就做了,而且做的就是义齿。义齿松了/紧了/该重衬是**维护**,不是没做过。
      
      ── 判据:按颌,不按牙位 ──
      活动义齿是**整颌跨度**的修复体 —— 判出"这一颌有义齿",那一颌的缺牙位就都被它盖住。
        「上颌…义齿」→ 上排全部解除;「下颌…义齿」→ 下排;「上下颌/全口…义齿」→ 两排。
      不需要逐段判状态,也不需要条目牙位跟召回牙位对上。
      
      🔴 这条**天然绕开了混合句问题**,而混合句正是前一条(RESTORATION_IN_PLACE_*,按条目
      牙位 + 同颌闸)卡住两例待定个案的原因:
        王志荣 TS0K051842「上下颌吸附性义齿修复,下颌固位可,上颌固位稍差」(28 颗跨颌)
        童然夫 TS0M013276「上颌义齿卡环紧,下颌义齿无法完全就位」(17 颗跨颌)
      两条检查所见牙位都横跨上下颌 → 同颌闸整条跳过;而句子本身按颌写清了,按颌判直接可用。
      此前评估"要先做按颌切分"需要两个新机制(颌作用域粘连 + 存在即在位),按颌判定都不需要。
      
      ── 影响面 ──
      在跑计划命中 541 条 / 511 患者(前一条按条目判的是 386 条,两者互补:
      按条目那条接的是句中不提颌、但条目牙位单颌的写法,如周燕芬「见活动义齿,基托边缘密合」)。
      导出复核:命中句几乎全是真「那一颌有义齿」——「上颌活动义齿修复」「上下颌全口义齿,
      咬合接触均匀」「上下颌种植临时义齿存」等。状态不好的(卡环折断/固位欠佳/压痛/易脱落)
      **按口径同样算已治疗** —— 义齿存在,「未启动」就不成立。
      
      ️ 「≤10 字」距离限制是关键:要求颌词紧挨义齿词,否则「上颌见残根……下次考虑做义齿」
      会被误连(已写成测试)。 未发生词一票否决:「建议上颌活动义齿修复」是还没做。
      ️ 同前三条路线:只往 resolved 加牙位、永不新增召回 → 失败方向只有少召且不报错。
       只对缺失牙(K08)开闸。
      
      测试 20 例,含两例待定个案的原句 + 生产真实句 + 距离限制的反例。
      100 suites / 1773 tests,tsc 0 error。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(召回): 第 9 例周燕芬改为已修复;第 6、17 例待定补上原因(检查所见跨颌) · edc81ff1
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(召回): 检查所见写着修复体在位 —— 缺牙缺口的第三条举证路线 · 96765e1d
      周燕芬 TS0M013273 上颌 11~27:那次来做**下前牙**冠修复,上颌的检查所见记着
      「见活动义齿,基托边缘密合,伸展范围良好,固位力良好,无压痛」—— 上颌义齿明明在位,
      却因为"这次不是为它来的"而前两条路线(治疗名 / 主诉现病史)都接不住 → 误召。
      本分支接的就是「患者为别的牙来、医生顺带记了全口状况」这一类。
      
      ── 🔴 先纠正一个把 12% 当成全部的判断 ──
      此前一直认为"检查所见这条路要先解决混合句按颌切分才能动"。实测推翻:
        提到义齿的检查所见 81,812 条,**71,832 条(87.8%)牙位本就在单一颌内**;
        跨颌的只有 9,980 条(12.2%),其中文中点名了颌、理论上可切分的仅 676 条(上下颌都点名 150)。
      即 88% 根本不需要切分,直接加**同颌闸**跳过那 12% 就能做。按颌切分整个生产只值 150 条,
      性价比远不如先做这 88%。(王志荣/童然夫恰好落在那 12% 里,是巧合。)
      
      ── 判据 ──
      修复体名词 ∧ 在位状态词 ∧ ¬失效词,且该条目牙位不跨颌。
       失效词是命门:修复体**做了但不能用**仍然该召回(需要重做的不是"已修复")。
      
      ── 🔴 中文否定前缀踩了五轮才收敛,全部由生产命中逐条核出来 ──
      每一轮都是把全量命中导出人工过,发现漏就补,再导一次:
       ① 「密合」子串会匹配它的否定形式 —— 赵河 BA27761「边缘欠密合」/ 陈德家 BA40818
         「密合度差,继发龋坏」/ 翟健民 BJ0C021369「修复体松动,边缘欠密合,部分崩瓷」
         / 李露 BJ0D049016「松动I,边缘不密合」全被误销。
       ② 「无义齿修复」含「义齿」却是说**没有** —— 徐磊 BJ0U001139。同一个坑的第二次。
       ③ 反方向:「松动」是坏词,而「无松动 / 未见明显松动 / 松动(-)」是好话,列进 FAIL
         会把好的一起否掉 → 改用**先 regexp_replace 抹掉好话、再判坏词**
         (与 refusal 判定里"先抹掉'拒绝拍片'再判拒绝治疗"同一手法)。
       ④ 「密合」的否定形式**逐个列举列不完**(不密合/欠密合/密合度差/密合性不佳/密合度欠佳/
         密合较差/密合度不佳/密合差…)→ 改用模式 `密合[^标点]{0,3}(差|不佳|欠佳|不良)`,
         且不许跨标点(否则「边缘密合,…口腔卫生差」会误否)。「固位」同理(周锡英 TS0B001674
         「覆盖义齿修复,固位不良」)。
       ⑤ 松动度的写法:「11Ⅰ°松」(王秋枫)/「松动二度」(侯永生)/「牙松动2度」(康振英)/
         「有明显松动」(吴实)。
      
      ── 影响面(六轮导出复核后)──
      在跑计划命中 386 条,平均解 1.8 颗牙,一次解 ≥10 颗的 6 条(全是全口活动义齿/覆盖义齿在位,
      已逐条看过)。周燕芬本人四条件全过(她的计划已 abandoned,故不在"在跑"的导出里)。
      
      ️ 同前两条路线:只往 resolved 加牙位、永不新增召回 → 失败方向只有少召且不报错。
       只对缺失牙(K08)开闸 —— 判据是拿 K08 的检查所见核出来的。
      
      测试 45 例,正反例全部取自生产真实串(含上述五轮踩出来的反例 + 防误否的"尚可/较密合/
      固位良好/无松动"正例)。99 suites / 1753 tests,tsc 0 error。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(召回): 补第 22 例 陶美玉 TS0K070812 —— 种植覆盖义齿复查,两条修复均已覆盖 · dc98ee00
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(召回): 补第 21 例 季炎萍 TS0B010543 —— 首例「治疗项不准」反馈,根子是调磨未入库 · 6ad3c318
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(摄入): 姑息处置改为 route 到 review —— 不进真治疗类目,但也不再丢弃 · 6ee7b8e9
      季炎萍 TS0B010543 牙41~37:下颌活动义齿戴数年,2026-03-19 因压痛来修整边缘。
        主诉「下颌活动义齿戴牙数年余」/ 现病史「…有压痛,今来就诊」
        检查所见「义齿密合良好,边缘过长,牙龈略红肿」/ 处置「调磨过长边缘,抛光」
        本次治疗 treatName =「调磨」→ 被  块丢弃 → **她库里零条治疗事实**,
        系统看成"有诊断、从来没治过" → 判「缺失牙未启动修复」,还推种植(75 岁)。
      客服反馈:abandon_reasons={inaccurate,other} / inaccurate_treatments={implant} /
      备注「BPS已修复」—— 客服抓的是"推种植不准",根子是那条治疗根本没入库。
      
      ── 纠正原  块的根因分析 ──
      原文:「category 同时被①治疗史完整性 ②resolver 判缺口已解 两处消费,只有一个旋钮,
      只能二选一(现选都不要=丢弃)。要两全需在 category 之外再加一维"是否根治"」。
      **前提不成立** —— 第二个旋钮一直都在,就是 review 类目,且它就是为这件事设计的:
        PACTreatmentCategories.review:「医生做了某个流程节点 / 临床判断本次不动手」
          「不该塞 preventive(会污染已做预防判定),也不该丢弃(丢失事实)」
        结构家族注释:「刻意排除 periodontic / orthodontic / preventive / **review**」
        实测:review 出现在 0 个 resolver 家族里
      即 review 精确地给①不给② —— 冠周炎冲洗上药之后仍照常召拔牙,但治疗史里看得到。
      (我中途还错误建议过映射到 preventive —— review 的注释明写着那样会污染"已做预防"判定。)
      
      ── 影响面(DW 全量实测)──
      含这五词的 EMR 5,184;treat_plan **全是**这五词的 4,336 → 进治疗史,不解任何缺口。
      
      ──  单独一条 route,不并进 &review_terms ──
      那个 anchor 被 C.9 dispose 闸门复用(blank_or_all_in)。并进去会同时打开闸门,而那
      4,336 份处置 **100% 非空**:TOP400 处置跑分类器,约 216 条落进结构 resolver 家族,
      且含「冠周冲洗,派力奥上药」→prosthodontic(因为含"冠"字)、「去暂封…玻璃离子暂封」
      →restorative 这类误判 → 会误销缺口。dispose 闸门维持现状(不开 = 不回退,是放弃
      一块带风险的额外收益),要开另行评估。
      两个消费方语义本就不同:route 问"哪些名字不是真治疗",闸门问"何时可安全抽处置"。
      route 算子支持两条规则指向同一 output(outputs 初始化在推数据之前完成),已实测。
      
      顺带:TREATED_EVIDENCE_COMPLETION_RE 量词补「数/多/几」—— 真实主诉大量写
      「戴牙数年余」「多年」而非确切数字,不认就漏(季炎萍即是)。DW 实测 +4 份。
      
      测试新增 palliative-route-to-review.spec.ts(11 例),直接加载真实 manifest 锁住:
      ① 五词落 review 表、真治疗不受影响、建议仍走 recommendation;
      ② dispose 闸门词表仍是原 34 词且不含这五词、两条 route 指向同一 output 是手段不是笔误。
      98 suites / 1708 tests,tsc 0 error。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed