Commit 9de8b34e by luoqi

feat(plan): 分配只留两个基数(容量+时效),都沿用主管上一次的值

批次规模不再是一个独立的默认常量(100),改成由容量推出来:
  本批人数 = Σ max(0, 容量 − 该客服在手)

这实际上是**恢复**裁决表里一直写着的「拟分 N 人 = 默认分满容量」——
代码里那个 DEFAULT_BATCH_SIZE=100 与它矛盾了两周,且注释还引 T5 给自己背书
(T5 的「100 人」是举例,约束的是容量该拍多大,不是批次规模)。常量删掉。

· 容量首次默认取**下界 20**(原来取上界 50):17 位客服 × 50 = 850 条一批,
  正是 T5 反对的做法。起点低、不够再往上调;反过来那一批已经分出去了。
· 两个基数都从 plan_assignments.criteria 快照里读上一次的值(零新列),
  查找顺序:该主管在该诊所的上一次 → 该诊所任何人的上一次 → 首次默认。
· 确认单显示**推导链 + 出处**:「在岗 17 位 × 每人容量 20 − 已在手 0 = 可分 340 人;
  容量/时效沿用 7-28 那次分配」—— 规模会随在手量波动,看不到推导只能怀疑系统抽风。
· 时效初值改为跟着提案走(原来写死 3),确认时把卡片当前值存回快照。
· 容量**不做成卡片控件**:改容量会改变人群,而卡片微调项只允许不改人群的那两项(T13)。
  主管说「每人 30 条」→ 助手带 capacity 重出单。
· targetCount 保留为**一次性**覆盖, 不回写基数 —— 拿「这批只要 60 人」反推出
  「以后每人 6.7 条」等于把临时决定固化成长期参数。

本地实测:充填·窗口外 1,080 → 本批 340 人 = 17 × 20 − 0,每人 20 条。
教条补 T22,T5 补一条「100 是举例不是参数」的反读说明。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent e986f5ef
import { Permission, AGENT_CAPACITY_DEFAULT } from '@pac/types'; import { Permission, AGENT_CAPACITY_DEFAULT, ASSIGNMENT_EXPIRES_DAYS_DEFAULT } from '@pac/types';
/** /**
* 按能力切换的助手工作流约束(拼在 SYSTEM_PROMPT 之后)。 * 按能力切换的助手工作流约束(拼在 SYSTEM_PROMPT 之后)。
...@@ -39,8 +39,10 @@ const DISPATCHER_EXTRA = ` ...@@ -39,8 +39,10 @@ const DISPATCHER_EXTRA = `
3. **凡是没有历史数据支撑的建议值,必须当场标明是默认值。** 3. **凡是没有历史数据支撑的建议值,必须当场标明是默认值。**
工具返回里的 rosterNote / capacityBasis 是给你**原话抄**的,别自己改写措辞。 工具返回里的 rosterNote / capacityBasis 是给你**原话抄**的,别自己改写措辞。
例:时效说「3 天(默认值,暂无历史结案数据,积累后按实际反推)」, ⚠️ 容量/时效现在有三种出处,**照 capacityNote 说,别自己归因**:
⛔ 不要说成「依据该诊所平均结案 2.4 天」—— 那个数算不出来。 沿用上次(「沿用 7 月 28 日那次分配」)/ 首次默认 / 本次主管指定。
⛔ 不要把"沿用上次"说成"系统算出来的",也不要把默认值说成「依据该诊所平均结案 2.4 天」
—— 那个数算不出来。
4. **「处理」不等于「成功」,⛔ 绝不能混为一谈。** 4. **「处理」不等于「成功」,⛔ 绝不能混为一谈。**
批次跟踪给的是 progress:**处理率不是成功率**,只说「这单动过了」,不说「谈成了」。 批次跟踪给的是 progress:**处理率不是成功率**,只说「这单动过了」,不说「谈成了」。
...@@ -84,11 +86,14 @@ const DISPATCHER_EXTRA = ` ...@@ -84,11 +86,14 @@ const DISPATCHER_EXTRA = `
- **专属优先**:该患者有专属客服且在名册内 → 分给他 - **专属优先**:该患者有专属客服且在名册内 → 分给他
- **溢出铺平**:专属客服已达容量 / 无专属 / 专属已离岗 → 铺给其他在岗客服, - **溢出铺平**:专属客服已达容量 / 无专属 / 专属已离岗 → 铺给其他在岗客服,
按当前在手量从少到多补,**均分**;在手已达上限的人本批跳过但仍列出来标「已满」 按当前在手量从少到多补,**均分**;在手已达上限的人本批跳过但仍列出来标「已满」
- 容量默认 ${AGENT_CAPACITY_DEFAULT}(**默认值**,无历史数据支撑,要标注)
- 分不下去就**明说分不下去**,建议缩小批次;⛔ 不要硬塞给已经满的人 - 分不下去就**明说分不下去**,建议缩小批次;⛔ 不要硬塞给已经满的人
- 批次规模**你自己定,不要问主管**(T13:直出不追问)—— - **两个基数:容量 + 时效,都自动沿用主管上一次的值** —— ⛔ 别问他,那正是这个设计要省掉的输入。
不传 targetCount 即用默认规模,服务端已夹好上限。selectionNote 会说明依据,照抄即可。 首次没有上一次才用默认(容量 ${AGENT_CAPACITY_DEFAULT}、时效 ${ASSIGNMENT_EXPIRES_DAYS_DEFAULT} 天)。
主管觉得要调,他自然会说"这批只要 60 人",那时再带 targetCount 重出一版。 capacityNote 里写好了推导链和出处(「沿用 X 月 X 日那次」/「首次默认」),**照抄**。
- 批次规模**不是你定的,也不是主管定的,是算出来的**:Σ(容量 − 各人在手)。
他说「每人 30 条」→ 传 capacity(基数,会被记住);「给 5 天」→ 传 expiresInDays;
「这批只要 60 人」→ 传 targetCount(**一次性**,不改基数,下次仍按容量算)。
⛔ 不要既传 capacity 又传 targetCount —— 两者矛盾时听谁的说不清。
- 每一条都要说得出「为什么是他」:专属 / 手上最空 / 主管指定,三选一 - 每一条都要说得出「为什么是他」:专属 / 手上最空 / 主管指定,三选一
`.trim(); `.trim();
......
...@@ -120,7 +120,9 @@ export class AssistantService { ...@@ -120,7 +120,9 @@ export class AssistantService {
'并把卡片直接呈现给主管。' + '并把卡片直接呈现给主管。' +
'\n⚠️ 卡片由界面渲染,**你看不到明细也不需要看** —— 你的任务是转述返回的那句摘要。' + '\n⚠️ 卡片由界面渲染,**你看不到明细也不需要看** —— 你的任务是转述返回的那句摘要。' +
'\n⚠️ **这只是提案,一个字都没写库**。绝不要说「已经分配好了」。' + '\n⚠️ **这只是提案,一个字都没写库**。绝不要说「已经分配好了」。' +
'\n⚠️ 批次规模你自己定(不传 targetCount 即用默认),**不要问主管**;他要改会自己说。', '\n⚠️ 容量与时效**会自动沿用主管上一次的值**,⛔ 别问他 —— 那正是这个设计要省掉的输入。' +
'他明确说「每人 30 条」「给 5 天」时才传 capacity / expiresInDays(会被记住);' +
'说「这批只要 60 人」传 targetCount(一次性,不改基数)。',
inputSchema: jsonSchema({ inputSchema: jsonSchema({
type: 'object', type: 'object',
properties: { properties: {
...@@ -141,7 +143,21 @@ export class AssistantService { ...@@ -141,7 +143,21 @@ export class AssistantService {
'主管在调整阶段追加的画像条件("key:value" 逗号串,同维 OR、跨维 AND)。' + '主管在调整阶段追加的画像条件("key:value" 逗号串,同维 OR、跨维 AND)。' +
'先用 get_cohort_attributes 看清各口子多少人,再带着它重出确认单。', '先用 get_cohort_attributes 看清各口子多少人,再带着它重出确认单。',
}, },
targetCount: { type: 'number', description: '拟分人数(不传用默认批次规模)' }, capacity: {
type: 'number',
description:
'每个客服的在手容量(基数①)。主管说「每人 30 条」时传;不传自动沿用他上一次的值。' +
'⭐ 批次规模由它推出(Σ 容量−在手),⛔ 不要既传 capacity 又传 targetCount。',
},
expiresInDays: {
type: 'number',
description: '批次时效天数(基数②)。主管说「给 5 天」时传;不传自动沿用上一次的值。',
},
targetCount: {
type: 'number',
description:
'本批人数的**一次性**覆盖(主管说「这批只要 60 人」)。⚠️ 不会改基数,下次仍按容量算。',
},
exploreRatio: { type: 'number', description: '探索配额占比 0-0.2' }, exploreRatio: { type: 'number', description: '探索配额占比 0-0.2' },
}, },
}), }),
...@@ -151,6 +167,8 @@ export class AssistantService { ...@@ -151,6 +167,8 @@ export class AssistantService {
potentialTreatment?: string; potentialTreatment?: string;
temperature?: TemperatureValue; temperature?: TemperatureValue;
personaTags?: string; personaTags?: string;
capacity?: number;
expiresInDays?: number;
targetCount?: number; targetCount?: number;
exploreRatio?: number; exploreRatio?: number;
}; };
......
...@@ -176,7 +176,11 @@ describe('selectionNote —— 候选不够时的措辞', () => { ...@@ -176,7 +176,11 @@ describe('selectionNote —— 候选不够时的措辞', () => {
* 这个真 bug 在测试里**根本不可能出现**,500 那条用例一直是假绿。 * 这个真 bug 在测试里**根本不可能出现**,500 那条用例一直是假绿。
* count 查询与明细查询靠 SQL 文本区分,与 service 里的两条查询一一对应。 * count 查询与明细查询靠 SQL 文本区分,与 service 里的两条查询一一对应。
*/ */
function svcWith(candidateCount: number, agentCount: number) { function svcWith(
candidateCount: number,
agentCount: number,
opts: { inHand?: number; lastCriteria?: Record<string, unknown> | null } = {},
) {
const prisma = { const prisma = {
$queryRaw: jest.fn(async (sql: { strings?: string[]; values?: unknown[] }) => { $queryRaw: jest.fn(async (sql: { strings?: string[]; values?: unknown[] }) => {
const text = (sql?.strings ?? []).join(' '); const text = (sql?.strings ?? []).join(' ');
...@@ -188,46 +192,156 @@ describe('selectionNote —— 候选不够时的措辞', () => { ...@@ -188,46 +192,156 @@ describe('selectionNote —— 候选不够时的措辞', () => {
})); }));
}), }),
patient: { findMany: jest.fn(async () => []) }, patient: { findMany: jest.fn(async () => []) },
// 基数沿用:上一次分配的 criteria 快照(null = 从来没分过)
planAssignment: {
findFirst: jest.fn(async () =>
opts.lastCriteria
? { criteria: opts.lastCriteria, createdAt: new Date('2026-07-28T02:00:00.000Z') }
: null,
),
},
}; };
const roster = { const roster = {
list: jest.fn(async () => ({ list: jest.fn(async () => ({
agents: Array.from({ length: agentCount }, (_, i) => ({ userId: `a${i}`, name: `客服${i}`, inHand: 0 })), agents: Array.from({ length: agentCount }, (_, i) => ({
userId: `a${i}`, name: `客服${i}`, inHand: opts.inHand ?? 0,
})),
rosterNote: '名册说明', rosterNote: '名册说明',
})), })),
}; };
return new AssignmentProposalService(prisma, roster); return new AssignmentProposalService(prisma, roster);
} }
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。⚠️ 批次规模现在是**推出来的**,不是默认常量
const DEFAULT_TARGET = 9 * 20;
test('⭐⭐ 候选 44 < 默认批次 100 → 说「一共就 44 人,全部纳入」,⛔ 不许说「取前 100 人」', 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('全部纳入');
expect(r.selectionNote).not.toContain('取前 100 人'); expect(r.selectionNote).not.toContain('取前');
// ⛔ 候选不够时不该再吹「批次规模 100 为默认值」—— 那个默认值根本没起作用
expect(r.selectionNote).not.toContain('批次规模 100');
}); });
test('⭐ 候选 500 > 默认批次 100 → 照实说「从 500 位候选里取前 100 人」+ 标默认值', 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.selectionNote).toContain('从 500 位候选里取前 100 人'); expect(r.target).toBe(DEFAULT_TARGET);
expect(r.selectionNote).toContain('默认值'); expect(r.selectionNote).toContain(`从 500 位候选里取前 ${DEFAULT_TARGET} 人`);
// ⚠️ 「只分这么多」必须说清是**容量吃不下**而不是**候选不够** —— 主管的下一步动作相反
expect(r.selectionNote).toContain('按容量算出来的');
}); });
/** /**
* 🔴🔴 取数上限**不许**冒充候选数。 * 🔴🔴 取数上限**不许**冒充候选数。
* *
* 真实场景(2026-08-03 走查发现):主管在矩阵上点「充填 · 窗口外 1,080」, * 真实场景(2026-08-03 走查发现):主管在矩阵上点「充填 · 窗口外 1,080」,
* 确认单却说「从 170 位候选里取前 100 人」—— 170 正是 `ceil(100*1.5)+20` 这个 LIMIT。 * 确认单却说「从 170 位候选里取前 100 人」—— 170 正是 `ceil(target*1.5)+20` 这个 LIMIT。
* 而这句话助手要**原话转述**,等于让系统当着主管的面报一个他刚看过的、对不上的数。 * 而这句话助手要**原话转述**,等于让系统当着主管的面报一个他刚看过的、对不上的数。
*/ */
test('🔴 候选 1080 但取数只取回 170 → 说的必须是 1080,⛔ 不许出现 170', async () => { test('🔴 候选 1080 但取数只取回 1.5 倍窗口 → 说的必须是 1080', async () => {
const svc = svcWith(1080, 9); const svc = svcWith(1080, 9);
const r = await svc.propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'filling', temperature: 'cold' }); const r = await svc.propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'filling', temperature: 'cold' });
expect(r.candidateTotal).toBe(1080); expect(r.candidateTotal).toBe(1080);
expect(r.selectionNote).toContain('从 1080 位候选里取前 100 人'); expect(r.selectionNote).toContain(`从 1080 位候选里取前 ${DEFAULT_TARGET} 人`);
expect(r.selectionNote).not.toContain('170'); expect(r.placed).toBe(DEFAULT_TARGET);
// 明细仍只取回 170 条(不因为要报真数就把整池拉回来) });
expect(r.placed).toBe(100); });
/**
* 两个基数(容量 + 时效)的**沿用**。
*
* 🔴 这是「先出确认单、主管反馈调整」这个设计的兑现点:第一次调顺手,第二次起零输入。
* 沿用断了不会报错 —— 只是主管每次都要重调一遍,然后觉得"这系统不记事"。
*/
describe('基数沿用 —— 容量与时效', () => {
const { AssignmentProposalService } = require('../src/modules/plan/assignment-proposal.service');
const SCOPE = { hostId: 'h', tenantId: 't', sourceUnits: [], clinicIds: ['c1'], userId: 'u' };
// 复用上面的构造器(同文件内提升不了,直接再拿一次)
const mk = (candidateCount: number, agentCount: number, opts: Record<string, unknown> = {}) => {
const prisma = {
$queryRaw: jest.fn(async (sql: { strings?: string[]; values?: unknown[] }) => {
const text = (sql?.strings ?? []).join(' ');
if (text.includes('count(DISTINCT')) return [{ n: BigInt(candidateCount) }];
const limit = Number((sql?.values ?? []).at(-1) ?? candidateCount);
return Array.from({ length: Math.min(candidateCount, limit) }, (_, i) => ({
planId: `p${i}`, patientId: `pat${i}`, priorityScore: 50 - i,
}));
}),
patient: { findMany: jest.fn(async () => []) },
planAssignment: {
findFirst: jest.fn(async () =>
opts.lastCriteria
? { criteria: opts.lastCriteria, createdAt: new Date('2026-07-28T02:00:00.000Z') }
: null,
),
},
};
const roster = {
list: jest.fn(async () => ({
agents: Array.from({ length: agentCount }, (_, i) => ({
userId: `a${i}`, name: `客服${i}`, inHand: (opts.inHand as number) ?? 0,
})),
rosterNote: '名册说明',
})),
};
return new AssignmentProposalService(prisma, roster);
};
test('⭐⭐ 上次用了容量 30 / 时效 5 天 → 本次沿用,批次规模 = 9 × 30', async () => {
const r = await mk(1000, 9, { lastCriteria: { capacity: 30, expiresInDays: 5 } }).propose(SCOPE, {
clinicId: 'c1',
potentialTreatment: 'implant',
});
expect(r.capacity).toBe(30);
expect(r.expiresInDays).toBe(5);
expect(r.basis).toBe('inherited');
expect(r.target).toBe(270);
// 出处必须说出来 —— 「这 30 哪来的」和这个数本身一样重要
expect(r.capacityNote).toContain('沿用 2026-07-28');
});
test('⭐ 从来没分过 → 首次默认(容量 20 / 3 天),且明说是默认', async () => {
const r = await mk(1000, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' });
expect(r.capacity).toBe(20);
expect(r.expiresInDays).toBe(3);
expect(r.basis).toBe('default');
expect(r.capacityNote).toContain('首次默认值');
});
test('⭐ 主管本次说「每人 40 条」→ explicit,批次 = 9 × 40', async () => {
const r = await mk(1000, 9, { lastCriteria: { capacity: 20, expiresInDays: 3 } }).propose(SCOPE, {
clinicId: 'c1',
capacity: 40,
});
expect(r.capacity).toBe(40);
expect(r.basis).toBe('explicit');
expect(r.target).toBe(360);
});
/**
* 🔴 一次性人数**不许**回写成基数。
* 拿「这批只要 60 人」反推出「以后每人 6.7 条」= 把临时决定固化成长期参数,
* 而下次没人记得为什么变了。
*/
test('🔴 主管说「这批只要 60 人」→ 人数 60,但容量基数原样不动', async () => {
const r = await mk(1000, 9, { lastCriteria: { capacity: 30, expiresInDays: 5 } }).propose(SCOPE, {
clinicId: 'c1',
targetCount: 60,
});
expect(r.target).toBe(60);
expect(r.capacity).toBe(30); // ⛔ 不是 60/9
expect(r.basis).toBe('inherited');
expect(r.selectionNote).toContain('不改容量基数');
});
test('⭐ 在手 ≥ 容量的人本批不参与,但要列进 skippedAgents', async () => {
const r = await mk(1000, 9, { inHand: 25, lastCriteria: { capacity: 20, expiresInDays: 3 } }).propose(
SCOPE,
{ clinicId: 'c1' },
);
expect(r.target).toBe(0);
expect(r.placed).toBe(0);
expect(r.skippedAgents).toHaveLength(9);
// 「分不了」必须同时说出旋钮在哪,否则主管只看到死路
expect(r.selectionNote).toContain('每人 30 条');
}); });
}); });
...@@ -4,6 +4,7 @@ import { useState } from 'react'; ...@@ -4,6 +4,7 @@ import { useState } from 'react';
import { ChevronRight, Check, Loader2, Users } from 'lucide-react'; import { ChevronRight, Check, Loader2, Users } from 'lucide-react';
import { import {
ASSIGN_STRATEGY_META, ASSIGN_STRATEGY_META,
ASSIGNMENT_EXPIRES_DAYS_PRESETS,
Permission, Permission,
type AssignmentProposal, type AssignmentProposal,
type AssignStrategy, type AssignStrategy,
...@@ -42,7 +43,13 @@ export function AssignmentConfirmSheet({ ...@@ -42,7 +43,13 @@ export function AssignmentConfirmSheet({
/** 落库成功 → 交给外层把卡片切终态 + 往消息流注入一条文本(补模型记忆) */ /** 落库成功 → 交给外层把卡片切终态 + 往消息流注入一条文本(补模型记忆) */
onConfirmed: (assignmentId: string, summary: string) => void; onConfirmed: (assignmentId: string, summary: string) => void;
}) { }) {
const [expiresInDays, setExpiresInDays] = useState(3); /**
* 时效初值来自**提案**(沿用主管上一次的值),⛔ 不是写死的 3 ——
* 写死的话"记住上次时效"这件事在界面上就永远看不见,主管每次还得重调一遍。
* ⚠️ 容量**不做成卡片控件**:改容量会改变人群(人数由它推出),
* 而卡片的微调项只允许"不改人群"的那两个(T13)。改容量回对话说一句,助手重出单。
*/
const [expiresInDays, setExpiresInDays] = useState(sheet.expiresInDays);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState<Set<string>>(new Set()); const [open, setOpen] = useState<Set<string>>(new Set());
...@@ -64,6 +71,10 @@ export function AssignmentConfirmSheet({ ...@@ -64,6 +71,10 @@ export function AssignmentConfirmSheet({
candidateTotal: sheet.candidateTotal, candidateTotal: sheet.candidateTotal,
target: sheet.target, target: sheet.target,
selectionNote: sheet.selectionNote, selectionNote: sheet.selectionNote,
// ⭐ 两个基数落进快照 —— **下一次分配就是从这里读出来沿用的**(零新列)。
// ⚠️ 时效存**卡片当前值**不是提案值:主管刚在上面改成 5 天,记住的就得是 5。
capacity: sheet.capacity,
expiresInDays,
}, },
expiresInDays, expiresInDays,
// ⚠️ 落库的是**卡片当前值**,不是模型原提案 —— 主管改了时效就得按改后的走 // ⚠️ 落库的是**卡片当前值**,不是模型原提案 —— 主管改了时效就得按改后的走
...@@ -177,7 +188,7 @@ export function AssignmentConfirmSheet({ ...@@ -177,7 +188,7 @@ export function AssignmentConfirmSheet({
<div className="space-y-1.5 border-t border-slate-100 px-3 py-2"> <div className="space-y-1.5 border-t border-slate-100 px-3 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="flex-none text-slate-500">时效</span> <span className="flex-none text-slate-500">时效</span>
{[3, 5, 7].map((d) => ( {ASSIGNMENT_EXPIRES_DAYS_PRESETS.map((d) => (
<button <button
key={d} key={d}
type="button" type="button"
...@@ -194,8 +205,24 @@ export function AssignmentConfirmSheet({ ...@@ -194,8 +205,24 @@ export function AssignmentConfirmSheet({
{d} {d}
</button> </button>
))} ))}
{/* ⭐ T14:没有历史结案数据,这个 3 天就是默认值,必须当场标明 */} {/* ⭐ T14:这个初值哪来的必须当场说清 —— 沿用上次 / 首次默认,信任度不同 */}
<span className="text-[10px] text-slate-400">默认 3 天(暂无历史结案数据)</span> <span className="text-[10px] text-slate-400">
{sheet.basis === 'inherited' && sheet.basisFrom
? `沿用 ${sheet.basisFrom.slice(5, 10).replace('-', '/')} 那次`
: sheet.basis === 'explicit'
? '本次指定'
: '首次默认(暂无历史结案数据)'}
</span>
</div>
{/* 容量:**只读**。人数由它推出来,改它就是换人群 —— 卡片不做这种事(T13) */}
<div className="flex items-center gap-2 text-[11px] text-slate-500">
<span className="flex-none">容量</span>
<span className="rounded border border-slate-200 px-2 py-0.5 tabular-nums text-slate-600">
每人 {sheet.capacity}
</span>
<span className="text-[10px] text-slate-400">
本批 {sheet.target} 人由它推出 · 要改说「每人 {sheet.capacity + 10} 条」
</span>
</div> </div>
<p className="text-[10.5px] leading-relaxed text-slate-400"> <p className="text-[10.5px] leading-relaxed text-slate-400">
指定客服请在对话里说(如「王强这批给李莉」);换人群也回对话让助手重新圈。 指定客服请在对话里说(如「王强这批给李莉」);换人群也回对话让助手重新圈。
......
...@@ -68,6 +68,49 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾 ...@@ -68,6 +68,49 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
一次批次宁可只做 100 人做透,不做 1000 人做浅。 一次批次宁可只做 100 人做透,不做 1000 人做浅。
**池子里剩下的不是遗漏,是还没轮到。** **池子里剩下的不是遗漏,是还没轮到。**
> ⚠️ 「100」是**举例,不是参数**。批次规模由 T22 的容量推出来,⛔ 别把这句话读成
> 「系统里应该有一个 `DEFAULT_BATCH_SIZE = 100`」——2026-08-03 之前就是这么读的,
> 结果代码里立了一个 100 的常量,和裁决表里写死的「拟分 N 人 = 默认分满容量」互相矛盾了两周。
> 这一条约束的是**容量该拍多大**(所以首次默认取下界 20,不是上界 50),不是批次规模本身。
### T22 · 分配只有两个基数:**容量** 和 **时效**,且都沿用主管上一次的值
(2026-08-03 产品定)
| 基数 | 含义 | 首次默认 |
|---|---|---|
| **容量** | 一个客服**同时**能压多少条(存量上限,不是每日增量) | 20(区间下界) |
| **时效** | 这批单子多久没动就自动回池 | 3 天 |
**批次规模不是基数,是推出来的**`本批人数 = Σ max(0, 容量 − 该客服在手)`
在手 ≥ 容量的人本批不参与,但**仍要列出来**标「已满」——「他不是被漏了,是已经满了」。
**不许再引入第三个旋钮**("默认批次规模"那种)。两个旋钮能推出的东西,
再给一个就会互相打架:改容量还是改人数?两者矛盾时听谁的?
**为什么是"沿用上一次"而不是"每次问"**:确认单本来就是给主管调的 ——
他第一次把容量和时效调到顺手,系统记住,**第二次起零输入**
这是「先出全景确认单、再让主管反馈调整」这个设计的兑现点:调一次,以后省一次。
⛔ 所以既不能做成每次弹窗问一遍(违 T13 直出不追问),也不能永远用系统默认(第一次的调整白费)。
查找顺序:**该主管在该诊所的上一次 → 该诊所任何人的上一次 → 首次默认**
值存在 `plan_assignments.criteria` 这个 Json 快照里(零新列),读不到就当没有,⛔ 不抛错。
⚠️ **一次性人数不回写基数**:主管说「这批只要 60 人」→ 这一批 60 人,容量原样不动。
拿 60 反推出「以后每人 6.7 条」等于把临时决定固化成长期参数,而下次没人记得为什么变了。
他真想改容量会直接说「每人 30 条」—— 那是另一句话。
⚠️ **容量不做成卡片控件**:改容量会改变人群(人数由它推出),而卡片的微调项
只允许"不改人群"的那两个(T13)。改容量回对话说一句,助手重出单。时效不改人群,留在卡片上。
⚠️ 批次规模会随在手量波动(同样的容量,这次推出 340、下次 80)。因此确认单上
**必须显示推导链**`在岗 17 位 × 每人容量 20 − 已在手 260 = 可分 80 人`
外加**出处**(沿用 7-28 那次 / 首次默认 / 本次指定)——
看不到推导过程,主管只能怀疑系统抽风;看不到出处,他分不清这个数该信几分(T14)。
> 这条实际上是**恢复**裁决表里一直写着的「拟分 N 人 = 默认分满容量」,
> 只是把"容量"从一个系统常量变成了会被记住的主管偏好。
### T6a · 初选 X 轴 = 画像的「潜在治疗」8 类,不是 PAC 治疗类目 ### T6a · 初选 X 轴 = 画像的「潜在治疗」8 类,不是 PAC 治疗类目
矩阵横轴用 `persona_features.potential_treatment`**8 类业务机会**), 矩阵横轴用 `persona_features.potential_treatment`**8 类业务机会**),
...@@ -703,9 +746,9 @@ patient_transactions 经 patient_id → 客观新预约(canonical_payload.cre ...@@ -703,9 +746,9 @@ patient_transactions 经 patient_id → 客观新预约(canonical_payload.cre
| 福利是否核销 | **v1 不核销** | 先验证「带福利批次转化是否更高」,再谈打通卡券系统 | | 福利是否核销 | **v1 不核销** | 先验证「带福利批次转化是否更高」,再谈打通卡券系统 |
| **助手要不要校验专属客服在岗** | **不校验** | 在岗数据目前不够精确;**由主管在确认单上自行说明**,不让助手拿半准的数据挡人 | | **助手要不要校验专属客服在岗** | **不校验** | 在岗数据目前不够精确;**由主管在确认单上自行说明**,不让助手拿半准的数据挡人 |
| **「在岗」怎么判** | **近似即可**:默认按 `source_created_at` 近 12 月;**最终以主管信息为准** | 数据只做默认值,主管说了算 | | **「在岗」怎么判** | **近似即可**:默认按 `source_created_at` 近 12 月;**最终以主管信息为准** | 数据只做默认值,主管说了算 |
| 容量上限 | **在手总量 20-50** | 不是每日增量 | | 容量上限 | **在手总量 20-50,首次取下界 20,之后沿用主管上次的值** | 不是每日增量;见 T22 |
| 初选 X 轴用什么 | **画像的潜在治疗 8 类** | 见 T6a;`focusCategory` 是技术类目不是业务机会,且会把早矫埋进正畸 | | 初选 X 轴用什么 | **画像的潜在治疗 8 类** | 见 T6a;`focusCategory` 是技术类目不是业务机会,且会把早矫埋进正畸 |
| 拟分 N 人怎么定 | **默认分满容量** | 第一性:容量上限本身已是「一个人同时能处理多少」的约束,不必再打折 | | 拟分 N 人怎么定 | **默认分满容量**`Σ 容量−在手`) | 第一性:容量上限本身已是「一个人同时能处理多少」的约束,不必再打折。⚠️ 曾被实现成一个 100 的常量,与本裁决矛盾,2026-08-03 改回,见 T22 |
| 明细默认展示多少 | **按客服折叠**,展开才看 | 兼顾「尽明细」与「一眼可确认」 | | 明细默认展示多少 | **按客服折叠**,展开才看 | 兼顾「尽明细」与「一眼可确认」 |
| 全景是否先问意图 | **不问**,助手推导 | 每多问一句就多一次决策成本,与极致减负相悖 | | 全景是否先问意图 | **不问**,助手推导 | 每多问一句就多一次决策成本,与极致减负相悖 |
| 批次表叫什么 | **`plan_assignments`** | 沿用 `plan_*` 家族(已有 6 张);语义 = 一次分配动作 | | 批次表叫什么 | **`plan_assignments`** | 沿用 `plan_*` 家族(已有 6 张);语义 = 一次分配动作 |
......
...@@ -72,32 +72,44 @@ export type CreateAssignmentRequest = z.infer<typeof CreateAssignmentRequestSche ...@@ -72,32 +72,44 @@ export type CreateAssignmentRequest = z.infer<typeof CreateAssignmentRequestSche
// ============================================================= // =============================================================
/** /**
* ═══ 分配只有两个基数:**容量** 和 **时效** ═══════════════════════
*
* 两者的取值方式完全一致(2026-08-03 产品定):
* **沿用该主管在该诊所上一次分配用的值**;没有上一次才用下面的默认。
*
* ⭐ 为什么是"沿用"而不是"每次问":确认单本来就是给主管调的 ——
* 第一次他在卡片/对话里把容量和时效调到顺手,系统记住,**第二次起他什么都不用输**。
* 这是"先出全景确认单、再让主管反馈调整"这个设计的兑现点:调一次,以后省一次。
* ⛔ 所以别把它们做成"每次弹窗问一遍"或"永远用系统默认" —— 前者违 T13(直出不追问),
* 后者让第一次的调整白费。
*
* ⚠️ 批次规模**不是基数**,是这两个基数 + 在手量推出来的结果:
* `本批人数 = Σ max(0, 容量 − 该客服在手)`。
* ⛔ 别再引入"默认批次规模"那种第三个旋钮 —— 两个旋钮能推出的东西,
* 再给一个就会互相打架(改容量还是改人数?两者矛盾时听谁的?)。
*/
/**
* 单个客服同时能跟进的**在手总量**区间(不是每日增量)。 * 单个客服同时能跟进的**在手总量**区间(不是每日增量)。
* *
* ⚠️ 这是**默认值,没有数据支撑** —— 全生产 `plan_executions` 仅 7 条, * ⚠️ 区间本身仍是**没有数据支撑的默认值** —— 全生产 `plan_executions` 仅 7 条,
* 算不出任何人的真实吞吐。凡是用到它的地方都必须按 T14 当场标明"默认值", * 算不出任何人的真实吞吐。凡是用到它的地方都必须按 T14 当场标明出处,
* 等 T20 沉淀出「完成率开始下滑的拐点」再替换。 * 等 T20 沉淀出「完成率开始下滑的拐点」再替换。
* 产品 2026-08 定:默认取**上界 50**。
*/ */
export const AGENT_CAPACITY_RANGE: readonly [number, number] = [20, 50]; export const AGENT_CAPACITY_RANGE: readonly [number, number] = [20, 50];
/// 助手算拟分人数时用的那一个数(区间上界)。⚠️ 仍是默认值,不是实测容量。
export const AGENT_CAPACITY_DEFAULT = AGENT_CAPACITY_RANGE[1];
/** /**
* 一批默认分多少人。 * **首次**分配的容量起点(之后沿用上一次)。
*
* ⚠️⚠️ **「团队还能吃多少」不是「这批该分多少」** —— 这是两个量,混了会得到很荒谬的数:
* 17 位客服 × 每人容量 50 = 850,而 T5 说的是「宁可只做 100 人做透,不做 1000 人做浅」。
* 容量是"一个人同时能压多少"(存量上限),批次规模是"这一轮想推多少"(运营选择)。
* *
* 取 100 的依据就是 T5 那句话本身 —— 它是**教条给的口径**,不是我们拍的。 * ⚠️ 取**下界 20** 而不是上界:批次规模现在由容量推出来(17 人 × 50 = 850 条一批,
* 但它仍然是**默认值**:没有任何数据证明 100 比 80 或 150 好, * 正是 T5「宁可 100 人做透,不做 1000 人做浅」反对的做法)。
* 等 T20 沉淀出"批次规模 × 完成率"的关系再替换。所以凡是用到它的地方都要按 T14 标注。 * 起点低、主管觉得不够再往上调 —— 反过来(起点高、发现做不完再往下调)那一批已经分出去了。
*
* ⭐ 助手**自己按这个默认值出方案,不要每次问主管**(T13:直出不追问)。
* 主管觉得要调,他自然会说"这批只要 60 人"。
*/ */
export const DEFAULT_BATCH_SIZE = 100; export const AGENT_CAPACITY_DEFAULT = AGENT_CAPACITY_RANGE[0];
/// **首次**分配的时效起点(之后沿用上一次)。同容量,是基数不是常量。
export const ASSIGNMENT_EXPIRES_DAYS_DEFAULT = 3;
/// 卡片上给的时效档位(主管在卡片上直接改;改容量要回对话让助手重出单 —— 容量会改变人群)
export const ASSIGNMENT_EXPIRES_DAYS_PRESETS: readonly number[] = [3, 5, 7];
export const AgentInfoSchema = z.object({ export const AgentInfoSchema = z.object({
userId: z.string(), userId: z.string(),
...@@ -282,7 +294,17 @@ export const AssignmentProposalSchema = z.object({ ...@@ -282,7 +294,17 @@ export const AssignmentProposalSchema = z.object({
/// ⭐ 与矩阵格子同一种数法(count DISTINCT patient_id),⛔ 不受取明细的 LIMIT 影响 —— /// ⭐ 与矩阵格子同一种数法(count DISTINCT patient_id),⛔ 不受取明细的 LIMIT 影响 ——
/// 从矩阵点进来的主管会拿这个数跟他刚看到的格子对 /// 从矩阵点进来的主管会拿这个数跟他刚看到的格子对
candidateTotal: z.number().int().describe('候选总数 = 该格子/该条件下的患者数(与矩阵格子对得上)'), candidateTotal: z.number().int().describe('候选总数 = 该格子/该条件下的患者数(与矩阵格子对得上)'),
target: z.number().int().describe('拟分人数'), target: z.number().int().describe('拟分人数 —— 由容量与在手量推出,不是独立旋钮'),
/// ── 两个基数,连同它们的**出处**一起下发 ────────────────────────
/// ⚠️ 出处必须跟着值走:主管看到「容量 20」时,「这 20 是哪来的」和这个数本身一样重要 ——
/// 沿用上次 / 首次默认 / 他自己刚说的,三者对应完全不同的信任度(T14:界面元素也算证据)。
capacity: z.number().int().describe('每个客服的在手容量(本批实际用的那个值)'),
expiresInDays: z.number().int().describe('批次时效天数(卡片的初值,主管可在卡片上改)'),
basis: z
.enum(['inherited', 'default', 'explicit'])
.describe('基数来源:沿用上次 / 首次默认 / 本次主管明确指定'),
/// 沿用时是"沿用哪一次"(ISO 日期);非沿用为 null
basisFrom: z.string().nullable(),
placed: z.number().int(), placed: z.number().int(),
/// ⚠️ 分不下去的**不摊派**给已满的人:硬塞是 T5 的反面,而且会立刻造出 over_capacity 退回, /// ⚠️ 分不下去的**不摊派**给已满的人:硬塞是 T5 的反面,而且会立刻造出 over_capacity 退回,
/// 而那正是要用来反推容量默认值的信号 —— 自己造出来就没法反推了 /// 而那正是要用来反推容量默认值的信号 —— 自己造出来就没法反推了
......
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