Commit b823023a by luoqi

feat(助手): P2 事实与措辞分离 —— 给模型结构化事实, 不再给成品句子

🔴 核心:propose_assignment 的返回值从「三段成品句子 + 十几行『原样输出、
不要改写、不要合并』」换成**纯结构化事实**(assignment-facts.modelFacts)。

为什么:
  ① 成品句子有固定形状,模型学会形状就能**凭空背出来** —— 实测背出过
     「已撤销批次:收回 9 条。」,而撤销工具一次都没被调用。
     结构化事实编不出来:JSON 里的具体值瞎写一眼就对得出来。
  ② 「照抄」浪费了模型唯一不可替代的能力:把事实组织成人话。
     让它当复读机等于花钱买了个模板引擎。

- assignment-facts.ts:三段投影(怎么选的 / 怎么派的 / 要主管定的),中文键。
   一个字的指令都没有; 不含 planId / 患者姓名(那些走侧信道)。
  候选不够时**本批人数与基数分别给** —— 否则主管以为系统偷偷改小了他的设置。
- 提示词第 0.5 条重写成**唯一一处写作规范**;删掉靠模型自觉的 `###` 骨架
  (2026-08-11 实测:给了规则它一个 `###` 都不出)—— 结构现在由返回值的键名决定,
  ③ 级校验换成 ① 级结构。
- 清掉三处「照抄 rosterNote / basisNote」「转述那三句依据」的残留指令。
- ️ basisNote/selectionNote/pendingNote 字段**暂时保留**(卡片与 criteria
  快照还在用),只是不再喂给模型。

测试:
- 两条**对源码 grep 的断言**改成行为断言 —— 它们锁的不变式还在,但台词稿一删
  整条就失效了。⇒ 断行为不断源码。
