Commit f650093d by luoqi

merge: 引导「一类一调」纪律进事实层 + 每步形状留痕 + 两处措辞

· 每条引导带一份 `开放方式`(show_guidance 的调用纪律)—— 产品实测模型排布确实变了
· `agent_invocations.output.steps` 记下每步「讲了多少字 + 调了哪些工具」,
  让「一类一调、紧挨着调」从写着变成看得见
· 确认单「无主」→「无专属」(同一张卡上两个词指同一件事)
· 引导标识「打不完」→「最忙和超期」(「打不完」是下判断,卡片刻意只报数)
· 中间那次「纪律挂在组上」的实验已 revert,正文留在 82f357e0 可查
parents 50f342b3 875a406c
Pipeline #3578 failed in 0 seconds
...@@ -109,12 +109,21 @@ import { Permission } from '@pac/types'; ...@@ -109,12 +109,21 @@ import { Permission } from '@pac/types';
/** /**
* 提示词版本 —— 落进 `agent_invocations.prompt_version`,是回放「当时那份提示词长什么样」的**唯一锚**。 * 提示词版本 —— 落进 `agent_invocations.prompt_version`,是回放「当时那份提示词长什么样」的**唯一锚**。
* *
* 🔴 **改动 ①〜⑤ 任意一层的正文,必须 bump 这个值**(连同当天日期)。 * 🔴 **改动模型看得见的任何常驻文本,必须 bump 这个值**(连同当天日期):
* · ①〜⑤ 任意一层的正文
* · **工具描述**(它占模型上下文的大头 —— 实测每轮约 11K token,比五层还多)
* · **给模型的固定标识**(如 `GUIDANCE_KIND` 的中文名,它会出现在模型嘴里)
* · **事实层里的固定文本**(如 `assignment-facts.ts` 的 `GUIDANCE_HOWTO`)——
* 它每轮一模一样、且模型看得见,与工具描述同性质。
* ⚠️ 原来这条只写了"五层",于是改工具描述不 bump、版本号照旧 ——
* 回放时"当时那份提示词长什么样"就对不上了,而且不会有任何报错。
* ⚠️ 系统提示词**不逐行落库**:主管那份 6.9 KB 每次一模一样,逐行存等于把同一份东西 * ⚠️ 系统提示词**不逐行落库**:主管那份 6.9 KB 每次一模一样,逐行存等于把同一份东西
* 抄一万遍。审计靠「这一行的 promptVersion + 代码里那个版本的正文」两边对 —— * 抄一万遍。审计靠「这一行的 promptVersion + 代码里那个版本的正文」两边对 ——
* ⇒ 版本不 bump,这条链就断了,而断了不会有任何报错。 * ⇒ 版本不 bump,这条链就断了,而断了不会有任何报错。
*/ */
export const ASSISTANT_PROMPT_VERSION = 'assistant@2026-08-16-b'; // ⚠️ `-b` 是 2026-08-17 那次「纪律挂在组上」的实验,已 revert(`git show 82f357e`)——
// ⛔ 别复用它:库里有那一版的 `agent_invocations` 行,复用等于两份不同正文同名。
export const ASSISTANT_PROMPT_VERSION = 'assistant@2026-08-17-c';
/** /**
* ① 装置 —— 你是谁、和使用者什么关系、你看不见什么。 * ① 装置 —— 你是谁、和使用者什么关系、你看不见什么。
...@@ -179,6 +188,8 @@ const EVIDENCE = `说人话:使用者的词汇表就是他在界面上见过 ...@@ -179,6 +188,8 @@ const EVIDENCE = `说人话:使用者的词汇表就是他在界面上见过
*/ */
export const VOICE_DEFAULT = `## 怎么说话 export const VOICE_DEFAULT = `## 怎么说话
用中文输出。
一段话是**一条思路**,⛔ 不是几个要点并排放着。前一句给出的结论,后一句顺着它往下走; 一段话是**一条思路**,⛔ 不是几个要点并排放着。前一句给出的结论,后一句顺着它往下走;
要转折就把转折写出来。判据:把相邻两句调换顺序,读起来应该明显变差 —— 如果无所谓, 要转折就把转折写出来。判据:把相邻两句调换顺序,读起来应该明显变差 —— 如果无所谓,
说明它们之间本来就没有关系,那就不该放在一起。 说明它们之间本来就没有关系,那就不该放在一起。
...@@ -302,7 +313,7 @@ const ASSIGNMENT_SCENE = `## 他现在做的这件事:把一批人分给客服 ...@@ -302,7 +313,7 @@ const ASSIGNMENT_SCENE = `## 他现在做的这件事:把一批人分给客服
方案是「已呈现」,不是「已经分配好了」—— 点确认的是他的手,**这一下才真的分下去**。 方案是「已呈现」,不是「已经分配好了」—— 点确认的是他的手,**这一下才真的分下去**。
这批还没带福利,就在他按下确认之前提一句:要带现在说,不带就算了。⛔ 别劝,⛔ 别讲效果 这批还没带福利,就在他按下确认之前提一句:要带现在说,不带就算了。措辞自己组织
### 确认之后 ### 确认之后
......
...@@ -19,6 +19,7 @@ import { ...@@ -19,6 +19,7 @@ import {
briefToolArgs, briefToolArgs,
truncateOutputText, truncateOutputText,
joinStepTexts, joinStepTexts,
stepShapes,
assistantCallKey, assistantCallKey,
assistantInputHash, assistantInputHash,
type ToolTraceEntry, type ToolTraceEntry,
...@@ -643,10 +644,10 @@ export class AssistantService { ...@@ -643,10 +644,10 @@ export class AssistantService {
enum: [...GUIDANCE_KINDS], enum: [...GUIDANCE_KINDS],
description: description:
'要开放哪一类:' + '要开放哪一类:' +
'待分配 = 有人卡着没发出去、等他定去向;' +
'换个条件选 = 这一格可以只选其中一类人,重出一版;' + '换个条件选 = 这一格可以只选其中一类人,重出一版;' +
'本批人数 = 这一批发多少人,可以调;' + '本批人数 = 这一批发多少人,可以调;' +
'打不完 = 按当前时限算这批打不完,可以调时限或每天几通。' + '最忙和超期 = 分完之后最忙那位手上的量,超过了「每天几通 × 时限」;可以调时限或每天几通。' +
'待分配 = 有人卡着没发出去、等他定去向;' +
'\n⚠️ 只能开放**这一版真的有**的那几类;没有的会被退回,并告诉你这一版有哪几类。', '\n⚠️ 只能开放**这一版真的有**的那几类;没有的会被退回,并告诉你这一版有哪几类。',
}, },
}, },
...@@ -1068,7 +1069,7 @@ export class AssistantService { ...@@ -1068,7 +1069,7 @@ export class AssistantService {
* ⚠️ 实测那轮 5 次工具调用、6924 输出 token,落库只有 60 字 —— 那次凑巧没丢, * ⚠️ 实测那轮 5 次工具调用、6924 输出 token,落库只有 60 字 —— 那次凑巧没丢,
* 因为它把话都留到了最后一步。⛔ 别指望它每次都这样。 * 因为它把话都留到了最后一步。⛔ 别指望它每次都这样。
*/ */
steps?: ReadonlyArray<{ text?: string }>; steps?: ReadonlyArray<{ text?: string; toolCalls?: ReadonlyArray<{ toolName?: string }> }>;
finishReason?: string; finishReason?: string;
// AI SDK 的 usage 里混着嵌套的 tokenDetails,这里只取几个标量 —— 用宽类型收口 // AI SDK 的 usage 里混着嵌套的 tokenDetails,这里只取几个标量 —— 用宽类型收口
totalUsage?: Record<string, unknown>; totalUsage?: Record<string, unknown>;
...@@ -1101,6 +1102,12 @@ export class AssistantService { ...@@ -1101,6 +1102,12 @@ export class AssistantService {
output: { output: {
toolCalls: toolTrace as unknown as Prisma.InputJsonValue, toolCalls: toolTrace as unknown as Prisma.InputJsonValue,
toolCallCount: toolTrace.length, toolCallCount: toolTrace.length,
/**
* ⭐ **每一步的形状** —— 这是「一类一调、紧挨着调」唯一能验证的形式。
* 只看 toolCalls 的次数看不出对错:3 次 show_guidance 可以是分 3 步各讲一段后各调
* (对),也可以是同一步里一口气调 3 次(错,三排按钮叠在同一段末尾)。
*/
steps: stepShapes(ev.steps) as unknown as Prisma.InputJsonValue,
finishReason: ev.finishReason ?? null, finishReason: ev.finishReason ?? null,
} as Prisma.InputJsonValue, } as Prisma.InputJsonValue,
outputText: truncateOutputText(joinStepTexts(ev.steps, ev.text)), outputText: truncateOutputText(joinStepTexts(ev.steps, ev.text)),
......
...@@ -581,7 +581,7 @@ export class AssistantLabController { ...@@ -581,7 +581,7 @@ export class AssistantLabController {
'待分配 = 有人卡着没发出去、等他定去向;' + '待分配 = 有人卡着没发出去、等他定去向;' +
'换个条件选 = 这一格可以只选其中一类人,重出一版;' + '换个条件选 = 这一格可以只选其中一类人,重出一版;' +
'本批人数 = 这一批发多少人,可以调;' + '本批人数 = 这一批发多少人,可以调;' +
'打不完 = 按当前时限算这批打不完,可以调时限或每天几通。' + '最忙和超期 = 分完之后最忙那位手上的量,超过了「每天几通 × 时限」;可以调时限或每天几通。' +
'\n⚠️ 只能开放**这一版真的有**的那几类;没有的会被退回,并告诉你这一版有哪几类。', '\n⚠️ 只能开放**这一版真的有**的那几类;没有的会被退回,并告诉你这一版有哪几类。',
}, },
}, },
......
...@@ -29,10 +29,44 @@ import { GUIDANCE_KIND } from './assignment-signals'; ...@@ -29,10 +29,44 @@ import { GUIDANCE_KIND } from './assignment-signals';
* ⛔ **这里一个字的指令都不许有**(「请照抄」「不要说成…」之类)。 * ⛔ **这里一个字的指令都不许有**(「请照抄」「不要说成…」之类)。
* 2026-08-08 栽过:`get_assignment_detail` 的 note 字段混着给模型的指令, * 2026-08-08 栽过:`get_assignment_detail` 的 note 字段混着给模型的指令,
* 模型照抄就把内部指令原样贴进了主管的对话框。⇒ 事实归事实,规范归提示词。 * 模型照抄就把内部指令原样贴进了主管的对话框。⇒ 事实归事实,规范归提示词。
* ⚠️ **例外一条、且只有一条**:每条引导上的 `开放方式`(见 `GUIDANCE_HOWTO`)。
* 2026-08-17 产品实测定的,破例的理由和它的边界都写在那个常量上。
* ⛔ 别照着它再开第二处 —— 一个"有过先例"的例外会变成没有边界。
* ⛔ 不含任何 planId / 患者姓名 —— 那些走侧信道给界面,模型手里不该有。 * ⛔ 不含任何 planId / 患者姓名 —— 那些走侧信道给界面,模型手里不该有。
* ⚠️ 键名用中文:模型照着键名说就是对的,省掉一轮"把 inHandPending 翻译成人话"。 * ⚠️ 键名用中文:模型照着键名说就是对的,省掉一轮"把 inHandPending 翻译成人话"。
*/ */
/**
* 每条引导上都带一份的**调用纪律** —— `show_guidance` 要一类一调、紧挨着调。
*
* ═══ 为什么明知是指令还是要放在这一层 ═══════════════════════════════
* 同一条纪律 `show_guidance` 的**工具描述里已经有一份**,但那份离「这一版到底有哪几类」
* 隔着整个上下文。2026-08-17 产品把这句话手工粘到 `标识` 的值里试了一轮,
* **模型的排布确实变了** —— ⇒ 这不是"多说一遍更保险",是实测拿到的差别。
*
* ⚠️ 挂在**每一条**上,⛔ 不是每一组上:纪律管的是"这一条讲完、紧接着调这一条",
* 它的作用域就是一条。挂在组上时模型仍然可能把一组里的两条攒在一起。
* ⚠️ 位置紧跟 `标识`:产品实测有效的那一版就是粘在标识后面的,⛔ 别挪到末尾去
* "省一点注意力" —— 那等于换了个没测过的位置。
*
* ═══ 边界(破例只破这一处)═══════════════════════════════════════
* ⚠️ 措辞与 `show_guidance` 的描述**同源**(都用「开放」这个动词、都说「一类」)——
* ⛔ 别在这里另发明一套说法:两处打架时模型只会各取一半。
* ⛔ 不写工具名 `show_guidance`:这一层是给模型的**中文事实**,代码标识符进来
* 早晚会被念给主管听(沿革见 `assignment-signals.ts` 的 `GUIDANCE_KIND`)。
* 「开放」就是工具描述里的原词,不会有歧义。
* ⛔ 禁词表照旧管着这句话(「照抄」「原样」「绝不」「必须说」这些字面都不许出现)——
* 例外放行的是"一句指令",⛔ 不是把禁词表撕了。有测试盯着。
* ⚠️ 改这句话要 bump `ASSISTANT_PROMPT_VERSION`:它每轮一模一样、且模型看得见,
* 与工具描述同性质。
*
* ⇒ **怎么验它还在起作用**:`agent_invocations.output.steps`(`stepShapes`)——
* 几条引导落在各自独立、且 textLen>0 的步里 = 守住了;挤在同一步 = 没守住。
*/
const GUIDANCE_HOWTO =
'一类一调:讲完其中一类,紧接着开放这一类;再讲下一类,再开放一类。' +
'不许连着开放两类 —— 中间要隔着你讲下一类的话。';
/** 给模型的事实投影。⚠️ 纯函数,输入一份提案、输出一个可序列化对象。 */ /** 给模型的事实投影。⚠️ 纯函数,输入一份提案、输出一个可序列化对象。 */
export function modelFacts(p: AssignmentProposal): Record<string, unknown> { export function modelFacts(p: AssignmentProposal): Record<string, unknown> {
/// ⚠️ **本批分到人的客服数**,⛔ 不是在岗名册人数 —— 名册走 `p.rosterCount`。 /// ⚠️ **本批分到人的客服数**,⛔ 不是在岗名册人数 —— 名册走 `p.rosterCount`。
...@@ -49,12 +83,14 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> { ...@@ -49,12 +83,14 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> {
* ⚠️ 标识放在**每条的第一位**:键序就是模型讲述的顺序,标识要先于内容出现。 * ⚠️ 标识放在**每条的第一位**:键序就是模型讲述的顺序,标识要先于内容出现。
* ⛔ 别把 `signal.key` 直接给模型(`pending` / `batch_size_basis` 是内部码)—— * ⛔ 别把 `signal.key` 直接给模型(`pending` / `batch_size_basis` 是内部码)——
* 中文标识即使被念出口,读起来也是一句正常的话。 * 中文标识即使被念出口,读起来也是一句正常的话。
* ⚠️ `开放方式` 紧跟标识 —— 这一层唯一一处指令,理由和边界见 `GUIDANCE_HOWTO`。
*/ */
const pick = (stage: 'cohort' | 'size' | 'dispatch') => const pick = (stage: 'cohort' | 'size' | 'dispatch') =>
p.signals p.signals
.filter((s) => s.tier === 'action' && s.stage === stage) .filter((s) => s.tier === 'action' && s.stage === stage)
.map((s) => ({ .map((s) => ({
'标识': GUIDANCE_KIND[s.key] ?? s.key, '标识': GUIDANCE_KIND[s.key] ?? s.key,
'开放方式': GUIDANCE_HOWTO,
'是什么': s.title, '是什么': s.title,
'为什么': s.why, '为什么': s.why,
'不处理会怎样': s.defaultLabel, '不处理会怎样': s.defaultLabel,
......
...@@ -595,12 +595,12 @@ export class AssignmentProposalService { ...@@ -595,12 +595,12 @@ export class AssignmentProposalService {
(ownership.topOwners.length (ownership.topOwners.length
? `\n候选 ${ownership.withOwner + ownership.free} 人里 ${ownership.withOwner} 人有专属` + ? `\n候选 ${ownership.withOwner + ownership.free} 人里 ${ownership.withOwner} 人有专属` +
`(${ownership.topOwners.map((o) => `${o.name ?? o.userId} ${o.n} `).join('、')})` + `(${ownership.topOwners.map((o) => `${o.name ?? o.userId} ${o.n} `).join('、')})` +
`,${ownership.free} 人无。` `,${ownership.free} 人无专属。`
: '') + : '') +
// ⭐ 只说"能重新排一版",⛔ 不说名次、不说顶几个 —— // ⭐ 只说"能重新排一版",⛔ 不说名次、不说顶几个 ——
// 产品:「排名其实不重要,重要的是运营策略(一批人有相同特征)」。 // 产品:「排名其实不重要,重要的是运营策略(一批人有相同特征)」。
(canRefill (canRefill
? `\n卡片上有个「重新排一版」—— 我把池子里**无的都换进来**重挑这 ${target} 人,` + ? `\n卡片上有个「重新排一版」—— 我把池子里**无专属的都换进来**重挑这 ${target} 人,` +
`能排好的会多一些,一条专属关系还是不动。要不要换由您点。` `能排好的会多一些,一条专属关系还是不动。要不要换由您点。`
: '') : '')
: '', : '',
...@@ -615,7 +615,7 @@ export class AssignmentProposalService { ...@@ -615,7 +615,7 @@ export class AssignmentProposalService {
// ⚠️ 中文标点**一律全角**(,;:())—— 这段是直接摆给主管看的, // ⚠️ 中文标点**一律全角**(,;:())—— 这段是直接摆给主管看的,
// 和系统提示词里对模型的要求同一条规矩。半角混在中文里挤成一团, // 和系统提示词里对模型的要求同一条规矩。半角混在中文里挤成一团,
// 而句号又是全角,一行里宽窄不一,读起来发涩。 // 而句号又是全角,一行里宽窄不一,读起来发涩。
: `**已重挑这 ${target} 人**:池子里 ${freeInBatch} 位无的全换进来了,` + : `**已重挑这 ${target} 人**:池子里 ${freeInBatch} 位无专属的全换进来了,` +
`其余按优先级用有专属的补齐。\n` + `其余按优先级用有专属的补齐。\n` +
`排好 ${items.placed.length} 人` + `排好 ${items.placed.length} 人` +
(pending.length > 0 (pending.length > 0
...@@ -1145,7 +1145,7 @@ export function placeAgents( ...@@ -1145,7 +1145,7 @@ export function placeAgents(
agents.length > 0 ? Math.max(1, Math.ceil((inHandSum + chosen.length) / agents.length)) : 0; agents.length > 0 ? Math.max(1, Math.ceil((inHandSum + chosen.length) / agents.length)) : 0;
const placed: PlacedItem[] = []; const placed: PlacedItem[] = [];
const free: typeof chosen = []; // 无 / 专属已离岗 const free: typeof chosen = []; // 无专属 / 专属已离岗
const held: typeof chosen = []; // 有主,但专属这轮的份额已满 → 第三趟进「待分配」 const held: typeof chosen = []; // 有主,但专属这轮的份额已满 → 第三趟进「待分配」
let unplaced = 0; let unplaced = 0;
......
...@@ -110,7 +110,12 @@ export const GUIDANCE_KIND: Readonly<Record<string, string>> = { ...@@ -110,7 +110,12 @@ export const GUIDANCE_KIND: Readonly<Record<string, string>> = {
pending: '待分配', pending: '待分配',
narrow: '换个条件选', narrow: '换个条件选',
batch_size_basis: '本批人数', batch_size_basis: '本批人数',
daily_overload: '打不完', // 🔴 「最忙和超期」⛔ 不叫「打不完」——「打不完」是**下判断**,而这条引导的卡片
// 刻意只报数(「最忙的是王强:手上共 45 条,约 3 天的量」)。这个名字会出现在
// 模型嘴里(见上方注释),叫「打不完」等于让它替主管说出卡片刻意不说的那句话;
// 而且界面上一处「打不完」都没有 —— 那是只活在代码里的词(②诚实层:使用者的
// 词汇表就是他在界面上见过的那些)。
daily_overload: '最忙和超期',
}; };
/** `show_guidance` 的取值域 —— 与上表同源,⛔ 别另写一份。 */ /** `show_guidance` 的取值域 —— 与上表同源,⛔ 别另写一份。 */
......
...@@ -493,7 +493,13 @@ describe('给模型的事实', () => { ...@@ -493,7 +493,13 @@ describe('给模型的事实', () => {
expect(Object.keys(modelFacts(base()))).toEqual(['怎么选的', '怎么派的']); expect(Object.keys(modelFacts(base()))).toEqual(['怎么选的', '怎么派的']);
}); });
test('🔴 ⛔ 里面一个字的指令都不许有', () => { /**
* ⚠️ 2026-08-17 起这条边界有**一处具名例外**:每条引导上的 `开放方式`
* (`show_guidance` 的调用纪律,沿革与破例理由见 `assignment-facts.ts` 的 `GUIDANCE_HOWTO`)。
* ⇒ 例外放行的是"一句指令",⛔ 不是把禁词表撕了 —— 下面这几个字面**照旧全域禁**,
* 连那句纪律自己也得守(另有一条测试单独盯着它)。
*/
test('🔴 ⛔ 里面不许有指令性措辞(`开放方式` 那一句也不例外)', () => {
const json = JSON.stringify(modelFacts(base())); const json = JSON.stringify(modelFacts(base()));
for (const w of ['照抄', '原样', '不要说', '绝不', '⛔', '请把', '转述', '必须说']) { for (const w of ['照抄', '原样', '不要说', '绝不', '⛔', '请把', '转述', '必须说']) {
expect(json).not.toContain(w); expect(json).not.toContain(w);
...@@ -602,7 +608,8 @@ describe('给模型的事实', () => { ...@@ -602,7 +608,8 @@ describe('给模型的事实', () => {
const f = modelFacts(p) as { 怎么派的: { 要你定的: Array<Record<string, unknown>> } }; const f = modelFacts(p) as { 怎么派的: { 要你定的: Array<Record<string, unknown>> } };
expect(f.怎么派的.要你定的.length).toBeGreaterThan(0); expect(f.怎么派的.要你定的.length).toBeGreaterThan(0);
for (const x of f.怎么派的.要你定的) { for (const x of f.怎么派的.要你定的) {
expect(Object.keys(x)).toEqual(['标识', '是什么', '为什么', '不处理会怎样']); // ⚠️ `开放方式` 紧跟标识 —— 2026-08-17 加的调用纪律,沿革见 `GUIDANCE_HOWTO`
expect(Object.keys(x)).toEqual(['标识', '开放方式', '是什么', '为什么', '不处理会怎样']);
} }
// ⛔ intent 技术细节不许进 —— 模型说出 `pending.to_owner` 只会吓到主管 // ⛔ intent 技术细节不许进 —— 模型说出 `pending.to_owner` 只会吓到主管
expect(JSON.stringify(f.怎么派的.要你定的)).not.toMatch(/pending\.|expiry\.|cohort\./); expect(JSON.stringify(f.怎么派的.要你定的)).not.toMatch(/pending\.|expiry\.|cohort\./);
...@@ -692,6 +699,70 @@ describe('给模型的事实', () => { ...@@ -692,6 +699,70 @@ describe('给模型的事实', () => {
for (const st of ['cohort', 'size', 'dispatch'] as const) for (const st of ['cohort', 'size', 'dispatch'] as const)
expect(titles(st).length).toBeGreaterThan(0); expect(titles(st).length).toBeGreaterThan(0);
}); });
/**
* 🔴 每条引导上的 `开放方式` —— 事实层**唯一一处指令**(2026-08-17 产品实测定的)。
* 沿革、破例理由、撤销条件都写在 `assignment-facts.ts` 的 `GUIDANCE_HOWTO` 上。
*
* ⚠️ 这几条锁的是**破例的边界**,⛔ 不是"这句话写得好不好":
* ① 挂在**每一条**上(纪律的作用域就是一条;挂在组上时一组里的两条仍会被攒着);
* ② 位置**紧跟标识**(产品实测有效的那一版就是粘在标识后面的,换位置 = 换了个没测过的东西);
* ③ 措辞与 `show_guidance` 描述同源,且⛔ 不含工具名;
* ④ 例外只放行"一句指令",禁词字面照旧不许出现。
*/
describe('每条引导上的「开放方式」', () => {
const items = () => {
const p = base();
p.pending = [{}] as AssignmentProposal['pending'];
p.signals = computeSignals(p, [
{
key: 'narrow',
severity: 3,
tier: 'action',
stage: 'cohort',
title: '这一格 1000 人,也可以只选其中一类',
why: '',
defaultLabel: '',
options: [],
},
]);
const f = modelFacts(p) as {
怎么选的: Record<string, unknown>;
怎么派的: Record<string, unknown>;
};
return [
...((f.怎么选的['这一格还能怎么选'] ?? []) as Array<Record<string, unknown>>),
...((f.怎么选的['本批人数还能怎么调'] ?? []) as Array<Record<string, unknown>>),
...((f.怎么派的['要你定的'] ?? []) as Array<Record<string, unknown>>),
];
};
test('🔴 每一条都带,且**紧跟标识** —— ⛔ 不是每组一份', () => {
const xs = items();
expect(xs.length).toBeGreaterThanOrEqual(3); // 三组都得有,否则这条等于没测
for (const x of xs) {
expect(Object.keys(x).slice(0, 2)).toEqual(['标识', '开放方式']);
}
// ⚠️ 每条都是**同一句** —— ⛔ 别让谁按 stage 改写出第二个版本
expect(new Set(xs.map((x) => x['开放方式'])).size).toBe(1);
});
test('🔴 例外只放行「一句指令」,⛔ 禁词字面照旧管着它', () => {
const s = String(items()[0]!['开放方式']);
expect(s.length).toBeGreaterThan(0);
for (const w of ['照抄', '原样', '不要说', '绝不', '⛔', '请把', '转述', '必须说']) {
expect(s).not.toContain(w);
}
});
test('⭐ 措辞与 show_guidance 的描述同源,且⛔ 不含工具名', () => {
const s = String(items()[0]!['开放方式']);
expect(s).toContain('开放'); // 工具描述的原词:「把他接下来可以动手的事**开放**给他」
expect(s).toContain('一类'); // 工具描述:「讲完其中**一类**…再讲下**一类**」
// ⛔ 代码标识符不进事实层 —— 进来早晚被念给主管听(沿革见 GUIDANCE_KIND)
expect(s).not.toContain('show_guidance');
});
});
}); });
/** /**
......
...@@ -4,6 +4,7 @@ import { ...@@ -4,6 +4,7 @@ import {
briefToolArgs, briefToolArgs,
truncateOutputText, truncateOutputText,
joinStepTexts, joinStepTexts,
stepShapes,
assistantCallKey, assistantCallKey,
assistantInputHash, assistantInputHash,
} from '../src/modules/assistant/assistant-invocation'; } from '../src/modules/assistant/assistant-invocation';
...@@ -131,6 +132,41 @@ describe('joinStepTexts —— 多步时把每一步的话都留下', () => { ...@@ -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 按现场分', () => { describe('callKey 按现场分', () => {
it('主管走分配线,客服走打单线', () => { it('主管走分配线,客服走打单线', () => {
expect(assistantCallKey(true)).toBe('assistant_assignment'); expect(assistantCallKey(true)).toBe('assistant_assignment');
......
import fs from 'node:fs';
import path from 'node:path';
/**
* 用词一致性守卫 —— 主管在**同一张确认单**上不该看到两个词指同一件事。
*
* 🔴 2026-08-17 审计发现:确认单段落里写「N 人无主」,而同一张卡片上的按钮写
* 「换无专属客服的患者补上」。产品早就定过统一用「无专属」,`assignment-facts`
* (喂模型那份)也带着守卫注释,唯独界面文案没跟上。
*
* ⚠️ 这类漂移不会报错、测试也不会红 —— 只有主管一眼看出"这俩是不是两回事"。
* ⇒ 用规则钉死:**会到达人或模型的字**里不许再出现废弃词。
*/
const ROOTS = ['src', '../pac-web/src', '../../packages/types/src'];
/** 废弃词 → 该用的词 */
const RETIRED: Array<[RegExp, string]> = [
[/无主/, '无专属'],
[/时效/, '时限'],
/**
* 🔴 「打不完」是**下判断**,而那条引导的卡片刻意只报数
* (「最忙的是王强:手上共 45 条,约 3 天的量」)。
* ⚠️ 它曾经是 `GUIDANCE_KIND.daily_overload` 的值 —— 而那个值**会出现在模型嘴里**,
* 等于让助手替主管说出卡片刻意不说的那句话;界面上一处「打不完」也没有。
* ⛔ 同类禁词:几乎每批都会有人超,说成故障主管就会开始怀疑系统。
*/
[/打不完/, '最忙的那位(只报数,⛔ 不下判断)'],
];
/** ⛔ 语义不同的例外(临床禁忌有效期 / 话术语气紧迫),⛔ 别连坐 */
const EXEMPT = /clinical-signals|persona-feature-specs|signal-extractor|contraindication|shared\/tone/;
function walk(dir: string, out: string[] = []): string[] {
if (!fs.existsSync(dir)) return out;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
if (!/node_modules|dist|\.next/.test(p)) walk(p, out);
} else if (/\.tsx?$/.test(e.name)) out.push(p);
}
return out;
}
/**
* 只看**会到达人或模型**的行 —— 注释里的沿革记录保留(那是历史,改了反而失真)。
*
* ⚠️ 必须**跟踪块注释状态**,⛔ 不能只看行首:`/* … *\/` 和 JSX 的 `{/* … *\/}`
* 中间那些行不以 `*` 开头,只判行首会把历史记录当成活文案报出来(第一版就是这么假红的)。
*/
function liveLines(file: string): Array<{ n: number; text: string }> {
const out: Array<{ n: number; text: string }> = [];
let inBlock = false;
fs.readFileSync(file, 'utf8')
.split('\n')
.forEach((text, i) => {
const t = text.trim();
const opens = (t.match(/\/\*/g) ?? []).length;
const closes = (t.match(/\*\//g) ?? []).length;
const wasInBlock = inBlock;
if (opens > closes) inBlock = true;
else if (closes > opens) inBlock = false;
if (wasInBlock || inBlock || t.startsWith('*') || t.startsWith('//') || opens > 0) return;
out.push({ n: i + 1, text });
});
return out;
}
describe('用词一致性 —— 废弃词不许出现在会被看见的字里', () => {
const files = ROOTS.flatMap((r) => walk(path.join(__dirname, '..', r))).filter(
(f) => !EXEMPT.test(f) && !f.includes('wording-consistency.spec'),
);
it('扫到了文件(否则这条测试是假绿)', () => {
expect(files.length).toBeGreaterThan(200);
});
for (const [bad, good] of RETIRED) {
it(`⛔ 不再出现「${bad.source}」—— 产品定的是「${good}」`, () => {
const hits: string[] = [];
for (const f of files) {
for (const { n, text } of liveLines(f)) {
if (bad.test(text)) hits.push(`${path.basename(f)}:${n} ${text.trim().slice(0, 80)}`);
}
}
expect(hits).toEqual([]);
});
}
});
...@@ -1715,7 +1715,7 @@ export function AssignmentConfirmSheet({ ...@@ -1715,7 +1715,7 @@ export function AssignmentConfirmSheet({
⚠️ ⛔ 不写名次/优先级:产品判定「排名不重要,重要的是这批人共享同一组特征」。 ⚠️ ⛔ 不写名次/优先级:产品判定「排名不重要,重要的是这批人共享同一组特征」。
*/} */}
{/* ⛔ 这里原来有一个"重排凑满"按钮 —— 2026-08-13 去掉: {/* ⛔ 这里原来有一个"重排凑满"按钮 —— 2026-08-13 去掉:
待分配已经是引导节点,同一个动作在节点里叫「换无患者补上」 待分配已经是引导节点,同一个动作在节点里叫「换无专属客服的患者补上」
(intent `pending.refill`,由 use-assistant-chat 执行)。 (intent `pending.refill`,由 use-assistant-chat 执行)。
同一个动作两个入口必然漂,而且主管要在两处之间猜哪个是哪个。 */} 同一个动作两个入口必然漂,而且主管要在两处之间猜哪个是哪个。 */}
</div> </div>
......
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