- 02 Aug, 2026 19 commits
-
-
裁决开发规划 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 -
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 -
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 -
⚠ ️ **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 -
分配功能开工前的前置修复。这一刀**不含任何新功能**,但把四个 「不修就会静默出错」的洞补上 —— 分配一上线它们会同时被放大。 ## 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 -
## 三处裁决(产品 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/(与 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 -
luoqi committed
-
luoqi committed
-
现象: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 -
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
-
- 01 Aug, 2026 14 commits
-
-
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 -
主管指派工单要选人,而 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 -
luoqi committed
-
测试服实测: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 -
测试服定向补数(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 -
前两次 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 -
测试服实测(第二轮增量):cohort 列出 15,107 人,其中「复杂病例变了但人没来诊」的 19 人确实被 UNION 分支正确带进了 cohort,但他们的 host_follow_up_active 始终没更新。 根因在 loadTablesForCohort:主档 query 同时被注入 cursor 和 cohort 过滤 → WHERE last_visit_time > '2026-07-30 09:36' AND (patient_id,brand) IN (<本批 tuples>) 这些人末次就诊在几个月前 → 被 cursor 挡掉 → 主档整轮拉不到 → 不 upsert。 batch 日志早有征兆:cohort 726 人,fact_client_out 只回 675 行。 cohort 已经是比 cursor 更强的限定(它就是"本轮要处理哪些患者"的答案), 主档再叠 cursor 是多余且有害的。非 cohort 模式靠 reversePullPatientMaster 兜这个场景, cohort 模式此前无兜底 —— 与其再补一次反向拉,不如从源头去掉这个条件。
⚠ ️ 这是**既有缺陷**,不是本次接入引入的:任何"事实变了但 last_visit_time 没变"的患者 (EMR 补写、预约改期),其主档在 cohort 模式下本来就整轮不更新。此前只表现为主档字段 偶尔陈旧、被 stub 兜住不易察觉;到了派生列(主档不拉 = 列不重算)才致命。 cursorAdvances 不受影响:bundle 水位写的是 run_start baseline,不取 max。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
测试服 dry-run 实测翻车(未落库,水位未污染): Missing columns: 'last_visit_time' 'patient_id' while processing query: 'SELECT patient_id, brand FROM dw_group.fact_complex_cases_out WHERE last_visit_time > …' listPatientPairs 构造 UNION 分支时是第三处朴素正则 `/FROM\s+([\w.]+)/`: ① 主档 query 现在带 SELECT 列子查询 → 抓到子查询的 FROM,表名取成 fact_complex_cases_out, cursor 列却仍是主档的 last_visit_time,两个错叠在一起 ② 分支直查物理表 → 读不到 query 里的 `customer_id AS patient_id` 别名(该表物理列名是 customer_id),patient_id 根本不存在 改为**包裹 manifest 的 query**(复用已按顶层 FROM 解析的 injectIncrementalCursor): SELECT <keys> FROM (SELECT … AS patient_id … FROM <表> WHERE <cursor> AND <业务过滤>) 别名在子查询里成立;顺带继承该 query 的业务过滤,与真实拉取同口径 —— 否则"游标之后但 会被业务条件过滤掉"的行会把无关患者拖进 cohort(同 extractBusinessFilters 那次的教训)。 解析不了则回退旧形态(表名改用顶层解析),不比改动前差。 补 2 项回归锁这两条,共 21 项。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
DW 新表 fact_complex_cases_out(comment 原文「潜在治疗ID」)= FRIDAY 侧 complex_case_info「复杂病例」:宿主自己在跟的患者,PAC 不该再发起电话召回。 落点复用 FRIDAY 那次已建好的 patient_profiles.host_follow_up_active, canonical / 副表 / 召回 SQL 一行未动 —— 本次全部改动落在 yaml + 通用引擎。 口径与 FRIDAY 对齐(实测 113,657 行 / 88,300 患者): 未删除(is_del=1;98.8% 为 1,反直觉但两家源库一致) 且 case_stage ∈ 1待跟进/2已咨询/3已预约/5诊疗中 → 在跟(70,275 患者) 6已成单 / 7已丢单 = 宿主停手 → 交还召回 【判定为什么内联进主档 SQL,而不是 lookup / join】 FRIDAY 的演进方向是「宿主侧 join,PAC 侧不 lookup」(其 manifest 四处注明), jvs-dw 是 pull、SQL 由 PAC 自己写,故用纪律允许的 `IN (SELECT …)` 形状, 且 tuple 版天然带 brand —— 集团内同号跨品牌是两个人,单键会误标瑞泰同号患者。 更关键的是这样判定是**实时全量**的:每次拉到主档都重算,不依赖增量窗口。 若改成"拉变化的病例再 join",窗口外的人会 lookup 未命中 → full upsert 落 null → 正在跟进的患者被静默洗回召回池。 【通用层两处改动 —— 都是既有脆弱性,被这个 SQL 形状第一次触发】 1. SQL 改写器改按括号深度定位顶层关键字(新增 topLevelIndexOf / splitSelectFrom): 主档 query 头一次出现 SELECT 列里的标量子查询,而 · injectIncrementalCursor 的 /SELECT (.+?) FROM (\w+)/ 非贪婪会撞上子查询的 FROM → 切出半截列表 + 错误表名,增量拉错表 · extractBusinessFilters 抓第一个 WHERE → 把子查询的 is_del=1 当成主档业务过滤 搬到外层 → 主档无该列 → CH 报错(增量空转探针一并错) 两者都是静默错法,故补 13 项测试锁死。 2. 反向拉主档:表名从 manifest.cohort.reverse_pull_from 读(不配 → 历史四张默认, 行为不变),键列/主档表名同步改读 cohort 配置。原先四张 jvs-dw 表名硬编码在通用 代码里,新增一张就要改 service —— 与「yaml 是宿主唯一差异」相悖。⭐ 同时修一处真 bug:反向拉写死 `SELECT *`,会丢掉主档 query 的派生列 —— 该列缺席 → canonical 无此键 → full 分支落 null → 在跟患者被反向拉这步洗回池。 改为复用主档 query 的 SELECT 列。 【增量怎么感知】主档 cursor 是 last_visit_time,人不来诊就拉不到 → 病例开/关 PAC 不知道。 故把该表作为独立 query 拉(不产 fact、无 assembler),仅为把变化的患者带进 cohort, 再由反向拉补出主档、重算派生列。cursor 用 SELECT 别名 changed_at = coalesce(updated_gmt_at, created_gmt_at):updated 有 44% 为 NULL(建后没改过, 恰是刚入池的新病例),直接当 cursor 会 `NULL > x` 恒 UNKNOWN 静默漏掉这批。 测试服 DW 实跑验证:主档/增量两条改写后的 SQL 均可执行,标记数 70,275 与直接统计一致。 790 tests / 51 suites 全绿。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
测试服务器每小时告警一次「friday DW 数据滞后 37 小时」,是误报: friday 是 **push 宿主**,数据由宿主主动推,sync_logs 的 cursor_before/after 按设计恒为 null,根本没有游标可推进。它之所以有游标,是历史上误跑过一次增量、 留下一条 fetched=0 的 incremental_bundle 记录,游标就永久停在 2026-07-30T10:00Z。 而同期 friday 正常 push 了 614 批 10.79 万行(含新接的跟进闸字段),数据流毫无问题 —— 拿"游标多久没推进"衡量 push 宿主,是**指标本身用错了**。 危害不只是烦人:该告警**永不自愈**(游标不可能再推进),每小时响一次, 最终把真告警淹掉 —— 一个永远在响的黄灯,看的人很快就不看了。 改法:遍历宿主时按 manifest 是否声明 sql_source 短路。 - 判据选 sql_source 而非 auto_sync:前者与 files **二选一**(manifest.schema 原话), 是"数据从哪来"的定义即摄入模式本身;auto_sync 只是"要不要自动跑",可临时关, 跟模式是两回事。 - 短路放在「还没跑过增量(无 cursor)」那条 warn **之前** —— 否则 push 宿主 只是换个姿势继续刷日志。 - getHostOpsConfig 读不到 manifest 时 hasSqlSource 兜底 false:宁可不报, 也不要对着未知宿主刷告警。 回归闸 tests/dw-lag-monitor-scope.spec.ts(5 项),已验证去掉守卫会报 2 项失败。 注:push 宿主的健康度应看「距上次成功 push 的时长」,是当前的监控盲区 (FRIDAY 断推三天也不会有任何告警),本次按要求不做,单独评估。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
按「文档即契约」推进,不再等宿主口头确认:契约已写明 has_active_complex_case, PAC 侧按此接好映射;宿主未推该列时 canonical 无此键 → 副表不写 → 闸保持未启用, 行为与接入前完全一致,所以提前接是零风险的。 【is_del 悬案已查源库坐实,不必再问宿主】 complex_case_info.is_del 注释「未删除1/已删除0」——**反直觉但是对的**,is_del=1 才是未删除: - 同注释的 complex_potential_demand 7 行全为 1(全存活,且被 30 行明细引用), 若 1=已删则全表皆删,不合理 - complex_case_info 47:8 ≈ 85% 存活是正常比例,反过来不是 之前之所以看着可疑,是因为**该库同时存在两种相反惯例**(customer_gift 是「未删除0/已删除1」) → 取数必须逐表看注释,不能套惯例。结论已写进 yaml 注释与契约文档。 【防护:脏值降级,不牵连主数据】 canonical hostFollowUpActive 加 .catch(null)。该字段是**可选的召回闸信号**, 而 patient 是**主数据**:若宿主推来无法识别的值(如 "Y2"/"待定"),不加 catch 会让 整条患者主档被 zod 拒收 —— 姓名/电话/生日全丢,为一个 nice-to-have 信号赔上主数据, 代价完全不成比例。脏值降级成 null(= 信号未提供 → 不启用闸),失败方向朝「照常召回」, 而不是「静默把人挡在池外」。0/1/true/false/y/n 等常见写法仍由 booleanFields 先行 coerce。 测试增至 21 项:新增映射存在断言 + 脏值不拖垮主档(校验 name 仍完好)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
宿主自己已建复杂病例 / 工单、客服或咨询师在推进的患者,PAC 不再发起召回: 两边同时联系同一个人,患者体验差,也显得两个系统各说各话。 落点 patient_profiles.host_follow_up_active(canonical hostFollowUpActive), 与 do_not_contact / deceased 并列在召回②合规硬过滤,但性质不同: 合规闸 = 法务/风险,永久,需人工解除 跟进闸 = 协作分工,临时,宿主停手下次推主档即回池 【本次最关键的决定:三态而非两态】 null = 宿主未提供该信号(jvs-dw 未接入)→ 不启用该闸 false = 宿主明确说"没在跟" true = 正在跟 → 排除 由此三条纪律,全部有测试锁住: 1. 列可空且**无 @default** —— 给默认值等于把存量 38 万患者一次性断言成某种状态, 且将来分不出"没接入"和"接了但没跟" 2. SQL 一律 `IS NOT TRUE`,**绝不能** `= false` —— 后者遇 NULL 恒 UNKNOWN, 会把未接入宿主的患者全部静默挡在池外(不报错、不留痕,只表现为池子空了) 3. upsert full 分支不能 `?? false`(那是替宿主表态);partial 分支未提供则不进 update 集合 改动: - canonical: hostFollowUpActive(可空无默认)+ 挂进 patient.booleanFields,自动吃 0/1 - prisma: 副表加列 + 索引;migration 加可空列不重写表,存量行为不变 - scenario SQL: 入池闸 + 注释框图同步 - recall-debug: compliance 增补该项,否则排查时会显示"合规通过"却查不到人 - 测试 host-follow-up-gate.spec.ts(18 项) FRIDAY 侧对应 has_active_complex_case,assembler 映射待宿主确认列名与 is_del 语义后再接, 故本次**未接 FRIDAY 映射** —— 该闸对所有宿主当前均为 NULL,行为与上线前完全一致。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
上一版定的是 active_complex_case_count(计数),多余: - 闸门只判有无,条数 PAC 不消费,纯属未被消费的精度 - complex_case_info 带 organization_id,跨诊所累加的条数在多品牌 SaaS 里语义含糊; EXISTS 反而是干净、且宿主易保证正确的口径 改为 has_active_complex_case(接受 1/0 或 true/false,PAC 归一层 coerce 成布尔)。 PAC 侧字段 hostFollowUpActive 不变(本就是布尔)。luoqi committed -
宿主自己已建复杂病例、客服/咨询师在推进的患者,PAC 不该再发起召回: 两边同时联系同一个人,患者体验差,也显得两个系统各说各话。 【落点】patient_profiles.host_follow_up_active(canonical: hostFollowUpActive), 与 do_not_contact / deceased 并列在②合规硬过滤,但性质不同: 合规闸 = 法务/风险,永久,需人工解除 跟进闸 = 协作分工,临时,宿主停止跟进即回池 判定用 `IS NOT TRUE` 而非 `= false` —— 该列可空,NULL 表示宿主未提供该信号 (如 jvs-dw 暂未接入),不能因 NULL != false 把这些患者全挡在池外。 【FRIDAY 侧口径】customer_basic_info 新增 inline 列 active_complex_case_count = 该患者未删除且 case_stage ∈ {待跟进,已咨询,已预约,诊疗中} 的 complex_case_info 条数; 已成单(需求已闭环)/ 暂停跟进(宿主已停手)不计,交还 PAC 召回。 为什么由宿主算而非整表推 complex_case_info: 1. case_stage 的码→中文映射不在库里(column_comment 只写「病例阶段:」值为空),PAC 无从翻译 2. 一患者可有多条病例,PAC 要的是患者粒度聚合结论 3. 与既有 inline 纪律一致(同 contacts_tel / std_code / class_name)⚠ ️ 两处待 FRIDAY 确认,确认前不启用该闸: ① 列名 active_complex_case_count 是 PAC 建议名(源表无此列,属新定义); 宿主若另有习惯叫法以宿主为准 —— 契约纪律是列名随宿主 ② complex_case_info.is_del 注释写「未删除1/已删除0」与惯例相反,实际数据 0/1 都有, 取数前须确认哪个值代表未删除,否则计数整体反掉 顺手修一处已与线上代码矛盾的文档:customer_referee_circle.referee_relationship 的方向说明还是旧的「本行 customer 是 referee 的 X」,而 a38f1b60 已按源库年龄实测 改成同向直译。文档同步为「本行 referee 是 customer 的 X」。 本次仅文档,无代码/schema 改动。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed
-
- 30 Jul, 2026 7 commits
-
-
## 嵌宿主可用性(修一个已上线的回归) - 宿主槽位跳转三级兜底:新标签页 → 顶层跳转 → 本 frame。上一版只有一行 window.open, 宿主 iframe 缺 allow-popups 时**点了没反应**(生产 actionUrls 全配着,路径可达)。 - 一步 open 带 URL,不再"先开 about:blank 再导航" —— 那个写法在 sandbox 下会因 「断了 opener 就无权导航该弹窗」抛 SecurityError。 - 交付文档 docs/integration/postmessage-actions.mdx(可直接给宿主开发)。 ## 宿主对接 - 打开潜在治疗的 postMessage 补 desc / treatments / stage 三字段(treatments 发项目名,不带「治疗」后缀)。 - 顶栏「回访」→「跟进」;去掉「已在新标签页打开」toast。 ## 画像 / 详情页 - 标签按业务字典 A/B/C/D 类区上色 + 定序(C→B→D→A),首屏与画像详情抽屉收成一套(删原三分组)。 - 话术头部新增「关联客户」:亲戚姓名 / 关系 / 年龄 + 档案链接(配了 VIEW_PATIENT 跳宿主,否则回落 PAC 工单页)。 - 亲戚关系方向按年龄实时纠正 —— 兜的是人工录错(瑞尔 4%),与 friday 摄入侧根治是两件事,两者都要。 - 潜在治疗中文一律 code 查表,修「潜在补牙」vs「充填治疗」口径不一致。 - 选患者列 / 详情左栏 300 → 320;召回池卡片去掉优先级五色点。 ## 数据 / 权限 - 「机会识别不准确」补必填多选:哪几类推荐治疗不准 → plan_executions.inaccurate_treatments (存 code / 仅统计 / 不参与抑制)。 - 医生名单缓存 6h → 10min + 重算收尾主动清;客服可自助返池(带归属闸,只能退自己的)。
luoqi committed -
patient_relation.yaml 的注释断言「码 = 本人(customer)是对方(referee)的 X」(依据 RecommendRelationshipEnum.reverseValue() 源码 + 早期年龄差推断),于是 enum_mapping 整体取逆:码2 爸爸→child、码3 子女→father/mother、码5 爷爷→grandchild…… 数据把这个假设推翻了:码本来就是「对方是本人的 X」,与 PAC 契约同向,不该反转。 【证据一:PAC 侧方向矛盾率】按「关系人比本人年长/年轻是否合理」判定,只算两边都建档 且都有生日的边: friday 1187/1255 = 94.6% ← 系统性反转 jvs-dw 432/10842 = 4.0% ← 人工零星录错的正常水平(其 yaml 是对的,不动) 互反对更硬:mother
↔ child 326 对里只有 8 对方向正确(2.5%);father↔ child 231 对里 9 对。 实样:秦佳(47) --mother--> 韩秦瑜(21),同时 韩秦瑜(21) --child--> 秦佳(47) —— 两条边互相自洽但都与年龄矛盾,去掉逆映射两条就都对了。 【证据二:源库独立复核(不经 PAC 摄入)】join customer_basic_info 两侧 birthday: 码10 妈妈 关系人年长 419/430(97%),平均大 27.4 岁 码 3 子女 本人年长 643/675(95%),平均小 26.8 岁 码 2 爸爸 关系人年长 252/277(91%),平均大 26.1 岁 码15 外公 关系人年长 20/22 (91%) | 码11 奶奶 17/19(89%) | 码16 外婆 15/17(88%) 码17 外孙 本人年长 31/36 (86%) | 码 6 孙辈 32/46(70%) 方法学对照:对称码(1配偶 348/354、4兄弟 56/55、7朋友 265/264)全是 50/50,说明这个 年龄判据本身干净,有方向码上的强烈偏斜不是判据偏差。 剩余 3–5% 符合逆读法的边判为源侧人工录错(与 jvs-dw 的 4% 同量级),不为它们反转全局。 顺手修掉一个静默 bug:旧版码3 只配了 "3|1"/"3|2",没有 "3|" —— 对方性别缺失的边 (源库 15 条)会掉进 _default: other。本版每个码的 |1 / |2 / | 三个变体都列全。 同向映射后性别不再参与判定(码2 自带 father、码10 自带 mother、码3→child 不分性别), transforms 里的 _referee_with_sex + push_fallback 成了死配置 —— 留待单独一次清理, 不跟方向修正混在一起改。 回归闸 tests/friday-relation-direction.spec.ts:不比对字符串字面量,而是用源库实测的 辈分方向锁语义(长辈码不得映射成晚辈词,反之亦然)。已验证该闸对旧 yaml 报 21 项失败。⚠ ️ 未含数据修复:patient_relations 唯一键是 (patient_id, related_external_id, relationship),relationship 变了就是新行而非更新 —— 重摄只会在错边旁边新增对边。 必须先删 friday 存量关系边再重摄。该步骤待部署后单独执行。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
同一个患者两处口径不一致(测试服实证): 召回池卡片 充填治疗 ← 拿 types=['filling'] 查 POTENTIAL_TREATMENT_CARD_LABEL 详情页 chip 潜在补牙 ← 读 data.labels,那是**算画像那一刻烤进 JSON 的**旧措辞 画像 JSON 长这样:{"types":["filling"],"labels":["潜在补牙"]} —— 措辞这几天改过三轮 (潜在种植→种植治疗→种植),烤死的那份自然全是旧词,要改回来得全量重算画像(百万级、几小时)。 labels.ts 里当初就写了这条预警,卡片按它改了,详情页那两处漏改。 改:首屏 chip + 画像标签云 + 画像详情抽屉,potential_treatment 的中文统统从 code 查表。 compactPersonaValue / personaValueLabels 多收一个 featureKey 参数,只对 potential_treatment 生效 —— 其余多值特征(治疗史/权益/禁忌/时间偏好…)没有 code 表、措辞也没改过,继续读 labels。 纪律写进注释:**凡是中文措辞可能改的标签,展示一律 code → 查表,别读 data.labels。** 本地实测(赵欣冉,types=[endo,filling]):首屏 chip 从「潜在根管 +1」变成「根管治疗 +1」, 与卡片一致。717 tests / 47 suites + web typecheck 通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>luoqi committed -
## 测试服全量实证(不是本地样本) jvs-dw 43,279 条亲戚边,与年龄矛盾 428 条 → 1.0%,方向基本可信 friday 2,427 条;只看父母/子女**对**更刺眼: mother↔ child 326 对里只有 8 对方向对(2.5%) father↔ child 231 对里只有 9 对(3.9%) → 97% 反了 ## 根因 data/friday/assemblers/patient_relation.yaml 的 enum_mapping 假设源码语义是 「本人是对方的 X」,于是映射时取了**逆关系**(码2 爸爸 → child)。数据说这个假设是错的: FRIDAY 存的码本来就是「对方是本人的 X」,跟 PAC 契约同向 —— 多反转了一次。 互反边可以证:秦佳(47)--mother-->韩秦瑜(21) 与 韩秦瑜--child-->秦佳 两条边互相自洽、 但都与年龄矛盾;去掉那次反转两条就都对了。⚠ ️ 摄入侧的修法(改 yaml + 重摄该资源)另开一件事 —— patient_relation 是 upsert 资源、 不进 transaction,没有原文可 reparse,得从源重拉。 ## 这次做的是展示侧:年龄定方向,不降级 按你的要求不降级成「亲属」,而是**实时算出真实关系**: · 标成长辈但对方更年轻 → 子女 / 孙辈 · 标成晚辈但对方更年长 → 按对方性别拆父/母(性别缺 → 中性「父母」,不硬猜) · 配偶 / 兄弟姐妹是对称关系,没有方向可纠,原样返回(同岁配偶很正常,不许被误标) · 任一方缺生日 → 原样返回,不猜(会显示的边 98.8% 两边都有生日) 纠正过的在关系后打一个 `*`,hover 显示「源数据记的是「mother」,与双方年龄不符,已按年龄纠正」—— 客服跟宿主对账时能看出差异在哪,而不是以为 PAC 显示错了。载荷同时留 relationshipRaw。 口径收在 @pac/types/kin-relationship.ts(纯函数,附实证数字),11 条用例锁住, 其中"同岁也算矛盾""配偶不许被纠""缺生日不猜"三条是最容易被后来人改坏的。 本地实测(王红兵 58 岁):源里的「妈妈 王迪 31岁」现在显示「子女* · 31岁」,配偶 59 岁不动。 717 tests / 47 suites + 两端 typecheck 通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>luoqi committed -
落表:plan_executions 加一列 inaccurate_treatments TEXT[] NOT NULL DEFAULT '{}'。 · **加列不建表**:它是 abandon_reasons 里 'inaccurate' 那项的限定词,不是独立事实 —— 同一次提交、同一条 execution、基数 ≤8。单独建表只多一次 join。 · **存 code 不存中文**:这列唯一价值是可统计(哪类召回最容易被判不准 → 回去改规则)。 中文措辞两天内改过三轮(潜在种植→种植治疗→种植),存中文等于把当时措辞烤进历史数据。 (同类教训:原 abandon_other 自由文本列就是因为"只能取到字面量、统计不了"被删的。) · **不塞 notes**:自由文本统计不了,正好废掉这个字段的唯一价值。⚠ ️ 按业务口径明确两条,都写进 schema 注释免得后来人误解: ① **不参与抑制**。抑制仍是信号级、按 plan 全部 reason 一起压 —— 客服选"只有种植不准" 不会只放过根管那条。看到这列别以为闸接上了。 ② **跟「其他原因」不构成关联**。只在勾了 inaccurate 时收集/落库;后端落库时再判一次, 免得前端残留勾选污染统计口径。 必填校验前后端各一道。服务端那道不是冗余:这条反馈事后补不回来(没人会为已结案的单再来一遍), 收进来一批空的就等于白填。 候选项 = 本患者画像 potential_treatment.types(同召回池卡片那排标签的来源),所以每人不同; 中文在前端查 POTENTIAL_TREATMENT_CARD_LABEL(改措辞即时生效)。 患者没有潜在治疗标签时不卡必填 —— 理论上不该发生,真遇到宁可放行,不让客服卡在填不了的必填项上。 708 tests / 46 suites + 两端 typecheck 通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>luoqi committed -
原来只跳 PAC 自己的工单页。宿主档案才是客服真正要看的那一页(有真手机号、有全量病历), PAC 工单页只是兜底 —— 所以跟本人的「原始档案」同一套逻辑: 配了 actionUrls.VIEW_PATIENT → 跳宿主档案,占位换成**这位关系人**的 {patientId} / {medicalRecordNumber} 没配 → 回落 /plans/<planId>(本 scope 内的活跃工单) 都没有 → 只显示信息,标「无档案入口」(原来叫「无工单」,现在两条路都可能缺,措辞跟着改) 为此后端 include 补了关系人的 externalId / medicalRecordNumber —— 注意 VIEW_PATIENT 模板里的 {patientId} 是**宿主侧 id**,不是 PAC 的 uuid,拿 relatedPatientId 去填会得到一个查不到人的链接。 关系人没建档时 externalId 退回边上的 relatedExternalId(那个始终有)。 本地两条路都实测: 未配 → /plans/2669df95… 配了 → …/patient?pid=115199&mrn=JN0A016246 —— pid/mrn 是**关系人的**(本人是 110959/JN0A025573), 没有串成本人的 id 708 tests / 46 suites + 两端 typecheck 通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>luoqi committed -
一家人常在同一家诊所看牙。客服打电话前看到"这人的配偶也是我们的客户",既能顺口关心、 也能顺手跟进那一位,所以放在话术头部(AI 简报下方)而不是收进抽屉。 patient_relations 表本来就有(摄自 fact_customer_referee_out,family_structure 特征在用), 详情接口原来也已经返回 contacts,这次补三件:
⭐ 只列**亲戚**。新增 KIN_RELATIONSHIPS 白名单(配偶/子女/孙辈/父母/祖辈/兄弟姐妹), friend 和 other **刻意不进** —— 那条边表摄自推荐关系,other 占了一半以上(本地 4043/6222), 混的是推荐人、代付人这类"认识但不是亲属"。字典把 other 译成「亲属」,但照它列出来 客服会把推荐人当家属去问病情,比不显示更糟。 (sibling 这里算亲戚,而 family_structure 把它算"非直系" —— 两处判的不是同一件事,口径不同是有意的)⭐ 年龄:从关系人 birthDate 现算(include 补 birthDate)。⭐ 链接目标 = PAC 自己的工单页,**且必须过 scope**。关系人常和本人不同品牌/诊所,不过滤就会 给出一个点进去 404 的链接 —— 那比"没有链接"更糟,客服会以为系统坏了。 批量一条 SQL 查(loadRelatedPlanIds),查不到 → planId=null → 只显示信息、位置上标「无工单」。 新标签页走 openHostUrl(带 sandbox 三级兜底),不顶掉当前工单。 未建档(linked=false / 无姓名)的关系不出行:一行只有关系没有人,客服拿不到任何可用信息。 本地实测(王红兵):渲染出「王希亮 配偶·59岁 → 关联客户档案」「王迪 妈妈·31岁 → …」, href=/plans/<id> target=_blank rel=noopener noreferrer。 708 tests / 46 suites + 两端 typecheck 通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>luoqi committed
-