Commit 427202a6 by luoqi

fix(assistant): support exact patient assignment and live sheet receipts

parent f9716ee1
Pipeline #3867 failed in 0 seconds
...@@ -123,7 +123,7 @@ import { Permission } from '@pac/types'; ...@@ -123,7 +123,7 @@ import { Permission } from '@pac/types';
*/ */
// ⚠️ `-b` 是 2026-08-17 那次「纪律挂在组上」的实验,已 revert(`git show 82f357e`)—— // ⚠️ `-b` 是 2026-08-17 那次「纪律挂在组上」的实验,已 revert(`git show 82f357e`)——
// ⛔ 别复用它:库里有那一版的 `agent_invocations` 行,复用等于两份不同正文同名。 // ⛔ 别复用它:库里有那一版的 `agent_invocations` 行,复用等于两份不同正文同名。
export const ASSISTANT_PROMPT_VERSION = 'assistant@2026-08-22-a'; export const ASSISTANT_PROMPT_VERSION = 'assistant@2026-10-23-sheet-receipts';
/** /**
* ① 装置 —— 你是谁、和使用者什么关系、你看不见什么。 * ① 装置 —— 你是谁、和使用者什么关系、你看不见什么。
......
import { SheetBridgeService } from './sheet-bridge.service';
import { Body, Controller, Logger, Post, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common'; import { Body, Controller, Logger, Post, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
...@@ -53,6 +54,7 @@ interface ChatBody { ...@@ -53,6 +54,7 @@ interface ChatBody {
* ⚠️ 用 zod 现场解析而不是直接透传:字段少一个模型就会读到 `undefined` 并当成 0。 * ⚠️ 用 zod 现场解析而不是直接透传:字段少一个模型就会读到 `undefined` 并当成 0。
*/ */
sheetState?: unknown; sheetState?: unknown;
sheetBridge?: boolean;
/** /**
* 详情页当前开着的那位患者 —— 与 `activeClinicId` 同一条旁路。 * 详情页当前开着的那位患者 —— 与 `activeClinicId` 同一条旁路。
* *
...@@ -117,6 +119,7 @@ export class AssistantController { ...@@ -117,6 +119,7 @@ export class AssistantController {
constructor( constructor(
private readonly assistant: AssistantService, private readonly assistant: AssistantService,
private readonly sheetBridge: SheetBridgeService,
private readonly transcriber: TranscribeService, private readonly transcriber: TranscribeService,
) {} ) {}
...@@ -167,6 +170,13 @@ export class AssistantController { ...@@ -167,6 +170,13 @@ export class AssistantController {
} }
} }
@Post('sheet-receipt')
@ApiOperation({ summary: '回传当前用户本轮确认单执行结果' })
sheetReceipt(@TenantScope() scope: TenantScopeContext, @Body() body: { commandId?: string; snapshot?: unknown; message?: string }) {
this.sheetBridge.receive(scope, body.commandId ?? '', body);
return { received: true };
}
@Post('chat') @Post('chat')
@ApiOperation({ summary: '助手对话(SSE)— 模型自主调 PAC MCP 工具' }) @ApiOperation({ summary: '助手对话(SSE)— 模型自主调 PAC MCP 工具' })
async chat( async chat(
...@@ -188,7 +198,7 @@ export class AssistantController { ...@@ -188,7 +198,7 @@ export class AssistantController {
}; };
const ac = new AbortController(); const ac = new AbortController();
req.on('close', () => ac.abort()); res.on('close', () => ac.abort());
// 流式 artifact:render_artifact 的入参(HTML)逐字生成,边生成边推 → 前端实时"长出"卡片, // 流式 artifact:render_artifact 的入参(HTML)逐字生成,边生成边推 → 前端实时"长出"卡片,
// 不必等整段(~30s)生成完。按 toolCallId 累积 JSON 入参,增量提取 html 字段,250ms 节流发送。 // 不必等整段(~30s)生成完。按 toolCallId 累积 JSON 入参,增量提取 html 字段,250ms 节流发送。
...@@ -206,6 +216,9 @@ export class AssistantController { ...@@ -206,6 +216,9 @@ export class AssistantController {
permissions: user.permissions, permissions: user.permissions,
// ⭐ 侧信道:本地工具把确认单肥载荷直接推前端,不经模型上下文(见 AssistantChatInput) // ⭐ 侧信道:本地工具把确认单肥载荷直接推前端,不经模型上下文(见 AssistantChatInput)
onSideEvent: send, onSideEvent: send,
...(body.sheetBridge === true ? {
sheetCommand: (command: import('./sheet-bridge.service').SheetCommand) => this.sheetBridge.request(scope, command, send, ac.signal),
} : {}),
scope, scope,
// ⭐ 角色层与现场层由服务端按权限装配(见 assistant-prompts 的分层)—— // ⭐ 角色层与现场层由服务端按权限装配(见 assistant-prompts 的分层)——
// ⚠️ 控制器只**供料**,⛔ 不在这里拼提示词:企微那条路也走同一个装配。 // ⚠️ 控制器只**供料**,⛔ 不在这里拼提示词:企微那条路也走同一个装配。
......
import { SheetBridgeService } from './sheet-bridge.service';
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';
...@@ -20,7 +21,7 @@ import { DictationGateway } from './dictation.gateway'; ...@@ -20,7 +21,7 @@ import { DictationGateway } from './dictation.gateway';
imports: [AiModule, AuthModule, PlanModule], imports: [AiModule, AuthModule, PlanModule],
// 🔬 AssistantLabController = 裸 agent 试验台,⛔ 与产品助手无代码关联(见该文件头) // 🔬 AssistantLabController = 裸 agent 试验台,⛔ 与产品助手无代码关联(见该文件头)
controllers: [AssistantController, AssistantLabController], controllers: [AssistantController, AssistantLabController],
providers: [AssistantService, McpClientService, TranscribeService, DictationGateway], providers: [SheetBridgeService, AssistantService, McpClientService, TranscribeService, DictationGateway],
exports: [AssistantService], // 供 WeixinAibotModule 等其它入口复用同一个助手大脑 exports: [AssistantService], // 供 WeixinAibotModule 等其它入口复用同一个助手大脑
}) })
export class AssistantModule {} export class AssistantModule {}
import type { SheetCommand, SheetReceipt } from './sheet-bridge.service';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai'; import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai';
...@@ -10,6 +11,7 @@ import { ...@@ -10,6 +11,7 @@ import {
TEMPERATURE_TOOL_DESC, TEMPERATURE_TOOL_DESC,
TEMPERATURE_TOOL_VALUES, TEMPERATURE_TOOL_VALUES,
type SheetSnapshot, type SheetSnapshot,
type SheetEditOp,
type TemperatureValue, type TemperatureValue,
} from '@pac/types'; } from '@pac/types';
import { AiProviderService } from '../ai/core/ai-provider.service'; import { AiProviderService } from '../ai/core/ai-provider.service';
...@@ -138,6 +140,7 @@ export interface AssistantChatInput { ...@@ -138,6 +140,7 @@ export interface AssistantChatInput {
* 见 `SheetSnapshotSchema`)。⚠️ 每次请求现取现发,⛔ 这里不许缓存。 * 见 `SheetSnapshotSchema`)。⚠️ 每次请求现取现发,⛔ 这里不许缓存。
*/ */
sheetState?: SheetSnapshot; sheetState?: SheetSnapshot;
sheetCommand?: (command: SheetCommand) => Promise<SheetReceipt>;
/** /**
* 详情页当前开着的那位患者 —— **只作指代的默认落点**,⛔ 不注入工具参数 * 详情页当前开着的那位患者 —— **只作指代的默认落点**,⛔ 不注入工具参数
* (注入等于堵死"查另一位",而他随时会问别人)。 * (注入等于堵死"查另一位",而他随时会问别人)。
...@@ -299,6 +302,13 @@ export class AssistantService { ...@@ -299,6 +302,13 @@ export class AssistantService {
let guidanceKeys: string[] = []; let guidanceKeys: string[] = [];
/// 本轮出过确认单没有 —— show_sheet 只有在出过之后才有意义 /// 本轮出过确认单没有 —— show_sheet 只有在出过之后才有意义
let sheetPushed = false; let sheetPushed = false;
let currentRequestId = input.sheetState?.requestId;
const readSheet = async () => {
if (!input.sheetCommand) return { snapshot: null, message: '当前客户端不支持实时回执,请刷新页面后重试' };
const receipt = await input.sheetCommand({ kind: 'read', requestId: currentRequestId });
if (receipt.snapshot) currentRequestId = receipt.snapshot.requestId;
return receipt;
};
tools.propose_assignment = tool({ tools.propose_assignment = tool({
/** /**
* 🔴 2026-08-14 按裸台验过的写法重写。删掉的五句各有去处 —— ⛔ 别抄回来: * 🔴 2026-08-14 按裸台验过的写法重写。删掉的五句各有去处 —— ⛔ 别抄回来:
...@@ -386,6 +396,13 @@ export class AssistantService { ...@@ -386,6 +396,13 @@ export class AssistantService {
* ⛔ 要恢复"按诊所选",正解是给一个**带名字的 enum**(取值域 = 他有权限的那几家), * ⛔ 要恢复"按诊所选",正解是给一个**带名字的 enum**(取值域 = 他有权限的那几家),
* ⛔ 不是把这个裸 id 参数放回来。 * ⛔ 不是把这个裸 id 参数放回来。
*/ */
patientIds: {
type: 'array', items: { type: 'string', format: 'uuid' }, minItems: 1, maxItems: 100,
description: '指定患者的精确 ID,从患者查询结果取得。点名分配时使用,只选择这些患者,不按人数或人群条件替代。未传时按原批量规则选人。',
},
assignee: {
type: 'string', description: '明确指定的接收人姓名;“我”表示当前登录人。仅与 patientIds 一起使用,不调整其他客服额度。',
},
potentialTreatment: { potentialTreatment: {
type: 'string', type: 'string',
description: POTENTIAL_TREATMENT_DESC, description: POTENTIAL_TREATMENT_DESC,
...@@ -519,6 +536,8 @@ export class AssistantService { ...@@ -519,6 +536,8 @@ export class AssistantService {
}), }),
execute: async (args: unknown) => { execute: async (args: unknown) => {
const a = (args ?? {}) as { const a = (args ?? {}) as {
patientIds?: string[];
assignee?: string;
potentialTreatment?: string; potentialTreatment?: string;
temperature?: TemperatureValue; temperature?: TemperatureValue;
personaTags?: string; personaTags?: string;
...@@ -535,17 +554,32 @@ export class AssistantService { ...@@ -535,17 +554,32 @@ export class AssistantService {
if (!clinicId) { if (!clinicId) {
return '当前登录人没有绑定诊所,无法出确认单 —— 请主管确认数据范围。'; return '当前登录人没有绑定诊所,无法出确认单 —— 请主管确认数据范围。';
} }
const sheet = await this.proposals.propose(scope, { ...a, clinicId }); if (a.assignee && !a.patientIds?.length) return '指定接收人时请同时提供指定患者';
let assigneeUserId: string | undefined;
if (a.assignee) {
if (a.assignee.trim() === '我') assigneeUserId = scope.userId;
else {
const roster = await this.roster.list(scope, clinicId, { extraUserIds: [scope.userId] });
const matches = roster.agents.filter(x => x.name === a.assignee!.trim());
if (matches.length !== 1) return '接收人未找到或存在同名,本次未生成确认单';
assigneeUserId = matches[0]!.userId;
}
}
const sheet = await this.proposals.propose(scope, a.patientIds
? { clinicId, patientIds: a.patientIds, assigneeUserId, expiresInDays: a.expiresInDays }
: { ...a, clinicId });
// ⭐ 肥载荷走侧信道;requestId 由**服务端**在这里铸造(D-2)—— // ⭐ 肥载荷走侧信道;requestId 由**服务端**在这里铸造(D-2)——
// 前端确认时原样回传,这样重连/重试都命中同一个幂等键。 // 前端确认时原样回传,这样重连/重试都命中同一个幂等键。
const requestId = `pa_${scope.userId}_${randomUUID()}`; const requestId = `pa_${scope.userId}_${randomUUID()}`;
push({ type: 'assignment_sheet', requestId, sheet }); push({ type: 'assignment_sheet', requestId, sheet });
currentRequestId = requestId;
// 记下这一版有哪几类,供 show_guidance 校验 + 解析成 signalKey(见上面的闭包注释) // 记下这一版有哪几类,供 show_guidance 校验 + 解析成 signalKey(见上面的闭包注释)
// ⚠️ 过滤条件必须与 `modelFacts` 的 `pick` **一致**:只有 action 层进模型上下文。 // ⚠️ 过滤条件必须与 `modelFacts` 的 `pick` **一致**:只有 action 层进模型上下文。
const actions = sheet.signals.filter((s) => s.tier === 'action'); const actions = sheet.signals.filter((s) => s.tier === 'action');
guidanceKinds = actions.map((s) => GUIDANCE_KIND[s.key] ?? s.key); guidanceKinds = actions.map((s) => GUIDANCE_KIND[s.key] ?? s.key);
guidanceKeys = actions.map((s) => s.key); guidanceKeys = actions.map((s) => s.key);
sheetPushed = true; sheetPushed = true;
const receipt = await readSheet();
/** /**
* ⭐ 回给模型的是**结构化事实**,⛔ 不再是成品句子(P2,2026-08-12)。 * ⭐ 回给模型的是**结构化事实**,⛔ 不再是成品句子(P2,2026-08-12)。
* *
...@@ -562,7 +596,9 @@ export class AssistantService { ...@@ -562,7 +596,9 @@ export class AssistantService {
*/ */
return JSON.stringify( return JSON.stringify(
{ {
确认单已呈现: true, 确认单已呈现: receipt.snapshot != null,
回执: receipt.message,
...(receipt.snapshot ? { 当前确认单: sheetSnapshotFacts(receipt.snapshot) } : {}),
/** /**
* ⚠️ 事实,不是叮嘱:主管点确认之前一条都没真的分下去。 * ⚠️ 事实,不是叮嘱:主管点确认之前一条都没真的分下去。
* *
...@@ -573,7 +609,7 @@ export class AssistantService { ...@@ -573,7 +609,7 @@ export class AssistantService {
* ⚠️ 措辞与提示词里那句「这一下才真的分下去」对齐 —— ⛔ 同一件事别起两个说法。 * ⚠️ 措辞与提示词里那句「这一下才真的分下去」对齐 —— ⛔ 同一件事别起两个说法。
*/ */
已经分下去了: false, 已经分下去了: false,
...modelFacts(sheet), ...(a.patientIds ? { 选择方式: '指定患者', 已排好: sheet.items.length } : modelFacts(sheet)),
}, },
null, null,
2, 2,
...@@ -779,27 +815,27 @@ export class AssistantService { ...@@ -779,27 +815,27 @@ export class AssistantService {
* 它连累加的材料都没有 —— 卡片上写着 3 天,它嘴上还说 1 天。 * 它连累加的材料都没有 —— 卡片上写着 3 天,它嘴上还说 1 天。
* ⇒ 现在两条一起补:回声带上当前值(推),这个工具随时能取(拉)。 * ⇒ 现在两条一起补:回声带上当前值(推),这个工具随时能取(拉)。
* *
* ⚠️ 它查的是**随本次请求捎来的那份快照**,⛔ 不是服务端的什么状态 —— * 每次通过本轮回执通道读取浏览器最新状态;回执不写业务表,不替代确认分配。
* 服务端没有这个东西,也⛔ 不许为它去开一条"草稿回传"的写路径。 * 只有现役那张单可查;明细可按患者或分页读取。⛔ 别加 requestId 之类的参数,
* ⚠️ 无参数:只有一张单可查(现役那张)。⛔ 别加 requestId 之类的参数,
* 模型没有办法知道该填哪个(同 `propose_assignment.clinicId` 被删掉那次的判断)。 * 模型没有办法知道该填哪个(同 `propose_assignment.clinicId` 被删掉那次的判断)。
*/ */
tools.get_current_sheet = tool({ tools.get_current_sheet = tool({
description: description:
'看他眼前那张确认单**现在**什么样:已排好几条、落在几位客服头上、' + '看他眼前那张确认单**现在**什么样:已排好几条、落在几位客服头上、' +
'还有几个待他定、时限几天、带没带福利。' + '患者姓名、病历号和当前客服归属,以及待分配、移出、时限和福利。' +
'\n什么时候调:他在卡片上动过手(拖人、移出、改时限、改福利)之后,' + '\n什么时候调:他在卡片上动过手(拖人、移出、改时限、改福利)之后,' +
'你要报数、要判断"还差什么"、或者他问"现在什么情况",先调它。' + '你要报数、要判断"还差什么"、或者他问"现在什么情况",先调它。' +
'\n⚠️ 卡片上的改动**你看不见** —— ⛔ 别拿出方案那一版的数去算现在的数。' + '\n⚠️ 卡片上的改动**你看不见** —— ⛔ 别拿出方案那一版的数去算现在的数。' +
'\n⛔ 它不改任何东西,只是看一眼。', '\n⛔ 它不改任何东西,只是看一眼。',
inputSchema: jsonSchema({ type: 'object', properties: {} }), inputSchema: jsonSchema({ type: 'object', properties: {
execute: async () => { patient: { type: 'string', description: '只查指定患者的姓名或病历号,精确匹配;不传读取名单。' },
offset: { type: 'integer', minimum: 0, description: '名单起点,默认 0;继续读取用返回的下一页起点。' },
limit: { type: 'integer', minimum: 1, maximum: 100, description: '本次明细条数,默认 50,最多 100。概览仍为整单。' },
} }),
execute: async (args: unknown) => {
// ⚠️ 没有 = **他手上根本没有确认单**,⛔ 别回一个空壳让模型以为是"全 0 的单" // ⚠️ 没有 = **他手上根本没有确认单**,⛔ 别回一个空壳让模型以为是"全 0 的单"
if (!input.sheetState) { const receipt = await readSheet();
return '他手上现在没有确认单 —— 还没出过,或者那一版已经作废了。'; return JSON.stringify({ 回执: receipt.message, 当前确认单: receipt.snapshot ? sheetSnapshotFacts(receipt.snapshot, (args ?? {}) as { patient?: string; offset?: number; limit?: number }) : null });
}
this.logger.log('工具调用:get_current_sheet');
return JSON.stringify(sheetSnapshotFacts(input.sheetState), null, 2);
}, },
}); });
...@@ -815,9 +851,7 @@ export class AssistantService { ...@@ -815,9 +851,7 @@ export class AssistantService {
'\n患者用姓名或病历号指代,客服用姓名 —— 匹配由界面拿它手里那份单子完成。' + '\n患者用姓名或病历号指代,客服用姓名 —— 匹配由界面拿它手里那份单子完成。' +
// ⚠️ 这里说的是**先后**不是**位置**:位置("上面")是他屏幕上的事,你看不见, // ⚠️ 这里说的是**先后**不是**位置**:位置("上面")是他屏幕上的事,你看不见,
// 写成位置等于教模型去描述界面 —— 而 base 里明令 ⛔ 别描述界面位置。 // 写成位置等于教模型去描述界面 —— 而 base 里明令 ⛔ 别描述界面位置。
'\n⚠️ 改完**什么都不用补**:界面会把结果直接发进对话,而且**先于你这段话**到他那儿。' + '\n工具等待界面执行后返回真实回执和当前患者归属;未匹配、部分执行或未收到回执都不能当成成功。' +
'⛔ 别说"已完成"(成没成只有界面知道),也⛔ 别说"等结果再告诉您" ——' +
'他读到那句时结果早就在了,顺序是倒的。' +
'\n⚠️ 批次**已经确认分配**之后:只有 `set_benefit` 还能改(福利只影响此后生成的话术,' + '\n⚠️ 批次**已经确认分配**之后:只有 `set_benefit` 还能改(福利只影响此后生成的话术,' +
'界面会真的改并回报"有几条已经写好的话术作废了 / 几条客服已经打开过");' + '界面会真的改并回报"有几条已经写好的话术作废了 / 几条客服已经打开过");' +
'人员和时限**在这张单上改不了** —— 单子已经在客服手上。' + '人员和时限**在这张单上改不了** —— 单子已经在客服手上。' +
...@@ -931,17 +965,10 @@ export class AssistantService { ...@@ -931,17 +965,10 @@ export class AssistantService {
if (!Array.isArray(ops) || ops.length === 0) return '没有给出任何操作,确认单未改动。'; if (!Array.isArray(ops) || ops.length === 0) return '没有给出任何操作,确认单未改动。';
const bad = checkEditOps(ops, SheetEditOpSchema); const bad = checkEditOps(ops, SheetEditOpSchema);
if (bad) return bad; if (bad) return bad;
push({ type: 'assignment_sheet_edit', ops }); if (!input.sheetCommand) return '当前客户端不支持真实回执,确认单未修改;请刷新页面后重试';
/** const receipt = await input.sheetCommand({ kind: 'edit', requestId: currentRequestId, ops: ops as SheetEditOp[] });
* ⚠️ 只回**事实**,⛔ 一个字的指令都不许有。 if (receipt.snapshot) currentRequestId = receipt.snapshot.requestId;
* return JSON.stringify({ 回执: receipt.message, 当前确认单: receipt.snapshot ? sheetSnapshotFacts(receipt.snapshot) : null });
* 🔴 2026-08-13:原来这里写着「请等那句结果,⛔ 不要自己宣布已完成」——
* 模型把它当话说了出去:「已把这条修改交给界面执行,等它回报结果再告诉您。」
* 而界面的结果**就在那句话上面一行**(它是同步注入的),读起来是倒的:
* 结果已经出来了,助手却还在说"等结果"。
* ⇒ 指令归工具描述,返回值只留事实(这条规矩 2026-08-08 就定了,这里漏网)。
*/
return JSON.stringify({ 已交给界面执行: ops.length, 界面已把结果发进对话: true });
}, },
}); });
...@@ -1194,7 +1221,7 @@ export class AssistantService { ...@@ -1194,7 +1221,7 @@ export class AssistantService {
// 2. resolve provider(可切换)+ streamText 跑 model-driven tool-calling 循环 // 2. resolve provider(可切换)+ streamText 跑 model-driven tool-calling 循环
const resolved = this.provider.resolve(input.modelId ?? 'deepseek'); const resolved = this.provider.resolve(input.modelId ?? 'deepseek');
/** /**
* ⚠️ 日志打**解析后的型号**(`deepseek-v4-flash`),⛔ 不是逻辑键(`deepseek`)。 * ⚠️ 日志打**解析后的型号**(`deepseek-flash`),⛔ 不是逻辑键(`deepseek`)。
* 裸键会被 `AI_DEFAULT_MODEL` 解析成具体型号,只打裸键的话 * 裸键会被 `AI_DEFAULT_MODEL` 解析成具体型号,只打裸键的话
* 「这一轮到底跑的哪个模型」从日志里根本答不出来 —— 实测被这个问住过。 * 「这一轮到底跑的哪个模型」从日志里根本答不出来 —— 实测被这个问住过。
*/ */
...@@ -1211,6 +1238,9 @@ export class AssistantService { ...@@ -1211,6 +1238,9 @@ export class AssistantService {
* ⚠️ `scope` 缺失(比如某些内部入口)就整条跳过 —— hostId/tenantId 是必填列。 * ⚠️ `scope` 缺失(比如某些内部入口)就整条跳过 —— hostId/tenantId 是必填列。
*/ */
const toolTrace: ToolTraceEntry[] = []; const toolTrace: ToolTraceEntry[] = [];
// 同一轮的出单、改单、读单串行,防止模型并行调用读到前一版。
let sheetQueue: Promise<unknown> = Promise.resolve();
const sheetTools = new Set(['propose_assignment', 'edit_assignment_sheet', 'get_current_sheet']);
// 统一给**所有**工具(MCP 拉来的 + 本地那几个)套一层计时留痕。 // 统一给**所有**工具(MCP 拉来的 + 本地那几个)套一层计时留痕。
// ⚠️ 装在这里而不是各自 execute 里:漏一个就等于那个工具永远不出现在验收数据里。 // ⚠️ 装在这里而不是各自 execute 里:漏一个就等于那个工具永远不出现在验收数据里。
for (const name of Object.keys(tools)) { for (const name of Object.keys(tools)) {
...@@ -1223,7 +1253,9 @@ export class AssistantService { ...@@ -1223,7 +1253,9 @@ export class AssistantService {
t.execute = async (args: any, opts: any) => { t.execute = async (args: any, opts: any) => {
const t0 = Date.now(); const t0 = Date.now();
try { try {
const r = await orig(args, opts); const operation = sheetTools.has(name) ? sheetQueue.then(() => orig(args, opts)) : orig(args, opts);
if (sheetTools.has(name)) sheetQueue = Promise.resolve(operation).catch(() => undefined);
const r = await operation;
toolTrace.push({ name, ok: true, ms: Date.now() - t0, args: briefToolArgs(args) }); toolTrace.push({ name, ok: true, ms: Date.now() - t0, args: briefToolArgs(args) });
return r; return r;
} catch (e) { } catch (e) {
......
import { Injectable, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { SheetSnapshotSchema, type SheetSnapshot, type SheetEditOp } from '@pac/types';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
export interface SheetCommand { kind: 'read' | 'edit'; requestId?: string; ops?: SheetEditOp[] }
export interface SheetReceipt { snapshot: SheetSnapshot | null; message: string }
/** 回执仅用于本轮工具核验,不是分配授权,也不写业务数据。 */
@Injectable()
export class SheetBridgeService {
private readonly pending = new Map<string, { owner: string; requestId?: string; finish: (r: SheetReceipt) => void }>();
private owner(s: TenantScopeContext): string {
return JSON.stringify([s.hostId, s.tenantId, s.userId, [...s.clinicIds].sort(), [...s.sourceUnits].sort()]);
}
request(scope: TenantScopeContext, command: SheetCommand, send: (e: Record<string, unknown>) => void,
signal?: AbortSignal, timeoutMs = 12000): Promise<SheetReceipt> {
if (signal?.aborted) return Promise.resolve({ snapshot: null, message: '连接已断开,执行结果未确认' });
const commandId = randomUUID();
return new Promise(resolve => {
const finish = (r: SheetReceipt) => {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
this.pending.delete(commandId);
resolve(r);
};
const abort = () => finish({ snapshot: null, message: '连接已断开,执行结果未确认;重新读取确认单后再操作' });
const timer = setTimeout(() => finish({ snapshot: null, message: '未收到界面回执,执行结果未知;重新读取确认单后再操作' }), timeoutMs);
this.pending.set(commandId, { owner: this.owner(scope), requestId: command.requestId, finish });
signal?.addEventListener('abort', abort, { once: true });
try { send({ type: 'assignment_sheet_command', commandId, ...command }); }
catch { abort(); }
});
}
receive(scope: TenantScopeContext, id: string, raw: unknown): void {
const pending = this.pending.get(id);
if (!pending || pending.owner !== this.owner(scope)) throw new NotFoundException('回执已过期或不属于当前会话');
const body = raw as { snapshot?: unknown; message?: unknown };
const parsed = SheetSnapshotSchema.safeParse(body?.snapshot);
if (parsed.success && pending.requestId && parsed.data.requestId !== pending.requestId) {
pending.finish({ snapshot: null, message: '确认单版本不一致,执行结果未确认' });
return;
}
pending.finish({ snapshot: parsed.success ? parsed.data : null,
message: typeof body?.message === 'string' ? body.message.slice(0, 12000) : '未提供执行说明' });
}
}
...@@ -32,7 +32,7 @@ import { GUIDANCE_KIND } from './assignment-signals'; ...@@ -32,7 +32,7 @@ import { GUIDANCE_KIND } from './assignment-signals';
* ⚠️ **例外一条、且只有一条**:每条引导上的 `开放方式`(见 `GUIDANCE_HOWTO`)。 * ⚠️ **例外一条、且只有一条**:每条引导上的 `开放方式`(见 `GUIDANCE_HOWTO`)。
* 2026-08-17 产品实测定的,破例的理由和它的边界都写在那个常量上。 * 2026-08-17 产品实测定的,破例的理由和它的边界都写在那个常量上。
* ⛔ 别照着它再开第二处 —— 一个"有过先例"的例外会变成没有边界。 * ⛔ 别照着它再开第二处 —— 一个"有过先例"的例外会变成没有边界。
* ⛔ 不含任何 planId / 患者姓名 —— 那些走侧信道给界面,模型手里不该有。 * 批量概览不含患者明细;确认单核验工具按需要提供患者及归属。
* ⚠️ 键名用中文:模型照着键名说就是对的,省掉一轮"把 inHandPending 翻译成人话"。 * ⚠️ 键名用中文:模型照着键名说就是对的,省掉一轮"把 inHandPending 翻译成人话"。
*/ */
...@@ -264,8 +264,16 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> { ...@@ -264,8 +264,16 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> {
* 前者是"都处置完了",后者是"不知道"。⛔ 别学别处的 `...(x ? {} : {})` 省略法。 * 前者是"都处置完了",后者是"不知道"。⛔ 别学别处的 `...(x ? {} : {})` 省略法。
* ⚠️ `已经分下去了` 与 `propose_assignment` 返回值里那个键**同名同义**,⛔ 别起第二个说法。 * ⚠️ `已经分下去了` 与 `propose_assignment` 返回值里那个键**同名同义**,⛔ 别起第二个说法。
*/ */
export function sheetSnapshotFacts(s: SheetSnapshot): Record<string, unknown> { export function sheetSnapshotFacts(s: SheetSnapshot, query: { patient?: string; offset?: number; limit?: number } = {}): Record<string, unknown> {
const patient = query.patient?.trim();
const patients = s.patients?.filter(p => !patient || p.name === patient || p.medicalRecordNumber === patient);
const offset = Number.isInteger(query.offset) ? Math.max(0, query.offset!) : 0;
const limit = Number.isInteger(query.limit) ? Math.min(100, Math.max(1, query.limit!)) : 50;
return { return {
确认单版本: s.requestId,
患者明细总数: patients?.length ?? null,
下一页起点: patients && offset + limit < patients.length ? offset + limit : null,
患者明细: patients?.slice(offset, offset + limit).map(p => ({ 姓名: p.name, 病历号: p.medicalRecordNumber, 客服: p.assigneeName, 状态: p.status })) ?? null,
/** /**
* ⭐ **人群条件排在最前** —— 「这批人是谁」是重算的前提,⛔ 不能让它排在 * ⭐ **人群条件排在最前** —— 「这批人是谁」是重算的前提,⛔ 不能让它排在
* 一堆分配结果后面:模型读到「改成 200 人」时第一件要确认的就是这一格是什么。 * 一堆分配结果后面:模型读到「改成 200 人」时第一件要确认的就是这一格是什么。
......
...@@ -126,6 +126,9 @@ export class AssignmentProposalService { ...@@ -126,6 +126,9 @@ export class AssignmentProposalService {
scope: TenantScopeContext, scope: TenantScopeContext,
input: { input: {
clinicId: string; clinicId: string;
/** 精确选择患者;接收人可指定,不影响原批量规则。 */
patientIds?: string[];
assigneeUserId?: string;
/** 画像「潜在治疗」8 类之一(矩阵 X 轴);不传 = 不按治疗项收窄 */ /** 画像「潜在治疗」8 类之一(矩阵 X 轴);不传 = 不按治疗项收窄 */
potentialTreatment?: string; potentialTreatment?: string;
/** 窗口温度(矩阵 Y 轴)。⚠️ 必须与 potentialTreatment 同时给 */ /** 窗口温度(矩阵 Y 轴)。⚠️ 必须与 potentialTreatment 同时给 */
...@@ -187,8 +190,14 @@ export class AssignmentProposalService { ...@@ -187,8 +190,14 @@ export class AssignmentProposalService {
*/ */
const clinicId = resolveClinicId(scope, input.clinicId); const clinicId = resolveClinicId(scope, input.clinicId);
const { potentialTreatment } = input; const { potentialTreatment } = input;
const patientIds = input.patientIds ? [...new Set(input.patientIds)] : undefined;
if (patientIds && (!patientIds.length || patientIds.length > 100 || patientIds.some(id => !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)))) {
throw new Error('指定患者应为 1–100 个有效患者 ID');
}
if (input.assigneeUserId && !patientIds) throw new Error('指定接收人必须同时指定患者');
const criteria: CohortCriteria = { const criteria: CohortCriteria = {
clinicId, clinicId,
...(patientIds ? { patientIds } : {}),
...(potentialTreatment ? { potentialTreatment } : {}), ...(potentialTreatment ? { potentialTreatment } : {}),
...(input.temperature ? { temperature: input.temperature } : {}), ...(input.temperature ? { temperature: input.temperature } : {}),
...(input.personaTags ? { personaTags: input.personaTags } : {}), ...(input.personaTags ? { personaTags: input.personaTags } : {}),
...@@ -196,10 +205,13 @@ export class AssignmentProposalService { ...@@ -196,10 +205,13 @@ export class AssignmentProposalService {
}; };
assertCohortCriteria(criteria); assertCohortCriteria(criteria);
const [roster, baseline] = await Promise.all([ const [roster, baseline] = await Promise.all([
this.roster.list(scope, clinicId), this.roster.list(scope, clinicId, input.assigneeUserId === scope.userId ? { extraUserIds: [scope.userId] } : {}),
this.resolveBaseline(scope, clinicId), this.resolveBaseline(scope, clinicId),
]); ]);
const agents = roster.agents; const agents = roster.agents;
if (input.assigneeUserId && !agents.some(a => a.userId === input.assigneeUserId)) {
throw new Error('指定接收人不在当前诊所可分配名册中');
}
// ── 两个基数:本批人数 N + 时限 ────────────────────────── // ── 两个基数:本批人数 N + 时限 ──────────────────────────
// 优先级:本次明确指定 > 沿用上一次 > 首次默认。 // 优先级:本次明确指定 > 沿用上一次 > 首次默认。
...@@ -236,12 +248,12 @@ export class AssignmentProposalService { ...@@ -236,12 +248,12 @@ export class AssignmentProposalService {
input.dailyCalls != null input.dailyCalls != null
? Math.min(60, Math.max(1, Math.floor(input.dailyCalls))) ? Math.min(60, Math.max(1, Math.floor(input.dailyCalls)))
: DAILY_CALLS_PER_AGENT; : DAILY_CALLS_PER_AGENT;
const batchSize = const batchSize = patientIds ? patientIds.length :
input.targetCount != null input.targetCount != null
? Math.max(0, Math.floor(input.targetCount)) ? Math.max(0, Math.floor(input.targetCount))
: agents.length * dailyCalls * expiresInDays; : agents.length * dailyCalls * expiresInDays;
const basis: 'inherited' | 'default' | 'explicit' = const basis: 'inherited' | 'default' | 'explicit' =
input.targetCount != null || input.expiresInDays != null || input.dailyCalls != null patientIds || input.targetCount != null || input.expiresInDays != null || input.dailyCalls != null
? 'explicit' ? 'explicit'
: 'default'; : 'default';
// 按客服精调:本次传的压过沿用的(同一个人两处都有 → 本次赢);空对象 = 撤掉他的精调 // 按客服精调:本次传的压过沿用的(同一个人两处都有 → 本次赢);空对象 = 撤掉他的精调
...@@ -290,6 +302,9 @@ export class AssignmentProposalService { ...@@ -290,6 +302,9 @@ export class AssignmentProposalService {
this.poolOwnership(scope, criteria, agents, now), this.poolOwnership(scope, criteria, agents, now),
]); ]);
const candidateTotal = counts.total; const candidateTotal = counts.total;
if (patientIds && new Set(pool.map(c => c.patientId)).size !== patientIds.length) {
throw new Error('部分指定患者不在当前诊所可分配召回池中,本次未生成确认单');
}
// ⭐ **每一版提案都记下"按什么条件、圈出多少人"** —— 这不是调试日志,是这条生产线的黑匣子。 // ⭐ **每一版提案都记下"按什么条件、圈出多少人"** —— 这不是调试日志,是这条生产线的黑匣子。
// 主管连着说三句话就会出三版确认单,人数一版一个样时,唯一能分清 // 主管连着说三句话就会出三版确认单,人数一版一个样时,唯一能分清
// 「是条件变了」还是「是取数飘了」的证据就是这行(2026-08-06 排查 12→17→13 用的就是它)。 // 「是条件变了」还是「是取数飘了」的证据就是这行(2026-08-06 排查 12→17→13 用的就是它)。
...@@ -373,7 +388,9 @@ export class AssignmentProposalService { ...@@ -373,7 +388,9 @@ export class AssignmentProposalService {
: [...head, ...explore]; : [...head, ...explore];
// ── ② 落人:专属优先 → 无主补空 → 待分配(两种模式**同一套**)──────── // ── ② 落人:专属优先 → 无主补空 → 待分配(两种模式**同一套**)────────
const raw = placeAgents(chosen, agents, dedicated, capOf); const raw = input.assigneeUserId
? { placed: chosen.map(c => ({ ...c, assigneeUserId: input.assigneeUserId!, assignStrategy: AssignStrategy.MANUAL })), unplaced: 0, pending: [] as PendingItem[] }
: placeAgents(chosen, agents, dedicated, capOf);
// ⭐ 身份(姓名/病历号)在**落人之后**贴上 —— placeAgents 是纯算法, // ⭐ 身份(姓名/病历号)在**落人之后**贴上 —— placeAgents 是纯算法,
// 让它认识"患者叫什么"只会让它更难测,而对分法没有任何影响。 // 让它认识"患者叫什么"只会让它更难测,而对分法没有任何影响。
/// 贴身份的公共部分 —— 已落人的和待分配的用**同一份**,⛔ 别各贴各的(会漂) /// 贴身份的公共部分 —— 已落人的和待分配的用**同一份**,⛔ 别各贴各的(会漂)
...@@ -636,8 +653,8 @@ export class AssignmentProposalService { ...@@ -636,8 +653,8 @@ export class AssignmentProposalService {
: `,没有需要您定的了。`), : `,没有需要您定的了。`),
}; };
// ⚠️ 「再筛一刀」单独查库,⛔ 不放进 computeSignals —— 那个函数要保持纯。 // ⚠️ 「再筛一刀」单独查库,⛔ 不放进 computeSignals —— 那个函数要保持纯。
const narrow = await this.narrowSignal(scope, criteria, now, input.preferPlaceable === true); const narrow = patientIds ? undefined : await this.narrowSignal(scope, criteria, now, input.preferPlaceable === true);
return { ...proposal, signals: computeSignals(proposal as AssignmentProposal, narrow) }; return { ...proposal, canRefill: patientIds ? false : proposal.canRefill, signals: patientIds ? [] : computeSignals(proposal as AssignmentProposal, narrow) };
} }
/** /**
......
...@@ -25,6 +25,8 @@ import { labelExistsSql, labelTemperatureExistsSql } from './reason-temperature. ...@@ -25,6 +25,8 @@ import { labelExistsSql, labelTemperatureExistsSql } from './reason-temperature.
*/ */
export interface CohortCriteria { export interface CohortCriteria {
/** 定向分配的精确患者集合,仍受宿主、品牌、诊所和召回池范围限制。 */
patientIds?: string[];
/** 批次不跨诊所 */ /** 批次不跨诊所 */
clinicId: string; clinicId: string;
/** 矩阵 X 轴:画像「潜在治疗」8 类之一 */ /** 矩阵 X 轴:画像「潜在治疗」8 类之一 */
...@@ -195,6 +197,7 @@ export function cohortWhereSql( ...@@ -195,6 +197,7 @@ export function cohortWhereSql(
assertCohortCriteria(c); assertCohortCriteria(c);
return Prisma.sql` return Prisma.sql`
${poolBaseSql(scope, c.clinicId)} ${poolBaseSql(scope, c.clinicId)}
${c.patientIds ? Prisma.sql`AND fp.patient_id = ANY(${c.patientIds}::uuid[])` : Prisma.empty}
${c.potentialTreatment ? treatmentSql(c.potentialTreatment) : Prisma.empty} ${c.potentialTreatment ? treatmentSql(c.potentialTreatment) : Prisma.empty}
${ ${
c.temperature && c.potentialTreatment c.temperature && c.potentialTreatment
......
...@@ -117,7 +117,8 @@ describe('确认单草稿态 —— 前端产、请求带、工具读', () => { ...@@ -117,7 +117,8 @@ describe('确认单草稿态 —— 前端产、请求带、工具读', () => {
expect(CHAT_HOOK).toMatch(/sheetState:\s*useAssistantStore\.getState\(\)\.sheetSnapshot/); expect(CHAT_HOOK).toMatch(/sheetState:\s*useAssistantStore\.getState\(\)\.sheetSnapshot/);
expect(CONTROLLER).toMatch(/SheetSnapshotSchema\.safeParse\(body\.sheetState\)/); expect(CONTROLLER).toMatch(/SheetSnapshotSchema\.safeParse\(body\.sheetState\)/);
expect(ASSISTANT).toMatch(/tools\.get_current_sheet\s*=\s*tool\(/); expect(ASSISTANT).toMatch(/tools\.get_current_sheet\s*=\s*tool\(/);
expect(ASSISTANT).toContain('sheetSnapshotFacts(input.sheetState)'); expect(ASSISTANT).toContain('sheetSnapshotFacts(receipt.snapshot)');
expect(ASSISTANT).not.toContain('sheetSnapshotFacts(input.sheetState)');
}); });
test('⭐ 解不出来就当没有 —— ⛔ 不许把半份快照喂给模型(缺的字段会被读成 0)', () => { test('⭐ 解不出来就当没有 —— ⛔ 不许把半份快照喂给模型(缺的字段会被读成 0)', () => {
......
import { AssignmentProposalService } from '../src/modules/plan/assignment-proposal.service';
import { cohortWhereSql } from '../src/modules/plan/cohort-filter';
const pid = '11111111-1111-4111-8111-111111111111';
const scope = { hostId: 'h', tenantId: 't', userId: 'admin', clinicIds: ['c'], sourceUnits: ['brand'] };
function setup(found = true) {
const query = jest.fn(async (sql: { strings: string[]; values: unknown[] }) => {
const text = sql.strings.join(' ');
if (text.includes('count(DISTINCT')) return [{ n: BigInt(found ? 1 : 0) }];
return found ? [{ planId: 'plan', patientId: pid, priorityScore: 85 }] : [];
});
const service = new AssignmentProposalService({ $queryRaw: query,
patient: { findMany: async () => [{ id: pid, name: '于常瑞', medicalRecordNumber: 'demo', preferences: null }] },
planAssignment: { findFirst: async () => null },
} as never, { list: async () => ({ agents: [{ userId: 'admin', name: '管理员', inHand: 39 }, { userId: 'other', name: '其他客服', inHand: 0 }], rosterNote: '' }) } as never, {} as never);
return { service, query };
}
describe('指定患者分配与批量规则隔离', () => {
it('仅选指定患者,直接给管理员,不调整其他客服额度', async () => {
const { service, query } = setup();
const result = await service.propose(scope, { clinicId: 'c', patientIds: [pid], assigneeUserId: 'admin' });
expect(result.items).toHaveLength(1);
expect(result.items[0]).toMatchObject({ patientId: pid, assigneeUserId: 'admin', assignStrategy: 'manual' });
expect(result.agentOverrides).toEqual({});
expect(result.target).toBe(1);
expect(result.canRefill).toBe(false);
for (const [sql] of query.mock.calls) expect(sql.values).toContainEqual([pid]);
});
it('有患者不在可分配范围时整单拒绝,不换成别人补足人数', async () => {
await expect(setup(false).service.propose(scope, { clinicId: 'c', patientIds: [pid], assigneeUserId: 'admin' })).rejects.toThrow('不在当前诊所');
});
it('跨诊所与未知接收人拒绝', async () => {
await expect(setup().service.propose(scope, { clinicId: 'other', patientIds: [pid] })).rejects.toThrow();
await expect(setup().service.propose(scope, { clinicId: 'c', patientIds: [pid], assigneeUserId: 'outsider' })).rejects.toThrow('名册');
});
it('指定患者的 SQL 保留品牌、租户和召回池约束', () => {
const sql = cohortWhereSql(scope, { clinicId: 'c', patientIds: [pid] }, new Date());
expect(sql.sql).toContain('assignee_user_id IS NULL');
expect(sql.sql).toContain('source_unit');
expect(sql.values).toEqual(expect.arrayContaining(['h', 't', 'c', 'brand', [pid]]));
expect(cohortWhereSql(scope, { clinicId: 'c' }, new Date()).sql).not.toContain('fp.patient_id = ANY');
});
});
import { SheetBridgeService } from '../src/modules/assistant/sheet-bridge.service';
import { sheetSnapshotFacts } from '../src/modules/plan/assignment-facts';
import type { SheetSnapshot } from '@pac/types';
const scope = { hostId: 'h', tenantId: 't', userId: 'u', clinicIds: ['c'], sourceUnits: ['brand'] };
const snapshot: SheetSnapshot = { requestId: 's1', confirmed: false, placed: 1, agents: 1, pending: 0, dropped: 0, moved: 1, expiresInDays: 3, rowExpiryOverrides: 0, benefit: null,
byAgent: [{ name: '管理员', count: 1 }], criteria: { potentialTreatment: null, temperature: null, narrowedBy: null, candidateTotal: 1, batchSize: 1 },
patients: [{ patientId: 'p1', planId: 'plan1', name: '于常瑞', medicalRecordNumber: 'demo', assigneeUserId: 'u', assigneeName: '管理员', status: 'placed' }] };
describe('确认单真实回执', () => {
it('发出指令不等于成功,收到界面状态后才返回', async () => {
const bridge = new SheetBridgeService(); const send = jest.fn(); let resolved = false;
const waiting = bridge.request(scope, { kind: 'edit', requestId: 's1', ops: [] }, send).then(r => { resolved = true; return r; });
await Promise.resolve(); expect(resolved).toBe(false);
bridge.receive(scope, send.mock.calls[0][0].commandId, { snapshot, message: '于常瑞已改派给管理员' });
expect((await waiting).snapshot?.patients?.[0]?.assigneeName).toBe('管理员');
expect(sheetSnapshotFacts(snapshot).患者明细).toEqual([{ 姓名: '于常瑞', 病历号: 'demo', 客服: '管理员', 状态: 'placed' }]);
});
it('大名单有明确分页,点名可直接查到末尾患者', () => {
const large = { ...snapshot, patients: Array.from({ length: 120 }, (_, i) => ({ ...snapshot.patients![0]!, name: `患者${i}` })) };
expect(sheetSnapshotFacts(large)).toMatchObject({ 患者明细总数: 120, 下一页起点: 50 });
expect(sheetSnapshotFacts(large).患者明细).toHaveLength(50);
expect(sheetSnapshotFacts(large, { patient: '患者119' })).toMatchObject({ 患者明细总数: 1, 下一页起点: null, 患者明细: [expect.objectContaining({ 姓名: '患者119' })] });
expect(sheetSnapshotFacts(large, { offset: 100 }).患者明细).toHaveLength(20);
});
it('不接受其他用户、品牌、诊所范围的回执', async () => {
const bridge = new SheetBridgeService(); const send = jest.fn();
const waiting = bridge.request(scope, { kind: 'read' }, send);
const id = send.mock.calls[0][0].commandId;
for (const other of [{ ...scope, userId: 'other' }, { ...scope, sourceUnits: ['other'] }, { ...scope, clinicIds: ['other'] }]) {
expect(() => bridge.receive(other, id, { snapshot })).toThrow();
}
bridge.receive(scope, id, { snapshot, message: '最新' }); await waiting;
expect(() => bridge.receive(scope, id, { snapshot })).toThrow();
});
it('旧版本回执不能冒充当前确认单', async () => {
const bridge = new SheetBridgeService(); const send = jest.fn();
const waiting = bridge.request(scope, { kind: 'read', requestId: 'new' }, send);
bridge.receive(scope, send.mock.calls[0][0].commandId, { snapshot });
expect(await waiting).toMatchObject({ snapshot: null, message: expect.stringContaining('版本不一致') });
});
it('超时及断开均返回未确认,不报成功;晚回执拒绝', async () => {
const bridge = new SheetBridgeService(); const send = jest.fn();
const result = await bridge.request(scope, { kind: 'edit' }, send, undefined, 5);
expect(result).toMatchObject({ snapshot: null, message: expect.stringContaining('未知') });
expect(() => bridge.receive(scope, send.mock.calls[0][0].commandId, { snapshot })).toThrow();
const abort = new AbortController(); const waiting = bridge.request(scope, { kind: 'read' }, send, abort.signal);
abort.abort(); expect((await waiting).snapshot).toBeNull();
});
});
'use client'; 'use client';
import { finishSheetCommand } from './sheet-command';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, ChevronRight, GripVertical, Loader2, Undo2, Users, X } from 'lucide-react'; import { Check, ChevronDown, ChevronRight, GripVertical, Loader2, Undo2, Users, X } from 'lucide-react';
import { import {
...@@ -158,7 +159,7 @@ export function AssignmentConfirmSheet({ ...@@ -158,7 +159,7 @@ export function AssignmentConfirmSheet({
* 助手下发的语义编辑指令队列(edit_assignment_sheet)。 * 助手下发的语义编辑指令队列(edit_assignment_sheet)。
* ⚠️ 由**本组件**去匹配姓名 —— 模型手里没有 planId,那是故意的。 * ⚠️ 由**本组件**去匹配姓名 —— 模型手里没有 planId,那是故意的。
*/ */
edits?: { seq: number; ops: SheetEditOp[] }[]; edits?: { seq: number; ops: SheetEditOp[]; commandId?: string }[];
/** 执行结果回一句进对话(既给主管看,也补进模型上下文 —— 否则它不知道到底成没成) */ /** 执行结果回一句进对话(既给主管看,也补进模型上下文 —— 否则它不知道到底成没成) */
/** /**
* 改单结果进对话。 * 改单结果进对话。
...@@ -464,6 +465,14 @@ export function AssignmentConfirmSheet({ ...@@ -464,6 +465,14 @@ export function AssignmentConfirmSheet({
const snapshot = useMemo<SheetSnapshot>( const snapshot = useMemo<SheetSnapshot>(
() => ({ () => ({
requestId, requestId,
patients: [...sheet.items, ...(sheet.pending ?? [])].map(p => {
const assignee = moveByPlan[p.planId] ?? ('assigneeUserId' in p ? p.assigneeUserId : null);
const agent = [...sheet.byAgent, ...sheet.skippedAgents].find(a => a.userId === assignee);
return { patientId: p.patientId, planId: p.planId, name: p.patientName,
medicalRecordNumber: p.medicalRecordNumber, assigneeUserId: dropped.has(p.planId) ? null : assignee,
assigneeName: dropped.has(p.planId) ? null : agent?.name ?? null,
status: dropped.has(p.planId) ? 'removed' as const : assignee ? 'placed' as const : 'pending' as const };
}),
confirmed: assignmentId != null, confirmed: assignmentId != null,
placed: items.length, placed: items.length,
agents: groups.length, agents: groups.length,
...@@ -492,6 +501,8 @@ export function AssignmentConfirmSheet({ ...@@ -492,6 +501,8 @@ export function AssignmentConfirmSheet({
items, items,
groups, groups,
pendingList.length, pendingList.length,
moveByPlan,
dropped,
dropped.size, dropped.size,
expiresInDays, expiresInDays,
dayByPlan, dayByPlan,
...@@ -507,12 +518,13 @@ export function AssignmentConfirmSheet({ ...@@ -507,12 +518,13 @@ export function AssignmentConfirmSheet({
* ⛔ 不会报错。 * ⛔ 不会报错。
*/ */
useEffect(() => { useEffect(() => {
if (state !== 'active' && state !== 'confirmed') return;
useAssistantStore.getState().setSheetSnapshot(snapshot); useAssistantStore.getState().setSheetSnapshot(snapshot);
return () => { return () => {
const s = useAssistantStore.getState(); const s = useAssistantStore.getState();
if (s.sheetSnapshot?.requestId === snapshot.requestId) s.setSheetSnapshot(null); if (s.sheetSnapshot?.requestId === snapshot.requestId) s.setSheetSnapshot(null);
}; };
}, [snapshot]); }, [snapshot, state]);
/** /**
* 回声 —— **主管看增量,模型另外拿当前值**。 * 回声 —— **主管看增量,模型另外拿当前值**。
...@@ -537,6 +549,17 @@ export function AssignmentConfirmSheet({ ...@@ -537,6 +549,17 @@ export function AssignmentConfirmSheet({
const [echoSeq, setEchoSeq] = useState(0); const [echoSeq, setEchoSeq] = useState(0);
const snapRef = useRef(snapshot); const snapRef = useRef(snapshot);
snapRef.current = snapshot; snapRef.current = snapshot;
const commandReceipts = useRef<{ id: string; message: string }[]>([]);
const [receiptSeq, setReceiptSeq] = useState(0);
const acknowledge = (id: string | undefined, message: string) => {
if (!id) return;
commandReceipts.current.push({ id, message });
setReceiptSeq(n => n + 1);
};
useEffect(() => {
const receipts = commandReceipts.current.splice(0);
for (const r of receipts) finishSheetCommand(r.id, snapRef.current, r.message);
}, [receiptSeq]);
const echo = useCallback((summary: string) => { const echo = useCallback((summary: string) => {
echoQueue.current.push(summary); echoQueue.current.push(summary);
setEchoSeq((n) => n + 1); setEchoSeq((n) => n + 1);
...@@ -728,33 +751,20 @@ export function AssignmentConfirmSheet({ ...@@ -728,33 +751,20 @@ export function AssignmentConfirmSheet({
// ⚠️ 2026-08-20 起它们**不是没辙了**:走「调整各客服在手的单」那条路(独立的一张单)。 // ⚠️ 2026-08-20 起它们**不是没辙了**:走「调整各客服在手的单」那条路(独立的一张单)。
// ⛔ 别再把话说成「只能撤销重分」,那句从这天起就是错的。 // ⛔ 别再把话说成「只能撤销重分」,那句从这天起就是错的。
if (assignmentId) { if (assignmentId) {
for (const { ops } of fresh) { void (async () => {
for (const op of ops) { for (const { ops, commandId } of fresh) {
if (op.action === 'set_benefit') { const results: string[] = [];
void assignmentsApi for (const op of ops) {
// ⚠️ schema 的 superRefine 保证 set_benefit 必有 text;`?? ''` 只是类型兜底 if (op.action !== 'set_benefit') { results.push('已确认批次不能在草稿上修改人员或时限,请使用在手调整'); continue; }
.setBenefit(assignmentId, op.text ?? '') try {
.then((r) => { const r = await assignmentsApi.setBenefit(assignmentId, op.text ?? '');
setBenefit(r.benefitText ?? ''); setBenefit(r.benefitText ?? ''); results.push(r.note);
// ⭐ 回报**服务端那句**,不是前端自己编的 —— 里面有"作废了几条话术缓存" } catch (e) { results.push(`福利未修改:${e instanceof Error ? e.message : '请求失败'}`); }
// 和"几条客服已经打开过",这两个数只有服务端知道(T14)。
echo(`确认单已更新:${r.note}`);
})
.catch((e) =>
echo(
`福利没改成:${e instanceof Error ? e.message : '接口报错'} —— 请如实告诉主管,⛔ 不要说已经改好了。`,
),
);
continue;
} }
notes.push( const message = results.join(';');
`这批**已经确认分配**了,「${OP_ZH[op.action] ?? op.action}」在这张单上改不了 —— ` + echo(message); acknowledge(commandId, message);
`单子已经在客服手上 —— 让我把在手的摆出来调(换人、改时限、移出),` +
`或者撤销整批重新分`,
);
} }
} })();
if (notes.length) echo(`确认单没有改动:${notes.join(';')}。`);
return; return;
} }
/** /**
...@@ -807,7 +817,7 @@ export function AssignmentConfirmSheet({ ...@@ -807,7 +817,7 @@ export function AssignmentConfirmSheet({
const k = q.trim(); const k = q.trim();
const inSheet = groups.find((g) => g.name === k || g.userId === k); const inSheet = groups.find((g) => g.name === k || g.userId === k);
if (inSheet) return inSheet; if (inSheet) return inSheet;
const idle = sheet.skippedAgents.find((a) => a.name === k || a.userId === k); const idle = [...sheet.byAgent.map(a => ({ userId: a.userId, name: a.name, inHand: a.inHandBefore })), ...sheet.skippedAgents].find((a) => a.name === k || a.userId === k);
return idle return idle
? { userId: idle.userId, name: idle.name, inHandBefore: idle.inHand, list: [] } ? { userId: idle.userId, name: idle.name, inHandBefore: idle.inHand, list: [] }
: null; : null;
...@@ -1132,6 +1142,7 @@ export function AssignmentConfirmSheet({ ...@@ -1132,6 +1142,7 @@ export function AssignmentConfirmSheet({
setMoveByPlan((m) => ({ ...m, ...moveDraft })); setMoveByPlan((m) => ({ ...m, ...moveDraft }));
setDropped((sset) => new Set([...sset, ...dropDraft])); setDropped((sset) => new Set([...sset, ...dropDraft]));
if (notes.length) echo(`确认单已更新:${notes.join(';')}。`); if (notes.length) echo(`确认单已更新:${notes.join(';')}。`);
for (const e of fresh) acknowledge(e.commandId, notes.join(';') || '没有匹配到可修改的内容');
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [edits]); }, [edits]);
......
...@@ -263,6 +263,7 @@ function Markdown({ text: raw }: { text: string }) { ...@@ -263,6 +263,7 @@ function Markdown({ text: raw }: { text: string }) {
} }
function BlockView({ function BlockView({
hideSheet,
block, block,
onSheetConfirmed, onSheetConfirmed,
onSheetEdited, onSheetEdited,
...@@ -279,6 +280,7 @@ function BlockView({ ...@@ -279,6 +280,7 @@ function BlockView({
orphanKeys, orphanKeys,
}: { }: {
block: Block; block: Block;
hideSheet?: boolean;
onSheetConfirmed?: ( onSheetConfirmed?: (
requestId: string, requestId: string,
assignmentId: string, assignmentId: string,
...@@ -357,7 +359,7 @@ function BlockView({ ...@@ -357,7 +359,7 @@ function BlockView({
} }
if (block.kind === 'assignment_sheet') { if (block.kind === 'assignment_sheet') {
return ( return (
<AssignmentConfirmSheet <div hidden={hideSheet}><AssignmentConfirmSheet
requestId={block.requestId} requestId={block.requestId}
sheet={block.sheet} sheet={block.sheet}
state={block.state} state={block.state}
...@@ -375,7 +377,7 @@ function BlockView({ ...@@ -375,7 +377,7 @@ function BlockView({
onConfirmed={(id, summary, modelFacts) => onConfirmed={(id, summary, modelFacts) =>
onSheetConfirmed?.(block.requestId, id, summary, modelFacts) onSheetConfirmed?.(block.requestId, id, summary, modelFacts)
} }
/> /></div>
); );
} }
/** /**
...@@ -837,7 +839,7 @@ function MessageView({ ...@@ -837,7 +839,7 @@ function MessageView({
const ordered = [ const ordered = [
...body, ...body,
...(streaming ? [] : orphan), ...(streaming ? [] : orphan),
...(anchorAt !== -1 || streaming ? [] : sheets), ...(anchorAt !== -1 ? [] : sheets),
// 与确认单同一条规矩:漏调锚点 / 无根 → 等说完、钉末尾。⛔ 卡片永远不会不出现 // 与确认单同一条规矩:漏调锚点 / 无根 → 等说完、钉末尾。⛔ 卡片永远不会不出现
...(rearrangeAnchorAt !== -1 || streaming ? [] : rearranges), ...(rearrangeAnchorAt !== -1 || streaming ? [] : rearranges),
]; ];
...@@ -866,6 +868,7 @@ function MessageView({ ...@@ -866,6 +868,7 @@ function MessageView({
: `b:${i}` : `b:${i}`
} }
block={b} block={b}
hideSheet={b.kind === 'assignment_sheet' && !!streaming && anchorAt === -1}
placedKeys={placedKeys} placedKeys={placedKeys}
signalOf={signalOf} signalOf={signalOf}
streaming={streaming} streaming={streaming}
......
...@@ -128,7 +128,7 @@ export type Block = ...@@ -128,7 +128,7 @@ export type Block =
* 卡片组件按 `seq` 只执行新增的那一批,执行完把结果回一句进对话。 * 卡片组件按 `seq` 只执行新增的那一批,执行完把结果回一句进对话。
* ⚠️ 用队列而不是"最新一条":主管连着说两句时,两批指令都要生效。 * ⚠️ 用队列而不是"最新一条":主管连着说两句时,两批指令都要生效。
*/ */
edits?: { seq: number; ops: SheetEditOp[] }[]; edits?: { seq: number; ops: SheetEditOp[]; commandId?: string }[];
} }
/** /**
* 调整单卡片**摆在这** —— 位置由模型决定(`show_rearrange` 工具)。 * 调整单卡片**摆在这** —— 位置由模型决定(`show_rearrange` 工具)。
......
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { SheetSnapshot } from '@pac/types';
import { useAssistantStore } from '@/stores/assistant-store';
import { readLiveSheet, waitSheetEdit, finishSheetCommand, postSheetReceipt } from './sheet-command';
vi.mock('@/stores/auth-store', () => ({ useAuthStore: { getState: () => ({ accessToken: 'new-session' }) } }));
const snapshot = (requestId: string) => ({ requestId, patients: [{ name: '患者甲', assigneeName: '管理员' }] } as SheetSnapshot);
afterEach(() => { useAssistantStore.setState({ sheetSnapshot: null }); vi.useRealTimers(); vi.unstubAllGlobals(); });
describe('确认单实时回执', () => {
it('等新单挂载,不把旧单当成新单', async () => {
useAssistantStore.setState({ sheetSnapshot: snapshot('old') });
const read = readLiveSheet('new');
let done = false; void read.then(() => { done = true; });
await Promise.resolve(); expect(done).toBe(false);
useAssistantStore.setState({ sheetSnapshot: snapshot('new') });
expect((await read).snapshot?.requestId).toBe('new');
});
it('每次读取当前版本的患者及归属', async () => {
useAssistantStore.setState({ sheetSnapshot: snapshot('a') });
expect((await readLiveSheet()).snapshot?.patients?.[0]?.assigneeName).toBe('管理员');
const changed = snapshot('a'); changed.patients![0]!.assigneeName = '客服乙';
useAssistantStore.setState({ sheetSnapshot: changed });
expect((await readLiveSheet()).snapshot?.patients?.[0]?.assigneeName).toBe('客服乙');
});
it('收到执行完成回执才返回,未知命令不影响待处理命令', async () => {
const edit = waitSheetEdit('one'); let done = false; void edit.then(() => { done = true; });
finishSheetCommand('other', snapshot('a'), '无关'); await Promise.resolve(); expect(done).toBe(false);
finishSheetCommand('one', snapshot('a'), '已改派给管理员');
expect(await edit).toMatchObject({ message: '已改派给管理员', snapshot: { requestId: 'a' } });
});
it('丢回执和找不到指定版本明确返回未知,不宣告成功', async () => {
vi.useFakeTimers();
const edit = waitSheetEdit('timeout'), read = readLiveSheet('missing');
await vi.advanceTimersByTimeAsync(9000);
expect(await edit).toMatchObject({ snapshot: null, message: expect.stringContaining('未确认') });
expect(await read).toMatchObject({ snapshot: null, message: expect.stringContaining('未就绪') });
});
it('登录切换后不借新令牌发送旧回执', async () => {
const fetcher = vi.fn(); vi.stubGlobal('fetch', fetcher);
await postSheetReceipt('one', { snapshot: snapshot('a'), message: '完成' }, 'old-session');
expect(fetcher).not.toHaveBeenCalled();
});
});
import type { SheetSnapshot } from '@pac/types';
import { useAssistantStore } from '@/stores/assistant-store';
import { useAuthStore } from '@/stores/auth-store';
import { env } from '@/lib/env';
type Receipt = { snapshot: SheetSnapshot | null; message: string };
const pending = new Map<string, (r: Receipt) => void>();
/** 回执必须在卡片提交 React 状态之后调用。 */
export function finishSheetCommand(id: string, snapshot: SheetSnapshot | null, message: string) {
pending.get(id)?.({ snapshot, message });
}
export function waitSheetEdit(id: string): Promise<Receipt> {
return new Promise(resolve => {
const timer = setTimeout(() => finishSheetCommand(id, null, '界面执行未确认,请重新读取确认单'), 9000);
pending.set(id, r => { clearTimeout(timer); pending.delete(id); resolve(r); });
});
}
export function readLiveSheet(requestId?: string): Promise<Receipt> {
const current = useAssistantStore.getState().sheetSnapshot;
if (!requestId || current?.requestId === requestId) return Promise.resolve({ snapshot: current, message: current ? '已读取最新确认单' : '当前没有确认单' });
return new Promise(resolve => {
const timer = setTimeout(() => { unsubscribe(); resolve({ snapshot: null, message: '确认单版本已变化或未就绪,本次未执行' }); }, 5000);
const unsubscribe = useAssistantStore.subscribe(s => {
if (s.sheetSnapshot?.requestId !== requestId) return;
clearTimeout(timer); unsubscribe(); resolve({ snapshot: s.sheetSnapshot, message: '已读取最新确认单' });
});
});
}
export async function postSheetReceipt(commandId: string, receipt: Receipt, token: string | null) {
// 登录切换后,旧流的回执不能借用新会话发送。
if (!token || useAuthStore.getState().accessToken !== token) return;
await fetch(new URL('/pac/v1/assistant/sheet-receipt', env.apiBaseUrl), {
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ commandId, ...receipt }),
});
}
'use client'; 'use client';
import { readLiveSheet, waitSheetEdit, postSheetReceipt, finishSheetCommand } from './sheet-command';
import { useCallback, useRef, useState } from 'react'; import { useCallback, useRef, useState } from 'react';
import type { import type {
AssignmentProposal, AssignmentProposal,
...@@ -246,6 +247,34 @@ export function useAssistantChat() { ...@@ -246,6 +247,34 @@ export function useAssistantChat() {
}), }),
); );
break; break;
case 'assignment_sheet_command': {
const commandId = String(evt.commandId ?? '');
const expectedId = typeof evt.requestId === 'string' ? evt.requestId : undefined;
const receiptToken = useAuthStore.getState().accessToken;
void (async () => {
const current = await readLiveSheet(expectedId);
if (evt.kind === 'read' || !current.snapshot) {
await postSheetReceipt(commandId, current, receiptToken);
return;
}
const receipt = waitSheetEdit(commandId);
setMessages(prev => {
const at = findActiveSheet(prev);
if (!at) { queueMicrotask(() => finishSheetCommand(commandId, null, '当前没有有效确认单,未执行修改')); return prev; }
const { messageIndex: mi, blockIndex: bi } = at;
const b = prev[mi]!.blocks[bi] as Extract<Block, { kind: 'assignment_sheet' }>;
if (b.requestId !== current.snapshot!.requestId) {
queueMicrotask(() => finishSheetCommand(commandId, null, '确认单版本已变化,未执行修改')); return prev;
}
const next: Block = { ...b, edits: [...(b.edits ?? []), {
seq: (b.edits?.length ?? 0) + 1, ops: (evt.ops ?? []) as SheetEditOp[], commandId,
}] };
return prev.map((m, k) => k === mi ? { ...m, blocks: m.blocks.map((x, j) => j === bi ? next : x) } : m);
});
await postSheetReceipt(commandId, await receipt, receiptToken);
})().catch(() => { /* 服务端超时会报告未确认,⛔ 不伪造成功 */ });
break;
}
case 'assignment_sheet_edit': case 'assignment_sheet_edit':
// ⭐ 助手要改**已经呈现**的那张卡:指令挂到最后一张确认单上,由它自己去匹配执行。 // ⭐ 助手要改**已经呈现**的那张卡:指令挂到最后一张确认单上,由它自己去匹配执行。
// //
...@@ -517,6 +546,7 @@ export function useAssistantChat() { ...@@ -517,6 +546,7 @@ export function useAssistantChat() {
messages: apiMessages, messages: apiMessages,
model, model,
conversationId: conversationIdRef.current, conversationId: conversationIdRef.current,
sheetBridge: true,
...(useAssistantStore.getState().activeClinicId ...(useAssistantStore.getState().activeClinicId
? { activeClinicId: useAssistantStore.getState().activeClinicId } ? { activeClinicId: useAssistantStore.getState().activeClinicId }
: {}), : {}),
......
...@@ -946,8 +946,8 @@ export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>; ...@@ -946,8 +946,8 @@ export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>;
* ═══ 为什么它必须由浏览器产 ═══════════════════════════════════════ * ═══ 为什么它必须由浏览器产 ═══════════════════════════════════════
* 确认之前,改动**全部只存在于卡片的组件状态里**(改派 / 移出 / 时限 / 福利)—— * 确认之前,改动**全部只存在于卡片的组件状态里**(改派 / 移出 / 时限 / 福利)——
* 服务端手上只有最初生成的那一版提案,还是个请求内的闭包,没落库也没缓存。 * 服务端手上只有最初生成的那一版提案,还是个请求内的闭包,没落库也没缓存。
* ⇒ ⛔ 别指望服务端"查"得到它:`get_current_sheet` 查的就是这份随请求捎来的快照, * ⇒ ⛔ 不从服务端缓存推测草稿状态:`get_current_sheet` 向浏览器实时读取。
* ⛔ 也别为它去开一条"草稿回传"的写路径(多一份要维护的状态,只为省模型一次加法)。 * 读单和改单通过本轮回执返回最新快照;快照不写业务表,也不作为分配授权。
* *
* ⚠️ 它是**这一刻**的照片,不是账本:主管下一秒拖一下它就过期了。 * ⚠️ 它是**这一刻**的照片,不是账本:主管下一秒拖一下它就过期了。
* 所以它随**每次** chat 请求重新捎一份(与 `activeClinicId` 同一条旁路), * 所以它随**每次** chat 请求重新捎一份(与 `activeClinicId` 同一条旁路),
...@@ -956,6 +956,12 @@ export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>; ...@@ -956,6 +956,12 @@ export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>;
* (`sheetSnapshotFacts`)—— 与 `modelFacts` 同一条纪律:事实投影只在服务端一处。 * (`sheetSnapshotFacts`)—— 与 `modelFacts` 同一条纪律:事实投影只在服务端一处。
*/ */
export const SheetSnapshotSchema = z.object({ export const SheetSnapshotSchema = z.object({
/** 最新界面状态中的患者与归属;旧客户端缺失时明确不可核验,不能当成空名单。 */
patients: z.array(z.object({
patientId: z.string(), planId: z.string(), name: z.string().nullable(),
medicalRecordNumber: z.string().nullable(), assigneeUserId: z.string().nullable(),
assigneeName: z.string().nullable(), status: z.enum(['placed', 'pending', 'removed']),
})).max(20000).optional(),
/// 哪一张单(服务端铸造的幂等键)——多轮之后能认出"说的是不是同一张" /// 哪一张单(服务端铸造的幂等键)——多轮之后能认出"说的是不是同一张"
requestId: z.string(), requestId: z.string(),
/// 已经点过确认了没有。⚠️ true 时人员/时限已锁死,只有福利还能改 /// 已经点过确认了没有。⚠️ true 时人员/时限已锁死,只有福利还能改
......
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