- 新增 7 条事实投影回归,其中「 里面一个字的指令都不许有」直接扫关键词。
1237 passed;web 13 passed;type-check(含 tests)干净。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent e2a38baf
...@@ -51,46 +51,36 @@ const DISPATCHER_EXTRA = ` ...@@ -51,46 +51,36 @@ const DISPATCHER_EXTRA = `
⚠️ 这不是"措辞优化" —— 主管看到一个他不认识的英文词,第一反应是**系统出错了**, ⚠️ 这不是"措辞优化" —— 主管看到一个他不认识的英文词,第一反应是**系统出错了**,
于是他要么不信这个数,要么来问你这是什么意思。两种都是白白多出来的一轮。 于是他要么不信这个数,要么来问你这是什么意思。两种都是白白多出来的一轮。
### 🔴 第 0.5 条:怎么写 —— 排版也是内容的一部分 ### 🔴 第 0.5 条:怎么写 —— 这是**唯一**一处写作规范
主管一屏里能真正读进去的就三五行。写法按这四条: ⚠️ 措辞的要求全在这一节。⛔ 别再去别处找 —— 工具返回值里**只有事实,没有指令**
(那是 2026-08-12 起的规矩:成品句子有固定形状,你学会形状就能凭空背出来,
而背出来的是假的)。
**① 用 \`###\` 分块,每块一个 4–8 字的小标题** **① 用 \`###\` 分块,块名照工具返回值的键名走。**
界面会把它渲染成小节标签(灰色小字),这是他扫视时唯一的落脚点。 \`propose_assignment\` 回给你的就是三段:\`怎么选的\` / \`怎么派的\` / \`要主管定的\`。
常用三块,**按这个顺序**:\`### 怎么排的\` → \`### 已排好\` → \`### 要您定的\`。 有哪几段就写哪几段,**顺序照给**,⛔ 别自己重排、⛔ 别硬凑一个空标题。
⚠️ **已排好的在前,要他定的在后**(2026-08-06 产品定)—— 主管要先知道这批本身是什么样, ⚠️ 「要主管定的」为空数组时就是**没有要他定的**,那一块整块不写。
再看还剩什么要他处理。⛔ 这与工具返回值的段落顺序一致,别自己重排。
⛔ 别拿加粗当标题(那正是现在读不出重点的原因)。
**② 加粗一条消息最多 2 处**,只标**需要他动手 / 会造成后果**的那句 **② 你的活是把事实组织成人话,⛔ 不是复述 JSON。**
(「不动他们就是不分」这种)。⛔ 别给数字、名词、每段开头加粗。 ✅「这批 405 人是从种植·三个月内的 1200 人里挑的,没被分过的排前面」
⛔「怎么选的:治疗项=种植,时间档=三个月内,符合条件的总人数=1200」
**③ ⛔ 不要复述卡片上已有的数字。** **③ ⛔ 不要复述卡片上已有的数字。**
卡片头已经写着「共 50 人 · 拟分 16(9 位客服) · 待分配 34 人」—— 卡片头已经写着「共 50 人 · 拟分 16(9 位客服) · 待分配 34 人」——
你再说一遍,他就得读两遍同样的东西,而第二遍还没第一遍清楚。 你再说一遍,他就得读两遍同样的东西,而第二遍还没第一遍清楚。
你要写的是**卡片说不出来的那半句**:为什么是这些人、他现在该做什么。 你要写的是**卡片说不出来的那半句**:为什么是这些人、他现在该做什么。
**④ 中文标点一律全角**:半角的 , ; : ( ) 一律写成 ,;:()。 **④ 加粗一条消息最多 2 处**,只标**需要他动手 / 会造成后果**的那句
(「不动他们就是不分」这种)。⛔ 别给数字、名词、每段开头加粗。
**⑤ 中文标点一律全角**:半角的 , ; : ( ) 一律写成 ,;:()。
⚠️ 半角逗号在中文里挤成一团,而句号又是全角,一行里宽窄不一,读起来发涩。 ⚠️ 半角逗号在中文里挤成一团,而句号又是全角,一行里宽窄不一,读起来发涩。
例外只有两个:小数点(3.5 天)、英文/代码原文里的标点。 例外只有两个:小数点(3.5 天)、英文/代码原文里的标点。
**⑤ 照这个骨架写** —— ⚠️ 这是**骨架不是措辞**,内容按当轮真实情况写: **⑥ 「要主管定的」那几条,卡片上已经有按钮了。**
你只需要说清**是什么、不管会怎样**,⛔ 不要教他怎么点、⛔ 不要把选项列一遍 ——
\`\`\` 按钮就在那几行字下面(T14:界面元素也算证据,已经有的不用再说一遍)。
### 怎么排的
<候选是怎么来的、这批人数怎么定的;这块最长两行。>
### 已排好
<一句话说清落人规则 + 时效;⛔ 不要重复卡片上已有的数字。>
### 要您定的
<一句话说清他要做的动作 + 不做的后果。这里可以有一处加粗。>
\`\`\`
⚠️⚠️ **给了规则仍然只写大段落 = 没照做**(2026-08-11 实测:只给上面四条规则时,
模型把标点改对了、\`###\` 却一个都没出,仍旧三大段 + 6 处加粗)。
⇒ 每次回复前先自问:**这几行 \`###\` 写了吗?** 没写就是不合格。
⚠️ 没有"要您定的"时(全部排好、没有待分配),就只写前两块,⛔ 别硬凑一个空标题。
### 批次分配的生产线(不要跳步,也不要替主管跨步) ### 批次分配的生产线(不要跳步,也不要替主管跨步)
1. 主管已在召回池选定人群(**矩阵上的一格** = 一个治疗项 × 一档时间), 1. 主管已在召回池选定人群(**矩阵上的一格** = 一个治疗项 × 一档时间),
...@@ -139,7 +129,7 @@ const DISPATCHER_EXTRA = ` ...@@ -139,7 +129,7 @@ const DISPATCHER_EXTRA = `
「成功与否现在不统计,但底账都在(出池原因 + 客观事实),要看随时能回过头算」。 「成功与否现在不统计,但底账都在(出池原因 + 客观事实),要看随时能回过头算」。
5. **在岗与专属客服都是近似值。** 5. **在岗与专属客服都是近似值。**
原样转述 rosterNote,⛔ 不要说成「系统确认在职」,也不要替主管挡人 —— ⛔ 不要说成「系统确认在职」,也不要替主管挡人 ——
名册外的客服他照样可以指定(有人只做召回不做回访,名册里查不到)。 名册外的客服他照样可以指定(有人只做召回不做回访,名册里查不到)。
6. **确认单是一张真卡片,你能直接改它。** 6. **确认单是一张真卡片,你能直接改它。**
......
...@@ -13,6 +13,8 @@ import { ...@@ -13,6 +13,8 @@ import {
import { AiProviderService } from '../ai/core/ai-provider.service'; import { AiProviderService } from '../ai/core/ai-provider.service';
import { McpClientService } from './mcp-client.service'; import { McpClientService } from './mcp-client.service';
import { AssignmentProposalService } from '../plan/assignment-proposal.service'; import { AssignmentProposalService } from '../plan/assignment-proposal.service';
// ⭐ 给模型的事实投影(⛔ 不再给成品句子,见该文件顶部注释)
import { modelFacts } from '../plan/assignment-facts';
import { PERSONA_TAGS_DESC, POTENTIAL_TREATMENT_DESC } from '../mcp/persona-tags.desc'; import { PERSONA_TAGS_DESC, POTENTIAL_TREATMENT_DESC } from '../mcp/persona-tags.desc';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
...@@ -153,17 +155,17 @@ export class AssistantService { ...@@ -153,17 +155,17 @@ export class AssistantService {
const push = input.onSideEvent; const push = input.onSideEvent;
tools.propose_assignment = tool({ tools.propose_assignment = tool({
description: description:
'出「全景确认单」:从召回池按排序键选人,按「专属优先 / 溢出铺平」落到客服头上,' + '出「全景确认单」:从召回池选人,按「专属优先 → 无主补空」落到客服头上,' +
'并把卡片直接呈现给主管。' + '并把卡片直接呈现给主管。' +
'\n⚠️ 卡片由界面渲染,**你看不到明细也不需要看** —— 你的任务是转述返回的那句摘要。' + '\n⚠️ 卡片由界面渲染,**你看不到明细也不需要看**。' +
'\n⭐ 返回的是**结构化事实**(怎么选的 / 怎么派的 / 要主管定的),' +
'你的活是把它组织成人话 —— 怎么写见系统提示词的「第 0.5 条」。' +
'\n⚠️ **这只是提案,一个字都没写库**。绝不要说「已经分配好了」。' + '\n⚠️ **这只是提案,一个字都没写库**。绝不要说「已经分配好了」。' +
'\n⚠️ 本批人数与时效**会自动沿用主管上一次的值**,⛔ 别问他 —— 那正是这个设计要省掉的输入。' + '\n⚠️ 人数与时效**不问主管**:不传就按「在岗人数 × 每天 15 通 × 时效」估。' +
'他明确说「这批 200 人」「给 5 天」时才传 targetCount / expiresInDays(都会被记住)。' + '他明确说「这批 200 人」「给 5 天」时才传 targetCount / expiresInDays。' +
'\n⚠️ **没有"容量上限"** —— 落人走「专属(封顶在目标水位)→ 无主补空」两趟,' + '\n⚠️ **没有"容量上限"** —— 池子里还有人就随时能再分一批。' +
'池子里还有人就随时能再分一批。' +
'\n🔴 **专属客服排满的那些人不会被自动改派**(2026-08-06 改判):它们进「待分配」,' + '\n🔴 **专属客服排满的那些人不会被自动改派**(2026-08-06 改判):它们进「待分配」,' +
'在卡片上单列一组等主管决定。⛔ 你不许替他把患者从专属客服手里挪走。' + '在卡片上单列一组等主管决定。⛔ 你不许替他把患者从专属客服手里挪走。',
'selectionNote 里有那句要说的话,**照抄**,⛔ 别省。',
inputSchema: jsonSchema({ inputSchema: jsonSchema({
type: 'object', type: 'object',
properties: { properties: {
...@@ -246,53 +248,30 @@ export class AssistantService { ...@@ -246,53 +248,30 @@ export class AssistantService {
// 前端确认时原样回传,这样重连/重试都命中同一个幂等键。 // 前端确认时原样回传,这样重连/重试都命中同一个幂等键。
const requestId = `pa_${scope.userId}_${randomUUID()}`; const requestId = `pa_${scope.userId}_${randomUUID()}`;
push({ type: 'assignment_sheet', requestId, sheet }); push({ type: 'assignment_sheet', requestId, sheet });
if (sheet.placed === 0) { /**
return `没有可分配的候选(${sheet.selectionNote})。请把这句如实转述给主管。`; * ⭐ 回给模型的是**结构化事实**,⛔ 不再是成品句子(P2,2026-08-12)。
} *
// ⭐ 回给模型的**只有台词稿**,不含任何 planId。 * 原来这里返回三段服务端拼好的话 + 十几行「原样输出、不要改写、不要合并」。
// * 两个问题:
// ── 为什么是台词稿而不是数据(方法论)────────────────── * ① 成品句子有固定形状,模型学会形状就能**凭空背出来** ——
// T14/T20 那两类要求(标注默认值、样本不足不出百分比)全是除法和阈值判断, * 实测背出过「已撤销批次:收回 9 条。」,而撤销工具一次都没调。
// 恰恰是 LLM 最不可靠的地方。把话写好交给它抄,任务就从"让模型算对" * 结构化事实编不出来(JSON 里的具体值瞎写一眼就对得出来)。
// 降级成"让模型照抄"。 * ② 「照抄」浪费了模型唯一不可替代的能力:把事实组织成人话。
// *
// ⚠️ 2026-08-03 走查后改了三处: * ⛔ **这个返回值里一个字的指令都不许有** —— 措辞规范集中在系统提示词
// ① **先说话再看卡** —— 主管要先知道"怎么选的、怎么分的",再去核对明细; * (assistant-prompts 的「怎么写」一节),⛔ 别再往返回值里塞。
// (卡片本身由界面钉在这段话下面,见 assistant-chat 的 blocks 排序) * ⛔ 仍然不含任何 planId / 患者姓名:那些走侧信道给界面。
// ② **说人话** —— 原来那三句里全是"排序键/铺平/水位/基数",那是给程序员看的; */
// ③ **只讲怎么选 + 怎么分**,别的都别说;最后给一句"要调怎么说"。 return JSON.stringify(
return [ {
`确认单已呈现给主管(卡片会显示在你这段话**下面**,你看不到明细,也不要复述明细)。`, 确认单已呈现: true,
``, // ⚠️ 事实,不是叮嘱:主管点确认之前一条都没落库
`请把下面两段**原样输出**(它们已经排好版:每段第一行是加粗的「数字+是什么」,`, 已写入数据库: false,
`第二行才是解释)。⛔ 不要合并成一段、不要改写、不要重新编号:`, ...modelFacts(sheet),
``, },
sheet.selectionNote, null,
``, 2,
// ⚠️ 顺序:**已排好的在前,要他定的在后**(2026-08-06 产品定)。 );
// 原来把"要您定"排在前面,理由是"要动手的先说";走查后改判 ——
// 主管先要知道**这批本身是什么样**,再看还剩什么要他处理。
sheet.basisNote,
...(sheet.pendingNote ? ['', sheet.pendingNote] : []),
``,
``,
// 🔴 那四条「怎么调」的引导**已经搬到卡片上常驻**(2026-08-06 走查)。
// 原来要求你每出一版都念一遍 —— 第一次有用,第二次起就是噪音,
// 而主管一天要看好几版。卡片底部那行小字永远在,想用时看得见。
// ⛔ 别再自己把那四条抄进对话,⛔ 也别复述卡片上已经写着的东西。
`说完这两段就停。`,
`⛔ **不要再列"您可以这样调整"那几条** —— 卡片底部一直写着。`,
`⛔ **也不要写「确认无误请点『确认分配』」** —— 那个按钮就在这段话下面,`,
` 他看得见;每次都补一句等于把最没信息量的话放在最后(T14 的推论:界面元素也算证据,`,
` 已经有的东西不用再说一遍)。`,
``,
`⚠️ 上面两句是**成品句子,原话抄**:里面的数字、"没被分过的排在前面"、"第一次的估算值"` +
`这类措辞都不要改写、不要省。`,
`⛔ 不要说"已经分配好了"(主管点确认之前一条都没真的分下去)。`,
`⛔ 不要出现「排序键 / 收敛 / 铺平 / 水位 / 基数 / 探索配额」这类词 —— 主管看不懂。`,
`⛔ 不要复述患者名单或客服分配明细 —— 卡片上有,而且你手里根本没有。`,
`⚠️ 引导那几句要用**主管的话**说,别报工具名(⛔ 不要出现 get_cohort_attributes 这种词)。`,
].join('\n');
}, },
}); });
......
import { AssignStrategy, DAILY_CALLS_PER_AGENT, type AssignmentProposal } from '@pac/types';
// ⚠️ 只借它的三个纯标签函数(取值码 → 中文),⛔ 不引 mcp 的任何服务/状态。
import { zhAnchorMode, zhTemperature, zhTreatment } from '../mcp/model-facing';
/**
* assignment-facts —— 把一份确认单投影成**给模型的结构化事实**。
*
* ═══ 为什么不再给成品句子 ═══════════════════════════════════════
* 原来 `propose_assignment` 回给模型的是 `selectionNote` / `basisNote` / `pendingNote`
* 三段服务端拼好的话,外加十几行「原样输出、不要改写、不要合并」。两个问题:
*
* ① **成品句子有固定形状,模型学会形状就能凭空背出来。** 实测背出过
* 「已撤销批次:收回 9 条。」—— 而撤销工具一次都没被调用。
* 结构化事实编不出来:JSON 里的具体值瞎写一眼就能对出来。
* ② **「照抄」浪费了模型唯一不可替代的能力。** 它最擅长的就是把一堆事实
* 组织成人话;让它当复读机,等于花钱买了个模板引擎。
*
* ⇒ 服务端给**事实**,模型负责**措辞**,写作规范集中写在系统提示词里一处。
*
* ═══ 边界 ═══════════════════════════════════════════════════════
* ⛔ **这里一个字的指令都不许有**(「请照抄」「不要说成…」之类)。
* 2026-08-08 栽过:`get_assignment_detail` 的 note 字段混着给模型的指令,
* 模型照抄就把内部指令原样贴进了主管的对话框。⇒ 事实归事实,规范归提示词。
* ⛔ 不含任何 planId / 患者姓名 —— 那些走侧信道给界面,模型手里不该有。
* ⚠️ 键名用中文:模型照着键名说就是对的,省掉一轮"把 inHandPending 翻译成人话"。
*/
/** 给模型的事实投影。⚠️ 纯函数,输入一份提案、输出一个可序列化对象。 */
export function modelFacts(p: AssignmentProposal): Record<string, unknown> {
const agents = p.byAgent.length;
const dedicated = p.byAgent.reduce((s, a) => s + a.dedicated, 0);
const spread = p.byAgent.reduce((s, a) => s + a.spread, 0);
const loads = p.byAgent.map((a) => a.loadAfter);
const perBatch = p.byAgent.map((a) => a.count);
return {
'怎么选的': {
'治疗项': zhTreatment(p.potentialTreatment),
'时间档': zhTemperature(p.temperature),
'口径': zhAnchorMode(p.anchorMode ?? null),
'符合条件的总人数': p.candidateTotal,
'本批人数': p.target,
// ⚠️ 候选不够时 target 被压低,但**基数没变** —— 两个数要分别给,
// 不然主管会以为系统偷偷把他的设置改小了。
...(p.target < p.batchSize ? { '您设的基数': p.batchSize } : {}),
'本批人数怎么来的':
p.basis === 'explicit'
? '主管这次指定的'
: p.basis === 'inherited'
? `沿用 ${p.basisFrom ?? '上一次'}`
: `系统默认:在岗 ${agents} × 每天 ${DAILY_CALLS_PER_AGENT} × ${p.expiresInDays} `,
'排序': '没被分过的排在前面,其余按优先级从高到低',
...(p.candidateTotal > p.target
? { '这轮还没轮到': p.candidateTotal - p.target }
: {}),
},
'怎么派的': {
'已排好': p.placed,
'涉及客服数': agents,
'分给自己专属客服的': dedicated,
'给手上最空的人的': spread,
// ⚠️ 「一条都没分到」的人也要给 —— 主管要看见"他不是被漏了"
'本批没分到的客服': p.skippedAgents.length,
'时效天数': p.expiresInDays,
'本批每人拿到': range(perBatch),
'分完之后每人手上': range(loads),
'按每天几通算': DAILY_CALLS_PER_AGENT,
'落人规则': '有专属的先回自己人手上;无主的给当前手上最少的那个',
...(p.pending.length > 0 ? { '专属客服排满、这批没发的': p.pending.length } : {}),
},
// ⚠️ 只给 action 层:info 层是折叠在卡片里的说明,模型复述它纯属噪音。
// ⛔ 不给 options 的技术细节 —— 那是按钮的事,模型说出 `pending.to_owner` 只会吓到主管。
'要主管定的': p.signals
.filter((s) => s.tier === 'action')
.map((s) => ({ '是什么': s.title, '为什么': s.why, '不处理会怎样': s.defaultLabel })),
};
}
/** `[3, 5, 4]` → `'3~5'`;全相等时给单值。空数组给 0。 */
function range(xs: number[]): string {
if (xs.length === 0) return '0';
const lo = Math.min(...xs);
const hi = Math.max(...xs);
return lo === hi ? `${lo}` : `${lo}~${hi}`;
}
/** 落人策略的取值码 → 中文(给明细用;目前 facts 里只用聚合数,留着备用)。 */
export function zhAssignStrategy(s: string): string {
return s === AssignStrategy.DEDICATED ? '专属客服' : '手上最空的人';
}
...@@ -4,6 +4,7 @@ import { ...@@ -4,6 +4,7 @@ import {
computeHighlights, computeHighlights,
computeSignals, computeSignals,
} from '../src/modules/plan/assignment-signals'; } from '../src/modules/plan/assignment-signals';
import { modelFacts } from '../src/modules/plan/assignment-facts';
/** /**
* 引导节点的判定 —— 规格见 docs/design/assignment-agent-flow.md §五。 * 引导节点的判定 —— 规格见 docs/design/assignment-agent-flow.md §五。
...@@ -291,3 +292,72 @@ describe('引导节点 · 画像亮点', () => { ...@@ -291,3 +292,72 @@ describe('引导节点 · 画像亮点', () => {
]); ]);
}); });
}); });
/**
* 给模型的事实投影 —— P2 的核心不变式。
*
* 🔴 这里锁的是「**返回值里一个字的指令都不许有**」:
* 成品句子有固定形状,模型学会形状就能凭空背出来(实测背出过「已撤销批次:收回 9 条」,
* 而撤销工具一次都没调)。结构化事实编不出来 —— 但前提是我们别又把话写回去。
*/
describe('给模型的事实', () => {
const base = () =>
proposal({
candidateTotal: 1000,
target: 405,
batchSize: 405,
placed: 405,
basis: 'default',
expiresInDays: 3,
byAgent: [agent('张悦', 45, 10), agent('李莉', 45)],
skippedAgents: [{ userId: 'x', name: '王强', inHand: 80 }],
});
test('⭐ 三段齐全且顺序固定:怎么选的 → 怎么派的 → 要主管定的', () => {
expect(Object.keys(modelFacts(base()))).toEqual(['怎么选的', '怎么派的', '要主管定的']);
});
test('🔴 ⛔ 里面一个字的指令都不许有', () => {
const json = JSON.stringify(modelFacts(base()));
for (const w of ['照抄', '原样', '不要说', '绝不', '⛔', '请把', '转述', '必须说']) {
expect(json).not.toContain(w);
}
});
test('🔴 ⛔ 不含 planId / 患者姓名(那些走侧信道给界面)', () => {
const p = base();
p.items = [{ planId: 'plan-uuid-1', patientName: '张三' }] as never;
expect(JSON.stringify(modelFacts(p))).not.toContain('plan-uuid-1');
expect(JSON.stringify(modelFacts(p))).not.toContain('张三');
});
test('⭐ 候选不够时,本批人数与基数**分别给** —— 否则主管以为系统偷偷改小了他的设置', () => {
const f = modelFacts(proposal({ candidateTotal: 44, target: 44, batchSize: 405 })) as {
怎么选的: Record<string, unknown>;
};
expect(f.怎么选的['本批人数']).toBe(44);
expect(f.怎么选的['您设的基数']).toBe(405);
});
test('⭐ 候选够时不给「您设的基数」(没有歧义就别多一个数)', () => {
const f = modelFacts(base()) as { 怎么选的: Record<string, unknown> };
expect(f.怎么选的['您设的基数']).toBeUndefined();
});
test('⭐ 取值码换成中文,⛔ 模型手里不该有 implant / hot 这种词', () => {
const f = modelFacts(base()) as { 怎么选的: Record<string, unknown> };
expect(f.怎么选的['治疗项']).not.toBe('implant');
expect(f.怎么选的['时间档']).not.toBe('hot');
});
test('⭐ 要主管定的只给 action 层,且不含 intent 技术细节', () => {
const p = base();
p.signals = computeSignals(p);
const f = modelFacts(p) as { 要主管定的: Array<Record<string, unknown>> };
expect(f.要主管定的.length).toBeGreaterThan(0);
for (const x of f.要主管定的) {
expect(Object.keys(x)).toEqual(['是什么', '为什么', '不处理会怎样']);
}
expect(JSON.stringify(f.要主管定的)).not.toMatch(/pending\.|expiry\.|cohort\./);
});
});
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import type { AssignmentProposal } from '@pac/types';
import { modelFacts } from '../src/modules/plan/assignment-facts';
import { join } from 'node:path'; import { join } from 'node:path';
import { TEMPERATURE_TOOL_DESC } from '@pac/types'; import { TEMPERATURE_TOOL_DESC } from '@pac/types';
import { import {
...@@ -559,6 +561,10 @@ describe('确认单话术 —— 让主管一眼看懂', () => { ...@@ -559,6 +561,10 @@ describe('确认单话术 —— 让主管一眼看懂', () => {
join(__dirname, '../src/modules/assistant/assistant.service.ts'), join(__dirname, '../src/modules/assistant/assistant.service.ts'),
'utf8', 'utf8',
); );
const PROMPTS = readFileSync(
join(__dirname, '../src/modules/assistant/assistant-prompts.ts'),
'utf8',
);
const SHEET = readFileSync( const SHEET = readFileSync(
join(__dirname, '../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx'), join(__dirname, '../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx'),
'utf8', 'utf8',
...@@ -581,7 +587,9 @@ describe('确认单话术 —— 让主管一眼看懂', () => { ...@@ -581,7 +587,9 @@ describe('确认单话术 —— 让主管一眼看懂', () => {
}); });
test('⭐⭐ 四条操作指引搬到卡片常驻,⛔ 不再要求助手每轮复述', () => { test('⭐⭐ 四条操作指引搬到卡片常驻,⛔ 不再要求助手每轮复述', () => {
expect(SVC_ASSIST).toMatch(/不要再列"您可以这样调整"那几条/); // 🔴 2026-08-12(P2):这条规矩从**工具返回值**搬到了系统提示词 ——
// 返回值里现在只有事实、一个字的指令都没有(见 assignment-facts 顶部注释)。
expect(PROMPTS).toMatch(/不要把选项列一遍/);
expect(SHEET).toMatch(/可以直接跟我说:「把某某移出这批」/); expect(SHEET).toMatch(/可以直接跟我说:「把某某移出这批」/);
// ⚠️ 只在还能改的时候显示 —— 已确认/已撤销时它是误导 // ⚠️ 只在还能改的时候显示 —— 已确认/已撤销时它是误导
// ⚠️ 类名别写太死:`border-slate-100` 2026-08-11 随「全局 border 规则放进 @layer base」 // ⚠️ 类名别写太死:`border-slate-100` 2026-08-11 随「全局 border 规则放进 @layer base」
...@@ -859,14 +867,32 @@ describe('重新排一版 —— 只换挑谁,不动落人', () => { ...@@ -859,14 +867,32 @@ describe('重新排一版 —— 只换挑谁,不动落人', () => {
expect(SHEET).toMatch(/temperature: sheet\.temperature/); expect(SHEET).toMatch(/temperature: sheet\.temperature/);
}); });
test('⭐ 待分配那段排在「已排好」之后', () => { /**
const ASSIST = readFileSync( * 🔴 2026-08-12(P2):由「断源码里两个字段的先后」改成**断真实产出的键顺序**。
join(__dirname, '../src/modules/assistant/assistant.service.ts'), *
'utf8', * 原来的写法 grep 的是 `sheet.basisNote` / `sheet.pendingNote` 在源码里的位置 ——
* 台词稿一删就整条失效,而它锁的那个不变式(**已排好在前、要他定的在后**)其实还在。
* ⇒ 断行为不断源码。
*/
test('⭐ 给模型的事实:怎么选的 → 怎么派的 → 要主管定的', () => {
const keys = Object.keys(
modelFacts({
potentialTreatment: 'implant',
temperature: 'hot',
anchorMode: 'diagnosis',
candidateTotal: 100,
target: 50,
batchSize: 50,
basis: 'default',
basisFrom: null,
expiresInDays: 3,
placed: 50,
pending: [],
byAgent: [],
skippedAgents: [],
signals: [],
} as unknown as AssignmentProposal),
); );
const iBasis = ASSIST.indexOf('sheet.basisNote'); expect(keys).toEqual(['怎么选的', '怎么派的', '要主管定的']);
const iPending = ASSIST.indexOf('sheet.pendingNote');
expect(iBasis).toBeGreaterThan(0);
expect(iPending).toBeGreaterThan(iBasis);
}); });
}); });
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