Commit b6868af6 by luoqi

feat: P4.4 原生确认单 —— S1 生产线全线打通

主管在召回池点「移交助手」→ 助手出确认单 → 点确认 → 100 条落到 12 位客服名下。
本地真实数据端到端跑通。

## 批次规模:助手自己定,不问主管(产品定调)

上一刀留的问题:target 不传时取"团队剩余容量之和"= 17 人 × 50 = **850**,
而 T5 说的是「宁可 100 人做透」。️ **「团队还能吃多少」不是「这批该分多少」**。

新增 `DEFAULT_BATCH_SIZE = 100`,依据就是 T5 那句话本身(教条给的口径,不是拍的),
再被两个上限夹住:团队剩余容量、候选总数。
 助手**按默认值直出,不问**(T13);主管要改自己说"这批只要 60 人"。
原先 systemExtra 里写的"主动问主管这批想做多少人"是违 T13 的,已删。

## propose_assignment 从 MCP 改成本地工具 + 侧信道

️ MCP 工具的返回值会**原样回灌进模型上下文** —— 确认单含几百个 planId
(一批 850 人 ≈ 12k token),而模型对它唯一要做的事是"转述一句话"。
改成 assistant 的本地工具:肥载荷走 `onSideEvent` 直接推前端,
**回给模型的只有摘要 + 三句要它原话转述的依据**,一个 planId 都没有。
 也不能让模型在 tool_call 参数里写这些 id —— 那些参数是模型生成的,同样是它在编。
`requestId` 由**服务端在此铸造**(D-2),随卡片下发、确认时原样回传。

## 确认单必须是原生组件

artifact 跑在 `sandbox="allow-scripts"` 的 iframe、CSP `connect-src 'none'`,
**卡片内不可能发出写请求**。分岔记牢:只读展示用 artifact,可交互用原生组件。

三层:汇总 → 按客服(可折叠看患者明细)→ 微调 + 确认。
· 助手窗只有 400px,**不用 table**(横向撑爆),用卡片列表
·  T15:溢出转铺平**显式可见**(每人一个「铺平 N」角标)——
  主管要看见发生了什么,而不是只看到结果
· 已满被跳过的人**仍然列出来** —— 他不是被漏了,是已经满了
· 微调**只有两项**(T13):时效、指定客服(后者回对话说)。多一项就违教条,评审按这条卡
· 三句依据(selectionNote / capacityNote / rosterNote)原样展示,前端不改写

🔴 **确认成功后必须往消息流注入一条文本块**:
`toApiMessage` 只回传文本块(工具块不回传,模型自行重新决策)——
不注入的话下一轮主管说「刚才那批改成 5 天」,模型手里没有"那批"的任何痕迹,
会当成新需求重新提议一次。改动极小但极易漏,漏了会被当成模型能力问题。

## assistant-store 的消费纪律

`pending` 用 seq 自增而不是"消费完置空":置空要消费方回写 store,那是双向数据流,
StrictMode 双执行下会变成发一次跑两次、或一次都不跑。
只在 widget 变体消费(/assistant 整页可能同时开着,两处都消费会发两遍)。

## 一个自己制造又自己修掉的矛盾

上一刀我在 systemExtra 里加了「原生确认单尚未上线,别提不存在的按钮」——
这一刀把卡片做出来之后,那条约束当场过时,助手正指着自己上面的按钮说"界面上没有"。
已改成「确认单是一张真卡片,就在你这条消息里;不要复述明细,不要说界面上没有按钮」。
️ 教训:提示词里写"当前还没有 X"这类**会过期的事实**,做完 X 必须回来改。

## 端到端实测(本地 2,695 条召回池)

点移交 → 助手开窗 → propose_assignment → 卡片渲染「确认分配 100 条」
点确认 → 卡片切终态「✓ 已分配 · 批次 #f27ef359」
落库:批次 f27ef359 / 100 条 / 12 位客服 / 到期 2026-08-05 / confirmed
      策略 90 dedicated + 5 spread_overflow + 5 spread_no_dedicated
      快照 dedicated 90 条都有专属快照;spread_no_dedicated 5 条没有(本就没专属,正确)
           100 条全有 priority_score 快照
注入文本块「已确认分配:批次 #f27ef359 · 100 条 · 12 位客服 · 3 天有效」
跟踪:列表 createdBy 832 → **康慧捧**(姓名已解析);
      详情 agentStats 之和 = 100 = planned,untouched 99 

