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` 同一条纪律:前端可改的入参 ——
* 它只用来**告诉模型现在什么样**,⛔ 一个字都不许拿去写库。
......
......@@ -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 是通用兜底键,允许同名
......
......@@ -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 },
};
......
......@@ -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