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 { ...@@ -446,6 +446,12 @@ model PatientReturnVisit {
@@index([patientId, taskDate(sort: Desc)]) @@index([patientId, taskDate(sort: Desc)])
/// 客服名册查询: (诊所, 客服) 聚合 + taskDate 卡时间窗 /// 客服名册查询: (诊所, 客服) 聚合 + taskDate 卡时间窗
@@index([hostId, tenantId, clinicId, taskDirectorId, taskDate(sort: Desc)]) @@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") @@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'; ...@@ -6,6 +6,7 @@ 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 { buildSystemExtra } from './assistant-prompts';
/// multer 内存模式的最小文件形状(不引 @types/multer) /// multer 内存模式的最小文件形状(不引 @types/multer)
interface UploadedAudio { interface UploadedAudio {
...@@ -154,6 +155,13 @@ export class AssistantController { ...@@ -154,6 +155,13 @@ export class AssistantController {
// 只用于 MCP 工具清单的缓存分桶(见 mcpCapabilityKey)—— // 只用于 MCP 工具清单的缓存分桶(见 mcpCapabilityKey)——
// 不传的话主管和客服会共用一份缓存,谁先进来谁的清单被全员复用。 // 不传的话主管和客服会共用一份缓存,谁先进来谁的清单被全员复用。
permissions: user.permissions, permissions: user.permissions,
// ⭐ 按能力切工作流约束(主管 / 客服两套)。
// ⚠️ 在此之前这个参数**从来没被 /assistant/chat 传过** ——
// 「一个助手,按角色切换工作流约束」这条既定取舍此前是零实现。
systemExtra: buildSystemExtra({
permissions: user.permissions,
userName: user.dictionary?.users?.[user.sub] ?? null,
}),
abortSignal: ac.signal, abortSignal: ac.signal,
}); });
......
...@@ -8,6 +8,14 @@ import { OrgTreeService } from '../auth/org-tree'; ...@@ -8,6 +8,14 @@ import { OrgTreeService } from '../auth/org-tree';
export interface McpAuthContext { export interface McpAuthContext {
scope: TenantScopeContext; scope: TenantScopeContext;
permissions: string[]; permissions: string[];
/**
* 登录人姓名(可空)。
* ⚠️ 加它**不违 T19**:T19 说的是"权限判定只认 permission,不下传 role" ——
* 姓名是**显示串**不是判据,助手要能说"张主管,这批要分给…"而不是对着一个 uuid 说话。
* ⛔ 但绝不要顺手把 role 也带下来:一旦模型看得见 role,它就会自己发明
* "leader 应该也能 X" 这类规则,而那不是权限模型说了算的。
*/
userName?: string;
} }
/** /**
...@@ -55,6 +63,14 @@ export class McpAuthService { ...@@ -55,6 +63,14 @@ export class McpAuthService {
// MCP 这条路尤其要现算:工具清单是按能力**条件注册**的,权限少一个 = 助手手里少几个工具, // MCP 这条路尤其要现算:工具清单是按能力**条件注册**的,权限少一个 = 助手手里少几个工具,
// 模型只会说"我没有这个能力",既不报错也看不出是 token 过期。 // 模型只会说"我没有这个能力",既不报错也看不出是 token 过期。
permissions: ROLE_PERMISSIONS[payload.role] ?? payload.permissions ?? [], 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;
}
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;
}
...@@ -8,11 +8,13 @@ import type { TenantScopeContext } from '../../common/decorators/tenant-scope.de ...@@ -8,11 +8,13 @@ import type { TenantScopeContext } from '../../common/decorators/tenant-scope.de
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../../common/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../../common/decorators/current-user.decorator';
import { PlanAssignmentService } from './plan-assignment.service'; import { PlanAssignmentService } from './plan-assignment.service';
import { AgentRosterService } from './agent-roster.service';
import { import {
CreateAssignmentRequestDto, CreateAssignmentRequestDto,
CreateAssignmentResponseDto, CreateAssignmentResponseDto,
ListAssignmentsResponseDto, ListAssignmentsResponseDto,
AssignmentDetailResponseDto, AssignmentDetailResponseDto,
ListAgentsResponseDto,
} from './dto/plan-assignment.dto'; } from './dto/plan-assignment.dto';
/** /**
...@@ -30,7 +32,35 @@ import { ...@@ -30,7 +32,35 @@ import {
@ApiBearerAuth('accessToken') @ApiBearerAuth('accessToken')
@Controller('plans/assignments') @Controller('plans/assignments')
export class AssignmentController { 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() @Post()
@RequirePermission(Permission.PLAN_DISPATCH) @RequirePermission(Permission.PLAN_DISPATCH)
......
...@@ -4,9 +4,11 @@ import { ...@@ -4,9 +4,11 @@ import {
CreateAssignmentRequestSchema, CreateAssignmentRequestSchema,
CreateAssignmentResponseSchema, CreateAssignmentResponseSchema,
ListAssignmentsResponseSchema, ListAssignmentsResponseSchema,
ListAgentsResponseSchema,
} from '@pac/types'; } from '@pac/types';
export class CreateAssignmentRequestDto extends createZodDto(CreateAssignmentRequestSchema) {} export class CreateAssignmentRequestDto extends createZodDto(CreateAssignmentRequestSchema) {}
export class CreateAssignmentResponseDto extends createZodDto(CreateAssignmentResponseSchema) {} export class CreateAssignmentResponseDto extends createZodDto(CreateAssignmentResponseSchema) {}
export class ListAssignmentsResponseDto extends createZodDto(ListAssignmentsResponseSchema) {} export class ListAssignmentsResponseDto extends createZodDto(ListAssignmentsResponseSchema) {}
export class AssignmentDetailResponseDto extends createZodDto(AssignmentDetailResponseSchema) {} export class AssignmentDetailResponseDto extends createZodDto(AssignmentDetailResponseSchema) {}
export class ListAgentsResponseDto extends createZodDto(ListAgentsResponseSchema) {}
...@@ -3,6 +3,8 @@ import { PlanController } from './plan.controller'; ...@@ -3,6 +3,8 @@ import { PlanController } from './plan.controller';
import { PlanService } from './plan.service'; import { PlanService } from './plan.service';
import { AssignmentController } from './assignment.controller'; import { AssignmentController } from './assignment.controller';
import { PlanAssignmentService } from './plan-assignment.service'; import { PlanAssignmentService } from './plan-assignment.service';
import { AgentRosterService } from './agent-roster.service';
import { AssignmentProposalService } from './assignment-proposal.service';
import { ExecutionService } from './execution.service'; import { ExecutionService } from './execution.service';
import { ExecutionCallbackService } from './execution-callback.service'; import { ExecutionCallbackService } from './execution-callback.service';
import { RecycleSchedulerService } from './recycle-scheduler.service'; import { RecycleSchedulerService } from './recycle-scheduler.service';
...@@ -28,6 +30,8 @@ import { RecallDebugService } from './recall-debug/recall-debug.service'; ...@@ -28,6 +30,8 @@ import { RecallDebugService } from './recall-debug/recall-debug.service';
providers: [ providers: [
PlanService, PlanService,
PlanAssignmentService, PlanAssignmentService,
AgentRosterService,
AssignmentProposalService,
ExecutionService, ExecutionService,
ExecutionCallbackService, ExecutionCallbackService,
RecycleSchedulerService, RecycleSchedulerService,
...@@ -37,6 +41,7 @@ import { RecallDebugService } from './recall-debug/recall-debug.service'; ...@@ -37,6 +41,7 @@ import { RecallDebugService } from './recall-debug/recall-debug.service';
TreatmentInitiationRecallScenario, TreatmentInitiationRecallScenario,
RecallDebugService, RecallDebugService,
], ],
exports: [PlanService, ExecutionService, ExecutionCallbackService, PlanEngineService, ChainComposerService], // MCP 的主管工具直接用这两个 service(条件注册,见 mcp-server.factory)
exports: [PlanService, PlanAssignmentService, AgentRosterService, AssignmentProposalService, ExecutionService, ExecutionCallbackService, PlanEngineService, ChainComposerService],
}) })
export class PlanModule {} 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({ ...@@ -67,6 +67,55 @@ export const CreateAssignmentRequestSchema = z.object({
}); });
export type CreateAssignmentRequest = z.infer<typeof CreateAssignmentRequestSchema>; 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 猜 /// 没落上的单及原因 —— 助手照着这个说人话,别让主管对着 uuid 猜
export const AssignmentSkippedSchema = z.object({ export const AssignmentSkippedSchema = z.object({
planId: z.string(), planId: z.string(),
...@@ -146,3 +195,54 @@ export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({ ...@@ -146,3 +195,54 @@ export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({
untouched: z.number().int().describe('分下去后客服从未打开过详情页的条数'), untouched: z.number().int().describe('分下去后客服从未打开过详情页的条数'),
}); });
export type AssignmentDetailResponse = z.infer<typeof AssignmentDetailResponseSchema>; 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