Commit a85c04be by luoqi

feat(留痕): 记下每一步的形状 —— 让「一类一调、紧挨着调」从"写着"变成"看得见"

■ 先说结论:**那条规范 没加,因为它已经有两份了**
  `show_guidance`:「讲完其中一类,**紧接着**调一次; 别攒着一起调」
  `propose_assignment`:「可动手的事**一件一件**开放:说完它管的那件事,当场就开放它」
  两者是 2026-08-15 同一次事故之后一起调好的,`propose` 那句上面还有一条明确的
  「这里别再出现比"一件"更粗的量词 —— 粒度只该由 show_guidance 定」。
  ⇒ 再写第三份正是文件头禁止的「同一件事只许有一份」,两边必然各自漂。

■ 真正缺的是**验证手段**
  留痕此前只记「调了几次 show_guidance」,而那看不出对错:
    3 次可以是**分 3 步各讲一段后各调一次**(对)
    也可以是**同一步里一口气调 3 次**(错 —— 三排按钮叠在同一段末尾,正是那次事故的形状)
  ⇒ 新增 `output.steps = [{n, textLen, tools[]}]`:每一步讲了多少字 + 调了哪些工具。
    判据:show_guidance 落在**各自独立、且 textLen>0** 的步里 = 守规矩。

■ ️ 线上已经量出一次不合规
    23:25 | propose → show_guidance ×3 → show_sheet        看着像一类一调
    23:05 | propose → propose → show_sheet → show_guidance ← **明细先摆,引导落在后面**
  后者与 `propose_assignment` 描述里「最后让他看这一版的完整明细」相反。
  有了 steps 之后,这类问题不用再靠翻聊天记录看。

■ 顺带对齐两处落下的改名(工作区把标识改成了「最忙和超期」,但这两处还写着「最忙的那位」)
  · lab.controller 的工具描述
  · assignment-signals 里我自己写的那条注释

️ 字段名**查了 SDK 类型定义**再写的, 没靠猜:`StepResult.toolCalls[].toolName`
  (index.d.ts:854 / 695)—— 名字写错的话 tools 会永远是空数组,**静默失败**。
验证:tsc 通过;jest 87 套 1359 例全过;真模型跑一轮确认 steps 落库
  (`[{"n":1,"tools":[],"textLen":142}]`)。
parent 02831b1e
......@@ -184,6 +184,8 @@ const EVIDENCE = `说人话:使用者的词汇表就是他在界面上见过
*/
export const VOICE_DEFAULT = `## 怎么说话
用中文输出。
一段话是**一条思路**,⛔ 不是几个要点并排放着。前一句给出的结论,后一句顺着它往下走;
要转折就把转折写出来。判据:把相邻两句调换顺序,读起来应该明显变差 —— 如果无所谓,
说明它们之间本来就没有关系,那就不该放在一起。
......@@ -307,7 +309,7 @@ const ASSIGNMENT_SCENE = `## 他现在做的这件事:把一批人分给客服
方案是「已呈现」,不是「已经分配好了」—— 点确认的是他的手,**这一下才真的分下去**。
这批还没带福利,就在他按下确认之前提一句:要带现在说,不带就算了。⛔ 别劝,⛔ 别讲效果
这批还没带福利,就在他按下确认之前提一句:要带现在说,不带就算了。措辞自己组织
### 确认之后
......
......@@ -19,6 +19,7 @@ import {
briefToolArgs,
truncateOutputText,
joinStepTexts,
stepShapes,
assistantCallKey,
assistantInputHash,
type ToolTraceEntry,
......@@ -643,10 +644,10 @@ export class AssistantService {
enum: [...GUIDANCE_KINDS],
description:
'要开放哪一类:' +
'待分配 = 有人卡着没发出去、等他定去向;' +
'换个条件选 = 这一格可以只选其中一类人,重出一版;' +
'本批人数 = 这一批发多少人,可以调;' +
'最忙的那位 = 分完之后最忙那位手上的量,超过了「每天几通 × 时限」;可以调时限或每天几通。' +
'最忙和超期 = 分完之后最忙那位手上的量,超过了「每天几通 × 时限」;可以调时限或每天几通。' +
'待分配 = 有人卡着没发出去、等他定去向;' +
'\n⚠️ 只能开放**这一版真的有**的那几类;没有的会被退回,并告诉你这一版有哪几类。',
},
},
......@@ -1068,7 +1069,7 @@ export class AssistantService {
* ⚠️ 实测那轮 5 次工具调用、6924 输出 token,落库只有 60 字 —— 那次凑巧没丢,
* 因为它把话都留到了最后一步。⛔ 别指望它每次都这样。
*/
steps?: ReadonlyArray<{ text?: string }>;
steps?: ReadonlyArray<{ text?: string; toolCalls?: ReadonlyArray<{ toolName?: string }> }>;
finishReason?: string;
// AI SDK 的 usage 里混着嵌套的 tokenDetails,这里只取几个标量 —— 用宽类型收口
totalUsage?: Record<string, unknown>;
......@@ -1101,6 +1102,12 @@ export class AssistantService {
output: {
toolCalls: toolTrace as unknown as Prisma.InputJsonValue,
toolCallCount: toolTrace.length,
/**
* ⭐ **每一步的形状** —— 这是「一类一调、紧挨着调」唯一能验证的形式。
* 只看 toolCalls 的次数看不出对错:3 次 show_guidance 可以是分 3 步各讲一段后各调
* (对),也可以是同一步里一口气调 3 次(错,三排按钮叠在同一段末尾)。
*/
steps: stepShapes(ev.steps) as unknown as Prisma.InputJsonValue,
finishReason: ev.finishReason ?? null,
} as Prisma.InputJsonValue,
outputText: truncateOutputText(joinStepTexts(ev.steps, ev.text)),
......
......@@ -581,7 +581,7 @@ export class AssistantLabController {
'待分配 = 有人卡着没发出去、等他定去向;' +
'换个条件选 = 这一格可以只选其中一类人,重出一版;' +
'本批人数 = 这一批发多少人,可以调;' +
'最忙的那位 = 分完之后最忙那位手上的量,超过了「每天几通 × 时限」;可以调时限或每天几通。' +
'最忙和超期 = 分完之后最忙那位手上的量,超过了「每天几通 × 时限」;可以调时限或每天几通。' +
'\n⚠️ 只能开放**这一版真的有**的那几类;没有的会被退回,并告诉你这一版有哪几类。',
},
},
......
......@@ -110,12 +110,12 @@ export const GUIDANCE_KIND: Readonly<Record<string, string>> = {
pending: '待分配',
narrow: '换个条件选',
batch_size_basis: '本批人数',
// 🔴 「最忙的那位」⛔ 不叫「打不完」——「打不完」是**下判断**,而这条引导的卡片
// 🔴 「最忙和超期」⛔ 不叫「打不完」——「打不完」是**下判断**,而这条引导的卡片
// 刻意只报数(「最忙的是王强:手上共 45 条,约 3 天的量」)。这个名字会出现在
// 模型嘴里(见上方注释),叫「打不完」等于让它替主管说出卡片刻意不说的那句话;
// 而且界面上一处「打不完」都没有 —— 那是只活在代码里的词(②诚实层:使用者的
// 词汇表就是他在界面上见过的那些)。
daily_overload: '最忙的那位',
daily_overload: '最忙和超期',
};
/** `show_guidance` 的取值域 —— 与上表同源,⛔ 别另写一份。 */
......
......@@ -4,6 +4,7 @@ import {
briefToolArgs,
truncateOutputText,
joinStepTexts,
stepShapes,
assistantCallKey,
assistantInputHash,
} from '../src/modules/assistant/assistant-invocation';
......@@ -131,6 +132,41 @@ describe('joinStepTexts —— 多步时把每一步的话都留下', () => {
});
});
describe('stepShapes —— 让「一类一调、紧挨着调」变得可验证', () => {
const g = (name: string) => ({ toolName: name });
it('🔴 守规矩:每类各占一步,且每步都先讲了话', () => {
const shapes = stepShapes([
{ text: '这一版 405 人。', toolCalls: [g('propose_assignment')] },
{ text: '这一格还能再切一刀。', toolCalls: [g('show_guidance')] },
{ text: '本批人数是估的。', toolCalls: [g('show_guidance')] },
{ text: '明细在这儿。', toolCalls: [g('show_sheet')] },
]);
const guid = shapes.filter((s) => s.tools.includes('show_guidance'));
expect(guid).toHaveLength(2);
expect(guid.every((s) => s.tools.length === 1 && s.textLen > 0)).toBe(true);
});
it('🔴 攒着一起调:同一步里两次 show_guidance —— 这正是 2026-08-15 那次事故的形状', () => {
const shapes = stepShapes([
{ text: '选人这段讲完了。', toolCalls: [g('show_guidance'), g('show_guidance')] },
]);
expect(shapes[0].tools.filter((t) => t === 'show_guidance')).toHaveLength(2);
});
it('这一步只调工具没说话 → textLen = 0(引导会落在没头没尾的位置)', () => {
expect(stepShapes([{ text: ' ', toolCalls: [g('show_guidance')] }])[0]).toMatchObject({
n: 1,
textLen: 0,
});
});
it('没有 steps / 没有工具时不炸', () => {
expect(stepShapes(undefined)).toEqual([]);
expect(stepShapes([{ text: '就一句' }])).toEqual([{ n: 1, textLen: 3, tools: [] }]);
});
});
describe('callKey 按现场分', () => {
it('主管走分配线,客服走打单线', () => {
expect(assistantCallKey(true)).toBe('assistant_assignment');
......
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