1. 16 Aug, 2026 2 commits
    • fix(备份): 先轮转再 dump + 空间预检 —— 这个脚本自己把测试库崩过两次 · 5d2a3d02
      测试机 47.251.104.47 的每日备份(`/root/pac-backup.sh`,cron 0 4 * * *)
      连着两次(08-11、08-15)在 04:30 前后失败,backup.err 里是
      「server closed the connection unexpectedly」。翻 postgres 容器日志才看清:
      
        PANIC: could not write to file "pg_logical/replorigin_checkpoint.tmp":
               No space left on device
        checkpointer process was terminated by signal 6: Aborted
      
      ⇒ **不是 PG 的毛病,是这个脚本撑爆的盘。** 轮转写在 dump 成功之后,
        于是写第 4 份(~12G)时旧 3 份 33G 全程占着 → 盘满 → PG 当场 PANIC、
        整个实例重启走 WAL 崩溃恢复 → pg_dump 连接跟着断。
      
      ️ 最难受的一点:脚本删掉 .partial 之后空间就回来了、库自己恢复完毕,
        **白天查什么都正常**,所以连崩两次都没人发现。
      ️ 崩溃恢复还清空了 pg_stat_*(统计文件不跨崩溃保留)——
        之后查到的死元组数只是崩溃后攒的, 别拿它判断表膨胀。
      
      两处结构性修改:
      ① **先轮转、再 dump**:留 KEEP-1 份进 dump,写完正好 KEEP 份。
         峰值从 (KEEP+1)×份 压到 KEEP×份 —— 不再需要凭空多出一份的余量。
         代价:dump 失败时手上只剩 KEEP-1 份;比起把库撑崩,可接受。
      ② **动手前先算够不够**,不够就不开工并大声记日志。
          不许为腾地方自动多删旧备份 —— 少留几天恢复窗口是人的决定,不是脚本的。
         预检在**删之前**算(用"轮转能腾出多少"做加数),不够时一份都不动。
      
      另外:KEEP 3→2(盘 197G / PG 卷已 75G / 一份 dump 12G,KEEP=3 两天就回到
      5G 余量,正是事故前的水位);加 --dry-run(只算不动手);backup.err 超 5M 自转。
      
      📌 顺带把它收进仓库:此前**只存在于服务器上**,而 scripts/backup-db.sh 是另一个
         本地临时用的脚本,两者早已各走各的。README 写清谁是谁。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(名册/矩阵): 在岗按「在这儿干活」判,不是「碰过一次」;算不出档位的人数去重 · 4b2dec7e
      测试服上海世纪公园显示「193 位在岗」,而同规模的其他诊所都是 32–43。产品指认
      康慧捧是别家诊所的人 —— 查下来正是如此,而且是普遍现象。
      
      ① **名册闸**(agent-roster.service.ts)
         拆开看这 193 人:19 个做了 15,476 条(95.8%),**98 个只有 1 条**、63 个 2–5 条;
         161 人(83%)全年合计 296 条,占 1.8%。按主场分:**160 人主场在别家诊所**,
         他们在这儿总共只有 330 条(康慧捧:主场 3,799 条在另一家,这儿 3 条)。
         医生/护士/前台偶尔被记成一次回访负责人,就进了名册。
         ️ 危害不止那张表不好看:`rosterCount` 直接进默认批次估算
           (在岗人数 × 每天几通 × 时效)—— 193 × 15 = 2,895,比真实规模大一个数量级。
         ⇒ 判据改成「本诊所回访量占个人总量 ≥20%,**或**本诊所 ≥20 条」。
         ️ 两条取或,缺一不可:只用占比会挡掉 13 个在某诊所做了 50+ 条但个人总量更大的
           真客服;只用绝对量对小诊所和新人不公平(那正是"名册不是白名单"要护的人)。
         ️ 分母是**跨诊所**总量, 不是本诊所的 —— 否则占比恒为 100%,整道闸失效(已锁测试)。
          实测:世纪公园 193 → 34(25 个靠量进、9 个靠占比进),康慧捧被挡掉;
           其余诊所各减 1–6 人,每家留下的名册仍覆盖本诊所 **98% 以上**的回访量。
         ️ 闸只改"建议给谁", 没改"能分给谁":被挡掉的人走 extraUserIds 照样能被点名,
           姓名由 namesAnywhere 兜底、inRoster=false。rosterNote 的措辞跟着判据一起改。
      
      ② **「另有 N 人算不出档位」数错了**(cohort-attributes.service.ts)
         界面写 50,去重只有 42 —— 50 是把 8 个治疗行的 unknown **竖着相加**得来的,
         一个患者有几个治疗项就被数几次,而那句话写的是「人」。
         本方法开头那条口径(「矩阵按患者去重」「 别让主管一对数就觉得系统在骗他」)
         讲的正是这件事,偏偏这行汇总自己踩了。
         ⇒ 改成 GROUPING SETS 多取一组跨标签去重行。️ 必须与各格**同一次查询**算:
           分两次就是两个 NOW(),边界人群会在两个时刻落进不同档,差额又对不上。
         ️ 话里补一句「那一列竖着加会大于这个数」—— 不写清楚,主管一相加还是对不上,
           那只是换了种方式让他不信任这个数。
      
      📌 顺带定案:**咨询不算到诊**(产品定)。那 42 人名下只有 diagnosis_record +
         consultation_record、**一条 encounter/病历都没有**,末诊为空是对的,不是漏算。
          不改末诊口径(schema 上写死的 encounter + 治疗 + 挂号 + 病历 并集)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  2. 15 Aug, 2026 12 commits
    • chore: 删掉 docs/~/gitlab.md —— 波浪号没展开留下的空文件 · be90e662
      e6e079c2 带进来的 0 字节文件,路径里那个 ~ 是字面目录,不是家目录;没人引用。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(助手): 全角标点那条加一句「重点遵守」 · 1064a910
      VOICE 层里中文标点全角本来就写着,实际输出仍常混半角。 不新增规则、
      不改语义,只把这一条的语气加重(排版类里它最容易被顺手违反)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(助手): 撤回「qwen 结构上做不到边写边调工具」—— 那是伪证据,且早被裸台验过 · 9818aa95
      产品指出后复核,8-14 写在 use-assistant-chat 里的那段"能力判断"两处推理都断:
      
      ① 「百炼文档示例里出 tool_calls 那轮 content 为空 ⇒ 那一步写不了字」——
         示例是一次示范、不是契约;"某个示例没写字"推不出"写不了"。
      ② 「一旦某步没有工具调用多步循环就结束 ⇒ 所以它只能先把工具全调完再一口气写完」——
         前半句对**任何模型都成立**(deepseek 也一样),而且恰恰不推出后半句:
         文字+工具、文字+工具、末步只有文字,循环照样结束、输出照样交错。这个「所以」是空的。
      
      🔴 **而且它早就被验过了,结论相反。** assistant-lab 就是为这件事建的裸台,
         里面专门开了 qwenThinking provider(只塞 enable_thinking, 不碰 response_format),
         目的写在那儿:「这一轮只是要验开了思考 qwen 会不会好起来」。
         产品实测:**开了思考的 qwen 能边写正文边调工具**。⇒ 那条结论已被推翻, 别再引用。
      
      ️ 更该记的是:反证**当时就摆在旁边**。同一天的注释写着"共用 provider 无条件给
         每个 qwen 请求塞 enable_thinking=false,连 agent 这条带工具的链路也塞 ⇒ 这几天
         拿 qwen 跟 DeepSeek 比要不要边写边调工具,其实是「便宜档+会思考」对「最高档+不许思考」,
         比较本身不成立" —— 而"这几天"正是那条结论的产出期。两份注释直接打架,日期都是 8-14。
          根子在:结论只留在人脑子里,被它推翻的旧结论却用硬语气写在代码里,
         下一个人(包括 AI)读到的必然是错的那份。
      
      顺带修正 assistant-chat 里那条退化保护的**归因**:原文写"实测 qwen 会把 tool-call
      全提到文本之前,deepseek 则正常交错",当成模型差别。不对 —— 当天在 **deepseek** 上
      抓到过同一形状(golden 的 guidance-opens-in-place,失败形状 [工具×3][文本]),
      是补了工具描述里「可动手的事**一件一件**开放」才稳住的,那是**提示词缺口**。
      ⇒ 那条保护**与模型无关,一直留着**:退化时主管看到的是一排没头没尾的按钮。
      
      现在只保留一句站得住的:**默认 deepseek,因为跑批基线都在它上面测的。**
       别写成"另一个不行"。要比就在裸台上开着思考比,或 GOLDEN_MODEL=qwen3.8-max
      跑 guidance-opens-in-place 比通过率。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(助手): 患者详情页把「当前开着谁」发给模型 —— 跑批抓到的那条,补上 · 5ce0ec6e
      a8c60f7c 跑批当场记下、标着**未修**的那个:患者详情页的助手**从来没把
      "当前是哪位患者"发给模型** —— current 里只有 planId + 姓名,姓名只被拿去
      预填例句文字。界面上那条不带姓名的例句「这通电话怎么开口比较好?给我两句开场」
      当第一句发出去,**模型不知道是谁**。
      
      ⇒ 走旁路,与 activeClinicId 同一条:store 的 current 补 patientId,
        前端每次请求带 activePatientId,服务端拼在**现场之后、渠道之前**。
      
      ️ 措辞两件事都要说:**默认落点**(没点名时指的是他)+ **可以被推翻**
        (他点了别人的名字就以他说的为准)—— 只写前半句会让它连"查另一个人"都不敢。
       **不注入到工具参数**:注入等于把"查另一位患者"这条路堵死,而他随时会问别人。
       不进 buildSystemPrompt 的五层:那五层按**什么会让它变**分,而这一行每次请求
        都不一样,是**此刻的现场状态**,不是规则。
      ️ 只发 id —— 姓名由工具返回, 别把界面上的显示名当事实喂给它。
      ️ 前端可改的入参,越权仍由各工具自己的 assertPatientInScope 挡,
         不因为"是我们自己发的"就当可信。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(分配): 确认单每位客服加负载天数;换算尺跟着提案一起下发 · a99f8eb8
      主管在确认单上看「每个人要打几天」——「约 N 天」落在每位客服那一行,
      超出本批时效只用**颜色**(琥珀)提示。
      
      ️ 算的是**分完之后手上的总量**(在手 + 本批), 不是本批那几条:
        他手上原本压着的也要打,只算增量这个数会系统性偏小。
      ️ 与 daily_overload 引导节点**同一个式子** ceil(总量 / 每天几通):
        那条只报最忙的一位,这一列把每个人都摆出来, 两处别算出不同的数。
       措辞不带「打不完 / 超了 / 过载」——它几乎每批都会有人超,说成故障
        主管就会开始怀疑系统而不是做决定(同引导节点那条纪律)。
        在**卡片里现算**:他拖一个人、点一个 ×,这个数当场就变;服务端那份是快照。
      
      做这个功能撞出两个真 bug:
      
      ① **换算尺根本没跟着提案回来。** dailyCalls 此前**只是 propose 的入参**,
         算完就丢 —— 主管说过「每人每天按 20 通算」之后,卡片和 modelFacts 里
         **仍然写死常量 15**:「按每天几通算: 15」和他刚说的 20 直接打架,**而且不报错**。
         与 rosterCount 同一条理由:**乘法用到的数必须跟着积一起发**。
         现在 daily_overload 的判定与措辞、batch_size_basis 的式子、那个输入框的预填值
         全读 p.dailyCalls, 不再引常量(顺带修掉:他填了 20,下次输入框还预填 15
         = 又把他的改动抹了)。
      
      ② **「重算丢东西」的第四个** —— 按自己写的通例一测就撞上:refill 那条路不收
         dailyCalls,主管改成 30 通再点一下「补上」,天数整列悄悄退回按 15 算。
         ️ 它**不影响人数**(targetCount 已显式带回),只改换算与呈现 ——
         正因为不影响人数,比前三个更难被发现。已补齐 schema / controller / 前端,
         并加进契约测试那条通例(注明这一点)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • test(golden): 补客服线 3 条 —— 此前 12 条全是主管,那条线一条没测过 · a8c60f7c
      用例格式加 who / asUser:主管和客服是两套工具清单(21 vs 9)、两份角色与现场,
      runner 按 who 取 token 并缓存;客服侧不传 activeClinicId(那是主管那一屏才有的)。
      asUser 用真实客服 id —— 通用 mock 身份名下没有单,「今天该联系谁」会退化成空队列,
      测出来的是另一件事。
      
      三条(都指得出真实风险, 不是设想的场景):
      - 今天该联系谁 → 必须真查队列(名单编错他会真打过去,与主管侧「数字必须有锚」同源)
      - 帮我捋一下某某 → 必须先找到人(话术编得越顺越危险,客服照着念患者当场听出不对)
      - 还有哪些人我可以联系 →  不许说得像替他领了活(客服结构上领不了,测的是"会不会说得像做到了")
      
      ️ 防腐测试拦下了我第一版的 mustNotSay(「已经给你」「已经加到」)——它是对的:
      那两个会误伤合法回答,是**措辞锁**不是损失锁。只留「已领取」,并把它加进允许集 + 写明理由。
      
      🔴 跑批当场又抓到一个真问题(**未修**,见用例注释):患者详情页的助手
      **从来没把"当前是哪位患者"发给模型** —— current.patientName 只用来预填例句文字。
      界面上那条不带姓名的例句「这通电话怎么开口比较好?给我两句开场」当第一句发出去,
      模型不知道是谁。与主管侧 activeClinicId 同类,那边走旁路解决了,这边没有。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(站点): 新增《分配助手》—— 给产品业务看的 agent 说明 · ee4aab23
      apps/pac-docs/content/docs/design/assignment-agent.mdx(6 张图):
      ① 三者分工:主管做决定 / 助手组织语言调工具 / 程序算数取数落库
      ② **分配决策树**:从"主管圈定"到"已确认"的完整走向,含两条闭合的环
         (改派法→回同一版 / 改人群→出新版旧版作废),以及助手在这条线上
         唯一实质的那次判断(动的是「怎么派」还是「这批人是谁」,代价不对称)
      ③ 它由什么拼起来:五层提示词 + 工具 + 每次出方案给它的事实与引导节点
      ④ 提示词五层(按「什么会让它变」分)+ 两种登录人看到的两份
      ⑤ 引导节点长什么样 + 三条设计原则
      ⑥ 三层验收:算法/交接用测试,助手行为用通过率
      
      ️ 与 batch-assignment.mdx 分工:那份讲**功能**,这份讲**那个助手**。
      ️ 工具数(主管 21 / 客服 9)按条件注册实点过, 不是估的。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • test(golden): 接口错误 不算模型失败;样本太小时明说 · a142cb6f
      两条都来自 2026-08-15 自己踩的:
      
      ① 跑批跑到一半我改了服务端源码 → dev server 重启,请求全断(terminated /
         fetch failed),而 runner 把它算成"用例没通过" —— 输出读起来和模型退化一模一样。
         ⇒ 接口错误单列、不进通过率分母,并直接提示"服务是不是中途重启了"。
         一轮都没判成时退出码 1 并写明"本次结果作废"。
      
      ② 3 轮的 1/3 让我判成"模型判不准",干净重跑 10 轮是 9/10 —— 同一个行为。
         ⇒ rounds < 5 时输出一行:这个数分不出"判不准"和"运气差", 别据此改提示词。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(分配): 重排不再丢掉主管的按客服精调 —— 「重算丢东西」第三个 · f353f51b
      「李莉这周带教,这批最多给 5 条」只活在这一版的 agentOverrides 里,而 propose 的
      基线读的是**上一次已确认批次** —— 点一下「换无专属客服的患者补上」他刚设的那条就没了,
      李莉照常吃满,而且不报错。
      
      实测(上海世纪公园 · 拔牙 3 年以上):
        不带精调重排 → 胡婷 16 条
        带 maxThisBatch=5 → 胡婷 5 条, overridden=true, agentOverrides 原样回传
      
      三处补齐:RefillProposalRequestSchema 加 agentOverrides / 控制器透传 /
      前端发**当前这一版**的(已含基线合并结果, 不能只发本轮新加的,否则沿用下来的会被当成撤销)。
      
      契约测试加了一条**通例**断言:「他在这一版上做过的事」三样一样不能少
      (narrowedBy.personaTags / narrowedBy.minSpendYuan / agentOverrides)——
      这是同一形状的第三个 bug, 加第四个能触发重算的载体时先回来补一行。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(分配): 六份文档划清分工 —— 教条写为什么,规格写是什么 · 163b456e
      不合并(分工清晰、交叉引用完备),但修掉重复已经兑现的代价:
      
      🔴 落人算法在 doctrine 和 flow 各写了一遍,然后分叉了。
         doctrine 写着「第三趟 = 有主改派,标 spread_overflow」——那一趟 2026-08-06
         就改成「不动,交主管定」了,flow 跟上了、doctrine 没有。而 dev-plan 表头写着
         「冲突处以教条为准」,照那条规矩读者会信错的那一份。
         ⇒ doctrine 那节改成只留"为什么",具体规则指向 flow 与 placeAgents。
         ️ 并注明 spread_overflow 取值仍在枚举里(历史批次有), 别照枚举反推当前行为。
      
      + doctrine 头部加一张六份文档的分工表(只放这一处),写明「教条写为什么、
        规格写是什么、 别两边各写一遍」,并把这次的教训作为依据附在下面。
      + 两处陈旧状态行:plan-assignment-dev-plan 停在 2026-08-02(标为历史裁决记录,
        并指向现状去哪看);doctrine 的「讨论中」补一句「不等于还没做」。
      
      golden:退掉 no-invented-clinic-id 的 mustCall(get_current_user)——它测的风险
      2026-08-14 已被结构消灭(clinicId 参数整个删了,模型没地方能填 CL001),
      留着每三轮红一次而红的是用例不是产品。mustNotSay 保留。
      ⇒ 这是第二次"用例随产品过期",记了一条:跑批红了先问用例还成立吗。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(分配): agent 文档对齐实现 —— §五规格表漂了四行 · e96f34ca
      assignment-agent-flow.md:
      - §五「确认前」表重写:之前列着 short_supply(已删)、highlight(2026-08-13 被
        narrow 取代,下文自己写了、表却没改)、expiry_default/anchor_nondefault(从未实现),
        却没有 narrow 和 batch_size_basis。真源是 GUIDANCE_KIND 那四个键。
      - daily_overload 的选项从三个改成一个(不带数的两个已删)。
      - 「引导节点是第三段」→ 它们嵌在各自所属的那一段里;顺带删掉那句
        「assistant-prompts 第 0.5 条与此冲突,以本文为准」——冲突早已不存在。
      - 编号 → 中文标识;place_guidance → show_guidance。
      - 动词 圈 → 选。
      - 确认后那条 no_benefit 节点整节改写:福利前移到确认之前问,确认单里不做引导。
      - 补两条文档里根本没有的硬要求:重跑必须原样带回人群条件(三处载体)、
        草稿态模型看不见要查 get_current_sheet。
      - 修一处"拿已删的判据当理由":「重排后 short_supply 还会再兜一次」。
      
      plan-assignment-doctrine.md:精调点名的出处由已删的 basisNote 改成
      modelFacts.他单独设过的;批次名示例里的「窗口内」换成当前档位名。
      
      assignment-agent-dev-plan.md:26(golden 跑批 runner)由「未做」改成已落,
      并补 P7 一节记这一批实测改动与测试基建抓到的三件事。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(分配): 助手提示词分层 + 确认单状态给模型 + 一批实测修 · 8b19ff33
      提示词按「什么会让它变」分五层(装置/诚实/语气/角色/现场),现场段按
      选人 → 分人 → 他确认 → 确认之后 四步分节 —— 每条规矩站在它生效的那一步里,
      位置本身就是适用范围。次要业务线(分配追踪)走 pull:系统提示词只留索引,
      模型用 open_playbook 按需取正文。
      
      实测抓到并修掉的:
      - 「在岗 N 人 × 每天几通 × 时效」印的是 byAgent.length(本批分到人的客服数),
        实测印出「在岗 9 人」而主管同屏写着 33 位在岗,9×15×1 连它自己那行的
        「本批人数 214」都算不出来 ⇒ 新增 rosterCount,由算 batchSize 的同一行赋值。
      - 「换无专属客服的患者补上」不回传 narrowedBy,主管收窄过之后点它会悄悄
        退回整格取人(实测 2178 vs 296,差 1882 人)⇒ 链路补齐 + 按钮自己报范围。
      - 确认单的草稿态服务端查不到(改派/移出/时效/福利只在浏览器里),模型只能把
        对话里几行回声自己累加,而手动拖动/点×/改时效这几条路连回声都没有
        ⇒ SheetSnapshot 随请求捎来 + get_current_sheet(拉) + 回声带当前值(推)。
      - 「没分到的客服」只给了个数字,主管问「那 1 位为什么没轮到」答不上来
        ⇒ 补他们的在手量(实测 193 对 49,对比自己说清)。
      - 模型讲区间写 8~9,GFM 单波浪号当删除线吃掉 → 屏幕上是「89 条」
        ⇒ 渲染层 singleTilde: false(与 range() 拆两个键是同一条纪律的两半)。
      - daily_overload 里两颗不带数的按钮(改每天几通/减少本批人数)连同两个 intent
        删掉:点下去替主管说的话也不带数,那个数只能由模型自己拍。
      - 福利改判:确认之前问一句(客服一确认就开始打这批单),确认单里不再摆引导节点。
      
      模型对齐:qwen 统一 3.8-max;计划摘要跟另外三个摘要走同一个模型(裸键 'qwen'),
      schema 的硬长度约束改 describe 软引导 —— 否则 qwen 写得短必然 too_small
      (判据同 draft_plan_script v27)。
      
      界面:助手会话按 Claude Code 的排布重做(无边框头部、白底、底部工具栏、
      思维链/工具步骤可折叠),确认单卡片与引导节点跟着四步走。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  3. 12 Aug, 2026 14 commits
    • docs(分配): 画像亮点判据的实测依据 —— 朝阳公园 40 格扫描,29 格命中 · 17fdd11e
       只留「倍数 ≥2」那一条的话,「一般发展 69% vs 48%」这类大基数偏移全部打不着 ——
      这就是加百分点判据的实证,记下来免得以后有人把它当冗余删掉。
      
      结果有明显规律,可作为判据可信的旁证:
      - 2-3 年那一列几乎全是「低活跃」(4.6x / 6.2x / 5.0x)
      - 3 年以上几乎全是「重要价值」+「口碑客户」(种植 60%、牙周 83%)
        → 沉睡的恰恰是高价值的那批
      - 早矫/正畸的 3 年以上是「儿牙会员」(81%),买了年卡然后没再来
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(分配): 画像亮点补「百分点」判据 + 获客渠道成维度 + 修圈人提示语泄露内部码 · a93a2567
      🔴 本地实测发现「倍数 ≥2」**结构上打不着大类**:
      朝阳公园的获客渠道是 口碑 33% / 集团营销 23% / 走入 22% ——
      口碑要够 2 倍得占到 66%(现实里不可能);而能翻倍的小类(电商 2.3%)
      在一格 71 人里连 10 个人都凑不出 ⇒ **整个维度永远不会命中**。
      ⇒ 命中改成两条取其一:倍数 ≥2(抓小基数聚集) 或 高出 ≥12 个百分点(抓大基数偏移)。
      措辞跟着口径走:大基数说"高出 N 个百分点",小基数说"约 N 倍";
      排序按可比的偏离强度, 不能只按倍数(那会让百分点命中项永远垫底)。
      
      🔴 修一处内部码泄露:点「只圈这些人」时替主管说的那句话是
      「这批只圈符合『referral_champion:yes』的人」—— 内部码原样进对话框,
      正是 P0 花一整节要消灭的东西。改成中文名 + 人数(「只圈『口碑客户』那 62 人」),
      ️ 拿不到中文名就**宁可不发**, 不许退化成把码念出来。
      人数带上是因为「圈完还剩多少」正是主管点这一下想知道的事。
      
      获客渠道补成 hidden 可筛维度(闭集 9 项,data.channel);
      折扣锚点**不纳入** —— 它是连续量没有闭集,要用得先定分桶(产品判断)。
      
      补一行画像亮点的 trace:没有它,「算了但没命中」和「压根没算」在日志里
      长得一模一样(实测就吃了这个亏)。
      
      1247 + 29 passed;web 20 passed。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(分配): 默认时效 3 → 1 天(一批推一天的活);卡片只留标题+按钮,解释交给模型 · 7ea5a365
      ① 时效默认 1 天(产品定):
         N = 在岗 × 每天 15 通 × 时效,D=1 正好是**一天的量** ——
         当天没打完的自动落回池子、明天重排。朝阳公园实测:765 → 255。
         ️ 连带后果(已知并接受):daily_overload 阈值变成 15×1,只要谁手上还有旧单
         就会亮。那不是 bug,它是工作量预估,每批本来就该讲清楚。
      
      ② 引导节点在卡片上**只渲染标题 + 按钮**:
         🔴 2026-08-12 浏览器实测:模型那段就在卡片正上方,两边都写全 ——
         「72 人的专属客服这轮已排满 / 他们有专属… / 不处理 = 这批不发给他们」
         主管要把同一件事读两遍。
         ⇒ 分工:**卡片管动作,对话管解释**。why / defaultLabel 仍随 signal 下发
         (模型要用),只是卡片不再渲染;折叠区的纯信息类旁边没有对话解释,仍给一句。
          按钮**不能**搬进对话:打字 → 模型理解 → 翻译成 intent,每一环都可能错
         (实测栽过 owner/balance 选反,18 人被散给 17 位别人);点击没有理解环节。
      
      ③ 两份设计文档同步这条分工。
      
      测试:1247 passed;web 20 passed;tsc / build 干净。
      ️ 改了一条断言:原来锁死"整体时效=3",改成断"整体没被那个人的精调带偏"
      —— 锁死默认值会让每次调默认都红一片。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(分配): 本地改单后撤掉已经不成立的引导节点(浏览器实测抓到) · bf9f86d0
      现象:点了「各自归专属客服」,72 人的待分配已经清零、汇总行也更新成
      「拟分 103 人」,而节点区还挂着「72 人的专属客服这轮已排满」+ 四个按钮 ——
      再点一次是空操作,主管只会觉得"我明明处理过了"。
      
      根因:signals 是**服务端随提案下发的快照**,本地 edit 只改了前端的生效条目,
      没有任何东西去更新它。违了引导节点设计规则④「每次重算,不做增量」。
      
      修法:只撤掉**主语已经消失**的那些,判据全部来自卡片本地看得见的事实:
        · pending        —— 待分配被处置光了,这条没有主语了
        · expiry_default —— 主管改过时效,"用的是默认值"就不成立了
       **不在前端重算判定** —— 判定逻辑复制到两边必然漂(T6a 同源)。
      其余(候选不足 / 工作量 / 画像亮点)本地改单不影响、或需重跑才知道 → 原样留着。
      
      ️ 这个 bug 单测抓不到:逻辑各自都对,错在"服务端快照 + 本地可变状态"的接缝上。
      只有真的点一下才看得见。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(助手): P6 提示词减法 + golden set 用例集 · 77e84aaf
      25 提示词减法 —— 重点不是删多少行,而是修掉**三处已经变成错的**
      (留着它们模型会照着告诉主管错的东西):
      - 「两个基数都自动沿用主管上一次的值」+ 首次估法 `在岗 × 20`
        → P1 已改成 `在岗 × 15 × D` 且不再沿用
      - 「 不许拿上一轮的数字回答」→ 与 P3 之后的第 1 条**直接矛盾**
        (现在是"条件对得上就能用",而参数就写在 〔调用 …〕 摘要行里)
      - 「转述那三句依据(原话)」→ P2 之后那三句成品句子已经不喂给模型了
      外加清掉五处「照抄 xxxNote」残留;重写文件顶部方法论注释 ——
      原文写的是「凡是靠模型算对的约束一律降级成照抄」,P2 已经把这条推翻了。
      
      26 golden set:
      - tests/golden/assignment-golden.ts —— 9 条用例,每条都来自**真实踩过的坑**
        且写明由来。🔴 判定标准是**工具调用**不是文字:最贵的那类失败恰恰是
        「话说得挺好、工具一次没调」(实测栽过两次,两次文字读起来都完全正常)。
        ️ 用「必须调到 / 必须没调」两个集合, 不锁完整顺序 —— 多查一次不算错,
        锁死顺序会让每次合理优化都变成红。
      - golden-set-integrity.spec.ts(**进 CI**)—— 防腐:用例引用的工具还在不在、
        每条有没有写由来、id 有没有重复、mustNotSay 有没有被拿去锁措辞偏好。
      -  **跑批 runner 未做**,dev-plan 里如实标了。它要真调模型(花钱/慢/有波动)、
        刻意不进 CI,且开工前要先定:拿哪套凭据、用哪个诊所的数据、多少失败率算回归。
         不做一个跑不起来的 runner 然后宣称 P6 完成。
      
      1247 passed;web 20 passed;type-check(含 tests)干净。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(助手): P5 explain_assignment —— 「为什么这个人给了张悦」改成查, 不让模型推理 · 50f0dec7
      在此之前模型没有这个工具,被问到只能**推理** —— 而推理出来的理由听起来完全合理,
      主管照着去调策略就是白跑一趟。这是 F5(每加一个写工具,先回答"它怎么知道自己做对了")
      在读侧的对应物。
      
      - 依据全部来自 followup_plans 上那组**分配当时的决策快照**列
        (assign_strategy / dedicated_cs_at_assign / dedicated_cs_last_visit_at /
        priority_score_at_assign / selection_mode)—— schema 注释写的正是
        「为了半年后回答'当初为什么选了这个人'」。
         不拿"现在"的值解释"当时"的决定:专属客服会被摄入覆盖、优先级分会被引擎
        就地改分,两者都不留痕。
      - 🔴 查不到就**如实说查不到**, 不返回空壳 —— 空壳会让模型顺着编。有测试锁。
      - 「专属排满溢出」与「从来没有专属」必须分得开:主管据此做的事完全不同
        (前者是要不要挪回专属,后者是这人根本没人管)。
      - dedicated_cs_last_visit_at 回**时间戳不回结论**:「在岗」是滚动窗口算的,
        今天在岗的人半年后回查会变成离岗,给证据让主管按当时口径自己判断。
      - 只回**已落库**的分配;草稿阶段的「为什么」由卡片解释(明细在它手里,不在模型手里)。
      
      dev-plan 标了 P5 各项真实状态:21 validate 暂缓(草稿不落库,服务端拿不到它);
      23 count 模式已有一半;22/24 未做。
      
      1242 passed(新增 5 条);type-check(含 tests)干净。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(助手): P3 历史分层回传 —— 工具调用带回来了,但只带摘要 · 156a7cff
      在此之前 `toApiMessage` **只回传文本块**,工具块整块丢弃。代价:
      - 模型每轮从零开始,会重复调同一个工具;
      - 它上一轮唯一能看到的只有**自己说过的话** —— 而那正是"背出成品句子"的温床。
      
      现在按 blocks 原始顺序拼( 不把工具记录堆到末尾:先查什么、再说什么本身是脉络):
      - 工具调用 → `〔调用 名字 {关键参数} → 结果摘要〕`,args 截 120 / result 截 220
      - 失败 → **只留第一行且切掉堆栈**。️ 光靠长度截不住(实测:堆栈就在前 120 字里,
        回归当场红了)—— 要按结构剥,` at ` 之后的是给开发者看的,进历史纯属污染
      - 卡片 / 确认单 → 只留一个存在性锚(内容是渲染产物,复述没意义且很长)
      
      配套改提示词:原来那条「 不许从上文抄数字」是为"历史里没有数字"写的,
      现在自相矛盾了。改成**条件对得上才能用**:摘要行里写着当时传的参数,
      主管一换条件旧数就作废 —— 这正是"同一批人报出 12、17、13、70"的由来。
      
      测试:web 20 passed(新增 7 条分层回传回归,含肥载荷截断与堆栈剥离);
      service 1237 passed;tsc / build 干净。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(助手): P2 事实与措辞分离 —— 给模型结构化事实, 不再给成品句子 · b823023a
      🔴 核心:propose_assignment 的返回值从「三段成品句子 + 十几行『原样输出、
      不要改写、不要合并』」换成**纯结构化事实**(assignment-facts.modelFacts)。
      
      为什么:
        ① 成品句子有固定形状,模型学会形状就能**凭空背出来** —— 实测背出过
           「已撤销批次:收回 9 条。」,而撤销工具一次都没被调用。
           结构化事实编不出来:JSON 里的具体值瞎写一眼就对得出来。
        ② 「照抄」浪费了模型唯一不可替代的能力:把事实组织成人话。
           让它当复读机等于花钱买了个模板引擎。
      
      - assignment-facts.ts:三段投影(怎么选的 / 怎么派的 / 要主管定的),中文键。
         一个字的指令都没有; 不含 planId / 患者姓名(那些走侧信道)。
        候选不够时**本批人数与基数分别给** —— 否则主管以为系统偷偷改小了他的设置。
      - 提示词第 0.5 条重写成**唯一一处写作规范**;删掉靠模型自觉的 `###` 骨架
        (2026-08-11 实测:给了规则它一个 `###` 都不出)—— 结构现在由返回值的键名决定,
        ③ 级校验换成 ① 级结构。
      - 清掉三处「照抄 rosterNote / basisNote」「转述那三句依据」的残留指令。
      - ️ basisNote/selectionNote/pendingNote 字段**暂时保留**(卡片与 criteria
        快照还在用),只是不再喂给模型。
      
      测试:
      - 两条**对源码 grep 的断言**改成行为断言 —— 它们锁的不变式还在,但台词稿一删
        整条就失效了。⇒ 断行为不断源码。
      - 新增 7 条事实投影回归,其中「 里面一个字的指令都不许有」直接扫关键词。
      1237 passed;web 13 passed;type-check(含 tests)干净。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(分配): 确认单上的引导节点区 —— 按钮与助手共用同一组 intent · e2a38baf
      - confirm-sheet-signals.tsx:纯呈现。action 层**全部展开**、info 层折叠成一行,
        🔴 防噪音靠分层 不靠丢弃 —— 藏起一条主管就不知道有东西卡着。
        每条都显示「不处理会怎样」,让「一条不点直接确认」成为**看得见**的安全选项。
      - 位置紧贴汇总行, 不放卡片底部:主管视线从汇总往下走,"还有什么要我定的"
        必须在这一跳被看到 —— 埋到底部等于没有(那正是待分配当初被漏掉的原因)。
      - intent 路由(chat-blocks,纯函数 + 回归):
        · 待分配三动作 / 改时效 → 翻成 SheetEditOp[],压进和 `edit_assignment_sheet`
          **同一条 edits 队列**。 不给按钮另开执行路径 —— 两条路各做各的必然漂。
        · 换人群 / 换基数 → 必须重跑算法,替主管说那句话交给助手。
           不能用局部改单去凑:凑出来人群没变,他以为条件生效了其实没有(静默错)。
        · 重排一版 / 改时效由卡片就地做(它本来就有实现)。
      - 🔴 owner / balance 由按钮直出,没有理解环节 —— 实测栽过:主管说「各自分给
        各自的专属客服」,助手用了铺平,18 个人被散给 17 位别人。
      
      测试:web 13 passed(新增 5 条 intent 路由,含"两张表不能都认领同一个 intent");
      service 1230 passed;tsc + build 干净。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(分配): 画像亮点节点 + 修一处"假绿"(测试用 require 绕过了类型检查) · 5eb72b84
      画像亮点:
      - computeHighlights —— 纯函数,比两份分布:**这一格** vs **同治疗项整个池子**。
         判据是「相对基线的偏离」而不是绝对占比:「这批 80% 自费」没用(全池也是),
        「转介绍是平时的 2.6 倍」才是主管会据此改打法的东西。
        ️ 基线取同治疗项, 不取全池 —— 种植的重要价值天然高于补牙,用全池会永远命中。
      - 三道闸,缺一道就变噪音:倍数 ≥2 · 本批命中 ≥10 人 · 基线 ≥50 人。
        基线为 0 时**不比** ——  不许当成「这一格独有」凭空下结论。
      - CohortAttributesService.dimShares:精简版取数(只出分布),与 describe 共用
        同一份 cohortWhereSql / personaBreakdown, 不另写一套(T6a 同源)。
      - 维度只留 rfm / referral_champion / entitlement_status 三个。
        ️ 产品还点了「获客渠道」「折扣锚点」,它们目前只是 PersonaFeatureKey、
        不在 PERSONA_TAG_FILTER_DIMS 里,取不到分布 —— 要加得先补成 hidden 维度。
      - 亮点失败不让确认单挂掉(锦上添花),但要 warn 出来。
      
      🔴 修一处假绿:
        给 AssignmentProposalService 加第三个依赖后,测试仍按两参构造却全绿 ——
        因为那两处用的是 `const { X } = require(...)`,返回 any,**arity 查不出来**;
        运行时 this.cohorts 是 undefined,又正好被业务侧 try/catch 吞成"没有亮点"
        ⇒ 测试绿着,而新加的整条路径根本没跑。
        - require → 有类型的 import;补 cohortsStub 显式传入
        - 由此暴露并修掉 5 处此前被掩盖的类型错误(TemperatureValue / find() 可能 undefined)
        - package.json 的 type-check 接上仓库里**已有**的 tsconfig.typecheck.json
          (2026-08-05 因 ReleaseReason.OVER_CAPACITY 同类假绿建的,之前没进脚本)
      
      1230 passed;type-check 含 tests 干净。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(分配): daily_overload 重写为「工作量预估」—— 它常亮是对的,措辞不能像报错 · 718e1bbf
      产品定(2026-08-12):N 只算新增不扣在手,所以这个节点每批基本都会亮 ——
      那不是 bug,每一批本来就该把「这活多大、要打几天」讲清楚。
      
      -  措辞去掉「打不完 / 超了 / 过载」:常亮 + 报错口吻 = 主管开始怀疑系统,
        而不是做决定。改成「这批发下去,最忙的是张悦:手上共 60 条,约 4 天的量」。
      - ️ 数字**拆开说**:「本批 45 条、原本在手 15 条」。不拆开主管会读成
        "这批一下压了 60 条给他"(增量与总量分不开,同类问题栽过)。
      - 默认路径说清后果是可接受的(到期落回池子、下批还能再分), 不制造紧迫感。
      - 补第三个动作 DAILY_RATE_SET「改每人每天打几通」—— 它同时决定 N,
        原来只给了延时效/减人数两条。
      - 文档补一节说明为什么不改成 `Σ max(0, 15D − 在手)`:那会踩回 2026-08-03
        删掉的坑(第二批恒为 0)。
      
      测试:新增「措辞不许像报错 + 必须拆开说」一条,fixture 支持分别给
      本批条数与在手条数。1224 passed。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(分配): P1 引导节点(判定层)+ 基数改口径 —— 在岗 × 每天 15 通 × 时效 · e6e079c2
      引导节点(Signal):
      - 新增 assignment-signals.ts —— **纯函数**,输入一份已组装的提案、输出整套节点。
        ️ 不查库不看时钟:节点用的每个数都来自这一份提案, 不许再查一次 ——
        否则卡片和节点两个数字都"对"但对不上,那是最难查的一类 bug(T6a 同源)。
      - 五个节点:pending / daily_overload / short_supply / expiry_default /
        anchor_nondefault。前三个 tier=action(全部展开),后两个 tier=info(可折叠)。
        🔴 **不截断** —— 防噪音靠分层不靠丢弃:藏起一条主管就不知道有东西卡着。
      - 每个节点的默认都是 no-op,「一条不点直接确认」永远安全(有测试锁)。
      - ASSIGNMENT_INTENTS:**按钮与模型工具共用的同一组动作 id**。这是「按钮能做的、
        说话也能做」的结构保证, 不是提示词里的一句叮嘱。
      
      基数改口径(产品定 2026-08-12):
      - N = 在岗人数 × DAILY_CALLS_PER_AGENT(15) × 时效 D, **不再沿用上一次**。
        原设计"调一次省一次"会让 N 悄悄漂:某次为小格子调成 44,此后永远是 44,
        而主管看不出为什么变小了。
      - N 与时效挂钩:D 从 3 改到 5,能承接的量本来就该跟着变。
      - ️ 15 仍然**不是容量上限**、 不参与落人 —— 落人只有水位法。
        它只回答"一批推多大",以及当 daily_overload 的判定尺。
      - 沿用机制本身留着(resolveBaseline 还在读),要开回来只需把 baseline.batchSize
        接回去。
      - basisNote 改成把算式写出来 —— 主管看到 405 的第一反应是"怎么这么多"。
      
      测试:改 11 条旧口径断言,**不变式留住**(如「第二批不为 0」改成断言 >0 而不是
      断言具体数字);新增 11 条节点判定。共 1223 passed。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(助手): P4 草稿状态机 —— 出新版即作废旧版,作废态显式标注且不可确认 · 2ef1a8af
      产品定(2026-08-12):作废发生在**新草稿生成时**,不是在确认时。
      否则主管面前会同时摆着两张都能点确认的卡,点错就是分错一批人。
      
      - state: 'pending' → 'active' | 'superseded' | 'confirmed' | 'cancelled'
        ️ 顺带解掉一个命名撞车:原 state:'pending' 与「待分配」的 group:'pending'
        同名不同义,读代码时极易混。
      - 新草稿到达时**跨消息**扫描,把此前所有 active 作废;confirmed/cancelled
        是终态, 不许被回退。
      - edit 指令改为落到**唯一那张 active**, 不再是"最后一张" —— 重出一版后
        最后一张可能正是刚作废的,改上去主管一点反应都看不到。
      - 作废态:顶部横幅 + 底部说明 + 确认按钮禁用 + 降到 60% 不透明度。
         不隐藏、 不折叠 —— 他要能看见自己出过几版、哪一版才是当前的。
        ️ 顶部横幅不能省:确认单可以很长,视线停在上半部分时看不到底部那句。
      
      代码组织(按 dev-plan §三):
      - 数据模型 + 纯函数操作抽到 chat-blocks.ts(use-assistant-chat 534 → 479 行)。
        草稿状态机的失败形态全是静默的,端到端点一遍代价高且不稳 —— 抽成纯函数锁住。
      - pac-web 补 vitest(此前无任何测试基建),8 条回归覆盖跨消息作废、终态不回退、
        同 id 重推清空 edits、findActiveSheet 不退化成"最后一张"。
      
      tsc(service/web) 干净;pac-web build 通过;vitest 8 passed;jest 1212 passed。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs+feat(助手): 四份 agent 设计文档 + P0 落地(取值码中文化/解读规范下沉/截断可见) · 6658baca
      文档(新增四份,成套):
      - agent-doctrine.md      通用教条 49 条,按「后补代价」分三档
      - agent-architecture.md  四控制面 + 八原则 + 术语甄别 + 三方分工
      - assignment-agent-flow.md   分配的 Agent 行为规格(状态机 + 引导节点,定稿)
      - assignment-agent-dev-plan.md  P0-P6 改造规划 + 代码组织规范
      
      P0 落地:
      - model-facing.ts —— 取值码/桶名 → 中文。给模型现成的中文(①级),
        替代提示词里两页「 不许说出取值码」(③级)。只翻译不判断,底层
        service 形状不动(REST/前端是另一个消费者)。
      - guides.ts —— 解读规范随返回值下发(`_guide`)。get_assignment_detail
        的 35 行描述是常驻成本,而只有约三成会话用得到;挂返回值上零往返、
        必然到达、用不到时零成本。
      - stopWhen 到顶不再静默 —— finishReason='tool-calls' 时发 step_limit
        事件,前端如实告知。原来模型可能停在「我先查一下」之后,主管以为查完了。
      - SYSTEM_PROMPT 不再写死「为牙科诊所的客服人员」—— 主管也走同一条链路,
        与后面拼上的 DISPATCHER_EXTRA 当场矛盾。
      - 提示词减 35 行:删掉已被结构保证的部分(取值码、五个进度桶、noTag 桶名),
         保留仍需靠模型的(工具名、内部黑话)。
      - 骨架顺序改成「怎么排的 → 已排好 → 要您定的」,与 propose_assignment
        的实际输出顺序对齐(此前提示词与代码打架)。
      
      tsc(service/web) 干净;jest 1212 passed。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  4. 11 Aug, 2026 12 commits
    • fix(助手): 窗口 400 → 480,确认单汇总行改成可换行 · d0a9f5f4
      ① 窗口宽度 400 → 480(产品:「调宽一点」)。️ `openAt` 里的 480 与 className 的
         `w-[480px]` 是一对,Tailwind 任意值必须是字面量、插不进常量,改一处要改两处;
         顺带把注释里当年按 400 写的依据一并同步(下一个人不会再按 400 做决定)。
      
      ② 确认单汇总行**没做响应式**:`flex items-center` 不换行,窄窗里
         「共 N 人 · 拟分 X · 待分配 Y」+ 角标 + 时效下拉挤在一行 ——
         实测「已移除 1」被压成**竖排单字**、时效下拉顶出卡片边界。
         ⇒ 改 `flex-wrap` + `gap-y-1`;两个角标补 `flex-none whitespace-nowrap`
         (不锁就会被压到零宽、中文按单字折行,那正是竖排的成因);
         时效那组补 whitespace-nowrap。`ml-auto` 换行后仍按行推到右端,不受影响。
         同样的毛病在"已分配 + 撤销三个数"那一行,一并加了 wrap。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(助手): 标题与正文的缝收窄 —— 外边距折叠把它撑成了段间距 · c20339c4
      标题 `mb` 6px 与紧随其后那段的 `mt` 14px **折叠成较大的那个**,
      于是"标题正文"的缝跟"段段"一样宽,标题看着浮在半空。
      ⇒ 容器加 `[&_hN+*]:mt-1.5`,只压**紧跟标题**的那个块;段与段仍是 14px。
       别改 p 的 my 去凑 —— 那会把所有段落间距一起缩掉,段落又不成块了。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(助手): 标题改回"更大更粗更黑",撤掉限行长 —— 第一版把层级做反了 · 251f27d3
      产品指出两处:
      ① **标题比正文还小、还不加粗**。第一版 h3 做成了 12px/slate-500 的"小节标签",
         结果句中那个 font-medium 的加粗反而比标题更抢眼 —— 层级是**反的**。
         ⇒ h3 = 14px semibold slate-900(比正文 13.5 大半号),h1/h2 顺次放大。
          别再改回小灰字:那是**卡片小节**的写法,不是**对话正文标题**的写法 ——
           这段文字里标题是扫视的落脚点,它得是这一屏最重的东西。
      ② **一行断在半路、右边空一大片**:上一版加的 `max-w-[34em]`(限中文行长 25–35 字)
         在助手窗口放大后看起来像渲染坏了。⇒ 撤掉。
         窄窗本来就只有 ~28 字/行,行长问题只在放大时出现,而放大正是主管要"看得多"的时候;
          真要限行长得连用户气泡一起收进居中的对话栏(整块布局的事),别只勒住助手这一半。
      
      实测(强制模型输出 ###):h3 = 14px/600/slate-900,正文 = 13.5px/400/slate-700,
      段距 14px,容器 maxWidth: none。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(助手): 排版重做 —— 段落成块 / 标题独立一层 / 标点全角 · e5b0f01f
      产品:「输出抓不住重点」。查下来不是字体的事,四条:
      
      ① **段落根本没成块**:`p` 是 my-1.5(6px),而行距 22px —— 段距只有行距的 1/4,
         段与段比行与行还紧。→ 14px(通则:段距 ≥ 1.5 倍行距)。
      ② **加粗身兼三职**(段落标题 / 句中强调 / 后果警示),还同时跳字重和颜色
         (semibold + slate-900),一屏 7 处黑块 = 没有重点。
         → strong 降成 font-medium;**标题交给 h3**。
      ③ **标题层此前是死的**:h1-h3 定义得好好的,模型一个 `#` 都没输出过 ——
         它只有粗体一件武器,所有层级都拿它凑。
         → h3 改成小节标签(12px / slate-500 / 加字距 / mt-4),与正文靠**字号+颜色**分层,
            不靠字重(靠字重就又跟句中加粗抢)。
      ④ 行长 50+ 字(中文舒适区 25–35)→ max-w-[34em];数字加 tabular-nums。
      
      🔴 **标点:模型是在照抄提示词**。实测提示词里中文之间用半角逗号/分号 91 处、
         全角 0 处 —— 光加一条"要用全角"的规则等于跟例子对着干。
         ⇒ 提示词自身的标点也一并改了(逗号 176 / 分号 18 / 冒号 92 / 括号 78 处),
           再加一条第 0.5 条「怎么写」。改完实测:**半角残留 0**。
      
      ️ 但**只给规则,结构没跟上**:第一轮实测标点全对、`###` 一个没出、仍旧三大段 + 6 处加粗。
         ⇒ 按本仓既有方法论(「靠模型算对的一律降级成照抄」)补了一个**成品骨架**
           (要您定的 / 已排好 / 怎么排的),并把那次失败写进提示词当反例。
         第二轮实测:h3 三块齐、加粗降到 2 处、要主管动手的那块排到了最前面。
      
      ️ 两条断言里的半角标点改成 `[,,]` 兼容 —— 把测试钉死在某种标点上,
         失败与被测行为无关,只是噪音。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • refactor(中间表): 三个控件并成一个搜索框 —— 姓名/病历号/医生/客服 一起搜 · afd4d8cc
      原来是「搜索框 + 医生下拉 + 客服下拉」三件。并成一个:输入任意词,
      姓名 / 病历号 / 主治医生 / 上次就诊医生 / 专属客服名 任一命中即可(全部服务端 ILIKE)。
      
       别再拆回下拉:医生和客服的候选各要拉一个接口(/plans/doctors + 名册),
         而主管想的只是"跟薛玫有关的",他不关心那个名字落在哪一列。
      ️ 专属客服名直接读 `patients.preferences.dedicatedCs.name`(库里就有),
          不用先查名册再翻 id —— 那要在查询前多一次往返。
      ️ `%` `_` `\` 转义后再进 pattern:病历号里真有下划线,不转义会变通配符,
         搜「BJ0U_1」把 BJ0UX1 也捞出来 —— 多捞不报错,更难发现。
      
      顺带删掉 NO_OWNER(「无专属客服」那一档随下拉一起没了, 别留没人用的常量)。
      
      本地实测:薛玫→首页 50 条、金芮冰→24 条、BJ0U0175→19 条;
      按钮仍然写「确定,交给助手(420 人)」——搜索不改变交出去的人群。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(中间表): 分页改滚动加载 + 姓名/病历号/医生/客服 四个筛选(服务端过滤) · b3334df7
      翻页按钮换成滚到底继续加载(与批次列表同一套:scroll 事件 + inFlight ref 守卫 +
      按 planId 去重, 不用 IntersectionObserver —— 零高度哨兵不回调、内置浏览器面板里
      整个不工作,那条路没法在我们的验证环境里证伪)。
      
      筛选四个:姓名/病历号(防抖 300ms)、医生(主治**或**上次接诊任一命中,候选走
      /plans/doctors)、专属客服(候选走本诊所名册,另有「无专属」一档)。
      🔴 全部在**服务端**过滤:滚动加载只加载了前几页,放前端过滤就成了"在已加载的那几页里搜",
         第 7 页的张三搜不到还不报错。
      
      🔴 **筛选只影响"看",不影响"分"**:确定按钮上永远写整格人数(「确定,交给助手(420 人)」),
         筛选条旁边再明说一遍。这是"筛到 12 人却分出去 420"这个误解唯一的拦截点。
         要让筛选参与圈人是另一件事(医生/客服本就是画像维度,得一并传给助手), 别偷偷用 filtered。
      
      🔴 途中真炸过一次,注释钉住了:`input.agentUserId === NO_OWNER` 这种写法,在服务进程
         抱着旧 @pac/types dist 时 `NO_OWNER` 是 undefined,而不传筛选时 agentUserId 也是
         undefined → 恒等成立 → **每次查询都被悄悄加上"只看无主的"**,420 人的格子只回 20 行,
         而 count 仍是 420(它不走这段)。改成先判"传没传"再判哨兵。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(矩阵): 点格子先弹中间表看全量患者,确定才交给助手;助手不再默认最大化 · 07e5cabf
      主管点的是一个数字(「充填 · 半年到1年 420」),而他要为这批人负责。
      现在多一步:摊开这一格圈定的**全部**患者(姓名/病历号/性别/年龄/主治医生/
      上次就诊医生/专属客服),看过再点「确定,交给助手」。
      
      🔴 与出确认单**同源**:GET /plans/assignments/cohort 用的是 propose 那段
         **同一个** cohortWhereSql + 同一个 SELECTION_ORDER。 别为它另写查询 ——
         排序一旦不同,第 1 页看到的人 ≠ 助手真会挑走的前 N 个,而且两边都不报错(T14)。
         实测:中间表标题 420,助手随后说「这批 50 人从符合条件的 420 人里挑」,对得上。
      
      分页取,不一次拉全:最大的格子四千多人(测试服 充填·3年以上 4,159),
      一次拉回来 DOM 撑不住,主管也不会逐行看完。
      ️ 这里**用 offset 不用游标**(与列表页那条纪律相反,刻意):候选集是静态快照、
         排序键确定性、而且主管要「第 3 / 84 页」这种可回跳的翻页。
      
       助手**默认不最大化**了(handoff 原来传 maximize: true):
         前面已经有中间表让他看过人,再铺满整屏等于把他刚看完的工作台盖掉,
         而他多半还要回去点下一格。要看大图,窗口自己有最大化按钮。
      
      ️ 粒子起点从"格子"改成中间表上的「确定」按钮 —— 从一个已被弹窗盖住的格子
         起飞,看起来像凭空出现。
      
      越权闸两层都有:controller 一次(让"收 clinicId 必过闸"在源码上可数)+
      service 一次(换任何入口进来都拦得住);测试两条都锁。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(矩阵): 档位两版口径 —— 按诊断 / 按末诊,主管现切 · 291a3cdc
      「机会有多老」不可观测(onset_date 宿主 0 填充),手上只有两个观测时间:
        · diagnosis(默认) 医生最后一次写下该诊断 → 患者最后一次被提醒多久了(话术)
        · last_visit      患者末诊               → 这人多久没露面(接通率)
      
      改口径的由头是实测出来的偏差:复诊时医生常不重复写同一诊断 ——
      41.3% 的机会在诊断后患者又来过(平均 6 次)却一次都没再写,诊断日与末诊日
      中位差 526 天(矩阵上差 1-2 档);各医生重复记录率 20.7%~43.2%,差一倍。
      ⇒ diagnosis 档位里含着"这个医生爱不爱重复写"的成分,不该被读成临床新鲜度。
      
      🔴 **这是口径开关不是样式开关**,它决定分到的是哪批人。所以三处同时到位:
        ① 看(/plans/matrix?anchor=)与圈人(cohort-filter ← criteria.anchorMode)同 mode;
        ② 随确认单进 plan_assignments.criteria 快照;
        ③ 批次名标「(按末诊)」—— 只标非默认那版,老批次不用改名。
      少任何一处就是「主管看到 87 人、确认单给另一批」,两个数都对且不报错(T14)。
      
      助手侧判据只有一条:那句话里带「按末诊」三个字才传 last_visit
      (ANCHOR_MODE_TOOL_DESC);矩阵的移交措辞与它必须同进同退。
      
      两版**只换锚点,不换档位切分**(90/180 天 + 自然年),测试锁着这条。
      本地实测(朝阳):按末诊后「3 年以上」整列清空、人数整体左移
      (充填 半年到1年 326→420),因为诊断三年前没做的人其实一直在来。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(矩阵): 第三档改叫「半年到 1 年」—— 前一档到半年为止,「1 年内」会被读成从 0 起 · a3e31288
      六档改成绝对时间轴之后,第三档的实际区间是 180 天–1 年,而列头还写「1 年内」——
      挨着「三个月到半年」看过去,很容易读成"从 0 到 1 年"(那就跟前两档重叠了)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(召回): 就诊冷静 14→90 天;矩阵六档改成一条绝对时间轴 · ad4c7a44
      ① 就诊冷静(⑤f)14 天 → **90 天**。这不是文案改动:近三个月到过诊的人整批退出
         召回池,而引擎跑全量时会把他们**已经在跑的单一并关掉**(0 命中 → supersede,
         连 assigned 的也关,补 auto_release 账)。本地库实测:池子 2,711 条里 276 条(10.2%)
         会被新闸挡掉,其中 3 条已认领。
      
      ② 矩阵前两档从「该治疗自己的临床周期」改成**固定 90/180 天**,列头改叫
         「三个月内」「三个月到半年」——六档从此共用一个锚点、一把尺子。
         · 常量单一真理源 HOT_BUCKET_DAYS / WARM_BUCKET_DAYS(@pac/types)
         · 判档 SQL 里那张按码的 (code,urg,wnd) VALUES 表连同 JOIN 一起删了 ——
           它当年顺带起的过滤由 labelCase 全额承担,行集不变(测试锁着)
         · ️ DiagnosisTreatmentMap 的 urgency/window **仍然**管入池 cooldown 与打分衰减,
           只是不再管矩阵档位,两者已脱钩
         本地实测重分布:3,678 个 (单×码) 里 409 个(11.1%)换档,新的第一档 +20%、第二档 +25%。
      
      ③ 矩阵去掉「潜在治疗」标题行,底部说明缩成一句(前两档那半句解释没有了)。
      
      🔴 `make_interval(days => $n)` 必须带 `::int`:Prisma 把 JS number 绑成 double,
         而这个重载不存在 → /plans/matrix 直接 500(改的过程中实炸过一次)。
      
      不需要重算画像、也不需要手动重跑引擎:档位是读时从召回单证据算的,
      新闸随每日增量 cron 的全量 runAllForHost 自然生效。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(web): 全局 border 规则放进 @layer base —— 全站 258 处彩色 border 类此前一个都没生效 · 4eb9d804
      globals.css 里那条 `* { border-color: hsl(var(--border)) }` **不在任何 @layer 里**。
      无层级样式整体胜过 `@layer utilities`(与优先级无关),于是它压过了 Tailwind
      **全部** border 颜色工具类:写 `border-l-brand-400` / `border-rose-300` 不报错、
      也不生效,算出来永远是那个灰 #D6DDE6。实测登录框 91 个有边框的元素,91 个都是灰的。
      
      被吃掉的不只是"某处颜色不对":
        - `border-t-transparent` —— 两处转圈 loading 的缺口被填成灰,整圈同色,转了看不出来
        - `border-current` —— 本该跟随文字色调,恒为灰
        - ~32 处 hover:/focus: 边框反馈全哑,配着 `transition-colors` 动画一个空
        - chain-viz / facts-timeline 整套语义色板的 border 分支形同虚设
      
      改法:把这条规则包进 `@layer base`。
      ️ Tailwind v4 没有 `--default-border-color` 主题变量(v3 的 borderColor.DEFAULT
      已移除,preflight 改成 `border: 0 solid`),分层的 `*` 规则就是 v4 官方写法。
      
      同时删掉 147 处中性 `border-slate-*` / `divide-slate-*` 类(31 个文件):
      它们此前零效果,而分层后会**开始**生效 —— `border-slate-100`(114 处)会从
      #D6DDE6 变 #F1F5F9(对白底对比度 1.30→1.06,发丝线几乎消失),`slate-50` 直接看不见。
      这些类当初就是对着灰写的(该 `*` 规则从首次提交就在),删掉后落回 base 层默认灰,
      **构造上保证零像素变化**。三个页面实测:中性边框数量与颜色与改前逐一对齐,
      彩色边框恢复(brand-600 = rgb(0,50,160)、brand-400 = rgb(59,118,247))。
      
      batch-tracking 那条竖轨收回工具类(`border-l-2 border-l-brand-400`),
      连同解释这条地雷的注释一起删;`past` 保留 2px 透明边以维持两组首列对齐。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(主管工作台): 矩阵摊开常驻,历史批次退成灰调 · 73cf9501
      「分一批新的」不再是按钮 + 浮层:
      · 删掉「样式2 里世界」整版(传送门 / 锤子光标 / 敲击动效)与那个样式开关,
        连同 globals.css 的 .pac-portal-* 一起清掉 —— 只留矩阵那版。
      · 矩阵直接摊在「我分的批次」上方:左栏是上下结构(挑格子 → 看这些格子
        分出去之后跑成什么样),整栏再与「团队现在什么状态」左右并排。
      · 矩阵在助手确认/撤销后跟着重拉 —— 刚分走的人就是从这些格子里出去的。
      
      「我分的批次」区分在跑与历史:
      · 在跑的 = 主色竖轨 + 白底 + 满饱和语义色;历史 = 灰底 + 同色相低饱和。
      · 语义色**保留**(还看得出哪列是退回哪列是约上), 不做成纯灰。
      
      🔴 顺带记下一个全站地雷:globals.css 的 `* { border-color: … }` 不在任何
      @layer 里,压过 Tailwind 所有 border 颜色工具类 —— `border-l-brand-400`
      写了不报错也不生效(竖轨最初就是这么哑掉的)。彩色边框只能走 inline style。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed