Commit 245f0b01 by luoqi

fix(助手): 「这批都给某某」改不动确认单 —— 三层各错一处,且都不报错

本批 5 人,主管说「这5人都分配给韩维」。界面回「已把整批铺给 1 位客服()」,
括号是空的:一条都没动,而助手接着说「这版已经改成 5 人都归韩维」。

三层:

① schema 判断本身就是错的
   `batch + assign` 被 refine 判非法。而 batch 是这句话唯一自然的表达,
   韩维就印在卡片下面那行「本批未分到」里。
   改判:batch + balance 合法;只有 batch + owner 不行 ——
   专属关系只有「待分配」那份数据带着(pending[].ownerUserId),
   已排好的条目卡片上查不到它归谁专属。

② 那条 refine 一次都没跑过(根因,不是 ① )
   两个改单工具的入参用的是**手写的 JSON Schema**(给模型看的那份),
   zod 那份从没进过运行时。两份 schema 各写各的,
   写在没跑的那份里的约束只是注释。
   ⇒ 加 `checkEditOps`:推给界面之前按 zod 校一遍,校不过整组退回给模型,
   并说清哪一条错在哪(只说「参数错误」它会原样再发一遍)。
   确认单、调整单同一条路。

③ 界面把 batch 解成空名单
   `return { planIds: [], label: '整批' }`,注释说"只有 set_expiry /
   set_benefit 走得到这里" —— 而那两件在上面就短路了(走批次级控件),
   真正走到这一行的恰恰是 assign / remove。
   改:整批 = 生效条目 + 还留在待分配里的,与顶栏「共 N 人」同一口径。
   另加一道总闸:选中 0 人就到此为止 —— 下面每个分支都会 push 一句
   "已如何如何",一条没动而主管读到的是成功。

顺带(同一类):owner 分不下去的两种原因分开报。
「卡片没带这条的专属关系」被报成「查不到在岗的专属客服」,
那是一句关于数据的断言,而卡片没有资格下这个断言。

