1. 03 Aug, 2026 9 commits
    • fix(plan): 候选总数改 count 出来,别拿取数上限冒充 · e986f5ef
      主管在矩阵点「充填 · 窗口外 1,080」,确认单却说「从 170 位候选里取前 100 人」
      —— 170 正是 ceil(100*1.5)+20 这个取明细的 LIMIT。而 selectionNote 是要求助手
      原话转述的句子(T14),等于让系统当着主管的面报一个他刚看过的、对不上的数。
      
      · 新增 countCandidates():走同一份 cohortWhereSql,count(DISTINCT patient_id)
        —— 与矩阵格子同一种数法,不是 count(*)(同一患者两条召回会被数两次)。
      · candidateTotal / selectionNote 三处判断全改用真候选数;取明细仍只取 1.5 倍窗口。
      · zod describe 里那句"已按取数上限截断"一并改掉(它把 bug 写成了规格)。
      
      测试侧同时修一个假绿:原来的假 prisma 不管 LIMIT、一律吐 candidateCount 行,
      所以"截断"在测试里根本不可能发生。改成照 LIMIT 截断 + count/明细分流,
      并加一条 1080 的回归(已验证:改回旧实现该用例即红)。
      
      本地实测:矩阵 1,080 → 确认单「从 1080 位候选里取前 100 人」。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • refactor(web): 初选不再筛左栏 —— 撤掉「初选 xx·xx」告示条 · 541ea76a
      点矩阵格子只往助手走,左栏一动不动(原筛选/滚动位置全保留)。
      
      两个理由:
      ① 人群的证据在助手的确认单里,那才是要审的东西。左栏再给一份,
         两处口径一旦不一致(列表按 plan 分页、矩阵按患者去重,本来就不是一个数)
         主管不知道信哪个;
      ② 左栏是他挑患者的地方,被上一次初选筛住会让他以为池子空了 ——
         而那条告示要他先看懂、再点掉,才回得到整池。
      
      cell 只留作矩阵内部回显(再打开时看得出刚交过哪一格)。
      服务端 potentialTreatment / temperature 两个列表参数保留未动,只是前端不再自动带上。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • refactor(web): 分配按钮内置 loading,取到数再开矩阵浮层 · fa0b286b
      原来是「点分配 → 立刻弹浮层 → 浮层里转圈 → 数据到了重排」。
      改成 shadcn 的常规做法:**加载态落在触发钮上**(钮里转圈 + 禁用),
      取到数才把浮层打开,于是浮层一出现就是完整的矩阵,不会先以骨架尺寸出现再重排
      —— 浮层的抖动比列表刺眼得多。
      
      实现上 Popover 改**受控**:Radix 请求"打开"时先不开,去取数,取回来再置 open。
      ️ 关闭要照常放行(点外面 / 再点一次钮),否则浮层关不掉。
      
      PoolMatrix 随之变成**纯展示组件**:数据由调用方传入,不再自取,
      loading / error 两段 UI 一并删掉 —— 取数失败只弹 toast **不开浮层**,
      开一个空浮层比不开更让人困惑。
      
      ️ 每次点都重取,不缓存:池子随时在变(客服在处理、引擎在重算),
      缓存一份旧的会让主管照着过期的数字挑格子,而他完全看不出这数是旧的。
      一次 ~90ms,不值得为它引入失效逻辑。
      
      触发钮换成 ui/button 的 <Button>(尺寸压到 h-7 贴合 tab 行):
      拿到 shadcn 的 gap-2 / disabled:opacity-50 / focus ring,不再手写一套。
      
      next build 通过,971 tests passing。(按要求未在浏览器复验)
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(web): 人群移交的数据流动画 —— 格子 → 助手 → 开大窗 · 08a4b565
      点中矩阵某一格后:一串粒子沿弧线飞向助手 → 助手进「吸收」姿态 → 落地后自动
      开会话窗(最大化)并说出那句话,接上原有流程。
      
      沿用本仓既有取舍(不引动画库):CSS Motion Path(offset-path)+ 原生 keyframes,
      曲线一次算好塞进 --pac-stream-path,粒子只改 offset-distance —— 位置由合成器算,
      不占主线程,与宠物那 31 个 keyframes 同一套路。
      
       **两个端点都在播放那一刻现取**,这是"位置要准"的关键:
      · 起点 = 事件带来的格子矩形 —— 浮层紧接着就关,不当场取就没了;
      · 终点 = 助手 FAB 的**实时** rect(挂 data-assistant-fab 现查)——
        那颗钮可被主管拖到任意角落并记进 localStorage,写死右下角的话,
        拖过钮的人看到的就是粒子飞向空气。
      弧线的控制点取中点沿法线抬起,抬的方向跟着走向翻转,否则从左上飞和从右下飞
      会一个上凸一个下凹,看着像两套动画。
      
      分层没有破:业务只发语义事件(cohort_handoff + from),**不描述怎么飞**;
      飞法/粒子数/时长全在舞台层 cohort-stream。换成传送带只改那一个文件。
      ️ 时长的真理源在舞台层,store 引它来排"飞完再开窗" ——
      业务侧自己 setTimeout 的话,每个调用点都要知道动画多长,改一次要满仓找。
      反过来也不让舞台层去开助手:那会让"怎么演"决定"接下来做什么"。
      
      顺带把 maximized 从 AssistantWidget 的组件内 useState 提到 store ——
      与 open 当初同一个毛病:业务侧要"移交后直接开大窗"就够不着。
      
      无障碍:prefers-reduced-motion: reduce 时粒子整段不出现,移交本身照常发生。
      
      浏览器实测:一次点击产生 10 颗粒子,offset-path 起点 = 格心(312,180)、
      终点 = FAB 中心(1236,676) 精确吻合,0.62s/颗、45ms 间隔;
      落地后助手自动最大化并发出「帮我给「种植·黄金期」这批患者出一份分配方案」,
      确认单渲染出「确认分配 43 条」与该格数字一致。
      next build 通过,971 tests passing,控制台零报错。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • style(web): 矩阵 hover/选中描边改主色 · 89ecf16b
      黑框会被读成边界线、像数据的一部分;这一圈框表达的是「你正指着谁 / 选中了谁」,
      属于交互态,而交互态在全站都该是品牌主色。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(design): 教条补回矩阵那一节 —— 三档叫法 + 1b 配色 · 0a68b25e
      这一节此前两次改动都**没落到文档上**(python 替换静默 no-match,只改了代码没改教条),
      于是教条里还写着早就撤掉的「蓝→琥珀→橙 温度渐变 + 🔥热/🌡温/️冷」。
      现补齐到实际状态:列头临床说法(黄金期/窗口内/窗口外)、代码名与显示名为何刻意不一致、
      1b 连续色带、以及「数量不参与配色」「hover 用内描边不换底色」两条纪律。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(web): 矩阵改用 claude design 的 1b「连续色带」+ 浮层真动效 · a4dd6fa6
      按产品在 claude design 出的稿子(分配矩阵.dc.html 的 1b 方案)重做:
      
      · 三列共用**一条** amber → emerald → sky 的横向渐变,格子透明浮在这张面上 ——
        列与列之间没有缝,整片读起来是一条"由烫到冷"的轴,只用色相区分窗口。
      · 用 shadcn Card(CardHeader/CardContent)承载,浮层只负责定位与动效,宽度交给 Card 自己定
        —— 两处都写宽度必然对不齐,而且改一处会静默留下另一处的白边。
      · 表头补列汇总(黄金期 271 / 窗口内 280 / 窗口外 3,254),现算不新增字段。
      · hover 文案**保持原样**:只给天数区间,不给解释。
      
      ️ 配色是**恢复**但不是回到老样子:老那套是"温度比喻"的冷暖渐变(已撤),
      这套是与列头一一对应的三个色相 —— 色相与文字是同一信息的两种编码,
      刻意的冗余(色盲靠文字、扫视靠颜色)。「数量不参与配色」这条自始至终没变。
      
       顺带修掉两处**写了不生效、且完全不报错**的样式:
      
      ① popover.tsx 一直挂着 shadcn 官方的 `animate-in fade-in-0 …`,而那套来自
         tailwindcss-animate 插件、**本仓根本没装** → 浮层一帧动画都没有。
         改用原生 keyframes,并**经 @theme 注册成 --animate-***:
         Tailwind 的 variant 只作用在它自己生成的 utility 上,手写同名 class 拿不到
         `data-[state=open]:` —— 实测 animation-name: none(class 在、样式在,就是没被选中)。
         缩放锚点接 --radix-popover-content-transform-origin,浮层从触发按钮那个角长出来。
      
      ② 格子的 hover 描边先写成 `shadow-[inset_0_0_0_2px_var(--color-slate-900)]`,
         Tailwind v4 把带 var() 的任意值解析成"只有颜色的阴影",偏移和扩散全丢,
         实测是 `oklab(0 0 0 / 0) 0 0 0 0 inset` —— 完全透明。改用原生 outline;
         ️ 还必须带 `outline-solid`,否则基态的 `outline-none` 把 outline-style 钉成 none,
         只加 outline-2 就是"宽度 2px、样式 none",一条线都不画。
      
      浏览器逐项实测:渐变面(oklab 插值)、hover 内描边(solid 2px, offset -2px)、
      出现/消失动画(overlayIn 0.14s / overlayOut 0.1s,Radix 会等动画结束再卸载)、
      tooltip 区间不变;next build 通过,971 tests passing,控制台零报错。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • refactor(web): 矩阵列头改临床说法,撤掉温度配色 · c9d70688
      列头「热 / 温 / 冷」→「**黄金期 / 窗口内 / 窗口外**」,并把温度渐变整套撤掉。
      
       两件事是一件事:「热」是**比喻**,而这三档本来就有精确的临床语义。
      名字说准之后,冷暖色就成了**第二套更弱的编码** —— 只是把列头已经说清的事再说一遍,
      还要额外维护对比度、色标位置、深色模式。所以列头改名与撤配色一起做。
      
       在文件头和教条里都写死了「别再把冷暖色加回来」,尤其别按数量深浅上色:
      「这格橙是因为在黄金期、还是因为人多」分不清 —— 数量已用数字表达、档位已用列头表达。
      唯一允许的颜色是交互态(hover / 选中),那表达的是"你正指着谁",不是数据。
      
      ️ **代码名不动**:枚举值、API 参数 temperature=、persona_features.data.temperature 的
      JSON 路径仍是 hot/warm/cold。改它们要同时动 API 契约、SQL 谓词、已落库的 JSON 路径
      和前端 query,收益只是"看着顺眼"。已在 TEMPERATURE_META 上写明这条,免得下一个人顺手重构。
      
      顺带收口一处漂移:TEMPERATURE_META 此前**定义了却没人用**,
      rail 里另有两张内联中文表(初选 chip、宠物气泡)。现在三处都走 META,
      改措辞只改一处 —— 否则这次改名就会漏掉那两处,而且不会报错。
      
      浏览器实测:列头三档正确、矩阵内温度色节点归零(0)、hover 区间不变;
      next build 通过,971 tests passing。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • refactor(web): 矩阵二轮走查 —— 整片连成一条渐变 + hover 只给区间 · ce100876
      ① 去掉底部文字(那条「或者:按左栏当前筛选的 N 人移交」)。
          连带把「按筛选人群移交」这条路整个撤了,入口只留矩阵一个 ——
         生产线 ① 明写「主管在矩阵上点一格」,两个入口做同一件事,主管得先想用哪个。
         要复活是加回一个按钮 + 一次 emitPetEvent/ask 的事,模型和后端都不用动。
         顺手删掉随之变成死代码的 handoffToAssistant。
      
      ② hover 只给**天数区间**:「≤ 120 天」/「120–180 天」/「> 180 天」。
         格子里已有数字、列头已写热/温/冷,再叠一段说明就是噪声;
         区间是主管唯一看不出来的那个信息。
         ️ 多码标签给各码区间的**并集**(拔牙 = K01 ∪ K03 → 热 ≤90 / 温 60–180 / 冷 >90):
         挑其中一个码的数字会骗人,而并集是对该档人群天数范围的如实描述。
         加了一条回归锁「三档必须覆盖整条数轴、不留缝也不写反」。
      
      ③ **整片矩阵共用一条横向渐变**,格子不再各有底色。
         温度是**连续量**,给每格各刷一块色 = 把它退化成三个并列的分类,冷热轴的意思就没了。
         实现上渐变挂在行容器、格子透明浮在上面 → 列与列、行与行连成一整片;
         列头那条刻度用的是**同一个** SCALE 常量,写两遍必然漂。
         ️ 色标位置(20%/50%/80%)是对着三列**列心**(16.7/50/83.3%)定的,
         正好落在纯橙/纯琥珀/纯蓝上,数字对比度才够 —— 改列宽必须同步改色标。
          「数量不参与配色」的老纪律没变:渐变只由列的位置决定。
      
      浏览器实测:整片渐变无缝、8 行标签、hover 区间(单码/多码)均正确,无横向溢出;
      next build 通过,971 tests passing。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  2. 02 Aug, 2026 25 commits
    • refactor(web): 矩阵按产品走查改版 —— 分配主按钮 + 浮层 + 温度计刻度 · 35c10335
      按产品对着界面逐条走查的意见改:
      
      ① 「矩阵」→「**分配**」主色实心按钮,点击**浮层展开**矩阵(不再替换左栏内容)
         —— 分配是主管在这一页的主行动,该给主色;浮层展开则挑格子时列表还在原处,
         关掉就回到原位置,不丢滚动位置和筛选。
      ② 去掉矩阵底部那段常驻说明文字 —— 口径("去重患者数""点一格即移交")挪进
         **每个格子的 hover**,那才是主管会去看的地方;常驻小字既挤地方又没人读。
      ③ 行标签去掉「治疗」二字(种植/正畸/早矫/根管/牙周/充填/修复/拔牙)——
         直接用现成的 potentialTreatmentItemName,不另写表。
      ④ 格子 hover 说明**窗口期**:「黄金期:诊断后 120 天内」,天数从 DiagnosisTreatmentMap 现算。
         ️ 多码标签(拔牙 ← K01+K03、牙周 ← K05+K06)给的是**区间**并注明"按具体诊断码各判各的"
         —— 每条 gap 按自己那个码判档,本来就不存在"标签级的那一个天数",
         给单个数会让主管以为口径统一了(那正是 Q-6 当初以为无解的地方)。
      ⑤ 坐标改成**温度计刻度**:一条连续渐变(橙→琥珀→蓝)横跨三列。
         ️ 是 colSpan 跨列的**一条**,不是三段各画各的 —— 温度是连续量,
         切成三段独立色块就退化成"三个分类",冷热轴的意思没了。
      
       常驻的「把这 N 人移交助手」横幅一并撤掉,入口收敛到「分配」一个;
         但那条路没删,收进浮层底部(「或者:按左栏当前筛选的 N 人移交」)——
         矩阵只有「治疗项 × 温度」两轴,主管想按别的条件圈人时仍要走筛选标签。
      
      新增 POTENTIAL_TREATMENT_SOURCE_CODES(标签 ← 诊断码的投影)供展示层算窗口。
      ️ 它**不是真理源**(真理源是 classifyGapToLabel,那里还有年龄闸和诊断名判断),
      已加回归把两者锁在一起 —— 漂了的话 hover 上的天数就是错的,而且不会报错。
      
      浏览器实测:8 行标签、连续渐变条(327×6px)、单码/多码 hover 文案、浮层开合均正确;
      next build 通过。968 tests passing。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(plan): 归因继承的判据换轴 —— 看客服碰过没,不看召回理由变没变 · 2d8b146e
      上一版判据(「召回理由整组换掉就不继承」)是**找错了轴**,产品当场指出两个反例:
      
        ① 召回一会儿出现一会儿不出现,**恰恰可能是被客服处理过**造成的
           (患者来了、治了一部分、又诊断出新问题)→ 该结账的反而被当成"理由变了"继承走
        ② 同一种诊断**再次出现,也不代表客服没处理过** → 该结账的同样继承了
      
      **理由的变化根本不携带「客服做没做事」的信息**,它只反映患者临床状态在动。
      两个方向都会错,所以整条判据作废,换成:
      
        客服没碰过 → 这单还欠着 → 继承(理由怎么变都继承)
        客服碰过了 → 批次这一条的账已落定 → 不继承,后面再冒出来的是新工单
      
      批次分的是**人**不是诊断:主管要知道的是「这 100 个人有多少被联系/处理了」。
      一个人一次都没被联系过,账就还欠着 —— 临床理由怎么变都不改变这件事。
      
      ️ 「碰过没」不新造判据,**直接复用 T21 / D-11 那一套**(撤销整批判"哪些单不能收"):
      以 view 事件为主(实测 view 1,024 条 vs plan_executions 7 条,回写率 11%),
      外加行上三个现成强信号(snoozedUntil / releaseReason / contactAttempts)。
       别改成只看 plan_executions —— 会把 89% 已打过电话的单判成"没碰过",
      于是归因一直继承下去,**批次的账永远结不掉**。
      
      性能:view 事件**只对带 assignment_id 的单查**,批量路径一次 groupBy 预取。
      不收窄的话 runAllForHost 全量会为 44 万患者各查一次(已加回归锁住)。
      
      与 T20′ 咬合不变:不继承时旧行仍带 assignment_id 且已 superseded → 跟踪按患者取最新版
      取到的就是它,身上带着当时的处置(releaseReason / 已 view)→ 批次分子分母都不丢。
      
      D-12 那条 🔴🔴 守恒断言(「退回后升版本归因仍在」)改为断言**结果**而非机制:
      退回也是一种处置 → 新版本不带归因,但旧行带着 assignment_id + releaseReason 留在批次里,
      退回率照样算得出。原断言测的是当时的实现手段,不是它想守的东西。
      
      顺带补上 mock 的 planEventLog.groupBy/findFirst(此前没有,view 判据在单测里无从验证)。
      
      965 tests passing。教条 T20″ 与契约 D-12 已按新轴重写,并把"曾经写错的那版判据 + 为什么错"
      留在文档里 —— 这个错很容易再犯一次。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(plan): 归因继承加边界 —— 召回理由整组换掉 = 全新工单,不继承 · aa5fdaf7
      产品指出:「该患者新的诊断等信号形成的工单升版本了是全新的,不应该继承」。核实后成立。
      
      先分清此前被混在一起的**两套继承**:
        carryAssignment  客服相关(status/assignee/assignedAt/contactAttempts)—— 还挂在同一个人名下吗
        carryAttribution 批次归因(assignment_id/assigned_by/assign_strategy + 快照五列)—— 还是同一张工单吗
      
      升版本的判据就是 (scenario, subKey) 集合变了 = 召回理由换了。理由**整组**换掉时,
      新版本已是一张全新工单 —— 患者新长了颗龋齿,跟主管当初按「潜在种植」分下去的那单没关系,
      不该继续算在那个批次头上。
      
      判据取**临床缺口类型**交集(subKey 去牙位),不是 subKey 原文:
      subKey 自带牙位(caries_no_filling@18;28),按原文比会把「又坏了一颗牙」误判成全新工单。
      快照五列与 assignment_id **同生共死** —— 留下 selectionMode='explore' 却查不到批次的孤儿行,
      会直接污染 T20 的因果分析(探索组样本本来就小)。
      
       为什么现在可以推翻 D-12 的「无条件继承」:那条其实是**给当时跟踪查询缺陷打的补丁** ——
      那时 detail 过滤 `supersededAt: null`,旧行看不见,不继承就等于分母静默缩水。
      上一个 commit 把跟踪改成「按患者取最新版、不过滤 superseded」之后,历史留在旧行上,
      「无条件」不再必需,而且有害。已加回归锁住这个前提(旧行的归因绝不能被清)。
      
       与 T20′ 恰好咬合:不继承时旧行仍带 assignment_id 留在批次里且已 superseded →
      跟踪取到的就是那条 superseded 行 → 记为 resolved(已处理)。
      语义正确:**这个批次针对的需求确实没了**;新工单干净地回池等下一批。两边都不用打补丁。
      
      ️ 已知边界(刻意不做):批次目标需求消失但别的需求还在时({缺牙,龋齿}→{龋齿}),
      交集非空仍继承。要判准需把 criteria.potentialTreatment 映射到 subKey —— 那是新口径,
      得产品先定,当前样本量下不值得引入映射表(T14)。已写进教条 T20″。
      
      顺带修掉单测里的一处**假绿**:mock 的 create/seed 都没带那五列快照,
      断言拿到的永远是 undefined —— 「快照有没有被正确继承/清空」在单测里根本看不见,怎么写都过。
      
      962 tests passing(D-12 原三条守恒断言改用「理由有交集」的常见情形,仍然锁着;
      新增四条锁「整组更换不继承」「旧行不丢」「牙位变化不算全新」「需求增加照样继承」)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(plan): S2.6 完成口径改判 —— 看池子状态,不等回写 · 124d3b19
      产品裁决:「完成」不需要等成功,只要知道**这个患者的召回还在不在池、有没有被抑制**,
      两者任一成立就说明这单处理过了。成功不成功先不统计。
      
      否掉原案的「转化率」:它卡在 plan_executions 11% 回写率上,永远只能输出「样本不足」,
      等于这一格报表上线即死。新口径的两条判据是**互补不是重复**:
      
        出池 status='superseded'   引擎按客观事实判定该患者已无活信号(治疗真做了/诊断没了)
                                    **零回写依赖** —— 接住不写回访的 89%
        被抑制 snoozedUntil > now  客服写了回访结果(约下次/拒绝/放弃)
                                   接住写了的 11%
      
      为什么现在可以不算成功、以后又不会抓瞎:出池原因记在 plan_event_logs、客观事实在
      patient_facts(append-only)—— 任何时候都能回过头重算「这批人后来做没做治疗」。
      不是放弃了这个数据,是它不需要现在算。
      
       连带抓出并修掉一个**静默缺陷**(教条 T20′ 尾):
      跟踪查询写的是 `where: { supersededAt: null }`,而引擎关闭走 closeStaleActivePlan ——
      `status='superseded'` 且**不建后继版本** → 这些行整个消失。
      本地实测(60 条批次,8 条已出池):旧口径 planned=52,新口径 60。
      **批次跑得越好、数字缩得越厉害,而缩掉的恰恰是唯一算得上成功的那批。**
      (D-12 无条件继承只保住了升版本那条路 —— 后继行继承 assignment_id;
       关闭这条路没有后继行,继承救不了。R12 当时只想到了前者。)
      正解:拿本批全部行、按患者取最新版本(升版本时新旧行都带 assignment_id,
      max(version) 天然去重;关闭时最新版就是那条 superseded 行,正好是信号)。
      
      五桶穷尽(resolved/suppressed/closed/inHandPending/backToPool),
      done + inHandPending + backToPool 恒等于 planned —— 少一个分支就是静默丢人,
      主管一对数发现少了几个、而少的那几个永远查不出去哪了。回归里锁着。
      
      助手侧双保险:提示词 + 工具描述都写死「处理率不是成功率」,
       不许把 resolved 说成「转化成功」、 不许自己拿这些数算转化率;
      报处理率必须带批次年龄(跑三个月的批次天然比跑三天的好看)。
      
      本地真实验证(60 条批次 / 8 出池 / 5 抑制):
        已处理 13 = 出池 8 + 抑制 5;在手未处理 31;回池 16
        五桶求和 60 = planned 60    agentStats 各人之和 60 = planned 60 
        列表页与详情页同一批数字一致 
      958 tests passing;测试数据已清理。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(plan): S2.5 初选矩阵 —— 端点 + 组件 + 点格子直通助手 · 1d1b7636
      生产线第一环补齐:主管在矩阵上点一格 = 完成初选,人群随即交给助手出确认单。
      
       关键设计:温度边界改存**稳定路径** `data.temperature.<标签>.hotUntil`,
      不再塞进 `detail[]` 数组。原因是数组下标因人而异 —— `detail.0.hotUntil` 对谁都不成立,
      Prisma 的 json 路径过滤(以及任何索引)都用不上,于是**召回池列表永远对不上矩阵**。
      挪成稳定路径后:矩阵走原生 SQL、列表走 Prisma,谓词字面等价。
      本地实测 **24 格逐格与列表 total 相等**,这是 T14「口径对数」的硬要求 ——
      格子里写 44、点进去列表 373,主管对整个功能的信任当场就没了。
      
      后端:
      · GET /plans/matrix(8×3,去重患者数,PLAN_DISPATCH 门控 ——  不是 PLAN_VIEW_OWN,
        那等于从后门把召回池露给客服,违 T16);路由必须声明在裸 @Get(':id') 之前
      · ListPlansQuery 加 temperature + potentialTreatment;单给 temperature 直接 400
        (同一个人可能对种植是热、对补牙是冷,脱离治疗项的"热"没有意义)
      · 「待重算」单独一列, 不并进「冷」—— 并进去行合计好看但那是假分布(T14)
      
      前端:
      · pool-matrix.tsx:底色**按列固定**、与数量完全无关(否则「这格橙是因为热还是因为人多」
        分不清);热用橙不用红; 不用 brand-*(品牌蓝比 blue-100 深太多,会让「冷」像选中态)
      · 矩阵是召回池的**视图模式**(列表 ⇄ 矩阵),不新开路由;初选格子有回显 chip + 一键清除
      · 三层动效架构在此得到验证:发射点从「移交助手」按钮换到格子上,只改调用处,
        pet-events / pet-brain / pet-body 一个字没动(只给 cohort_handoff 加了个温度文案字段)
      
      浏览器实测(主管 康慧捧 / 北京朝阳公园诊所)抓到并修掉两处:
      · 规划建议的「矩阵模式 rail 加宽到 420px」→ **撤销**,1280px 视口下会把中间
        「参考话术」列挤成竖排文字;8×3 在 320px 内完全够用
      · 页脚文案里的 markdown `**` 被原样渲染 → 改成 <span>
      
       另修一句会让助手替系统撒谎的话:候选 44 人时 selectionNote 仍说「排序取前 100 人」,
      主管会以为有 56 个人被系统悄悄丢了。改为「这批候选一共就 44 人,全部纳入(未做取舍)」。
      它是要求助手**原话转述**的句子,措辞错等于让助手说假话(T14),已加回归。
      
      端到端实测:点「根管治疗·🔥热」→ 列表 5 人 → 助手直出确认单「确认分配 5 条」,
      三句依据原话转述、默认值字样都在;矩阵/列表/chip/按钮四处数字一致。
      944 tests passing;service + web typecheck 干净;控制台与服务端零报错。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(plan): S2.4 画像圈人 —— get_cohort_attributes + 人群取数收口 · cb56c1fd
      T9-B「调整阶段画像圈人」落地。主管说「只要商保直付的」「排掉怕疼的」时,
      助手先看这批人里各口子多少人,再据实回话,然后带 personaTags 重出确认单。
      
      T10 是真的成立而不只是口号:维度全集直接取 PERSONA_TAG_FILTER_DIMS
      (与列表页筛选面板、list_recall_queue 的 personaTags 同一张表)——
      以后加权益身份/治疗史/家庭结构是往那张表加一行的事,本工具一个字都不用改。
      教条五要合并的 getDedicatedCs + getPersonaFeatures 就是这一个口。
      
       人群取数抽成共用的 cohort-filter.ts,出确认单与看分布**同一份 SQL**:
      各写一遍必然出现「分布说商保 32 人、提案只有 28 人」,主管当场不信且分不清哪边错。
      顺带把温度(矩阵 Y 轴)与 personaTags 接进 propose_assignment —— 在此之前
      主管的收窄条件没有任何通路能流进确认单。
      
       noTag(没有这条画像证据)≠ 反面:
      「32 人有商保标签」剩下的**不是自费**,是院内没留痕。模型极容易说成「其余 68 人自费」,
      那是凭空造事实(T14),而主管会拿它去定价。返回结构单列 noTag + 提示词写死,双保险。
      
       隐藏维度可点名但默认不给:教条举的「排掉怕疼的」正落在 treatment_sensitivity
      (hidden:true)上 —— hidden 语义是「面板不展示」不是「不能用」;
      但默认推 16 个维度,模型会挑个不相干的开始发挥。
      
       temperatureUnknown:上线到全量重算跑完之间,老画像没有窗口边界,
      三档之和会少于该行总数。把差额报出来(而不是塞进「冷」把数字凑上)——
      凑上是假分布,报出来主管知道那是重算进度。
      
      本地真实数据验证(5,825 患者 / 2,724 池子):
        三档求和对数    44+25+304 = 373 = 潜在种植全部 373  
        口径对数        分布 22 → 收窄 22 → 提案 candidateTotal 22  
        隐藏维度点名    treatment_sensitivity → 看牙恐惧 2 人  
        耗时           23-96ms
      
      937 tests passing。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(persona): S2.3 温度口径裁决 —— 在温度层聚合 + 锚点存边界时刻 · 0df27893
      裁决开发规划 Q-6(原判「8 标签 ← K 码一对多,无解」)。原建议是给 8 个业务标签
      另定一张标签级窗口表并明写「这是新口径」;**否掉**,因为真正的解法是换一层聚合:
      
        逐条 gap 用自己 K 码的窗口判档 → 再取最热
      
      一对多自动消解(extraction 里 K01 那条按 K01 判、K03 那条按 K03 判),
      不需要任何新常量,DiagnosisTreatmentMap 是真的被复用了 ——
      教条 T6「不新增口径」与 canonical-codes「不允许任一处再硬编码窗口」都不用破例。
      原判「无解」源于默认了聚合必须发生在天数层。
      
      同时修掉旧聚合的**反单调**:先 Math.max(daysSince) 再判档,会让「上周刚查出的龋齿」
      因为身上还有一颗两年前的旧龋而被判成冷 —— 多一条未满足需求反而更冷。
      
      存边界时刻而非天数/档位(解冻):
        daysSince 在 VOLATILE_DATA_KEYS 里被 stripVolatile 剔掉 → 时间流逝永不触发重算
        → 温度档永久冻结在画像算出来那一刻。改存 hotUntil/warmUntil(= 锚点 + 窗口),
        读时跟 now 比。二者由「事实日期 + 静态配置」推出,不是易变键。
        同一套路本仓已用过两次(visitRecencyRange / applyLiveDays),这是第三次。
      
      「不知道」不等于「冷」:老画像无边界 → classifyTemperature 返回 null 而非 cold,
      否则老数据会静默塞满冷格子,主管看到一个假分布还看不出哪里假(T14)。
      
       顺带抓出并修掉一个**静默归零**缺陷(教条 §4.38):
      assignment-proposal 按 `pe.id = fp.persona_id AND pe.superseded_at IS NULL` 关联画像,
      而 plan 的 persona_id 不随画像升版本更新 → 重算一次后条件恒为假、筛选变 0 条。
      实测:全量重算后池子里只剩 97/2,724 指向活版本,「潜在种植」按 persona_id 得 0 条、
      按 patient_id 得 376 条(列表页一直是后者)。已改为按 patient_id + 源码形态护栏。
      
      实测量级(本地 5,825 患者,召回池 3,880 个「患者×标签」格位):
      翻档 1.31%(43 变热 / 8 变冷,其中 6 条是压线、2 条是 K06 被当成 K05 的误判改对)。
      ️ 拿未过 gap 闸的原始诊断事实去量会得到 40-70% 的夸张差值,那是错的人群。
      
      917 tests passing;本地全量 persona 重算 success=3020 refreshed=2143 unchanged=662 failed=0。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(ai): S2.2 福利进话术 + 缓存失效规则 · cebde6b1
      🔴 分配功能里**唯一一条「做错了会对患者做出虚假承诺」**的路。
      
      ## 那个缓存坑的实际形状(已核实)
      
      `plan_scripts.planId` 是 **@unique**(一 plan 一话术),orchestrator 写的是 upsert;
      而生成**只在有人点「生成」时才跑**,之后一路读缓存(loadPlanScript 直接 findUnique)。
      召回池又是共享的、未分配的单谁都能打开触发生成。三段后果一段比一段重:
      
        ① 分配**之前**有人点开过 → 话术已 ready 落库,**福利段永远不会出现**,T4 的归因目的失效
        ② plan 在批次 A(福利甲)→ 退回 → 进批次 B(福利乙)→ 话术缓存**还是甲的文案**,
           客服照着念 = **对患者做了一个不存在的承诺**
        ③ 撤销批次后,话术里的福利仍在
      
      ②③ 不是体验问题,是患者会按一个不存在的优惠上门、前台兑现不了。
      
      ## 失效规则:**删除**,不是置 pending
      
      置 pending 会把旧 content 留在库里,任何一条**只读 content 不看 status** 的路径
      (现在没有,将来难保)都会把过期福利念出去 —— 而这正是要防的那件事。
      删掉则物理上不可能读到;审计不丢(agent_invocations 那条记录还在,丢的只是指针)。
      
      落在两处写路径的**事务里**:
      · `create()`  配了福利 → 作废这批的话术缓存
      · `revoke()`  批次撤销 → **收回的和没收回的都作废**。没收回的那些(客服已打开过)
        手里正拿着一份写着福利的话术,而福利刚被撤销 —— 那才是最危险的一批,他马上要打电话。
      
      ️ 只**作废**不急切重生成:作废是一句 deleteMany(很便宜);重生成是**懒的**,
      客服打开详情页时走现有的"点生成"流程。一批 100 人若急切重生成 = 100 次 LLM 调用的
      钱和延迟,而其中大部分单可能根本没人打开。懒生成让成本随**真实使用**走,不随批次大小走。
      
      ## 福利作事实输入进 prompt(T4),带四条禁令
      
       不走 `AGENT_IDENTITY_PLACEHOLDER` 那套占位符:那是给 **PII 与缓存**用的
      (人名不进 LLM、换客服不用重生成);福利是**内容**不是身份 token,硬插一句会打断口语流,
      且三档输出形态差异大,占位符要在每档各实现一次。作为事实输入则三档通用。
      
      护栏的四条禁令,每条都对应一种真实会发生的编造:
        不得追加条件/期限/名额/人群  ← "限本月前 20 名""老客户专享"
        不得改写金额/折扣/项目、不得夸大 ← 把"免费"说成"5折"
        原文没写的一律不说            ← 兜底
        追问细节 → "以到院时前台说明为准" ← 不给出口它就会现编一个条款
      标成「硬约束」,与"高龄不主推种植""低龄不承诺能不能种"同级,落点也放在一起。
      
      ️ 第一版我在 fact-block(标准/深度)和 stable/prompt(稳健)**各写了一份** ——
      那必然会漂,而「哪一档漏了哪条禁令」要等客服念出去才发现。已抽成导出的 `benefitBlock`,
      两档共用;spec 里加了一条断言直接读 stable/prompt 源码,拦"图省事再抄一份"。
      
      没配福利 → **整段不生成**,不留空钩子(留了模型会自己编一个)。
      
      ## 取数只认 confirmed 的批次
      
      orchestrator 装配时 `plan.assignment.status === 'confirmed'` 才带福利 ——
      撤销后的批次福利已不适用,带进去就是念一个作废的优惠。
      ️ 不判 expiresAt:时效是"客服什么时候该打完",不是"福利什么时候失效";
      福利本身的有效期写在文案里(如"8 月…"),由主管负责。
      
      ## 验证
      
      900 单测(新增 6 条护栏断言)+ 本地端到端:
        造 6 份「旧话术」(模拟分配前被人点开过)→ 带福利分配 → **剩余 0 份** 
        重新生成含新福利的话术 → 撤销批次 → **剩余 0 份** 
      测试数据已清理。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(plan): S2.1 撤销整批 —— 限时 + 「已打开过的不收」 · 64b8d4fd
      分错人 / 条件填错的唯一出口。POST /plans/assignments/:id/revoke。
      
      ##  「已动过」的判据只能用 view 事件
      
      直觉会去查 plan_executions(有执行记录 = 动过),但回写率仅 **11%**
      (生产 65 个认领单只有 7 条执行结果)—— 拿它判会把 89% **已经打过电话**的单
      当成"没动过"收走,那通电话永久蒸发,而客服第二天才发现单子没了。
      `contactAttempts` 与 plan_executions 同源(提交执行时才累加)、同为 7 条,一样不可用。
      
      唯一可用的是 PV/UV 埋点的 `view` 事件 —— 客服打开过详情页 = 至少看过。
      ⇒ 已 view 的**跳过不收**,并在返回的成品句子里如实告诉主管有几条没收、为什么。
      这是 PV/UV 埋点(本为统计而做)的意外收益。
      
      ## 撤销 ≠ 退回(T21)—— 写入纪律三条
      
        撤销 revoke  主管收回**整批**(分错人)     → auto_release + reason=revoked
        退回 release 客服退回**单条**(不是我的客户)→ release + ReleaseReason
      
       **不写 release_reason**:那列只属于客服的处置,混进去退回率的分子分母一起虚高
       **不清批次归因三列**:"这批曾经分过 20 条"是历史事实,批次头已标 revoked,
         统计按 status 排除即可。清了这次误操作影响了多少人就再也说不清
       **不动 snoozedUntil**(与退回、到期同一条纪律)
       账本 actor 记**主管**(不像到期回收那样为 null)—— 撤销是人做的决定,要能追责
      
      ## 授权与时限
      
      授权(D-10)在 service 顶部**一次性**校验:`createdBy===actor || PLAN_VIEW_ALL`。
       不逐行调 assertCanRecycle —— 那是单条路径判"这单是不是你的",
         撤销判的是"这**批**是不是你的",逐行会变成"有一条不是你的就整批失败",语义不对。
      
      窗口 30 分钟。语义是「**手滑/分错人**的补救」,不是「改主意重新调度」——
      改主意应走"客服退回 + 重新分配",那条路有完整的原因记录。
      ️ 它是 **UX 摩擦不是安全边界**:leader 本就有 PLAN_RECYCLE + PLAN_VIEW_ALL,
      超窗口照样能逐条 recycle。 别基于「30 分钟后就锁死」去设计别的东西。
      超窗口的报错**指路到逐条退回**,不是只说"不行"。
      
      幂等:重复撤销不报错(主管手抖点两下很正常),返回零改动。
      合成身份(企微 `wx:`)硬拒 —— 与写路径同一道闸。
      
      ## MCP 工具
      
      `revoke_assignment` 是助手手里**唯一会改数据的工具**,描述里写死:
      只在主管**明确要求**时调, 不主动建议、不试探性调用;
      skippedTouched 不许说成"失败",note 原话转述。
      
      ## 验证
      
      894 单测(新增 13 条断言)+ 本地真实数据端到端:
        20 条在手 / 9 条被打开过 → 收回 11 · **跳过 9** · 批次标 revoked
        归因 20 条全在(没清)· 退回原因 0 条(没混写)· 账本 actor=832 
      seed 数据已清理。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(cli): seed-assignment 造批次验工程 + 修它抓出的跟踪 bug · efe60136
      ## 边界写在文件头:只验工程,不许反推规律
      
      造数据能验的:报表 SQL 的查询计划、分子分母口径、样本不足判定、
      退回原因聚合有没有漏过滤、边界(全退回/全到期/零转化/单人独吞)。
       **不能**用来调任何默认值(容量 50 / 时效 3 天 / 专属优先 / 批次规模 100)——
      T20 要的是"真人在真实压力下怎么反应",而这里每个数都是按 `--release-rate` 编的。
      拿它反推 = 自己教自己,且比没数据更危险:假信号会让错的策略顶着"数据支持"的名义跑起来。
      
      伪随机用 mulberry32 固定种子, 不用 Math.random():
      造完发现报表不对,得能原样重造一遍再看。
      
      批次一律带 `SEED_` 前缀的 requestId,`--clean` 只清自己造的(实测零残留)。
      
      ## 🔴 seed 当场抓出一个真 bug
      
      `agentStats` 各人 planned 之和 **149 ≠ planned 199** —— 50 条已退回/已到期的
      从所有人名下**蒸发**了,而"某人退了几条"恰恰是主管最想看的列。
      
      根因:回捞原始承接人时用了 `assign 事件.createdAt >= 批次头.createdAt`
      (想排掉这条 plan 在**上一个**批次里的 assign 事件)。生产里事件与批次头同事务、
      时间必然在后,**看不出问题**;seed 造的事件在 5 天前,整批当场消失,且**不报任何错**。
      
      正解:plan 当前的 `assignment_id` 就是本批 ⇒ 它**最近一次** assign 必然属于本批。
      按 planId 取 latest,不依赖时间窗。修完 199 = 199 
      
      这就是造数据的价值 —— 它把一个"生产环境下永远碰不到、但一旦碰到就静默错"的
      时序假设逼了出来。
      
      ## 两条不变量已上锁(4 条断言)
      
      ① agentStats 之和必须 = planned(含事件时间早于批次头的情形)
      ② 退回原因分布不得混进系统原因
      
      第 ② 条实测反例:不过滤 `event='release'` 的话,22 条 `assignment_expired`
      会被当成退回原因 —— 退回从 28 变 50,**退回率几乎翻倍**,而报表看起来完全正常。
      (当前 detail 从 `followup_plans.release_reason` 列读,天然隔离 —— 因为到期**不写**那一列;
       但将来若有人改成从事件表聚合,这条断言会拦住。)
      
      ## 顺带查清了「结果信号」的时间锚
      
      `appointment_record` 的 fact 层 `occurred_at` = **预约时刻**(assembler 的
      `occurredAtField: scheduledAt`),content 里**没有** createdAt →
      拿它做归因会把"分配前就约好、分配后才到诊"的算成战果。
      
      但**事务层有**:`patient_transactions.canonical_payload.createdAt`
      (实测 `"2019-07-08T11:17:08+08:00"`),且事务是 append-only、reparse-proof。
      ⇒ 「分配后 N 天内患者建了新预约」**可以客观算出来,客服一个字都不用填**。
      本地 74,040 条 appointment_created 事务,按批次患者收窄后查询 439ms(全表扫要 973ms,
      jsonb 提取用不上索引 —— 报表可接受,别拿它做交互查询)。
      
       这条对 S3 很关键:`n≥50` 不必卡在 `plan_executions`(回写率 11%)上,**换个分子就行** ——
      不用"客服说成了",用"患者真的来了",后者还更硬。
      
      881 单测通过,seed 数据已清理。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat: P4.4 原生确认单 —— S1 生产线全线打通 · b6868af6
      主管在召回池点「移交助手」→ 助手出确认单 → 点确认 → 100 条落到 12 位客服名下。
      本地真实数据端到端跑通。
      
      ## 批次规模:助手自己定,不问主管(产品定调)
      
      上一刀留的问题:target 不传时取"团队剩余容量之和"= 17 人 × 50 = **850**,
      而 T5 说的是「宁可 100 人做透」。️ **「团队还能吃多少」不是「这批该分多少」**。
      
      新增 `DEFAULT_BATCH_SIZE = 100`,依据就是 T5 那句话本身(教条给的口径,不是拍的),
      再被两个上限夹住:团队剩余容量、候选总数。
       助手**按默认值直出,不问**(T13);主管要改自己说"这批只要 60 人"。
      原先 systemExtra 里写的"主动问主管这批想做多少人"是违 T13 的,已删。
      
      ## propose_assignment 从 MCP 改成本地工具 + 侧信道
      
      ️ MCP 工具的返回值会**原样回灌进模型上下文** —— 确认单含几百个 planId
      (一批 850 人 ≈ 12k token),而模型对它唯一要做的事是"转述一句话"。
      改成 assistant 的本地工具:肥载荷走 `onSideEvent` 直接推前端,
      **回给模型的只有摘要 + 三句要它原话转述的依据**,一个 planId 都没有。
       也不能让模型在 tool_call 参数里写这些 id —— 那些参数是模型生成的,同样是它在编。
      `requestId` 由**服务端在此铸造**(D-2),随卡片下发、确认时原样回传。
      
      ## 确认单必须是原生组件
      
      artifact 跑在 `sandbox="allow-scripts"` 的 iframe、CSP `connect-src 'none'`,
      **卡片内不可能发出写请求**。分岔记牢:只读展示用 artifact,可交互用原生组件。
      
      三层:汇总 → 按客服(可折叠看患者明细)→ 微调 + 确认。
      · 助手窗只有 400px,**不用 table**(横向撑爆),用卡片列表
      ·  T15:溢出转铺平**显式可见**(每人一个「铺平 N」角标)——
        主管要看见发生了什么,而不是只看到结果
      · 已满被跳过的人**仍然列出来** —— 他不是被漏了,是已经满了
      · 微调**只有两项**(T13):时效、指定客服(后者回对话说)。多一项就违教条,评审按这条卡
      · 三句依据(selectionNote / capacityNote / rosterNote)原样展示,前端不改写
      
      🔴 **确认成功后必须往消息流注入一条文本块**:
      `toApiMessage` 只回传文本块(工具块不回传,模型自行重新决策)——
      不注入的话下一轮主管说「刚才那批改成 5 天」,模型手里没有"那批"的任何痕迹,
      会当成新需求重新提议一次。改动极小但极易漏,漏了会被当成模型能力问题。
      
      ## assistant-store 的消费纪律
      
      `pending` 用 seq 自增而不是"消费完置空":置空要消费方回写 store,那是双向数据流,
      StrictMode 双执行下会变成发一次跑两次、或一次都不跑。
      只在 widget 变体消费(/assistant 整页可能同时开着,两处都消费会发两遍)。
      
      ## 一个自己制造又自己修掉的矛盾
      
      上一刀我在 systemExtra 里加了「原生确认单尚未上线,别提不存在的按钮」——
      这一刀把卡片做出来之后,那条约束当场过时,助手正指着自己上面的按钮说"界面上没有"。
      已改成「确认单是一张真卡片,就在你这条消息里;不要复述明细,不要说界面上没有按钮」。
      ️ 教训:提示词里写"当前还没有 X"这类**会过期的事实**,做完 X 必须回来改。
      
      ## 端到端实测(本地 2,695 条召回池)
      
      点移交 → 助手开窗 → propose_assignment → 卡片渲染「确认分配 100 条」
      点确认 → 卡片切终态「✓ 已分配 · 批次 #f27ef359」
      落库:批次 f27ef359 / 100 条 / 12 位客服 / 到期 2026-08-05 / confirmed
            策略 90 dedicated + 5 spread_overflow + 5 spread_no_dedicated
            快照 dedicated 90 条都有专属快照;spread_no_dedicated 5 条没有(本就没专属,正确)
                 100 条全有 priority_score 快照
      注入文本块「已确认分配:批次 #f27ef359 · 100 条 · 12 位客服 · 3 天有效」
      跟踪:列表 createdBy 832 → **康慧捧**(姓名已解析);
            详情 agentStats 之和 = 100 = planned,untouched 99 
      
      877 单测通过,测试数据已清理。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(web): P4 前端 —— T16 改造 + 助手外部控制口 + 移交入口与动效 · 2a58939b
      ## T16:客服只看得到「我的」
      
      ️ **藏 tab 不等于做到 T16** —— view 还有两条会自己滑到 pool 的路,不堵住的话
      tab 看不见但列表里躺着的还是整池,而且**不报任何错**:
      
      1. 🔴 rail 的自动回落 effect:客服的「我的」为空时,原逻辑 `setView('pool')`
         把他**直接扔进召回池**。这是 T16 最容易漏的一处。
      2. /plans 落地页的兜底:mine 空 → 自动跳召回池第一个患者的详情页,
         而那个人根本不是分给他的。
      
      两条都按 canDispatch 门控。 判据用 PLAN_DISPATCH,不用 PLAN_VIEW_ALL / PLAN_ASSIGN
      —— 后者连 staff 都有(自助认领语义),两个都区分不出主管。
      
      认领入口整段删除(后端 POST :id/assign 保留 —— T12/T16:将来放开客服主动性
      是把入口加回来的事,不用改模型)。行内动作按钮从「认领/返池」二选一降为只剩「退回」,
      ️ grid 叠层机制保留 —— 它防的是 hover 时整行位移,与按钮有几个无关。
      
      顺带清了三处指向不存在按钮的文案(这类文案比不给提示更糟,主管会去找):
      · 状态药丸「待认领」→「待分配」(语义也变了:active 现在是"在池里等主管派")
      · 详情页顶栏「未认领 · 仅查看」→「未分配 · 仅查看」
      · 认领闸提示「在左侧列表点该患者的『认领』接手」→「请联系主管分配」
      
      ## 客服姓名:详情页不再显示 uuid
      
      `FollowupPlan` 加 `assigneeName`,服务端用回访表解析后下发(取最近一次)。
      前端拿不到就退回 `#` + id 前 8 位, 不显示完整 uuid(400px 卡片会撑爆)。
      
      ## assistant-store:此前「移交助手」根本无处落地
      
      不是难写,是**没有接口** —— `AssistantWidget` 的 `open` 是组件内 useState,
      外面打不开;`useAssistantChat()` 在 `AssistantChat` 内实例化,`send` 不外露。
      新 store 30 行:`open` / `ask(text)` / `consume(seq)`。
      ️ 用 seq 自增而不是"消费完置空"(与 plan-sync-store 同款):置空要消费方回写 store,
      那是双向数据流,StrictMode 双执行下会变成发一次跑两次、或一次都不跑。
      ️ widget 收起仍是 **CSS 隐藏不卸载**,别顺手改条件渲染 —— 改了每收一次就丢一次对话。
      ️ 只在 widget 变体消费 pending:/assistant 整页可能同时开着,两处都消费会发两遍。
      
      ## 移交入口 + absorb 动效
      
      业务侧**只有两行**:发一个语义事件 + 说一句话。
      ```
      emitPetEvent({ type: 'cohort_handoff', payload: { count, treatment } })
      assistantStore.ask('帮我给…这批患者出一份分配方案')
      ```
      「怎么演」归 pet-brain(三层单向架构的大脑层),换演法不动分配功能一行代码。
      受限动作词表加 `absorb`(数字流被吸进嘴里),keyframes 加在 `PET_CSS` 模板串里
      —— ️ 宠物的 29 个动画都注入在 pet-body 的 `<style>` 里,**不在 globals.css**。
      /pet-lab 已加进预览列表。
      
       移交时**不传 planId 列表** —— 传了等于把收敛规则搬到前端,而那是会漂的。
      后端 propose_assignment 自己按排序键圈。
      
      ## 浏览器实测暴露的两个真问题(已修)
      
      1. **助手编了一个不存在的按钮**:它说「请在卡片上点击『确认并下发此批次』」——
         原生确认单(P4.4)还没做。这是 T14 的反面:没有证据的东西不许写进结论,
         **界面元素也算证据**。systemExtra 加第 7 条硬约束。
      2. **默认分满 = 850 人**:target 不传时取"团队剩余容量之和"(17 人 × 50),
         而 T5 说的是「宁可 100 人做透」。️ 「团队还能吃多少」≠「这批该分多少」。
          批次规模上限属于产品决策(教条七·待确认),工程不替他定数 ——
         改为在 selectionNote 里把这个数的来历直说:「850 = 在岗 17 位客服的剩余容量之和
         (**分满**),不是建议规模」,并提示可以直接说个数字缩小。
      
      ## 浏览器端到端(本地真实数据 2,695 条召回池)
      
      主管:两个 tab + 移交按钮 + 「待分配」药丸 
      客服:**只有「我的」;为空时停在空态,没有跳进召回池** 
            空态文案「暂无分配给你的任务 / 新任务由主管统一派发」,不提召回池 
      点移交 → 助手自动开窗 → 自动发问 → 调 propose_assignment → 渲染按客服分配表 
      助手原话:「**确认单已呈现,请过目。**…容量上限 50 条/人为**默认值**,
                暂无历史数据支撑,积累后将按实际完成率反推替换。」
                —— 没说"已经分配好了",且照抄了 capacityNote 
      
      877 单测通过。️ **P4.4 原生确认单尚未实现**,是 S1 剩下的最后一块。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(plan): 客服姓名改从回访表取,不再读 mock 花名册 · 83f62942
      产品指出:姓名本来就在 `patient_return_visits.task_director_name` 里。
      原实现去读了 `data/jvs-dw/users.json` —— 那份是 mock 登录用的**派生物、只在 dev 存在**,
      拿它当生产功能的姓名源等于让线上依赖一个开发态文件。而它本身就是从这张回访表派生的,
      兜不住任何回访表查不到的人。
      
      ## 但不能直接取:id→姓名是**时间性的**
      
      本地实测 `task_director_id` 有 101 个、`task_director_name` 100% 有值,
      可**同一个 id 会对到多个姓名**:
      
      ```
      4090 → 李宇琦 601 条  2021-06 ~ 2024-12   ← 现任
             赵惠    32 条  2021-01 ~ 2021-07
             杨柳     4 条  / 刘博学 1 条
      ```
      
      DW 侧这个 id 被换过人。取错会让界面写着"赵惠"、实际派给了"李宇琦" ——
      比不显示姓名更糟。按**最近一次**取之后干净了:101 个 id → 97 个名(剩下是真重名)。
      
      ⇒ 用 `DISTINCT ON` 而不是 groupBy:Prisma 的 groupBy 表达不了
      「取 max(时间) 那一行的另一列」。顺带把原来的两次查询(名册 + 姓名)合成一次。
      
      ## 名册外被点名的人也要有名字
      
      主管点名的客服可能只是**本诊所近 12 月**没回访(在别的诊所、或更早),
      回访表里查得到。不兜这层,他点的人在确认单上就是一片空白。
      加一次跨诊所、不限时间窗的兜底查询(只在确有人未解析时才发)。
      
      实测:`4090` → 李宇琦(inRoster=false 如实标着);真不存在的 `99999` 仍是 null,不编。
      
      ## 批次跟踪的两处姓名也接上同一个源
      
      `AssignmentBrief.createdByName` 与 `agentStats[].name` 原来硬编码 null(留了 P4.3 的 TODO),
      现在同源解析。️ 这里是**读时解析不是快照**:批次跟踪看的是"这个 id 现在是谁",
      换人极罕见且不会发生在一个批次的几天窗口内,不为它再立一列。
      
      ## 顺带修一条把 URL 写错的注释
      
      端点实际是 `GET /pac/v1/plans/assignments/agents`(控制器前缀 `plans/assignments`),
      注释里写成了 `/plans/agents` —— 那个路径归 PlanController,会被它的裸 `@Get(':id')`
      当成 planId='agents',报出来是 Prisma 的 uuid 解析错,看不出是路由问题。实测踩过。
      
      877 单测通过。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(plan): 召回分配 P3 助手侧 —— 身份/名册/工作流约束/全景确认单 · ad8475f7
      S1 后端最后一块。助手从"只会查患者"变成"能替主管出确认单",
      但 T8 的边界一步没让:**全程只读,唯一的写动作仍是主管点确认**。
      
      ## 工具门控:主管 12 个 / 客服 8 个(实测)
      
      四个主管工具**条件注册**,客服连清单里都看不到。
      ️ 为什么是"不注册"而不是"注册了再拒":模型看得见就会去试,试了被拒它会自己
      编解释("可能是权限问题,要不换个账号"),对客服是纯噪声。不注册则行为自然收敛。
      这条依赖 P0.4 的能力分桶缓存 —— 不分桶的话进程重启后第一个人的清单会被全员复用。
      
      顺带堵了一个绕过 T16 的口子:`plan.service.list` **只对 view='all' 校验 PLAN_VIEW_ALL**,
      `view='pool'` 一路无校验 —— 客服在前端看不到召回池,却能直接问助手「池子里还有谁」
      把整池捞出来。list_recall_queue / recall_queue_stats 现按能力**静默降级**成 'mine'
      (不报错:客服问"今天该联系谁"是正当需求,甩权限错误会让他以为系统坏了)。
      
      ## get_current_user —— 返回能力,不返回 role
      
      放在工具清单**最前**:顺序影响模型的默认注意力,而"在跟主管还是客服说话"
      决定了后面走哪条工作流。
      返回 `capabilities: { canDispatch, canViewAllPlans, canExecute }`, 不带 role ——
      模型看得见 role 就会自己发明"leader 应该也能 X",而那不是权限模型说了算的。
      McpAuthContext 加了 `userName`(显示串,不违 T19 —— 助手要能称呼人而不是对着 uuid 说话)。
      ️ canDispatch=false 时提示"登录态可能过期",**不说"你没有权限"**:
      权限按 role 现算但 token 里的 role 是签发时固化的,高频原因是 token 旧了。
      
      ## 名册 + 负载:一个端点,不开两个
      
      分配问「还能吃多少」、跟踪问「压了多少」是同一份数据的两种读法,
      拆开必然口径漂移(一个算 assigned、一个算 assigned+active)而且漂了不报错。
      
      · 在岗按 `source_created_at` 近 12 月。 **绝不用 task_date** —— 含未来排程
        (实测最远 2033、DW 侧 2121),拿它卡窗口会把离职的人判成在岗。
        迁移 C 补了对应索引:**单语句 + CONCURRENTLY + 独立目录**(回访表生产 166.7 万行
        且正被增量同步写入,普通 CREATE INDEX 会锁死同步;多语句会 25001 堵死流水线)。
      ·  **不返回 remaining**:容量 20-50 是**默认值**,把它减出来会让助手说
        「李莉还能吃 38 个」—— 那是拿默认值做完减法再当事实说出口,免责声明救不回来。
        返回 capacityRange + inHand + capacityBasis:'default',减法留给主管。
      · inHand **跨诊所全量**统计(容量是人的属性),但展示拆「本诊所 / 其他」——
        ️ 与 F4「写路径补 clinicIds 边界」方向相反,别顺手在这里也加诊所过滤,
        否则那 24% 跨诊所客服会显得手上很空,被每个诊所各灌一轮。
      · 名册是**建议不是白名单**:实测 11 人只在登录侧有行为、回访数 0(只做召回不做回访),
        主管点名的人即使查不到也照样返回,只标注。
      
      ## systemExtra —— 「按角色切工作流」此前是零实现
      
      实测 `/assistant/chat` **从来没传过 systemExtra**,全仓只有企微在传。
      新 assistant-prompts.ts 出两套约束(主管 / 客服),六条硬约束里最要紧的一条:
      「 绝不要说『已经分配好了』,正确说法是『确认单已呈现,请过目』」——
      说错会让主管以为事情办完了,而实际一条都没落。
      
      方法论写进注释:**凡是靠模型"算对"的约束一律降级成"照抄"**。
      T14/T20 全是除法和阈值判断,恰恰是 LLM 最不可靠的地方 ——
      所以工具返回值里直接带成品句子(rosterNote / capacityNote / selectionNote),
      提示词只负责让它原话转述。少任何一半都会漏。
      
      ## propose_assignment —— 圈人与落人
      
      **① 收敛排序键(三层)**
      ```
      (assignment_id IS NULL) DESC   从没进过任何批次的优先
      priority_score          DESC
      patient_id              ASC
      ```
      首键解决"退回的高分单反复插队":它回池后分数几乎不变(freshness 降但 daysSince
      涨反而推高急迫性),会立刻回到队首,300 名开外永远轮不到。生产实证 57 单里 79% 已过时限
      ——"打了没结果然后挂着"是常态,这批一旦回池就是稳定插队源。而 assignment_id
      退回时不清空,天然就是这个标记,**零新列**。
      第三层不是装饰:filling 格 1,189 人只有 306 个不同取值,第 100 名撞并列是必然事件。
      ️ **刻意不加「专属可用性」分层**(违直觉,已产品确认):85.1% 有在岗专属,拿它当首键
      则批次近乎 100% dedicated,T20 要反推的对比**永远凑不满样本** ——
      用未验证的假设排序,而该排序保证假设无法被验证。
      
      **② 落人:专属优先 → 溢出均分 + 在手硬护栏**
      ️ 推翻了先前主张的「水位拉平」,三条理由:输入 remaining 是拍的默认值;
      在手量当前不可信(自动回收长期关、回写率 11%、79% 过期);且它把消灭负载方差
      当目标函数,而 T20 第一项要反推的正是"各人实际能吃多少"、需要方差作自变量。
      另外它全局耦合 —— 改一条指定客服所有人数字都跳,违 T13。
       容量不够**截断不摊派**:硬塞会立刻造出 over_capacity 退回,而那正是要用来
      反推容量默认值的信号,自己造出来就没法反推了。
      
      探索配额(产品批准)按**等距抽样**从排名之外取, 不用随机数 ——
      主管微调后会重算,随机会让名单无故跳动,他就不敢按确认键。
      
      ️ 落人算法拆成**导出的纯函数** placeAgents:它是本文件唯一值得单测的算法,
      纯函数不必起 Nest 容器、不必 mock Prisma。第一版把专属客服表挂成实例字段,
      那是并发 bug(单例 service,两个主管同时出单会互相冲掉),改成参数传递 ——
      本仓已有 ingest-resolver-no-instance-state.spec 在防同一类错。
      
      ## 验证(本地真实数据)
      
      877 单测(新增 11 条落人算法断言)+ 端到端:
        工具清单        主管 12 个 / 客服 8 个 
        get_current_user 返回 capabilities,**无 role 字段** 
        get_agents      17 人在岗,**无 remaining 字段**,note 是成品句子 
        propose         潜在种植 100 人:候选 170 → 落 100 / 分不下去 0
                        策略 **67 专属 / 22 溢出 / 11 无专属**(三档分得开)
                        入选 90 rank / 10 explore
                        康慧捧吃满 50 触顶 → 其余 22 条溢出给别人标 spread_overflow 
      零 drift,无数据残留。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(plan): 到期自动回池 + 五列不可逆决策快照(按产品裁决) · 546bad00
      产品 2026-08-02 的六条裁决,本刀落三条(容量默认 50 / 低意愿门不做 / 不看
      plan_executions 是下游口径,随 P3、P5 落)。
      
      ## 到期自动回池(产品:也是给主管减负)
      
      新 AssignmentExpiryScheduler,**默认开**(PAC_ASSIGNMENT_EXPIRY=off 可关)——
      注意与 PAC_PLAN_AUTO_RECYCLE(默认关)相反,spec 里有断言防搞混。
      
      不做的后果不是"多几条过期单",是**容量口径整体失效**:客服在手只增不减,
      几批之后全员触顶、再分就分不下去,而这在主管看来就是分配功能坏了。
      
      四条纪律,全部有断言:
      ·  **照抄 recycle-scheduler 的 snoozedUntil 守卫** —— 客服约了 6/10 回访、
        plan 已 snooze 到那天,到期也不能收。收了就是系统主动毁掉客服对患者的承诺,
        而且单子还会被别人从池里捞走。已本地实测:一条过期但 snoozed 的单**没被动**。
      ·  **不写 release_reason** —— 那列只属于客服的处置。退回是"客服看了判断不该我做",
        到期是"客服压根没动",混进一列则退回率的分子分母一起虚高,而"到期未动"这个数
        本身才是主管要的信号(分多了?人不在?)。到期量单独从账本按 reason 数。
      ·  不清批次归因三列(清了这批的分母就少一条)。
      · 新 PlanEventReason.ASSIGNMENT_EXPIRED,不复用 timeout(那是认领超时,另一条路)。
      
      判定时刻可注入(`runExpiry(at?)`),照 sync-incremental 刚立的规矩 ——
      测试不靠真实时钟凑时间差。第一版没这么写,当场就复现了同一类 flake。
      
      ## 五列不可逆快照(迁移 B)
      
      对抗压测抓出来的:我原以为"拆四值枚举"解决了不可逆问题,**那是错的**。
      真正补不回来的是快照 —— 有了它四个值全可推导,反过来不成立。
      
        dedicated_cs_at_assign        preferences.dedicatedCs 是 upsert 覆盖的当前值
        dedicated_cs_last_visit_at     记**证据不是结论**:"在岗"是滚动窗口判定,
                                      今天在岗的人半年后回查变离岗,存 boolean 就无法
                                      按分配当时的口径重算(而回访表还在被 reparse 重摄)
        priority_score_at_assign      引擎在 reason 未变时**就地改分、不升版本、不留痕**
        source_confidence_at_assign   它是 score 里的 2× 乘子,不留就分不清"按分选人"
                                      实际是不是在"按诊断来源选人"
        selection_mode                rank/explore。探索配额是整套系统里**唯一的因果抓手**
                                      (入选本身与结果相关,纯观察数据解不开),不标记等于白留
      
      ️ 五列**已同步加进引擎的无条件继承集合**。漏了比不加更危险:引擎重出版本
      由数据变化触发 → 与患者活跃度相关 → 与完成率相关,缺失是**系统性偏向**的,
      半年后拿到一列 70% 填充率的快照,看着还能用,算出来的结论是错的。
      
      ## 写路径改用 VALUES join,不再按桶 updateMany
      
      快照列逐行不同,updateMany 的 data 全桶共用表达不了;逐行 update 是 500 次往返。
      一条 `UPDATE ... FROM (VALUES ...)` 两个问题一起解决,且 RETURNING 直接给出
      哪些行真落上了,不必再回查一次猜差额。9 变量/行 × 500 = 4,500,远低于上限 32,767。
      ️ 走原生 SQL 的两个代价手工补上:`@updatedAt` 不触发 → 显式 SET;where 自己写全。
      
      ## 一致性硬闸(实测踩出来的)
      
      端到端时发现:一条患者的专属客服是 576,却被标 `dedicated` 分给了 832 ——
      标签与事实矛盾,而服务端**刚刚把当时的专属客服取到手**,这是可验证的。
      不拦的话 T20 按 assign_strategy 分组时,一批实际铺平的单顶着 dedicated 标签混进
      "专属完成率",**没有任何报错**。
      ️ 只拦这一个方向:spread_*/manual 依赖容量与主管意图,服务端不知道,仍以调用方为准
      (错了也能靠 dedicated_cs_at_assign 事后重算 —— 这正是那列的价值)。
      
      ## 验证
      
      866 单测(新增 9 条到期断言)+ 本地真实数据端到端:
        快照五列落库     selection_mode=rank/explore、pscore=94.8/91.8、conf=1.0、
                        dcs=832/576、dcs_last=2026-06-24 
        标签不符         → 10001 拒绝,不落库
        到期回收         过期无约定 → 回池(status=active、期限清空、**批次归因三列全在**、
                        release_reason 为空);账本 auto_release + assignment_expired
         snoozed 守卫  过期**但约了回访** → 未被收走,仍 assigned 
        未过期的两条     未动
      零 drift(diff 只剩两条与本次无关的既有项)。测试数据已清理。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(plan): 召回分配 P2 —— 批量写路径(唯一的写动作) · 300b3f50
      POST/GET /pac/v1/plans/assignments,@RequirePermission(PLAN_DISPATCH)。
      这是整条生产线上唯一改库的地方(T8),所以护栏比功能本身花的力气多。
      
      ## AssignStrategy 定四值,不是三值
      
      原设计 dedicated/spread/manual。但 spread 会同时装下两群**完全不同**的人:
        · 有专属客服、只是这次容量满了没轮到他 —— 医患关系还在
        · 从头就没有可用专属(无专属 / 专属已离岗)—— 本来就是关系薄弱那批
      实测本地 2,724 条候选:专属且在岗 85.1% / 专属但已离岗 7.5% / 无专属 7.3%,
      后两类合计约 15%。混成一个值,T20 要算的「专属 vs 铺平完成率差」就废了:
      铺平那组低,到底是策略不好还是那群人本来难打,永远分不出来。
      
      ️ 主管界面**不多一种状态**:ASSIGN_STRATEGY_META.groupZh 把两种 spread 合并成
      「铺平」一档,只有反推分析才拆开看 —— 内部口径的精细度不倒灌到主管的注意力上(T13)。
      
      ## 三道闸
      
      ① requirePermission —— 与 controller 上的装饰器**故意重复**:装饰器只在 guard 链生效,
         而 MCP 端点是 @Public() 直接短路。判据同时放进 service,换个入口进来护栏还在。
      ② rejectSyntheticIdentity —— 企微 mintToken 造的 `wx:` 合成身份直接拒。
         教条原文是「本期不管(demo 用途)」,那句话的前提是当时还没有写路径;
         有了写路径,"不管"必须落成"硬拒",否则就是把已知的身份伪造面留在最危险的位置。
      ③ scope 绑定 + **数量对不上整体拒绝**。对不上只有两种可能:确认单过期(单子被引擎
         supersede 了)或跨诊所越权,两种都不该落一半 —— 落一半的批次事后既解释不清也回不去,
         而主管看到"成功 87/100"完全无从判断哪 13 条为什么没落。
      
      两个 helper 单独成文件(dispatch-guard.ts),S4 挂 MCP 写工具时直接复用,不重写一遍。
      
      ## 并发与幂等
      
      按 (客服,策略,时效) 分桶,每桶一条**带条件** updateMany:
      `where: { id: {in}, status:'active', assigneeUserId: null, supersededAt: null }`
      —— where 带状态条件即并发安全,count 与 chunk 长度的差就是确认期间被抢走的。
      同款技巧见 recycle-scheduler。 不循环调 PlanService.assign(500 条 ≈1500 次往返,
      且它写不了归因列); 也不让单条 assign 委托批量(每次自助认领都会造一条垃圾批次)。
      共用判据收口到 claim-guard.assertAssignable —— 教条七「已认领能否强制改派」
      那条待确认决策正好落在这一个函数上,将来只改这一处。
      
      requestId 幂等:**前置回查**(重放零副作用)+ P2002 兜底回查。 冲突不抛错 ——
      抛了模型会以为失败、换个参数重试,那才是真正的重复分配。
      
      applied=0 → 抛错回滚,库里不留空批次(空批次会让报表出现一堆 0/0 且查不到原因)。
      
      ## 两个实测踩出来的坑
      
      1. 🔴 **路由被吃**:GET /plans/assignments 被 PlanController 的裸 `@Get(':id')` 匹配成
         planId='assignments',报的是 Prisma uuid 解析错(90000),完全看不出是路由撞了。
         → AssignmentController 必须在 module 里**排在 PlanController 之前**(Nest 按注册顺序匹配)。
         同类先例:plan.controller:80 的 `doctors` 路由也踩过。
      2. **agentStats 加起来比批次少**:退回后 assignee 被清空,那条从所有人名下消失,
         500 条退 1 条 → 各人 planned 之和 499,主管一眼看出对不上,而"某人退了几条"
         恰恰是他最想看的列。→ 从 plan_event_logs 的 assign 事件回捞原始承接人
         (createdAt >= 批次创建时刻)。这正是账本存在的意义:主表存当前值,历史归属只有账本留得住。
      
      ## 其他
      
      · expiresInDays 收**相对天数**,服务端按 host 时区转**当地日末** ——
        让模型算 ISO 时刻正是 commit 45498176 修过的坑(纯日期被当 UTC 零点偏 8 小时);
        按小时算则上午分的和下午分的在同一天不同时刻过期,客服形不成预期。
      · items 是 (planId, assigneeUserId) **对**不是分组:溢出转铺平后归属逐条算,
        且 T13 的逐条微调时效在分组形状里无处安放。
      · 500 硬护栏是**技术**上限(单事务 + bind 变量),不是批次规模的业务上限 ——
        业务上限该由产品定并加在助手圈人那层,写这里会让两件事永远分不开。
      · AssignmentDetail 的字段叫 agentStats 不叫 agents:Brief 里 `agents` 是人数(number),
        同名不同型会让前端拿 brief 类型读 detail 时静默拿到 undefined。
      
      ## 验证(本地真实数据 2,724 条活跃 plan)
      
      857 单测通过 + 端到端 11 项:
        staff 调分配端点          → 403 Missing permissions: plan:dispatch
        500 条一次调用            → **741ms**,assigned=500,4 个客服 × 125
        expiresAt                → 2026-08-05T15:59:59.999Z = 北京 08-05 当地日末 
        同 requestId 再调         → duplicate:true,库里仍 1 个批次
        混入他诊所 planId         → 整批拒绝,不留空批次
        确认期间被人抢先认领       → assigned=2,skipped=[claimed_by_other],其余照落
        全部落不上                → 拒绝,批次数不变
        归属账本                  → 504 条 assign 事件,承接人 5 / 发起人 1
        批次列表 / 单批详情        → planned=500 inHand=499 released=1 untouched=499
        agentStats 之和            → 500 == 批次 planned 
        GET /plans/:id            → 未被路由改动误伤
      测试数据已清理(批次=0 归因单=0 事件=0 在手=0)
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • test(sync): 僵尸锁回收判据改为注入时间界 —— 去掉对真实时钟的依赖 · fa58bdf6
      sync-lock-reap.spec.ts 在整套 jest 并行跑、机器同时有重负载时间歇失败:
      该套件平时约 5 秒,失败时耗到 68 秒。
      
      根因不是逻辑错,是判据两端取的时间**不同源**:
        · 界   = PROCESS_STARTED_AT,模块加载时刻 T0
        · 造行 = `new Date(Date.now() - 1000)`,用例执行时刻 T0+Δ 减 1 秒
      要判成僵尸需要 Δ < 1s;负载高时模块加载到用例执行的间隔超过 1 秒,
      行反而落到界**之后**、不再算僵尸 → findMany 空 → updateMany 没被调
      → `updateMany.mock.calls[0][0]` 直接抛。
      
      修法:把时间界作参数注入 reapStaleRunningLocks(默认仍是 PROCESS_STARTED_AT,
      **生产行为一字未变** —— onModuleInit 那个调用点不传参),测试用固定基准
      构造 before()/after(),全程不碰真实时钟。
      
      顺带补一条边界断言:startedAt >= 界的行(并存 CLI 的真锁)连 updateMany
      都不该发 —— 原来只锁了 where 用 lt 不用 gte,没锁住"真的不碰"这个行为。
      
       没有用调大 jest timeout 掩盖:那只是把失败推后。
      
      验证:并行跑 `next build` 制造负载,连跑 3 次整套 857 测试全绿。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(plan): 召回分配 P1 —— plan_assignments 建表 + 归因六列 + 引擎守恒 · dc31d486
      ️ **P1.1 与 P1.2 必须同一次发布,中间不许发版本。**
      只上 P1.1(有列没有守恒)会让每日重算把已分配单的归因静默抹掉,不报错无告警,
      等发现时历史已经花了;只回滚 P1.2 同理。
      
      ## P1.1 建表与六列
      
      新表 `plan_assignments` = **一次分配动作**(一个批次)。立柱保守,按
      plan_event_logs.details 那条既有口径:「只放查询不按它过滤的内容,要按它筛就该立柱」——
      初筛条件进 criteria JSON、福利进 attributes JSON、人数/客服数**不立柱**(从
      followup_plans COUNT,立柱等于埋一个会漂的冗余)。
      
       撤销功能本身推迟到第二刀,但 status/revoked_at/revoked_by **三列现在就建齐** ——
      否则那时要再取一次 followup_plans 的 ACCESS EXCLUSIVE。
      
      followup_plans 加六列,**全部可空、全部无默认** —— 这是迁移不重写 25 万行的前提
      (PG 11+ 纯元数据操作)。存量 NULL 即语义正确(= 上线前的自助认领单),**不回填**。
      
      其中 `assign_strategy`(dedicated/spread/manual)是四份子方案全都漏掉的第六列:
      T20 要按「专属 vs 铺平」算完成率差,而唯一的反推来源
      patients.preferences.dedicatedCs 是 upsert 覆盖的当前值 ——
      **分配当时不记就永久没了**。
      
      关系显式写 `onDelete: Restrict`:Prisma 对可选关系默认 SetNull,
      将来任何一次误删批次都会把 N 行归因静默置空且不报错。已实测 FK 拒绝删除。
      
      迁移单文件多语句普通 DDL,**刻意不用 CONCURRENTLY**(20260728020000 踩过:
      Prisma 整份 SQL 一次 simple query → 隐式事务块 → 25001 + P3018 堵死流水线)。
      本次不需要:唯一在存量表建的索引是 followup_plans(assignment_id),25 万行秒级。
      首句 SET LOCAL lock_timeout='10s' 应对 R3(等待中的 ACCESS EXCLUSIVE 会挡住其后所有
      SELECT,把「全站排队几分钟」换成「迁移快速失败」)。六个 ADD COLUMN 合并成**一条**
      ALTER —— 一次拿锁,不是六次抢锁。FK 走 NOT VALID + 单独 VALIDATE。
      
      ️ id 列**不给 DB 默认值**:本仓 uuid 一律 Prisma 客户端生成,写
      DEFAULT gen_random_uuid() 会留永久 drift(第一版写了,已回滚重来)。
      
      SCOPED_MODELS 补登 PlanEventLog + PlanAssignment(F5)。 不进 SOURCE_UNIT_MODELS:
      两表都没有 source_unit 列,加进去每次查询都会注入不存在的字段。
      
      ## P1.2 归因守恒(D-12)—— 本刀最容易写错且写错不报错的地方
      
      直觉写法是把归因列也挂到 carryAssignment 上,但那个标志只在
      `latest.status === 'assigned'` 时为真,而**退回后的 plan 是 `status='active'`**。
      于是每一条被退回过的单,只要引擎升一次版本,批次归因就连**分子带分母**一起归零:
      跟踪看到「分了 55 条」(实际 60),退回率的分母凭空缩水,而 T20 的全部结论
      都建立在这个分母上。归因是历史事实,跟"现在还挂不挂在人名下"是两件事。
      
      → assignment_id / assigned_by / assign_strategy / release_reason / release_note
        **无条件继承**,与 carryAssignment 解耦。
      
      ️ 唯独 assignment_expires_at **跟随归属**(对规划"六列全无条件继承"的一处收窄):
      它不是归因,是"当前这次分配的截止时刻"。归属都没了还留着期限,列就不自洽
      (非空 ⟺ 有一次在办的分配),超期口径得靠每个调用方都记得再 AND 一个
      status='assigned' 才不出错 —— 那种隐式约定迟早破。
      
      三处丢归属(引擎就地改 / recycle / 自动回收 cron)统一口径:
      只清归属四件套 + 期限,**批次归因三列保留**。
      
      assign() 补写 assigned_by(自认领时 == assignee,主表上就能分出"自己领的"和
      "主管派的"),并清掉上一次的 release_reason(否则主表会同时显示
      「已分配给 A」和「因手上排满被退回」)。 不碰 assignment_id / assign_strategy ——
      单条认领不属于任何批次,瞎写会造出孤儿归因。
      
      ## 已接受的口径漂移(产品 2026-08 确认)
      
      一条 plan 在批次 N 被分 → 退回 → 进批次 N+1,assignment_id 就地翻成 N+1,
      批次 N 的 COUNT 悄悄少 1。不为此另立 plan_assignment_members 关联表,
      与「主表存当前值、全量历史留 plan_event_logs」的既有模式一致。
       也不走 plan_event_logs.details 塞 assignmentId 做离线校正:那种用法按本仓
      自己的规矩就该立柱,而立柱正是通用日志表要避免的业务化。已写进 schema 注释。
      
      ## 验证
      
      · 857 单测通过(新增 5 条守恒断言,含**退回后 status='active'** 那一支 ——
        四份子方案全都漏了它,只测 assigned 会漏掉真正的看门场景)
      · migrate deploy 成功,**零 drift**(diff 只剩两条与本次无关的既有项)
      · 六列实测 nullable / 无 default → 元数据操作
      · 本地真实数据端到端:造批次 + 两条归因单(一 assigned、一已退回),
        各触发一次升版本 ——
          assigned 支:批次/assigned_by/strategy/assignee/expiry 全守恒
          退回  支:批次/assigned_by/strategy/release_reason **全守恒**,expiry 为空
        批次 COUNT live=2 in_hand=1 released=1;FK RESTRICT 实测拒绝删除批次
      · 测试数据已清理(remaining batches=0)
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(plan): 召回分配 P0 地基 —— 权限/退回原因/引擎记账/写路径边界 · 3cbb3899
      分配功能开工前的前置修复。这一刀**不含任何新功能**,但把四个
      「不修就会静默出错」的洞补上 —— 分配一上线它们会同时被放大。
      
      ## P0.1 新增 PLAN_DISPATCH 权限(主管判据)
      
      PLAN_ASSIGN 连 staff 都有(自助认领语义),STATS_VIEW 是零端点零组件的
      死权限 —— 现有权限没一条能区分主管,新立一条。只授 leader + admin。
      
      ️ 顺带修了 R7,且**原方案不够**:规划只说前端 auth-store 补 merge
      permissions,但 GET /auth/session 是把 JWT 里那份快照原样回传的,
      merge 了也还是旧清单。真正的口径是 —— **ROLE_PERMISSIONS 是真理源,
      JWT 只是缓存**,三处统一改成按 role 现算:
        · PermissionsGuard   (后端判定)
        · GET /auth/session  (前端拿新权限)
        · McpAuthService     (工具条件注册,少一个工具模型只会说"我没这能力")
      不改的话,发版当天所有已登录的 leader 在 token 到期前都是 staff 待遇,
      不报错不告警。已用伪造的「发版前 token」实测:JWT 里没有 plan:dispatch,
      session 仍返回 true。
      
      ## P0.2 ReleaseReason(8 值)+ PlanEventReason
      
      退回原因结构化,每个值绑一根**主管可调的杠杆**(lever)——
      否则原因分布只是一张好看的饼图。
      
       类型里**不给 suppressDays 字段**:照抄放弃原因那套抑制窗会把被退回的
      患者静默压 30~90 天,池子里凭空少一批人。用类型系统拦住,再加运行时断言。
       不复用 RECALL_FEEDBACK_OPTIONS 的 bad_timing:同名不同义是统计事故的
      标准配方(那个指"召回时机不对",这里会被读成"时效太紧")。
      
      PlanEventReason 给 plan_event_logs.reason 列做登记 —— 该列即将同时承载
      系统原因与 8 个退回原因,不登记就是第二个「随手写字符串」的地方。
      
      ## P0.5 引擎丢归属补记账 —— 实测是 4 处,规划里漏了最大的那处
      
      规划列的是单刷路径 closeStaleActivePlan,但**每日全量跑的批量收尾**
      (runAllForHost 的 updateMany)才是量级最大的:它同样会关掉 assigned 的单,
      同样零记账。补完四处:
        · unchanged 分支的诊所重归属        → reason=clinic_moved
        · 升版本 clinicMoved 不继承归属     → reason=clinic_moved(planId 记**旧版本**)
        · closeStaleActivePlan(单刷)      → reason=signals_cleared
        · runAllForHost 批量收尾(全量)    → reason=signals_cleared
      
      ️ 判定必须在事务**之前**定好:supersede 那句 update 之后 latest.status
      已经是 superseded,进了事务再读条件当场失效(内存 mock 暴露了这个别名陷阱,
      真 Prisma 返回脱离副本看不出来)。
      ️ 批量路径顺带修了一个既有隐患:原来是一条 `id: { in: staleIds }`,
      PG bind 变量上限 32767,池子上三万条就直接报错。改成 1000 一片、每片自成事务。
      ️ 无人认领的关闭**一条事件都不写**,否则每日全量会造事件洪峰;
      真有洪峰时打一行 warn 说明"这不是 bug"。
      
      ## P0.6 recycle 收退回原因 —— T7 此前根本落不了地
      
      controller 写的是 `@Body() _dto`,下划线,收了就扔。客服填了等于没填。
      链路三处打通(schema → controller → service),原因落 plan_event_logs.reason、
      说明落 details。other 不带说明**服务端**拒绝(不能只信前端)。
       全程不动 snoozedUntil,并在 update 处写死注释 + 单测钉住。
      
      ## P0.7 assign/recycle 补诊所硬边界(F4)
      
      findFirst 只校验 host/tenant/sourceUnit,没有 targetClinicId ——
      A 诊所 leader 拿到 B 诊所的 planId 就能跨诊所写入。他**看不到**那些单
      (读路径的 buildListWhere 明确挡着),但写入不受挡:隔离在写路径上比读路径
      弱一档。批量分配会把它从「知道 planId 才能利用」放大成「有 UI、一次几百条」。
      
      ## P0.3 / P0.4 / F3
      
      · 删 5 个未挂载的死文件(1,524 行),顺手改 3 处指向它们的过期注释
      · MCP toolsCache 从单例改成按**能力指纹**分桶 —— 这是工具条件注册(P3.5)的
        安全前置:不分桶会串号,进程重启后第一个进来的若是客服,全公司主管都拿
        客服清单,反之更糟,且两种错法都不报错。企微那条合成身份路径显式走 basic 桶。
      · schema.prisma 的 recycle_at 注释指向不存在的 tenant.rules_config,删掉
      
      ## 验证
      
      · 846 单测通过(新增 23 条断言:权限红线 / 抑制窗红线 / 四处记账 / 边界)
      · 本地真实数据(5,825 患者 / 2,724 plan)端到端实测:
          跨诊所 assign → 10004 not found;同诊所 → ok
          other 缺说明 → 10001 拒;非法枚举 → 10002 拒
          退回落库 event=release reason=over_capacity held=12s note 在 details
          plan 回到 active,snoozed_until 未被动
      · 文档两处实测更正:迁移是 37 个不是 38;data/jvs-dw/users.json 存在且已入库
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(design): 三处裁决 + 阶段性开放规划 + 开发起点 · f1b0b4e8
      ## 三处裁决(产品 2026-08-02)
      
      1) MCP 写工具:**最终必须实现,但分期** —— 原规划 D-4 建议「v1 不做」,
         改为「v1 不注册、但设计不得堵死」。MCP 现为 @Public(PermissionsGuard 短路),
         护栏会退化成 handler 自查 + 模型自填布尔,故 v1 写路径走 REST;
         requirePermission / rejectSyntheticIdentity 两个 helper S1 就写好,S4 直接挂。
      
      2) 矩阵留第一刀 —— 规划建议「分两刀、矩阵推后」的理由**已证伪**:
         原称「温度轴需 persona 全量重算,挂钟不受人日控制」,生产实测两个轴数据全现成
         (X 轴 590,427 条特征 / Y 轴 566,365 条 signals),矩阵直接跑通 25 秒零重算。
         25 秒对交互不可接受 → 转为明确的性能任务(索引/物化),不是排期依赖。
      
      3) 撤销判据以 view 事件为主 —— 规划建议叠加 contactAttempts,实测该字段
         **与 plan_executions 同源、同为 7 条**,帮不上忙;可用信号只有 view(已 1,024 条)。
      
      ## 新增 T21:撤销 ≠ 退回
      
      撤销=主管收回整批,退回=客服退单条(必须带原因)。两者被混谈过,现分开定义。
      撤销的「已动过」判据不能用 plan_executions —— 那是执行阶段产物而回写率仅 11%,
      客服打了电话没填表就会被当成「没动过」收走,那通电话永久蒸发。
      今天刚上的 PV/UV 埋点成了唯一可用信号,属意外收益。
      
      ## 新增两节(上下文压缩后的接续入口)
      
      - 七之二 阶段性开放规划:S1 闭环 → S2 完备 → S3 自优化 → S4 MCP 写 → S5 客服主动性,
        每阶段标「开放给谁 + 技术前提」;三条跨阶段硬约束(assign_strategy 必须 S1 立柱、
        写路径不得堵死 S4、S3 不能在 n<50 时提前)。
      - 七之三 开发起点:本地环境已就绪的清单、Gate 0 未闭合项、
        以及「第一件事不是写新功能而是修 F1-F5 前置洞」。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(design): 召回分配设计教条 + 开发规划 —— 需求未定稿前不开工的沉淀 · bb019d32
      新开 docs/design/(与 docs/adr/ 平级):ADR 记「一个已定的架构决策」,
      design 记「一个还在长的产品教条集」。设计冻结后可整体提升成 ADR。
      
      ## plan-assignment-doctrine.md — 设计教条(21 条 T1-T20)
      
      讨论过程中逐条经产品确认才写入的教条,以及**为什么这么定**的依据。
      关键几条:
      - T1  分配是批次运营,不是工单派发 —— 从不追求把池子分完
      - T6a 初选 X 轴用画像的「潜在治疗」8 类,不用 focusCategory
            (后者会把 81 个早矫机会埋进 152 个正畸里,而两者话术/沟通对象完全不同)
      - T6  温度 = 该治疗项目自身的临床周期(黄金/周期内/超周期),各用自己尺度归一化后横向可比
      - T13 全景确认单直出不追问,意图由助手推导;只微调「指定客服 / 时效」两项
      - T14 助手不出没有证据的结果 —— 缺数据就标默认值,有数据后反推替换
      - T17 保守立柱:会用来筛的才立柱,其余进 JSON(沿用 plan_event_logs.details 已立的口径)
      - T18 通用表不得业务化(曾提议给 plan_event_logs 加 assignment_id,已撤回)
      - T19 权限由 permission 控制,role 只给人看
      - T20 跟踪的目的是自优化,不是找对照组(认领将隐藏 → 没有对照组)
      
      第四节「关键数据事实」把生产实测数字钉住,避免后人重新推导 ——
      含为什么温度选临床窗口而非 RFM/生命周期(三者分布对比)、专属客服 83.5% 覆盖
      但在岗仅 30-39 人、task_date 有 2033/2121 年脏数据等。
      
      ## plan-assignment-dev-plan.md — 开发规划
      
      四层并行方案 → 双路对抗批判 → 汇总(7 agent)。含 15 条跨层契约裁决、
      Gate 0、前置修复、六阶段计划(MVS ≈ 19.25 人日)、风险登记、验证与回滚策略。
      
      批判环节抓出四份分层方案**都漏掉**的五条,已同步回教条 §4.36:
      - assign_strategy 必须立柱 —— dedicatedCs 是 upsert 覆盖的当前值,
        分配当时不记就永久没了,事后反推不出来
      - 撤销判据不能只看 plan_executions —— 回写率仅 11%,会把已打过电话的单静默收走
      - 归因列须无条件继承,与 carryAssignment 解耦 —— 退回后 plan 是 active,
        走不到那个分支,归因会连分子带分母静默归零
      - 「引擎丢归属不记账」实测是 5 处不是 1 处
      - assign/recycle 不校验 scope.clinicIds;plan_event_logs 不在 SCOPED_MODELS
      
      G0.1(go/no-go)已实测闭合:JWT.sub 与 task_director_id 重合 47/58 = 81%,
      同一 id 空间成立。但 11 个只在登录侧的回访数全为 0 → 新入职/只做召回不做回访的
      客服名册里查不到,故名册之外仍须允许主管显式指定。
      
      ## 两处规划与教条冲突,已列入待产品裁决
      
      - MCP 是否注册写工具(教条定 A 方案走 MCP;规划因 @Public 短路建议写路径只走 REST)
      - 矩阵是否放第一刀(取舍表定 v1;规划建议分两刀,因温度轴需 persona 全量重算)
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(monitor): push 断流告警加显式开关 —— FRIDAY 联调期关掉,别把真告警淹了 · 5aef6252
      现象:FRIDAY 每小时报一次 [PAC CRITICAL] push 断流(28.9h / 29.9h 无推送),
      但它根本还没正式上线推送。
      
      根因在触发条件 —— 监控只跳过"从未推过"的宿主(!lastPush → continue),
      而 FRIDAY 联调期推过几次就停了,于是被当成"已上线、现在挂了"。
      "推几次就停"恰恰是联调的正常节奏,不是数据在漏。
      
      加 monitoring.push_lag_alert(默认 true):
        - FRIDAY manifest 置 false,并写明**正式上线推送后删掉该段即恢复**
        - 不配 → true,既有宿主行为不变,不会静默失去监控
        - 命中时打一行 log( 已按 manifest 关闭),不是无声跳过
      
      【为什么不用"把阈值调大"】那样语义是"容忍 10 万小时不推",读的人分不出是故意关掉
      还是填错了;显式布尔把意图留在 yaml 里,上线时删一行即可。
      
      测试 818 项(+3)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(sync): 宿主纯日期按当地零点解释 —— 直通会被 JS 当成 UTC 零点,整整偏 8 小时 · 45498176
      normalizeDatetime 旧版对纯日期('2026-05-10')直通不补 offset,下游 new Date() 按 ISO
      规范解析成 **UTC 零点**;而宿主给的是**当地日期**(jvs-dw 的 DW 是 Asia/Shanghai):
          DW created_date = 2022-09-30(北京)
            旧:2022-09-30T00:00:00Z = 北京 09-30 08:00  
            新:2022-09-29T16:00:00Z = 北京 09-30 00:00  
      
      【怎么发现的】回访补 source_created_at 时,手写回填(按 +08:00)与摄入路径写出的值
      差整 8 小时。查下去发现不是新字段的问题,是这条既有路径 —— 测试服实测
      diagnosis_record 有 106,860 条 occurred_at 落在 UTC 零点(占 6%),正是它;
      落在 UTC 16:00(正确形态)的只有 5 条。
      
      【边界:为什么不怕补 offset 导致跨日】本函数只作用于 CanonicalResourceMeta.datetimeFields
      声明的字段,那些目标列全是 timestamptz。真正的纯日期列(patient_return_visit.taskDate /
      patient.birthDate)刻意不在该清单里,走各自 new Date() 直解 —— 对它们补 offset 会让 PG
      取 UTC 日期时退一天。新增字段按"目标列类型"归类,不能凭字段名像不像时间。
      
      【存量】老数据不会自动修正(需 reparse,会触发一次性 fact 版本波)。偏差是 8 小时、
      日期级判断基本不受影响,故不随本次修 —— 但新摄入的数据从此正确。
      ️ 若不修,回访刚回填好的正确值会被下一轮增量覆盖成错的。
      
      测试 815 项(+7),含"旧行为偏 8 小时"的固化回归。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  3. 01 Aug, 2026 6 commits
    • feat(sync): 回访再补宿主侧创建/更新时间 —— 名册时间窗不能用 task_date · 2516596f
      DW fact_returnvisit_out 有 created_date / updated_date(均非空),之前没摄。
      
      【为什么不复用本表已有的 created_at / updated_at】那两个是 **PAC 入库时间**
      (@default(now()) / @updatedAt),存量回填和每次重摄都会把它们刷成"现在",
      反映不了业务时间。命名沿用 patient_facts.source_updated_at 的既有口径(source* = 宿主侧)。
      
      【为什么名册需要它】task_date 含**未来排程** —— 生产实测最远到 2033-11-12。
      名册按"近 N 月在岗"筛人时若拿 task_date 卡窗口,会把"排了远期任务但早已不干活"的人
      算成在岗。用 created_date(任务何时被创建)才是真实的行为时间。
      
      datetimeFields 注册这两个字段(走无时区补 offset 归一);taskDate 刻意不进 ——
      它是 @db.Date 纯日期,补 offset 会跨日。
      
      顺带修正上一条 commit 的措辞:DW 给 task_director 的 comment 写的是"专属客服",
      但实测近 12 月 367 万对回访,它与患者主档 current_task_director 仅 **23.1%** 相同 ——
      本字段是"该任务当时派给谁",患者主档那个是"此刻挂在谁名下",确实是两回事。
      
      测试 808 项(+2)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(sync): 回访摄入带上执行客服 —— 按诊所反推客服名册供主管指派 · e3d4b3ef
      主管指派工单要选人,而 host **不提供「客服归属诊所」字段**,只能按行为反推
      「该诊所近 N 月有过回访记录的客服」。DW fact_returnvisit_out 一直带 task_director_id/name,
      PAC 侧此前没映射 —— 花名册只能靠离线快照 data/jvs-dw/users.json(已陈旧、无刷新机制,
      之前排查生产操作人时 10 个 id 有 8 个查不到姓名就是这个原因)。
      
      【与「专属客服」是两回事,刻意不合并】
        patients.preferences.dedicatedCs  ← fact_client_out.current_task_director(患者挂谁名下)
        patient_return_visits.task_director_* ← 本次回访是谁做的
      两者可以是不同人;口径差 2.7 倍(回访表 5,110 个 distinct 客服 vs 患者表 1,865 个)——
      回访是操作留痕,覆盖更全,97.2% 的专属客服在此出现过。合并成一列会让名册少掉大半人。
      
      改动(三层,通用代码零改动):
        - manifest query 补 SELECT 两列(漏了则映射静默失效 → 落 null)
        - assembler 映射 taskDirectorId / taskDirectorName
        - canonical 加两字段(id 用 z.coerce.string:host 是 Int64,PAC 的 external 标识一律字符串)
        - schema + migration:可空两列(166.7 万存量加列不重写表)+ 名册复合索引
          (host, tenant, clinic, task_director, task_date desc) —— 单列不够,clinic 基数仅 64
        - upsert 落库经 emptyToNull:空串 / "0" 归 null,否则名册会多出假的"无名客服"分组
      
      【存量怎么补(本次不含)】reparse **无效** —— 回访是 upsert 资源、不进 transaction,
      没有 rawPayload 可重放;整表重摄又会连带重摄这批患者的病历/结算/预约(2026-08-01 实测
      那条路把测试服磁盘写满)。存量走一次性 DW 回填:按 external_id 批量 UPDATE 两列。
      
      测试 806 项(+7),锁住"两处客服字段不互相挪用"与源 query 必须选这两列。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(sync): 定向名单支持命名空间维 —— 患者主键只在命名空间内唯一 · 82d782dd
      测试服实测:7 万个纯 id 的定向名单列出 **140,566** 个 cohort key,整整翻倍 ——
      cohort key 是 (patient_key, tenant_key) 复合键,而 PAC_COHORT_ONLY_PATIENT 只承载
      patient_key,于是每个 id 在两个命名空间下各命中一次,一半是无关的同号患者:
      白摄一倍数据、批次翻倍。结果不错(另一命名空间的患者算出 false,不会误标),但纯属浪费。
      
       通用性:这一维**不叫 brand**。整条 cohort 链路(CohortKey / tenant_key_column /
      injectCohortFilter)本来就只认 manifest 声明的列名,代码里不出现宿主字样 —— jvs-dw 恰好
      填的是 brand,别的宿主可能是区域 / 诊所 / 不设。漏的只有 ONLY_PATIENT 这个运维参数,
      它返回 string[] 把第二维丢了。
      
      改:新增 resolveOnlyPatientKeys() → OnlyPatientKey{key, tenant?},名单每项支持
        `1855960`         纯 key(单命名空间宿主 / 该 id 在所有命名空间下都要)—— 行为不变
        `261067|瑞尔`      显式分隔
        `261067<TAB>瑞尔`  TSV(SQL dump 可直接喂)
      全部带命名空间且宿主配了 tenant_key_column → 拼复合键 IN;否则退回单键 IN,
      混写(只有部分带)不做部分匹配,warn 一次后整体退回 —— 半精确比全模糊更难排查。
      分片路径同步支持三种起手形态。resolveOnlyPatientIds() 保留为 key 投影,旧调用点
      (cold-import 判"是否定向模式 → 不推进游标")零改动。
      
      测试 799 项(+7)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(sync): 定向名单分片查询 —— 7 万 id 撑爆 ClickHouse max_query_size · c7d5b4c4
      测试服定向补数(PAC_COHORT_ONLY_PATIENT=@file,70,283 个 id)实跑 fatal:
        Syntax error: failed at position 262142
      262142 = 256 KiB,ClickHouse max_query_size 默认上限。7 万 id 拼 IN (...) 约 630KB,
      超 2.4 倍;重试 3 次全败,patients upserted: 0(没写坏任何数据)。
      
      ️ 这与 resolveOnlyPatientIds 的 `@file` 是**两个不同的上限**,之前混为一谈了:
        · @file 解的是环境变量 128KB(E2BIG)—— 传参侧
        · 本次是 SQL 文本长度 —— 服务端解析侧
      文件读进来了,SQL 照样超。注释里"大名单走文件"的承诺此前并不成立。
      
      改为按 10k 分片跑(≈90KB/片,离上限有充足余量),其余条件(cursor / clinics / union 分支)
      每片原样带上,结果用 Map 按 (key, tenant) 去重合并 —— 与单条 SQL 的结果集等价。
      名单未超阈值时仍走单条 SQL,行为不变。
      
      分片时不套 orderTail 的 LIMIT:PAC_COHORT_LIMIT 采样与分片叠加会"每片各取 N"而超量,
      且定向重摄本就是显式点名,不该再被采样截断。
      
      补 3 项回归(含"7 万单条必超上限"的反证),共 18 项。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(sync): 更正增量感知的机制描述 —— cohort 模式走 UNION 分支,不走反向拉 · 835edcc9
      前两次 commit 把机制写成"靠 reverse_pull_from 反向拉主档"。测试服实测日志里
      cohort 模式**没有**出现反向拉那一行:reversePullPatientMaster 只在 loadAllTables
      (single-shot)里调用,日常增量走 loadTablesForCohort,不经过它。
      
      真正生效的是 listPatientPairs 把每张「配了 cursor 且有水位」的表拼成 UNION 分支来列患者
      —— 也就是 incremental.per_query 里给 fact_complex_cases_out 配的 changed_at。
      reverse_pull_from 的声明仍保留(single-shot 路径要用,且把硬编码挪进 yaml 本身是目的),
      但注释已写明它在 cohort 模式下不参与。
      
      同时补一条上线须知:**新表首轮不生效** —— 无历史水位 → cursorValue 为空 → 该分支被跳过,
      第一轮只建水位,第二轮起才感知变化。实测第一轮 UNION 是 6 张、第二轮才 7 张。
      
      最终验证(第三轮增量,10153aab 之后):6 个"末次就诊在 2025-09 ~ 2026-04、但复杂病例刚变"
      的患者全部写上 host_follow_up_active=t,时间戳与该轮一致 —— 主档 cursor 不可能够到他们,
      只能是复杂病例表的变化带进来的。全库 t=1164 / f=14553 / null=323576。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed