1. 27 Jul, 2026 1 commit
    • fix(ai): 历史联系摘要只讲回访事实,不再对到诊/失联下结论 · 469ef2c9
      生产实例(陈芃霖 BJ0A094257):
        摘要「上次**有效到诊**为2023年8月,已超一年未复诊」
        实际  接诊记录末次 2025-12-20,画像也写着「末诊207天」—— 差 2 年 4 个月
      同句另两处错:「已超一年未复诊」实为 7 个月;「近3个月涂氟邀约」那条回访实为 226 天前。
      
      源头是这条回访记录:
        2023-08-15 | 常规回访 | 已回访 | 已完成 | 内容:复查 | 结果:8月已就诊
      诊所客服 2023 年写的一句备注,被模型升格成了患者当前的到诊状态。「有效到诊」这个词
      源数据里没有,是模型自己造的。
      
      ── 根因 ──
      本 AiCall 的输入**只有回访记录,没有任何到诊数据**(见 input.types)。模型只能:
        ① 从回访备注文字里猜到诊 —— 「8月已就诊」被当成事实
        ② 用"没有回访记录"推"没来过" —— 但回访是诊所主动打电话,患者自己来院不产生回访任务
      两条路都错。而旧 prompt 不但没拦,关键要求 4 明写「优先突出…长期未到诊」,
      示例里还有「此前半年 5 次回访无到诊」在**示范这个错法**。
      
      ── 生产规模 ──
      795 条 recall_history 摘要,162 条断言"失联/沉睡/未到诊",
      其中 88 条(54%)患者实际 180 天内来过,18 条 90 天内来过。
      最离谱一条:「自2018年至今无到诊或有效联系,属长期失联状态,建议优先核实联系方式」
      —— 该患者 2026-06-24 刚到诊,上个月的事。客服照这个打电话会很尴尬。
      
      ── 改法(按业务口径「这里只摘要回访事实」)──
      不给它喂到诊数据,而是划清职责:到诊状态有专门的地方出(画像卡「末诊 N 天」按真实接诊算)。
        1) prompt 加【职责边界】硬约束:输入里没有到诊数据,禁止判断是否到诊/失联/沉睡;
           结果字段里的"已就诊"只能当那次回访的记录引述,不得当成到诊状态;
           明确掐断"没有回访 ≠ 没来过"这条推理链
        2) 修掉关键要求 4 和示例里示范错法的句子,新增反例段(把三条真实事故句列为 ✗)
        3) 补程序算好的 daysAgo —— 相对时间不让模型自己减日期
        4) 版本 @2026-07-26-f → @2026-07-27-g
      
      ── 这是同一个错误第三次以不同形式出现 ──
      07-24 是"未来 vs 过去"(未来排程被说成漏做),今天是"回访 vs 到诊"和"距今多久"。
      当初立的纪律是「能程序算的事实全算好、LLM 只润色」,但只补了一个轴,
      于是同类错误换个轴又来。本次把职责边界和 daysAgo 都用测试锁住(新增 8 例)。
      
      注:已生成的 795 条摘要是缓存,需另行重刷才会用新 prompt;
          本次先删了陈芃霖那一条供业务观察重新生成效果。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  2. 26 Jul, 2026 17 commits
    • fix(deploy): 机器形态自检 —— 该托管却漏 COMPOSE_MANAGED=1 直接退出 · 0165bc11
      ## 事故(2026-07-26,生产 friday / 47.99.62.30)
      
      在生产机漏了 `COMPOSE_MANAGED=1` 跑 deploy-prod.sh。不叠加 managed override 时,
      docker-compose.prod.yml 把 DATABASE_URL 硬编码成 `postgres:5432`(容器内自带 PG),于是:
      
        22:35:33  起了 pac-postgres-1 / pac-redis-1 两个空容器(volume 同刻创建)
        22:35~36  pac-migrate 对着这个**全新空库**跑完 29 条迁移,建出整套 schema
                  ← 在这里被打断
        22:38:29  正确那次(COMPOSE_MANAGED=1)把两条新迁移写进托管库
      
      托管生产库**未被写坏**,逐项核对:patients 425,874 / facts 25,022,720 /
      在池 236,553 / assigned 36 / personas 409,368 / plan_executions 7 —— 与事故前一致
      (patients、facts 的 +2/+206 是正常增量摄入)。空库那侧唯一有行的表是 _prisma_migrations(29 行)。
      
      ## 真正的后遗症(已清理)
      
      机器上留下一个「schema 齐全的空库」。危害不在占资源,在于:
      **下次再漏 COMPOSE_MANAGED,service 会顺利连上它、health 200、脚本三项验证全绿,
      生产静默服务空库且零告警。** 原本"空 PG 没 schema 会崩"这张天然安全网就此失效。
      
      已在生产执行:docker rm -f pac-postgres-1 pac-redis-1
                    docker volume rm pac_postgres_data pac_redis_data
      清理前核过:两 volume 无其他容器挂载 / pac-service 连的是 RDS+托管 Redis /
      pac-web 无 DB 环境变量 / 本地库业务表全 0 / 全机再无其他 volume。清理后 health+web+私网 200。
      
      ## 防线
      
      不依赖人记得加环境变量,而是问机器自己 —— 读 `.env` 的 DATABASE_URL 指向哪儿:
        · postgres / localhost / 127.0.0.1 → 自带 DB 形态,放行(测试机)
        · 其他主机(RDS 等)               → 托管形态,没设 COMPOSE_MANAGED=1 就 die
      
      闸放在 main() 最前,早于任何 git/docker 动作 —— 拦下时机器状态零变更。
      报错只打主机名不打整串 URL(不泄露 .env 口令)。
      
      ## 验证
      
      - 新增 tests/deploy-managed-guard.spec.ts 5 例,真起 bash 跑脚本:
        托管+漏设→退出且给出正确命令 / 报错不含口令 / 托管+设了→放行 /
        自带 DB→放行(不误伤测试机) / localhost 与 127.0.0.1 同样放行
      - 482 单测通过(35 suites),service tsc 干净
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • merge: feat/persona-explainability → main(画像可解释性 + 历史联系最近一次 + ⑤g 未来回访排除闸) · 13733519
      已在 test(a0d20f82)部署验证。三块内容:
      
      1. 画像可解释性 —— 水位对齐 fact 层 + 写入侧节流;到期改档精确调度(nextBoundaryAt);
         详情页说明收回 @pac/types 单一真理源 + 打通证据链 + 分组定序 + 圈人标签 hint
      2. 历史联系「最近一次」—— 程序判定 isLatestPast,摘要开头必须交代;prompt 加数据边界
         硬约束(源里只有标签没有原话,没记的不许补);抽屉标最近一次/未到期;
         修 fallback 拿 items[0] 当最近一次的老 bug;两处 orderBy 改 nulls:'last'
      3. ⑤g 未来回访排除闸 —— 诊所已排未来回访则本轮不召,排到哪天挡到哪天,到期自愈
      
      ️ 部署后必须做的两件事(见两个 migration 文件内注释):
        a. patient_facts 复合索引已在 migration 内,大表建议先 CONCURRENTLY 预建
        b. 画像回填 **--force 必须加**:next_boundary_at 为 NULL 语义是「不再改档」,
           水位闸认为这些版本新鲜、永远不会重算,不会自愈
        c. ⑤g 需 plan 重算才整体生效(生产预估挡下 30,426 条在池 plan / 12.9%)
      
      本地 477 单测 / service+web tsc 干净;test 服已验闸的两个方向。
      luoqi committed
    • feat(recall): ⑤g 排除闸 —— 诊所已排未来回访则本轮不召 · 5ed2e888
      业务可用性测试 Issue「超百天自动加急覆盖回访计划」:
        [被测者]「超过百天他就会给你加急自动升了一个紧急,而且他设置过的回访的话他实际是不记得」
      
      ## 先纠两个事实
      
      1. 阈值是 **90 天**不是 100 天(urgency_level:>90 紧急 / 30-90 高 / <30 中),
         而且它不只是标签 —— 急迫性 ×0.4 是综合分权重最大的一块。
      2. 「覆盖了回访计划」不准确:PAC **从没看过** task_date,不是覆盖是没接。
         (回访表本身早就进召回了 —— gapWhere 里的外院已治疗闸读 rv.result;
          只是一直没用 task_date 这一列。)
      
      ## 口径:排到哪天挡到哪天
      
        AND NOT EXISTS (SELECT 1 FROM patient_return_visits rv
                        WHERE rv.patient_id = p.id AND rv.task_date > now()::date)
      
      跟 ⑤b(未来预约)同一个道理,注释原话就够用:「召回目的 = 让客服建预约。
      患者已经有未来预约 → 客服不需要再 push」。回访是同构的,只是接触形式换成电话/微信。
      
      三个设计选择,都是被生产数据推翻假设之后定的:
      
      · **不设窗口上限。** 原本想拍 N=60 天,查完发现理由不成立:在池患者的未来排程
        99.65% 在一年内(2026 年 25,105 / 2027 年 5,271 / 2028 年以后共 50 条,最远 2033-11-12)。
        固定窗口反而会在第 N+1 天把"诊所排了 N+30 天回访"的人放回池,制造重复触达。
        那 50 条脏数据不值得引一个新常量 —— 真要防该在摄入侧校 task_date 范围。
      
      · **不为"排了不做"加兜底。** 全库到期任务执行率 74.3%(118.1 万 / 159.1 万),
        确实有 1/4 烂尾。但闸的条件是 task_date > today,任务日一过闸自动失效、患者当天回池 ——
        SQL 每轮重算就是自愈机制,不需要解除逻辑。
      
      · **放 scenario 不放 gapWhere。** 这是本次唯一的架构判断:
          gapWhere(召回 + 画像共享)= 【临床事实】层 —— 已治疗/患者拒绝/外院已做 → 缺口真没了
          scenario WHERE(仅召回)   = 【运营时机】层 —— 缺口还在,只是现在不该打电话(⑤b/⑤f)
        未来回访显然是后者:排了个电话不代表那颗缺牙补上了。放进 gapWhere 会让画像因为
        "排了个回访"就说这人没有潜在治疗 —— 那才是口径分叉。
      
      ## 生产影响(上线前实测)
      
        在池 plan            236,508
        ⑤g 将挡下             30,426  (12.9%)
          其中 urgent         17,263  ← 正是一线抱怨的"明明排了回访还催我"
        未来排程到期分布      ≤30天 8,724 / 31-90天 8,502 / 91-180天 9,407 / >180天 3,793
      
      被挡住的 plan 会被 supersede(0 命中 → closeStaleActivePlan),到期是新版本 —— 已确认
      可接受:数据都有记录,跟踪得到;分配逻辑将来单独做。contactAttempts 归零暂不处理。
      
      ## 顺带修一处过期注释
      
      plan-engine.service.ts:70 还写着「关闭 active(非 assigned)plan / assigned 不动」,
      但代码从批量路径修复那次起就已含 assigned(:145 条件是 active OR assigned,
      :261 注释也写明是有意改的)。注释改成与代码一致。
      
      ## 本地验证
      
      - 477 单测通过(新增 8 例:闸形状 / 只认未到期 / 患者级 / 无窗口上限 /
        ⑤b⑤f 未被替换 / gapWhere 不含未来 task_date / gapWhere 仍保留外院闸 / 画像 SQL 不含本闸)
        测试用递归摊平 Prisma.Sql 的方式断言 —— 嵌套片段在 tagged template 里只是绑定参数,
        不摊开断言会假通过(第一版就踩了这个,gapWhere 内容根本没被检查到)。
      - service / web tsc 干净
      - 真库跑引擎:
          吕学文 1921711(有 2026-10-09 未来回访)→ plansClosed=1,plan 转 superseded 出池
          李石明 1873810(无未来回访)            → 不受影响,仍 active
          把 2026-10-09 改成昨天再跑 → plansCreated=1,v2 active 回池(自愈方向验证),随后还原
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(recall): 历史联系摘要突出「最近一次」+ 抽屉标注最近一次/未到期 · f634f274
      业务可用性测试 Issue 2「历史联系记录缺具体内容」(患者余同学 BJ0L028292):
        [被测者]「说了常规的提醒没有特殊的」
        [主持人]「那就是暂时没有办法详细的看到具体一次沟通什么内容」
      
      ## 先查源:PAC 没有藏数据,源侧确实只有标签
      
      DW 直查 fact_returnvisit_out(customer_id=1683102 brand=瑞尔),该患者 5 条:
      
        2026-05-20 常规回访 未回访/未完成 内容「复查洁牙」  结果 null
        2026-02-25 常规回访 未回访/未完成 内容「方案跟进」  结果 null
        2026-02-23 常规回访 已回访/已预约 内容 null        结果「复查」
        2025-11-16 常规回访 未回访/未完成 内容「半年复查」  结果 null
        2025-08-17 常规回访 已回访/已完成 内容 null        结果「复查」
      
      PAC 库里 5 条一条不差,7 个业务字段全部已在抽屉展示 —— **没有少展示任何东西**。
      
      ️ 排查中的一个坑,顺手确认了不是 bug:DW 直查会返回 15 条,多出的 10 条 brand=瑞泰。
         patient_id 跨品牌复用(374 万 id 中 196 万重号),1683102 在瑞尔=余继来(9岁,
         BJ0L028292)、在瑞泰=宋可超(33岁,QD0D020738),是两个人。摄入按 (patient_id, brand)
         复合键切分,各归各家,正确。
      
      全库口径(生产 165.2 万条回访):
        follow_content 填充 51.0%(均 12.4 字)· return_visit_result 填充 67.4%(均 9.1 字)
        取值 TOP:「种植潜客」8.6万「提醒洗牙」3.9万「无不适」8.2万「已约」3.5万「未接」1.2万
      
      即业务想要的三样东西,源里的实际情况是:
        · 沟通结论 / 患者态度 → **只有粗粒度标签**(无不适/已约/未接/挂断/拒接),没有原话
        · 下次建议联系时间   → **完全没有**(个别混在 result 自由文本里,如「7月份再联系」)
        · 逐字沟通内容       → **没有**;dw_group 全库只有 fact_returnvisit_out 一张联系表
      
      → 结论:摘要说「常规的提醒」是**如实转述**,不是 PAC 概括丢了信息。PAC 不造数据。
      
      ## 再改:业务关心「最近一次」这条建议成立,采纳
      
      列表按日期倒序时头部常是**还没到日子的排程**,客服最想知道的"上次聊到哪儿了"
      反而被埋在中间 —— 这是真问题,跟"源里没内容"是两回事。
      
      摘要侧(promptVersion → @2026-07-26-f):
        · orchestrator 程序判定 isLatestPast(第一条有日期且已到期的),同 isFuture 的纪律
          —— 哪条是"最近一次"是排序问题,不交给 LLM 比日期
        · prompt 渲染【最近一次·已发生】标记 + 要求摘要开头交代它(时间+事由+结果)
        · 新增「数据边界」硬约束段:明写源里只有标签、没有原话和下次时间,
          没记的一个字都不许补,禁止输出"患者表示…"这类原话式表述
        · 最近一次那条正文预算 40 → 160 字(源里偶有几百字的方案沟通记录,
          一刀切 40 字会把唯一一条有信息量的记录砍没)
        · 它没记结果时显式渲染「结果:未记录」—— 不写模型会拿别条的结果脑补这条
      
      抽屉侧:最近一次加 teal 角标 + 底色,未到期的加「未到期」灰标;
      最近一次没结果时明写「结果:宿主未记录」(空着会被当成 PAC 没展示,正是这次的误会来源)。
      
      顺带两个同源 bug:
        · fallback()(LLM 失败时给客服看的兜底句)还在拿 items[0] 当"最近一次",
          等于把 2026-07 那次未来排程事故在兜底路径上原样复刻 → 改走 isLatestPast
        · returnVisits 的 orderBy taskDate desc 在 Postgres 默认 NULLS FIRST,
          无日期的记录会顶到列表最前冒充"最近一次",还吃掉 take 名额 → nulls:'last'
          (摘要 take:12 与聚合 take:100 两处都改,口径一致)
      
      ## 本地验证
      - 469 单测通过(本文件 13 → 24,新增「最近一次」13 例,含余同学生产原始形态回放)
      - service / web tsc 干净
      - 吕学文(BJ0U017795,4 条历史 + 1 条 2026-10-09 未来排程)清缓存连跑 3 次稳定:
          「最近一次4月4日治疗后回访已完成;此前种植咨询未回访,已排10月9日复查洁牙邀约。」
        李石明(BJ0U017487,最近一次无结果)同样 3 次稳定:
          「最近一次4月种植咨询回访未执行、未留结果;此前3次常规回访均已约到诊复查…」
        —— 开头讲最近一次、没记结果如实说、未来排程仍说"已排"不说"漏做",三个方向都对
      - 抽屉截图确认:2026-10-09 带「未到期」灰标,2026-04-04 带「最近一次」角标+底色
      
      ## 待定(本次不做,需业务决定)
      - DW 有两列我们没摄:task_director(回访负责人姓名,「上次是谁跟的」)、
        appointment_id(该次回访是否真开出预约 —— 比 result 文本硬的结果证据)。
        加这两列要 migration + 165 万行 reparse,先记着。
      - fact_complain_out(8.15 万条投诉)整张表未摄入,是目前唯一没用的患者态度硬信号。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(build): .swcrc 的 exclude 是正则,必须锚定 · eb4d01c6
      跟刚 revert 的 hover 改动无关,单独留下 —— 这是个静默的构建坑,任何人加文件都可能撞上。
      
      原写 ["node_modules", "dist"],看着像目录名,swc 实际当**正则**匹配整个文件路径。
      于是文件名里含 "dist" 的源文件被直接跳过:271 个源文件只编译出 270 个,swc 不报错
      (照样打印 "Successfully compiled: 270 files"),直到进程启动才 MODULE_NOT_FOUND。
      同类地雷:distinct / distance / district / redistribute…
      
      更难查的是两边表现不一致 —— 只在 swc 路径(dev / --builder swc)复现,
      nest build 走 tsc 一切正常。
      
      锚定成 ["^node_modules/", "^dist/"],并加断言:exclude 必须以 ^ 开头,
      且遍历 src 下全部 .ts 确认一个都没被误伤。
      
      456 tests green。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • Revert "优先级 hover 可解释性" 三个 commit · 0eddcffa
      Revert a4b75100 + 22b2c95f + 4477a8c2。
      
      理由:改完比原来更不清晰。截图实例 —— 价值性 9 分、意愿度 8.0 分,右边却写着
      「画像未标潜在治疗」「画像未标价值分群」:分数明明算出来了,解释说没数据,自相矛盾。
      根因是 breakdown 里那几个展示字段只在 plan 重算后才有,存量 / 其他 host 的 plan 一律落到
      "未标"分支 —— 而"未标"这个措辞本身也把「后端没透出」说成了「画像没打标」。
      
      绕了一圈说明这块的设计还没想清楚,不该边改边试。退回原状,择期重做。
      
      保留(与本次 UI 无关,单独重提):.swcrc exclude 正则未锚定的构建坑。
      
      ## 重做时手上已有的事实(都已核过,别再查一遍)
      
      · 「看不出高低」是真问题:生产 active+assigned 23.6 万,priority_score
        p50=31 / p80=56 / p90=66 / p95=76 / p99=82。9.78 分是前 0.05%,而客服无从判断。
      · 三维输入里 urgencyLevel / rfmSegment / lifecycleStage / treatmentHistory /
        referralChampion / specialAttention 来自画像;consultIntentMatch 来自
        consultation_record;primaryCode / toothCount 来自本条召回的 gap。
      · consultIntentMatch 基本不可用:237 万条 consultation_record 只有 9.7% 解析出非空
        intent_categories,其余 214.8 万条源端 intent/content 全为 null(DW 没给,非解析 bug)。
        intentBehavior=2 的 22 万患者里真的没咨询过的只有 230 人(0.1%)。
      · 存量对不齐(与本次无关,仍在):按年龄排治疗只落到召回目标,没落到画像 ——
        classifyGapToLabel 里 K08 仍只卡 age>18,生产 2,539 条 plan 目标写「活动义齿为主」
        而画像·潜在治疗仍标「潜在种植」。改它会同时改变「潜在种植」的圈人口径,需业务定。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(priority): 三维只念画像标签,不从别处补第二套口径 · a4b75100
      三维的输入本来就取自画像(急迫等级 / 价值分群 / 生命周期 / 潜在治疗,见 scorer 入参),
      但我为了"讲得细"又从 reason.signals 和 consultation_record 各推了一段话 —— 那是第二套口径,
      和「画像标签」抽屉必然对不齐。两处已经翻车:
      
        · 从 signals.triggers 反推出「种植 · 缺 6 颗」。同一屏上 79 岁患者的召回目标写的是
          「活动义齿为主」,画像自己写的是「潜在种植」—— 三处三个说法。
        · 把 intentBehavior=2 说成「没主动问过」(上个 commit 已修措辞,这次直接移除):
          它来自 consultation_record,根本不是画像标签。
      
      改成照抄画像:
      
          急迫性 10 × 0.4     急迫等级 紧急
          价值性 10 × 0.3     潜在治疗 潜在种植 / 潜在牙周
          意愿度 2.63 × 0.3   价值分群 重要挽留 · 生命周期 流失客
      
      与该患者「画像标签」抽屉里的取值逐字一致。画像没标就说「画像未标 X」,不编。
      
      为此 scenario 的 persona 上下文多取一个 potential_treatment,由 scorer 原样透传进
      breakdown(仅展示、不参与打分)—— 前端拿画像的原文,而不是自己拿 K 码 + 牙位再算一遍。
      中文名一律走 personaTagLabel(圈人字典),本文件不留映射表。hover 不再需要 signals 入参。
      
      守卫测试相应收紧:三维文案里出现「缺 N 颗」「咨询」等非画像来源的词就红。
      
      ️ 顺带查出一处**存量对不齐**(本次未改,需业务定):按年龄排治疗只落到了召回目标,
      没落到画像 —— classifyGapToLabel 里 K08 仍只卡 age>18。生产 2,539 条 plan 目标写着
      「活动义齿为主」而画像·潜在治疗仍标「潜在种植」。改它会同时改变「潜在种植」的圈人口径。
      
      481 tests green · pac-web build 通过 · 本地真实数据逐条比对过画像抽屉。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(priority): 「没主动问过」是错的 —— 查不到 ≠ 患者没做过 · 22b2c95f
      上一版把 intentBehavior=2 写成「没主动问过」。生产核对下来这话 99.9% 的情况是错的,
      22 万个 2 分患者拆开是:
      
          真的从没咨询过              230 人   0.1%
          咨询过但意向没解析出来   176,578 人    80%
          咨询过、问的是别的项目    43,911 人    20%
      
      而且 80% 那批不是解析 bug —— 237 万条 consultation_record 里 214.8 万条源端
      intent/content **全是 null**(DW 没给),再改 parser 也挖不出来。
      
      客服是照着这句开口的(「您之前没问过种植吧?」),说错等于当场翻车。这跟我在画像那边
      批评过的「权益身份承诺了数据里没有的保司和日期」是同一个错误。
      
      改为只陈述查到了什么,不替患者下结论:
          8 分 → 「问过这个项目」   (意向命中本次治疗类别,确凿)
          2 分 → 「没查到咨询意向」 (我们没查到,不是他没问)
      
      顺带在 scorer 记下这条数据现实:调权重的人要知道 2 分不代表患者被动,这一维现状更接近
      "有意向信号则加分"而非"无意向则扣分",把 2 当负面证据会系统性压低大批患者。
      
      新增 tests/priority-explain-wording.spec.ts 守住红线:低分档文案里不许出现
      没主动问过 / 没问过 / 未咨询 / 被动 这类断言患者行为的措辞。
      (测试放 pac-service 是因为 pac-web 没有测试环境,而该模块是纯函数零 React 依赖。)
      
      482 tests green · pac-web build 通过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(priority): 优先级 hover 讲清「为什么这个分」和「这分算高还是低」 · 4477a8c2
      ## 问题
      
      hover 把三维拆解列得很细,但两个关键问题都没答:
      
      1. **看不出高低**。只给「9.78 / 10」,客服会以为满分 10、9.78 只是"还行"。
         生产实测分布完全不是这样(active+assigned 23.6 万):p50=31 · p90=66 · p99=82
         —— 97.8 分其实是**前 0.05%**。反过来本地库最高才 31 分,那 31 就是该先打的。
         没有参照系,这个数字读不出任何决策含义。
      2. **解释是口径定义,不是这个患者**。右列写的是「病情多急(末诊/超期)」
         「治疗类型 + 牙数预估收入」—— 换谁看都一样,看完仍不知道他为什么是 10 分。
      
      ## 改法
      
      **池内位置**:新增 personas 之外的 PriorityDistributionService —— 租户级整数分直方图,
      缓存 30 分钟 + single-flight,算分位纯内存(列表一页只查一次分布,不是每行一次)。
      池 <50 返回 null 不显示(样本不足给分位会误导)。查询失败降级 null,不拖垮列表。
      
      **逐患者解释**:文案抽到 priority-explain.ts,能从已有数据推的就不另存 ——
      急迫档位 ← urgency 分值;治疗类型/牙数 ← reason.signals.triggers/toothPosition;
      主诉命中 ← intentBehavior 二值。只有 RFM 分群与生命周期反推不出(8 分既可能是重要保持
      也可能是重要发展),由 scorer 在 breakdown 里带上。code→中文复用圈人字典
      (新增 personaTagLabel),不另抄映射表。
      
      实际效果(本地真实数据):
      
          优先级 3.12 / 10   召回池里最该先打的一批(前 1%)
          急迫性 10 × 0.4    紧急 · 90 天以上没来
          价值性 10 × 0.3    种植 · 缺 6 颗以上
          意愿度 2.63 × 0.3  重要挽留 · 没主动问过 · 流失客
          × 新鲜度 0.40      诊断偏老,已按时间降权
          × 置信度 1.00      来自医生诊断
      
      两个 10 分却只有 3.12,原因一眼可见:意愿度低 + 诊断偏老降权 —— 这是旧版讲不出来的。
      
      ## 连带修的两个坑
      
      **① breakdown 进不去存量 plan。** reasonNeedsRefresh 只比 signals/evidence,所以
      「算法给 breakdown 加字段」和「急迫档位跨了 90 天线」都刷不到存量行(和 2026-07 高龄缺牙
      同一形态)。把 breakdown 纳入判据,同样剔掉随时间漂移的 freshness / raw —— 否则每日重算
      全量重写。本地 17,298 / 17,304 条就地回填,**0 个新版本**。
      
      **② .swcrc 的 exclude 是正则,不是目录名。** 原写 ["node_modules", "dist"],
      而 priority-distribution.service.ts 文件名含 "dist" → 被静默排除,271 个源文件只编译 270 个,
      swc 不报错("Successfully compiled: 270 files"),直到启动才 MODULE_NOT_FOUND。
      锚定成 ["^node_modules/", "^dist/"],并加断言防回归 —— 这个坑只在 swc 构建路径复现,
      nest build(tsc)一切正常,极难排查。
      
      471 tests green · pac-web build 通过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(persona-ui): 说明卡去掉底部注解行 · 3a85d4b1
      「已经在别处做掉的,这里看不到」「拔牙、补牙、根管等基础项目不计入」这类是在交代
      算法边界,客服不需要知道,占位还把卡片拉长。note 从 display 契约里整体删除(16 处)。
      
      三条确实影响判断的信息没丢,并进它该在的位置:
        · 价值分群 —— 「重要 = 消费排同院前 40%」原本藏在注解里,而它正是这 8 个取值的
          第一分野。改成第一条对照项,顺带把副标题写成「按消费高低 × 来得勤不勤 / 最近来没来」
        · 权益身份 —— 副标题补「(不代表现在仍有效)」,免得客服当成当前在保
        · 禁忌标签 —— 副标题补「· 目前只判年龄」,保住"没标记 ≠ 没禁忌"这个边界
      折扣锚点的时效提醒也不靠注解 —— 卡片上本来就有超 3 年才出现的 ️ 行,比常驻一句更准。
      
      顺带核对了时间偏好的时区(生产 120 万条预约):
        · 北京时区小时分布是干净的 9-17 营业钟形,12 点午休凹陷 —— +8 漏加或重复加都会把峰值
          推到 1-9 或 17-1
        · 星期分布周六 21.1% / 周日 19.3%(合计 40.4%),周一最低 9.8%,符合"休息日看牙"
        · 抽样对账:画像存的 recordCount=67 / weekendPct=15 与 SQL 现场复算一致
        · planned_for 120 万条 0 个 null → `?? occurredAt` 兜底(钟点不可靠)在生产从不触发
      结论:换算无误。硬编 +8 对中国安全(1991 年后无夏令时),多宿主时区仍是既有 follow-up。
      
      454 tests green · pac-web build 通过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(persona-ui): 说明卡恢复「取值 + 短说明」两列结构 · 7e3a4f67
      上一版为了"精简"把自明标签的 rules 清成空数组,结果卡片退化成三段平铺文字 ——
      没有层次,也没有取值之间的对照,取值多的标签(权益身份 5 类、治疗史 4 类)尤其难读。
      
      精简该精简的是 body 字数,不是砍结构。16 个标签统一回「加粗取值 + 一句短说明」:
      客服先扫左边那列找到自己要的取值,再读右边一句。措辞仍是上一版改过的客服版,
      口径订正(权益身份 5 类、转介绍达人门槛、急迫档位)都保留。
      
      `rules[].label` 由可选改必填;CI 断言相应收紧:至少一条、每条都得有 label。
      
      454 tests green · pac-web build 通过 · 本地逐个 hover 核对过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(persona-ui): 说明写给客服不写给程序员 + 依据日期不折行 + 圈人标签给解释 · 5b72d147
      ## ① 依据行的日期折行了
      
      定宽 68px 装不下 `2026-03-27`(11px 字号),每条依据的日期都断成两行。
      改为按内容撑开 + nowrap;tabular-nums 保证各行等宽,不定宽也自然对齐。
      
      ## ② hover 说明太啰嗦,而且是写给程序员的
      
      16 个标签的说明全部重写。原则:客服扫三秒能用,取值本身说得清就不再复述一遍。
      典型对比(潜在治疗):
      
        改前 8 行 —— 副标题 + 3 条把 chip 上已有的词再列一遍 + 一句
          「口径与召回完全一致(就是召回在挖的机会),只是不受冷静期等时间限制」
        改后 3 行 —— 副标题「诊断或建议过、但还没做的项目」+ 取值 + 「已经在别处做掉的,这里看不到」
      
      rules 现在允许为空(性别 / 治疗史 / 权益身份 / 治疗敏感 / 潜在治疗等 6 个都清空了),
      note 只留会改变客服**做法**的那句。新增一条 CI 断言拦实现术语(fact / 字段 / K0 / data. …),
      免得下次又写成实现说明。
      
      ## ③ 圈人面板的标签用户看不懂
      
      「重要保持」vs「重要挽留」差在哪?「待激活」vs「沉睡客」几个月分界?这些名字是算法口径的
      产物,客服只能凭感觉勾。给 PERSONA_TAG_FILTER_DIMS 的选项补 hint(64 项),内容是**判定条件**
      的大白话,如「重要挽留 · 消费高,但很久没来了」「待激活 · 半年到一年半没来」。
      
      展示方式**没用浮层**:这些选项是拿来横向比较的,浮层恰好盖住正要比的那几个(实测会遮住
      2 个 chip)。改成面板底部一行定高说明,悬停即换,不遮挡、无 z-index 问题、扫读时一直在。
      
      454 tests green · pac-web build 通过 · 三处均在本地真实数据上人工验过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(persona): 到期改档精确调度 —— 时间跨档也能刷新,但不靠全量扫 · d7dc50eb
      ## 问题
      
      水位闸只在**事实变了**时放行重算。这是对的 —— 没有它 40 万患者天天全量重算。
      但有一类变化不伴随任何事实变动,时间自己走了就会改档:
      
        急迫等级跨 30/90 天线 · 生命周期跨 180/540/730 · RFM 的 R 跨 540/730/1095/1460
        年龄跨段 · 种植年龄禁忌满 19 岁解除 · 时间偏好/特别关注的滑动窗口把最老记录挤出去
      
      这类患者永远等不到重算。年龄段和年龄禁忌最极端 —— 它们**完全不依赖 fact**,
      一个 54 岁患者会一直显示「中老年」,直到他碰巧有新事实进来。而年龄还喂着按龄排治疗。
      
      ## 解法:算出确切到期时刻,而不是每天盲扫
      
      FeatureExtractor 新增可选 `nextBoundaryAt(ctx)`,报出"纯时间流逝会让我改档的最近时刻";
      PersonaService 取全体最小值存 personas.next_boundary_at;夜扫加一条 UNION 分支
      只捞 `next_boundary_at <= now()`,走 (superseded_at, next_boundary_at) 索引。
      isFresh 同步加这一维 —— 否则夜扫捞出来入队也会被水位当场 noop 掉,白捞。
      
      八个特征声明了边界(rfm / lifecycle_stage / urgency_level / age_bracket /
      contraindication / potential_treatment / time_preference / special_attention),
      日期算术统一走 features/time-boundary.ts 的四个原语,不手搓。
      
      顺带把 rfm / lifecycle_stage / urgency_level 各抄一遍的「到诊」口径收进 visit-facts.ts
      —— 三个标签的 description 都印着"就诊 N 次 / 末诊 N 天",口径对不上客服当场就能发现。
      
      ## 本地实测(friday host,13,268 患者)
      
        --force 回填   success=0 · refreshed=885 · unchanged=12,383 → 全量跑几乎不产新版本
        边界分布       10,808 有到期时刻 / 2,463 已过全部档界(NULL)
        每日到期量     5~30 条(外推生产 40 万约 150~900/天,而不是 40 万)
        端到端         人为把某条置为已过期 → 夜扫 SQL 捞到 → 不带 --force 重算 noop=0(闸放行)
                       → 内容真没变故判 unchanged 不写 → 边界推进到 2029-06-06,不会重复捞
      
      ## 一并清理(逻辑清晰向)
      
      · persona-display.ts 删掉 `[enum] 文本` 前缀解析与 PERSONA_STATUS_ZH —— 服务的四个特征
        (treatment_chain_status / recall_risk / value / do_not_contact_status)W7 已摘除,
        现存 16 个 extractor 没有一个产这种前缀,抽屉里那个 tag 恒为 null。
      · PERSONA_FEATURE_META 从 33 个键收到 16 个(= 在跑的全集)。原来混着已摘除的和只存在于
        路线图上的,"看着 30 个标签实际只出 16 个"。枚举保留路线图,展示元数据只登记会上屏的。
      · tone 重排:红色只留给警示(急迫/特别关注/禁忌/治疗敏感)。原来「转介绍达人」「折扣锚点」
        这类正面或中性信息也是红的,和「禁忌」同色,客服扫一眼分不出轻重。两条 CI 断言守住。
      · mock 画像换成在跑的特征 —— 原来摆着六个系统根本不产出的标签,拿它当参照会看错。
      · 折扣锚点超过 3 年在详情页给过时提醒(生产上见过 2018 年的锚点被原样展示)。
      
      453 tests green · pac-web build 通过。
      
      ## 部署
      
      两个迁移的回填是同一次运行,且**必须带 --force**:
      next_boundary_at 的 NULL 语义是"不再改档",不会被判 stale,不强制跑就永远算不出来。
      
        pnpm recompute-persona:prod -- --host=jvs-dw --concurrency=8 --force
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(persona): 详情页说明收回单一真理源 + 打通证据链 + 分组定序 · e874e74b
      页面上「对画像的解释」的四个毛病,一次修掉。
      
      ## ① 说明写错了 —— 前端自己硬编了一份算法字典
      
      persona-feature-hover.tsx 里的 ALGORITHMS 与后端口径各写各的,已经漂:
        · 权益身份只列「商保客户 / 医保结算」2 类,extractor 实际产 5 类;还承诺
          「显示保司 + 最近日期」—— description/data 里压根没有这两样
        · 转介绍达人写「社交型 = 推外人 ≥3 人」,真实口径是「推荐 ≥3 人**且**带来成交,
          直系亲属关系 <3 才归社交型」
        · 急迫等级缺档位说明;年龄段 labelValues 写 0-3 / 46-55,与自家 algorithm 的
          0-2 / 46-54 自相矛盾
      客服是照着这段话跟患者讲的,写错比不写更糟。
      
      改为收进 PERSONA_FEATURE_SPECS[key].display(与 algorithm 口径贴脸放,前端只渲染)。
      不新开接口 —— packages/types 本来就是前后端共享的。
      新增 tests/persona-spec-drift.spec.ts:registry  注册表  圈人字典三方必须对齐,
      每个标签必须有 display —— 光收拢不设闸,过两个月照样漂。
      
      ## ② 只给结论不给出处
      
      后端一直存着 evidence.factIds(生产上 rfm + lifecycle 两个特征就占 1 GB),
      但 adapt-data 把它写死成 `evidence: []`。现在 /full 透出 evidenceFactIds,
      详情页反查同一响应的 facts 渲染「依据 · N 条」(默认 4 行,可展开)。
      本地实测证据 id 100% 可解析。factLabel 从 tooth-timeline 抽成共享模块并补齐
      结算/预约/就诊/病历等类型 —— 否则证据行会把 `payment_record` 这种表名甩给客服。
      
      ## ③ 顺序是随机的
      
      抽屉排序取 `orderBy createdAt`,而同一版 feature 行是一条 createMany 写进去的、
      createdAt 完全相同 → 排序实际未定义,生产上呈现为英文 key 字母序:
      「急迫等级 = 紧急」排最后一个,「性别」「获客渠道」排最前。
      现按用途分三组(跟进要点 / 价值与阶段 / 基础属性),组与组内序都定义在标签卡里。
      标签云用同一套序。
      
      ## ④ `?` 够不着
      
      原来是 16×16 的 <span>,无 tabindex 无 role、纯 hover —— 键盘、读屏、平板触屏都点不到
      (可访问性树里一个都读不出来)。改成 24×24 button,focus 即可唤出说明卡,
      aria-label 带标签名。
      
      顺带:「更新于」改用 refreshedAt(就地刷新不升版本,computedAt 会停在旧值,
      拿它显示会让客服以为数据比实际更旧);extractor 清单从 module/registry 两处收成一处。
      
      ## 验证
      
      432 tests green · pac-web build 通过 · 本地真实数据人工验:
      分组与依据渲染正确、hover 内容来自 spec、`?` 可聚焦且 focus 能唤出对应说明卡。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(persona): 画像水位对齐到 fact 层 + 写入侧节流 · 889dd6f0
      ## 事故
      
      画像算的是 patient_facts,stale 判定却只看 patient_transactions 的 event_seq。
      reparse 重写事实但不产新事务,于是 2026-07-21「amount_cents 应收→实付」reparse
      之后紧跟的全量重算被水位闸整批 noop 掉 —— 生产日志:
      
          manual:cli  noop=325,979  success=79,010
      
      后果(生产 3000 抽样):reparse 前算的画像 96% 累计消费与实时事实不符,中位高估 91%。
      页面上同屏两个数打架 —— 关键事实 ¥154,350(实时)vs 画像 ¥195,750(旧口径)。
      而且不只是显示错:monetaryCents 要跟实时算出的 M 分位阈值比大小 → mScore 系统性
      偏高 → RFM 分群整体偏「重要」→ 直接抬高召回优先级排序。
      
      ## 修法
      
      水位对齐:新增 personas.fact_watermark = max(patient_facts.updated_at),
      与 event_watermark 取「任一落后即重算」。存量行为 NULL → 判 stale,自然回填。
      stale-scan 同步加这一维。
      
      闸放宽必然带来「算了但没变」,所以补一道**写入侧的闸**(persona-diff.ts):
        unchanged 一字未变     → 不写 feature 行,只推水位
        refreshed 只有时间派生值变 → 就地刷新,不升版本(记 refreshedAt)
        semantic  真变了       → 升版本
      判据比 data(剔易变键)+ evidence.factIds,不解析 description —— 同 reason-refresh 思路,
      canonical 序列化抽成 common/canonical-json.ts 两边共用(jsonb 不保留键序)。
      
      顺带:闸里查过的水位不再在正算路径重查(原来 transaction / persona 各查两遍)。
      
      ## 本地验证(friday host,13,268 患者)
      
        首轮回填  noop=0(旧代码这里会大批 noop)· success=48 · refreshed=11,155 · unchanged=2,065
                  → 写侧节流把 13,268 次升版本压到 48 次
        再次运行  noop=13,268 全零其余,142s → 29s,幂等
        事故复现  只改 fact 不动 transaction → 触发重算,¥24,994 订正为 ¥15,996,v1 留痕
      
      ## 部署注意
      
      patient_facts 370 万行,新索引请先手动 CREATE INDEX CONCURRENTLY 预建(迁移里是
      IF NOT EXISTS,预建后即 no-op);上线后跑一次全量 recompute-persona 补 fact_watermark。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • merge: fix/idle-probe-false-alarm → main(增量空转探针误报修复) · 13737a51
      - 探针只在 status=success 的空轮上跑(失败轮误报修复)
      - 探针反查带业务过滤,与真实增量拉取同一套 WHERE(退费单过度计数修复)
      
      生产同样受此误报影响;本次仅合入,暂不部署生产。
      luoqi committed
    • fix(sync): 增量空转探针不再误报 —— 失败轮守卫 + 探针带业务过滤 · 0ffb6e07
      测试服务器收到 CRITICAL「增量空转:PAC 拉到 0 行,但 DW 有新数据」。排查 sync_logs:
      
        07-25 16:15 UTC  failed  fetched=0  err="fatal: unexpected end of file"
        07-26 00:15 UTC  success fetched=239959 tx=103652   ← 下一轮全额补回
      
      即:一次瞬时 ClickHouse 连接断开导致该轮失败,游标未推进,下一轮把积压全额补回
      (tx=10万+,远高于平时 ~1万),**无数据丢失**。告警是误报,两处 bug 叠加:
      
      ## ① 探针在失败轮上也跑(主因)
      空转告警条件只看 `tx+dup=0`,没看 status。失败轮 tx=dup=0 → 走进探针 →
      把瞬时网络失败误诊成「游标格式失效/空转」。失败轮本就经日报失败计数 + error_message
      暴露,不该再被探针误标。
      修:加 `status===SUCCESS` 守卫,只在**成功空轮**上探。
      
      ## ② 探针反查不带业务过滤(放大误报)
      探针反查只拼 `cursor > val`,而真实增量拉取还叠加业务过滤(结算正表
      `is_refund=0 AND settlement_status=1`、退费表 `is_refund=1 OR settlement_status=4`、
      结算方式 `settlement_status='1'`)。于是"游标之后、但会被业务条件过滤掉"的退费行
      被探针数进去 → 真实拉取正确地不拉,探针却告警。
      告警数据自证:settlement 正/退表各 5788 完全相等,正是这批全为退费单的铁证。
      修:探针复用 extractBusinessFilters,与真实拉取同一套 WHERE。
      
      ## 验证
      - 388 单测通过(新增 6 例:各表业务过滤保留/剔除、括号 OR 不被 AND 拆开)
      - 失败原因「unexpected end of file」是瞬时网络错误,已自愈,无需数据补摄
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
  3. 24 Jul, 2026 15 commits
    • fix(plan): unchanged 分支支持 reason 就地刷新 —— 修存量 plan 的 signals 不生效 · 7808f53c
      ## 问题
      
      引擎变化判定只比 `(scenario, subKey)` 集合;集合没变就走 unchanged 分支,
      **plan_reasons 一个字都不改**。于是"只改 signals 内容"的算法升级对存量 plan
      永远不生效 —— 生产 3,170 条高龄缺牙即使上线了按年龄排治疗(signals 新增
      focusCategory / patientAge),仍显示「目标·种植」。
      
      原先只能删库重算(丢认领、丢版本号、丢触达计数)或写一次性 SQL 回填(逻辑重复、
      易与引擎口径漂移)。
      
      ## 做法
      
      unchanged 分支新增就地刷新:**不升版本、不动认领/状态/抑制窗/触达计数**。
      同时补 plan.goal 的比较(goal 是静态文案,算法改措辞要能落到存量)。
      
      刻意不动的字段:closedReason / closedAt(关闭状态)、source / sourceActorId /
      campaignId(来源溯源)、lifecycle;已关闭的 reason 行直接跳过。
      
      ##  关键:只在语义变化时才写
      
      reason 文案与 signals 都内嵌天数(`${days} 天前` / `signals.daysSince`),天天变。
      无条件刷新 = 每日重算把全部 plan_reasons 重写一遍(生产 23 万+ plan),纯废写 + WAL 膨胀。
      
      判据(reason-refresh.ts):比 **signals(去掉 daysSince)+ evidence.factIds(排序后)**,
      **不解析文案** —— reason 文案完全由 signals 派生(诊断名←triggers、牙位←toothPosition、
      治疗类目←expectedCategories+patientAge),所以比 signals 既充分又不依赖文案格式。
      
      踩到并修掉的坑:**Postgres jsonb 不保留键序**(按键长+字节序重排),直接 JSON.stringify
      比较会让库里读出的和新算出的永远不等 → 每次都判"变了"。本地实测连跑三次每次都刷新。
      改用递归排序键的规范化序列化后,第二三次不再写。
      
      ## 验证
      - 382 单测通过(新增 16 例 reason-refresh 单元 + 2 例批量路径集成)
      - 本地端到端(90 岁吕学文,人为退回存量旧状态 + 认领 + 触达 2 次):
        第 1 次重算 → 「reason 就地刷新 3 条」,signals 补上 focusCategory/patientAge、
        文案变「未启动活动义齿 / 种植」、goal 换高龄版;
        version 仍为 1、status=assigned、assignee=u-tester、contactAttempts=2 全部未动;
        第 2、3 次重算 → 0 条刷新(幂等,不产生废写)
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • docs(extraction): 修正冷静期字典 + 补认领取数口径(plan_event_logs) · e8e121c5
      - 关闭原因冷静期:inaccurate / treated 由 14 天改永久
      - 品牌患者数更新:瑞尔 351,024 / 瑞泰 64,883(瑞泰必须排除)
      - 新增「三之二、认领怎么捞」:快照与账本的分工、plan_event_logs 字段字典、
        三条常用查询(处理过哪些患者 / 接手后放弃 / 认领时长分布)
      - 表关系图补 plan_event_logs
      - 坑表补两条:assignee_user_id 是快照、按 plan_id 聚合会因升版本重复计数
      
      SQL 均已在生产库执行验证。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • docs(ops): 回滚必须带 --no-pull + 补 DEPLOY_BRANCH 用法 · d9fb836e
      排查分支状态时发现文档与脚本长期不一致:main 上文档早已写「部署脚本不写死分支」,
      而 main 的脚本仍是 `git pull --ff-only origin main`(该改动只在 test 上)。
      本次已 cherry-pick 脚本改动到 main,文档主体因此自然对齐,只补两处:
      
      1. ️ 回滚命令必须带 --no-pull —— 原文档写的
         `git checkout <commit> && bash deploy/deploy-prod.sh`
         在旧脚本下会紧接着 pull 回分支最新,**把刚回滚的 commit 又拉走且静默无提示**;
         新脚本在 detached HEAD 下 fetch 会失败退出,不再假成功,但正确用法仍是 --no-pull。
         并提醒回滚后切回 main,否则下次部署仍在 detached HEAD。
      
      2. 补 DEPLOY_BRANCH 用法 —— 脚本一直支持,文档从没提过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • chore(deploy): 部署分支不写死,默认跟随当前 checkout(main=生产/test=测试) · 738e1f57
      deploy-prod.sh 原写死 git pull origin main。改为 BRANCH=${DEPLOY_BRANCH:-当前分支}:
      生产机停 main、测试机停 test 即各自部署对应分支;可用 DEPLOY_BRANCH 覆盖。
      拉取改 fetch+checkout+ff-only(有分歧仍失败退出,不静默 reset)。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • merge: fix/age-aware-treatment-goal → main(召回侧一组修复) · e3d16433
      - 缺牙建议治疗按年龄排序(>70 活动义齿在前、种植并行保留;只改顺序不增删)
      - 「已完成治疗」「机会识别不准确」抑制期 14d → 永久(原档押注排除闸接手,生产证伪)
      - 认领闸:未认领只能看不能操作(服务端 20504/20505 硬拦),认领入口收口到列表页
      - 历史联系摘要不再把未来排程当漏做(程序给今日锚点 + isFuture 标记 + taskStatus)
      - 关闭超时自动回收(默认关,开关 PAC_PLAN_AUTO_RECYCLE=on)
      - 新增 plan_event_logs 生命周期事件账本(认领/指派/返池/自动回收/召回反馈)
      
      含 DB 迁移:20260724091507_add_plan_event_log
      已在测试服务器(47.251.104.47)部署验证通过。
      luoqi committed
    • merge: feat/friday-ingestion → main(FRIDAY 摄入契约收敛 + 推送自助反馈) · f3d7ae77
      - 摄入契约收敛为 9 张自洽表(宿主 inline 自己的引用,全路径一致)
      - 结算 WHERE 迁移单一真理源 + 冷导入网页通道 + 数据对账
      - push 预检(dryRun)+ 推送记录查询(HMAC 免登录)
      - 修 traceRawSourceTable union 回溯(refund_full 在 push/reparse 静默跳过)
      
      阶段性完成,业务确认可合。
      luoqi committed
    • feat(plan): 关闭超时自动回收 + 新增 plan 生命周期事件账本 · 3fd9af36
      ## 1. 自动回收改为默认关闭(PAC_PLAN_AUTO_RECYCLE=on 才跑)
      
      它一直在生产跑着(ScheduleModule 已启用、服务已注册),日志可查:两天触发 9 次、
      累计回收 32 单。前端那句"暂无自动回收机制"说的是倒计时控件被隐藏,不是后端。
      
      关闭理由:回收会把 assignee_user_id / assigned_at **就地置 null**,而「认领」现在要
      当作「该患者已被客服处理」用于统计 —— 每 10 分钟抹一次,统计地基是漏的。
      
      代码保留不删:超时兜底的原始问题(领了不做 → 患者被锁死)依然成立,后续想改成
      更长超时或"只提醒不回收",打开开关即可。启动时打印开关状态(它会静默改数据)。
      
      注:生产当前登录角色全是 leader,自认领 / 自返池都在权限内,无"staff 无法释放工单"
      的沉淀风险。将来放开 staff 角色需重新评估(staff 无 PLAN_RECYCLE)。
      
      ## 2. PlanEventLog — plan 生命周期事件账本(append-only)
      
      为什么 PlanGenerationLog 顶不上:它是**每次引擎跑批一行**的管道账本,**连 plan_id
      都没有**,只记"引擎跑了没有、产出几个"(生产 750 万行 / 24 万患者,约 80 万行/天)。
      回答不了"这个 plan 经历了什么"。
      
      已收事件:claim / assign / release / auto_release(归属)+ feedback(召回 👍👎)。
      assign 为门诊经理分配预留 —— actor != assignee 时自动区分,无需事后反推。
      
       收录边界(注释里划死,防止变垃圾桶):
         人工动作 + 会被就地覆盖且事后无法还原的状态变更
         引擎重算 / supersede —— 80 万行/天,与人工动作(约 30~100/天)差 4 个数量级,
           混入会淹没人工动作;引擎侧已有 PlanGenerationLog + followup_plans 版本流
         前端行为埋点(浏览/点击)—— 噪声大、可刷,属产品分析那一摊
      
      ### 写入方式:应用层 + 同事务,**不用数据库触发器**
        1. 触发器拿不到操作人 —— actor 在 JWT 里,DB 只看得见行变了;靠 session 变量传递
           还是得应用配合,只是变隐式更易漏。本项目 AsyncLocalStorage 目前也不含 user。
        2. 拿不到 actor 就分不出 claim / assign —— 这恰是本表最核心的价值。
        3. 触发器会被运维脚本误触发:任何一句 `UPDATE followup_plans SET assignee_user_id=NULL`
           (数据修复 / 清理测试数据)都会凭空造出假的 release 事件。
        4. Prisma 不管触发器,得走 raw SQL 迁移,schema 里看不见、易随漂移丢失。
        弱点(已认):靠自觉。缓解 = 写入收口到 recordPlanEvent() 单一入口 + 只接受事务客户端
        (状态变更与账本同生共死)+ 事件类型走枚举编译期拦拼写错误。
      
      ### 扩展性
        新增 PlanEventType + PLAN_EVENT_META(@pac/types,照 EXECUTION_OUTCOME_META 的样子):
        事件类型的单一真理源,带 labelZh / group / byHuman / holdsPatient。
        新增事件 = META 加一行,不是在调用处随手写字符串。
        HUMAN_TOUCH_EVENTS 收口「算不算客服处理过」的口径(auto_release 是系统行为,不算)。
      
      两个关键字段的存在理由:
        · heldSeconds 必须在清空 assigned_at **之前**算好(computeHeldSeconds),事后算不出来
        · feedback 立 reason 列(up/down):followup_plans.recall_feedback 是就地覆盖的,
          先 👍👎 会把前一次冲掉,准确度统计只看最终值会低估分子、也看不出"改判"
      
      配套:assign / recycle 接口补传操作人(controller 原先根本没取 user.sub,
      自认领与指派他人在数据上完全无法区分)。
      
      ## 验证
      - 340 单测通过(新增 15 例:四种归属事件、幂等不重复记账、事件类型登记完整性、
        auto_release 不计入人工、computeHeldSeconds 边界、开关默认关且只认显式 'on')
      - 本地端到端:认领 → 👎(带 note)→ 返池,账本 3 行齐全、held_seconds 准确;
        同期 followup_plans.recall_feedback 只剩最终值 —— 正是就地覆盖会丢的那部分
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • revert(ingest): 撤回回访 org 白名单的删除 —— 生产范围内并无数据损失 · 2c4471a4
      上一提交(33dcecb6)基于错误的范围假设删了 fact_returnvisit_out 的 org 白名单,
      现予撤回。摘要侧的修复保留不动。
      
      ## 为什么撤回
      
      生产的数据范围是**瑞尔 + 起始 2025-01-01**(存量 cold-import 圈定),瑞泰不在范围内。
      按品牌重新切分后:
      
        被该白名单丢掉的【瑞尔】记录 = 7,212 条,organization_id **全部为 NULL**,
        时间跨度 2016-08 ~ 2021-01 —— 全早于 2025-01-01 起始线。
      
      即:**在生产真实范围内,这个过滤造成的有效数据损失为零。** 之前那个"丢 98.8%"
      是瑞泰的数字,而瑞泰患者本不该在库里(见下)。删掉白名单反而有两个副作用:
      把不该进的瑞泰回访放进来 + 增量每轮拉取量 336 万 → 1,340 万(该表无游标)。
      
      ## 顺带确认的两个事实(排查副产品)
      
      1. **一个患者的回访确实跨多诊所**:瑞尔患者里 14.6% 跨 2 家、2.9% 跨 3 家,
         最多 16 家(张学军跨 4 家)。所以过滤条件按 org 收窄确实有风险 —— 只是
         实际被收窄掉的都是 NULL org 的老数据,没伤到有效数据。
      2. **跨品牌同号不是同一个人**:196 万个跨品牌重号 id 里,姓名+生日都相同的
         仅 10 个。manifest 顶部原有的"同号不同品牌=不同人"注释是对的。
      
      ## 真正的问题在别处(本提交不处理,单独跟进)
      
      瑞泰患者是**增量漏进来的**:07-15 存量跑当天瑞泰 0 人;07-16 起增量每天带入,
      07-21 单日进 32,586 人(比当天瑞尔的 12,718 还多),累计 62,992 人。
      根因是增量路径没有继承 cold-import 的 `--clinics` / `--since` 范围限定。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(recall): 回访摄入去掉 EMR 诊所白名单 + 历史联系摘要不再把未来排程当漏做 · 33dcecb6
      生产排查(张学军 BJ0D036664 等)带出的两个独立问题。
      
      ## 1. 摄入:删除 fact_returnvisit_out 的 org 白名单
      
      原条件 `organization_id IN (SELECT ... FROM fact_emr_treatment_out)` 本意"与核心
      数据同范围",但那张 EMR 表 **瑞尔 63 家 / 瑞泰仅 2 家**,而回访表瑞泰有 98 家:
      
        瑞尔  保留 3,238,588 / 丢 7,212        → 丢 0.2%
        瑞泰  保留   119,884 / 丢 10,039,288   → 丢 98.8%
      
      后果:PAC 的 6.3 万瑞泰患者只有 5.4% 有回访记录(瑞尔 78.9%),历史联系整体空白。
      回访是纯展示数据、不进召回信号,没理由绑定 EMR 诊所集合 → 删掉,范围交给 cohort。
      
      ️ 排查中的一个反转,记在 manifest 注释里防后人再踩:DW 的 patient_id **跨品牌复用**
      (374 万 id 中 196 万重号,如 708971 在瑞尔=张学军、瑞泰=余盼盼)。所以 cohort 必须用
      `(patient_id, brand)` 复合键;摄入侧 sourceUnit 取自本行 brand、按 `brand#patient_id`
      索引患者,跨品牌行天然各归各家,不会串号。按品牌正确切分后,瑞尔患者其实一条没漏。
      
      ## 2. 摘要:未来排程被写成"未执行,需优先补做"
      
      patient_return_visits.task_date **含未来排程**,而 prompt 既无今日锚点、又按 taskDate
      desc 排序 → 未到期的排程恒排第一被当成"最新一次",status=未回访 就被判成漏做。
      生产实测:934 条已生成摘要中 196 条最新回访是未来排程,其中 158 条(81%)带
      "未执行/补做/漏/尚未"措辞;对照组(最新在过去)仅 38%。
      
      修复按"能程序算的事实全算好、LLM 只润色"这条既有纪律:
        · orchestrator 算 today 与每条的 isFuture(确定性比较,不交给模型)
        · prompt 渲染成【未来排程·尚未到期】显式标记 + 条数警告 + 硬约束文案
        · 补 taskStatus(「已预约」≠「没做」,原先根本没喂给模型)
        · promptVersion → draft_recall_summary@2026-07-24-e
      
      ## 本地验证
      - 325 单测通过(新增 11 例锁住"未来排程不得说成漏做" + promptVersion 必须 bump)
      - 摄入:导入瑞泰患者 殷海霖(2332662,12 条回访旧过滤下一条留不住)→ 12 条全部落库
      - 摘要:吕学文(未来排程 2026-10-09 + 真·过期未执行 2026-03-28)连跑 3 次稳定输出
        「3月28日种植咨询回访未执行…需优先跟进;…10月9日已排复查洁牙邀约」
        —— 未来的说"已排"、真漏做的仍报出,两个方向都对
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(recall): 识别不准确也改永久 + 认领入口收口到列表页 + 潜在/预约过闸 · 25fc944e
      三处业务反馈跟进。
      
      ## 1. 「机会识别不准确」14d → 永久
      
      原 14d 的理由是"算法修好后本就该重新召回",但那是**我们**的排期,不该由
      患者兜着 —— 两周后同一条不准的召回原样弹回来,客服只会再关一次再骂一次,
      而算法多半还没改。真要在修复后放回来,应由「改完规则 → 定向重算/解除抑制」
      这条显式动作驱动,而不是赌"两周内一定修好"的定时器。
      
      至此 WEAK_SIGNAL_SUPPRESS_DAYS(14d)的两个使用者(treated / inaccurate)
      全部转永久 → **删除该常量**,免得后来者把新原因挂到一个已被推翻的假设上。
      
      ## 2. 认领入口收口到列表页
      
      详情页顶栏的「认领」按钮去掉,改为**只读**徽标(未认领 · 仅查看 / 仅查看)。
      同一动作两个入口会让状态同步和归属口径都变复杂;列表页本就有行内认领 +
      批量认领,那里才是自然入口。拦截文案同步改为指回列表。
      
      ️ 随之出现的缺口已一并补上:列表认领只 patchItem 本地行,详情页不会知道
         → 刚认领完回详情点操作仍被拦。为此给 plan-sync-store 加**反向通道**
         notifyClaim(列表 → 详情),认领/返池后详情页的闸即时解锁/上锁。
         assigneeUserId 用 undefined 表示"本次事件与认领无关"(不能用 null,
         那会被当成已返池而误上锁)。
      
      ## 3. 「潜在」「预约」也过认领闸
      
      顶栏跳宿主的三个动作(潜在/预约/回访)现在口径统一:全部过闸。
      「潜在」虽是查看,但看完顺手就在宿主侧动手了,而 PAC 这边没认领 = 没归属人,
      回头对不上账。统一口径比"哪个算查看"的细分更好维护。
      
      ## 验证
      - 314 单测通过(抑制期用例改用 unreachable/wrong_number 的 30d 档
        继续锁住"短档不被 outcome 60d 盖掉"这条红线)
      - 本地真实患者(吕学文 90 / 李石明 78,已从本地 CH 摄入)实测:
        未认领 → 顶栏「未认领 · 仅查看」徽标、点「潜在」被拦并提示去列表认领;
        列表点认领 → 详情页徽标即时消失、「关闭机会」弹窗正常打开(未重拉页面)
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(recall): 已完成治疗改永久抑制 + 未认领只能看不能操作 · 435cd818
      两项业务反馈驱动的改动。
      
      ## 1. 「已完成治疗」抑制期 14d → 永久
      
      原 14d(WEAK_SIGNAL_SUPPRESS_DAYS)押的注是「等一个 DW 同步周期 + 排除闸
      接手」,生产实测证伪:缺牙召回两例(吕学文 DW 无义齿完成记录、李石明义齿
      在上颌而召回在下颌)排除闸都接不住,到期只会原样弹回来再挨一次投诉。
      档位本身也不自洽 —— 「已在外院治疗」早就是永久,在别家做完永久闭嘴、
      在自己家做完两周后再问,说不通。
      
      误判不会把患者封死:抑制是**信号级**的(key=scenario|subKey,subKey 含
      牙位)且带时间锚,结案后新发的同类信号照常放行。
      WEAK_SIGNAL_SUPPRESS_DAYS 现仅剩「机会识别不准确」使用(算法问题,修好
      规则后本就该重新召回)。
      
      ## 2. 认领闸 —— 未认领只能看,不能操作
      
      submitExecution 原先只校验 status、**完全没看 assignee**,生产上因此
      出现两条 plan 在 assignee IS NULL 时被直接关闭。危害不止撞单:
      execution.service 特意保留 assigneeUserId 作业绩归属(清了会让
      「我的已完成」/ 转化率永远为 0),未认领就提交 = 这条执行没有归属人,
      统计里直接蒸发 —— 前端提示替代不了服务端约束。
      
      新增 plan/claim-guard.ts,两种拒绝态分码(20504 未认领 / 20505 他人认领,
      文案必须分开,否则组员以为自己没点对)。
      
      过闸:提交通话结果 / 关闭机会、重生成话术(含 SSE)、话术反馈、召回反馈
      不过闸:
        · assign(认领本身是入口,过闸会死锁)
        · recycle(PLAN_RECYCLE 是 leader 独占「回收 staff 已认领的单」,
          按"必须自己认领"拦会把组长回收功能整个废掉)
        · recompute(ops 工具)
        · 只读端点 + 「潜在」(查看类)+ 刷新(拉数据,不改业务态)
      
      前端同步:详情页按钮**不禁用**,点了才讲原因(禁用会让客服不知道为什么
      点不动);顶栏未认领显「认领」按钮(闸的唯一出口)、他人认领显「仅查看」。
      
      ## 验证
      - 314 个单测通过(新增 claim-guard 5 例 + treated 永久 2 例)
      - 本地端到端:未认领 3 个入口全返 20504;他人认领返 20505 带占用人;
        自己认领后放行;treated 关闭落库 snoozed_until = 36500 天(99.9 年)
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(recall): 缺牙建议治疗按年龄排序 —— >70 岁活动义齿在前、种植并行 · 3ff9c827
      现象(业务反馈):90 岁(吕学文)、78 岁(李石明)的缺牙召回,界面一律显示
      「目标 · 种植」,话术也推种植。业务反弹"为什么给 90 岁老人推种植"。
      
      根因:UI 取 signals.expectedCategories[0],而 K08 的 categories 首项恒为 implant。
           整条链路(目标标签 / 理由文案 / goal / 话术)都没有年龄维度 ——
           召回场景 SQL 与 priority-scorer 里一个年龄条件都没有。
      
      ## 修法:改在诊断→治疗的源头,不在流程末端打特例
      
      canonical-codes.ts 新增 recommendedCategoriesForAge(categories, age) 作建议侧单一源:
      候选同时含 implant + prosthodontic 时,age > DENTURE_FIRST_AGE(70)→ prosthodontic 提前。
      scenario / 前端 reason-line / 话术 prompt 全部调这一个 resolver,不各写年龄分支。
      
      ## 业务方口径(对齐后修正)
      
      初版做成了"高龄禁推种植",过重。业务反馈:**70-80 不是种植禁忌**,能否种植取决于
      骨量骨质 / 全身状况 / 患者意愿,由医生评估。故改为:
        - 建议**只调顺序**:活动义齿在前,**种植并行保留**(不删)
        - 话术从"严禁推荐种植"改为"先讲活动义齿,种植可并行提及;不主推手术、
          不承诺能不能种 —— 落到来院让医生按身体条件评估"
        - PAC 不替医生下禁忌结论
      
      ## 三条设计红线(各有用例锁住)
      
      1. 只排序不增删 —— 任何年龄下返回集合恒等于入参(种植不会被抹掉)
      2. 不碰排除闸 —— DiagnosisTreatmentMap[].categories 仍是完整候选集
         (否则高龄患者做过种植反而排除不掉 → 重复召回)
      3. 不碰"要不要召回" —— 缺牙影响咀嚼营养,高龄同样该管
      
      ## 改动
      
      - canonical-codes:DENTURE_FIRST_AGE=70 + recommendedCategoriesForAge + treatmentCategoryNameZhFor
      - reason-signals:signals 加 focusCategory / patientAge(均 optional,旧 plan 回落原行为)
      - scenario:SQL 取年龄 → 走 resolver;高龄 goal 文案(义齿为主、种植并行)
      - 前端:目标标签用 focusCategory;理由行调同一 resolver 显示「活动义齿 / 种植」
      - 话术:fact-block + stable prompt 加高龄沟通约束;三档 promptVersion bump
      
      实测(本地 75 岁真实患者重算):focusCategory=prosthodontic · patientAge=75 ·
      理由「…未启动活动义齿 / 种植」。308 tests passed,service + web typecheck 干净。
      
      ️ 存量:plan 变化判定只比 (scenario, subKey),signals 变化不升版本 →
         已有 plan 不会自动拿到新字段(前端回落原行为)。生产 775 个 80+ 缺牙患者
         需回填或强制重算才生效,另行处理。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(push): 推送记录支持 HMAC 免登录查询 + API 文档补全 dryRun/limit 参数 · ec026cb2
      对接反馈:① 文档站 API 参考没更新 ② 查推送记录还要登录,联调不便。
      
      1) 新增 GET /pac/v1/push/logs(HMAC 验签,免登录)
         复用宿主推送时已在用的 X-PAC-Host-Id / Timestamp / Signature 三件套,联调不必另开账号;
         GET 无 body,签名对空串算 hex(HMAC-SHA256(`<ts>.`, secret))。
         **不是公开接口** —— 签名错误返回 10106,拿不到任何数据,不泄露宿主推送情况。
         与登录态 /admin/host/self/push-logs 同一实现(sync.module 引入 AdminModule 复用
         HostsService.getPushLogs,两模块无环),返回同一份数据。
      
      2) API 文档补全:@Query 缺 @ApiQuery 导致 Swagger 采不到 —— push/rows 的 dryRun、
         push/logs 的 limit 现已进 spec;重新 dump 静态 spec(apps/pac-docs/openapi/pac.json,
         59 paths),文档站 API 参考随之更新。
      
      3) 契约文档 §7.2 改写为「两种查法」:免登录(HMAC,联调推荐)+ 登录态,并说明签名对空串计算。
      
      验证:正确签名返回推送记录、错误签名被拒(10106 签名不匹配);typecheck 干净,299 测试绿。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(push): 宿主对接自助反馈 — push 预检(dryRun)+ 推送记录查询 · aaf8805f
      FRIDAY 开发对接 push 时需要"我推了什么 / PAC 收到什么 / 成了几条 / 错在哪",补两个自助能力:
      
      1) 预检 POST /pac/v1/push/rows?dryRun=1
         跑完整管线(transforms + 装配 + 归一 + 租户解析)但**不落任何库、不触发重算**,
         响应带 dryRun 标志与各资源**样本 canonical** —— 宿主可直接核对 PAC 把原生行翻译成了什么。
         复用 cold-import 既有 dryRun 通路(processSubject/processPatients 已支持),
         ingestRawTables 此前硬编码 false,现按 opts.dryRun 透传;预检批次 syncLog 以
         triggeredBy `push-dryrun:` 前缀标识,不与正式推送混淆。
      
      2) 推送记录 GET /pac/v1/admin/host/self/push-logs?limit=50(宿主自助权限)
         按时间倒序列最近 N 批(上限 200):source / status / dryRun / 收行数 / 落库数 /
         去重 / 失败 / 报错原因。sync_logs 本已记全,此前未对宿主暴露 —— 当时的同步响应
         没留存也能事后查"哪批错了、错在哪"。
      
      文档补 §7「对接自测」:预检用法与通过标准(failed=0 且 mappingMisses/suspectFields=0)、
      推送记录、可用面(API 文档 /api/docs、宿主自助页、队列面板)、常见错误码速查。
      
      验证:本地实推 —— 预检 6 行→"若推会落 5"(1 噪音被过滤)、返回 payment/refund 样本、
      **患者数仍为 0 确认零落库**;push-logs 正确区分预检(dryRun=true)与正式批次。
      typecheck 干净,299 测试绿。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • refactor(friday): inline 列沿用宿主原表列名 + 契约去掉「推送范围」 · 96d37e38
      两条纪律收紧(宿主零改名、零行过滤):
      
      1) inline 列不再用 PAC 自造名,一律沿用宿主源列名:
         - customer_basic_info:phone/phone_relationship → contacts_tel/relationship(customer_contacts 原名)
         - med_emr_info.diag[]:stdCode → std_code(std_diag 原名)
         - class_name / organization_id / plan_name 本就是宿主原名,不变
         宿主无需为 PAC 做任何字段改名。
      
      2) 文档删除各表「推送范围」:med_emr_info 那条 status∈{3,4} 是残留的宿主侧过滤,
         而 manifest E.0 已在 PAC 侧排草稿 —— 重复且违背单一真理源。改为 §1 统一声明
         「整表照推,宿主不做任何行过滤;status/金额切分全部由 PAC 完成」。
      
      验证:元和王永 dry-run 与改前逐项一致(patients=16472 txns=117718 failed=0;
      phone/K04/modality/退费 190+308 全对);typecheck 干净,299 测试绿。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
  4. 23 Jul, 2026 7 commits
    • feat(friday): refund_item 身份 inline(宿主反填真 patient_id)+ drift 误报修复 · ab9ee7d5
      push 自洽收尾(判据:PAC 已有主体的不 inline,值不可信的才 inline):
      - refund_item:spec.patient_id 部分品牌是诊所本地垃圾 id、不可信 → 宿主导出时 JOIN 结算头
        把真 patient_id 反填进 spec(inline);删 S.3.1 跨表 lookup,refund_item 单表 push 自洽。
        (patient_relation 不动:referee 本身是 PAC 患者实体,referee_sex 可从自有实体解析,无需 inline)
      - drift 误报修复:refund_full/refund_item 共用 subjectType='refund' 但源表列集不同,
        detectRawColumnDrift 按「与本批列签名 Jaccard≥0.5」自选同源样本,排除跨源混列(消除 suspectFields 虚警)
      
      验证:元和王永 dry-run refund_item=308(样本 patientExternalId=659325 真 6 位 id,无 lookup)、
      refund_full=190、failed=0;299 测试绿、typecheck 干净。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(friday): 摄入契约收敛为 9 张自洽表 — 宿主 inline 自己的引用(全路径一致) · c4636e36
      manifest 共用于所有摄入路径(cold-import/file/pull/push/reparse),改它一处全生效。
      删 5 处跨表 lookup + 5 张源表,宿主导出时 within-host join 后 inline:
      - 私有字典(std_diag/std_check_class)→ diag[].stdCode / med_check.class_name(删 lookup + 源表)
      - 联系方式 1:N(customer_contacts)→ customer_basic_info.phone/phone_relationship(挑默认号)
      - 计划头(customer_treat_plan)→ 计划行 organization_id/plan_name(删 lookup + 头源表 + group)
      - 支付通道(settlement_modes)→ 不摄入(金额在结算头 net_receipts_this;payment 去掉 method)
      - incremental 清废弃表名(patient_settlement_refund/spec_refund)+ 补 patient_settlement_spec
      
      export.sh 同步改成自洽形态(within-host join;含 diag stdCode 跨库预取 map);
      image.yaml primary image_rows→med_check;payment.yaml 去 method。
      
      验证:元和王永 16472 患者 dry-run 逐资源计数与 inline 前**完全一致**
      (patient/diagnosis 4458/treatment 5781/image 6170/payment 20995/refund 190+308);
      中间表 41→33;typecheck 干净;全量 299 测试绿。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • docs(friday): push 契约收敛为 9 张自洽 source(宿主 inline 自己的引用) · 08c515b6
      确立职责边界:宿主推送前解析好自己的引用、inline 进相关行;PAC 只做跨宿主临床归一。
      14 → 9 个 source,删掉 5 张 lookup-only 源表:
      - std_diag / std_check_class(纯翻译字典)→ 宿主解析 stdCode/class_name inline 进 diag[]/med_check;
        省掉大表全量重推 + PAC 持久化字典存储(仅留 _shared 跨宿主临床字典)
      - customer_contacts(PAC 只要默认号+归属)→ inline phone/phone_relationship 进 customer_basic_info
      - settlement_modes(总值在结算头 net_receipts_this,24% 多通道拆付暂不需要)→ 不摄入
      - customer_treat_plan 头(PAC 只取诊所/方案名)→ inline organization_id/plan_name 进计划行
      
      结果:每张 source 自洽,增量推变更行天然成立,无跨表依赖/无新表/无 hydration。
      注:manifest + export.sh 的对应改动(删 lookup、宿主侧 join inline)随后跟进。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • docs(friday): 补 std_check_class 独立小节 + 字典改增量推 + 作用域澄清 · 4ca27401
      - std_check_class 此前仅在 med_check 末尾一行带过 → 补成与 std_diag 对齐的正式字段小节;
        §4 病历链 3→4 个 source(14 张源表全部有独立小节)
      - 两字典作用域澄清:按品牌维护、但 code(diag_code/class_code)全局唯一 → lookup 不需租户限定
      - 推送方式从「变更时全量重推」改为「按 code upsert、增量推变更行」(与其他表一致);
        规模更正:std_diag 生产多品牌合计 1.5万~3万+行(非测试库 4 品牌的 2739),全推确实浪费。
        依赖 PAC 侧持久化字典存储(落地中)
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(sync): traceRawSourceTable 支持 union 回溯 — 修 refund_full 在 push/reparse 静默跳过 · b19f0f3b
      union-primary 资源(refund_full_rows ← union[status4, status3负额])此前回溯不到源表
      (traceRawSourceTable 只认单 input/output),导致 push source=patient_settlement 时
      refund_full assembler 不入选、reparse 也跳过 → **整单退费在这两条路径静默丢财务数据**。
      
      修:union 各输入独立回溯,全部收敛到同一根表(两路都 → patient_settlement)才返回该根;
      发散到多根则停在 union 输出(维持旧行为不误判)。export 供单测。
      
      验证:108467 重推 patient_settlement → refund_full 落库(txn=1 新增,2 payment 幂等 dup);
      单测 6 绿(union 收敛/发散/单链/route/lookup);全量 299 绿无回归。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • docs(friday): push 契约对齐迁移后原表 + 冷导入/对账文档 · dc8add18
      - friday-push-payload:结算段从「宿主分流 4 source」改为「推 3 张原表、WHERE 全在 PAC」;
        删 patient_settlement_refund/spec_refund 两个已废弃 source;新增 spec.patient_id 不可信说明;
        source 计数 15→14
      - 新增 data-reconciliation:源  PAC 计数对账口径
      - ingestion / meta:配套更新
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(friday): 结算WHERE迁移单一真理源 + 冷导入网页通道 + 数据对账 · b3e3a6c2
      - 结算/病历 WHERE 从导出侧迁入 manifest transforms(单一真理源):宿主推原表全 status,
        PAC 侧 filter 切消费/退费/退费明细;transforms 新增数值算子 lt/lte/gt/gte
      - 退费明细身份纠正(S.3.1):spec.patient_id 部分品牌是诊所本地 id 不可信,
        改从结算头 settlement_id→patient_id 继承(元和王永实测 26421 行全不符)
      - 冷导入网页上传通道:上传 zip → yauzl 白名单解压 → 迷你 data 根(含 _shared)→
        队列 cold-import;宽容缺独立表 + 成组完整性校验 + dry-run 预检
      - 数据对账:日报复用摄入查询做「源  PAC 去重患者数」比对(clickhouse countDistinct)
      - 测试 18 绿:friday-settlement-where / filter-numeric / cold-import-extract / cold-import-groups
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed