Commit c33dafed by luoqi

refactor(plan): 删掉"容量"这个数,批次人数改基数 + 落人走水位法

主管走查抓到死路:批次规模 = Σ(容量−在手) 时,**第二批必然是 0** ——
第一批把所有人填到水位,再算就是 Σ(20−20)。想连着圈两批人分,只能把容量
往上棚,而容量会被记住 → 下周的"习惯"是个虚高的数。

根子是一个数当了两个用:「这轮推多少」和「一个人最多压多少」。拆开后发现
上限那一半根本不需要 —— 负载不需要阈值,inHand 本身就是负载。

· 基数改为 **本批人数 N(首次 100)+ 时效(3 天)**,都沿用主管上一次的值;
  容量、AGENT_CAPACITY_RANGE、capacityRange 全删。
· 落人第二趟由「轮转均分」改 **水位法**:每条都给当前在手最少的人。
  均分是"每人加一样多",起点不齐终点还是不齐;水位法把差距抹平。
· 🔴 **专属那一趟也受目标水位约束**(=(团队在手+N)/在岗人数,由 N 推出,非新旋钮):
  容量删掉后没东西约束专属了,实测 100 条里 76 条是同一人的专属,他吃到 76,
  水位法只剩零头可铺。加约束后同一批变成每人 5~6 条。
  超出份额的专属转 spread_overflow(关系还在,这轮没轮到),语义同原「专属已满转铺平」。
· 按客服精调 capacity → maxThisBatch(本批名额,0 = 这轮不给),不再是"上限"。
· 沿用同时认新键 batchSize 与老键 target —— 否则改版后所有人的沿用静默退回默认。
· 卡片「已满未分配」改「本批未分到(在手 N)」:没有上限了,写「已满(0)」自相矛盾。

