1. 05 Aug, 2026 2 commits
    • fix(security): Bull Board 面板默认不挂载 —— 它是公网无鉴权的可写入口 · 85b3cc2c
      bull-board.module.ts 原注释称「本路由由 NestJS 路由系统接管;需要走全局
      JwtAuthGuard」—— 不成立。@bull-board/nestjs 用 ExpressAdapter 自己挂独立
      Express handler,不经过 Nest 的路由与守卫。
      
      2026-08-05 从公网实测旧生产(网关把 /admin/queues 转到了 3101):
        GET /admin/queues              → 200,面板 HTML
        GET /admin/queues/api/queues   → 200,队列数据
      返回体带 "readOnlyMode":false / "allowRetries":true —— 未鉴权即可翻 job
      payload(含 patientId / hostId / tenantId)并重试、清理任务。
      
      加守卫要下沉到 Express 中间件层;而这个面板平时没人用(前端无任何入口链接,
      只在 docs/monitoring 里作为排障手段提过),故取成本最低的解法:默认不挂载,
      排障时 PAC_BULL_BOARD=1 临时开。
      
      关掉的只是**面板**:队列本身、定时任务、job 消费全部照常,它只是查看器。
      
      同步订正三处文档里「需登录 admin」「豁免前缀」的旧描述。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  2. 04 Aug, 2026 25 commits
    • fix(docs): OpenAPI server 地址不再硬编码 —— 生产 docs 一直指向测试服 · ab0f7101
      docker-compose.prod.yml 里写死 DOCS_API_URL: https://pac.jarvismedical.asia,
      而这份 compose 是**测试服与生产共用**的 —— 于是生产 docs 页上点 Scalar 的"发送请求",
      实际打到测试服去。当前生产就是这个状态(不影响文档阅读,但"试一试"跑错环境)。
      
      改为复用 NEXT_PUBLIC_API_BASE_URL:它就是同一个东西(API 的对外地址),
      各环境 apps/pac-web/.env 里本来就有 —— 零额外配置,且不会再随环境漂移:
          DOCS_API_URL: ${DOCS_API_URL:-${NEXT_PUBLIC_API_BASE_URL:-http://localhost:3101}}
      要单独指定时才设 DOCS_API_URL 覆盖;都没有则退回本地。
      
      ️ 它是 **build-time** 参数(server 在 next build 期烘进 SSG 页),
      改了必须重新构建 pac-docs 镜像,不是改 env 重启就行。
      
      测试锁住:值必须是变量插值、且不得出现任何具体域名。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(deploy): 部署停机写实 —— 「秒级重启」实测是 ~16 秒 · 5950b2fa
      README 原文「期间 service 秒级重启」会让人以为一两秒,实测三次都在 15.97~16.86s。
      force-recreate 不是滚动更新,就是停旧起新,中间必然有真空期。
      
      补三点容易误解的:
        · build 阶段不影响服务(老容器一直跑)→ 部署总耗时长短与停机无关
        · 影响面:API 报错、刷新即恢复;登录态不掉(JWT 在浏览器);无数据风险
        · 要真零停机得网关层蓝绿,不在本脚本范围
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(deploy): force-recreate 失败才回退 stop —— 只在 rootless 上多做一步 · 8b41273c
      rootless podman 下 `up -d --force-recreate` 要删**运行中**的容器,而 rootless 杀不掉
      它的网络进程,直接失败:
          rootless netns: kill network process: permission denied
      失败还留下 <hash>_<proj>-<svc>-1 半成品容器,不清则重建报名字冲突。
      
      改为:主路径不变(直接 force-recreate),**仅在它失败时**回退 ——
      清残留 → stop → 重建。docker 侧一行路径都没变,podman 侧多走一次回退。
      
      【停机实测,顺带纠正我自己两次错判】2026-08-04 三组对照(0.5s 间隔探 /health):
          docker + 直接 force-recreate   ~16s
          docker + 先 stop 再 recreate   15.97s
          podman + 先 stop 再 recreate    5.44s
      即 **部署本来就有约 16 秒停机,一直如此** —— force-recreate 不是滚动更新,
      它就是停旧起新。
      
      ️ 第一轮曾测出 docker「0 停机(535 样本全 200)」,那是**测量错误**:
      探测有 300s 上限,而那次是全量构建、耗时更长,容器重建发生在探测结束之后
      (实测 probe 比 deploy 早结束 29s),535 个样本全采自"还在 build、老容器好好跑着"的阶段。
      我据此先判「本改动引入退化」、后判「修正后恢复零停机」,两次都错。
      教训:探测窗口必须覆盖被测阶段,否则"全绿"只是没看见。
      
      本改动保留的理由因此不是"避免退化",而是**改动面最小**:docker 走原路径,
      只有 rootless 才多一步。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(deploy): 部署先 stop 再 force-recreate —— rootless podman 删不掉运行中容器的网络进程 · b49d3e14
      2026-08-04 新生产机(Ubuntu 26.04,运维刻意配的 rootless podman:非 root 账号 +
      netavark/pasta,攻击面更小)实测:
        rootless netns: kill network process: permission denied
        Error: ... up -d --force-recreate pac-service: exit status 1
      失败还留下 <hash>_pac-pac-service-1 半成品容器,要手工清。
      
      根因:force-recreate 要删**运行中**的容器,rootless 杀不掉其网络进程。
      手动 stop → rm 实测正常,说明不是普遍权限问题,只是删运行中容器这条路走不通。
      
      改为先 stop 再 up --force-recreate:
        · rootless podman 下通过(网络进程正常退出后再删)
        · docker 上语义等价甚至更彻底(stop 后必然重建,不依赖 compose 判定)
        · 仍保留 --force-recreate,不退回裸 up -d —— 那正是本脚本要绕开的 compose diff 缺陷
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(ops): compose 显式声明 json-file 日志驱动 + rotate —— podman 上看不到日志 · ae624632
      【podman 差异】docker 默认就是 json-file(写不写行为一样),但 **podman rootless 默认
      journald**。2026-08-04 新服务器(Ubuntu 26.04 + podman 5.7 兼容层)实测:
        · `docker logs <c>`  → 只回一行兼容层提示
        · `podman logs <c>`  → 0 行
        · 日志进用户 journal,普通用户不在 systemd-journal 组 → 读不到
      等于线上出问题**完全看不到日志**。今天排查那四个增量 bug 全靠 docker logs,这在新环境行不通。
      显式声明后两种运行时行为一致。
      
      【顺带治一个既有隐患】此前没有任何 rotate —— 当前生产 /var/lib/docker/containers 已 1.4 GB,
      只增不减。50m × 5 = 单容器上限 250MB:够排查近期问题,又不会把盘吃满
      (2026-08-01 测试服正是被写满后 Postgres 写不了 pg_wal 崩溃重启)。
      
      用 YAML anchor 统一 7 个服务,避免逐个漂移。对 docker 侧唯一的行为变化是**加了 rotate**,
      驱动本身就是原默认值。
      
      顺带纠正一个误判:pac-asr 早已是 `profiles: ["asr"]` 默认不启(当前生产也没跑它),
      「迁移时 ASR 不部署」不需要改 deploy 脚本 —— 之前判断错了,已用测试钉住该事实。
      
      测试 828 项(+4)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(ops): 定时任务写侧总闸 PAC_SCHEDULER_DISABLED —— 迁移期新旧实例不双跑 · 79d04ee8
      服务器迁移窗口里新旧实例并存,新实例要能起来接流量、验证配置,但绝不能同时对 DW 做
      增量摄入 + persona/plan 重算:
        - 各连各库:两边各拉各的,待迁移的数据快照持续偏移,dump 出来就是旧的
        - 同连一库:互抢 sync_logs 的 partial UNIQUE(host_id) WHERE status='running' 锁,
          还会把对方**正在跑**的锁当"僵尸"回收(判据只看 startedAt 早于本进程启动)
      
      【为什么必须是代码开关,env 关不掉】
        - jvs-dw 的 manifest 写死 auto_sync: true,scheduler 启动即自动发现并注册
        - cron 也写在 manifest(incremental_cron),优先级高于全局 PAC_INCREMENTAL_CRON
        - PAC_INCREMENTAL_HOSTS= 留空只会 fallback 到自动发现
        - 改 manifest 能关,但 deploy-prod.sh 会 git pull 覆盖掉
      
      覆盖范围**只关写、不关读**:
         sync-incremental(摄入+persona+plan)—— 闸判定放在 reapStaleRunningLocks **之前**,
           禁用态完全不碰 sync_logs
         stale-scan(enqueue persona 重算)
         dw-lag-monitor / daily-health-report —— 只发告警和报表,备用实例照常跑反而多一双眼睛
        这条边界用测试钉住,防后来人"顺手统一"掉 → 迁移期静默失去监控。
      
      只认严格的 '1','true'/'yes' 都不算 —— 半开状态比全开更难查。
      
      ️ 迁移完成、旧实例下线后务必移除该 env 并重启,否则新生产静默不摄入(只表现为数据越来越旧)。
      
      测试 824 项(+6)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • merge: docs/plan-assignment-doctrine → test(放回左栏「真」号码筛选) · 59e9f53b
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(web): 放回左栏「真」号码筛选(标签筛选仍隐藏) · 1c115cc3
      原来两个入口共用一个 RAIL_FILTERS_VISIBLE,只要电话这个就得拆开:
        PHONE_FILTER_VISIBLE = true   ← 放回
        TAG_FILTER_VISIBLE   = false  ← 仍隐藏
      
      外层条件跟着两个开关一起判 —— 只判其中一个的话,以后放开另一个时
      整排还是不显示,而且不会报错。
      
      服务端谓词一直都在(plan.service.ts 的 patientWhere.phoneVerified),
      这次只是把入口露出来;realPhoneOnly 初值仍是"不筛",不会出现看不见的筛选。
      `!hasPatientArchive` 守卫保留:宿主自带患者档案时号码整个不展示,
      筛「真」没有意义(同 PatientRow 的 hidePhone 判据)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(api): 重导 OpenAPI spec —— 跟上召回分配合入(70 paths) · dae62dbf
      CI 的 openapi-drift 闸只在 MR 和默认分支跑,合 test 时不会拦;
      但合 main 时会 fail。在这里补掉,别把债留到下一刀。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • merge: docs/plan-assignment-doctrine → test(召回分配全量 + 企微话术) · 9e2a2ce5
      主管侧批次分配从地基到闭环:三趟落人(专属封顶 → 无主补空 → 有主改派)、
      确认单可局部修正、批次归因走 plan_event_logs 账本、通话成效四桶、撤销/退回
      分离。企微话术(深度档单块可复制)+ 复制埋点。含路由遮蔽修复 ——
      script-feedback / script-copy 此前被 script:regenerate 整个吞掉。
      
      自动合并无冲突;删除的 plans-list-app / task-drawer 等来自 3cbb3899 的重构。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(web): 主管开场建议改「批次总览表 + 单批报表」 · 1de76e9a
      「我分过的批次,哪批出问题了?」换成两条能落到工具上的:
        · 总览我在跑的所有分配批次,出个表格  → list_assignment_batches
        · 最近一批分配出份报表:…            → get_assignment_detail
      
      指标措辞用服务端原词:「未动过」不写「曝光」、「已处理」不写「完成」。
      后者服务端每次返回都在喊「这是处理率不是成功率」,例句写「完成」会把
      模型往"谈成了"上带 —— 同文件里那条「别写转化率」的注释防的是同一件事。
      点名的七个指标每个都有真实返回值(planned/untouched/progress.done/
      outcomes.success/expired/releaseReasons/byOutcome),不是许愿。
      
      顺带:/assistant 整页的兜底建议此前写死一份客服版,主管在那儿看不到任何
      分配入口 —— 而整页恰恰是他做批次活最可能待的地方(表格宽、能铺开)。
      按同一个 PLAN_DISPATCH 闸拆成 EXAMPLES_LEADER / EXAMPLES_STAFF。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat: 企微话术复制埋点 + 修复路由遮蔽(反馈按钮一直在偷偷重新生成话术) · a67c3f0c
      ═ 埋点 ═
      plan_event_logs 新增事件 script_copy,reason 列存渠道(wecom / phone,都登记进
      PlanEventReason —— 那一列的注释写死了"不要在调用处随手写字符串")。
      
      为什么值得记:企微稿的正常用法就是复制出去发给患者,复制那一下之后客服就离开
      PAC 了 —— 这是我们能观测到的**最接近"真的用了"**的信号。生成完没人复制 =
      生成了但没人用,那是产品问题不是模型问题,而这件事此前完全看不见。
      
      口径(回归里锁着,这两条改错不会有任何编译错误):
      · byHuman=false —— 复制 ≠ 联系了患者(复制完可能没发)。算进 HUMAN_TOUCH_EVENTS
        的话「处理过的患者」会静默膨胀成"复制一下也算",与 view 同一类错误。
      · holdsPatient=false —— 可能发生在未认领的单上,不参与归属区间。
      端点不加认领闸(同 :id/view):主管浏览时也能复制,加闸统计直接偏。
      
      ═ 顺带修掉两个真 bug ═
      1) 🔴 路由遮蔽 —— **已有的话术 👍👎 反馈按钮从来没生效过**。
         Express 把 ':id/script:regenerate' 里的 :regenerate 当路径参数,模式实际是
         「字面量 script + 参数」,于是 /script-feedback 和 /script-copy 全部命中它
         (参数 = '-feedback' / '-copy')。点一次反馈 = 真的重新生成一次话术:
         花 AI 钱 + 覆盖已有稿,而前端拿到 200、界面一切正常,**不报任何错**。
         治标:字面量路由挪到冒号路由之前(Express 按声明顺序匹配)。
         治本是把 :verb 改成 /verb,会动前端与文档契约,没在这一刀做。
         新增 route-shadowing.spec 用文本扫描锁住顺序 —— 这个 bug 恰恰不会在类型
         或运行时暴露,只能这么防。已验证:把 script-copy 挪回去,测试立刻红。
      
      2) 复制按钮在 http / 宿主 iframe 下**点了毫无反应**。
         navigator.clipboard.writeText 在非 https 或 iframe 里直接 NotAllowedError,
         而原写法把 setCopied 放在它后面 → 不报错、不变文案,客服会以为按钮坏了。
         加 execCommand 回落路径。
          埋点挪到最前面,与剪贴板成败**解耦** —— 记的是"点了复制"这个意图。
         剪贴板被拒时客服往往改成手动选中复制(真的用了),埋点不该跟着一起丢。
      
      实测:点复制 → script_copy | wecom | 操作人 832 | 带批次号 t 落库。
      1024 tests,两个 tsc + next build 干净。
      luoqi committed
    • docs: 召回分配产品设计文档(面向产品/业务) · dc6f9469
      基于 docs/design/plan-assignment-doctrine.md 重写成产品视角,放进 docs app
      新建的「设计 › 产品设计」分组。
      
      与原教条文档的关系:那份是工程内部的决策留痕(含表名、字段、弯路记录、
      踩坑复盘),给开发看;这份只讲**为什么这么设计**和**流程长什么样**,
      去掉全部实现细节与代码路径 —— 讲给产品和业务听。
      
      结构按设计思路重排(不按 T 编号顺序):
        问题 → 全流程 → 这是什么 → 谁做什么 → 怎么选人 → 怎么落到人头上
        → 确认单 → 闭环
      
      5 张图代替长段落:
        ① 五步生产线(带反哺回环)  ② 三方职责与唯一写动作
        ③ 初选两轴矩阵            ④ 落人三趟(专属封顶 → 无主补空 → 有主改派)
        ⑤ 一条单子的状态机(含退回/到期/撤销三条回池路径)
      
      保留了对业务最有说服力的两处真实数据:70% 挂在同一客服名下、
      封顶前后 248/34/31/14/14 → 每人 20。
      
      实测:docs 已渲染,5 张图全部成 SVG(4 flowchart + 1 stateDiagram),
      无残留代码块;导航挂在「设计 › 产品设计 › 召回分配」。
      ️ 新增 content 目录会让 Fumadocs 索引发僵(整树打不开),
         清 .next/.source 重建才恢复 —— 老坑,已按记录处理。
      luoqi committed
    • fix: 企微话术沿用顶栏生成入口 + 走深度交互;禁止输出时间占位 · e5a1b5c2
      1) 不再另造生成按钮
         企微视图里的「生成/重新生成」删掉,统一走顶栏那一个入口(与电话稿同一个)。
         两个"生成"按钮=两条路径两套状态,必然不一致。
         切到企微时隐藏档位下拉(只有深度档,摆一个单选下拉是误导);模型下拉保留。
      
      2) 交互与电话深度档一致(过程可见)
         加 GET :id/wecom-script:stream(SSE),事件形状与 script:stream 对齐 →
         前端 ScriptDeepProcess 时间线 / 停止 / AIStamp 整套复用,不另画一套。
         企微一轮跑 60s 上下,没有过程可见就是一个转圈白屏。
         ️ 只推步骤不推正文增量:企微是一整块,逐字推只是闪。
      
      3)  禁止输出任何时间占位符
         电话稿留【时间段1】是对的(客服边打边填);企微这条消息是整段复制直发的 ——
         占位会原样发到患者微信里,或者客服得先手动编辑一遍,"可直接发送"当场不成立。
         改成不含具体时间的邀约(「您方便的时候回我一下,我帮您安排」)。
         四处一起改才拦得住:format.md / verify system / verify user prompt / repair 铁律,
         外加策略侧硬扫兜底(只靠 prompt 拦不住,实测模型照着电话档习惯写出来了)。
         forbiddenWordsBlock 加 timePlaceholders 参数 —— 不然它那句「占位照旧保留」
         会和企微 format.md 的「一个都不许出现」拼进同一份 system 打架。
      
      踩的坑:
      · 企微 done 事件不带 costYuan → 外层 toast 直接 .toFixed() 整页崩(实测)。
        两条流的 done 形状不一样,别假设一致。
      · 步骤时间线渲染了两遍(外层已有一份,我在视图里又画了一份)。
      
      实测:切企微 → 顶栏「重新生成」→ 步骤逐个亮 → 正文出,收尾是
      「您看最近工作日哪天方便,回我一下帮您安排复查时间」,无任何【时间段】占位。
      1020 tests(1 条并发计时测试在全量负载下抖动,单独跑通过,与本次改动无关),
      两个 tsc + next build 干净。
      luoqi committed
    • feat: 企微话术(深度档,一次性单块可复制)+ 话术面板改渠道 tab · fa96a613
      ═ 沿用了什么、没沿用什么 ═
      共用(直接 import, 不复制):ScriptContext / buildRichFactBlock 患者事实块 /
        安全护栏 forbiddenWordsBlock / 福利硬约束 / 自报家门占位 / 医生姓脱敏 /
        人群 skills / composeSystem 装配 —— 与"用电话还是企微说"无关。
        复制的代价是护栏两处,改了一处另一处悄悄留旧版,而漏了哪条要等客服已经发给患者才发现。
      自己写(draft-wecom-script/):
        · schema:电话 sections[](伴飞逐段高亮要它)→ 企微单块 markdown
        · format.md:口语/分段/口头二选一 → 书面/断行/可复制即发
        · verify 多一条⑤「可直接发送」(无小标题、无占位残留、无给客服看的话、
          无"您现在方便吗"这类需对方当场回话的电话句式)
        · plan 步语义改成"排要点顺序"而非"拆几段"
      
      ═ 几个关键取舍 ═
      · composeSystem 加 formatPath 覆盖参数, 不往 ScriptTier 里塞 'wecom' ——
        tier 是质量档、渠道是另一维度,混进一个枚举后"深度档企微"就表达不出来了。
        formatPath 一并进 composeHash(否则两个渠道 promptVersion 撞车,eval 数据混在一起)。
      · plan_scripts 加 channel + 改 @@unique([planId,channel]), 不另立表:
        状态机/source/agentInvocationId/聚合查询全一样,两张表必然漂。
      · 企微**无模板兜底**,失败就是 failed。电话失败可以给模板(客服拿着电话必须有东西念,
        平淡但不出事);企微是原样发给患者的,套话复制发出去比没有更糟,而且发出去收不回。
      
      ═ 前端 ═
      · 话术面板 3 视图切换(伴飞/卡片/原文)隐藏,那个位置改成渠道 tab 电话/企微;
        电话固定「原文」渲染(视图代码整套保留,开关可恢复)
      · 企微视图单块 + 复制按钮;whitespace-pre-wrap 原样呈现, 不过 markdown 渲染器 ——
        客服复制的必须是他看到的那些字
      · 执行结果的「触达方式」选择器隐藏(️ 期间 channel 全落 'phone',触达方式分布不可用)
      
      ═ 踩的三个坑 ═
      1. __dirname 拿不到 format.md(ENOENT):SWC dev 产物在 dist/src/,tsc prod 在 dist/,
         同一个 __dirname 指向不同层级。照抄 resolveScriptRoot 的 cwd 策略。
      2. serializeScript 会把正文拆成 sections 并丢原文 → 企微稿 content 长度 0。
         企微单独序列化,不复用那个。
      3. ai.module 的 exports 没加(正则打在了 providers 上,那里有同样的三行序列)→
         Nest 启动时 UnknownDependenciesException。
      
      实测:真库生成一份企微稿(单块、空行断段、无小标题、收尾是"您回我一下方便的时间就行"
      而不是电话的口头二选一),界面 tab 切换 + 复制按钮 + 【回访客服】按登录人回填全部正常。
      1020 tests,两个 tsc + next build 干净。
      luoqi committed
    • style(web): 隐藏左栏两个筛选 + 电话「假」角标;列表电话改明文 · 40f578e9
      1) 左栏「真」号码筛选 + 「筛选标签」隐藏
         开关 RAIL_FILTERS_VISIBLE=false,置 true 即恢复;PersonaTagFilter 与服务端
         phoneVerified 谓词都留着,不是删除。
         ️ 藏的只是入口:realPhoneOnly / tags 初值都是"不筛",所以请求里不会残留条件。
          别改它们的初值,否则会出现"看不见的筛选"——列表少人而主管找不到原因。
      
      2) 详情页号码角标:只在已核实时打「真」,未核实**不打标**
         原来给未核实号打「假」,而那是绝大多数 —— 整页每个患者都挂着「假」,既是噪音,
         又在客服正要拨号的那一秒告诉他"这号是假的",平白制造犹豫。
         「未核实」≠「假」:只是外部对照表里没有,号码本身可能完全正常。
      
      3) 列表电话明文(PlanPatientBrief 加 phone)
         脱敏号 138****2417 客服拨不出去,等于每次都要点进详情再拨。
         ️ 不是新开暴露面:详情页早就明文下发同一个号(plan-aggregate.phone),
           两处同权限同 tenant scope。
         ️ phoneMasked 保留:配了原始档案(VIEW_PATIENT)的宿主整行隐藏手机号,
           两个字段都不显示 —— 别因为加了明文就把脱敏删了。
      
      1020 tests,两个 tsc + next build 干净。按你说的没进浏览器调试。
      luoqi committed
    • feat: 批次成效统计 —— 完成 / 成功 / 不成功及原因 · 9456b544
      前一刀(跳转前必答)让 plan_executions 有数了,这一刀把它接进批次报表。
      
      1) plan_executions 加 assignment_id
         理由与 plan_event_logs 那次一字不差:followup_plans.assignment_id 会被下一次
         分配覆盖,时间窗是启发式不是键。
         ️ 落这一列的判据是 assignment_expires_at != null(「非空 ⟺ 有一次在办的分配」),
          不是 plan.assignment_id 非空 —— 后者在退回后仍留着(那是退回率的分母),
         此时客服自己捞回来打的电话会被算进一个早就结束的批次。
      
      2) detail 增加 outcomes:success / failed / keep / noOutcome / byOutcome / note
         · 每个患者只取最近一次执行(DISTINCT ON) —— 打三次才约上是一个成功不是三个
         · noOutcome 单独成桶:那不是效果差,是根本没做/没记。并进 failed 会把执行问题
           读成召回问题,而两者要改的东西相反
         · 四桶穷尽 success+failed+keep+noOutcome === planned
      
      3)  outcomes 与 releaseReasons 不能互相顶替(工具描述里也写死了)
         releaseReasons = 没打就还回去(分配问题);outcomes = 打了结果这样(召回效果问题)
      
      写这一版时踩了一次:noOutcome 里多减了 reassigned → 被压成 0。
      「现在归谁」与「做没做」是正交维度,不能放进同一个减法。已加回归锁住。
      
      实测(真库):3 人批次打 refused + no_answer → 执行带批次号落库 →
      详情读出「成功 0 · 不成功 1 · 打了没进展 1 · 一次结果都没有 1」,四桶合计 3 === planned 3,
      明细「明确拒绝 1」,note 自带样本不足与「成功含约定下次回访」两条护栏。
      1020 tests,两个 tsc 干净。
      luoqi committed
    • feat: 预约/跟进跳转前先落一条执行结果 + 约定下次回访归「成功」 · 2e0610be
      ═ 为什么 ═
      这两个按钮跳走之后客服基本不回来 —— 这正是 plan_executions 回写率只有 11%
      (生产 65 个认领单只有 7 条结果)的根源。回写率低到这个程度,"成功率""退回率"
      全是猜的。⇒ 把一次必答的极简输入挪到跳转之前:人还在 PAC 里、手还在键盘上,
      那一刻才录得到。
      
      ═ 做了什么 ═
      1) 吸附在按钮上的小浮层(Popover),各只收**一个必填**:
         · 预约 → 备注   → 落 success_appointed(结案 + 60 天抑制)
         · 跟进 → 下次时间 → 落 scheduled_next(不结案,snooze 到那天)
         多一个字段都不行 —— 这是拦在人和他真正想做的事之间的门,门越重越会被绕开。
      
      2) scheduled_next 的 group: keep → close(「保持」→「成功」)。
         ️ drivesStatus 保持 'keep' —— group 与状态机在这一项上**故意不一致**:
         算成功是统计口径,工单必须留着 snooze 到回访日。改成 completed 的话,
         客服刚答应患者"那天再联系您",这单当场出池,约好的回访再也不会发生。
         面板分组是读 meta 现算的 → 「保持」组自动只剩未接通/秒挂,历史数据一并重归类。
      
      ═ 几条纪律 ═
      · **先落库、后跳转**。反过来的话写失败人已跳走,他会以为记过了。
      · URL 未配置时**先拦住**, 不先把单结了再告诉他跳不过去。
      · channel 记 'other', 不许默认 'phone' —— 我们不知道他怎么触达的,编一个会把
        触达方式分布做脏。
      · 浮层里写明后果(「记为转化新预约并结案」)—— 不写就是让他在不知情下关掉工单。
      
      实测(真库):跟进 → scheduled_next/other/2026-08-12 落库,plan 仍 assigned +
      snoozed_until=回访日;预约 → success_appointed + 备注落库,plan → completed。
      未配 URL 时确认不落库、浮层保持打开。新增 taxonomy 回归锁住 group/drivesStatus 分离。
      1013 tests,两个 tsc + next build 干净。
      luoqi committed
    • style(web): 详情页头部的优先级条先隐藏 · 2cea1a0d
      开关 HEADER_PRIORITY_VISIBLE=false,置 true 即恢复;
      代码整段保留(含 hover 的算分明细),不是删除。
      
      ️ 只关头部这一处。左栏每行的优先级分数不动 ——
      那是主管挑人的排序依据,隐藏了他就没法判断先跟谁。
      
      实测:头部已无「优先级」;左栏分数 7.38/6.78/6.70 完好。tsc + build 干净。
      luoqi committed
    • style(web): 左栏状态药丸只显示"非默认态" · 5cd0e3b6
      召回池每行都写「待分配」、我的每行都写「进行中」—— 整列同一个词,
      读者第二行就不再看它,却一直占着行尾最值钱的位置(退回按钮就在那儿)。
      
       但不能把药丸整个删掉:「我的」不带 status 过滤(where.assigneeUserId = 我),
      已完成 / 已放弃的单也在这一栏里,而那两个才是真要一眼看见的。
      删掉等于让客服分不出哪条已经结案。
      
      ⇒ 按「这一行是不是本视图自身的定义」判断:
        pool → 隐藏 active,mine → 隐藏 assigned,其余照常显示。
      
      两栏处理还不一样:
        · 召回池没有退回按钮 → 默认态**整个不渲染**(留 42px 空白纯属白占,
          而右侧正是手机号/诊所名要用的地方)
        · 我的有退回按钮,药丸与它叠在同一 grid 格、格宽取较大值 →
          默认态用 invisible **占位**,否则 hover 前是 0 宽,按钮一冒出来整行位移,
          而那个叠层机制存在的全部理由就是防这个
      
      实测:召回池 25 行 0 个药丸;我的 22 个药丸全 invisible、占位 42px,
      hover 姓名位移 0(退回按钮 37px 落在占位内)。tsc + build 干净。
      luoqi committed
  3. 03 Aug, 2026 13 commits
    • fix: 退回原因分布与 agentStats 名册也走账本(补上一刀的漏) · 41566fd7
      上一刀把 released 计数改成账本口径,但**分布和名册还读 followup_plans**,
      于是自己跟自己对不上:
      
        followup_plans.release_reason 是当前值,而 assign 的 SQL 里有 release_reason = NULL
        → 一条退过的单被后面的批次挑走后:
            released      1  (账本)
            releaseReasons []  (列已被清空)
        主管看到"退了 1 条"却没有任何原因,会以为是客服没填。实测复现。
      
      更糟的是 agentStats 的名册按当前 plans 建 —— 被挑走的人从所有人名下蒸发,
      「各人之和 === planned」这条本仓最早锁的不变量当场破(而 planned 已是账本口径)。
      
      修法:名册与退回都从本批账本事件建(按 assignment_id 取,不是按当前 planIds)。
        · release 事件自身 assigneeUserId 是 null → 按 planId 回查 assign 的原始承接人
        · 同一 plan 多条 assign(退回→自认领→再退回)去重后计数
        · 老批次(账本没批次号)整条回落到原路径,行为不变
      
      实测(真库):把退过的那条重新分配一次 → 列被清空、账本仍在 →
        分布之和 1 === released 1;agentStats 各人之和 9 === planned 9。
      1008 tests,tsc 干净。
      luoqi committed
    • feat: 批次归因走账本 + 到期可统计 + 批次人话名 · 9eeb29b9
      主管四问引出的一组修复。
      
      1) 归因失血(不报错的真 bug)
         followup_plans.assignment_id 会被下一次分配覆盖 —— 一条单退回/到期回池后
         被后面的批次挑走,旧批次就少一个人。批次跑得越久缩得越厉害。
         实测 c02e1b80 分过 9 条,重分后 planned 显示 0。与 supersededAt 那坑同构。
         ⇒ plan_event_logs 加 assignment_id(不建明细表:账本本来就是明细,
           缺的只是"属于哪一批"这个维度;该表自己写着"要按它筛就该立柱")。
           四个写入点全覆盖;claim 刻意不写(自认领捞的是池子,plan 上那个 id 是陈迹)。
         ⇒ planned/agents/released/expired 走账本,inHand/done 走当前状态。
         ⇒ 补 progress.reassigned 让穷尽性重新成立(不补则主管一对数就少人)。
         ⇒ 老批次回落到 followup_plans 现算;到期数无源可落,只能给 0。
      
      2) 到期终于能统计
         到期事件一直在账本里(auto_release + assignment_expired),但任何接口都没报,
         全被 backToPool 一桶吞掉。而到期与退回主管的下一步动作相反:
         退回多=分配策略不对,到期多=派多了/时效太紧。现在分开报,note 也分开说。
      
      3) overdue 加 snoozed 守卫
         回收器刻意跳过"约了下次回访"的单,于是它们永远超期;而 progress 已把它算作
         suppressed(已处理)。不加守卫,同一条单「已处理」和「超期」同时成立 ——
         主管看到「薛玫 超期 3」以为她压单,实际她打了电话约好了下次。
      
      4) 批次人话名 label(服务端唯一生成)
         「8/3 23:35 · 牙周治疗 · 窗口内 · 9 人 · 2 位客服」。没有列表页时这是主管
         指认一批的唯一抓手。做这个才发现 temperature 一直没进 criteria 快照 ——
         它参与圈人却没随确认单下发,导致批次说不清"当时按哪个温度圈的"。已补。
      
      顺带修一个潜伏编译错误:assignmentExpiresAt 加进 FollowupPlanSchema 时漏了
      serializePlan(19bd658f)。当时没报错是因为 packages/types/dist 是旧的。
      
      实测(真库):新批次 9 人 → 撤销 → 同一批人重分 → 旧批次 planned 仍是 9
      (旧实现下是 0)、五桶+重分 0+0+0+9=9;退回 1 条、到期 2 条(等回收器真扫)
      分别落账,详情读出「客服主动退回 1 条、到期没人动 2 条」。
      1005 tests,两个 tsc + next build 干净。
      luoqi committed
    • fix(web): 生成中文案一闪而过 + 输入框/开场建议按角色分 · a6f8c3db
      走查两条:
      
      1) loading 文案闪
         - 加最短停留 700ms(useStickyLabel)。get_current_user / get_agents 这类
           100ms 就返回,文案在屏幕上活不过两帧,主管只看到一片闪烁,反而以为卡了;
           慢工具的文案看得见,于是他以为"只有那一句"。
           用「最新覆盖待显示」而非排队 —— 排队会在活干完后继续播,5 个快工具能
           拖出 3 秒假忙碌。上限就是一个停留周期。
         - 修并行调用:原来只看最后一块,3 个工具并行时最后那块可能已 done 而前两个
           还在跑 → 退回「生成中」,前两句文案一次都不出现。改成找还在跑的那个。
         - 补齐 4 个漏掉的工具中文名(get_cohort_attributes / edit_assignment_sheet /
           revoke_assignment / render_artifact),此前兜底把英文工具名摆给主管看。
      
         实测(多工具一轮):5 个文案,最短 955ms,无一低于 700ms。
      
      2) 输入框占位符 + 开场建议是客服口径
         主管打开助手是来分配和看批次的,占位符从不提这两件事 = 把一半能力藏起来。
         按 PLAN_DISPATCH 分两版(与召回池/确认单同一个闸)。
         顺带删掉「(Enter 发送,Shift+Enter 换行)」—— 输入框只有 300px,而原句 450px,
         那半句从来就没显示出来过,占着长度却看不见。
      
      996 tests,web tsc + next build 干净。
      luoqi committed
    • fix: 助手撤销走不通(批次号被截成8位)+ 助手"假撤销"对账 · 2e75bef9
      主管实测:助手撤销必挂,报「批次 c02e1b80 不存在」。
      
      根因不在撤销,在确认后注入消息流的那句话:
      `已确认分配:批次 #${id.slice(0,8)}` —— 界面与模型共用这一份,
      模型手里只有 8 位短号,拿短号查主键当然查不到。
      卡片上的撤销按钮握着完整 id 一直是好的,所以只测卡片那条就漏了。
      
      修法两层:
      - 文本块加 modelText:界面显示一份、喂模型一份(后者带完整 uuid);
        界面顺带不再显示批次号(uuid 对主管没有意义)
      - 服务端 resolveAssignmentId 兜底认短号,并能从「批次 #c02e1b80」
        这种整句里抠出 id(实测模型就这么传);撞到多个报错不猜
      
      追查中发现更严重的一个:助手会把「已撤销批次:收回 9 条。」**背出来** ——
      没调 revoke_assignment,库里那批仍是 confirmed、9 条工单一条没动。
      工具返回被设计成"成品句子、原话转述",模型学会形状后就能凭空生成。
      报错主管会重试,假成功会让他停止补救。
      
      ⇒ 前端每轮结束对账:主管在要求撤销 + 助手自称撤成了 + 本轮没有
        revoke_assignment 的 tool_result ⇒ 把事实贴出来。
        措辞只陈述观察(不断言撒谎):主管问"刚才那批撤了吗"时如实回顾也不调工具。
      
      实测:短号经模型这条路已跑通(a7e1b6de → revoked,9 条回池无认领人)。
      996 tests,两个 tsc + next build 干净。
      luoqi committed
    • docs: 教条补撤销整批那节(含 key={i} 那个回归) · d850dd4f
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(web): 卡片加「撤销这批」+ 去掉批次号 + 助手撤销时卡片同步 · 58266293
      主管问"有撤销吗、前端实现了吗" —— 后端(限时 30 分钟 + view 判据)和助手工具
      revoke_assignment 都在,但**前端零实现**:assignmentsApi 连 revoke 方法都没有,
      批次列表页也不存在。先做 A(卡片上的入口),批次列表页单独排。
      
      · 确认后的卡片终态加「撤销这批」,30 分钟窗口内可见(按**确认那一刻**算,
         不是提案生成时间 —— 主管可能盯着单子改了二十分钟才点确认,那不该算进补救窗口),
        到点按钮自己消失, 不让他点了才被服务端拒。
      · 弹窗**先说「客服已经打开过的收不回来」**:撤销几乎总是部分成功,
        主管以为"点了就全回来了"就会停止补救,而那几条还挂在别人手上。
        结果如实报三个数(revoked/skippedTouched/alreadyReleased), 不只弹「已撤销」。
        note 原话进对话(既给主管看,也补模型上下文)。
      ·  去掉「· 批次 #9a9fc222」:uuid 前 8 位对主管没意义,只占位置 + 制造记忆负担。
        模型照旧走 list_assignment_batches 拿完整 id,与界面无关。
      ·  **助手撤销时卡片跟着切态**:revoke_assignment 是 MCP 工具推不了侧信道,
        但前端本来就收得到它的 tool_result —— 拿它当同步信号,按 assignmentId 精确匹配。
        不同步的话卡片说「已分配」、助手说「已撤回」,两句互相打架。
      
      🔴 顺带修一个**我自己上一版引入的回归**:`key={i}` + 「把确认单钉到消息末尾」——
      助手每追加一句文本,sheet 下标就变 → React 卸载重建 → **卡片本地状态全丢**
      (主管删的条、拖的改派、逐条时效、确认时刻)。症状是"撤销按钮压根不出现",
      但丢的远不止那一个按钮。改成按 requestId 做 key。
      
      本地实测:分 17 条 → 卡片出现「撤销这批」→ 弹窗 → 确认 →
      「已分配 已撤销 17 条」;库里 plan_assignments.status=revoked、
      plan_event_logs 落 17 条 auto_release/revoked。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat: 详情页显示分配单时效「还剩 N 天退回」 · 19bd658f
      主管问"现在有时效了,是不是可以显示以前隐藏的自动回收时间" —— 查下来
      **不是取消隐藏,是要接一条新的**:那个被隐藏的倒计时基于 `recycleAt`(认领的 24h 兜底,
      PAC_PLAN_AUTO_RECYCLE 默认 off,**从未启用**,值几乎恒为 null),
      而现在真正生效的是 `assignmentExpiresAt`(主管在确认单上定的 1-7 天,
      AssignmentExpiryScheduler 每 10 分钟扫,到点回池记 assignment_expired)。
      两个字段一直被混为一谈,详情接口**根本没返回后者**。
      
      · 服务端 /plans/:id/full 补 assignmentExpiresAt;契约里给两个字段各写清适用场景。
      · 前端 RecycleCountdown 换成 ExpiryBadge 并挂回头部,只在**单子还挂在人手上**时显示。
      · ️ 粒度跟着换:原来是 HH:MM:SS(为 24h 设计),而时效是**天**级 ——
        3 天的单显示 71:59:58 没人读得出来。改成「还剩 2 天」/「还剩 5 小时」,
        最后 1 小时才精确到分钟;刷新从 1s 降到 30s(天级不需要每秒重渲染整页)。
      · ️ 配色:最后一天转琥珀、最后一小时转红。 不更早变红 —— 时效本来就是几天,
        提前两天飘红会让客服对颜色脱敏,真到期时反而看不见。
      
      本地实测(客服「位其蕾」):头部显示「还剩 3 天退回」,hover 出具体到期时刻。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat: 补上退回原因弹窗(T7 此前是零实现) + 分配单服务端强制 · 1409f6f5
      走查:分配完登录客服账号点「退回」,没有任何弹窗 —— 一点就退掉了。
      
      查下来这条教条**此前是零实现**:枚举齐了(8 个 ReleaseReason + needNote)、
      API 收得了、releaseReasons 分布报表也写了,但前端唯一的退回入口是
      `plansApi.recycle(planId)` —— **一个参数都没传**。于是那张分布表永远拿到 null:
      主管只知道"退了 12 条",不知道该改什么,而"派多了 / 时效太紧 / 压根不该派给他"
      这三种的下一步动作完全不同(T7 说这张表是主管调整分配策略的输入)。
      
      · 新增 ReleaseReasonDialog:8 个原因,每个**带一句 desc** ——「不是我的客户」和
        「该由其他角色跟」光看标题分不出,分不出就会随手点第一个,**假数据比没数据更难发现**。
        other 展开必填说明框,不填不给提交。
      · 服务端:**分配来的单**(assignment_id 非空)强制要 releaseReason。
        ️ 只卡分配来的 —— 自助认领的单没有派单方,原因对谁都没用;
        **系统自动到期回收不走这条路**(scheduler 直接改库,记 assignment_expired),不会被卡死。
        ️ 必填必须服务端拦:只做前端的话,换个客户端(助手/脚本/企微)就绕过去了
        (与 other 必填说明、execution 的 inaccurate 必填同一条纪律)。
      
      本地实测(客服「位其蕾」12 条在手):点退回 → 弹窗 → 选「其他原因」出必填框且确认禁用 →
      改选「时效太紧」→ 确认 → toast「已退回」、我的 12→10、
      followup_plans.release_reason=deadline_too_tight、
      plan_event_logs 落 release/deadline_too_tight 且带 held_seconds=2839。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat: 排序话术条件化 + 卡片等说完再出现 + 引导补画像收窄与福利 · 9f8c62f6
      ① **「没被分过的排在前面」改成条件化**。查了口径:池子基线已经排除"还在客服手上"的单,
         分过但已退回/到期回池的**仍在池子里**(T7:退回是正常路径,不能分过一次就永不再召),
         只是排到后面。本地实测池子 1094 人里回池的 **0 人** —— 这时说那句是废话,
         还会让主管以为系统在防一个不存在的情况。
         现在:有回池的人才说「之前分过又退回的 N 人排在后面」,否则只说「优先级高的排在前面」。
         countCandidates 顺带 FILTER 出这个数,不多一次查询。
      
      ② **确认单等这段话说完才出现**。上一版只做了"钉到消息末尾",但流式期间卡片已经在下面,
         主管看到的是"卡片先蹦出来、文字再在它上面一行行长" —— 比卡片在前更怪。
         现在流式期间不渲染 sheet,说完再出现。
      
      ③ **引导语补两条**(主管不知道能这么用,不说他就只会点确认):
         · 再挑一挑人:「只要商保直付的」「排掉最近来过的」→ 助手先看分布再重新圈;
         · 带个福利:「这批带上『老客户复查免挂号费』」→ 新增 set_benefit,写进 attributes.benefit.text。
         福利链路本来就通(话术 prompt 已接),缺的只是主管怎么填进去。
         ️ 福利在卡片上**只读**(有才显示一个绿标):在 400px 卡里放自由文本框,
         主管十有八九写成一段带承诺的话,而那道护栏在话术生成侧,别污染源头。
         ️ 引导语要求用主管的话说, 不许报工具名。
      
      本地实测:说「这批带上「老客户复查免挂号费」」→ 卡片顶部出现「福利 · 老客户复查免挂号费」
      绿标,界面回报那句也进了对话。989 tests green。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • refactor: 助手先说话再给卡 + 三句依据改说人话,只留「怎么选/怎么分」 · 3f7b78c2
      走查:那段输出像给程序员看的 ——「按『未进过批次优先 → 优先级高优先 → 患者号』排序」
      「从手上最少的开始填」「首次按在岗 17 位 × 20 估」,而且卡片在前、文字在后。
      
      ① **顺序:文字在前,卡片在后**。
         ️ 不是"让模型先说话"能解决的:工具调用**必然在文字之前**(模型要先拿到结果才知道说什么),
         而卡片是工具执行时经侧信道推来的,到达顺序天然是"卡片 → 文字"。
         所以在**渲染层**把 assignment_sheet 块钉到消息末尾(stable 排序, 不整体 sort ——
         文字块之间的先后是流式拼出来的,打乱就成乱码)。
      
      ② **措辞改成说给主管听**:
         · 怎么选:「符合条件的一共 1084 人,这批挑了 340 人:没被分过的排在前面,其次是优先级高的。
           剩下 744 人在排队,随时可以再分一批。」
         · 怎么分:「340 人分给 17 位客服:有专属客服的先回到自己人手上,剩下的谁手上活少先给谁。
           分完后每人手里 20 条,3 天没动会自动退回池子。人数和时效是第一次的估算值……」
          「排序键/收敛/铺平/水位/基数/探索配额/患者号」一律不进主管界面 —— 加了断言守着。
         ️ T14 要的三件事一件没少,只是换了说法:分完每人多少、这两个数哪来的、精调过的人点名。
      
      ③ **内容收敛到「怎么选 + 怎么分」两句**,末尾加一句调整引导
         (「把某某移出这批」「某某的单给 5 天」「这批 200 人」—— 我直接改)。
         名册那句(近 12 月有回访记录的近似判定)不再单独占一条 bullet,rosterNote 字段保留。
      
      本地实测通过,助手实际输出已核对。989 tests green。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat: 助手能直接改确认单(edit_assignment_sheet) + 卡片去掉只读的人数块 · 8ff25259
      走查实录:主管说「把杨丽华移出这个确认单」,助手回"我这边没法单独剔除,您先点确认、
      之后在批次详情里单条退回" —— 把界面能做的事推回给主管手工做,而卡片就在他眼前。
      
      新增本地工具 edit_assignment_sheet(不走 MCP,同 propose_assignment):
      能做的与卡片完全一致 —— 删患者 / 移除客服 / 改派 / 改时效(整批、按客服、按患者)。
      
       **指令用姓名,不用 planId**:那些 id 从来没进过模型上下文(确认单走侧信道,故意的)。
         模型负责"听懂",**界面拿自己手里那份确认单去匹配**。为了让模型能指名道姓而把明细
         喂给它,等于把当初走侧信道的理由(几百个 uuid、幻觉、成本)全部退回去。
      ️ **结果由界面回一句进对话**(复用 appendAssistantNote), 模型不许自己宣布成功 ——
         它看不到卡片,匹配不到/同名/客服不在本批都是界面才知道的事。那一句同时进模型上下文,
         下一轮它才不会替系统撒谎(T14)。工具返回值里也写死了"等界面那句再说"。
      
      🔴 修两个实测抓到的坑:
      · 指令挂载**必须跨消息找**最后一张确认单 —— 卡片几乎总在上一轮消息里,
        只在当前消息里找永远找不到,而且不报错、界面毫无反应;
      · **同名歧义不许猜**:实测「张悦」既是本批客服又是本批患者,模型选了 remove_agent,
        于是"把张悦移出确认单"变成把客服连同 20 条一起移除。现在这种情况直接拒执行并说明,
        提示词也定死默认按**患者**理解(猜错的代价不对称:一下子动 20 条 vs 1 条)。
      
      · 卡片底部那块只读的「人数 本批 340 人 / 要改说…」撤掉:只读信息不该占跟可操作区
        一样重的位置,它想说的两件事都有更好的落点(顶部数字 + 现在可以直接让助手改)。
      
      本地实测:对话说「把张悦移出这个分配确认单」→ 助手调工具 → 界面执行并回报
      → 确认按钮 340 条变 320 条。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(web): 患者带性别年龄 + 拖拽自动滚动 + 输入框加回边框/回到底部 + 去掉三句依据 · 7cb0048d
      产品走查四条:
      
      ① **输入框加回边框**(上一版"无边框"太散,找不到落点):外层仍与会话同底、无分隔线,
         输入块自己有边框。同时补一个**「回到底部」**圆钮 —— 只在没贴底时出现,浮在输入框
         正上方居中(对齐 Claude Code)。️ 判据复用 onScroll 里那个 80px 阈值, 不另写一个:
         两个阈值不一致会出现"按钮还在、其实已经到底"这种解释不清的状态。
         为此把 stickRef 加了个可渲染副本 atBottom(ref 改了不触发重渲染)。
      
      ② 患者条目补**性别·年龄**,与患者详情卡片同口径(formatGender + calcAge)。
         ️ 年龄**读时算**(存的是 birthDate),两处各写一遍必然在生日当天差一岁。
      
      ③ 🔴 **拖拽自动滚动**:HTML5 DnD 不会自己滚容器,助手窗一屏只放得下三四个客服,
         拖到边缘就卡住 —— 想丢给下面的人基本不可能。现在拖动中指针进入上/下 56px 感应带
         就按 rAF 持续滚。️ 滚的是**离卡片最近的可滚动祖先**(运行时找),不是 window:
         写死 window 在最大化/非最大化两种形态下会滚错东西。
      
      ④ 卡片底部那三句(selectionNote/basisNote/rosterNote)撤掉展示 ——
         "规则拼成的,用户不需要知道"。️ 三句话没删:助手在消息正文里已经原话转述过一遍,
         卡片再贴一次是同一段出现两遍。 服务端那三个字段别删,助手要靠它们照抄。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • style(web): 助手窗去掉消息头像 + 输入区改成无边框同底(对齐 Claude Code) · 1d7041e6
      · 助手每条消息左边那个 28px 圆头像撤掉:助手窗只有 400px,头像 + 间距白白吃掉
        一成宽度,而"这段是助手说的"靠气泡形态(用户右对齐深底 / 助手左对齐无底)已经分得清。
        头像只留在 header 和空态引导页。
      · 底部输入区改成与会话区**同底色、无边框、无分隔线**:去掉 border-t、外层白底、
        内层的圆角边框与 focus ring。️ 底色用 `bg-inherit` 而不是写死 bg-slate-50 ——
        写死的话根容器换底色时这一块会漂。
      · 去掉「结果仅供参考 请核对后使用」那行免责声明。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed