Commit ad8475f7 by luoqi

feat(plan): 召回分配 P3 助手侧 —— 身份/名册/工作流约束/全景确认单

S1 后端最后一块。助手从"只会查患者"变成"能替主管出确认单",
但 T8 的边界一步没让:**全程只读,唯一的写动作仍是主管点确认**。

## 工具门控:主管 12 个 / 客服 8 个(实测)

四个主管工具**条件注册**,客服连清单里都看不到。
️ 为什么是"不注册"而不是"注册了再拒":模型看得见就会去试,试了被拒它会自己
编解释("可能是权限问题,要不换个账号"),对客服是纯噪声。不注册则行为自然收敛。
这条依赖 P0.4 的能力分桶缓存 —— 不分桶的话进程重启后第一个人的清单会被全员复用。

顺带堵了一个绕过 T16 的口子:`plan.service.list` **只对 view='all' 校验 PLAN_VIEW_ALL**,
`view='pool'` 一路无校验 —— 客服在前端看不到召回池,却能直接问助手「池子里还有谁」
把整池捞出来。list_recall_queue / recall_queue_stats 现按能力**静默降级**成 'mine'
(不报错:客服问"今天该联系谁"是正当需求,甩权限错误会让他以为系统坏了)。

## get_current_user —— 返回能力,不返回 role

放在工具清单**最前**:顺序影响模型的默认注意力,而"在跟主管还是客服说话"
决定了后面走哪条工作流。
返回 `capabilities: { canDispatch, canViewAllPlans, canExecute }`, 不带 role ——
模型看得见 role 就会自己发明"leader 应该也能 X",而那不是权限模型说了算的。
McpAuthContext 加了 `userName`(显示串,不违 T19 —— 助手要能称呼人而不是对着 uuid 说话)。
️ canDispatch=false 时提示"登录态可能过期",**不说"你没有权限"**:
权限按 role 现算但 token 里的 role 是签发时固化的,高频原因是 token 旧了。

## 名册 + 负载:一个端点,不开两个

分配问「还能吃多少」、跟踪问「压了多少」是同一份数据的两种读法,
拆开必然口径漂移(一个算 assigned、一个算 assigned+active)而且漂了不报错。

· 在岗按 `source_created_at` 近 12 月。 **绝不用 task_date** —— 含未来排程
  (实测最远 2033、DW 侧 2121),拿它卡窗口会把离职的人判成在岗。
  迁移 C 补了对应索引:**单语句 + CONCURRENTLY + 独立目录**(回访表生产 166.7 万行
  且正被增量同步写入,普通 CREATE INDEX 会锁死同步;多语句会 25001 堵死流水线)。
·  **不返回 remaining**:容量 20-50 是**默认值**,把它减出来会让助手说
  「李莉还能吃 38 个」—— 那是拿默认值做完减法再当事实说出口,免责声明救不回来。
  返回 capacityRange + inHand + capacityBasis:'default',减法留给主管。
· inHand **跨诊所全量**统计(容量是人的属性),但展示拆「本诊所 / 其他」——
  ️ 与 F4「写路径补 clinicIds 边界」方向相反,别顺手在这里也加诊所过滤,
  否则那 24% 跨诊所客服会显得手上很空,被每个诊所各灌一轮。
· 名册是**建议不是白名单**:实测 11 人只在登录侧有行为、回访数 0(只做召回不做回访),
  主管点名的人即使查不到也照样返回,只标注。

## systemExtra —— 「按角色切工作流」此前是零实现

实测 `/assistant/chat` **从来没传过 systemExtra**,全仓只有企微在传。
新 assistant-prompts.ts 出两套约束(主管 / 客服),六条硬约束里最要紧的一条:
「 绝不要说『已经分配好了』,正确说法是『确认单已呈现,请过目』」——
说错会让主管以为事情办完了,而实际一条都没落。

方法论写进注释:**凡是靠模型"算对"的约束一律降级成"照抄"**。
T14/T20 全是除法和阈值判断,恰恰是 LLM 最不可靠的地方 ——
所以工具返回值里直接带成品句子(rosterNote / capacityNote / selectionNote),
提示词只负责让它原话转述。少任何一半都会漏。

## propose_assignment —— 圈人与落人

**① 收敛排序键(三层)**
```
(assignment_id IS NULL) DESC   从没进过任何批次的优先
priority_score          DESC
patient_id              ASC
```
首键解决"退回的高分单反复插队":它回池后分数几乎不变(freshness 降但 daysSince
涨反而推高急迫性),会立刻回到队首,300 名开外永远轮不到。生产实证 57 单里 79% 已过时限
——"打了没结果然后挂着"是常态,这批一旦回池就是稳定插队源。而 assignment_id
退回时不清空,天然就是这个标记,**零新列**。
第三层不是装饰:filling 格 1,189 人只有 306 个不同取值,第 100 名撞并列是必然事件。
️ **刻意不加「专属可用性」分层**(违直觉,已产品确认):85.1% 有在岗专属,拿它当首键
则批次近乎 100% dedicated,T20 要反推的对比**永远凑不满样本** ——
用未验证的假设排序,而该排序保证假设无法被验证。

**② 落人:专属优先 → 溢出均分 + 在手硬护栏**
️ 推翻了先前主张的「水位拉平」,三条理由:输入 remaining 是拍的默认值;
在手量当前不可信(自动回收长期关、回写率 11%、79% 过期);且它把消灭负载方差
当目标函数,而 T20 第一项要反推的正是"各人实际能吃多少"、需要方差作自变量。
另外它全局耦合 —— 改一条指定客服所有人数字都跳,违 T13。
 容量不够**截断不摊派**:硬塞会立刻造出 over_capacity 退回,而那正是要用来
反推容量默认值的信号,自己造出来就没法反推了。

探索配额(产品批准)按**等距抽样**从排名之外取, 不用随机数 ——
主管微调后会重算,随机会让名单无故跳动,他就不敢按确认键。

️ 落人算法拆成**导出的纯函数** placeAgents:它是本文件唯一值得单测的算法,
纯函数不必起 Nest 容器、不必 mock Prisma。第一版把专属客服表挂成实例字段,
那是并发 bug(单例 service,两个主管同时出单会互相冲掉),改成参数传递 ——
本仓已有 ingest-resolver-no-instance-state.spec 在防同一类错。

## 验证(本地真实数据)

877 单测(新增 11 条落人算法断言)+ 端到端:
  工具清单        主管 12 个 / 客服 8 个 
  get_current_user 返回 capabilities,**无 role 字段** 
  get_agents      17 人在岗,**无 remaining 字段**,note 是成品句子 
  propose         潜在种植 100 人:候选 170 → 落 100 / 分不下去 0
                  策略 **67 专属 / 22 溢出 / 11 无专属**(三档分得开)
                  入选 90 rank / 10 explore
                  康慧捧吃满 50 触顶 → 其余 22 条溢出给别人标 spread_overflow 
零 drift,无数据残留。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 546bad00
-- 客服名册索引 —— 按 source_created_at 卡"近 N 月在岗"窗口
--
-- 【为什么现有索引不够】20260801150000 建的 roster 索引末列是 `task_date`,
-- 而名册判定**只能用 source_created_at**:task_date 含未来排程
-- (生产实测最远 2033 年,DW 侧甚至 2121 年),拿它卡窗口会把早已离职的人判成在岗。
-- 末列不匹配 ⇒ 按 source_created_at 过滤会退化成对 166.7 万行逐行 Filter。
-- ⛔ 不删旧索引:它另有 (patientId, taskDate) 之外的用途,且 task_date 本身仍是业务字段。
--
-- 【⚠️ 这份文件必须只有一条语句】
-- Prisma 把整份 migration.sql 用**一次 simple query** 发给 PG;PG 对"一个查询串里多条语句"
-- 隐式开事务块,而 CONCURRENTLY 明确禁止在事务块内跑:
-- ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block (25001)
-- 随后 _prisma_migrations 会留一条卡死记录(P3018)**堵死整条迁移流水线**(20260728020000 踩过)。
-- ⛔ 所以**不要**往这个文件里加 `SET LOCAL lock_timeout`、不要加注释以外的任何语句、
-- 更不要把它和迁移 A/B 合并。分界线是**语句条数**,不是 Prisma 版本。
--
-- 【为什么用 CONCURRENTLY】patient_return_visits 生产 166.7 万行,普通 CREATE INDEX 会
-- ACCESS EXCLUSIVE 锁全表;而回访表正被增量同步持续写入,锁上就是同步阻塞。
-- CONCURRENTLY 全程不锁写,代价是慢一倍且失败会留 INVALID 索引(重跑前需先 DROP)。
--
-- 【失败救援】CONCURRENTLY 失败会留下一个 INVALID 索引,必须先删再重来:
-- DROP INDEX CONCURRENTLY IF EXISTS "patient_return_visits_roster_source_idx";
-- npx prisma migrate resolve --rolled-back 20260802150000_add_return_visit_roster_source_idx
CREATE INDEX CONCURRENTLY IF NOT EXISTS "patient_return_visits_roster_source_idx" ON "patient_return_visits" ("host_id", "tenant_id", "clinic_id", "task_director_id", "source_created_at" DESC);
......@@ -446,6 +446,12 @@ model PatientReturnVisit {
@@index([patientId, taskDate(sort: Desc)])
/// 客服名册查询: (诊所, 客服) 聚合 + taskDate 卡时间窗
@@index([hostId, tenantId, clinicId, taskDirectorId, taskDate(sort: Desc)])
/// 名册「近 N 月在岗」判定专用 —— 末列必须是 source_created_at
/// 上面那条末列是 task_date, task_date **含未来排程**(实测最远 2033 年、DW 2121 ),
/// 拿它卡在岗窗口会把早已离职的人判成在岗。两条索引各有其用,别删上面那条。
/// ⚠️ 建它的迁移(20260802150000)**单语句 + CONCURRENTLY**、独立目录 ——
/// 回访表生产 166.7 万行且正被增量同步持续写入,普通 CREATE INDEX 会锁死同步。
@@index([hostId, tenantId, clinicId, taskDirectorId, sourceCreatedAt(sort: Desc)], map: "patient_return_visits_roster_source_idx")
@@map("patient_return_visits")
}
......
import { Permission, AGENT_CAPACITY_DEFAULT } from '@pac/types';
/**
* 按能力切换的助手工作流约束(拼在 SYSTEM_PROMPT 之后)。
*
* ⚠️ 实测:在此之前 `/assistant/chat` **从来没有传过 systemExtra** ——
* 全仓只有企微机器人在传。「一个助手,按角色切换工作流约束」这条既定取舍
* 此前是**零实现**。这个文件是它的落地。
*
* ── 方法论:凡是靠模型"算对"的约束,一律降级成"照抄" ──────────
* 下面 T14 / T20 那两类要求(标注默认值、样本不足不出百分比)全是**除法和阈值判断**,
* 而这恰恰是 LLM 最不可靠的地方。所以工具返回值里直接带成品句子
* (`rosterNote` / `capacityBasis` / `sufficient`),提示词只负责让它**原话抄**。
* 「提示词 + 工具返回值」双保险,少任何一半都会漏。
*/
/** 主管(有 plan:dispatch)的工作流约束 */
const DISPATCHER_EXTRA = `
## 你现在在跟**门诊经理(主管)**说话
先调 get_current_user 确认身份与能力,再决定走哪条路。
### 批次分配的生产线(不要跳步,也不要替主管跨步)
1. 主管已在召回池选定人群(潜在治疗 × 温度),你拿到的是**已初选**的人群,不必再问他要筛什么
2. 你出「全景确认单」——**直出,不追问**
3. 主管在卡片上确认(可微调:指定客服、时效);**确认这一下才会真的写库**
4. 分配完成后可以跟踪:list_assignment_batches / get_assignment_detail
### 六条硬约束(违反任何一条都会造成真实损失)
1. **你全程只读。** 唯一改变数据的动作是主管在确认单上点确认,那由界面完成。
⛔ **绝不要说「已经分配好了」/「已经派下去了」** —— 正确说法是
「确认单已呈现,请过目」。说错会让主管以为事情办完了,而实际上一条都没落。
2. **全景阶段不问意图、不做画像分层,直接出确认单。**
每多问一句就多一次决策成本。主管要的是"看一眼就能点确认"。
他若主动提要求(「只要商保直付的」「排掉怕疼的」),那时才用画像收窄。
3. **凡是没有历史数据支撑的建议值,必须当场标明是默认值。**
工具返回里的 rosterNote / capacityBasis 是给你**原话抄**的,别自己改写措辞。
例:时效说「3 天(默认值,暂无历史结案数据,积累后按实际反推)」,
⛔ 不要说成「依据该诊所平均结案 2.4 天」—— 那个数算不出来。
4. **样本不足就明说,不要输出百分比、不要画图。**
工具返回带 sufficient=false 或 note 时**照抄 note**。
⛔ 见到 0/12 不要渲染成「0.0% 转化率」—— 主管看了会以为功能没用。
正确说法:「已处置 12 人,暂无转化记录,样本量不足以计算转化率」。
5. **退回率永远给两个数。**
「退回 5 / 已处置 40 = 12.5%(另有 60 条未动)」——
"没人动"和"动了但退回"是完全不同的信号,只报一个百分比会把前者藏起来。
6. **在岗与专属客服都是近似值。**
原样转述 rosterNote,⛔ 不要说成「系统确认在职」,也不要替主管挡人 ——
名册外的客服他照样可以指定(有人只做召回不做回访,名册里查不到)。
### 拟分方案怎么给(主管会把关,你负责有理有据)
- **专属优先**:该患者有专属客服且在名册内 → 分给他
- **溢出铺平**:专属客服已达容量 / 无专属 / 专属已离岗 → 铺给其他在岗客服,
按当前在手量从少到多补,**均分**;在手已达上限的人本批跳过但仍列出来标「已满」
- 容量默认 ${AGENT_CAPACITY_DEFAULT}(**默认值**,无历史数据支撑,要标注)
- 分不下去就**明说分不下去**,建议缩小批次;⛔ 不要硬塞给已经满的人
- 每一条都要说得出「为什么是他」:专属 / 手上最空 / 主管指定,三选一
`.trim();
/** 客服(无 plan:dispatch)的工作流约束 */
const STAFF_EXTRA = `
## 你现在在跟**客服**说话
- 他只处理**自己名下**的任务。问"今天该联系谁"时给他自己的单,按优先级排。
- ⛔ 不要提"召回池""分配""批次"这些概念 —— 任务由主管派发,他不需要也无法自助领取。
- 他若问「池子里还有谁」,如实说明他看到的是自己名下的任务,派单由主管统一安排。
- 帮他把单打好:患者背景、上次来做了什么、这次为什么召回、开场怎么说。
`.trim();
/**
* @param permissions 调用人的权限清单(按 role 现算,见 permissions.guard)
* @param userName 显示用姓名;⛔ 没有就不要编,直接用"你"
*/
export function buildSystemExtra(input: {
permissions: readonly string[];
userName?: string | null;
}): string {
const who = input.userName ? `\n当前登录人:${input.userName}。` : '';
const body = input.permissions.includes(Permission.PLAN_DISPATCH)
? DISPATCHER_EXTRA
: STAFF_EXTRA;
return `${body}${who}`;
}
......@@ -6,6 +6,7 @@ import type { ModelMessage } from 'ai';
import { AssistantService } from './assistant.service';
import { TranscribeService } from './transcribe.service';
import { CurrentUser, type AuthenticatedUser } from '../../common/decorators/current-user.decorator';
import { buildSystemExtra } from './assistant-prompts';
/// multer 内存模式的最小文件形状(不引 @types/multer)
interface UploadedAudio {
......@@ -154,6 +155,13 @@ export class AssistantController {
// 只用于 MCP 工具清单的缓存分桶(见 mcpCapabilityKey)——
// 不传的话主管和客服会共用一份缓存,谁先进来谁的清单被全员复用。
permissions: user.permissions,
// ⭐ 按能力切工作流约束(主管 / 客服两套)。
// ⚠️ 在此之前这个参数**从来没被 /assistant/chat 传过** ——
// 「一个助手,按角色切换工作流约束」这条既定取舍此前是零实现。
systemExtra: buildSystemExtra({
permissions: user.permissions,
userName: user.dictionary?.users?.[user.sub] ?? null,
}),
abortSignal: ac.signal,
});
......
......@@ -8,6 +8,14 @@ import { OrgTreeService } from '../auth/org-tree';
export interface McpAuthContext {
scope: TenantScopeContext;
permissions: string[];
/**
* 登录人姓名(可空)。
* ⚠️ 加它**不违 T19**:T19 说的是"权限判定只认 permission,不下传 role" ——
* 姓名是**显示串**不是判据,助手要能说"张主管,这批要分给…"而不是对着一个 uuid 说话。
* ⛔ 但绝不要顺手把 role 也带下来:一旦模型看得见 role,它就会自己发明
* "leader 应该也能 X" 这类规则,而那不是权限模型说了算的。
*/
userName?: string;
}
/**
......@@ -55,6 +63,14 @@ export class McpAuthService {
// MCP 这条路尤其要现算:工具清单是按能力**条件注册**的,权限少一个 = 助手手里少几个工具,
// 模型只会说"我没有这个能力",既不报错也看不出是 token 过期。
permissions: ROLE_PERMISSIONS[payload.role] ?? payload.permissions ?? [],
// 宿主换票时把姓名放进 dictionary.users[sub];没有就留空,助手会退回"你"
...(resolveUserName(payload) ? { userName: resolveUserName(payload)! } : {}),
};
}
}
/** 从 JWT 字典里取当前登录人姓名(宿主换票时带;取不到返回 undefined) */
function resolveUserName(payload: AccessTokenPayload): string | undefined {
const n = payload.dictionary?.users?.[payload.sub];
return typeof n === 'string' && n.trim() ? n : undefined;
}
......@@ -2,11 +2,14 @@ import { Injectable } from '@nestjs/common';
// clean subpath(运行时 exports map 放行);类型见 src/types/mcp-sdk.d.ts ambient 声明
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { PERSONA_TAG_FILTER_DIMS, personaTagDimId } from '@pac/types';
import { PERSONA_TAG_FILTER_DIMS, personaTagDimId, Permission } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import { PatientService } from '../patient/patient.service';
import { PersonaService } from '../persona/persona.service';
import { PlanService } from '../plan/plan.service';
import { AgentRosterService } from '../plan/agent-roster.service';
import { PlanAssignmentService } from '../plan/plan-assignment.service';
import { AssignmentProposalService } from '../plan/assignment-proposal.service';
import type { ListPlansQueryDto } from '../plan/dto/plan.dto';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import type { McpAuthContext } from './mcp-auth.service';
......@@ -75,12 +78,47 @@ export class McpServerFactory {
private readonly patient: PatientService,
private readonly persona: PersonaService,
private readonly plans: PlanService,
private readonly roster: AgentRosterService,
private readonly assignments: PlanAssignmentService,
private readonly proposals: AssignmentProposalService,
) {}
build(ctx: McpAuthContext): McpServer {
const { scope, permissions } = ctx;
const server = new McpServer({ name: 'pac-patient-tools', version: '0.1.0' });
// ⚠️ 身份工具放**最前**:工具清单的顺序会影响模型的默认注意力,
// 而"我在跟主管说话还是跟客服说话"决定了后面走哪条工作流,必须第一个被看到。
server.registerTool(
'get_current_user',
{
description:
'当前登录人是谁、能做什么。**每轮对话开始时先调它**,据此决定走主管工作流(可发起批次分配)还是客服工作流(只处理自己的任务)。',
inputSchema: {},
},
async () =>
jsonResult({
userId: scope.userId,
displayName: ctx.userName ?? null,
// ⭐ 返回**能力**不返回 role(T19):真正控制权限的是 permission,role 只是给人看的标签。
// 模型该问的是"我能不能分配",不是"我是不是 leader" —— 后者会诱导它自己发明规则
// (比如"leader 应该也能看全部执行记录"),而那不是权限模型说了算的。
capabilities: {
canDispatch: permissions.includes(Permission.PLAN_DISPATCH),
canViewAllPlans: permissions.includes(Permission.PLAN_VIEW_ALL),
canExecute: permissions.includes(Permission.PLAN_EXECUTE),
},
clinicIds: scope.clinicIds,
tenantId: scope.tenantId,
// ⚠️ canDispatch=false 时助手要说"登录态可能已过期,请重新登录",**不要**说"你没有权限":
// 权限是按 role 现算的,但 token 里的 role 是签发时固化的 ——
// 真正的高频原因是 token 旧了,不是这个人真没权限。
hint: permissions.includes(Permission.PLAN_DISPATCH)
? '主管工作流:可发起批次分配'
: '客服工作流:只处理自己名下的任务;若你确信自己是主管,可能是登录态已过期,请重新登录',
}),
);
server.registerTool(
'find_patient',
{
......@@ -188,10 +226,18 @@ export class McpServerFactory {
},
},
async ({ view, personaTags, scenario, status, keyword, phoneVerified, sort, clinicId, limit }) => {
// ⭐ view 的权限分流(2026-08 补):`plan.service.list` **只对 view='all' 校验 PLAN_VIEW_ALL**,
// `view='pool'` 一路无校验 —— 于是客服在前端看不到召回池(T16 已撤入口),
// 却可以直接问助手「召回池里还有谁」把整池捞出来,绕过 T16。
// 这里按能力降级:没有看全池的权限就只给 'mine'。
// ⚠️ 是**静默降级**不是报错:客服问"今天该联系谁"是正当需求,
// 给他自己的单就好,不该甩一个权限错误让他以为系统坏了。
const canSeePool = permissions.includes(Permission.PLAN_VIEW_ALL);
const effectiveView = view === 'mine' ? 'mine' : canSeePool ? (view ?? 'pool') : 'mine';
// 注:schema 的诊所过滤字段是 targetClinicId(不是 clinicId);以前 cast 成 any 传 clinicId
// 被静默忽略。这里类型化构造,过滤真正生效。
const query: ListPlansQueryDto = {
view: view ?? 'pool',
view: effectiveView,
sort: sort ?? 'priority_desc',
page: 1,
pageSize: limit ?? 20,
......@@ -221,8 +267,10 @@ export class McpServerFactory {
},
},
async ({ view, personaTags, scenario, clinicId }) => {
// 同 list_recall_queue:没有看全池的权限就只统计自己的(否则总数本身就泄漏了池子规模)
const canSeePool = permissions.includes(Permission.PLAN_VIEW_ALL);
const query: ListPlansQueryDto = {
view: view ?? 'pool',
view: view === 'mine' ? 'mine' : canSeePool ? (view ?? 'pool') : 'mine',
sort: 'priority_desc',
page: 1,
pageSize: 1, // stats 不取明细行
......@@ -234,6 +282,95 @@ export class McpServerFactory {
},
);
// ═══════════════════════════════════════════════════════════
// 主管专属工具 —— **条件注册**:没有 plan:dispatch 的人连工具清单里都看不到
// ═══════════════════════════════════════════════════════════
//
// ⚠️ 为什么是"不注册"而不是"注册了再在 handler 里拒":
// 模型看得见的工具就会去试,试了被拒它会自己编解释("可能是权限问题,要不你换个账号"),
// 对客服来说这是纯噪声。不注册则它压根不知道有这回事,行为自然收敛。
// ⚠️ 这条依赖 `McpClientService.listTools` 的**能力分桶缓存**(mcp-client.service.ts)——
// 缓存不分桶的话,进程重启后第一个进来的人的清单会被全公司复用,当场串号。
if (permissions.includes(Permission.PLAN_DISPATCH)) {
server.registerTool(
'get_agents',
{
description:
'某诊所的在岗客服名册 + 各自在手负载。分配前用它看"有哪些人、谁手上空"。' +
'\n⚠️ 在岗按「近 N 月有回访记录」近似判定,**不代表系统确认在职**;' +
'名册外的客服也可以指定(用 include 传 userId)。' +
'\n⚠️ 容量上限是**默认值**不是实测,返回里带 capacityRange 与 inHand,' +
'**不要替主管做减法说"还能吃 N 个"** —— 他知道谁在休假,你不知道。',
inputSchema: {
clinicId: z.string().describe('诊所 id(必填,名册天然是诊所维度的)'),
months: z.number().int().min(1).max(36).optional().describe('在岗窗口月数,默认 12'),
include: z
.array(z.string())
.optional()
.describe('额外带上这些 userId(主管点名的人,即使不在名册)'),
},
},
async ({ clinicId, months, include }) =>
jsonResult(await this.roster.list(scope, clinicId, { months, extraUserIds: include })),
);
server.registerTool(
'propose_assignment',
{
description:
'出「全景确认单」的数据 —— 从召回池按排序键选人、按「专属优先/溢出铺平」落到客服头上。' +
'\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(
'list_assignment_batches',
{
description:
'我分过的批次列表 + 每批汇总(分了多少 / 还在手 / 已退回 / 涉及几个客服)。' +
'回答"我分的那些批,哪批出问题了"。要看某批细节再用 get_assignment_detail。',
inputSchema: {
mine: z.boolean().optional().describe('只看自己发起的,默认看本范围全部'),
},
},
async ({ mine }) =>
jsonResult(await this.assignments.list(scope, mine ? scope.userId : undefined)),
);
server.registerTool(
'get_assignment_detail',
{
description:
'单个批次的全貌:按客服拆 + 退回原因分布 + 未动过的条数。' +
'\n⚠️ 退回率**永远给两个数**:「退回 5 / 已处置 40 = 12.5%(另有 60 条未动)」——' +
'"没人动"和"动了但退回"是完全不同的信号,只报一个百分比会让主管把前者误读成后者。' +
'\n⚠️ 分母小的时候(如 <50)直接说"样本量不足",**不要输出百分比、不要画图**。',
inputSchema: { assignmentId: z.string() },
},
async ({ assignmentId }) => jsonResult(await this.assignments.detail(scope, assignmentId)),
);
}
return server;
}
......
import { Injectable } from '@nestjs/common';
import { AGENT_CAPACITY_RANGE, type AgentInfo, type ListAgentsResponse } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { loadMockUsers } from '../auth/mock-users';
/**
* AgentRosterService —— 「这个诊所现在有哪些客服、各自手上压了多少」。
*
* ── 为什么名册和负载是**一个**接口不是两个 ────────────────────
* 分配时问「还能吃多少」、跟踪时问「手上压了多少」,是同一份数据的两种读法。
* 开两个端点必然口径漂移(一个算 assigned、另一个算 assigned+active),
* 而漂了不会报错,只会让两个页面显示不同的数字然后没人说得清哪个对。
*
* ── 在岗判定 ────────────────────────────────────────────────
* 「近 N 月有回访记录」,按 `source_created_at`(宿主侧创建时刻)。
* ⛔⛔ **绝不能用 `task_date`** —— 那是**计划回访日**,含未来排程,
* 生产实测最远 2033 年、DW 侧甚至 2121 年。拿它卡窗口会把早已离职的人判成在岗,
* 而分配给一个离职的人不会报任何错,只会让那批单永远躺着。
*
* ⚠️ 名册是**建议来源,不是白名单**:实测有 11 个人只在登录侧有行为、回访数为 0
* (新入职 / 只做召回不做回访)。主管显式指定的人即使名册里查不到也必须能分,
* 只在旁边给一个中性提示。挡人是主管的权力,不是名册的。
*/
@Injectable()
export class AgentRosterService {
constructor(private readonly prisma: PrismaService) {}
/**
* @param clinicId 限定诊所(必填 —— 名册天然是诊所维度的)
* @param months 在岗窗口,默认 12
* @param extraUserIds 主管显式指定、但可能不在名册里的人 —— 一并返回并标注
*/
async list(
scope: TenantScopeContext,
clinicId: string,
opts: { months?: number; extraUserIds?: string[] } = {},
): Promise<ListAgentsResponse> {
const months = opts.months ?? 12;
const since = new Date();
since.setMonth(since.getMonth() - months);
// ── 名册:该诊所近 N 月有回访记录的人 ──────────────────────
const roster = await this.prisma.patientReturnVisit.groupBy({
by: ['taskDirectorId'],
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
clinicId,
taskDirectorId: { not: null },
// ⛔ 不是 taskDate,见类注释
sourceCreatedAt: { gte: since },
},
_count: { _all: true },
_max: { sourceCreatedAt: true },
});
const ids = new Set<string>();
for (const r of roster) if (r.taskDirectorId) ids.add(r.taskDirectorId);
for (const id of opts.extraUserIds ?? []) ids.add(id);
if (ids.size === 0) return { clinicId, rosterMonths: months, agents: [], rosterNote: emptyNote(months) };
// ── 负载:在手总量 ─────────────────────────────────────────
// ⚠️ **跨诊所全量统计**,不按 clinicId 过滤 —— 容量说的是"这个人同时能跟进多少",
// 是人的属性不是诊所的属性。实测 24% 的客服跨诊所,只算本诊所会把他们
// 系统性地显示成"手上很空",然后被每个诊所各灌一轮。
// ⚠️ 这与 F4「写路径补 clinicIds 硬边界」方向相反,别顺手在这里也加诊所过滤:
// 候选**患者**按诊所圈,客服**容量**跨诊所算,两个口径不同是有意的。
const load = await this.prisma.followupPlan.groupBy({
by: ['assigneeUserId'],
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
assigneeUserId: { in: [...ids] },
status: 'assigned',
supersededAt: null,
},
_count: { _all: true },
});
const inHandAll = new Map<string, number>();
for (const l of load) if (l.assigneeUserId) inHandAll.set(l.assigneeUserId, l._count._all);
// 本诊所在手(只用于展示拆分,不参与容量判断)
const loadHere = await this.prisma.followupPlan.groupBy({
by: ['assigneeUserId'],
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
assigneeUserId: { in: [...ids] },
status: 'assigned',
supersededAt: null,
targetClinicId: clinicId,
},
_count: { _all: true },
});
const inHandHere = new Map<string, number>();
for (const l of loadHere) if (l.assigneeUserId) inHandHere.set(l.assigneeUserId, l._count._all);
const rosterById = new Map(roster.filter((r) => r.taskDirectorId).map((r) => [r.taskDirectorId!, r]));
const nameById = mockNameIndex();
const agents: AgentInfo[] = [...ids].map((userId) => {
const r = rosterById.get(userId);
const inHand = inHandAll.get(userId) ?? 0;
return {
userId,
name: nameById.get(userId) ?? null,
inHand,
inHandThisClinic: inHandHere.get(userId) ?? 0,
recentVisits: r?._count._all ?? 0,
lastVisitAt: r?._max.sourceCreatedAt?.toISOString() ?? null,
/// 名册里没有 = 近 N 月无回访记录。**不是"不能分"** —— 见类注释
inRoster: r != null,
// ⛔ **不返回 remaining**。容量「20-50」是一个**区间默认值**,不是这个人的真实上限;
// 把它减出来会让助手说「李莉还能吃 38 个」—— 那是拿默认值做完了减法再当事实说出口,
// 一句免责声明救不回来。返回区间 + 在手,减法留给主管(他知道谁在休假)。
capacityRange: [...AGENT_CAPACITY_RANGE] as [number, number],
capacityBasis: 'default' as const,
};
});
// 在手少的排前面(铺平时先给谁的自然顺序);同值按 userId 保证**确定性** ——
// 主管微调后重算两次必须看到同样的分法,否则他会以为系统在乱跳。
agents.sort((a, b) => a.inHand - b.inHand || a.userId.localeCompare(b.userId));
return {
clinicId,
rosterMonths: months,
agents,
// ⭐ 成品句子,给助手**照抄**用。T14 的落地方式是"提示词 + 工具返回值"双保险:
// LLM 做阈值判断和免责声明都不可靠,直接把该说的话给它抄。
rosterNote:
`在岗名册按「近 ${months} 个月有回访记录」近似判定,不代表系统确认在职;` +
`容量上限 ${AGENT_CAPACITY_RANGE[0]}-${AGENT_CAPACITY_RANGE[1]} 为默认值(暂无历史数据反推)。` +
`名册外的客服也可以指定。`,
};
}
}
function emptyNote(months: number): string {
return `该诊所近 ${months} 个月无回访记录,名册为空 —— 可能是新开诊所或回访数据未接入;请主管直接指定客服。`;
}
/**
* userId → 姓名。当前唯一来源是 mock 花名册(`data/<host>/users.json`,派生自回访表)。
* ⚠️ PAC **没有 users 表**,而前端 `dictionary.users` 只覆盖当前登录人 ——
* 所以姓名必须由**服务端**解析后随 payload 下发,不能指望前端自己翻。
* 将来接了真 users 表,只换这个函数。
*/
function mockNameIndex(): Map<string, string> {
const m = new Map<string, string>();
for (const u of loadMockUsers()) m.set(u.externalId, u.name);
return m;
}
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
AGENT_CAPACITY_DEFAULT,
AssignStrategy,
type AgentInfo,
type AssignmentProposal,
type ProposedItem,
} from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { AgentRosterService } from './agent-roster.service';
/**
* AssignmentProposalService —— 「1,189 人的格子 → 一批 100 人 → 落到 N 个客服头上」。
*
* 这是助手出确认单的取数与算法层。**纯只读**(T8:唯一的写动作是主管点确认)。
*
* ═══ 两个正交的决策,别混成一件事 ═══════════════════════════
* ① 选谁进这批(收敛) ← 排序键的事
* ② 这批怎么落到客服头上 ← 专属优先 / 溢出铺平的事
* 「优先级排序」只解决 ①,不解决 ②。
*/
/**
* ① 收敛排序键 —— 三层,每层都能用一句话向主管解释。
*
* ```
* (assignment_id IS NULL) DESC 从没进过任何批次的优先
* priority_score DESC 层内沿用现成的分,不自研公式
* patient_id ASC 全序兜底
* ```
*
* **首键为什么必须有**:退回的高分单回池后分数几乎不变(freshness 降,但 daysSince 增大
* 反而可能推高急迫性),它会立刻回到下一批的头部**反复插队**,300 名开外的人永远轮不到。
* 这不是"还没轮到",是被插队插到死。生产实证:现存 57 个认领单里 79% 已过时限 ——
* "打了没结果然后挂着"是常态不是例外,这批一旦回池就是稳定的插队源。
* `assignment_id` 在退回时**不清空**(见 plan.service.recycle),所以它天然就是
* 「这条从没进过任何批次」的标记 —— **零新列**。
* 一句话解释:「先给还没联系过的人排队;联系过一轮还没成的,等下一圈。」
*
* **第三层不是装饰**:filling 格 1,189 人只有 306 个不同取值(均值 3.9 人同分),
* 第 100 名撞并列是**必然事件**;而 PG 不承诺没有完整 ORDER BY 时的行序。
* 被问「她俩分一样,凭什么她进我不进」时必须答得上来。
* (现有列表排序 `plan.service` 也已经是复合键,同一个问题在分页上踩过。)
*
* ⚠️ **退化点**:跑满一整圈后(1,189 / 100 ≈ 12 批)所有人 assignment_id 都非空,
* 首键恒 false,排序退化成纯 score,插队问题原样回来。届时需要第二圈机制
* (按 `plan_assignments.created_at` 最早优先);⛔ 不要用 `assignedAt` —— 退回和到期都会清空它。
* 那是 12 批之后的事,那时已有足够样本用数据决定,现在设计属于过度设计。
*
* ⚠️ **不加「专属客服可用性」分层**(重要,且违直觉):85.1% 的池子有在岗专属,
* 一旦拿它当首键,批次会近乎 100% 是 dedicated,于是 T20 点名要反推的
* 「专属 vs 铺平完成率差」**永远凑不满样本** —— 用一个未验证的假设去排序,
* 而这个排序保证了该假设无法被验证。第一批必须混合,才有东西可反推。
* 这条已经产品确认,不要"顺手优化"掉。
*/
const SELECTION_ORDER = Prisma.sql`
ORDER BY (fp.assignment_id IS NULL) DESC, fp.priority_score DESC, fp.patient_id ASC
`;
@Injectable()
export class AssignmentProposalService {
private readonly logger = new Logger(AssignmentProposalService.name);
constructor(
private readonly prisma: PrismaService,
private readonly roster: AgentRosterService,
) {}
async propose(
scope: TenantScopeContext,
input: {
clinicId: string;
/** 画像「潜在治疗」8 类之一(矩阵 X 轴);不传 = 不按治疗项收窄 */
potentialTreatment?: string;
/** 拟分人数;不传 = 按在岗容量总量估 */
targetCount?: number;
/** 探索配额占比(0-0.2)。⭐ 唯一的因果抓手,见 selectionMode 注释 */
exploreRatio?: number;
},
): Promise<AssignmentProposal> {
const { clinicId, potentialTreatment } = input;
const roster = await this.roster.list(scope, clinicId);
const agents = roster.agents;
// ── 拟分人数 ────────────────────────────────────────────
// 默认 = 在岗客服的**剩余容量总和**(容量默认值 - 在手),不是"人数 × 容量"。
// ⚠️ 容量是**默认值**,所以这个数也是默认值,确认单上必须标出来。
const totalRoom = agents.reduce((a, g) => a + Math.max(0, AGENT_CAPACITY_DEFAULT - g.inHand), 0);
const target = Math.max(0, input.targetCount ?? totalRoom);
if (target === 0 || agents.length === 0) {
return emptyProposal(clinicId, potentialTreatment, agents, roster.rosterNote, target);
}
// ── ① 收敛:按三层键取前 N ────────────────────────────────
// ⭐ 多取一些(1.5 倍)给去重和探索配额留余量;不取全量是因为大格子上万条。
const pool = await this.selectCandidates(scope, clinicId, potentialTreatment, Math.ceil(target * 1.5) + 20);
// 同患者只留一条(schema 注释承诺的 partial UNIQUE 实际不存在,不能当保障用)
const seenPatient = new Set<string>();
const ranked = pool.filter((c) => {
if (seenPatient.has(c.patientId)) return false;
seenPatient.add(c.patientId);
return true;
});
// ── 探索配额 ────────────────────────────────────────────
// 从**排名之外**抽,不是从头部抽 —— 抽头部等于没探索。
const ratio = Math.min(0.2, Math.max(0, input.exploreRatio ?? 0));
const exploreN = Math.floor(target * ratio);
const rankN = Math.max(0, target - exploreN);
const head = ranked.slice(0, rankN).map((c) => ({ ...c, selectionMode: 'rank' as const }));
const tailPool = ranked.slice(rankN);
// ⚠️ 确定性抽样:按 patientId 的字典序等距取,不用随机数 ——
// 主管微调后会重算,随机会让人数和名单无故跳动,他会以为系统在乱来。
const explore = pickEvenly(tailPool, exploreN).map((c) => ({
...c,
selectionMode: 'explore' as const,
}));
const chosen = [...head, ...explore];
// ── ② 落人:专属优先 → 溢出均分 ──────────────────────────
// ⚠️ 专属客服表**作参数传下去**,不挂实例字段:service 是单例,
// 两个主管同时出确认单会互相冲掉对方的缓存,而且是**静默**串数据。
// (本仓已有 ingest-resolver-no-instance-state.spec 在防同一类错。)
const dedicated = await this.dedicatedCsOf(chosen.map((c) => c.patientId));
const items = placeAgents(chosen, agents, dedicated);
const byAgent = new Map<string, ProposedItem[]>();
for (const it of items.placed) {
const arr = byAgent.get(it.assigneeUserId) ?? [];
arr.push(it);
byAgent.set(it.assigneeUserId, arr);
}
const agentById = new Map(agents.map((a) => [a.userId, a]));
return {
clinicId,
potentialTreatment: potentialTreatment ?? null,
candidateTotal: ranked.length,
target,
placed: items.placed.length,
unplaced: items.unplaced,
items: items.placed,
byAgent: [...byAgent].map(([userId, list]) => ({
userId,
name: agentById.get(userId)?.name ?? null,
inHandBefore: agentById.get(userId)?.inHand ?? 0,
count: list.length,
dedicated: list.filter((x) => x.assignStrategy === AssignStrategy.DEDICATED).length,
spread: list.filter((x) => x.assignStrategy !== AssignStrategy.DEDICATED).length,
})),
// 已满但仍列出来:主管要看见"这个人不是被漏了,是已经满了"
skippedAgents: agents
.filter((a) => a.inHand >= AGENT_CAPACITY_DEFAULT)
.map((a) => ({ userId: a.userId, name: a.name, inHand: a.inHand })),
rosterNote: roster.rosterNote,
// ⭐ 成品句子,助手照抄(T14 双保险的"工具返回值"那一半)
capacityNote:
`容量上限按每人在手 ${AGENT_CAPACITY_DEFAULT} 条计,这是**默认值** ——` +
`暂无历史数据反推各人真实吞吐,积累后按实际完成率拐点替换。`,
selectionNote:
`按「未进过批次优先 → 优先级高优先 → 患者号」排序取前 ${target} 人;` +
(exploreN > 0
? `其中 ${exploreN} 人来自探索配额(排名之外抽取,用于日后验证排序是否选得准)。`
: '') +
(items.unplaced > 0
? `⚠️ 有 ${items.unplaced} 人按当前容量分不下去,建议本批缩到 ${items.placed.length} 人。`
: ''),
};
}
/**
* 候选取数 —— 走原生 SQL 是因为排序键的首层 `(assignment_id IS NULL)` 是**表达式**,
* Prisma 的 orderBy 表达不了。
*
* ⚠️ 过滤条件必须与召回池列表同 scope,否则"矩阵上说有 383 人、点进去列表 350 条"
* 这种对不上的事一发生,主管对整个功能的信任就没了。
*/
private async selectCandidates(
scope: TenantScopeContext,
clinicId: string,
potentialTreatment: string | undefined,
limit: number,
): Promise<Array<{ planId: string; patientId: string; priorityScore: number }>> {
const brandFilter = scope.sourceUnits.length
? Prisma.sql`AND p.source_unit IN (${Prisma.join(scope.sourceUnits)})`
: Prisma.empty;
// 潜在治疗:画像特征 data->'types' 是字符串数组,用 @> 命中(与 persona-tag-filters 同口径)
const treatmentFilter = potentialTreatment
? Prisma.sql`
AND EXISTS (
SELECT 1 FROM personas pe
JOIN persona_features pf ON pf.persona_id = pe.id AND pf.key = 'potential_treatment'
WHERE pe.id = fp.persona_id
AND pe.superseded_at IS NULL
AND (pf.data #> '{types}') @> ${JSON.stringify([potentialTreatment])}::jsonb
)`
: Prisma.empty;
return this.prisma.$queryRaw<Array<{ planId: string; patientId: string; priorityScore: number }>>(
Prisma.sql`
SELECT fp.id AS "planId", fp.patient_id AS "patientId", fp.priority_score AS "priorityScore"
FROM followup_plans fp
JOIN patients p ON p.id = fp.patient_id
WHERE fp.host_id = ${scope.hostId}::uuid
AND fp.tenant_id = ${scope.tenantId}
AND fp.target_clinic_id = ${clinicId}
AND fp.status = 'active'
AND fp.assignee_user_id IS NULL
AND fp.superseded_at IS NULL
-- 冷静期/约定回访未到 → 不该进池,更不该被分
AND (fp.snoozed_until IS NULL OR fp.snoozed_until <= NOW())
${brandFilter}
${treatmentFilter}
${SELECTION_ORDER}
LIMIT ${limit}
`,
);
}
private async dedicatedCsOf(patientIds: string[]): Promise<Map<string, string>> {
const ids = [...new Set(patientIds)];
if (ids.length === 0) return new Map();
const rows = await this.prisma.patient.findMany({
where: { id: { in: ids } },
select: { id: true, preferences: true },
});
const out = new Map<string, string>();
for (const r of rows) {
const id = (r.preferences as { dedicatedCs?: { id?: string } } | null)?.dedicatedCs?.id;
if (typeof id === 'string' && id) out.set(r.id, id);
}
return out;
}
}
/**
* ② 落人:**专属优先 → 溢出均分 + 在手硬护栏**。
*
* ⭐ 导出成**纯函数**(不进 class):它是本文件里唯一值得单独测的算法,
* 而纯函数不需要起 Nest 容器、不需要 mock Prisma,测试成本低一个量级。
*
* ── 为什么是均分,不是「水位拉平」 ──────────────────────────
* 水位拉平(按在手量补到同一水位)看着更聪明,实际站不住,三条:
* 1. 它的输入是 `remaining = 容量 − 在手`,而**容量是拍的默认值** ——
* 拿默认值做完减法再当事实说出口("李莉还能吃 38 个"),一句免责声明救不回来。
* 2. 在手量当前**不可信**:自动回收长期关闭、执行回写率仅 11%、
* 现存认领单 79% 已过时限。以它为决策依据 = 把噪声固化成政策。
* 3. **自毁 T20**:要反推的第一项就是"各客服实际能吃多少",那需要负载**方差**作自变量;
* 而水位拉平把消灭方差当目标函数。
* 另外它是全局耦合的:主管改一条指定客服,所有人的数字都要跳 —— 违 T13「一次确认」。
* 均分是局部的:改一条只影响那一条。
*
* ⚠️ 切换到贪心「最少在手优先」将来只是一行排序的改动、不需要新列、不影响任何历史归因 ——
* 所以这个决定**应该等在手量可信之后再用数据做**,现在不预支。
*/
export function placeAgents(
chosen: Array<{ planId: string; patientId: string; selectionMode: 'rank' | 'explore' }>,
agents: AgentInfo[],
dedicatedByPatient: Map<string, string>,
): { placed: ProposedItem[]; unplaced: number } {
const room = new Map<string, number>();
for (const a of agents) room.set(a.userId, Math.max(0, AGENT_CAPACITY_DEFAULT - a.inHand));
const rosterIds = new Set(agents.map((a) => a.userId));
const placed: ProposedItem[] = [];
const overflow: typeof chosen = [];
// ── 第一趟:专属命中 ──────────────────────────────────────
for (const c of chosen) {
const dcs = dedicatedByPatient.get(c.patientId);
if (dcs && rosterIds.has(dcs) && (room.get(dcs) ?? 0) > 0) {
room.set(dcs, room.get(dcs)! - 1);
placed.push({
planId: c.planId,
patientId: c.patientId,
assigneeUserId: dcs,
assignStrategy: AssignStrategy.DEDICATED,
selectionMode: c.selectionMode,
});
} else {
overflow.push(c);
}
}
// ── 第二趟:溢出均分 ──────────────────────────────────────
// ⚠️ 剩余容量必须**接着第一趟的结果**算(room 是同一个累加器)——
// 分开算的话专属大户会被再灌一轮,而这一点在代码上完全不显眼。
const takers = agents.filter((a) => (room.get(a.userId) ?? 0) > 0).map((a) => a.userId);
let cursor = 0;
let unplaced = 0;
for (const c of overflow) {
// 找下一个还有余量的人;一圈都没有 → 分不下去
let hops = 0;
while (hops < takers.length && (room.get(takers[cursor % takers.length]!) ?? 0) <= 0) {
cursor++;
hops++;
}
if (takers.length === 0 || hops >= takers.length) {
// ⛔ **截断不摊派**:硬塞给已经满的人是 T5「宁缺毋滥」的反面,
// 而且会立刻制造 over_capacity 退回 —— 那正是要反推容量默认值的那个信号,
// 自己造出来就没法反推了。
unplaced++;
continue;
}
const who = takers[cursor % takers.length]!;
cursor++;
room.set(who, room.get(who)! - 1);
const dcs = dedicatedByPatient.get(c.patientId);
placed.push({
planId: c.planId,
patientId: c.patientId,
assigneeUserId: who,
// ⭐ 两种铺平必须分开记:有专属只是没轮到(关系还在) vs 从头没有专属(关系本就薄)。
// 混成一个值,T20 算出来的"铺平完成率低"就分不清是策略问题还是人群问题。
assignStrategy:
dcs && rosterIds.has(dcs)
? AssignStrategy.SPREAD_OVERFLOW
: AssignStrategy.SPREAD_NO_DEDICATED,
selectionMode: c.selectionMode,
});
}
return { placed, unplaced };
}
/**
* 等距抽样 —— **确定性**,不用随机数。
* 主管微调后会重算,随机会让名单无故跳动;而"同样的输入两次算出同样的分法"
* 是他敢按确认键的前提。
*/
function pickEvenly<T>(arr: T[], n: number): T[] {
if (n <= 0 || arr.length === 0) return [];
if (n >= arr.length) return [...arr];
const step = arr.length / n;
return Array.from({ length: n }, (_, i) => arr[Math.floor(i * step)]!);
}
function emptyProposal(
clinicId: string,
potentialTreatment: string | undefined,
agents: AgentInfo[],
rosterNote: string,
target: number,
): AssignmentProposal {
return {
clinicId,
potentialTreatment: potentialTreatment ?? null,
candidateTotal: 0,
target,
placed: 0,
unplaced: 0,
items: [],
byAgent: [],
skippedAgents: agents
.filter((a) => a.inHand >= AGENT_CAPACITY_DEFAULT)
.map((a) => ({ userId: a.userId, name: a.name, inHand: a.inHand })),
rosterNote,
capacityNote: `容量上限按每人在手 ${AGENT_CAPACITY_DEFAULT} 条计(默认值)。`,
selectionNote:
agents.length === 0
? '该诊所名册为空,无法出分配方案 —— 请主管直接指定客服。'
: '在岗客服当前在手量已达容量上限,本批无可分配余量。',
};
}
......@@ -8,11 +8,13 @@ import type { TenantScopeContext } from '../../common/decorators/tenant-scope.de
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../../common/decorators/current-user.decorator';
import { PlanAssignmentService } from './plan-assignment.service';
import { AgentRosterService } from './agent-roster.service';
import {
CreateAssignmentRequestDto,
CreateAssignmentResponseDto,
ListAssignmentsResponseDto,
AssignmentDetailResponseDto,
ListAgentsResponseDto,
} from './dto/plan-assignment.dto';
/**
......@@ -30,7 +32,35 @@ import {
@ApiBearerAuth('accessToken')
@Controller('plans/assignments')
export class AssignmentController {
constructor(private readonly assignments: PlanAssignmentService) {}
constructor(
private readonly assignments: PlanAssignmentService,
private readonly roster: AgentRosterService,
) {}
/**
* 客服名册 + 在手负载 —— **一个端点**,不拆成两个。
* 分配问「还能吃多少」、跟踪问「压了多少」,是同一份数据的两种读法;
* 拆开必然口径漂移(一个算 assigned、一个算 assigned+active),而漂了不报错。
*
* ⚠️ 路由声明必须在 `@Get(':id')` **之前** —— 否则 'agents' 会被当成 assignmentId
* (Nest 按声明顺序匹配)。同一个坑本文件的 module 注册顺序也踩过一次。
*/
@Get('agents')
@RequirePermission(Permission.PLAN_DISPATCH)
@ZodResponse({ status: 200, type: ListAgentsResponseDto })
@ApiOperation({ summary: '在岗客服名册 + 在手负载(近 N 月有回访记录者;名册外亦可指定)' })
async agents(
@TenantScope() scope: TenantScopeContext,
@Query('clinicId') clinicId: string,
@Query('months') months?: string,
@Query('include') include?: string,
) {
return this.roster.list(scope, clinicId, {
months: months ? Number(months) : undefined,
// 主管显式点名的人即使不在名册也要能查到 —— 名册是建议不是白名单
extraUserIds: include ? include.split(',').filter(Boolean) : undefined,
});
}
@Post()
@RequirePermission(Permission.PLAN_DISPATCH)
......
......@@ -4,9 +4,11 @@ import {
CreateAssignmentRequestSchema,
CreateAssignmentResponseSchema,
ListAssignmentsResponseSchema,
ListAgentsResponseSchema,
} from '@pac/types';
export class CreateAssignmentRequestDto extends createZodDto(CreateAssignmentRequestSchema) {}
export class CreateAssignmentResponseDto extends createZodDto(CreateAssignmentResponseSchema) {}
export class ListAssignmentsResponseDto extends createZodDto(ListAssignmentsResponseSchema) {}
export class AssignmentDetailResponseDto extends createZodDto(AssignmentDetailResponseSchema) {}
export class ListAgentsResponseDto extends createZodDto(ListAgentsResponseSchema) {}
......@@ -3,6 +3,8 @@ import { PlanController } from './plan.controller';
import { PlanService } from './plan.service';
import { AssignmentController } from './assignment.controller';
import { PlanAssignmentService } from './plan-assignment.service';
import { AgentRosterService } from './agent-roster.service';
import { AssignmentProposalService } from './assignment-proposal.service';
import { ExecutionService } from './execution.service';
import { ExecutionCallbackService } from './execution-callback.service';
import { RecycleSchedulerService } from './recycle-scheduler.service';
......@@ -28,6 +30,8 @@ import { RecallDebugService } from './recall-debug/recall-debug.service';
providers: [
PlanService,
PlanAssignmentService,
AgentRosterService,
AssignmentProposalService,
ExecutionService,
ExecutionCallbackService,
RecycleSchedulerService,
......@@ -37,6 +41,7 @@ import { RecallDebugService } from './recall-debug/recall-debug.service';
TreatmentInitiationRecallScenario,
RecallDebugService,
],
exports: [PlanService, ExecutionService, ExecutionCallbackService, PlanEngineService, ChainComposerService],
// MCP 的主管工具直接用这两个 service(条件注册,见 mcp-server.factory)
exports: [PlanService, PlanAssignmentService, AgentRosterService, AssignmentProposalService, ExecutionService, ExecutionCallbackService, PlanEngineService, ChainComposerService],
})
export class PlanModule {}
import { AssignStrategy, AGENT_CAPACITY_DEFAULT, type AgentInfo } from '@pac/types';
import { placeAgents } from '../src/modules/plan/assignment-proposal.service';
/**
* 「专属优先 / 溢出铺平」落人算法回归。
*
* 这是主管每天都要看的那张确认单背后的算法 —— 它错了不会报错,
* 只会让主管把单分给不该分的人,而他要到客服抱怨时才知道。
*/
function agent(userId: string, inHand: number, name = userId): AgentInfo {
return {
userId,
name,
inHand,
inHandThisClinic: inHand,
recentVisits: 10,
lastVisitAt: '2026-07-01T00:00:00.000Z',
inRoster: true,
capacityRange: [20, 50],
capacityBasis: 'default',
};
}
const pick = (n: number, prefix = 'p') =>
Array.from({ length: n }, (_, i) => ({
planId: `${prefix}-plan-${i}`,
patientId: `${prefix}-pat-${i}`,
selectionMode: 'rank' as const,
}));
describe('placeAgents —— 专属优先', () => {
test('⭐ 专属客服在名册内且有余量 → 命中 dedicated', () => {
const chosen = pick(3);
const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
const { placed } = placeAgents(chosen, [agent('a', 0), agent('b', 0)], dedicated);
expect(placed).toHaveLength(3);
expect(placed.every((p) => p.assigneeUserId === 'a')).toBe(true);
expect(placed.every((p) => p.assignStrategy === AssignStrategy.DEDICATED)).toBe(true);
});
test('⭐ 专属客服**不在名册** → 走铺平,且标 spread_no_dedicated', () => {
const chosen = pick(2);
// 专属是 'ghost'(已离职,名册里查不到)
const dedicated = new Map(chosen.map((c) => [c.patientId, 'ghost']));
const { placed } = placeAgents(chosen, [agent('a', 0)], dedicated);
expect(placed.every((p) => p.assigneeUserId === 'a')).toBe(true);
// 「从头没有可用专属」≠「专属满了溢出」—— 混成一个值,T20 就分不清
// "铺平完成率低"是策略问题还是人群问题
expect(placed.every((p) => p.assignStrategy === AssignStrategy.SPREAD_NO_DEDICATED)).toBe(true);
});
test('⭐ 无专属客服 → spread_no_dedicated', () => {
const chosen = pick(2);
const { placed } = placeAgents(chosen, [agent('a', 0)], new Map());
expect(placed.every((p) => p.assignStrategy === AssignStrategy.SPREAD_NO_DEDICATED)).toBe(true);
});
test('⭐⭐ 专属**已满** → 溢出给别人,且标 spread_overflow(与"无专属"区分开)', () => {
const chosen = pick(3);
const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
// a 只剩 1 个余量
const { placed } = placeAgents(
chosen,
[agent('a', AGENT_CAPACITY_DEFAULT - 1), agent('b', 0)],
dedicated,
);
const byStrategy = placed.reduce<Record<string, number>>((m, p) => {
m[p.assignStrategy] = (m[p.assignStrategy] ?? 0) + 1;
return m;
}, {});
expect(byStrategy[AssignStrategy.DEDICATED]).toBe(1);
// 关系还在,只是这次没轮到他 —— 与 spread_no_dedicated 是两群人
expect(byStrategy[AssignStrategy.SPREAD_OVERFLOW]).toBe(2);
expect(byStrategy[AssignStrategy.SPREAD_NO_DEDICATED]).toBeUndefined();
});
});
describe('placeAgents —— 溢出均分 + 在手硬护栏', () => {
test('⭐ 均分:9 条给 3 个空闲客服 → 各 3 条', () => {
const chosen = pick(9);
const { placed, unplaced } = placeAgents(
chosen,
[agent('a', 0), agent('b', 0), agent('c', 0)],
new Map(),
);
expect(unplaced).toBe(0);
const counts = ['a', 'b', 'c'].map((u) => placed.filter((p) => p.assigneeUserId === u).length);
expect(counts).toEqual([3, 3, 3]);
});
test('⭐⭐ 已满的人**本批一条都不给**(不是少给)', () => {
const chosen = pick(4);
const { placed } = placeAgents(
chosen,
[agent('full', AGENT_CAPACITY_DEFAULT), agent('free', 0)],
new Map(),
);
// 硬塞给已满的人是 T5 的反面,而且会立刻造出 over_capacity 退回 ——
// 那正是要用来反推容量默认值的信号,自己造出来就没法反推了
expect(placed.filter((p) => p.assigneeUserId === 'full')).toHaveLength(0);
expect(placed.filter((p) => p.assigneeUserId === 'free')).toHaveLength(4);
});
test('⭐⭐ 容量不够 → **截断不摊派**,如实报 unplaced', () => {
const chosen = pick(5);
// 只剩 2 个余量
const { placed, unplaced } = placeAgents(
chosen,
[agent('a', AGENT_CAPACITY_DEFAULT - 2)],
new Map(),
);
expect(placed).toHaveLength(2);
expect(unplaced).toBe(3);
});
test('⭐⭐ 专属段与铺平段**共用同一个余量累加器**(否则专属大户会被再灌一轮)', () => {
// 6 条全是 a 的专属;a 只剩 2 个余量 → 2 条 dedicated,4 条该溢出给 b
const chosen = pick(6);
const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
const { placed, unplaced } = placeAgents(
chosen,
[agent('a', AGENT_CAPACITY_DEFAULT - 2), agent('b', 0)],
dedicated,
);
expect(unplaced).toBe(0);
// ⚠️ 若两段各自算余量,a 会在铺平段又被当成"还有余量"再吃一轮 —— 这一点在代码上完全不显眼
expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(2);
expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(4);
});
test('名册为空 → 一条都落不上,不报错', () => {
const { placed, unplaced } = placeAgents(pick(3), [], new Map());
expect(placed).toHaveLength(0);
expect(unplaced).toBe(3);
});
});
describe('placeAgents —— 确定性', () => {
test('⭐ 同样的输入两次算出**同样的分法**(主管微调后会重算,跳动他就不敢按确认)', () => {
const chosen = pick(7);
const agents = [agent('a', 3), agent('b', 1), agent('c', 5)];
const r1 = placeAgents(chosen, agents, new Map());
const r2 = placeAgents(chosen, agents, new Map());
expect(r1.placed.map((p) => `${p.planId}:${p.assigneeUserId}`)).toEqual(
r2.placed.map((p) => `${p.planId}:${p.assigneeUserId}`),
);
});
test('selectionMode 原样带过去(探索配额标记不能在落人这步丢)', () => {
const chosen = [
{ planId: 'p1', patientId: 'x1', selectionMode: 'rank' as const },
{ planId: 'p2', patientId: 'x2', selectionMode: 'explore' as const },
];
const { placed } = placeAgents(chosen, [agent('a', 0)], new Map());
expect(placed.find((p) => p.planId === 'p2')!.selectionMode).toBe('explore');
// 丢了就等于探索配额白留 —— 事后无法把两组分开
expect(placed.find((p) => p.planId === 'p1')!.selectionMode).toBe('rank');
});
});
......@@ -67,6 +67,55 @@ export const CreateAssignmentRequestSchema = z.object({
});
export type CreateAssignmentRequest = z.infer<typeof CreateAssignmentRequestSchema>;
// =============================================================
// 客服名册 + 负载 —— GET /pac/v1/plans/agents
// =============================================================
/**
* 单个客服同时能跟进的**在手总量**区间(不是每日增量)。
*
* ⚠️ 这是**默认值,没有数据支撑** —— 全生产 `plan_executions` 仅 7 条,
* 算不出任何人的真实吞吐。凡是用到它的地方都必须按 T14 当场标明"默认值",
* 等 T20 沉淀出「完成率开始下滑的拐点」再替换。
* 产品 2026-08 定:默认取**上界 50**。
*/
export const AGENT_CAPACITY_RANGE: readonly [number, number] = [20, 50];
/// 助手算拟分人数时用的那一个数(区间上界)。⚠️ 仍是默认值,不是实测容量。
export const AGENT_CAPACITY_DEFAULT = AGENT_CAPACITY_RANGE[1];
export const AgentInfoSchema = z.object({
userId: z.string(),
/// 服务端解析好的姓名(PAC 无 users 表,前端翻不了 —— 见 agent-roster.service 注释)
name: z.string().nullable(),
/// 在手总量,**跨诊所全量** —— 容量是人的属性不是诊所的属性(实测 24% 客服跨诊所)
inHand: z.number().int(),
/// 其中属于本诊所的。⚠️ 只用于展示拆分,**不参与容量判断** ——
/// 不拆开显示的话,跨诊所客服会显得"手上很空",主管第一反应是数据错了
inHandThisClinic: z.number().int(),
recentVisits: z.number().int().describe('在岗窗口内的回访条数'),
lastVisitAt: z.string().nullable(),
/// 是否在名册内。⚠️ **不是"能不能分"** —— 名册是建议来源不是白名单,
/// 实测有客服只做召回不做回访(回访数 0),分给他完全合法
inRoster: z.boolean(),
capacityRange: z.tuple([z.number().int(), z.number().int()]),
/// 'default' = 区间是拍的默认值;将来数据够了会变成 'derived'
capacityBasis: z.literal('default'),
});
export type AgentInfo = z.infer<typeof AgentInfoSchema>;
export const ListAgentsResponseSchema = z.object({
clinicId: z.string(),
rosterMonths: z.number().int(),
agents: z.array(AgentInfoSchema),
/**
* ⭐ **给助手直接照抄的成品句子**,不是给人读的说明。
* T14 的落地方式是「提示词 + 工具返回值」双保险:LLM 做阈值判断和写免责声明
* 都不可靠,把该说的话直接给它,任务就从"让模型算对"降级成"让模型照抄"。
*/
rosterNote: z.string(),
});
export type ListAgentsResponse = z.infer<typeof ListAgentsResponseSchema>;
/// 没落上的单及原因 —— 助手照着这个说人话,别让主管对着 uuid 猜
export const AssignmentSkippedSchema = z.object({
planId: z.string(),
......@@ -146,3 +195,54 @@ export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({
untouched: z.number().int().describe('分下去后客服从未打开过详情页的条数'),
});
export type AssignmentDetailResponse = z.infer<typeof AssignmentDetailResponseSchema>;
// =============================================================
// 全景确认单(助手直出,主管确认)—— 只读提案,不落库
// =============================================================
export const ProposedItemSchema = z.object({
planId: z.string(),
patientId: z.string(),
assigneeUserId: z.string(),
assignStrategy: AssignStrategySchema,
selectionMode: z.enum(['rank', 'explore']),
});
export type ProposedItem = z.infer<typeof ProposedItemSchema>;
export const ProposalAgentRowSchema = z.object({
userId: z.string(),
name: z.string().nullable(),
inHandBefore: z.number().int().describe('分配前在手(跨诊所全量)'),
count: z.number().int(),
dedicated: z.number().int(),
spread: z.number().int().describe('两种铺平合并显示 —— 主管界面只分「专属/铺平」两档'),
});
export type ProposalAgentRow = z.infer<typeof ProposalAgentRowSchema>;
/**
* 全景确认单的数据体。
*
* ⚠️ 三个 `*Note` 字段是**给助手直接照抄的成品句子**,不是给人读的说明文字。
* T14/T20 那两类要求(标注默认值、样本不足不出百分比)全是除法和阈值判断 ——
* 恰恰是 LLM 最不可靠的地方。把话写好交给它抄,任务就从"让模型算对"降级成"让模型照抄"。
*/
export const AssignmentProposalSchema = z.object({
clinicId: z.string(),
potentialTreatment: z.string().nullable(),
candidateTotal: z.number().int().describe('去重后的候选总数(不是格子总量,已按取数上限截断)'),
target: z.number().int().describe('拟分人数'),
placed: z.number().int(),
/// ⚠️ 分不下去的**不摊派**给已满的人:硬塞是 T5 的反面,而且会立刻造出 over_capacity 退回,
/// 而那正是要用来反推容量默认值的信号 —— 自己造出来就没法反推了
unplaced: z.number().int(),
items: z.array(ProposedItemSchema),
byAgent: z.array(ProposalAgentRowSchema),
/// 已达容量、本批跳过的人。**仍然列出来** —— 主管要看见"他不是被漏了,是已经满了"
skippedAgents: z.array(
z.object({ userId: z.string(), name: z.string().nullable(), inHand: z.number().int() }),
),
rosterNote: z.string(),
capacityNote: z.string(),
selectionNote: z.string(),
});
export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>;
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