1. 03 Aug, 2026 30 commits
    • fix: 退回原因分布与 agentStats 名册也走账本(补上一刀的漏) · 41566fd7
      上一刀把 released 计数改成账本口径,但**分布和名册还读 followup_plans**,
      于是自己跟自己对不上:
      
        followup_plans.release_reason 是当前值,而 assign 的 SQL 里有 release_reason = NULL
        → 一条退过的单被后面的批次挑走后:
            released      1  (账本)
            releaseReasons []  (列已被清空)
        主管看到"退了 1 条"却没有任何原因,会以为是客服没填。实测复现。
      
      更糟的是 agentStats 的名册按当前 plans 建 —— 被挑走的人从所有人名下蒸发,
      「各人之和 === planned」这条本仓最早锁的不变量当场破(而 planned 已是账本口径)。
      
      修法:名册与退回都从本批账本事件建(按 assignment_id 取,不是按当前 planIds)。
        · release 事件自身 assigneeUserId 是 null → 按 planId 回查 assign 的原始承接人
        · 同一 plan 多条 assign(退回→自认领→再退回)去重后计数
        · 老批次(账本没批次号)整条回落到原路径,行为不变
      
      实测(真库):把退过的那条重新分配一次 → 列被清空、账本仍在 →
        分布之和 1 === released 1;agentStats 各人之和 9 === planned 9。
      1008 tests,tsc 干净。
      luoqi committed
    • feat: 批次归因走账本 + 到期可统计 + 批次人话名 · 9eeb29b9
      主管四问引出的一组修复。
      
      1) 归因失血(不报错的真 bug)
         followup_plans.assignment_id 会被下一次分配覆盖 —— 一条单退回/到期回池后
         被后面的批次挑走,旧批次就少一个人。批次跑得越久缩得越厉害。
         实测 c02e1b80 分过 9 条,重分后 planned 显示 0。与 supersededAt 那坑同构。
         ⇒ plan_event_logs 加 assignment_id(不建明细表:账本本来就是明细,
           缺的只是"属于哪一批"这个维度;该表自己写着"要按它筛就该立柱")。
           四个写入点全覆盖;claim 刻意不写(自认领捞的是池子,plan 上那个 id 是陈迹)。
         ⇒ planned/agents/released/expired 走账本,inHand/done 走当前状态。
         ⇒ 补 progress.reassigned 让穷尽性重新成立(不补则主管一对数就少人)。
         ⇒ 老批次回落到 followup_plans 现算;到期数无源可落,只能给 0。
      
      2) 到期终于能统计
         到期事件一直在账本里(auto_release + assignment_expired),但任何接口都没报,
         全被 backToPool 一桶吞掉。而到期与退回主管的下一步动作相反:
         退回多=分配策略不对,到期多=派多了/时效太紧。现在分开报,note 也分开说。
      
      3) overdue 加 snoozed 守卫
         回收器刻意跳过"约了下次回访"的单,于是它们永远超期;而 progress 已把它算作
         suppressed(已处理)。不加守卫,同一条单「已处理」和「超期」同时成立 ——
         主管看到「薛玫 超期 3」以为她压单,实际她打了电话约好了下次。
      
      4) 批次人话名 label(服务端唯一生成)
         「8/3 23:35 · 牙周治疗 · 窗口内 · 9 人 · 2 位客服」。没有列表页时这是主管
         指认一批的唯一抓手。做这个才发现 temperature 一直没进 criteria 快照 ——
         它参与圈人却没随确认单下发,导致批次说不清"当时按哪个温度圈的"。已补。
      
      顺带修一个潜伏编译错误:assignmentExpiresAt 加进 FollowupPlanSchema 时漏了
      serializePlan(19bd658f)。当时没报错是因为 packages/types/dist 是旧的。
      
      实测(真库):新批次 9 人 → 撤销 → 同一批人重分 → 旧批次 planned 仍是 9
      (旧实现下是 0)、五桶+重分 0+0+0+9=9;退回 1 条、到期 2 条(等回收器真扫)
      分别落账,详情读出「客服主动退回 1 条、到期没人动 2 条」。
      1005 tests,两个 tsc + next build 干净。
      luoqi committed
    • fix(web): 生成中文案一闪而过 + 输入框/开场建议按角色分 · a6f8c3db
      走查两条:
      
      1) loading 文案闪
         - 加最短停留 700ms(useStickyLabel)。get_current_user / get_agents 这类
           100ms 就返回,文案在屏幕上活不过两帧,主管只看到一片闪烁,反而以为卡了;
           慢工具的文案看得见,于是他以为"只有那一句"。
           用「最新覆盖待显示」而非排队 —— 排队会在活干完后继续播,5 个快工具能
           拖出 3 秒假忙碌。上限就是一个停留周期。
         - 修并行调用:原来只看最后一块,3 个工具并行时最后那块可能已 done 而前两个
           还在跑 → 退回「生成中」,前两句文案一次都不出现。改成找还在跑的那个。
         - 补齐 4 个漏掉的工具中文名(get_cohort_attributes / edit_assignment_sheet /
           revoke_assignment / render_artifact),此前兜底把英文工具名摆给主管看。
      
         实测(多工具一轮):5 个文案,最短 955ms,无一低于 700ms。
      
      2) 输入框占位符 + 开场建议是客服口径
         主管打开助手是来分配和看批次的,占位符从不提这两件事 = 把一半能力藏起来。
         按 PLAN_DISPATCH 分两版(与召回池/确认单同一个闸)。
         顺带删掉「(Enter 发送,Shift+Enter 换行)」—— 输入框只有 300px,而原句 450px,
         那半句从来就没显示出来过,占着长度却看不见。
      
      996 tests,web tsc + next build 干净。
      luoqi committed
    • fix: 助手撤销走不通(批次号被截成8位)+ 助手"假撤销"对账 · 2e75bef9
      主管实测:助手撤销必挂,报「批次 c02e1b80 不存在」。
      
      根因不在撤销,在确认后注入消息流的那句话:
      `已确认分配:批次 #${id.slice(0,8)}` —— 界面与模型共用这一份,
      模型手里只有 8 位短号,拿短号查主键当然查不到。
      卡片上的撤销按钮握着完整 id 一直是好的,所以只测卡片那条就漏了。
      
      修法两层:
      - 文本块加 modelText:界面显示一份、喂模型一份(后者带完整 uuid);
        界面顺带不再显示批次号(uuid 对主管没有意义)
      - 服务端 resolveAssignmentId 兜底认短号,并能从「批次 #c02e1b80」
        这种整句里抠出 id(实测模型就这么传);撞到多个报错不猜
      
      追查中发现更严重的一个:助手会把「已撤销批次:收回 9 条。」**背出来** ——
      没调 revoke_assignment,库里那批仍是 confirmed、9 条工单一条没动。
      工具返回被设计成"成品句子、原话转述",模型学会形状后就能凭空生成。
      报错主管会重试,假成功会让他停止补救。
      
      ⇒ 前端每轮结束对账:主管在要求撤销 + 助手自称撤成了 + 本轮没有
        revoke_assignment 的 tool_result ⇒ 把事实贴出来。
        措辞只陈述观察(不断言撒谎):主管问"刚才那批撤了吗"时如实回顾也不调工具。
      
      实测:短号经模型这条路已跑通(a7e1b6de → revoked,9 条回池无认领人)。
      996 tests,两个 tsc + next build 干净。
      luoqi committed
    • docs: 教条补撤销整批那节(含 key={i} 那个回归) · d850dd4f
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(web): 卡片加「撤销这批」+ 去掉批次号 + 助手撤销时卡片同步 · 58266293
      主管问"有撤销吗、前端实现了吗" —— 后端(限时 30 分钟 + view 判据)和助手工具
      revoke_assignment 都在,但**前端零实现**:assignmentsApi 连 revoke 方法都没有,
      批次列表页也不存在。先做 A(卡片上的入口),批次列表页单独排。
      
      · 确认后的卡片终态加「撤销这批」,30 分钟窗口内可见(按**确认那一刻**算,
         不是提案生成时间 —— 主管可能盯着单子改了二十分钟才点确认,那不该算进补救窗口),
        到点按钮自己消失, 不让他点了才被服务端拒。
      · 弹窗**先说「客服已经打开过的收不回来」**:撤销几乎总是部分成功,
        主管以为"点了就全回来了"就会停止补救,而那几条还挂在别人手上。
        结果如实报三个数(revoked/skippedTouched/alreadyReleased), 不只弹「已撤销」。
        note 原话进对话(既给主管看,也补模型上下文)。
      ·  去掉「· 批次 #9a9fc222」:uuid 前 8 位对主管没意义,只占位置 + 制造记忆负担。
        模型照旧走 list_assignment_batches 拿完整 id,与界面无关。
      ·  **助手撤销时卡片跟着切态**:revoke_assignment 是 MCP 工具推不了侧信道,
        但前端本来就收得到它的 tool_result —— 拿它当同步信号,按 assignmentId 精确匹配。
        不同步的话卡片说「已分配」、助手说「已撤回」,两句互相打架。
      
      🔴 顺带修一个**我自己上一版引入的回归**:`key={i}` + 「把确认单钉到消息末尾」——
      助手每追加一句文本,sheet 下标就变 → React 卸载重建 → **卡片本地状态全丢**
      (主管删的条、拖的改派、逐条时效、确认时刻)。症状是"撤销按钮压根不出现",
      但丢的远不止那一个按钮。改成按 requestId 做 key。
      
      本地实测:分 17 条 → 卡片出现「撤销这批」→ 弹窗 → 确认 →
      「已分配 已撤销 17 条」;库里 plan_assignments.status=revoked、
      plan_event_logs 落 17 条 auto_release/revoked。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat: 详情页显示分配单时效「还剩 N 天退回」 · 19bd658f
      主管问"现在有时效了,是不是可以显示以前隐藏的自动回收时间" —— 查下来
      **不是取消隐藏,是要接一条新的**:那个被隐藏的倒计时基于 `recycleAt`(认领的 24h 兜底,
      PAC_PLAN_AUTO_RECYCLE 默认 off,**从未启用**,值几乎恒为 null),
      而现在真正生效的是 `assignmentExpiresAt`(主管在确认单上定的 1-7 天,
      AssignmentExpiryScheduler 每 10 分钟扫,到点回池记 assignment_expired)。
      两个字段一直被混为一谈,详情接口**根本没返回后者**。
      
      · 服务端 /plans/:id/full 补 assignmentExpiresAt;契约里给两个字段各写清适用场景。
      · 前端 RecycleCountdown 换成 ExpiryBadge 并挂回头部,只在**单子还挂在人手上**时显示。
      · ️ 粒度跟着换:原来是 HH:MM:SS(为 24h 设计),而时效是**天**级 ——
        3 天的单显示 71:59:58 没人读得出来。改成「还剩 2 天」/「还剩 5 小时」,
        最后 1 小时才精确到分钟;刷新从 1s 降到 30s(天级不需要每秒重渲染整页)。
      · ️ 配色:最后一天转琥珀、最后一小时转红。 不更早变红 —— 时效本来就是几天,
        提前两天飘红会让客服对颜色脱敏,真到期时反而看不见。
      
      本地实测(客服「位其蕾」):头部显示「还剩 3 天退回」,hover 出具体到期时刻。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat: 补上退回原因弹窗(T7 此前是零实现) + 分配单服务端强制 · 1409f6f5
      走查:分配完登录客服账号点「退回」,没有任何弹窗 —— 一点就退掉了。
      
      查下来这条教条**此前是零实现**:枚举齐了(8 个 ReleaseReason + needNote)、
      API 收得了、releaseReasons 分布报表也写了,但前端唯一的退回入口是
      `plansApi.recycle(planId)` —— **一个参数都没传**。于是那张分布表永远拿到 null:
      主管只知道"退了 12 条",不知道该改什么,而"派多了 / 时效太紧 / 压根不该派给他"
      这三种的下一步动作完全不同(T7 说这张表是主管调整分配策略的输入)。
      
      · 新增 ReleaseReasonDialog:8 个原因,每个**带一句 desc** ——「不是我的客户」和
        「该由其他角色跟」光看标题分不出,分不出就会随手点第一个,**假数据比没数据更难发现**。
        other 展开必填说明框,不填不给提交。
      · 服务端:**分配来的单**(assignment_id 非空)强制要 releaseReason。
        ️ 只卡分配来的 —— 自助认领的单没有派单方,原因对谁都没用;
        **系统自动到期回收不走这条路**(scheduler 直接改库,记 assignment_expired),不会被卡死。
        ️ 必填必须服务端拦:只做前端的话,换个客户端(助手/脚本/企微)就绕过去了
        (与 other 必填说明、execution 的 inaccurate 必填同一条纪律)。
      
      本地实测(客服「位其蕾」12 条在手):点退回 → 弹窗 → 选「其他原因」出必填框且确认禁用 →
      改选「时效太紧」→ 确认 → toast「已退回」、我的 12→10、
      followup_plans.release_reason=deadline_too_tight、
      plan_event_logs 落 release/deadline_too_tight 且带 held_seconds=2839。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat: 排序话术条件化 + 卡片等说完再出现 + 引导补画像收窄与福利 · 9f8c62f6
      ① **「没被分过的排在前面」改成条件化**。查了口径:池子基线已经排除"还在客服手上"的单,
         分过但已退回/到期回池的**仍在池子里**(T7:退回是正常路径,不能分过一次就永不再召),
         只是排到后面。本地实测池子 1094 人里回池的 **0 人** —— 这时说那句是废话,
         还会让主管以为系统在防一个不存在的情况。
         现在:有回池的人才说「之前分过又退回的 N 人排在后面」,否则只说「优先级高的排在前面」。
         countCandidates 顺带 FILTER 出这个数,不多一次查询。
      
      ② **确认单等这段话说完才出现**。上一版只做了"钉到消息末尾",但流式期间卡片已经在下面,
         主管看到的是"卡片先蹦出来、文字再在它上面一行行长" —— 比卡片在前更怪。
         现在流式期间不渲染 sheet,说完再出现。
      
      ③ **引导语补两条**(主管不知道能这么用,不说他就只会点确认):
         · 再挑一挑人:「只要商保直付的」「排掉最近来过的」→ 助手先看分布再重新圈;
         · 带个福利:「这批带上『老客户复查免挂号费』」→ 新增 set_benefit,写进 attributes.benefit.text。
         福利链路本来就通(话术 prompt 已接),缺的只是主管怎么填进去。
         ️ 福利在卡片上**只读**(有才显示一个绿标):在 400px 卡里放自由文本框,
         主管十有八九写成一段带承诺的话,而那道护栏在话术生成侧,别污染源头。
         ️ 引导语要求用主管的话说, 不许报工具名。
      
      本地实测:说「这批带上「老客户复查免挂号费」」→ 卡片顶部出现「福利 · 老客户复查免挂号费」
      绿标,界面回报那句也进了对话。989 tests green。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • refactor: 助手先说话再给卡 + 三句依据改说人话,只留「怎么选/怎么分」 · 3f7b78c2
      走查:那段输出像给程序员看的 ——「按『未进过批次优先 → 优先级高优先 → 患者号』排序」
      「从手上最少的开始填」「首次按在岗 17 位 × 20 估」,而且卡片在前、文字在后。
      
      ① **顺序:文字在前,卡片在后**。
         ️ 不是"让模型先说话"能解决的:工具调用**必然在文字之前**(模型要先拿到结果才知道说什么),
         而卡片是工具执行时经侧信道推来的,到达顺序天然是"卡片 → 文字"。
         所以在**渲染层**把 assignment_sheet 块钉到消息末尾(stable 排序, 不整体 sort ——
         文字块之间的先后是流式拼出来的,打乱就成乱码)。
      
      ② **措辞改成说给主管听**:
         · 怎么选:「符合条件的一共 1084 人,这批挑了 340 人:没被分过的排在前面,其次是优先级高的。
           剩下 744 人在排队,随时可以再分一批。」
         · 怎么分:「340 人分给 17 位客服:有专属客服的先回到自己人手上,剩下的谁手上活少先给谁。
           分完后每人手里 20 条,3 天没动会自动退回池子。人数和时效是第一次的估算值……」
          「排序键/收敛/铺平/水位/基数/探索配额/患者号」一律不进主管界面 —— 加了断言守着。
         ️ T14 要的三件事一件没少,只是换了说法:分完每人多少、这两个数哪来的、精调过的人点名。
      
      ③ **内容收敛到「怎么选 + 怎么分」两句**,末尾加一句调整引导
         (「把某某移出这批」「某某的单给 5 天」「这批 200 人」—— 我直接改)。
         名册那句(近 12 月有回访记录的近似判定)不再单独占一条 bullet,rosterNote 字段保留。
      
      本地实测通过,助手实际输出已核对。989 tests green。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat: 助手能直接改确认单(edit_assignment_sheet) + 卡片去掉只读的人数块 · 8ff25259
      走查实录:主管说「把杨丽华移出这个确认单」,助手回"我这边没法单独剔除,您先点确认、
      之后在批次详情里单条退回" —— 把界面能做的事推回给主管手工做,而卡片就在他眼前。
      
      新增本地工具 edit_assignment_sheet(不走 MCP,同 propose_assignment):
      能做的与卡片完全一致 —— 删患者 / 移除客服 / 改派 / 改时效(整批、按客服、按患者)。
      
       **指令用姓名,不用 planId**:那些 id 从来没进过模型上下文(确认单走侧信道,故意的)。
         模型负责"听懂",**界面拿自己手里那份确认单去匹配**。为了让模型能指名道姓而把明细
         喂给它,等于把当初走侧信道的理由(几百个 uuid、幻觉、成本)全部退回去。
      ️ **结果由界面回一句进对话**(复用 appendAssistantNote), 模型不许自己宣布成功 ——
         它看不到卡片,匹配不到/同名/客服不在本批都是界面才知道的事。那一句同时进模型上下文,
         下一轮它才不会替系统撒谎(T14)。工具返回值里也写死了"等界面那句再说"。
      
      🔴 修两个实测抓到的坑:
      · 指令挂载**必须跨消息找**最后一张确认单 —— 卡片几乎总在上一轮消息里,
        只在当前消息里找永远找不到,而且不报错、界面毫无反应;
      · **同名歧义不许猜**:实测「张悦」既是本批客服又是本批患者,模型选了 remove_agent,
        于是"把张悦移出确认单"变成把客服连同 20 条一起移除。现在这种情况直接拒执行并说明,
        提示词也定死默认按**患者**理解(猜错的代价不对称:一下子动 20 条 vs 1 条)。
      
      · 卡片底部那块只读的「人数 本批 340 人 / 要改说…」撤掉:只读信息不该占跟可操作区
        一样重的位置,它想说的两件事都有更好的落点(顶部数字 + 现在可以直接让助手改)。
      
      本地实测:对话说「把张悦移出这个分配确认单」→ 助手调工具 → 界面执行并回报
      → 确认按钮 340 条变 320 条。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(web): 患者带性别年龄 + 拖拽自动滚动 + 输入框加回边框/回到底部 + 去掉三句依据 · 7cb0048d
      产品走查四条:
      
      ① **输入框加回边框**(上一版"无边框"太散,找不到落点):外层仍与会话同底、无分隔线,
         输入块自己有边框。同时补一个**「回到底部」**圆钮 —— 只在没贴底时出现,浮在输入框
         正上方居中(对齐 Claude Code)。️ 判据复用 onScroll 里那个 80px 阈值, 不另写一个:
         两个阈值不一致会出现"按钮还在、其实已经到底"这种解释不清的状态。
         为此把 stickRef 加了个可渲染副本 atBottom(ref 改了不触发重渲染)。
      
      ② 患者条目补**性别·年龄**,与患者详情卡片同口径(formatGender + calcAge)。
         ️ 年龄**读时算**(存的是 birthDate),两处各写一遍必然在生日当天差一岁。
      
      ③ 🔴 **拖拽自动滚动**:HTML5 DnD 不会自己滚容器,助手窗一屏只放得下三四个客服,
         拖到边缘就卡住 —— 想丢给下面的人基本不可能。现在拖动中指针进入上/下 56px 感应带
         就按 rAF 持续滚。️ 滚的是**离卡片最近的可滚动祖先**(运行时找),不是 window:
         写死 window 在最大化/非最大化两种形态下会滚错东西。
      
      ④ 卡片底部那三句(selectionNote/basisNote/rosterNote)撤掉展示 ——
         "规则拼成的,用户不需要知道"。️ 三句话没删:助手在消息正文里已经原话转述过一遍,
         卡片再贴一次是同一段出现两遍。 服务端那三个字段别删,助手要靠它们照抄。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • style(web): 助手窗去掉消息头像 + 输入区改成无边框同底(对齐 Claude Code) · 1d7041e6
      · 助手每条消息左边那个 28px 圆头像撤掉:助手窗只有 400px,头像 + 间距白白吃掉
        一成宽度,而"这段是助手说的"靠气泡形态(用户右对齐深底 / 助手左对齐无底)已经分得清。
        头像只留在 header 和空态引导页。
      · 底部输入区改成与会话区**同底色、无边框、无分隔线**:去掉 border-t、外层白底、
        内层的圆角边框与 focus ring。️ 底色用 `bg-inherit` 而不是写死 bg-slate-50 ——
        写死的话根容器换底色时这一块会漂。
      · 去掉「结果仅供参考 请核对后使用」那行免责声明。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(web): 修 shadcn 浮层 z 层级(点删除卡死) + 时效移到卡片右上角 · 96b590a3
      🔴 **点客服删除会卡死** —— 根因与上一条下拉看不见同源,但更严重:
         助手窗 fixed z-[60],而 shadcn 给**所有**浮层的默认层级是 z-50。
         · Select/Popover/DropdownMenu/HoverCard:点了有反应(DOM 里 listbox 确实打开),
           但一个选项都看不见;
         · Dialog/AlertDialog:Radix 会给 body 设 pointer-events:none —— **弹窗看不见、
           整页却已经被锁住**,用户看到的就是卡死。实测确认:alertdialog=true、
           overlayZ=50、bodyPE=none。
         两次都**不报任何错**。这次在 components/ui/* **统一修**而不是在调用处打补丁:
         浮层 z ≥ 70(select/popover/dropdown-menu/hover-card),模态 z ≥ 80(dialog/alert-dialog),
         六个文件顶部都写了层级约定,免得下次 re-generate 或新组件又掉回 50。
      
      · 批次时效移到卡片**右上角**,与左边「拟分 N 人」同一层级(它是这一整批的设置);
        文案改成「3 天**后自动退回**」—— 光写"时效"主管不知道到期会发生什么。
      · 底部微调区只留人数 + 出处说明(沿用哪次/首次默认/本次指定 + 几条单独设了时效)。
      
      本地实测:点康慧捧的 X → 弹窗正常显示 → 移出本批 → 340/17 位 变 320/16 位 + 「已移除 20」。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(web): 确认单重做 —— 逐条时效/删除/拖拽改派/移除客服,全部走 shadcn · 7cfeb862
      产品走查:时效应该落到每一条召回计划;条目要能删、能拖给别的客服;客服要能移除;
      整体用 shadcn 组件承载。
      
      · 布局全部换成 shadcn:Card / Badge / Button / Select / AlertDialog。
      · **时效逐条**:每条召回计划一个 1-7 天下拉,批次级那个只是"没单独设过的默认"。
        落库时只在与批次不同时带 expiresInDays(一样也带 = 把批次时效冻进每条,
        将来批次层面改时效就改不动这些单子了)。
      · **单条删除** + **移除客服**(AlertDialog 二次确认,明说"他名下的条目一并移出本批")。
      · **拖拽改派**:原生 HTML5 DnD( 不引 DnD 库),整个客服块都是投放区 ——
        展开后列表很长,只认标题行会让人反复试。拖过的落库时 assignStrategy 改成 **manual**,
        不改的话事后分析会把它算成算法的选择,而 T20 要反推的正是"算法选得准不准"。
      · 所有展示改从**生效条目**派生(sheet.items − 删掉的 + 改派 + 逐条时效),
         不再直接用 sheet.byAgent —— 那是提案时的分组,拖过一条就对不上了。
      
      🔴 修一个"改了不报错、但功能等于没有"的坑:助手窗 fixed z-[60],而 shadcn SelectContent
      自带 z-50,Radix 会把 content 的 z 复制到 body 上那层 popper wrapper → 下拉渲染在助手窗
      **底下**:listbox 确实打开了(DOM 里查得到)、但一个选项都看不见。实测才发现,已抬到 z-[70]
      并写进教条(凡在助手窗里用 Radix 浮层都要抬 z)。
      
      本地实测:删一条 → 薛玫 20→19 且在手 0→19;改一条时效 3→5 天只影响那一条;
      下拉 1-7 天全部可见可选。989 tests green。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(web): 确认单显示患者姓名 + 时效改 1-7 天下拉 + 撤掉「铺平」标 · 14ec24ec
      产品走查三条:
      ① 患者明细只有 `#912e5dae` —— 主管展开客服看的就是"这 20 个人是谁",
         给一串十六进制等于让他猜(T14)。ProposedItem 加 patientName / medicalRecordNumber,
         服务端把 dedicatedCsOf 合并成 patientsOf 一次取齐(同一批 id 同一张表,分两次纯属多一个来回)。
         ️ 身份在**落人之后**贴上:placeAgents 是纯算法,让它认识"患者叫什么"只会更难测。
      ② 时效 3/5/7 三档 → **1-7 天下拉**(批次级 + 按客服两处同一个控件)。
         档位是我们拍的,主管对自己团队的节奏有判断(「明天就要」= 1 天)。
         顺带确认:时效**早已落到每条召回计划**(assignment_expires_at 逐行写),
         不是只挂在批次头上 —— 卡片上补了一句说明。
      ③ 汇总行的「铺平 N」标撤掉(看不懂)。明细里只标「专属」,没标的自然就是不是他的老客户 ——
         同一个信息换成主管的语言。️ assignStrategy 仍逐条落库,T20 反推不受影响
         (T15 要的是"可见",不是"必须以这三个字出现在汇总行")。
      
      本地实测:展开康慧捧 20 条全带姓名+病历号+专属标;薛玫 20 条全是铺来的,无标。
      989 tests green。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(plan): 落人改三趟「专属封顶 → 无主补空 → 有主改派」,做到又满又平 · 39cd460a
      主管走查:一批 340 人分布是 248/34/31/2×14,完全不齐。查了数据,根因是
      专属关系极度集中 —— 池子 1,081 人里 755 人(70%)挂在同一个客服名下,
      而 17 位在岗中有 10 位名下一个患者都没有,自由患者只有 119。
      
      上一版的硬约束(客服不能接别人的专属患者,给不下就 unplaced)让"满"和"平"
      不可能同时成立:严格齐平上限是每人 9 条、一批只有 153 人。
      按产品意见改成**优先级**而不是禁止:
      
      ① 专属:分给他,但**封顶在目标水位**((团队在手+N)/在岗人数,由 N 推出,非新旋钮)
      ② 无主补空:无主/专属已离岗的患者给最空的人 —— 拿他们填坑零代价
      ③ 有主改派:无主的用完还没填平,才把超出水位的专属患者改派出去,标 spread_overflow
      
      ️ ②③ 的先后不能颠倒:两趟都是水位法、总量一样,但**拆散的专属关系数不一样**。
      合成一趟会随机改派某个有主患者,而同时某个无主患者落给了别人。
      
      实测同一批:248/34/31/2×14 → **每人 20 条,17 位完全齐平,340 条一条不丢**。
      spread_overflow 枚举复活(硬约束那版里它"不再产生"),别当死枚举清掉。
      989 tests green;教条把两条弯路都记了进去。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(plan): 首批 N 按「在岗人数×20」估 + 与候选取小;专属改为硬约束独占 · 61537b7d
      ① 基数 N 的取值改成产品定的算法:
         首次 min(在岗人数 × 20, 候选总数),之后 min(上一次的 N, 候选总数),时效首次 3 天后沿用。
         ️ 与候选取小压低的是 target, **不回写基数** —— 候选不够是这一批的偶然事实,
         存进去的话主管点一次 44 人的小格子,以后所有批次永远 44 人,而他看不出为什么。
         所以确认单同时给 batchSize(基数)与 target(本批实际),卡片存前者。
         ️ fetchLimit 改按基数算,让 count 与取明细还能并发,不多一个来回。
      
      ② 🔴 硬约束:**客服不能接别人的专属患者**。有专属(且在名册内)的患者只有一个去处,
         给不下就落不下(unplaced),不允许溢出给第三个人。
         · 推翻原「专属已满 → 溢出转铺平」,SPREAD_OVERFLOW 不再产生(枚举保留,老批次在用);
         · 两趟分工改为「专属独占 → 无主患者水位法」,后者负责抹平前者造成的不齐;
         · maxThisBatch 精调压过专属:说了只给 5 条,第 6 个她的专属患者就落不下, 不许改派;
         · 上一版给专属加的"目标水位封顶"与本约束冲突(会把专属患者改派给第三人),撤掉。
      
      ️ 直接后果,已在教条里写明:「最后齐平」变成尽力而为。实测 N=340 时康慧捧
      一个人是其中 248 人的专属,他就拿 248 条 —— 那些患者只有他能接。
      调节手段是给他设 maxThisBatch 或换更小的 N, 不是让系统偷偷分给别人。
      
      本地实测:1,081 候选 → 首批 340 人(17×20),池子里还有 741 人排队。988 tests green。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • refactor(plan): 删掉"容量"这个数,批次人数改基数 + 落人走水位法 · c33dafed
      主管走查抓到死路:批次规模 = Σ(容量−在手) 时,**第二批必然是 0** ——
      第一批把所有人填到水位,再算就是 Σ(20−20)。想连着圈两批人分,只能把容量
      往上棚,而容量会被记住 → 下周的"习惯"是个虚高的数。
      
      根子是一个数当了两个用:「这轮推多少」和「一个人最多压多少」。拆开后发现
      上限那一半根本不需要 —— 负载不需要阈值,inHand 本身就是负载。
      
      · 基数改为 **本批人数 N(首次 100)+ 时效(3 天)**,都沿用主管上一次的值;
        容量、AGENT_CAPACITY_RANGE、capacityRange 全删。
      · 落人第二趟由「轮转均分」改 **水位法**:每条都给当前在手最少的人。
        均分是"每人加一样多",起点不齐终点还是不齐;水位法把差距抹平。
      · 🔴 **专属那一趟也受目标水位约束**(=(团队在手+N)/在岗人数,由 N 推出,非新旋钮):
        容量删掉后没东西约束专属了,实测 100 条里 76 条是同一人的专属,他吃到 76,
        水位法只剩零头可铺。加约束后同一批变成每人 5~6 条。
        超出份额的专属转 spread_overflow(关系还在,这轮没轮到),语义同原「专属已满转铺平」。
      · 按客服精调 capacity → maxThisBatch(本批名额,0 = 这轮不给),不再是"上限"。
      · 沿用同时认新键 batchSize 与老键 target —— 否则改版后所有人的沿用静默退回默认。
      · 卡片「已满未分配」改「本批未分到(在手 N)」:没有上限了,写「已满(0)」自相矛盾。
      
      本地实测:1,081 候选 → 本批 100 人铺给 17 位客服,分完后每人在手 5~6 条,
      池子里还有 981 人排队(话术明说"随时可以再分一批")。986 tests green。
      教条 T22 整节重写,把这条弯路记进去。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(plan): 容量去掉上下限 + 两个基数支持按客服精调 · 9fa29b7b
      ① 容量无上下限:原来卡在 20-50。那是主管对自己团队的判断,系统没有任何数据
         能证明 5 太少或 200 太多(全生产 plan_executions 仅 7 条),拿一个同样没依据的
         区间去卡他,只会让他撞上一个解释不了的墙。只校验正整数。
         连带撤掉名册接口的 capacityRange —— 返回区间会被读成「合法范围」,而它从来不是。
      
      ② 两个基数从"整体一个值"扩成"整体默认 + 按客服精调"(agentOverrides):
         · 按客服**容量**:改的是人数(Σ 容量−在手)→ 会换人群 → 走对话让助手重出单;
         · 按客服**时效**:不改人群 → 卡片上展开那一行直接改,落到他名下每条任务的
           assignment_expires_at(写路径早已支持逐条覆盖)。
         精调与基数一样沿用,所以卡片标「精调」+ capacityNote 点名 ——
         一条上个月的临时精调静默沿用三个月,没人会发现。
      
      ️ 精调必须穿到落人层:placeAgents 从收单个 capacity 改成收 capacityOf(userId)。
         只改展示不改 room,李莉照样被分满 20 条而卡片上写着 5 —— 不报错,要等她抱怨。
         已加回归并验证:改回单值该用例即红。
      
      本地实测:薛玫单独设 7 天 → 行上出现「精调」标,整批仍 3 天;总人数不变。
      教条 T22 补精调与无上下限两节。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(plan): 分配只留两个基数(容量+时效),都沿用主管上一次的值 · 9de8b34e
      批次规模不再是一个独立的默认常量(100),改成由容量推出来:
        本批人数 = Σ max(0, 容量 − 该客服在手)
      
      这实际上是**恢复**裁决表里一直写着的「拟分 N 人 = 默认分满容量」——
      代码里那个 DEFAULT_BATCH_SIZE=100 与它矛盾了两周,且注释还引 T5 给自己背书
      (T5 的「100 人」是举例,约束的是容量该拍多大,不是批次规模)。常量删掉。
      
      · 容量首次默认取**下界 20**(原来取上界 50):17 位客服 × 50 = 850 条一批,
        正是 T5 反对的做法。起点低、不够再往上调;反过来那一批已经分出去了。
      · 两个基数都从 plan_assignments.criteria 快照里读上一次的值(零新列),
        查找顺序:该主管在该诊所的上一次 → 该诊所任何人的上一次 → 首次默认。
      · 确认单显示**推导链 + 出处**:「在岗 17 位 × 每人容量 20 − 已在手 0 = 可分 340 人;
        容量/时效沿用 7-28 那次分配」—— 规模会随在手量波动,看不到推导只能怀疑系统抽风。
      · 时效初值改为跟着提案走(原来写死 3),确认时把卡片当前值存回快照。
      · 容量**不做成卡片控件**:改容量会改变人群,而卡片微调项只允许不改人群的那两项(T13)。
        主管说「每人 30 条」→ 助手带 capacity 重出单。
      · targetCount 保留为**一次性**覆盖, 不回写基数 —— 拿「这批只要 60 人」反推出
        「以后每人 6.7 条」等于把临时决定固化成长期参数。
      
      本地实测:充填·窗口外 1,080 → 本批 340 人 = 17 × 20 − 0,每人 20 条。
      教条补 T22,T5 补一条「100 是举例不是参数」的反读说明。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • 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 10 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