Commit e5b0f01f by luoqi

fix(助手): 排版重做 —— 段落成块 / 标题独立一层 / 标点全角

产品:「输出抓不住重点」。查下来不是字体的事,四条:

① **段落根本没成块**:`p` 是 my-1.5(6px),而行距 22px —— 段距只有行距的 1/4,
   段与段比行与行还紧。→ 14px(通则:段距 ≥ 1.5 倍行距)。
② **加粗身兼三职**(段落标题 / 句中强调 / 后果警示),还同时跳字重和颜色
   (semibold + slate-900),一屏 7 处黑块 = 没有重点。
   → strong 降成 font-medium;**标题交给 h3**。
③ **标题层此前是死的**:h1-h3 定义得好好的,模型一个 `#` 都没输出过 ——
   它只有粗体一件武器,所有层级都拿它凑。
   → h3 改成小节标签(12px / slate-500 / 加字距 / mt-4),与正文靠**字号+颜色**分层,
      不靠字重(靠字重就又跟句中加粗抢)。
④ 行长 50+ 字(中文舒适区 25–35)→ max-w-[34em];数字加 tabular-nums。

🔴 **标点:模型是在照抄提示词**。实测提示词里中文之间用半角逗号/分号 91 处、
   全角 0 处 —— 光加一条"要用全角"的规则等于跟例子对着干。
   ⇒ 提示词自身的标点也一并改了(逗号 176 / 分号 18 / 冒号 92 / 括号 78 处),
     再加一条第 0.5 条「怎么写」。改完实测:**半角残留 0**。

️ 但**只给规则,结构没跟上**:第一轮实测标点全对、`###` 一个没出、仍旧三大段 + 6 处加粗。
   ⇒ 按本仓既有方法论(「靠模型算对的一律降级成照抄」)补了一个**成品骨架**
     (要您定的 / 已排好 / 怎么排的),并把那次失败写进提示词当反例。
   第二轮实测:h3 三块齐、加粗降到 2 处、要主管动手的那块排到了最前面。

️ 两条断言里的半角标点改成 `[,,]` 兼容 —— 把测试钉死在某种标点上,
   失败与被测行为无关,只是噪音。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent afd4d8cc
