Commit 7ea5a365 by luoqi

feat(分配): 默认时效 3 → 1 天(一批推一天的活);卡片只留标题+按钮,解释交给模型

① 时效默认 1 天(产品定):
   N = 在岗 × 每天 15 通 × 时效,D=1 正好是**一天的量** ——
   当天没打完的自动落回池子、明天重排。朝阳公园实测:765 → 255。
   ️ 连带后果(已知并接受):daily_overload 阈值变成 15×1,只要谁手上还有旧单
   就会亮。那不是 bug,它是工作量预估,每批本来就该讲清楚。

② 引导节点在卡片上**只渲染标题 + 按钮**:
   🔴 2026-08-12 浏览器实测:模型那段就在卡片正上方,两边都写全 ——
   「72 人的专属客服这轮已排满 / 他们有专属… / 不处理 = 这批不发给他们」
   主管要把同一件事读两遍。
   ⇒ 分工:**卡片管动作,对话管解释**。why / defaultLabel 仍随 signal 下发
   (模型要用),只是卡片不再渲染;折叠区的纯信息类旁边没有对话解释,仍给一句。
    按钮**不能**搬进对话:打字 → 模型理解 → 翻译成 intent,每一环都可能错
   (实测栽过 owner/balance 选反,18 人被散给 17 位别人);点击没有理解环节。

③ 两份设计文档同步这条分工。