本地实测:1,081 候选 → 本批 100 人铺给 17 位客服,分完后每人在手 5~6 条,
池子里还有 981 人排队(话术明说"随时可以再分一批")。986 tests green。
教条 T22 整节重写,把这条弯路记进去。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 9fa29b7b
import { Permission, AGENT_CAPACITY_DEFAULT, ASSIGNMENT_EXPIRES_DAYS_DEFAULT } from '@pac/types';
import { Permission, ASSIGNMENT_EXPIRES_DAYS_DEFAULT, BATCH_SIZE_DEFAULT } from '@pac/types';
/**
* 按能力切换的助手工作流约束(拼在 SYSTEM_PROMPT 之后)。
......@@ -10,7 +10,7 @@ import { Permission, AGENT_CAPACITY_DEFAULT, ASSIGNMENT_EXPIRES_DAYS_DEFAULT } f
* ── 方法论:凡是靠模型"算对"的约束,一律降级成"照抄" ──────────
* 下面 T14 / T20 那两类要求(标注默认值、样本不足不出百分比)全是**除法和阈值判断**,
* 而这恰恰是 LLM 最不可靠的地方。所以工具返回值里直接带成品句子
* (`rosterNote` / `capacityBasis` / `sufficient`),提示词只负责让它**原话抄**。
* (`rosterNote` / `basisNote` / `sufficient`),提示词只负责让它**原话抄**。
* 「提示词 + 工具返回值」双保险,少任何一半都会漏。
*/
......@@ -38,8 +38,8 @@ const DISPATCHER_EXTRA = `
他若主动提要求(「只要商保直付的」「排掉怕疼的」),那时才用画像收窄。
3. **凡是没有历史数据支撑的建议值,必须当场标明是默认值。**
工具返回里的 rosterNote / capacityBasis 是给你**原话抄**的,别自己改写措辞。
⚠️ 容量/时效现在有三种出处,**照 capacityNote 说,别自己归因**:
工具返回里的 rosterNote / basisNote 是给你**原话抄**的,别自己改写措辞。
⚠️ 人数/时效有三种出处,**照 basisNote 说,别自己归因**:
沿用上次(「沿用 7 月 28 日那次分配」)/ 首次默认 / 本次主管指定。
⛔ 不要把"沿用上次"说成"系统算出来的",也不要把默认值说成「依据该诊所平均结案 2.4 天」
—— 那个数算不出来。
......@@ -84,16 +84,19 @@ const DISPATCHER_EXTRA = `
### 拟分方案怎么给(主管会把关,你负责有理有据)
- **专属优先**:该患者有专属客服且在名册内 → 分给他
- **溢出铺平**:专属客服已达容量 / 无专属 / 专属已离岗 → 铺给其他在岗客服,
按当前在手量从少到多补,**均分**;在手已达上限的人本批跳过但仍列出来标「已满」
- 分不下去就**明说分不下去**,建议缩小批次;⛔ 不要硬塞给已经满的人
- **两个基数:容量 + 时效,都自动沿用主管上一次的值** —— ⛔ 别问他,那正是这个设计要省掉的输入。
首次没有上一次才用默认(容量 ${AGENT_CAPACITY_DEFAULT}、时效 ${ASSIGNMENT_EXPIRES_DAYS_DEFAULT} 天)。
capacityNote 里写好了推导链和出处(「沿用 X 月 X 日那次」/「首次默认」),**照抄**。
- 批次规模**不是你定的,也不是主管定的,是算出来的**:Σ(容量 − 各人在手)。
他说「每人 30 条」→ 传 capacity(基数,会被记住);「给 5 天」→ 传 expiresInDays;
「这批只要 60 人」→ 传 targetCount(**一次性**,不改基数,下次仍按容量算)。
⛔ 不要既传 capacity 又传 targetCount —— 两者矛盾时听谁的说不清。
- **溢出走水位法**:无专属 / 专属已离岗 / 专属这轮名额用完 → 铺给其他在岗客服,
**每一条都给当前手上最少的那个人**,分完后大家的在手量趋于齐平。
⛔ 不是"每人加一样多" —— 那样起点不齐终点还是不齐。
本批一条没分到的人仍会列出来(他手上本来就最多),⛔ 别说成"他被跳过了"。
- **⛔ 没有"容量上限"这个东西。** 负载就是在手量本身,水位法已经在照顾它。
主管问"会不会分太多"就照 basisNote 说分完后每人多少条,⛔ 不要编一个"上限"出来。
- **两个基数:本批人数 + 时效,都自动沿用主管上一次的值** —— ⛔ 别问他,那正是这个设计要省掉的输入。
首次没有上一次才用默认(${BATCH_SIZE_DEFAULT} 人 / ${ASSIGNMENT_EXPIRES_DAYS_DEFAULT} 天)。
basisNote 里写好了值、出处(「沿用 X 月 X 日那次」/「首次默认」)和分配后的水位,**照抄**。
- 他说「这批 200 人」→ 传 targetCount(**基数**,会被记住);「给 5 天」→ 传 expiresInDays;
「李莉这周最多 5 条」→ 传 agentOverrides(按客服精调,也会被记住)。
- **池子里还有人就随时能再分一批** —— 主管连着圈第二批人时照常出确认单,
⛔ 不要说"团队满了"(没有这个概念了)。
- 每一条都要说得出「为什么是他」:专属 / 手上最空 / 主管指定,三选一
`.trim();
......
......@@ -120,9 +120,9 @@ export class AssistantService {
'并把卡片直接呈现给主管。' +
'\n⚠️ 卡片由界面渲染,**你看不到明细也不需要看** —— 你的任务是转述返回的那句摘要。' +
'\n⚠️ **这只是提案,一个字都没写库**。绝不要说「已经分配好了」。' +
'\n⚠️ 容量与时效**会自动沿用主管上一次的值**,⛔ 别问他 —— 那正是这个设计要省掉的输入。' +
'他明确说「每人 30 条」「给 5 天」时才传 capacity / expiresInDays(会被记住);' +
'说「这批只要 60 人」传 targetCount(一次性,不改基数)。',
'\n⚠️ 本批人数与时效**会自动沿用主管上一次的值**,⛔ 别问他 —— 那正是这个设计要省掉的输入。' +
'他明确说「这批 200 人」「给 5 天」时才传 targetCount / expiresInDays(都会被记住)。' +
'\n⚠️ **没有"容量上限"** —— 落人走水位法(先给手上最少的),池子里还有人就随时能再分一批。',
inputSchema: jsonSchema({
type: 'object',
properties: {
......@@ -143,12 +143,6 @@ export class AssistantService {
'主管在调整阶段追加的画像条件("key:value" 逗号串,同维 OR、跨维 AND)。' +
'先用 get_cohort_attributes 看清各口子多少人,再带着它重出确认单。',
},
capacity: {
type: 'number',
description:
'每个客服的在手容量(基数①)。主管说「每人 30 条」时传;不传自动沿用他上一次的值。' +
'⭐ 批次规模由它推出(Σ 容量−在手),⛔ 不要既传 capacity 又传 targetCount。',
},
expiresInDays: {
type: 'number',
description: '批次时效天数(基数②)。主管说「给 5 天」时传;不传自动沿用上一次的值。',
......@@ -156,20 +150,21 @@ export class AssistantService {
targetCount: {
type: 'number',
description:
'本批人数的**一次性**覆盖(主管说「这批只要 60 人」)。⚠️ 不会改基数,下次仍按容量算。',
'本批人数 N(基数①)。主管说「这批 200 人」时传;不传自动沿用他上一次的值(首次 100)。',
},
agentOverrides: {
type: 'object',
additionalProperties: {
type: 'object',
properties: {
capacity: { type: 'number' },
maxThisBatch: { type: 'number' },
expiresInDays: { type: 'number' },
},
},
description:
'按客服精调(userId → {capacity, expiresInDays}),压过整体基数。' +
'主管说「李莉这周只给 5 条」「王强那批给 7 天」时传。' +
'按客服精调(userId → {maxThisBatch, expiresInDays})。' +
'maxThisBatch = 本批最多给他几条(0 = 这轮不给他),⛔ 不是"容量上限"。' +
'主管说「李莉这周最多 5 条」「王强那批给 7 天」时传。' +
'⚠️ 先用 list_agents 拿到 userId,⛔ 不要用姓名当 key。' +
'⚠️ 精调**也会被记住**,所以主管说「李莉恢复正常」时要把她从这个表里去掉' +
'(传一个不含她的完整表,⛔ 别只传变化的那一条)。',
......@@ -183,9 +178,8 @@ export class AssistantService {
potentialTreatment?: string;
temperature?: TemperatureValue;
personaTags?: string;
capacity?: number;
expiresInDays?: number;
agentOverrides?: Record<string, { capacity?: number; expiresInDays?: number }>;
agentOverrides?: Record<string, { maxThisBatch?: number; expiresInDays?: number }>;
targetCount?: number;
exploreRatio?: number;
};
......@@ -208,7 +202,7 @@ export class AssistantService {
(sheet.unplaced > 0 ? `,另有 ${sheet.unplaced} 人按当前容量分不下去` : '') + '。',
`请转述以下三句(**原话**,不要改写、不要省略"默认值"字样):`,
${sheet.selectionNote}`,
${sheet.capacityNote}`,
${sheet.basisNote}`,
${sheet.rosterNote}`,
`然后提示主管:确认无误请在卡片上点「确认分配」。⛔ 不要说已经分配好了。`,
].join('\n');
......
......@@ -138,9 +138,10 @@ export class AgentRosterService {
/// 名册里没有 = 近 N 月无回访记录。**不是"不能分"** —— 见类注释
inRoster: r != null,
// ⛔ **不返回 remaining,也不再返回容量区间**。
// 容量没有上下限(见 AGENT_CAPACITY_DEFAULT),给一个「20-50」会被读成"合法范围";
// 而把余量减出来会让助手说「李莉还能吃 38 个」—— 拿一个未经验证的数做完减法当事实说出口。
// 名册只回**在手**这个客观量;本批实际容量在确认单的 byAgent 里逐人给。
// "容量/上限"这个概念已经删掉(见 BATCH_SIZE_DEFAULT):负载就是 inHand 本身,
// 落人走水位法(先给手上最少的)。给一个区间会被读成"合法范围",
// 把余量减出来会让助手说「李莉还能吃 38 个」—— 拿未经验证的数做完减法当事实说出口。
// 名册只回**在手**这个客观量;分配后的水位在确认单的 byAgent 里逐人给。
};
});
......
......@@ -21,8 +21,8 @@ import { cn } from '@/lib/utils';
* artifact 跑在 `sandbox="allow-scripts"` 的 iframe 里、CSP `connect-src 'none'`,
* **卡片内不可能发出写请求**。分岔记牢:只读展示用 artifact,可交互用原生组件。
*
* ── 微调**只有两**(T13)──────────────────────────────────
* 指定客服、时效。多加一项就直接违 T13 ——
* ── 微调**只有两**(T13)──────────────────────────────────
* 指定客服、时效(整批 + 按客服两级)。多加一类就直接违 T13 ——
* 微调项越多,「一次确认」这个设计目标就越不可能实现。
* 要换人群请回到对话里让助手重新圈,不要在卡片上做筛选。
*
......@@ -31,10 +31,10 @@ import { cn } from '@/lib/utils';
* 患者明细按客服折叠,展开才看 —— 兼顾"尽明细"与"一眼可确认"。
*/
/**
* 精调表落库形态 = 提案带来的(含容量部分) + 卡片上刚改的时效。
* 精调表落库形态 = 提案带来的(含名额上限部分) + 卡片上刚改的时效。
*
* ⚠️ 卡片只能改时效,所以**容量部分必须原样带走** —— 少带一次,主管上次设的
* 「李莉只给 5 条」就在这一次确认里被悄悄清掉了,而界面上完全看不出来。
* ⚠️ 卡片只能改时效,所以**名额上限部分必须原样带走** —— 少带一次,主管上次设的
* 「李莉这批最多 5 条」就在这一次确认里被悄悄清掉了,而界面上完全看不出来。
*/
function mergeOverrides(
fromSheet: Record<string, AgentOverride>,
......@@ -42,8 +42,8 @@ function mergeOverrides(
): Record<string, AgentOverride> {
const out: Record<string, AgentOverride> = {};
for (const [userId, o] of Object.entries(fromSheet)) {
// 时效部分交给卡片当前值决定(下面统一写),这里只留容量
if (o.capacity != null) out[userId] = { capacity: o.capacity };
// 时效部分交给卡片当前值决定(下面统一写),这里只留名额上限
if (o.maxThisBatch != null) out[userId] = { maxThisBatch: o.maxThisBatch };
}
for (const [userId, days] of Object.entries(expiryByAgent)) {
out[userId] = { ...(out[userId] ?? {}), expiresInDays: days };
......@@ -68,8 +68,8 @@ export function AssignmentConfirmSheet({
/**
* 时效初值来自**提案**(沿用主管上一次的值),⛔ 不是写死的 3 ——
* 写死的话"记住上次时效"这件事在界面上就永远看不见,主管每次还得重调一遍。
* ⚠️ 容量**不做成卡片控件**:改容量会改变人群(人数由它推出),
* 而卡片的微调项只允许"不改人群"的那两个(T13)。改容量回对话说一句,助手重出单。
* ⚠️ **本批人数**不做成卡片控件:改人数会换一批人,而卡片的微调项只允许
* "不改人群"的那些(T13)。改人数回对话说一句(「这批 200 人」),助手重出单。
*/
const [expiresInDays, setExpiresInDays] = useState(sheet.expiresInDays);
const [submitting, setSubmitting] = useState(false);
......@@ -78,8 +78,8 @@ export function AssignmentConfirmSheet({
/**
* 按客服的**时效精调**(userId → 天数)。初值来自提案(沿用上次的精调)。
*
* ⚠️ 为什么时效能在卡片上精调、容量不能:时效**不改人群**(只是这几条单子多久回池),
* 容量会 —— 人数是 Σ(容量−在手) 推出来的,改一下整批人就变了,那必须重出确认单。
* ⚠️ 为什么时效能在卡片上精调、人数/名额不能:时效**不改人群**(只是这几条单子多久回池),
* 另两个会 —— 改了就是换一批人,那必须重出确认单。
* ⚠️ 落库时写到该客服名下**每一条**任务的 `assignment_expires_at`(逐条覆盖批次时效)。
*/
const [expiryByAgent, setExpiryByAgent] = useState<Record<string, number>>(() =>
......@@ -109,7 +109,9 @@ export function AssignmentConfirmSheet({
selectionNote: sheet.selectionNote,
// ⭐ 两个基数落进快照 —— **下一次分配就是从这里读出来沿用的**(零新列)。
// ⚠️ 时效存**卡片当前值**不是提案值:主管刚在上面改成 5 天,记住的就得是 5。
capacity: sheet.capacity,
// ⚠️ 存 batchSize 这个**新键**,同时 target 仍在(上面)—— 沿用时两个都认,
// 老批次只有 target,新批次两个都有,改版不丢"沿用"
batchSize: sheet.target,
expiresInDays,
// ⭐ 按客服的精调也进快照 —— 下次一并沿用(容量部分原样带走,时效部分用卡片当前值)
agentOverrides: mergeOverrides(sheet.agentOverrides, expiryByAgent),
......@@ -209,7 +211,9 @@ export function AssignmentConfirmSheet({
{/* 按客服的时效精调 —— 落到他名下**每一条**任务上。
容量只读:改它会换人群,得回对话让助手重出单(见组件顶部注释)。 */}
<div className="mb-1.5 flex flex-wrap items-center gap-1.5 text-[10.5px] text-slate-500">
<span>容量 {a.capacity}</span>
<span>
在手 {a.inHandBefore}<span className="text-slate-700">{a.loadAfter}</span>
</span>
<span className="text-slate-300">·</span>
<span>时效</span>
{ASSIGNMENT_EXPIRES_DAYS_PRESETS.map((d) => {
......@@ -268,10 +272,12 @@ export function AssignmentConfirmSheet({
})}
</div>
{/* 已满被跳过的人 —— 仍然列出来:主管要看见"他不是被漏了,是已经满了" */}
{/* 本批没分到的人 —— 仍然列出来:主管要看见"他不是被漏了"。
⚠️ 措辞不能再写「已满」:没有容量上限这回事了,他没分到是因为**手上本来就最多**
(水位法没轮到)或被精调成 0 名额。写「已满(0)」会自相矛盾。 */}
{sheet.skippedAgents.length > 0 && (
<div className="border-t border-slate-100 px-3 py-1.5 text-[10.5px] text-slate-400">
已满未分配:{sheet.skippedAgents.map((a) => `${a.name ?? a.userId}(${a.inHand})`).join('、')}
本批未分到:{sheet.skippedAgents.map((a) => `${a.name ?? a.userId}(在手 ${a.inHand})`).join('、')}
</div>
)}
......@@ -307,14 +313,14 @@ export function AssignmentConfirmSheet({
: '首次默认(暂无历史结案数据)'}
</span>
</div>
{/* 容量:**只读**。人数由它推出来,改它就是换人群 —— 卡片不做这种事(T13) */}
{/* 本批人数:**只读**。改它就是换一批人 —— 卡片不做这种事(T13) */}
<div className="flex items-center gap-2 text-[11px] text-slate-500">
<span className="flex-none">容量</span>
<span className="flex-none">人数</span>
<span className="rounded border border-slate-200 px-2 py-0.5 tabular-nums text-slate-600">
每人 {sheet.capacity}
本批 {sheet.target}
</span>
<span className="text-[10px] text-slate-400">
本批 {sheet.target} 人由它推出 · 要改说「每人 {sheet.capacity + 10} 条」
要改说「这批 {sheet.target * 2} 人」· 池子里还有人随时能再分一批
</span>
</div>
<p className="text-[10.5px] leading-relaxed text-slate-400">
......@@ -325,7 +331,7 @@ export function AssignmentConfirmSheet({
{/* 依据 —— 成品句子原样展示,不在前端改写 */}
<div className="space-y-0.5 border-t border-slate-100 bg-slate-50/50 px-3 py-2 text-[10.5px] leading-relaxed text-slate-500">
<p>{sheet.selectionNote}</p>
<p>{sheet.capacityNote}</p>
<p>{sheet.basisNote}</p>
<p>{sheet.rosterNote}</p>
</div>
......
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