1. 28 Aug, 2026 9 commits
    • 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
    • fix(召回): 举证触发改为「该动作解不开本场景缺口」—— 硬清单会漏牙周 · 79e63437
      王祖妹 TS0B006805 牙46:4 年前种植戴牙,2026-04-25 复诊。
        主诉「种植戴牙4年余按约复查」/ 现病史「4年前在本院种植戴牙,无不适,今按约复诊」
        检查所见「冠边缘密合」/ 诊断 K08 牙齿缺少 46
        本次治疗「转洁牙中心全口牙洁治」→ **periodontic**
      文本证据完美,却因为触发闸硬写成 ['preventive','review'] 而不生效。
      
      牙周洁治对缺牙缺口同样是"什么也没解决",跟宣教/复查没有区别 —— 判据本来就该是
      **类目 ∉ 该场景的 resolverCats**,而不是一张写死的清单。改成 treatedEvidenceTriggersFor()。
      生产实测放宽后只多 3 条命中(周海峰/马丽/王振权),形状与王祖妹一致,全部正确。
      
       但牙周/正畸的牙位是**全口批量**写的(一条洁治记录挂 28 颗),不表达"针对这颗牙",
      拿它当牙位锚会大面积误销 → 这两类额外加牙位上限 8。预防/复查类**不设**上限:
      那些牙位是医生针对性写的,且全口种植修复复查确实会写满一颌(吴庆安 12 / 高惠霞 14 /
      周根娣 15,实测都是对的),一刀切上限反而会砍掉正确命中。
      
      (放宽后的全量复核受限:牙周记录牙位数大,不加上限时导出查询 860s 超时两次;
       加上限后可导出,即上面那 3 条。)
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(召回): 病历文本举证按场景开闸 —— 只开缺失牙,别的场景没验过就不开 · 362f484e
      上一版漏了作用域:抛光那条规则用 cfgFlags.polishImpliesRestoration 只对 K08 开,
      本分支却没设闸,凡是 resolverCats 含 prosthodontic 的场景(整个结构家族
      K00/K01/K02/K03/K04/K08/K09)都会触发。而我那 59 条人工复核**全是 missing_tooth**,
      非缺牙场景一条没验过。
      
      想补验,但非缺牙理由量太大(hard_tissue_damage 单独就 18 万条),导出复核的查询
      两次 860s 超时 —— 也就是"验不完"。少召不报错,验不完就不该默认开。
      
      改为 GAP_FLAGS_BY_PRIMARY.K08.treatedEvidenceFromEmrText,机制保持场景无关
      (判据是牙位级的),开关按场景给。要给别的场景开,先把该场景的命中全量导出人工过一遍。
      
      顺带记下通用性自查的其余结论(都通过):
       · 关联键 emr_external_id  source_encounter_external_id 都是 PAC 规范字段,非 host 字段名
       · 词表在 packages/types 共享层,收的是临床通用词(戴牙/假牙/义齿/修复体/烤瓷冠),
         无 host 痕迹 —— 符合「通用→共享,怪癖→宿主」的分层
       · 宿主不填主诉(如 FRIDAY)→ join 落空,分支自动失效,不报错
       · 类目闸对 K05/K07 天然为空(resolverCats 不含 prosthodontic),全口路径本就不接本表
      
      测试补一例锁住开闸范围。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(召回): 病历自由文本自证已治疗 —— 预防/复查类动作的补充举证 · 50a9c9a5
      王作荣 TS0M002910 牙34:2025-01 戴牙完成,2026-04 复诊重记「牙齿缺少」,当次治疗
      只有「常规口腔卫生宣教」(preventive,不解结构缺口) → 误召。而同一份病历的主诉
      写着「种植戴牙3个月余按约复查」、现病史写着「3个月前在本院行左下后牙种植戴牙」。
      主诉/现病史一直摄在 emr_record 里,只是召回从不读它(召回读过的自由文本只有
      exam_findings 一处,且只用于 NO_RESTORATION_GAP_EXAM_PATTERNS 那四个词)。
      
      ── 大前提不动 ──
      最新诊断永远第一位。 不用「前有治疗、后有诊断」翻案 —— 那是猜疑链(修复体会失败、
      会脱落、会重做)。本分支只在【诊断后 · 同牙 · 本次动作是 preventive/review】这个
      模糊地带,再审同次病历。治疗类动作不走本表(resolver 已正确处理)。
      
      ── 词表独立,不复用治疗名那几张(实测)──
      ① 覆盖不足:REVIEW_IMPLIES_TREATMENT 要求「治疗词+复查」,真实主诉一半以上不带"复查"
         (「种植戴牙3月余」「右侧后牙种植戴牙后一年」)。
      ② 时态不同:treat_plan 里的词一定已发生,主诉里可能是诉求(「要求窝沟封闭」)。
      🔴 ③ 致命反例就在王志荣 TS0K051842 身上:2024-06 现病史「…要求拔除口内剩余牙齿后
         全口义齿修复」vs 2026-01「全口假牙1年半前于我院修复」—— 字面同、语义反。
      否定侧复用 strip 的「建议/推荐/考虑/择期」语义(剥"未发生",跨字段一致)。
      
      ── 上线前把 59 条命中全量人工过了一遍,四条判据全是踩出来的 ──
      🔴 只认「戴牙」不认裸「种植」:种植是多阶段(一期→二期→取模→戴牙)。裸「种植」放行会
         误销掉最该召的:「种植体拔除后两个月」「种植体及牙冠一起脱落」「三周前行种植手术」
         「拔牙后三个月,种植检查」「转诊种植科」「种植导板设计」「种植前检查」。
      🔴 症状词不当完成标记:原先收「脱落/松动」,理由是"不存在的东西不会掉" —— 那只证明
         曾经存在,不证明现在在位;对缺牙修复判定,脱落 = 需要重做 = 该召回。
      🔴 同颌闸:陈春洪 TS0K070402 主诉明说「右上后牙种植戴牙」,那次宣教牙位却写了
         16;17;27;41;42;46;47 跨四象限,会把下前牙一起误销。按颌切比按牙位数设上限有依据。
      🔴 不扫 disposal:生产实测它夹带 toothPositionBak 的 base64,会制造假命中。
         另补「无法就位/无法佩戴」类否定 —— 修复体做了但不能用,仍该召回。
      收紧后 59 → 57 条命中,逐条复核无明确误销(1 条边界:上颌义齿"不贴合",按第 17 例
      童然夫同口径视为在位)。
      
      ️ 本分支只往 resolved 加牙位,永不新增召回 → 失败方向只有少召,且不报错。
      ️ 整颌案例单条命中即解 12~15 颗牙(吴庆安/高惠霞/周根娣),判错代价大,需重点盯。
      扩展点:TREATED_EVIDENCE_EMR_FIELDS。要加 exam_findings 得先解决段内按颌切分。
      
      测试 38 例,正反例全部取自生产真实串(含上述七条踩出来的反例)。
      97 suites / 1685 tests,tsc 0 error。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(摄入): 补「调合/调颌」含词规则 —— 精确表认得单词,认不得组合词 · 0366b572
      陈惠英 TS0K028757 牙34;35;36;37:
        2024-07 起做种植,中间 37 植体失败取出重做,2025-03-21 四颗全部戴牙完成
        2026-05-20 复诊,主诉「种植戴牙1年按约复查」,检查所见「冠边缘密合,牙龈未见异常」
                   诊断「牙齿缺少 34;35;36;37」+ 本次治疗「调合,抛光」(牙位完全一致)
      一年多的种植治疗史被这次重记的诊断挡在时间门外;当天那条本可以过门的「调合,抛光」
      落 _default 整条丢弃 → 生产库里那天只有诊断没有治疗 → 判「缺失牙未启动修复」误召。
      
      ── 与「卡环加力」不是同一种失效 ──
      卡环加力是词表里压根没有;这次两个词**都在词表里**,但都只在**精确表**:
        treatment_actual.yaml:147  调合: prosthodontic / 调颌: prosthodontic
        treatment-category-core.yaml:189  抛光: preventive
      医生把两个词写在一起 → 精确匹配不上,含词表又没有这两个词 → 一个都不命中。
      即"产品早就判定了它算修复,只是接不住组合词"。
      
      ── 影响面(真实引擎代码回放 DW 全量 135,457 个 treatName)──
        改前 丢弃 207,209 EMR → 改后 204,670 EMR
        救回 2,539 EMR / 1,065 词形,新丢 0 词形
      
      ──  顺序是这条规则的命门 ──
      必须排在 restorative 之后。「去腐净,GIC垫,Z250充填,调合抛光。」这类补牙散文含「调合」,
      放进上方 prosthodontic 行会把上千条充填记录改判成修复(两者都在结构 resolver 里,
      召回判定不变,但治疗史和标签会错)。放在 restorative 之后让「充填/树脂」先赢 —— 已实测。
      种植/正畸语境仍由更前面的规则先赢(种植体调合→implant、正畸调合→orthodontic)。
      「调磨」不收,沿用词表文末  块(姑息调整 ≠ 问题已解决);调合=咬合调整,是宿主已判定的修复动作。
      
      测试新增 9 例(含三条生产真实补牙散文的防回归断言)。96 suites / 1647 tests,tsc 0 error。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(召回): 抑制改为「全有或全无」—— 处置是计划级的,抑制就不能逐条 · 1284675e
      陆雪 TS0K090243:
        08-23 客服点一次「已完成治疗」→ 该 plan 名下 4 条理由(缺牙种植 / 牙周 /
              影像AI缺牙 / 残根)全部被压,treated 的 suppressDays = 36500 天(永久)
        08-26 我们的残根→K03 改判把 subKey 从 missing_tooth@17 换成
              hard_tissue_damage@17,它不在抑制集里 → **只有它一条**复活
      结果卡片上:病历三类应治未治、画像标签也写着「种植/牙周/拔牙三类待跟进」,
      召回理由却只剩一条 —— 读的人只能以为算法漏了。而那次「已完成治疗」本身是错的
      (该患者信号后只做过 OHI),等于把两条正确召回永久埋掉。
      
      ── 根因:两个粒度对不上 ──
      处置是**计划级**的(schema PlanExecution.inaccurateTreatments 注释写明:客服勾
      "只有种植不准"也不会只放过根管那条 —— 业务 2026-07-30 明确),抑制却是**逐 hit**
      过滤的。于是产生"部分抑制"这种中间态:一部分理由回来了、另一部分还压着。
      而且这一条复活还是**靠巧合** —— 走的是 `if (!anchor) return true`(键查不到就放行),
      不是设计好的时间锚逃逸。生产实测:2026-08-01 以来 4 例结案后复活,时间锚逃逸 0 例,
      改判逃逸 4 例(王丽芳/孙海燕/吴梅英/陆雪,全部是残根→K03),同一条诊断 subject 未变。
      
      ── 新规则 ──
      缺口开没开由**事实**说了算,抑制只是人为覆盖,不改写事实:
        · 一条都逃不出去 → 整个 plan 不生成(一起抑制)
        · 有任意一条逃得出去 → 本轮**全部** hit 都进 plan(一起生效)
      真治好了的缺口会被 gap 计算自己消掉,轮不到抑制来盖;抑制盖住的从来只是
      ①数据没摄到 或 ②人标错了 —— 这两种都不该永久。
      
      改动本身只有一处:逃逸判定的结果从"过滤后的 hits"改成"要不要整体放行"的开关,
      放行后用全量 hits。旧的时间锚逃逸口径原样保留。
      
      测试:新增 2 例(部分逃逸→全部复活 / 全不逃逸→整体抑制),
      既有 suppressed 用例不变仍绿。96 suites / 1638 tests,tsc 0 error。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(摄入): 补义齿维护族 + 扩根/松牙固定/用氟/洁牙 —— 救回 11,672 条被整条丢弃的治疗 · 67fe9d19
      薛希明 TS0K018752 牙14;15;16;17;25;26;27:
        2025-01-19 戴活动义齿(10 颗,含本次全部信号牙位)
        2026-05-21 复诊,主诉「上颌假牙松2周」,检查所见「活动义齿修复,义齿卡环松」
                   诊断「牙缺失 14;15;16;17;25;26;27」+ 本次治疗「卡环加力」(牙位完全一致)
      2025-01 的戴牙被 2026-05 重记的诊断挡在时间门外;当天那条本可以过门的「卡环加力」
      含词表一个词都不含 → 落 _default 整条丢弃 → 生产库里那天只有诊断没有治疗 → 误召。
      
      ── 影响面用真实引擎代码回放 DW 全量得出,不是手工估 ──
      把 fact_emr_treatment_out 的 135,457 个 distinct treatName 全量喂给 runDerive(normalize)
      + resolveDictIncludes(真实字典) + classifyByKeyword(真实分类器),并按 manifest 的
      route_by_pattern 先剔掉「建议/推荐」和复查词:
        改前  命中 2,146,498 / 分流 501,468 / **丢弃 218,881 EMR(35,817 词形)**
        改后  命中 2,158,170 / 分流 501,468 / **丢弃 207,209 EMR(34,493 词形)**
        → 救回 11,672 EMR / 1,324 词形,新丢 0 词形(无回归)
      
      ── 加了什么 ──
      ① prosthodontic += 卡环, 基托, 重衬, 假牙   478 EMR / 216 词形
         四个词临床上只属于活动义齿。基托选磨 49、卡环加力 40、重衬 26、紧卡环 21…
      ② endodontic    += 扩根                    604 EMR,与已收的「根备」同一动作
      ③ periodontic   += 松牙固定                269 EMR,牙周炎松动牙夹板固定
      ④ preventive    += 用氟                  4,470 EMR,与「涂氟」同一动作
         (preventive 不在结构 resolver 里 → 只补治疗史,不改召回判定)
      ⑤ periodontic   += 洁牙(独立规则,none: 清洁牙) 5,894 EMR
         裸「洁牙」当初被刻意拿掉是怕误命中散文「清洁牙面」。改成单开一条挂 none:
         「清洁牙面/清洁牙齿/清洁牙间隙」(332 EMR)照旧丢弃,其余全收。none 不挂原规则
         是为了混合句「清洁牙面,龈下刮治」仍由原规则正常命中。
      
      ── 碰撞全部实测,不是推理 ──
      活动矫治器也有卡环/基托,但 orthodontic 排在 prosthodontic 之前 → 矫治器卡环调整 /
      正畸基托调磨 / 保持器重衬 全部仍归 orthodontic;扫了 DW 里全部不含正畸词的卡环/基托
      记录,无一条正畸语义。扩弓不含扩根。洁牙后树脂充填仍归 restorative。
      
      只改 actual 侧(resolver 读的那份);planned 侧本就与 actual 有意分歧(它保留裸洁牙和
      裸二期,因计划名无散文),沿用「加牙/活托」只加 actual 的先例。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(召回): 刷新 f.status='active' 不变量守卫的实测依据 —— 5,034 → 109 万 · 3454c739
      reason-temperature.sql 那条注释拿早期 5,034 条证据的观测当依据,量级已差 216 倍,
      读的人无从判断这道守卫今天还成不成立。
      
      2026-08-28 生产全量实测:
        plan=active     1,092,580 条理由 —— 无 active 证据的 0
        plan=assigned       1,265        —— 0
        plan=completed         41        —— 0
        plan=abandoned        282        —— 6
      即引擎重算追得上事实换代,这道过滤没有在挡真数据。
      
      顺带记下它的信号价值:这个数掉下来 = 重算落后于摄入,矩阵格位会静默变空且不报错。
      
      (排查缘起:误以为 evidence.factIds 大面积断链。实际 missing_tooth 的 103,980 条
      理由里,真断链只有 1,297 条且全在 7 月那周的 superseded 历史快照上;先前看到的
      15.4% 是 planned 类信号 occurred_at 本就为空 —— 它们有 planned_for,靠
      COALESCE(occurred_at, planned_for) 取锚,并非缺失。)
      
      纯注释,无逻辑改动。tsc 0 error,96 suites / 1602 tests 全绿。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(召回): 缺失牙位上的裸「抛光」= 修复体在位的证据 —— 牙都拔了,抛的是义齿 · e0a12c44
      李钟瑜 TS0M006971 牙31:
        2025-03-27 拔除 31
        2025-06-26 K08 牙缺失 31 → 活动义齿戴牙 31   ← 真实修复证据
        2026-02-12 复诊重记「牙齿缺失 31」+「抛光 31」← 新信号,把上面的戴牙挡在时间门外
      当日唯一能过时间门的证据是那条抛光,而它归 preventive —— 不在结构家族 resolver
      里 → 判「缺失牙未启动修复」,误召。
      
      31 号牙 2025 年就拔了,牙不存在,抛光的对象只能是那副义齿。这是逻辑蕴含,不是
      统计推断;生产实测也印证:裸抛光落在 K08 缺牙位上共 101 个(患者×牙位),94 个
      (93.1%)在同一颗牙找得到 prosthodontic/implant 的 actual 治疗,患者级 98.0%。
      (当年 REVIEW_IMPLIES_TREATMENT 上线的同口径是 88.2% / 95.2%。)
      
      三条闸,少一条就会静默误销:
       ① 词形:只收光秃秃一个「抛光」(容尾标点)。生产 36 万条带牙位的含抛光记录里,
          绝大多数是「全口龈上洁治,抛光」这类洗牙流程(periodontic,牙位动辄 28 颗)——
          针对天然牙,放行会把洗过牙的患者所有缺牙位一次性解光;其次是「树脂充填…修整
          抛光」「试戴全瓷冠…抛光」这类别的治疗里的一个步骤,它们本来就带 restorative/
          prosthodontic,本来就解得开。「义齿抛光」同理已是 prosthodontic。真正落单的
          只有裸「抛光」→ preventive 这一支(带牙位 2,405 条)。
          「抛光,涂氟」刻意不收 —— 涂氟是全牙列预防,针对天然牙。
       ② 牙位数 ≤ 6:裸抛光里仍有 56 条挂了 7~32 颗牙,那是洁治语境漏进裸词的。
       ③ 只对 K08 开闸(GAP_FLAGS_BY_PRIMARY.polishImpliesRestoration):牙还在时抛光
          就是抛天然牙除渍,什么也不蕴含。
      
      时间方向沿用 afterDxFor,与治疗排除同口径。
      测试 96 suites / 1602 tests 全绿(新增 1 suite / 38 tests);tsc 0 error。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  2. 27 Aug, 2026 5 commits
    • fix(摄入): reparse 源表回溯两处失效 —— 治疗类 reparse 从来没工作过且报成功 · 7ad3aa93
      验证 加牙 补词时发现:reparse 治疗类恒 0 变更(superseded=0 created=0),
      exit 0、日志写"重衍完成"。dry-run 用同一过滤条件能查到流水,实跑却把 0 行
      喂给 transform —— 问题在源表回溯。
      
      traceRawSourceTable 实测:
        treatment_actual_rows   → _treatment_actual_raw_emr   ← 中间表
        treatment_planned_rows  → _treatment_planned_raw_emr  ← 中间表
        treatment_review_rows   → _treatment_review_raw       ← 中间表
        diagnosis_rows          → fact_emr_treatment_out      ← 正确
      
      两个独立 bug,只修一个仍失效:
      ① route_by_pattern 的字段名是 `routes`,代码找的是 `outputs` → 恒 undefined,
         route 从不登记进 byOutput → 凡链路过 route 的资源回溯都停在中间表。
         diagnosis 没踩到只因它的链 split→derive→derive 不过 route。
      ② `_treat_plan_raw → _treat_plan_raw` 这类**原地 derive** 被登记成指向自己,
         resolve 撞环保护当场返回 → 修完①仍停在 _treat_plan_raw。
      
      后果:rawPayload 被灌进中间表,随即被 transform 链用空结果覆盖 → 0 变更。
      守卫 `raw === cfg.primary.table` 判不出来(中间表 ≠ primary 表),所以不跳过、
      不报错、报成功。任何治疗类字典/parser 修补都无法回填存量,且无人会发现 ——
      本次 加牙/SRP/缝线 补词若不修这个,只对新摄入生效。
      
      既有测试 trace-raw-source.spec.ts 的 fixture 也写的 `outputs` —— 跟实现犯同一个错,
      所以一直绿。fixture 已按契约(transforms.schema.ts)改正并留注,新增 8 条断言直接
      拿真实 manifest 回溯四个资源。
      
      测试:新增 8 条,全量 1564 条通过;type-check 17 个错为 main 既有,引入 0 个。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(摄入): 含词匹配改大小写不敏感 + 补 SRP/加牙/活托/缝线 四词 · 66ed2e46
      过碧霞 TS0B006258 牙12;13;21:2026-04-07(信号当天)有一条 treat_plan
      「加牙 @12;13;21」,牙位齐全、时间过闸、类目本该是 prosthodontic ——
      但「加牙」不在任何字典里,落 _default 后**整条治疗事实不落库**,
      于是 missing_tooth@12;13 和 hard_tissue_damage@21 两条召回都留了下来。
      客服标「已治疗」是对的:04-05 取模 → 04-07 加牙 → 04-10 上颌活动义齿佩戴。
      
      ① 大小写:classifyByKeyword 用 text.includes(kw) 原样比较,全链路无一处转小写
         (op: lowercase 算子存在但所有 yaml 未用)。生产实测 `RCT` 3,122 条正常落库、
         `rct` 725 条整条丢弃。改为双侧 toLowerCase;中文恒等,零副作用。
          只放宽含词层,不动精确 enum_mapping(key 可能有意区分大小写,撞了不报错)。
      
      ② 补词(共享层,临床通用非宿主怪癖),落位与碰撞逐个验过:
         SRP  → periodontic  (648+局部SRP 40+SRP+冲洗上药 76;正是 perio_no_srp 要认的治疗)
         加牙 → prosthodontic(裸词 58/50 带牙位;「根管治疗加牙周治疗」被 endodontic 先吃)
         活托 → prosthodontic(活动托牙简称)
         缝线 → surgical     (「拆线」已在精确表 37,253 条;变体「拆除缝线」699 漏)
      
       冲洗/换药/上药/调磨/试戴**刻意不收**并在 yaml 写明理由:对症处置≠根治,
         收了会消掉 K01 阻生牙等真缺口(少召不报错)。根因是 category 同时被
         "治疗史完整性"和"resolver 判缺口已解"两处消费,只有一个旋钮 —— 不在本表解决。
      
      口径更正:早先从 source_event_id 反推的漏配清单作废 —— 宿主改病历会产生新流水
      (幂等键含 updated_date),SRP 实测 1,135 流水 / 729 去重 emr / DW 648 行,
      系统性高估 1.56 倍,且未与 normalize 对齐。本次四词均以 DW 归一后口径为准。
      
      测试:新增 30 条(大小写/四词/碰撞/刻意不收/既有顺序回归),全量 1556 条通过;
      type-check 17 个错为 main 既有,本次引入 0 个。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(召回): 「种植/正畸/牙周复查」按类目解除对应缺口 —— 复查是修复体在位的证据 · 0d458785
      宋志宏 TS0M001982 牙46;36;37:三颗种植冠在位、按约复查,却被判"缺失牙未启动
      修复"。那次就诊里四处表述都说明修复体存在(诊断「种植术后」/ 本次治疗「种植复查」/
      检查所见「种植冠无松动」/ 主诉「种植戴牙术后1年余」),PAC 一处也没接住 ——
      唯一带牙位的证据「种植复查」落在 review 类目,而 review 被刻意排除在
      STRUCTURAL_RESOLVER_CATEGORIES 之外。
      
      那个排除本身是对的:生产带牙位的 review 共 4.4 万条,「观察」11,165 /
      「观察,必要时拔除」928 /「无治疗」623 —— 这些恰恰说明还没治,整类放行会误销。
      
      做法:新增 REVIEW_IMPLIES_TREATMENT(复查词 → 蕴含的治疗类目),
      resolvedTeethSql 加一条 UNION 分支,两道闸都不能松:
        ① 词形闸:必须「治疗词 + 复查/复诊」——「正畸复诊」(在做)vs「正畸会诊」(还在谈)
           只差一字,生产各 35 / 103 条;泛指「复查/常规复查/定期复查」刻意不收。
        ② 类目闸:按 resolverCategoriesFor 过滤 —— 生产实测 19 条候选里 9 条是跨类目
           错配(牙周复查去解 K02 龋齿 4 条 / 种植复查去解 K06 牙龈 1 条 /
           正畸复查去解 K01 阻生 1 条),必须挡住。
      
      生产验证前提:带牙位「种植复查」覆盖 3,893 个牙位,3,434(88.2%)在同一颗牙上
      找得到 implant/prosthodontic 的 actual 治疗,患者级 95.2% —— 蕴含关系成立。
      剩下 11.8% 正是本规则要救的(外院种的 / 摄入窗口之前 / 治疗记录漏牙位)。
      
      影响面:窄口径 19 条候选 → 类目闸后实际约 10 条生效。宽口径(subtype 含「复」)
      391 条**未采纳** —— 那 372 条差额混着"一颗从没治过的牙上做了常规复查",
      误销的代价不对称(少召不报错)。全口场景(K05/K07)实测无一命中,gapWhere 不动。
      
      测试:新增 39 条(词形闸 24 / 类目闸 7 / 表自洽 3 等),全量 1526 条通过;
      type-check 17 个错为 main 既有,本次引入 0 个。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(召回): 残根/残冠的目标类目改外科 —— 与分配的「拔牙治疗」对齐 · cccb88e8
      孙海燕 TS0M012582 牙16;18;28;47 是残根,分配矩阵已正确落在「拔牙治疗」列
      (POTENTIAL_LABEL_RULES: K03 + EXTRACTION_NAME_KEYWORDS → extraction),
      卡片上的目标标签却是「充填 / 嵌体」—— 残根没法充填。
      
      根因:focusCategory = recCats[0],而 K03 类目序固定为
      [restorative, prosthodontic, surgical],同码内不区分「补得回来」(楔状缺损/
      牙体缺损)与「补不回来」(残根/残冠)。跟 K00 那次(乳牙早失被打「目标·外科」)
      是同一形状的问题。
      
      做法:把 refineCategoriesForDiagnosis 从 K00 专用改成表驱动
      (LEAD_CATEGORY_RULES_BY_CODE),新增 K03_LEAD_CATEGORY_RULES。
       词表直接复用 EXTRACTION_NAME_KEYWORDS —— 分配标签与召回目标同源,
      不会再出现「进了拔牙列、卡片写充填」这种自相矛盾。
      
      只挪位不增删,排除闸不受影响;K03 无 implant,年龄闸空转不会挪回去。
      前端 reason-line / 目标标签共用同一函数,自动跟随。
      存量 plan 靠 reason-refresh 的 unchanged 就地刷新拿到新 focusCategory,
      不升版本、不丢认领。
      
      测试:新增 22 条(含 K00 回归 + 标签⟺目标同源断言),全量 1487 条通过;
      type-check 17 个错为 main 既有,本次引入 0 个。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  3. 26 Aug, 2026 4 commits
    • fix(召回): 残根/残冠归 K03 —— K08.3 被截成 K08 后误判"缺失牙未启动修复" · 4d9c6f13
      王丽芳 TS0M013040 牙42 是残根(医生计划残根拔除术),召回却给出"邀约启动缺失牙
      修复(种植/桥/义齿)",分配矩阵也落在「种植治疗」列。
      
      根因:源 ICD 用 K08.3 牙根残留(医学上没标错),std_code 截前 3 位后跟 K08.1
      后天缺失挤进同一个 K08。而医生没填 std_code 时按中文名映射本来就落 K03
      (全库 97,970 条)—— 填了码的这 932 条反而归错,少数派拉回多数派。
      
      K03 的 categories 含 surgical、目标文案本就写着"残根/残冠 → 充填/嵌体/拔除
      修复",POTENTIAL_LABEL_RULES 的 K03 + EXTRACTION_NAME_KEYWORDS → extraction
      (拔牙治疗)也正等着这批名字。
      
       带反向闸:复合诊断("牙列缺损,残根"等 325 条)两个病挤在一个 name,
      翻 K03 会丢掉缺牙那一半 → 保持 K08。
      
      落点选 reconcileDiagnosisCode 而非 manifest recode:源里同概念已有 3 个码变体
      (K08.300x002/K08.302/K08.300),精确匹配必漏;按(K大类+诊断名)判更稳,且共享层
      将来对 FRIDAY 同样生效(今天 FRIDAY 无残根诊断,影响面为零)。
      源码不丢 —— transaction.rawPayload 原样留着 stdCode。
      
      影响面(生产实测):932 条诊断事实 / 776 位患者 / 149 条在池召回理由,
      全部从 implant(种植治疗)列挪到 extraction(拔牙治疗)列,其中 6 条已分配、
      会走 staleRows 收尾补 auto_release。上线需 reparse --subject-type=diagnosis。
      
      测试:新增 22 条,全量 1465 条通过;type-check 17 个错为 main 既有,本次引入 0 个。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(生产): 主进程堆上限 4G→8G —— 每 4 小时崩一次,三天没跑成过一次全量 plans · 470a44d9
      【症状】容器 exitCode=139 自动重启,08-23~08-26 共 **11 次**,间隔约 4 小时。
        页面无感(自动重启 + health 200),所以一直没人发现。
      
      【根因】每轮增量同步结束会在**主进程内**跑一次 runAllForHost 全量召回。补摄把患者
        灌到 111 万、召回池 54 万之后,selectHits 一次出 **96 万条命中**,连同 prefetch 的
        latest/snoozed/persona 全在堆里 —— 而主进程启动命令
        `node --enable-source-maps dist/main.js` **没带 max-old-space-size**,
        取 Node 默认 **4144 MB**,装不下:
          Mark-Compact 3985.6 → 3953.5 MB (mu = 0.008)   ← GC 已经回收不动
          FATAL ERROR: Reached heap limit — JavaScript heap out of memory
      
      【代价】那三天 `Result(ruier-grp)` 在日志里出现 **0 次** —— 一次完整的全量 plans
        都没跑成过。每轮崩在 selectHits 之后、写库之前:
           增量导入成功落库    persona 重算成功落库    plans 整轮作废
        数据不丢,丢的是"召回池的定时刷新"。另外每次崩溃留一个僵尸 sync 锁(重启后被回收),
        且会带走同容器里正在跑的 docker exec —— bj04 第一次尝试就是这么死的(白跑 1h27m,
        靠 batch.sh 的 3 次重试救回)。
      
      【为什么现在才发现】补摄期间增量被 sync 并发锁挡下(00:15/08:15/10:15 全是 skip),
        不跑全量 plans 就不崩 —— 实测连续 12 小时零崩溃。补摄一停,下一轮就会再崩。
      
      ️ 排查时别往"容器内存不够"上找:容器**没有内存上限**(HostConfig.Memory=0),
        宿主 15.36G 一直有 7-11G 空闲。撑爆的是 V8 自己的堆,与宿主余量无关。
      ️ 同一容器里 CLI 那条路(cold-import / recompute-plans)一直是 8192,
        **两条路配置不一致**,崩的一直是没配的这条。这次补齐。
      ️ 宿主内存账写进注释了:8G(服务) + 8G(CLI) = 16G > 15.36G。实测两者不会同时顶到
        上限(max-old-space 是天花板不是预留;实测峰值 服务 4G / CLI 4.9G),但
         别手工并发跑全量 `recompute-plans`(它不走 sync 锁)。
      
      ️ **这是止血不是根治**:池子还在涨,8G 迟早也会到顶。根治是「增量之后别跑全量」——
        recompute-plans 已经有 --clinics 收窄能力,同样的思路搬到
        SyncIncrementalSchedulerService 那一处即可。另开分支做。
      
      改动只有一行环境变量(docker-compose.prod.yml 的 pac-service),不动代码不动数据。
      YAML 已校验;managed overlay 只合并 DATABASE_URL/REDIS_URL(非 !override),不影响本项。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  4. 23 Aug, 2026 4 commits
    • fix(宿主跳转): 按子串换,别按域名标签精确匹配 —— arrail-test 漏了 · 8827ac0b
      2026-08-23 测试环境实测:瑞泰患者点跳转,还是去了 arrail。
      
      根因:上一版把 hostname 拆成标签、找**等于** `arrail` 的那一段。而测试环境的域名是
      `arrail-test.5i5ya.com`,标签是 `arrail-test` —— 精确匹配匹配不上,一个字都没换。
      各环境的域名前缀会带后缀(test / uat / …),枚举不完。
      
      ⇒ 认这个**词**本身,不认它周围是什么:hostname 里出现的别的品牌片段整词替换
         (arrail-test → rytime-test)。
      
      ️ 仍然只换 hostname, 不碰路径和查询串(`?from=arrail` 是宿主自己的业务参数)。
      ️ 解不出 URL(相对路径 / 哨兵值 'postMessage')仍原样返回。
      
      判据本身没问题 —— 同一时刻查过那个患者:sourceUnit='瑞泰',取值是对的,
      只是替换规则太严。补回归用例(带环境后缀的域名),前端 15 条全绿。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(宿主跳转): 瑞泰患者跳瑞尔门户 —— 打开时把 arrail 换成 rytime · eb5d8b9f
      jvs-dw 底下两个品牌各有一套门户:瑞尔 arrail.5i5ya.com / 瑞泰 rytime.5i5ya.com,
      两边**只差域名这一段**,路径参数完全一样。而 host.actionUrls 是 host 级配置、只有一套
      (现在配的是瑞尔那套)。于是瑞泰患者点「查看档案 / 病历 / 新建预约 / 回访」,
      会被带到瑞尔门户 —— 那边查不到这个人。
      
      ⇒ 打开的那一刻按**患者所属品牌**把域名换掉(lib/host-brand-url.ts)。
      
      【为什么按患者判、不按登录人判】
      登录人的品牌前端**拿不到**:/auth/session 下发的 sourceUnits 对诊所级用户是哨兵
      `__pac_deny_no_brand__`(那条路按 clinicIds 过滤,不走品牌维)。2026-08-23 拿生产
      三家诊所的 token 实测,全是这个值 —— 判不出品牌。而患者的 sourceUnit 是实打实的品牌名。
      语义上也是患者对:这些链接要打开的就是**那个患者**在宿主里的页面。
      
      【改了什么】
      - 后端 plan-aggregate:患者 + **关系人**都下发 sourceUnit
        (关系人用他自己的, 不拿本人的顶替 —— 亲属跨品牌时会跳错门户)
      - 新增 lib/host-brand-url.ts:applyBrandHost(url, sourceUnit)
      - resolveActionUrl / openHostAction 加可选的品牌参数;plan-detail-app 六个调用点全传上
      
      【两个容易漏的点】
      - 🔴 **HOST_ORIGIN 也必须换** —— 它是 postMessage 的 targetOrigin。PAC 嵌在 rytime 里
        而 targetOrigin 写着 arrail,浏览器**静默丢弃**这条消息:按钮点了没反应,控制台连个错都没有。
      - ️ **只换域名,不碰路径和查询串**:`?from=arrail` 是宿主自己的业务参数,替换它属于越界。
        URL 解不出来(相对路径 / 哨兵值 'postMessage')原样返回 —— 这条补丁不许把能用的链接变没。
      
      【纪律】 别把它做成通用的"多品牌路由"。它成立的前提是"两个门户只差一个域名片段"。
      真要支持多品牌,正解是 actionUrls 按品牌分组配置(host 管理页加一层),那时删掉这个文件。
      
      【验证】
      - 新增 host-brand-url.test.ts 14 条:替换 / 幂等 / 只换域名不碰路径查询串 / 哨兵值与
        相对路径原样 / 未知品牌与空值不动 / **每个调用点都带上了品牌参数**(源码断言,
        防的是"新加调用点时忘了传" —— 参数可选,漏了 tsc 不报)
      - 本地实测 /plans/:id/full:瑞泰患者 sourceUnit='瑞泰'、瑞尔患者 ='瑞尔'
      - 两端 tsc + 后端 1443 用例 + 前端 46 用例全绿
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  5. 22 Aug, 2026 5 commits
    • docs(口径): 钉住预约成功率的分母 —— 不筛结果项,未接通也算他打过 · e46bd0dd
      产品确认(2026-08-22):分母判据只有一条——**他有没有真打**。
      所以「没进展」那一桶(未接通 / 待跟进 / 需要找医生 / 已发短信 / 改约)
      照样进分母:打了没人接,那也是打了。
      
      ️ 未接通往往占分母的大头,所以这个数读的是「触达 × 转化」合起来的效率,
       不是"聊上了的人里谈成几成"。 别为了好看把 keep 组从分母里摘掉——
      那会变成另一个指标,而名字没变。
      
      唯一不在分母里的是「没有结果」:那些单一条执行记录都没有,天然不在
      plan_executions 里,不需要额外排除。
      
      代码一行没动,只是把这条决定写进 schema 注释 + 加一条回归(分母 SQL 里
      出现 outcome 就红)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • refine(主管工作台): 约上→预约上、完成→近N天已处置、成功率只出一个值 · 6d74e284
      主管走查第二轮:
      
      ① 「还没收尾」说明补「(含已经超期的)」
         到期不再自动回池(回收器 2026-08-19 删了),过了时限的单就压在客服手上,
         批次靠 inHand>0 留在这一组。不写明的话主管会以为过了时限的都沉到历史里了,
         而那恰恰是最该动手的一批。判据一个字没动。
      
      ② 「约上」→「预约上」
         「约上」仍有歧义:约好了既可以是约了预约、也可以是约了下次再联系,
         而这两列分的正是这件事。
      
      ③ 「完成」→「近 N 天已处置」
         主管问「完成和已处置是同一个意思吗」—— 是。同一个判据(真的落了通话结果),
         差的只是范围(本批 vs 近 N 天),而同一屏上叫了两个词。
         而且「完成」听起来像"做成了",是这套口径一直在躲的读法。
         ⇒ 统一叫「已处置」,并带上窗口前缀(预约成功率同)——
         不带的话切了 7天/30天,数字变了却看不出为什么。
      
      ④ 预约成功率只出一个百分比
         下面那行 rateNote(退回 N · 没动 M)删掉。
          released / idle 没有消失,仍在响应里 —— 只是不该挤在这一列下面。
      
      顺带拆掉一颗雷:AgentWorkloadRow.handled(= done + released)删了。
      它和界面上的「已处置」(只算写了结果的)是两个意思,同一个词两个含义 ——
      主管问的正是这个。服务端留局部量 touched 算「没动」,语义不变。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(主管工作台): 「约上」拆成约上/约下次,退回率换成预约成功率,「还在跑的」改名 · 0d7c6a53
      四处口径/措辞,主管实测提的:
      
      ① 「还在跑的」→「还没收尾」
         被读成"分配还没完成",像有个后台任务在跑。它说的是**批次**的生命周期。
         ️ 没叫「时限内」:判据有两支,另一支(还有单压在客服手上)在时限过了
         之后仍然成立,叫「时限内」会让过了期、单还压着的批次莫名待在这一组。
         判据一个字没动。
      
      ② 「约上」收窄成**只数转化新预约**,约定下次回访单列「约下次」
         两者原来都算 close 组、合成一个数,靠小字解释「含约定下次回访」。
         而两件事差着一整个台阶 —— 前者患者进了预约表、有个日子会来;
         后者只是客服答应"那天再联系您",患者什么都没答应,单子还挂在他手上
         (drivesStatus 一个 completed 一个 keep,状态机上就是两回事)。
         主管看到「约上 6」会当成 6 个人要来了。
      
      ③ 抽屉「通话成效」四个桶 → 五个
         第一格「成功」拆成「约上 / 约下次」。要靠小字解释的数,本身就该拆开摆。
         五个桶仍然穷尽(= 条数),回归里锁着。
      
      ④ 「退回率」→「预约成功率」
         分子 = 真的约上的( 不含约定下次回访 —— 算进来的话,一个只会往后拖的人
         能拖出一条漂亮的成功率);
         分母 = 「完成」他真打过的( 不是"已处置" —— 退回的单他压根没打,
         摆进分母等于因为他退单而扣他的成功率)。
          退回和没动没有消失,降级成后面那行小字:两者仍是主管调分配的输入(T7)。
      
      - schema: brief/detail 加 bookedNext,outcomes 加 appointed/scheduledNext
        (success 保留为两者合计, 但界面不再单独摆它),workload row 加 booked
      - SQL 由「close 组 IN (...)」改成按 outcome 逐项 FILTER,DISTINCT ON 不动
      - 助手解读规范同步(guides.ts),旧那句「outcomes.success 含约定下次回访」删掉
      - 新增 booked-vs-scheduled-next.spec.ts(21 条)
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(助手): 「这批都给某某」改不动确认单 —— 三层各错一处,且都不报错 · 245f0b01
      本批 5 人,主管说「这5人都分配给韩维」。界面回「已把整批铺给 1 位客服()」,
      括号是空的:一条都没动,而助手接着说「这版已经改成 5 人都归韩维」。
      
      三层:
      
      ① schema 判断本身就是错的
         `batch + assign` 被 refine 判非法。而 batch 是这句话唯一自然的表达,
         韩维就印在卡片下面那行「本批未分到」里。
         改判:batch + balance 合法;只有 batch + owner 不行 ——
         专属关系只有「待分配」那份数据带着(pending[].ownerUserId),
         已排好的条目卡片上查不到它归谁专属。
      
      ② 那条 refine 一次都没跑过(根因,不是 ① )
         两个改单工具的入参用的是**手写的 JSON Schema**(给模型看的那份),
         zod 那份从没进过运行时。两份 schema 各写各的,
         写在没跑的那份里的约束只是注释。
         ⇒ 加 `checkEditOps`:推给界面之前按 zod 校一遍,校不过整组退回给模型,
         并说清哪一条错在哪(只说「参数错误」它会原样再发一遍)。
         确认单、调整单同一条路。
      
      ③ 界面把 batch 解成空名单
         `return { planIds: [], label: '整批' }`,注释说"只有 set_expiry /
         set_benefit 走得到这里" —— 而那两件在上面就短路了(走批次级控件),
         真正走到这一行的恰恰是 assign / remove。
         改:整批 = 生效条目 + 还留在待分配里的,与顶栏「共 N 人」同一口径。
         另加一道总闸:选中 0 人就到此为止 —— 下面每个分支都会 push 一句
         "已如何如何",一条没动而主管读到的是成功。
      
      顺带(同一类):owner 分不下去的两种原因分开报。
      「卡片没带这条的专属关系」被报成「查不到在岗的专属客服」,
      那是一句关于数据的断言,而卡片没有资格下这个断言。
      
      - 整批移出仍然拦掉,但界面要吭声, 不许静默什么都不做
      - 工具描述补 batch 那一格该怎么填;ASSISTANT_PROMPT_VERSION → 2026-08-22-a
      - 新增 sheet-edit-batch-assign.spec.ts(14 条),改判 mcp-clinic-scope 两条旧断言
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  6. 21 Aug, 2026 7 commits
    • merge: perf/batch-unnest → main · 0e32600a
      luoqi committed
    • feat(埋点): user_activities —— 一张通用行为表(PV/UV + 漏斗 + 登录审计) · 1eddc1b6
      主管工作台此前没有任何使用数据可查,而 plan_event_logs 的 plan_id 是 NOT NULL,
      工作台没有「单」这个实体可挂 —— 这是「没有表可以落」的由来。
      
      不为工作台单独建表,建一张通用的:后面再有页面要看数据,加两个枚举值即可,不用迁移。
      
      【一张表回答三类问题】
        ① 谁在用    —— 按 (day, surface) 聚合;PV=条数,UV=distinct user_id
        ② 卡在哪一步 —— 同 surface 下 action 逐级衰减(打开 → 点格子 → 出确认单 → 确认)
        ③ 登录审计   —— surface='auth' 的行。PAC 此前**不记录登录**(没有 session/login 表,
           role 只活在 JWT 里),「谁何时以什么角色进来的」事后无从查证,这次补上。
      
      【几条纪律,都写进了 docs/design/user-activities.md】
      -  身份不由客户端给:userId/role/orgScope 一律从 JWT 取。上报体是 strictObject,
        前端多传一个 userId 被 Zod 直接拒(已实测:code=10002 unrecognized_keys)。
      -  页面 PV 不许塞进 plan_event_logs:那是「单的归属账本」,放空 plan_id 会让所有
        按 plan 聚合的查询悄悄多算,且不报错。
      -  surface/action 在库里是 text 不是 PG enum:加埋点不该要迁移,且改枚举不会让
        历史行变非法。role 同理存快照文本 → 读取侧 roles 是 string[] 而非 UserRole[],
        否则会出现「写得进读不出」。
      - ️ 埋点是旁路:前端不 await 不抛(微批 400ms + pagehide 冲刷),后端写失败只落日志。
        埋点挂了不能让主管点不了确认。
      
      【已埋 6 个点】
        auth/token_exchange      宿主后端换票(无 Origin —— 服务器间调用)
        auth/code_exchange       浏览器换 code(有 Origin = 从哪个宿主系统进来);只记首次
        supervisor_workbench/open            权限判定之后 —— 被弹走的客服不算一次访问
        supervisor_workbench/matrix_cell     {treatment,temperature,count}; 不记 rect(屏幕坐标换分辨率就没意义)
        supervisor_workbench/proposal_shown  按 requestId 去重,只在 active 记
        supervisor_workbench/assign_confirm  服务端返回之后 —— 点了但失败不算确认
      
      【本地实测(pac-service:3101 + pac-web:3100 + 真浏览器)】
        - 正常上报 accepted=2;伪造 userId 被拒 10002;无 token 拦在 10106(库里无脏行)
        - 真走一次换票+换 code:两条 auth 行落库,Origin 只出现在 code_exchange 上(符合预期)
        - 浏览器打开工作台 + 点格子:open/matrix_cell 落库,payload 正确
        - stats 端点:PV/UV 正确;leader 调被 platform:manage 拦下(10107)
      
      【过程中修掉的三个自己的坑】
        - track 路径漏了 /pac/v1 前缀(api-client 用 new URL(path,base) 不自动补)→ 请求打到
          /activities,envelope 里其实是 404 但 HTTP 仍 200(项目约定),网络面板一看才发现
        - open 记了两条:React 18 StrictMode 双挂载 + canDispatch 翻转 → 加 ref 守卫
        - session_id 恒空:access token 没有 jti(只有 refresh token 有)→ 改用 sub:iat,
          同一次登录内所有行为共享,漏斗能串起来
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • perf(分配): 落库改 unnest 数组 —— 甩掉 PG bind 上限那堵墙,上限 500 → 20000 · aabd8ae9
      生产实遇(杭州大厦 2026-08-20):23 位客服按默认值 23×15×3=1035,提案出了 537 条,
      主管点确认只回一句「请求字段校验失败」,在卡片上改什么都没用。
      
      根因不是业务,是**写法**:落库那条 UPDATE 用 `VALUES (…),(…),…`,每行 9 个 bind,
      而 PG 单条语句上限 32,767 → 32767/9 ≈ 3,640 行就是硬墙。测试机实测 N=4000 直接报
      `too many bind variables in prepared statement, expected maximum of 32767, received 36000`。
      
      ── 改法 ──────────────────────────────────────────────────
      每列打成一个数组,`unnest($1::uuid[], $2::text[], …)` —— **不管多少行永远 9 个 bind**。
      墙没了,而且顺带更快(PG 不用再解析几千个占位符)。
      
      测试机实测(事务内跑完回滚,一行数据没改):
        N=500    VALUES 444ms → unnest  74ms
        N=2000   VALUES 458ms → unnest 220ms
        N=4000   VALUES 炸  → unnest 433ms
      忠实形状(13 列全写 + 同事务账本写入),线性到 5 万条:
        500→0.25s  2千→0.6s  5千→1.4s  1万→2.7s  2万→5.6s  5万→13.6s(≈0.27ms/条)
      
      ── 事务保持不变 ──────────────────────────────────────────
      · 仍然是**一个事务**,全成或全不成 ——  没有拆成多批提交
      · 并发安全的那三个 where 守卫(status='active' / assignee IS NULL / superseded_at IS NULL)
        和 RETURNING 一字未动 —— 抢先认领的行照旧落不上、照旧出现在 skipped 里
      · 超时改成**随条数放宽**:30 秒打底 + 每条 3ms,上限 180 秒。
        一刀切 30 秒的话小批次白留 100 倍余量、大批次又不够。 不设无限:事务开着就持有行锁。
      
      ── 一起改的 ──────────────────────────────────────────────
      · 调整在手的三条写路径(改派/改时限/移出)同样换 unnest
      · 那条 `fp.id IN (Prisma.join(...))` 换 `= ANY($1::uuid[])`(3 万个 id:521ms → 219ms)
      · 报错文案说清他能动哪个旋钮 —— 他手上只有时限和每天几通,没有"本批人数"那个输入框
      
      ️ 上限没敢一次拉满。再往上调之前要先量这三样(注释里列了):事务外那几个
         Prisma `id:{in:[]}` 读(~32,000 个 bind 就炸)、推给浏览器的 6.3MB 载荷、卡片渲染 2 万行。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(recompute-plans): 加 --clinics 子集重算 —— 补摄后不必再全 host 全扫 · d5dce069
      按诊所补摄的三步里,前两步(cold-import / recompute-persona)都能用 --clinics 收窄,
      只有 plans 是全量:每批 patientsHit 26-29 万、耗时 1h50m,其中约 1h43m 花在 selectHits
      全表扫。今晚四批补摄,plans 一项就吃掉约 7 小时,而 90%+ 的扫描结果是 unchanged。
      
      本次给 recompute-plans 加 --clinics=<orgId,...>,口径与 cold-import / recompute-persona
      完全一致(patient_transactions.clinic_id 反查患者),把 selectHits 收窄到子集。
      
      ️ 关键点:收窄命中集**必须同步收窄关闭步骤**。runAllForHost 第 3 步会扫全租户的
      active+assigned,凡患者不在本轮命中集就判定"信号消失"关掉,挂在客服名下的还各记一条
      auto_release。只收窄 selectHits 不收窄关闭 = 一条命令清空整个召回池。故 runAllForHost
      新增 patientIds 入参时,两处一起收窄;关闭侧用内存 Set 过滤而非 SQL in(),避开 PG
      32767 bind 上限(子集动辄数万)。
      
      ️ SQL 形式踩坑:子集过滤写 `IN (SELECT … unnest(array))` 而非 `= ANY(array)`。
      结果等价但代价差一个数量级 —— 后者让规划器转去嵌套循环,而本 SQL 带 gap lateral join
      每行代价很高。本地实测(30000 患者库 / 子集 5825 人 / 11 个子场景):
        = ANY(array)          53.6s  ← 比全量 42.3s 还慢,perio_no_srp 一个就 35.5s
        IN (SELECT unnest())  10.2s  ← 命中数逐个相同,快 4.2 倍
      细节写进 treatment-initiation-recall.scenario.ts 注释,改那行前先按同口径量一遍。
      
      本地验证(jvs-dw 30000 患者 / 18591 active + 283 assigned):
        - 子集跑(5825 人):patientsHit 2552,plansClosed 0,duration 9.8s
          范围外 active 16317 / assigned 278 —— 跑前跑后逐字相同,一条没动
          (没有护栏的话这 16595 条会被全部关掉,plansClosed=0 就是护栏生效的证据)
        - 全量跑:patientsHit 19147,日志标 (全量),行为与改动前一致,无回归
        - 重复跑幂等:第二次 created=0 superseded=0 closed=0
      
      ️ --clinics 只用于「补摄后立刻补计划」,**不能替代日常全量**:召回是时间驱动的,
      数据一个字没变、沉默时长跨过阈值也该出计划,收窄就等于其余诊所当天不评估。
      定时全量照跑,这个参数只省掉补摄后的那一次全扫。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  7. 20 Aug, 2026 6 commits
    • fix(web): 冷启动那次续期绕过了共享单飞 —— 一次性 refresh token 被换两次 · 2207d61b
      「用着用着登录已过期」的根因,不是超时也不是网络,是 10103。
      
      生产 client-diag 实录(2026-08-20,两次):
      
        ev=start                accessValid=N      ← 隔了 2 小时回到页面,access 到点过期
        ev=refresh-ok                              ← bootstrap 自己换了一次,成功
        ev=runtime-refresh-fail err=code=10103     ← 首屏请求 401 兜底刷,拿的是同一张旧票
        ev=runtime-auth-clear   err=code=10106     ← 清凭据 → 弹「登录已过期」
      
      服务端的 refresh token 是**一次性**的:换票时 `redis.del(旧 jti)` 再发新的
      (auth.service.ts:468)。同一张票被两条路各换一次,后到的那条必得 10103。
      
      ️ 这个坑**被发现过一次,只修了一半**:本文件末尾"过期前 60s 定时刷"那段
         早就改成走 `tryRefresh` 了,注释把根因写得清清楚楚 —— 唯独 bootstrap 这条漏了。
         而它恰恰是**每次冷启动**都会走的路径,比定时器常见得多。
      
      改法:bootstrap 第 3 步(仅 refresh token 还在 → silent refresh)改走
      api-client 的共享单飞 `tryRefresh()`,与请求 401 兜底刷共用同一个"在途 refresh"。
      
       别改成"把 refresh token 做成可重复使用":一次性是安全设计(泄露的旧票立即失效)。
       也别在 doRefresh 里对 10103 重试:票已经死了,重试只会多一次 401。
      
      顺带核实 doRefresh 的清除边界是对的, 别动:
        5xx        → 不清 store(可能是暂时的)
        网络异常   → 不清
        服务端明确拒绝(10103)→ 才清
      所以掉线主因确实只有这一个竞态,不是网络抖动。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • merge: test → main —— 调整在手 + 三个分配修正上生产 · b7132fae
      带上去的:
      · 调整各客服在手的单(换人 / 改时限 / 收回)—— 主管终于能动已经分下去的单
      · 批次表新增「回收」列,「退回」改叫「客服退回」
      · 团队状态六列全部按诊所收窄(别家诊所的负荷串进本诊所工作台)
      · 提案封顶到单批硬上限 500(23 人的诊所按默认值必然出一张确认不了的单)
      · 准星最边上那一列/那一行被裁
      
      ️ 需要一次迁移:plan_event_logs 加 operation_id(可空 + 索引)。
         生产实测该表 2,298 行 / 1MB,普通 CREATE INDEX 毫秒级,不用 CONCURRENTLY。
       不需要重摄、不需要重算 —— 本次没动召回算法,也没动画像。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • merge: 调整在手 + 三个分配修正 → test · 504b0e19
      带进来两部分:
      · fix/session-batch 那三个修正(团队状态按诊所收窄 / 提案封顶到 500 / 准星裁切)
        —— 它与 feat/plan-rearrange 指向同一个提交,合一次两边都进来
      · 调整各客服在手的单(换人 / 改时限 / 收回)+ 批次表的「回收」列
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(调整在手): 主管可以调已经分下去的单 —— 换人 / 改时限 / 收回 · 523d260f
      门诊经理提的:单子分下去之后主管什么都做不了。原来只有一条绕道 ——
      让客服退回池子再重新分一批,而那条路有五处漏水:退回原因被污染、回池后
      不保证拿得回来、掉批次(旧批 planned 静默缩水)、福利断了、超期被洗掉。
      
      现在:主管跟助手说一句 → 助手出一版**调整单** → 他过目微调 → 手动确认。
      行为跟确认单一致,但是**独立的一条路,不碰确认单**(产品定:不要污染)。
      
      ── 数据层(最小)──────────────────────────────────────────
      · plan_event_logs 加 operation_id(可空 + 索引),把同一次调整的 N 条串起来。
         没借用 assignment_id:那一列没有外键、塞得进,但批次报表全在它上面出数,
          两个 id 空间混一列迟早串台。
      · 三个新事件 transfer / expiry_changed / rearrange_remove,都 byHuman:false。
        🔴 移出**绝不能复用 release**:批次的退回原因分布只按 assignment_id 过滤、
          不看是谁干的,复用会同时污染那张分布、并给原客服记一笔他没做过的退回。
      
      ── 落库规则 ──────────────────────────────────────────────
      · assignment_id 一律不动(原批归属完整保留,分母不塌)
      · 逐条落,落不上的如实回报 ——  不照抄 create() 的「数量对不上整体拒绝」:
        主管调了二十条因为客服刚打完一条就全作废,一次就废掉这个功能
      · 幂等:同一个 operationId 重复提交一个字都不写
      
      ── 批次表认得这件事 ──────────────────────────────────────
      主管回收的单原来在批次表上凭空消失(既不在没动、也不在退回)。
      新增「回收」一列,「退回」改叫「客服退回」,两者在列表 / 详情 / 按客服拆
      三处都分开;进度那句也补上第三种来路。
      
      ── 助手 ──────────────────────────────────────────────────
      三个新工具(propose_rearrange / edit_rearrange_sheet / show_rearrange),
      与确认单那三件完全隔离。两条纪律写进描述:
      · 出完就停,等他说怎么调 —— 把患者从一位客服手里挪走是关系层面的决定
      · 他说出了要动哪些才动手;只说不满意什么的时候,那三个数是他要定的
      
      ASSISTANT_PROMPT_VERSION → assistant@2026-08-20-f
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(web): 准星最边上那一列/那一行被裁 —— 放大挪到数字上,别放大按钮盒子 · 2b8ba8b4
      现场:「3 年以上」那列(最右)瞄中时,左两角完好,右两角只剩两个短横杠、
      竖臂一条不见。
      
      几何(格子 90px):
      
        渐变面为圆角带 overflow-hidden
        button 上的 scale(1.06) → 盒子右边缘外溢 2.70px
        准星 ::after inset:2px  → 右边缘落在「容器右 + 0.58px」
        ⇒ 竖臂(缩放后 1.59px 宽)被裁掉 0.58px,横臂同样丢掉外侧一截
      
      修法:把 scale 从 button 挪到里面包数字的 span。
      盒子回到格子内,准星竖臂稳稳落在 [右-3.5px, 右-2.0px];
      而「数字微微抬起」这个原意一点没丢。
      
       别改成去掉 overflow-hidden:它是用来把**行/列高亮的白纱**裁进圆角的,
         去掉之后首行末行的纱会把四个圆角戳成方的。
       也别把 HARD 值往里缩(inset 调大):那会让准星整体离格子边更远,
         四个角互相靠拢,读起来就不是"框住这一格"了。
      
      顺带:pacReticleLock 的 from 从 inset:-2px 改成 0 —— 负 inset = 画到格子外面,
      最边上那一格的入场帧同样会被裁半个角。0 → 2px 一样读得出"从边缘咬进来"。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(分配): 提案封顶到单批硬上限 —— 23 人的诊所按默认值必然出一张确认不了的单 · 9f992b4f
      生产实遇(杭州大厦,2026-08-20):确认单 537 条,点「确认分配」只回一句
      「请求字段校验失败」,主管在卡片上改什么都没用。
      
      根因是**提案层不知道确认接口的上限**:
      
        基数 = 在岗人数 × 每人每天通话数 × 时限天数 = 23 × 15 × 3 = 1035
        target = min(基数, 候选总数)                = min(1035, 5996) = 1035
        落人后 items                                 = 537
        确认接口 CreateAssignmentRequestSchema        items.max(500)   ← 撞这里
      
      ️ 不是边界情况:**12 个客服以上、用默认值就必然触发**(12×15×3 = 540 > 500)。
      
      修法一:target 多取一个 min。与既有的「候选不够就压低 target」完全同构,
      且同样  不回写基数(回写会把主管的习惯值永久钉死在 500)。
       不是把 HARD_LIMIT 调大 —— 它是技术护栏(单事务 + PG bind 变量上限),
         不是业务旋钮,schema 那条注释写得很清楚。
      
      修法二:让报错说人话。这一条独立于上面 —— 就算永不再触发,
      下一个撞上 zod 校验的人也不该只看到七个字。
      
        · schema: items.max 带自定义 message,含具体数字与下一步动作
        · 前端:  确认失败时拼 ApiError.details 里的字段级 message
                 (服务端 all-exceptions.filter 早就把 details 带出来了,前端只取了 msg)
        ️ 只拼 message, 不拼 path/code —— 「items」「too_big」对主管没有意义,
           只会让他觉得"这是 bug,不是我该处理的事"。
      
      排查期间未做任何确认操作:plan_assignments 仍为 0 行。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed