1. 28 May, 2026 3 commits
    • feat(sync): PR2 — cohort batch + checkpoint(内存稳 + 进度可观测) · d72f557a
      资源 + 续跑 2 件:
      1. **Cohort batch** — 按 patient 分批 load+transform+assemble+write,
         每批跑完中间表出作用域 → V8 GC 释放,峰值内存从 5-10GB 降到 500MB-1GB
         14GB 机器全量跑稳,不再撞 PG panic 那种磁盘 / OOM。
      
      2. **Per-batch checkpoint** — sync_logs.metadata JSONB 记 cohortDone /
         cohortTotal / lastBatchMs / lastBatchRssMb,Dashboard + 监控可观测;
         readCheckpointOffset 从同 syncLogId 读 cohortDone(为 PR3 --resume 留口)。
      
      变更:
        prisma migration 20260528000001:syncLog 加 metadata JSONB 列
        prisma schema 同步 metadata Json? 字段
        ClickHouseSourceService:
          - listPatientPairs:DISTINCT (patient_id, brand) FROM fact_client_out
            ORDER BY patient_id,增量 cursor 同步过滤;返回 batch 的边界
          - loadTablesForCohort:跟 loadAllTables 同形态,SQL 注入
            (patient_id, brand) IN (tuples) 过滤,增量 cursor 仍生效;
            不做反向拉主档(本批 fact_client_out 已含本批所有 patient 主档)
          - injectCohortFilter:把 IN tuple 在原 SQL 的 WHERE 末 / ORDER BY 前插入
        ColdImportService.importDirectory:
          - 加 cohortBatchSize option(env PAC_COHORT_BATCH_SIZE 兜底,默认 5000)
          - 抽出 processCohort 私有方法(单 cohort 完整 load→transform→write 流程)
          - cohortBatchSize > 0 + sql_source → 分批 loop,每批结束更新 metadata
          - 否则 single-shot(向后兼容,文件源走此路径)
          - chunk + resolveCohortBatchSize 导出工具函数(给 PR4 + 测试用)
        CLI cold-import.cli.ts:
          - 加 --incremental / --cohort-batch=N / --no-cohort 参数
          - 启动日志打印分批配置
      
      向后兼容:
        - 既有 importDirectory({dryRun, incremental}) 调用全不动
        - 文件源(manifest.tables[])仍走 single-shot
        - ClickHouse 源默认走 5000 cohort,可 --no-cohort 退回 single-shot
      
      PR3 后续:
        - 加 --resume 用 readCheckpointOffset(stale running lock 需手动 abort 后才能用)
        - 加 cron 看门狗自动清 stale running
      luoqi committed
    • feat(sync): PR1 — partial-unique 并发锁 + run_start baseline cursor · fcc2a9d6
      数据正确性 2 件:
      1. **并发锁**:sync_logs 加 partial UNIQUE (host_id) WHERE status='running'
         同 host 同时只能 1 个 sync 在跑(存量 / 增量 cron / 手动一律抢同一把锁)
         INSERT 撞 P2002 → 抛 SyncAlreadyRunningError → 调用方 skip
         scheduler 捕获该 error 时 warn 不 error,下次 cron 自然 retry
         CLI 撞锁退出 code=4(区分于 2=真失败 / 3=bootstrap 崩)
      
      2. **cursor=run_start 而非 max(updated_date)**:
         存量跑期间 DW 持续写入(已摄入患者更新 / 未摄入患者新增),
         max(updated_date) cursor 会漏:
           · batch 1 摄入患者 100,T+1h DW 又写患者 100 一笔(updated_date=T+1h)
             max cursor 推到 T+4:25(末批的 max),下次增量 WHERE > T+4:25 → 漏掉这笔
         run_start cursor 保证捞回:
           · 下次 WHERE > T+0 → 全部 T+0~now 的变化都进入增量
           · 同行同 updatedAt → source_event_id 一致 → P2002 path → parser re-run idempotent
           · 同行不同 updatedAt → 不同 source_event_id → 新 tx + 新 fact 版本
         重复读浪费 read 但无害,数据正确性 > 带宽优化
      
      importDirectory 重构:
      - runStart 入口冻结
      - SyncLog create 提前(在 table load 前),作为锁 acquisition 点
      - 整段 work 包 try/finally,finally 统一 finalize syncLog(释放锁 + 写 cursor)
      - 之前没有 finally,work throw 时 syncLog 卡在 status='running' = 永久死锁
      - 加 SyncAlreadyRunningError 导出类,scheduler / CLI 分别处理
      
      本地验证(docker exec psql):
         1st INSERT running OK
         2nd INSERT running for same host → unique violation
         UPDATE 1st status='success' 后,新 running INSERT OK(锁释放)
      
      stale 锁兜底(进程崩留 status='running'):
        目前需人工清:
          UPDATE sync_logs SET status='aborted' WHERE status='running'
                                                  AND started_at < NOW() - INTERVAL '12 hours';
        PR2/PR3 期间会加 cron 看门狗自动清。
      luoqi committed
  2. 27 May, 2026 13 commits
    • fix(ai/script/skills): resolve skills dir from cwd, not __dirname · 3012d8a0
      dev (nest start --watch --builder swc) 编译产物在 dist/src/modules/...,
      __dirname 指向 dist/src/.../draft-plan-script,__dirname/skills 找不到
      (nest-cli.json assets 只 copy 到 dist/modules/...,SWC + tsc 两态目录不同)。
      
      改用 cwd-based 多路径 resolver(跟 sync.service.ts 同模式):
        1. env PAC_SCRIPT_SKILLS_DIR 优先
        2. src/modules/.../skills 存在则用(dev 直接读源 MD,无需 watchAssets)
        3. 回退 dist/modules/.../skills(prod docker 只含 dist,nest build tsc 已 copy)
      
      registry + composer 共用 resolveScriptSkillsRoot 导出函数。dev/prod 都 work。
      luoqi committed
    • feat(ai/script): Phase B — K00/K01/K03/K06/K07/K09 + objection-bank + safety-self-check · 52ca8c28
      补齐 K00-K09 全 10 个 ICD-10 牙科 dx skill 覆盖 + 2 个 playbook。
      
      diagnosis/(6 个):
        K00-development     发育/萌出异常(乳牙滞留/多生牙,allowedPop: child/teen)
        K01-impacted        阻生牙(智齿,allowedPop: teen/adult/elder)
        K03-hard-tissue     牙体硬组织非龋损伤(磨损/楔状缺损/酸蚀/隐裂)
        K06-gum-alveolar    牙龈/牙槽嵴疾患(增生/肿物,谨慎不主动提"癌")
        K07-ortho           正畸(K07,长周期1-3y,儿童8-12黄金窗,严禁报价)
        K09-jaw-cyst        颌骨囊肿(高风险,必须外科会诊,话术高度谨慎,
                             1 周内必须复联拒绝者)
      
      playbooks/(2 个):
        objection-bank       异议总库 8 种通用高频异议 + scenario × pop 优先级矩阵
                             (priority 200,全场景加载,跟 dx 特化异议互补)
        safety-self-check    safety 规则描述版,让 LLM 输出前主动核查 6 条 close 段
                             约束 + 禁词,主动规避 safety gate 命中(命中→走 fallback)
                             (priority 250,机器规则仍在 call.ts safetyRules,
                              本 SKILL.md 是 LLM 看的描述版,两者同步维护)
      
      验证(本地 smoke):
        - nest build 成功,19 个 SKILL.md + base-system.md 全部 copy 到 dist
        - composer 4 案例端到端验证全通过:
          * 成人 K08 → matches 6 个 skill(scenario + K08 + returning + adult
            + objection + safety)
          * 儿童 K02 → matches 6 个 skill(child + new + K02 + ...)
          * 老人 K05+K08 → matches 7 个 skill(多 dx 多 skill 命中)
          * 儿童 K08 → 正确排除 K08(allowedPopulation 跨维度约束生效),
            只 matches 5 个 skill(scenario + child + ...)
      luoqi committed
    • feat(ai/script): Skills harness — SKILL.md registry + composer + 11 P0 packs · c5129c72
      Phase A: 把 draft_plan_script 的 system prompt 从单一长字符串重构为
      Anthropic Skills 标准格式 — base-system.md(通用铁律) + N 个 SKILL.md
      (场景特化),composer 按 input 动态装配。
      
      新增基建:
        skill.types.ts            zod frontmatter schema + match context types
        skill-registry.service.ts 启动期 scan + parse + 强校验(fail-fast,跟 yaml
                                  assemblers 风格一致),__dirname/skills 路径
        skill-composer.ts         纯函数 — applies match + allowedPopulation 跨维
                                  度排除 + priority 排序 + composeHash
        skills/base-system.md     从原 DRAFT_PLAN_SCRIPT_SYSTEM 抽出 50 行通用部分
                                  (§0 总则白名单 / §3 禁词 / §4 销售文风 / §6 时间
                                  排班 等全场景铁律)
      
      11 个 P0 SKILL.md:
        scenario/treatment-initiation       新链(启治召回)
        diagnosis/K02-caries                龋齿 / 补牙
        diagnosis/K04-endo                  根管(allowedPop: teen/adult/elder)
        diagnosis/K05-perio                 牙周
        diagnosis/K08-edentulism            缺牙 / 种植(allowedPop: teen/adult/elder,
                                             严禁报价铁律已内置)
        population/child                    儿童(<14,找家长 / 临床禁忌交叉)
        population/teen                     青少年(14-17,半自主)
        population/adult                    成年(18-64,baseline,故意保持薄)
        population/elder                    老年(>=65,慢节奏 / 家属同决策 / warm
                                             默认 / 不能 urgent)
        relationship/new-customer           新客(无上次治疗可引,降门槛加倍)
        relationship/returning              回头/熟客(可引主诊医生 / 治疗链)
      
      call.ts:
        - 注入 SkillRegistry,buildPrompt 走 composer
        - env AI_SCRIPT_USE_SKILLS=0 退回 legacy 全量 prompt(回滚保险)
        - promptVersion 区分 'skills-base-v1' / 'time-marker' (legacy),
          SQL 对比版本效果时拆分群体
        - fallback close 段去加粗时间 + 加 (示例) + "以诊所排班为准" —
          避免 fallback 自己触发 close_no_bold_time block
      
      input.types.ts:
        reason 加 subKey + dxCode + scenarioKey(skill composer 显式映射,
        composer 不做文本推断)
      
      orchestrator.buildCallInput:
        传 raw subKey + 派生 dxCode(K00-K09 全表 map);primaryScenarioKey 直传
      
      prompt.ts:
        - 原 DRAFT_PLAN_SCRIPT_SYSTEM rename → _LEGACY(env=0 回滚路径)
        - buildDraftPlanScriptPrompt 加 matchedSkills 参数,末尾追加"本次激活的
          skills" 清单(LLM 跨 skill 自检 + 落账归因)
        - 删 inline hint "(老客可家常)/(新客需详细)" — relationship skill 接管
      
      nest-cli.json: 加 assets 配置把 modules/ai/calls/skills/**​/*.md 拷到 dist
      ai.module.ts: 注册 DraftPlanScriptSkillRegistry provider
      
      Phase B(下一 commit):补 K00/K01/K03/K06/K07/K09 6 个 dx skill +
      objection-playbook + safety description skill。
      luoqi committed
    • docs(w3): align timeline + dashboard 调整 · 6edc3040
      W6 目标改 1 → 5 家试点;W4 描述细化(DW 直连 + 试点对接 + 业务验证);
      W1 起点改"框架定稿 + 数据库结构评审 closure"。
      luoqi committed
    • feat(web/plans): per-view filter options + priority hover integration · 9c0fe5ee
      FilterBar 增 view prop:
        我的工单 / 召回池 两个 view 的 status 选项应该不同(工单看跟进状态,
        召回池看入池状态)。setView 时清 status 避免无效组合残留。
      
      行级优先级数字接 PriorityHover:鼠标悬浮看 6 因子算分明细,
      跟详情页效果一致。
      luoqi committed
    • feat(web/plan-detail): responsive layout + algorithm hover cards + MD key fix · 8d708bac
      新组件:
        - priority-hover.tsx — 优先级数字悬浮展示 6 因子 breakdown
          (timeWindowFactor / valueBonus / urgencyBonus / signalQualityDiscount 等),
          无 breakdown 时 fallback 简版文案
        - persona-feature-hover.tsx — 患者画像卡片右上角悬浮算法说明
          (key → 中文映射 + 算法 evidence + 当前 score)
      
      plan-detail-app.tsx:
        ResponsiveDetail wrapper — xl: 三栏 grid;<xl: shadcn Tabs 折叠
        TopBar 响应式隐藏/显示;PriorityHover 接入
      
      chain-viz.tsx:
        加 "★ 再启新链" 状态(目标 + ongoing/entered)— 上条治疗链未闭环又再次诊断同
        类型时,以"再启新链"语义渲染,跟普通 discovered 区分
      
      reason-line.tsx:
        多 trigger 来源标签:typeSet.size > 1 → "诊断+医生建议"(过去只显单一)
      
      shared.tsx — MD parser 修 React 重复 key 警告:
        blockquote/bullet 内 while 消费多行后 i 已前移,push 时 key={i} 跟下一个
        paragraph push 的 key={i} 相同 → React reconciliation 报 duplicate key 3,
        视觉表现为 blockquote 被重复渲染。改用独立 key++ 计数,跟 line index i 解耦。
      
      drawer.tsx / task-drawer.tsx:
        drawer cards 右上角接入 persona-feature-hover;英文 key 中文映射统一
      luoqi committed
    • feat(web/ui): add HoverCard + Tabs primitives + useMediaQuery hook · 140c9003
      shadcn 风格 wrappers:
        - ui/hover-card.tsx — Radix Hover Card 包装,默认 200ms open delay
          给详情页 / 列表"算法解释/优先级 breakdown" 悬浮使用
        - ui/tabs.tsx — Radix Tabs,响应式 <xl 下 plan-detail 折叠 sidebar 用
        - hooks/use-media-query.ts — SSR 安全,匹配 tailwind breakpoint
      
      加 @radix-ui/react-hover-card 依赖,pnpm-lock 同步。
      luoqi committed
    • feat(ai/script): prompt v3 — §0 总则白名单 + (示例) 时间标记 + 上下文增强 · e7a88608
      prompt.ts (DRAFT_PLAN_SCRIPT_SYSTEM 重写):
        - 删 few-shot JSON 大段(LLM 把示例文本当模板照抄,如"工作日 19:00 后"
          伪事实就是这么漏的)
        - 加 §0 总则:白名单(诊所/患者/触发原因/画像/临床上下文)+ 自检方法,
          白名单之外具体表述视为虚构 → 失败
        - 加 §6 时间/排班规则:必含"待确认"短语;(示例) 显式标记或方向词代替具体点;
          严禁加粗具体时间("**周六上午10点**" 被读作已敲定)
        - 加 医生引用规则:followup 引诊断必须用 reason.triggerDoctor,不能拿
          全患者高频医生顶替(38 是姜医生发现,不能写李医生)
        promptVersion → draft_plan_script@2026-05-27-time-marker
      
      schema.ts:
        close 段 describe() 明确 (示例) 标记规则 + 加粗禁令
      
      call.ts safety rules 新增 3 条:
        - close_no_commit_phrasing(block):"已为您约好"/"约定"/"敲定" 等承诺词
        - close_no_bold_time(block):正则禁加粗具体时间词
        - close_has_tentative_phrasing(warn):未含"待确认"语义短语提示
      
      input.types.ts + orchestrator.buildCallInput:
        reason 加 triggerDoctor + triggerDate;plan 加 goal;
        clinicalContext 加 ongoingChains + completedTreatmentCount(信任锚);
        loadPlanContext fact status filter 改 ['active','fulfilled'](漏 fulfilled 会让
        AI 看不到实际 treatment_record → primaryDoctor 偏移、pendingTreatments 漏算);
        extractPrimaryDoctor 改"doctor_id 频次 top 1"(对齐前端);
        visitFacts EMR 兜底(同 plan-aggregate);
        pendingTreatments 改从 plan.reasons 派生(SQL 权威集,旧 DX_TO_CAT 漏 K01/K03)。
      luoqi committed
    • feat(plan): tooth-overlap union-find merge + EMR fallback for visit facts · b6147297
      scenarios/treatment-initiation-recall:
        按 tooth set 重叠做 union-find 合并同 patient 同 sub_scenario 的多 sig,
        sub_key 改 '<sub>@<tooth|whole>' 粒度,允许同 patient 不同牙位各 1 reason 行,
        cluster_fact_ids / triggers 注入 evidence + signals,源标签 (诊断+医生建议)
        在 cluster 含两种 sig 时合并显示。跟 chain-composer bucket 口径对齐,reason
        与 chain 1:1。
      
      plan-aggregate.serializeProfile:
        visitFacts 优先 encounter_record,缺失时回退 emr_record。
        场景:DW 部分 host 的 appointment.in_time 字段空 → encounter 全空,但 EMR
        完整(医生写病历必到诊),不该让 lastVisit/daysSinceLastVisit 为 null。
      luoqi committed
    • fix(sync): resolve data dir from cwd, not __dirname · bfd5fd14
      __dirname 走 dist 编译产物路径(dev: dist/modules/sync, prod 容器同),
      向上 ../../../data 会越界到不存在的 dist/data。改用 cwd/data —
      两态(dev/prod)cwd 都是 apps/pac-service 根,稳定指向源 data 目录。
      luoqi committed
    • chore(types): expose plan reason breakdown for PriorityHover · 75e9a138
      PlanReasonBriefSchema 增 breakdown(z.unknown().nullable().optional()),
      plan.service.toPlanReasonBrief 透传 — 前端 PriorityHover 拿到 6 因子
      明细做悬浮算法解释,无 breakdown 时 fallback 简版文案。
      luoqi committed
    • fix(prisma): bump $transaction timeout 5s → 60s · 86ddd374
      cold-import 在 swap thrashing 大压力下,单 fact-write 事务可能跑 20s+,
      默认 5s timeout abort 整条 patient pipeline。服务器全量 cold-import 跑到
      15% (479k/3.15M txn) 就被 "Transaction already closed" 杀死。
      
      4 处 $transaction 加 { maxWait:30000, timeout:60000 }:
        fact-writer (最热,每 fact 1 次)
        persona recompute / plan execution / plan engine 上版
      
      风险:长事务期间持锁久,但 cold-import 本身就是串行 patient,无锁竞争。
      luoqi committed
    • fix(web): align dev/start port + redirect home to /plans · dc8b6a0f
      - package.json: next dev/start -p 3000 → 3100(端口迁移遗漏)
      - (app)/page.tsx: 调试页(会话信息+权限按钮 demo)改为直接跳 /plans,
        未登录走 AuthGate 弹 mock-login dialog
      luoqi committed
  3. 26 May, 2026 12 commits
  4. 25 May, 2026 12 commits
    • fix: closed chain 永不 ★ — 修陈化冰 K04 显示矛盾 · 2f8c962a
      陈化冰 case(v1 plan 显示):
        K04 chain.status=closed(alt-closed by 种植 16;17;26;46)
        K04 chain.target=true(老 plan reason 还含 K04)
        → 前端 TargetTimelineRow 强制按 discovered 渲染 ★潜在新链 + stage=1 hint
        → 但底部 HistoryStrip / ChainSidebarRow 看 status=closed 渲染 ✓已闭环
        → 同一条 chain 两个组件给出矛盾展示
      
      修(双侧防御):
        1. plan-aggregate.assemble 加约束:
           if (c.status === 'closed') { c.target = false; continue; }
           closed = 临床已结束(拔了/被替代/全治完);即使 SQL 老 reason 仍命中也不 ★
           (SQL 召回老 closed chain 是 data race / 老 plan 残留,不该误导客服)
        2. chain-viz.findFocused:
           chains.find((c) => c.target && c.status !== 'closed')
           双层防御,即使后端漏改也不显示 closed 在 focused
      
      验证(陈化冰 v1 plan superseded,5 reasons 仍含 K04):
        改前:K04 closed chain target=true → focused 错位
        改后:K04 chain target=false → focused = K00 先天/萌出处置·46(下一个 target=true)
              底部历史 ✓已闭环 正常显示
      
      Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
      luoqi committed
    • fix: scenario SQL 加 ⑤c — 同牙位 surgical 拔除 = 任何信号排除 · 5967b684
      陈化冰 case (5 reasons → 2 reasons):
        改前误召:K05@17;18 / K04@46 / K00@46 — 3 颗都已拔(2025-12-14/12-20)
        根因:SQL ⑤a 只看"同 expectedCats actual 排除",surgical 不在任何 K0x 的 expectedCats
             但临床上拔除是任何牙病的终结,拔了就不需要后续治疗
      
      修(treatment-initiation-recall.scenario.ts):
        加新 NOT EXISTS ⑤c 子句:
          任何 actual surgical 同牙位 overlap → 排除该信号
        关键设计:
          - 不限时间方向(拔了就拔了,后期诊断只是记录现状如"牙列缺失")
            → 解决 K00@46 在 2026-03 才诊但 46 已 2025-12 拔的情况
          - 仅对**有具体牙位的信号**生效(信号 tooth_position 必须非空)
            → 全口诊断(K05 全口)不走这分支,避免任何拔牙误排全口
      
      验证(陈化冰):
        recompute-plans: tenant=77057 plansSuperseded=1 (陈化冰 v1→v2)
        v2 reasons (2 条):
          K02@24;25;47 (caries_no_filling)  ✓ 该召(未充填)
          K03@14;15 (hard_tissue_damage)    ✓ 该召(未充填)
        v1 排除的 3 条 (✓ 正确):
          K05@17;18 (17;18 已拔) ✓
          K04@46 (46 已拔) ✓
          K00@46 (46 已拔) ✓
      
      副作用:全量重导 5K 患者后,召回池可能下降 5-10%(扣掉"已拔不该召"的噪音)
      Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
      luoqi committed
    • fix: chain.target 按 (code, tooth) 联合对齐 SQL reason(林兆星 K05 误判) · 961328bd
      林兆星 case:
        K05 SQL 召回(无 actual periodontic) ✓
        chain.status=entered(1 年前 fulfilled "牙周" 预约) — 客观真实
        矛盾:旧逻辑 plan-aggregate 只对 discovered chain 改 target → K05 entered target=false
              → UI 不 ★ → 客服看不到该召回
      
      修(plan-aggregate.assemble):
        ① target 计算覆盖所有 status(不限 discovered)
        ② 用 (code + tooth) 联合 key 匹配,不再仅 code:
           reason 有 tooth → chain.code 同 + tooth overlap 才 ★
           reason 无 tooth(全口诊断 K05) → 任何 code 同的 chain ★
        → K08 林兆星 4 条 chain,只 17 那条 ★(SQL 召回 K08@17),其他 K08 不 ★ ✓
      
      前端(chain-viz.chainStatusVisual):
        target=true 时优先返回 "★ 潜在新链",压过 chain.status(entered/ongoing)
        原因:SQL 是真理 — 该召回的就显示 ★,即使历史预约让 chain composer 算 entered
             chain.status 仍展示真实状态(S2 节点显示历史预约),但顶部 badge 优先 ★
      
      验证(林兆星):
        3 个 SQL reasons:K08@17 / K05@全口 / K03@46;47;48
        对应 3 个 chain ★:
          ★ 牙体修复·46;47;48 (K03)
          ★ 牙周治疗·全口 (K05,entered+target=true)
          ★ 种植修复·17 (K08)
        非 ★ chain:K08 其他牙位(已做种植 SQL 排除)+ 外科 closed
      
      Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
      luoqi committed
    • feat: lifecycle linear_then_crown — 根管闭环额外要冠保护 · cadbe1d6
      临床背景:
        根管治疗后牙变脆(髓腔抽空 + 牙体含水量下降),不戴冠保护 2-3 年内
        ~30% 概率牙冠折裂(literature: Caplan & Weintraub 1997 等)
        所以"根管完成 + 牙体充填" 不算真闭环,必须 + "冠修复" 才算完整治疗
      
      改动:
        1. canonical-codes.ts:
           - TreatmentLifecycle 加 requiresCrownProtection?: boolean
           - 新加 lifecycle = 'linear_then_crown' (maxStage=5, 需冠保护)
           - endodontic.lifecycle 'linear' → 'linear_then_crown'
        2. chain-composer:
           - 新加 hasCrownProtection(bucketTooth, byType, anchor) helper
             检查同牙位 prosthodontic actual,treat_stages 含 crown_restoration/post_core
             fallback subtype.includes('冠'/'桩核')
           - S5 闸口加 crownOk 条件:lifecycle.requiresCrownProtection 时必须满足
           - buildStageNodes 透 crownOk 给 S5 节点 → hint "待冠保护(...防牙冠折裂)"
      
      验证(杨光宗 27 K04):
        改前:closed stage=5(根管+充填+复诊 触发 S5 → 误闭环)
        改后:ongoing stage=4 → S5 "未闭环 · 待冠保护(根管后建议戴冠,防牙冠折裂)"
      
      副作用:其他做了根管但没戴冠的患者也会从 closed → ongoing
        → 客服可视化看到缺口,适合补做"根管后冠修复召回"scenario(future)
      
      不影响 SQL recall(scenario 仍按 treatment_initiation_recall ⑤a actual overlap 排除,
      K04 27 有 endodontic actual 仍被排除,不会重新进召回池;
      真正"该补冠"的召回是另一个 scenario,后续加)
      
      Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
      luoqi committed
    • feat: 摄入 treat_stages 字段 + PAC 标准 step enum · a57a58a8
      设计原则:字段名 + 内容都用 PAC 标准,host 字面值在 yaml 翻译
        - canonical 跟宿主无关(其他宿主接入只改 yaml,代码不动)
        - host "开髓"/"拔髓" → PAC pulp_extirpation
        - host "根备"/"根管预备" → PAC canal_preparation
        - host "根充"/"根管充填" → PAC canal_filling
        - host "种植体植入"/"种植一期" → PAC implant_placement
        - host "种植上部修复"/"种植冠修复"/"种植戴牙" → PAC crown_placement
        - 等 22 个 PAC 标准 step
      
      改动:
        1. canonical-codes.ts:
           - 新加 PACTreatmentStep enum(22 个,英文 snake_case + 中文 label)
           - TreatmentMilestones.steps 改用 PAC step key(不再用中文词)
           - 新加 LegacyStepSubtypeKeywords:host 没填 stages 时词根 fallback
        2. canonical.ts: TreatmentCanonicalSchema 加 treatStages?: string[]
        3. fact-content-schemas.ts: treatment_record.content 加 treat_stages
        4. manifest.yaml § C.1/C.2: element_fields 加 treatStages
        5. treatment_actual.yaml + treatment_planned.yaml:
           - field_mapping 加 treatStages
           - enum_mapping 新加 treatStages 段(host 字面 → PAC enum,22 条)
        6. assembler-engine.applyEnum: 支持 array 字段(每元素 lookup mapping)
        7. treatment.parser: content.treat_stages 存数组(已 PAC 标准)
        8. chain-composer.matchMilestoneSteps:
           - 优先用 treat_stages 精确匹配 PAC step
           - fallback 用 LegacyStepSubtypeKeywords + subtype.includes()
           - S3 title 用 PACTreatmentStep 字典渲染中文(非 enum key)
      
      验证(杨光宗 27 牙):
        入库:
          4-10 stages=["pulp_extirpation"] ← host "开髓"
          4-20 stages=["canal_preparation"] ← host "根备"
          5-04 stages=null ← host 没填,落空
          5-18 stages=null ← 充填 actual host 没填
        Chain:
          根管治疗 S3 done ✓ "开髓/拔 → 根管预备" / 已完成 3 次
          牙体修复 S3 done ✓ "树脂充填" / 已完成 1 次(走 subtype 词根 fallback)
      
      待办(后续):
        - lifecycle="linear_then_crown" 新加,endodontic 闭环额外要求 prosthodontic actual(冠)
        - 当前杨光宗 27 没戴冠仍 closed,临床其实差最后一步
      
      Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
      luoqi committed
    • ui: S3 步骤展示改"已完成 N 次",字典分子分母不直观 · e44e599e
      旧版 "1/3 步骤" 含义 = 命中字典 step 种类数 / 字典总 step 种类
         杨光宗 27 根管 3 次 actual 同 subtype → 字典只 hit 1 种 "根管" → 1/3
         客服看 1/3 像"差 2 步"误导,实际 minSteps=1 已满足,临床完成
      
      新版:
        s3Reached 满足   → "已完成 N 次"(N=actual.length,直观)
        s3Reached 不满足 + multi-step(种植 minSteps=2) → "X/Y 步骤(进行中)"
        s3Reached 不满足 + single-step → "N 次治疗"
      
      验证:
        杨光宗 K04 根管(3 次 actual): "根管 · 已完成 3 次"
        杨光宗 K03 充填(1 次 actual): "充填 · 已完成 1 次"
        路遥 K05 牙周(2 次刮治):      "刮治 · 已完成 2 次"
      Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
      luoqi committed
    • fix: endodontic milestone 字典词根化(杨光宗 27 根管 bug) · fd7ab151
      杨光宗 27 牙真实临床:
        2026-04-10/04-20/05-04  3 次根管治疗(磨牙) actual
        2026-05-18              K03 + 非美学区树脂充填 actual
      临床:完整"根管 + 后续充填修复"链 ✓ 已闭环
      
      改前 UI:根管治疗·27 ★潜在新链(0/2 步骤)  错!
      根因:endodontic milestone steps=["开髓","根管充填"] minSteps=2
           host subtype "根管治疗(磨牙)" includes 任一 step 都 false → matched=0/2
           → s3Reached=false → status=discovered → ★ 误标
      
      修法(跟 periodontic 同款词根化):
        steps: ["开髓","根管充填"] → ["根管","根充","开髓"]
        minSteps: 2 → 1
        rationale:
          - "根管治疗(磨牙)" includes "根管" ✓
          - "根管充填" includes "根充" ✓
          - "开髓" includes "开髓" ✓
        minSteps 降 1 因:host subtype 不区分 step 类型(同次 actual 字典只能 hit 1),
        强制 minSteps=2 实际上要求 2 个 distinct step,但 host 永远只 1 个匹配
      
      改后验证:
        根管治疗·27: status=closed stage=5 target=False(已闭环,跟 K03 后充填+复诊一起)
        S3 done=True "根管·1/3 步骤"
        SQL ⑤a 排除生效:K04 被 endodontic actual overlap 排除,不进召回池
      
      向下兼容:其他患者根管动作真正写"开髓"/"根管充填"的也照常 hit
      Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
      luoqi committed
    • fix: S2 done 加 S3 反推 — 已治疗必然已进入 · a73f5d7c
      逻辑漏洞:
        之前 s2Done = !!s2Earliest(只看强信号)
        → 牙周链 S3 已治疗(actual 刮治),但因无 appointment 信号 → S2 done=(矛盾)
      
      修复:
        s2Done = !!s2Earliest || s3Reached
        → S3 reached 反推 S2 必然已经过(已治疗 ⇒ 已进入治疗链)
      
      S2 node 文案优先级(5 路):
        ① s2Earliest=appointment → "预约就诊 · {主诉}"
        ② s2Earliest=payment → "已付款 · ¥X"
        ③ plannedHint 存在 → "{subtype} · 已开计划"(已执行)/"已开计划(待执行)"(未执行)
        ④ S3 reached 但无 planned → "直接执行 · 未经预约"(急诊)
        ⑤ status=discovered 无信号 → "尚未启动" + hint
      
      路遥牙周链验证:
        S2 ✓ "牙周刮治术 · 已开计划 · 段路路 · 2026.03.11"(用 plannedHint 展示)
        S3 ✓ "刮治 · 1/3 步骤"
      
      K01/K07/K08 仍 ★ 召回(医生计划只是 hint,患者未行动)。
      
      Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
      luoqi committed
    • fix: S2 严格化 — planned treatment 不再触发 entered 状态 · 9416620a
      路遥 case(N=4 chains):
        改前:K01 智齿拔除 / K08 种植 因 EMR.plan 有医生计划 → status=entered(误)
        改后:status=discovered(正确)— 患者未预约/付款/到诊
              plannedHint 仍展示 "延期种植术 · 已开计划(待执行)" 给客服暗示
      
      W2/W3 旧版用 planned treatment 当 S2 fallback,跟 collectS2Facts 顶部注释
      "医生侧动作 ≠ 患者承诺" 自相矛盾。W4 末彻底清理。
      
      S2 真信号(只 1 路):
        appointment.complaint_category 匹配 → 患者主动预约(挂号/约时间/到店)
      
      planned treatment 信息没浪费:
        - 不进 s2Hits(不升 status=entered)
        - 在 S2 node 渲染时作为 plannedHint 展示 "已开计划(待执行)" + done=false
        - 客服看到"医生计划是 XX 但患者还没动" — 仍属召回目标
      
      副作用预期:
        - 召回率会上升(之前误升 entered 的现在回 discovered,被召回)
        - 准确率上升(真没动作的患者被正确召回)
      
      新加 helper:findPlannedTreatmentHint(category, byType, s1AnchorTime)
        从 byType.treatment 找同 category 的 planned(s1 之后,最早一条)— 纯展示用
      
      Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
      luoqi committed
    • fix: 牙周链(lifelong_maintenance + wholeMouth)误标已闭环 + S3 步骤 0/3 · 3e31eb58
      路遥 case:
        K05 全口牙周 + actual 牙周刮治术全口 26 牙
        另有 K08 21;41 + planned 延期种植 21;41
        → 之前:牙周链被"种植修复·21;41" alt-close → 显示"已闭环",且 S3 显示"0/3 步骤"
        → 修后:牙周链保持 ongoing 维护期(终身),S3 "牙周刮治 · 1/3"
      
      修 2 处:
      1. chain-composer.markAlternativeClosed 加 2 个豁免:
         - lifelong_maintenance(periodontic 等 maxStage=4)→ 永不被替代闭环
           (慢性病终身维护,做种植 21;41 不代表整体牙周不需要继续治疗)
         - wholeMouth(牙位 ≥ 20 颗 视为全口)→ 不被 per-tooth 链替代
           (全口治疗 vs 局部种植 临床上是"维护 + 局部",不是替代)
      2. canonical-codes.periodontic milestone steps:
         ['全口洁治','龈下刮治','牙周维护'] → ['洁治','刮治','维护']
         旧版全词匹配导致"牙周刮治术" 命中 0/3,改"词根"匹配后 1/3 ✓
      
      不需要重导数据(chain-composer 实时算);只需重启 pac-service。
      
      Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
      luoqi committed
    • fix: 删 yaml duplicate key(OHI,涂氟 / OHI,涂氟) · 53d0127e
      js-yaml strict mode 不允许 duplicate key,cold-import 时报错:
        treatment_actual.yaml:272 'OHI,涂氟' 重复(连续两行同 key 同值)
        treatment_planned.yaml:266-267 'OHI,涂氟'(中逗号)+ 'OHI,涂氟'(英逗号) 共存
      
      normalize op 上线后 DW 中文逗号版自动归一到英文版,中逗号 yaml 条目变 dead config。
      删掉 dup / dead,留唯一 'OHI,涂氟' → preventive。
      
      100 患者验证:cold-import success patients=100 txns=0 dups=10235 facts(c=0 u=9810)
      (数据全部 unchanged,因 source_event_id 幂等,只刷新 fact metadata)
      召回:18 plans(瑞泰 13 + 瑞尔 5),持平 / 略降于改前 23 plans —
      EMR 单源 actual 让排除更准(真治疗才算覆盖),净效应略降但更精确。
      luoqi committed
    • W4: scenario SQL 排除升级牙位级 overlap(EMR.treat_plan 带 48.7% 牙位) · 89e68c1a
      之前 ⑤a 排除是 patient + category 级("DW 限制" 注释);
      EMR.treat_plan 进来后 actual 带牙位,可以升级到 tooth-level overlap。
      
      逻辑(3 路 OR):
        ① 信号无牙位(K05 全口诊断)→ 仍 patient/category 级
        ② actual 无牙位(全口洁治/牙周治疗)→ 视为"全口覆盖"→ 仍排除
        ③ 双方都有牙位 → tooth array overlap(PG && 操作符)
      
      实现细节:
      - regexp_replace 把"15 B;24 B"非数字非分号字符替换为 ; → "15;24"
      - string_to_array + array_remove '' 去空元素(关键:两 array 都有 '' 时 '' = '' 误返 true)
      - PG && 任一元素相同即 overlap
      
      收益例子:
      - 罗国标 K04 14;15 + actual endodontic 36 → tooth overlap false → 正确召回 14;15
      - 之前 patient/category 级会被误排除
      
      向下兼容:48.7% actual 带牙位 → 这部分走 tooth-level 精筛;
      其余 52% actual 无牙位(全口治疗)→ 走 ② 分支,跟现状一致(全口覆盖)。
      
      不需要重导(SQL 改动,数据不动);下次 recompute-plans 立刻生效。
      
      Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
      luoqi committed