- 03 Aug, 2026 25 commits
-
-
主管问"有撤销吗、前端实现了吗" —— 后端(限时 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 -
主管问"现在有时效了,是不是可以显示以前隐藏的自动回收时间" —— 查下来 **不是取消隐藏,是要接一条新的**:那个被隐藏的倒计时基于 `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 -
走查:分配完登录客服账号点「退回」,没有任何弹窗 —— 一点就退掉了。 查下来这条教条**此前是零实现**:枚举齐了(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 -
① **「没被分过的排在前面」改成条件化**。查了口径:池子基线已经排除"还在客服手上"的单, 分过但已退回/到期回池的**仍在池子里**(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 -
走查:那段输出像给程序员看的 ——「按『未进过批次优先 → 优先级高优先 → 患者号』排序」 「从手上最少的开始填」「首次按在岗 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 -
走查实录:主管说「把杨丽华移出这个确认单」,助手回"我这边没法单独剔除,您先点确认、 之后在批次详情里单条退回" —— 把界面能做的事推回给主管手工做,而卡片就在他眼前。 新增本地工具 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 -
产品走查四条: ① **输入框加回边框**(上一版"无边框"太散,找不到落点):外层仍与会话同底、无分隔线, 输入块自己有边框。同时补一个**「回到底部」**圆钮 —— 只在没贴底时出现,浮在输入框 正上方居中(对齐 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 -
· 助手每条消息左边那个 28px 圆头像撤掉:助手窗只有 400px,头像 + 间距白白吃掉 一成宽度,而"这段是助手说的"靠气泡形态(用户右对齐深底 / 助手左对齐无底)已经分得清。 头像只留在 header 和空态引导页。 · 底部输入区改成与会话区**同底色、无边框、无分隔线**:去掉 border-t、外层白底、 内层的圆角边框与 focus ring。
⚠ ️ 底色用 `bg-inherit` 而不是写死 bg-slate-50 —— 写死的话根容器换底色时这一块会漂。 · 去掉「结果仅供参考 请核对后使用」那行免责声明。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
🔴 **点客服删除会卡死** —— 根因与上一条下拉看不见同源,但更严重: 助手窗 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 -
产品走查:时效应该落到每一条召回计划;条目要能删、能拖给别的客服;客服要能移除; 整体用 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 -
产品走查三条: ① 患者明细只有 `#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 -
主管走查:一批 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 -
① 基数 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 -
主管走查抓到死路:批次规模 = Σ(容量−在手) 时,**第二批必然是 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 -
① 容量无上下限:原来卡在 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 -
批次规模不再是一个独立的默认常量(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 -
主管在矩阵点「充填 · 窗口外 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 -
点矩阵格子只往助手走,左栏一动不动(原筛选/滚动位置全保留)。 两个理由: ① 人群的证据在助手的确认单里,那才是要审的东西。左栏再给一份, 两处口径一旦不一致(列表按 plan 分页、矩阵按患者去重,本来就不是一个数) 主管不知道信哪个; ② 左栏是他挑患者的地方,被上一次初选筛住会让他以为池子空了 —— 而那条告示要他先看懂、再点掉,才回得到整池。 cell 只留作矩阵内部回显(再打开时看得出刚交过哪一格)。 服务端 potentialTreatment / temperature 两个列表参数保留未动,只是前端不再自动带上。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
luoqi committed -
原来是「点分配 → 立刻弹浮层 → 浮层里转圈 → 数据到了重排」。 改成 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 -
点中矩阵某一格后:一串粒子沿弧线飞向助手 → 助手进「吸收」姿态 → 落地后自动 开会话窗(最大化)并说出那句话,接上原有流程。 沿用本仓既有取舍(不引动画库):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 -
黑框会被读成边界线、像数据的一部分;这一圈框表达的是「你正指着谁 / 选中了谁」, 属于交互态,而交互态在全站都该是品牌主色。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
luoqi committed -
这一节此前两次改动都**没落到文档上**(python 替换静默 no-match,只改了代码没改教条), 于是教条里还写着早就撤掉的「蓝→琥珀→橙 温度渐变 +
🔥 热/🌡 温/❄ ️冷」。 现补齐到实际状态:列头临床说法(黄金期/窗口内/窗口外)、代码名与显示名为何刻意不一致、 1b 连续色带、以及「数量不参与配色」「hover 用内描边不换底色」两条纪律。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
按产品在 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 -
列头「热 / 温 / 冷」→「**黄金期 / 窗口内 / 窗口外**」,并把温度渐变整套撤掉。
⭐ 两件事是一件事:「热」是**比喻**,而这三档本来就有精确的临床语义。 名字说准之后,冷暖色就成了**第二套更弱的编码** —— 只是把列头已经说清的事再说一遍, 还要额外维护对比度、色标位置、深色模式。所以列头改名与撤配色一起做。⛔ 在文件头和教条里都写死了「别再把冷暖色加回来」,尤其别按数量深浅上色: 「这格橙是因为在黄金期、还是因为人多」分不清 —— 数量已用数字表达、档位已用列头表达。 唯一允许的颜色是交互态(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 -
① 去掉底部文字(那条「或者:按左栏当前筛选的 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
-
- 02 Aug, 2026 15 commits
-
-
按产品对着界面逐条走查的意见改: ① 「矩阵」→「**分配**」主色实心按钮,点击**浮层展开**矩阵(不再替换左栏内容) —— 分配是主管在这一页的主行动,该给主色;浮层展开则挑格子时列表还在原处, 关掉就回到原位置,不丢滚动位置和筛选。 ② 去掉矩阵底部那段常驻说明文字 —— 口径("去重患者数""点一格即移交")挪进 **每个格子的 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 -
上一版判据(「召回理由整组换掉就不继承」)是**找错了轴**,产品当场指出两个反例: ① 召回一会儿出现一会儿不出现,**恰恰可能是被客服处理过**造成的 (患者来了、治了一部分、又诊断出新问题)→ 该结账的反而被当成"理由变了"继承走 ② 同一种诊断**再次出现,也不代表客服没处理过** → 该结账的同样继承了 **理由的变化根本不携带「客服做没做事」的信息**,它只反映患者临床状态在动。 两个方向都会错,所以整条判据作废,换成: 客服没碰过 → 这单还欠着 → 继承(理由怎么变都继承) 客服碰过了 → 批次这一条的账已落定 → 不继承,后面再冒出来的是新工单 批次分的是**人**不是诊断:主管要知道的是「这 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 -
产品指出:「该患者新的诊断等信号形成的工单升版本了是全新的,不应该继承」。核实后成立。 先分清此前被混在一起的**两套继承**: 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 -
产品裁决:「完成」不需要等成功,只要知道**这个患者的召回还在不在池、有没有被抑制**, 两者任一成立就说明这单处理过了。成功不成功先不统计。 否掉原案的「转化率」:它卡在 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 -
生产线第一环补齐:主管在矩阵上点一格 = 完成初选,人群随即交给助手出确认单。
⭐ 关键设计:温度边界改存**稳定路径** `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 -
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 -
裁决开发规划 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 -
🔴 分配功能里**唯一一条「做错了会对患者做出虚假承诺」**的路。 ## 那个缓存坑的实际形状(已核实) `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 -
分错人 / 条件填错的唯一出口。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 -
## 边界写在文件头:只验工程,不许反推规律 造数据能验的:报表 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 -
主管在召回池点「移交助手」→ 助手出确认单 → 点确认 → 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 -
## 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 -
产品指出:姓名本来就在 `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 -
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 -
产品 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
-