Commit 3f2c02fd by luoqi

merge: 助手会话 id + 多步文本修复 + 术语「时效」→「时限」全线统一

· 会话 id 落 workflow_run_id —— 同一次对话各轮共用, 不再靠 turnNo 反推
· outputText 拼全每一步 —— OnFinishEvent 只带末步,中间说的话原本会丢
· 时效 → 时限:代码/界面 213 处 + 文档 71 处; 跳过语义不同的(话术语气、临床有效期)与 _archive
parents fe79f0f3 9cd3cd5e
Pipeline #3574 failed in 0 seconds
......@@ -22,10 +22,10 @@ flowchart TD
A1["① 选人 · 主管初选<br/>1 做哪一类还没启动的治疗<br/>2 找多久没来的人<br/>—— 凭运营经验,他张口就来"] --> A2
A2["① 选人 · 助手精选<br/>3 要不要只挑高价值的 —— 先摆这批人的全景<br/>4 团队还吃得下多少 —— 先摆在岗人手与在手量<br/>—— · —— · —— · —— · —— · ——<br/>不等他给全,先按这几条默认出一版:<br/>没联系过的排前面,其余按优先级<br/>每人每天 15 通 · 时效 1 天<br/>这批多大 = 在岗人数 × 15 × 时效"]
A2["① 选人 · 助手精选<br/>3 要不要只挑高价值的 —— 先摆这批人的全景<br/>4 团队还吃得下多少 —— 先摆在岗人手与在手量<br/>—— · —— · —— · —— · —— · ——<br/>不等他给全,先按这几条默认出一版:<br/>没联系过的排前面,其余按优先级<br/>每人每天 15 通 · 时限 1 天<br/>这批多大 = 在岗人数 × 15 × 时限"]
A2 --> D1{"这批人对吗?"}
D1 --> G1["助手摆出的引导 —— 满足条件才出现<br/>「能加个条件」:候选 ≥50 人 · 他还没加过 · 切完还剩 ≥10 人<br/>  消费高于本批平均 / 转介绍达人 / 权益身份 / 获客渠道<br/>「这批多大」:人数是系统估的(他没自己指定过)<br/>  可改 每人每天几通 · 时几天"]
D1 --> G1["助手摆出的引导 —— 满足条件才出现<br/>「能加个条件」:候选 ≥50 人 · 他还没加过 · 切完还剩 ≥10 人<br/>  消费高于本批平均 / 转介绍达人 / 权益身份 / 获客渠道<br/>「这批多大」:人数是系统估的(他没自己指定过)<br/>  可改 每人每天几通 · 时几天"]
G1 --> R1["重新选一版<br/>⚠️ 已经排好的分法一并作废"]
R1 --> A2
......@@ -34,7 +34,7 @@ flowchart TD
B["② 分人 —— 谁去打<br/>先算目标水位 =(团队在手 + 本批人数)÷ 在岗人数<br/>一趟:有专属的回自己人,到水位为止<br/>二趟:无专属的补给当前手上最少的<br/>三趟:专属这轮排满的单列成一组,交他定"]
B --> D2{"这么分行吗?"}
D2 --> G2["助手摆出的引导 —— 满足条件才出现<br/>「专属排满了」:第三趟真有人排不进去<br/>  换无专属的补上(池子还有人时才给)<br/>  铺平给在岗 / 各自归专属 / 移出本批<br/>「最忙的那位」:分完后的量 > 每天通数 × 时效<br/>  报最忙那位 + 另有几位也超<br/>  整批时效改成 N 天(一个算好的数)"]
D2 --> G2["助手摆出的引导 —— 满足条件才出现<br/>「专属排满了」:第三趟真有人排不进去<br/>  换无专属的补上(池子还有人时才给)<br/>  铺平给在岗 / 各自归专属 / 移出本批<br/>「最忙的那位」:分完后的量 > 每天通数 × 时限<br/>  报最忙那位 + 另有几位也超<br/>  整批时限改成 N 天(一个算好的数)"]
G2 --> R2["不重新选人<br/>人不变,只动这一版的分法"]
R2 --> D2
......@@ -65,7 +65,7 @@ flowchart TD
所以顺序不能颠倒:**人先定下来,才谈怎么分。** 先排分法再让他改人群,前面那趟排班就白做了 —— 助手讲这一版时也按这个顺序讲,理由一样。
三档里最容易看走眼的是中间那条:**「换无专属的补上」摆在分人环里,换的却是人。**「改时」也有两条路 —— 从「这批多大」改会连人数一起重估(重出),从「最忙的那位」改只延长期限、人不变(就地改)。
三档里最容易看走眼的是中间那条:**「换无专属的补上」摆在分人环里,换的却是人。**「改时」也有两条路 —— 从「这批多大」改会连人数一起重估(重出),从「最忙的那位」改只延长期限、人不变(就地改)。
<Callout type="warn">**代价不对称,判断也要跟着不对称**:把「改人群」误判成「改分法」→ 主管以为条件生效了、其实没有(**看不出来的错**);反过来只是多算一次。⇒ **拿不准时一律走重出。**</Callout>
......@@ -110,13 +110,13 @@ flowchart TD
| **谁排前面** | 没联系过的排前面,其余按优先级从高到低 | 「先打消费高的」 |
| **每人每天打几通** | 15 通 | 「按 20 通算」 |
| **多久要打完** | 1 天 | 「给 5 天」 |
| **这批发多大** | 在岗人数 × 每天通数 × 时 | 「改成 200 人」 |
| **这批发多大** | 在岗人数 × 每天通数 × 时 | 「改成 200 人」 |
<Callout type="warn">**默认值一个都不许藏。** 出方案时式子摊开写:「本批 405 人 = 在岗 27 人 × 每天 15 通 × 1 天」。
一个他看不见的默认值,等于系统替他做了一个他不知道的决定 —— 而这批人是真发下去了。
⇒ 摆出来他才有得改;不摆,他连「原来还能改这个」都不知道。</Callout>
人手在**两个地方**主动摆给他看,不用他去找:出方案时是上面那个式子;确认单上是每位客服一行「约 3 天」,算的是**分完之后他手上的总量**(在手 + 本批),不只是本批那几条。超出时的用**颜色**提示,⛔ 不写「打不完 / 超了 / 过载」—— 几乎每批都会有人超,说成故障主管就会开始怀疑系统,而不是做他该做的判断。
人手在**两个地方**主动摆给他看,不用他去找:出方案时是上面那个式子;确认单上是每位客服一行「约 3 天」,算的是**分完之后他手上的总量**(在手 + 本批),不只是本批那几条。超出时的用**颜色**提示,⛔ 不写「打不完 / 超了 / 过载」—— 几乎每批都会有人超,说成故障主管就会开始怀疑系统,而不是做他该做的判断。
### 能加哪几个条件
......@@ -299,7 +299,7 @@ flowchart TD
| | 看这批人构成 | 这批人各画像维度各多少人 |
| **出方案** | 出一版方案 | 选人 + 排客服 + 算引导,一次算完 |
| | 看当前确认单 | 那张单**现在**什么样(他动过手之后助手看不见) |
| | 改确认单 | 移出谁 · 改派 · 改时 · 设福利 |
| | 改确认单 | 移出谁 · 改派 · 改时 · 设福利 |
| | 摆确认单 / 摆引导 | 决定它们落在正文哪一句之后(两个工具) |
| **追踪** | 分过哪几批 | 批次列表 + 每批汇总 |
| | 某批怎么样了 | 进度 · 按客服拆 · 退回原因 · 通话成效 · 逐条纪要 |
......@@ -343,11 +343,11 @@ flowchart LR
| **专属排满了** | 「182 人的专属客服这轮已排满」——**第三趟那一组**,四个选择:换无专属的补上(池子还有人时才给)/ 铺平给在岗 / 各自归专属客服 / 移出本批 | 这批不发给他们 |
| **能加个条件** | 「这批候选 2,663 人,也可以只选其中一类」+ 四个带人数的选项(见 §1) | 就按这批候选全部人来 |
| **这批多大** | 「本批 405 人 = 在岗 27 人 × 每天 15 通 × 1 天」,式子摊开给他看 | 就按这个数发 |
| **最忙的那位** | 「这批发下去,最忙的是王强:手上共 45 条,约 3 天的量;**另有 2 位也超过 1 天**」 | 就按这个时发,到期没打完的自动回池 |
| **最忙的那位** | 「这批发下去,最忙的是王强:手上共 45 条,约 3 天的量;**另有 2 位也超过 1 天**」 | 就按这个时发,到期没打完的自动回池 |
⚠️ **为什么要报「另有几位」**:只说最忙的一个,主管分不出两种局面 —— 而这两种局面该做的事**正好相反**:只有王强超,就给他少分点、改派几个;全队都超,就得减少这批、延长时。只有他一个人超时那半句不出现,⛔ 不制造无谓噪音;也⛔ 不在这里铺开每个人 —— 确认单上每位客服那一行已经写着「约 N 天」,引导只负责**点出要他定的事**,不负责展示数据。
⚠️ **为什么要报「另有几位」**:只说最忙的一个,主管分不出两种局面 —— 而这两种局面该做的事**正好相反**:只有王强超,就给他少分点、改派几个;全队都超,就得减少这批、延长时。只有他一个人超时那半句不出现,⛔ 不制造无谓噪音;也⛔ 不在这里铺开每个人 —— 确认单上每位客服那一行已经写着「约 N 天」,引导只负责**点出要他定的事**,不负责展示数据。
⚠️ **「最忙的那位」只给一个选项,而且必须带数**:「整批时改成 **3** 天」—— 那个 3 是按最忙那位的量算出来的。曾经还有「改每人每天打几通」「减少本批人数」两个,删掉了:它们**一个数都不带**,点下去等于替主管说了句「减少一些」—— **一个不带数的按钮,严格弱于他自己开口说一句。**
⚠️ **「最忙的那位」只给一个选项,而且必须带数**:「整批时改成 **3** 天」—— 那个 3 是按最忙那位的量算出来的。曾经还有「改每人每天打几通」「减少本批人数」两个,删掉了:它们**一个数都不带**,点下去等于替主管说了句「减少一些」—— **一个不带数的按钮,严格弱于他自己开口说一句。**
每条按钮都带三样:**是什么**、**为什么**、**不处理等于什么**。三条设计原则:
......
......@@ -136,7 +136,7 @@ flowchart LR
| 基数 | 含义 | 首次 | 之后 |
|---|---|---|---|
| **本批人数** | 这一轮推多少人 | 在岗人数 × 20 | 沿用上次 |
| **时** | 多久没动自动回池 | 3 天 | 沿用上次 |
| **时** | 多久没动自动回池 | 3 天 | 沿用上次 |
> 没有第三个数。曾经有过"每人容量",删掉了 —— 一个数当两个用,第二批必然分不出来。
......@@ -179,12 +179,12 @@ flowchart TB
| 卡片上直接做 | 回对话让助手做 |
|---|---|
| 改批次时效 · 改单条时效 | 换人群(换治疗项 / 时机 / 画像条件) |
| 改批次时限 · 改单条时限 | 换人群(换治疗项 / 时机 / 画像条件) |
| 删掉某一条 | 改本批人数 |
| 把某一条拖给别的客服 | 给某个客服设本批名额 |
| 移除某个客服 | 设本批福利 |
效的文案是「**N 天后自动退回**」而不是光一个"时效" —— 主管要知道到期会发生什么,否则这个数对他没有意义。
限的文案是「**N 天后自动退回**」而不是光一个"时限" —— 主管要知道到期会发生什么,否则这个数对他没有意义。
### 福利挂在批次上,不挂在个人上
......@@ -198,7 +198,7 @@ flowchart TB
- 首次无历史 → 用默认值,**并标明这是默认值**
- 有数据之后 → 反推真实习惯,替换默认值
> 时"3 天"现在只能写「默认值,暂无历史数据」,**不能**写成「依据平均结案 2.4 天」—— 那个数还算不出来。
> 时"3 天"现在只能写「默认值,暂无历史数据」,**不能**写成「依据平均结案 2.4 天」—— 那个数还算不出来。
---
......@@ -225,7 +225,7 @@ stateDiagram-v2
### 退回必须写原因
退回是**正常路径**不是异常。原因分布是主管调整下一批的输入 ——
「派多了」「时太紧」「压根不该派给他」,这三种的下一步动作完全不同。
「派多了」「时太紧」「压根不该派给他」,这三种的下一步动作完全不同。
### 撤销 ≠ 退回
......@@ -248,7 +248,7 @@ stateDiagram-v2
|---|---|
| 分了多少人 / 在手 / 已处理 | 「已处理」= 这单动过了,**不等于谈成了** |
| 客服主动退回 · 原因分布 | 「不该我做」,是**分配**问题 |
| 到期没人动 | **没处置**,是派多了 / 时太紧 / 人不在岗 |
| 到期没人动 | **没处置**,是派多了 / 时太紧 / 人不在岗 |
| 通话结果:成功 / 不成功及原因 | 打了之后的结果,是**召回效果**问题 |
| 一次结果都没有 | 最该先看的数 —— 不是效果差,是**根本没做** |
......
......@@ -11,7 +11,7 @@
* (不过滤会把 auto_release 的 timeout/assignment_expired 和 feedback 的 up/down 混进来)
* · 边界:全退回 / 全到期 / 零转化 / 单人独吞
*
* ⛔⛔ **绝对不许拿它的数去调任何默认值**(容量 50、时 3 天、专属优先策略、批次规模 100)。
* ⛔⛔ **绝对不许拿它的数去调任何默认值**(容量 50、时 3 天、专属优先策略、批次规模 100)。
* T20 的自优化要的是"真实客服在真实压力下怎么反应",而这里的每一个数都是我按
* `--release-rate` 之类的参数**编出来的** —— 拿它反推 = 自己教自己。
* 而且比没有数据更危险:假信号会驱动你真的去改策略,然后错的策略顶着"数据支持"的名义跑起来。
......
......@@ -663,7 +663,7 @@ export class PlanScriptOrchestrator {
// 临时:hardcoded jvs-dw 诊所字典(TODO #56 接 host 字典或新建 clinics 表)
// ⚠️ 直接吐 UUID 进 prompt 会让 LLM 编造"XX 客服中心",必须翻译成中文名
clinicName: resolveClinicName(plan.targetClinicId),
// 福利:只有批次仍 confirmed 时才带。⚠️ 不判 expiresAt —— 时是"客服什么时候该打完",
// 福利:只有批次仍 confirmed 时才带。⚠️ 不判 expiresAt —— 时是"客服什么时候该打完",
// 不是"福利什么时候失效";福利本身的有效期写在文案里(如"8月…"),由主管负责。
benefit:
plan.assignment?.status === 'confirmed'
......
......@@ -28,7 +28,7 @@ import { Permission } from '@pac/types';
* ⛔ 同一件事**只许有一份**。写第二份等于开了两个事实源,两边会各自漂移
* (2026-08-13 实测:名册那句提示词写「是近似值」,工具描述写「只报有/无,不判在岗」,
* 后者才是实现 —— 而改实现的人只会去改工具描述,没人想得起提示词里还有一份)。
* 2026-08-14 又清一处:「不传就按在岗 × 每天几通 × 时估」在提示词和 `targetCount`
* 2026-08-14 又清一处:「不传就按在岗 × 每天几通 × 时估」在提示词和 `targetCount`
* / `dailyCalls` 两个参数说明里各有一份,且提示词那份把 15 硬编码进了字符串。
* ⛔ 结构上已经不可能发生的事不写。死条文不是无害的:规则越多每条被遵守的概率越低
* (实测:给了四条排版规则,`###` 一个都没出),它在挤占真正要紧那几条的注意力。
......@@ -85,7 +85,7 @@ import { Permission } from '@pac/types';
*
* 🔴 **⛔ 别把内部键名写进提示词。** 2026-08-13 实测:提示词里点名了
* 「这一格还能怎么选」「本批人数还能怎么调」,于是判据把这两组挡掉、事实里根本没有它们时,
* 模型仍然照着名字**补了一段出来**(还顺手编了「改时只能减少」这种假话)。
* 模型仍然照着名字**补了一段出来**(还顺手编了「改时只能减少」这种假话)。
*
* 🔴 **⛔ 别把 UI 部件名写进 ①②③。** 「卡片」「按钮」会随前端改,而这三层是两条链路共用的。
* 部件名留在工具描述里 —— 部件没了工具就没了,描述跟着一起消失。
......@@ -114,7 +114,7 @@ import { Permission } from '@pac/types';
* 抄一万遍。审计靠「这一行的 promptVersion + 代码里那个版本的正文」两边对 ——
* ⇒ 版本不 bump,这条链就断了,而断了不会有任何报错。
*/
export const ASSISTANT_PROMPT_VERSION = 'assistant@2026-08-16-a';
export const ASSISTANT_PROMPT_VERSION = 'assistant@2026-08-16-b';
/**
* ① 装置 —— 你是谁、和使用者什么关系、你看不见什么。
......@@ -252,7 +252,7 @@ const STAFF_ROLE = `## 你是客服的助手
* ═══ 为什么这一块按四步排 ═══════════════════════════════════════════
* 🔴 2026-08-15 改成 `选人 → 分人 → 他确认 → 确认之后`(产品定)。
* 在此之前它是**一句流程线 + 一堆平铺的规矩**:确认语义、专属底线、画像分布、
* 不统计成功、福利(一整段挤着四个 ⛔)、落人算法、人数时 —— 各自都对,
* 不统计成功、福利(一整段挤着四个 ⛔)、落人算法、人数时 —— 各自都对,
* 但读者(模型)要自己去认每一条属于哪一步。
* ⚠️ 分节之后每条规矩**站在它生效的那一步里**,位置本身就是它的适用范围 ——
* 福利这件事在③和④各有一条,靠**它在哪一节**就分得清谁提:
......@@ -286,7 +286,7 @@ const ASSIGNMENT_SCENE = `## 他现在做的这件事:把一批人分给客服
要按画像收窄时先取分布再选,不然选完才发现只剩几个人,他白等一轮。
各维度的数是「分别命中多少」,不是交叉后的人数;想知道交叉数,把条件一起传进去再调一次。
人数和时**不问他**,直接出一版;他说了数你才带上。池子里还有人就随时能再分一批。
人数和时**不问他**,直接出一版;他说了数你才带上。池子里还有人就随时能再分一批。
### 分人
......@@ -306,7 +306,7 @@ const ASSIGNMENT_SCENE = `## 他现在做的这件事:把一批人分给客服
### 确认之后
这批的人员和时改不了,要改只能**撤销重分**。你对已经分下去的这批能做的只有两件:撤销、补挂福利 —— 「已撤销」只能出现在撤销工具真的返回之后。
这批的人员和时改不了,要改只能**撤销重分**。你对已经分下去的这批能做的只有两件:撤销、补挂福利 —— 「已撤销」只能出现在撤销工具真的返回之后。
福利挂在整批上,会进这批人的话术,补挂只影响此后生成的那些。他说什么就原样写进去,⛔ 别替他加条件、期限或承诺。⛔ 「这批还没带福利」**不用你再提一遍** —— 确认之前你已经问过一次了;他回头问起,或者要补,照办就是。
......
......@@ -18,10 +18,22 @@ interface UploadedAudio {
mimetype: string;
}
/** 只认标准 uuid —— conversationId 直接进 `workflow_run_id`(uuid 列),⛔ 别让脏值打到数据库。 */
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
interface ChatBody {
messages: ModelMessage[];
model?: string;
/**
* 这一串对话的 id —— 前端在**开始一段新对话时**现生成,此后每轮原样带上。
*
* 🔴 它是「哪几轮属于同一次对话」的**唯一可靠依据**。此前只能靠 `turnNo` 归 1 反推,
* 而那是启发式:两位主管并发聊天时行是交错的,前端一旦裁剪历史 turnNo 也会错位。
* ⚠️ 前端可改的入参 —— 只用于**归组**,⛔ 不参与任何鉴权或取数。
* 非 uuid 一律丢弃(服务端另生成),⛔ 不让它成为写库的注入面。
*/
conversationId?: string;
/**
* 主管当前正看着哪家诊所 —— 作 `propose_assignment` 的诊所兜底。
*
* 🔴 移交那句话里**没有诊所**(「帮我给「拔牙 · 3 年以上」这批患者出一份分配方案」),
......@@ -33,7 +45,7 @@ interface ChatBody {
/**
* 眼前那张**还没确认**的确认单,此刻的样子 —— 模型用 `get_current_sheet` 取。
*
* 🔴 为什么由前端捎来:确认之前,改派 / 移出 / 时 / 福利**只存在于卡片组件里**,
* 🔴 为什么由前端捎来:确认之前,改派 / 移出 / 时 / 福利**只存在于卡片组件里**,
* 服务端手上只有最初那一版提案(见 `SheetSnapshotSchema` 上那段)。
* ⚠️ 与 `activeClinicId` 同一条纪律:前端可改的入参 ——
* 它只用来**告诉模型现在什么样**,⛔ 一个字都不许拿去写库。
......@@ -199,6 +211,7 @@ export class AssistantController {
// ⚠️ 控制器只**供料**,⛔ 不在这里拼提示词:企微那条路也走同一个装配。
// ⭐ 主管当前看的诊所 —— propose_assignment 的诊所兜底(见 DTO 上那段)
...(body.activeClinicId ? { activeClinicId: body.activeClinicId } : {}),
...(UUID_RE.test(body.conversationId ?? '') ? { conversationId: body.conversationId } : {}),
// ⚠️ 解不出来就当没有 —— ⛔ 别把半份快照喂给模型(缺的字段会被读成 0)
...(typeof body.activePatientId === 'string' && body.activePatientId
? { activePatientId: body.activePatientId }
......
......@@ -18,6 +18,7 @@ import {
slimInputSnapshot,
briefToolArgs,
truncateOutputText,
joinStepTexts,
assistantCallKey,
assistantInputHash,
type ToolTraceEntry,
......@@ -104,6 +105,11 @@ export interface AssistantChatInput {
*/
activePatientId?: string;
/** 当前登录人(本地工具要用 scope 取数);缺省则本地写工具不注册 */
/**
* 这一串对话的 id —— 落进 `agent_invocations.workflow_run_id`,
* 同一次对话的每一轮共用一个值。⛔ 缺失时服务端现生成(那一轮自成一组)。
*/
conversationId?: string;
scope?: TenantScopeContext;
abortSignal?: AbortSignal;
}
......@@ -258,7 +264,7 @@ export class AssistantService {
* 「返回的是结构化事实…组织成人话」→ 系统提示词那一节整段在管。
* 「绝不要说已经分配好了」→ 提示词权责段 + 返回值键名 `已经分下去了`,这是第三份。
* 「专属客服排满的不会自动改派」→ 返回值里 `专属客服排满、这批没发的` 自己说清了(判据②)。
* 「人数与时不问主管」→ 提示词流程线里的「直出,不追问」,复述。
* 「人数与时不问主管」→ 提示词流程线里的「直出,不追问」,复述。
*
* ⚠️ 末尾那段**讲述顺序**放在这里而不是提示词里:耦合有方向 ——
* 生产者知道自己产出什么、该被怎么读;show_* 是呈现器,⛔ 它们不该知道内容结构。
......@@ -308,7 +314,7 @@ export class AssistantService {
description:
'从召回池按条件选出一批人,算出一版分配方案:谁分给谁、每人几条、谁卡着没发。' +
'\n什么时候调:他说出要哪一格的人(治疗项 + 时间档)就调。' +
'他要换条件重来(只选其中一类、改人数、改时)也是**重调这个工具出新的一版**,' +
'他要换条件重来(只选其中一类、改人数、改时)也是**重调这个工具出新的一版**,' +
'⛔ 不是在上一版已经排好的人里再挑。' +
// 🔴 2026-08-14:这两句里原来各有一个「基数」——**它就列在提示词的禁说清单里**,
// 而这里是直接递到模型眼前的词。实测原话:「比估算的**基数**(120)少得多」
......@@ -393,7 +399,7 @@ export class AssistantService {
// ⚠️ 点名那个估法,别写「下面那个公式」:模型看到的是 `properties` 里
// 各自独立的一段描述,谁在"下面"没有任何保证 —— 指过去指空了不报错。
'本批发多少人,只在他说了具体数量时传。' +
'\n传了就直接按这个数,「在岗人数 × 每天几通 × 时」那个估法整个不参与,' +
'\n传了就直接按这个数,「在岗人数 × 每天几通 × 时」那个估法整个不参与,' +
'dailyCalls 一起传也不起作用。' +
'\n不传就走那个估法。每一批都重新估,他上一次说的数不会带到这一批。',
},
......@@ -404,7 +410,7 @@ export class AssistantService {
minimum: 1,
maximum: 60,
description:
'每位客服每天打几通,只用来估这一批发多少人(在岗人数 × 它 × 时)。' +
'每位客服每天打几通,只用来估这一批发多少人(在岗人数 × 它 × 时)。' +
'他调这个估法时传。' +
'\n乘法由工具做,报给他的人数以返回值为准。' +
'\n它不限制任何人这批能接多少条,也不参与谁分给谁;传了 targetCount 时它不起作用。',
......@@ -430,7 +436,7 @@ export class AssistantService {
// ⚠️ **允许 0**(= 这轮不给他),⛔ 别写成 exclusiveMinimum:
// 服务端 sanitizeOverrides 特意用的是 `>= 0` 而不是 posInt。
maxThisBatch: { type: 'number', minimum: 0 },
// 与批次时同一个上限;超了会被 sanitizeOverrides **静默丢掉**这一条
// 与批次时同一个上限;超了会被 sanitizeOverrides **静默丢掉**这一条
expiresInDays: { type: 'number', minimum: 1, maximum: 90 },
},
},
......@@ -640,7 +646,7 @@ export class AssistantService {
'待分配 = 有人卡着没发出去、等他定去向;' +
'换个条件选 = 这一格可以只选其中一类人,重出一版;' +
'本批人数 = 这一批发多少人,可以调;' +
'打不完 = 按当前时效算这批打不完,可以调时效或每天几通。' +
'打不完 = 按当前时限算这批打不完,可以调时限或每天几通。' +
'\n⚠️ 只能开放**这一版真的有**的那几类;没有的会被退回,并告诉你这一版有哪几类。',
},
},
......@@ -677,7 +683,7 @@ export class AssistantService {
*
* ⚠️ 指令用**姓名**表达,⛔ 不用 planId:那些 id 根本没进过模型上下文(故意的)。
* 匹配由界面用自己手里那份确认单完成,结果它会回一句话进对话。
* ⚠️ 能做的只有卡片上能做的那几件(删/改派/改时)。
* ⚠️ 能做的只有卡片上能做的那几件(删/改派/改时)。
* 换人群、改批次人数要重跑算法 → 只能重新调 propose_assignment 出一版。
*/
/**
......@@ -717,13 +723,13 @@ export class AssistantService {
* 🔴 **查眼前那张确认单现在什么样** —— 2026-08-15 加。
*
* ═══ 为什么非要有它 ═══════════════════════════════════════════
* 确认之前那张单是**草稿**:主管拖一个人、点一个 ×、改一次时
* 确认之前那张单是**草稿**:主管拖一个人、点一个 ×、改一次时
* 数就变了,而这些改动**只存在于浏览器的卡片组件里**。
* 模型此前唯一的信息来源是对话里那几行「确认单已更新:…」,于是:
* ① 它得**自己把几行累加**才能得出当前值(实测原话
* 「系统排的 313 人…加上刚还回专属客服的 182 人…一共 495 人」——
* 这次算对了,但那是心算,错了不报错);
* ② 手动拖动 / 点 × / 改时这几条路**当时连回声都没有**,
* ② 手动拖动 / 点 × / 改时这几条路**当时连回声都没有**,
* 它连累加的材料都没有 —— 卡片上写着 3 天,它嘴上还说 1 天。
* ⇒ 现在两条一起补:回声带上当前值(推),这个工具随时能取(拉)。
*
......@@ -735,8 +741,8 @@ export class AssistantService {
tools.get_current_sheet = tool({
description:
'看他眼前那张确认单**现在**什么样:已排好几条、落在几位客服头上、' +
'还有几个待他定、时几天、带没带福利。' +
'\n什么时候调:他在卡片上动过手(拖人、移出、改时、改福利)之后,' +
'还有几个待他定、时几天、带没带福利。' +
'\n什么时候调:他在卡片上动过手(拖人、移出、改时、改福利)之后,' +
'你要报数、要判断"还差什么"、或者他问"现在什么情况",先调它。' +
'\n⚠️ 卡片上的改动**你看不见** —— ⛔ 别拿出方案那一版的数去算现在的数。' +
'\n⛔ 它不改任何东西,只是看一眼。',
......@@ -753,7 +759,7 @@ export class AssistantService {
tools.edit_assignment_sheet = tool({
description:
'改他眼前那张确认单:移出谁、改派给谁、改时、设本批福利。' +
'改他眼前那张确认单:移出谁、改派给谁、改时、设本批福利。' +
'\n什么时候调:他对这一版提出具体改动就调。这几件事界面上他自己也能做,' +
'而他开口说了就是要你替他做完 —— 让他先确认再逐条退回,等于把几十次拖拽推回给他。' +
'\n一条指令由三件事拼起来:**选谁**(select)· **干什么**(action)· **给谁**(to,只有改派要)。' +
......@@ -768,7 +774,7 @@ export class AssistantService {
'他读到那句时结果早就在了,顺序是倒的。' +
'\n⚠️ 批次**已经确认分配**之后:只有 `set_benefit` 还能改(福利只影响此后生成的话术,' +
'界面会真的改并回报"作废了几条话术缓存 / 几条客服已经打开过");' +
'人员和时**改不了** —— 单子已经在客服手上,那要走撤销重分。' +
'人员和时**改不了** —— 单子已经在客服手上,那要走撤销重分。' +
'⛔ 这两种情况都以界面回的那句为准,别自己判断成没成。',
inputSchema: jsonSchema({
type: 'object',
......@@ -815,7 +821,7 @@ export class AssistantService {
type: 'string',
enum: ['assign', 'remove', 'set_expiry', 'set_benefit'],
description:
'assign=改派,配 to;remove=移出本批;set_expiry=改时,配 days;' +
'assign=改派,配 to;remove=移出本批;set_expiry=改时,配 days;' +
'set_benefit=设本批福利,配 text,且 select 必须是整批。',
},
to: {
......@@ -845,13 +851,13 @@ export class AssistantService {
},
required: ['mode'],
},
// 与批次时同一个上限(落库那份 schema 是 max(90))——
// 与批次时同一个上限(落库那份 schema 是 max(90))——
// ⛔ 只写在描述里的话,91 会一路走到确认才被打回来
days: {
type: 'number',
minimum: 1,
maximum: 90,
description: '新的时天数,action=set_expiry 时必填。',
description: '新的时天数,action=set_expiry 时必填。',
},
text: {
type: 'string',
......@@ -976,8 +982,12 @@ export class AssistantService {
promptVersion: ASSISTANT_PROMPT_VERSION,
modelProvider: resolved.provider,
modelName: resolved.modelId,
// 助手一轮 = 一个 run。⛔ 不跨轮串:每轮的上下文都不同,串起来没有可比性。
workflowRunId: randomUUID(),
/**
* 🔴 **同一次对话的每一轮共用一个 run id** —— 这是「哪几轮是一批」的唯一可靠依据。
* ⚠️ 曾经每轮各生成一个,只能靠 `turnNo` 归 1 反推会话边界 —— 那是启发式:
* 并发对话行是交错的,前端裁剪历史时 turnNo 还会错位。
*/
workflowRunId: input.conversationId ?? randomUUID(),
inputHash: assistantInputHash(callKey, ASSISTANT_PROMPT_VERSION, snapshot),
// ⛔ 只放本轮 —— 理由见 assistant-invocation.ts 头注(全量存会按平方涨)
inputSnapshot: snapshot as unknown as Prisma.InputJsonValue,
......@@ -1051,6 +1061,14 @@ export class AssistantService {
toolTrace: readonly ToolTraceEntry[],
ev: {
text?: string;
/**
* 🔴 **多步时必须拼所有步的文本** —— `OnFinishEvent` 继承的是**最后一步**的
* `StepResult`,`ev.text` 只有末步那段。模型在工具之间穿插说的话
* (「我先查一下」「这批人比预想的少」)全在前面的步里,只取 `ev.text` 就丢了。
* ⚠️ 实测那轮 5 次工具调用、6924 输出 token,落库只有 60 字 —— 那次凑巧没丢,
* 因为它把话都留到了最后一步。⛔ 别指望它每次都这样。
*/
steps?: ReadonlyArray<{ text?: string }>;
finishReason?: string;
// AI SDK 的 usage 里混着嵌套的 tokenDetails,这里只取几个标量 —— 用宽类型收口
totalUsage?: Record<string, unknown>;
......@@ -1085,7 +1103,7 @@ export class AssistantService {
toolCallCount: toolTrace.length,
finishReason: ev.finishReason ?? null,
} as Prisma.InputJsonValue,
outputText: truncateOutputText(ev.text),
outputText: truncateOutputText(joinStepTexts(ev.steps, ev.text)),
promptTokens,
completionTokens,
totalTokens: num('totalTokens') || promptTokens + completionTokens,
......
......@@ -156,7 +156,7 @@ const SYSTEM = `你是 PAC(疗效保障 / 患者分析中心)工作台里的
不是每人加一样多,是每条都给当前手上最少的那个。没有「容量上限」这回事,负载就是在手量本身。
人数和时效不问他:不传就按「在岗人数 × 每天 ${DAILY_CALLS_PER_AGENT} 通 × 时效」估,并与符合条件的总人数取小。他说了数才传。池子里还有人就随时能再分一批。`;
人数和时限不问他:不传就按「在岗人数 × 每天 ${DAILY_CALLS_PER_AGENT} 通 × 时限」估,并与符合条件的总人数取小。他说了数才传。池子里还有人就随时能再分一批。`;
// 🔴 `relabel`(编号→标识)与 `regroup`(要主管定的→怎么派的)已删(2026-08-14):
// 两者都搬进了产品的 `modelFacts`,裸台再做一遍就是**空转** —— 而空转的代码
......@@ -393,12 +393,12 @@ export class AssistantLabController {
* 「返回的是结构化事实…组织成人话」→ 系统提示词的「怎么把工具给的事实讲出来」已经管了
* 「绝不要说已经分配好了」 → 系统提示词的权责段 + 返回值键名 `已经分下去了` 各一份,这是第三份
* 「专属客服排满的不会自动改派」 → 返回值里 `专属客服排满、这批没发的` 自己就说清了(判据②)
* ⚠️ 「不问人数时、直出不追问」也删了 —— 系统提示词的流程线里写着,这里是复述。
* ⚠️ 「不问人数时、直出不追问」也删了 —— 系统提示词的流程线里写着,这里是复述。
*/
description:
'从召回池按条件选出一批人,算出一版分配方案:谁分给谁、每人几条、谁卡着没发。' +
'\n什么时候调:他说出要哪一格的人(治疗项 + 时间档)就调。' +
'他要换条件重来(只选其中一类、改人数、改时)也是**重调这个工具出新的一版**,' +
'他要换条件重来(只选其中一类、改人数、改时)也是**重调这个工具出新的一版**,' +
'⛔ 不是在上一版已经排好的人里再挑。' +
'\n⚠️ 本批人数取**小的那个**:估出来的基数 vs 符合条件的总人数。' +
'⛔ 所以这一格人不够时,把人数往上调没有任何作用 —— 卡住的是候选,不是基数。' +
......@@ -470,13 +470,13 @@ export class AssistantLabController {
dailyCalls: {
type: 'number',
description:
'「每人每天打几通」,1–60。**只用来估本批人数**(在岗人数 × 它 × 时)。' +
'「每人每天打几通」,1–60。**只用来估本批人数**(在岗人数 × 它 × 时)。' +
'\n⛔ 它不是容量上限,⛔ 也不参与"谁分给谁"。⚠️ 传了 targetCount 时它不起作用。',
},
expiresInDays: {
type: 'number',
description:
'批次时天数。他明确说了天数(「给 5 天」)时才传。' +
'批次时天数。他明确说了天数(「给 5 天」)时才传。' +
'\n⚠️ 它**身兼两职**:① 单据多久到期自动退回池子;' +
'② 没传 targetCount 时,它还参与估本批人数。改它两件事一起变。',
},
......@@ -507,7 +507,7 @@ export class AssistantLabController {
: sheet.candidateTotal,
已排好: sheet.placed,
待分配: sheet.pending.length,
天数: sheet.expiresInDays,
天数: sheet.expiresInDays,
},
});
const facts = modelFacts(sheet);
......@@ -581,7 +581,7 @@ export class AssistantLabController {
'待分配 = 有人卡着没发出去、等他定去向;' +
'换个条件选 = 这一格可以只选其中一类人,重出一版;' +
'本批人数 = 这一批发多少人,可以调;' +
'打不完 = 按当前时效算这批打不完,可以调时效或每天几通。' +
'打不完 = 按当前时限算这批打不完,可以调时限或每天几通。' +
'\n⚠️ 只能开放**这一版真的有**的那几类;没有的会被退回,并告诉你这一版有哪几类。',
},
},
......
......@@ -31,7 +31,7 @@ import type { TenantScopeContext } from '../../common/decorators/tenant-scope.de
* 主场 3,799 条在另一家,这儿 3 条)。医生/护士/前台偶尔被记成一次回访负责人,
* 就进了名册。
* ⚠️ 危害不止那张表不好看:`rosterCount` 直接进默认批次估算
* (在岗人数 × 每天几通 × 时),193 会让默认批次比真实规模**大一个数量级**。
* (在岗人数 × 每天几通 × 时),193 会让默认批次比真实规模**大一个数量级**。
*
* ⇒ 判据 = **在本诊所的回访量占他个人总量 ≥20%,或在本诊所 ≥20 条**。
* ⚠️ 两条是**或**,缺一不可:
......
......@@ -8,18 +8,18 @@ import { recordPlanEventsBulk, computeHeldSeconds } from './plan-event.recorder'
* AssignmentExpiryScheduler —— 分配单到期,系统收回池子。
*
* ── 为什么这件事必须做,而且默认开 ────────────────────────────
* 时是分配单的一部分(T11:不存在无限期批次),但**光写一个到期时刻不会让任何事发生**。
* 时是分配单的一部分(T11:不存在无限期批次),但**光写一个到期时刻不会让任何事发生**。
* 不收回的后果不是"多了几条过期单",是**容量口径整体失效**:
* 客服的「在手」只增不减,几批之后全员触顶,再分就分不下去 ——
* 而这在主管看来就是分配功能坏了。
*
* 产品定调(2026-08):到期自动回池**也是给主管减负** —— 让他不必去追"这单还要不要"。
* 主管在确认单上确认时的那一下,就是对到期行为的预授权,不违 T8
* 主管在确认单上确认时的那一下,就是对到期行为的预授权,不违 T8
* (T8 要防的是"助手替主管做决定",不是"主管定好的规则到点执行")。
*
* ── 与既有 RecycleSchedulerService 的关系:两条互不干扰的路 ────
* · RecycleScheduler 看 `recycle_at`,是**认领**的 24h 兜底,生产**未启用**
* · 本服务 看 `assignment_expires_at`,是**分配**的时,默认启用
* · 本服务 看 `assignment_expires_at`,是**分配**的时,默认启用
* 刻意不合并:两者的语义、开关、口径都不同,合了之后想单独关一边就得加分支,
* 而那个分支迟早写错。
*/
......
......@@ -81,7 +81,7 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> {
const tuned = Object.entries(p.agentOverrides ?? {}).map(([userId, o]) => ({
'客服': nameOf.get(userId) ?? userId.slice(0, 8),
...(o.maxThisBatch != null ? { '这批最多给他': o.maxThisBatch } : {}),
...(o.expiresInDays != null ? { '他这批的时天数': o.expiresInDays } : {}),
...(o.expiresInDays != null ? { '他这批的时天数': o.expiresInDays } : {}),
}));
return {
......@@ -146,8 +146,8 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> {
* 「这几条要讲在第 1 段」:结构能表达的事就别写成规则(规则越多越不被遵守)。
*
* 🔴 **两组必须分开给。** 合成一个键时模型会拿一个标题罩住两组按钮 ——
* 实测走查抓到「还能怎么选:」下面挂着「改时 / 改每天几通」,
* 而改时根本不是选人。⇒ 一个键只说一件事。
* 实测走查抓到「还能怎么选:」下面挂着「改时 / 改每天几通」,
* 而改时根本不是选人。⇒ 一个键只说一件事。
* ⚠️ 顺序 `人群 人数`,⛔ 不许反:换人群会让本批人数重新估一遍
* (实测候选 2509→312 后,本批 495 被压成 312),先调人数那下就白调了。
*/
......@@ -179,7 +179,7 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> {
...(p.skippedAgents.length > 0
? { '他们现在手上有': range(p.skippedAgents.map((a) => a.inHand)) }
: {}),
'时天数': p.expiresInDays,
'时天数': p.expiresInDays,
'本批每人拿到': range(perBatch),
'分完之后每人手上': range(loads),
// ⚠️ 读**这一版真用的**那个数,⛔ 不引常量:主管说过「按 20 通算」就该是 20
......@@ -247,8 +247,8 @@ export function sheetSnapshotFacts(s: SheetSnapshot): Record<string, unknown> {
待你定的: s.pending,
他移出本批的: s.dropped,
被改派过的: s.moved,
整批时天数: s.expiresInDays,
单独改过时的条数: s.rowExpiryOverrides,
整批时天数: s.expiresInDays,
单独改过时的条数: s.rowExpiryOverrides,
本批福利: s.benefit,
每位客服现在几条: s.byAgent.map((a) => ({ 客服: a.name, 条数: a.count })),
};
......
......@@ -133,13 +133,13 @@ export class AssignmentProposalService {
personaTags?: string;
/** 只要累计净消费高于这个数(元)的人 —— 「还能再筛一刀」里那条消费选项 */
minSpendYuan?: number;
/** 批次时天数(基数②);不传 = 沿用上一次分配的值 */
/** 批次时天数(基数②);不传 = 沿用上一次分配的值 */
expiresInDays?: number;
/**
* 「每人每天打几通」的换算尺 —— 只用来估 N(在岗 × 它 × 时)。
* 「每人每天打几通」的换算尺 —— 只用来估 N(在岗 × 它 × 时)。
*
* ⭐ 2026-08-13 加:引导节点上主管填一个数(比如 8),要重出一版。
* ⛔ **不让模型自己乘** —— 它手里有"在岗 33 人""时 1 天",
* ⛔ **不让模型自己乘** —— 它手里有"在岗 33 人""时 1 天",
* 但报出去的每一个数都必须来自工具计算(T14)。这里由服务端算。
* ⚠️ 它**不是容量上限**、⛔ 不参与落人,和 DAILY_CALLS_PER_AGENT 同性质。
*/
......@@ -153,7 +153,7 @@ export class AssignmentProposalService {
agentOverrides?: Record<string, { maxThisBatch?: number; expiresInDays?: number }>;
/**
* 本批人数 N(基数①);不传 = 沿用上一次分配的值。
* ⭐ 与时同为基数 —— 主管说「这批 200 人」会被记住,下次默认就是 200。
* ⭐ 与时同为基数 —— 主管说「这批 200 人」会被记住,下次默认就是 200。
*/
targetCount?: number;
/** 探索配额占比(0-0.2)。⭐ 唯一的因果抓手,见 selectionMode 注释 */
......@@ -200,7 +200,7 @@ export class AssignmentProposalService {
]);
const agents = roster.agents;
// ── 两个基数:本批人数 N + 时 ──────────────────────────
// ── 两个基数:本批人数 N + 时 ──────────────────────────
// 优先级:本次明确指定 > 沿用上一次 > 首次默认。
// ⭐ `basis` 要跟着值一起下发 —— 主管看到「本批 100 人」时,"这 100 哪来的"和这个数一样重要。
//
......@@ -209,7 +209,7 @@ export class AssignmentProposalService {
// 主管想连着圈两批人分,只能把容量往上棚,而容量会被记住 → 下周的"习惯"是个虚高的数。
// 现在:N 决定推多少,水位法决定给谁,负载由 `inHand` 本身表达,不设阈值。
/**
* 时 D —— 本次指定 > 默认。⚠️ 必须**先算它**,因为 N 要用到(见下)。
* 时 D —— 本次指定 > 默认。⚠️ 必须**先算它**,因为 N 要用到(见下)。
* 🔴 2026-08-12 起**不再沿用上一次**(产品定)。
*/
const expiresInDays =
......@@ -222,7 +222,7 @@ export class AssignmentProposalService {
* 🔴 2026-08-12 改判(产品定):
* ① **不再沿用上一次**。原设计是"调一次省一次",但它让 N 悄悄漂 ——
* 某次为了一个小格子调成 44,此后所有批次就永远是 44,主管看不出为什么变小了。
* ② **N 与时挂钩**:一批推多少,取决于这批要在几天内打完。
* ② **N 与时挂钩**:一批推多少,取决于这批要在几天内打完。
* D 从 3 天改成 5 天,能承接的量本来就该跟着变。
* ⚠️ 这里的 15(`DAILY_CALLS_PER_AGENT`)**仍然不是容量上限**,⛔ 不参与落人 ——
* 落人只有水位法,没有阈值("容量"被删过一次,理由见 BATCH_SIZE_PER_AGENT_FIRST)。
......@@ -463,9 +463,9 @@ export class AssignmentProposalService {
*
* 🔴 2026-08-13 整块删掉 —— 同一批数现在有**三个**出口,卡片那份是第三遍:
* ① 模型的正文(modelFacts 的「本批每人拿到 / 分完之后每人手上」);
* ② 引导节点 `batch_size_basis`(这批多大 = 在岗 × 每天几通 × 时,可改);
* ② 引导节点 `batch_size_basis`(这批多大 = 在岗 × 每天几通 × 时,可改);
* ③ 引导节点 `daily_overload`(最忙那位手上共几条、约几天的量)。
* ⛔ 别再加回来:卡片上那个位置现在放**只有卡片能做的事**(拖、点 ×、逐条改时),
* ⛔ 别再加回来:卡片上那个位置现在放**只有卡片能做的事**(拖、点 ×、逐条改时),
* 那些是模型说不了、也做不成引导节点的。
*/
......@@ -517,7 +517,7 @@ export class AssignmentProposalService {
spread: list.filter((x) => x.assignStrategy !== AssignStrategy.DEDICATED).length,
/// ⭐ 分配后他手上有多少 —— 「负载」的唯一表达(没有阈值线,主管自己看这列齐不齐)
loadAfter: (agentById.get(userId)?.inHand ?? 0) + list.length,
/// 逐人下发生效时 —— 前端直接显示,⛔ 不让它自己再合并一次(合并逻辑写两遍必然漂)
/// 逐人下发生效时 —— 前端直接显示,⛔ 不让它自己再合并一次(合并逻辑写两遍必然漂)
expiresInDays: expOf(userId),
overridden: agentOverrides[userId] != null,
})),
......@@ -807,10 +807,10 @@ export class AssignmentProposalService {
}
/**
* 两个基数的**沿用** —— 取该主管在该诊所上一次分配用的容量与时
* 两个基数的**沿用** —— 取该主管在该诊所上一次分配用的容量与时
*
* ⭐ 这是「先出全景确认单、主管反馈调整」这个设计的兑现点:
* 他第一次把容量/时调顺手,系统记住,**第二次起零输入**。
* 他第一次把容量/时调顺手,系统记住,**第二次起零输入**。
* ⚠️ 先找**他自己**的上一次,再退到**本诊所**任何人的上一次 ——
* 容量是团队吞吐的判断,但不同主管的手感不同;先私后公,两级都没有才用默认。
* ⚠️ 值存在 `criteria` 这个 Json 快照里(**零新列**);读不到就当没有,⛔ 不抛错 ——
......@@ -1289,7 +1289,7 @@ function sanitizeOverrides(
typeof o.maxThisBatch === 'number' && Number.isInteger(o.maxThisBatch) && o.maxThisBatch >= 0
? o.maxThisBatch
: null;
// 时有上限 90 天:那不是业务判断,是 `expiresInDays` 契约本来就写死的(schema max(90))
// 时有上限 90 天:那不是业务判断,是 `expiresInDays` 契约本来就写死的(schema max(90))
const days = posInt(o.expiresInDays);
const expiresInDays = days != null && days <= 90 ? days : null;
if (maxThisBatch == null && expiresInDays == null) continue;
......
......@@ -37,7 +37,7 @@ export const ASSIGNMENT_INTENTS = {
PENDING_REFILL: 'pending.refill',
/// 待分配的整组移出本批
PENDING_REMOVE: 'pending.remove',
/// 改批次时
/// 改批次时
EXPIRY_SET: 'expiry.set',
/**
* 🔴 `DAILY_RATE_SET`(daily_rate.set)与 `BATCH_SIZE_SET`(batch_size.set)
......@@ -51,11 +51,11 @@ export const ASSIGNMENT_INTENTS = {
* ⇒ 一个 intent 只该有一种带数的走法。
*/
/**
* 改「本批多大」那个式子里的一项(`args.days` 时 / `args.rate` 每天几通)——
* **要重跑算法**:N = 在岗 × 每天几通 × 时,改哪一项人数都跟着变。
* 改「本批多大」那个式子里的一项(`args.days` 时 / `args.rate` 每天几通)——
* **要重跑算法**:N = 在岗 × 每天几通 × 时,改哪一项人数都跟着变。
*
* 🔴 ⛔ 别拿 `EXPIRY_SET` 去凑:那个只把已经算好的这批人的**到期日**改掉,
* 人数一个不变 —— 而这条节点的原话是「改时或改每天几通,这批人数都会跟着变」。
* 人数一个不变 —— 而这条节点的原话是「改时或改每天几通,这批人数都会跟着变」。
* 用错了节点就在说谎(2026-08-13 实测:卡片显示 3 天,节点仍写着 ×1 天,人数没动)。
*/
BASIS_SET: 'basis.set',
......@@ -362,9 +362,9 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
// ── ② 工作量预估 —— 这批要打几天 ──────────────────────────────────
//
// ⭐ 2026-08-12 产品定:这**不是异常告警,是每批都该讲清楚的一件事**。
// N 按 `在岗 × 每天 15 通 × 时` 算,只算新增、⛔ 不扣在手 ——
// 所以只要谁手上还有东西,需要的天数就会超过时。**那是事实,不是 bug**:
// 主管要么延长时、要么调低每日通数(N 跟着变小)、要么直接减这批人数。
// N 按 `在岗 × 每天 15 通 × 时` 算,只算新增、⛔ 不扣在手 ——
// 所以只要谁手上还有东西,需要的天数就会超过时。**那是事实,不是 bug**:
// 主管要么延长时、要么调低每日通数(N 跟着变小)、要么直接减这批人数。
// ⛔ 所以措辞里不许出现「打不完 / 超了 / 过载」这类像报错的词 ——
// 它常亮,说成故障主管就会开始怀疑系统而不是做决定。
//
......@@ -379,11 +379,11 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
if (heaviest && heaviest.loadAfter > p.dailyCalls * d) {
const needDays = Math.ceil(heaviest.loadAfter / p.dailyCalls);
/**
* 🔴 **超时的一共几位**(2026-08-16 产品定)。只报最忙那一位分不出两种相反的局面:
* 🔴 **超时的一共几位**(2026-08-16 产品定)。只报最忙那一位分不出两种相反的局面:
* · 只有他一个人超 → 该做的是**给他少分点 / 改派几个**;
* · 全队都超 → 该做的是**减少这批 / 延长时**。
* 同一句话、相反的处置 —— 而这条唯一给的选项是「整批时改成 N 天」,
* 碰上第一种局面它本身就是错的(为一个人的负载去延长整批时)。
* · 全队都超 → 该做的是**减少这批 / 延长时**。
* 同一句话、相反的处置 —— 而这条唯一给的选项是「整批时改成 N 天」,
* 碰上第一种局面它本身就是错的(为一个人的负载去延长整批时)。
* ⚠️ 只加一个数,⛔ 不在这里铺开每个人:确认单上每位客服那一行已经有「约 N 天」,
* 分布本来就在眼皮底下 ⇒ 这里一句话把他引过去看就够(引导节点的职责是
* **点出要他定的事**,不是展示数据)。
......@@ -401,7 +401,7 @@ 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} 天;每位分完之后要打几天,确认单上逐位都写着。`
: ''),
......@@ -410,7 +410,7 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
* 🔴 **只留带数的那一个**(2026-08-15 产品定)。
*
* 删掉的是「改每人每天打几通」和「减少本批人数」—— 它们**一个数都不带**:
* 既没算出目标值(不像 `改成 ${needDays} `),也没有输入框
* 既没算出目标值(不像 `改成 ${needDays} `),也没有输入框
* (不像 `batch_size_basis` 那两条,各自预填着当前值)。点下去发出的那句话
* 于是也是空的:「这批人数**减少一些**,重新排一版」「每人每天按**更少的通数**算」——
* 那个数只能由模型自己拍,而它没有依据可拍。
......@@ -420,7 +420,7 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
*/
options: [
{
label: `整批时改成 ${needDays} `,
label: `整批时改成 ${needDays} `,
intent: ASSIGNMENT_INTENTS.EXPIRY_SET,
args: { days: needDays },
},
......@@ -459,7 +459,7 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
//
// 🔴 **2026-08-16 撤掉「候选够不着 N 就不出」那道闸**(产品指出)。
// 原判据是 `candidateTotal > batchSize`,理由写的是:候选 312 / N 495 时瓶颈是候选,
// 往上调(时 1→3 天 ⇒ N=1485)`target` 还是 312,点了没反应,而 why 说"人数会跟着变"⇒ 说谎。
// 往上调(时 1→3 天 ⇒ N=1485)`target` 还是 312,点了没反应,而 why 说"人数会跟着变"⇒ 说谎。
// ⚠️ **那个理由只考虑了往上调。** 主管同样可以**往下调**:候选 312 / N 495 时把每天几通
// 从 15 改成 5,N=165 < 312 ⇒ target 真的变成 165。他想少发一些,而这道闸把入口藏了。
// ⇒ 闸撤掉,改成**据实说明**:候选够不着 N 时,why 里直接讲清楚"往上不会更多、往下可以少发"。
......@@ -480,11 +480,11 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
why: capped
? `这是系统按这个式子估的,不是根据历史算的。符合条件的只有 ${p.candidateTotal} 人,已经全在本批里了 —— ` +
`往上调不会更多,往下调可以少发一些。`
: `这是系统按这个式子估的,不是根据历史算的。改时或改每天几通,这批人数都会跟着变。`,
: `这是系统按这个式子估的,不是根据历史算的。改时或改每天几通,这批人数都会跟着变。`,
defaultLabel: '不处理 = 就按这个数发',
options: [
{
label: '改时',
label: '改时',
intent: ASSIGNMENT_INTENTS.BASIS_SET,
input: { argKey: 'days', unit: '天', min: 1, max: 90, default: d },
},
......
......@@ -1285,7 +1285,7 @@ export class PlanAssignmentService {
(bucket.resolved ? `,其中 ${bucket.resolved} 位患者的召回需求**已经不在池子里了**(引擎判定无活信号)` : '') +
(bucket.suppressed ? `,${bucket.suppressed} 条客服写了回访结果(约下次/拒绝/放弃)` : '') +
`;还在客服手上未处理 ${bucket.inHandPending} 条,落回池子 ${bucket.backToPool} 条` +
// 退回与到期必须分开说 —— 主管的下一步动作相反(改分配策略 vs 派多了/时太紧)
// 退回与到期必须分开说 —— 主管的下一步动作相反(改分配策略 vs 派多了/时太紧)
(counts.released || counts.expired
? `(其中客服主动退回 ${counts.released} 条、到期没人动 ${counts.expired} 条)`
: '') +
......
......@@ -54,7 +54,7 @@ import { planLabelAnchorsSql, temperatureBucketCaseSql } from './reason-temperat
/// assignment 后多久未结案自动回收。
/// ⚠️ 别再基于「这个值应该可配」去做 env 化/配置化 —— 自动回收生产**未启用**
/// (PAC_PLAN_AUTO_RECYCLE 未配),这个常量当前没有任何读者会真正生效;
/// 而分配的时走的是另一条路(plan_assignments.expires_at / assignment_expires_at),
/// 而分配的时走的是另一条路(plan_assignments.expires_at / assignment_expires_at),
/// 与它互不干扰。原注释写的「后续接 tenant 配置」会误导人 —— PAC 全仓没有 Tenant 模型。
const RECYCLE_TIMEOUT_HOURS = 24;
......
......@@ -132,8 +132,8 @@ describe('确认单草稿态 —— 前端产、请求带、工具读', () => {
describe('改单回声 —— 每一条改动路径都要发', () => {
/**
* 🔴 2026-08-15 之前**只有一条**路径发回声,另外几条全静默:
* 拖动改派、点 ×(明细行 / 待分配组)、右上角整批时效、每行单独时效
* 连引导按钮里的「改时」都不发 —— 而那颗按钮正是模型自己开出来的。
* 拖动改派、点 ×(明细行 / 待分配组)、右上角整批时限、每行单独时限
* 连引导按钮里的「改时」都不发 —— 而那颗按钮正是模型自己开出来的。
* ⇒ 这条测的是**纪律在源码上可数**:改状态的地方后面必须紧跟一个 echo。
* ⚠️ 窗口给 12 行:批量改单那条会在 `setX(...)` 与 `notes.push(...)` 之间夹一段注释,
* 6 行会把它误判成静默。⛔ 别再放宽 —— 真静默的点周围**几十行内**一个都没有,
......@@ -212,7 +212,7 @@ describe('模型选择 —— 谁压过谁', () => {
*
* 拆开看:19 人做了 95.8% 的回访,98 人只有 1 条;**160 人的主场在别家诊所**,
* 他们在这儿总共 330 条(产品指认的康慧捧:主场 3,799 条在另一家,这儿 3 条)。
* 而 `rosterCount` 直接进默认批次估算(在岗人数 × 每天几通 × 时)——
* 而 `rosterCount` 直接进默认批次估算(在岗人数 × 每天几通 × 时)——
* 名册虚高一个数量级,默认批次就跟着虚高一个数量级。
*
* ⚠️ 这里锁的是**判据在不在**,⛔ 不锁阈值数字(20% / 20 条是产品可调的)。
......
......@@ -14,7 +14,7 @@ import { PrismaService } from '../src/prisma/prisma.service';
*
* 另一半:到期与退回**必须分开数**。主管的下一步动作是相反的 ——
* 退回多 → 分配策略不对(派给了不该派的人);
* 到期多 → 派多了 / 时太紧 / 人不在岗。
* 到期多 → 派多了 / 时太紧 / 人不在岗。
* 合成一个"回池率"两种病都看不出来。
*/
......
......@@ -508,12 +508,12 @@ describe('selectionNote —— 候选不够时的措辞', () => {
});
/**
* 两个基数(本批人数 + 时)的**沿用**,以及"连着分第二批"。
* 两个基数(本批人数 + 时)的**沿用**,以及"连着分第二批"。
*
* 🔴 这是「先出确认单、主管反馈调整」这个设计的兑现点:第一次调顺手,第二次起零输入。
* 沿用断了不会报错 —— 只是主管每次都要重调一遍,然后觉得"这系统不记事"。
*/
describe('基数沿用 —— 本批人数与时', () => {
describe('基数沿用 —— 本批人数与时', () => {
const SCOPE = { hostId: 'h', tenantId: 't', sourceUnits: [], clinicIds: ['c1'], userId: 'u' };
const mk = (candidateCount: number, agentCount: number, opts: Record<string, unknown> = {}) => {
const prisma = {
......@@ -567,7 +567,7 @@ describe('基数沿用 —— 本批人数与时效', () => {
});
/**
* 🔴 2026-08-12 产品改判:**不再沿用上一次**,每次都按 `在岗 × 每天 15 通 × 时` 算。
* 🔴 2026-08-12 产品改判:**不再沿用上一次**,每次都按 `在岗 × 每天 15 通 × 时` 算。
*
* 原设计是"调一次省一次",但它让 N 悄悄漂 —— 某次为了一个小格子调成 44,
* 此后所有批次就永远是 44,而主管完全看不出为什么变小了。
......@@ -603,8 +603,8 @@ describe('基数沿用 —— 本批人数与时效', () => {
}
});
/** 🔴 N 跟时挂钩:D 从 3 改到 5,能承接的量本来就该跟着变。 */
test('🔴 时给 5 天 → N 跟着变成 9 × 15 × 5', async () => {
/** 🔴 N 跟时挂钩:D 从 3 改到 5,能承接的量本来就该跟着变。 */
test('🔴 时给 5 天 → N 跟着变成 9 × 15 × 5', async () => {
const r = await mk(2000, 9).propose(SCOPE, { clinicId: 'c1', expiresInDays: 5 });
expect(r.expiresInDays).toBe(5);
expect(r.batchSize).toBe(9 * 15 * 5);
......@@ -621,7 +621,7 @@ describe('基数沿用 —— 本批人数与时效', () => {
/**
* 🔴 2026-08-14 **改判**:原断言是 `basisNote` 里要有「您设的 135 没变」。两处都错了 ——
* ① basisNote 没有消费方(已删);
* ② 135 是系统按「在岗 × 每天几通 × 时」算的,**主管一个数都没设过**,
* ② 135 是系统按「在岗 × 每天几通 × 时」算的,**主管一个数都没设过**,
* 叫它"您设的"会让他去找自己什么时候设过 135。
* ⇒ `basis='default'` 时这个数**根本不进模型上下文**(见 assignment-facts 那段注释),
* 对应的断言在 `assignment-signals.spec` 的「首次默认的基数不给模型」。
......@@ -668,7 +668,7 @@ describe('基数沿用 —— 本批人数与时效', () => {
expect(r.placed).toBe(100);
});
test('⭐ 时精调只落在那个人身上,不动整体基数', async () => {
test('⭐ 时精调只落在那个人身上,不动整体基数', async () => {
const r = await mk(1000, 9, {
lastCriteria: { expiresInDays: 3, agentOverrides: { a3: { expiresInDays: 7 } } },
}).propose(SCOPE, { clinicId: 'c1', targetCount: 100 });
......@@ -693,7 +693,7 @@ describe('基数沿用 —— 本批人数与时效', () => {
* 2026-08-04 产品评审要的是"结论性的东西"(本批拿到几条 / 分完手上几条 / 要打几天)。
* 那个诉求没变,只是**出口换了**——同一批数现在有三处,卡片那份是第三遍:
* ① 模型正文(modelFacts 的「本批每人拿到 / 分完之后每人手上」);
* ② 引导节点 `batch_size_basis`(这批多大 = 在岗 × 每天几通 × 时,可改);
* ② 引导节点 `batch_size_basis`(这批多大 = 在岗 × 每天几通 × 时,可改);
* ③ 引导节点 `daily_overload`(最忙那位手上共几条、约几天的量)。
* ⇒ 下面只留 basisNote 那半条不变式(增量与总量**仍然不许混**)。
*/
......
......@@ -104,8 +104,8 @@ describe('引导节点 · 判定', () => {
* 同样是空的(「减少一些」)。⇒ 按钮不带数 ⇒ 严格弱于他自己说一句。
* ⚠️ 这条断言用 `toEqual` 锁**整个数组**,就是为了拦住"顺手再加一个"。
*/
test('🔴 手上的量超出时 → 出 daily_overload,只给带数的那一个动作', () => {
// 每天 15 通 × 3 天 = 45 条是时内打得完的量;60 条要 4 天
test('🔴 手上的量超出时 → 出 daily_overload,只给带数的那一个动作', () => {
// 每天 15 通 × 3 天 = 45 条是时内打得完的量;60 条要 4 天
const p = proposal({ expiresInDays: 3, byAgent: [agent('李莉', 30), agent('张悦', 60)] });
const s = computeSignals(p).find((x) => x.key === 'daily_overload')!;
expect(s).toBeDefined();
......@@ -165,7 +165,7 @@ describe('引导节点 · 判定', () => {
/**
* 🔴 两项都必须走 `basis.set`(**重跑算法**),⛔ 不许是 `expiry.set`:
* 那个只改已经算好这批人的到期日,人数一个不变 —— 而本节点的 why 写着
* 「改时或改每天几通,这批人数都会跟着变」。用错了节点就在说谎。
* 「改时或改每天几通,这批人数都会跟着变」。用错了节点就在说谎。
*/
expect(s.options.map((o) => o.intent)).toEqual(['basis.set', 'basis.set']);
const byKey = Object.fromEntries(s.options.map((o) => [o.input!.argKey, o.input!]));
......@@ -178,7 +178,7 @@ describe('引导节点 · 判定', () => {
}
});
test('⛔ 主管自己设过基数/时 → 不出这条(他知道那两个数哪来的)', () => {
test('⛔ 主管自己设过基数/时 → 不出这条(他知道那两个数哪来的)', () => {
expect(keys(proposal({ basis: 'explicit' }))).not.toContain('batch_size_basis');
});
......@@ -230,7 +230,7 @@ describe('引导节点 · 判定', () => {
expect(short.map((x) => x.key)).toEqual(['pending', 'daily_overload', 'batch_size_basis']);
/**
* 🔴 候选够不着 N 时,那句话必须**据实**说清方向 —— 往上调确实不会更多。
* ⛔ 不许再说「改时或改每天几通,这批人数都会跟着变」:他往上调一次没反应,
* ⛔ 不许再说「改时或改每天几通,这批人数都会跟着变」:他往上调一次没反应,
* 下次就不信这张卡片上的任何一句话了。
*/
const capped = short.find((x) => x.key === 'batch_size_basis')!;
......@@ -258,7 +258,7 @@ describe('引导节点 · 判定', () => {
* 🔴 三条判据都要认出「这一版是主管自己收窄来的」(2026-08-13 走查)。
* 在此之前它们只看这一版的绝对状态,于是主管按消费切完之后:
* · short_supply 把他自己造成的"人少"报成故障,还递上两个会**丢掉他刚加的条件**的按钮;
* · batch_size_basis 在候选是瓶颈时仍然说「改时人数会跟着变」—— 那句话是假的;
* · batch_size_basis 在候选是瓶颈时仍然说「改时人数会跟着变」—— 那句话是假的;
* · narrow 又递上同一把刀(阈值还是他那一刀造出来的),形成自我收窄的螺旋。
*/
/**
......@@ -519,7 +519,7 @@ describe('给模型的事实', () => {
* 🔴 **键名本身就会被念出去** —— 2026-08-14 实测原话:
* 「这一格总共就 3 人,比估算的**基数**(120)少得多」。
* 当时这个键叫 `您设的基数`:① 「基数」就列在提示词的禁说清单里,
* ② 那 120 是系统按「在岗 × 每天几通 × 时」算的,主管一个数都没设过。
* ② 那 120 是系统按「在岗 × 每天几通 × 时」算的,主管一个数都没设过。
* ⇒ 禁令拦不住紧挨着数字的**键名**;键名必须自己说人话。
*/
test('🔴 事实里不许出现「基数」「您设的」这类词(键名会被原样念给主管)', () => {
......@@ -529,7 +529,7 @@ describe('给模型的事实', () => {
});
/**
* 🔴 首次默认那个数是**机器算的**(在岗 × 每天几通 × 时),主管没设过 ——
* 🔴 首次默认那个数是**机器算的**(在岗 × 每天几通 × 时),主管没设过 ——
* 给了他就要听一句「估的基数是 45,但这格只有 5 人」(2026-08-14 实测原话)。
* ⇒ 没有"设置被改小"这回事可防时,这个数就不进模型上下文。
*/
......@@ -684,7 +684,7 @@ describe('给模型的事实', () => {
expect(f.怎么选的.本批人数还能怎么调?.map((x) => x['是什么'])).toEqual(titles('size'));
// 🔴 派活那一组 2026-08-14 从顶层搬进「怎么派的」—— 就近,跟另外两组一致
expect(f.怎么派的.要你定的?.map((x) => x['是什么'])).toEqual(titles('dispatch'));
// 🔴 改人数那条**⛔ 不许**混进选人那一组(走查抓到过「还能怎么选:」下面挂着改时
// 🔴 改人数那条**⛔ 不许**混进选人那一组(走查抓到过「还能怎么选:」下面挂着改时
expect(f.怎么选的.这一格还能怎么选 ?? []).not.toContainEqual(
expect.objectContaining({ 是什么: expect.stringContaining('每天') }),
);
......@@ -767,9 +767,9 @@ describe('没分到的那几位 —— 「为什么没轮到」要答得上来',
});
/**
* 🔴 「最忙的那位」要报**一共几位超时**(2026-08-16 产品定)。
* 🔴 「最忙的那位」要报**一共几位超时**(2026-08-16 产品定)。
* 只报最忙一位,主管分不出"个别人忙"和"全队都忙" —— 而这两种局面的处置相反:
* 前者该给他少分点/改派,后者该减量/延时效。而本节点唯一的选项是「整批时效改成 N 天」,
* 前者该给他少分点/改派,后者该减量/延时限。而本节点唯一的选项是「整批时限改成 N 天」,
* 碰上前者它本身就是错的。
*/
describe('引导节点 · 最忙的那位要说清是一个人还是一片', () => {
......@@ -781,8 +781,8 @@ describe('引导节点 · 最忙的那位要说清是一个人还是一片', ()
loadAfter,
});
test('🔴 多人超时 → 报「另有 N 位」,并把他引到确认单看逐位明细', () => {
// 时 1 天 × 每天 15 通 = 15 条封顶;三个人都超
test('🔴 多人超时 → 报「另有 N 位」,并把他引到确认单看逐位明细', () => {
// 时 1 天 × 每天 15 通 = 15 条封顶;三个人都超
const s = computeSignals(
proposal({
expiresInDays: 1,
......
......@@ -3,6 +3,7 @@ import {
slimInputSnapshot,
briefToolArgs,
truncateOutputText,
joinStepTexts,
assistantCallKey,
assistantInputHash,
} from '../src/modules/assistant/assistant-invocation';
......@@ -106,6 +107,30 @@ describe('truncateOutputText', () => {
});
});
describe('joinStepTexts —— 多步时把每一步的话都留下', () => {
it('🔴 拼所有步,⛔ 不只取最后一步', () => {
const steps = [{ text: '我先查一下' }, { text: '' }, { text: '这批人比预想的少' }];
expect(joinStepTexts(steps, '这批人比预想的少')).toBe('我先查一下\n这批人比预想的少');
});
it('末步文本不在 steps 里时补上', () => {
expect(joinStepTexts([{ text: '第一步' }], '收尾这句')).toBe('第一步\n收尾这句');
});
it('⛔ 不把末步拼两遍', () => {
expect(joinStepTexts([{ text: 'a' }, { text: 'b' }], 'b')).toBe('a\nb');
});
it('没有 steps 时退回 ev.text(单步调用)', () => {
expect(joinStepTexts(undefined, '就一句')).toBe('就一句');
expect(joinStepTexts([], '就一句')).toBe('就一句');
});
it('全空返回 undefined', () => {
expect(joinStepTexts([{ text: '' }, {}], undefined)).toBeUndefined();
});
});
describe('callKey 按现场分', () => {
it('主管走分配线,客服走打单线', () => {
expect(assistantCallKey(true)).toBe('assistant_assignment');
......
......@@ -234,7 +234,7 @@ export const GOLDEN_CASES: GoldenCase[] = [
/**
* 🔴 主管在卡片上做的改动**模型看不见** —— 确认之前那份状态只在浏览器里。
* 在 `get_current_sheet` 之前,它只能把对话里几行回声自己累加,
* 而手动拖动 / 点 × / 改时这几条路当时连回声都没有:卡片上写着 3 天,它嘴上还说 1 天。
* 而手动拖动 / 点 × / 改时这几条路当时连回声都没有:卡片上写着 3 天,它嘴上还说 1 天。
* ⛔ `mustNotCall: propose_assignment` —— 问"现在什么情况"是**看**,不是重出一版。
*/
why: '2026-08-15 实测:主管问「那 1 位为什么没轮到」,模型手里只有一个光秃秃的数字,答不上来。',
......
......@@ -447,10 +447,10 @@ describe('确认后没配福利 → 提醒补挂,但不许编效果', () => {
* 锁整句等于把"这条规矩必须写成一句话"也锁了进去。
* ⇒ 锁的是**这一步在、且三条不变量都在**。
*/
test('提示词里要说清"确认后仍可补挂、但人员时改不了"', () => {
test('提示词里要说清"确认后仍可补挂、但人员时改不了"', () => {
expect(PROMPTS).toMatch(/### 确认之后/);
expect(PROMPTS).toMatch(/补挂/);
expect(PROMPTS).toMatch(/人员和时改不了/);
expect(PROMPTS).toMatch(/人员和时改不了/);
expect(PROMPTS).toMatch(/撤销重分/);
});
});
......@@ -630,8 +630,8 @@ describe('待分配的人 —— 对话指令要能指到', () => {
expect(SHEET).toMatch(/pendingRest\(\)\.filter\(hit\)/);
});
test('⭐ 给待分配的人改时要说明"分给客服后才生效"(否则主管以为已进批次)', () => {
expect(SHEET).toMatch(/分给客服后这个时才生效/);
test('⭐ 给待分配的人改时要说明"分给客服后才生效"(否则主管以为已进批次)', () => {
expect(SHEET).toMatch(/分给客服后这个时才生效/);
});
test('⭐⭐ findAgent 也要能指到「本批未分到」的客服 —— 他们恰恰手上最空', () => {
......@@ -944,9 +944,9 @@ describe('确认单指令 —— 三个正交的轴', () => {
],
['待分配的先都不管', { select: { group: 'pending' }, action: 'remove' }],
// ⭐ 下面三条在旧设计里**一条都表达不了** —— 正交化白捡的
['待分配的时给 5 天', { select: { group: 'pending' }, action: 'set_expiry', days: 5 }],
['待分配的时给 5 天', { select: { group: 'pending' }, action: 'set_expiry', days: 5 }],
[
'张悦名下的时给 5 天',
'张悦名下的时给 5 天',
{ select: { group: 'agent', agent: '张悦' }, action: 'set_expiry', days: 5 }
],
['王强的单给 5 天', { select: { group: 'patients', patients: ['王强'] }, action: 'set_expiry', days: 5 }],
......@@ -1067,7 +1067,7 @@ describe('重新排一版 —— 只换挑谁,不动落人', () => {
*
* 2026-08-04 产品评审要的"结论性的东西"(这活多大)诉求没变,只是出口换了 ——
* 同一批数现在有三处,卡片那份是第三遍:模型正文 + `batch_size_basis` + `daily_overload`。
* ⇒ 卡片那个位置改放**只有卡片能做的事**(拖、点 ×、逐条改时)。
* ⇒ 卡片那个位置改放**只有卡片能做的事**(拖、点 ×、逐条改时)。
* ⚠️ 这条测试锁的是**不许加回来**。
*/
test('⛔ opsNote 已删,卡片那个位置放的是"这张单能怎么改"', () => {
......@@ -1230,7 +1230,7 @@ describe('工具描述不许与实现漂移', () => {
join(__dirname, '../src/modules/assistant/assistant.service.ts'),
'utf8',
);
// 服务端夹的三处:dailyCalls 1–60 / exploreRatio 0–0.2 / 时 ≤90(与落库 schema 对齐)
// 服务端夹的三处:dailyCalls 1–60 / exploreRatio 0–0.2 / 时 ≤90(与落库 schema 对齐)
expect(SRC).toMatch(/minimum: 1,\s*\n\s*maximum: 60/);
expect(SRC).toMatch(/minimum: 0,\s*\n\s*maximum: 0\.2/);
expect(SRC).toMatch(/maximum: 90/);
......
......@@ -73,7 +73,7 @@ describe('ReleaseReason —— 退回原因', () => {
test('⭐ 与召回反馈值域不重叠 —— 同名不同义是统计事故的标准配方', () => {
// RECALL_FEEDBACK_OPTIONS.bad_timing = "召回时机不对(太早/太晚)",冲算法去的;
// 退回的"时太紧"冲派单去的。故本枚举刻意不设 bad_timing。
// 退回的"时太紧"冲派单去的。故本枚举刻意不设 bad_timing。
const feedbackValues = RECALL_FEEDBACK_OPTIONS.map((o) => o.value as string);
for (const k of Object.values(ReleaseReason)) {
if (k === ReleaseReason.OTHER) continue; // other 是通用兜底键,允许同名
......
......@@ -53,7 +53,7 @@ import { cn, formatGender } from '@/lib/utils';
*
* ── 卡片上能做什么(2026-08-03 产品定)──────────────────────────
* 四件**局部**操作,都不需要重新圈人、也不需要重跑落人算法:
* ① 逐条改时(1-7 天) ② 删掉某一条
* ① 逐条改时(1-7 天) ② 删掉某一条
* ③ 把某一条拖给别的客服 ④ 移除某个客服(连同他名下的条目)
* ⛔ 换人群、改批次人数、按条件重新收敛 —— 一律回对话让助手重出一版。
* 判据很简单:**要不要重跑服务端算法**。要,就回对话;不要,才放到卡片上。
......@@ -70,7 +70,7 @@ import { cn, formatGender } from '@/lib/utils';
interface EffectiveItem extends ProposedItem {
/** 生效的经办人(拖拽后可能不是提案里的那个) */
assignee: string;
/** 生效时(天) */
/** 生效时(天) */
days: number;
/** 是否被主管拖到了别人名下 —— 落库时策略要改成 manual */
moved: boolean;
......@@ -79,19 +79,19 @@ interface EffectiveItem extends ProposedItem {
/**
* 精调表落库形态 = 提案带来的名额上限部分(原样带走)。
*
* ⚠️ 时**不再进这里** —— 它已经细到"每一条召回计划"一级,再往 agentOverrides
* 里塞一个"按客服的时"就是两套口径打架(同一个人两处不同的值,听谁的?)。
* ⚠️ 时**不再进这里** —— 它已经细到"每一条召回计划"一级,再往 agentOverrides
* 里塞一个"按客服的时"就是两套口径打架(同一个人两处不同的值,听谁的?)。
* ⚠️ 名额上限必须原样带走:少带一次,主管上次设的「李莉这批最多 5 条」
* 就在这一次确认里被悄悄清掉了,而界面上完全看不出来。
*/
/**
* 助手编辑动作的中文名 —— 只用在**拒绝**的那句话里(「已经确认了,『改时』改不了」)。
* 助手编辑动作的中文名 —— 只用在**拒绝**的那句话里(「已经确认了,『改时』改不了」)。
* ⚠️ 拒绝必须说清拒的是**哪一件事**:一句笼统的"改不了"会让模型下一轮换个说法再试一遍。
*/
const OP_ZH: Record<SheetEditOp['action'], string> = {
assign: '改派',
remove: '移出本批',
set_expiry: '改时',
set_expiry: '改时',
set_benefit: '改福利',
};
......@@ -161,18 +161,18 @@ function carryOverrides(fromSheet: Record<string, AgentOverride>): Record<string
}
/**
* 时选择 —— 1~7 天。用 shadcn `Select`(Radix),不用原生 `<select>`:
* 时选择 —— 1~7 天。用 shadcn `Select`(Radix),不用原生 `<select>`:
* 原生下拉在深色/窄屏下样式不可控,而这个控件现在**每一条召回计划都有一个**,
* 出现几百次,样式必须跟卡片其余部分一致。
* ⚠️ 值域来自 `ASSIGNMENT_EXPIRES_DAYS_PRESETS`(共享常量),⛔ 别在这里内联 [1..7]。
*/
/**
* 时下拉 —— **点了才挂真正的 Radix Select**。
* 时下拉 —— **点了才挂真正的 Radix Select**。
*
* 🔴 由来(2026-08-05 实测):原来每一行直接渲染一个 `<Select>`。展开一个 36 条的客服组
* = 一次性挂 36 个 Radix Select,每个都带 portal / 焦点管理 / 键盘导航,
* 展开那一下明显卡顿。而这些下拉**绝大多数根本不会被点** —— 主管一批 200 人,
* 真正逐条改时的是个位数。
* 真正逐条改时的是个位数。
*
* ✅ 平时只是一个 `<button>`(样式与 SelectTrigger 一致),点击才换成真 Select 并自动展开。
* ⚠️ `defaultOpen` 是关键:换过去之后要立刻弹开,否则用户得点两下。
......@@ -287,7 +287,7 @@ export function AssignmentConfirmSheet({
/**
* ⭐ 引导节点上的选项被点了 —— **按钮与助手共用同一组 intent**。
*
* 卡片自己只处理两件它本来就会做的事(重排一版 / 改时),其余**原样抛给上层**:
* 卡片自己只处理两件它本来就会做的事(重排一版 / 改时),其余**原样抛给上层**:
* 上层要么把它翻成 `SheetEditOp[]` 压进和助手同一条 edits 队列,要么交给助手重出一版。
* ⛔ 别在这里为每个 intent 各写一套执行 —— 那就等于两条路各做各的,必然漂。
*/
......@@ -313,7 +313,7 @@ export function AssignmentConfirmSheet({
busyIntent?: string | null;
}) {
/**
* 批次默认时 —— 初值来自**提案**(沿用主管上一次的值),⛔ 不是写死的 3。
* 批次默认时 —— 初值来自**提案**(沿用主管上一次的值),⛔ 不是写死的 3。
* 改它 = 把所有**没有单独设过**的条目一起改;单独设过的不动(下面 dayByPlan 优先)。
*/
/**
......@@ -331,7 +331,7 @@ export function AssignmentConfirmSheet({
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState<Set<string>>(new Set());
/// 逐条时效覆盖(planId → 天)。⚠️ 时效**落到每一条召回计划**,不是按客服
/// 逐条时限覆盖(planId → 天)。⚠️ 时限**落到每一条召回计划**,不是按客服
const [dayByPlan, setDayByPlan] = useState<Record<string, number>>({});
/// 拖拽改派(planId → 新经办人)。落库时这些条的策略改成 manual(主管指定)
const [moveByPlan, setMoveByPlan] = useState<Record<string, string>>({});
......@@ -441,7 +441,7 @@ export function AssignmentConfirmSheet({
const readOnly = done || cancelled || superseded || submitting;
/**
* 生效条目 = 提案条目 − 删掉的 + 拖拽改派 + 逐条时
* 生效条目 = 提案条目 − 删掉的 + 拖拽改派 + 逐条时
* ⭐ 所有展示都从这里派生(条数、按客服分组、确认按钮上的数字),
* ⛔ 别再直接用 `sheet.byAgent` —— 它是**提案时**的分组,拖过一条就对不上了。
*/
......@@ -549,8 +549,8 @@ export function AssignmentConfirmSheet({
*
* ⚠️ 存在的理由:确认之前这些数**只在这个组件里**,服务端查不到
* (沿革见 `SheetSnapshotSchema`)。此前模型只能把对话里几行「已更新…」自己累加,
* 而**手动拖动、点 ×、改时这几条路连回声都没有** —— 它连累加的材料都没有,
* 于是一边说「时 1 天」,卡片上写着 3 天,两边都不报错。
* 而**手动拖动、点 ×、改时这几条路连回声都没有** —— 它连累加的材料都没有,
* 于是一边说「时 1 天」,卡片上写着 3 天,两边都不报错。
*/
const snapshot = useMemo<SheetSnapshot>(
() => ({
......@@ -677,8 +677,8 @@ export function AssignmentConfirmSheet({
...(benefit.trim() ? { attributes: { benefit: { text: benefit.trim() } } } : {}),
expiresInDays,
// ⚠️ 落库的是**卡片当前值**,不是模型原提案。
// 逐条 expiresInDays 只在**与批次不同**时才带:一样还带等于把批次时冻进每条,
// 将来批次层面改时(撤销重发之类)就改不动这些单子了。
// 逐条 expiresInDays 只在**与批次不同**时才带:一样还带等于把批次时冻进每条,
// 将来批次层面改时(撤销重发之类)就改不动这些单子了。
items: items.map((it) => ({
planId: it.planId,
assigneeUserId: it.assignee,
......@@ -738,7 +738,7 @@ export function AssignmentConfirmSheet({
批次id: res.assignmentId,
落了几条: res.assigned,
几位客服: groups.length,
天数: expiresInDays,
天数: expiresInDays,
...(dropped.size ? { 主管移除: dropped.size } : {}),
...(res.skipped.length ? { 没落上: res.skipped.length } : {}),
本批福利: benefit.trim() || null,
......@@ -756,7 +756,7 @@ export function AssignmentConfirmSheet({
* 移除某个客服 = 他名下的条目**一并移出本批**。
*
* 🔴 **这是第七条改动路径,2026-08-15 补回声时漏掉了**(当天的契约测试抓到)。
* 我数了拖动改派 / 点 ×(两处)/ 整批时效 / 每行时效 / 引导按钮改时效 六条,
* 我数了拖动改派 / 点 ×(两处)/ 整批时限 / 每行时限 / 引导按钮改时限 六条,
* 偏偏漏了这条 —— 而它一次移走的是**一整个客服名下的所有条目**,
* 静默起来比单点一个 × 严重得多:模型下一轮还以为那几条在他手上。
* ⚠️ 报的是**人名 + 条数**:主管点的是"移除这个人",回声里只说条数他对不上是谁。
......@@ -799,7 +799,7 @@ export function AssignmentConfirmSheet({
const notes: string[] = [];
// ⭐ 已确认 = 卡片进入只读态。此时唯一还能改的是**福利**(它是前向的,只影响之后生成的话术),
// 而且必须走接口真写库;人员 / 时那些动的是已经发到客服手上的单,只能走撤销重分。
// 而且必须走接口真写库;人员 / 时那些动的是已经发到客服手上的单,只能走撤销重分。
if (assignmentId) {
for (const { ops } of fresh) {
for (const op of ops) {
......@@ -1001,7 +1001,7 @@ export function AssignmentConfirmSheet({
}
if (op.action === 'set_expiry' && op.select.group === 'batch') {
setExpiresInDays(op.days!);
notes.push(`整批时改为 ${op.days} `);
notes.push(`整批时改为 ${op.days} `);
continue;
}
const sel = resolve();
......@@ -1024,13 +1024,13 @@ export function AssignmentConfirmSheet({
for (const id of sel.planIds) n[id] = days;
return n;
});
// ⚠️ 待分配的人还没有经办人:时先记下,等他被分给某位客服才随之落库。
// ⚠️ 待分配的人还没有经办人:时先记下,等他被分给某位客服才随之落库。
// ⛔ 别默不作声 —— 主管会以为这条已经进批次了。
const stillPending = pendingRest().filter((p) => sel.planIds.includes(p.planId)).length;
notes.push(
`${sel.label}改为 ${days} ` +
`${sel.label}改为 ${days} ` +
(stillPending > 0
? `(其中 ${stillPending} 人还在「待分配」里,分给客服后这个时才生效)`
? `(其中 ${stillPending} 人还在「待分配」里,分给客服后这个时才生效)`
: ''),
);
continue;
......@@ -1164,7 +1164,7 @@ export function AssignmentConfirmSheet({
const handleIntent = async (intent: string, args?: Record<string, unknown>) => {
if (intent === 'expiry.set' && typeof args?.days === 'number') {
setExpiresInDays(args.days);
echo(`确认单已更新:整批时改成 ${args.days} 天。`);
echo(`确认单已更新:整批时改成 ${args.days} 天。`);
return;
}
// ⚠️ `pending.refill` 曾经在这里实现过 —— 待分配的按钮搬到文案下面之后
......@@ -1177,7 +1177,7 @@ export function AssignmentConfirmSheet({
* 主语还在的引导节点。
* ⚠️ 判据只用**卡片本地就能看见**的事实,⛔ 不重算服务端的判定:
* · `pending` —— 待分配被处置光了,这条就没有主语了
* · `batch_size_basis` —— 主管改过时,"本批 N 人 = 在岗 × 每天几通 × D 天"里的 D 就变了
* · `batch_size_basis` —— 主管改过时,"本批 N 人 = 在岗 × 每天几通 × D 天"里的 D 就变了
* 其余(候选不足 / 工作量 / 画像亮点)本地改单不影响,或需重跑才知道 → 原样留着。
*/
const liveSignals = useMemo(
......@@ -1281,9 +1281,9 @@ export function AssignmentConfirmSheet({
{/*
① 汇总
🔴 **必须 `flex-wrap`**(2026-08-11 产品指出):窗口窄的时候这一行装不下
「共 N 人 · 拟分 X · 待分配 Y」+ 角标 + 时下拉,不换行就只能挤 ——
实测「已移除 1」被压成**竖排单字**,时下拉顶出卡片边界。
⚠️ 换行之后 `ml-auto` 仍然有效(它按**行**推到右端),时自己落到第二行右侧。
「共 N 人 · 拟分 X · 待分配 Y」+ 角标 + 时下拉,不换行就只能挤 ——
实测「已移除 1」被压成**竖排单字**,时下拉顶出卡片边界。
⚠️ 换行之后 `ml-auto` 仍然有效(它按**行**推到右端),时自己落到第二行右侧。
⚠️ `gap-y-1` 不能省:换行后两行会贴在一起。
*/}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 border-b bg-brand-50/50 px-3 py-2">
......@@ -1341,9 +1341,9 @@ export function AssignmentConfirmSheet({
福利 · {benefit}
</Badge>
)}
{/* ⭐ 批次时放**右上角**:它是"这一整批"的设置,和左边"这一整批多少人"同一层级,
放在底部微调区会让人以为它跟下面的逐条时是一回事。
⚠️ 文案是「N 天**后自动退回**」而不是光一个「时」——
{/* ⭐ 批次时放**右上角**:它是"这一整批"的设置,和左边"这一整批多少人"同一层级,
放在底部微调区会让人以为它跟下面的逐条时是一回事。
⚠️ 文案是「N 天**后自动退回**」而不是光一个「时」——
主管要知道到期会发生什么(单子回池),不然这个数对他没有意义。 */}
<div className="ml-auto flex flex-none items-center gap-1 whitespace-nowrap text-[10.5px] text-slate-500">
<DaySelect
......@@ -1351,7 +1351,7 @@ export function AssignmentConfirmSheet({
disabled={readOnly}
onChange={(d) => {
setExpiresInDays(d);
echo(`确认单已更新:整批时改成 ${d} 天。`);
echo(`确认单已更新:整批时改成 ${d} 天。`);
}}
/>
<span>后自动退回</span>
......@@ -1374,8 +1374,8 @@ export function AssignmentConfirmSheet({
{!readOnly && (
<div className="bg-slate-50/70 px-3 py-2 text-[11.5px] leading-relaxed text-slate-500">
展开客服看名单 · <span className="text-slate-700">拖动患者</span>改派给别人 ·
点 <span className="text-slate-700">×</span> 移出本批 · 每行可单独改时
右上角改整批时
点 <span className="text-slate-700">×</span> 移出本批 · 每行可单独改时
右上角改整批时
{onCompose && (
<>
{' '}想自定义谁分给谁等更多操作,直接{' '}
......@@ -1528,7 +1528,7 @@ export function AssignmentConfirmSheet({
* 而服务端那份是出方案那一刻的快照。)
* ⛔ 措辞不带「打不完 / 超了 / 过载」——它几乎每批都会有人超,
* 说成故障主管就会开始怀疑系统而不是做决定(同引导节点那条纪律)。
* 超出时只用**颜色**提示,把判断留给他。
* 超出时只用**颜色**提示,把判断留给他。
*/}
{(() => {
const after = g.inHandBefore + g.list.length;
......@@ -1541,7 +1541,7 @@ export function AssignmentConfirmSheet({
)}
title={
`按每人每天 ${sheet.dailyCalls} 通算:分完手上共 ${after} ${need} 天;` +
`本批时 ${expiresInDays} `
`本批时 ${expiresInDays} `
}
>
约 {need} 天
......@@ -1636,14 +1636,14 @@ export function AssignmentConfirmSheet({
探索
</Badge>
)}
{/* ⭐ 时**逐条**:落库时写进这条 plan 的 assignment_expires_at */}
{/* ⭐ 时**逐条**:落库时写进这条 plan 的 assignment_expires_at */}
<DaySelect
compact
value={i.days}
disabled={readOnly}
onChange={(d) => {
setDayByPlan((m) => ({ ...m, [i.planId]: d }));
echo(`确认单已更新:${zhPatient(i.planId)}」这条时单独改成 ${d} 天。`);
echo(`确认单已更新:${zhPatient(i.planId)}」这条时单独改成 ${d} 天。`);
}}
/>
{!readOnly && (
......@@ -1818,7 +1818,7 @@ export function AssignmentConfirmSheet({
</div>
)}
{/* ⛔ 这里原来有一块「人数 本批 340 人 / 要改说『这批 680 人』/ 时为首次默认…」——
{/* ⛔ 这里原来有一块「人数 本批 340 人 / 要改说『这批 680 人』/ 时为首次默认…」——
撤掉(2026-08-03 走查)。人数是**只读**的,而只读信息不该占一块跟可操作区一样重的位置;
它想说的两件事都已经有更好的落点:
· 本批多少人 → 顶部「拟分 N 人」和确认按钮上都写着;
......
......@@ -785,7 +785,7 @@ function MessageView({
// 🔴 确认单**必须用稳定 key**(requestId),⛔ 不能用下标。
// 下标 key + "把 sheet 钉到消息末尾"的排序 = 助手每追加一句文本,
// sheet 的下标就变 → React 卸载重建组件 → **本地状态全丢**:
// 主管删的条、拖的改派、逐条时、以及"能不能撤销"的确认时刻,
// 主管删的条、拖的改派、逐条时、以及"能不能撤销"的确认时刻,
// 都会在助手说下一句话时被悄悄清空(实测:确认后撤销按钮直接不出现)。
// ⚠️ 这是排序改动引入的回归 —— 排序本身没错,错在 key 跟着位置走。
key={b.kind === 'assignment_sheet' ? `sheet:${b.requestId}` : `b:${i}`}
......
......@@ -39,7 +39,7 @@ export function AssistantWidget() {
* ⚠️ **这个 480 与下面 className 里的 `w-[480px]` 是一对,改一处要改两处**
* (Tailwind 的任意值必须是静态字面量,没法从这里的常量插进去)。
* 🔴 400 → 480(2026-08-11 产品:「窗口调宽一点」):确认单在 400px 里排不开 ——
* 汇总行的「已移除 1」被挤成竖排单字,时下拉顶出边界。
* 汇总行的「已移除 1」被挤成竖排单字,时下拉顶出边界。
* ⛔ 别再往回收:那张卡是这个窗口的**主要内容**,窗口要迁就它。
*/
const W = Math.min(480, window.innerWidth - 16);
......
......@@ -179,7 +179,7 @@ describe('引导节点 intent 路由', () => {
]);
});
it('改时 → 局部改单;缺 days → null(⛔ 不许瞎猜一个天数)', () => {
it('改时 → 局部改单;缺 days → null(⛔ 不许瞎猜一个天数)', () => {
expect(intentToSheetOps('expiry.set', { days: 5 })).toEqual([
{ select: { group: 'batch' }, action: 'set_expiry', days: 5 },
]);
......
......@@ -327,13 +327,13 @@ export function intentToPrompt(
// 删于 2026-08-14:换格子是主管在矩阵上一点的事,而矩阵每格都写着人数,
// 卡片上的按钮反而不带数 ⇒ 严格弱于矩阵。⛔ 别加回来。
/**
* 「本批多大」那个式子改了一项 → **重跑算法**(N = 在岗 × 每天几通 × 时)。
* 「本批多大」那个式子改了一项 → **重跑算法**(N = 在岗 × 每天几通 × 时)。
* ⭐ 带上主管填的那个数:propose_assignment 有 `expiresInDays` / `dailyCalls` 两个参数,
* **服务端**据此重估 N。⛔ 别让模型自己去乘 —— 报出去的数必须是工具算的。
* ⚠️ ⛔ 别退化成 `expiry.set`:那个只改到期日,人数一个不变,而节点原话说会变。
*/
case 'basis.set': {
if (typeof args?.days === 'number') return `时改成 ${args.days} 天,重新排一版`;
if (typeof args?.days === 'number') return `时改成 ${args.days} 天,重新排一版`;
if (typeof args?.rate === 'number') return `每人每天按 ${args.rate} 通算,重新排一版`;
return null;
}
......
......@@ -105,7 +105,7 @@ function Row({
muted?: boolean;
}) {
/**
* ⭐ **`post_confirm` 那类在只读态下照样可点** —— 只读说的是"这批人和时定死了",
* ⭐ **`post_confirm` 那类在只读态下照样可点** —— 只读说的是"这批人和时定死了",
* 而这类节点恰恰是**分下去之后仍然能做**的那几件(补挂福利只影响此后生成的话术)。
* ⛔ 跟着 readOnly 一起禁掉,就等于摆一句「这批还没带福利」然后什么也不让他做。
*/
......
......@@ -28,12 +28,12 @@ import type { Signal, SignalOption } from '@pac/types';
* ⇒ 兜底是**同一排里更次要的一个按钮**(虚线框、灰字),⛔ 不做成和选项平级的输入框。
*/
/**
* 要填一个数的选项 —— 「改时 [1] 天」「改每天几通 [15] 通」。
* 要填一个数的选项 —— 「改时 [1] 天」「改每天几通 [15] 通」。
*
* ⭐ 为什么是输入框而不是几个固定档:天数和通数是**连续量**,列 3/5/7 天必然漏掉他要的那个,
* 而漏掉的那次他只能去下面的自由输入打字 → 走模型理解 → 慢且可能错。填数是确定性的。
* ⚠️ `min/max` 是护栏:越界直接不给提交,⛔ 别让它走到服务端才报错。
* ⚠️ 值没变就不提交 —— 「改时」填回原来的 1 天等于什么都没做,
* ⚠️ 值没变就不提交 —— 「改时」填回原来的 1 天等于什么都没做,
* 真发出去会白跑一次重排(`daily_rate.set` 那条要重跑算法)。
*/
function NumberOption({
......
......@@ -70,6 +70,8 @@ export async function transcribeAudio(blob: Blob): Promise<string> {
export function useAssistantChat() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
/** 当前这段对话的 id(见下方 send 里那段)—— 组件重挂即换新的。 */
const conversationIdRef = useRef<string>('');
const [status, setStatus] = useState<ChatStatus>('idle');
/**
* 正在执行的引导节点 intent —— 按钮据此转圈并整排禁用。
......@@ -181,6 +183,16 @@ export function useAssistantChat() {
*/
const apiMessages = [...messages, userMsg].flatMap(toApiMessages);
/**
* ⭐ 这一串对话的 id —— **messages 为空就是一段新对话的第一句**,此刻现生成;
* 此后每轮原样带上,服务端拿它把同一次对话的各轮串成一组。
*
* 🔴 助手没有"新建会话"(产品定),会话跟着他此刻在做的那件事走 ——
* 离开工作台、组件重挂,messages 归空,下一句就是新的一段。
* ⚠️ 只用于**归组**,⛔ 不参与鉴权取数;服务端非 uuid 一律丢弃。
*/
if (messages.length === 0) conversationIdRef.current = crypto.randomUUID();
setMessages((prev) => [...prev, userMsg, assistantMsg]);
setStatus('streaming');
emitPetEvent({ type: 'ai_thinking_start' }); // 宠物演"思考"(详情页助手宠物)
......@@ -458,6 +470,7 @@ export function useAssistantChat() {
body: JSON.stringify({
messages: apiMessages,
model,
conversationId: conversationIdRef.current,
...(useAssistantStore.getState().activeClinicId
? { activeClinicId: useAssistantStore.getState().activeClinicId }
: {}),
......
......@@ -358,7 +358,7 @@ export const mockPlan = {
assigneeUserId: 'usr_csliu' as string | null,
assignedAt: NOW,
recycleAt: new Date(NOW.getTime() + 4 * 3600_000) as Date | null,
// 分配单时:mock 给 2 天后到期(真实值 1-7 天,由主管在确认单上定)
// 分配单时:mock 给 2 天后到期(真实值 1-7 天,由主管在确认单上定)
assignmentExpiresAt: new Date(NOW.getTime() + 2 * 24 * 3600_000) as Date | null,
/// 召回冷静期 / 终态抑制窗到期时间(null=无抑制)— 头部"下次回访 / 已暂缓至 X"角标
snoozedUntil: null as Date | null,
......
......@@ -1430,7 +1430,7 @@ function TopBar({
)}
</div>
)}
{/* ⭐ 分配单时 —— 只在单子**还挂在人手上**时显示(回到池子里的没有"还剩多久")。
{/* ⭐ 分配单时 —— 只在单子**还挂在人手上**时显示(回到池子里的没有"还剩多久")。
⚠️ 基于 assignmentExpiresAt(已启用),不是 recycleAt(认领的 24h 兜底,从未启用)。 */}
{plan.assigneeUserId && <ExpiryBadge expiresAt={plan.assignmentExpiresAt} />}
{/*
......@@ -1544,16 +1544,16 @@ function SupervisorBackLink() {
// ExpiryBadge — 分配单还剩多久(到点自动退回召回池)
// ──────────────────────────────────────────
/**
* ⭐ 显示的是 **`assignmentExpiresAt`**(分配单时),⛔ 不是 `recycleAt`。
* ⭐ 显示的是 **`assignmentExpiresAt`**(分配单时),⛔ 不是 `recycleAt`。
*
* 两个机制,长期被混为一谈:
* · `recycleAt` 认领后 24h 兜底回收 —— 生产**从未启用**,值几乎恒为 null。
* 头部原来那个 `HH:MM:SS` 倒计时就是基于它的,所以一直挂着"未设置回收时间",
* 后来干脆注释掉了(见 git 历史里那句"暂无自动回收机制")。
* · `assignmentExpiresAt` 主管在确认单上定的时(1-7 天)—— **已启用**,
* · `assignmentExpiresAt` 主管在确认单上定的时(1-7 天)—— **已启用**,
* AssignmentExpiryScheduler 每 10 分钟扫一次,到点回池并记 `assignment_expired`。
*
* ⚠️ 粒度也跟着换了:原来是 `HH:MM:SS`(为 24h 设计的),而现在时是**天**级 ——
* ⚠️ 粒度也跟着换了:原来是 `HH:MM:SS`(为 24h 设计的),而现在时是**天**级 ——
* 3 天的单显示 `71:59:58` 没人读得出来。改成「还剩 2 天」/「还剩 5 小时」,
* 只有进入最后 1 小时才精确到分钟(那时候才真需要紧迫感)。
* ⚠️ 只在**单子还挂在人手上**时显示:回到池子里的单没有"还剩多久"可言。
......@@ -1581,7 +1581,7 @@ function ExpiryBadge({ expiresAt }: { expiresAt: Date | null }) {
const days = Math.floor(hours / 24);
const label =
days >= 1 ? `还剩 ${days} 天` : hours >= 1 ? `还剩 ${hours} 小时` : `还剩 ${mins} 分钟`;
// 最后一天开始转暖色,最后一小时转红 —— ⛔ 别更早变红:时本来就是几天,
// 最后一天开始转暖色,最后一小时转红 —— ⛔ 别更早变红:时本来就是几天,
// 提前两天就飘红会让客服对颜色脱敏,真到期时反而看不见
const tone =
hours < 1
......
......@@ -26,7 +26,7 @@ import { cn } from '@/lib/utils';
* `plansApi.recycle` 早就支持传 releaseReason、枚举齐了、聚合报表(releaseReasons 分布)
* 也写了 —— 唯一的调用点却是 `plansApi.recycle(planId)`,一个参数都不传。
* 于是那张分布报表永远拿到 null:主管只知道"退了 12 条",不知道该改什么
* (是派多了?时太紧?还是压根不该派给他?)—— 而这三种的下一步动作完全不同。
* (是派多了?时太紧?还是压根不该派给他?)—— 而这三种的下一步动作完全不同。
*
* ⚠️ 每个原因都带一句 `desc`:「不是我的客户」和「该由其他角色跟」光看标题分不出,
* 分不出就会随手点第一个,那比不填还糟(假数据比没数据难发现)。
......@@ -71,7 +71,7 @@ export function ReleaseReasonDialog({
<div className="max-h-[46vh] space-y-1 overflow-y-auto py-1">
{/*
⚠️ 走 releaseReasonsForForm() 而不是 Object.keys —— META 里留着 5 个**历史值**
(不是我的客户 / 这段时间不在 / 时太紧 / 最近刚联系过 / 其他),
(不是我的客户 / 这段时间不在 / 时太紧 / 最近刚联系过 / 其他),
它们只为翻译库里的老数据而存在,⛔ 不能出现在选项里。
直接 Object.keys 会把它们一起渲染出来,而且不报错。
*/}
......
......@@ -18,7 +18,7 @@ const PAGE = 20;
const IDLE_HEAVY = 0.2;
/**
* 「还在跑的」判据 —— **时还没到,或还有没人动过的单**。
* 「还在跑的」判据 —— **时还没到,或还有没人动过的单**。
*
* ⚠️ 这是**前端算的**,因为它是一个展示分组不是业务状态:
* 服务端的 `status` 只有 confirmed/revoked,没有"跑没跑完"这个概念,
......@@ -164,13 +164,13 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) {
const out: BatchGroupData[] = [];
if (scope === 'running') {
return [
{ key: '还在跑的', note: '时未到、或还有没人动过的单', past: false, rows: shown },
{ key: '还在跑的', note: '时未到、或还有没人动过的单', past: false, rows: shown },
];
}
const runIds = new Set(running.map((b) => b.id));
const run = shown.filter((b) => runIds.has(b.id));
if (run.length)
out.push({ key: '还在跑的', note: '时未到、或还有没人动过的单', past: false, rows: run });
out.push({ key: '还在跑的', note: '时未到、或还有没人动过的单', past: false, rows: run });
let lastKey = '';
for (const b of shown) {
if (runIds.has(b.id)) continue;
......
......@@ -73,7 +73,7 @@ interface AssistantState {
* 🔴 **眼前那张还没确认的确认单,此刻的样子** —— 与 `activeClinicId` 同一条旁路,
* 跟着每次 chat 请求发给服务端,模型用 `get_current_sheet` 取。
*
* ⚠️ 为什么非走这条路不可:确认之前,改派/移出/时/福利**只存在于卡片组件里**,
* ⚠️ 为什么非走这条路不可:确认之前,改派/移出/时/福利**只存在于卡片组件里**,
* 服务端没有可查的东西(见 `SheetSnapshotSchema` 上那段)。
* ⚠️ 只有**现役**那张挂在这里 —— 卡片自己在 effect 里挂、卸载时只清自己那份
* (按 requestId 比对),否则重出一版后旧卡片卸载会把新的一起清掉。
......
......@@ -262,7 +262,7 @@ components/assistant/
|---|---|---|
| 27 | 提示词按「什么会让它变」**分五层**(装置 / 诚实 / 语气 / 角色 / 现场),现场段按 `选人 → 分人 → 他确认 → 确认之后` **四步分节** | `assistant-prompts.ts` |
| 28 | 次要业务线走 **pull**:系统提示词只留索引,模型用 `open_playbook` 按需取正文 | **新建** `assistant-playbooks.ts` |
| 29 | **`rosterCount`** —— 「在岗 N 人 × 每天几通 × 时」里的 N 由算 `batchSize`**同一行**赋值 | `packages/types` + proposal |
| 29 | **`rosterCount`** —— 「在岗 N 人 × 每天几通 × 时」里的 N 由算 `batchSize`**同一行**赋值 | `packages/types` + proposal |
| 30 | **`SheetSnapshot` + `get_current_sheet`(拉)+ 回声带当前值(推)** —— 草稿态服务端查不到,模型此前只能把对话里几行回声自己累加 | 三端 |
| 31 | 重算类载体**原样带回人群条件**(refill 的 `narrowedBy`、快照的 `criteria`) | 三处载体 |
| 32 | **契约测试** —— 测"两端各自都对、错在交接" | **新建** `tests/assignment-contract.spec.ts` |
......
......@@ -101,7 +101,7 @@
├── 说节点外的话 ──── [模] 判断动的是哪一层
│ │
│ ├─ 动「怎么派」 → [程] 局部改 ────▶ 同一版,重算节点
│ │ 谁给谁 · 时 · 移出本批 · 福利
│ │ 谁给谁 · 时 · 移出本批 · 福利
│ │
│ └─ 动「这批人」 → [程] 重跑 ──────▶ **出新版,旧版当场作废**
│ 换口径 · 加画像条件 · 改人数 · 换治疗项
......@@ -128,7 +128,7 @@
| 必须带回 | 漏了会怎样 |
|---|---|
| 治疗项 · 时间档 | 按全池重算。实测:主管说「改成 200 人」,模型手里只有确认单本身(120 人、8 位客服、时 1 天),不知道原来是哪一格 —— 它自己的话:「刚才我按全部候选重算了一版,结果对不上……跟您眼前那批完全不是一回事」 |
| 治疗项 · 时间档 | 按全池重算。实测:主管说「改成 200 人」,模型手里只有确认单本身(120 人、8 位客服、时 1 天),不知道原来是哪一格 —— 它自己的话:「刚才我按全部候选重算了一版,结果对不上……跟您眼前那批完全不是一回事」 |
| 主管自己加的那几刀(`narrowedBy`) | **退回整格**。实测「换无专属客服的患者补上」漏传 `narrowedBy`:候选 2,178 vs 296,差 1,882 人 —— 他刚圈掉的人全回来了 |
**凡是能触发重算的载体都要带全这一段**,目前有三处:
......@@ -176,7 +176,7 @@
| | |
|---|---|
| 每人每日 | **15 条**(当前默认;⛔ 暂不沿用上一次的值) |
| 时 | `D` 天 |
| 时 | `D` 天 |
| **本批目标 N** | **在岗人数 × 15 × D**,并与候选总数取小 |
### 事实② 怎么派的(**给谁**)
......@@ -224,9 +224,9 @@
| sev | 层 | key | 给模型的标识 | 判定(程序) | 默认(no-op) | 选项(点击执行) |
|---|---|---|---|---|---|---|
| **1** | 需处置 | `pending` | 待分配 | `pending > 0` | 留着 = 这批不发给他们 | ① **换无专属客服的患者补上**(见下,带取人范围)<br>② 铺平给在岗<br>③ 各自归专属客服<br>④ 移出本批 |
| **2** | 需处置 | `daily_overload` | 打不完 | 分完后最重的那位 `手上总条数 > 15 × D` | 就按 D 天发,到期落回池子 | **只有一个:`时改成 N 天`**(N 由程序算) |
| **2** | 需处置 | `daily_overload` | 打不完 | 分完后最重的那位 `手上总条数 > 15 × D` | 就按 D 天发,到期落回池子 | **只有一个:`时改成 N 天`**(N 由程序算) |
| **3** | 需处置 | `narrow` | 换个条件选 | 候选 ≥ 50 且切完 ≥ 10 人;主管已收窄过 / 已重挑过则不出 | 就按这一格全部来 | 几刀 + 兜底(见下) |
| **4** | 需处置 | `batch_size_basis` | 本批人数 | `basis === 'default'` 且候选 > 基数 | 就按这个数发 | 改时 / 改每天几通(**都带输入框、预填当前值**) |
| **4** | 需处置 | `batch_size_basis` | 本批人数 | `basis === 'default'` 且候选 > 基数 | 就按这个数发 | 改时 / 改每天几通(**都带输入框、预填当前值**) |
**终止分支(不是引导节点)**`empty` —— `total == 0`,无法继续,只能放宽或换格。
......@@ -243,9 +243,9 @@
#### `daily_overload` 是**工作量预估**,不是异常告警
N 按「在岗 × 每天 15 通 × 时」算,**只算新增、⛔ 不扣在手** —— 所以只要谁手上还有东西,
需要的天数就会超过时,这个节点**每批基本都会亮****这是对的**(产品定 2026-08-12):
每一批本来就该把「这活多大、要打几天」讲清楚,主管据此决定延时、调每日通数、还是减量。
N 按「在岗 × 每天 15 通 × 时」算,**只算新增、⛔ 不扣在手** —— 所以只要谁手上还有东西,
需要的天数就会超过时,这个节点**每批基本都会亮****这是对的**(产品定 2026-08-12):
每一批本来就该把「这活多大、要打几天」讲清楚,主管据此决定延时、调每日通数、还是减量。
由此推出两条硬要求:
......@@ -332,8 +332,8 @@ N 按「在岗 × 每天 15 通 × 时效」算,**只算新增、⛔ 不扣在
| `dispatch` | 怎么派 | `pending` · `daily_overload` | 「怎么派的」 |
> 🔴 **`cohort` 与 `size` 必须是两个键**,虽然都嵌在同一段。合成一个键时模型会拿
> 一个标题罩住两组按钮 —— 走查抓到「还能怎么选:」下面挂着「改时 / 改每天几通」,
> 而改时根本不是选人。⇒ **一个键只说一件事**。
> 一个标题罩住两组按钮 —— 走查抓到「还能怎么选:」下面挂着「改时 / 改每天几通」,
> 而改时根本不是选人。⇒ **一个键只说一件事**。
>
> 🔴 **开放的粒度是「一件」不是「一段」**(2026-08-15 实测)。工具描述里一度写着
> 「在**它所属的那一段**讲完之后开放」,而「怎么选的」这一段里**有两件**
......@@ -409,7 +409,7 @@ N 按「在岗 × 每天 15 通 × 时效」算,**只算新增、⛔ 不扣在
|---|---|
| 福利 | ✅ 可补挂,不设时限 |
| 人员 | ❌ 单已在客服手上 → 走撤销重分 |
| 时 | ❌ 同上 |
| 时 | ❌ 同上 |
---
......@@ -435,7 +435,7 @@ N 按「在岗 × 每天 15 通 × 时效」算,**只算新增、⛔ 不扣在
### 🔴 草稿态**模型看不见** —— 要查 `get_current_sheet`(2026-08-15 加)
确认之前,主管在卡片上做的改动(改派 / 移出 / 改时 / 设福利)**只存在于浏览器里**
确认之前,主管在卡片上做的改动(改派 / 移出 / 改时 / 设福利)**只存在于浏览器里**
服务端手上只有最初生成的那一版提案。所以模型对"这张单现在什么样"只有两条来路:
| | 谁推给它 | 覆盖 |
......@@ -443,7 +443,7 @@ N 按「在岗 × 每天 15 通 × 时效」算,**只算新增、⛔ 不扣在
| **推** | 每次改单往对话里追加一行回声,`modelText` 后缀带当前值 | 改动发生的那一刻 |
| **拉** | `get_current_sheet`(无参数,读随请求捎来的 `SheetSnapshot`) | 任何时候 |
⚠️ 在这之前它只能把对话里几行回声**自己累加**,而手动拖动 / 点 × / 改时这几条路
⚠️ 在这之前它只能把对话里几行回声**自己累加**,而手动拖动 / 点 × / 改时这几条路
**当时连回声都没有** —— 卡片上写着 3 天,它嘴上还说 1 天,两边都不报错。
⛔ 别去给服务端加"草稿回传"的写路径:多一份要维护的状态,只为省模型一次加法。
......
......@@ -28,8 +28,8 @@
|---|---|---|
| 1 | 批次实体 `plan_assignments` + `followup_plans` 归因列 | T17 / 五之二 |
| 2 | 主管确认触发的**批量分配写入**(唯一写动作,走 REST) | T8 |
| 3 | 助手全景直出**原生确认单**(微调仅「指定客服 / 时」两项) | T13 |
| 4 | 分配时 `assignment_expires_at`(写入 + 超期展示) | T11 |
| 3 | 助手全景直出**原生确认单**(微调仅「指定客服 / 时」两项) | T13 |
| 4 | 分配时 `assignment_expires_at`(写入 + 超期展示) | T11 |
| 5 | 客服退回带**结构化原因** | T7 |
| 6 | 前端 T16 改造:客服只有「我的」,认领入口全撤 | T16 |
| 7 | `PLAN_DISPATCH` 权限 + MCP 按能力过滤工具 | T19 |
......@@ -84,7 +84,7 @@ Y 轴 窗口温度 plan_reasons 566,365 条 ← daysSince 现成,读时
| **D-5** | `followup_plans` 新增列数 | **6 列**(不是 4 也不是 5) | data 的 5 列 + 新增第 6 列 `assign_strategy`,见 D-6 |
| **D-6** | 「专属命中 vs 溢出铺平」怎么记 | **立柱** `assign_strategy TEXT``dedicated` / `spread` / `manual`) | 四份方案**全都没记**。T20 要按它分组算完成率 → 「会用来筛」→ 按 T17 就该立柱。⚠️ 事后从 `patients.preferences.dedicatedCs` 反推**不可行**:那列是 upsert 覆盖的「当前值」,历史丢失。**分配当时不记就永久没了** |
| **D-7** | `ReleaseReason` 值域 | 取 **data 层的 8 个**`not_my_patient` / `over_capacity` / `agent_unavailable` / `needs_other_role` / `deadline_too_tight` / `recently_contacted` / `patient_info_missing` / `other`)。⛔ **类型里不定义 `suppressDays`** | ❌ service 的 6 个:其中 `bad_timing``RECALL_FEEDBACK_OPTIONS.bad_timing``enums/index.ts:641`**同名不同义**。值域仍需产品过目,见 Q-3 |
| **D-8** | 时入参形状 | 收 **`expiresInDays: number`**(相对天数),服务端按 `hosts.pullConfig.timezone` 转绝对时刻 | ❌ mcp 的 `expiresAt: z.string().datetime()` 必填:强制模型算 ISO 时刻,正是本仓 `4549817` 修过的「纯日期被当 UTC 零点偏 8 小时」 |
| **D-8** | 时入参形状 | 收 **`expiresInDays: number`**(相对天数),服务端按 `hosts.pullConfig.timezone` 转绝对时刻 | ❌ mcp 的 `expiresAt: z.string().datetime()` 必填:强制模型算 ISO 时刻,正是本仓 `4549817` 修过的「纯日期被当 UTC 零点偏 8 小时」 |
| **D-9** | 名册 / 负载 | **一个** REST 端点 `GET /pac/v1/plans/agents?clinicId=&withWorkload=`,MCP 工具 `get_agents` 与前端共用 | ❌ service 的两端点方案:五之四明写「开两个必然口径漂移」 |
| **D-10** | 撤销授权判据 | `batch.createdBy === actorUserId \|\| permissions.includes(PLAN_VIEW_ALL)`,在 service 顶部一次性校验,**不逐行调 `assertCanRecycle`** | ❌ mcp 的 `confirmedByUser: boolean`:模型自己填,拦不住任何东西 |
| **D-11** | 「已动过 / 不可撤销」判据 | **以 `plan_event_logs` 的 `view` 事件为主** | ⚠️ **原案已部分证伪(2026-08-02 实测)**:原建议 `EXISTS(plan_executions) OR contactAttempts > 0 OR view`。实测 `contact_attempts > 0` **仅 7 条,与 `plan_executions` 同源**(提交执行时才累加),帮不上忙。可用信号只有 **`view` 事件(已 1,024 条)**。理由不变:回写率仅 11%,只看执行记录会把 89% 已打过电话的单静默收走。⚠️ 另注意 **撤销(主管收整批) ≠ 退回(客服退单条)**,见教条 T21 |
......@@ -178,7 +178,7 @@ Y 轴 窗口温度 plan_reasons 566,365 条 ← daysSince 现成,读时
### F3 · `RECYCLE_TIMEOUT_HOURS` 硬编码 —— **降级,不做 env 化**
教条五之二写「T11 时不可配」是**误判**:T11 的可配性由新的 `assignment_expires_at` 承担,且同节自己也定了「两条路不互相干扰」。加上自动回收生产未启用(`PAC_PLAN_AUTO_RECYCLE` 未配)—— env 化一个**没人读的常量**是纯噪音。
教条五之二写「T11 时不可配」是**误判**:T11 的可配性由新的 `assignment_expires_at` 承担,且同节自己也定了「两条路不互相干扰」。加上自动回收生产未启用(`PAC_PLAN_AUTO_RECYCLE` 未配)—— env 化一个**没人读的常量**是纯噪音。
**降级为 0.05 人日改两处注释**`plan.service.ts:50`(「后续接 tenant 配置」)与 `schema.prisma:1024`(「= assignedAt + tenant.rules_config.recycleTimeoutHours」)—— **PAC 全仓没有 `Tenant` 模型**,那句话会持续误导后来人。删掉它。
......@@ -292,7 +292,7 @@ created_at / updated_at
| 列 | 类型 | 注释要点 |
|---|---|---|
| `assignment_id` | `UUID?` | **null = 自助认领**,不是「未知」。⚠️ 退回时**不清空**(退回率的分母) |
| `assignment_expires_at` | `TIMESTAMPTZ(3)?` | 生效时;⚠️ 与 `recycle_at` 是两条互不干扰的路,注释互指 |
| `assignment_expires_at` | `TIMESTAMPTZ(3)?` | 生效时;⚠️ 与 `recycle_at` 是两条互不干扰的路,注释互指 |
| `assigned_by` | `TEXT?` | 谁分的 |
| `release_reason` | `TEXT?` | ⚠️ ≠ `recall_feedback`(1044);⚠️ 不写抑制窗 |
| `release_note` | `TEXT?` `@db.Text` | `other` 时必填 |
......@@ -434,7 +434,7 @@ POST /pac/v1/plans/assignments/:id/revoke @RequirePermission(PLAN_DISPATCH)
1. 「你**全程只读**。唯一改变数据的动作是主管在确认单上点确认,那由界面完成。**绝不要说『已经分配好了』**,正确说法是『确认单已呈现,请过目』」(T8 最容易破的地方)
2. 「全景阶段**不问意图、不做画像分层**,直接出确认单」(T13 / T9-A)
3. 「凡是没有历史数据支撑的建议值(时 / 容量 / 专属优先),**必须当场标明是默认值**;工具返回的 `capacityNote` / `rosterNote` **原话抄进去**」(T14)
3. 「凡是没有历史数据支撑的建议值(时 / 容量 / 专属优先),**必须当场标明是默认值**;工具返回的 `capacityNote` / `rosterNote` **原话抄进去**」(T14)
4.`sufficient=false`**照抄 note,不输出百分比、不画图、不出 0.0%**」+ 背景事实(全生产 `plan_executions` 仅 7 条、`success_appointed` 0 条)(T20 / 五之四)
5. 「退回率永远给两个数:『退回 5 / 已处置 40 = 12.5%(另有 60 未动)』」(五之四)
6. 「在岗/专属客服是**近似值**,原样转述 `rosterNote`,不说成『系统确认在职』,不替主管挡人」(六·已定取舍:不校验在岗)
......@@ -448,7 +448,7 @@ POST /pac/v1/plans/assignments/:id/revoke @RequirePermission(PLAN_DISPATCH)
**验收**
- P3.2 生产量级(166.7 万行)`EXPLAIN ANALYZE`**新索引**,< 500ms;⭐ 造一条 `task_date=2033` 的记录,验证该客服**不**出现在名册里
- P3.3 对话验收:给 `n=12、转化 0` 的批次 → 输出必须含「样本量不足」,**不得出现「0.0%」**;给时建议 → 必须含「默认值」
- P3.3 对话验收:给 `n=12、转化 0` 的批次 → 输出必须含「样本量不足」,**不得出现「0.0%」**;给时建议 → 必须含「默认值」
- P3.4 侧信道验收:tool result 回到模型的内容 **< 100 token**,而前端收到的 payload 含完整 planId 列表
- P3.5 staff token 调 `list_recall_queue({view:'pool'})` → 拒绝或降级为 `mine`
......@@ -506,7 +506,7 @@ POST /pac/v1/plans/assignments/:id/revoke @RequirePermission(PLAN_DISPATCH)
| **新建** `components/assistant/assignment-confirm-sheet.tsx` | 三层:汇总 → 按客服卡片列表(**不是 table**,400px 宽 `assistant-widget.tsx:27`)→ 患者明细(按客服折叠) |
| **新建** `components/plans/assignments-api.ts` | 对齐 `plans-api.ts:12-70` |
**微调只有两项**(T13:指定客服 / 时效)。多加一项直接违 T13,**评审按这条卡**。时效必须带「默认值」标注,文案照抄教条:「3 天(默认值,暂无历史结案数据,积累后按实际反推)」。溢出转铺平要**显式可见**(T15):被铺平的组打角标「专属容量不足,已转铺平」,可点开改(并写回 `assignStrategy`)。
**微调只有两项**(T13:指定客服 / 时限)。多加一项直接违 T13,**评审按这条卡**。时限必须带「默认值」标注,文案照抄教条:「3 天(默认值,暂无历史结案数据,积累后按实际反推)」。溢出转铺平要**显式可见**(T15):被铺平的组打角标「专属容量不足,已转铺平」,可点开改(并写回 `assignStrategy`)。
底部「确认分配」按 `<Can perm={PLAN_DISPATCH}>` 包住(`components/can.tsx:24` 支持 fallback)。
......@@ -575,7 +575,7 @@ POST /pac/v1/plans/assignments/:id/revoke @RequirePermission(PLAN_DISPATCH)
→ 点「移交助手」(emitPetEvent + assistantStore.ask)
② 精选 助手全景直出:产能视图 + 拟分方案(get_agents 名册+负载)
③ 确认 propose_assignment → 侧信道 → 原生确认单三层卡片
微调仅两项(指定客服 / 时),铺平角标可见
微调仅两项(指定客服 / 时),铺平角标可见
④ 分配 点确认 → POST /pac/v1/plans/assignments
→ 落批次 + N 条归属 + N 条 assign 事件 → 注入文本块补记忆
⑤ 执行 切 staff 账号 → 「我的」看到单(左栏只有「我的」,无认领入口)→ 提交执行结果
......@@ -768,7 +768,7 @@ T4 已把落点写死:`shared/fact-block.ts`(标准 + 深度)与 `tiers/st
| **Q-4** 🟠 | **MVS 期福利的落地方式降级** | T4 明写福利要进 prompt(`shared/fact-block.ts` + `tiers/stable/prompt.ts`),但撞上 `plan_scripts.planId @unique` 的缓存问题(R8),最坏会**对患者做虚假承诺** | 建议 MVS 期降级为「独立静态字段,客服自己念,不经 LLM」—— 零合规风险、零重生成成本,T4 的归因目的完全达成;P6.6 再做 prompt 融入 + 失效规则 | P5 |
| **Q-5** 🟠 | **批次规模统计的漂移是否可接受** | 一个 plan 在批次 N 被分 → 退回 → 进批次 N+1 → `assignment_id` 翻成 N+1,批次 N 的 COUNT 悄悄少 1 | ① 接受(简单,符合「主表存当前值」的既有模式)② 不接受 → 需要一张 `plan_assignment_members` 关联表(+0.5 人日 + 一列迁移)。⛔ 「写进 `plan_event_logs.details`」的补丁方案已被否(R12) | P1.1 建表 |
| ~~**Q-6**~~ ✅ | ~~**温度的 8 标签 × 窗口映射**~~ | **已裁决(2026-08-02),见教条 T6′** | ⭐ 原建议(标签级窗口表 8 行常量)**已否**。真正的解法是**换一层聚合**:逐条 gap 用自己 K 码的窗口判档、再取最热 —— 一对多自动消解,不需要任何新常量,`DiagnosisTreatmentMap` 是真的被复用了。原判「无解」源于默认了聚合必须发生在天数层 | — |
| **Q-7** 🟡 | **分配单到期后的行为**(教条七·待确认) | ① 自动回池 ② 只提醒 ③ 两者皆有。⚠️ ①/③ 会擦 T8 的判据(「任何改变数据库状态的动作只能由主管确认触发」)。可辩护的解释是「主管在确认单上确认了时 = 预授权」,但**这属于产品解释权,不该由实现认定** | 建议 **MVS 只做 ②**(写入 + 超期标红),①/③ 推到 v2。若选 ①/③,需在确认单文案上把到期行为显式写出来,把预授权变成看得见的;且**必须落 `release_reason='expired'`**(否则 T20 的分母不干净)、**必须照抄 `recycle-scheduler.service.ts:64-68` 的 `snoozedUntil` 守卫**(约好 6/10 回访的单不能被收走) | 不卡 MVS |
| **Q-7** 🟡 | **分配单到期后的行为**(教条七·待确认) | ① 自动回池 ② 只提醒 ③ 两者皆有。⚠️ ①/③ 会擦 T8 的判据(「任何改变数据库状态的动作只能由主管确认触发」)。可辩护的解释是「主管在确认单上确认了时 = 预授权」,但**这属于产品解释权,不该由实现认定** | 建议 **MVS 只做 ②**(写入 + 超期标红),①/③ 推到 v2。若选 ①/③,需在确认单文案上把到期行为显式写出来,把预授权变成看得见的;且**必须落 `release_reason='expired'`**(否则 T20 的分母不干净)、**必须照抄 `recycle-scheduler.service.ts:64-68` 的 `snoozedUntil` 守卫**(约好 6/10 回访的单不能被收走) | 不卡 MVS |
| **Q-8** 🟡 | **撤销时限** | 建议可配 env、默认 **30 分钟**,并按 T14 在助手话术里标明是默认值 | 语义是「手滑/分错人」的补救,不是「改主意重新调度」(改主意应走退回 + 重分)。⚠️ 它是 UX 摩擦不是安全边界(R18) | P6.4 |
| **Q-9** 🟡 | **已被认领的单能否强制改派**(教条七·待确认) | 当前后端拦住(`plan.service.ts:589-591`),教条建议保留该摩擦 | 建议保持拦住 → 进 `skipped(reason:'claimed_by_other')`。⭐ 该判据已收口到 `claim-guard.assertAssignable` **一个函数**,将来改只改这一处 | 不卡 |
| **Q-10** 🟢 | 矩阵在 `<lg` 小屏是否提供 | 建议**不提供**(入口加 `lg:` 藏掉)—— 8×3 在 375px 抽屉里做不出可读密度,主管基本桌面办公。若要求移动端可用,需另做纵向堆叠版 +1.0 人日 | 不卡 |
......
......@@ -88,14 +88,14 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
> ⚠️ 它**仍然是默认值**:没有任何数据证明 100 比 80 或 150 好,等 T20 沉淀出
> 「批次规模 × 完成率」再替换。⛔ 别给它加上下限 —— 这是主管对自己团队的判断。
### T22 · 分配只有两个基数:**本批人数 N** 和 **时**,且都沿用主管上一次的值
### T22 · 分配只有两个基数:**本批人数 N** 和 **时**,且都沿用主管上一次的值
(2026-08-03 产品定;同日两次改判,下面把弯路一并记下来,别再走一遍)
| 基数 | 含义 | 首次 | 之后 |
|---|---|---|---|
| **本批人数 N** | 这一轮推多少人 | `min(在岗人数 × 20, 候选总数)` | `min(上一次的 N, 候选总数)` |
| **时** | 这批单子多久没动就自动回池 | 3 天 | 沿用上一次 |
| **时** | 这批单子多久没动就自动回池 | 3 天 | 沿用上一次 |
⚠️ 首次那个 **20 不是容量上限**,只是"第一次没有任何历史时,一批推多大"的估法,
之后就再也不出现(沿用主管上次用的数)。
......@@ -170,15 +170,15 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
| 卡片上做 | 回对话做 |
|---|---|
| 改批次时(右上角「N 天后自动退回」) | 换人群(换治疗项 / 温度 / 画像条件) |
| 逐条改时(1-7 天) | 改批次人数 N |
| 改批次时(右上角「N 天后自动退回」) | 换人群(换治疗项 / 温度 / 画像条件) |
| 逐条改时(1-7 天) | 改批次人数 N |
| 删掉某一条 | 按客服设本批名额 `maxThisBatch` |
| 把某一条**拖**给别的客服 | |
| 移除某个客服(连同他名下条目) | |
⚠️ 批次时放在**卡片右上角**,与左边"拟分 N 人"同一层级 —— 它是"这一整批"的设置。
放在底部微调区会让人以为它跟条目上的逐条时是一回事。
⚠️ 文案是「N 天**后自动退回**」而不是光一个「时」:主管要知道到期会发生什么(单子回池),
⚠️ 批次时放在**卡片右上角**,与左边"拟分 N 人"同一层级 —— 它是"这一整批"的设置。
放在底部微调区会让人以为它跟条目上的逐条时是一回事。
⚠️ 文案是「N 天**后自动退回**」而不是光一个「时」:主管要知道到期会发生什么(单子回池),
不然这个数对他没有意义。
⚠️ 这不违 T13「微调只有两项」—— 那条防的是"卡片变成第二个筛选器"。
......@@ -248,7 +248,7 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
> 🔴 **踩过的坑:`key={i}` + 「把确认单钉到消息末尾」= 卡片状态全丢。**
> 助手每追加一句文本,sheet 在排序后数组里的下标就变 → React 卸载重建组件 →
> 主管删的条、拖的改派、逐条时、以及"能不能撤销"的确认时刻**全部清空**。
> 主管删的条、拖的改派、逐条时、以及"能不能撤销"的确认时刻**全部清空**。
> 症状是"确认完撤销按钮压根不出现",但真正丢的远不止那一个按钮。
> 现在确认单按 `requestId` 做 key。⛔ 凡是**带本地状态**的 block,key 都不能跟着位置走。
......@@ -312,7 +312,7 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
| `maxThisBatch` | **这一批**最多给他几条(0 = 这轮不给) | 会(少的那部分由别人接走,总数不变;但名单变了) | 回对话说一句,助手重出确认单 |
| `expiresInDays` | 他名下每条任务的到期 | 不会 | 卡片上展开那一行直接改(1-7 天下拉) |
⚠️ 时**落到每一条召回计划**`followup_plans.assignment_expires_at` 逐行写),
⚠️ 时**落到每一条召回计划**`followup_plans.assignment_expires_at` 逐行写),
不是只在批次头上挂一个 —— 所以按客服精调、乃至将来按单条精调,都不需要动表结构。
⚠️ 卡片上给**全部 1-7 天**而不是 3/5/7 三档:档位是我们拍的,
而主管对自己团队的节奏有判断(「明天就要」= 1 天)。⛔ 别用三个拍的档位限制他。
......@@ -323,10 +323,10 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
> (服务端算一遍、随确认单发到浏览器、然后被丢掉)—— 也就是说这个能力早就不在了,
> 删掉 `basisNote` 只是让它显形。现在唯一的出处是 `modelFacts` 的 **`他单独设过的`**
> (卡片上也没有:精调只在确认时随请求回传)。⛔ 别再往 `basisNote` 里加东西,那个字段已删。
精调落到 `followup_plans.assignment_expires_at`(写路径早已支持逐条覆盖)。
精调落到 `followup_plans.assignment_expires_at`(写路径早已支持逐条覆盖)。
**为什么是"沿用上一次"而不是"每次问"**:确认单本来就是给主管调的 ——
他第一次把人数和时调到顺手,系统记住,**第二次起零输入**
他第一次把人数和时调到顺手,系统记住,**第二次起零输入**
这是「先出全景确认单、再让主管反馈调整」这个设计的兑现点:调一次,以后省一次。
⛔ 既不能做成每次弹窗问一遍(违 T13 直出不追问),也不能永远用系统默认(第一次的调整白费)。
......@@ -498,7 +498,7 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
> 枚举齐了(8 个 `ReleaseReason` + `needNote`)、API 收得了、`releaseReasons` 分布报表也写了 ——
> 但**前端唯一的退回入口 `plansApi.recycle(planId)` 一个参数都没传**,也没有弹窗。
> 于是那张分布表拿到的永远是 null:主管只知道"退了 12 条",不知道该改什么,
> 而"派多了 / 时太紧 / 压根不该派给他"这三种的下一步动作完全不同。
> 而"派多了 / 时太紧 / 压根不该派给他"这三种的下一步动作完全不同。
>
> 现在:前端弹 `ReleaseReasonDialog`(8 个原因各带一句 desc —— 光看标题分不出「不是我的客户」
> 和「该由其他角色跟」,分不出就会随手点第一个,**假数据比没数据更难发现**);
......@@ -534,10 +534,10 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
以后要加权益身份、治疗史、家庭结构,只是多传一个 `key`**不为每个因素开新接口**
画像能力对助手是**一个可触发的动作**,不是一堆预置维度。
### T11 · 分配单有时性,且必须在确认单上指明
### T11 · 分配单有时性,且必须在确认单上指明
分配不是永久归属,**带有效期**(如 3 天)。
依据 = 召回池存量 + 客服负载完成量 + 主管习惯,**综合预估后由助手建议、主管确认**
依据 = 召回池存量 + 客服负载完成量 + 主管习惯,**综合预估后由助手建议、主管确认**
### T12 · 认领与分配同构 —— 差别只在触发人
......@@ -552,7 +552,7 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
- **不问主管意图** —— 意图由**助手推导**(可用依据:初选的治疗项+温度、当前月份、池子存量),
是助手给自己找依据的内部推理,**只影响建议措辞**,不占主管的注意力,也不要求主管事后反馈。
- **直出明细**,分三层:汇总 → 按客服(产能视图)→ 患者明细(按客服折叠,展开才看)。
- **页面可微调,且只微调两项**:指定客服、时
- **页面可微调,且只微调两项**:指定客服、时
微调项越多,「一次确认」就越不可能实现;换人群回到对话,由助手触发画像圈人(T9-B)。
### T14 · 助手不出没有证据的结果
......@@ -563,7 +563,7 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
- 首次无历史 → 用**行业通用 / 第一性**推出的默认值,**并标明是默认值**
- 有数据之后 → 反推真实习惯,替换默认值
> 例:时「3 天」现在只能标注为「默认值(暂无历史结案数据,积累后按实际反推)」,
> 例:时「3 天」现在只能标注为「默认值(暂无历史结案数据,积累后按实际反推)」,
> **不能**写成「依据该诊所平均结案 2.4 天」—— 生产 `plan_executions` 仅 7 条,算不出来。
### T15 · 溢出默认转铺平
......@@ -578,7 +578,7 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
① 初选 主管在矩阵上点一格(潜在治疗项目 × 温度) ← 主管界面,与助手无关
② 精选 A 全景:产能视图 + 意图承接 + 引导建议 ← 助手登场,不做画像分层
B 调整:主管追问 → 助手触发画像圈人精确响应
③ 定制 分配策略 + 福利 + 时确认
③ 定制 分配策略 + 福利 + 时确认
④ 分配 主管确认 → 落到客服 ← 唯一的写动作
⑤ 跟踪 在手/超期/退回率/退回原因/批次效果
```
......@@ -687,7 +687,7 @@ plan_event_logs view 1,024 ✅ 唯一可用 —— 客服打开过详情页
| 助手当前的默认值 | 沉淀后反推成 |
|---|---|
| 时「3 天(默认)」 | 该诊所该治疗项的**实际结案中位数** |
| 时「3 天(默认)」 | 该诊所该治疗项的**实际结案中位数** |
| 容量「20-50」 | 各客服**实际能吃多少**(完成率开始下滑的拐点) |
| 「专属优先,溢出铺平」 | **专属 vs 铺平的完成率差**是否显著 |
| 福利「建议配 / 不必配」 | 带福利 vs 不带的**完成率差** |
......@@ -857,7 +857,7 @@ created_by 发起分配的主管(宿主侧 user id)
created_at / updated_at
criteria Json 初筛条件快照 { potentialTreatment, temperature, ... } ← T17 不立柱
attributes Json? 附加属性 { benefit?: { text } } ← T17 福利只是其中一种
expires_at 时(T11);单条可覆盖
expires_at 时(T11);单条可覆盖
status confirmed | revoked
revoked_at / revoked_by
```
......@@ -868,7 +868,7 @@ revoked_at / revoked_by
```
assignment_id FK → plan_assignments null = 自助认领
assignment_expires_at 该单时(继承批次,允许单条改)
assignment_expires_at 该单时(继承批次,允许单条改)
assigned_by 谁分的(现有只记 assignee_user_id「分给谁」,缺「谁分的」)
release_reason 退回原因(结构化,可统计分布 → T7)
release_note 退回文字说明
......@@ -894,10 +894,10 @@ release_note 退回文字说明
|---|---|
| 引擎丢归属不记账(`plan-engine.service.ts:398`) | 分配单被静默收回,主管无感知;退回率偏低 |
| `recycle``reason` 被丢弃(`plan.controller.ts:127`) | **T7「退回必须说明原因」现在落不了地** |
| `RECYCLE_TIMEOUT_HOURS` 硬编码 | T11 时不可配 |
| `RECYCLE_TIMEOUT_HOURS` 硬编码 | T11 时不可配 |
> 自动回收当前**未启用**(`PAC_PLAN_AUTO_RECYCLE` 未配),`recycle_at` 保持现状不动;
> 分配时走新的 `assignment_expires_at`,两条路不互相干扰。
> 分配时走新的 `assignment_expires_at`,两条路不互相干扰。
---
......@@ -1034,7 +1034,7 @@ artifact iframe 是 `sandbox="allow-scripts"` + CSP `connect-src 'none'`,**卡
> ⚠️ 同一 plan 可能有**多条 assign**(退回→自认领→再退回),去重后再计数。
> ⭐ **到期 ≠ 退回,两个数必须分开报。** 主管的下一步动作**相反**:
> 退回多 → 分配策略不对(派给了不该派的人);到期多 → 派多了 / 时太紧 / 人不在岗。
> 退回多 → 分配策略不对(派给了不该派的人);到期多 → 派多了 / 时太紧 / 人不在岗。
> 合成一个"回池率"两种病都看不出来。
> (枚举注释早就写清了「到期是**没处置**、退回是**处置**」,所以到期刻意不写
> `followup_plans.release_reason` —— 但此前**任何接口都没把到期数报出来**,
......@@ -1123,7 +1123,7 @@ getAgentWorkload({ userIds? })
### 数据来源:全部现有表 + 一个新增关联,**不需要新建统计表**
```
plan_assignments 批次本身:时、福利、初筛条件、状态
plan_assignments 批次本身:时、福利、初筛条件、状态
followup_plans .assignment_id → 分了哪些、现在什么状态、超期没
plan_event_logs 经 plan_id join → 认领/退回/反馈全流水
plan_executions 经 plan_id → 通话结果、转化
......@@ -1184,7 +1184,7 @@ patient_transactions 经 patient_id → 客观新预约(canonical_payload.cre
- 已被认领的单能否强制改派(现后端拦住 —— 「Plan 已分配给 X;回收后再分配」,建议保留该摩擦)
- 分配单**到期后**的行为:自动回池 / 提醒主管 / 两者皆有
- 全景阶段主管意图的**捕获方式**:助手主动问 / 预设选项 / 可跳过
-性建议的**具体算法**(召回池存量 + 负载完成量 + 主管习惯,如何加权)
-性建议的**具体算法**(召回池存量 + 负载完成量 + 主管习惯,如何加权)
- 批次的规模建议上限
---
......@@ -1197,7 +1197,7 @@ patient_transactions 经 patient_id → 客观新预约(canonical_payload.cre
|---|---|---|---|
| **S1 · 闭环**<br/>(MVS,≈19 人日) | 初选矩阵 → 助手全景确认单 → 主管确认分配 → 客服执行/退回 → 基础跟踪 | 主管 + 客服 | 表结构 + REST 写路径 + 助手读工具 + 前端 T16 改造 |
| **S2 · 完备** | 撤销整批 · 福利进话术 · 调整阶段画像圈人 · 矩阵性能优化 | 同上 | S1 已上线并跑过真实批次 |
| **S3 · 自优化** | 用沉淀数据**替换默认值**(时/容量/专属策略/福利效果) | 主管(体现在助手建议里) | 需 **n≥50** 的执行样本(T20/T14);当前生产仅 7 条 |
| **S3 · 自优化** | 用沉淀数据**替换默认值**(时/容量/专属策略/福利效果) | 主管(体现在助手建议里) | 需 **n≥50** 的执行样本(T20/T14);当前生产仅 7 条 |
| **S4 · MCP 写** | 助手可直接执行分配(外部 agent 亦可) | 助手 / 外部 agent | **MCP 鉴权补齐**(现为 `@Public()``PermissionsGuard` 短路) |
| **S5 · 客服主动性** | 客服在余量内自助认领(余量 + 任务相关性) | 客服 | 分配这条路跑顺;见 T16 「加回一个入口的事」 |
......
......@@ -839,7 +839,7 @@ export function abandonReasonsFor(panel: 'form' | 'close'): AbandonReason[] {
* 2. **≠ 召回反馈 [[RECALL_FEEDBACK_OPTIONS]]**。反馈回答「这条召回准不准」(冲算法去的);
* 退回回答「我为什么不接」(冲派单去的)。同一次退回可能两者都填,也可能只填一个。
* ⚠️ 值域刻意不复用:`bad_timing` 在反馈里指「召回时机不对(太早/太晚)」,
* 在退回里会被读成「时太紧」—— 同名不同义是统计事故的标准配方,故本枚举不设该键。
* 在退回里会被读成「时太紧」—— 同名不同义是统计事故的标准配方,故本枚举不设该键。
*
* 3. ⛔ **本枚举永远不带 `suppressDays`** —— 类型里就不给这个字段,用编译器拦住。
* 照抄 ABANDON_REASON_META 那套抑制窗会把被退回的患者静默压 30~90 天,
......@@ -917,7 +917,7 @@ export type ReleaseReasonLever =
| 'capacity'
/// 名册口径 —— 在岗判定准不准
| 'roster'
/// 时默认值 —— 给几天
/// 时默认值 —— 给几天
| 'timing'
/// 数据质量 —— 摄入侧的缺口,不是分配策略问题
| 'data'
......@@ -964,7 +964,7 @@ export const RELEASE_REASON_META: Record<
// ── 历史值:不再展示,仅供翻译老数据 ────────────────────────────
not_my_patient: { labelZh: '不是我的客户', group: 'person', lever: 'targeting', hidden: true },
agent_unavailable: { labelZh: '这段时间不在', group: 'capacity', lever: 'roster', hidden: true },
deadline_too_tight: { labelZh: '时太紧', group: 'timing', lever: 'timing', hidden: true },
deadline_too_tight: { labelZh: '时太紧', group: 'timing', lever: 'timing', hidden: true },
recently_contacted: { labelZh: '最近刚联系过', group: 'timing', lever: 'targeting', hidden: true },
over_capacity: { labelZh: '手上排满了', group: 'capacity', lever: 'capacity', hidden: true },
};
......
......@@ -11,7 +11,7 @@ import { AssignStrategySchema } from '../enums';
* 为什么不用分组形状(看着更省字节):
* 1. 归属是**逐条**算出来的 —— 溢出转铺平后同一个客服名下会混着 dedicated 与
* spread_overflow 两种来源,分组形状表达不了「这一条为什么落到他头上」。
* 2. T13 允许主管在确认单上微调时,而微调是**逐条**的
* 2. T13 允许主管在确认单上微调时,而微调是**逐条**的
* (「本批 3 天,其中 5 条单独设了 7 天」)—— 分组形状里那 5 条无处安放。
*/
export const AssignmentItemSchema = z.object({
......@@ -20,7 +20,7 @@ export const AssignmentItemSchema = z.object({
assigneeUserId: z.string().min(1),
/// ⭐ 逐条带:这条是怎么落到他头上的。事后补不回来,见 AssignStrategy 的注释
assignStrategy: AssignStrategySchema,
/// 单条覆盖批次时(不传 = 跟批次)。相对天数,与批次同口径,服务端统一转绝对时刻
/// 单条覆盖批次时(不传 = 跟批次)。相对天数,与批次同口径,服务端统一转绝对时刻
expiresInDays: z.number().int().positive().max(90).optional(),
/**
* 这条是**怎么被选进批次**的。
......@@ -63,7 +63,7 @@ export const CreateAssignmentRequestSchema = z.object({
.object({ benefit: z.object({ text: z.string().min(1).max(200) }).optional() })
.optional(),
/**
* 批次时(相对天数)。
* 批次时(相对天数)。
* ⚠️ 收相对天数而不是绝对时刻:让模型算 ISO 时刻正是本仓 commit 4549817 修过的坑
* (纯日期被当成 UTC 零点,整整偏 8 小时)。服务端按 host 时区转成**当地日末**。
*/
......@@ -77,13 +77,13 @@ export type CreateAssignmentRequest = z.infer<typeof CreateAssignmentRequestSche
// =============================================================
/**
* ═══ 分配只有两个基数:**本批人数 N** 和 **时** ═══════════════════
* ═══ 分配只有两个基数:**本批人数 N** 和 **时** ═══════════════════
*
* 两者的取值方式完全一致(2026-08-03 产品定):
* **沿用该主管在该诊所上一次分配用的值**;没有上一次才用下面的默认。
*
* ⭐ 为什么是"沿用"而不是"每次问":确认单本来就是给主管调的 ——
* 第一次他把人数和时调到顺手,系统记住,**第二次起他什么都不用输**。
* 第一次他把人数和时调到顺手,系统记住,**第二次起他什么都不用输**。
* 这是"先出全景确认单、再让主管反馈调整"这个设计的兑现点:调一次,以后省一次。
* ⛔ 所以别把它们做成"每次弹窗问一遍"或"永远用系统默认" —— 前者违 T13(直出不追问),
* 后者让第一次的调整白费。
......@@ -95,7 +95,7 @@ export type CreateAssignmentRequest = z.infer<typeof CreateAssignmentRequestSche
/**
* **首次**分配时,每个在岗客服按多少条估 —— 首批 N = `min(在岗人数 × 20, 本批实际候选数)`。
*
* ⭐ 2026-08-03 定案:分配只有 **本批人数 N** 和 **时** 两个基数,⛔ **没有"容量"这个数**。
* ⭐ 2026-08-03 定案:分配只有 **本批人数 N** 和 **时** 两个基数,⛔ **没有"容量"这个数**。
* 这里的 20 **不是容量上限**,只是"第一次没有任何历史时,一批推多大"的估法 ——
* 它只在**首次**参与一次计算,之后 N 沿用主管上次用的值,这个常量再也不出现。
*
......@@ -129,10 +129,10 @@ export const BATCH_SIZE_PER_AGENT_FIRST = 20;
export const DAILY_CALLS_PER_AGENT = 15;
/**
* 批次时的默认天数。
* 批次时的默认天数。
*
* 🔴 2026-08-12 由 3 改为 **1**(产品定):走「**每天推一天的活**」的节奏 ——
* N = 在岗人数 × 每天 ${DAILY_CALLS_PER_AGENT} 通 × 时,D=1 时正好是一天的量,
* N = 在岗人数 × 每天 ${DAILY_CALLS_PER_AGENT} 通 × 时,D=1 时正好是一天的量,
* 当天没打完的自动落回池子、明天重排。
* ⚠️ 连带后果(已知并接受):`daily_overload` 的阈值随之变成 `15 × 1`,
* 于是**只要谁手上还有旧单,这个节点就会亮**。那不是 bug ——
......@@ -140,12 +140,12 @@ export const DAILY_CALLS_PER_AGENT = 15;
*/
export const ASSIGNMENT_EXPIRES_DAYS_DEFAULT = 1;
/**
* 卡片上时可选的天数。**给全部 1-7 天**,不是 3/5/7 三档。
* 卡片上时可选的天数。**给全部 1-7 天**,不是 3/5/7 三档。
*
* ⭐ 档位是我们拍的,而主管对自己团队的节奏有判断(「明天就要结果」= 1 天;「这周内」= 5 天)——
* ⛔ 别用三个我们拍的档位去限制他。
* ⚠️ 到 7 为止不是业务上限:契约允许 90 天,真要更长在对话里说个数,助手照传。
* ⚠️ 时**不改人群**,所以留在卡片上;改人数会换一批人,那必须回对话重出单。
* ⚠️ 时**不改人群**,所以留在卡片上;改人数会换一批人,那必须回对话重出单。
*/
export const ASSIGNMENT_EXPIRES_DAYS_PRESETS: readonly number[] = [1, 2, 3, 4, 5, 6, 7];
......@@ -201,7 +201,7 @@ export const CreateAssignmentResponseSchema = z.object({
duplicate: z.boolean(),
assigned: z.number().int(),
skipped: z.array(AssignmentSkippedSchema),
/// 落库后的批次时(绝对时刻,前端直接展示,不要自己再算一遍)
/// 落库后的批次时(绝对时刻,前端直接展示,不要自己再算一遍)
expiresAt: z.string(),
});
export type CreateAssignmentResponse = z.infer<typeof CreateAssignmentResponseSchema>;
......@@ -253,7 +253,7 @@ export const AssignmentBriefSchema = z.object({
*
* ⚠️ 这是**"没处置"**,不是"处置了"。它和 released 必须分开看,因为主管的下一步相反:
* 退回多 → 分配策略不对(派给了不该派的人);
* 到期多 → 派多了 / 时太紧 / 人不在岗。
* 到期多 → 派多了 / 时太紧 / 人不在岗。
* 合成一个"回池率"两种病都看不出来。
*/
expired: z.number().int().describe('到期自动回池(客服没动)'),
......@@ -309,7 +309,7 @@ export const AssignmentAgentStatSchema = z.object({
inHand: z.number().int(),
released: z.number().int(),
/**
* 仍在手、已过时、**且没有约定回访**。
* 仍在手、已过时、**且没有约定回访**。
*
* 🔴 「且没有约定回访」这半句不能少。到期回收器刻意跳过 `snoozedUntil` 在未来的单
* (客服约了 6/10 回访就绝不能在那之前收走,否则毁掉他对患者的承诺)——
......@@ -317,7 +317,7 @@ export const AssignmentAgentStatSchema = z.object({
* 不加这条守卫,同一条单就会「已处理」和「超期」同时成立:主管看到「薛玫 超期 3」
* 以为她压着单没动,**实际上她打了电话、约好了下次** —— 干得最好的那个被指责了。
*/
overdue: z.number().int().describe('仍在手、已过时、且没约下次回访'),
overdue: z.number().int().describe('仍在手、已过时、且没约下次回访'),
/**
* 这个客服在本批里已处置多少条。
* ⚠️ 判据与批次级的 `progress.done` **同一个函数**(classifyPlanProgress)——
......@@ -552,13 +552,13 @@ export type PoolOwnership = z.infer<typeof PoolOwnershipSchema>;
/**
* 按客服的**精调**。
*
* 两个基数(容量/时)是**整体默认**,这里是针对某一个人的覆盖:
* 两个基数(容量/时)是**整体默认**,这里是针对某一个人的覆盖:
* 「李莉这周带教,只给 5 条」「王强手上都是老客,给他 7 天」。
*
* ⚠️ 两者的改动性质**不同**,所以入口也不同:
* · `capacity` 改的是**人数**(Σ 容量−在手)→ 会换人群 → 必须回对话让助手重出确认单;
* · `expiresInDays` 不改人群 → 就在卡片上那一行改(落到该客服名下**每一条任务**的
* `followup_plans.assignment_expires_at`,与批次时同口径)。
* `followup_plans.assignment_expires_at`,与批次时同口径)。
*/
export const AgentOverrideSchema = z.object({
/**
......@@ -580,7 +580,7 @@ export const ProposalAgentRowSchema = z.object({
/// ⭐ 分配**之后**他手上有多少(= inHandBefore + count)。
/// 这是「负载」的唯一表达 —— 没有阈值线,主管自己看这一列齐不齐、高不高
loadAfter: z.number().int(),
/// 本批对**这个人**生效的时效(没精调就等于批次时效)。逐人下发,⛔ 别让前端再合并一次
/// 本批对**这个人**生效的时限(没精调就等于批次时限)。逐人下发,⛔ 别让前端再合并一次
expiresInDays: z.number().int(),
/// 是否被精调过(卡片上要标出来,否则"为什么李莉只有 5 条"没人答得上)
overridden: z.boolean(),
......@@ -615,7 +615,7 @@ export const SignalOptionSchema = z.object({
intent: z.string().describe('动作契约 id'),
args: z.record(z.string(), z.unknown()).optional(),
/**
* 这个选项要主管**填一个数**(时几天 / 每天几通)—— 界面渲染成内联数字输入框,
* 这个选项要主管**填一个数**(时几天 / 每天几通)—— 界面渲染成内联数字输入框,
* 提交时把值并进 `args[argKey]`。
*
* ⭐ 为什么不做成几个固定档的按钮:天数和通数是**连续量**,列 3/5/7 天必然漏掉他要的那个,
......@@ -651,7 +651,7 @@ export const SignalSchema = z.object({
* 这一条**改的是什么** —— 决定它在对话里出现在哪儿(2026-08-13 产品定)。
*
* · `cohort` 改**圈进来哪些人**(换个条件圈 / 这一格人不够,往后放一档)
* · `size` 改**这一批发多少**(时几天 × 每天几通 → N)
* · `size` 改**这一批发多少**(时几天 × 每天几通 → N)
* · `dispatch` 改**怎么派**(专属客服排满怎么办 / 最忙的那位扛不扛得住)
* · `post_confirm` **已经分下去之后**还能做的事(补挂福利)——
* ⚠️ 它与前三个的区别不只是时间:前三个由 `computeSignals` 从提案算出来,
......@@ -662,7 +662,7 @@ export const SignalSchema = z.object({
*
* 🔴 `cohort` 与 `size` **必须分开**,虽然两者都讲在第 1 段。
* 合成一个键时模型会拿一个标题罩住两组按钮(实测:「还能怎么圈:」下面挂着
* 「改时效 / 改每天几通」)—— 而改时效根本不是圈人。⇒ **一个键只说一件事**。
* 「改时限 / 改每天几通」)—— 而改时限根本不是圈人。⇒ **一个键只说一件事**。
* ⚠️ 顺序是 `cohort` → `size`,⛔ 不许反:换人群会让 N 重新估一遍
* (实测候选 2509→312 之后,本批 495 直接被压成 312),先调 N 那下就白调了。
*
......@@ -734,7 +734,7 @@ export const AssignmentProposalSchema = z.object({
batchSize: z.number().int(),
/**
* 🔴 **算 `batchSize` 时乘进去的那个在岗人数** —— 存在的唯一理由是让
* 「在岗 N 人 × 每天几通 × 时」这句话里的 N **就是乘法用的那个变量**。
* 「在岗 N 人 × 每天几通 × 时」这句话里的 N **就是乘法用的那个变量**。
*
* ⚠️ 2026-08-15 实测事故:两处显示(`modelFacts`、`batch_size_basis` 卡片标题)
* 都拿 `byAgent.length` 当"在岗人数"印 —— 那是**本批分到人的客服数**,不是名册。
......@@ -762,7 +762,7 @@ export const AssignmentProposalSchema = z.object({
/// ── 两个基数,连同它们的**出处**一起下发 ────────────────────────
/// ⚠️ 出处必须跟着值走:主管看到「容量 20」时,「这 20 是哪来的」和这个数本身一样重要 ——
/// 沿用上次 / 首次默认 / 他自己刚说的,三者对应完全不同的信任度(T14:界面元素也算证据)。
expiresInDays: z.number().int().describe('批次时天数(基数②;卡片初值,主管可在卡片上改)'),
expiresInDays: z.number().int().describe('批次时天数(基数②;卡片初值,主管可在卡片上改)'),
basis: z
.enum(['inherited', 'default', 'explicit'])
.describe('基数来源:沿用上次 / 首次默认 / 本次主管明确指定'),
......@@ -850,7 +850,7 @@ export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>;
* 🔴 **眼前那张确认单「此刻」的样子** —— 主管在卡片上改过之后的**草稿态**。
*
* ═══ 为什么它必须由浏览器产 ═══════════════════════════════════════
* 确认之前,改动**全部只存在于卡片的组件状态里**(改派 / 移出 / 时 / 福利)——
* 确认之前,改动**全部只存在于卡片的组件状态里**(改派 / 移出 / 时 / 福利)——
* 服务端手上只有最初生成的那一版提案,还是个请求内的闭包,没落库也没缓存。
* ⇒ ⛔ 别指望服务端"查"得到它:`get_current_sheet` 查的就是这份随请求捎来的快照,
* ⛔ 也别为它去开一条"草稿回传"的写路径(多一份要维护的状态,只为省模型一次加法)。
......@@ -864,7 +864,7 @@ export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>;
export const SheetSnapshotSchema = z.object({
/// 哪一张单(服务端铸造的幂等键)——多轮之后能认出"说的是不是同一张"
requestId: z.string(),
/// 已经点过确认了没有。⚠️ true 时人员/时已锁死,只有福利还能改
/// 已经点过确认了没有。⚠️ true 时人员/时已锁死,只有福利还能改
confirmed: z.boolean(),
/// 现在会真正落库的条数(= 卡片上「已排好 N 人」)
placed: z.number().int(),
......@@ -876,9 +876,9 @@ export const SheetSnapshotSchema = z.object({
dropped: z.number().int(),
/// 被改派过的条数(拖动 / 助手改单都算)
moved: z.number().int(),
/// 整批时
/// 整批时
expiresInDays: z.number().int(),
/// 有几条单独改过时(不跟整批走)
/// 有几条单独改过时(不跟整批走)
rowExpiryOverrides: z.number().int(),
/// 本批福利原文;null = 还没带
benefit: z.string().nullable(),
......@@ -887,9 +887,9 @@ export const SheetSnapshotSchema = z.object({
/**
* 🔴 **这批人是谁** —— ⛔ 不许省(2026-08-15 golden 跑批当场抓到)。
*
* 快照第一版只带了"分得怎么样"(几条、几位客服、时、福利),主管说
* 快照第一版只带了"分得怎么样"(几条、几位客服、时、福利),主管说
* 「这批改成 200 人」时模型要重跑 `propose_assignment`,却**不知道原来那一格是什么**。
* 它的原话:「我这边只看到确认单本身(120 人、8 位客服、时 1 天),
* 它的原话:「我这边只看到确认单本身(120 人、8 位客服、时 1 天),
* 没留下当时选的是哪个治疗项、哪个时间档 —— 刚才我按全部候选重算了一版,
* 结果对不上……跟您眼前那批完全不是一回事」。
* ⇒ 要么它回头问主管(白跑一轮),要么**不带条件重算**(静默换成另一批人,更糟)。
......@@ -921,7 +921,7 @@ export type SheetSnapshot = z.infer<typeof SheetSnapshotSchema>;
export function sheetSnapshotLine(s: SheetSnapshot): string {
return (
`当前这张单:已排好 ${s.placed} 人 · ${s.agents} 位客服 · 待你定的 ${s.pending} 人 · ` +
`整批时 ${s.expiresInDays} 天 · 福利${s.benefit ? `「${s.benefit}」` : '还没带'}` +
`整批时 ${s.expiresInDays} 天 · 福利${s.benefit ? `「${s.benefit}」` : '还没带'}` +
(s.dropped ? ` · 已移出本批 ${s.dropped} 人` : '')
);
}
......@@ -968,7 +968,7 @@ export const RefillProposalRequestSchema = z.object({
* 正因为不影响人数,漏了更难被发现。
*/
dailyCalls: z.number().int().min(1).max(60).optional(),
/// 基数 N 与时原样带回,否则重排会退回"沿用上一次",人数悄悄变了
/// 基数 N 与时原样带回,否则重排会退回"沿用上一次",人数悄悄变了
targetCount: z.number().int().positive().optional(),
expiresInDays: z.number().int().positive().max(90).optional(),
});
......@@ -992,7 +992,7 @@ export type RefillProposalRequest = z.infer<typeof RefillProposalRequestSchema>;
* 每加一种"选谁"或一种"给谁",条数就要**乘一遍** —— 而没被乘出来的那些格子,
* 主管说到时模型只能掉进最像的那一格。当天实测栽的两次都是这个形状:
* ·「各自分给各自的专属客服」→ 只有铺平可用 → 18 人被散给了 17 位**别人**(语义正相反)
* ·「把康慧捧名下的都还给她」「待分配的时给 5 天」→ 至今一条都表达不了
* ·「把康慧捧名下的都还给她」「待分配的时给 5 天」→ 至今一条都表达不了
*
* ⚠️ **正交化不消灭"枚举漏值",只是把它变得可数**:少写一个 `mode` 照样会栽。
* 它真正买到的是 ① 加维度不再乘一遍 ② 缺的组合是**表格里的空格**(看得见),
......
......@@ -55,7 +55,7 @@ export const FollowupPlanSchema = z.object({
*
* 与 `recycleAt` 是**两个机制**,别混:
* · recycleAt 认领后 24h 兜底,**未启用**;
* · assignmentExpiresAt 主管在确认单上定的时(1-7 天),**已启用**
* · assignmentExpiresAt 主管在确认单上定的时(1-7 天),**已启用**
* (AssignmentExpiryScheduler 每 10 分钟扫一次,到点回池并记 assignment_expired)。
* 客服要看的是后者:它决定他手上这单还剩多久。
*/
......
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