Commit afd8ff1f by luoqi

merge: 确认单整批改派修复 + 主管工作台口径拆分 → test

① 助手「这批都给某某」改不动确认单 —— 三层各错一处且都不报错
   · schema 判 batch+assign 非法(判断本身就错)
   · 那条 refine 一次都没跑过(工具入参走手写 JSON Schema,zod 从没进运行时)
   · 界面把整批解成空名单,还回了「已把整批铺给 1 位客服()」
   ⇒ 加 checkEditOps 在推给界面前按 zod 校一遍;整批解成真名单;选中 0 人不许回报成功

② 「约上」→「预约上」,只数转化新预约;约定下次回访单列「约下次」
③ 通话成效四个桶 → 五个;「成功」不再单独摆(要靠小字解释的数就该拆开)
④ 「退回率」→「近 N 天预约成功率」= 预约上 / 已处置(他真打过的,含未接通)
⑤ 「还在跑的」→「还没收尾(含已经超期的)」
⑥ 「完成」→「近 N 天已处置」,与批次表同一个词;删掉含义打架的 handled 字段

无新迁移。ASSISTANT_PROMPT_VERSION → assistant@2026-08-22-a
parents c3556daf e46bd0dd
Pipeline #3590 failed in 0 seconds
......@@ -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 实测栽过)。
*
......
......@@ -24,12 +24,14 @@ export const TRACKING_GUIDE = [
'「处理」不等于「成功」:progress 是处理率,只说「这单动过了」,不说「谈成了」。',
'「已出池·引擎判定需求已了」是引擎按客观事实判定召回需求没了,⛔ 不是「转化成功/成交」,⛔ 不要拿这些数算转化率。',
'报处理率必须带上「本批已跑天数」:跑了三个月的批次天然比跑了三天的好看,不带年龄直接比是耍流氓。',
'退回率永远给两个数:「客服退回 5 / 已处置 40 = 12.5%(另有 60 条未动)」——「没人动」和「动了但退回」是完全不同的信号,只报一个百分比会把前者藏起来。',
'released(客服退回)与 recalled(主管回收)是两件事:前者是客服说这单我不接,带原因、进退回原因分布;后者是主管在调整各客服在手的单时把活收回来,没有原因。把 recalled 算进退回率,他就是在拿自己的动作当调下一批的依据。',
'退回和没动一起报:「客服退回 5、另有 60 条一次都没动」——「没人动」和「动了但退回」是完全不同的信号,只报一个会把另一个藏起来。',
'主管工作台那一列叫「预约成功率」,是 booked / done(真打过的),⛔ 分子不含约定下次回访、⛔ 分母不含退回的(那些他压根没打)。',
'released(客服退回)与 recalled(主管回收)是两件事:前者是客服说这单我不接,带原因、进退回原因分布;后者是主管在调整各客服在手的单时把活收回来,没有原因。把 recalled 算进客服的退回数,他就是在拿自己的动作当调下一批的依据。',
'分母小于 50 时直接说「样本量不足」,⛔ 不要输出百分比、⛔ 不要画图。',
'outcomes(通话成效)与 releaseReasons(退回原因)是两件不同的事,⛔ 绝不能混说:releaseReasons =「这单不该我做」,客服没打就还回去了,是**分配**问题;outcomes =「打了,结果这样」,客服做了事,是**召回效果**问题。说反了主管会去改错的东西。',
'outcomes.noOutcome(一次结果都没有)必须单独报出来,⛔ 不许算进「不成功」——那不是效果差,是根本没做/没记。',
'outcomes.success 含「约定下次回访」,⛔ 别说成「成交/转化了这么多」。',
'outcomes.appointed(预约上)= 患者定下了来的日子;outcomes.scheduledNext(约下次)= 客服答应了那天再联系一次,患者什么都没答应、单子还在他手上。两个数差着一整个台阶,⛔ 别加起来说成「预约上了这么多」。',
'outcomes.success 是这两项的合计,⛔ 别单独报它 —— 报了就得解释,而要解释的数本身就该拆开说。',
'outcomes.records 是逐条明细 + 客服手写的电话纪要(notes)。主管问「哪个患者/为什么/客服怎么说的」,答案只在这里,⛔ 别只回聚合数。',
'notes 为 null =「没留纪要」(不是没打)。结果填了、纪要空着本身是信息:说明只点了个选项。',
'引用纪要时照原话,⛔ 别润色成「客户表示…」——主管要看的就是客服当时怎么写的。',
......
......@@ -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('⭐ 两种铺法的回话措辞必须能一眼分开', () => {
......
......@@ -559,7 +559,9 @@ describe('批次表要认得"主管回收"', () => {
test('⭐ 界面:列头分「客服退回」与「回收」,两处都要', () => {
// 列表行(常量里)
expect(UI).toMatch(/const NUM_COLS = \['条数', '已处置', '没动', '客服退回', '回收', '超期', '约上'\]/);
// ⚠️ 2026-08-22「约上」拆成「约上 / 约下次」—— 这条锁的是**退回与回收分列**,
// ⛔ 别把整个列表的列名写死在这里(那会让每次加列都在这条上假红)
expect(UI).toMatch(/const NUM_COLS = \['条数', '已处置', '没动', '客服退回', '回收',/);
// 详情里按客服拆
expect(UI).toMatch(/'客服', '分到', '已处置', '没动', '客服退回', '回收'/);
// 原因分布只属于客服退回
......
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;
}
......
......@@ -96,7 +96,7 @@ const EXAMPLES_STAFF = [
* ⇒ 例句是**摆在他眼前、还请他点**的一句话,用一个界面上不存在的词最糟:
* 他点了,模型还得去猜那是哪一档。
* 🔴 **「成功 / 不成功」是 `TRACKING_GUIDE` 专门在防的那个词。** 字段确实有
* (`outcomes.success`),但它含「约定下次回访」,⛔ 不是"谈成了几个"
* (`outcomes.appointed`=约上 / `outcomes.scheduledNext`=约下次,⛔ 别加起来说)
* 而提示词那条更硬:本系统**不统计成功与否**。例句把这个词递给他、还请他点,
* 模型只有两条路:如实说没有这个数(那这条例句就是个坑),或者去圆。
* ⚠️ **「任务」是客服的词。** 他这一屏叫「我分的批次」;单条任务是客服执行页的事。
......
......@@ -74,7 +74,7 @@ export function AssistantWidget() {
* 3. get_assignment_detail 单批下钻
*
* ⛔ 第 2、3 条里点名的指标**每一个都得是工具真返回的**,否则例句就是许愿:
* 分配数=planned、未动过=untouched、已处理=progress.done、成功=outcomes.success
* 分配数=planned、未动过=untouched、已处理=progress.done、约上=outcomes.appointed
* 超期=expired、退回+原因=released/releaseReasons、不成功+原因=outcomes.failed/byOutcome。
* ⚠️ 写「未动过」不写「曝光」、写「已处理」不写「完成」—— 都是服务端的原词,
* 换个说法会把口径带偏(「已处理」≠ 谈成了,这条 note 服务端每次都在喊)。
......
......@@ -15,7 +15,7 @@ import { useAssistantStore } from '@/stores/assistant-store';
* ⚠️ 设计稿是 1440×1000 的固定框,这里做**自适应**(2026-08-07 产品定,与现有页面一致):
* 右栏固定 34rem、左栏吃剩下的;<1280px 时右栏落到下面(⛔ 别横向滚 ——
* 这页会嵌在宿主 iframe 里,横条比换行难受得多)。
* ⚠️ 34rem 比设计稿的 470px 宽 —— 那个宽度装不下退回率的两个分母(见下方注释)。
* ⚠️ 34rem 比设计稿的 470px 宽 —— 那个宽度装不下预约成功率下面那行分母(见下方注释)。
*
* ⚠️ 诊所是这一屏**所有查询的前提**(批次、团队都按诊所)。没有诊所就什么都不查,
* ⛔ 别用「全部诊所」兜底:那会把别家的负载混进来,而主管管的是自己这一家。
......@@ -77,7 +77,7 @@ export function SupervisorWorkbench() {
<BatchTracking clinicId={clinicId} />
</section>
</div>
{/* ⚠️ 470px(设计稿宽度)装不下「退回率」那一列:
{/* ⚠️ 470px(设计稿宽度)装不下「预约成功率」那一列:
百分比下面还有「退回 3 / 已处置 3 = 100.0%,另有 65 条没动」——
两个分母都得给(那是定死的口径),整行就被切掉右半截(实测)。
⇒ 放宽到 34rem;⛔ 别为了塞进 470 去砍分母。 */}
......
......@@ -17,9 +17,11 @@ const WINDOWS = [
*
* ── 一张表里混着两种时间性,这是最容易读错的地方 ────────────────
* · **当前在手 / 当前超期** —— 此刻的状态,与窗口无关;
* · **完成 / 退回率 / 没动** —— 都在选中的那个窗口里,换窗口一起变。
* ⚠️ 所以这两列都写死「**当前**」。只写「在手」时实测会被读成"这 7 天分了 62 条",
* 而「超期」如果不带「当前」,摆在窗口切换器正下方就会被当成"这 7 天超了几条"。
* · **已处置 / 预约成功率** —— 都在选中的那个窗口里,换窗口一起变。
* ⚠️ 所以前两列写死「**当前**」、后两列写死「**近 N 天**」(2026-08-22 产品要求)。
* 只写「在手」时实测会被读成"这 7 天分了 62 条";而「超期」如果不带「当前」,
* 摆在窗口切换器正下方就会被当成"这 7 天超了几条"。
* 反过来「已处置」「预约成功率」不带「近 N 天」,切了窗口数字变了却看不出为什么。
*
* 🔴 「超期」2026-08-19 **由窗口口径改回此刻口径**(到期不再回池,见 plan.module 的墓碑):
* · 2026-08-07 当初改成窗口口径,是因为回收器每 10 分钟把过期的单收走,
......@@ -28,9 +30,22 @@ const WINDOWS = [
* ⚠️ 而窗口那一支数的是**已经回池、没有客服挂着**的单,那不是谁的超期。
* 实测:界面上「超期 41 / 193」100% 来自它,真正在手且过时限的是 0 条。
*
* ⚠️ 退回率那行小字是**服务端拼好的**(`rateNote`),⛔ 前端别自己算:
* 它必须带两个分母(「退回 3 / 已处置 21 = 14.3%,另有 2 条没动」)——
* 只给百分比会把"没动"藏起来,而那个数本身就是信号。
* 🔴 「**预约成功率**」2026-08-22 顶掉了原来的「退回率」(产品定):
* · 分子 —— 真的**约上**的(`success_appointed`,患者定下了来的日子),
* ⛔ **不含「约定下次回访」**:那只是客服答应了"那天再联系您",患者什么都没答应。
* 算进来的话,一个只会往后拖的人能拖出一条漂亮的成功率。
* · 分母 —— 「完成」(他**真打过**的),⛔ 不是"已处置":
* 退回的单他压根没打,摆进分母等于因为他退单而扣他的成功率。
* ⛔ 退回和没动**没有消失**,降级到那行小字里 —— 两者仍是主管调分配的输入(T7),
* 而"没人动"和"动了但退回"是完全不同的信号。
*
* ⚠️ 成功率下面**不挂小字**(2026-08-22 产品定"值就一个就行"):
* 原来那行是服务端拼的 `rateNote`,带退回/没动两个旁支。
* ⛔ `released` / `idle` 没有消失,仍在响应里 —— 它们是另一个问题,不该挤在这一列下面。
*
* 🔴 「已处置」这一列 2026-08-22 **由「完成」改名**:主管当场问「完成和已处置
* 是同一个意思吗」—— 是同一个判据(真的落了通话结果),差的只是范围
* (本批 vs 近 N 天),而同一屏上叫了两个词。而且「完成」听起来像"做成了"。
*/
export function TeamStatus({ clinicId }: { clinicId: string | null }) {
const [days, setDays] = useState<number>(7);
......@@ -38,7 +53,7 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/// ⭐ 助手那边确认/撤销后跟着重拉 —— 每个人的在手/分到/退回率分母都变了(见 assignment-sync-store)
/// ⭐ 助手那边确认/撤销后跟着重拉 —— 每个人的在手/分到/成功率分母都变了(见 assignment-sync-store)
const syncSeq = useAssignmentSyncStore((s) => s.seq);
useEffect(() => {
if (!clinicId) return;
......@@ -135,11 +150,15 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) {
<th className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500">
当前超期
</th>
{/* ⚠️ 「近 N 天」跟着切换器走 —— 不带的话切了窗口数字变了却看不出为什么。
⚠️ 「已处置」与批次表那一列同一个词:同一个判据(真的落了通话结果),
⛔ 别再叫「完成」——「完成」听起来像"做成了" */}
<th className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500">
完成
{days} 天已处置
</th>
{/* ⚠️ 「预约」两个字不能省 —— 「成功率」会被读成"打通了就算成功" */}
<th className="whitespace-nowrap px-3.5 py-1.5 text-right text-[11px] font-medium text-slate-500">
退回
{days} 天预约成功
</th>
</tr>
</thead>
......@@ -188,14 +207,10 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) {
)}
</td>
<td className="nums px-2.5 py-2 text-right text-emerald-700">{a.done}</td>
<td className="px-3.5 py-2 text-right">
{/* ⚠️ 百分比与两个分母**分两行**:一行塞不下,而分母不能省 */}
<div className="nums text-slate-900">
{a.handled ? `${((a.released / a.handled) * 100).toFixed(1)}%` : '—'}
</div>
<div className="nums text-[10.5px] leading-snug text-slate-400">
{a.rateNote}
</div>
{/* ⚠️ 分母是 `done`(他真打过的),⛔ 不含退回的 —— 见文件头那段。
⚠️ 一个百分比,⛔ 下面不挂小字(2026-08-22 产品定) */}
<td className="nums px-3.5 py-2 text-right text-slate-900">
{a.done ? `${((a.booked / a.done) * 100).toFixed(1)}%` : '—'}
</td>
</tr>
);
......
......@@ -329,14 +329,28 @@ export const AssignmentBriefSchema = z.object({
*/
handled: z.number().int(),
/**
* 「约上」—— 本批里最近一次通话结果落在 `close` 组的人数(转化新预约 + **约定下次回访**)
* 「预约上」—— 本批里最近一次通话结果是 **`success_appointed`(转化新预约)** 的人数
*
* ⚠️ 与 detail 的 `outcomes.success` **同一定义**,⛔ 别只数"转化新预约":
* 两处都写着"约上",小一号主管就只能猜哪个对。
* 🔴 **2026-08-22 收窄口径**:此前它是整个 `close` 组的合计,
* 也就是「转化新预约 + 约定下次回访」两项加在一起。
* ⚠️ 那两件事差着一整个台阶:前者患者**进了预约表**、有个日子会来;
* 后者只是客服答应了"那天再联系您",患者什么都没答应。
* 混成一个数,主管看到「预约上 6」会当成 6 个人要来了。
* ⇒ 「预约上」= 只数真的预约上的;约了下次回访的走 `bookedNext`。
*
* ⚠️ 与 detail 的 `outcomes.appointed` **同一定义**,⛔ 别在两处各立标准。
* ⚠️ 上线初期这个数会长期是 0 或个位数 —— ⛔ **不许拿它算转化率**,
* 样本不足时要明说(见 outcomes.note 的写法),画出 0.x% 会让主管以为功能没用。
*/
booked: z.number().int(),
/**
* 「约下次」—— 最近一次通话结果是 `scheduled_next`(约定下次回访)的人数。
*
* ⚠️ 它**不是成效**,是**还没结束**:单子仍挂在客服手上、snooze 到回访日。
* ⛔ 别把它和 `booked` 加起来对外叫「预约上」—— 那正是收窄口径要拆开的东西。
* ⚠️ `booked + bookedNext === outcomes.success`(close 组的合计),回归里锁着。
*/
bookedNext: z.number().int(),
});
export type AssignmentBrief = z.infer<typeof AssignmentBriefSchema>;
......@@ -412,8 +426,27 @@ export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({
* 把两者放进同一个减法会重复扣减,noOutcome 被压成 0(踩过)。
*/
outcomes: z.object({
/// 成功(EXECUTION_OUTCOME_GROUP close):转化新预约 + 约定下次回访
/**
* 成功组(EXECUTION_OUTCOME_GROUP `close`)的合计 = `appointed + scheduledNext`。
*
* ⚠️ 它**只是个合计**,⛔ 界面上不许单独摆一个「成功 N」——
* 2026-08-22 产品实测:主管看到「成功 0 / 不成功 0 / 没进展 0 / 没有结果 6」,
* 问的第一句就是"这个成功是什么意思"。答案得靠下面那行小字,
* 而要靠小字解释的数,本身就该拆开摆。
*/
success: z.number().int(),
/**
* ⭐ 「预约上」—— `success_appointed`(转化新预约):患者**进了预约表**,有个日子会来。
* ⚠️ 与列表那一列 `booked` 同一个数,⛔ 别让它们分叉。
*/
appointed: z.number().int(),
/**
* ⭐ 「约下次」—— `scheduled_next`(约定下次回访):客服答应了"那天再联系您",
* 患者什么都没答应,单子仍挂在他手上 snooze 到回访日。
* ⚠️ 它归 `close` 组是**统计口径**上的事(是有效推进,不该跟"未接通"混在一起),
* ⛔ 但它不是"预约上了" —— 见 EXECUTION_OUTCOME_META 里 scheduled_next 那段。
*/
scheduledNext: z.number().int(),
/// 不成功(give_up):明确拒绝 / 已在外院 / 再考虑
failed: z.number().int(),
/// 打了但没进展(keep):未接通 / 秒挂
......@@ -1082,7 +1115,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 +1179,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: '整批不能"移出自己" —— 请选具体的人' });
......@@ -1191,20 +1243,44 @@ export const AgentWorkloadRowSchema = z.object({
* 把它们的"压了多久"算进来是在说一件早就结束的事。
*/
overdueOldestDays: z.number().int().nullable(),
/// 窗口内写过通话结果的条数(按单去重)
/**
* 窗口内**真的落了通话结果**的条数(按单去重)—— 界面上的「近 N 天已处置」。
*
* ⚠️ 与批次那边的 `AssignmentBriefSchema.handled`(本批已处置)、
* `AssignmentAgentStatSchema.done`(批内按人)是**同一个判据** ——
* 都在问"有没有人真的做了事"。差的只是**范围**:本批 vs 近 N 天。
* 🔴 所以界面上必须用**同一个词**(2026-08-22 主管当场问「完成和已处置是同一个意思吗」)。
* ⛔ 原来这一列叫「完成」,而批次表那一列叫「已处置」 ——
* 同一屏上两个词指同一件事,主管只能猜。而且「完成」听起来像"做成了",
* 这正是整套口径反复要避免的读法。
*
* 🔴 **它同时是「预约成功率」的分母,⛔ 不筛结果项**(2026-08-22 产品确认)。
* 判据只有一条:**他有没有真打**。所以「没进展」那一桶(未接通 / 待跟进 /
* 需要找医生 / 已发短信 / 改约)照样进分母 —— 打了没人接,那也是打了。
* ⚠️ 未接通往往占分母的大头,所以这个数读的是「**触达 × 转化**」合起来的效率,
* ⛔ 不是"聊上了的人里谈成几成"。⛔ 别为了"好看"把 keep 组从分母里摘掉:
* 那会变成另一个指标,而名字没变。
* ⚠️ 唯一不在分母里的是「没有结果」—— 那些单**一条执行记录都没有**,
* 他压根没打,自然也不该算进"他打过的"。
*/
done: z.number().int(),
/// 窗口内主动退回的条数(走账本,⛔ 不读 followup_plans.release_reason —— 那是当前值会被清)
released: z.number().int(),
/// 窗口内已处置 = done + released(两者都是"这单他动过了")
handled: z.number().int(),
/// 窗口内分到但一条都没动的
idle: z.number().int(),
/**
* ⭐ 成品句子:「退回 3 / 已处置 21 = 14.3%,另有 2 条没动」。
* 🔴 **两个分母都要带** —— 只给一个百分比会把"没动"藏起来,而那个数本身就是信号。
* ⛔ 前端别自己拼:界面、助手、导出三处口径必须一致。
* ⭐ 窗口内**真的预约上**的条数 —— 最近一次通话结果是 `success_appointed`(转化新预约)。
*
* 🔴 「预约成功率」= `booked / done`,界面上只出这**一个百分比**(2026-08-22 产品定)。
* ⚠️ `released`(退回)与 `idle`(没动)仍在这一行上,只是不再挂在成功率下面 ——
* ⛔ 它们没有消失,主管要看时是另一个问题、另一个位置。
*
* 🔴 ⛔ **不含「约定下次回访」**(2026-08-22 产品定):那只是客服答应了
* "那天再联系您",患者什么都没答应。算进来的话,一个只会往后拖的人
* 能拖出一条漂亮的成功率,而主管正是靠这一列判断谁真的把人叫回来了。
* ⚠️ 归属按**那条执行的操作人**算:同一条单 A 打过、B 才约上的,算 B 的。
*/
rateNote: z.string(),
booked: z.number().int(),
});
export type AgentWorkloadRow = z.infer<typeof AgentWorkloadRowSchema>;
......
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