877 单测通过,测试数据已清理。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 2a58939b
...@@ -23,8 +23,7 @@ const DISPATCHER_EXTRA = ` ...@@ -23,8 +23,7 @@ const DISPATCHER_EXTRA = `
### 批次分配的生产线(不要跳步,也不要替主管跨步) ### 批次分配的生产线(不要跳步,也不要替主管跨步)
1. 主管已在召回池选定人群(潜在治疗 × 温度),你拿到的是**已初选**的人群,不必再问他要筛什么 1. 主管已在召回池选定人群(潜在治疗 × 温度),你拿到的是**已初选**的人群,不必再问他要筛什么
2. 你出「全景确认单」——**直出,不追问** 2. 你出「全景确认单」——**直出,不追问**
3. 主管确认(可微调:指定客服、时效);**确认这一下才会真的写库** 3. 主管在**卡片**上确认(可微调:指定客服、时效);**确认这一下才会真的写库**
⚠️ 原生确认单卡片尚未上线 —— 现在是文字/表格呈现,见下方第 7 条
4. 分配完成后可以跟踪:list_assignment_batches / get_assignment_detail 4. 分配完成后可以跟踪:list_assignment_batches / get_assignment_detail
### 七条硬约束(违反任何一条都会造成真实损失) ### 七条硬约束(违反任何一条都会造成真实损失)
...@@ -55,11 +54,13 @@ const DISPATCHER_EXTRA = ` ...@@ -55,11 +54,13 @@ const DISPATCHER_EXTRA = `
原样转述 rosterNote,⛔ 不要说成「系统确认在职」,也不要替主管挡人 —— 原样转述 rosterNote,⛔ 不要说成「系统确认在职」,也不要替主管挡人 ——
名册外的客服他照样可以指定(有人只做召回不做回访,名册里查不到)。 名册外的客服他照样可以指定(有人只做召回不做回访,名册里查不到)。
7. ⛔ **不要提任何界面上不存在的按钮。** 7. **确认单是一张真卡片,就在你这条消息里。**
原生确认单卡片**尚未上线**,现在你能做的就是把方案用文字/表格讲清楚。 调完 propose_assignment,界面会自动渲染出确认单(含「确认分配 N 条」按钮和时效微调)。
⛔ 别说「请在卡片上点击『确认并下发』」—— 那个按钮不存在,主管会去找,找不到。 ⛔ **不要复述明细**(你也拿不到 planId,那是故意的);
正确说法:「方案如上,确认无误请回复『确认分配』,我会告诉你下一步。」 ⛔ **不要说"界面上没有按钮"**,更不要引导主管"回复确认分配" —— 按钮就在卡片上,他点就行。
⚠️ 这条是 T14 的直接推论:没有证据的东西不许写进结论,**界面元素也算证据**。 你要做的只有两件:转述那三句依据(原话),然后一句「确认无误请点卡片上的『确认分配』」。
⚠️ 反过来也一样:**不要提任何界面上不存在的东西**。这是 T14 的直接推论 ——
没有证据的不许写进结论,**界面元素也算证据**。
### 拟分方案怎么给(主管会把关,你负责有理有据) ### 拟分方案怎么给(主管会把关,你负责有理有据)
- **专属优先**:该患者有专属客服且在名册内 → 分给他 - **专属优先**:该患者有专属客服且在名册内 → 分给他
...@@ -67,9 +68,9 @@ const DISPATCHER_EXTRA = ` ...@@ -67,9 +68,9 @@ const DISPATCHER_EXTRA = `
按当前在手量从少到多补,**均分**;在手已达上限的人本批跳过但仍列出来标「已满」 按当前在手量从少到多补,**均分**;在手已达上限的人本批跳过但仍列出来标「已满」
- 容量默认 ${AGENT_CAPACITY_DEFAULT}(**默认值**,无历史数据支撑,要标注) - 容量默认 ${AGENT_CAPACITY_DEFAULT}(**默认值**,无历史数据支撑,要标注)
- 分不下去就**明说分不下去**,建议缩小批次;⛔ 不要硬塞给已经满的人 - 分不下去就**明说分不下去**,建议缩小批次;⛔ 不要硬塞给已经满的人
- ⚠️ 不传 targetCount 时服务端会**按团队剩余容量分满**(可能好几百人)—— - 批次规模**你自己定,不要问主管**(T13:直出不追问)——
那是技术上限不是建议规模。selectionNote 里会带提示,**照抄它**, 不传 targetCount 即用默认规模,服务端已夹好上限。selectionNote 会说明依据,照抄即可。
并主动问主管这批想做多少人。T5:一批做透好过多批做浅 主管觉得要调,他自然会说"这批只要 60 人",那时再带 targetCount 重出一版
- 每一条都要说得出「为什么是他」:专属 / 手上最空 / 主管指定,三选一 - 每一条都要说得出「为什么是他」:专属 / 手上最空 / 主管指定,三选一
`.trim(); `.trim();
......
...@@ -6,6 +6,8 @@ import type { ModelMessage } from 'ai'; ...@@ -6,6 +6,8 @@ import type { ModelMessage } from 'ai';
import { AssistantService } from './assistant.service'; import { AssistantService } from './assistant.service';
import { TranscribeService } from './transcribe.service'; import { TranscribeService } from './transcribe.service';
import { CurrentUser, type AuthenticatedUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser, type AuthenticatedUser } from '../../common/decorators/current-user.decorator';
import { TenantScope } from '../../common/decorators/tenant-scope.decorator';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { buildSystemExtra } from './assistant-prompts'; import { buildSystemExtra } from './assistant-prompts';
/// multer 内存模式的最小文件形状(不引 @types/multer) /// multer 内存模式的最小文件形状(不引 @types/multer)
...@@ -126,6 +128,7 @@ export class AssistantController { ...@@ -126,6 +128,7 @@ export class AssistantController {
@Res() res: Response, @Res() res: Response,
@Body() body: ChatBody, @Body() body: ChatBody,
@CurrentUser() user: AuthenticatedUser, @CurrentUser() user: AuthenticatedUser,
@TenantScope() scope: TenantScopeContext,
): Promise<void> { ): Promise<void> {
const token = (req.headers['authorization'] ?? '').replace(/^Bearer\s+/i, ''); const token = (req.headers['authorization'] ?? '').replace(/^Bearer\s+/i, '');
...@@ -155,6 +158,9 @@ export class AssistantController { ...@@ -155,6 +158,9 @@ export class AssistantController {
// 只用于 MCP 工具清单的缓存分桶(见 mcpCapabilityKey)—— // 只用于 MCP 工具清单的缓存分桶(见 mcpCapabilityKey)——
// 不传的话主管和客服会共用一份缓存,谁先进来谁的清单被全员复用。 // 不传的话主管和客服会共用一份缓存,谁先进来谁的清单被全员复用。
permissions: user.permissions, permissions: user.permissions,
// ⭐ 侧信道:本地工具把确认单肥载荷直接推前端,不经模型上下文(见 AssistantChatInput)
onSideEvent: send,
scope,
// ⭐ 按能力切工作流约束(主管 / 客服两套)。 // ⭐ 按能力切工作流约束(主管 / 客服两套)。
// ⚠️ 在此之前这个参数**从来没被 /assistant/chat 传过** —— // ⚠️ 在此之前这个参数**从来没被 /assistant/chat 传过** ——
// 「一个助手,按角色切换工作流约束」这条既定取舍此前是零实现。 // 「一个助手,按角色切换工作流约束」这条既定取舍此前是零实现。
......
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AiModule } from '../ai/ai.module'; import { AiModule } from '../ai/ai.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { PlanModule } from '../plan/plan.module';
import { AssistantController } from './assistant.controller'; import { AssistantController } from './assistant.controller';
import { AssistantService } from './assistant.service'; import { AssistantService } from './assistant.service';
import { McpClientService } from './mcp-client.service'; import { McpClientService } from './mcp-client.service';
...@@ -13,7 +14,8 @@ import { DictationGateway } from './dictation.gateway'; ...@@ -13,7 +14,8 @@ import { DictationGateway } from './dictation.gateway';
* 复用 AiModule 的 AiProviderService(provider 可切换);McpClientService 真连 PAC MCP 端点。 * 复用 AiModule 的 AiProviderService(provider 可切换);McpClientService 真连 PAC MCP 端点。
*/ */
@Module({ @Module({
imports: [AiModule, AuthModule], // PlanModule:本地工具 propose_assignment 用 AssignmentProposalService 取数
imports: [AiModule, AuthModule, PlanModule],
controllers: [AssistantController], controllers: [AssistantController],
providers: [AssistantService, McpClientService, TranscribeService, DictationGateway], providers: [AssistantService, McpClientService, TranscribeService, DictationGateway],
exports: [AssistantService], // 供 WeixinAibotModule 等其它入口复用同一个助手大脑 exports: [AssistantService], // 供 WeixinAibotModule 等其它入口复用同一个助手大脑
......
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai'; import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai';
import { randomUUID } from 'node:crypto';
import { Permission } from '@pac/types'; import { Permission } from '@pac/types';
import { AiProviderService } from '../ai/core/ai-provider.service'; import { AiProviderService } from '../ai/core/ai-provider.service';
import { McpClientService } from './mcp-client.service'; import { McpClientService } from './mcp-client.service';
import { AssignmentProposalService } from '../plan/assignment-proposal.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
const SYSTEM_PROMPT = `你是一个通用智能助手,目前在为牙科诊所的客服人员提供帮助。 const SYSTEM_PROMPT = `你是一个通用智能助手,目前在为牙科诊所的客服人员提供帮助。
你可以回答任何问题——日常闲聊、常识、写作、计算、以及与牙科或患者完全无关的话题,都请正常、友好地回答。 你可以回答任何问题——日常闲聊、常识、写作、计算、以及与牙科或患者完全无关的话题,都请正常、友好地回答。
...@@ -44,6 +47,18 @@ export interface AssistantChatInput { ...@@ -44,6 +47,18 @@ export interface AssistantChatInput {
* 不传 = 按无特权处理:宁可少给工具,也不能把主管的工具漏给客服。 * 不传 = 按无特权处理:宁可少给工具,也不能把主管的工具漏给客服。
*/ */
permissions?: readonly string[]; permissions?: readonly string[];
/**
* 侧信道 —— 把**肥载荷**直接推给前端,不经过模型上下文。
*
* ⭐ 确认单是这条通道存在的理由:它含几百个 planId(一批 850 人 ≈ 12k token),
* 而模型对它唯一要做的事是"转述一句话"。走工具返回值等于让模型
* **逐字复述几百个 uuid** —— 幻觉率接近 100%,而且贵。
* ⛔ 也不能让模型自己在 tool_call 参数里写这些 id:那些参数是**模型生成的**,
* 同样是它在编。真数据只能由服务端产、由服务端推。
*/
onSideEvent?: (event: Record<string, unknown>) => void;
/** 当前登录人(本地工具要用 scope 取数);缺省则本地写工具不注册 */
scope?: TenantScopeContext;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
} }
...@@ -73,6 +88,7 @@ export class AssistantService { ...@@ -73,6 +88,7 @@ export class AssistantService {
constructor( constructor(
private readonly provider: AiProviderService, private readonly provider: AiProviderService,
private readonly mcp: McpClientService, private readonly mcp: McpClientService,
private readonly proposals: AssignmentProposalService,
) {} ) {}
// 显式收窄返回类型为控制器实际消费的最小形状,避开 streamText 返回类型引用 ai 内部 // 显式收窄返回类型为控制器实际消费的最小形状,避开 streamText 返回类型引用 ai 内部
...@@ -92,6 +108,66 @@ export class AssistantService { ...@@ -92,6 +108,66 @@ export class AssistantService {
}); });
} }
// ── 本地工具:全景确认单 ─────────────────────────────────
// ⚠️ 刻意**不做成 MCP 工具**:MCP 的返回值会原样回灌进模型上下文,
// 而这个载荷有几百个 planId。这里走侧信道推前端,只回给模型一句短话。
if (input.scope && input.onSideEvent && input.permissions?.includes(Permission.PLAN_DISPATCH)) {
const scope = input.scope;
const push = input.onSideEvent;
tools.propose_assignment = tool({
description:
'出「全景确认单」:从召回池按排序键选人,按「专属优先 / 溢出铺平」落到客服头上,' +
'并把卡片直接呈现给主管。' +
'\n⚠️ 卡片由界面渲染,**你看不到明细也不需要看** —— 你的任务是转述返回的那句摘要。' +
'\n⚠️ **这只是提案,一个字都没写库**。绝不要说「已经分配好了」。' +
'\n⚠️ 批次规模你自己定(不传 targetCount 即用默认),**不要问主管**;他要改会自己说。',
inputSchema: jsonSchema({
type: 'object',
properties: {
clinicId: { type: 'string', description: '诊所 id;不传用当前登录人的第一个诊所' },
potentialTreatment: {
type: 'string',
description:
'画像「潜在治疗」8 类之一:implant/ortho/early_ortho/endo/perio/filling/restoration/extraction',
},
targetCount: { type: 'number', description: '拟分人数(不传用默认批次规模)' },
exploreRatio: { type: 'number', description: '探索配额占比 0-0.2' },
},
}),
execute: async (args: unknown) => {
const a = (args ?? {}) as {
clinicId?: string;
potentialTreatment?: string;
targetCount?: number;
exploreRatio?: number;
};
const clinicId = a.clinicId ?? scope.clinicIds[0];
if (!clinicId) {
return '当前登录人没有绑定诊所,无法出确认单 —— 请主管确认数据范围。';
}
const sheet = await this.proposals.propose(scope, { ...a, clinicId });
// ⭐ 肥载荷走侧信道;requestId 由**服务端**在这里铸造(D-2)——
// 前端确认时原样回传,这样重连/重试都命中同一个幂等键。
const requestId = `pa_${scope.userId}_${randomUUID()}`;
push({ type: 'assignment_sheet', requestId, sheet });
if (sheet.placed === 0) {
return `没有可分配的候选(${sheet.selectionNote})。请把这句如实转述给主管。`;
}
// ⭐ 回给模型的**只有摘要**,不含任何 planId
return [
`确认单已呈现给主管(卡片在对话里,你看不到明细,也不要复述明细)。`,
`摘要:候选 ${sheet.candidateTotal} 人,本批 ${sheet.placed} 人,涉及 ${sheet.byAgent.length} 位客服` +
(sheet.unplaced > 0 ? `,另有 ${sheet.unplaced} 人按当前容量分不下去` : '') + '。',
`请转述以下三句(**原话**,不要改写、不要省略"默认值"字样):`,
${sheet.selectionNote}`,
${sheet.capacityNote}`,
${sheet.rosterNote}`,
`然后提示主管:确认无误请在卡片上点「确认分配」。⛔ 不要说已经分配好了。`,
].join('\n');
},
});
}
// 本地"渲染"工具(不走 MCP):模型把自包含 HTML 片段交给前端,在沙箱 iframe 里渲染成卡片/报表。 // 本地"渲染"工具(不走 MCP):模型把自包含 HTML 片段交给前端,在沙箱 iframe 里渲染成卡片/报表。
// html 通过 tool-call 入参流式到前端;execute 仅回执,模型据此继续给一句话总结。 // html 通过 tool-call 入参流式到前端;execute 仅回执,模型据此继续给一句话总结。
tools.render_artifact = tool({ tools.render_artifact = tool({
......
...@@ -314,35 +314,11 @@ export class McpServerFactory { ...@@ -314,35 +314,11 @@ export class McpServerFactory {
jsonResult(await this.roster.list(scope, clinicId, { months, extraUserIds: include })), jsonResult(await this.roster.list(scope, clinicId, { months, extraUserIds: include })),
); );
server.registerTool( // ⛔ **`propose_assignment` 刻意不在这里注册** —— 它是 assistant 的**本地工具**。
'propose_assignment', // 原因:确认单载荷含**几百个 planId**,MCP 工具的返回值会原样回灌进模型上下文
{ // (实测一批 850 人 ≈ 12k token),而模型对它唯一要做的事是"转述一句话"。
description: // 本地工具可以走**侧信道**把肥载荷直接推给前端渲染,只回给模型一句短话。
'出「全景确认单」的数据 —— 从召回池按排序键选人、按「专属优先/溢出铺平」落到客服头上。' + // 见 assistant.service 的 propose_assignment。
'\n⚠️ **这只是提案,一个字都没写库**。把它呈现给主管过目,' +
'**绝不要说「已经分配好了」** —— 正确说法是「确认单已呈现,请过目」。' +
'\n⚠️ 返回里的 rosterNote / capacityNote / selectionNote 是**成品句子,请原话转述**,' +
'不要自己改写、更不要省略里面的「默认值」字样。' +
'\n⚠️ unplaced > 0 说明按当前容量分不下去 —— 如实说,建议缩小批次,' +
'**不要**建议硬塞给已经满的人。',
inputSchema: {
clinicId: z.string(),
potentialTreatment: z
.string()
.optional()
.describe('画像「潜在治疗」8 类之一:implant/ortho/early_ortho/endo/perio/filling/restoration/extraction'),
targetCount: z.number().int().min(1).max(500).optional().describe('拟分人数,不传按在岗剩余容量估'),
exploreRatio: z
.number()
.min(0)
.max(0.2)
.optional()
.describe('探索配额占比(0-0.2):从排名之外抽人,用于日后验证排序是否选得准'),
},
},
async (args) => jsonResult(await this.proposals.propose(scope, args)),
);
server.registerTool( server.registerTool(
'list_assignment_batches', 'list_assignment_batches',
{ {
......
...@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; ...@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { import {
AGENT_CAPACITY_DEFAULT, AGENT_CAPACITY_DEFAULT,
DEFAULT_BATCH_SIZE,
AssignStrategy, AssignStrategy,
type AgentInfo, type AgentInfo,
type AssignmentProposal, type AssignmentProposal,
...@@ -85,15 +86,20 @@ export class AssignmentProposalService { ...@@ -85,15 +86,20 @@ export class AssignmentProposalService {
const agents = roster.agents; const agents = roster.agents;
// ── 拟分人数 ──────────────────────────────────────────── // ── 拟分人数 ────────────────────────────────────────────
// ⚠️⚠️ **「团队还能吃多少」不等于「这批该分多少」** —— 这两件事必须分开。 // ⚠️⚠️ **「团队还能吃多少」不等于「这批该分多少」**:前者是容量(存量上限),
// totalRoom 是技术上限(17 人 × 每人 50 = 850);而 T5 说的是 // 后者是运营选择。混了就会得到 17 人 × 50 = 850 这种荒谬的批次 ——
// 「宁可只做 100 人做透,不做 1000 人做浅」。默认分满余量正是它反对的那种做法。 // 正是 T5「宁可 100 人做透,不做 1000 人做浅」反对的做法。
// ⛔ 但**批次规模上限属于产品决策**(教条七·待确认),工程不该替他定一个数。 //
// 折中:默认仍取余量(不凭空造一个没依据的上限),但在 selectionNote 里 // ⭐ 助手**自己定,不问主管**(T13:直出不追问)。默认取 DEFAULT_BATCH_SIZE,
// **把这个数的来历和风险直说**,让主管一眼看出 850 是"分满"而不是"建议值"。 // 再被两个上限夹住:团队剩余容量(分不下去)、候选总数(没这么多人)。
// 主管觉得要调,他自然会说"这批只要 60 人",那时才走 targetCount。
const totalRoom = agents.reduce((a, g) => a + Math.max(0, AGENT_CAPACITY_DEFAULT - g.inHand), 0); const totalRoom = agents.reduce((a, g) => a + Math.max(0, AGENT_CAPACITY_DEFAULT - g.inHand), 0);
const target = Math.max(0, input.targetCount ?? totalRoom); const target =
const isFullCapacity = input.targetCount == null; input.targetCount != null
? Math.max(0, input.targetCount)
: Math.min(DEFAULT_BATCH_SIZE, totalRoom);
const sizeBasis: 'explicit' | 'default' | 'capacity' =
input.targetCount != null ? 'explicit' : totalRoom < DEFAULT_BATCH_SIZE ? 'capacity' : 'default';
if (target === 0 || agents.length === 0) { if (target === 0 || agents.length === 0) {
return emptyProposal(clinicId, potentialTreatment, agents, roster.rosterNote, target); return emptyProposal(clinicId, potentialTreatment, agents, roster.rosterNote, target);
} }
...@@ -167,9 +173,10 @@ export class AssignmentProposalService { ...@@ -167,9 +173,10 @@ export class AssignmentProposalService {
`暂无历史数据反推各人真实吞吐,积累后按实际完成率拐点替换。`, `暂无历史数据反推各人真实吞吐,积累后按实际完成率拐点替换。`,
selectionNote: selectionNote:
`按「未进过批次优先 → 优先级高优先 → 患者号」排序取前 ${target} 人;` + `按「未进过批次优先 → 优先级高优先 → 患者号」排序取前 ${target} 人;` +
(isFullCapacity (sizeBasis === 'default'
? `⚠️ ${target} 人 = 在岗 ${agents.length} 位客服的剩余容量之和(**分满**),不是建议规模。` + ? `批次规模 ${target} 人为**默认值**(一批做透好过多批做浅),要改直接说个数字。`
`一批做透通常比做浅有效,如需缩小规模直接说个数字(如"这批只要 100 人")。` : sizeBasis === 'capacity'
? `⚠️ 只取到 ${target} 人 —— 在岗 ${agents.length} 位客服的剩余容量之和就这么多,不是候选不够。`
: '') + : '') +
(exploreN > 0 (exploreN > 0
? `其中 ${exploreN} 人来自探索配额(排名之外抽取,用于日后验证排序是否选得准)。` ? `其中 ${exploreN} 人来自探索配额(排名之外抽取,用于日后验证排序是否选得准)。`
......
'use client';
import { useState } from 'react';
import { ChevronRight, Check, Loader2, Users } from 'lucide-react';
import {
ASSIGN_STRATEGY_META,
Permission,
type AssignmentProposal,
type AssignStrategy,
} from '@pac/types';
import { Can } from '@/components/can';
import { assignmentsApi } from '@/components/plans/assignments-api';
import { cn } from '@/lib/utils';
/**
* 全景确认单 —— 主管**一次确认**就落库的那张卡片。
*
* ⚠️ 必须是**原生 React 组件**,不能用 `render_artifact`:
* artifact 跑在 `sandbox="allow-scripts"` 的 iframe 里、CSP `connect-src 'none'`,
* **卡片内不可能发出写请求**。分岔记牢:只读展示用 artifact,可交互用原生组件。
*
* ── 微调**只有两项**(T13)──────────────────────────────────
* 指定客服、时效。多加一项就直接违 T13 ——
* 微调项越多,「一次确认」这个设计目标就越不可能实现。
* 要换人群请回到对话里让助手重新圈,不要在卡片上做筛选。
*
* ── 布局 ────────────────────────────────────────────────
* 助手窗只有 400px 宽,所以**不用 table**(横向撑爆),用卡片列表;
* 患者明细按客服折叠,展开才看 —— 兼顾"尽明细"与"一眼可确认"。
*/
export function AssignmentConfirmSheet({
requestId,
sheet,
state,
assignmentId,
onConfirmed,
}: {
requestId: string;
sheet: AssignmentProposal;
state: 'pending' | 'confirmed' | 'cancelled';
assignmentId?: string;
/** 落库成功 → 交给外层把卡片切终态 + 往消息流注入一条文本(补模型记忆) */
onConfirmed: (assignmentId: string, summary: string) => void;
}) {
const [expiresInDays, setExpiresInDays] = useState(3);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState<Set<string>>(new Set());
const done = state === 'confirmed';
const confirm = async () => {
setSubmitting(true);
setError(null);
try {
const res = await assignmentsApi.create({
// ⭐ requestId **原样回传**服务端铸造的那个 —— 幂等键。
// 前端自己生成挡不住 SSE 重连与重试。
requestId,
clinicId: sheet.clinicId,
// 初筛条件快照:批次要能解释"当时按什么圈的"
criteria: {
potentialTreatment: sheet.potentialTreatment,
candidateTotal: sheet.candidateTotal,
target: sheet.target,
selectionNote: sheet.selectionNote,
},
expiresInDays,
// ⚠️ 落库的是**卡片当前值**,不是模型原提案 —— 主管改了时效就得按改后的走
items: sheet.items.map((it) => ({
planId: it.planId,
assigneeUserId: it.assigneeUserId,
assignStrategy: it.assignStrategy,
selectionMode: it.selectionMode,
})),
});
const summary =
`已确认分配:批次 #${res.assignmentId.slice(0, 8)} · ` +
(sheet.potentialTreatment ? `${sheet.potentialTreatment} · ` : '') +
`${res.assigned} 条 · ${sheet.byAgent.length} 位客服 · ${expiresInDays} 天有效` +
(res.skipped.length ? ` · ${res.skipped.length} 条未落(确认期间被他人认领)` : '');
onConfirmed(res.assignmentId, summary);
} catch (e) {
setError(e instanceof Error ? e.message : '确认失败');
} finally {
setSubmitting(false);
}
};
const byPlan = new Map(sheet.items.map((i) => [i.planId, i]));
return (
<div className="rounded-lg border border-brand-100 bg-white text-[12px] shadow-sm">
{/* ① 汇总 */}
<div className="flex items-center gap-2 border-b border-slate-100 bg-brand-50/50 px-3 py-2">
<Users className="h-3.5 w-3.5 flex-none text-brand-600" />
<span className="font-semibold text-slate-800">
拟分 {sheet.placed} 人 · {sheet.byAgent.length} 位客服
</span>
{sheet.unplaced > 0 && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[10.5px] text-amber-700">
{sheet.unplaced} 人分不下去
</span>
)}
</div>
{/* ② 按客服(产能视图)—— 这一层是主管真正在看的 */}
<div className="divide-y divide-slate-50">
{sheet.byAgent.map((a) => {
const isOpen = open.has(a.userId);
const mine = sheet.items.filter((i) => i.assigneeUserId === a.userId);
return (
<div key={a.userId}>
<button
type="button"
onClick={() =>
setOpen((s) => {
const n = new Set(s);
if (n.has(a.userId)) n.delete(a.userId);
else n.add(a.userId);
return n;
})
}
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-slate-50"
>
<ChevronRight
className={cn('h-3 w-3 flex-none text-slate-400 transition-transform', isOpen && 'rotate-90')}
/>
{/* 姓名解析不出来时退回 id 前缀 —— ⛔ 不显示完整 uuid,400px 卡片会撑爆 */}
<span className="flex-1 truncate font-medium text-slate-700">
{a.name ?? `#${a.userId.slice(0, 8)}`}
</span>
<span className="flex-none tabular-nums text-slate-500">
{a.count}
{a.inHandBefore > 0 && (
<span className="ml-1 text-[10.5px] text-slate-400">(在手 {a.inHandBefore})</span>
)}
</span>
{/* ⭐ T15:溢出转铺平必须**显式可见** —— 主管要看见发生了什么,而不是只看到结果 */}
{a.spread > 0 && (
<span className="flex-none rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">
铺平 {a.spread}
</span>
)}
</button>
{isOpen && (
<ul className="space-y-0.5 bg-slate-50/60 px-3 pb-2 pl-8 text-[11px] text-slate-500">
{mine.slice(0, 30).map((i) => (
<li key={i.planId} className="flex items-center gap-1.5">
<span className="truncate">#{i.patientId.slice(0, 8)}</span>
<span className="text-[10px] text-slate-400">
{ASSIGN_STRATEGY_META[i.assignStrategy as AssignStrategy]?.groupZh}
</span>
{i.selectionMode === 'explore' && (
<span className="rounded bg-violet-100 px-1 text-[10px] text-violet-600">探索</span>
)}
</li>
))}
{mine.length > 30 && (
<li className="text-[10px] text-slate-400">…另有 {mine.length - 30}</li>
)}
</ul>
)}
</div>
);
})}
</div>
{/* 已满被跳过的人 —— 仍然列出来:主管要看见"他不是被漏了,是已经满了" */}
{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('、')}
</div>
)}
{/* ③ 微调 —— **只有两项**(T13)。多加一项就违教条,评审按这条卡 */}
<div className="space-y-1.5 border-t border-slate-100 px-3 py-2">
<div className="flex items-center gap-2">
<span className="flex-none text-slate-500">时效</span>
{[3, 5, 7].map((d) => (
<button
key={d}
type="button"
disabled={done}
onClick={() => setExpiresInDays(d)}
className={cn(
'rounded border px-2 py-0.5 text-[11px] transition-colors',
expiresInDays === d
? 'border-brand-500 bg-brand-50 font-medium text-brand-700'
: 'border-slate-200 text-slate-500 hover:bg-slate-50',
done && 'opacity-60',
)}
>
{d}
</button>
))}
{/* ⭐ T14:没有历史结案数据,这个 3 天就是默认值,必须当场标明 */}
<span className="text-[10px] text-slate-400">默认 3 天(暂无历史结案数据)</span>
</div>
<p className="text-[10.5px] leading-relaxed text-slate-400">
指定客服请在对话里说(如「王强这批给李莉」);换人群也回对话让助手重新圈。
</p>
</div>
{/* 依据 —— 成品句子原样展示,不在前端改写 */}
<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.rosterNote}</p>
</div>
{/* ④ 确认 */}
<div className="border-t border-slate-100 px-3 py-2">
{error && <p className="mb-1.5 text-[11px] text-rose-600">{error}</p>}
{done ? (
<div className="flex items-center gap-1.5 text-[11.5px] font-medium text-emerald-700">
<Check className="h-3.5 w-3.5" />
已分配 · 批次 #{assignmentId?.slice(0, 8)}
</div>
) : (
<Can
perm={Permission.PLAN_DISPATCH}
fallback={<p className="text-[11px] text-slate-400">你没有分配权限,请让主管确认</p>}
>
<button
type="button"
disabled={submitting || sheet.placed === 0}
onClick={() => void confirm()}
className="flex w-full items-center justify-center gap-1.5 rounded-md bg-brand-600 px-3 py-1.5 text-[12px] font-medium text-white transition-colors hover:bg-brand-700 disabled:opacity-50"
>
{submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
确认分配 {sheet.placed}
</button>
</Can>
)}
</div>
</div>
);
}
...@@ -29,6 +29,7 @@ import { ...@@ -29,6 +29,7 @@ import {
floatToPcm16, floatToPcm16,
} from '@/components/realtime-coach/audio-utils'; } from '@/components/realtime-coach/audio-utils';
import { useAssistantStore } from '@/stores/assistant-store'; import { useAssistantStore } from '@/stores/assistant-store';
import { AssignmentConfirmSheet } from './assignment-confirm-sheet';
import { import {
useAssistantChat, useAssistantChat,
type Artifact, type Artifact,
...@@ -60,6 +61,11 @@ const TOOL_META: Record<string, { label: string; running: string }> = { ...@@ -60,6 +61,11 @@ const TOOL_META: Record<string, { label: string; running: string }> = {
get_recall_plan: { label: '读取召回计划', running: '正在读取召回计划' }, get_recall_plan: { label: '读取召回计划', running: '正在读取召回计划' },
list_recall_queue: { label: '检索召回队列', running: '正在检索召回队列' }, list_recall_queue: { label: '检索召回队列', running: '正在检索召回队列' },
recall_queue_stats: { label: '统计召回池', running: '正在统计召回池' }, recall_queue_stats: { label: '统计召回池', running: '正在统计召回池' },
get_current_user: { label: '确认身份', running: '正在确认你的身份与权限' },
get_agents: { label: '查在岗客服', running: '正在查在岗客服与在手负载' },
propose_assignment: { label: '出分配方案', running: '正在挑人、排客服,出分配方案' },
list_assignment_batches: { label: '查历史批次', running: '正在查你分过的批次' },
get_assignment_detail: { label: '查批次详情', running: '正在查这批的执行情况' },
}; };
function toolMeta(tool: string) { function toolMeta(tool: string) {
return TOOL_META[tool] ?? { label: tool, running: `正在调用 ${tool}` }; return TOOL_META[tool] ?? { label: tool, running: `正在调用 ${tool}` };
...@@ -122,10 +128,27 @@ function Markdown({ text }: { text: string }) { ...@@ -122,10 +128,27 @@ function Markdown({ text }: { text: string }) {
); );
} }
function BlockView({ block }: { block: Block }) { function BlockView({
block,
onSheetConfirmed,
}: {
block: Block;
onSheetConfirmed?: (requestId: string, assignmentId: string, summary: string) => void;
}) {
// 工具调用不再单独显示卡片 —— 其"正在做什么"通过下方 loading 指示体现(见 MessageView)。 // 工具调用不再单独显示卡片 —— 其"正在做什么"通过下方 loading 指示体现(见 MessageView)。
if (block.kind === 'tool') return null; if (block.kind === 'tool') return null;
if (block.kind === 'artifact') return <ArtifactView artifact={block.artifact} />; if (block.kind === 'artifact') return <ArtifactView artifact={block.artifact} />;
if (block.kind === 'assignment_sheet') {
return (
<AssignmentConfirmSheet
requestId={block.requestId}
sheet={block.sheet}
state={block.state}
assignmentId={block.assignmentId}
onConfirmed={(id, summary) => onSheetConfirmed?.(block.requestId, id, summary)}
/>
);
}
return <Markdown text={block.text} />; return <Markdown text={block.text} />;
} }
...@@ -334,7 +357,15 @@ function GeneratingHint({ label = '生成中' }: { label?: string }) { ...@@ -334,7 +357,15 @@ function GeneratingHint({ label = '生成中' }: { label?: string }) {
); );
} }
function MessageView({ message, streaming }: { message: ChatMessage; streaming?: boolean }) { function MessageView({
message,
streaming,
onSheetConfirmed,
}: {
message: ChatMessage;
streaming?: boolean;
onSheetConfirmed?: (requestId: string, assignmentId: string, summary: string) => void;
}) {
if (message.role === 'user') { if (message.role === 'user') {
return ( return (
<div className="flex justify-end gap-2.5"> <div className="flex justify-end gap-2.5">
...@@ -357,7 +388,7 @@ function MessageView({ message, streaming }: { message: ChatMessage; streaming?: ...@@ -357,7 +388,7 @@ function MessageView({ message, streaming }: { message: ChatMessage; streaming?:
</span> </span>
<div className="min-w-0 flex-1 space-y-2.5 pt-0.5"> <div className="min-w-0 flex-1 space-y-2.5 pt-0.5">
{message.blocks.map((b, i) => ( {message.blocks.map((b, i) => (
<BlockView key={i} block={b} /> <BlockView key={i} block={b} onSheetConfirmed={onSheetConfirmed} />
))} ))}
{showHint && <GeneratingHint label={hintLabel} />} {showHint && <GeneratingHint label={hintLabel} />}
</div> </div>
...@@ -438,7 +469,20 @@ export function AssistantChat({ ...@@ -438,7 +469,20 @@ export function AssistantChat({
/** 空态开场建议(规则生成,调用方按场景定制);不传用页面版默认 */ /** 空态开场建议(规则生成,调用方按场景定制);不传用页面版默认 */
examples?: string[]; examples?: string[];
} = {}) { } = {}) {
const { messages, status, model, setModel, send, stop } = useAssistantChat(); const { messages, status, model, setModel, send, stop, appendAssistantNote, settleSheet } =
useAssistantChat();
/**
* 确认单落库成功。两件事一个都不能少:
* ① 卡片切终态(按钮禁用 + 显示批次号)
* ② ⭐ **往消息流注入一条文本块** —— `toApiMessage` 只回传文本块,
* 不注入的话下一轮主管说「刚才那批改成 5 天」,模型手里没有"那批"的任何痕迹,
* 会当成新需求重新提议一次。改动极小但极易漏,漏了会被当成模型能力问题。
*/
const onSheetConfirmed = (requestId: string, assignmentId: string, summary: string) => {
settleSheet(requestId, assignmentId);
appendAssistantNote(summary);
};
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const taRef = useRef<HTMLTextAreaElement>(null); const taRef = useRef<HTMLTextAreaElement>(null);
...@@ -658,6 +702,7 @@ export function AssistantChat({ ...@@ -658,6 +702,7 @@ export function AssistantChat({
key={m.id} key={m.id}
message={m} message={m}
streaming={status === 'streaming' && i === messages.length - 1 && m.role === 'assistant'} streaming={status === 'streaming' && i === messages.length - 1 && m.role === 'assistant'}
onSheetConfirmed={onSheetConfirmed}
/> />
)) ))
)} )}
......
'use client'; 'use client';
import { useCallback, useRef, useState } from 'react'; import { useCallback, useRef, useState } from 'react';
import type { AssignmentProposal } from '@pac/types';
import { env } from '@/lib/env'; import { env } from '@/lib/env';
import { emitPetEvent } from '@/lib/pet-events'; import { emitPetEvent } from '@/lib/pet-events';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
...@@ -29,7 +30,26 @@ export interface Artifact { ...@@ -29,7 +30,26 @@ export interface Artifact {
export type Block = export type Block =
| { kind: 'text'; text: string } | { kind: 'text'; text: string }
| { kind: 'tool'; step: ToolStep } | { kind: 'tool'; step: ToolStep }
| { kind: 'artifact'; artifact: Artifact }; | { kind: 'artifact'; artifact: Artifact }
/**
* 全景确认单 —— **原生 React 组件**,不是 artifact。
*
* ⚠️ 为什么不能用 artifact:artifact 跑在 `sandbox="allow-scripts"` 的 iframe 里,
* CSP 是 `connect-src 'none'` —— **卡片内不可能发出写请求**。确认单要能点确认落库,
* 只能是原生组件。
* 分岔记牢:**只读展示用 artifact,可交互用原生组件。**
*
* ⚠️ sheet 由**服务端经侧信道推来**,不是模型生成的 —— 它含几百个 planId,
* 让模型吐等于让它编。
*/
| {
kind: 'assignment_sheet';
requestId: string;
sheet: AssignmentProposal;
state: 'pending' | 'confirmed' | 'cancelled';
/** 确认后的批次 id(终态展示用) */
assignmentId?: string;
};
/** render_artifact 是本地"渲染"工具,不当普通工具步骤展示,而是渲成卡片。 */ /** render_artifact 是本地"渲染"工具,不当普通工具步骤展示,而是渲成卡片。 */
const ARTIFACT_TOOL = 'render_artifact'; const ARTIFACT_TOOL = 'render_artifact';
...@@ -167,6 +187,22 @@ export function useAssistantChat() { ...@@ -167,6 +187,22 @@ export function useAssistantChat() {
case 'text': case 'text':
appendText((evt.text as string) ?? ''); appendText((evt.text as string) ?? '');
break; break;
case 'assignment_sheet':
// ⭐ 侧信道推来的确认单。upsert 按 requestId —— 模型若因某种原因重复调用,
// 不该在对话里堆出两张卡片。
patch((blocks) => {
const rid = String(evt.requestId ?? '');
const i = blocks.findIndex((b) => b.kind === 'assignment_sheet' && b.requestId === rid);
const next: Block = {
kind: 'assignment_sheet',
requestId: rid,
sheet: evt.sheet as AssignmentProposal,
state: 'pending',
};
if (i >= 0) return [...blocks.slice(0, i), next, ...blocks.slice(i + 1)];
return [...blocks, next];
});
break;
case 'artifact_html': case 'artifact_html':
// 流式:render_artifact 的 html 边生成边到(按 callId upsert,实时"长出"卡片)。 // 流式:render_artifact 的 html 边生成边到(按 callId upsert,实时"长出"卡片)。
patch((blocks) => patch((blocks) =>
...@@ -287,5 +323,34 @@ export function useAssistantChat() { ...@@ -287,5 +323,34 @@ export function useAssistantChat() {
[messages, model, status], [messages, model, status],
); );
return { messages, status, model, setModel, send, stop }; /**
* ⭐ 确认单点了确认之后,**必须往消息流注入一条文本块**。
*
* 因为 `toApiMessage` **只回传文本块**(工具块不回传,模型自行重新决策)——
* 不注入的话,下一轮主管说「刚才那批改成 5 天」,模型手里完全没有"那批"的痕迹,
* 会当成新需求重新提议一次。改动很小但极易漏,漏了会被当成模型能力问题。
*/
const appendAssistantNote = useCallback((text: string) => {
setMessages((prev) => {
if (prev.length === 0) return prev;
const last = prev[prev.length - 1]!;
return [...prev.slice(0, -1), { ...last, blocks: [...last.blocks, { kind: 'text', text }] }];
});
}, []);
/** 确认单落库后把卡片切到终态(按钮禁用,显示批次号) */
const settleSheet = useCallback((requestId: string, assignmentId: string) => {
setMessages((prev) =>
prev.map((m) => ({
...m,
blocks: m.blocks.map((b) =>
b.kind === 'assignment_sheet' && b.requestId === requestId
? { ...b, state: 'confirmed' as const, assignmentId }
: b,
),
})),
);
}, []);
return { messages, status, model, setModel, send, stop, appendAssistantNote, settleSheet };
} }
'use client';
import type {
CreateAssignmentRequest,
CreateAssignmentResponse,
ListAgentsResponse,
ListAssignmentsResponse,
AssignmentDetailResponse,
} from '@pac/types';
import { api } from '@/lib/api-client';
/**
* 批次分配 API。
*
* ⚠️ 路径都在 `plans/assignments` 之下 —— **别写成 `/plans/agents`**:
* 那个路径归 PlanController,会被它的裸 `@Get(':id')` 当成 planId,
* 报出来是 Prisma 的 uuid 解析错,完全看不出是路由撞了(实测踩过)。
*/
export const assignmentsApi = {
/**
* 确认分配 —— **整条生产线唯一的写动作**。
* ⚠️ `requestId` 必须原样回传服务端在出确认单时铸造的那个(幂等键);
* 前端自己生成挡不住 SSE 重连与重试。
*/
create: (body: CreateAssignmentRequest) =>
api.post<CreateAssignmentResponse>('/pac/v1/plans/assignments', body),
list: (mine?: boolean) =>
api.get<ListAssignmentsResponse>(`/pac/v1/plans/assignments${mine ? '?mine=1' : ''}`),
detail: (id: string) =>
api.get<AssignmentDetailResponse>(`/pac/v1/plans/assignments/${encodeURIComponent(id)}`),
/** 在岗客服名册 + 在手负载(一个端点,不拆两个 —— 拆开必然口径漂移) */
agents: (clinicId: string, include?: string[]) =>
api.get<ListAgentsResponse>(
`/pac/v1/plans/assignments/agents?clinicId=${encodeURIComponent(clinicId)}` +
(include?.length ? `&include=${include.map(encodeURIComponent).join(',')}` : ''),
),
};
...@@ -83,6 +83,22 @@ export const AGENT_CAPACITY_RANGE: readonly [number, number] = [20, 50]; ...@@ -83,6 +83,22 @@ export const AGENT_CAPACITY_RANGE: readonly [number, number] = [20, 50];
/// 助手算拟分人数时用的那一个数(区间上界)。⚠️ 仍是默认值,不是实测容量。 /// 助手算拟分人数时用的那一个数(区间上界)。⚠️ 仍是默认值,不是实测容量。
export const AGENT_CAPACITY_DEFAULT = AGENT_CAPACITY_RANGE[1]; export const AGENT_CAPACITY_DEFAULT = AGENT_CAPACITY_RANGE[1];
/**
* 一批默认分多少人。
*
* ⚠️⚠️ **「团队还能吃多少」不是「这批该分多少」** —— 这是两个量,混了会得到很荒谬的数:
* 17 位客服 × 每人容量 50 = 850,而 T5 说的是「宁可只做 100 人做透,不做 1000 人做浅」。
* 容量是"一个人同时能压多少"(存量上限),批次规模是"这一轮想推多少"(运营选择)。
*
* 取 100 的依据就是 T5 那句话本身 —— 它是**教条给的口径**,不是我们拍的。
* 但它仍然是**默认值**:没有任何数据证明 100 比 80 或 150 好,
* 等 T20 沉淀出"批次规模 × 完成率"的关系再替换。所以凡是用到它的地方都要按 T14 标注。
*
* ⭐ 助手**自己按这个默认值出方案,不要每次问主管**(T13:直出不追问)。
* 主管觉得要调,他自然会说"这批只要 60 人"。
*/
export const DEFAULT_BATCH_SIZE = 100;
export const AgentInfoSchema = z.object({ export const AgentInfoSchema = z.object({
userId: z.string(), userId: z.string(),
/// 服务端解析好的姓名(PAC 无 users 表,前端翻不了 —— 见 agent-roster.service 注释) /// 服务端解析好的姓名(PAC 无 users 表,前端翻不了 —— 见 agent-roster.service 注释)
......
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