- 整批移出仍然拦掉,但界面要吭声, 不许静默什么都不做
- 工具描述补 batch 那一格该怎么填;ASSISTANT_PROMPT_VERSION → 2026-08-22-a
- 新增 sheet-edit-batch-assign.spec.ts(14 条),改判 mcp-clinic-scope 两条旧断言

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 82282b6c
......@@ -123,7 +123,7 @@ import { Permission } from '@pac/types';
*/
// ⚠️ `-b` 是 2026-08-17 那次「纪律挂在组上」的实验,已 revert(`git show 82f357e`)——
// ⛔ 别复用它:库里有那一版的 `agent_invocations` 行,复用等于两份不同正文同名。
export const ASSISTANT_PROMPT_VERSION = 'assistant@2026-08-20-f';
export const ASSISTANT_PROMPT_VERSION = 'assistant@2026-08-22-a';
/**
* ① 装置 —— 你是谁、和使用者什么关系、你看不见什么。
......
......@@ -5,6 +5,8 @@ import { randomUUID } from 'node:crypto';
import type { Prisma } from '@prisma/client';
import {
Permission,
RearrangeEditOpSchema,
SheetEditOpSchema,
TEMPERATURE_TOOL_DESC,
TEMPERATURE_TOOL_VALUES,
type SheetSnapshot,
......@@ -58,6 +60,39 @@ import type { AppConfig } from '../../config/configuration';
*/
export const MAX_TOOL_STEPS = 16;
/**
* 🔴 **改单指令落地前先按 zod 校一遍** —— 校不过就退回给模型,⛔ 不推给界面。
*
* 由来(2026-08-22 实测):本批 5 人,主管说「这 5 人都分配给韩维」。
* 模型给了 `select:{group:'batch'}` + `action:'assign'` —— 表达是对的,
* 而当时 `SheetEditOpSchema` 里恰好有一条 refine 判它非法。
* 结果这条 refine **一次都没跑过**:两个改单工具的入参用的是**手写的 JSON Schema**
* (给模型看的那份),zod 那份从来没在运行时参与过。
* 于是指令原样推给了界面,界面解不出人,照样回了一句「已把整批铺给 1 位客服()」。
*
* ⇒ 两份 schema 只要有一份不落到运行时,它写的约束就只是注释。
* ⚠️ 一条不合法就**整组退回**:半组落地等于把主管的一句话执行一半,
* 而他看到的是"改好了"(正是这次事故的形状)。
* ⚠️ 返回值是给**模型**看的:说清哪一条、错在哪,它下一轮才改得对;
* ⛔ 别只回"参数错误",那它只会原样再发一遍。
*/
function checkEditOps(
ops: unknown[],
schema: { safeParse: (v: unknown) => { success: boolean; error?: { issues: { message: string }[] } } },
): string | null {
const bad: string[] = [];
ops.forEach((op, i) => {
const r = schema.safeParse(op);
if (!r.success) {
const why = (r.error?.issues ?? []).map((x) => x.message).join(';') || '参数不合法';
bad.push(`第 ${i + 1} 条:${why}`);
}
});
return bad.length
? JSON.stringify({ 没有执行: true, 单子未改动: true, 问题: bad })
: null;
}
/** 桌宠"小牙"的人设(pet-say 专用,无工具、极短输出)。 */
const PET_SYSTEM_PROMPT = `你是牙科客服工作台 PAC 的桌面宠物"小牙"——一颗 Q 版小磨牙。
根据给你的环境观察,用第一人称说一句话:中文,不超过 30 个字,口语化、俏皮但不油腻,最多一个 emoji。
......@@ -812,7 +847,12 @@ export class AssistantService {
enum: ['patients', 'agent', 'pending', 'batch'],
description:
'patients=点名的这几位患者;agent=某位客服名下的全部;' +
'pending=待分配那一组;batch=整批。' +
'pending=待分配那一组;' +
'batch=整批,卡片上此刻还在的所有人(已排好的 + 还在待分配里的)。' +
'\n他说「这批都给某某 / 全部给某某 / 这 N 人都分给某某」用 batch,' +
'N 就是卡片顶上那个「共 N 人」。' +
'batch 配 set_expiry 改的是整批时限,配 set_benefit 设的是本批福利;' +
'batch 配 owner 做不到,专属关系只有待分配那组带着。' +
'\n「把某某移出这批」里的某某默认是**患者**。' +
'只有他明说「客服某某不参与 / 某某的单都别给他」才用 agent ——' +
'agent 一次动的是几十条,而同一个名字可能既是本批客服又是本批患者,' +
......@@ -889,6 +929,8 @@ export class AssistantService {
execute: async (args: unknown) => {
const ops = (args as { ops?: unknown[] })?.ops;
if (!Array.isArray(ops) || ops.length === 0) return '没有给出任何操作,确认单未改动。';
const bad = checkEditOps(ops, SheetEditOpSchema);
if (bad) return bad;
push({ type: 'assignment_sheet_edit', ops });
/**
* ⚠️ 只回**事实**,⛔ 一个字的指令都不许有。
......@@ -1071,6 +1113,8 @@ export class AssistantService {
execute: async (args: unknown) => {
const ops = (args as { ops?: unknown[] })?.ops;
if (!Array.isArray(ops) || ops.length === 0) return '没有给出任何操作,调整单未改动。';
const bad = checkEditOps(ops, RearrangeEditOpSchema);
if (bad) return bad;
/**
* 🔴 **⛔ 这里绝不能拦「本轮还没出过调整单」**(2026-08-20 实测栽过)。
*
......
......@@ -1014,7 +1014,16 @@ describe('确认单指令 —— 三个正交的轴', () => {
['set_benefit 没给 text', { select: { group: 'batch' }, action: 'set_benefit' }],
['福利挂到了个人头上', { select: { group: 'patients', patients: ['王强'] }, action: 'set_benefit', text: 'x' }],
['整批"移出自己"', { select: { group: 'batch' }, action: 'remove' }],
['整批当改派对象', { select: { group: 'batch' }, action: 'assign', to: { mode: 'balance' } }],
/**
* 🔴 2026-08-22 改判:**整批当改派对象是对的**,拒掉的是 batch + owner。
* 实测本批 5 人,主管说「这5人都分配给韩维」—— batch + balance 是这句话
* 唯一自然的表达。原来这条把它判成非法,而那条判断**一次都没跑过**
* (工具入参走的是手写 JSON Schema),于是指令原样进了界面,
* 界面解出空名单还回了一句「已把整批铺给 1 位客服()」。
* 完整回归见 `sheet-edit-batch-assign.spec.ts`。
* ⚠️ owner 仍然不行:专属关系只有「待分配」那份数据带着。
*/
['整批配 owner(专属关系只有待分配那组带着)', { select: { group: 'batch' }, action: 'assign', to: { mode: 'owner' } }],
['patients 一个都没给', { select: { group: 'patients', patients: [] }, action: 'remove' }],
])('⛔ %s → schema 直接拒掉', (_why, op) => {
expect(ok(op)).toBe(false);
......@@ -1048,8 +1057,13 @@ describe('确认单指令 —— 三个正交的轴', () => {
test('🔴🔴 界面侧 owner:收的人必须是患者**自己的** ownerUserId', () => {
expect(SHEET).toMatch(/moveDraft\[id\] = owner\.id/);
// ⛔ 专属客服不在名册的分不下去,如实报数,别顺手改派给别人
expect(SHEET).toMatch(/查不到在岗的专属客服,没动他们/);
/**
* ⛔ 分不下去的如实报数,别顺手改派给别人。
* ⚠️ 2026-08-22 拆成两句:「专属客服不在名册」与「卡片没带这条的专属关系」
* 是两回事,报成同一句等于替数据下结论(说人家没有专属客服)。
*/
expect(SHEET).toMatch(/专属客服不在本批名册里,没动他们/);
expect(SHEET).toMatch(/卡片上不带他们的专属关系,没动/);
});
test('⭐ 两种铺法的回话措辞必须能一眼分开', () => {
......
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { SheetEditOpSchema } from '@pac/types';
/**
* 「整批分给一个人」——「已把整批铺给 1 位客服()」的回归(2026-08-22 实测)。
*
* ── 事故经过 ──────────────────────────────────────────────────────
* 本批只有 5 人(已排好 4、待分配 1)。主管说「这5人都分配给韩维」。
* 模型给了 `select:{group:'batch'}` + `action:'assign'` + `to:{mode:'balance',agents:['韩维']}`
* —— 这是这句话唯一自然的表达,韩维就印在卡片下面那行「本批未分到」里。
*
* 界面回:「确认单已更新:已把整批按谁手上少先给谁铺给 1 位客服()。」
* 那对括号是空的。一条都没动,而助手接着说「这版已经改成 5 人都归韩维」。
*
* ── 三层各错一处,每一处单独看都"没报错" ──────────────────────────
* ① `SheetEditOpSchema` 有一条 refine 判 batch+assign 非法 —— 判断本身就是错的
* ② 那条 refine **一次都没跑过**:改单工具的入参是**手写的 JSON Schema**,
* zod 那份从没进过运行时。两份 schema 各写各的,写在没跑的那份里的约束只是注释。
* ③ 界面把 batch 解成**空名单**(`planIds: []`),然后每个分支都照样 push 一句"已如何如何"。
*
* 🔴 最坏的地方:三层都没抛错,主管读到的是**成功**。
* 与 mcp-clinic-scope 那次(编了个诊所 id → 返回 0 → 模型去解释"为什么是空的")同一个形状:
* 一个合法的空结果,比一个报错危险得多。
*/
const ASSISTANT = readFileSync(
join(__dirname, '../src/modules/assistant/assistant.service.ts'),
'utf8',
);
const SHEET = readFileSync(
join(__dirname, '../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx'),
'utf8',
);
describe('① schema:整批可以作为改派的对象', () => {
test('⭐⭐ batch + assign + balance —— 主管说「这批都给某某」', () => {
const r = SheetEditOpSchema.safeParse({
select: { group: 'batch' },
action: 'assign',
to: { mode: 'balance', agents: ['韩维'] },
});
expect(r.success).toBe(true);
});
test('batch + assign + balance 不点名(整批铺给全体在岗)也合法', () => {
expect(
SheetEditOpSchema.safeParse({
select: { group: 'batch' },
action: 'assign',
to: { mode: 'balance' },
}).success,
).toBe(true);
});
/**
* ⚠️ owner 是**唯一**做不到的那个:专属关系只有「待分配」那份数据带着
* (`pending[].ownerUserId`),已排好的条目卡片上查不到它归谁专属。
* 放它过去的话那几条会被报成"查不到在岗的专属客服" —— 那是一句关于数据的断言。
*/
test('⭐ batch + owner 仍然拦掉,且说清该改用什么', () => {
const r = SheetEditOpSchema.safeParse({
select: { group: 'batch' },
action: 'assign',
to: { mode: 'owner' },
});
expect(r.success).toBe(false);
const msg = r.success ? '' : r.error.issues.map((i) => i.message).join('|');
expect(msg).toContain('pending');
});
test('batch + remove 仍然拦掉 —— 整批移出等于把这批全作废', () => {
expect(
SheetEditOpSchema.safeParse({ select: { group: 'batch' }, action: 'remove' }).success,
).toBe(false);
});
test('pending / agent + owner 不受影响', () => {
expect(
SheetEditOpSchema.safeParse({
select: { group: 'pending' },
action: 'assign',
to: { mode: 'owner' },
}).success,
).toBe(true);
});
});
describe('② 运行时:zod 必须真的跑', () => {
/**
* 🔴 这条是本次事故的**根因**,不是 batch 那条判断错。
* 判断错了随时能改,而"写在没跑的那份 schema 里"意味着**下一条约束还会这样**。
*/
test('⭐⭐ 两个改单工具都在推给界面之前按 zod 校一遍', () => {
expect(ASSISTANT).toContain('checkEditOps(ops, SheetEditOpSchema)');
expect(ASSISTANT).toContain('checkEditOps(ops, RearrangeEditOpSchema)');
for (const [schema, evt] of [
['SheetEditOpSchema', "push({ type: 'assignment_sheet_edit'"],
['RearrangeEditOpSchema', "push({ type: 'rearrange_sheet_edit'"],
] as const) {
const gate = ASSISTANT.indexOf(`checkEditOps(ops, ${schema})`);
const push = ASSISTANT.indexOf(evt);
expect(gate).toBeGreaterThan(0);
expect(push).toBeGreaterThan(gate); // ⛔ 校验必须在 push 之前
}
});
test('校不过整组退回,⛔ 不许落一半', () => {
// 半组落地 = 把主管的一句话执行一半,而他看到的是"改好了"
expect(ASSISTANT).toContain('单子未改动');
expect(ASSISTANT).toMatch(/if \(bad\) return bad;/);
});
test('退回给模型的话要说清哪一条错在哪 —— 只说"参数错误"它会原样再发一遍', () => {
expect(ASSISTANT).toMatch(/第 \$\{i \+ 1\} 条/);
});
});
describe('③ 界面:batch 解出来的是真名单,选中 0 人不许回报成功', () => {
test('⭐⭐ ⛔ 不许再把整批解成空名单', () => {
expect(SHEET).not.toContain("return { planIds: [], label: '整批' }");
});
test('⭐ 整批 = 生效条目 + 还留在待分配里的(与顶栏「共 N 人」同一口径)', () => {
expect(SHEET).toContain('const whole = [');
expect(SHEET).toContain('...pendingRest().map((p) => p.planId)');
expect(SHEET).toMatch(/label: `整批 \$\{whole\.length\} 人`/);
});
test('⭐⭐ 选中 0 个人就到此为止 —— 下面每个分支都会 push 一句"已如何如何"', () => {
expect(SHEET).toContain('if (sel.planIds.length === 0)');
expect(SHEET).toContain('没有可动的人,没有执行');
});
test('整批移出要吭声,⛔ 不许静默什么都不做', () => {
expect(SHEET).toContain("op.action === 'remove' && op.select.group === 'batch'");
expect(SHEET).toContain('整批移出等于把这批全作废了');
});
/**
* ⚠️ 「分不下去」有两种,报成同一句就等于替数据下结论:
* 卡片没带这条的专属关系 ≠ 这个人没有专属客服。
*/
test('owner 分不下去的两种原因分开报', () => {
expect(SHEET).toContain('let offRoster = 0');
expect(SHEET).toContain('let unknown = 0');
expect(SHEET).not.toContain('查不到在岗的专属客服,没动他们');
});
});
describe('④ 给模型的那份描述要跟得上', () => {
test('⭐ batch 那一格要说明它是整批、以及配什么动作', () => {
expect(ASSISTANT).toContain('batch=整批');
expect(ASSISTANT).toContain('batch 配 owner 做不到');
});
});
......@@ -917,8 +917,23 @@ export function AssignmentConfirmSheet({
}
return { planIds: rest.map((p) => p.planId), label: `「待分配」的 ${rest.length} ` };
}
// batch:整批 —— 只有 set_expiry / set_benefit 走得到这里(schema 已拦住其余)
return { planIds: [], label: '整批' };
/**
* batch:整批 —— **卡片上此刻还在的所有人**,已排好的 + 还留在「待分配」里的。
*
* 🔴 2026-08-22 实测:这里原来写死 `planIds: []`,注释说"只有 set_expiry /
* set_benefit 走得到"。但那两件在上面就短路了(走批次级控件),
* 真正走到这一行的恰恰是 `assign` / `remove`。
* 本批 5 人,主管说「这 5 人都分配给韩维」,模型给了 batch + assign ——
* 空名单进了铺平分支,`targets` 有韩维、`next` 一条没有,
* 于是回了「已把整批铺给 1 位客服()」:括号是空的,人一个没动,而话说的是成了。
* ⚠️ 口径与卡片顶栏那行「共 N 人 · 已排好 X · 待分配 Y」**必须一致** ——
* 主管说"这 N 人"指的就是他看着的那个数。
*/
const whole = [
...items.filter((i) => !dropDraft.has(i.planId)).map((i) => i.planId),
...pendingRest().map((p) => p.planId),
];
return { planIds: whole, label: `整批 ${whole.length} ` };
};
// ── ② 干什么 ───────────────────────────────────────────────
......@@ -932,8 +947,27 @@ export function AssignmentConfirmSheet({
notes.push(`整批时限改为 ${op.days} `);
continue;
}
/**
* ⛔ 整批移出 = 把这批全作废,而卡片上没有"撤销"。
* ⚠️ 落在这里说明它绕过了 schema(手写的那份 JSON Schema 表达不了这条约束)——
* ⛔ 那也不许静默:什么都不做而不吭声,正是这次事故的形状。
*/
if (op.action === 'remove' && op.select.group === 'batch') {
notes.push('整批移出等于把这批全作废了,没有执行 —— 不想发就别按确认,要移出哪几位请点名');
continue;
}
const sel = resolve();
if (!sel) continue;
/**
* 🔴 **选中 0 个人就别再往下走**(2026-08-22)。
* 下面每个分支都会 `notes.push` 一句"已如何如何",而实际一条没动 ——
* 主管读到的是成功。⛔ 宁可说"没找到人",不许说空话。
*/
if (sel.planIds.length === 0) {
notes.push(`${sel.label}里没有可动的人,没有执行`);
continue;
}
if (op.action === 'remove') {
for (const id of sel.planIds) dropDraft.add(id);
......@@ -991,11 +1025,24 @@ export function AssignmentConfirmSheet({
(sheet.pending ?? []).map((p) => [p.planId, { id: p.ownerUserId, name: p.ownerName }]),
);
const done = new Map<string, number>();
let orphan = 0;
/**
* 🔴 **分不下去有两种,⛔ 不能报成同一句**(2026-08-22)。
* · `offRoster` —— 查到了专属客服,但他不在本批名册里(已离岗 / 本期不管)
* · `unknown` —— 卡片**根本没带这条的专属关系**:已经排好的条目不带
* `ownerUserId`(只有「待分配」那份数据带着)。
* 原来两者都报「查不到在岗的专属客服」—— 后者读起来像"这些人没有专属客服",
* 那是一句关于数据的断言,而卡片没有资格下这个断言。
*/
let offRoster = 0;
let unknown = 0;
for (const id of sel.planIds) {
const owner = ownerOf.get(id);
if (!owner?.id || !roster.has(owner.id)) {
orphan++;
if (!owner) {
unknown++;
continue;
}
if (!owner.id || !roster.has(owner.id)) {
offRoster++;
continue;
}
moveDraft[id] = owner.id;
......@@ -1003,11 +1050,15 @@ export function AssignmentConfirmSheet({
done.set(key, (done.get(key) ?? 0) + 1);
}
const detail = [...done].map(([n, c]) => `${n} ${c} `).join('、');
const tail =
(offRoster > 0 ? `;另有 ${offRoster} 人的专属客服不在本批名册里,没动他们` : '') +
(unknown > 0
? `;还有 ${unknown} 人是已经排好的条目,卡片上不带他们的专属关系,没动`
: '');
notes.push(
done.size === 0
? `一个也没分下去:${sel.label}查不到在本批名册里的专属客服(可能已离岗)`
: `已把${sel.label}中的 ${sel.planIds.length - orphan} **各自还给自己的专属客服**(${detail})` +
(orphan > 0 ? `;另有 ${orphan} 人查不到在岗的专属客服,没动他们` : ''),
? `一个也没分下去:${sel.label}${tail.replace(/^;/, '')}`
: `已把${sel.label}中的 ${sel.planIds.length - offRoster - unknown} **各自还给自己的专属客服**(${detail})${tail}`,
);
continue;
}
......
......@@ -1082,7 +1082,17 @@ export const SheetSelectSchema = z.discriminatedUnion('group', [
z.object({ group: z.literal('agent'), agent: z.string().describe('客服姓名') }),
/** 「待分配」那一组的全部(专属客服排满、还没落到人头上的那些) */
z.object({ group: z.literal('pending') }),
/** 整批 —— 只对 `set_expiry` / `set_benefit` 有意义 */
/**
* 整批 —— 卡片上此刻还在的所有人(已排好的 + 还留在「待分配」里的)。
*
* 🔴 2026-08-22 实测补的:本批只有 5 人,主管说「这 5 人都分配给韩维」,
* 模型选了 `batch` + `assign`(整批是这句话唯一自然的表达),
* 而当时这一条被写死成"只对 set_expiry / set_benefit 有意义" ——
* 界面把它解成**空名单**,照样回了一句「已把整批铺给 1 位客服()」。
* 那对括号里是空的:一个人都没动,而回报说成了。
* ⚠️ `set_expiry` / `set_benefit` 配 batch 走的是**批次级**的那两个控件
* (整批时限 / 本批福利),⛔ 不逐条落到 planId 上。
*/
z.object({ group: z.literal('batch') }),
]);
......@@ -1136,8 +1146,17 @@ export const SheetEditOpSchema = z
.superRefine((op, ctx) => {
if (op.action === 'assign') {
if (!op.to) ctx.addIssue({ code: 'custom', message: "action:'assign' 必须给 to(owner / balance)" });
if (op.select.group === 'batch')
ctx.addIssue({ code: 'custom', message: '整批不能作为改派的对象 —— 请选 pending / agent / patients' });
/**
* ⚠️ `batch` + `owner` 做不到:**专属关系只有「待分配」那份数据带着**
* (`pending[].ownerUserId`),已经排好的条目卡片上查不到它归谁专属。
* 放它过去的话,那几条会被报成"查不到在岗的专属客服" ——
* 听起来像"这些人没有专属客服",而事实是卡片没带这条关系。
*/
if (op.select.group === 'batch' && op.to?.mode === 'owner')
ctx.addIssue({
code: 'custom',
message: "整批配不了 owner —— 专属关系只有「待分配」那组带着,要各自归专属请选 group:'pending'",
});
}
if (op.action === 'remove' && op.select.group === 'batch')
ctx.addIssue({ code: 'custom', message: '整批不能"移出自己" —— 请选具体的人' });
......
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