Commit 61537b7d by luoqi

feat(plan): 首批 N 按「在岗人数×20」估 + 与候选取小;专属改为硬约束独占

① 基数 N 的取值改成产品定的算法:
   首次 min(在岗人数 × 20, 候选总数),之后 min(上一次的 N, 候选总数),时效首次 3 天后沿用。
   ️ 与候选取小压低的是 target, **不回写基数** —— 候选不够是这一批的偶然事实,
   存进去的话主管点一次 44 人的小格子,以后所有批次永远 44 人,而他看不出为什么。
   所以确认单同时给 batchSize(基数)与 target(本批实际),卡片存前者。
   ️ fetchLimit 改按基数算,让 count 与取明细还能并发,不多一个来回。

② 🔴 硬约束:**客服不能接别人的专属患者**。有专属(且在名册内)的患者只有一个去处,
   给不下就落不下(unplaced),不允许溢出给第三个人。
   · 推翻原「专属已满 → 溢出转铺平」,SPREAD_OVERFLOW 不再产生(枚举保留,老批次在用);
   · 两趟分工改为「专属独占 → 无主患者水位法」,后者负责抹平前者造成的不齐;
   · maxThisBatch 精调压过专属:说了只给 5 条,第 6 个她的专属患者就落不下, 不许改派;
   · 上一版给专属加的"目标水位封顶"与本约束冲突(会把专属患者改派给第三人),撤掉。

️ 直接后果,已在教条里写明:「最后齐平」变成尽力而为。实测 N=340 时康慧捧
一个人是其中 248 人的专属,他就拿 248 条 —— 那些患者只有他能接。
调节手段是给他设 maxThisBatch 或换更小的 N, 不是让系统偷偷分给别人。

