1. 27 Aug, 2026 1 commit
    • docs(召回): 补第 3 例 孙海燕 TS0M012582 —— 召回正确,客服误标 · 480deab0
      原稿把她当残根误判个案归错了类。核实:27;45 是 2026-01-10 诊断的后天性
      牙齿缺失,医生给了简单种植术和固定桥两套方案,27;45 上的实际治疗 0 条,
      召回判得准(35 被 35-38 固定桥覆盖,引擎已正确排除)。
      
      她身上的残根误判(16;18;28;47)确实也存在,但已随今日上线修复,不是本例要害。
      要害是:真实的种植需求被一次"已治疗"误标压到 2126-07-30。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  2. 26 Aug, 2026 4 commits
    • docs(召回): 补第 2 例 王丽芳 TS0M013040 —— 残根被推荐种植,已改为拔牙 · 377d4547
      新增「处理」列区分已修复与待定。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(召回): 新建误召个案表 —— 首条 宋志宏 TS0M001982 · da7fc018
      一患者一行,后续个案追加。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(生产): 主进程堆上限 4G→8G —— 每 4 小时崩一次,三天没跑成过一次全量 plans · 470a44d9
      【症状】容器 exitCode=139 自动重启,08-23~08-26 共 **11 次**,间隔约 4 小时。
        页面无感(自动重启 + health 200),所以一直没人发现。
      
      【根因】每轮增量同步结束会在**主进程内**跑一次 runAllForHost 全量召回。补摄把患者
        灌到 111 万、召回池 54 万之后,selectHits 一次出 **96 万条命中**,连同 prefetch 的
        latest/snoozed/persona 全在堆里 —— 而主进程启动命令
        `node --enable-source-maps dist/main.js` **没带 max-old-space-size**,
        取 Node 默认 **4144 MB**,装不下:
          Mark-Compact 3985.6 → 3953.5 MB (mu = 0.008)   ← GC 已经回收不动
          FATAL ERROR: Reached heap limit — JavaScript heap out of memory
      
      【代价】那三天 `Result(ruier-grp)` 在日志里出现 **0 次** —— 一次完整的全量 plans
        都没跑成过。每轮崩在 selectHits 之后、写库之前:
           增量导入成功落库    persona 重算成功落库    plans 整轮作废
        数据不丢,丢的是"召回池的定时刷新"。另外每次崩溃留一个僵尸 sync 锁(重启后被回收),
        且会带走同容器里正在跑的 docker exec —— bj04 第一次尝试就是这么死的(白跑 1h27m,
        靠 batch.sh 的 3 次重试救回)。
      
      【为什么现在才发现】补摄期间增量被 sync 并发锁挡下(00:15/08:15/10:15 全是 skip),
        不跑全量 plans 就不崩 —— 实测连续 12 小时零崩溃。补摄一停,下一轮就会再崩。
      
      ️ 排查时别往"容器内存不够"上找:容器**没有内存上限**(HostConfig.Memory=0),
        宿主 15.36G 一直有 7-11G 空闲。撑爆的是 V8 自己的堆,与宿主余量无关。
      ️ 同一容器里 CLI 那条路(cold-import / recompute-plans)一直是 8192,
        **两条路配置不一致**,崩的一直是没配的这条。这次补齐。
      ️ 宿主内存账写进注释了:8G(服务) + 8G(CLI) = 16G > 15.36G。实测两者不会同时顶到
        上限(max-old-space 是天花板不是预留;实测峰值 服务 4G / CLI 4.9G),但
         别手工并发跑全量 `recompute-plans`(它不走 sync 锁)。
      
      ️ **这是止血不是根治**:池子还在涨,8G 迟早也会到顶。根治是「增量之后别跑全量」——
        recompute-plans 已经有 --clinics 收窄能力,同样的思路搬到
        SyncIncrementalSchedulerService 那一处即可。另开分支做。
      
      改动只有一行环境变量(docker-compose.prod.yml 的 pac-service),不动代码不动数据。
      YAML 已校验;managed overlay 只合并 DATABASE_URL/REDIS_URL(非 !override),不影响本项。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  3. 23 Aug, 2026 4 commits
    • fix(宿主跳转): 按子串换,别按域名标签精确匹配 —— arrail-test 漏了 · 8827ac0b
      2026-08-23 测试环境实测:瑞泰患者点跳转,还是去了 arrail。
      
      根因:上一版把 hostname 拆成标签、找**等于** `arrail` 的那一段。而测试环境的域名是
      `arrail-test.5i5ya.com`,标签是 `arrail-test` —— 精确匹配匹配不上,一个字都没换。
      各环境的域名前缀会带后缀(test / uat / …),枚举不完。
      
      ⇒ 认这个**词**本身,不认它周围是什么:hostname 里出现的别的品牌片段整词替换
         (arrail-test → rytime-test)。
      
      ️ 仍然只换 hostname, 不碰路径和查询串(`?from=arrail` 是宿主自己的业务参数)。
      ️ 解不出 URL(相对路径 / 哨兵值 'postMessage')仍原样返回。
      
      判据本身没问题 —— 同一时刻查过那个患者:sourceUnit='瑞泰',取值是对的,
      只是替换规则太严。补回归用例(带环境后缀的域名),前端 15 条全绿。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(宿主跳转): 瑞泰患者跳瑞尔门户 —— 打开时把 arrail 换成 rytime · eb5d8b9f
      jvs-dw 底下两个品牌各有一套门户:瑞尔 arrail.5i5ya.com / 瑞泰 rytime.5i5ya.com,
      两边**只差域名这一段**,路径参数完全一样。而 host.actionUrls 是 host 级配置、只有一套
      (现在配的是瑞尔那套)。于是瑞泰患者点「查看档案 / 病历 / 新建预约 / 回访」,
      会被带到瑞尔门户 —— 那边查不到这个人。
      
      ⇒ 打开的那一刻按**患者所属品牌**把域名换掉(lib/host-brand-url.ts)。
      
      【为什么按患者判、不按登录人判】
      登录人的品牌前端**拿不到**:/auth/session 下发的 sourceUnits 对诊所级用户是哨兵
      `__pac_deny_no_brand__`(那条路按 clinicIds 过滤,不走品牌维)。2026-08-23 拿生产
      三家诊所的 token 实测,全是这个值 —— 判不出品牌。而患者的 sourceUnit 是实打实的品牌名。
      语义上也是患者对:这些链接要打开的就是**那个患者**在宿主里的页面。
      
      【改了什么】
      - 后端 plan-aggregate:患者 + **关系人**都下发 sourceUnit
        (关系人用他自己的, 不拿本人的顶替 —— 亲属跨品牌时会跳错门户)
      - 新增 lib/host-brand-url.ts:applyBrandHost(url, sourceUnit)
      - resolveActionUrl / openHostAction 加可选的品牌参数;plan-detail-app 六个调用点全传上
      
      【两个容易漏的点】
      - 🔴 **HOST_ORIGIN 也必须换** —— 它是 postMessage 的 targetOrigin。PAC 嵌在 rytime 里
        而 targetOrigin 写着 arrail,浏览器**静默丢弃**这条消息:按钮点了没反应,控制台连个错都没有。
      - ️ **只换域名,不碰路径和查询串**:`?from=arrail` 是宿主自己的业务参数,替换它属于越界。
        URL 解不出来(相对路径 / 哨兵值 'postMessage')原样返回 —— 这条补丁不许把能用的链接变没。
      
      【纪律】 别把它做成通用的"多品牌路由"。它成立的前提是"两个门户只差一个域名片段"。
      真要支持多品牌,正解是 actionUrls 按品牌分组配置(host 管理页加一层),那时删掉这个文件。
      
      【验证】
      - 新增 host-brand-url.test.ts 14 条:替换 / 幂等 / 只换域名不碰路径查询串 / 哨兵值与
        相对路径原样 / 未知品牌与空值不动 / **每个调用点都带上了品牌参数**(源码断言,
        防的是"新加调用点时忘了传" —— 参数可选,漏了 tsc 不报)
      - 本地实测 /plans/:id/full:瑞泰患者 sourceUnit='瑞泰'、瑞尔患者 ='瑞尔'
      - 两端 tsc + 后端 1443 用例 + 前端 46 用例全绿
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  4. 22 Aug, 2026 5 commits
    • docs(口径): 钉住预约成功率的分母 —— 不筛结果项,未接通也算他打过 · e46bd0dd
      产品确认(2026-08-22):分母判据只有一条——**他有没有真打**。
      所以「没进展」那一桶(未接通 / 待跟进 / 需要找医生 / 已发短信 / 改约)
      照样进分母:打了没人接,那也是打了。
      
      ️ 未接通往往占分母的大头,所以这个数读的是「触达 × 转化」合起来的效率,
       不是"聊上了的人里谈成几成"。 别为了好看把 keep 组从分母里摘掉——
      那会变成另一个指标,而名字没变。
      
      唯一不在分母里的是「没有结果」:那些单一条执行记录都没有,天然不在
      plan_executions 里,不需要额外排除。
      
      代码一行没动,只是把这条决定写进 schema 注释 + 加一条回归(分母 SQL 里
      出现 outcome 就红)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • refine(主管工作台): 约上→预约上、完成→近N天已处置、成功率只出一个值 · 6d74e284
      主管走查第二轮:
      
      ① 「还没收尾」说明补「(含已经超期的)」
         到期不再自动回池(回收器 2026-08-19 删了),过了时限的单就压在客服手上,
         批次靠 inHand>0 留在这一组。不写明的话主管会以为过了时限的都沉到历史里了,
         而那恰恰是最该动手的一批。判据一个字没动。
      
      ② 「约上」→「预约上」
         「约上」仍有歧义:约好了既可以是约了预约、也可以是约了下次再联系,
         而这两列分的正是这件事。
      
      ③ 「完成」→「近 N 天已处置」
         主管问「完成和已处置是同一个意思吗」—— 是。同一个判据(真的落了通话结果),
         差的只是范围(本批 vs 近 N 天),而同一屏上叫了两个词。
         而且「完成」听起来像"做成了",是这套口径一直在躲的读法。
         ⇒ 统一叫「已处置」,并带上窗口前缀(预约成功率同)——
         不带的话切了 7天/30天,数字变了却看不出为什么。
      
      ④ 预约成功率只出一个百分比
         下面那行 rateNote(退回 N · 没动 M)删掉。
          released / idle 没有消失,仍在响应里 —— 只是不该挤在这一列下面。
      
      顺带拆掉一颗雷:AgentWorkloadRow.handled(= done + released)删了。
      它和界面上的「已处置」(只算写了结果的)是两个意思,同一个词两个含义 ——
      主管问的正是这个。服务端留局部量 touched 算「没动」,语义不变。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(主管工作台): 「约上」拆成约上/约下次,退回率换成预约成功率,「还在跑的」改名 · 0d7c6a53
      四处口径/措辞,主管实测提的:
      
      ① 「还在跑的」→「还没收尾」
         被读成"分配还没完成",像有个后台任务在跑。它说的是**批次**的生命周期。
         ️ 没叫「时限内」:判据有两支,另一支(还有单压在客服手上)在时限过了
         之后仍然成立,叫「时限内」会让过了期、单还压着的批次莫名待在这一组。
         判据一个字没动。
      
      ② 「约上」收窄成**只数转化新预约**,约定下次回访单列「约下次」
         两者原来都算 close 组、合成一个数,靠小字解释「含约定下次回访」。
         而两件事差着一整个台阶 —— 前者患者进了预约表、有个日子会来;
         后者只是客服答应"那天再联系您",患者什么都没答应,单子还挂在他手上
         (drivesStatus 一个 completed 一个 keep,状态机上就是两回事)。
         主管看到「约上 6」会当成 6 个人要来了。
      
      ③ 抽屉「通话成效」四个桶 → 五个
         第一格「成功」拆成「约上 / 约下次」。要靠小字解释的数,本身就该拆开摆。
         五个桶仍然穷尽(= 条数),回归里锁着。
      
      ④ 「退回率」→「预约成功率」
         分子 = 真的约上的( 不含约定下次回访 —— 算进来的话,一个只会往后拖的人
         能拖出一条漂亮的成功率);
         分母 = 「完成」他真打过的( 不是"已处置" —— 退回的单他压根没打,
         摆进分母等于因为他退单而扣他的成功率)。
          退回和没动没有消失,降级成后面那行小字:两者仍是主管调分配的输入(T7)。
      
      - schema: brief/detail 加 bookedNext,outcomes 加 appointed/scheduledNext
        (success 保留为两者合计, 但界面不再单独摆它),workload row 加 booked
      - SQL 由「close 组 IN (...)」改成按 outcome 逐项 FILTER,DISTINCT ON 不动
      - 助手解读规范同步(guides.ts),旧那句「outcomes.success 含约定下次回访」删掉
      - 新增 booked-vs-scheduled-next.spec.ts(21 条)
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(助手): 「这批都给某某」改不动确认单 —— 三层各错一处,且都不报错 · 245f0b01
      本批 5 人,主管说「这5人都分配给韩维」。界面回「已把整批铺给 1 位客服()」,
      括号是空的:一条都没动,而助手接着说「这版已经改成 5 人都归韩维」。
      
      三层:
      
      ① schema 判断本身就是错的
         `batch + assign` 被 refine 判非法。而 batch 是这句话唯一自然的表达,
         韩维就印在卡片下面那行「本批未分到」里。
         改判:batch + balance 合法;只有 batch + owner 不行 ——
         专属关系只有「待分配」那份数据带着(pending[].ownerUserId),
         已排好的条目卡片上查不到它归谁专属。
      
      ② 那条 refine 一次都没跑过(根因,不是 ① )
         两个改单工具的入参用的是**手写的 JSON Schema**(给模型看的那份),
         zod 那份从没进过运行时。两份 schema 各写各的,
         写在没跑的那份里的约束只是注释。
         ⇒ 加 `checkEditOps`:推给界面之前按 zod 校一遍,校不过整组退回给模型,
         并说清哪一条错在哪(只说「参数错误」它会原样再发一遍)。
         确认单、调整单同一条路。
      
      ③ 界面把 batch 解成空名单
         `return { planIds: [], label: '整批' }`,注释说"只有 set_expiry /
         set_benefit 走得到这里" —— 而那两件在上面就短路了(走批次级控件),
         真正走到这一行的恰恰是 assign / remove。
         改:整批 = 生效条目 + 还留在待分配里的,与顶栏「共 N 人」同一口径。
         另加一道总闸:选中 0 人就到此为止 —— 下面每个分支都会 push 一句
         "已如何如何",一条没动而主管读到的是成功。
      
      顺带(同一类):owner 分不下去的两种原因分开报。
      「卡片没带这条的专属关系」被报成「查不到在岗的专属客服」,
      那是一句关于数据的断言,而卡片没有资格下这个断言。
      
      - 整批移出仍然拦掉,但界面要吭声, 不许静默什么都不做
      - 工具描述补 batch 那一格该怎么填;ASSISTANT_PROMPT_VERSION → 2026-08-22-a
      - 新增 sheet-edit-batch-assign.spec.ts(14 条),改判 mcp-clinic-scope 两条旧断言
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  5. 21 Aug, 2026 7 commits
    • merge: perf/batch-unnest → main · 0e32600a
      luoqi committed
    • feat(埋点): user_activities —— 一张通用行为表(PV/UV + 漏斗 + 登录审计) · 1eddc1b6
      主管工作台此前没有任何使用数据可查,而 plan_event_logs 的 plan_id 是 NOT NULL,
      工作台没有「单」这个实体可挂 —— 这是「没有表可以落」的由来。
      
      不为工作台单独建表,建一张通用的:后面再有页面要看数据,加两个枚举值即可,不用迁移。
      
      【一张表回答三类问题】
        ① 谁在用    —— 按 (day, surface) 聚合;PV=条数,UV=distinct user_id
        ② 卡在哪一步 —— 同 surface 下 action 逐级衰减(打开 → 点格子 → 出确认单 → 确认)
        ③ 登录审计   —— surface='auth' 的行。PAC 此前**不记录登录**(没有 session/login 表,
           role 只活在 JWT 里),「谁何时以什么角色进来的」事后无从查证,这次补上。
      
      【几条纪律,都写进了 docs/design/user-activities.md】
      -  身份不由客户端给:userId/role/orgScope 一律从 JWT 取。上报体是 strictObject,
        前端多传一个 userId 被 Zod 直接拒(已实测:code=10002 unrecognized_keys)。
      -  页面 PV 不许塞进 plan_event_logs:那是「单的归属账本」,放空 plan_id 会让所有
        按 plan 聚合的查询悄悄多算,且不报错。
      -  surface/action 在库里是 text 不是 PG enum:加埋点不该要迁移,且改枚举不会让
        历史行变非法。role 同理存快照文本 → 读取侧 roles 是 string[] 而非 UserRole[],
        否则会出现「写得进读不出」。
      - ️ 埋点是旁路:前端不 await 不抛(微批 400ms + pagehide 冲刷),后端写失败只落日志。
        埋点挂了不能让主管点不了确认。
      
      【已埋 6 个点】
        auth/token_exchange      宿主后端换票(无 Origin —— 服务器间调用)
        auth/code_exchange       浏览器换 code(有 Origin = 从哪个宿主系统进来);只记首次
        supervisor_workbench/open            权限判定之后 —— 被弹走的客服不算一次访问
        supervisor_workbench/matrix_cell     {treatment,temperature,count}; 不记 rect(屏幕坐标换分辨率就没意义)
        supervisor_workbench/proposal_shown  按 requestId 去重,只在 active 记
        supervisor_workbench/assign_confirm  服务端返回之后 —— 点了但失败不算确认
      
      【本地实测(pac-service:3101 + pac-web:3100 + 真浏览器)】
        - 正常上报 accepted=2;伪造 userId 被拒 10002;无 token 拦在 10106(库里无脏行)
        - 真走一次换票+换 code:两条 auth 行落库,Origin 只出现在 code_exchange 上(符合预期)
        - 浏览器打开工作台 + 点格子:open/matrix_cell 落库,payload 正确
        - stats 端点:PV/UV 正确;leader 调被 platform:manage 拦下(10107)
      
      【过程中修掉的三个自己的坑】
        - track 路径漏了 /pac/v1 前缀(api-client 用 new URL(path,base) 不自动补)→ 请求打到
          /activities,envelope 里其实是 404 但 HTTP 仍 200(项目约定),网络面板一看才发现
        - open 记了两条:React 18 StrictMode 双挂载 + canDispatch 翻转 → 加 ref 守卫
        - session_id 恒空:access token 没有 jti(只有 refresh token 有)→ 改用 sub:iat,
          同一次登录内所有行为共享,漏斗能串起来
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • perf(分配): 落库改 unnest 数组 —— 甩掉 PG bind 上限那堵墙,上限 500 → 20000 · aabd8ae9
      生产实遇(杭州大厦 2026-08-20):23 位客服按默认值 23×15×3=1035,提案出了 537 条,
      主管点确认只回一句「请求字段校验失败」,在卡片上改什么都没用。
      
      根因不是业务,是**写法**:落库那条 UPDATE 用 `VALUES (…),(…),…`,每行 9 个 bind,
      而 PG 单条语句上限 32,767 → 32767/9 ≈ 3,640 行就是硬墙。测试机实测 N=4000 直接报
      `too many bind variables in prepared statement, expected maximum of 32767, received 36000`。
      
      ── 改法 ──────────────────────────────────────────────────
      每列打成一个数组,`unnest($1::uuid[], $2::text[], …)` —— **不管多少行永远 9 个 bind**。
      墙没了,而且顺带更快(PG 不用再解析几千个占位符)。
      
      测试机实测(事务内跑完回滚,一行数据没改):
        N=500    VALUES 444ms → unnest  74ms
        N=2000   VALUES 458ms → unnest 220ms
        N=4000   VALUES 炸  → unnest 433ms
      忠实形状(13 列全写 + 同事务账本写入),线性到 5 万条:
        500→0.25s  2千→0.6s  5千→1.4s  1万→2.7s  2万→5.6s  5万→13.6s(≈0.27ms/条)
      
      ── 事务保持不变 ──────────────────────────────────────────
      · 仍然是**一个事务**,全成或全不成 ——  没有拆成多批提交
      · 并发安全的那三个 where 守卫(status='active' / assignee IS NULL / superseded_at IS NULL)
        和 RETURNING 一字未动 —— 抢先认领的行照旧落不上、照旧出现在 skipped 里
      · 超时改成**随条数放宽**:30 秒打底 + 每条 3ms,上限 180 秒。
        一刀切 30 秒的话小批次白留 100 倍余量、大批次又不够。 不设无限:事务开着就持有行锁。
      
      ── 一起改的 ──────────────────────────────────────────────
      · 调整在手的三条写路径(改派/改时限/移出)同样换 unnest
      · 那条 `fp.id IN (Prisma.join(...))` 换 `= ANY($1::uuid[])`(3 万个 id:521ms → 219ms)
      · 报错文案说清他能动哪个旋钮 —— 他手上只有时限和每天几通,没有"本批人数"那个输入框
      
      ️ 上限没敢一次拉满。再往上调之前要先量这三样(注释里列了):事务外那几个
         Prisma `id:{in:[]}` 读(~32,000 个 bind 就炸)、推给浏览器的 6.3MB 载荷、卡片渲染 2 万行。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(recompute-plans): 加 --clinics 子集重算 —— 补摄后不必再全 host 全扫 · d5dce069
      按诊所补摄的三步里,前两步(cold-import / recompute-persona)都能用 --clinics 收窄,
      只有 plans 是全量:每批 patientsHit 26-29 万、耗时 1h50m,其中约 1h43m 花在 selectHits
      全表扫。今晚四批补摄,plans 一项就吃掉约 7 小时,而 90%+ 的扫描结果是 unchanged。
      
      本次给 recompute-plans 加 --clinics=<orgId,...>,口径与 cold-import / recompute-persona
      完全一致(patient_transactions.clinic_id 反查患者),把 selectHits 收窄到子集。
      
      ️ 关键点:收窄命中集**必须同步收窄关闭步骤**。runAllForHost 第 3 步会扫全租户的
      active+assigned,凡患者不在本轮命中集就判定"信号消失"关掉,挂在客服名下的还各记一条
      auto_release。只收窄 selectHits 不收窄关闭 = 一条命令清空整个召回池。故 runAllForHost
      新增 patientIds 入参时,两处一起收窄;关闭侧用内存 Set 过滤而非 SQL in(),避开 PG
      32767 bind 上限(子集动辄数万)。
      
      ️ SQL 形式踩坑:子集过滤写 `IN (SELECT … unnest(array))` 而非 `= ANY(array)`。
      结果等价但代价差一个数量级 —— 后者让规划器转去嵌套循环,而本 SQL 带 gap lateral join
      每行代价很高。本地实测(30000 患者库 / 子集 5825 人 / 11 个子场景):
        = ANY(array)          53.6s  ← 比全量 42.3s 还慢,perio_no_srp 一个就 35.5s
        IN (SELECT unnest())  10.2s  ← 命中数逐个相同,快 4.2 倍
      细节写进 treatment-initiation-recall.scenario.ts 注释,改那行前先按同口径量一遍。
      
      本地验证(jvs-dw 30000 患者 / 18591 active + 283 assigned):
        - 子集跑(5825 人):patientsHit 2552,plansClosed 0,duration 9.8s
          范围外 active 16317 / assigned 278 —— 跑前跑后逐字相同,一条没动
          (没有护栏的话这 16595 条会被全部关掉,plansClosed=0 就是护栏生效的证据)
        - 全量跑:patientsHit 19147,日志标 (全量),行为与改动前一致,无回归
        - 重复跑幂等:第二次 created=0 superseded=0 closed=0
      
      ️ --clinics 只用于「补摄后立刻补计划」,**不能替代日常全量**:召回是时间驱动的,
      数据一个字没变、沉默时长跨过阈值也该出计划,收窄就等于其余诊所当天不评估。
      定时全量照跑,这个参数只省掉补摄后的那一次全扫。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  6. 20 Aug, 2026 7 commits
    • fix(web): 冷启动那次续期绕过了共享单飞 —— 一次性 refresh token 被换两次 · 2207d61b
      「用着用着登录已过期」的根因,不是超时也不是网络,是 10103。
      
      生产 client-diag 实录(2026-08-20,两次):
      
        ev=start                accessValid=N      ← 隔了 2 小时回到页面,access 到点过期
        ev=refresh-ok                              ← bootstrap 自己换了一次,成功
        ev=runtime-refresh-fail err=code=10103     ← 首屏请求 401 兜底刷,拿的是同一张旧票
        ev=runtime-auth-clear   err=code=10106     ← 清凭据 → 弹「登录已过期」
      
      服务端的 refresh token 是**一次性**的:换票时 `redis.del(旧 jti)` 再发新的
      (auth.service.ts:468)。同一张票被两条路各换一次,后到的那条必得 10103。
      
      ️ 这个坑**被发现过一次,只修了一半**:本文件末尾"过期前 60s 定时刷"那段
         早就改成走 `tryRefresh` 了,注释把根因写得清清楚楚 —— 唯独 bootstrap 这条漏了。
         而它恰恰是**每次冷启动**都会走的路径,比定时器常见得多。
      
      改法:bootstrap 第 3 步(仅 refresh token 还在 → silent refresh)改走
      api-client 的共享单飞 `tryRefresh()`,与请求 401 兜底刷共用同一个"在途 refresh"。
      
       别改成"把 refresh token 做成可重复使用":一次性是安全设计(泄露的旧票立即失效)。
       也别在 doRefresh 里对 10103 重试:票已经死了,重试只会多一次 401。
      
      顺带核实 doRefresh 的清除边界是对的, 别动:
        5xx        → 不清 store(可能是暂时的)
        网络异常   → 不清
        服务端明确拒绝(10103)→ 才清
      所以掉线主因确实只有这一个竞态,不是网络抖动。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • merge: test → main —— 调整在手 + 三个分配修正上生产 · b7132fae
      带上去的:
      · 调整各客服在手的单(换人 / 改时限 / 收回)—— 主管终于能动已经分下去的单
      · 批次表新增「回收」列,「退回」改叫「客服退回」
      · 团队状态六列全部按诊所收窄(别家诊所的负荷串进本诊所工作台)
      · 提案封顶到单批硬上限 500(23 人的诊所按默认值必然出一张确认不了的单)
      · 准星最边上那一列/那一行被裁
      
      ️ 需要一次迁移:plan_event_logs 加 operation_id(可空 + 索引)。
         生产实测该表 2,298 行 / 1MB,普通 CREATE INDEX 毫秒级,不用 CONCURRENTLY。
       不需要重摄、不需要重算 —— 本次没动召回算法,也没动画像。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • merge: 调整在手 + 三个分配修正 → test · 504b0e19
      带进来两部分:
      · fix/session-batch 那三个修正(团队状态按诊所收窄 / 提案封顶到 500 / 准星裁切)
        —— 它与 feat/plan-rearrange 指向同一个提交,合一次两边都进来
      · 调整各客服在手的单(换人 / 改时限 / 收回)+ 批次表的「回收」列
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(调整在手): 主管可以调已经分下去的单 —— 换人 / 改时限 / 收回 · 523d260f
      门诊经理提的:单子分下去之后主管什么都做不了。原来只有一条绕道 ——
      让客服退回池子再重新分一批,而那条路有五处漏水:退回原因被污染、回池后
      不保证拿得回来、掉批次(旧批 planned 静默缩水)、福利断了、超期被洗掉。
      
      现在:主管跟助手说一句 → 助手出一版**调整单** → 他过目微调 → 手动确认。
      行为跟确认单一致,但是**独立的一条路,不碰确认单**(产品定:不要污染)。
      
      ── 数据层(最小)──────────────────────────────────────────
      · plan_event_logs 加 operation_id(可空 + 索引),把同一次调整的 N 条串起来。
         没借用 assignment_id:那一列没有外键、塞得进,但批次报表全在它上面出数,
          两个 id 空间混一列迟早串台。
      · 三个新事件 transfer / expiry_changed / rearrange_remove,都 byHuman:false。
        🔴 移出**绝不能复用 release**:批次的退回原因分布只按 assignment_id 过滤、
          不看是谁干的,复用会同时污染那张分布、并给原客服记一笔他没做过的退回。
      
      ── 落库规则 ──────────────────────────────────────────────
      · assignment_id 一律不动(原批归属完整保留,分母不塌)
      · 逐条落,落不上的如实回报 ——  不照抄 create() 的「数量对不上整体拒绝」:
        主管调了二十条因为客服刚打完一条就全作废,一次就废掉这个功能
      · 幂等:同一个 operationId 重复提交一个字都不写
      
      ── 批次表认得这件事 ──────────────────────────────────────
      主管回收的单原来在批次表上凭空消失(既不在没动、也不在退回)。
      新增「回收」一列,「退回」改叫「客服退回」,两者在列表 / 详情 / 按客服拆
      三处都分开;进度那句也补上第三种来路。
      
      ── 助手 ──────────────────────────────────────────────────
      三个新工具(propose_rearrange / edit_rearrange_sheet / show_rearrange),
      与确认单那三件完全隔离。两条纪律写进描述:
      · 出完就停,等他说怎么调 —— 把患者从一位客服手里挪走是关系层面的决定
      · 他说出了要动哪些才动手;只说不满意什么的时候,那三个数是他要定的
      
      ASSISTANT_PROMPT_VERSION → assistant@2026-08-20-f
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(web): 准星最边上那一列/那一行被裁 —— 放大挪到数字上,别放大按钮盒子 · 2b8ba8b4
      现场:「3 年以上」那列(最右)瞄中时,左两角完好,右两角只剩两个短横杠、
      竖臂一条不见。
      
      几何(格子 90px):
      
        渐变面为圆角带 overflow-hidden
        button 上的 scale(1.06) → 盒子右边缘外溢 2.70px
        准星 ::after inset:2px  → 右边缘落在「容器右 + 0.58px」
        ⇒ 竖臂(缩放后 1.59px 宽)被裁掉 0.58px,横臂同样丢掉外侧一截
      
      修法:把 scale 从 button 挪到里面包数字的 span。
      盒子回到格子内,准星竖臂稳稳落在 [右-3.5px, 右-2.0px];
      而「数字微微抬起」这个原意一点没丢。
      
       别改成去掉 overflow-hidden:它是用来把**行/列高亮的白纱**裁进圆角的,
         去掉之后首行末行的纱会把四个圆角戳成方的。
       也别把 HARD 值往里缩(inset 调大):那会让准星整体离格子边更远,
         四个角互相靠拢,读起来就不是"框住这一格"了。
      
      顺带:pacReticleLock 的 from 从 inset:-2px 改成 0 —— 负 inset = 画到格子外面,
      最边上那一格的入场帧同样会被裁半个角。0 → 2px 一样读得出"从边缘咬进来"。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(分配): 提案封顶到单批硬上限 —— 23 人的诊所按默认值必然出一张确认不了的单 · 9f992b4f
      生产实遇(杭州大厦,2026-08-20):确认单 537 条,点「确认分配」只回一句
      「请求字段校验失败」,主管在卡片上改什么都没用。
      
      根因是**提案层不知道确认接口的上限**:
      
        基数 = 在岗人数 × 每人每天通话数 × 时限天数 = 23 × 15 × 3 = 1035
        target = min(基数, 候选总数)                = min(1035, 5996) = 1035
        落人后 items                                 = 537
        确认接口 CreateAssignmentRequestSchema        items.max(500)   ← 撞这里
      
      ️ 不是边界情况:**12 个客服以上、用默认值就必然触发**(12×15×3 = 540 > 500)。
      
      修法一:target 多取一个 min。与既有的「候选不够就压低 target」完全同构,
      且同样  不回写基数(回写会把主管的习惯值永久钉死在 500)。
       不是把 HARD_LIMIT 调大 —— 它是技术护栏(单事务 + PG bind 变量上限),
         不是业务旋钮,schema 那条注释写得很清楚。
      
      修法二:让报错说人话。这一条独立于上面 —— 就算永不再触发,
      下一个撞上 zod 校验的人也不该只看到七个字。
      
        · schema: items.max 带自定义 message,含具体数字与下一步动作
        · 前端:  确认失败时拼 ApiError.details 里的字段级 message
                 (服务端 all-exceptions.filter 早就把 details 带出来了,前端只取了 msg)
        ️ 只拼 message, 不拼 path/code —— 「items」「too_big」对主管没有意义,
           只会让他觉得"这是 bug,不是我该处理的事"。
      
      排查期间未做任何确认操作:plan_assignments 仍为 0 行。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(分配): 团队状态六列全部按诊所收窄 —— 别家诊所的负荷串进本诊所工作台 · ba12851e
      杭州大厦主管工作台显示「23 位在岗 · 在手 53」,其中李欣 52 条。实查:
      
        杭州大厦自己的 followup_plans:active 17,938 / superseded 4,137 / **assigned 0**
        那 52 条的 target_clinic_id 全是上海世纪大道 —— 07-30 她在**那边**自己认领的
        (plan_event_logs: event=claim, actor=assignee=5679, 08:52~10:54)
      
      ⇒ 杭州大厦一条都没分出去,整栏是幻觉。
      
      根因:**名册按诊所取、计数却按全租户取**。客服在哪家做过回访就进哪家名册
      (名册从回访行为反推,宿主没给权威员工名单),跨诊所工作的人于是把别家的
      在手/超期/完成全带了过来。`assignee_user_id IN (本诊所名册)` 挡不住 ——
      它限的是**人**,不是**单**。李欣在杭州大厦有 3,225 条回访、在上海世纪大道
      有 196 条,两家名册都有她。
      
      六支查询全改, 不只改「在手」:同一屏上"在手 0 / 超期 41"这种自相矛盾
      比全错更难查。plan_event_logs / plan_executions 没有诊所列,经 plan_id 反查
      followup_plans.target_clinic_id(**归属**), 不是 executor_clinic_id(执行地)。
      
      生产数据实测(只读):
        杭州大厦   修复前 5679→52 / 2998→1(合计 53)  修复后 **0 行** ✓
        上海世纪大道 修复后 5679→52 / 1928→2          ← 52 条归位 ✓
      
      代价(已知并接受):跨诊所客服本屏不再显示他在别家的负荷。分配前想知道
      "这人在别处忙不忙"需要另加一列(产品未定), 别为此把诊所过滤去掉。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  7. 19 Aug, 2026 11 commits
    • merge: test → main —— 分配功能上生产 · bbc530b9
      分配(生产首次上线)
      · 初选矩阵 8 治疗项 × 6 时间档,点一格 = 把这批人交给助手
      · 助手出确认单:谁分给谁、每人几条、谁待定;可移出/改派/改时限/挂福利
      · 落人规则:有专属的回自己人手上,其余给当前手上最少的( 无容量上限)
      · 确认 30 分钟内可撤销;批次跟踪 + 团队现在什么状态
      
      口径
      · 默认时限 3 天(它是乘数:本批人数 = 在岗 × 每天 15 通 × 时限)
      · **到期不回池** —— 时限到了什么都不发生,单子留在原客服手上,只记为超期
      · 超期 = 此刻还挂在客服手上 + 过了时限 + 没约下次;已回池的 不算
      
      顺带
      · 助手每轮往 agent_invocations 落一行(只落该调的工具调没调)
      · 提示词与 21 个工具描述全面去黑话;修了助手画图用错品牌色(teal → #0032A0)
      · 初选矩阵提速约 9 倍(标签预计算落库)
      · 话术分渠道;客服名册按「在这儿干活」判在岗;文档站新增《分配助手》
      
      🔴 上线必做两件(详见预案)
      · 部署完**立刻**回填标签:397,712 行,不跑矩阵 48 格全是 0 且不报错
      · 全量重算 plan **今晚不跑** —— 冷却期 14→90 天会关掉 32,371 条在跑的单(17.5%)
      luoqi committed
    • merge: 到期不回池 + 默认时限 3 天 + 提示词去黑话 + 两处交互 · 413a27d0
      行为
      · **到期不再回池** —— 时限到了什么都不发生,单子留在原客服手上,只记为超期
        (回收器整个删掉;主管分配的意思是有始有终、容许短时超期、减少客服间调度)
      · **超期只认此刻在手的** —— 已经回池、没有客服挂着的 不算(它不是谁的超期);
        连带从窗口口径改回此刻口径,表头改「当前超期」
      · **默认时限 1 天 → 3 天** —— 时限是乘数,连动本批人数 N 与 daily_overload 阈值
      
      文案
      · 提示词与工具描述去黑话:掩码/键序/只读/维度/命中/负载/分档/快照/360 全景/口子…
        其中「本系统」是提示词在违反它自己那份禁词表
      · 「到期自动退回」整套说法退场,统一成 时限(几天内打完)+ 超期(单子仍在他手上)
      · 矩阵那句「按…分档」→「每一列 = 患者最后一次到诊距今多久」,并进面板抬头
      🔴 顺手修:render_artifact 让模型用「PAC 主题 teal #0D9488」——那不是主色(#0032A0),
        助手画的每张图表都跟界面不是一套色
      
      交互
      · 点确认前顺手问一句「这批带个福利吗」(不填也能确认)
      · 矩阵悬停 = 瞄准:角括号准星(咬合→呼吸)+ 行列十字
      · 助手默认窗口 620 → 760,拿掉工具灰行与「执行过程」
      
      工作台超期提醒加强:抬头「其中 N 条已超期」+ 每人「最久 N 天」
      luoqi committed
    • fix(超期): 已经回池的 不算超期 —— 只认此刻还挂在客服手上的 · fde942f1
      产品指出的判据:「在手的才算超期,已经在池(没有客服)的不算」。**对,而上一版不是这样。**
      
      ── 实测:界面上那个数是幻觉 ──────────────────────────────────────
      测试服此刻:
        账本那一支(已回池、没有客服挂着)   841 条
        在手那一支(真挂在人手上)             0 条
      而工作台上显示的「超期 41 / 193」**100% 来自前者** —— 一屏数字没有一条是真的,
      主管对它们也做不了任何事(没人挂着的单不是谁的超期),而且不报错。
      
      ── 改法:一个来源,两处同判据 ────────────────────────────────────
      `workload()` 的 overdue 与批次的 `expired` 都收敛成:
        status='assigned' + 过了时限 + 没约下次回访
       账本里 `auto_release/assignment_expired` 那一支整个删掉(含那段 LATERAL 归属回捞)——
        它是**回收器时代**的产物:当时过期的单当场被收走,"当前超期"结构上永远是 0,
        只能去账本里数"曾经被收走过多少条"。回收器没了,这个前提也没了。
      ️ 代价:2026-08-19 之前的批次这一列变 0。分配功能还没正式上线,没有要保的历史(产品定)。
      
      ── 连带:「超期」从**窗口口径**改回**此刻口径** ──────────────────
      它现在是「在手」的一个子集,不随近 7 天 / 近 30 天变。
      ⇒ 表头改「**当前**超期」,与左边「**当前**在手」对齐 —— 不写「当前」的话,
        它紧挨在窗口切换器下面,会被读成"这 7 天超了几条"。
      ️ 2026-08-07 当初把它改成窗口口径的那条理由(回收器让此刻口径永远是 0)
        连同前提一起作废了,契约与面板注释都标了沿革, 别照旧注释改回去。
      
      ── 用例 ────────────────────────────────────────────────────────
      上一版我写的两条方向反了(「老批次的到期事件仍然算数」「两支并存时相加」),
      改成断**反面**:账本填满也一条都不许漏进来;两边都有值时只认在手那个, 不相加。
      
      验证:1357 passed,两个 app 的 tsc 绿,next build 通过。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(提示词): 清掉黑话 —— 模型看得见的每一句都换成主管认得的说法 · a8712aaa
      产品:「检查提示词和工具描述里还有没有黑话,改成好的产品名称定义」。
      判据是这份提示词自己的第一条:**使用者的词汇表就是他在界面上见过的那些**。
      
      ── 五层提示词 ──────────────────────────────────────────────────
        取值码 / 字段名 / 工具名   → 英文代号 / 数据里的列名 / 工具的名字
        手机号只显示**掩码**       → 手机号只给中间打了码的那种
        你做的一切都是**只读**     → 你做的都只是查看,改不了任何东西
        **键序**就是段落顺序       → 一份排好顺序的事实,从上往下的次序就是你该讲的次序
        **渲染**成图               → 画成图
        不做**画像分层**           → 不先把人分成几类
        各**维度**的数是分别**命中**多少 → 每一类的数是各自有多少人
        **负载**就是在手量本身     → 手上压着多少就是多少
      🔴 **本系统**不统计成功与否 → 没有成功与否的统计
         —— 「本系统」就明明白白列在同一份提示词的公文腔禁词表里,**它在违反自己**
         (与 2026-08-13 抓到的「写库」「水位」同一类,那条纪律写在文件头)。
      
      ── 工具描述 ────────────────────────────────────────────────────
        患者 **360 全景**  → 一次把一个患者拉全
        **价值分群 / 生命周期阶段** → 价值与阶段(产品里那一组的名字)
        哪些**口子**可切   → 能按哪几类切
        优先级**分档**     → 优先级高/中/低各多少
        在手**负载**       → 手上压着多少
        决策**快照**       → 分配当时记下来的依据
        话术**缓存**作废   → 已经写好的话术作废
        **消歧**           → 认人
        聚焦关键**字段**   → 只留要紧的那几项
        渲染成可视化卡片   → 画成一张图表卡片
      ️ `render_artifact` 的 execute 返回值也一起改(「已在界面渲染该卡片」→「已经画在界面上了」):
        返回值会回到模型上下文,它可能照着复述。
      
      🔴 **顺手抓到一个不是黑话的错**:`render_artifact` 让模型用「PAC 主题 teal #0D9488」——
        **那不是 PAC 的主色**。前端早把 `teal-*` 整体改名成 `brand-*`(263 处),
        主色是 PANTONE 286 C `#0032A0`,而这句留在服务端没跟着改
        ⇒ 助手画出来的每一张图表都是青绿色的,跟同屏界面不是一套色。已改。
      ️ 这里只能写死十六进制(模型拿不到 CSS 变量),改品牌色时这一处要跟着改。
      
      ️ 测试跟着改判据:`mcp-clinic-scope` 原来钉着「取值码、字段名、工具名」那串原文,
        改成断**这四类还在不在** —— 钉原文的话,每次把话说得更像人话都要来改一次测试,
        而规则一个字没变。
      
      promptVersion → assistant@2026-08-19-a。验证:1357 passed,tsc 绿。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(分配): 到期不再回池 —— 超期留在原客服手上,只是记为超期 · db989933
      产品定。理由是**分配的语义**:
        · **主管分配的意思是有始有终** —— 他决定了这批人交给谁,那就该在他手上走完;
          回池等于把这个决定作废、再重新分一次。
        · **容许客服短时超期** —— 没按时打完是常态不是异常,给缓冲让他继续跟进。
        · **减少客服之间的调度** —— 回池再分会让同一批患者在人之间来回换手,而换手本身有成本。
       别用测试服的落人分布给这条"补证据":那是种子数据,说明不了真实运营。
        这条站在语义上,不站在概率上。
      
      ── 行为 ────────────────────────────────────────────────────────
      `AssignmentExpiryScheduler` **整个删掉**( 不是翻 PAC_ASSIGNMENT_EXPIRY):
      时限到了什么都不发生 —— 状态不变、归属不变,只是从此算「超期」。
      墓碑与沿革写在 `plan.module.ts` 上,原实现与那 10 条用例在 git 里。
      ️ 单子回池仍有两条路,都是**人主动做的**:客服退回、主管撤销(限时 30 分钟)。
      
      ️ 连带成立、 别当 bug 修:①「在手」只增不减 —— 那是真的还压在他手上,
        产品要看的就是整体负载( **不拆**成"时限内/超期"两个数);
        ②「最忙的那位」会常亮 —— 它本来就是工作量预估不是异常告警。
      
      ── 🔴 超期换了源(最容易静默出错的一处)────────────────────────
      从此不会再有新的 `auto_release/assignment_expired` 事件。只数账本的话,
      **08-19 之后每个批次这一列都永远是 0**,而主管看到的是"这批没人超期"。
      ⇒ 批次的 `expired` 改成**取并集**:账本里到期回收过的(历史) ∪ 此刻仍挂在人手上
        且已过时限的(现状),与 `workload()` 那两支同一套算法。三条新用例钉住。
      
      ── 文案统一口径(「回池」不再出现在与"到期"相关的任何一句里)──────
      退场:自动退回 / 落回池子 / 到期回池 / 即将退回池子
      在用:**时限**(几天内打完)· **超期**(过了时限还没处置,单子仍在他手上)
        · 引导节点「不处理会怎样」→「就按 N 天发;超过这个天数没打完的记为超期,单子仍在这位客服手上」
        · propose_assignment 的 expiresInDays 描述(模型会照着念)→「要在几天内打完;
          过了不会被收走,仍在原来那位客服手上,只是记为超期」
        · 确认单右上角「N 天后自动退回」→「N 天内打完」
        · 客服执行页「已过期 · 即将退回池子」→「已超期」;「还剩 3 天退回」→「还剩 3 天」
        · 批次列头「到期回池」→「超期」
      ️ 「退回」这个词**没有全禁** —— 客服主动退回、主管撤销确实会回池,那两条路照旧。
      
      ── 工作台的超期提醒加强 ────────────────────────────────────────
      · 抬头多一句「其中 N 条已超期」—— 一个人 41 条不吓人,全队 196 条是另一回事
      · 超期那一格带上「最久 N 天」(新增 `overdueOldestDays`)—— 昨天刚过时限的 41 条
        和压了 12 天的 41 条,该做的事完全不同
      
      存量不用管:分配功能还没正式上线。
      
      验证:1354 passed,两个 app 的 tsc 绿,next build 通过。
      
      ️ 顺手修了一处**上一个提交漏掉的**:`mcp-clinic-scope` 扫源码断言 `本批福利: benefit.trim()`,
        而福利浮层那次把它改成了 `benefitNow` —— 那次只跑了 tsc/build,没跑 jest。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(矩阵): 悬停 = 瞄准 —— 角括号准星 + 行列十字 · b85ac222
      产品:「鼠标可以标出忽大忽小的瞄靶,其他动效奇思妙想一下」。
      点下去已经有一串粒子飞向助手(pacStreamFly)——悬停这一段就接在它前面:**瞄准,然后发射**。
      
      ── 一件事的两半 ────────────────────────────────────────────────
      · **准星**(globals.css `.pac-reticle`)—— 你正指着**哪一格**
        四角角括号,零 DOM 零 SVG(8 条渐变画四个角,随格子尺寸自适应)。
        两段动画:先「咬合」(0.18s 从格子外一口收进来),再「呼吸」(2.4s,±1.5px)。
        ️ 呼吸幅度刻意只有 1.5px —— 再大就从"锁定中"变成"这里出错了"。
        按下时收紧 3.5px 并停住:扣扳机的那一下,紧接着交给粒子。
      · **十字**(组件里的行/列高亮)—— 那一格**是谁 × 什么时候**
        8 行 × 6 列,格子离行头和列头都很远;光把格子框起来答不了后一半。
        命中行:行标签右移 2px + 上主色(位移比变色更早被眼睛捕捉到,而那列字只有两三个)。
        命中列:列头圆点长大一圈,当这条轴的刻度指针。
        其余整片退让(opacity 35~45%), 不是给命中的加亮 —— 加亮会动到色相,而色相本身是信息。
      
      ── ️ 几条老纪律都没破 ─────────────────────────────────────────
      · 「hover  别换底色,会在渐变面上戳一个洞」管的是**一格**单独换底;
        这里是**一行 + 一列**,读出来是十字不是洞,且瞬时、 从不由数量驱动。白纱封顶 8%。
      · 实线框留给「**已经选中**」,准星是「**正指着**」—— 两个状态长得一样就分不出,
        而这一点下去人就发出去了。
      · focus-visible 仍走实线 outline:键盘可达性 不能只靠一段动画。
      · `prefers-reduced-motion`:准星照旧画出来(它是**信息**),只是不动。
      
      🔴 `pac-reticle` 由 **state** 挂, 不写成 CSS 的 `:hover::after`:
        0 人的格子是 disabled,而 disabled 的 hover 跨浏览器不一致 —— 走 state 才拦得住;
        且准星与十字必须同生同灭,同一个来源才不会一个亮一个不亮。
      ️ 整片渐变面上挂 `onMouseLeave` 兜底清空:只靠每格的 leave,鼠标直接飞出容器时
        有概率被下一个 enter 抢先,十字会留在原地不走。
      
      验证:tsc 绿,next build 通过;产物 CSS 里 `.pac-reticle::after`、两个 keyframes、
      `:active` 与 reduced-motion 分支都在,白纱编成 #ffffff14。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(确认单): 点确认前顺手问一句「这批带个福利吗」 · e6558355
      产品:「点确认分配的时候轻量弹一个输入框,提醒输入福利,说明用意,不挂也没关系」。
      形态对齐 `plan-detail/quick-log-popover`(这一屏已有的同类:跳走之前顺手问一句)。
      
      ── 为什么是**这个时刻** ──────────────────────────────────────────
      福利只影响**此后生成**的话术。确认之后客服就开始陆续打开这批单,
      每过一会儿能用上它的人就少一个 —— 这颗按钮是最后一个还来得及的停顿点。
      
      ── ️ 它不是一道闸 ──────────────────────────────────────────────
      输入框空着照样确认(浮层里主按钮永远可点)。 别改成必填:
      门越重人越会去找绕开的路(同 QuickLogPopover 上那段)。
      
      ── ️ 与 2026-08-15 删掉的 `benefit_missing` 不是一回事 ─────────────
      那条是**确认之后**在卡片里长出来的黄色引导块(产品判定:确认单里不做福利引导)。
      这一层在**确认之前**、吸附在按钮上、不占卡片版面、不留残留。
       别因为"福利提醒删过一次"就把这层也删了 —— 删的是位置和形态,不是这件事。
      
      ── ️ 刻意不写的 ────────────────────────────────────────────────
      福利话术的护栏(不得追加条件/期限/承诺)在**生成侧**, 不在这个输入框 ——
      写在这儿就是第二份规则,两边迟早会漂。这里只用 placeholder 示范**形状**
      (一句短的、能直接念出口的话)。maxLength 200 与落库 schema 对齐。
      
      🔴 `confirm()` 改成收**参数**而不是读 `benefit` 这个 state:
        浮层是"边填边确认",`setBenefit(v)` 之后紧接着 `confirm()`,闭包里拿到的是
        **上一帧**的空串 —— 福利会静默丢掉,而落库和话术都不报错。
      ️ 外层按钮只当触发器, 别同时挂 `onClick={confirm}`:两条路都会确认,
        而幂等键相同的第二次请求服务端当重放静默吃掉,界面上像"点了没反应"。
      
      验证:tsc 绿,next build 通过。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(分配): 默认时限 1 天 → 3 天 · 9cd29898
      产品定。️ 时限是**乘数不是摆设**,改它连动三处,一处都不能漏:
      
        ① 本批人数 N = 在岗人数 × 每天 15 通 × 时限  → 一批推的人数跟着变(135 → 405,以 9 人在岗算)
        ② `daily_overload` 阈值 = 每天几通 × 时限     → 判「谁超期」的尺从 15 条变成 45 条
        ③ 对外文档里的示例式子                        → 改了默认不改示例,文档就在教一个不存在的口径
      
      ── 改了哪些 ──────────────────────────────────────────────────────
      · `ASSIGNMENT_EXPIRES_DAYS_DEFAULT` 1 → 3(唯一真源;服务端两处引用它,web 全程读服务端下发,
        DB 无默认值 —— 所以没有第二个地方存着这个数)
      · 测试:默认口径那几条改成断**式子**(`9 * RATE * D`), 不再写死 135/405 那个积 ——
        这个数调过两次(3→1→3),每次都要来改一遍字面量,而式子一次没变。
        另加一条**单独钉住 D=3** 的用例:式子是不变式,而"默认几天"是产品决定,改它必须是有意识的动作。
      · `assignment-agent.mdx` 六处示例重算成自洽的一组:
        405 = 在岗 9 人 × 15 通 × 3 天;最忙那位 75 条 ≈ 5 天 > 3 天;建议「改成 5 天」。
      · 两份文档里「daily_overload 每批基本都会亮」的说法:那是 **D=1 的推论**(阈值 15 条),
         不是这个节点的性质。D=3 之后它回到「真的压不下」才亮 —— 措辞要求( 不写「打不完/超了/过载」)
        与频率无关,照旧成立。
      
      ──  刻意没动 ───────────────────────────────────────────────────
      注释里那些「× 1 天」全是**事故记录**(2026-08-14/15 实测原话),沿革改了反而失真。
      `golden/run.ts` 与 signals 用例里的 `expiresInDays: 1` 是**显式传值**的夹具,不是默认值。
      
      验证:1363 passed(+1),两个 app 的 tsc 绿,types dist 运行时读到 3。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(措辞): 「分档」是内话 —— 改成「每一列 = …」并入面板抬头 · 645aa88c
      产品:「分档是个内话黑话」+「不用分那么散」。
      
      · 「按患者最后一次到诊距今多久**分档**」→「每一列 = 患者最后一次到诊距今多久」
        主管看到的是一张表,他要知道的是「这些列各是什么」, 不是"系统按什么规则把人分进桶里"。
      · 从表格**下面**并进面板抬头,跟「点一格 = 把这批人交给助手」同一行,用 `·` 分隔
        (这一页别处也是这么并的:「1 个诊所 · 主管」)。两句同为"这一屏怎么读",
        分在表上表下等于被表格劈成两半。
      · 两句都不加粗:同一条灰色说明带,只加粗其中一句会读成"这句更要紧"。
      
      ️ 「患者」两个字不能丢 —— 末诊是**患者级**的,同一个人的几个潜在治疗必然落同一列;
        去掉就变成"这一格治疗三个月内",主管会以为这人刚诊断。理由钉在 `TEMPERATURE_AXIS_ZH` 上。
       `pool-matrix` 原地留注释指向新位置,别再写回第二份。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • merge: 助手窗口调高 + 拿掉工具灰行与「执行过程」 · edfc2010
      · 默认高度 620 → 760(max-h 78vh → 88vh)——确认单一出正文就被挤到要滚
      · 正文里的「出分配方案 `propose_assignment`」灰行删掉:后半是函数名,不是主管的词
      · 末尾「执行过程 · N 步 …→ 摆选项按钮」删掉:讲的是装置内部怎么跑的
      · 组件(StepRow / StepTrace)一并删, 不留成没人用的导出
      ️ 可核查性换了地方:等待文案(说动作不说工具名)+ agent_invocations.output.steps + 裸台
      luoqi committed
    • feat(助手): 默认窗口调高 + 拿掉工具灰行与「执行过程」 · d49d42e5
      产品定:「调用工具和执行过程去掉,用户不需要知道」。
      
      ── 高度 620 → 760(max-h 78vh → 88vh)─────────────────────────────
      这个窗口的主要内容是**确认单**,它下面还要跟一段正文和几排引导按钮 ——
      620px 里卡片一出,正文就被挤到要滚,而正文正是「这一版为什么长这样」的那半句。
      ️ 两处硬编码(`openAt` 里的 760 / className 里的 `h-[760px]`)是一对,改一处要改两处。
      
      ── 删掉的两样 ────────────────────────────────────────────────────
      · 正文里的工具灰行「出分配方案 `propose_assignment`」—— 前半是产品说法、后半是**函数名**,
        而主管的词汇表就是他在界面上见过的那些(②诚实层)。
      · 消息末尾的「执行过程 · 4 步 出分配方案 → 摆选项按钮 → 摆确认单卡片 → 摆选项按钮」——
        连「摆按钮」这种渲染动作都列出来了,讲的是装置内部怎么跑的。
      ⇒ 开关(脑图标)现在只管**思考**那一类,文案跟着改。
      
      ── ️ 可核查性没有丢,只是换了地方 ───────────────────────────────
      「模型到底调没调工具」是这条生产线最贵的一类问题(2026-08-13 吃过一次:
      数字自洽的整份汇报,而 propose_assignment 一次都没跑)。现在:
        · 「正在挑人、排客服,出分配方案」等待文案还在 —— 说的是**动作**,不是工具名
        · 每一步的形状落在 `agent_invocations.output.steps`(服务端留痕,可回查)
        · 裸台 `/assistant-lab` 原样展示全部步骤(️ 它自己写了一份, 不引 step-trace)
      
      ──  组件一并删掉,不留成「没人用的导出」──────────────────────────
      `StepRow` / `StepTrace` 及只服务于它们的 `fmt` / `TraceRow.status` 全删。
      一段永远不渲染的组件读起来像「这个功能还在」。要放回来:改 `assistant-chat`
      里 `rest` 那一处过滤(单一过滤点),再把组件写回来。
      
      ️ `BlockView` 尾巴改成显式判 `text`:原来那行能编译过,是因为上面 `kind === 'tool'`
        分支替它收窄了类型 —— 删掉分支后不成立。判据写在自己那一行, 不靠别处顺手兜。
      
      验证:tsc 绿,`next build` 通过;产物里「执行过程」只剩裸台那个 chunk。
      ️ pac-web 的 eslint **本来就跑不起来**(配置加载阶段 circular structure,
        `eslint --print-config` 对任何文件都炸),与本次改动无关。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  8. 18 Aug, 2026 1 commit