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 }
......
......@@ -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 是通用兜底键,允许同名
......
......@@ -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 天,两边都不报错。
⛔ 别去给服务端加"草稿回传"的写路径:多一份要维护的状态,只为省模型一次加法。
......
......@@ -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 },
};
......
......@@ -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