测试:1247 passed;web 20 passed;tsc / build 干净。
️ 改了一条断言:原来锁死"整体时效=3",改成断"整体没被那个人的精调带偏"
—— 锁死默认值会让每次调默认都红一片。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent bf9f86d0
...@@ -458,8 +458,8 @@ describe('selectionNote —— 候选不够时的措辞', () => { ...@@ -458,8 +458,8 @@ describe('selectionNote —— 候选不够时的措辞', () => {
} }
const SCOPE = { hostId: 'h', tenantId: 't', sourceUnits: [], clinicIds: ['c1'], userId: 'u' }; const SCOPE = { hostId: 'h', tenantId: 't', sourceUnits: [], clinicIds: ['c1'], userId: 'u' };
/// 首次估法:在岗 9 人 × 20 = 180(⚠️ 那个 20 不是容量上限,只是首次没有历史时的估法) /// 首次估法:在岗 9 人 × 20 = 180(⚠️ 那个 20 不是容量上限,只是首次没有历史时的估法)
// 默认口径:在岗 9 人 × 每天 15 通 × 3 天(2026-08-12 产品改判,⛔ 不再沿用上一次) // 默认口径:在岗 9 人 × 每天 15 通 × 1 天(2026-08-12:一批推一天的活,⛔ 不再沿用上一次)
const N = 9 * 15 * 3; const N = 9 * 15 * 1;
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' });
...@@ -574,16 +574,16 @@ describe('基数沿用 —— 本批人数与时效', () => { ...@@ -574,16 +574,16 @@ describe('基数沿用 —— 本批人数与时效', () => {
const r = await mk(1000, 9, { lastCriteria: { batchSize: 300, expiresInDays: 5 } }).propose(SCOPE, { const r = await mk(1000, 9, { lastCriteria: { batchSize: 300, expiresInDays: 5 } }).propose(SCOPE, {
clinicId: 'c1', clinicId: 'c1',
}); });
expect(r.batchSize).toBe(9 * 15 * 3); expect(r.batchSize).toBe(9 * 15 * 1);
expect(r.expiresInDays).toBe(3); expect(r.expiresInDays).toBe(1);
expect(r.basis).toBe('default'); expect(r.basis).toBe('default');
}); });
test('⭐ 默认口径 = 在岗 9 人 × 每天 15 通 × 3 天 = 405,且说清是默认值', async () => { test('⭐ 默认口径 = 在岗 9 人 × 每天 15 通 × 1 天 = 135,且说清是默认值', async () => {
const r = await mk(1000, 9).propose(SCOPE, { clinicId: 'c1' }); const r = await mk(1000, 9).propose(SCOPE, { clinicId: 'c1' });
expect(r.batchSize).toBe(405); expect(r.batchSize).toBe(135);
expect(r.target).toBe(405); expect(r.target).toBe(135);
expect(r.expiresInDays).toBe(3); expect(r.expiresInDays).toBe(1);
expect(r.basis).toBe('default'); expect(r.basis).toBe('default');
expect(r.basisNote).toContain('系统默认'); expect(r.basisNote).toContain('系统默认');
// ⚠️ 算式必须写出来 —— 主管看到 405 的第一反应是"怎么这么多" // ⚠️ 算式必须写出来 —— 主管看到 405 的第一反应是"怎么这么多"
...@@ -606,8 +606,8 @@ describe('基数沿用 —— 本批人数与时效', () => { ...@@ -606,8 +606,8 @@ describe('基数沿用 —— 本批人数与时效', () => {
test('🔴 这批候选只有 44 → 本批 44,但基数仍是默认那个数', async () => { test('🔴 这批候选只有 44 → 本批 44,但基数仍是默认那个数', async () => {
const r = await mk(44, 9).propose(SCOPE, { clinicId: 'c1' }); const r = await mk(44, 9).propose(SCOPE, { clinicId: 'c1' });
expect(r.target).toBe(44); expect(r.target).toBe(44);
expect(r.batchSize).toBe(405); expect(r.batchSize).toBe(135);
expect(r.basisNote).toContain('您设的 405 没变'); expect(r.basisNote).toContain('您设的 135 没变');
}); });
test('⭐ 主管本次说「这批 200 人」→ explicit,压过默认口径', async () => { test('⭐ 主管本次说「这批 200 人」→ explicit,压过默认口径', async () => {
...@@ -647,9 +647,9 @@ describe('基数沿用 —— 本批人数与时效', () => { ...@@ -647,9 +647,9 @@ describe('基数沿用 —— 本批人数与时效', () => {
const r = await mk(1000, 9, { const r = await mk(1000, 9, {
lastCriteria: { expiresInDays: 3, agentOverrides: { a3: { expiresInDays: 7 } } }, lastCriteria: { expiresInDays: 3, agentOverrides: { a3: { expiresInDays: 7 } } },
}).propose(SCOPE, { clinicId: 'c1', targetCount: 100 }); }).propose(SCOPE, { clinicId: 'c1', targetCount: 100 });
expect(r.expiresInDays).toBe(3); // ⚠️ 断的是"整体没被那个人的精调带偏",⛔ 不锁具体天数(它随默认口径变)
expect(r.byAgent.find((x) => x.userId === 'a3')!.expiresInDays).toBe(7); expect(r.byAgent.find((x) => x.userId === 'a3')!.expiresInDays).toBe(7);
expect(r.byAgent.find((x) => x.userId === 'a0')!.expiresInDays).toBe(3); expect(r.byAgent.find((x) => x.userId === 'a0')!.expiresInDays).toBe(r.expiresInDays);
}); });
test('⛔ 精调表里的垃圾值静默丢弃,不让一条坏精调把整张确认单顶掉', async () => { test('⛔ 精调表里的垃圾值静默丢弃,不让一条坏精调把整张确认单顶掉', async () => {
......
...@@ -14,8 +14,9 @@ import { Button } from '@/components/ui/button'; ...@@ -14,8 +14,9 @@ import { Button } from '@/components/ui/button';
* ── 三条来自教条的硬要求 ──────────────────────────────────────── * ── 三条来自教条的硬要求 ────────────────────────────────────────
* ① **不截断**:`action` 层全部展开。防噪音靠把 `info` 折叠,⛔ 不靠丢弃 —— * ① **不截断**:`action` 层全部展开。防噪音靠把 `info` 折叠,⛔ 不靠丢弃 ——
* 藏起一条主管就不知道有东西卡着(G9)。 * 藏起一条主管就不知道有东西卡着(G9)。
* ② **默认永远是 no-op 且要写出来**:每条都显示"不处理会怎样", * ② **默认永远是 no-op**:不点任何按钮 = 不动。「一条不点、直接确认」永远安全。
* 这样「一条不点、直接确认」是**看得见**的安全选项(G4/G8)。 * ⚠️ "不处理会怎样"这句话由**模型在卡片上方讲**,⛔ 卡片不再重复渲染
* (2026-08-12 实测:两边都写全,主管要把同一件事读两遍)。
* ③ **选项是可点击的动作,不是让他照着打字**(G10)。 * ③ **选项是可点击的动作,不是让他照着打字**(G10)。
* 打字 → 模型理解 → 翻译成动作,每一环都可能错;点击是确定性的。 * 打字 → 模型理解 → 翻译成动作,每一环都可能错;点击是确定性的。
*/ */
...@@ -89,16 +90,19 @@ function Row({ ...@@ -89,16 +90,19 @@ function Row({
}) { }) {
return ( return (
<div className={`px-3 py-2 ${muted ? 'pt-1.5' : 'border-b border-amber-100/70 last:border-b-0'}`}> <div className={`px-3 py-2 ${muted ? 'pt-1.5' : 'border-b border-amber-100/70 last:border-b-0'}`}>
{/* ⚠️ 标题是**唯一**加粗的地方:一条消息里加粗超过两处就等于没有重点 */} {/*
🔴 **卡片只说「能做什么」,「为什么 / 不管会怎样」交给模型**(2026-08-12 实测后改)。
在此之前两边都写全,主管要把同一件事读两遍 —— 而模型那段就在卡片正上方。
⇒ 分工:卡片 = 动作(标题 + 按钮),对话 = 解释。
⛔ 但**按钮不能搬走**:打字 → 模型理解 → 翻译成 intent,每一环都可能错
(实测栽过 owner/balance 选反,18 人被散给 17 位别人);点击没有理解环节。
⚠️ `why` / `defaultLabel` 仍然随 signal 下发(模型要用),只是卡片不再渲染。
*/}
<p className={muted ? 'text-[11.5px] text-slate-600' : 'text-[12px] font-medium text-slate-800'}> <p className={muted ? 'text-[11.5px] text-slate-600' : 'text-[12px] font-medium text-slate-800'}>
{s.title} {s.title}
</p> </p>
<p className="mt-0.5 text-[11px] leading-relaxed text-slate-500">{s.why}</p> {/* 折叠区里的纯信息类没有对话在旁边解释,仍要给一句 */}
{/* {muted && <p className="mt-0.5 text-[11px] leading-relaxed text-slate-500">{s.why}</p>}
⭐ 默认路径**必须显示**,而且要看起来是个正常选项而不是失败兜底 ——
「一条不点直接确认」在任何情况下都安全,这句话是它的证据。
*/}
<p className="mt-0.5 text-[10.5px] text-slate-400">{s.defaultLabel}</p>
{!readOnly && s.options.length > 0 && ( {!readOnly && s.options.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1.5"> <div className="mt-1.5 flex flex-wrap gap-1.5">
{s.options.map((o) => { {s.options.map((o) => {
......
...@@ -786,7 +786,13 @@ Agent 的特征恰恰是「同一个长前缀被反复发送几十轮」,所 ...@@ -786,7 +786,13 @@ Agent 的特征恰恰是「同一个长前缀被反复发送几十轮」,所
6. **选项做成可直接点击执行的动作**,⛔ 不是让人照着打字;自由输入只作兜底。 6. **选项做成可直接点击执行的动作**,⛔ 不是让人照着打字;自由输入只作兜底。
打字 → 模型理解 → 翻译成动作,这条链每一环都可能出错;点击是**确定性**的,动作直接给出,没有理解环节。 打字 → 模型理解 → 翻译成动作,这条链每一环都可能出错;点击是**确定性**的,动作直接给出,没有理解环节。
**三方各取所需**:界面渲染成可点的行;模型只拿 `title / why / defaultLabel` 用来组织语言(⛔ 不给 options 的技术细节);人来决定。 **三方各取所需**
- **界面** —— 渲染成可点的行,**只给标题 + 按钮**(能做什么)
- **模型** —— 拿 `title / why / defaultLabel` 组织语言,**负责解释**(为什么 / 不管会怎样);⛔ 不给它 options 的技术细节
- **人** —— 决定
> ⚠️ 两边都写全,用户会把同一件事读两遍(实测)。分工:**界面管动作,对话管解释**。
> ⛔ 但按钮不能搬进对话 —— 打字 → 模型理解 → 翻译成动作,每一环都可能错;点击没有理解环节。
**两个入口必须收敛到同一个 `intent` 契约**——界面点击与「用户直接说」走同一条路,这样「界面能做的模型也能做」是结构保证,不是提示词里的一句叮嘱。 **两个入口必须收敛到同一个 `intent` 契约**——界面点击与「用户直接说」走同一条路,这样「界面能做的模型也能做」是结构保证,不是提示词里的一句叮嘱。
> 附带收益:节点数组天然就是那段话的**结构骨架**(未处置的节点 = 「要你定的」那一块)。 > 附带收益:节点数组天然就是那段话的**结构骨架**(未处置的节点 = 「要你定的」那一块)。
......
...@@ -244,6 +244,9 @@ N 按「在岗 × 每天 15 通 × 时效」算,**只算新增、⛔ 不扣在 ...@@ -244,6 +244,9 @@ N 按「在岗 × 每天 15 通 × 时效」算,**只算新增、⛔ 不扣在
**每个选项都是一个可直接点击执行的动作**,⛔ 不是让主管照着打字。 **每个选项都是一个可直接点击执行的动作**,⛔ 不是让主管照着打字。
⚠️ **卡片只渲染标题 + 按钮**`为什么` / `不处理会怎样` 由模型在卡片上方讲
(2026-08-12 实测:两边都写全,主管要把同一件事读两遍)。
``` ```
┌──────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────┐
│ 34 人的专属客服这轮排满了 │ │ 34 人的专属客服这轮排满了 │
......
...@@ -128,8 +128,17 @@ export const BATCH_SIZE_PER_AGENT_FIRST = 20; ...@@ -128,8 +128,17 @@ export const BATCH_SIZE_PER_AGENT_FIRST = 20;
*/ */
export const DAILY_CALLS_PER_AGENT = 15; export const DAILY_CALLS_PER_AGENT = 15;
/// **首次**分配的时效起点(之后沿用上一次)。同批次人数,是基数不是常量。 /**
export const ASSIGNMENT_EXPIRES_DAYS_DEFAULT = 3; * 批次时效的默认天数。
*
* 🔴 2026-08-12 由 3 改为 **1**(产品定):走「**每天推一天的活**」的节奏 ——
* N = 在岗人数 × 每天 ${DAILY_CALLS_PER_AGENT} 通 × 时效,D=1 时正好是一天的量,
* 当天没打完的自动落回池子、明天重排。
* ⚠️ 连带后果(已知并接受):`daily_overload` 的阈值随之变成 `15 × 1`,
* 于是**只要谁手上还有旧单,这个节点就会亮**。那不是 bug ——
* 它是工作量预估,每批本来就该讲清楚(见 assignment-signals 里该节点的注释)。
*/
export const ASSIGNMENT_EXPIRES_DAYS_DEFAULT = 1;
/** /**
* 卡片上时效可选的天数。**给全部 1-7 天**,不是 3/5/7 三档。 * 卡片上时效可选的天数。**给全部 1-7 天**,不是 3/5/7 三档。
* *
......
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