Commit 2e75bef9 by luoqi

fix: 助手撤销走不通(批次号被截成8位)+ 助手"假撤销"对账

主管实测:助手撤销必挂,报「批次 c02e1b80 不存在」。

根因不在撤销,在确认后注入消息流的那句话:
`已确认分配:批次 #${id.slice(0,8)}` —— 界面与模型共用这一份,
模型手里只有 8 位短号,拿短号查主键当然查不到。
卡片上的撤销按钮握着完整 id 一直是好的,所以只测卡片那条就漏了。

修法两层:
- 文本块加 modelText:界面显示一份、喂模型一份(后者带完整 uuid);
  界面顺带不再显示批次号(uuid 对主管没有意义)
- 服务端 resolveAssignmentId 兜底认短号,并能从「批次 #c02e1b80」
  这种整句里抠出 id(实测模型就这么传);撞到多个报错不猜

追查中发现更严重的一个:助手会把「已撤销批次:收回 9 条。」**背出来** ——
没调 revoke_assignment,库里那批仍是 confirmed、9 条工单一条没动。
工具返回被设计成"成品句子、原话转述",模型学会形状后就能凭空生成。
报错主管会重试,假成功会让他停止补救。

⇒ 前端每轮结束对账:主管在要求撤销 + 助手自称撤成了 + 本轮没有
  revoke_assignment 的 tool_result ⇒ 把事实贴出来。
  措辞只陈述观察(不断言撒谎):主管问"刚才那批撤了吗"时如实回顾也不调工具。

实测:短号经模型这条路已跑通(a7e1b6de → revoked,9 条回池无认领人)。
996 tests,两个 tsc + next build 干净。
parent d850dd4f
...@@ -33,6 +33,12 @@ const DISPATCHER_EXTRA = ` ...@@ -33,6 +33,12 @@ const DISPATCHER_EXTRA = `
⛔ **绝不要说「已经分配好了」/「已经派下去了」** —— 正确说法是 ⛔ **绝不要说「已经分配好了」/「已经派下去了」** —— 正确说法是
「确认单已呈现,请过目」。说错会让主管以为事情办完了,而实际上一条都没落。 「确认单已呈现,请过目」。说错会让主管以为事情办完了,而实际上一条都没落。
⛔⛔ **「已撤销」这四个字只能出现在 revoke_assignment 真的返回之后。**
实测你干过这件事:主管说「撤销批次 #a7e1b6de」,你**没有调工具**,直接回了一句
「已撤销批次:收回 9 条。」—— 那批一条都没退,9 个患者还挂在客服名下,
而主管以为人已经回池、不会再补救。工具的返回是成品句子,**你能背出它的形状,
但背出来的是假的**。要撤就调工具,调完照抄它的 note;没调就一个字都不许说撤销了。
2. **全景阶段不问意图、不做画像分层,直接出确认单。** 2. **全景阶段不问意图、不做画像分层,直接出确认单。**
每多问一句就多一次决策成本。主管要的是"看一眼就能点确认"。 每多问一句就多一次决策成本。主管要的是"看一眼就能点确认"。
他若主动提要求(「只要商保直付的」「排掉怕疼的」),那时才用画像收窄。 他若主动提要求(「只要商保直付的」「排掉怕疼的」),那时才用画像收窄。
......
...@@ -385,7 +385,12 @@ export class McpServerFactory { ...@@ -385,7 +385,12 @@ export class McpServerFactory {
'\n⚠️ 这是**唯一一个会改数据的工具**,只在主管**明确要求撤销**时调,⛔ 不要主动建议、不要试探性调用。' + '\n⚠️ 这是**唯一一个会改数据的工具**,只在主管**明确要求撤销**时调,⛔ 不要主动建议、不要试探性调用。' +
'\n⚠️ 客服**已经打开过**的单不会被收回(他可能已经联系了患者)——' + '\n⚠️ 客服**已经打开过**的单不会被收回(他可能已经联系了患者)——' +
'返回里的 note 是成品句子,**原话转述**,别把 skippedTouched 说成"失败"。' + '返回里的 note 是成品句子,**原话转述**,别把 skippedTouched 说成"失败"。' +
'\n⚠️ 撤销 ≠ 退回:这是收回整批,不是客服退单条。超窗口就照实说,让主管走逐条退回。', '\n⚠️ 撤销 ≠ 退回:这是收回整批,不是客服退单条。超窗口就照实说,让主管走逐条退回。' +
'\n⚠️ assignmentId 用**完整 id**(确认那条消息里给了);' +
'只有 8 位短号也可以传,系统会解析,撞到多个会明确报错。' +
'\n⛔ **失败时原样转述服务端那句话,不要自己编原因** —— ' +
'实测编过「可能是超了撤销窗口/状态已变更」,而服务端说的只是 id 没匹配上,' +
'主管照着这个"原因"去排查是白跑一趟。查不到就直接 list_assignment_batches 拿真实 id 重试。',
inputSchema: { assignmentId: z.string() }, inputSchema: { assignmentId: z.string() },
}, },
async ({ assignmentId }) => async ({ assignmentId }) =>
......
...@@ -388,6 +388,71 @@ export class PlanAssignmentService { ...@@ -388,6 +388,71 @@ export class PlanAssignmentService {
} }
/** /**
* 把「批次引用」解析成真实 id —— **完整 uuid 或 8 位以上短号前缀都认**。
*
* 🔴 2026-08-03 实测踩到:助手撤销必然失败,报「批次 c02e1b80 不存在」。
* 根因不在撤销,在**界面注入给模型的那句话**里把 id 截成了 8 位
* (`已确认分配:批次 #c02e1b80 …`)。模型手里只有短号,拿短号查主键当然查不到 ——
* 而卡片上的撤销按钮握着完整 id,所以那条路一直是通的,单测也一直是绿的。
* ⇒ 注入那句已改成给模型全 id(见 assignment-confirm-sheet),**但这里必须也认短号**:
* 短号会出现在对话历史、截图、主管口述里,模型没有任何办法把它还原成 uuid。
*
* ⚠️ 歧义**必须报错**,⛔ 不许"取第一个" —— 这个解析器服务于 revoke(唯一的写动作),
* 猜错了就是撤销了另一批人的单子,而主管以为撤的是刚才那批。
* ⚠️ 下限 8 位:再短的前缀在一个诊所几百个批次里撞得到,而撞上了才报错就晚了。
*/
private async resolveAssignmentId(scope: TenantScopeContext, ref: string): Promise<string> {
const trimmed = (ref ?? '').trim().replace(/^#/, '');
// 完整 uuid 走原路(不做多余查询,行为与从前完全一致)
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trimmed)) {
return trimmed;
}
// ⚠️ 从整串里**抠出** id —— 模型很可能把「批次 #c02e1b80」整句当参数传进来
// (实测它就这么干过,然后照着报错编了个"可能超了撤销窗口"告诉主管)。
// 抠出来是安全的:后面仍然只在本 scope 内匹配,撞到多个照样报错不猜。
const m = trimmed.match(/[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}|[0-9a-f]{8,}/i);
if (!m) {
throw new NotFoundException(
`「${ref}」里找不到批次 id。批次 id 是 uuid,界面上显示的是它的前 8 位。` +
`⚠️ 请先用 list_assignment_batches 查到真实 id,⛔ 不要猜。`,
);
}
const raw = m[0];
if (raw.includes('-')) return raw.toLowerCase(); // 整串里夹着完整 uuid
// ⚠️ id 是 `@db.Uuid`,Prisma 的 `startsWith` 会生成 `uuid LIKE text` —— Postgres 直接报
// `operator does not exist`。必须先 `::text` 再 LIKE。
const like = `${raw.toLowerCase()}%`;
const hits = await this.prisma.$queryRaw<Array<{ id: string; created_at: Date }>>(Prisma.sql`
SELECT id, created_at FROM plan_assignments
WHERE id::text LIKE ${like}
AND host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
${
scope.clinicIds.length
? Prisma.sql`AND clinic_id IN (${Prisma.join(scope.clinicIds)})`
: Prisma.empty
}
ORDER BY created_at DESC
LIMIT 5`);
if (hits.length === 0) {
throw new NotFoundException(
`本范围内没有批次 ${raw}。⚠️ 这**不是**撤销窗口超时、也**不是**状态变更 ——` +
`就是这个 id 没匹配上。请用 list_assignment_batches 查真实 id,⛔ 不要替系统编原因。`,
);
}
if (hits.length > 1) {
throw new BadRequestException(
`短号 ${raw} 匹配到 ${hits.length} 个批次,不能确定是哪一批 —— ` +
`请用完整 id(list_assignment_batches 里有)。`,
);
}
return hits[0]!.id;
}
/**
* 批次列表 + 每批汇总。 * 批次列表 + 每批汇总。
* *
* ⚠️ 计数**现算不落列**(T17):planned/inHand/released 都能从 followup_plans 一次 * ⚠️ 计数**现算不落列**(T17):planned/inHand/released 都能从 followup_plans 一次
...@@ -426,7 +491,9 @@ export class PlanAssignmentService { ...@@ -426,7 +491,9 @@ export class PlanAssignmentService {
} }
/** 单批全貌:按客服拆 + 退回原因分布 + 未动过的条数 */ /** 单批全貌:按客服拆 + 退回原因分布 + 未动过的条数 */
async detail(scope: TenantScopeContext, id: string): Promise<AssignmentDetailResponse> { async detail(scope: TenantScopeContext, ref: string): Promise<AssignmentDetailResponse> {
// 短号也认(模型手里常常只有界面上那 8 位)—— 见 resolveAssignmentId
const id = await this.resolveAssignmentId(scope, ref);
const head = await this.prisma.planAssignment.findFirst({ const head = await this.prisma.planAssignment.findFirst({
where: { where: {
id, id,
...@@ -648,11 +715,16 @@ export class PlanAssignmentService { ...@@ -648,11 +715,16 @@ export class PlanAssignmentService {
async revoke( async revoke(
scope: TenantScopeContext, scope: TenantScopeContext,
actor: DispatchActor, actor: DispatchActor,
assignmentId: string, ref: string,
): Promise<RevokeAssignmentResponse> { ): Promise<RevokeAssignmentResponse> {
requirePermission(actor, Permission.PLAN_DISPATCH); requirePermission(actor, Permission.PLAN_DISPATCH);
rejectSyntheticIdentity(actor); rejectSyntheticIdentity(actor);
// ⭐ 先解析:助手手里往往只有界面上那 8 位短号(见 resolveAssignmentId 的实测记录)。
// ⚠️ 解析在**权限校验之后**没有意义、在**归属校验之前**是安全的:
// 它只在本 scope 内匹配,匹配到多个直接报错不猜。
const assignmentId = await this.resolveAssignmentId(scope, ref);
const head = await this.prisma.planAssignment.findFirst({ const head = await this.prisma.planAssignment.findFirst({
where: { where: {
id: assignmentId, id: assignmentId,
......
...@@ -13,6 +13,15 @@ import { PrismaService } from '../src/prisma/prisma.service'; ...@@ -13,6 +13,15 @@ import { PrismaService } from '../src/prisma/prisma.service';
* ③ 清了批次归因 → 这次误操作影响了多少人,事后再也说不清 * ③ 清了批次归因 → 这次误操作影响了多少人,事后再也说不清
*/ */
/**
* ⚠️ 必须是**真 uuid**,不能再用 'b1' 这种假 id ——
* 服务端现在会先解析批次引用(完整 uuid 走快路,短号走前缀查询),
* 假 id 两条路都不匹配。这不是测试凑合,是被 2026-08-03 的线上失败逼出来的:
* 假 id 的测试**永远绿**,而真实调用里模型手上只有 8 位短号,必然失败。
*/
const BATCH = 'c02e1b80-1111-4222-8333-444455556666';
const SHORT = BATCH.slice(0, 8);
const SCOPE = { const SCOPE = {
hostId: 'h1', tenantId: 't1', sourceUnits: [] as string[], clinicIds: [] as string[], userId: 'leader-1', hostId: 'h1', tenantId: 't1', sourceUnits: [] as string[], clinicIds: [] as string[], userId: 'leader-1',
} as never; } as never;
...@@ -24,6 +33,8 @@ function makePrisma(opts: { ...@@ -24,6 +33,8 @@ function makePrisma(opts: {
status?: string; status?: string;
plans?: Array<{ id: string; status: string; assigneeUserId: string | null }>; plans?: Array<{ id: string; status: string; assigneeUserId: string | null }>;
viewedPlanIds?: string[]; viewedPlanIds?: string[];
/** 短号前缀查询命中的批次(默认恰好一个 = 正常情况) */
prefixHits?: Array<{ id: string }>;
}) { }) {
const captured: { data?: Record<string, unknown>; ids?: string[] } = {}; const captured: { data?: Record<string, unknown>; ids?: string[] } = {};
const events: Array<Record<string, unknown>> = []; const events: Array<Record<string, unknown>> = [];
...@@ -56,7 +67,7 @@ function makePrisma(opts: { ...@@ -56,7 +67,7 @@ function makePrisma(opts: {
const prisma = { const prisma = {
planAssignment: { planAssignment: {
findFirst: jest.fn(async () => ({ findFirst: jest.fn(async () => ({
id: 'b1', hostId: 'h1', tenantId: 't1', clinicId: 'c1', id: BATCH, hostId: 'h1', tenantId: 't1', clinicId: 'c1',
createdBy: opts.createdBy ?? 'leader-1', createdBy: opts.createdBy ?? 'leader-1',
status: opts.status ?? 'confirmed', status: opts.status ?? 'confirmed',
createdAt: new Date(Date.now() - (opts.ageMinutes ?? 5) * 60_000), createdAt: new Date(Date.now() - (opts.ageMinutes ?? 5) * 60_000),
...@@ -73,6 +84,10 @@ function makePrisma(opts: { ...@@ -73,6 +84,10 @@ function makePrisma(opts: {
planEventLog: { planEventLog: {
groupBy: jest.fn(async () => (opts.viewedPlanIds ?? []).map((planId) => ({ planId }))), groupBy: jest.fn(async () => (opts.viewedPlanIds ?? []).map((planId) => ({ planId }))),
}, },
// 短号前缀解析(id::text LIKE 'xxxxxxxx%')
$queryRaw: jest.fn(async () =>
(opts.prefixHits ?? [{ id: BATCH }]).map((h) => ({ ...h, created_at: new Date() })),
),
$transaction: jest.fn(async (fn: (t: typeof tx) => Promise<unknown>) => fn(tx)), $transaction: jest.fn(async (fn: (t: typeof tx) => Promise<unknown>) => fn(tx)),
} as unknown as PrismaService; } as unknown as PrismaService;
return { prisma, captured, events, headUpdates, tx }; return { prisma, captured, events, headUpdates, tx };
...@@ -93,7 +108,7 @@ describe('撤销整批 —— 「已动过」的判据', () => { ...@@ -93,7 +108,7 @@ describe('撤销整批 —— 「已动过」的判据', () => {
// 已经打过电话的单当成"没动过"收走,那通电话永久蒸发。 // 已经打过电话的单当成"没动过"收走,那通电话永久蒸发。
const { prisma, captured } = makePrisma({ viewedPlanIds: ['p1'] }); const { prisma, captured } = makePrisma({ viewedPlanIds: ['p1'] });
const svc = await build(prisma); const svc = await build(prisma);
const r = await svc.revoke(SCOPE, LEADER, 'b1'); const r = await svc.revoke(SCOPE, LEADER, BATCH);
expect(r.revoked).toBe(1); expect(r.revoked).toBe(1);
expect(r.skippedTouched).toBe(1); expect(r.skippedTouched).toBe(1);
expect(captured.ids).toEqual(['p2']); // p1 被跳过 expect(captured.ids).toEqual(['p2']); // p1 被跳过
...@@ -103,7 +118,7 @@ describe('撤销整批 —— 「已动过」的判据', () => { ...@@ -103,7 +118,7 @@ describe('撤销整批 —— 「已动过」的判据', () => {
test('全都没打开过 → 全收', async () => { test('全都没打开过 → 全收', async () => {
const { prisma } = makePrisma({}); const { prisma } = makePrisma({});
const svc = await build(prisma); const svc = await build(prisma);
const r = await svc.revoke(SCOPE, LEADER, 'b1'); const r = await svc.revoke(SCOPE, LEADER, BATCH);
expect(r.revoked).toBe(2); expect(r.revoked).toBe(2);
expect(r.skippedTouched).toBe(0); expect(r.skippedTouched).toBe(0);
}); });
...@@ -116,7 +131,7 @@ describe('撤销整批 —— 「已动过」的判据', () => { ...@@ -116,7 +131,7 @@ describe('撤销整批 —— 「已动过」的判据', () => {
], ],
}); });
const svc = await build(prisma); const svc = await build(prisma);
const r = await svc.revoke(SCOPE, LEADER, 'b1'); const r = await svc.revoke(SCOPE, LEADER, BATCH);
expect(r.revoked).toBe(1); expect(r.revoked).toBe(1);
expect(r.alreadyReleased).toBe(1); expect(r.alreadyReleased).toBe(1);
}); });
...@@ -126,7 +141,7 @@ describe('撤销整批 —— 写入纪律', () => { ...@@ -126,7 +141,7 @@ describe('撤销整批 —— 写入纪律', () => {
test('⭐⭐ 红线②:**不写 release_reason**(那是客服的处置,不是主管的收回)', async () => { test('⭐⭐ 红线②:**不写 release_reason**(那是客服的处置,不是主管的收回)', async () => {
const { prisma, captured } = makePrisma({}); const { prisma, captured } = makePrisma({});
const svc = await build(prisma); const svc = await build(prisma);
await svc.revoke(SCOPE, LEADER, 'b1'); await svc.revoke(SCOPE, LEADER, BATCH);
// 混进去会让退回率的分子分母一起虚高 // 混进去会让退回率的分子分母一起虚高
expect(captured.data).not.toHaveProperty('releaseReason'); expect(captured.data).not.toHaveProperty('releaseReason');
expect(captured.data).not.toHaveProperty('releaseNote'); expect(captured.data).not.toHaveProperty('releaseNote');
...@@ -135,7 +150,7 @@ describe('撤销整批 —— 写入纪律', () => { ...@@ -135,7 +150,7 @@ describe('撤销整批 —— 写入纪律', () => {
test('⭐⭐ 红线③:**不清批次归因三列**(否则这次误操作影响了多少人再也说不清)', async () => { test('⭐⭐ 红线③:**不清批次归因三列**(否则这次误操作影响了多少人再也说不清)', async () => {
const { prisma, captured } = makePrisma({}); const { prisma, captured } = makePrisma({});
const svc = await build(prisma); const svc = await build(prisma);
await svc.revoke(SCOPE, LEADER, 'b1'); await svc.revoke(SCOPE, LEADER, BATCH);
expect(captured.data).not.toHaveProperty('assignmentId'); expect(captured.data).not.toHaveProperty('assignmentId');
expect(captured.data).not.toHaveProperty('assignedBy'); expect(captured.data).not.toHaveProperty('assignedBy');
expect(captured.data).not.toHaveProperty('assignStrategy'); expect(captured.data).not.toHaveProperty('assignStrategy');
...@@ -145,14 +160,14 @@ describe('撤销整批 —— 写入纪律', () => { ...@@ -145,14 +160,14 @@ describe('撤销整批 —— 写入纪律', () => {
test('⭐ 红线④:绝不动 snoozedUntil', async () => { test('⭐ 红线④:绝不动 snoozedUntil', async () => {
const { prisma, captured } = makePrisma({}); const { prisma, captured } = makePrisma({});
const svc = await build(prisma); const svc = await build(prisma);
await svc.revoke(SCOPE, LEADER, 'b1'); await svc.revoke(SCOPE, LEADER, BATCH);
expect(captured.data).not.toHaveProperty('snoozedUntil'); expect(captured.data).not.toHaveProperty('snoozedUntil');
}); });
test('⭐ 账本记 actor = **主管**(不像到期回收那样为 null)—— 撤销是人做的决定,要能追责', async () => { test('⭐ 账本记 actor = **主管**(不像到期回收那样为 null)—— 撤销是人做的决定,要能追责', async () => {
const { prisma, events } = makePrisma({}); const { prisma, events } = makePrisma({});
const svc = await build(prisma); const svc = await build(prisma);
await svc.revoke(SCOPE, LEADER, 'b1'); await svc.revoke(SCOPE, LEADER, BATCH);
expect(events[0]).toMatchObject({ expect(events[0]).toMatchObject({
event: PlanEventType.AUTO_RELEASE, event: PlanEventType.AUTO_RELEASE,
reason: 'revoked', reason: 'revoked',
...@@ -164,16 +179,77 @@ describe('撤销整批 —— 写入纪律', () => { ...@@ -164,16 +179,77 @@ describe('撤销整批 —— 写入纪律', () => {
test('批次头标 revoked + 记录撤销人与时刻', async () => { test('批次头标 revoked + 记录撤销人与时刻', async () => {
const { prisma, headUpdates } = makePrisma({}); const { prisma, headUpdates } = makePrisma({});
const svc = await build(prisma); const svc = await build(prisma);
await svc.revoke(SCOPE, LEADER, 'b1'); await svc.revoke(SCOPE, LEADER, BATCH);
expect(headUpdates[0]).toMatchObject({ status: 'revoked', revokedBy: 'leader-1' }); expect(headUpdates[0]).toMatchObject({ status: 'revoked', revokedBy: 'leader-1' });
}); });
}); });
/**
* 🔴 2026-08-03 线上实测:助手撤销**必然失败**,报「批次 c02e1b80 不存在」。
*
* 根因不在撤销,在界面注入给模型的那句话:`已确认分配:批次 #${id.slice(0,8)}`。
* 模型手里只有 8 位短号,拿短号查主键当然查不到 —— 而卡片上的撤销按钮握着完整 id,
* 所以那条路一直是通的,**测一条就漏了另一条**。
* 修法两层:注入给模型的那份带完整 id(前端),这里再兜底认短号。
*/
describe('撤销整批 —— 批次引用解析(短号)', () => {
test('⭐⭐ 只有 8 位短号也能撤销(模型手上常常只有这个)', async () => {
const { prisma } = makePrisma({});
const svc = await build(prisma);
const r = await svc.revoke(SCOPE, LEADER, SHORT);
expect(r.revoked).toBe(2);
expect(r.assignmentId).toBe(BATCH); // 回的是**完整 id**,不是短号
});
test('界面上带 # 的写法一并认(主管会直接把 `#c02e1b80` 念给助手)', async () => {
const { prisma } = makePrisma({});
const svc = await build(prisma);
await expect(svc.revoke(SCOPE, LEADER, `#${SHORT}`)).resolves.toMatchObject({ revoked: 2 });
});
test('⭐⭐ 短号撞到多个 → **报错不猜**(猜错就是撤了另一批人的单)', async () => {
const { prisma } = makePrisma({ prefixHits: [{ id: BATCH }, { id: `${SHORT}-9999-4000-8000-000000000000` }] });
const svc = await build(prisma);
await expect(svc.revoke(SCOPE, LEADER, SHORT)).rejects.toThrow(/匹配到 2 个批次/);
});
test('⭐ 查不到 → 报错必须说「id 没匹配上」,⛔ 不许给出窗口/状态之类的假原因', async () => {
// 模型实测会照着报错编原因:服务端只说"不存在",它却告诉主管
// 「可能是已经过了可撤销窗口,或者状态已变更」—— 主管照这个去排查是白跑一趟。
const { prisma } = makePrisma({ prefixHits: [] });
const svc = await build(prisma);
await expect(svc.revoke(SCOPE, LEADER, SHORT)).rejects.toThrow(/不是.*撤销窗口超时/);
});
test('⭐ 整句话当参数传进来也能撤 —— 实测模型会传「批次 #c02e1b80」而不是裸 id', async () => {
const { prisma } = makePrisma({});
const svc = await build(prisma);
await expect(svc.revoke(SCOPE, LEADER, `批次 #${SHORT}`)).resolves.toMatchObject({
revoked: 2,
assignmentId: BATCH,
});
});
test('整句里夹着完整 uuid → 直接用它,不走前缀查询', async () => {
const { prisma } = makePrisma({});
const svc = await build(prisma);
const r = await svc.revoke(SCOPE, LEADER, `撤销批次 ${BATCH} 谢谢`);
expect(r.assignmentId).toBe(BATCH);
expect((prisma as unknown as { $queryRaw: jest.Mock }).$queryRaw).not.toHaveBeenCalled();
});
test('太短的前缀直接拒(在几百个批次里撞得到,撞上才报错就晚了)', async () => {
const { prisma } = makePrisma({});
const svc = await build(prisma);
await expect(svc.revoke(SCOPE, LEADER, 'c02e')).rejects.toThrow(/找不到批次 id/);
});
});
describe('撤销整批 —— 授权与时限', () => { describe('撤销整批 —— 授权与时限', () => {
test('⭐ 别人分的批次 → 拒(除非有 PLAN_VIEW_ALL)', async () => { test('⭐ 别人分的批次 → 拒(除非有 PLAN_VIEW_ALL)', async () => {
const { prisma } = makePrisma({ createdBy: 'other-leader' }); const { prisma } = makePrisma({ createdBy: 'other-leader' });
const svc = await build(prisma); const svc = await build(prisma);
await expect(svc.revoke(SCOPE, LEADER, 'b1')).rejects.toThrow(/只能撤销自己发起的批次/); await expect(svc.revoke(SCOPE, LEADER, BATCH)).rejects.toThrow(/只能撤销自己发起的批次/);
}); });
test('有 PLAN_VIEW_ALL → 可撤别人的批次', async () => { test('有 PLAN_VIEW_ALL → 可撤别人的批次', async () => {
...@@ -182,7 +258,7 @@ describe('撤销整批 —— 授权与时限', () => { ...@@ -182,7 +258,7 @@ describe('撤销整批 —— 授权与时限', () => {
const r = await svc.revoke( const r = await svc.revoke(
SCOPE, SCOPE,
{ userId: 'leader-1', permissions: [Permission.PLAN_DISPATCH, Permission.PLAN_VIEW_ALL] }, { userId: 'leader-1', permissions: [Permission.PLAN_DISPATCH, Permission.PLAN_VIEW_ALL] },
'b1', BATCH,
); );
expect(r.revoked).toBe(2); expect(r.revoked).toBe(2);
}); });
...@@ -190,13 +266,13 @@ describe('撤销整批 —— 授权与时限', () => { ...@@ -190,13 +266,13 @@ describe('撤销整批 —— 授权与时限', () => {
test('⭐ 超出撤销窗口 → 拒,并指路到逐条退回(那条路会留下原因)', async () => { test('⭐ 超出撤销窗口 → 拒,并指路到逐条退回(那条路会留下原因)', async () => {
const { prisma } = makePrisma({ ageMinutes: REVOKE_WINDOW_MINUTES + 1 }); const { prisma } = makePrisma({ ageMinutes: REVOKE_WINDOW_MINUTES + 1 });
const svc = await build(prisma); const svc = await build(prisma);
await expect(svc.revoke(SCOPE, LEADER, 'b1')).rejects.toThrow(/撤销窗口/); await expect(svc.revoke(SCOPE, LEADER, BATCH)).rejects.toThrow(/撤销窗口/);
}); });
test('⭐ 幂等:重复撤销不报错(主管手抖点两下很正常)', async () => { test('⭐ 幂等:重复撤销不报错(主管手抖点两下很正常)', async () => {
const { prisma, tx } = makePrisma({ status: 'revoked' }); const { prisma, tx } = makePrisma({ status: 'revoked' });
const svc = await build(prisma); const svc = await build(prisma);
const r = await svc.revoke(SCOPE, LEADER, 'b1'); const r = await svc.revoke(SCOPE, LEADER, BATCH);
expect(r.revoked).toBe(0); expect(r.revoked).toBe(0);
expect(tx.followupPlan.updateMany).not.toHaveBeenCalled(); expect(tx.followupPlan.updateMany).not.toHaveBeenCalled();
}); });
...@@ -205,7 +281,7 @@ describe('撤销整批 —— 授权与时限', () => { ...@@ -205,7 +281,7 @@ describe('撤销整批 —— 授权与时限', () => {
const { prisma } = makePrisma({}); const { prisma } = makePrisma({});
const svc = await build(prisma); const svc = await build(prisma);
await expect( await expect(
svc.revoke(SCOPE, { userId: 'wx:someone', permissions: [Permission.PLAN_DISPATCH] }, 'b1'), svc.revoke(SCOPE, { userId: 'wx:someone', permissions: [Permission.PLAN_DISPATCH] }, BATCH),
).rejects.toThrow(/合成身份/); ).rejects.toThrow(/合成身份/);
}); });
}); });
...@@ -13,7 +13,7 @@ import { PrismaService } from '../src/prisma/prisma.service'; ...@@ -13,7 +13,7 @@ import { PrismaService } from '../src/prisma/prisma.service';
*/ */
const SCOPE = { hostId: 'h1', tenantId: 't1', sourceUnits: [] as string[], clinicIds: [] as string[], userId: 'u1' } as never; const SCOPE = { hostId: 'h1', tenantId: 't1', sourceUnits: [] as string[], clinicIds: [] as string[], userId: 'u1' } as never;
const BATCH = 'b1'; const BATCH = 'b1a55555-1111-4222-8333-444455556666'; // ⚠️ 真 uuid:detail 现在先解析批次引用(短号也认),假 id 两条路都不匹配
function makePrisma(opts: { function makePrisma(opts: {
plans: Array<{ plans: Array<{
......
...@@ -143,7 +143,8 @@ export function AssignmentConfirmSheet({ ...@@ -143,7 +143,8 @@ export function AssignmentConfirmSheet({
state: 'pending' | 'confirmed' | 'cancelled'; state: 'pending' | 'confirmed' | 'cancelled';
assignmentId?: string; assignmentId?: string;
/** 落库成功 → 交给外层把卡片切终态 + 往消息流注入一条文本(补模型记忆) */ /** 落库成功 → 交给外层把卡片切终态 + 往消息流注入一条文本(补模型记忆) */
onConfirmed: (assignmentId: string, summary: string) => void; /** `modelSummary` = 同一句话的**喂模型版**(带完整批次 id,界面不显示) */
onConfirmed: (assignmentId: string, summary: string, modelSummary?: string) => void;
/** /**
* 助手下发的语义编辑指令队列(edit_assignment_sheet)。 * 助手下发的语义编辑指令队列(edit_assignment_sheet)。
* ⚠️ 由**本组件**去匹配姓名 —— 模型手里没有 planId,那是故意的。 * ⚠️ 由**本组件**去匹配姓名 —— 模型手里没有 planId,那是故意的。
...@@ -343,13 +344,22 @@ export function AssignmentConfirmSheet({ ...@@ -343,13 +344,22 @@ export function AssignmentConfirmSheet({
})), })),
}); });
const summary = const summary =
`已确认分配:批次 #${res.assignmentId.slice(0, 8)} · ` + '已确认分配:' +
(sheet.potentialTreatment ? `${sheet.potentialTreatment} · ` : '') + (sheet.potentialTreatment ? `${sheet.potentialTreatment} · ` : '') +
`${res.assigned} 条 · ${groups.length} 位客服 · ${expiresInDays} 天有效` + `${res.assigned} 条 · ${groups.length} 位客服 · ${expiresInDays} 天有效` +
(dropped.size ? ` · 主管移除 ${dropped.size} 条` : '') + (dropped.size ? ` · 主管移除 ${dropped.size} 条` : '') +
(res.skipped.length ? ` · ${res.skipped.length} 条未落(确认期间被他人认领)` : ''); (res.skipped.length ? ` · ${res.skipped.length} 条未落(确认期间被他人认领)` : '');
setConfirmedAt(Date.now()); setConfirmedAt(Date.now());
onConfirmed(res.assignmentId, summary); // 🔴 模型那份**必须带完整 uuid**:它要撤销只能靠这里拿 id。
// 此前这句写的是 `批次 #${id.slice(0,8)}`,两边共用 —— 于是模型手里只有 8 位短号,
// 一调 revoke_assignment 就报「批次不存在」,而卡片上的撤销按钮握着完整 id 一直是好的,
// 两条路测一条就漏了(实测 2026-08-03)。
// ⚠️ 界面这份**不显示 id**:uuid 对主管没有意义,短号则会诱导他把短号念给助手。
onConfirmed(
res.assignmentId,
summary,
`${summary}(批次 id ${res.assignmentId} —— 主管要撤销这批时,把这个完整 id 传给 revoke_assignment)`,
);
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : '确认失败'); setError(e instanceof Error ? e.message : '确认失败');
} finally { } finally {
......
...@@ -134,7 +134,7 @@ function BlockView({ ...@@ -134,7 +134,7 @@ function BlockView({
onSheetEdited, onSheetEdited,
}: { }: {
block: Block; block: Block;
onSheetConfirmed?: (requestId: string, assignmentId: string, summary: string) => void; onSheetConfirmed?: (requestId: string, assignmentId: string, summary: string, modelSummary?: string) => void;
/** 助手改确认单后,界面回报的那一句(注入消息流) */ /** 助手改确认单后,界面回报的那一句(注入消息流) */
onSheetEdited?: (summary: string) => void; onSheetEdited?: (summary: string) => void;
}) { }) {
...@@ -152,7 +152,9 @@ function BlockView({ ...@@ -152,7 +152,9 @@ function BlockView({
// 执行结果回一句进对话:既给主管看,也补进模型上下文 —— // 执行结果回一句进对话:既给主管看,也补进模型上下文 ——
// 否则它不知道到底成没成(可能有人没找到),下一句就会替系统撒谎 // 否则它不知道到底成没成(可能有人没找到),下一句就会替系统撒谎
onEditApplied={onSheetEdited} onEditApplied={onSheetEdited}
onConfirmed={(id, summary) => onSheetConfirmed?.(block.requestId, id, summary)} onConfirmed={(id, summary, modelSummary) =>
onSheetConfirmed?.(block.requestId, id, summary, modelSummary)
}
/> />
); );
} }
...@@ -372,7 +374,7 @@ function MessageView({ ...@@ -372,7 +374,7 @@ function MessageView({
}: { }: {
message: ChatMessage; message: ChatMessage;
streaming?: boolean; streaming?: boolean;
onSheetConfirmed?: (requestId: string, assignmentId: string, summary: string) => void; onSheetConfirmed?: (requestId: string, assignmentId: string, summary: string, modelSummary?: string) => void;
/** 助手改确认单后,界面回报的那一句(注入消息流) */ /** 助手改确认单后,界面回报的那一句(注入消息流) */
onSheetEdited?: (summary: string) => void; onSheetEdited?: (summary: string) => void;
}) { }) {
...@@ -514,9 +516,15 @@ export function AssistantChat({ ...@@ -514,9 +516,15 @@ export function AssistantChat({
* 不注入的话下一轮主管说「刚才那批改成 5 天」,模型手里没有"那批"的任何痕迹, * 不注入的话下一轮主管说「刚才那批改成 5 天」,模型手里没有"那批"的任何痕迹,
* 会当成新需求重新提议一次。改动极小但极易漏,漏了会被当成模型能力问题。 * 会当成新需求重新提议一次。改动极小但极易漏,漏了会被当成模型能力问题。
*/ */
const onSheetConfirmed = (requestId: string, assignmentId: string, summary: string) => { const onSheetConfirmed = (
requestId: string,
assignmentId: string,
summary: string,
modelSummary?: string,
) => {
settleSheet(requestId, assignmentId); settleSheet(requestId, assignmentId);
appendAssistantNote(summary); // ⭐ 第二个参数带完整批次 id —— 模型撤销时唯一的 id 来源(见 assignment-confirm-sheet)
appendAssistantNote(summary, modelSummary);
}; };
/** /**
* 助手改完确认单,界面把**实际结果**回一句进消息流。 * 助手改完确认单,界面把**实际结果**回一句进消息流。
......
...@@ -28,7 +28,16 @@ export interface Artifact { ...@@ -28,7 +28,16 @@ export interface Artifact {
} }
export type Block = export type Block =
| { kind: 'text'; text: string } /**
* 文本块。`modelText` = **只给模型看**的版本(缺省时模型看 `text`)。
*
* 🔴 为什么需要两份:确认后注入的那句话里要带**完整批次 uuid**,否则模型撤销时
* 只能拿界面上的 8 位短号去调 `revoke_assignment` —— 实测必然报「批次不存在」。
* 但 uuid 对主管毫无意义,摆在对话里纯属噪音。⇒ 显示一份、喂模型一份。
* ⚠️ 用它承载的必须是**同一件事的两种措辞**,⛔ 不许塞模型专属的隐藏指令:
* 主管看不见的话就无法纠正,那是"界面替主管说话"。
*/
| { kind: 'text'; text: string; modelText?: string }
| { kind: 'tool'; step: ToolStep } | { kind: 'tool'; step: ToolStep }
| { kind: 'artifact'; artifact: Artifact } | { kind: 'artifact'; artifact: Artifact }
/** /**
...@@ -134,7 +143,8 @@ function upsertArtifact( ...@@ -134,7 +143,8 @@ function upsertArtifact(
function toApiMessage(m: ChatMessage): { role: 'user' | 'assistant'; content: string } | null { function toApiMessage(m: ChatMessage): { role: 'user' | 'assistant'; content: string } | null {
const text = m.blocks const text = m.blocks
.filter((b): b is Extract<Block, { kind: 'text' }> => b.kind === 'text') .filter((b): b is Extract<Block, { kind: 'text' }> => b.kind === 'text')
.map((b) => b.text) // ⭐ modelText 优先 —— 界面显示"批次已确认",模型拿到的那份还带着完整 uuid
.map((b) => b.modelText ?? b.text)
.join('') .join('')
.trim(); .trim();
if (!text) return null; if (!text) return null;
...@@ -345,6 +355,59 @@ export function useAssistantChat() { ...@@ -345,6 +355,59 @@ export function useAssistantChat() {
} }
}; };
/**
* 🔴🔴 **对账:说撤销了,就必须真调过工具。**
*
* 2026-08-03 实测(通义千问):主管说「撤销批次 #a7e1b6de」,助手回
* 「已撤销批次:收回 9 条。」—— 而库里那批仍是 confirmed,9 条工单**一条没动**,
* 卡片也还写着「已分配」。它**根本没调 revoke_assignment**,把成品话术**背了出来**。
* (工具的返回值被设计成"成品句子、原话转述",模型学会形状后就能凭空生成。)
*
* 这比报错更危险:报错主管会重试,而"假成功"会让他**停止补救** ——
* 他以为 9 个人回池了,实际还挂在客服名下,而 3 天后到期才会被发现。
*
* ⚠️ 这里**只陈述一个观察到的事实**(本轮没调过撤销工具),⛔ 不断言助手在撒谎 ——
* 主管问「刚才那批撤了吗」时,助手如实回顾**也**不会调工具,那句回顾是对的。
* 两种情形界面分不出来,所以两种都说得通的措辞才是诚实的。
* ⚠️ 因此还要求**本轮主管确实在要求撤销**,把纯回顾挡在外面。
* ⚠️ 判据用**工具事件**而不是"卡片状态",因为卡片可能压根不在本轮消息里。
* ⛔ 不要改成"帮他补调一次工具" —— 撤销是写操作,不能由界面替主管做决定。
*/
const assertRevokeActuallyHappened = () => {
// 主管这一轮是不是在**要求**撤销(而不是在问结果)
if (!/撤销|撤回|收回/.test(trimmed)) return;
setMessages((prev) => {
const last = prev[prev.length - 1];
if (!last || last.role !== 'assistant') return prev;
const said = last.blocks
.filter((b): b is Extract<Block, { kind: 'text' }> => b.kind === 'text')
.map((b) => b.text)
.join('');
// 只认"已经做完"的说法;「要我撤销吗」这类询问不算
if (!/已撤销|撤销成功|已收回|已经撤回|已撤回/.test(said)) return prev;
const called = last.blocks.some(
(b) => b.kind === 'tool' && b.step.tool === 'revoke_assignment' && b.step.status === 'done',
);
if (called) return prev;
return [
...prev.slice(0, -1),
{
...last,
blocks: [
...last.blocks,
{
kind: 'text' as const,
text:
'\n\n⚠️ **这一轮助手没有调用撤销工具。** 若你刚才要的是撤销,那它没有执行 ——' +
'这批还在客服手上,请点确认单卡片上的『撤销这批』,或再说一次「撤销刚才那批」。' +
'(若只是在回顾之前撤过的批次,可忽略这句。)',
},
],
},
];
});
};
const controller = new AbortController(); const controller = new AbortController();
abortRef.current = controller; abortRef.current = controller;
try { try {
...@@ -384,6 +447,7 @@ export function useAssistantChat() { ...@@ -384,6 +447,7 @@ export function useAssistantChat() {
} }
} }
} }
assertRevokeActuallyHappened();
setStatus('idle'); setStatus('idle');
} catch (err) { } catch (err) {
if ((err as Error).name !== 'AbortError') { if ((err as Error).name !== 'AbortError') {
...@@ -407,11 +471,14 @@ export function useAssistantChat() { ...@@ -407,11 +471,14 @@ export function useAssistantChat() {
* 不注入的话,下一轮主管说「刚才那批改成 5 天」,模型手里完全没有"那批"的痕迹, * 不注入的话,下一轮主管说「刚才那批改成 5 天」,模型手里完全没有"那批"的痕迹,
* 会当成新需求重新提议一次。改动很小但极易漏,漏了会被当成模型能力问题。 * 会当成新需求重新提议一次。改动很小但极易漏,漏了会被当成模型能力问题。
*/ */
const appendAssistantNote = useCallback((text: string) => { const appendAssistantNote = useCallback((text: string, modelText?: string) => {
setMessages((prev) => { setMessages((prev) => {
if (prev.length === 0) return prev; if (prev.length === 0) return prev;
const last = prev[prev.length - 1]!; const last = prev[prev.length - 1]!;
return [...prev.slice(0, -1), { ...last, blocks: [...last.blocks, { kind: 'text', text }] }]; return [
...prev.slice(0, -1),
{ ...last, blocks: [...last.blocks, { kind: 'text', text, ...(modelText ? { modelText } : {}) }] },
];
}); });
}, []); }, []);
......
...@@ -222,6 +222,36 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾 ...@@ -222,6 +222,36 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
> 症状是"确认完撤销按钮压根不出现",但真正丢的远不止那一个按钮。 > 症状是"确认完撤销按钮压根不出现",但真正丢的远不止那一个按钮。
> 现在确认单按 `requestId` 做 key。⛔ 凡是**带本地状态**的 block,key 都不能跟着位置走。 > 现在确认单按 `requestId` 做 key。⛔ 凡是**带本地状态**的 block,key 都不能跟着位置走。
> 🔴🔴 **踩过的坑:注入给模型的那句话把批次号截成了 8 位。**
> 确认后往消息流注入的是 `已确认分配:批次 #${id.slice(0,8)} …` —— 界面和模型**共用这一份**。
> 于是模型手里**只有短号**,主管一说「撤销」,它拿短号调 `revoke_assignment`,
> 服务端按主键精确查,必然报「批次不存在」。而卡片上的撤销按钮握着完整 id 一直是好的,
> **测了卡片那条就以为整条路都通**。
> 修法两层:① 文本块加 `modelText`(界面显示一份、喂模型一份,后者带完整 uuid);
> ② 服务端 `resolveAssignmentId` **兜底认短号**,还要能从「批次 #c02e1b80」这种整句里抠出 id
> (实测模型就这么传)——撞到多个**报错不猜**(撤销是写操作,猜错就是撤了另一批人的单)。
> ⛔ 别只做 ①:短号会出现在对话历史、截图、主管口述里,模型没法把它还原成 uuid。
> 🔴🔴🔴 **最危险的一次:助手把「已撤销」**背**了出来。**
> 主管说「撤销批次 #a7e1b6de」,助手回 **「已撤销批次:收回 9 条。」** ——
> 而库里那批仍是 `confirmed`,9 条工单**一条没动**,卡片也还写着「已分配」。
> 它**根本没调 `revoke_assignment`**。
> 根因是我们自己给的便利:工具返回值被设计成「成品句子、原话转述」,
> 模型见过几次形状后**不调工具也能生成得一模一样**(连条数都算对了)。
>
> 这比报错危险得多:**报错主管会重试,假成功会让他停止补救** ——
> 他以为 9 个人回池了,实际还挂在客服名下,3 天后到期才会暴露。
>
> ⇒ 前端在每轮结束时**对账**:主管这轮在要求撤销 + 助手自称撤成了 + 本轮没有
> `revoke_assignment` 的 `tool_result` ⇒ 当场把事实贴出来。
> ⚠️ 措辞只**陈述观察到的事实**("这一轮没有调用撤销工具"),⛔ 不断言助手撒谎 ——
> 主管问「刚才那批撤了吗」时助手如实回顾**也**不调工具,界面分不出这两种,
> 两种都说得通的措辞才是诚实的。
> ⛔ 不许改成"界面帮他补调一次" —— 撤销是写操作,不能由界面替主管做决定。
>
> 📌 更一般的教训:**凡是把"成品话术"交给模型转述的工具,都要有独立的执行证据**。
> 话术越顺口,模型越容易在没执行的情况下把它说出来。
⚠️ 移除客服 = **他名下的条目一并移出本批**(患者留在池子里,下批再说), ⚠️ 移除客服 = **他名下的条目一并移出本批**(患者留在池子里,下批再说),
必须有二次确认并把这句话写在弹窗里 —— 否则主管以为只是把人从名单上划掉, 必须有二次确认并把这句话写在弹窗里 —— 否则主管以为只是把人从名单上划掉,
回头发现少了 20 个患者。要把这些人交给别人:先拖走,或回对话让助手重出一版。 回头发现少了 20 个患者。要把这些人交给别人:先拖走,或回对话让助手重出一版。
......
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