Commit 6a1bee6f by luoqi

refactor(术语): 时效 → 时限,全线统一 213 处 —— 但三类同名不同义的不动

产品定的口径是「时限」(截止期限),而界面此前 63 处全写「时效」。
提示词第②层的铁律是「使用者的词汇表 = 他在界面上见过的那些」⇒ 界面、提示词、
工具描述、文档必须同时改,只改一处等于制造新的漂移。

改了 35 个文件 / 213 处:界面文案 · 提示词正文 · 工具描述 · 枚举 labelZh · 注释。
 字段名一律不动(expiresInDays / set_expiry / assignment_expires_at)——
  那是接口与数据库契约,跟着中文改会连累落库与老数据。

️ 跳过 7 个文件 —— 那里的「时效」是**别的意思**,连坐会改出假语义:
  · tone.ts:「urgent = 有时效紧迫」是话术**语气**,不是期限
  · clinical-signals / persona-feature-specs / signal-extractor / contraindication:
    临床禁忌的「时效」= 有效期(妊娠 280 天、哺乳 180 天、放疗终身)
  (enums 里那条 `deadline_too_tight` 的注释早就警告过「同名不同义是统计事故的
    标准配方」—— 这次正是同一类风险,只是方向反过来。)

提示词正文变了 ⇒ 按纪律 bump `ASSISTANT_PROMPT_VERSION` → assistant@2026-08-16-b。
packages/types 改了 src ⇒ 重建 dist(否则 tsc 绿、一跑就炸)。

