Commit 3f7b78c2 by luoqi

refactor: 助手先说话再给卡 + 三句依据改说人话,只留「怎么选/怎么分」

走查:那段输出像给程序员看的 ——「按『未进过批次优先 → 优先级高优先 → 患者号』排序」
「从手上最少的开始填」「首次按在岗 17 位 × 20 估」,而且卡片在前、文字在后。

① **顺序:文字在前,卡片在后**。
   ️ 不是"让模型先说话"能解决的:工具调用**必然在文字之前**(模型要先拿到结果才知道说什么),
   而卡片是工具执行时经侧信道推来的,到达顺序天然是"卡片 → 文字"。
   所以在**渲染层**把 assignment_sheet 块钉到消息末尾(stable 排序, 不整体 sort ——
   文字块之间的先后是流式拼出来的,打乱就成乱码)。

② **措辞改成说给主管听**:
   · 怎么选:「符合条件的一共 1084 人,这批挑了 340 人:没被分过的排在前面,其次是优先级高的。
     剩下 744 人在排队,随时可以再分一批。」
   · 怎么分:「340 人分给 17 位客服:有专属客服的先回到自己人手上,剩下的谁手上活少先给谁。
     分完后每人手里 20 条,3 天没动会自动退回池子。人数和时效是第一次的估算值……」
    「排序键/收敛/铺平/水位/基数/探索配额/患者号」一律不进主管界面 —— 加了断言守着。
   ️ T14 要的三件事一件没少,只是换了说法:分完每人多少、这两个数哪来的、精调过的人点名。

③ **内容收敛到「怎么选 + 怎么分」两句**,末尾加一句调整引导
   (「把某某移出这批」「某某的单给 5 天」「这批 200 人」—— 我直接改)。
   名册那句(近 12 月有回访记录的近似判定)不再单独占一条 bullet,rosterNote 字段保留。

