1. 28 May, 2026 26 commits
    • fix(sync): CH 重试补 ETIMEDOUT 等 socket 级网络错误(高并发打远程 DW 超时) · 53be5136
      concurrency=5 时每批 5 表并行 → 25 个并行 CH 查询打远程阿里云 DW,公网扛不住
      → read ETIMEDOUT → 整轮 fatal abort(并连锁触发 teardown 中 Prisma "Engine is not
      yet connected")。根因:queryJsonWithRetry 瞬时正则只匹配 "timeout",不匹配
      "ETIMEDOUT"(无该子串)→ 没重试就直接抛。
      
      补:ETIMEDOUT / ECONNREFUSED / EHOSTUNREACH / ENETUNREACH / EPIPE / "HTTP request
      error" / "fetch failed" 进瞬时白名单,可退避重试。
      
      注:并发本身仍是 DW 限制 —— 实测 concurrency=3(15 并行)稳,5(25 并行)超时。
      此 host 走 concurrency=3 为上限;重试只兜偶发抖动,不是提并发的许可证。
      
      Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
      luoqi committed
    • feat(sync): 自动同步开关挪进 manifest(auto_sync)— 消除 PAC_INCREMENTAL_HOSTS drift 坑 · d9a77a65
      问题:每日增量同步的 host 清单写在 env(PAC_INCREMENTAL_HOSTS=jvs-dw)。接入新 host
      (manifest + 冷启都做了)却忘了改这条 env → 静默不被 cron 同步,不报错。env 跟 host
      配置分离,易 drift。
      
      改:把"是否自动增量"声明进 host 自己的 manifest(顶层 auto_sync: bool,默认 false)。
      - manifest.schema 加 auto_sync 字段。
      - ColdImportService.discoverAutoSyncHostDirs(dataDir):扫各 host 子目录 manifest,
        返回 auto_sync=true 的目录名(宽松:无 manifest/解析失败/无 flag 跳过)。
      - scheduler:env 设了 → 显式 override(escape hatch);未设 → 自动发现。无 host → warn+skip。
      - jvs-dw manifest 置 auto_sync: true;.env.example PAC_INCREMENTAL_HOSTS 改为可选/默认空。
      
      效果:接入新 host 只在其 manifest 置 auto_sync: true 即纳入每晚同步,不碰 env。
      验证:real data/ → [jvs-dw];synthetic(有flag/无flag/无manifest/坏yaml)→ 只 [hostA]。
      tsc 0,全量 89 测试通过。
      
      注:服务器 .env 现仍有 PAC_INCREMENTAL_HOSTS=jvs-dw(override 生效,行为不变);
      要切到 manifest 自动发现,部署时清空该 env 即可。
      
      Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
      luoqi committed
    • fix(chain): actual-only 链立链改牙位级 — 拔除等治疗不再隐身 · 2cb3710c
      问题:actual-only(无诊断)链立链判据是 category 级(`!dxCategories.has(cat)`)。
      当某 cat 恰好被一个【不同牙位】的诊断占了槽,该 cat 落在其他牙位的 actual 就既进不了
      诊断链(牙位不重叠)、又立不了自己的链 → 整条隐身。
      李梦维:K00(先天/萌出,主类目=surgical)占了 surgical 槽,3 次拔除(28/18/15)隐身 →
      "已被替代:外科手术·28" 指向一条不存在的链(看着自相矛盾)。
      
      修:改成牙位级 —— cat 有诊断桶但 actual 落在【同类诊断未覆盖的牙位】→ 单独立 actual-only
      桶(tooth=未覆盖牙位,只收这些牙,不跟诊断链重叠;code='' 不参与召回 ★,纯展示)。
      李梦维 → 多出 "外科手术·15;18;28" 闭环链,K00 仍 ★,替代标注指向真实链。
      
      防双显:无牙位诊断(host 诊断常不填牙位)覆盖范围未知 → 视为该 cat 全覆盖,不 carve
      (否则诊断链借 actual 牙位显示,跟新链撞同 (cat,tooth);023cbb47 K02 无牙位+充填 17;47 即此坑)。
      
      回归(800 患者):总链 1722→1751(+29 条原本隐身的真实治疗现身),同 (cat,tooth) 双显
      违规 11→11(我引入 0;那 11 条是 K02+K03 同牙的既有现象,与本改无关)。全量 89 测试通过。
      注:治疗链 read-time 合成,无需重算 DB。
      
      Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
      luoqi committed
    • feat(sync): 术式 category 关键词分类兜底(精确未命中长尾,救回 ~70% 漏配) · cd4ed7d9
      问题:enum_mapping 是 normalize 后的【整串精确匹配】,treatName 2万+ distinct
      精确字典覆盖不全 → 长尾全靠 _default:'' 丢弃 → 真 actual 治疗被丢 → 召回排除
      失效 → 误召(诊断后明明做过同类治疗却仍被召回)。实测 tx|act missing-category
      59410 occ / 20681 distinct。
      
      方案(分层,只接管精确未命中,精确命中零影响,最坏退化=_default 现状,无回归):
      - 引擎(host 无关算法):applyEnum 精确未命中 → keyword 分类器 → _default。
        classifyByKeyword 提为导出纯函数:按 ,;。切段 + 剥离条件从句(stripClauses)
        + 按 rules 顺序含词匹配(优先级裁决:种植>冠、根管>冠、操作词>人群词)。
      - yaml(host 术式差异):treatment_actual/planned 加 keyword_mapping(10 类真治疗,
        有序优先级)+ keyword_strip_clauses(actual 剥必要时/建议等;planned 只剥必要时,
        保留建议/拟——计划本就这么措辞)。流程/无操作不配 → 落 _default(复用上游
        route_by_pattern 的 review 分流,不重复)。
      
      实测回收:distinct 72.4% / occurrence 70.0%;仍丢弃 Top 全是流程噪音(咨询/复诊/
      无需处理/口扫…),负向词剥离使"定期观察,必要时拔除"正确落空不误判 surgical。
      新增单测 26 例(真实漏配术式 fixture);全量 89 测试通过,tsc 0 错。
      
      生效路径:新增量数据自动走;存量需 reparse(truncate facts + sync --full)才回填。
      
      Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
      luoqi committed
    • feat(plan-detail): supersede 老 URL 自动跳最新版 + 刷新按钮旁加"更新于" · 70d53a9d
      问题:刷新/重算会 supersede 出新版本 plan(新 id),老 plan 行仍保留
      冻结快照。getPlanDetailByPlanId 用 findUnique(id) 照样返回 superseded 的
      旧数据(不 404),导致老 plan URL(刷新后 refetch / 收藏 / 分享 / 任务抽屉)
      停在陈旧快照 —— 召回原因读老 plan 的 1;4,治疗链实时合成 1B;4C,对不上 →
      "暂不召回",看着像"刷了没变化"。
      
      修复:
      - 后端 getPlanDetailByPlanId 检测请求的 plan 为 superseded → 解析该患者当前
        active/assigned plan → 响应带 currentPlanId(否则 = 请求 id);serializePlan
        增加 updatedAt。
      - 前端 page 据 currentPlanId !== planId 时 router.replace 落到最新版(过场
        提示,不渲染陈旧快照),通吃所有老 URL 入口。
      - 刷新按钮左侧加"更新于 X"(plan.updatedAt 相对时间),给数据新鲜度。
      
      验证:老 plan(superseded)→ currentPlanId=新版;新版 K00 chain.target=true
      (★潜在新链)、reason=1B;4C。service + web tsc 均通过。
      
      Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
      luoqi committed
    • fix(sync): cold-import Prisma 热路径加瞬时错误重试 · bcf26264
      首次全量在 batch 183 因 prisma.patientTransaction.findMany() 报
      "Response from the Engine was empty"(Prisma 引擎 socket 瞬时抖动)整轮 abort。
      上次只给 ClickHouse 查询加了重试,Prisma 侧未覆盖。
      
      补 withDbRetry 指数退避(0.5/1.5/4.5s),包裹 cold-import 写主循环里
      会 re-throw 而中断整轮的 DB 调用:createMany / findMany(回查 tx)/
      buildPatientIndex / ensurePatientStub / patient+profile upsert。
      只重试已知瞬时类错误(empty engine / 连接池 / ECONNRESET / server closed /
      too many connections),P2002 等确定性错误立即抛交 caller。
      
      Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
      luoqi committed
    • fix(plan): 乳牙牙位归一化统一 — 修 K00 召回链显示"暂不召回"自相矛盾 · 368b2a9e
      bug(李梦维案例):召回原因有"牙发育/萌出异常 牙位 1;4"(K00),但治疗链那条
      显示"已发现·暂不召回"(target=false),自相矛盾。
      
      根因:两套牙位归一化口径不一致(仅乳牙 Palmer 记号分叉):
        - scenario 的 parseToothSet:/^\d+/ 只取前导数字 → "1B"→"1"(剥 Palmer 字母)
          → reason 牙位/sub_key = "1;4"
        - chain-composer:原样保留 → chain 牙位 = "1B;4C"
        - plan-aggregate target 匹配用共享 toothSet:保留 Palmer → "1B;4C"→{1B,4C}
        → toothOverlap({1,4}, {1B,4C}) = 空 → target=false → UI"暂不召回"
        (恒牙 FDI 36/46 两边都 "36" 一致,只乳牙 1B/4C 这种分叉)
      
      修复:scenario 的 parseToothSet 改为委托共享 toothSet(单一真理源),
      不再自己剥字母。乳牙 reason 牙位 → "1B;4C",跟 chain + target 匹配口径一致。
      
      验证(本地重算后):active K00 reason 牙位已保留 Palmer(@3E / @2C / @1C;2C
      等 209 条),与 chain 对齐 → target=true → ★潜在新链。
      (李梦维本例 plan=assigned 被引擎跳过不重算,需 unassign 后才刷新 — 数据状态非 bug)
      
      union-find 合并也受益:1B / 1C 现在是不同乳牙(不再都归"1"误并)。
      luoqi committed
    • fix(sync): CH query 加瞬时错误重试(远程 DW 抖动健壮性) · c5ffe5ca
      服务器全量 sync 在 batch 180/260 撞远程阿里云 DW 瞬时错误
      "Response from the Engine was empty" → 整跑 failed(数据已提交 180 批,
      锁正常释放,cursor 因 status=failed 被过滤不误推进 — 都按设计работает)。
      
      根因:一次全量 260 批 × 6 query = 1560 次远程查询,只要一次网络抖动就挂。
      
      修复:queryJsonWithRetry —— 对瞬时错误(empty/timeout/reset/socket/5xx)
      指数退避重试 3 次(0.5/1.5/4.5s);确定性错误(SQL/权限)不重试快速失败。
      应用到 3 个热路径:listPatientPairs / loadTablesForCohort / loadAllTables。
      (reversePull / 单患者 refresh 数据量小,暂不包)
      luoqi committed
    • docs(algorithm): 修正示例患者 卜晓平(不存在)→ 李梦维(真实召回 91 分) · d849b1b2
      卜晓平是 W3 老 demo 患者,现数据库不存在、不在召回 → 文档示例是"假证据"。
      换成真实召回数据里最高分的 李梦维(91 分,可核):
      - K08 缺失牙@46,128 天前诊断未启动种植
      - 钻卡 ¥98,220 / 流失风险 none(距上次 72 天活跃)
      - breakdown 逐项取自 plan_reasons.breakdown JSONB:
        (60×1.0 + 20[钻卡] + 6[risk none (3-0)×2] + 5[128>120紧迫]) ×1.0 = 91
      §三 example 表 + §六 端到端走查 同步替换为李梦维真实路径
      luoqi committed
    • docs(algorithm): 补充治疗链合成 + AI 话术生成两个算法(5 算法完整) · c0614d3a
      审计发现通俗文档漏了 2 个够格的决策算法,补上:
      
      §四 治疗链合成算法(chain-composer):
      - 5 阶段 S1发现→S5闭环 + 4 状态(发现/进入/执行中/已闭环)
      - 按牙位 union-find 分链;6 种生命周期(一次性/线性/根管+冠/长周期/周期性/终身维护)
      - 牙周 lifelong_maintenance maxStage=4 永不闭环(临床事实非 bug)
      - 闭环条件严(全 milestone+复查+无退费+无反弹)+ 替代闭环
      - 定位:画像"治疗链状态"标签的深层引擎 + UI 可视化进度条
      
      §五 AI 话术生成算法(Skills harness):
      - "搭积木"非套模板:4 维度装配话术包(场景/病种 K00-K09/人群/新老客)+ 大模型润色
      - 三道安全护栏(只认提纲事实 / 禁词不承诺不写死时间 / 失败降级模板)
      - 可追溯(skills_used + promptVersion + token)
      - 运营改 MD 不动代码
      
      配套更新:
      - §〇 流水线图 3→5 算法(治疗链作支撑算法喂画像 + 可视化)
      - §六 端到端 卜晓平 加治疗链 + 话术两步
      - 一句话总括 + FAQ 补 治疗链永不闭环 / AI 话术护栏 两问
      
      至此 5 算法完整:召回(谁)→画像+治疗链(是谁/到哪)→优先级(先打谁)→话术(怎么打)
      luoqi committed
    • docs(algorithm): 列出默认调参值 + 补医生建议码(作基线对照) · b7d424d7
      按反馈:默认值列出来作基线对照(线上偏离时好核是不是配置被调过)。
      
      §一 病种表:
      - 补「建议码」列(IMPLANT_RECOMMENDED 等)— 诊断码之外的第二触发源,
        说明任一命中即召回;建议码也是码(置信度低些)
      - 恢复 + 扩充「默认调参值」表:起步分 / 冷静期 / 黄金窗末 / 紧迫临界 全 10 病种
        标"出厂默认,可配,真理源在 SUB_SCENARIOS + DiagnosisTreatmentMap"
      
      §三 优先级因素表:
      - 补回「默认值」列(钻+20/金+15… / confidence ×1.0/0.9/0.75 / 时间窗曲线)
        跟 §一 一致,作基线对照
      
      ️ 全局注释口径调整:从"不写死数字" → "列默认值作基线对照,算法形状稳定数值可调"
      (呼应"万一偏离了呢" — 默认值是对照基准)
      luoqi committed
    • docs(algorithm): 权重数值不写死 — 标注"可按宿主配置,真理源在代码" · 9bea4619
      按反馈:权重/分档/阈值是可调参数(不同集团对种植vs补牙重视不同),
      不该写死进文档(改参就要改文档 + 误导成"铁律")。改为只讲算法结构:
      
      - 顶部加 ️ 全局说明:数值皆默认可配,文档讲结构不写死数字
      - §一 病种表:去掉 base/cooldown 具体数值,保留病种 + 临床价值排序;
        证据块只点"起步分/冷静期是可配默认值,真理源在代码"
      - §三 优先级因素表:去掉精确权重(60/+20/×0.9 等),改"方向"描述
        (越值钱起步越高 / 越可信折扣越接近1);保留算法结构公式形状
        + clamp/round 含义说明(取整 + 夹 0~100 保证不越界)
      - 卜晓平例子:保留(具体走一遍需数字),标注"用默认权重,换配置随之变"
      
      顺修 §四 端到端 流失风险 低→中(跟修正后的 87 分例子自洽:链有缺口=medium)
      luoqi committed
    • docs(algorithm): 通俗算法文档每个论断加 📐 证据(公式/数值/谓词) · 6e68bae1
      按需求:正文保持大白话,关键论断下加简短证据块,专业人员可核,非技术可跳过。
      
      召回算法:
      - 10 情况表加 诊断码 + 基线分 + 冷静期(SUB_SCENARIOS.base / DiagnosisTreatmentMap.cooldownDays)
      - 触发条件谓词(双信号源 + confidence)
      - 3 道过滤网各加证据:合规 SQL 三条件 / 只设下界不设上界 / NOT EXISTS 牙位 overlap + 时间方向
      - 合并规则:union-find 牙位重叠 + sub_key 标识示例
      
      画像算法 4 标签全加阈值证据:
      - 价值:净额公式 + 5 档(¥30k/10k/3k/500)
      - 流失风险:540+gap/360|gap/180 天分档
      - 治疗链:in_progress/closed/gap 判据
      - 不打扰:DNC 硬命中条件(+phone 缺失不算 DNC)
      
      优先级算法:
      - 6 因素表 举例列 → 精确取值证据(各 bonus 分档 + timeWindow 曲线 + confidence 阶梯)
      - 完整公式 priority-scorer.ts 1:1
      - 卜晓平 87 分例子修正自洽:流失风险 低→中(formula (3−2)×2=+2),
        补"对应公式"列,(60×1.0+20+2+5)×1.0=87 可逐项核
      
      (原例子"风险低 +2"跟 formula 矛盾;low→+4,medium→+2;改 medium 使总分仍 87 且自洽)
      luoqi committed
    • docs(algorithm): 新增 PAC 三大算法通俗说明(非技术读者) · c2f19278
      pac-algorithms-overview.md — 用大白话讲清三大算法,面向产品/运营/诊所管理者/客服主管:
      - 召回算法:谁该被请回来(10 子场景 K00-K09 + 三道过滤网 + tooth 合并)
      - 画像算法:这个患者是谁(4 标签 value/流失风险/治疗链状态/不打扰,全规则可解释)
      - 优先级算法:先打给谁(6 因子打分 0-100,卜晓平 87 分实例)
      - 三者配合端到端流程 + 常见疑问 FAQ
      
      无代码/SQL/字段名;实现细节指向 potential-treatment-recall-flow.md
      luoqi committed
    • docs: 对齐 onboarding/canonical-fact-layer/potential-recall 到代码 · e44f5d9a
      审计发现 3 文档跟 sync 重构 + scenario 扩展(K00-K09)漂移,修正:
      
      onboarding-runbook.md(运维命令,最高优先):
      - Phase 4 + 能力表 + 完成标准 + 落点图:pnpm cold-import → pnpm sync
        (--no-recompute + 手动 persona/plan;PAC_COHORT_CONCURRENCY 并行提示)
      - 4 子场景 → 10 子场景(K00-K09)
      - 加统一 sync 入口说明(cold-import = legacy 全量 alias)
      
      canonical-fact-layer.md:
      - §六/§九 "3 子规则(K08/K02/K05)" → 10 子规则 K00-K09 + tooth-overlap union-find
      - 实现状态表 DW 直连增量 🟡框架预留 →  统一 pnpm sync(cohort/并发/bulk/run_start cursor)
      
      potential-treatment-recall.md(W2 末设计稿):
      - 顶部加 banner:已被 potential-treatment-recall-flow.md(实现版)取代
      - 列关键差异(4→10 子场景 / 去时间上界 / tooth-overlap union-find / cold-import→sync)
      - 标"勿照本文实施",保留作设计思路存档
      
      design-v2.md 审计无漂移(讲的是另一个 scenario post_treatment_recall,不动)
      luoqi committed
    • docs(algorithm): 对齐 potential-treatment-recall-flow 到代码实现 · 418d47d9
      §L3 召回 SQL 重写到当前实现:
      - 子场景 4 → 10(K00–K09 全覆盖),表补全 base/dx/rec/cooldown/window/urgent/exclude
        (真理源 SUB_SCENARIOS + DiagnosisTreatmentMap@canonical-codes)
      - 入池 SQL 修正:
        · 只设时间下界 cooldown,️ 去掉上界(W3 末:缺口不自愈,超 window 仍入池交 scorer 衰减)
        · COALESCE(occurred_at, planned_for)(诊断 vs 推荐)
        · 排除升级为牙位级 overlap(W4)+ 时间方向 tx.occurred_at >= sig + ⑤b拔除 ⑤c未来预约排除
      - 合并逻辑:旧"取 days_since 最大丢其余" → tooth-overlap union-find
        · sub_key = '<sub>@<union(tooth)|whole>';同 patient 同 sub 不同牙位 = 多 reason 行
        · cluster_triggers/factIds 全量;来源标签 dx+rec → "(诊断+医生建议)"
      - 总览图 mermaid "4 子规则" → 10
      - 实现状态:scenario 3→10 子规则;DW 增量 cursor 🟡→(统一 sync 重构)
      luoqi committed
    • docs(deployment): 对齐 sync harness 重构(PR1-5b + 统一 sync 入口) · ddeb2661
      deployment-data-ingest.md 更新到当前实现:
      - §2.2 加 sync 资源调优 env(COHORT_BATCH_SIZE/CONCURRENCY/WRITE_BATCH_SIZE)
        + Docker Compose --env-file 必带 + connection_limit 说明
      - §2.4 加 docker compose prod 部署(--env-file + pac-migrate/pac-service 两镜像都要 rebuild)
      - §3 重写数据摄入 SOP:
        · 3.1 cold-import → 统一 pnpm sync 首跑(cohort 分批 + 并行,~1.5-2h vs 旧 9-12h)
        · 3.2 增量走同一 importDirectory(并发锁 + run_start cursor)
        · 3.3 幂等 DB 双 UNIQUE(含补的 source_event partial unique)+ cohort 宿主无关
        · 3.4 新增资源调优表(cohort/concurrency/write-batch/query 并行)
      - §7.3 cursor 倒退加 --full;§7.4 新增 stale running 锁清理 SOP
      - §8 已知边界更新(去掉过时的 LIMIT 100;加 COPY/看门狗/并行锁)
      - 一句话归纳重写
      
      反映:cold-import/sync-incremental 合并;cohort batch(PR2);
      统一 mode(PR3);bulk write(PR4);query 并行(PR5a);cohort 并行(PR5b);
      source_event partial unique 补建;宿主无关 cohort 配置。
      luoqi committed
    • feat(sync): PR5b — cohort 级并行 worker pool(资源充分利用) · 45255896
      env PAC_COHORT_CONCURRENCY:
        默认 1 = 完全串行(行为不变,最稳)
        >1 = worker pool 并发处理 N 个 cohort
      
      并发安全(数据完整性靠架构,非"小心写代码"):
        - cohort = disjoint 患者集(chunk distinct pairs)→ 所有写入患者级隔离
        - source_event_id partial UNIQUE(本 PR 前一 commit 补)+ fact (subject_id,version)
          UNIQUE 双保险 → 任何并发下 DB 强制一致
        - totals/seenTenants/firstError 共享但 JS 单线程,+= / .add() 同步原子无 race
        - cursorAdvances race 无害(PR1 后 cursor=run_start 不读它)
      
      worker pool 实现:N 个 runner 从 indices 队列各自拉下一个直到耗尽(无额外依赖)。
      
      并行模式调整:
        - checkpoint resume 禁用(完成乱序前缀语义失效;靠 source_event_id 幂等从头重跑安全)
        - metadata.cohortDone 改 count-based(近似进度)+ 记 cohortMode/concurrency
      
      本地验证(concurrency=4, 9 batch × 50 患者):
        - 4 cohort 同时 start,worker pool 滚动,9 批 8 秒(串行约 20-45s)
        - 内存峰值 338MB(单 cohort × 并发,14GB 有余量)
        - 数据完整性自查全过:0 dup_source_events / 0 multi_active_facts
        - 幂等:重跑全 skip
      
      运维注意(已在 resolveCohortConcurrency 注释):
        并行需 bump Prisma 连接池 DATABASE_URL?connection_limit=N×N+余量;
        内存 N× 单 cohort(并发 4 ≈ 1.3GB)
      
      服务器用法:pnpm sync:prod -- --dir=./data/jvs-dw(默认串行);
        榨资源:PAC_COHORT_CONCURRENCY=4 pnpm sync:prod ...
      luoqi committed
    • fix(sync): 补 patient_transactions 缺失的 source_event_id partial UNIQUE · bf921bbc
      🔴 预存 bug:schema 注释一直声称有 partial UNIQUE 幂等键,但 init migration
      从没真正建过 → source_event_id 幂等从未在 DB 层强制:
        - createMany({skipDuplicates:true}) 一直是空操作(无约束可依据,全插入)
        - per-row create 的 P2002 catch 永不触发
        - 重复导入 / 同源 dup 行 → 产生重复 patient_transactions
      
      发现过程:PR5b 并行测试做完整性自查,发现 12 个重复 source_event_id
      (都是 fact_appointment_out 里同 appointment 同时间戳的真重复行)。
      查 \d patient_transactions 确认无 unique index,查 init migration 确认从没建。
      
      注:fact 层的 (subject_id, version) UNIQUE 是真建了的,所以 fact 没坏
      (自查 multi_active_facts=0 验证),只有 tx 层有重复风险。
      
      migration 20260528000002:
        1. dedup 已有重复(每组保留 event_seq 最小 = 最早写入)
        2. 建 partial UNIQUE (host_id, tenant_id, source_event_id) WHERE NOT NULL
      
      本地验证:
        dedup: 4330 → 4318(删 12 重复),remaining_dups=0
        加约束后重跑并行:txns=4318 dups=12(constraint 正确拦截 12 重复 skip)
        DB 终态:0 dup_source_events / 0 multi_active_facts
        → createMany skipDuplicates 现在真正生效;幂等在 DB 层强制
      luoqi committed
    • perf(sync): PR5a — 并行化 CH query(load 阶段 ~4x) · d3635199
      loadTablesForCohort + loadAllTables 的 N 个 ClickHouse query 从串行
      for-await 改 Promise.all 并行。
      
      瓶颈分析:远程 DW 每 query 往返 170-580ms,5-6 个串行 = ~2.3s/批,
      是 cohort batch 里 load 阶段的主要耗时。@clickhouse/client 基于 HTTP,
      单 client 可并发多请求。
      
      并发安全:
      - cursorAdvances 在 Promise.all 前预初始化(避免并行 callback 各自 ?? {} 互相覆盖)
      - 每个 tableName 写自己的 key,无竞争
      - tables[tableName] 各写各的 key,无竞争
      
      本地验证(2 批 × ~100 患者):
        并行前(串行累加):191+193+329+393+579+584 = ~2269ms/批
        并行后(取最慢):  max ≈ 584ms/批  → ~4x load 加速
        幂等正确:superseded=2 unchanged=285 evidence=1632(重读决策正确)
      
      资源利用:之前 1 核串行等 IO,现在 6 query 并发打满网络/CH;
      内存不变(本来就 hold 全部表)。
      
      PR5b(可选,未做):cohort 级并行 worker pool(env PAC_COHORT_CONCURRENCY)
        — 多 cohort 并发处理,3-4x 吞吐;但需 bump prisma pool + checkpoint 改 count-based,
        风险略高,留作后续按需开。
      luoqi committed
    • refactor(sync): cohort config 宿主无关化 — 表名/列名移到 manifest · 24a7ce2c
      修复 PR2/PR4 引入的硬编码违反"各宿主只 yaml 不同"原则的问题。
      
      问题:
        listPatientPairs / loadTablesForCohort / injectCohortFilter 硬编码了
        jvs-dw 专属的 `dw_group.fact_client_out` + `patient_id` + `brand`,
        其它 host 接入需改代码 → 违反 PAC 核心设计(摄入流程跟宿主无关)。
      
      修复:
        manifest.schema 加 sql_source.cohort 配置段:
          patient_list_from    列患者清单的源表全名(库.表)
          patient_key_column   患者主键列(所有源表共用做 cohort 过滤,默认 patient_id)
          tenant_key_column    租户区分列(可选;jvs-dw=brand;单 tenant host 删此行)
          list_cursor_column   列患者增量 cursor 列(对应主档表时间列)
      
        ClickHouseSourceService:
          - CohortKey 类型 { key, tenant? } 替代 { patient_id, brand }(值载体,列名外置)
          - listPatientPairs:SELECT DISTINCT <key>[,<tenant>] FROM <list_from>
            [WHERE <cursor_col> > x] ORDER BY <key> — 全从 cohort 配置读
          - loadTablesForCohort + buildCohortClause:
            有 tenant_key → (key,tenant) IN ((..))  无 → key IN (..)
          - injectCohortFilter 接收已构造好的 clause,不再硬编码列名
      
        cold-import.service:
          - cohort 类型改 CohortKey;canCohort 检查 manifest.sql_source.cohort 存在
          - 配了 cohortBatchSize 但没 cohort 段 → warn + 退回 single-shot
      
        manifest.yaml(jvs-dw)加 cohort 段:
          patient_list_from: dw_group.fact_client_out
          patient_key_column: patient_id
          tenant_key_column: brand
          list_cursor_column: last_visit_time
      
      本地端到端验证(33,400 患者 / 759k tx):
         内存峰值 389MB(对比服务器 OOM 7.6GB)— cohort batching 决定性
         8 个 subject_type 全覆盖(含之前服务器 0 的 emr/payment/image)
         并发锁拦截 + cursor=run_start 推进
         每批增量提交(checkpoint)
         plan compose:10,028 plans / 17,828 reasons
         sub_key tooth-overlap union-find(impacted_tooth@18;28;38;48 多牙合并)
         K01-K08 全场景召回触发
      
      新 host 接入清单(零代码):
        1. manifest.yaml 写 connection + queries + incremental.per_query + cohort 段
        2. 写 assemblers/*.yaml(canonical 映射)
        3. 写 transforms(如需 JSON 拆行等)
        完事 — sync / cohort / 增量 / 锁 全部复用
      luoqi committed
    • feat(sync): PR4 — bulk createMany + batched parser(9h→30min 性能优化) · 3fd28974
      Hot path 优化 — 把每行一次 SQL 改成每 1000 行一次 SQL,SQL 往返从 ~3N 降到 ~5。
      
      FactWriter 新增 bulkWrite(entries):
        - 1 次 SELECT 取所有相关 subject 的 latest version(by subjectId IN)
        - 内存里链式决策:unchanged / evidence_append / supersede / create
        - 处理 batch 内同 subject 多 draft(后 draft 跟前 draft 比,递推 liveLatest)
        - 1 个 $transaction commit:bulk updateMany supersede + bulk createMany 新版本
          + 罕见 evidence_append 走 array_append raw SQL
        - {maxWait: 30s, timeout: 120s} 防大批量 + swap 下 5s 默认超时
      
      ParserPipeline 新增 runForBatch(items):
        - 全部 tx 走 parser 收集 drafts(in-memory,无 DB)
        - 1 次 FactWriter.bulkWrite 提交;失败降级 per-entry writeDraft(保收尾)
        - 同 runForTransaction 的 metrics 接口,调用方零适配
      
      cold-import processSubject 大重构:
        - 引入 buffer 按 tenant 分桶(因 createMany 不能跨 tenant)
        - 每 N 行触发 flushBatchedWrite:
            1. createMany tx({skipDuplicates: true})— 1 SQL 写 N 行
            2. SELECT WHERE sourceEventId IN (...) — 取回 tx ids 喂 parser
            3. parserPipeline.runForBatch — 内部 bulkWrite
        - createMany 失败降级 fallbackPerRowWrite(per-row 老路径,保稳)
        - env PAC_WRITE_BATCH_SIZE 兜底(默认 1000;0 = 退回 per-row 回滚开关)
      
      性能预期(实测待验证):
        per-row baseline:  ~80 tx/s (实测服务器)
        bulk createMany +  10-20x → 800-1600 tx/s
        4.6M 行全量:       9-12h → **30-60min**
      
      跟 PR3 统一 sync 模式协同:
        - 任何 mode(sync/--full/cron 增量)都走同一条 hot path
        - cohort batch(PR2)+ write batch(PR4)正交叠加
        - 失败降级保稳(createMany 崩 → fallback per-row;bulkWrite 崩 → fallback writeDraft)
        - 同 fact subject_id 跨 batch 一致性靠 version + partial UNIQUE active 兜底,不变
      
      未来 PR5(可选):pg-copy-streams 真 COPY + staging 表 → 再 3-5x(总 30-50x)
      luoqi committed
    • feat(sync): PR3 — 统一 sync 模式,deprecate cold-import 独立路径 · f7a6d41f
      把 cold-import / incremental 两个独立模式合并为单一 sync 入口。
      
      importDirectory 关键改动:
      - 只要 manifest 配了 sql_source.incremental.per_query,**永远** 读 + 写 cursor
      - options.incremental === false → 走 --full 路径(忽略上次 cursor,但仍写新 cursor_after)
      - options.incremental 默认 true → 正常 sync(读 + 写 cursor)
      - resource 统一写 'incremental_bundle'(不再区分 cold_import / incremental)
      - readLastIncrementalCursor 接受 SUCCESS + PARTIAL 历史行(partial 已推进 cursor)
      
      修关键 bug:
        PR1 之前,--full 模式(incremental=false)不写 cursor → 下次 sync 从旧 cursor 拉
        → **漏掉 --full 期间 DW 新写入的数据**
        PR3 统一写 cursor_after=run_start,保证后续 sync 永远从最新 baseline 接力
      
      新 CLI 入口:
      - pnpm sync          → 推荐主入口(=sync-incremental,首跑全量 / 日常增量自动)
      - pnpm sync:prod     → docker prod 版
      - pnpm sync:once     → 别名(强调"手动触发一次")
      - pnpm cold-import   → legacy 保留(不读 cursor,等价 pnpm sync --full)
      - pnpm sync-incremental → legacy alias 保留
      
      sync-incremental.cli.ts 增强:
      - 加 --full / --cohort-batch=N / --no-cohort 参数
      - 文件头改 "PAC v1 唯一推荐 sync 命令" + 用法 + 退出码注释
      - 启动日志带 cohortBatch / full / dryRun 配置全量
      
      部署节奏(server 实战):
        首次:pnpm sync:prod -- --dir=./data/jvs-dw
              → 自动分批(5000 patient/批)
              → cursor_after = 启动时刻 ISO
              → 持续跑直到所有 patient 处理完成
              → 此后 cron 02:30 增量自动接力
        灾后:DELETE FROM patient_transactions...;
              pnpm sync:prod -- --dir=./data/jvs-dw  (cursor 自然为空 = 等价全量)
        强制:pnpm sync -- --full(罕见,cursor 损坏修复场景)
      
      向后兼容:
      - 所有老脚本(cold-import / sync-incremental)继续工作
      - SyncIncrementalSchedulerService 不动(默认 incremental:true)
      - 历史 sync_logs 行(resource='cold_import_bundle')不受影响,
        自然被 cursor_after IS NULL 过滤掉
      luoqi committed
    • 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 1 commit