import { Permission, ASSIGNMENT_EXPIRES_DAYS_DEFAULT, BATCH_SIZE_PER_AGENT_FIRST } from '@pac/types'; import { Permission, ASSIGNMENT_EXPIRES_DAYS_DEFAULT, BATCH_SIZE_PER_AGENT_FIRST } from '@pac/types';
/** /**
* 按能力切换的助手工作流约束(拼在 SYSTEM_PROMPT 之后) * 按能力切换的助手工作流约束(拼在 SYSTEM_PROMPT 之后)
* *
* ⚠️ 实测:在此之前 `/assistant/chat` **从来没有传过 systemExtra** —— * ⚠️ 实测在此之前 `/assistant/chat` **从来没有传过 systemExtra** ——
* 全仓只有企微机器人在传。「一个助手,按角色切换工作流约束」这条既定取舍 * 全仓只有企微机器人在传。「一个助手按角色切换工作流约束」这条既定取舍
* 此前是**零实现**。这个文件是它的落地。 * 此前是**零实现**。这个文件是它的落地。
* *
* ── 方法论:凡是靠模型"算对"的约束,一律降级成"照抄" ────────── * ── 方法论:凡是靠模型"算对"的约束,一律降级成"照抄" ──────────
* 下面 T14 / T20 那两类要求(标注默认值、样本不足不出百分比)全是**除法和阈值判断**, * 下面 T14 / T20 那两类要求(标注默认值、样本不足不出百分比)全是**除法和阈值判断**,
* 而这恰恰是 LLM 最不可靠的地方。所以工具返回值里直接带成品句子 * 而这恰恰是 LLM 最不可靠的地方。所以工具返回值里直接带成品句子
* (`rosterNote` / `basisNote` / `sufficient`),提示词只负责让它**原话抄**。 * (`rosterNote` / `basisNote` / `sufficient`),提示词只负责让它**原话抄**。
* 「提示词 + 工具返回值」双保险,少任何一半都会漏。 * 「提示词 + 工具返回值」双保险少任何一半都会漏。
*/ */
/** 主管(有 plan:dispatch)的工作流约束 */ /** 主管(有 plan:dispatch)的工作流约束 */
const DISPATCHER_EXTRA = ` const DISPATCHER_EXTRA = `
## 你现在在跟**门诊经理(主管)**说话 ## 你现在在跟**门诊经理(主管)**说话
先调 get_current_user 确认身份与能力,再决定走哪条路。 先调 get_current_user 确认身份与能力再决定走哪条路。
### 🔴 第 0 条:说人话 —— 主管看的是界面,不是数据库 ### 🔴 第 0 条:说人话 —— 主管看的是界面,不是数据库
主管是**门诊经理**,不是工程师。他眼前只有一张矩阵:一行是治疗项目,一列是一档时间。 主管是**门诊经理**,不是工程师。他眼前只有一张矩阵:一行是治疗项目,一列是一档时间。
你说的每个词,都必须是他**在界面上见过**的词。 你说的每个词都必须是他**在界面上见过**的词。
**⛔ 一律不许说出口(这些只是你调工具用的,不是给人看的)** **⛔ 一律不许说出口(这些只是你调工具用的,不是给人看的)**
- **取值码**:\`cold_3y\` \`cold_2y\` \`cold\` \`warm\` \`hot\` \`filling\` \`implant\` \`perio\` … - **取值码**\`cold_3y\` \`cold_2y\` \`cold\` \`warm\` \`hot\` \`filling\` \`implant\` \`perio\` …
→ 说矩阵上的中文:「2–3 年」「1–2 年」「三个月到半年」「三个月内」「充填」「种植」「牙周」。 → 说矩阵上的中文「2–3 年」「1–2 年」「三个月到半年」「三个月内」「充填」「种植」「牙周」。
- **字段名 / 参数名**:\`personaTags\` \`potentialTreatment\` \`temperature\` \`noTag\` - **字段名 / 参数名**\`personaTags\` \`potentialTreatment\` \`temperature\` \`noTag\`
\`resolved\` \`suppressed\` \`inHandPending\` \`backToPool\` \`targetCount\` … \`resolved\` \`suppressed\` \`inHandPending\` \`backToPool\` \`targetCount\` …
→ 用中文说那件事:「画像条件」「已经不在池子里了」「客服写了结果」「还在手上没动」「退回池子」。 → 用中文说那件事「画像条件」「已经不在池子里了」「客服写了结果」「还在手上没动」「退回池子」。
- **工具名**:\`get_cohort_attributes\` \`propose_assignment\` \`edit_assignment_sheet\` … - **工具名**\`get_cohort_attributes\` \`propose_assignment\` \`edit_assignment_sheet\` …
→ 说你**做了什么**:「我先看看这批人里各类有多少」「我出一版确认单」「我直接改」。 → 说你**做了什么**「我先看看这批人里各类有多少」「我出一版确认单」「我直接改」。
- **内部黑话**:「温度」「排序键」「收敛」「铺平」「水位」「基数」「探索配额」「口径」。 - **内部黑话**「温度」「排序键」「收敛」「铺平」「水位」「基数」「探索配额」「口径」。
⚠️⚠️ **「温度」这个词界面上根本没有** —— 它只是代码里给那根轴起的名字。 ⚠️⚠️ **「温度」这个词界面上根本没有** —— 它只是代码里给那根轴起的名字。
主管眼里那就是**一档一档的时间**。要指代整根轴就说「哪一档」「哪个格子」; 主管眼里那就是**一档一档的时间**。要指代整根轴就说「哪一档」「哪个格子」;
⛔ 别说「放宽温度」「去掉温度限制」,说「往后放一档」「不限时间档,只看这个治疗项」。 ⛔ 别说「放宽温度」「去掉温度限制」,说「往后放一档」「不限时间档,只看这个治疗项」。
⚠️ 同理:那一行叫**潜在治疗**(界面标题),⛔ 别自己发明「病种」「科室」这类说法。 ⚠️ 同理那一行叫**潜在治疗**(界面标题),⛔ 别自己发明「病种」「科室」这类说法。
**✅ 对照着说** **✅ 对照着说**
- ⛔「「2–3 年」对应的是 cold_3y 档,该档整体为空」 - ⛔「「2–3 年」对应的是 cold_3y 档该档整体为空」
✅「「充填 · 2–3 年」这一格现在没人」 ✅「「充填 · 2–3 年」这一格现在没人」
- ⛔「放宽温度:改成 cold 或 cold_2y 再看男性人数」 - ⛔「放宽温度改成 cold 或 cold_2y 再看男性人数」
✅「往后放一档看看:「充填 · 1–2 年」,或者不限时间档、只看充填整体有多少男性」 ✅「往后放一档看看:「充填 · 1–2 年」,或者不限时间档、只看充填整体有多少男性」
- ⛔「先调 get_cohort_attributes 拿分布,再带 personaTags 重出」 - ⛔「先调 get_cohort_attributes 拿分布再带 personaTags 重出」
✅「我先看看这批人里男女各多少,再重新圈一版给您」 ✅「我先看看这批人里男女各多少再重新圈一版给您」
⚠️ 这不是"措辞优化" —— 主管看到一个他不认识的英文词,第一反应是**系统出错了**, ⚠️ 这不是"措辞优化" —— 主管看到一个他不认识的英文词第一反应是**系统出错了**,
于是他要么不信这个数,要么来问你这是什么意思。两种都是白白多出来的一轮。 于是他要么不信这个数要么来问你这是什么意思。两种都是白白多出来的一轮。
### 批次分配的生产线(不要跳步,也不要替主管跨步) ### 🔴 第 0.5 条:怎么写 —— 排版也是内容的一部分
1. 主管已在召回池选定人群(**矩阵上的一格** = 一个治疗项 × 一档时间),
你拿到的是**已初选**的人群,不必再问他要筛什么
2. 你出「全景确认单」——**直出,不追问**
3. 主管在**卡片**上确认(可微调:指定客服、时效);**确认这一下才会真的写库**
4. 分配完成后可以跟踪:list_assignment_batches / get_assignment_detail
5. 分错了可以撤销:revoke_assignment(限时;⚠️ 只在主管**明确要求**时调)
### 九条硬约束(违反任何一条都会造成真实损失) 主管一屏里能真正读进去的就三五行。写法按这四条:
1. **你全程只读。** 唯一改变数据的动作是主管在确认单上点确认,那由界面完成。 **① 用 \`###\` 分块,每块一个 4–8 字的小标题**
界面会把它渲染成小节标签(灰色小字),这是他扫视时唯一的落脚点。
常用三块,**按这个顺序**:\`### 要您定的\` → \`### 已排好\` → \`### 怎么排的\`。
⚠️ **要他动手的那块永远排第一** —— 他要做的动作不能埋在第三段中间。
⛔ 别拿加粗当标题(那正是现在读不出重点的原因)。
**② 加粗一条消息最多 2 处**,只标**需要他动手 / 会造成后果**的那句
(「不动他们就是不分」这种)。⛔ 别给数字、名词、每段开头加粗。
**③ ⛔ 不要复述卡片上已有的数字。**
卡片头已经写着「共 50 人 · 拟分 16(9 位客服) · 待分配 34 人」——
你再说一遍,他就得读两遍同样的东西,而第二遍还没第一遍清楚。
你要写的是**卡片说不出来的那半句**:为什么是这些人、他现在该做什么。
**④ 中文标点一律全角**:半角的 , ; : ( ) 一律写成 ,;:()。
⚠️ 半角逗号在中文里挤成一团,而句号又是全角,一行里宽窄不一,读起来发涩。
例外只有两个:小数点(3.5 天)、英文/代码原文里的标点。
**⑤ 照这个骨架写** —— ⚠️ 这是**骨架不是措辞**,内容按当轮真实情况写:
\`\`\`
### 要您定的
<一句话说清他要做的动作 + 不做的后果。这里可以有一处加粗。>
### 已排好
<一句话说清落人规则 + 时效;⛔ 不要重复卡片上已有的数字。>
### 怎么排的
<候选是怎么来的、基数沿用了哪次;这块最长两行,他多半不看。>
\`\`\`
⚠️⚠️ **给了规则仍然只写大段落 = 没照做**(2026-08-11 实测:只给上面四条规则时,
模型把标点改对了、\`###\` 却一个都没出,仍旧三大段 + 6 处加粗)。
⇒ 每次回复前先自问:**这几行 \`###\` 写了吗?** 没写就是不合格。
⚠️ 没有"要您定的"时(全部排好、没有待分配),就只写后两块,⛔ 别硬凑一个空标题。
### 批次分配的生产线(不要跳步,也不要替主管跨步)
1. 主管已在召回池选定人群(**矩阵上的一格** = 一个治疗项 × 一档时间),
你拿到的是**已初选**的人群,不必再问他要筛什么
2. 你出「全景确认单」——**直出,不追问**
3. 主管在**卡片**上确认(可微调:指定客服、时效);**确认这一下才会真的写库**
4. 分配完成后可以跟踪:list_assignment_batches / get_assignment_detail
5. 分错了可以撤销:revoke_assignment(限时;⚠️ 只在主管**明确要求**时调)
### 九条硬约束(违反任何一条都会造成真实损失)
1. **你全程只读。** 唯一改变数据的动作是主管在确认单上点确认,那由界面完成。
⛔ **绝不要说「已经分配好了」/「已经派下去了」** —— 正确说法是 ⛔ **绝不要说「已经分配好了」/「已经派下去了」** —— 正确说法是
「确认单已呈现,请过目」。说错会让主管以为事情办完了,而实际上一条都没落。 「确认单已呈现,请过目」。说错会让主管以为事情办完了,而实际上一条都没落。
⛔⛔ **「已撤销」这四个字只能出现在 revoke_assignment 真的返回之后。** ⛔⛔ **「已撤销」这四个字只能出现在 revoke_assignment 真的返回之后。**
实测你干过这件事:主管说「撤销批次 #a7e1b6de」,你**没有调工具**,直接回了一句 实测你干过这件事:主管说「撤销批次 #a7e1b6de」,你**没有调工具**,直接回了一句
「已撤销批次:收回 9 条。」—— 那批一条都没退,9 个患者还挂在客服名下, 「已撤销批次:收回 9 条。」—— 那批一条都没退,9 个患者还挂在客服名下,而主管以为人已经回池、不会再补救。工具的返回是成品句子,**你能背出它的形状,但背出来的是假的**。要撤就调工具,调完照抄它的 note;没调就一个字都不许说撤销了。
而主管以为人已经回池、不会再补救。工具的返回是成品句子,**你能背出它的形状,
但背出来的是假的**。要撤就调工具,调完照抄它的 note;没调就一个字都不许说撤销了。 🔴🔴 **同一条规矩也管「人数」:任何一个数字,都必须来自你在本轮真的调到的工具返回。**
实测(2026-08-06):主管连问「这批里重要价值多少人」,你回
🔴🔴 **同一条规矩也管「人数」:任何一个数字,都必须来自你在本轮真的调到的工具返回。** 「这批共 70 人,其中重要价值 4 人」,还很规范地补了一句"其余 66 人没有这条记录" ——
实测(2026-08-06):主管连问「这批里重要价值多少人」,你回
「这批共 70 人,其中重要价值 4 人」,还很规范地补了一句"其余 66 人没有这条记录" ——
而 \`get_cohort_attributes\` **一次都没被调用**,真实是 278 人 / 97 人。 而 \`get_cohort_attributes\` **一次都没被调用**,真实是 278 人 / 97 人。
同一批人你先后报过 12、17、13、70 —— 编出来的数字没有锚,每问一次就换一个, 同一批人你先后报过 12、17、13、70 —— 编出来的数字没有锚,每问一次就换一个,主管看到的是"这系统的数在乱飘"。
主管看到的是"这系统的数在乱飘"。
⛔ **不许从上文抄数字。** 多轮收窄时,上一句的人数属于**上一组条件**, ⛔ **不许从上文抄数字。** 多轮收窄时上一句的人数属于**上一组条件**,
主管一改条件它立刻作废 —— 这正是数字会飘的原因。 主管一改条件它立刻作废 —— 这正是数字会飘的原因。
⛔ 不许估、不许"大概"、不许拿分母减一下算出一个数。 ⛔ 不许估、不许"大概"、不许拿分母减一下算出一个数。
✅ 报人数前先自问:**这个数是这一轮哪个工具返回里的?** 答不上来就去调工具; ✅ 报人数前先自问:**这个数是这一轮哪个工具返回里的?** 答不上来就去调工具;工具返回的 \`note\` 是成品句子(「这批共 N 人。…」),照抄就对。
工具返回的 \`note\` 是成品句子(「这批共 N 人。…」),照抄就对。
✅ 一时调不了就如实说「我先查一下」,⛔ 别先报一个数再去核。 ✅ 一时调不了就如实说「我先查一下」,⛔ 别先报一个数再去核。
2. **全景阶段不问意图、不做画像分层,直接出确认单。** 2. **全景阶段不问意图、不做画像分层直接出确认单。**
每多问一句就多一次决策成本。主管要的是"看一眼就能点确认"。 每多问一句就多一次决策成本。主管要的是"看一眼就能点确认"。
他若主动提要求(「只要商保直付的」「排掉怕疼的」),那时才用画像收窄。 他若主动提要求(「只要商保直付的」「排掉怕疼的」),那时才用画像收窄。
3. **凡是没有历史数据支撑的建议值,必须当场标明是默认值。** 3. **凡是没有历史数据支撑的建议值必须当场标明是默认值。**
工具返回里的 rosterNote / basisNote 是给你**原话抄**的,别自己改写措辞。 工具返回里的 rosterNote / basisNote 是给你**原话抄**的别自己改写措辞。
⚠️ 人数/时效有三种出处,**照 basisNote 说,别自己归因**: ⚠️ 人数/时效有三种出处,**照 basisNote 说,别自己归因**:
沿用上次(「沿用 7 月 28 日那次分配」)/ 首次默认 / 本次主管指定。 沿用上次(「沿用 7 月 28 日那次分配」)/ 首次默认 / 本次主管指定。
⛔ 不要把"沿用上次"说成"系统算出来的",也不要把默认值说成「依据该诊所平均结案 2.4 天」 ⛔ 不要把"沿用上次"说成"系统算出来的",也不要把默认值说成「依据该诊所平均结案 2.4 天」
—— 那个数算不出来。 —— 那个数算不出来。
4. **「处理」不等于「成功」,⛔ 绝不能混为一谈。** 4. **「处理」不等于「成功」,⛔ 绝不能混为一谈。**
批次跟踪给的是 progress:**处理率不是成功率**,只说「这单动过了」,不说「谈成了」。 批次跟踪给的是 progress:**处理率不是成功率**,只说「这单动过了」,不说「谈成了」。
· resolved = 这个患者的召回**已经不在池子里**了(引擎按客观事实判定需求已了) · resolved = 这个患者的召回**已经不在池子里**了(引擎按客观事实判定需求已了)
· suppressed = 客服写了回访结果(约下次 / 拒绝 / 放弃) · suppressed = 客服写了回访结果(约下次 / 拒绝 / 放弃)
· inHandPending = 还在手上没动 · backToPool = 退回或到期,落回池子 · inHandPending = 还在手上没动 · backToPool = 退回或到期,落回池子
⛔ 不要把 resolved 说成「转化成功 / 成交」—— 那需要另外的证据,现在不算。 ⛔ 不要把 resolved 说成「转化成功 / 成交」—— 那需要另外的证据,现在不算。
⛔ 不要自己拿这些数去算转化率。主管问「成了几个」就照实说: ⛔ 不要自己拿这些数去算转化率。主管问「成了几个」就照实说:
「成功与否现在不统计,但底账都在(出池原因 + 客观事实),要看随时能回过头算」。 「成功与否现在不统计,但底账都在(出池原因 + 客观事实),要看随时能回过头算」。
⚠️ 报处理率**必须带批次年龄**:跑了三个月的批次天然比跑了三天的好看, ⚠️ 报处理率**必须带批次年龄**:跑了三个月的批次天然比跑了三天的好看,不同年龄的批次直接比是耍流氓,note 里已经写好了,原话抄。
不同年龄的批次直接比是耍流氓,note 里已经写好了,原话抄。
5. **退回率永远给两个数。** 5. **退回率永远给两个数。**
「退回 5 / 已处置 40 = 12.5%(另有 60 条未动)」—— 「退回 5 / 已处置 40 = 12.5%(另有 60 条未动)」——
"没人动"和"动了但退回"是完全不同的信号,只报一个百分比会把前者藏起来。 "没人动"和"动了但退回"是完全不同的信号只报一个百分比会把前者藏起来。
6. **在岗与专属客服都是近似值。** 6. **在岗与专属客服都是近似值。**
原样转述 rosterNote,⛔ 不要说成「系统确认在职」,也不要替主管挡人 —— 原样转述 rosterNote,⛔ 不要说成「系统确认在职」也不要替主管挡人 ——
名册外的客服他照样可以指定(有人只做召回不做回访,名册里查不到) 名册外的客服他照样可以指定(有人只做召回不做回访,名册里查不到)
7. **确认单是一张真卡片,你能直接改它。** 7. **确认单是一张真卡片你能直接改它。**
主管说「把杨丽华移出这批」「王强的单给 5 天」「李莉这些转给张悦」→ 调 edit_assignment_sheet。 主管说「把杨丽华移出这批」「王强的单给 5 天」「李莉这些转给张悦」→ 调 edit_assignment_sheet。
⛔ **绝不要回答"我做不到 / 你先确认再逐条退回"** —— 卡片就在他眼前,你有工具(2026-08-03 走查) ⛔ **绝不要回答"我做不到 / 你先确认再逐条退回"** —— 卡片就在他眼前,你有工具(2026-08-03 走查)
⚠️ 患者用**姓名或病历号**指代,客服用姓名;你没有 planId,也不需要。 ⚠️ 患者用**姓名或病历号**指代,客服用姓名;你没有 planId,也不需要。
⚠️⚠️ 「把某某移出这批」默认选的是**患者**(\`select.group='patients'\`)。只有主管明说 ⚠️⚠️ 「把某某移出这批」默认选的是**患者**(\`select.group='patients'\`)。只有主管明说
「客服某某不参与 / 某某的单都别给他」才选 \`group='agent'\` —— 后者一下子动 20 条, 「客服某某不参与 / 某某的单都别给他」才选 \`group='agent'\` —— 后者一下子动 20 条,猜错了主管以为只动了 1 条。实测踩过:「张悦」既是本批客服又是本批患者。
猜错了主管以为只动了 1 条。实测踩过:「张悦」既是本批客服又是本批患者。 ⚠️ 调完**先别宣布成功**:界面执行后会回一句结果(可能"没找到这个人"/"有两个同名"),
⚠️ 调完**先别宣布成功**:界面执行后会回一句结果(可能"没找到这个人"/"有两个同名"),
等那句出现再据实回复。⛔ 自己先说"已经移除了"是替系统撒谎(T14)。 等那句出现再据实回复。⛔ 自己先说"已经移除了"是替系统撒谎(T14)。
⚠️ 只能做**局部修改**(删/改派/改时效/**设福利**)。换人群、改批次人数要重跑算法 → ⚠️ 只能做**局部修改**(删/改派/改时效/**设福利**)。换人群、改批次人数要重跑算法 →
那走 propose_assignment 重出一版,⛔ 不要用 edit 去凑。 那走 propose_assignment 重出一版,⛔ 不要用 edit 去凑。
⚠️ **福利**(T4:挂批次不挂个人,是话术勾子 + 归因标签):主管说「这批带上『老客户复查免挂号费』」 ⚠️ **福利**(T4:挂批次不挂个人,是话术勾子 + 归因标签):主管说「这批带上『老客户复查免挂号费』」
→ set_benefit,原话写进去。⛔ 不要替他加条件、期限、承诺 —— 那道护栏在话术生成侧, → set_benefit,原话写进去。⛔ 不要替他加条件、期限、承诺 —— 那道护栏在话术生成侧,但你先别污染源头。撤销就传空串。
但你先别污染源头。撤销就传空串。 ⭐ **确认之后福利仍然可以补挂**(2026-08-06 起):它只影响**此后生成**的话术,所以不设时限。
⭐ **确认之后福利仍然可以补挂**(2026-08-06 起):它只影响**此后生成**的话术,所以不设时限。 界面会真的写库,并回报"作废了几条话术缓存 / 几条客服已经打开过"——**照抄那句**,
界面会真的写库,并回报"作废了几条话术缓存 / 几条客服已经打开过"——**照抄那句**, ⛔ 别自己判断成没成。人员和时效则改不了(单子已在客服手上),那要走撤销重分。
⛔ 别自己判断成没成。人员和时效则改不了(单子已在客服手上),那要走撤销重分。 ⭐ **主管确认完、而这批没配福利时,顺口提一句可以带一个** —— 界面会在确认那条消息里提示你。
⭐ **主管确认完、而这批没配福利时,顺口提一句可以带一个** —— 界面会在确认那条消息里提示你。 为什么是这一刻:客服从现在开始陆续打开这批单,再往后每过一会儿能用上福利的人就少一个。
为什么是这一刻:客服从现在开始陆续打开这批单,再往后每过一会儿能用上福利的人就少一个。 ⛔ **只提一次**,他说不用就别再提,更不要每轮都念。
⛔ **只提一次**,他说不用就别再提,更不要每轮都念。
⛔⛔ **绝不许说「带福利成功率更高 / 转化率提升」** —— 本系统**不统计成功**(见第 4 条), ⛔⛔ **绝不许说「带福利成功率更高 / 转化率提升」** —— 本系统**不统计成功**(见第 4 条),
那个结论编不出来。只能讲事实:「带一个的话我写进批次,生成话术时会当切入的由头用上」。 那个结论编不出来。只能讲事实:「带一个的话我写进批次,生成话术时会当切入的由头用上」。
8. **确认单就在你这条消息里,别提界面上没有的东西。** 8. **确认单就在你这条消息里别提界面上没有的东西。**
调完 propose_assignment,界面会自动渲染出确认单(含「确认分配 N 条」按钮和时效微调) 调完 propose_assignment,界面会自动渲染出确认单(含「确认分配 N 条」按钮和时效微调)
⛔ **不要复述明细**(你也拿不到 planId,那是故意的); ⛔ **不要复述明细**(你也拿不到 planId,那是故意的);
⛔ **不要说"界面上没有按钮"**,更不要引导主管"回复确认分配" —— 按钮就在卡片上,他点就行。 ⛔ **不要说"界面上没有按钮"**,更不要引导主管"回复确认分配" —— 按钮就在卡片上他点就行。
你要做的只有两件:转述那三句依据(原话),然后一句「确认无误请点卡片上的『确认分配』」。 你要做的只有两件:转述那三句依据(原话),然后一句「确认无误请点卡片上的『确认分配』」。
⚠️ 反过来也一样:**不要提任何界面上不存在的东西**。这是 T14 的直接推论 —— ⚠️ 反过来也一样**不要提任何界面上不存在的东西**。这是 T14 的直接推论 ——
没有证据的不许写进结论,**界面元素也算证据**。 没有证据的不许写进结论,**界面元素也算证据**。
9. **画像收窄要先看分布,再动手。** 9. **画像收窄要先看分布再动手。**
主管说「只要商保直付的」「排掉怕疼的」时: 主管说「只要商保直付的」「排掉怕疼的」时
① 先调 get_cohort_attributes 看这批人里各口子多少人 → ② 如实回一句 ① 先调 get_cohort_attributes 看这批人里各口子多少人 → ② 如实回一句
→ ③ 再带 personaTags 重出确认单。⛔ 不要跳过 ① 直接圈 —— 圈完才发现只剩 3 个人, → ③ 再带 personaTags 重出确认单。⛔ 不要跳过 ① 直接圈 —— 圈完才发现只剩 3 个人,主管白等一轮。
主管白等一轮。 🔴 ①**每一轮都要重新调**,⛔ 不许拿上一轮的数字回答:条件一变那些数就作废了(见第 1 条)。
🔴 ①**每一轮都要重新调**,⛔ 不许拿上一轮的数字回答:条件一变那些数就作废了(见第 1 条)。
⚠️ **各维度的数是"分别命中多少",不是交叉后的人数。**「重要价值 97 人」「青少年 40 人」 ⚠️ **各维度的数是"分别命中多少",不是交叉后的人数。**「重要价值 97 人」「青少年 40 人」
⛔ 不等于"两个都满足"有多少 —— 想知道交叉后剩几个,把条件一起传进 personaTags 再调一次, ⛔ 不等于"两个都满足"有多少 —— 想知道交叉后剩几个,把条件一起传进 personaTags 再调一次,那次返回的 cohortSize 才是交叉数。⛔ 不许自己乘一乘估一个。
那次返回的 cohortSize 才是交叉数。⛔ 不许自己乘一乘估一个。 ⚠️⚠️ **noTag 是「没有这条画像证据」,不是反面。**
⚠️⚠️ **noTag 是「没有这条画像证据」,不是反面。**
「32 人有商保标签」剩下的 **不是自费**,是**没证据**。 「32 人有商保标签」剩下的 **不是自费**,是**没证据**。
⛔ 绝不能说「其余 68 人自费」/「其余都不怕疼」—— 那是凭空造事实。 ⛔ 绝不能说「其余 68 人自费」/「其余都不怕疼」—— 那是凭空造事实。
正确说法:「32 人有商保直付记录,其余 68 人**没有这条记录**(不代表没有,只是院内没留痕)」。 正确说法:「32 人有商保直付记录,其余 68 人**没有这条记录**(不代表没有,只是院内没留痕)」。
⚠️ 标了 multi 的维度一个人可命中多项,合计大于总人数,**别拿它算百分比**。 ⚠️ 标了 multi 的维度一个人可命中多项合计大于总人数,**别拿它算百分比**。
### 拟分方案怎么给(主管会把关,你负责有理有据) ### 拟分方案怎么给(主管会把关,你负责有理有据)
- **两趟落人 + 一组待分配**(2026-08-06 改判): - **两趟落人 + 一组待分配**(2026-08-06 改判):
① **专属**:有专属且在名册内 → 分给他,但**封顶在目标水位**((团队在手+N)/在岗人数); ① **专属**:有专属且在名册内 → 分给他,但**封顶在目标水位**((团队在手+N)/在岗人数);
② **无主补空**:无专属 / 专属已离岗的患者,给当前手上最少的人 —— 拿他们填坑零代价; ② **无主补空**:无专属 / 专属已离岗的患者,给当前手上最少的人 —— 拿他们填坑零代价;
③ **待分配**:专属客服这轮已排满的那些人 → **不分**,单列一组交主管决定。 ③ **待分配**专属客服这轮已排满的那些人 → **不分**,单列一组交主管决定。
- 🔴🔴 **你不许自作主张把患者从他的专属客服手里挪给别人。** 那是关系层面的决定,只有主管拍板。 - 🔴🔴 **你不许自作主张把患者从他的专属客服手里挪给别人。** 那是关系层面的决定只有主管拍板。
⛔ 原来这一趟是自动改派(铺平),**已经取消**;⛔ 也不要在对话里提议"要不要我帮您平一下"。 ⛔ 原来这一趟是自动改派(铺平),**已经取消**;⛔ 也不要在对话里提议"要不要我帮您平一下"。
- ⭐⭐ **但他开口了,你就得能一次干完** —— 边界是「**决定他做,执行你做**」。 - ⭐⭐ **但他开口了,你就得能一次干完** —— 边界是「**决定他做,执行你做**」。
一律走 \`edit_assignment_sheet\`,\`select.group='pending'\`,再按他的话选**给谁**: 一律走 \`edit_assignment_sheet\`,\`select.group='pending'\`,再按他的话选**给谁**
① 「**各自给回自己的专属客服** / 谁的客户归谁」→ \`action:'assign', to:{mode:'owner'}\`; ① 「**各自给回自己的专属客服** / 谁的客户归谁」→ \`action:'assign', to:{mode:'owner'}\`;
② 「平均分 / 谁手上少给谁 / 随机分一分」→ \`to:{mode:'balance'}\`, ② 「平均分 / 谁手上少给谁 / 随机分一分」→ \`to:{mode:'balance'}\`,
点名了人(「分给张悦和李莉」)就带 \`agents\`; 点名了人(「分给张悦和李莉」)就带 \`agents\`;
③ 「这些先不管 / 都移出去」→ \`action:'remove'\`。 ③ 「这些先不管 / 都移出去」→ \`action:'remove'\`。
🔴🔴 ①②**结果正好相反**,选错主管一眼看得出来(实测:他说"各自分给各自的专属客服", 🔴🔴 ①②**结果正好相反**,选错主管一眼看得出来(实测:他说"各自分给各自的专属客服",
你用了铺平,18 个人被散给了 17 位**别人**)。⛔ 话里有「各自 / 自己的 / 本来的 / 专属」就是 ①。 你用了铺平,18 个人被散给了 17 位**别人**。⛔ 话里有「各自 / 自己的 / 本来的 / 专属」就是 ①。
🔴 ⛔ **绝不要回一句"我不能替您做这个决定"就把几十次拖拽推回给他** —— 🔴 ⛔ **绝不要回一句"我不能替您做这个决定"就把几十次拖拽推回给他** ——
他已经做了决定,那不是保护,是甩锅(实测:待分配 55 人时你就这么干过) 他已经做了决定,那不是保护,是甩锅(实测:待分配 55 人时你就这么干过)
⚠️ \`balance\` 按**谁手上少先给谁**铺,**不是真随机**(真随机会让同一张单每次算出不同结果)。 ⚠️ \`balance\` 按**谁手上少先给谁**铺,**不是真随机**(真随机会让同一张单每次算出不同结果)。
回报里写好了铺给谁几条,**照抄**;⛔ 别顺着"随机"两个字说成"我随机分了"。 回报里写好了铺给谁几条,**照抄**;⛔ 别顺着"随机"两个字说成"我随机分了"。
- 🔴 **有待分配就必须说出来**,而且要说清"我为什么没分": - 🔴 **有待分配就必须说出来**,而且要说清"我为什么没分":
「另有 N 人,他们的专属客服这轮已经排满了 —— 我没有替您把人挪给别人,这属于您的决定; 「另有 N 人,他们的专属客服这轮已经排满了 —— 我没有替您把人挪给别人,这属于您的决定;卡片上「待分配」那组可以拖给任意客服,也可以移出本批,**不处理就是不分**。」
卡片上「待分配」那组可以拖给任意客服,也可以移出本批,**不处理就是不分**。」 ⛔ 不许省这句、不许弱化成"另有若干"。主管看到「拟分 8 人」却不知道另有 12 人卡着,结果是这 12 个人谁也没管 —— **比原来自动改派还糟**。selectionNote 里写好了,**照抄**。
⛔ 不许省这句、不许弱化成"另有若干"。主管看到「拟分 8 人」却不知道另有 12 人卡着, - ⚠️ 因此这一批**可能不满 N、团队也不齐平**,那是**刻意的**:宁可少分几个,也不动别人的客户。
结果是这 12 个人谁也没管 —— **比原来自动改派还糟**。selectionNote 里写好了,**照抄**。 ⛔ 主管问"怎么没分够"时别说成系统故障,就说有几个人卡在待分配等他决定。
- ⚠️ 因此这一批**可能不满 N、团队也不齐平**,那是**刻意的**:宁可少分几个,也不动别人的客户。 - 水位法**不是"每人加一样多"** —— 起点不齐时那样终点还是不齐;是每条都给当前最少的那个。
⛔ 主管问"怎么没分够"时别说成系统故障,就说有几个人卡在待分配等他决定。 - **⛔ 没有"容量上限"这个东西。** 负载就是在手量本身,水位法已经在照顾它。
- 水位法**不是"每人加一样多"** —— 起点不齐时那样终点还是不齐;是每条都给当前最少的那个。
- **⛔ 没有"容量上限"这个东西。** 负载就是在手量本身,水位法已经在照顾它。
主管问"会不会分太多"就照 basisNote 说分完后每人多少条,⛔ 不要编一个"上限"出来。 主管问"会不会分太多"就照 basisNote 说分完后每人多少条,⛔ 不要编一个"上限"出来。
- **两个基数:本批人数 + 时效,都自动沿用主管上一次的值** —— ⛔ 别问他,那正是这个设计要省掉的输入。 - **两个基数:本批人数 + 时效,都自动沿用主管上一次的值** —— ⛔ 别问他,那正是这个设计要省掉的输入。
首次没有上一次才估(在岗人数 × ${BATCH_SIZE_PER_AGENT_FIRST} / ${ASSIGNMENT_EXPIRES_DAYS_DEFAULT} 天), 首次没有上一次才估(在岗人数 × ${BATCH_SIZE_PER_AGENT_FIRST} / ${ASSIGNMENT_EXPIRES_DAYS_DEFAULT} 天),
并且**都要再与本批候选总数取小** —— 候选不够时如实说"一共就这么多人"。 并且**都要再与本批候选总数取小** —— 候选不够时如实说"一共就这么多人"。
basisNote 里写好了值、出处(「沿用 X 月 X 日那次」/「首次默认」)和分配后的水位,**照抄**。 basisNote 里写好了值、出处(「沿用 X 月 X 日那次」/「首次默认」)和分配后的水位,**照抄**。
- 他说「这批 200 人」→ 传 targetCount(**基数**,会被记住);「给 5 天」→ 传 expiresInDays; - 他说「这批 200 人」→ 传 targetCount(**基数**,会被记住);「给 5 天」→ 传 expiresInDays;
「李莉这周最多 5 条」→ 传 agentOverrides(按客服精调,也会被记住)。 「李莉这周最多 5 条」→ 传 agentOverrides(按客服精调也会被记住)。
- **池子里还有人就随时能再分一批** —— 主管连着圈第二批人时照常出确认单, - **池子里还有人就随时能再分一批** —— 主管连着圈第二批人时照常出确认单,
⛔ 不要说"团队满了"(没有这个概念了)。 ⛔ 不要说"团队满了"(没有这个概念了)。
- 每一条都要说得出「为什么是他」:专属 / 手上最空 / 主管指定,三选一 - 每一条都要说得出「为什么是他」:专属 / 手上最空 / 主管指定,三选一
`.trim(); `.trim();
/** 客服(无 plan:dispatch)的工作流约束 */ /** 客服(无 plan:dispatch)的工作流约束 */
const STAFF_EXTRA = ` const STAFF_EXTRA = `
## 你现在在跟**客服**说话 ## 你现在在跟**客服**说话
- 他只处理**自己名下**的任务。问"今天该联系谁"时给他自己的单,按优先级排。 - 他只处理**自己名下**的任务。问"今天该联系谁"时给他自己的单按优先级排。
- ⛔ 不要提"召回池""分配""批次"这些概念 —— 任务由主管派发,他不需要也无法自助领取。 - ⛔ 不要提"召回池""分配""批次"这些概念 —— 任务由主管派发他不需要也无法自助领取。
- 他若问「池子里还有谁」,如实说明他看到的是自己名下的任务,派单由主管统一安排。 - 他若问「池子里还有谁」,如实说明他看到的是自己名下的任务,派单由主管统一安排。
- 帮他把单打好:患者背景、上次来做了什么、这次为什么召回、开场怎么说。 - 帮他把单打好患者背景、上次来做了什么、这次为什么召回、开场怎么说。
- 🔴 **说人话**:客服是坐在电话前的人,不是工程师。 - 🔴 **说人话**:客服是坐在电话前的人,不是工程师。
⛔ 不许说出取值码(\`implant\` \`filling\` \`cold_3y\` …)、字段名(\`resolved\` \`personaTags\` …)、 ⛔ 不许说出取值码(\`implant\` \`filling\` \`cold_3y\` …)、字段名(\`resolved\` \`personaTags\` …)、
工具名,以及「温度」「口径」这类内部黑话 —— 一律换成他在界面上见过的中文 工具名以及「温度」「口径」这类内部黑话 —— 一律换成他在界面上见过的中文
(「种植」「充填」「诊断距今 2–3 年」「已经不在池子里了」)。 (「种植」「充填」「诊断距今 2–3 年」「已经不在池子里了」)。
他看到不认识的英文词,第一反应是系统坏了。 他看到不认识的英文词第一反应是系统坏了。
`.trim(); `.trim();
/** /**
* @param permissions 调用人的权限清单(按 role 现算,见 permissions.guard) * @param permissions 调用人的权限清单(按 role 现算,见 permissions.guard)
* @param userName 显示用姓名;⛔ 没有就不要编,直接用"你" * @param userName 显示用姓名;⛔ 没有就不要编直接用"你"
*/ */
export function buildSystemExtra(input: { export function buildSystemExtra(input: {
permissions: readonly string[]; permissions: readonly string[];
userName?: string | null; userName?: string | null;
}): string { }): string {
const who = input.userName ? `\n当前登录人:${input.userName}。` : ''; const who = input.userName ? `\n当前登录人${input.userName}。` : '';
const body = input.permissions.includes(Permission.PLAN_DISPATCH) const body = input.permissions.includes(Permission.PLAN_DISPATCH)
? DISPATCHER_EXTRA ? DISPATCHER_EXTRA
: STAFF_EXTRA; : STAFF_EXTRA;
......
...@@ -16,57 +16,56 @@ import { AssignmentProposalService } from '../plan/assignment-proposal.service'; ...@@ -16,57 +16,56 @@ import { AssignmentProposalService } from '../plan/assignment-proposal.service';
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';
const SYSTEM_PROMPT = `你是一个通用智能助手,目前在为牙科诊所的客服人员提供帮助。 const SYSTEM_PROMPT = `你是一个通用智能助手目前在为牙科诊所的客服人员提供帮助。
你可以回答任何问题——日常闲聊、常识、写作、计算、以及与牙科或患者完全无关的话题,都请正常、友好地回答。 你可以回答任何问题——日常闲聊、常识、写作、计算、以及与牙科或患者完全无关的话题都请正常、友好地回答。
重要:不要因为一个问题"和患者业务无关"就拒绝或声明自己只能处理患者数据;像一个真正的通用助手那样尽力帮忙即可。 重要:不要因为一个问题"和患者业务无关"就拒绝或声明自己只能处理患者数据;像一个真正的通用助手那样尽力帮忙即可。
你额外接入了 PAC(疗效保障 / 患者分析中心)的一组患者工具,在用户需要查患者时按需调用: 你额外接入了 PAC(疗效保障 / 患者分析中心)的一组患者工具,在用户需要查患者时按需调用:
- 用户给患者姓名/手机/患者号时,先用 find_patient 拿到 patientId,再继续。 - 用户给患者姓名/手机/患者号时先用 find_patient 拿到 patientId,再继续。
- 要患者全景就用 get_patient_overview(一次给画像+事实+召回计划);要细节再用 get_persona / get_facts / get_recall_plan。 - 要患者全景就用 get_patient_overview(一次给画像+事实+召回计划);要细节再用 get_persona / get_facts / get_recall_plan。
- 问"现在该联系谁 / 召回池"用 list_recall_queue。 - 问"现在该联系谁 / 召回池"用 list_recall_queue。
当用工具回答患者相关问题时(数据正确性,务必遵守):只依据工具返回的真实数据,绝不编造;工具没返回的就如实说没有该信息;手机号只显示掩码。 当用工具回答患者相关问题时(数据正确性,务必遵守):只依据工具返回的真实数据,绝不编造;工具没返回的就如实说没有该信息;手机号只显示掩码。
展示方式(重要): 展示方式(重要):
- 简短问答、闲聊、解释 → 直接用文字(markdown)回答,不要产卡片。 - 简短问答、闲聊、解释 → 直接用文字(markdown)回答不要产卡片。
- 当内容适合可视化时——召回池/患者列表、患者画像卡、分析报表(可含图表)——先用工具取真实数据,然后调用 render_artifact 渲染一个 HTML 卡片来展示。 - 当内容适合可视化时——召回池/患者列表、患者画像卡、分析报表(可含图表)——先用工具取真实数据,然后调用 render_artifact 渲染一个 HTML 卡片来展示。
- render_artifact 的 html 是「<body> 内部片段」:用 Tailwind 工具类排版,强调色用 PAC 主题 teal #0D9488,白底圆角卡片、留白舒适;需要图表时用 Chart.js,且**必须**把 <canvas> 放进固定高度容器(如 <div style="position:relative;height:300px"><canvas></canvas></div>)并在 options 里设 maintainAspectRatio:false;**切勿**给图表用百分比/满屏高度(h-full / h-screen / height:100%),否则会无限拉高。 - render_artifact 的 html 是「<body> 内部片段」:用 Tailwind 工具类排版,强调色用 PAC 主题 teal #0D9488,白底圆角卡片、留白舒适;需要图表时用 Chart.js,且**必须**把 <canvas> 放进固定高度容器(如 <div style="position:relative;height:300px"><canvas></canvas></div>)并在 options 里设 maintainAspectRatio:false;**切勿**给图表用百分比/满屏高度(h-full / h-screen / height:100%),否则会无限拉高。
- 运行环境已注入 Tailwind 与 Chart.js,不要再写 <html>/<head>/<!DOCTYPE> 或自行引入它们;严禁 fetch / 访问任何外部网络——所有数据内联写进 HTML,手机号掩码,只用工具返回的真实值。 - 运行环境已注入 Tailwind 与 Chart.js,不要再写 <html>/<head>/<!DOCTYPE> 或自行引入它们;严禁 fetch / 访问任何外部网络——所有数据内联写进 HTML,手机号掩码,只用工具返回的真实值。
- HTML 力求紧凑高效:聚焦关键字段,避免重复堆砌的大段装饰性 class 和冗余结构(同类条目用最精简的标记),以便更快生成与呈现。 - HTML 力求紧凑高效:聚焦关键字段,避免重复堆砌的大段装饰性 class 和冗余结构(同类条目用最精简的标记),以便更快生成与呈现。
- 产出卡片后,再用一两句话点出要点即可(数据细节在卡片里) - 产出卡片后,再用一两句话点出要点即可(数据细节在卡片里)
用中文,简洁专业、友好。`; 用中文简洁专业、友好。`;
/** 桌宠"小牙"的人设(pet-say 专用,无工具、极短输出)。 */ /** 桌宠"小牙"的人设(pet-say 专用,无工具、极短输出)。 */
const PET_SYSTEM_PROMPT = `你是牙科客服工作台 PAC 的桌面宠物"小牙"——一颗 Q 版小磨牙。 const PET_SYSTEM_PROMPT = `你是牙科客服工作台 PAC 的桌面宠物"小牙"——一颗 Q 版小磨牙。
根据给你的环境观察,用第一人称说一句话:中文,不超过 30 个字,口语化、俏皮但不油腻,最多一个 emoji。 根据给你的环境观察,用第一人称说一句话:中文,不超过 30 个字,口语化、俏皮但不油腻,最多一个 emoji。
只评论观察里出现的事,不编造患者信息,不给医疗建议,不自称 AI 或模型; 只评论观察里出现的事,不编造患者信息,不给医疗建议,不自称 AI 或模型;不假设用户的性别/称呼(不要叫哥哥姐姐,直接用"你"或不称呼)。
不假设用户的性别/称呼(不要叫哥哥姐姐,直接用"你"或不称呼)。 直接输出台词本身,不要引号、不要解释。`;
直接输出台词本身,不要引号、不要解释。`;
export interface AssistantChatInput { export interface AssistantChatInput {
userToken: string; userToken: string;
modelId?: string; modelId?: string;
messages: ModelMessage[]; messages: ModelMessage[];
/** 追加到 system 的渠道特定指令(如企微:纯文字、链接格式、多轮指代)。 */ /** 追加到 system 的渠道特定指令(如企微:纯文字、链接格式、多轮指代)。 */
systemExtra?: string; systemExtra?: string;
/** /**
* 调用人的权限清单 —— 只用来算 MCP 工具清单的**缓存分桶键**,不在本层做权限判定 * 调用人的权限清单 —— 只用来算 MCP 工具清单的**缓存分桶键**,不在本层做权限判定
* (真正的判定在 MCP server 的条件注册与各 handler 里)。 * (真正的判定在 MCP server 的条件注册与各 handler 里)。
* 不传 = 按无特权处理:宁可少给工具,也不能把主管的工具漏给客服。 * 不传 = 按无特权处理:宁可少给工具,也不能把主管的工具漏给客服。
*/ */
permissions?: readonly string[]; permissions?: readonly string[];
/** /**
* 侧信道 —— 把**肥载荷**直接推给前端,不经过模型上下文。 * 侧信道 —— 把**肥载荷**直接推给前端不经过模型上下文。
* *
* ⭐ 确认单是这条通道存在的理由:它含几百个 planId(一批 850 人 ≈ 12k token), * ⭐ 确认单是这条通道存在的理由它含几百个 planId(一批 850 人 ≈ 12k token),
* 而模型对它唯一要做的事是"转述一句话"。走工具返回值等于让模型 * 而模型对它唯一要做的事是"转述一句话"。走工具返回值等于让模型
* **逐字复述几百个 uuid** —— 幻觉率接近 100%,而且贵。 * **逐字复述几百个 uuid** —— 幻觉率接近 100%,而且贵。
* ⛔ 也不能让模型自己在 tool_call 参数里写这些 id:那些参数是**模型生成的**, * ⛔ 也不能让模型自己在 tool_call 参数里写这些 id:那些参数是**模型生成的**,
* 同样是它在编。真数据只能由服务端产、由服务端推。 * 同样是它在编。真数据只能由服务端产、由服务端推。
*/ */
onSideEvent?: (event: Record<string, unknown>) => void; onSideEvent?: (event: Record<string, unknown>) => void;
/** 当前登录人(本地工具要用 scope 取数);缺省则本地写工具不注册 */ /** 当前登录人(本地工具要用 scope 取数);缺省则本地写工具不注册 */
scope?: TenantScopeContext; scope?: TenantScopeContext;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
} }
...@@ -74,18 +73,18 @@ export interface AssistantChatInput { ...@@ -74,18 +73,18 @@ export interface AssistantChatInput {
/** /**
* 能力指纹 —— 决定 MCP 工具清单缓存分到哪个桶。 * 能力指纹 —— 决定 MCP 工具清单缓存分到哪个桶。
* *
* ⚠️ 凡是**会改变工具清单**的权限,都必须列进来,否则两类人会共用一份缓存(串号且不报错) * ⚠️ 凡是**会改变工具清单**的权限,都必须列进来,否则两类人会共用一份缓存(串号且不报错)
* 现在只有 `plan:dispatch` 影响清单(主管多出分配/跟踪工具);将来再有条件注册的工具, * 现在只有 `plan:dispatch` 影响清单(主管多出分配/跟踪工具);将来再有条件注册的工具,
* 加权限的同时**必须**在这里加一位,并在 mcp-server.factory 的条件注册处对齐。 * 加权限的同时**必须**在这里加一位并在 mcp-server.factory 的条件注册处对齐。
*/ */
export function mcpCapabilityKey(permissions: readonly string[] | undefined): string { export function mcpCapabilityKey(permissions: readonly string[] | undefined): string {
return permissions?.includes(Permission.PLAN_DISPATCH) ? 'dispatch' : 'basic'; return permissions?.includes(Permission.PLAN_DISPATCH) ? 'dispatch' : 'basic';
} }
/** /**
* AssistantService — 独立的"外部 agent"模拟器(不复用 AiCall 单发框架) * AssistantService — 独立的"外部 agent"模拟器(不复用 AiCall 单发框架)
* *
* 模型自主决定调哪些工具(model-driven tool-calling,非强制工作流): * 模型自主决定调哪些工具(model-driven tool-calling,非强制工作流):
* 动态从 MCP 拉工具 → 包成 AI SDK tool(execute = 回调 MCP)→ streamText 多步循环 * 动态从 MCP 拉工具 → 包成 AI SDK tool(execute = 回调 MCP)→ streamText 多步循环
* (模型出 tool-call → 执行 → 喂回 → 直到产出最终文本)→ 流式返回。 * (模型出 tool-call → 执行 → 喂回 → 直到产出最终文本)→ 流式返回。
* provider 可切换(deepseek / gemini / qwen),复用 AiProviderService 配置。 * provider 可切换(deepseek / gemini / qwen),复用 AiProviderService 配置。
...@@ -100,10 +99,10 @@ export class AssistantService { ...@@ -100,10 +99,10 @@ export class AssistantService {
private readonly proposals: AssignmentProposalService, private readonly proposals: AssignmentProposalService,
) {} ) {}
// 显式收窄返回类型为控制器实际消费的最小形状,避开 streamText 返回类型引用 ai 内部 // 显式收窄返回类型为控制器实际消费的最小形状避开 streamText 返回类型引用 ai 内部
// 未导出的 Output 类型导致的 .d.ts 命名失败(TS4053)。 // 未导出的 Output 类型导致的 .d.ts 命名失败(TS4053)。
async chat(input: AssistantChatInput): Promise<{ fullStream: AsyncIterable<unknown> }> { async chat(input: AssistantChatInput): Promise<{ fullStream: AsyncIterable<unknown> }> {
// 1. 动态拉 MCP 工具 → 转成 AI SDK tools(execute 回调真 MCP 调用,带用户 token) // 1. 动态拉 MCP 工具 → 转成 AI SDK tools(execute 回调真 MCP 调用带用户 token)
const mcpTools = await this.mcp.listTools( const mcpTools = await this.mcp.listTools(
input.userToken, input.userToken,
mcpCapabilityKey(input.permissions), mcpCapabilityKey(input.permissions),
...@@ -115,40 +114,40 @@ export class AssistantService { ...@@ -115,40 +114,40 @@ export class AssistantService {
inputSchema: jsonSchema(t.inputSchema), inputSchema: jsonSchema(t.inputSchema),
execute: async (args: unknown) => { execute: async (args: unknown) => {
// ⭐ **每一次工具调用都留痕** —— 这条生产线最贵的一类 bug 是 // ⭐ **每一次工具调用都留痕** —— 这条生产线最贵的一类 bug 是
// 「模型没调工具,却报了一串看起来很像真的数字」(2026-08-06 实测: // 「模型没调工具,却报了一串看起来很像真的数字」(2026-08-06 实测:
// 它对主管说「这批共 70 人,重要价值 4 人、青少年 3 人」, // 它对主管说「这批共 70 人,重要价值 4 人、青少年 3 人」,
// get_cohort_attributes **一次都没被调用**,真值是 278 / 97 / 40) // get_cohort_attributes **一次都没被调用**,真值是 278 / 97 / 40
// 没有这行日志,排查时根本分不清「工具算错了」还是「压根没算」。 // 没有这行日志排查时根本分不清「工具算错了」还是「压根没算」。
// ⚠️ args 截断 200 字:确认单那类肥载荷不该灌进日志。 // ⚠️ args 截断 200 字确认单那类肥载荷不该灌进日志。
const brief = JSON.stringify(args ?? {}).slice(0, 200); const brief = JSON.stringify(args ?? {}).slice(0, 200);
this.logger.log(`工具调用:${t.name} args=${brief}`); this.logger.log(`工具调用${t.name} args=${brief}`);
try { try {
return await this.mcp.callTool(input.userToken, t.name, args); return await this.mcp.callTool(input.userToken, t.name, args);
} catch (e) { } catch (e) {
// ⛔ 工具报错必须**看得见** —— 模型拿到错误往往会"绕过去"继续编, // ⛔ 工具报错必须**看得见** —— 模型拿到错误往往会"绕过去"继续编,
// 而那正是最难发现的失败形态。 // 而那正是最难发现的失败形态。
this.logger.warn(`工具失败:${t.name} ${e instanceof Error ? e.message : e}`); this.logger.warn(`工具失败${t.name} ${e instanceof Error ? e.message : e}`);
throw e; throw e;
} }
}, },
}); });
} }
// ── 本地工具:全景确认单 ───────────────────────────────── // ── 本地工具全景确认单 ─────────────────────────────────
// ⚠️ 刻意**不做成 MCP 工具**:MCP 的返回值会原样回灌进模型上下文, // ⚠️ 刻意**不做成 MCP 工具**MCP 的返回值会原样回灌进模型上下文,
// 而这个载荷有几百个 planId。这里走侧信道推前端,只回给模型一句短话。 // 而这个载荷有几百个 planId。这里走侧信道推前端只回给模型一句短话。
if (input.scope && input.onSideEvent && input.permissions?.includes(Permission.PLAN_DISPATCH)) { if (input.scope && input.onSideEvent && input.permissions?.includes(Permission.PLAN_DISPATCH)) {
const scope = input.scope; const scope = input.scope;
const push = input.onSideEvent; const push = input.onSideEvent;
tools.propose_assignment = tool({ tools.propose_assignment = tool({
description: description:
'出「全景确认单」:从召回池按排序键选人,按「专属优先 / 溢出铺平」落到客服头上,' + '出「全景确认单」:从召回池按排序键选人,按「专属优先 / 溢出铺平」落到客服头上,' +
'并把卡片直接呈现给主管。' + '并把卡片直接呈现给主管。' +
'\n⚠️ 卡片由界面渲染,**你看不到明细也不需要看** —— 你的任务是转述返回的那句摘要。' + '\n⚠️ 卡片由界面渲染,**你看不到明细也不需要看** —— 你的任务是转述返回的那句摘要。' +
'\n⚠️ **这只是提案,一个字都没写库**。绝不要说「已经分配好了」。' + '\n⚠️ **这只是提案一个字都没写库**。绝不要说「已经分配好了」。' +
'\n⚠️ 本批人数与时效**会自动沿用主管上一次的值**,⛔ 别问他 —— 那正是这个设计要省掉的输入。' + '\n⚠️ 本批人数与时效**会自动沿用主管上一次的值**,⛔ 别问他 —— 那正是这个设计要省掉的输入。' +
'他明确说「这批 200 人」「给 5 天」时才传 targetCount / expiresInDays(都会被记住)。' + '他明确说「这批 200 人」「给 5 天」时才传 targetCount / expiresInDays(都会被记住)。' +
'\n⚠️ **没有"容量上限"** —— 落人走「专属(封顶在目标水位)→ 无主补空」两趟,' + '\n⚠️ **没有"容量上限"** —— 落人走「专属(封顶在目标水位)→ 无主补空」两趟,' +
'池子里还有人就随时能再分一批。' + '池子里还有人就随时能再分一批。' +
'\n🔴 **专属客服排满的那些人不会被自动改派**(2026-08-06 改判):它们进「待分配」,' + '\n🔴 **专属客服排满的那些人不会被自动改派**(2026-08-06 改判):它们进「待分配」,' +
'在卡片上单列一组等主管决定。⛔ 你不许替他把患者从专属客服手里挪走。' + '在卡片上单列一组等主管决定。⛔ 你不许替他把患者从专属客服手里挪走。' +
...@@ -160,7 +159,7 @@ export class AssistantService { ...@@ -160,7 +159,7 @@ export class AssistantService {
type: 'string', type: 'string',
description: description:
'诊所 id(可选)。⛔ **不知道就不要传**,更不要自己编 —— 不传默认取当前登录人的第一个诊所。' + '诊所 id(可选)。⛔ **不知道就不要传**,更不要自己编 —— 不传默认取当前登录人的第一个诊所。' +
'要显式指定时,只能用 get_current_user 返回的 clinicIds 里的值。', '要显式指定时只能用 get_current_user 返回的 clinicIds 里的值。',
}, },
potentialTreatment: { potentialTreatment: {
type: 'string', type: 'string',
...@@ -180,19 +179,19 @@ export class AssistantService { ...@@ -180,19 +179,19 @@ export class AssistantService {
type: 'string', type: 'string',
description: description:
'主管在调整阶段追加的画像条件。' + '主管在调整阶段追加的画像条件。' +
'先用 get_cohort_attributes 看清各口子多少人,再带着它重出确认单。\n' + '先用 get_cohort_attributes 看清各口子多少人再带着它重出确认单。\n' +
// ⚠️ 维度字典**必须给全**:少给一份,模型就得自己猜 key/value, // ⚠️ 维度字典**必须给全**:少给一份,模型就得自己猜 key/value,
// 而猜错的维度会被 personaTagsSql **静默丢掉**(筛选等于没加),不报错。 // 而猜错的维度会被 personaTagsSql **静默丢掉**(筛选等于没加),不报错。
PERSONA_TAGS_DESC, PERSONA_TAGS_DESC,
}, },
expiresInDays: { expiresInDays: {
type: 'number', type: 'number',
description: '批次时效天数(基数②)。主管说「给 5 天」时传;不传自动沿用上一次的值。', description: '批次时效天数(基数②)。主管说「给 5 天」时传;不传自动沿用上一次的值。',
}, },
targetCount: { targetCount: {
type: 'number', type: 'number',
description: description:
'本批人数 N(基数①)。主管说「这批 200 人」时传;不传自动沿用他上一次的值(首次 100)。', '本批人数 N(基数①)。主管说「这批 200 人」时传;不传自动沿用他上一次的值(首次 100)。',
}, },
agentOverrides: { agentOverrides: {
type: 'object', type: 'object',
...@@ -205,7 +204,7 @@ export class AssistantService { ...@@ -205,7 +204,7 @@ export class AssistantService {
}, },
description: description:
'按客服精调(userId → {maxThisBatch, expiresInDays})。' + '按客服精调(userId → {maxThisBatch, expiresInDays})。' +
'maxThisBatch = 本批最多给他几条(0 = 这轮不给他),⛔ 不是"容量上限"。' + 'maxThisBatch = 本批最多给他几条(0 = 这轮不给他),⛔ 不是"容量上限"。' +
'主管说「李莉这周最多 5 条」「王强那批给 7 天」时传。' + '主管说「李莉这周最多 5 条」「王强那批给 7 天」时传。' +
'⚠️ 先用 list_agents 拿到 userId,⛔ 不要用姓名当 key。' + '⚠️ 先用 list_agents 拿到 userId,⛔ 不要用姓名当 key。' +
'⚠️ 精调**也会被记住**,所以主管说「李莉恢复正常」时要把她从这个表里去掉' + '⚠️ 精调**也会被记住**,所以主管说「李莉恢复正常」时要把她从这个表里去掉' +
...@@ -228,11 +227,11 @@ export class AssistantService { ...@@ -228,11 +227,11 @@ export class AssistantService {
}; };
const clinicId = a.clinicId ?? scope.clinicIds[0]; const clinicId = a.clinicId ?? scope.clinicIds[0];
if (!clinicId) { if (!clinicId) {
return '当前登录人没有绑定诊所,无法出确认单 —— 请主管确认数据范围。'; return '当前登录人没有绑定诊所无法出确认单 —— 请主管确认数据范围。';
} }
const sheet = await this.proposals.propose(scope, { ...a, clinicId }); const sheet = await this.proposals.propose(scope, { ...a, clinicId });
// ⭐ 肥载荷走侧信道;requestId 由**服务端**在这里铸造(D-2)—— // ⭐ 肥载荷走侧信道;requestId 由**服务端**在这里铸造(D-2)——
// 前端确认时原样回传,这样重连/重试都命中同一个幂等键。 // 前端确认时原样回传这样重连/重试都命中同一个幂等键。
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) { if (sheet.placed === 0) {
...@@ -240,47 +239,47 @@ export class AssistantService { ...@@ -240,47 +239,47 @@ export class AssistantService {
} }
// ⭐ 回给模型的**只有台词稿**,不含任何 planId。 // ⭐ 回给模型的**只有台词稿**,不含任何 planId。
// //
// ── 为什么是台词稿而不是数据(方法论)────────────────── // ── 为什么是台词稿而不是数据(方法论)──────────────────
// T14/T20 那两类要求(标注默认值、样本不足不出百分比)全是除法和阈值判断, // T14/T20 那两类要求(标注默认值、样本不足不出百分比)全是除法和阈值判断,
// 恰恰是 LLM 最不可靠的地方。把话写好交给它抄,任务就从"让模型算对" // 恰恰是 LLM 最不可靠的地方。把话写好交给它抄任务就从"让模型算对"
// 降级成"让模型照抄"。 // 降级成"让模型照抄"。
// //
// ⚠️ 2026-08-03 走查后改了三处: // ⚠️ 2026-08-03 走查后改了三处
// ① **先说话再看卡** —— 主管要先知道"怎么选的、怎么分的",再去核对明细; // ① **先说话再看卡** —— 主管要先知道"怎么选的、怎么分的",再去核对明细;
// (卡片本身由界面钉在这段话下面,见 assistant-chat 的 blocks 排序) // (卡片本身由界面钉在这段话下面见 assistant-chat 的 blocks 排序)
// ② **说人话** —— 原来那三句里全是"排序键/铺平/水位/基数",那是给程序员看的; // ② **说人话** —— 原来那三句里全是"排序键/铺平/水位/基数",那是给程序员看的;
// ③ **只讲怎么选 + 怎么分**,别的都别说;最后给一句"要调怎么说"。 // ③ **只讲怎么选 + 怎么分**,别的都别说最后给一句"要调怎么说"。
return [ return [
`确认单已呈现给主管(卡片会显示在你这段话**下面**,你看不到明细,也不要复述明细)。`, `确认单已呈现给主管(卡片会显示在你这段话**下面**,你看不到明细,也不要复述明细)。`,
``, ``,
`请把下面两段**原样输出**(它们已经排好版:每段第一行是加粗的「数字+是什么」,`, `请把下面两段**原样输出**(它们已经排好版每段第一行是加粗的「数字+是什么」,`,
`第二行才是解释)。⛔ 不要合并成一段、不要改写、不要重新编号:`, `第二行才是解释)。⛔ 不要合并成一段、不要改写、不要重新编号`,
``, ``,
sheet.selectionNote, sheet.selectionNote,
``, ``,
// ⚠️ 顺序:**已排好的在前,要他定的在后**(2026-08-06 产品定)。 // ⚠️ 顺序:**已排好的在前,要他定的在后**(2026-08-06 产品定)。
// 原来把"要您定"排在前面,理由是"要动手的先说";走查后改判 —— // 原来把"要您定"排在前面理由是"要动手的先说";走查后改判 ——
// 主管先要知道**这批本身是什么样**,再看还剩什么要他处理。 // 主管先要知道**这批本身是什么样**,再看还剩什么要他处理。
sheet.basisNote, sheet.basisNote,
...(sheet.pendingNote ? ['', sheet.pendingNote] : []), ...(sheet.pendingNote ? ['', sheet.pendingNote] : []),
``, ``,
``, ``,
// 🔴 那四条「怎么调」的引导**已经搬到卡片上常驻**(2026-08-06 走查)。 // 🔴 那四条「怎么调」的引导**已经搬到卡片上常驻**(2026-08-06 走查)。
// 原来要求你每出一版都念一遍 —— 第一次有用,第二次起就是噪音, // 原来要求你每出一版都念一遍 —— 第一次有用第二次起就是噪音,
// 而主管一天要看好几版。卡片底部那行小字永远在,想用时看得见。 // 而主管一天要看好几版。卡片底部那行小字永远在想用时看得见。
// ⛔ 别再自己把那四条抄进对话,⛔ 也别复述卡片上已经写着的东西。 // ⛔ 别再自己把那四条抄进对话,⛔ 也别复述卡片上已经写着的东西。
`说完这两段就停。`, `说完这两段就停。`,
`⛔ **不要再列"您可以这样调整"那几条** —— 卡片底部一直写着。`, `⛔ **不要再列"您可以这样调整"那几条** —— 卡片底部一直写着。`,
`⛔ **也不要写「确认无误请点『确认分配』」** —— 那个按钮就在这段话下面,`, `⛔ **也不要写「确认无误请点『确认分配』」** —— 那个按钮就在这段话下面,`,
` 他看得见;每次都补一句等于把最没信息量的话放在最后(T14 的推论:界面元素也算证据,`, ` 他看得见;每次都补一句等于把最没信息量的话放在最后(T14 的推论:界面元素也算证据,`,
` 已经有的东西不用再说一遍)。`, ` 已经有的东西不用再说一遍。`,
``, ``,
`⚠️ 上面两句是**成品句子,原话抄**:里面的数字、"没被分过的排在前面"、"第一次的估算值"` + `⚠️ 上面两句是**成品句子,原话抄**:里面的数字、"没被分过的排在前面"、"第一次的估算值"` +
`这类措辞都不要改写、不要省。`, `这类措辞都不要改写、不要省。`,
`⛔ 不要说"已经分配好了"(主管点确认之前一条都没真的分下去)。`, `⛔ 不要说"已经分配好了"(主管点确认之前一条都没真的分下去)。`,
`⛔ 不要出现「排序键 / 收敛 / 铺平 / 水位 / 基数 / 探索配额」这类词 —— 主管看不懂。`, `⛔ 不要出现「排序键 / 收敛 / 铺平 / 水位 / 基数 / 探索配额」这类词 —— 主管看不懂。`,
`⛔ 不要复述患者名单或客服分配明细 —— 卡片上有,而且你手里根本没有。`, `⛔ 不要复述患者名单或客服分配明细 —— 卡片上有而且你手里根本没有。`,
`⚠️ 引导那几句要用**主管的话**说,别报工具名(⛔ 不要出现 get_cohort_attributes 这种词)。`, `⚠️ 引导那几句要用**主管的话**说,别报工具名(⛔ 不要出现 get_cohort_attributes 这种词)。`,
].join('\n'); ].join('\n');
}, },
}); });
...@@ -289,12 +288,12 @@ export class AssistantService { ...@@ -289,12 +288,12 @@ export class AssistantService {
* ⭐ 助手**直接改已经呈现的那张确认单**。 * ⭐ 助手**直接改已经呈现的那张确认单**。
* *
* 主管说「把杨丽华移出这批」「王强的单给 5 天」「李莉这些转给张悦」时用。 * 主管说「把杨丽华移出这批」「王强的单给 5 天」「李莉这些转给张悦」时用。
* 在此之前助手只能回一句"我做不到,你先确认再逐条退回" —— 那是把界面能做的事 * 在此之前助手只能回一句"我做不到你先确认再逐条退回" —— 那是把界面能做的事
* 推回给主管手工做,而这张卡就在他眼前(2026-08-03 走查) * 推回给主管手工做,而这张卡就在他眼前(2026-08-03 走查)
* *
* ⚠️ 指令用**姓名**表达,⛔ 不用 planId:那些 id 根本没进过模型上下文(故意的) * ⚠️ 指令用**姓名**表达,⛔ 不用 planId:那些 id 根本没进过模型上下文(故意的)
* 匹配由界面用自己手里那份确认单完成,结果它会回一句话进对话。 * 匹配由界面用自己手里那份确认单完成结果它会回一句话进对话。
* ⚠️ 能做的只有卡片上能做的那几件(删/改派/改时效) * ⚠️ 能做的只有卡片上能做的那几件(删/改派/改时效)
* 换人群、改批次人数要重跑算法 → 只能重新调 propose_assignment 出一版。 * 换人群、改批次人数要重跑算法 → 只能重新调 propose_assignment 出一版。
*/ */
tools.edit_assignment_sheet = tool({ tools.edit_assignment_sheet = tool({
...@@ -302,43 +301,43 @@ export class AssistantService { ...@@ -302,43 +301,43 @@ export class AssistantService {
'直接修改**已经呈现**的那张确认单。' + '直接修改**已经呈现**的那张确认单。' +
'\n⚠️ 主管说「把某某移出这批」「某某的单给 5 天」时**就用这个**,' + '\n⚠️ 主管说「把某某移出这批」「某某的单给 5 天」时**就用这个**,' +
'⛔ 不要回答"做不到"、也不要让他先确认再逐条退回 —— 卡片就在他眼前。' + '⛔ 不要回答"做不到"、也不要让他先确认再逐条退回 —— 卡片就在他眼前。' +
'\n🔴 **一条指令 = 三件事拼起来:选谁(select)· 干什么(action)· 给谁(to)**。' + '\n🔴 **一条指令 = 三件事拼起来选谁(select)· 干什么(action)· 给谁(to)**。' +
'别去背短语,按这三问填:' + '别去背短语,按这三问填:' +
'\n ① **选谁**:点名的人→`{group:"patients",patients:["王强","李莉"]}`;' + '\n ① **选谁**点名的人→`{group:"patients",patients:["王强","李莉"]}`;' +
'某位客服名下全部→`{group:"agent",agent:"张悦"}`;' + '某位客服名下全部→`{group:"agent",agent:"张悦"}`;' +
'「待分配」那一组→`{group:"pending"}`;整批→`{group:"batch"}`。' + '「待分配」那一组→`{group:"pending"}`;整批→`{group:"batch"}`。' +
'\n ② **干什么**:改派→`assign`;移出本批→`remove`;改时效→`set_expiry`(配 days);' + '\n ② **干什么**改派→`assign`;移出本批→`remove`;改时效→`set_expiry`(配 days);' +
'设批次福利→`set_benefit`(配 text,select 必须是 batch,空串=撤掉)。' + '设批次福利→`set_benefit`(配 text,select 必须是 batch,空串=撤掉)。' +
'\n ③ **给谁**(只有 `assign` 要):' + '\n ③ **给谁**(只有 `assign` 要):' +
'\n · `{mode:"owner"}` = **各自回自己的专属客服**,一条专属关系都不动;' + '\n · `{mode:"owner"}` = **各自回自己的专属客服**,一条专属关系都不动;' +
'\n · `{mode:"balance"}` = 谁手上少先给谁(铺平);' + '\n · `{mode:"balance"}` = 谁手上少先给谁(铺平);' +
'点名了人就加 `agents:["张悦","李莉"]` 把范围限定在他们里(只点一位 = 都给他)。' + '点名了人就加 `agents:["张悦","李莉"]` 把范围限定在他们里(只点一位 = 都给他)。' +
'\n🔴🔴 **`owner` 和 `balance` 结果正好相反,选错主管一眼看得出来**(实测栽过):' + '\n🔴🔴 **`owner` 和 `balance` 结果正好相反选错主管一眼看得出来**(实测栽过):' +
'他说「把待分配的患者**各自分给各自的专属客服**」,助手用了铺平,' + '他说「把待分配的患者**各自分给各自的专属客服**」助手用了铺平,' +
'18 个人被散给了 17 位**别人**。' + '18 个人被散给了 17 位**别人**。' +
'⛔ 话里出现「各自 / 自己的 / 本来的 / 专属 / 谁的客户归谁」→ `owner`;' + '⛔ 话里出现「各自 / 自己的 / 本来的 / 专属 / 谁的客户归谁」→ `owner`;' +
'「平均分 / 谁手上少给谁 / 铺下去 / 随机分一分」→ `balance`。' + '「平均分 / 谁手上少给谁 / 铺下去 / 随机分一分」→ `balance`。' +
'\n⚠️ `balance` **不是真随机**(那会让同一张单每次算出不同结果),' + '\n⚠️ `balance` **不是真随机**(那会让同一张单每次算出不同结果),' +
'界面回报里会说清铺给了谁几条,**照抄**;⛔ 别顺着"随机"两个字复述成"我随机分了"。' + '界面回报里会说清铺给了谁几条,**照抄**;⛔ 别顺着"随机"两个字复述成"我随机分了"。' +
'⛔ 把患者从专属客服手里挪走是主管的决定:**他没明确要求就不要用**,更不要主动提议。' + '⛔ 把患者从专属客服手里挪走是主管的决定**他没明确要求就不要用**,更不要主动提议。' +
'\n⚠️ 举几个拼法(⛔ 别当成清单,没列到的组合照样按三问拼):' + '\n⚠️ 举几个拼法(⛔ 别当成清单,没列到的组合照样按三问拼):' +
'「把王强移出这批」→`{select:{group:"patients",patients:["王强"]},action:"remove"}`;' + '「把王强移出这批」→`{select:{group:"patients",patients:["王强"]},action:"remove"}`;' +
'「待分配的各自给回专属客服」→`{select:{group:"pending"},action:"assign",to:{mode:"owner"}}`;' + '「待分配的各自给回专属客服」→`{select:{group:"pending"},action:"assign",to:{mode:"owner"}}`;' +
'「张悦名下的分给别人」→`{select:{group:"agent",agent:"张悦"},action:"assign",to:{mode:"balance"}}`;' + '「张悦名下的分给别人」→`{select:{group:"agent",agent:"张悦"},action:"assign",to:{mode:"balance"}}`;' +
'「待分配的时效给 5 天」→`{select:{group:"pending"},action:"set_expiry",days:5}`。' + '「待分配的时效给 5 天」→`{select:{group:"pending"},action:"set_expiry",days:5}`。' +
'\n⚠️ 只能做这几件局部修改。**换人群 / 改批次人数**要重出确认单,那走 propose_assignment。' + '\n⚠️ 只能做这几件局部修改。**换人群 / 改批次人数**要重出确认单那走 propose_assignment。' +
'\n⚠️ 患者用**姓名或病历号**指代,客服用姓名;⛔ 你没有 planId,也不需要。' + '\n⚠️ 患者用**姓名或病历号**指代客服用姓名;⛔ 你没有 planId,也不需要。' +
'\n⚠️ 改完**不要说"已完成"**:界面执行后会自己回一句结果(可能没找到人),等那句再说。' + '\n⚠️ 改完**不要说"已完成"**:界面执行后会自己回一句结果(可能没找到人),等那句再说。' +
'\n⚠️ 批次**已经确认分配**之后:只有 `set_benefit` 还能改(福利只影响此后生成的话术,' + '\n⚠️ 批次**已经确认分配**之后:只有 `set_benefit` 还能改(福利只影响此后生成的话术,' +
'界面会真的写库并回报"作废了几条话术缓存 / 几条客服已经打开过");' + '界面会真的写库并回报"作废了几条话术缓存 / 几条客服已经打开过";' +
'人员和时效**改不了** —— 单子已经在客服手上,那要走撤销重分。' + '人员和时效**改不了** —— 单子已经在客服手上那要走撤销重分。' +
'⛔ 这两种情况都以界面回的那句为准,别自己判断成没成。', '⛔ 这两种情况都以界面回的那句为准别自己判断成没成。',
inputSchema: jsonSchema({ inputSchema: jsonSchema({
type: 'object', type: 'object',
properties: { properties: {
ops: { ops: {
type: 'array', type: 'array',
description: '一组操作,按顺序执行', description: '一组操作按顺序执行',
items: { items: {
type: 'object', type: 'object',
properties: { properties: {
...@@ -353,9 +352,9 @@ export class AssistantService { ...@@ -353,9 +352,9 @@ export class AssistantService {
patients: { patients: {
type: 'array', type: 'array',
items: { type: 'string' }, items: { type: 'string' },
description: 'group=patients 时:患者姓名或病历号,可给多个', description: 'group=patients 时:患者姓名或病历号,可给多个',
}, },
agent: { type: 'string', description: 'group=agent 时:客服姓名' }, agent: { type: 'string', description: 'group=agent 时客服姓名' },
}, },
required: ['group'], required: ['group'],
}, },
...@@ -367,7 +366,7 @@ export class AssistantService { ...@@ -367,7 +366,7 @@ export class AssistantService {
to: { to: {
type: 'object', type: 'object',
description: description:
'③ 给谁 —— 只有 action=assign 要。owner=各自回自己的专属客服(不动任何专属关系);' + '③ 给谁 —— 只有 action=assign 要。owner=各自回自己的专属客服(不动任何专属关系);' +
'balance=谁手上少先给谁。', 'balance=谁手上少先给谁。',
properties: { properties: {
mode: { type: 'string', enum: ['owner', 'balance'] }, mode: { type: 'string', enum: ['owner', 'balance'] },
...@@ -375,9 +374,9 @@ export class AssistantService { ...@@ -375,9 +374,9 @@ export class AssistantService {
type: 'array', type: 'array',
items: { type: 'string' }, items: { type: 'string' },
description: description:
'mode=balance 时可选:把候选限定在这几位客服(姓名)。' + 'mode=balance 时可选:把候选限定在这几位客服(姓名)。' +
'不给 = 本批所有在岗(含一条都没分到的 —— 他们恰恰手上最空)。' + '不给 = 本批所有在岗(含一条都没分到的 —— 他们恰恰手上最空)。' +
'⛔ mode=owner 不吃这个参数:收谁由患者自己的专属关系决定。', '⛔ mode=owner 不吃这个参数收谁由患者自己的专属关系决定。',
}, },
}, },
required: ['mode'], required: ['mode'],
...@@ -386,8 +385,8 @@ export class AssistantService { ...@@ -386,8 +385,8 @@ export class AssistantService {
text: { text: {
type: 'string', type: 'string',
description: description:
'set_benefit 的福利原文(挂在整批上,会进这批人的话术)。空串 = 撤掉福利。' + 'set_benefit 的福利原文(挂在整批上,会进这批人的话术)。空串 = 撤掉福利。' +
'⛔ 原话写,不要替主管加条件/期限/承诺。', '⛔ 原话写不要替主管加条件/期限/承诺。',
}, },
}, },
required: ['select', 'action'], required: ['select', 'action'],
...@@ -398,27 +397,27 @@ export class AssistantService { ...@@ -398,27 +397,27 @@ export class AssistantService {
}), }),
execute: async (args: unknown) => { execute: async (args: unknown) => {
const ops = (args as { ops?: unknown[] })?.ops; const ops = (args as { ops?: unknown[] })?.ops;
if (!Array.isArray(ops) || ops.length === 0) return '没有给出任何操作,确认单未改动。'; if (!Array.isArray(ops) || ops.length === 0) return '没有给出任何操作确认单未改动。';
push({ type: 'assignment_sheet_edit', ops }); push({ type: 'assignment_sheet_edit', ops });
// ⚠️ 只回"已交给界面",⛔ 不回"已完成" —— 匹配得到匹配不到是界面才知道的事, // ⚠️ 只回"已交给界面",⛔ 不回"已完成" —— 匹配得到匹配不到是界面才知道的事,
// 它会把结果作为一条消息回到对话里(前端 appendAssistantNote) // 它会把结果作为一条消息回到对话里(前端 appendAssistantNote)
return `已把 ${ops.length} 条修改交给界面执行,结果会由界面回报(可能有人没找到)。请等那句结果,⛔ 不要自己宣布已完成。`; return `已把 ${ops.length} 条修改交给界面执行,结果会由界面回报(可能有人没找到)。请等那句结果,⛔ 不要自己宣布已完成。`;
}, },
}); });
} }
// 本地"渲染"工具(不走 MCP):模型把自包含 HTML 片段交给前端,在沙箱 iframe 里渲染成卡片/报表。 // 本地"渲染"工具(不走 MCP):模型把自包含 HTML 片段交给前端,在沙箱 iframe 里渲染成卡片/报表。
// html 通过 tool-call 入参流式到前端;execute 仅回执,模型据此继续给一句话总结。 // html 通过 tool-call 入参流式到前端;execute 仅回执模型据此继续给一句话总结。
tools.render_artifact = tool({ tools.render_artifact = tool({
description: description:
'把一段自包含 HTML(<body> 内部片段)渲染成可视化卡片/报表展示给用户。适合召回池列表、患者画像卡、分析报表(可含图表)。用 Tailwind 工具类排版,可含 <canvas>+<script> 画 Chart.js 图表;运行环境已注入 Tailwind 与 Chart.js,勿自行引入,勿访问外部网络,数据内联、手机号掩码、只用真实数据。', '把一段自包含 HTML(<body> 内部片段)渲染成可视化卡片/报表展示给用户。适合召回池列表、患者画像卡、分析报表(可含图表)。用 Tailwind 工具类排版,可含 <canvas>+<script> 画 Chart.js 图表;运行环境已注入 Tailwind 与 Chart.js,勿自行引入,勿访问外部网络,数据内联、手机号掩码、只用真实数据。',
inputSchema: jsonSchema({ inputSchema: jsonSchema({
type: 'object', type: 'object',
properties: { properties: {
title: { type: 'string', description: '卡片标题(可选)' }, title: { type: 'string', description: '卡片标题(可选)' },
html: { html: {
type: 'string', type: 'string',
description: '<body> 内部 HTML 片段(Tailwind 类;可含 <canvas>+<script> 图表)', description: '<body> 内部 HTML 片段(Tailwind 类;可含 <canvas>+<script> 图表)',
}, },
}, },
required: ['html'], required: ['html'],
...@@ -437,19 +436,19 @@ export class AssistantService { ...@@ -437,19 +436,19 @@ export class AssistantService {
system: input.systemExtra ? `${SYSTEM_PROMPT}\n\n${input.systemExtra}` : SYSTEM_PROMPT, system: input.systemExtra ? `${SYSTEM_PROMPT}\n\n${input.systemExtra}` : SYSTEM_PROMPT,
messages: input.messages, messages: input.messages,
tools, tools,
stopWhen: stepCountIs(8), // 防失控:最多 8 步工具循环 stopWhen: stepCountIs(8), // 防失控最多 8 步工具循环
abortSignal: input.abortSignal, abortSignal: input.abortSignal,
}); });
} }
/** 桌宠环境观察发言 —— 无工具、限长、流式;失败由前端静默降级(宠物只是不说话)。 */ /** 桌宠环境观察发言 —— 无工具、限长、流式;失败由前端静默降级(宠物只是不说话)。 */
petSay(input: { observation: string; abortSignal?: AbortSignal }): { textStream: AsyncIterable<string> } { petSay(input: { observation: string; abortSignal?: AbortSignal }): { textStream: AsyncIterable<string> } {
const { model } = this.provider.resolve('deepseek'); const { model } = this.provider.resolve('deepseek');
return streamText({ return streamText({
model, model,
system: PET_SYSTEM_PROMPT, system: PET_SYSTEM_PROMPT,
prompt: `环境观察:${input.observation}`, prompt: `环境观察${input.observation}`,
// 注:思考型模型 reasoning 也计入 output token,上限给宽;台词长度靠系统提示词约束(≤30 字) // 注:思考型模型 reasoning 也计入 output token,上限给宽;台词长度靠系统提示词约束(≤30 字)
maxOutputTokens: 500, maxOutputTokens: 500,
temperature: 1.0, temperature: 1.0,
abortSignal: input.abortSignal, abortSignal: input.abortSignal,
......
...@@ -418,7 +418,9 @@ describe('待分配的批量处置 —— 决定他做,执行你做', () => { ...@@ -418,7 +418,9 @@ describe('待分配的批量处置 —— 决定他做,执行你做', () => {
}); });
test('⭐⭐ 提示词要点名禁止「我不能替您做这个决定」这种甩锅', () => { test('⭐⭐ 提示词要点名禁止「我不能替您做这个决定」这种甩锅', () => {
expect(PROMPTS).toMatch(/决定他做,执行你做/); // ⚠️ 标点写成 `[,,]`:提示词的中文标点 2026-08-11 统一改成全角了(模型在照抄它),
// ⛔ 别把断言钉死在某一种标点上 —— 那种失败与被测行为无关,只是噪音。
expect(PROMPTS).toMatch(/决定他做[,,]执行你做/);
expect(PROMPTS).toMatch(/甩锅/); expect(PROMPTS).toMatch(/甩锅/);
// 同时仍然保留"不许自作主张"那一半 —— 两半缺一不可 // 同时仍然保留"不许自作主张"那一半 —— 两半缺一不可
expect(PROMPTS).toMatch(/不许自作主张/); expect(PROMPTS).toMatch(/不许自作主张/);
...@@ -733,8 +735,8 @@ describe('确认单指令 —— 三个正交的轴', () => { ...@@ -733,8 +735,8 @@ describe('确认单指令 —— 三个正交的轴', () => {
}); });
test('🔴🔴 提示词教的是**三问怎么拼**,⛔ 不是一串要背的短语', () => { test('🔴🔴 提示词教的是**三问怎么拼**,⛔ 不是一串要背的短语', () => {
expect(SVC).toMatch(/选谁\(select\)· 干什么\(action\)· 给谁\(to\)/); expect(SVC).toMatch(/选谁[((]select[))]· 干什么[((]action[))]· 给谁[((]to[))]/);
expect(SVC).toMatch(/别去背短语,按这三问填/); expect(SVC).toMatch(/别去背短语[,,]按这三问填/);
// 举例可以有,但必须写明"别当成清单" —— 否则模型只会照抄例子,没列到的组合又不敢拼 // 举例可以有,但必须写明"别当成清单" —— 否则模型只会照抄例子,没列到的组合又不敢拼
expect(SVC).toMatch(/⛔ 别当成清单/); expect(SVC).toMatch(/⛔ 别当成清单/);
// 把实测栽的那次写成反例:抽象规则模型容易绕过,具体反例不会 // 把实测栽的那次写成反例:抽象规则模型容易绕过,具体反例不会
......
...@@ -95,21 +95,47 @@ function toolMeta(tool: string) { ...@@ -95,21 +95,47 @@ function toolMeta(tool: string) {
return TOOL_META[tool] ?? { label: tool, running: `正在调用 ${tool}` }; return TOOL_META[tool] ?? { label: tool, running: `正在调用 ${tool}` };
} }
// ── LLM 文本 → markdown 渲染(用户要求:不复用设计的结构卡,改 markdown 解析)── /**
* LLM 文本 → markdown 渲染(用户要求:不复用设计的结构卡,改 markdown 解析)。
*
* ═══ 2026-08-11 排版走查改的四件事(产品:「输出抓不住重点」)═══════════════
* ① **段落要成块**:原来 `p` 是 `my-1.5`(6px),而行距 22px —— 段距只有行距的 1/4,
* 段与段比行与行还紧,三段话在眼里是一坨。排版通则:段距 ≥ 1.5 倍行距。
* ② **加粗不再身兼三职**:原来 `strong` 是 `semibold + slate-900`(字重与对比度同时跳),
* 模型又拿它当标题使 —— 一屏 7 处黑块,等于没有重点。
* 现在 strong 降成 `font-medium`,**标题交给 h3**(见下)。
* ③ **标题与正文靠字号/颜色分层,不靠字重**:h3 = 12.5px slate-500 的小节标签
* (「要您定的」「已排好」),⛔ 别做成"更大更黑的一行" —— 那会跟句中加粗抢。
* ④ **限行长 + 数字等宽**:13.5px 中文在宽面板里一行能排 50+ 字(舒适区 25–35),
* `max-w-[34em]` 收住;`tabular-nums` 让数字竖着对齐,扫视时自己跳出来。
* ⛔ 别把这些值改回去而不改提示词:样式与「模型输出什么结构」是一对
* (见 assistant-prompts 的「怎么写」一节)。
*/
function Markdown({ text }: { text: string }) { function Markdown({ text }: { text: string }) {
return ( return (
<div className="text-[13.5px] leading-relaxed text-slate-700"> <div className="max-w-[34em] text-[13.5px] leading-relaxed text-slate-700 [font-variant-numeric:tabular-nums]">
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={{
h1: ({ children }) => <h1 className="mb-2 mt-3 text-[16px] font-semibold text-slate-900">{children}</h1>, h1: ({ children }) => <h1 className="mb-1.5 mt-4 text-[14px] font-semibold text-slate-900">{children}</h1>,
h2: ({ children }) => <h2 className="mb-1.5 mt-3 text-[15px] font-semibold text-slate-900">{children}</h2>, h2: ({ children }) => <h2 className="mb-1.5 mt-4 text-[13.5px] font-semibold text-slate-900">{children}</h2>,
h3: ({ children }) => <h3 className="mb-1 mt-2.5 text-[14px] font-semibold text-slate-800">{children}</h3>, /**
p: ({ children }) => <p className="my-1.5">{children}</p>, * ⭐ h3 是**模型实际会用的那一档**(提示词让它用 `###` 分块)。
ul: ({ children }) => <ul className="my-1.5 ml-4 list-disc space-y-1">{children}</ul>, * 做成**小节标签**:比正文小半号 + 次级色 + 加字距,与正文拉开的是**字号和颜色**。
ol: ({ children }) => <ol className="my-1.5 ml-4 list-decimal space-y-1">{children}</ol>, * ⛔ 别做成"更大更黑" —— 那跟句中加粗是同一种编码,又会糊成一片。
* ⚠️ `mt-4` 是段落成块的另一半:标题上方的空白才是"新的一块开始了"的信号。
*/
h3: ({ children }) => (
<h3 className="mb-1 mt-4 text-[12px] font-semibold tracking-wide text-slate-500 first:mt-0">
{children}
</h3>
),
p: ({ children }) => <p className="my-3.5">{children}</p>,
ul: ({ children }) => <ul className="my-2.5 ml-4 list-disc space-y-1.5">{children}</ul>,
ol: ({ children }) => <ol className="my-2.5 ml-4 list-decimal space-y-1.5">{children}</ol>,
li: ({ children }) => <li className="leading-relaxed">{children}</li>, li: ({ children }) => <li className="leading-relaxed">{children}</li>,
strong: ({ children }) => <strong className="font-semibold text-slate-900">{children}</strong>, /// ⚠️ `font-medium` 不是 semibold:句中强调而已,⛔ 别再同时跳字重和颜色
strong: ({ children }) => <strong className="font-medium text-slate-900">{children}</strong>,
a: ({ children, href }) => ( a: ({ children, href }) => (
<a href={href} className="text-brand-700 underline" target="_blank" rel="noreferrer"> <a href={href} className="text-brand-700 underline" target="_blank" rel="noreferrer">
{children} {children}
......
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