本地实测通过,助手实际输出已核对。989 tests green。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 8ff25259
...@@ -196,16 +196,34 @@ export class AssistantService { ...@@ -196,16 +196,34 @@ export class AssistantService {
if (sheet.placed === 0) { if (sheet.placed === 0) {
return `没有可分配的候选(${sheet.selectionNote})。请把这句如实转述给主管。`; return `没有可分配的候选(${sheet.selectionNote})。请把这句如实转述给主管。`;
} }
// ⭐ 回给模型的**只有摘要**,不含任何 planId // ⭐ 回给模型的**只有台词稿**,不含任何 planId。
//
// ── 为什么是台词稿而不是数据(方法论)──────────────────
// T14/T20 那两类要求(标注默认值、样本不足不出百分比)全是除法和阈值判断,
// 恰恰是 LLM 最不可靠的地方。把话写好交给它抄,任务就从"让模型算对"
// 降级成"让模型照抄"。
//
// ⚠️ 2026-08-03 走查后改了三处:
// ① **先说话再看卡** —— 主管要先知道"怎么选的、怎么分的",再去核对明细;
// (卡片本身由界面钉在这段话下面,见 assistant-chat 的 blocks 排序)
// ② **说人话** —— 原来那三句里全是"排序键/铺平/水位/基数",那是给程序员看的;
// ③ **只讲怎么选 + 怎么分**,别的都别说;最后给一句"要调怎么说"。
return [ return [
`确认单已呈现给主管(卡片在对话里,你看不到明细,也不要复述明细)。`, `确认单已呈现给主管(卡片会显示在你这段话**下面**,你看不到明细,也不要复述明细)。`,
`摘要:候选 ${sheet.candidateTotal} 人,本批 ${sheet.placed} 人,涉及 ${sheet.byAgent.length} 位客服` + ``,
(sheet.unplaced > 0 ? `,另有 ${sheet.unplaced} 人按当前容量分不下去` : '') + '。', `请**先用两句话开口**,顺序不要变:`,
`请转述以下三句(**原话**,不要改写、不要省略"默认值"字样):`, `1)「怎么选的」:${sheet.selectionNote}`,
${sheet.selectionNote}`, `2)「怎么分的」:${sheet.basisNote}`,
${sheet.basisNote}`, ``,
${sheet.rosterNote}`, `最后一句引导他怎么调:`,
`然后提示主管:确认无误请在卡片上点「确认分配」。⛔ 不要说已经分配好了。`, `「要调整直接跟我说 —— 比如『把某某移出这批』『某某的单给 5 天』『这批 200 人』,` +
`我直接改;确认无误就点卡片上的『确认分配』。」`,
``,
`⚠️ 上面两句是**成品句子,原话抄**:里面的数字、"没被分过的排在前面"、"第一次的估算值"` +
`这类措辞都不要改写、不要省。`,
`⛔ 不要说"已经分配好了"(一条都还没落库)。`,
`⛔ 不要出现「排序键 / 收敛 / 铺平 / 水位 / 基数 / 探索配额」这类词 —— 主管看不懂。`,
`⛔ 不要复述患者名单或客服分配明细 —— 卡片上有,而且你手里根本没有。`,
].join('\n'); ].join('\n');
}, },
}); });
......
...@@ -295,23 +295,24 @@ export class AssignmentProposalService { ...@@ -295,23 +295,24 @@ export class AssignmentProposalService {
.filter((a) => agentOverrides[a.userId] != null) .filter((a) => agentOverrides[a.userId] != null)
.map((a) => ({ name: a.name ?? a.userId.slice(0, 8), ...agentOverrides[a.userId]! })), .map((a) => ({ name: a.name ?? a.userId.slice(0, 8), ...agentOverrides[a.userId]! })),
}), }),
/**
* 「**怎么选**」—— ⭐ 说给主管听,不是说给程序员听(2026-08-03 走查)。
*
* ⛔ 不许出现「排序键 / 三层键 / 患者号 / 收敛 / 探索配额」这类内部词 ——
* 主管问的是"凭什么是这 340 个人",不是我们的实现方式。
* ⚠️ **候选不够时不能说"取前 N 人"**:主管从矩阵点了一个 44 人的格子进来,
* 句子却说「取前 340 人」,他会以为有 296 个人被系统丢了(T14)。
* ⚠️ 池子里还剩多少必须说 —— 否则他不知道"还能不能再分一批"(答案是:能)。
*/
selectionNote: selectionNote:
// ⚠️ **候选不够时不能说"取前 N 人"** —— 主管从矩阵点了一个 44 人的格子进来,
// 句子却说「排序取前 100 人」,他会以为有 56 个人被系统丢了。
// 这是一句**要求助手原话转述**的话,措辞错了等于让助手替系统撒谎(T14)。
(candidateTotal <= target (candidateTotal <= target
? `这批候选一共就 ${candidateTotal} 人,**全部纳入**(未做取舍)。` ? `符合条件的一共就 ${candidateTotal} 人,这批**全要了**,没有取舍。`
: `按「未进过批次优先 → 优先级高优先 → 患者号」排序,从 ${candidateTotal} 位候选里取前 ${target} 人;`) + : `符合条件的一共 ${candidateTotal} 人,这批挑了 ${target} 人:**没被分过的排在前面**,` +
// ⚠️ 池子里还剩多少必须说 —— 否则主管不知道「还能不能再分一批」(答案是:能) `其次是优先级高的。剩下 ${candidateTotal - target} 人在排队,**随时可以再分一批**。`) +
(candidateTotal > target
? `池子里还有 ${candidateTotal - target} 人排队,**随时可以再分一批**(要多分就说个数)。`
: '') +
(exploreN > 0 (exploreN > 0
? `其中 ${exploreN} 人来自探索配额(排名之外抽取,用于日后验证排序是否选得准)。` ? `另外有 ${exploreN} 人是特意从排名靠后的位置抽的 —— 用来日后检验"排在前面的是不是真的更容易成"。`
: '') + : '') +
(items.unplaced > 0 (items.unplaced > 0 ? `⚠️ 有 ${items.unplaced} 人没能分下去。` : ''),
? `⚠️ 有 ${items.unplaced} 人按当前容量分不下去,建议本批缩到 ${items.placed.length} 人。`
: ''),
}; };
} }
...@@ -677,14 +678,16 @@ function ymd(iso: string): string { ...@@ -677,14 +678,16 @@ function ymd(iso: string): string {
} }
/** /**
* 基数说明 —— **值 + 出处 + 分配后的水位** * 「**怎么分**」—— ⭐ 说给主管听,不是说给程序员听(2026-08-03 走查)
* *
* 为什么必须给出处:同一个「100 人」,是**他上次自己定的**还是**系统第一次拍的**, * ⛔ 不许出现「水位法 / 基数 / 铺平 / 目标水位 / 溢出」这些内部词。
* 信任度完全不同(T14:界面元素也算证据)。 * 主管问的是"凭什么李莉拿这 20 个",答案要能用一句大白话说清:
* **有专属的先回自己人手上,剩下的谁手上活少先给谁**。
* *
* 为什么必须给水位:批次规模不再由在手量推导,所以主管看不到"团队现在多满" —— * ⚠️ 三件事一件都不能少(都是 T14 的要求,只是换了说法):
* 但他仍然需要知道这一批压下去之后每人手上有多少。⛔ 这里**不设阈值、不做判断** * ① 分完之后每人手上多少 —— 他要判断会不会压太狠(⛔ 只报事实,不替他下"超载"的判断);
* ("是否超载"没有数据支撑),只报事实:分完后大家在 X~Y 条之间。 * ② 这两个数(人数/时效)**哪来的** —— 沿用上次 / 第一次估的 / 他自己刚说的,信任度不同;
* ③ 精调过的人要**点名** —— 否则"为什么李莉只有 5 条"没人答得上来。
*/ */
function basisNote(x: { function basisNote(x: {
agents: number; agents: number;
...@@ -703,34 +706,35 @@ function basisNote(x: { ...@@ -703,34 +706,35 @@ function basisNote(x: {
const lo = after.length ? Math.min(...after) : 0; const lo = after.length ? Math.min(...after) : 0;
const hi = after.length ? Math.max(...after) : 0; const hi = after.length ? Math.max(...after) : 0;
const top = x.loads.find((l) => l.after === hi); const top = x.loads.find((l) => l.after === hi);
const water = const how =
x.placed > 0 x.placed > 0
? `本批 ${x.placed} 人铺给 ${x.loads.length} 位客服(**从手上最少的开始填**),` + ? `${x.placed} 人分给 ${x.loads.length} 位客服:**有专属客服的先回到自己人手上**,` +
`分完后每人在手 ${lo === hi ? `${hi} 条` : `${lo}~${hi} 条(最高 ${top?.name})`};` + `剩下的**谁手上活少先给谁**。分完后每人手里 ` +
`团队原有在手 ${x.inHandTotal} 条。` (lo === hi ? `${hi} 条` : `${lo}~${hi} 条(最多的是${top?.name})`) +
(x.inHandTotal > 0 ? `(他们原本还有 ${x.inHandTotal} 条在手)` : '') +
`,${x.expiresInDays} 天没动会自动退回池子。`
: ''; : '';
// ⚠️ 精调必须**点名**:主管看到「李莉只有 5 条」要立刻知道那是他自己设的,不是算错了 // ⚠️ 精调必须**点名**:主管看到「李莉只有 5 条」要立刻知道那是他自己设的,不是算错了
const tuned = x.overrides.length const tuned = x.overrides.length
? `其中${x.overrides ? `其中${x.overrides
.map( .map(
(o) => (o) =>
`${o.name}${o.maxThisBatch != null ? `批最多 ${o.maxThisBatch} 条` : ''}${ `${o.name}${o.maxThisBatch != null ? `批最多 ${o.maxThisBatch} 条` : ''}${
o.expiresInDays != null ? ` 时效 ${o.expiresInDays} 天` : '' o.expiresInDays != null ? `时效 ${o.expiresInDays} 天` : ''
}`, }`,
) )
.join('、')}(单独精调,会一直沿用到您改回来);` .join('、')}(您单独设过,会一直沿用到改回来)`
: ''; : '';
// ⚠️ 候选不够被压低时要说清"基数还是那个" —— 否则主管以为系统偷偷把他的设置改小了 // ⚠️ 候选不够被压低时要说清"您设的数没变" —— 否则他以为系统偷偷把设置改小了
const capped = const capped = x.target < x.batchSize ? `(符合条件的只有这么多,您设的 ${x.batchSize} 没变)` : '';
x.target < x.batchSize ? `(候选只有 ${x.target} 人,本批就这么多;基数不变)` : '';
const from = const from =
x.basis === 'inherited' && x.basisFrom x.basis === 'inherited' && x.basisFrom
? `本批人数 ${x.batchSize}${capped} / 时效 ${x.expiresInDays} 天**沿用 ${ymd(x.basisFrom)} 那次分配**,要改直接说(如「这批 200 人」「给 5 天」),下次自动记住。` ? `人数和时效**沿用您 ${ymd(x.basisFrom)} 那次**${capped},要改直接说(如「这批 200 人」「给 5 天」),下次自动记住。`
: x.basis === 'explicit' : x.basis === 'explicit'
? `本批人数 ${x.batchSize}${capped} / 时效 ${x.expiresInDays} 天是**您本次指定的**,确认后下次自动沿用。` ? `人数和时效是**您这次定的**${capped},确认后下次自动沿用。`
: `本批人数 ${x.batchSize}${capped}(首次按在岗 ${x.agents} 位 × ${BATCH_SIZE_PER_AGENT_FIRST} 估)/ ` + : `人数和时效是**第一次的估算值**${capped} —— 按每位客服 ${BATCH_SIZE_PER_AGENT_FIRST} 条估的,` +
`时效 ${x.expiresInDays} 天(**默认值**,无历史结案数据可反推)—— 觉得不合适直接说个数,确认后下次自动沿用。`; `没有历史数据可参考;觉得不合适直接说个数,确认后下次就按您的来`;
return `${water}${tuned}${from}`; return `${how}${tuned}${from}`;
} }
function emptyProposal( function emptyProposal(
......
...@@ -284,14 +284,24 @@ describe('selectionNote —— 候选不够时的措辞', () => { ...@@ -284,14 +284,24 @@ describe('selectionNote —— 候选不够时的措辞', () => {
test('⭐⭐ 候选 44 < 基数 180 → 说「一共就 44 人,全部纳入」,⛔ 不许说「取前 N 人」', async () => { test('⭐⭐ 候选 44 < 基数 180 → 说「一共就 44 人,全部纳入」,⛔ 不许说「取前 N 人」', async () => {
const r = await svcWith(44, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' }); const r = await svcWith(44, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' });
expect(r.selectionNote).toContain('一共就 44 人'); expect(r.selectionNote).toContain('一共就 44 人');
expect(r.selectionNote).toContain('全部纳入'); expect(r.selectionNote).toContain('全要了');
// ⛔ 不许出现"挑了 N 人 / 取前 N 人"这类措辞 —— 主管会以为有人被丢了
expect(r.selectionNote).not.toContain('挑了');
expect(r.selectionNote).not.toContain('取前'); expect(r.selectionNote).not.toContain('取前');
}); });
test('⭐ 候选 500 > 基数 180 → 照实说「从 500 位候选里取前 180 人」+ 剩下的还在排队', async () => { test('⭐ 候选 500 > 基数 180 → 照实说「一共 500 人,这批挑了 180」+ 剩下的还在排队', async () => {
const r = await svcWith(500, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' }); const r = await svcWith(500, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' });
expect(r.target).toBe(N); expect(r.target).toBe(N);
expect(r.selectionNote).toContain(`从 500 位候选里取前 ${N} 人`); expect(r.selectionNote).toContain('一共 500 人');
expect(r.selectionNote).toContain(`这批挑了 ${N} 人`);
expect(r.selectionNote).toContain(`剩下 ${500 - N} 人在排队`);
// ⚠️ 必须告诉主管"还能再分一批" —— 这正是容量模型下他做不到的那件事
expect(r.selectionNote).toContain('随时可以再分一批');
// ⛔ 内部词不许进主管界面(2026-08-03 走查:"表达要给主管看懂,不是给程序员看")
for (const w of ['排序键', '收敛', '铺平', '水位', '基数', '探索配额', '患者号']) {
expect(r.selectionNote).not.toContain(w);
}
// ⚠️ 必须告诉主管"还能再分一批" —— 这正是容量模型下他做不到的那件事 // ⚠️ 必须告诉主管"还能再分一批" —— 这正是容量模型下他做不到的那件事
expect(r.selectionNote).toContain('随时可以再分一批'); expect(r.selectionNote).toContain('随时可以再分一批');
}); });
...@@ -308,7 +318,7 @@ describe('selectionNote —— 候选不够时的措辞', () => { ...@@ -308,7 +318,7 @@ describe('selectionNote —— 候选不够时的措辞', () => {
clinicId: 'c1', potentialTreatment: 'filling', temperature: 'cold', clinicId: 'c1', potentialTreatment: 'filling', temperature: 'cold',
}); });
expect(r.candidateTotal).toBe(1080); expect(r.candidateTotal).toBe(1080);
expect(r.selectionNote).toContain(`从 1080 位候选里取前 ${N} 人`); expect(r.selectionNote).toContain('一共 1080 人');
expect(r.placed).toBe(N); expect(r.placed).toBe(N);
}); });
}); });
...@@ -375,7 +385,7 @@ describe('基数沿用 —— 本批人数与时效', () => { ...@@ -375,7 +385,7 @@ describe('基数沿用 —— 本批人数与时效', () => {
expect(r.target).toBe(300); expect(r.target).toBe(300);
expect(r.expiresInDays).toBe(5); expect(r.expiresInDays).toBe(5);
expect(r.basis).toBe('inherited'); expect(r.basis).toBe('inherited');
expect(r.basisNote).toContain('沿用 2026-07-28'); expect(r.basisNote).toContain('2026-07-28');
}); });
/** /**
...@@ -396,7 +406,10 @@ describe('基数沿用 —— 本批人数与时效', () => { ...@@ -396,7 +406,10 @@ describe('基数沿用 —— 本批人数与时效', () => {
expect(r.target).toBe(180); expect(r.target).toBe(180);
expect(r.expiresInDays).toBe(3); expect(r.expiresInDays).toBe(3);
expect(r.basis).toBe('default'); expect(r.basis).toBe('default');
expect(r.basisNote).toContain('在岗 9 位 × 20'); expect(r.basisNote).toContain('第一次的估算值');
expect(r.basisNote).toContain('每位客服 20 条');
// ⛔ 同上:说人话,不许出现内部词
for (const w of ['水位', '基数', '铺平', '溢出']) expect(r.basisNote).not.toContain(w);
}); });
/** /**
...@@ -409,7 +422,7 @@ describe('基数沿用 —— 本批人数与时效', () => { ...@@ -409,7 +422,7 @@ describe('基数沿用 —— 本批人数与时效', () => {
}); });
expect(r.target).toBe(44); expect(r.target).toBe(44);
expect(r.batchSize).toBe(300); expect(r.batchSize).toBe(300);
expect(r.basisNote).toContain('基数不变'); expect(r.basisNote).toContain('您设的 300 没变');
}); });
test('⭐ 主管本次说「这批 200 人」→ explicit,且下次会沿用', async () => { test('⭐ 主管本次说「这批 200 人」→ explicit,且下次会沿用', async () => {
...@@ -436,7 +449,7 @@ describe('基数沿用 —— 本批人数与时效', () => { ...@@ -436,7 +449,7 @@ describe('基数沿用 —— 本批人数与时效', () => {
expect(lily.overridden).toBe(true); expect(lily.overridden).toBe(true);
// ⚠️ 名额上限**不改批次规模** —— 少的那部分由别人接走 // ⚠️ 名额上限**不改批次规模** —— 少的那部分由别人接走
expect(r.placed).toBe(100); expect(r.placed).toBe(100);
expect(r.basisNote).toContain('批最多 5 条'); expect(r.basisNote).toContain('批最多 5 条');
}); });
test('⭐ 精调成 0 → 这轮完全不给他,并列进 skippedAgents', async () => { test('⭐ 精调成 0 → 这轮完全不给他,并列进 skippedAgents', async () => {
...@@ -472,6 +485,6 @@ describe('基数沿用 —— 本批人数与时效', () => { ...@@ -472,6 +485,6 @@ describe('基数沿用 —— 本批人数与时效', () => {
const r = await mk(1000, 3, { inHand: 0, lastCriteria: { batchSize: 9, expiresInDays: 3 } }) const r = await mk(1000, 3, { inHand: 0, lastCriteria: { batchSize: 9, expiresInDays: 3 } })
.propose(SCOPE, { clinicId: 'c1' }); .propose(SCOPE, { clinicId: 'c1' });
expect(r.byAgent.every((x: { loadAfter: number }) => x.loadAfter === 3)).toBe(true); expect(r.byAgent.every((x: { loadAfter: number }) => x.loadAfter === 3)).toBe(true);
expect(r.basisNote).toContain('分完后每人在手 3 条'); expect(r.basisNote).toContain('分完后每人手里 3 条');
}); });
}); });
...@@ -394,9 +394,22 @@ function MessageView({ ...@@ -394,9 +394,22 @@ function MessageView({
// ⛔ 助手消息**不带左侧头像**(2026-08-03 走查):助手窗只有 400px, // ⛔ 助手消息**不带左侧头像**(2026-08-03 走查):助手窗只有 400px,
// 每条都挂一个 28px 的圆头像 + 间距 = 白白吃掉一成宽度,而"这段是助手说的" // 每条都挂一个 28px 的圆头像 + 间距 = 白白吃掉一成宽度,而"这段是助手说的"
// 靠气泡形态(用户消息右对齐深底、助手左对齐无底)已经分得清了。 // 靠气泡形态(用户消息右对齐深底、助手左对齐无底)已经分得清了。
/**
* ⭐ **确认单钉在这条消息的最后**(2026-08-03 走查:文字在前、卡片在后)。
*
* 为什么要在渲染层排序,而不是"让模型先说话":工具调用**必然发生在文字之前**
* (模型要先拿到结果才知道说什么),而卡片是工具执行时经侧信道推来的 ——
* 到达顺序天然是"卡片 → 文字",改不了。
* ⚠️ 用 stable 排序(只把 assignment_sheet 挪到末尾),⛔ 别整体 sort:
* 文字块之间的先后是流式拼出来的,打乱就成乱码了。
*/
const ordered = [
...message.blocks.filter((b) => b.kind !== 'assignment_sheet'),
...message.blocks.filter((b) => b.kind === 'assignment_sheet'),
];
return ( return (
<div className="min-w-0 space-y-2.5"> <div className="min-w-0 space-y-2.5">
{message.blocks.map((b, i) => ( {ordered.map((b, i) => (
<BlockView <BlockView
key={i} key={i}
block={b} block={b}
......
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