本地实测:1,081 候选 → 首批 340 人(17×20),池子里还有 741 人排队。988 tests green。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent c33dafed
import { Permission, ASSIGNMENT_EXPIRES_DAYS_DEFAULT, BATCH_SIZE_DEFAULT } from '@pac/types';
import { Permission, ASSIGNMENT_EXPIRES_DAYS_DEFAULT, BATCH_SIZE_PER_AGENT_FIRST } from '@pac/types';
/**
* 按能力切换的助手工作流约束(拼在 SYSTEM_PROMPT 之后)。
......@@ -83,15 +83,19 @@ const DISPATCHER_EXTRA = `
⚠️ 标了 multi 的维度一个人可命中多项,合计大于总人数,**别拿它算百分比**。
### 拟分方案怎么给(主管会把关,你负责有理有据)
- **专属优先**:该患者有专属客服且在名册内 → 分给他
- **溢出走水位法**:无专属 / 专属已离岗 / 专属这轮名额用完 → 铺给其他在岗客服,
**每一条都给当前手上最少的那个人**,分完后大家的在手量趋于齐平。
⛔ 不是"每人加一样多" —— 那样起点不齐终点还是不齐。
本批一条没分到的人仍会列出来(他手上本来就最多),⛔ 别说成"他被跳过了"。
- 🔴 **客服不能接别人的专属患者。** 有专属客服(且在名册内)的患者**只有一个去处**,
分不下就是分不下(计入 unplaced),⛔ 绝不能说"改派给别人"或"铺给其他客服"。
- **专属独占**:有专属的患者先落到各自专属客服头上(他们没有第二个去处)。
- **无主患者走水位法**:没有专属 / 专属已离岗的患者,**每一条都给当前手上最少的那个人**,
用这批自由人抹平第一趟造成的不齐。⛔ 不是"每人加一样多"(起点不齐终点还是不齐)。
⚠️ 「最后齐平」是**尽力而为不是保证**:100 个人里 76 个是同一个人的专属,他就是会拿 76 条。
主管的调节手段是给他设本批名额(agentOverrides)或换个更大的 N,⛔ 别建议"分给别人"。
本批一条没分到的人仍会列出来,⛔ 别说成"他被跳过了"。
- **⛔ 没有"容量上限"这个东西。** 负载就是在手量本身,水位法已经在照顾它。
主管问"会不会分太多"就照 basisNote 说分完后每人多少条,⛔ 不要编一个"上限"出来。
- **两个基数:本批人数 + 时效,都自动沿用主管上一次的值** —— ⛔ 别问他,那正是这个设计要省掉的输入。
首次没有上一次才用默认(${BATCH_SIZE_DEFAULT} 人 / ${ASSIGNMENT_EXPIRES_DAYS_DEFAULT} 天)。
首次没有上一次才估(在岗人数 × ${BATCH_SIZE_PER_AGENT_FIRST} / ${ASSIGNMENT_EXPIRES_DAYS_DEFAULT} 天),
并且**都要再与本批候选总数取小** —— 候选不够时如实说"一共就这么多人"。
basisNote 里写好了值、出处(「沿用 X 月 X 日那次」/「首次默认」)和分配后的水位,**照抄**。
- 他说「这批 200 人」→ 传 targetCount(**基数**,会被记住);「给 5 天」→ 传 expiresInDays;
「李莉这周最多 5 条」→ 传 agentOverrides(按客服精调,也会被记住)。
......
......@@ -138,7 +138,7 @@ export class AgentRosterService {
/// 名册里没有 = 近 N 月无回访记录。**不是"不能分"** —— 见类注释
inRoster: r != null,
// ⛔ **不返回 remaining,也不再返回容量区间**。
// "容量/上限"这个概念已经删掉(见 BATCH_SIZE_DEFAULT):负载就是 inHand 本身,
// "容量/上限"这个概念已经删掉(见 BATCH_SIZE_PER_AGENT_FIRST):负载就是 inHand 本身,
// 落人走水位法(先给手上最少的)。给一个区间会被读成"合法范围",
// 把余量减出来会让助手说「李莉还能吃 38 个」—— 拿未经验证的数做完减法当事实说出口。
// 名册只回**在手**这个客观量;分配后的水位在确认单的 byAgent 里逐人给。
......
......@@ -109,9 +109,10 @@ export function AssignmentConfirmSheet({
selectionNote: sheet.selectionNote,
// ⭐ 两个基数落进快照 —— **下一次分配就是从这里读出来沿用的**(零新列)。
// ⚠️ 时效存**卡片当前值**不是提案值:主管刚在上面改成 5 天,记住的就得是 5。
// ⚠️ 存 batchSize 这个**新键**,同时 target 仍在(上面)—— 沿用时两个都认,
// 老批次只有 target,新批次两个都有,改版不丢"沿用"
batchSize: sheet.target,
// ⚠️ 存的是**基数**不是 target —— 候选不够时 target 被压低,那是这一批的偶然事实。
// 存 target 的话,主管点一次 44 人的小格子,以后所有批次就永远是 44 人。
// ⚠️ 同时 target 也在(上面)—— 沿用时两个键都认,改版不丢"沿用"。
batchSize: sheet.batchSize,
expiresInDays,
// ⭐ 按客服的精调也进快照 —— 下次一并沿用(容量部分原样带走,时效部分用卡片当前值)
agentOverrides: mergeOverrides(sheet.agentOverrides, expiryByAgent),
......@@ -320,7 +321,9 @@ export function AssignmentConfirmSheet({
本批 {sheet.target}
</span>
<span className="text-[10px] text-slate-400">
要改说「这批 {sheet.target * 2} 人」· 池子里还有人随时能再分一批
{sheet.target < sheet.batchSize
? `候选只有这么多(基数 ${sheet.batchSize} 不变)`
: `要改说「这批 ${sheet.batchSize * 2} 人」· 池子里还有人随时能再分一批`}
</span>
</div>
<p className="text-[10.5px] leading-relaxed text-slate-400">
......
......@@ -76,10 +76,18 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
(2026-08-03 产品定;同日两次改判,下面把弯路一并记下来,别再走一遍)
| 基数 | 含义 | 首次默认 |
|---|---|---|
| **本批人数 N** | 这一轮推多少人 | 100 |
| **时效** | 这批单子多久没动就自动回池 | 3 天 |
| 基数 | 含义 | 首次 | 之后 |
|---|---|---|---|
| **本批人数 N** | 这一轮推多少人 | `min(在岗人数 × 20, 候选总数)` | `min(上一次的 N, 候选总数)` |
| **时效** | 这批单子多久没动就自动回池 | 3 天 | 沿用上一次 |
⚠️ 首次那个 **20 不是容量上限**,只是"第一次没有任何历史时,一批推多大"的估法,
之后就再也不出现(沿用主管上次用的数)。
🔴 **与候选总数取小的那一步压低的是 `target`,⛔ 不回写基数。**
候选不够是**这一批的偶然事实**:主管点一次 44 人的小格子,若把 44 存成基数,
以后所有批次就永远是 44 人,而他完全看不出为什么变小了(界面只会显示"沿用上次")。
所以确认单同时给两个数:`batchSize`(基数,被记住的)与 `target`(本批实际,= min 之后)。
**没有"容量"这第三个数。** 它被删过一次,别再加回来。
......@@ -92,22 +100,30 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
> `inHand` 本身就是负载,落人走水位法直接拿它排序。再挂一条"不强制的上限"比没有更糟:
> 主管会以为系统在拦,其实没拦(T14:别给假证据)。
**落人 = 专属优先 → 溢出水位法。**
### 🔴 硬约束:**客服不能接别人的专属患者**
1. **第一趟 · 专属**:该患者有专属客服且在名册内 → 给他,**但受目标水位约束**(下条)。
2. **第二趟 · 水位法**:每一条都给**当前在手最少**的那个人;同水位按 userId 打破平局(确定性)。
⛔ 不是"每人加一样多" —— 起点不齐时,均分增量的终点还是不齐。
(2026-08-03 产品定)有专属客服(且在名册内)的患者,**只有一个去处** —— 那个人。
给不下就落不下(计入 `unplaced`),⛔ 不允许溢出给第三个人。
**目标水位 = (团队现有在手 + N) / 在岗人数**(向上取整,至少 1)。它**不是新旋钮**,完全由 N 推出来,
存在的唯一理由是**给专属那一趟封顶**
> 这条**推翻**了原来的「专属已满 → 溢出转铺平」。`AssignStrategy.SPREAD_OVERFLOW`
> 曾表达"有专属只是没轮到",现在这种情况**不会再产生**(枚举值保留,老批次还在用)。
> 容量删掉之后没有任何东西约束专属了。本地实测:100 条里 76 条是同一个人的专属,
> 他一个人吃到 76,水位法只剩零头可铺 —— 「最后保持齐平」当场落空。
> 加上水位约束后同一批:**每人在手 5~6 条**。
>
> ⚠️ 这不是"不给专属了",是**他这批的份额满了**,后面同样属于他的患者转铺平
> (仍标 `spread_overflow`,关系还在、只是这轮没轮到)。语义与原来
> 「专属已达容量 → 溢出转铺平」完全一致,只是把"容量"换成了"这批的应得份额"。
**落人两趟,分工由这条硬约束决定:**
1. **专属独占**:有专属的患者先落到各自专属客服头上 —— 他们没有 plan B,先占坑。
⚠️ `maxThisBatch` 精调**压过专属**:主管说了只给李莉 5 条,第 6 个她的专属患者就落不下,
⛔ 不许改派。
2. **无主患者走水位法**:无专属 / 专属已离岗的患者,每一条都给**当前在手最少**的人;
同水位按 userId 打破平局(确定性)。⛔ 不是"每人加一样多" —— 起点不齐时终点还是不齐。
这批自由人的作用就是**抹平第一趟造成的不齐**
⚠️ **「最后齐平」是尽力而为,不是保证。** 本地实测:N=340 时,康慧捧一个人是其中 248 人的专属,
他就会拿 248 条 —— 那 248 个患者只有他能接。
主管的调节手段是给他设 `maxThisBatch`、或换个更小的 N,
**不是让系统偷偷分给别人**(那正是这条硬约束禁止的事)。
> 中途曾试过给专属那一趟加"目标水位"封顶(`(在手+N)/人数`),实测确实齐平(每人 5~6 条)。
> 但它与本硬约束直接冲突 —— 超出份额的专属患者会被改派给第三个人。硬约束赢,水位约束撤掉。
**两个基数都可以按客服精调**`agentOverrides: userId → {maxThisBatch?, expiresInDays?}`):
......
......@@ -83,14 +83,16 @@ export type CreateAssignmentRequest = z.infer<typeof CreateAssignmentRequestSche
* ⛔ 所以别把它们做成"每次弹窗问一遍"或"永远用系统默认" —— 前者违 T13(直出不追问),
* 后者让第一次的调整白费。
*
* ⚠️ **没有"容量"这第三个数**(它被删过一次,别再加回来 —— 理由见 BATCH_SIZE_DEFAULT)。
* ⚠️ **没有"容量"这第三个数**(它被删过一次,别再加回来 —— 理由见 BATCH_SIZE_PER_AGENT_FIRST)。
* 负载不靠阈值表达:落人走**水位法**(从在手最少的人开始填),手上多的人这轮自然少拿。
*/
/**
* **首次**分配的批次人数(之后沿用上一次)—— 基数①「这一轮推多少人」
* **首次**分配时,每个在岗客服按多少条估 —— 首批 N = `min(在岗人数 × 20, 本批实际候选数)`
*
* ⭐ 2026-08-03 定案:分配只有 **本批人数 N** 和 **时效** 两个基数,⛔ **没有"容量"这个数**。
* 这里的 20 **不是容量上限**,只是"第一次没有任何历史时,一批推多大"的估法 ——
* 它只在**首次**参与一次计算,之后 N 沿用主管上次用的值,这个常量再也不出现。
*
* ── 为什么容量被删掉 ────────────────────────────────────────
* 它当过一阵"每人在手上限",并用来推批次规模(`Σ 容量−在手`)。两个问题:
......@@ -100,11 +102,9 @@ export type CreateAssignmentRequest = z.infer<typeof CreateAssignmentRequestSche
* 水位法直接拿它排序、从最空的人开始填,压力已经被表达了。
* 再挂一条"不强制的上限"比没有更糟 —— 主管会以为系统在拦,其实没拦(T14:别给假证据)。
*
* ⚠️ 取 100 是 T5「宁可 100 人做透,不做 1000 人做浅」的量级,**仍是默认值**:
* 没有任何数据证明 100 比 80 或 150 好,等 T20 沉淀出「批次规模 × 完成率」再替换。
* ⛔ 别再给它加"上下限":这是主管对自己团队的判断,系统没有依据卡他。
* ⛔ 别给 N 加上下限:这是主管对自己团队的判断,系统没有依据卡他。
*/
export const BATCH_SIZE_DEFAULT = 100;
export const BATCH_SIZE_PER_AGENT_FIRST = 20;
/// **首次**分配的时效起点(之后沿用上一次)。同批次人数,是基数不是常量。
export const ASSIGNMENT_EXPIRES_DAYS_DEFAULT = 3;
......@@ -125,7 +125,7 @@ export const AgentInfoSchema = z.object({
/// 是否在名册内。⚠️ **不是"能不能分"** —— 名册是建议来源不是白名单,
/// 实测有客服只做召回不做回访(回访数 0),分给他完全合法
inRoster: z.boolean(),
/// ⛔ 这里**不返回容量/上限之类的数** —— 那个概念已经删掉(见 BATCH_SIZE_DEFAULT)。
/// ⛔ 这里**不返回容量/上限之类的数** —— 那个概念已经删掉(见 BATCH_SIZE_PER_AGENT_FIRST)。
/// `inHand` 就是负载本身,落人按它走水位法;返回一个区间只会被读成"合法范围"。
});
export type AgentInfo = z.infer<typeof AgentInfoSchema>;
......@@ -321,7 +321,14 @@ export const AssignmentProposalSchema = z.object({
/// ⭐ 与矩阵格子同一种数法(count DISTINCT patient_id),⛔ 不受取明细的 LIMIT 影响 ——
/// 从矩阵点进来的主管会拿这个数跟他刚看到的格子对
candidateTotal: z.number().int().describe('候选总数 = 该格子/该条件下的患者数(与矩阵格子对得上)'),
target: z.number().int().describe('本批人数 N(基数①:沿用上次 / 首次默认 / 本次指定)'),
target: z.number().int().describe('本批**实际**分多少人 = min(基数 N, 候选总数)'),
/**
* 基数 N —— **要被记住的那个数**(沿用上次 / 首次按 在岗人数×20 估 / 本次主管指定)。
* ⚠️ 与 `target` 分开:候选不够时 target 会被压低,但那是**这一批的偶然事实**,
* ⛔ 不能回写成基数 —— 否则点一次 44 人的小格子,以后所有批次就永远是 44 人,
* 而主管完全看不出为什么变小了。
*/
batchSize: z.number().int(),
/// ── 两个基数,连同它们的**出处**一起下发 ────────────────────────
/// ⚠️ 出处必须跟着值走:主管看到「容量 20」时,「这 20 是哪来的」和这个数本身一样重要 ——
/// 沿用上次 / 首次默认 / 他自己刚说的,三者对应完全不同的信任度(T14:界面元素也算证据)。
......
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