验证:service tsc + web tsc 通过;jest 85 套 1330 例全过。
parent df0d263d
......@@ -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 = `## 他现在做的这件事:把一批人分给客服
### 确认之后
这批的人员和时改不了,要改只能**撤销重分**。你对已经分下去的这批能做的只有两件:撤销、补挂福利 —— 「已撤销」只能出现在撤销工具真的返回之后。
这批的人员和时改不了,要改只能**撤销重分**。你对已经分下去的这批能做的只有两件:撤销、补挂福利 —— 「已撤销」只能出现在撤销工具真的返回之后。
福利挂在整批上,会进这批人的话术,补挂只影响此后生成的那些。他说什么就原样写进去,⛔ 别替他加条件、期限或承诺。⛔ 「这批还没带福利」**不用你再提一遍** —— 确认之前你已经问过一次了;他回头问起,或者要补,照办就是。
......
......@@ -45,7 +45,7 @@ interface ChatBody {
/**
* 眼前那张**还没确认**的确认单,此刻的样子 —— 模型用 `get_current_sheet` 取。
*
* 🔴 为什么由前端捎来:确认之前,改派 / 移出 / 时 / 福利**只存在于卡片组件里**,
* 🔴 为什么由前端捎来:确认之前,改派 / 移出 / 时 / 福利**只存在于卡片组件里**,
* 服务端手上只有最初那一版提案(见 `SheetSnapshotSchema` 上那段)。
* ⚠️ 与 `activeClinicId` 同一条纪律:前端可改的入参 ——
* 它只用来**告诉模型现在什么样**,⛔ 一个字都不许拿去写库。
......
......@@ -264,7 +264,7 @@ export class AssistantService {
* 「返回的是结构化事实…组织成人话」→ 系统提示词那一节整段在管。
* 「绝不要说已经分配好了」→ 提示词权责段 + 返回值键名 `已经分下去了`,这是第三份。
* 「专属客服排满的不会自动改派」→ 返回值里 `专属客服排满、这批没发的` 自己说清了(判据②)。
* 「人数与时不问主管」→ 提示词流程线里的「直出,不追问」,复述。
* 「人数与时不问主管」→ 提示词流程线里的「直出,不追问」,复述。
*
* ⚠️ 末尾那段**讲述顺序**放在这里而不是提示词里:耦合有方向 ——
* 生产者知道自己产出什么、该被怎么读;show_* 是呈现器,⛔ 它们不该知道内容结构。
......@@ -314,7 +314,7 @@ export class AssistantService {
description:
'从召回池按条件选出一批人,算出一版分配方案:谁分给谁、每人几条、谁卡着没发。' +
'\n什么时候调:他说出要哪一格的人(治疗项 + 时间档)就调。' +
'他要换条件重来(只选其中一类、改人数、改时)也是**重调这个工具出新的一版**,' +
'他要换条件重来(只选其中一类、改人数、改时)也是**重调这个工具出新的一版**,' +
'⛔ 不是在上一版已经排好的人里再挑。' +
// 🔴 2026-08-14:这两句里原来各有一个「基数」——**它就列在提示词的禁说清单里**,
// 而这里是直接递到模型眼前的词。实测原话:「比估算的**基数**(120)少得多」
......@@ -399,7 +399,7 @@ export class AssistantService {
// ⚠️ 点名那个估法,别写「下面那个公式」:模型看到的是 `properties` 里
// 各自独立的一段描述,谁在"下面"没有任何保证 —— 指过去指空了不报错。
'本批发多少人,只在他说了具体数量时传。' +
'\n传了就直接按这个数,「在岗人数 × 每天几通 × 时」那个估法整个不参与,' +
'\n传了就直接按这个数,「在岗人数 × 每天几通 × 时」那个估法整个不参与,' +
'dailyCalls 一起传也不起作用。' +
'\n不传就走那个估法。每一批都重新估,他上一次说的数不会带到这一批。',
},
......@@ -410,7 +410,7 @@ export class AssistantService {
minimum: 1,
maximum: 60,
description:
'每位客服每天打几通,只用来估这一批发多少人(在岗人数 × 它 × 时)。' +
'每位客服每天打几通,只用来估这一批发多少人(在岗人数 × 它 × 时)。' +
'他调这个估法时传。' +
'\n乘法由工具做,报给他的人数以返回值为准。' +
'\n它不限制任何人这批能接多少条,也不参与谁分给谁;传了 targetCount 时它不起作用。',
......@@ -436,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 },
},
},
......@@ -646,7 +646,7 @@ export class AssistantService {
'待分配 = 有人卡着没发出去、等他定去向;' +
'换个条件选 = 这一格可以只选其中一类人,重出一版;' +
'本批人数 = 这一批发多少人,可以调;' +
'打不完 = 按当前时效算这批打不完,可以调时效或每天几通。' +
'打不完 = 按当前时限算这批打不完,可以调时限或每天几通。' +
'\n⚠️ 只能开放**这一版真的有**的那几类;没有的会被退回,并告诉你这一版有哪几类。',
},
},
......@@ -683,7 +683,7 @@ export class AssistantService {
*
* ⚠️ 指令用**姓名**表达,⛔ 不用 planId:那些 id 根本没进过模型上下文(故意的)。
* 匹配由界面用自己手里那份确认单完成,结果它会回一句话进对话。
* ⚠️ 能做的只有卡片上能做的那几件(删/改派/改时)。
* ⚠️ 能做的只有卡片上能做的那几件(删/改派/改时)。
* 换人群、改批次人数要重跑算法 → 只能重新调 propose_assignment 出一版。
*/
/**
......@@ -723,13 +723,13 @@ export class AssistantService {
* 🔴 **查眼前那张确认单现在什么样** —— 2026-08-15 加。
*
* ═══ 为什么非要有它 ═══════════════════════════════════════════
* 确认之前那张单是**草稿**:主管拖一个人、点一个 ×、改一次时
* 确认之前那张单是**草稿**:主管拖一个人、点一个 ×、改一次时
* 数就变了,而这些改动**只存在于浏览器的卡片组件里**。
* 模型此前唯一的信息来源是对话里那几行「确认单已更新:…」,于是:
* ① 它得**自己把几行累加**才能得出当前值(实测原话
* 「系统排的 313 人…加上刚还回专属客服的 182 人…一共 495 人」——
* 这次算对了,但那是心算,错了不报错);
* ② 手动拖动 / 点 × / 改时这几条路**当时连回声都没有**,
* ② 手动拖动 / 点 × / 改时这几条路**当时连回声都没有**,
* 它连累加的材料都没有 —— 卡片上写着 3 天,它嘴上还说 1 天。
* ⇒ 现在两条一起补:回声带上当前值(推),这个工具随时能取(拉)。
*
......@@ -741,8 +741,8 @@ export class AssistantService {
tools.get_current_sheet = tool({
description:
'看他眼前那张确认单**现在**什么样:已排好几条、落在几位客服头上、' +
'还有几个待他定、时几天、带没带福利。' +
'\n什么时候调:他在卡片上动过手(拖人、移出、改时、改福利)之后,' +
'还有几个待他定、时几天、带没带福利。' +
'\n什么时候调:他在卡片上动过手(拖人、移出、改时、改福利)之后,' +
'你要报数、要判断"还差什么"、或者他问"现在什么情况",先调它。' +
'\n⚠️ 卡片上的改动**你看不见** —— ⛔ 别拿出方案那一版的数去算现在的数。' +
'\n⛔ 它不改任何东西,只是看一眼。',
......@@ -759,7 +759,7 @@ export class AssistantService {
tools.edit_assignment_sheet = tool({
description:
'改他眼前那张确认单:移出谁、改派给谁、改时、设本批福利。' +
'改他眼前那张确认单:移出谁、改派给谁、改时、设本批福利。' +
'\n什么时候调:他对这一版提出具体改动就调。这几件事界面上他自己也能做,' +
'而他开口说了就是要你替他做完 —— 让他先确认再逐条退回,等于把几十次拖拽推回给他。' +
'\n一条指令由三件事拼起来:**选谁**(select)· **干什么**(action)· **给谁**(to,只有改派要)。' +
......@@ -774,7 +774,7 @@ export class AssistantService {
'他读到那句时结果早就在了,顺序是倒的。' +
'\n⚠️ 批次**已经确认分配**之后:只有 `set_benefit` 还能改(福利只影响此后生成的话术,' +
'界面会真的改并回报"作废了几条话术缓存 / 几条客服已经打开过");' +
'人员和时**改不了** —— 单子已经在客服手上,那要走撤销重分。' +
'人员和时**改不了** —— 单子已经在客服手上,那要走撤销重分。' +
'⛔ 这两种情况都以界面回的那句为准,别自己判断成没成。',
inputSchema: jsonSchema({
type: 'object',
......@@ -821,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: {
......@@ -851,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',
......
......@@ -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,
......
......@@ -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({
......
......@@ -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 比对),否则重出一版后旧卡片卸载会把新的一起清掉。
......
......@@ -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