Commit fe79f0f3 by luoqi

merge: 分配助手文档重排 + 助手调用留痕(agent_invocations)

文档 25 个提交:《分配助手》按产品口径全面重排(决策树前置、意图分析、
去内部黑话、编号撞车修正、代价三档)。
功能 1 个提交:助手每轮往 agent_invocations 落一行(只存当轮, 不存上下文),
外加保留期清理任务与成本计算共享化。
parents 217ca734 ad264616
Pipeline #3573 failed in 0 seconds
---
title: 分配助手
description: 主管说一句话,它把几十次操作做完 —— 助手的分工、决策、边界与验收
description: 主管说一句话,它把几十次操作做完 —— 选人、分人、确认、确认之后
icon: Bot
---
主管在矩阵上点一格,助手挑人、排客服、出确认单。**他只要看一眼、点确认。**
主管说清这一批要召回谁,助手挑人、排客服、出确认单,**主管只要看一眼、点确认**。
功能本身见 [召回分配](./batch-assignment)。本文讲**那个助手**:谁做决定、它凭什么这么做、哪些事它绝对不做、怎么知道它没变坏
功能本身见 [召回分配](./batch-assignment)。本文讲**那个助手**:它怎么走、谁做决定、哪些事它绝对不做
---
## 一、三者分工
## 一、一批召回是怎么走完的
整条线只有四步:**① 选人 → ② 分人 → ③ 确认 → ④ 确认之后**。
其中「选人」不是一次动作,而是**两个人各答一半**:主管先定运营意图,助手再用全景数据把它落成一版具体的人。
```mermaid
flowchart LR
H["👤 主管<br/>做决定"] -->|说一句话| A["🤖 助手<br/>组织语言 · 调工具"]
A -->|调用| P["⚙️ 程序<br/>算数 · 取数 · 落库"]
P -->|事实| A
A -->|一段话 + 一张确认单| H
H -->|点确认| P
flowchart TD
S(["召回策略<br/>一批召回要回答的四个问题"]) --> A1
style H fill:#fef3c7,stroke:#f59e0b
style A fill:#e0e7ff,stroke:#6366f1
style P fill:#d1fae5,stroke:#10b981
A1["① 选人 · 主管初选<br/>1 做哪一类还没启动的治疗<br/>2 找多久没来的人<br/>—— 凭运营经验,他张口就来"] --> A2
A2["① 选人 · 助手精选<br/>3 要不要只挑高价值的 —— 先摆这批人的全景<br/>4 团队还吃得下多少 —— 先摆在岗人手与在手量<br/>—— · —— · —— · —— · —— · ——<br/>不等他给全,先按这几条默认出一版:<br/>没联系过的排前面,其余按优先级<br/>每人每天 15 通 · 时效 1 天<br/>这批多大 = 在岗人数 × 15 × 时效"]
A2 --> D1{"这批人对吗?"}
D1 --> G1["助手摆出的引导 —— 满足条件才出现<br/>「能加个条件」:候选 ≥50 人 · 他还没加过 · 切完还剩 ≥10 人<br/>  消费高于本批平均 / 转介绍达人 / 权益身份 / 获客渠道<br/>「这批多大」:人数是系统估的(他没自己指定过)<br/>  可改 每人每天几通 · 时效几天"]
G1 --> R1["重新选一版<br/>⚠️ 已经排好的分法一并作废"]
R1 --> A2
D1 ==>|"人定了 —— 这一关过了才谈怎么分"| B
B["② 分人 —— 谁去打<br/>先算目标水位 =(团队在手 + 本批人数)÷ 在岗人数<br/>一趟:有专属的回自己人,到水位为止<br/>二趟:无专属的补给当前手上最少的<br/>三趟:专属这轮排满的单列成一组,交他定"]
B --> D2{"这么分行吗?"}
D2 --> G2["助手摆出的引导 —— 满足条件才出现<br/>「专属排满了」:第三趟真有人排不进去<br/>  换无专属的补上(池子还有人时才给)<br/>  铺平给在岗 / 各自归专属 / 移出本批<br/>「最忙的那位」:分完后的量 > 每天通数 × 时效<br/>  报最忙那位 + 另有几位也超<br/>  整批时效改成 N 天(一个算好的数)"]
G2 --> R2["不重新选人<br/>人不变,只动这一版的分法"]
R2 --> D2
D2 ==>|"行"| K["③ 确认<br/>点下去,这一刻才真的分下去"]
K --> L["④ 确认之后<br/>限时撤销 · 补挂福利 · 跟踪进度"]
style S fill:#fef3c7,stroke:#f59e0b
style A1 fill:#fef3c7,stroke:#f59e0b
style A2 fill:#e0e7ff,stroke:#6366f1
style D1 fill:#fee2e2,stroke:#ef4444,stroke-width:2px
style G1 fill:#fce7f3,stroke:#ec4899
style R1 fill:#fee2e2,stroke:#ef4444
style B fill:#e0e7ff,stroke:#6366f1
style D2 fill:#fff,stroke:#94a3b8
style G2 fill:#fce7f3,stroke:#ec4899
style R2 fill:#ede9fe,stroke:#8b5cf6
style K fill:#fef3c7,stroke:#f59e0b
style L fill:#d1fae5,stroke:#10b981
```
| | 负责 | ⛔ 不负责 |
图上两个环,**代价差一个数量级 —— 这是全图最要紧的一点**。红色那个菱形也是助手在这条线上**唯一实质的判断**:主管说一句话,它得先判断他动的是哪一层。
| 他动的是 | 比如他说 | 代价 |
|---|---|---|
| **主管** | 所有决定 | —— |
| **助手** | 理解他的话、组织成人话、把决定翻译成动作 | **任何数字**、谁分给谁、要不要落库 |
| **程序** | 挑人、落人、算判据、写库 | 措辞 |
| **分法** | 「王强移出这批」「待分配都还给专属」「给 5 天」「带个福利」 | **就地改**(同步)—— 同一版上动 |
| **这一版里的人** | 「换无专属客服的患者补上」 | **重排**(几秒)—— 重排一遍分法,不重新选人 |
| **人群** | 「只要商保直付的」「改成 200 人」「换成一两年没来的」 | **重出**(几十秒)—— 重新选一版,旧版当场作废 |
**一条铁律**:凡是能算的,都由程序算。助手只转述
所以顺序不能颠倒:**人先定下来,才谈怎么分。** 先排分法再让他改人群,前面那趟排班就白做了 —— 助手讲这一版时也按这个顺序讲,理由一样
---
三档里最容易看走眼的是中间那条:**「换无专属的补上」摆在分人环里,换的却是人。**「改时效」也有两条路 —— 从「这批多大」改会连人数一起重估(重出),从「最忙的那位」改只延长期限、人不变(就地改)。
## 二、一次分配,它怎么走
<Callout type="warn">**代价不对称,判断也要跟着不对称**:把「改人群」误判成「改分法」→ 主管以为条件生效了、其实没有(**看不出来的错**);反过来只是多算一次。⇒ **拿不准时一律走重出。**</Callout>
```mermaid
flowchart TD
S(["主管圈定<br/>矩阵点一格 / 直接说要哪批人"]) --> P
⚠️ 图里只画了按钮。主管随口说的(「换成正畸」「改成 200 人」「只要今年来过的」)同样能触发重出 —— 那条路不走按钮,靠助手听懂。旧版作废发生在**新版生成时**,不是确认时:主管要能看见自己出过几版、哪一版才是当前的。
P["助手出一版<br/>① 选人:这批是谁、多大<br/>② 分人:专属回自己人 · 无主给最空的<br/>③ 专属排满的不动,单列出来"]
### 召回策略:一批召回要回答的四个问题
P --> D{"这一版<br/>还有要他定的吗?"}
D -->|"没有"| C
D -->|"有"| G["摆成按钮<br/>待分配 · 换个条件选 · 本批人数 · 打不完"]
**这一环在「选人」之前** —— 先把问题摆清楚,再看谁答得了。
G --> Q{"主管做什么?"}
Q -->|"一条不点<br/>(默认永远安全)"| C
Q -->|"点按钮"| M
Q -->|"开口说别的"| M
| | 要回答的 | 谁答 | **为什么是他 / 为什么要助手先摆** |
|---|---|---|---|
| **1** | 做哪一类还没启动的治疗(种植 / 正畸 / 拔牙…) | **主管** | 这是诊所这个季度的经营重点 —— 医生排期、设备耗材、话术准备都围着它转。**这件事只有他知道**,系统里没有任何数据能推出来 |
| **2** | 找多久没来的人(三个月内 … 三年以上) | **主管** | **他能从这个时间估出大概能回来多少** —— 见下,这一条是整套策略里最值钱的判断 |
| **3** | 要不要只挑高价值的(消费额、商保、意向) | 主管定,**助手先摆数据** | **他答不了**:不看数据,他不知道这批里有多少是高价值的,也不知道切完还剩几个 —— 可能一刀下去只剩三个人 |
| **4** | 团队还吃得下多少、几天内打完 | 主管定,**助手先摆数据** | **捞太多打不完,到期回池等于白发一轮**。而「还吃得下多少」要看在岗几人、各自手上压着多少 —— 这些数在系统里,不在他脑子里 |
M{"动的是哪一层?"}
M -->|"怎么派<br/>移出谁 · 改派 · 改时效 · 带福利"| J["就在这一版上改<br/>不重跑"]
M -->|"这批人是谁<br/>换条件 · 改人数 · 换时间档"| N["重跑,出新版<br/>旧版当场作废"]
**1、2 是运营意图,问他就有;3、4 问他他也说不出**,得先把全景摆到他面前。这就是为什么选人要拆成两半。
J --> D
N --> P
1 和 2 交叉出来的那批人,下文一律称**候选人群**(比如「种植 · 一到两年没来」这一批)—— 助手后面所有的比较、平均、门槛,算的都是这一批人自己的数,不是全库的数。
C["主管点确认<br/>这一下才真的分下去"] --> A(["已确认"])
A --> A1["限时撤销"]
A --> A2["补挂福利"]
A --> A3["跟踪进度"]
{/* ⚠️ 多段落的 Callout:开闭标签必须**各自独占一行**,且与正文之间留空行。
写成 `<Callout>正文…` 再空行,MDX 会把它当成段落内的行内 JSX,
要求在同一段里闭合 → Build Error: Expected a closing tag before the end of `paragraph`。 */}
<Callout type="info">
style S fill:#fef3c7,stroke:#f59e0b
style P fill:#e0e7ff,stroke:#6366f1
style D fill:#fff,stroke:#94a3b8
style Q fill:#fff,stroke:#94a3b8
style M fill:#fee2e2,stroke:#ef4444,stroke-width:2px
style G fill:#fce7f3,stroke:#ec4899
style J fill:#ede9fe,stroke:#8b5cf6
style N fill:#ede9fe,stroke:#8b5cf6
style C fill:#fef3c7,stroke:#f59e0b
style A fill:#d1fae5,stroke:#10b981
```
**「多久没来」不只是一个筛选条件 —— 它是这套策略里最值钱的那个判断。**
有经验的主管能从它估出这批大概能回来多少:一两年没来的和三年以上的,回头率不是一个量级。
而这个估算往下游走一步,就是**诊所的排班**:预计能回来多少人、其中多少要做种植,医生和科室要不要提前留出位置。**捞回来了却没人接诊,比没捞更伤客户。**
⇒ 所以这一项必须由主管拍板,系统不代劳、也不推荐 —— 它算得出人数,算不出诊所那边接得住多少。
### 红色那个菱形,是助手在这条线上**唯一实质的判断**
</Callout>
| 主管说 | 动的是 | 走 |
### 主管只说了 1 和 2,剩下的助手拿默认值先跑通
助手在这一步不是等他再下一个指令,而是**先把该看的摆出来、顺手把方案做出来**:这批人的画像构成、消费分布、在岗几人、各自手上压着多少、这批发下去谁最忙。主管看着这些改,或者直接确认。
| 默认的是 | 取值 | 他想改,说一句就行 |
|---|---|---|
| 「王强移出这批」「待分配都还给专属」「给 5 天」「带个福利」 | **怎么派** | 局部改,同一版 |
| 「只要商保直付的」「改成 200 人」「换成 1–2 年那档」 | **这批人是谁** | 重跑,出新版 |
| **谁排前面** | 没联系过的排前面,其余按优先级从高到低 | 「先打消费高的」 |
| **每人每天打几通** | 15 通 | 「按 20 通算」 |
| **多久要打完** | 1 天 | 「给 5 天」 |
| **这批发多大** | 在岗人数 × 每天通数 × 时效 | 「改成 200 人」 |
<Callout type="warn">**默认值一个都不许藏。** 出方案时式子摊开写:「本批 405 人 = 在岗 27 人 × 每天 15 通 × 1 天」。
一个他看不见的默认值,等于系统替他做了一个他不知道的决定 —— 而这批人是真发下去了。
⇒ 摆出来他才有得改;不摆,他连「原来还能改这个」都不知道。</Callout>
人手在**两个地方**主动摆给他看,不用他去找:出方案时是上面那个式子;确认单上是每位客服一行「约 3 天」,算的是**分完之后他手上的总量**(在手 + 本批),不只是本批那几条。超出时效的用**颜色**提示,⛔ 不写「打不完 / 超了 / 过载」—— 几乎每批都会有人超,说成故障主管就会开始怀疑系统,而不是做他该做的判断。
<Callout type="warn">**代价不对称**:把「这批人是谁」误判成「怎么派」→ 主管以为条件生效了、其实没有(**看不出来的错**);反过来只是多算一次。⇒ **拿不准时一律走重跑。**</Callout>
### 能加哪几个条件
### 两条闭合的环
助手只在**这批人还值得再收窄一层**时才提,一次给四条,每条都直接写清切完还剩多少人:
| 环 | 回到哪 | 结果 |
| 加什么条件 | 门槛怎么来的 | 这一条什么时候不给 |
|---|---|---|
| **改派法** | 回到同一版 | 人没变,分法变了 |
| **改人群** | 回到新的一版 | **旧版当场作废**,确认按钮禁用 |
| **只选消费高于本批平均的** | 门槛是**这批候选自己的平均**,⛔ 不是全库分位 —— 全库平均 ¥4,442,而某一批实测 ¥0 起,用全库的数会把人几乎筛空 | 高于平均的不足 10 人 |
| **只选转介绍达人** | 推荐过 ≥3 人**且带来成交**(家庭型和社交型合并成一条 —— 主管要的是「有没有这个能力」,不是哪一型) | 不足 10 人 |
| **只选某个权益身份的** | 取这批候选里**人最多的那一项** | 最多的那项不足 10 人 |
| **只选某个获客渠道的** | 同上 | 同上 |
| + **按别的条件选** | 兜底:助手把其余十几个维度各多少人报一遍,他再挑 | 始终给 |
⚠️ 作废发生在**新版生成时**,不是确认时 —— 主管要能看见自己出过几版、哪一版才是当前的。
<Callout type="warn">**加条件 = 重新选一版**,⛔ 不是在已经排好的那些人里再挑一遍。
点下去之后这批候选重新选进来多少人、这批发多大,都会跟着重算。
按钮上那个数字是**换完之后还剩多少人**,不是「从本批里筛掉几个」。</Callout>
### 助手讲这一版时,也按这个顺序讲
### 引导什么时候才冒出来
`① 选人 → ② 分人 → ③ 确认`。**顺序不能反**:选人那一档他一动,整版重出;先讲分法,他动一下就全白讲。
⛔ 不是每次都全给 —— 出场条件写在图上,卡这些条件的理由是:
---
| 引导 | 为什么卡那个条件 |
|---|---|
| **专属排满了** | 它是唯一「不处理就真的有人被漏掉」的一条,所以只要真排不进去就必出 |
| **能加个条件** | 人太少时「平均数」「最多的那一项」本身就是噪音;已经加过就不再一层层往下切 |
| **这批多大** | 他自己说过「就发 200 人」,就不必再解释这个数哪来的 |
| **最忙的那位** | 没超就不提,⛔ 不制造无谓的告警 |
## 三、它由什么拼起来
⚠️ **符合条件的人比这批还少时,「这批多大」照样给他**:往上调确实不会更多,但**往下调可以少发一些** —— 那句话据实写清方向,⛔ 不把入口藏起来。
```mermaid
flowchart TB
subgraph P["提示词(常驻)"]
direction TB
P1["装置 · 你是谁"] --> P2["诚实 · 什么不许编"] --> P3["语气 · 怎么说话"] --> P4["角色 · 你伺候谁"] --> P5["现场 · 他此刻在做什么"]
end
subgraph T["工具(按角色发)"]
T1["查"] --- T2["出方案"] --- T3["改单"] --- T4["追踪"]
end
subgraph O["每次出方案,程序给它"]
O1["结构化事实<br/>怎么选的 / 怎么派的"] --- O2["引导节点<br/>还有什么要你定"]
end
P --> M(("助手"))
T --> M
O --> M
M --> R["一段话 + 一张确认单 + 几个按钮"]
style P fill:#e0e7ff,stroke:#6366f1
style T fill:#ede9fe,stroke:#8b5cf6
style O fill:#fce7f3,stroke:#ec4899
style M fill:#fef3c7,stroke:#f59e0b
### ② 分人怎么分:又满又平,三趟走完
三趟怎么走见图。先按「这批多大」算出一条**目标水位**给第一趟封顶 —— ⭐ 它**不是一个新的可调项**,完全由「这批多大」推出来:
```
目标水位 = ⌈(团队现有在手总量 + 本批人数)÷ 在岗人数 ⌉ 至少 1
```
---
<Callout type="warn">
## 四、提示词分五层
**为什么必须三趟、不能合成两趟。** 二、三趟都是水位法、都填最空的人,合并起来总量一模一样 —— 但**被拆散的专属关系数不一样**。先用无专属的去补空手的人,能少动一个有主患者;合成一趟,系统就会随机地把某个有主患者改派出去,而同时某个无专属患者落给了别人。
**分层的依据是「什么会让它变」** —— 不是按话题分。
**为什么第一趟要封顶。** 本地实测:池子 1,081 人里 **755 人(70%)挂在同一个客服名下**。不封顶,他一批拿走 248 条;而 17 位在岗客服里有 10 位名下一个患者都没有,全队只有 119 个无专属患者可分。
| | 层 | 管什么 | 什么时候会变 |
|---|---|---|---|
| ① | **装置** | 你是谁、你看不见什么 | 产品形态变 |
| ② | **诚实** | 数从哪来、什么不许编 | 几乎不变 |
| ③ | **语气** | 衔接、顺序、分寸、排版 | **可配置**(换一套人设即可) |
| ④ | **角色** | 他有什么权责、你不许替他决定什么 | 登录人的权限 |
| ⑤ | **现场** | 他此刻在做的那条业务线 | 加一条新业务线 |
**为什么第三趟不自动改派。** 把患者从他的专属客服手里挪走,是**关系层面的决定,助手没资格替主管做**。所以这些人不是被丢掉,是原地不动、单列出来交他定 —— 关系还在,只是这轮没轮到。主管自己改派当然可以,那条会标成「他手动改的」。
**同一个助手,两种人看到两份提示词**:
</Callout>
```mermaid
flowchart LR
L["主管登录"] --> R1["角色:主管的助手"] --> S1["现场:把一批人分给客服"] --> T1["工具:21 个"]
C["客服登录"] --> R2["角色:客服的助手"] --> S2["现场:把手上的单打好"] --> T2["工具:9 个"]
⚠️ 同水位时按固定顺序打破平局 —— **同样的输入两次必须算出同样的分法**,这是主管敢按确认键的前提。
三趟只是默认。主管可以在这一版上移出某几个人、改派给谁、单独给某位少分点(「李莉这周带教,这批最多给 5 条」)—— 程序按规则落,主管微调。
### ③ 确认 · ④ 确认之后
**③ 确认**只有主管能做 —— 他点下去这一刻才真的分下去。**④ 确认之后**是发出去怎么管:发错了限时撤回、临时加个福利、跟踪这批打得怎么样,主管发起、程序执行。
---
## 二、这个助手是怎么装出来的
style L fill:#fef3c7,stroke:#f59e0b
style C fill:#dbeafe,stroke:#3b82f6
### 它是登录那一刻装出来的,⛔ 不是一个常驻的「对象」
系统里没有一个叫「助手」的东西守在那儿等人来问。**每次请求都按当前登录人现装一份**,装配的输入只有一个:他的权限。
```
登录人的权限 ──┬──→ 会话 主管的会话 / 客服的会话,各归各的
├──→ 工具清单 21 个 / 9 个
├──→ 数据范围 能查哪几家诊所的人
└──→ ④角色 ⑤现场 他是谁的助手、此刻在做哪条线
①②③ 与谁登录无关,永远是同一份。(①—⑤ 指下一节的提示词五层)
```
前三层完全一样;后两层与工具清单**跟着登录账号走**。将来加排班、复盘,就是加第 ⑥ 条现场,前面四层一个字不动。
### 为什么必须按身份装,而不是「一个助手换个说法」
| 这一维 | 要不要按身份分 |
|---|---|
| **会话** | **必须分** |
| **工具清单** | **必须分** —— 由登录态下发,**看不见比看见被拒更安全** |
| **数据范围** | **必须分** —— 下推到查询条件里,⛔ 不是查全量再过滤 |
| 系统提示词(④⑤) | 分,**但这是其中最不重要的一条** |
| 模型 | 不用分 |
| 代码实现 | 不用分 |
<Callout type="warn">
**次要的活不常驻**:分配追踪的做法写成单独一篇,提示词里只留一行索引,助手要用时自己去取。常驻上下文不为一件三成会话才用到的事买单。
**为什么同一个会话里不能「切换身份」。** 上下文是**单向**的:高权限会话里已经载入的数据,不会因为一句「你现在是低权限角色」而消失。
**提示词不是删除操作,也从来不是安全边界。**
</Callout>
所以「主管一个 agent、客服一个 agent」这个说法要拆开看:**同一套实现,按身份参数化**。这不是「多 agent 编排」—— 编排指 agent 之间互相调用,而不同身份之间**不需要通信**。
边界全都落在结构上而非嘴上:身份随每次调用传递、授权在工具内部执行、数据范围写进查询条件、工具清单按身份下发。
> **判据:如果模型不传某个参数,越权就不可能发生 —— 那这个参数就不该是参数。**
> 顺带的好处:模型不用猜,少一个必错的空格。
由此还得到一个平时容易忘的推论:**它没有「记性」** —— 不是存着状态的对象,上一轮的东西要么在对话历史里,要么就得重新查。所以主管在确认单上动过手之后,助手**必须重新去看那张单**,⛔ 不能拿出方案那一版的数接着算(「看当前确认单」那个工具就是为此存在的)。
### 提示词分层:判据是「什么会让它变」
助手的行为由一段系统提示词决定。它不是一篇散文,是**五层拼起来的**,装配顺序就是模型的阅读顺序:**通用 → 特殊**。
分层⛔ 不按话题分。同一个话题的话可能分属两层,只因为它们变化的原因不同。
| | 层 | 管什么 | 什么会让它变 | 占篇幅 |
|---|---|---|---|---|
| **①** | **装置** | 你是谁、和使用者什么关系、**你看不见什么** | 产品形态变才变 | 9% |
| **②** | **诚实** | 数从哪来、什么不许编、怎么忠于工具返回值 | 几乎不变 | 20% |
| **③** | **人设语气** | 衔接、顺序、分寸、排版 | **可换**(配一套人设即可) | 32% |
| **④** | **角色** | 他有什么权责、你不许替他决定什么 | 登录人的权限变 | 7% |
| **⑤** | **现场** | 他此刻在做的那条业务线 | 加一条新业务线 | 32% |
前三层**所有人一样**;④⑤ 与工具清单**跟着登录账号走**。
### 只有主职责常驻
常驻的成本不是「多几百字」,是**每条业务线都常驻之后的总和** —— 分配、追踪、排班、复盘、盘点,每加一条,其余所有会话都在白白背着它的篇幅,而**规则越多,每条被遵守的概率越低**。所以只有**主职责**(分配)常驻,次要的活写成单独一篇,常驻里**只留一行索引**。
⭐ 实测问「我前面分下去那两批现在怎么样了」,模型**第一步就自己去把做法取了**,然后才查批次 —— 提示词里没有一句叫它这么做。
---
## 、它手上的工具
## 、它手上的工具
工具是助手唯一能做事的通道。**没有对应工具的事,它做不了,也不会假装做了。**
<Callout type="info">**两个人拿到的清单不一样** —— 不是靠提示词叮嘱它"别看别人的",是那些工具**根本没发给它**。</Callout>
### 设计思路:能靠结构解决的,⛔ 不靠叮嘱
### 两种人都有
模型会说出 `cold_3y`、`implant` 这种内部取值码,而主管看到不认识的词,第一反应是**系统坏了**。
| | |
最初的对策是在提示词里写两页「不许说出取值码」—— **拦不住**。因为模型调工具时**必须**拿这些码当参数,回话时自然就带出来了。
真正的解法是**在返回值里就给它现成的中文**:既回显它传进来的条件,又把内部分档的名字换成中文。**它手里有话可说,就不会去说码。**
<Callout type="info">这是这套工具设计反复用的那条判据:**能让它「说不出来」的,就别写成「不许说」。** 提示词里的禁令只拦得住你预想到的那些;把码换成中文是结构上的 —— 它想说也说不出来。</Callout>
### 工具描述怎么写
工具描述是模型**决定调不调**时唯一能看的东西。一条描述要答三件事:
| | 写什么 |
|---|---|
| **我是谁** | 我能管哪几家诊所、有什么权限 |
| **查患者** | 找人 · 全貌 · 画像 · 关键事实 · 这次为什么要联系他 |
| **看召回池** | 名单 · 数字概览 |
| **干什么** | 一句话说清它回答哪个问题 —— 「回答『现在该联系谁 / 今日推荐』」 |
| **什么时候用、什么时候别用** | 「出方案**不需要**先调它,方案自己会取名册」「试探性调一次没有『预览』,那一次就是真撤」 |
| **怎么用** | 参数怎么填、跟哪个工具配套 —— 「先用它给摘要,再用名单取明细」 |
### 只有主管有
**「什么时候别用」是最值钱的那一条,也几乎每条背后都有一次实测事故。**「不需要先调看人手」是抓到模型写字前白调了一次、返回的数一个没用上;「撤销没有预览」是因为它真的没有。**一个工具能干什么,看名字就猜得到;什么时候不该用,只能踩出来。**
| | |
⚠️ 两条硬边界:
- **返回值只给事实,⛔ 不给成品句子** —— 踩过:某个返回值里混了给模型的指令,模型照抄,内部指令原样贴进了主管的对话框。护栏要写成**规范**,⛔ 不写成台词。
- **描述里⛔ 不留正面举例。** 也踩过:描述里写「他会说『只要商保直付的』」,模型把这个例子**逐字念给主管听**了,而那一版数据里根本没有这一类。⇒ 反例可以留,正例不留。
### 全部工具
<Callout type="info">**两个人拿到的清单不一样** —— 不是靠提示词叮嘱它「别看别人的」,是那些工具**根本没发给它**。</Callout>
**两种人都有(9 个)**
| 工具 | 一句话 |
|---|---|
| **看人手** | 在岗客服名册 + 每人手上压着多少 |
| **看这批人构成** | 各画像维度各多少人(切之前先看,免得切完只剩三个) |
| **出方案** | 挑人 · 排客服 · 算引导节点,**一次算完** |
| **看当前确认单** | 他在卡片上动过手之后,助手看不见 —— 得查 |
| **改确认单** | 移出谁 · 改派给谁 · 改时效 · 设福利 |
| **摆卡片 / 摆按钮** | 决定确认单和选项按钮**落在正文的哪个位置** |
| **追踪** | 我分过哪几批 · 某批怎么样了 · 这个人为什么分给了他 |
| **撤销** | 限时收回整批 |
| 我是谁 | 当前登录人、能管哪几家诊所 |
| 找患者 | 按姓名 / 手机 / 患者号模糊找人 |
| 患者全貌 | 一次拉全:画像要点 + 近期事实 + 当前召回计划 |
| 患者画像 | 全量画像 —— PAC **推断**出来的那一层 |
| 患者事实 | 真实发生过的事,按时间排 —— **不含推断** |
| 这次为什么召回他 | 当前召回计划:场景、原因、优先级 |
| 召回池名单 | 现在该联系谁,按优先级排 |
| 召回池概览 | 总量 + 优先级分档 + 病种分布 |
| 画出来 | 把适合看不适合读的东西渲染成图 |
**只有主管有(另 12 个)**
| | 工具 | 一句话 |
|---|---|---|
| **选人** | 看人手 | 在岗客服名册 + 各自在手量 |
| | 看这批人构成 | 这批人各画像维度各多少人 |
| **出方案** | 出一版方案 | 选人 + 排客服 + 算引导,一次算完 |
| | 看当前确认单 | 那张单**现在**什么样(他动过手之后助手看不见) |
| | 改确认单 | 移出谁 · 改派 · 改时效 · 设福利 |
| | 摆确认单 / 摆引导 | 决定它们落在正文哪一句之后(两个工具) |
| **追踪** | 分过哪几批 | 批次列表 + 每批汇总 |
| | 某批怎么样了 | 进度 · 按客服拆 · 退回原因 · 通话成效 · 逐条纪要 |
| | 为什么分给了他 | 从分配**当时**的决策快照查 |
| **收回** | 撤销整批 | 限时收回 —— **唯一会改数据的工具** |
| **取做法** | 取一份做法 | 某一类活按什么顺序做(见 §2) |
---
## 六、要他定的事,做成按钮
程序算出「这一版还有什么没定」,**摆成按钮**,不让主管打字。
## 四、三者分工
```mermaid
flowchart LR
subgraph S["确认单上的一条"]
direction TB
T["📌 182 人的专属客服这轮已排满"]
W["为什么:他们有专属客服,但那位这轮排满了"]
D["不处理 = 这批不发给他们"]
B["[换无专属客服的患者补上][铺平给在岗][各自归专属][移出本批][我自己说]"]
T --- W --- D --- B
end
style S fill:#fff7ed,stroke:#f59e0b
H["👤 主管<br/>做决定"] -->|说一句话| A["🤖 助手<br/>组织语言 · 调工具"]
A -->|调用| P["⚙️ 程序<br/>算数 · 取数 · 落库"]
P -->|事实| A
A -->|一段话 + 一张确认单| H
H -->|点确认| P
style H fill:#fef3c7,stroke:#f59e0b
style A fill:#e0e7ff,stroke:#6366f1
style P fill:#d1fae5,stroke:#10b981
```
| 节点 | 什么时候出 | 不处理会怎样 |
| | 负责 | ⛔ 不负责 |
|---|---|---|
| **主管** | 所有决定 | —— |
| **助手** | 理解他的话、组织成人话、把决定翻译成动作 | **任何数字**、谁分给谁、要不要落库 |
| **程序** | 挑人、落人、算判据、写库 | 措辞 |
**一条铁律**:凡是能算的,都由程序算。助手只转述。
---
## 五、要主管定的事,做成按钮
程序算出「这一版还有什么没定」,**摆成按钮**,不让主管打字。
| 节点 | 它对主管说什么 | 不处理会怎样 |
|---|---|---|
| **待分配** | 有人的专属客服这轮排满了 | 这批不发给他们 |
| **换个条件选** | 这一格还能再切 | 就按整格来 |
| **本批人数** | 这批多大是系统估的 | 就按这个数发 |
| **打不完** | 最忙的那位手上超过时效能打的量 | 就按这个时效发,到期没打完的自动回池 |
| **专属排满了** | 「182 人的专属客服这轮已排满」——**第三趟那一组**,四个选择:换无专属的补上(池子还有人时才给)/ 铺平给在岗 / 各自归专属客服 / 移出本批 | 这批不发给他们 |
| **能加个条件** | 「这批候选 2,663 人,也可以只选其中一类」+ 四个带人数的选项(见 §1) | 就按这批候选全部人来 |
| **这批多大** | 「本批 405 人 = 在岗 27 人 × 每天 15 通 × 1 天」,式子摊开给他看 | 就按这个数发 |
| **最忙的那位** | 「这批发下去,最忙的是王强:手上共 45 条,约 3 天的量;**另有 2 位也超过 1 天**」 | 就按这个时效发,到期没打完的自动回池 |
⚠️ **为什么要报「另有几位」**:只说最忙的一个,主管分不出两种局面 —— 而这两种局面该做的事**正好相反**:只有王强超,就给他少分点、改派几个;全队都超,就得减少这批、延长时效。只有他一个人超时那半句不出现,⛔ 不制造无谓噪音;也⛔ 不在这里铺开每个人 —— 确认单上每位客服那一行已经写着「约 N 天」,引导只负责**点出要他定的事**,不负责展示数据。
**三条设计原则**:
⚠️ **「最忙的那位」只给一个选项,而且必须带数**:「整批时效改成 **3** 天」—— 那个 3 是按最忙那位的量算出来的。曾经还有「改每人每天打几通」「减少本批人数」两个,删掉了:它们**一个数都不带**,点下去等于替主管说了句「减少一些」—— **一个不带数的按钮,严格弱于他自己开口说一句。**
每条按钮都带三样:**是什么**、**为什么**、**不处理等于什么**。三条设计原则:
| | |
|---|---|
| **只陈述事实** | ⛔ 不写「建议你铺平」—— 给建议就是替主管做决定 |
| **默认永远是"不动"** | 一条不点、直接确认,**在任何情况下都安全** |
| **只陈述事实** | ⛔ 不写「建议你铺平」「打不完」「人太多了」「建议减到 200」—— 给建议就是替主管做决定 |
| **默认永远是「不动」** | 一条不点、直接确认,**在任何情况下都安全** |
| **有后果的全亮** | ⛔ 不许省、不许弱化成「另有若干」—— 藏一条,他就不知道有东西卡着 |
按钮和说话**走同一套动作**:他点按钮,和他开口说「把待分配的都还给专属客服」,落到程序里是同一件事。
---
## 、红线
## 、红线
| ⛔ 绝不 | 为什么 |
|---|---|
......@@ -234,36 +371,45 @@ flowchart LR
| 把患者从他的专属客服手里挪走 | 关系层面的决定,只有主管能拍板 |
| 讲「成功率 / 转化率」 | 本系统**不统计成功与否**,那个结论编不出来 |
| 替他加福利的条件、期限、承诺 | 他说什么就原样写进去 |
| 说「我做不到」「你先自己弄完再说」 | 他能改的东西助手手上都有工具;把几十次手工操作推回去不是保护,是甩锅 |
| 说「我做不到」「你先自己弄完再说」 | 他能改的东西助手手上都有工具;把几十次手工操作推回给主管,不是保护,是推卸 |
---
## 、怎么知道它没变坏
## 、怎么知道它没变坏
助手这层**天生有波动** —— 同一句话问两次,它未必走同一条路。所以验收不能只看"跑通了一次"。
助手这层**天生有波动** —— 同一句话问两次,它未必走同一条路。所以验收分三层:
```mermaid
flowchart TB
L1["① 算法与取数<br/>挑人 · 落人 · 判据"] --> V1["单元测试<br/>过 / 不过"]
L2["② 交接<br/>谁传什么、谁压过谁"] --> V2["契约测试<br/>过 / 不过"]
L3["③ 助手行为<br/>该调的工具调没调"] --> V3["用例集跑批<br/>通过率"]
style L1 fill:#d1fae5,stroke:#10b981
style L2 fill:#e0e7ff,stroke:#6366f1
style L3 fill:#fce7f3,stroke:#ec4899
style V3 fill:#fef3c7,stroke:#f59e0b
```
| 层 | 测什么 | 判据 |
|---|---|---|
| **算法与取数** | 挑人 · 落人 · 判据 | 单元测试,过 / 不过 |
| **交接** | 谁传什么、谁压过谁 | 契约测试,过 / 不过 |
| **助手行为** | 该调的工具调没调 | 用例集跑批,**通过率** |
**第层的判据是「动作」,不是「文字」。** 同一个意思十种说法都对;而最贵的那类失败恰恰是**话说得挺好、工具一次没调**。
**第层的判据是「动作」,不是「文字」。** 同一个意思十种说法都对;而最贵的那类失败恰恰是**话说得挺好、工具一次没调**。
<Callout type="warn">**跑一次不算数。** 同一份配置跑 3 轮得到 1/3、跑 10 轮得到 9/10 —— 是同一个行为。判断"它是不是退步了"必须看多轮通过率。</Callout>
<Callout type="warn">**跑一次不算数。** 同一份配置跑 3 轮得到 1/3、跑 10 轮得到 9/10 —— 是同一个行为。判断「它是不是退步了」必须看多轮通过率。</Callout>
用例全部来自**真实踩过的坑**,每条都写明由来 —— 没有一条是设想出来的场景。改提示词、换模型、加工具之前后各跑一次,比的是通过率。
用例全部来自**真实踩过的坑**,每条都写明由来 —— 没有一条是设想出来的场景。
---
## 一句话总结
## 八、跟一般的助手不一样在哪
**主管做决定,助手把决定落成动作,程序保证数是真的。**
这些差别都不是技术选择,是**产品选择** —— 每一条背后都有一个「如果按常规做会怎样」。
| | 一般的助手 | 这一个 | 为什么 |
|---|---|---|---|
| **会话** | 有「新建会话」、有历史列表 | **都没有** —— 会话跟着他此刻在做的事,离开工作台就结束 | 它不是一个聊天产品,是**工作台的一部分**。让他管理「会话」,等于给他一件本来不存在的活 |
| **搞不清时** | 反问澄清,一轮一轮问 | **不追问,直接出一版** | 追问一轮 = 他等一轮。而**一版具体的方案本身就是最好的问题** —— 他看着改,比回答抽象提问快得多 |
| **让他做选择** | 让他打字说清楚 | **摆成按钮**,按钮和说话**走同一套动作**(见 §5) | 打字要过「模型理解 → 翻成动作」,而按钮是固定的,没有理解错的余地 |
| **输出** | 一段文字,卡片位置由模板定 | **模型自己排版** —— 卡片和按钮落在正文哪一句之后,由它调工具的位置决定 | 「先讲怎么排的,再摆明细」和「先甩一屏名单」是两种阅读体验,而只有它知道自己讲到哪了 |
| **和界面的关系** | 把知道的都说一遍 | **界面已经显示的不再说** —— 只写界面说不出来的那半句 | 同一件事读两遍,他下次就开始跳读,而跳读时最先丢的正是最要紧的那句 |
| **数字** | 模型自己算、自己推 | **一个数都不许自己产生**(见 §4) | 凡是能算的都由程序算,助手只转述 |
| **出错的兜底** | 出错了给个提示 | **默认永远安全** —— 一条引导不点、直接确认,在任何情况下都不会出事 | 他可以完全不理会助手的建议,而这不该有任何代价 |
| **怎么算验收通过** | 跑通一次就算 | **看多轮通过率**(见 §7) | 这一层天生有波动,跑一次说明不了任何事 |
<Callout type="info">**贯穿这些差别的是同一条:助手是工作台的一部分,不是工作台旁边的一个聊天机器人。** 它没有自己的「产品面」—— 没有会话管理、没有历史、没有设置。**主管手上那件事,就是它存在的全部理由。**</Callout>
---
三者边界不靠自觉,靠**结构**:算得出来的不让它算,看不到的不发给它,做不了的不给工具。
**一句话**:主管做决定,助手把决定落成动作,程序保证数是真的。三者边界不靠自觉,靠**结构** —— 算得出来的不让它算,看不到的不发给它,做不了的不给工具。
......@@ -43,6 +43,12 @@ export interface AppConfig {
assistantVoice: string;
/// 价格表(¥/M tokens)— 从 AI_PRICE_TABLE_JSON env 读;调价时改 env 重启即可
priceTable: Record<string, { inHit: number; inMiss: number; out: number }>;
/**
* `agent_invocations` 里**肥字段**(inputSnapshot / prompt / outputText)的保留天数。
* 到期后清成元数据行,⛔ 不删行(成本与通过率曲线要长期可比)。失败行留 3 倍时长。
* 0 = 不清理 —— ⚠️ 只在排查期临时这么配,助手是按对话轮数写库的,不清会涨得很快。
*/
invocationRetentionDays: number;
};
alert: { webhookUrl: string };
cors: { origins: string[] };
......@@ -84,6 +90,7 @@ export function loadConfig(): AppConfig {
requestTimeoutSec: Number(process.env.AI_REQUEST_TIMEOUT_SEC ?? 180),
assistantVoice: process.env.PAC_ASSISTANT_VOICE ?? '',
priceTable: parsePriceTable(process.env.AI_PRICE_TABLE_JSON),
invocationRetentionDays: Number(process.env.AI_INVOCATION_RETENTION_DAYS ?? 30),
},
alert: {
webhookUrl: process.env.ALERT_WEBHOOK_URL ?? '',
......
......@@ -10,6 +10,7 @@ import { PromptCacheService } from './core/prompt-cache.service';
import { SafetyGateRejectError, SafetyGateService } from './core/safety-gate.service';
import { computeInputHash } from './core/hash.util';
import type { AiCall, AiCallContext, AiCallResult } from './ai-call.interface';
import { estimateCostYuan } from './core/cost';
/**
* 流式事件 — orchestrator / controller 转换成 SSE 后吐给客户端
......@@ -460,8 +461,14 @@ export class AiCallRunnerService {
cachedInputTokens: number = 0,
): number {
const priceTable = this.config.get('ai', { infer: true }).priceTable;
const p = priceTable[modelId];
if (!p) {
const { yuan, priceMissing } = estimateCostYuan(
priceTable,
modelId,
promptTokens,
completionTokens,
cachedInputTokens,
);
if (priceMissing) {
/**
* 🔴 **价目表里没有这个模型 —— 必须吭声**(2026-08-13)。
* 原来这里静默回落到 `deepseek-v4-pro` 的价:换成 qwen 旗舰之后,
......@@ -474,13 +481,7 @@ export class AiCallRunnerService {
`补价:AI_PRICE_TABLE_JSON`,
);
}
const price = p ?? priceTable['deepseek-v4-pro'] ?? { inHit: 0.5, inMiss: 3.6, out: 25 };
// 防御:cached > prompt 不该发生,clamp
const hit = Math.min(cachedInputTokens, promptTokens);
const miss = Math.max(0, promptTokens - hit);
const yuan =
(hit * price.inHit + miss * price.inMiss + completionTokens * price.out) / 1_000_000;
return Math.max(0, yuan);
return yuan;
}
}
......
......@@ -74,6 +74,8 @@ import { PlanModule } from '../plan/plan.module';
],
exports: [
// 对外暴露 orchestrator(业务方调用入口)+ runner(高级使用 / eval CLI)
// 助手每轮往 agent_invocations 落一行,复用同一个 recorder(⛔ 别再抄一份写库逻辑)
InvocationRecorderService,
PlanScriptOrchestrator,
WecomScriptOrchestrator, // 企微话术(PlansAggregateController 注入)
PlanSummaryOrchestrator,
......
/**
* 成本估算 —— 纯函数,`AiCallRunner` 与助手共用**同一份**口径。
*
* ⚠️ 抽出来是因为它是**计价**逻辑:抄第二份必然会漂,而漂了之后报表上看不出任何异常
* (同一类 bug 2026-08-13 栽过一次:换 qwen 旗舰后成本被低报约四倍)。
*/
export interface ModelPrice {
/** ¥/M tokens —— 输入里命中 vendor prompt cache 的部分 */
inHit: number;
/** ¥/M tokens —— 输入里未命中的部分 */
inMiss: number;
/** ¥/M tokens —— 输出 */
out: number;
}
/** 价目表里查不到时的兜底价(与历史行为一致:按 deepseek-v4-pro 估)。 */
export const FALLBACK_PRICE: ModelPrice = { inHit: 0.5, inMiss: 3.6, out: 25 };
export interface CostResult {
yuan: number;
/** 价目表里没有这个模型 —— 调用方要吭声,⛔ 别静默 */
priceMissing: boolean;
}
export function estimateCostYuan(
priceTable: Record<string, ModelPrice>,
modelId: string,
promptTokens: number,
completionTokens: number,
cachedInputTokens = 0,
): CostResult {
const p = priceTable[modelId];
const price = p ?? priceTable['deepseek-v4-pro'] ?? FALLBACK_PRICE;
// 防御:cached > prompt 不该发生,clamp
const hit = Math.min(Math.max(0, cachedInputTokens), Math.max(0, promptTokens));
const miss = Math.max(0, promptTokens - hit);
const yuan =
(hit * price.inHit + miss * price.inMiss + Math.max(0, completionTokens) * price.out) /
1_000_000;
return { yuan: Math.max(0, yuan), priceMissing: !p };
}
......@@ -107,6 +107,16 @@ import { Permission } from '@pac/types';
*/
/**
* 提示词版本 —— 落进 `agent_invocations.prompt_version`,是回放「当时那份提示词长什么样」的**唯一锚**。
*
* 🔴 **改动 ①〜⑤ 任意一层的正文,必须 bump 这个值**(连同当天日期)。
* ⚠️ 系统提示词**不逐行落库**:主管那份 6.9 KB 每次一模一样,逐行存等于把同一份东西
* 抄一万遍。审计靠「这一行的 promptVersion + 代码里那个版本的正文」两边对 ——
* ⇒ 版本不 bump,这条链就断了,而断了不会有任何报错。
*/
export const ASSISTANT_PROMPT_VERSION = 'assistant@2026-08-16-a';
/**
* ① 装置 —— 你是谁、和使用者什么关系、你看不见什么。
*
* ⚠️ 这里**刻意不写角色**:同一个助手同时服务门诊经理(主管)和客服,
......@@ -280,7 +290,7 @@ const ASSIGNMENT_SCENE = `## 他现在做的这件事:把一批人分给客服
### 分人
两趟 + 一组:有专属且在岗的回自己人手上(最多到他这轮该拿的那份);无或专属已离岗的给当前手上最少的那个;专属客服这轮已排满的不动,单列成一组交他定。
两趟 + 一组:有专属且在岗的回自己人手上(最多到他这轮该拿的那份);无专属或专属已离岗的给当前手上最少的那个;专属客服这轮已排满的不动,单列成一组交他定。
不是每人加一样多,是每条都给当前手上最少的那个。没有「容量上限」这回事,负载就是在手量本身。
......
......@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai';
import { randomUUID } from 'node:crypto';
import type { Prisma } from '@prisma/client';
import {
Permission,
TEMPERATURE_TOOL_DESC,
......@@ -10,7 +11,17 @@ import {
type TemperatureValue,
} from '@pac/types';
import { AiProviderService } from '../ai/core/ai-provider.service';
import { buildSystemPrompt } from './assistant-prompts';
import { InvocationRecorderService } from '../ai/core/invocation-recorder.service';
import { estimateCostYuan } from '../ai/core/cost';
import { buildSystemPrompt, ASSISTANT_PROMPT_VERSION } from './assistant-prompts';
import {
slimInputSnapshot,
briefToolArgs,
truncateOutputText,
assistantCallKey,
assistantInputHash,
type ToolTraceEntry,
} from './assistant-invocation';
import { McpClientService } from './mcp-client.service';
import { AssignmentProposalService } from '../plan/assignment-proposal.service';
// ⭐ 给模型的事实投影(⛔ 不再给成品句子,见该文件顶部注释)
......@@ -177,6 +188,7 @@ export class AssistantService {
private readonly mcp: McpClientService,
private readonly proposals: AssignmentProposalService,
private readonly config: ConfigService<AppConfig, true>,
private readonly recorder: InvocationRecorderService,
) {}
// 显式收窄返回类型为控制器实际消费的最小形状,避开 streamText 返回类型引用 ai 内部
......@@ -913,6 +925,70 @@ export class AssistantService {
this.logger.log(
`assistant chat: model=${resolved.modelId} tools=${Object.keys(tools).join(',')}`,
);
/**
* ⭐ **每一轮往 `agent_invocations` 落一行** —— 落的是「该调的工具调没调」,
* ⛔ 不是聊天记录。在此之前这件事线上一次都没留过痕,只能靠临时跑批。
*
* ⚠️ 落库**绝不能影响这一轮对话** —— 全程 try/catch 吞掉,失败只打日志:
* 主管正等着一版方案,⛔ 不该因为审计写不进去而看不到结果。
* ⚠️ `scope` 缺失(比如某些内部入口)就整条跳过 —— hostId/tenantId 是必填列。
*/
const toolTrace: ToolTraceEntry[] = [];
// 统一给**所有**工具(MCP 拉来的 + 本地那几个)套一层计时留痕。
// ⚠️ 装在这里而不是各自 execute 里:漏一个就等于那个工具永远不出现在验收数据里。
for (const name of Object.keys(tools)) {
// AI SDK 的 ToolSet 是判别联合,execute 的入参类型在这一层无法窄化 ——
// 计时包装对参数**不做任何解释**,只透传,所以用 any 收口,边界只在这两行。
/* eslint-disable @typescript-eslint/no-explicit-any */
const t = tools[name] as { execute?: (...a: any[]) => any } | undefined;
const orig = t?.execute?.bind(t);
if (!t || !orig) continue;
t.execute = async (args: any, opts: any) => {
const t0 = Date.now();
try {
const r = await orig(args, opts);
toolTrace.push({ name, ok: true, ms: Date.now() - t0, args: briefToolArgs(args) });
return r;
} catch (e) {
toolTrace.push({ name, ok: false, ms: Date.now() - t0, args: briefToolArgs(args) });
throw e;
}
};
/* eslint-enable @typescript-eslint/no-explicit-any */
}
const scope = input.scope;
const callKey = assistantCallKey(!!input.permissions?.includes(Permission.PLAN_DISPATCH));
const snapshot = slimInputSnapshot(input.messages, {
activeClinicId: input.activeClinicId,
activePatientId: input.activePatientId,
});
const startedAt = Date.now();
let invocationId: string | undefined;
if (scope) {
try {
invocationId = await this.recorder.start({
hostId: scope.hostId,
tenantId: scope.tenantId,
kind: 'assistant',
callKey,
promptVersion: ASSISTANT_PROMPT_VERSION,
modelProvider: resolved.provider,
modelName: resolved.modelId,
// 助手一轮 = 一个 run。⛔ 不跨轮串:每轮的上下文都不同,串起来没有可比性。
workflowRunId: randomUUID(),
inputHash: assistantInputHash(callKey, ASSISTANT_PROMPT_VERSION, snapshot),
// ⛔ 只放本轮 —— 理由见 assistant-invocation.ts 头注(全量存会按平方涨)
inputSnapshot: snapshot as unknown as Prisma.InputJsonValue,
// ⛔ systemPrompt / promptTemplate 一律不落:前者每次一模一样(靠 promptVersion 回查),
// 后者的内容已经在 inputSnapshot.userText 里了。
});
} catch (e) {
this.logger.warn(`invocation start 失败(不影响对话):${e instanceof Error ? e.message : e}`);
}
}
const { model } = resolved;
return streamText({
model,
......@@ -953,9 +1029,78 @@ export class AssistantService {
// 静默截断会让模型停在「我先查一下」之后,而主管以为它查完了。
stopWhen: stepCountIs(MAX_TOOL_STEPS),
abortSignal: input.abortSignal,
onFinish: (ev) => {
void this.finishInvocation(invocationId, resolved.modelId, startedAt, toolTrace, ev);
},
onError: ({ error }) => {
void this.finishInvocation(invocationId, resolved.modelId, startedAt, toolTrace, {
errorMessage: error instanceof Error ? error.message : String(error),
});
},
});
}
/**
* 一轮结束时回填 `agent_invocations` —— **失败只打日志,⛔ 绝不外抛**。
* 它挂在 streamText 的 onFinish/onError 上,抛出去会污染正在给主管吐字的那条流。
*/
private async finishInvocation(
invocationId: string | undefined,
modelId: string,
startedAt: number,
toolTrace: readonly ToolTraceEntry[],
ev: {
text?: string;
finishReason?: string;
// AI SDK 的 usage 里混着嵌套的 tokenDetails,这里只取几个标量 —— 用宽类型收口
totalUsage?: Record<string, unknown>;
usage?: Record<string, unknown>;
errorMessage?: string;
},
): Promise<void> {
if (!invocationId) return;
try {
const u = ev.totalUsage ?? ev.usage ?? {};
const num = (k: string): number => (typeof u[k] === 'number' ? (u[k] as number) : 0);
const promptTokens = num('inputTokens');
const completionTokens = num('outputTokens');
const cachedInputTokens = num('cachedInputTokens');
const { yuan, priceMissing } = estimateCostYuan(
this.config.get('ai', { infer: true }).priceTable,
modelId,
promptTokens,
completionTokens,
cachedInputTokens,
);
if (priceMissing) {
this.logger.warn(`价目表里没有 ${modelId} —— 本轮助手成本按兜底价估算,数字**不准**。`);
}
await this.recorder.end(invocationId, {
/**
* 🔴 **这一列才是落这行的理由** —— 「该调的工具调没调」是助手这层的验收判据。
* ⛔ 不放工具**返回值**:确认单那类肥载荷正是量级失控的源头。
*/
output: {
toolCalls: toolTrace as unknown as Prisma.InputJsonValue,
toolCallCount: toolTrace.length,
finishReason: ev.finishReason ?? null,
} as Prisma.InputJsonValue,
outputText: truncateOutputText(ev.text),
promptTokens,
completionTokens,
totalTokens: num('totalTokens') || promptTokens + completionTokens,
cachedInputTokens,
reasoningTokens: num('reasoningTokens'),
costYuan: yuan,
latencyMs: Date.now() - startedAt,
status: ev.errorMessage ? 'failed' : 'succeeded',
errorMessage: ev.errorMessage,
});
} catch (e) {
this.logger.warn(`invocation end 失败(不影响对话):${e instanceof Error ? e.message : e}`);
}
}
/** 桌宠环境观察发言 —— 无工具、限长、流式;失败由前端静默降级(宠物只是不说话)。 */
petSay(input: { observation: string; abortSignal?: AbortSignal }): { textStream: AsyncIterable<string> } {
const { model } = this.provider.resolve('deepseek');
......
......@@ -150,7 +150,7 @@ const SYSTEM = `你是 PAC(疗效保障 / 患者分析中心)工作台里的
### 拟分怎么落人
两趟 + 一组:有专属且在岗的回自己人手上(最多到他这轮该拿的那份);无或专属已离岗的给当前手上最少的那个;专属客服这轮已排满的不动,单列成一组交他定。
两趟 + 一组:有专属且在岗的回自己人手上(最多到他这轮该拿的那份);无专属或专属已离岗的给当前手上最少的那个;专属客服这轮已排满的不动,单列成一组交他定。
因此这批可能不满、团队也不齐平 —— 那是刻意的:宁可少分几个,也不动别人的客户。他问「怎么没分够」就说有几个人卡在待分配等他定,不是系统故障。
......
......@@ -184,7 +184,9 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> {
'分完之后每人手上': range(loads),
// ⚠️ 读**这一版真用的**那个数,⛔ 不引常量:主管说过「按 20 通算」就该是 20
'按每天几通算': p.dailyCalls,
'落人规则': '有专属的先回自己人手上;无主的给当前手上最少的那个',
// ⚠️ 措辞用「无专属」,⛔ 别写「无主」—— 界面上的按钮文案就是「无专属客服的患者」,
// 模型会照着这句念给主管听,两处不一致他会以为是两拨人。
'落人规则': '有专属的先回自己人手上;无专属的给当前手上最少的那个',
// ⚠️ 他自己设过的那几位要点名 —— 「为什么某某只有 5 条」的答案只在这里
...(tuned.length > 0 ? { '他单独设过的': tuned } : {}),
...(p.pending.length > 0 ? { '专属客服排满、这批没发的': p.pending.length } : {}),
......
......@@ -378,6 +378,18 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
// 主管把它改成 20 之后,常量算出来的天数和卡片上每一行都对不上。
if (heaviest && heaviest.loadAfter > p.dailyCalls * d) {
const needDays = Math.ceil(heaviest.loadAfter / p.dailyCalls);
/**
* 🔴 **超时效的一共几位**(2026-08-16 产品定)。只报最忙那一位分不出两种相反的局面:
* · 只有他一个人超 → 该做的是**给他少分点 / 改派几个**;
* · 全队都超 → 该做的是**减少这批 / 延长时效**。
* 同一句话、相反的处置 —— 而这条唯一给的选项是「整批时效改成 N 天」,
* 碰上第一种局面它本身就是错的(为一个人的负载去延长整批时效)。
* ⚠️ 只加一个数,⛔ 不在这里铺开每个人:确认单上每位客服那一行已经有「约 N 天」,
* 分布本来就在眼皮底下 ⇒ 这里一句话把他引过去看就够(引导节点的职责是
* **点出要他定的事**,不是展示数据)。
* ⚠️ 只有他一个人超时这半句**不出现** —— ⛔ 不制造无谓的噪音。
*/
const overCount = p.byAgent.filter((a) => a.loadAfter > p.dailyCalls * d).length;
out.push({
key: 'daily_overload',
severity: SEV.WRONG_EXPECTATION,
......@@ -389,7 +401,10 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
// ⚠️ 「按每天 15 通算」这个前提要写出来:它是评审当天的口头经验值,不是实测。
why:
`其中本批 ${heaviest.count} 条、原本在手 ${heaviest.inHandBefore} 条;` +
`按每人每天 ${p.dailyCalls} 通算需要 ${needDays} 天,本批时效定的是 ${d} 天。`,
`按每人每天 ${p.dailyCalls} 通算需要 ${needDays} 天,本批时效定的是 ${d} 天。` +
(overCount > 1
? `另有 ${overCount - 1} 位也超过 ${d} 天;每位分完之后要打几天,确认单上逐位都写着。`
: ''),
defaultLabel: `不处理 = 就按 ${d} 天发,到期没打完的自动落回池子,下批还能再分`,
/**
* 🔴 **只留带数的那一个**(2026-08-15 产品定)。
......@@ -442,12 +457,16 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
// 列 3/5/7 天必然漏掉他要的那个,而漏掉那次他只能去打字 → 走模型理解 → 慢且可能错。
// ⚠️ 15(DAILY_CALLS_PER_AGENT)是**换算尺**,⛔ 不是容量上限、⛔ 不参与落人。
//
// 🔴 **候选够不着 N 时不出**(2026-08-13):`target = min(batchSize, candidateTotal)`,
// 候选 312 / N 495 时**瓶颈是候选不是 N** —— 而这两个按钮预填当前值、往上调:
// 改时效 1→3 天 ⇒ N=1485,改每天几通 15→30 ⇒ N=990,target 都还是 312。
// 点了没有任何反应,而这条的 why 写着「这批人数都会跟着变」⇒ **它在说谎**。
// ⚠️ 判据要用严格 `>`:相等时候选同样是瓶颈(往上调仍然无效)。
if (p.basis === 'default' && p.candidateTotal > p.batchSize) {
// 🔴 **2026-08-16 撤掉「候选够不着 N 就不出」那道闸**(产品指出)。
// 原判据是 `candidateTotal > batchSize`,理由写的是:候选 312 / N 495 时瓶颈是候选,
// 往上调(时效 1→3 天 ⇒ N=1485)`target` 还是 312,点了没反应,而 why 说"人数会跟着变"⇒ 说谎。
// ⚠️ **那个理由只考虑了往上调。** 主管同样可以**往下调**:候选 312 / N 495 时把每天几通
// 从 15 改成 5,N=165 < 312 ⇒ target 真的变成 165。他想少发一些,而这道闸把入口藏了。
// ⇒ 闸撤掉,改成**据实说明**:候选够不着 N 时,why 里直接讲清楚"往上不会更多、往下可以少发"。
// ⛔ 别再用"点了没反应"当不出的理由 —— 该修的是那句话,不是把节点藏起来。
if (p.basis === 'default') {
/// 候选就这么多人,N 已经够不着 —— 往上调无效、往下调有效,措辞必须分开
const capped = p.candidateTotal <= p.batchSize;
out.push({
key: 'batch_size_basis',
severity: SEV.INFO,
......@@ -458,7 +477,10 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
/// 2026-08-15 实测这里和 `modelFacts` 两处都印错过,沿革见 `rosterCount` 的字段注释。
title: `本批 ${p.batchSize} = 在岗 ${p.rosterCount} × 每天 ${p.dailyCalls} × ${d} `,
// ⚠️ 只陈述事实。⛔ 不写「建议改成 X 天」—— 一批推多大是主管的判断。
why: `这是系统按这个式子估的,不是根据历史算的。改时效或改每天几通,这批人数都会跟着变。`,
why: capped
? `这是系统按这个式子估的,不是根据历史算的。符合条件的只有 ${p.candidateTotal} 人,已经全在本批里了 —— ` +
`往上调不会更多,往下调可以少发一些。`
: `这是系统按这个式子估的,不是根据历史算的。改时效或改每天几通,这批人数都会跟着变。`,
defaultLabel: '不处理 = 就按这个数发',
options: [
{
......
......@@ -218,7 +218,7 @@ export class AssignmentController {
@ApiOperation({
summary: '重新排一版:遇到专属排满的就跳过,从池子里往后取,尽量凑满 N',
description:
'⚠️ **不改变"不动别人客户"这条底线** —— 只是换一批**专属没排满 / 无**的人来凑,' +
'⚠️ **不改变"不动别人客户"这条底线** —— 只是换一批**专属没排满 / 无专属**的人来凑,' +
'一条专属关系都不动。代价是这批人整体排名往后走(产品判定:批内名次对主管没有意义,' +
'这批人本来就共享同一组特征)。',
})
......
......@@ -834,10 +834,15 @@ export class PlanAssignmentService {
* 回答文档里那三个问题的第三个:「团队现在什么状态」。
*
* ── 两种时间性混在一张表里,必须说清 ────────────────────────────
* · **在手 / 超期** —— **此刻**的状态(手上还压着多少、其中多少过了时效),与窗口无关;
* · **完成 / 退回 / 已处置 / 没动** —— **窗口内**发生的事(近 7 天 / 近 30 天)。
* · **在手** —— **此刻**手上还压着多少(`status='assigned'`),与窗口无关;
* · **超期 / 完成 / 退回 / 已处置 / 没动** —— **窗口内**的事(近 7 天 / 近 30 天)。
* ⚠️ 界面上必须写明这件事,否则主管会把「在手 62」当成"这 7 天分了 62 条"。
*
* 🔴 **超期也是带窗口的**(2026-08-16 修注释:原文把它和「在手」并列写成"与窗口无关",
* 与下面那条 SQL 对不上 —— SQL 里有 `assignment_expires_at >= since`,是**对的**)。
* 理由:一年前过期的单报上来对主管没有意义,他此刻能处置的只有近期这批。
* ⛔ 别照注释把 SQL 的窗口去掉。
*
* ⚠️ 「超期」必须带上**且没约下次回访**这半句,与 detail 的 agentStats 同一条判据:
* 到期回收器刻意跳过 snoozedUntil 在未来的单(客服约了 6/10 回访,那之前不能收走),
* 不加守卫会把"打了电话、约好下次"的人显示成"压着单没动" —— 干得最好的那个被指责。
......
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import type { AppConfig } from '../config/configuration';
/**
* InvocationRetentionService —— 定期把 `agent_invocations` 里**过期的肥字段**清掉,只留元数据。
*
* ═══ 为什么必须有 ═══════════════════════════════════════════════
* schema 上早就写着「成功调用 N 天后清 inputSnapshot 仅保留元数据」,但这个任务一直不存在。
* 助手接进来之后写入频率上一个量级(每轮对话一行),⚠️ 而测试机盘常年 90%+、
* PG 卷已占 75 G —— **不清就是把一个已知的紧张问题变成事故**。
*
* ═══ 清什么、留什么 ════════════════════════════════════════════
* 清:inputSnapshot(置为 {"pruned":true})· promptTemplate · systemPrompt · outputText
* —— 这四样占了一行 95% 以上的字节。
* 留:token / 成本 / 延迟 / status / callKey / promptVersion / modelName / judge / userFeedback
* —— 仪表盘、成本聚合、通过率统计要的全在这些列上,⛔ 一个都不动。
*
* ⚠️ **失败的留更久**(默认 3 倍):失败行的输入正是排查时唯一能看的东西。
* ⚠️ `inputSnapshot` 是 NOT NULL 列 ⇒ 只能覆盖成占位对象,⛔ 不能置 null。
* ⚠️ ⛔ 不删行:元数据行 ~1 KB,留着才有长期的成本 / 通过率曲线;肥字段清掉后
* 增长量已经可控(按 1800 次/天算约 0.65 GB/年)。
*/
@Injectable()
export class InvocationRetentionService {
private readonly logger = new Logger(InvocationRetentionService.name);
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService<AppConfig, true>,
) {}
/** 每天 04:00(沪)—— 避开 DW 08:00 落库与随后的增量摄入。 */
@Cron(process.env.PAC_INVOCATION_RETENTION_CRON || '0 4 * * *', {
name: 'invocation-retention',
timeZone: 'Asia/Shanghai',
})
async prune(): Promise<void> {
const days = this.config.get('ai', { infer: true }).invocationRetentionDays;
if (days <= 0) {
this.logger.log('保留期配置为 0 —— 跳过清理(AI_INVOCATION_RETENTION_DAYS)');
return;
}
const okCount = await this.pruneOlderThan(days, ['succeeded', 'cached']);
// 失败的留 3 倍时长:排查时唯一能看的就是它的输入
const failCount = await this.pruneOlderThan(days * 3, ['failed']);
this.logger.log(
`invocation 清理完成:成功/缓存 ${okCount} 行(>${days} 天)· 失败 ${failCount} 行(>${days * 3} 天)`,
);
}
/**
* 清一批。⚠️ `updatedAt` 是 `@updatedAt` 列,这次 update 会把它刷新 ——
* 所以判据用 `startedAt`,⛔ 不能用 updatedAt(否则清过的行永远追不上闸门,每天重清一遍)。
*/
private async pruneOlderThan(days: number, statuses: string[]): Promise<number> {
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
const res = await this.prisma.agentInvocation.updateMany({
where: {
startedAt: { lt: cutoff },
status: { in: statuses },
// ⭐ 幂等闸:已经清过的不再进来(否则每天全表扫一遍已清的行)
NOT: { inputSnapshot: { equals: { pruned: true } } },
},
data: {
inputSnapshot: { pruned: true },
promptTemplate: null,
systemPrompt: null,
outputText: null,
},
});
return res.count;
}
}
......@@ -11,6 +11,7 @@ import { QueueProducer } from './queue-producer.service';
import { StaleScanService } from './stale-scan.service';
import { SyncIncrementalSchedulerService } from './sync-incremental.scheduler';
import { DwLagMonitorService } from './dw-lag-monitor.service';
import { InvocationRetentionService } from './invocation-retention.service';
import { DailyHealthReportService } from './daily-health-report.service';
import { DailyHealthReportController } from './daily-health-report.controller';
import { PersonaRecomputeProcessor } from './processors/persona-recompute.processor';
......@@ -71,6 +72,7 @@ import { ColdImportProcessor } from './processors/cold-import.processor';
SyncIncrementalSchedulerService,
DwLagMonitorService,
DailyHealthReportService,
InvocationRetentionService,
PersonaRecomputeProcessor,
PlanRecomputeProcessor,
PlanAssetGenerateProcessor,
......
......@@ -212,11 +212,9 @@ describe('引导节点 · 判定', () => {
});
/**
* 🔴 `short_supply` 与 `batch_size_basis` **互斥**(2026-08-13 加判据之后):
* 前者要 `候选 < N`,后者要 `候选 > N` —— 同一份提案不可能两个都成立。
* ⚠️ 所以"四个同时成立"这件事从此不存在,这条改成分两种局面各断一次。
* ⚠️ 2026-08-14 `short_supply` 整条删掉之后,局面 A 就只剩派活那两条了 ——
* 「这批怎么来的」那一组在候选不够时**本来就没有要主管定的事**。
* ⚠️ 2026-08-16:`batch_size_basis` 的「候选够不着 N 就不出」那道闸**撤了**(产品指出:
* 那个理由只考虑往上调,而主管同样可以往下调、少发一些)。⇒ 两种局面它都出,
* 差别在 `why` 的措辞。下面这条改成断"两种局面都有它"。
*/
test('🔴 ⛔ 不截断 —— 成立几条出几条,防噪音靠 tier 分层', () => {
// 局面 A:候选不够(N 是够的,人不够)
......@@ -229,7 +227,16 @@ describe('引导节点 · 判定', () => {
byAgent: [agent('张悦', 90)],
}),
);
expect(short.map((x) => x.key)).toEqual(['pending', 'daily_overload']);
expect(short.map((x) => x.key)).toEqual(['pending', 'daily_overload', 'batch_size_basis']);
/**
* 🔴 候选够不着 N 时,那句话必须**据实**说清方向 —— 往上调确实不会更多。
* ⛔ 不许再说「改时效或改每天几通,这批人数都会跟着变」:他往上调一次没反应,
* 下次就不信这张卡片上的任何一句话了。
*/
const capped = short.find((x) => x.key === 'batch_size_basis')!;
expect(capped.why).toContain('往上调不会更多');
expect(capped.why).toContain('往下调可以少发');
expect(capped.why).not.toContain('都会跟着变');
// 局面 B:候选管够(瓶颈是 N)—— 这时才轮到「本批多大是怎么估的」
const ample = computeSignals(
......@@ -273,18 +280,33 @@ describe('引导节点 · 判定', () => {
);
});
test('🔴 候选够不着 N → ⛔ 不出「本批多大」(改时效往上是空操作,说会变就是说谎)', () => {
// target = min(batchSize, candidateTotal):候选 312 / N 495 时改 N 往上,target 还是 312
expect(
keys(proposal({ basis: 'default', candidateTotal: 312, target: 312, batchSize: 495 })),
).not.toContain('batch_size_basis');
// 相等时候选同样是瓶颈 —— 判据必须是严格 >
expect(
keys(proposal({ basis: 'default', candidateTotal: 495, target: 495, batchSize: 495 })),
).not.toContain('batch_size_basis');
expect(
keys(proposal({ basis: 'default', candidateTotal: 5000, target: 495, batchSize: 495 })),
).toContain('batch_size_basis');
/**
* 🔴 2026-08-16 **反过来了**:原来这条锁的是「候选够不着 N 就不出」,
* 理由是"改 N 往上是空操作,说会变就是说谎"。产品指出:**那只考虑了往上调** ——
* 候选 312 / N 495 时把每天几通 15→5,N=165 < 312 ⇒ target 真的变成 165。
* 他想少发一些,而那道闸把入口藏了。
* ⇒ 现在**两种局面都出**,差别在 `why` 的措辞:够不着时据实说"往上不会更多、往下可以少发"。
* ⛔ 别再用"点了没反应"当不出的理由 —— 该修的是那句话,不是把节点藏起来。
*/
test('🔴 候选够不着 N 时照样出「本批多大」—— 他可以往下调、少发一些', () => {
for (const c of [
{ candidateTotal: 312, target: 312, batchSize: 495 }, // 候选 < N
{ candidateTotal: 495, target: 495, batchSize: 495 }, // 候选 = N
]) {
const s = computeSignals(proposal({ basis: 'default', ...c })).find(
(x) => x.key === 'batch_size_basis',
);
expect({ 局面: c.candidateTotal, 出了吗: s != null }).toEqual({
局面: c.candidateTotal,
出了吗: true,
});
expect(s!.why).toContain('往下调可以少发');
}
// 候选管够时仍是原来那句(往上往下都真的会变)
const ample = computeSignals(
proposal({ basis: 'default', candidateTotal: 5000, target: 495, batchSize: 495 }),
).find((x) => x.key === 'batch_size_basis')!;
expect(ample.why).toContain('都会跟着变');
});
test('⛔ 模型拿不到 options 的技术细节 —— why 里不许出现 intent id', () => {
......@@ -743,3 +765,39 @@ describe('没分到的那几位 —— 「为什么没轮到」要答得上来',
expect(facts([])).not.toHaveProperty('他们现在手上有');
});
});
/**
* 🔴 「最忙的那位」要报**一共几位超时效**(2026-08-16 产品定)。
* 只报最忙一位,主管分不出"个别人忙"和"全队都忙" —— 而这两种局面的处置相反:
* 前者该给他少分点/改派,后者该减量/延时效。而本节点唯一的选项是「整批时效改成 N 天」,
* 碰上前者它本身就是错的。
*/
describe('引导节点 · 最忙的那位要说清是一个人还是一片', () => {
const over = (name: string, loadAfter: number) => ({
userId: name,
name,
count: 5,
inHandBefore: loadAfter - 5,
loadAfter,
});
test('🔴 多人超时效 → 报「另有 N 位」,并把他引到确认单看逐位明细', () => {
// 时效 1 天 × 每天 15 通 = 15 条封顶;三个人都超
const s = computeSignals(
proposal({
expiresInDays: 1,
byAgent: [over('王强', 45), over('李莉', 30), over('赵敏', 20), over('周涛', 9)],
}),
).find((x) => x.key === 'daily_overload')!;
expect(s.title).toContain('王强'); // 最忙的那位仍然点名
expect(s.why).toContain('另有 2 位也超过 1 天'); // 3 位超 → 除最忙外还有 2 位
expect(s.why).toContain('确认单上逐位都写着');
});
test('⛔ 只有一个人超 → 那半句不出现(不制造无谓噪音)', () => {
const s = computeSignals(
proposal({ expiresInDays: 1, byAgent: [over('王强', 45), over('周涛', 9)] }),
).find((x) => x.key === 'daily_overload')!;
expect(s.why).not.toContain('另有');
});
});
import type { ModelMessage } from 'ai';
import {
slimInputSnapshot,
briefToolArgs,
truncateOutputText,
assistantCallKey,
assistantInputHash,
} from '../src/modules/assistant/assistant-invocation';
import { estimateCostYuan, FALLBACK_PRICE } from '../src/modules/ai/core/cost';
/**
* 助手落 `agent_invocations` 的**纯函数层**。
*
* 这组测试守的是同一件事:**落库量不能随对话轮数涨**。
* 助手的 messages 里带着确认单那类肥载荷,一旦把上下文原样落进去,
* 第 N 轮就要写前 N-1 轮的全部内容 —— 按平方涨(实测口径 ~100 KB/行 vs ~2.5 KB/行)。
*/
/** 造一段带工具往返的多轮对话 —— 工具返回值刻意做得很肥。 */
function conversation(turns: number, fatBytes = 50_000): ModelMessage[] {
const out: ModelMessage[] = [];
for (let i = 1; i <= turns; i++) {
out.push({ role: 'user', content: `第 ${i} 轮:给我出一版` } as ModelMessage);
out.push({
role: 'assistant',
content: [{ type: 'text', text: 'x'.repeat(fatBytes) }],
} as unknown as ModelMessage);
}
return out;
}
describe('slimInputSnapshot —— 只存当轮', () => {
it('只取最后一条用户消息,⛔ 不带历史', () => {
const s = slimInputSnapshot(conversation(3));
expect(s.userText).toBe('第 3 轮:给我出一版');
expect(s.turnNo).toBe(3);
expect(s.priorTurns).toBe(2);
});
it('🔴 快照大小不随轮数增长 —— 这是这层存在的全部理由', () => {
const small = JSON.stringify(slimInputSnapshot(conversation(1))).length;
const large = JSON.stringify(slimInputSnapshot(conversation(20))).length;
// 只有 turnNo/priorTurns 的位数会变,差几个字符而已
expect(large - small).toBeLessThan(10);
// 而原始上下文本身是几十万字节
expect(JSON.stringify(conversation(20)).length).toBeGreaterThan(1_000_000);
});
it('⛔ 不含任何工具返回值 / 助手历史发言', () => {
const raw = JSON.stringify(slimInputSnapshot(conversation(5)));
expect(raw).not.toContain('xxxx');
});
it('用户原话截断到 2000 字', () => {
const s = slimInputSnapshot([
{ role: 'user', content: '甲'.repeat(5000) } as ModelMessage,
]);
expect(s.userText).toHaveLength(2000);
});
it('content 为 parts 数组时也能取到文本', () => {
const s = slimInputSnapshot([
{ role: 'user', content: [{ type: 'text', text: '换成正畸' }] } as unknown as ModelMessage,
]);
expect(s.userText).toBe('换成正畸');
});
it('没有用户消息时不炸', () => {
expect(slimInputSnapshot([])).toMatchObject({ turnNo: 0, userText: '', priorTurns: 0 });
});
it('现场上下文有才带,⛔ 不塞 undefined 键', () => {
const bare = slimInputSnapshot([{ role: 'user', content: 'a' } as ModelMessage]);
expect(Object.keys(bare)).not.toContain('activeClinicId');
const withCtx = slimInputSnapshot([{ role: 'user', content: 'a' } as ModelMessage], {
activeClinicId: 'c1',
activePatientId: 'p1',
});
expect(withCtx).toMatchObject({ activeClinicId: 'c1', activePatientId: 'p1' });
});
});
describe('briefToolArgs —— 参数截断', () => {
it('肥载荷截到 200 字', () => {
expect(briefToolArgs({ patients: 'p'.repeat(9999) })).toHaveLength(200);
});
it('不可序列化的对象不抛', () => {
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
expect(briefToolArgs(cyclic)).toBe('[unserializable]');
});
it('undefined 归一成 {}', () => {
expect(briefToolArgs(undefined)).toBe('{}');
});
});
describe('truncateOutputText', () => {
it('截到 8000', () => {
expect(truncateOutputText('乙'.repeat(20_000))).toHaveLength(8000);
});
it('空值原样返回 undefined', () => {
expect(truncateOutputText(undefined)).toBeUndefined();
expect(truncateOutputText('')).toBeUndefined();
});
});
describe('callKey 按现场分', () => {
it('主管走分配线,客服走打单线', () => {
expect(assistantCallKey(true)).toBe('assistant_assignment');
expect(assistantCallKey(false)).toBe('assistant_execute');
});
});
describe('inputHash', () => {
it('同输入同 hash,改一个字就变', () => {
const a = slimInputSnapshot([{ role: 'user', content: '发 200 人' } as ModelMessage]);
const b = slimInputSnapshot([{ role: 'user', content: '发 300 人' } as ModelMessage]);
const k = 'assistant_assignment';
const v = 'assistant@test';
expect(assistantInputHash(k, v, a)).toBe(assistantInputHash(k, v, a));
expect(assistantInputHash(k, v, a)).not.toBe(assistantInputHash(k, v, b));
});
it('提示词版本变了,hash 必须跟着变', () => {
const s = slimInputSnapshot([{ role: 'user', content: 'x' } as ModelMessage]);
expect(assistantInputHash('k', 'v1', s)).not.toBe(assistantInputHash('k', 'v2', s));
});
});
describe('estimateCostYuan —— 与 AiCallRunner 共用同一份口径', () => {
const table = { m1: { inHit: 1, inMiss: 10, out: 100 } };
it('命中缓存的部分按 inHit 计价', () => {
const { yuan } = estimateCostYuan(table, 'm1', 1_000_000, 0, 1_000_000);
expect(yuan).toBeCloseTo(1, 6);
});
it('未命中的部分按 inMiss 计价', () => {
const { yuan } = estimateCostYuan(table, 'm1', 1_000_000, 0, 0);
expect(yuan).toBeCloseTo(10, 6);
});
it('输出按 out 计价', () => {
const { yuan } = estimateCostYuan(table, 'm1', 0, 1_000_000, 0);
expect(yuan).toBeCloseTo(100, 6);
});
it('🔴 价目表里没有就报 priceMissing —— ⛔ 不许静默低报', () => {
const { priceMissing } = estimateCostYuan(table, '未知模型', 100, 100);
expect(priceMissing).toBe(true);
});
it('cached > prompt 时 clamp,不出负数', () => {
const { yuan } = estimateCostYuan(table, 'm1', 100, 0, 999_999);
expect(yuan).toBeGreaterThanOrEqual(0);
expect(yuan).toBeCloseTo((100 * 1) / 1_000_000, 9);
});
it('表全空时走兜底价', () => {
const { yuan, priceMissing } = estimateCostYuan({}, 'x', 1_000_000, 0, 0);
expect(priceMissing).toBe(true);
expect(yuan).toBeCloseTo(FALLBACK_PRICE.inMiss, 6);
});
});
import { Test } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { InvocationRetentionService } from '../src/queues/invocation-retention.service';
import { PrismaService } from '../src/prisma/prisma.service';
/**
* `agent_invocations` 的保留期清理。
*
* 🔴 这段直接关系到**磁盘**:助手接进来之后按对话轮数写库,
* 而测试机盘常年 90%+、PG 卷已占 75 G。不清就是把一个已知的紧张问题变成事故。
*
* 这里锁住四件事:清哪些状态 · 失败留更久 · 清哪几列 · 幂等闸在不在。
*/
type Where = Record<string, unknown>;
type Call = { where: Where; data: Record<string, unknown> };
function build(retentionDays: number) {
const calls: Call[] = [];
const prisma = {
agentInvocation: {
updateMany: jest.fn(async (args: Call) => {
calls.push(args);
return { count: 1 };
}),
},
};
return { calls, prisma };
}
async function make(retentionDays: number) {
const { calls, prisma } = build(retentionDays);
const mod = await Test.createTestingModule({
providers: [
InvocationRetentionService,
{ provide: PrismaService, useValue: prisma },
{
provide: ConfigService,
useValue: { get: () => ({ invocationRetentionDays: retentionDays }) },
},
],
}).compile();
return { svc: mod.get(InvocationRetentionService), calls, prisma };
}
describe('InvocationRetentionService', () => {
it('分两批清:成功/缓存一批,失败一批', async () => {
const { svc, calls } = await make(30);
await svc.prune();
expect(calls).toHaveLength(2);
expect(calls[0].where.status).toEqual({ in: ['succeeded', 'cached'] });
expect(calls[1].where.status).toEqual({ in: ['failed'] });
});
it('🔴 失败的留 3 倍时长 —— 排查时唯一能看的就是它的输入', async () => {
const { svc, calls } = await make(30);
await svc.prune();
const okCutoff = (calls[0].where.startedAt as { lt: Date }).lt.getTime();
const failCutoff = (calls[1].where.startedAt as { lt: Date }).lt.getTime();
const days = (ms: number) => Math.round((Date.now() - ms) / 86_400_000);
expect(days(okCutoff)).toBe(30);
expect(days(failCutoff)).toBe(90);
});
it('只清肥字段,⛔ 不碰 token / 成本 / 状态这些元数据列', async () => {
const { svc, calls } = await make(30);
await svc.prune();
expect(calls[0].data).toEqual({
inputSnapshot: { pruned: true },
promptTemplate: null,
systemPrompt: null,
outputText: null,
});
for (const k of ['costYuan', 'totalTokens', 'status', 'callKey', 'promptVersion', 'judgeScore', 'userFeedback']) {
expect(Object.keys(calls[0].data)).not.toContain(k);
}
});
it('🔴 带幂等闸 —— 已清过的不再进来,否则每天全表重扫', async () => {
const { svc, calls } = await make(30);
await svc.prune();
expect(calls[0].where.NOT).toEqual({ inputSnapshot: { equals: { pruned: true } } });
});
it('⚠️ 判据用 startedAt,⛔ 不能用 updatedAt(清理本身会刷新它)', async () => {
const { svc, calls } = await make(30);
await svc.prune();
expect(calls[0].where).toHaveProperty('startedAt');
expect(calls[0].where).not.toHaveProperty('updatedAt');
});
it('配 0 = 关掉清理,一次都不写库', async () => {
const { svc, prisma } = await make(0);
await svc.prune();
expect(prisma.agentInvocation.updateMany).not.toHaveBeenCalled();
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment