1. 19 Aug, 2026 10 commits
    • 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
  2. 18 Aug, 2026 2 commits
    • refactor(db): 删除 patient_transactions.canonical_payload —— 写而不读的 7.9 GB · 91bc9cd6
      全仓库核查(pac-service / pac-web / packages / 原生 SQL / CLI / 测试 / mdx,
      并含 origin/test 分支):**1 处写、0 处读**。
      
        写:transaction-synthesizer.ts:137
        读:无
      
      它存 AssemblerEngine yaml 翻译后的中间态,设想用途两个 ——「审计:fact 算错时
      回查中间态」和「replay:yaml 改后局部重跑 parser 不必从 raw 重头算」。两个都
      没有落地成代码:reparse 走的是 raw_payload 从头重跑,审计路径(recall-debug)
      看的也是 rawPayload。
      
      体积实测(测试服,按 subject_type 抽样 1% 外推):
      
        raw_payload        ~18.3 GB
        canonical_payload   ~7.9 GB   ← 本次删除
      
      删掉不丢证据:canonical 是 raw_payload 经 field-mapping 推导出来的,需要时跑一次
      reparse 即得 —— 那正是 reparse 做的事。真原始证据 raw_payload 原样保留。
      
      执行成本:PostgreSQL 的 DROP COLUMN 只改系统目录、不重写表,42 GB 表上秒级完成。
      ️ 但空间不立刻还给操作系统 —— 已有行的那部分转为表内可复用空间,由后续写入吸收
      (效果 = 这张表在磁盘上停止增长约 8 周)。真正缩表需 VACUUM FULL / pg_repack,另议。
      
      同步订正 schema 注释、synthesizer 注释、data-model.mdx、data-ingestion.mdx。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  3. 17 Aug, 2026 17 commits
    • merge: 引导「一类一调」纪律进事实层 + 每步形状留痕 + 两处措辞 · f650093d
      · 每条引导带一份 `开放方式`(show_guidance 的调用纪律)—— 产品实测模型排布确实变了
      · `agent_invocations.output.steps` 记下每步「讲了多少字 + 调了哪些工具」,
        让「一类一调、紧挨着调」从写着变成看得见
      · 确认单「无主」→「无专属」(同一张卡上两个词指同一件事)
      · 引导标识「打不完」→「最忙和超期」(「打不完」是下判断,卡片刻意只报数)
      · 中间那次「纪律挂在组上」的实验已 revert,正文留在 82f357e0 可查
      luoqi committed
    • feat(引导): 每条引导上带一份「一类一调」的调用纪律 · 875a406c
      产品 2026-08-17 把这句话手工粘到 `标识` 的值里试了一轮,**模型的排布确实变了**
      ⇒ 收进代码,独立成 `开放方式` 字段,紧跟 `标识`。
      
      ️ 挂在**每一条**上, 不是每一组上:纪律管的是「这一条讲完、紧接着调这一条」,
        作用域就是一条 —— 挂在组上时一组里的两条仍会被攒着一起调(上一版实验就是这么做的,
        已 revert:`git show 82f357e0`)。
      ️ 位置紧跟标识:产品实测有效的那一版就是粘在标识后面的,
         别挪到末尾"省一点注意力" —— 那等于换了个没测过的位置。
      
      🔴 这是事实层「 一个字的指令都不许有」的**唯一一处破例**,所以破得有边界:
        · 措辞与 `show_guidance` 描述同源(都用「开放」、都说「一类」)—— 别两处各发明一套
        ·  不写工具名:代码标识符进事实层早晚被念给主管听(沿革见 GUIDANCE_KIND)
        · 禁词字面照旧全域禁,那句纪律自己也得守(单独一条测试盯着)
        · promptVersion → -c(-b 是被 revert 的那次实验,库里有它的行, 不复用)
      
      ⇒ 怎么验它还在起作用:`agent_invocations.output.steps`(stepShapes)——
        几条引导落在各自独立、且 textLen>0 的步里 = 守住了;挤在同一步 = 没守住。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • Revert "feat(实验): 把「一类一调」的纪律搬到事实层,紧挨着它管的那几条" · df9bc7fc
      This reverts commit 82f357e0.
      
      实验做完,撤回 —— 事实层恢复「 一个字的指令都不许有」那条边界,
      「一类一调」只留 `show_guidance` 描述里那一份( 别两处各留半句)。
      
      ️ 用 revert  不用 reset:线上/本地已经有 `assistant@2026-08-17-b` 的
        `agent_invocations` 行,而 promptVersion 的全部价值就是「回查代码里那个版本的正文」。
        hard reset 会让那批行指向一个 git 里根本不存在的状态 —— 断链且不报错。
        ⇒ 正文留在 82f357e0 里可查:`git show 82f357e0`。
        版本号跟着回到 -a:那份正文与 -a 逐字相同, 不是"新开一版"。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(实验): 把「一类一调」的纪律搬到事实层,紧挨着它管的那几条 · 82f357e0
      `show_guidance` 的「讲完一类紧接着调一次、 别攒着一起调」工具描述里已经有一份,
      但它离「这一版到底有哪几类」隔着整个上下文。这一版在 `modelFacts` 里再放一份到
      **紧挨着那几组清单前面**的位置(键名「开放方式」),看模型的排布行为会不会变。
      
      ️ 这是**对照实验**, 不是"多说一遍更保险":
        · 措辞与工具描述同源(都用「开放」这个动词、都说「一类」)—— 两处打架时模型只会各取一半
        ·  不写工具名:事实层是给模型的中文事实,代码标识符进来早晚被念给主管听
        · 两段各带一份 —— 「要你定的」跟另外两组隔着整段,"离得近不近"正是被测的变量
        · 没有可开放的引导时一份都不给(多一条纪律是纯噪音,噪音也占注意力)
      
      🔴 破了事实层「 一个字的指令都不许有」那条边界,所以破得可查、可撤:
        · 禁词测试改成**摘掉这个键再扫**, 不是把禁词减了 —— 删掉常量测试自动恢复严格
        · 例外只放行"一句指令",那几个禁词字面照旧不许出现(有测试盯着)
        · promptVersion bump 到 -b:不 bump 就分不出哪些调用带了这句纪律,实验白做
      
      ⇒ 怎么看结果:`agent_invocations.output.steps`(stepShapes)——
        几条引导落在各自独立、且 textLen>0 的步里 = 守住了;挤在同一步 = 没守住。
      ⇒ 结论出来后二选一:删掉常量,或升进工具描述并删掉那边的旧句。 别两处各留半句。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(留痕): 记下每一步的形状 —— 让「一类一调、紧挨着调」从"写着"变成"看得见" · a85c04be
      ■ 先说结论:**那条规范 没加,因为它已经有两份了**
        `show_guidance`:「讲完其中一类,**紧接着**调一次; 别攒着一起调」
        `propose_assignment`:「可动手的事**一件一件**开放:说完它管的那件事,当场就开放它」
        两者是 2026-08-15 同一次事故之后一起调好的,`propose` 那句上面还有一条明确的
        「这里别再出现比"一件"更粗的量词 —— 粒度只该由 show_guidance 定」。
        ⇒ 再写第三份正是文件头禁止的「同一件事只许有一份」,两边必然各自漂。
      
      ■ 真正缺的是**验证手段**
        留痕此前只记「调了几次 show_guidance」,而那看不出对错:
          3 次可以是**分 3 步各讲一段后各调一次**(对)
          也可以是**同一步里一口气调 3 次**(错 —— 三排按钮叠在同一段末尾,正是那次事故的形状)
        ⇒ 新增 `output.steps = [{n, textLen, tools[]}]`:每一步讲了多少字 + 调了哪些工具。
          判据:show_guidance 落在**各自独立、且 textLen>0** 的步里 = 守规矩。
      
      ■ ️ 线上已经量出一次不合规
          23:25 | propose → show_guidance ×3 → show_sheet        看着像一类一调
          23:05 | propose → propose → show_sheet → show_guidance ← **明细先摆,引导落在后面**
        后者与 `propose_assignment` 描述里「最后让他看这一版的完整明细」相反。
        有了 steps 之后,这类问题不用再靠翻聊天记录看。
      
      ■ 顺带对齐两处落下的改名(工作区把标识改成了「最忙和超期」,但这两处还写着「最忙的那位」)
        · lab.controller 的工具描述
        · assignment-signals 里我自己写的那条注释
      
      ️ 字段名**查了 SDK 类型定义**再写的, 没靠猜:`StepResult.toolCalls[].toolName`
        (index.d.ts:854 / 695)—— 名字写错的话 tools 会永远是空数组,**静默失败**。
      验证:tsc 通过;jest 87 套 1359 例全过;真模型跑一轮确认 steps 落库
        (`[{"n":1,"tools":[],"textLen":142}]`)。
      luoqi committed
    • fix(措辞): 「打不完」是下判断 —— 引导节点名改「最忙的那位」 · 02831b1e
      产品定过:那条引导**只报数、不下判断**( 不说「打不完」「人太多了」
      「建议减到 200」)。卡片也确实照做了:
          「这批发下去,最忙的是王强:手上共 45 条,约 3 天的量」
      
      但 `GUIDANCE_KIND.daily_overload` 的值是 **'打不完'** —— 而那张表是
      **给模型用的名字**,代码自己的注释就写着「它会出现在模型嘴里」。
      ⇒ 等于让助手替主管说出卡片刻意不说的那句判断。
      ️ 而且界面上**一处「打不完」也没有**(实测 0 命中)——
         那是只活在代码里的词,同时踩了②诚实层「使用者的词汇表就是他在界面上见过的那些」。
      
      改:节点名 → 「最忙的那位」(与卡片同源);两处工具描述里的解释一并改成只报数的说法。
      
       **另外三个节点没动** —— 逐个对过卡片,它们是中性描述、不是判断:
          待分配   ← 卡片「N 人的专属客服这轮已排满」,且界面确有该分组
          换个条件选 ← 卡片「这一格 N 人,也可以只选其中一类」
          本批人数  ← 卡片「本批 N 人 = 在岗 × 每天 × N 天」
        `mcp-server.factory` 那处「超了」也没动:它是**反例引用**
        (「实测编过『可能是超了撤销窗口』… 不要自己编原因」),反例允许留。
      
      ■ 提示词版本规则有个缺口,一并补上
        原文只写「改动①〜⑤任意一层的正文必须 bump」⇒ 改工具描述不 bump、版本号照旧,
        而**工具描述占模型上下文的大头**(实测每轮约 11K token,比五层还多),
        改了行为会变、回放却对不上,且不会有任何报错。
        ⇒ 规则改为「模型看得见的任何常驻文本」:五层正文 + 工具描述 + 给模型的固定标识。
        本次动了后两者 ⇒ bump 到 assistant@2026-08-17-a。
      
      ■ 「打不完」加进《用词一致性》守卫(️ 已变异校验:塞回节点名立刻变红)
      
      ■ 同批复查、确认干净的三条(都是这份文件头自己定的规矩)
        ·  别把 UI 部件名写进①②③ —— 0 处
        ·  别把内部键名写进提示词 —— 0 处
        ·  提示词正文不许出现禁词表里的词(写库/水位/无主/打不完)—— 0 处
      
      验证:tsc 通过;jest 87 套 1355 例全过;eslint 无新增错误。
      luoqi committed
    • fix(用词): 确认单上「无主」→「无专属」—— 同一张卡上两个词指同一件事 · d5132fca
      按《分配助手》那份 artifact 里的几条边界逐条扫代码,八项里七项干净,
      只揪出这一处,但它就在主管眼皮底下:
      
        确认单段落: 「候选 N 人里 X 人有专属,Y 人**无主**」
        同卡片按钮: 「换**无专属客服**的患者补上」
      
      产品早就定过统一用「无专属」,`assignment-facts`(喂模型那份)也已经改对、
      还带着守卫注释「 别写「无主」—— 界面上的按钮文案就是「无专属客服的患者」」,
      唯独 `pendingNote` 这段**界面卡片文案**没跟上(改了 3 处 + 1 处变量注释)。
      顺带修 `assignment-confirm-sheet` 里一条引错按钮名的注释。
      
      ️ 保留 1420 行那条 —— 它复述的是 2026-08-06 那个 bug 的历史现场
        (当时卡片确实写着"19 人无主"),改了反而失真。
      
      ■ 新增《用词一致性》守卫:会到达人或模型的字里不许出现废弃词(无主 / 时效)
        ·  语义不同的四类文件豁免(临床禁忌有效期、话术语气紧迫)
        · ️ 扫描器**跟踪块注释状态**, 不只看行首 —— `/* */` 与 JSX `{/* */}`
          中间那些行不以 `*` 开头,只判行首会把历史记录当活文案报出来(第一版就这么假红过)
        · ️ 已做变异校验:往 assignment-facts 里塞一个「无主」立刻变红
        · 带一条"扫到了 >200 个文件"的自检,防止路径写错导致整条测试假绿
      
      ■ 同批审计中确认干净的(artifact 的边界表逐条)
        · 系统提示词五层:0 处工具名 / 参数名
        · 语气层:0 处事实 / 工具名 / 界面部件名(`3.5 天` 是标点示范,VOICE 注释明确允许)
        · 工具描述:0 处正面例句(那条踩过:模型把例句逐字念给主管听)
        · 工具返回值:`assignment-facts` 的「只给事实、不给成品句子」边界完好
        · 提示词版本 assistant@2026-08-16-b 与最后一次提示词改动对得上
        · 术语「时效」只剩临床禁忌那三处(刻意排除)
        · 标签改预计算后,工具描述里无残留旧口径
      
      验证:tsc(service + web) 通过;jest 87 套 1354 例全过;eslint 干净。
      luoqi committed
    • perf(圈人): 圈人侧也改读预存标签 —— 603ms → 57ms,顺带堵上「两侧会漂」的口子 · 7bbe9a1b
      上一轮只改了**看**侧(初选矩阵),圈人侧的 `labelExistsSql` /
      `labelTemperatureExistsSql` 仍在实时回查 `patient_facts` 算标签。
      
      🔴 这不只是慢,是**我上一轮亲手造出的口径不一致**:
        · 看侧读预存的 potential_labels(回填那一刻的年龄)
        · 圈人侧实时算(当前年龄)
        标签规则里有三条带年龄(K08>18 / K07 3~12 / K07 13~40)⇒ 过生日跨档的人两边不一样。
        而 `reason-temperature.sql.ts` 里那段注释早就写过这个坑:
          「曾经它们各自带一个 mode 参数,一旦调用方漏传就是主管看到 87 人、
            确认单捞出另一批,而且全程不报错(T14)」
        我差点原样重演一遍,只是这次漂的原因从"参数"换成了"数据源"。
      
      改动:两处 EXISTS 都换成 `pr.potential_labels @> ARRAY[label]`。
      
      ■ 实测(本地,15,884 条 plan 的诊所,人数逐字未变)
          只按治疗       603 ms → 57 ms   2305 人 → 2305 人
          治疗 + 温度     971 ms → 82 ms    353 人 →  353 人
        这条路一次弹窗要走两遍(整格人数 + 当页明细),还被 propose 复用。
      
      ■ ️ 三条既有断言失败 —— 但它们守的不变量**没有消失,是搬家了**
        · 「只认未治疗的证据(f.status='active')」
        · 「证据必须有日期(COALESCE(occurred_at, planned_for) IS NOT NULL)」
        两者现在由回填 SQL 保证。 没有删断言,而是搬到 plan-label.spec.ts 钉住 ——
        删了的话,谁把"治完的诊断"或"没有日期的证据"算进标签都不会有人发现,
        而那会直接改变召回谁。️ 已做变异校验:拿掉 status 守卫立刻变红。
        · 第三条改成断言"锚点仍是末诊",并注明非空守卫搬去了哪。
      
      ■ 新增《看侧与圈人侧同源》一组:三处都读 potential_labels、
        三处都不再出现 patient_facts / jsonb_array_elements_text / 按年龄现算。
      
      验证:tsc 通过;jest 86 套 1351 例全过;eslint 无新增错误。
      luoqi committed
    • fix(矩阵): 补回 JOIN patients —— 线上 500 修复(missing FROM-clause "p") · a0a86b94
      3aa69952 上线后矩阵整页 500:`missing FROM-clause entry for table "p"`。
      
      原因:标签预计算后我判断"年龄已烘进标签、用不到 patients 了",把
      `JOIN patients p` 删了。但 `poolBaseSql` 在 **scope.sourceUnits 非空**时会拼
      `AND p.source_unit IN (...)`(多品牌隔离) —— 别名没了,SQL 直接失效。
      
      🔴 为什么本地全绿还是漏到线上 —— 三层防线**同时**失守,每一层都是我自己造的:
        1. **基准脚本硬编 `sourceUnits: []`** ⇒ 永远走不到那一支,测了个假场景。
           本地那台 host 恰好也没配品牌,连真实调用都碰不到。
        2. **单测把 bug 固化成了断言**:写着 `expect(sql).not.toMatch(/JOIN patients/)` ——
           它不但没拦住,反而会**保护**这个错误。️ 断言写的是"我以为的",不是"必须成立的"。
        3. tsc / jest / eslint 全绿 —— 这类 SQL 别名闭合问题它们天生看不见。
      
      修复与加固:
        · 补回 `JOIN patients p`(注释写明它是被 poolBaseSql 引用的, 别再删)
        · 基准脚本改为**两支都跑**:sourceUnits=[] 与真实品牌列表各跑一遍。
          ️ 已做变异校验:删掉 JOIN 后基准立刻报 missing FROM-clause。
        · 新增单测《poolBaseSql 用到的别名,查询里必须都在 FROM 里》——
          含一条**通用的别名闭合检查**(扫出 SQL 里所有 `x.` 前缀,逐个确认在 FROM/JOIN 里声明过),
          不连库、jest 里就红。️ 同样做了变异校验:删 JOIN 立刻 3 条红。
        · 删掉那条错的断言,换成断言**真正变了的事**:查询里不再出现按年龄推标签的 CASE。
      
      验证:tsc 通过;jest 86 套 1345 例全过;eslint 干净;
        基准两支都跑通(15,884 plan 的诊所 137ms,磁盘读 0)。
      luoqi committed
    • merge: 初选矩阵提速 8.7 倍 —— 潜在治疗标签预计算落库 · 3aa69952
      · plan_reasons 加 potential_labels,矩阵不再回查 patient_facts
        本地实测 958ms → 110ms,磁盘读 119,861 → 0,24 格逐字未变
      · 补上就地刷新 reason 那条路的标签失效(证据变了就标记重算)
      · 加 backfill-plan-labels CLI —— 上线后必须跑一次(迁移后全表 NULL,
        实测置空后矩阵返回 0 行)
      · 撤回「重算 plan 会释放客服」那个错判断
      luoqi committed
    • docs(cli): 撤回「重算 plan 会释放客服」—— 那是错的,因果说反了 · 5b01bfec
      上一条(2b0c5406)在 CLI 头注里写「 别为了填派生列去重算 plan,会 auto_release
      释放客服手上的单」。查代码后确认**这个判断是错的**:
      
      `auto_release` 只有两个触发条件,都在 plan-engine 里写得很清楚:
        · 本轮该患者 **0 命中**(信号真没了,比如治疗做完了)
        · 患者**最后到诊诊所变了**,原客服将出 scope
      两者都是**真实的业务状态变化**,不是"重算"这个动作造成的。
      而且 `plansSkippedAssigned = 0`(assigned 不再 skip),这套逻辑本来就跟着
      增量同步每天在跑 —— 该释放的早已释放,再跑一次不会多释放任何人。
      
      ⇒ 日常维护**本来就靠原机制**:plan 生成收尾会调 `backfillMissing()`, 并没有绕开它。
        这个 CLI 只解决一件事:**上线那一刻的空窗** —— 迁移后全表 NULL,矩阵遇到 NULL
        什么都不出 ⇒ 矩阵全是 0,要等下一次增量同步(最长两小时)或夜间刷新(03:30)
        才自愈;CLI 把窗口压到约一分钟。
      
      ️ 留着那句错话的代价不是"说错一次",是下一个人会据此**避开一个本来安全的操作**。
      luoqi committed
    • chore(cli): 加 backfill-plan-labels —— 别为了填派生列去重算 plan · 2b0c5406
      上线后必须立刻跑一次:迁移只是加了列,全表都是 NULL,而矩阵
      `unnest(potential_labels)` 遇到 NULL 什么都不出 ⇒ **部署完到回填完之间矩阵全是 0**。
       别指望夜间任务兜 —— 那要等到次日 03:30。
      
      ️ 「重算 plan 也能顺带补齐」是真的(收尾会调 backfillMissing),但**不该拿它当手段**:
        重算会升版本、关闭无信号的 plan、auto_release 释放客服手上的单 ——
        为一个派生列去动召回池,代价完全不对等。
        (测试服上还有一批"特殊处理过另有他用"的在手单,更不该被卷进去。)
      
      用法:
        pnpm backfill-plan-labels           # 只补没算过的(上线后跑这个)
        pnpm backfill-plan-labels -- --all  # 连算过的一起重算(改了标签规则后)
        pnpm backfill-plan-labels -- --check # 只报还差多少,不写库
      
      ️ 回填后若仍有剩余 → 报 error 且 exit 1:那说明有写入路径没接上补齐,
         别等矩阵少人了才发现。
      
      验证:tsc 通过;jest 86 套 1342 例全过;eslint 干净;
        本地实跑:--check 报 0 → 人为置空 119 条 → 回填写入 119 条 / 0.1s / 剩余 0。
      luoqi committed
    • fix(矩阵): 就地刷新 reason 时把标签标记重算 —— 补上一版漏掉的那条路 · 99032119
      上一版(1293108a)只在**新建** reason 时补标签,漏了 `plan-engine` 的**就地刷新**路径
      (plan-engine.service.ts:569):signals/evidence 语义变了但不升版本时,
      它直接 `planReason.update({ evidence: { factIds } })` 改写证据。
      
      🔴 那条路上 `potential_labels` 既不是 NULL(收尾的 backfillMissing 会跳过它)、
        又已经对不上新证据 ⇒ **初选矩阵按旧标签把人算进错的格子**,
        要到夜间全量重算才自愈,中间一整天不报错。
      
      ⇒ 同事务里把这些 reason 的 `potential_labels` 置回 NULL(=「没算过」),
        本轮收尾的 `backfillMissing()` 用同一份 SQL 填上。
         不在这里用 TS 顺手算一个:规则表是产品配置,第二份实现迟早和矩阵那份漂开。
      
      ️ 走 raw:Prisma 的**标量数组字段不能用类型化 API 置 null**(tsc 会拦);
        而改用 `[]` 会让「没算过」和「算过是空」分不开 —— 那正是这一列设成可空的理由。
      ️ 第一版 raw 写成 `IN (a,b)::uuid[]` —— **tsc 全绿但 SQL 是错的**
        (cast 套在了布尔结果上,PG 报 `cannot cast type boolean to uuid[]`)。
        这类错类型检查天生看不见,只能真连库跑一次。已改 `= ANY(ARRAY[...]::uuid[])` 并实测。
      
      顺带:单患者重算路径(recomputeForPatient)也补上 backfillMissing ——
         不补的话这位患者在矩阵里**当场消失**,直到夜间重算才回来。
        平时代价接近零:`potential_labels IS NULL` 上有部分索引。
      
      测试:
        · 替身补 `tx.$executeRaw`,并给 engine() 传标签刷新器替身
        · 新断言「证据被就地改写 ⇒ 必须同时标记重算」——
          ️ 做过变异校验(把期望改成 99 立刻变红),确认这条断言真的在跑,不是假绿
      
      验证:tsc 通过;jest 86 套 1342 例全过;矩阵基准 24 格仍与基线逐字相同;
        eslint 无新增错误(plan.service 里 3 条 no-empty-object-type 是既有的)。
      luoqi committed
    • perf(矩阵): 潜在治疗标签预计算落库 —— 958ms → 110ms,24 格逐字未变 · 1293108a
      初选矩阵此前每次开页面都从最原始的事实现推:
        plan_reasons → 展开 evidence.factIds → 回查 patient_facts(1567 万行 / 18 GB)
      最忙的诊所(15,884 条 plan)一次摊 34,459 次随机查、372 MB I/O ——
      **而这一切的唯一产出就是一个标签字符串**。温度那根轴根本不碰 fact
      (它来自 patient_profiles.last_visit_at)。
      
      ⇒ 加一列 `plan_reasons.potential_labels text[]`,矩阵改成
        followup_plans ⋈ plan_reasons ⋈ patient_profiles + unnest。
      
      ■ 实测(本地,15,884 条 plan 的诊所,与测试服 15,770 同规模)
          耗时      958 ms → 110 ms   (8.7×)
          磁盘读   119,861 → 13,500   (-89%)
          JIT       触发   → 不触发
          小诊所   11 ms  → 0.4 ms    (地板消失)
        🔴 **五个诊所的 24 格逐字相同**(scripts/bench-matrix.ts 的结果快照 diff 零差异)——
          性能改动最容易的失败是悄悄少算一批人,只比耗时看不出来。
      
      ■ 为什么是数组而不是单列
        一条依据可挂多个 fact:93.7% 只有一个,但**3.7%(本地 1,391 条)会推出不止一个 code**。
         存单值会把这批人算少。
      
      ■ ️ 年龄被冻结,所以必须刷
        规则里三条带年龄(K08>18 / K07 3~12 / K07 13~40)⇒ 标签不是纯函数。
        实测受影响 5,551 人中「明天跨档 0 人、30 天内 16 人」(≈0.5 人/天,占矩阵 1.4 万人的 0.003%)。
        ⇒ 每晚 03:30 全量重算(PlanLabelRefreshService);
           不刷就逐日漂,且不会有任何报错。
      
      ■ 三条写入路径共用同一份 SQL
        🔴  绝不在写入侧用 TS 再算一遍:规则表是产品配置,第二份实现迟早和矩阵那份漂开,
          而漂了之后矩阵的数会**静默**不一样。
        · 生成完 plan 立刻 backfillMissing() ——  不补的话新 plan 的列是 NULL,
          `unnest` 直接跳过 ⇒ **今天生成的人在矩阵里看不见**,且不报错。失败不阻断生成。
        · 每晚全量重算(年龄)
        · 部分索引 `WHERE potential_labels IS NULL` —— 平时几乎是空的,补齐即 O(1),
          否则每次生成完都要为找 NULL 扫一遍 31 万行。
      
      ■ 两个自己踩了又修的坑(都写进了注释与测试)
        · 全量重算最初只写 `ORDER BY id LIMIT n` **没有游标** —— 每次取同一批,原地打转,
          而且不报错只是每晚白跑。改成按 maxId 推进。
        · 停止条件最初看"写了几行" —— 全量重算时绝大多数行算出来跟原值一样、不会被写,
          written=0  不等于做完了。改成看 seen / maxId,并把两者分开报。
      
      列**刻意可空**,三态有别:NULL=没算过 / '{}'=算过但推不出标签 / 非空=标签集。
       别加 NOT NULL DEFAULT '{}' —— 那样刷新任务再也找不到漏网的行。
      
      验证:tsc 通过;jest 86 套 1342 例全过(新增 12);eslint 干净;
        真 AppModule 起容器确认两个 service 都注入成功;
        回填 37,300 条/8.4s;全量重算 37,300 条/2.7s 未卡死;人为挖 25 个洞→补齐→剩余 0。
      luoqi committed
    • chore(基准): 加初选矩阵基准脚本 —— 为「标签落库」改造留一把可复用的尺 · 628d253a
      优化之前先把现状量下来,否则改完只能凭感觉说"快了"。
      
      脚本同时产出两样, 缺一不可:
        · **耗时**:EXPLAIN ANALYZE 的 Execution Time,每档取 N 轮**最快值**
           不取平均 —— 本机跑着别的东西,实测同一条件 1388~2130ms 都出现过,
            平均值被噪声主导,最快值才稳定可比。
        · **结果快照**:24 格逐格的数,拼成一行可逐字比对
          🔴 只比耗时是不够的 —— 把 join 改掉、把标签预计算,最容易的失败是
            **悄悄少算一批人**,而那正是产品最不能接受的
            (矩阵那段注释里写着「主管一对数就觉得系统在骗他」)。
      
      本地基线(15,884 plan 的诊所,与测试服 15,770 几乎同规模):
        15884 →  958 ms  · 磁盘读 119,861 buffers
         2458 →  127 ms
           14 →   11 ms   ← 地板
      
       顺带印证了一件事:本地 `patient_facts` 只有 111 万行(测试服 1567 万,1/14),
        但同规模诊所耗时同一量级(958ms vs 1399ms)。⇒ **表大小不是主因,查询次数才是**
        —— 15,884 个 plan 摊出的三万多次随机查才是,这正是「标签落 plan_reasons」要消掉的东西。
      luoqi committed
  4. 16 Aug, 2026 11 commits
    • merge: 助手会话 id + 多步文本修复 + 术语「时效」→「时限」全线统一 · 3f2c02fd
      · 会话 id 落 workflow_run_id —— 同一次对话各轮共用, 不再靠 turnNo 反推
      · outputText 拼全每一步 —— OnFinishEvent 只带末步,中间说的话原本会丢
      · 时效 → 时限:代码/界面 213 处 + 文档 71 处; 跳过语义不同的(话术语气、临床有效期)与 _archive
      luoqi committed
    • docs: 文档口径跟上「时限」—— 71 处; _archive 保持原样 · 9cd3cd5e
      代码/界面已统一到「时限」(6a1bee6f),文档站与设计文档还写着「时效」——
      不跟上就等于刚消掉的漂移又从另一头长回来。
      
      改 6 个文件 71 处:站点两篇(assignment-agent / batch-assignment)+
      设计文档四篇(doctrine / agent-flow / 两份 dev-plan)。
      
       `docs/_archive/` 11 处不动 —— 那是历史记录,改了等于篡改当时的原文。
      
      验证:pac-docs build 通过(OG 图字体拉取失败是内网够不到 Google Fonts 的老问题,非致命)。
      luoqi committed
    • refactor(术语): 时效 → 时限,全线统一 213 处 —— 但三类同名不同义的不动 · 6a1bee6f
      产品定的口径是「时限」(截止期限),而界面此前 63 处全写「时效」。
      提示词第②层的铁律是「使用者的词汇表 = 他在界面上见过的那些」⇒ 界面、提示词、
      工具描述、文档必须同时改,只改一处等于制造新的漂移。
      
      改了 35 个文件 / 213 处:界面文案 · 提示词正文 · 工具描述 · 枚举 labelZh · 注释。
       字段名一律不动(expiresInDays / set_expiry / assignment_expires_at)——
        那是接口与数据库契约,跟着中文改会连累落库与老数据。
      
      ️ 跳过 7 个文件 —— 那里的「时效」是**别的意思**,连坐会改出假语义:
        · tone.ts:「urgent = 有时效紧迫」是话术**语气**,不是期限
        · clinical-signals / persona-feature-specs / signal-extractor / contraindication:
          临床禁忌的「时效」= 有效期(妊娠 280 天、哺乳 180 天、放疗终身)
        (enums 里那条 `deadline_too_tight` 的注释早就警告过「同名不同义是统计事故的
          标准配方」—— 这次正是同一类风险,只是方向反过来。)
      
      提示词正文变了 ⇒ 按纪律 bump `ASSISTANT_PROMPT_VERSION` → assistant@2026-08-16-b。
      packages/types 改了 src ⇒ 重建 dist(否则 tsc 绿、一跑就炸)。
      
      验证:service tsc + web tsc 通过;jest 85 套 1330 例全过。
      luoqi committed
    • fix(助手留痕): 补上会话 id,并把多步文本拼全 —— 两处都是「看起来对、其实会丢」 · df0d263d
      ■ 会话 id(`workflowRunId`)
        上一版每轮各生成一个 run id,判断"哪几轮是一批"只能靠 `turnNo` 归 1 反推 ——
        那是启发式,️ 两位主管并发聊天时行是交错的,前端一旦裁剪历史 turnNo 还会错位。
        ⇒ 前端在**messages 为空**(= 一段新对话的第一句)时现生成 uuid,此后每轮原样带上;
          服务端落进 `workflow_run_id`,同一次对话各轮共用一个值。
        ️ 前端可改的入参:只用于归组, 不参与鉴权取数;非 uuid 一律丢弃(服务端另生成),
           别让它成为写库的注入面(该列是 uuid 类型)。
         这也正好落在产品设计上:没有"新建会话",会话跟着他此刻在做的那件事走 ——
          离开工作台、组件重挂,messages 归空,下一句就是新的一段。
      
      ■ outputText 只落了最后一步
        🔴 AI SDK 的 `OnFinishEvent` 继承的是**最后一步**的 `StepResult`,`ev.text` 只有末步那段。
        模型在工具之间穿插说的话(「我先查一下」)全在前面的步里,只取 ev.text 就永远不进记录 ——
        而排查"它当时到底说了什么"全靠这一列。
        ️ 线上实测那轮 5 次工具调用、6924 输出 token,落库只有 60 字 —— 那次凑巧没丢
          (它把话都留到了最后一步), 别指望每次都这样。
        ⇒ `joinStepTexts` 拼所有步,去重末步(它通常已在 steps 末项里)。
      
      验证:
        · tsc(service + web)通过;jest 85 套 1330 例全过(新增 5);eslint 干净
        · 真模型跑三轮:会话A 两轮共用 run=1cd20f63、会话B 独立 run=985bf1d3,
          按 run 分组正好 2 个会话 
      
      ️ 未做,留给产品定:界面 63 处写「时效」、0 处「时限」,而文档已改口径为「时限」。
        提示词的铁律是「使用者的词汇表 = 他在界面上见过的那些」⇒ 现在**该改的是文档或界面**,
         不能只把提示词单方面改成「时限」。
      luoqi committed
    • merge: 分配助手文档重排 + 助手调用留痕(agent_invocations) · fe79f0f3
      文档 25 个提交:《分配助手》按产品口径全面重排(决策树前置、意图分析、
      去内部黑话、编号撞车修正、代价三档)。
      功能 1 个提交:助手每轮往 agent_invocations 落一行(只存当轮, 不存上下文),
      外加保留期清理任务与成本计算共享化。
      luoqi committed
    • feat(助手): 每轮往 agent_invocations 落一行 —— 落「该调的工具调没调」,不落聊天记录 · ad264616
      助手是这条生产线上唯一**一次都没留过痕**的一层:验收判据写着「该调的工具
      调没调」,而线上没有任何地方记录模型实际调了什么,只能靠临时跑批。
      
      表和写入器都是现成的(`agent_invocations` + `InvocationRecorderService`),
      后台 `admin/ai-invocations` 也不写死 kind —— 落进去直接就能看。缺的只是接上。
      
      ■ 只存当轮, 不存上下文
        助手的 messages 里带着 propose_assignment 返回的整张确认单(几百人 ×
        十几个字段)。第 N 轮把前 N-1 轮全带上 ⇒ **落库量按平方涨**。
        实测口径:全量约 100 KB/行,只存当轮约 2.5 KB/行;按 1800 次/天算是
        65 GB/年 vs 1.6 GB/年 —— ️ 而测试机盘常年 90%+、PG 卷已占 75 G。
        ⇒ `slimInputSnapshot` 只取本轮用户原话 + 轮次 + 现场 id,
           不含历史、 不含任何工具返回值。同 agent-architecture「摘要 + 指针」:
          在源头就只给摘要, 不事后压缩(事后压缩要改历史,而改历史作废 KV 缓存)。
      
      ■ systemPrompt 一律不落
        主管那份 6.9 KB **每次一模一样**,逐行存等于把同一份东西抄一万遍。
        改用 `ASSISTANT_PROMPT_VERSION` 做锚 —— 🔴 改任意一层正文必须 bump,
        ️ 不 bump 这条审计链就断,而断了不会有任何报错。
      
      ■ 工具留痕装在一处
        在 tools 建好之后统一给**每个** execute 套计时包装(MCP 拉来的 + 本地那 7 个)。
         不在各自 execute 里写:漏一个,那个工具就永远不出现在验收数据里。
        落 output.toolCalls = [{name, ok, ms, args截200}], 不落工具返回值。
      
      ■ 落库绝不影响对话
        start/end 全程 try/catch 吞掉,只打日志;onFinish/onError 挂在 streamText 上。
        主管正等着一版方案, 不该因为审计写不进去而看不到结果。
        scope 缺失(内部入口)整条跳过 —— hostId/tenantId 是必填列。
      
      ■ 顺带补两个既有缺口
        1. 保留期清理任务(`InvocationRetentionService`,每天 04:00 沪)——
           schema 注释里写了一年「N 天后清 inputSnapshot 仅保留元数据」,任务一直不存在。
           清 inputSnapshot/prompt/systemPrompt/outputText 四样肥字段(占一行 95%+ 字节),
           留 token/成本/延迟/status/judge/userFeedback;失败行留 3 倍时长;
            不删行(成本与通过率曲线要长期可比,元数据行才 ~1 KB)。
           ️ 判据用 startedAt 不用 updatedAt —— 清理本身会刷新 updatedAt,
             用它当闸门的话清过的行永远追不上,每天全表重清一遍。
        2. 成本计算抽成共享纯函数 `ai/core/cost.ts` —— 它是**计价**逻辑,
           抄第二份必然漂(2026-08-13 栽过:换 qwen 旗舰后成本被低报约四倍)。
      
      本地验证(不只是单测):
        · tsc 通过;jest 85 套 1325 例全过(新增 27 例);eslint 干净
        · 真 AppModule 起容器 → recorder 注入成功、retention 服务已注册
        · 保留期在真库上跑通:60 天前成功行已清且元数据完好、2 天前未动、
          失败行未动、第二次跑幂等、185 行真实数据零误伤
        · **真模型跑通一轮**(deepseek-v4-flash):
          tokens 1720/158/1878 · cached 896 · ¥0.001169 · latency 23.9s ·
          finishReason "stop" · inputSnapshot 82 B( 无历史)· systemPrompt 未落
          ️ 先用 MockLanguageModelV3 试过,usage 拿不到 —— 是 mock 喂不进去,
            不是代码问题;真模型一次跑通。
      
      ️ 遗留:代码里 ASSIGNMENT_SCENE 仍写「时效」,产品口径已改「时限」,未统一。
      luoqi committed
    • docs(站点): 只删与图重复的,提示词和工具原样保留 —— 470 → 415 行 · edbbe4f5
      上一版(已回退)把 §2 提示词、§3 工具整节删掉了,那是砍错地方:
      产品要的是「能在图里说明的就不另开篇幅」,不是删掉这两节。
      
      删的全是与决策树图重复的表:
      - 《选人 = 主管初选 + 助手精选》整表 —— 图上 A1/A2 就是这个结构,
        只留下面那段「助手不等指令、先把方案做出来」
      - 《引导什么时候才冒出来》的「满足什么才出」列 —— 触发条件写在 G1/G2 节点上,
        表只留「为什么卡那个条件」(这一列图里没有)
      - 分人三趟表 —— 图上 B 节点已逐趟写清,只留水位公式和三条「为什么」
      - 「人手在两个地方摆给他看」两行表 → 一句话
      - 《③ 确认 · ④ 确认之后》表 → 两句话
      - §5「另有几位」的两局面表 → 一句话(该做的事正好相反)
      
      合并:
      - 《代价不对称》整节上提到图下 —— 它是读图的钥匙,本来就该紧跟着图,
        放章末等于让人看完全章再回头理解开头那两个环
      
      §2 §3 内容一句没删,只压掉工程细节的铺陈:
      - 四条硬规则的分条罗列 → 一行(判据那句引言保留,它才是要点)
      - 《只有主职责常驻》两段并一段
      
      八节结构不变。验证:pnpm --filter pac-docs build 通过。
      luoqi committed
    • docs(站点): 分配助手按「拿去讲」的标准全面校订 —— 修编号撞车、去内部黑话、拆代价三档 · af291464
      拿这份去做产品介绍前的一次完整审校,改动分三类:
      
      一、会当场被问住的(3 处)
      - 编号撞车:①②③④ 同时指四步、四个引导节点、五层提示词。
        原第 71 行「从②『这批多大』改」,而 ② 在同一页刚被定义成「分人」。
        ⇒ 圈码只留给四步;引导节点本来就有名字,一律改用「名字」引用。
      - 「格」仍在用且从未定义:产品早已定过不用这个说法。
        ⇒ 全部改为「候选人群 / 这批候选」,并在四问表之后补一句它是什么
          (治疗 × 多久没来 交叉出来的那批人)—— 后面所有平均、门槛都以它为准。
      - 代价表自相矛盾:表称「分人环 = 就地改」,下一句就承认「换无专属的补上」
        走重排。实际是三档(就地改 / 重排 / 重出),只写了两档。
        ⇒ 新表按三档写,并点明中间那档是唯一「长在分人环、代价却偏向选人环」的。
      
      二、结构与重复
      - §1 原「三处例外」摆在《召回策略》之前,却引用尚未定义的引导节点 ⇒ 下沉。
      - 原「两条闭合的环」与开篇「两个环的代价不一样」讲同一件事 ⇒ 合并,
        与「红色菱形」一起收成《代价不对称:什么时候重来,什么时候就地改》。
      - 原「后三步」里 ② 分人是上一节的重复 ⇒ 删节,唯一新信息(主管可微调)
        并入分人那节;该节改名《③ 确认 · ④ 确认之后》。
      - §2 原名「提示词怎么搭的」盖住了它更重要的第一节(登录时装配、无对象实例)
        ⇒ 改名《这个助手是怎么装出来的》,下分「装配」「提示词分层」。
      
      三、文风与用词(要拿出去讲)
      - 术语统一:「自由患者」⇒「无专属患者」(全文其余处一直用后者)。
      - 去口语/网络语:层层套娃、甩锅、白付上下文、旋钮、拖一下、桶名、一刀切光。
      - 去代码术语:「按客服 id 打破平局」⇒「按固定顺序打破平局」。
      - 代词歧义:开篇「助手…出确认单。他只要看一眼」的「他」紧跟「助手」;
        §5 标题「要他定的事」无先行词;§8 结句「他在做的那件事就是它存在的理由」。
      - 引号统一为「」;mermaid 标签内的半角标点改全角(已在浏览器验证可渲染)。
      -  由 40+ 降到 26 —— 只留红线表与真禁令,句中当「不」用的去掉。
      
      事实层复核(未改动,均与代码一致):
        DAILY_CALLS_PER_AGENT=15、NARROW.MIN_COHORT=50 / MIN_COUNT=10、
        工具 9 通用(MCP 8 + render_artifact)/ 12 主管(MCP 6 + 助手侧 6)= 21。
      
      验证:pnpm --filter pac-docs build 通过;本地 3102 实测两张 mermaid
      均渲染(680×1582、680×158),零 syntax error。
      luoqi committed
    • docs(站点): 新增 §8「跟一般的助手不一样在哪」—— 八条产品选择,不是技术选择 · 64f7f351
      产品提到「没有新建会话功能」这条设计考量,并让我补齐能想到的。核过代码确认:
      `messages` 就是组件状态、助手挂在 plans/layout 上、全仓找不到任何
      新建会话/清空/历史列表的代码 —— **它确实不是一个聊天产品**。
      
      八条(每条都配"如果按常规做会怎样"):
        · 会话:没有新建、没有历史 —— 让他管理"会话"等于给他一件本来不存在的活
        · 搞不清时:**不追问,直接出一版** —— 追问一轮=他等一轮,
          而**一版具体的方案本身就是最好的问题**
        · 让他选择:摆成按钮,且按钮和说话走同一套动作 —— 打字要过"理解→翻成动作"
        · 输出:**模型自己排版**,卡片落在正文哪一句之后由它调工具的位置决定
        · 与界面的关系:界面显示过的不再说 —— 读两遍他就开始跳读
        · 数字:一个都不许自己产生
        · 兜底:**默认永远安全** —— 一条不点直接确认,任何情况下都不出事
        · 验收:看多轮通过率, 不是"跑通一次"
      
      🔴 收在一句上:**助手是工作台的一部分,不是工作台旁边的一个聊天机器人。**
        它没有自己的"产品面"—— 没有会话管理、没有历史、没有设置。
        他在做的那件事就是它存在的全部理由。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(站点): 装配那节按 agent-architecture §六 的深度重写 —— 补上判据,不只描述现象 · bfd92b98
      产品指出我写的不如 `docs/design/agent-architecture.md` §六。对比之后确实:
      我只写了「按权限装出角色/现场/工具」这个**现象**,而那份写的是**判据** ——
      为什么必须这么装、不这么装会怎样。照它的骨架重写。
      
      补进来的四块:
        · **哪几维必须按身份分**:会话、工具清单(看不见比看见被拒更安全)、数据范围
          (下推到查询条件, 不是查全量再过滤)必须分;系统提示词分**但这是最不重要的一条**;
          模型和代码实现不用分
        · **为什么同一会话不能切换身份**:上下文是**单向**的,高权限会话里已载入的数据
          不会因为一句「你现在是低权限角色」而消失 ——
          **提示词不是删除操作,也从来不是安全边界**
        · **「两个 agent」要拆开**:同一套实现按身份参数化; 这不是「多 agent 编排」——
          编排指 agent 互相调用,而不同身份之间不需要通信,真要协作走业务对象
        · **四条硬规则**(身份随调用传递 / 授权在工具内部 / 数据范围下推 / 工具清单按身份下发),
          连同那条判据:**如果模型不传某个参数,越权就不可能发生 —— 那这个参数就不该是参数**
      
      ️ 保留我原来那个推论(它没有"记性" ⇒ 动过手之后必须重新看那张单),
        那条在原文里没有,而它正好解释了「看当前确认单」为什么存在。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(站点): 补上「助手是登录之后现装出来的」—— 它是理解整个 §2 的前提 · b2de3308
      产品指出文档没讲 agent 是怎么形成的。代码里 `buildSystemPrompt` 就是一个纯函数:
        权限位 → ④角色 / ⑤现场 / 工具清单,①②③ 与谁登录无关
        return [IDENTITY, EVIDENCE, voice, role, scene].join('\n\n')
      **没有 agent 对象、没有实例、没有状态** —— 每次请求现拼一份字符串,工具也按权限现注册。
      
      ⇒ 补一节讲清三个后果:
        · 换个账号登录**它就是另一个助手**, 不是"同一个助手换了套权限"
        · **不登录就是一块白板**(现在没这种场景,但结构上就是如此)
        · 加一条新业务线 = 多一个 ⑤,前四层一个字不动
      
      ️ 并点出一个平时容易忘的推论:**它没有"记性"** —— 不是存着状态的对象,
        所以主管在确认单上动过手之后,助手**必须重新去看那张单**,
         不能拿出方案那一版的数接着算(这正是「看当前确认单」那个工具存在的理由)。
      
      顺带删掉原来那条「一个开关驱动三样东西」的 Callout —— 新那节已经把它讲全了。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed