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,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