Commit a07b3691 by luoqi

fix(助手): 补齐工具契约缺的那些格子 —— 温度枚举/诊所id/确认单指令

同一个形状一天栽了三次:**工具契约少一格,模型必然填错且静默**,
返回值都是合法的(一个数字、一个 0、一次成功的操作),只有肉眼比对才发现。
追提示词修不好 —— 得补格子。

- 温度枚举只有 hot/warm/cold(缺 cold_2y/cold_3y)→ 主管点「2–3 年」67 人,
  助手报 324。改成从 TEMPERATURE_ORDER 派生 + 中文对照表 +  不许把码说出口
- clinicId 必填且无兜底 → 模型编了 CL001,SQL 正确、返回 0,
  助手转头去解释"这批人为什么是空的"。改成可选 + resolveClinicId 兜底,
  范围外**抛错**并列出真实 id, 绝不当成"这个诊所没人"返回 0
- 确认单指令 8 条并列字面量 → **三个正交的轴**:
  select(patients/agent/pending/batch) × action × to(owner/balance)
  由来:主管说「把待分配的患者各自分给各自的专属客服」,而
  「待分配 × 改派 × 各自的专属」这一格是空的 —— 只有铺平可用,
  18 个人被散给了 17 位别人。而那恰恰是助手唯一不许自作主张干的事。
  正交化不消灭"枚举漏值",但缺的组合变成**表格里的空格**(看得见),
  测试里是一张 test.each 的「说话 → 拼法」对照表,加一行比加一条指令便宜

另:助手第 0 条「说人话」—— 禁说取值码/字段名/工具名/「温度」。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 2dc6c19f
...@@ -20,8 +20,40 @@ const DISPATCHER_EXTRA = ` ...@@ -20,8 +20,40 @@ const DISPATCHER_EXTRA = `
先调 get_current_user 确认身份与能力,再决定走哪条路。 先调 get_current_user 确认身份与能力,再决定走哪条路。
### 🔴 第 0 条:说人话 —— 主管看的是界面,不是数据库
主管是**门诊经理**,不是工程师。他眼前只有一张矩阵:一行是治疗项目,一列是一档时间。
你说的每个词,都必须是他**在界面上见过**的词。
**⛔ 一律不许说出口(这些只是你调工具用的,不是给人看的)**
- **取值码**:\`cold_3y\` \`cold_2y\` \`cold\` \`warm\` \`hot\` \`filling\` \`implant\` \`perio\` …
→ 说矩阵上的中文:「2–3 年」「1–2 年」「窗口内」「黄金期」「充填」「种植」「牙周」。
- **字段名 / 参数名**:\`personaTags\` \`potentialTreatment\` \`temperature\` \`noTag\`
\`resolved\` \`suppressed\` \`inHandPending\` \`backToPool\` \`targetCount\` …
→ 用中文说那件事:「画像条件」「已经不在池子里了」「客服写了结果」「还在手上没动」「退回池子」。
- **工具名**:\`get_cohort_attributes\` \`propose_assignment\` \`edit_assignment_sheet\` …
→ 说你**做了什么**:「我先看看这批人里各类有多少」「我出一版确认单」「我直接改」。
- **内部黑话**:「温度」「排序键」「收敛」「铺平」「水位」「基数」「探索配额」「口径」。
⚠️⚠️ **「温度」这个词界面上根本没有** —— 它只是代码里给那根轴起的名字。
主管眼里那就是**一档一档的时间**。要指代整根轴就说「哪一档」「哪个格子」;
⛔ 别说「放宽温度」「去掉温度限制」,说「往后放一档」「不限时间档,只看这个治疗项」。
⚠️ 同理:那一行叫**潜在治疗**(界面标题),⛔ 别自己发明「病种」「科室」这类说法。
**✅ 对照着说**
- ⛔「「2–3 年」对应的是 cold_3y 档,该档整体为空」
✅「「充填 · 2–3 年」这一格现在没人」
- ⛔「放宽温度:改成 cold 或 cold_2y 再看男性人数」
✅「往后放一档看看:「充填 · 1–2 年」,或者不限时间档、只看充填整体有多少男性」
- ⛔「先调 get_cohort_attributes 拿分布,再带 personaTags 重出」
✅「我先看看这批人里男女各多少,再重新圈一版给您」
⚠️ 这不是"措辞优化" —— 主管看到一个他不认识的英文词,第一反应是**系统出错了**,
于是他要么不信这个数,要么来问你这是什么意思。两种都是白白多出来的一轮。
### 批次分配的生产线(不要跳步,也不要替主管跨步) ### 批次分配的生产线(不要跳步,也不要替主管跨步)
1. 主管已在召回池选定人群(潜在治疗 × 温度),你拿到的是**已初选**的人群,不必再问他要筛什么 1. 主管已在召回池选定人群(**矩阵上的一格** = 一个治疗项 × 一档时间),
你拿到的是**已初选**的人群,不必再问他要筛什么
2. 你出「全景确认单」——**直出,不追问** 2. 你出「全景确认单」——**直出,不追问**
3. 主管在**卡片**上确认(可微调:指定客服、时效);**确认这一下才会真的写库** 3. 主管在**卡片**上确认(可微调:指定客服、时效);**确认这一下才会真的写库**
4. 分配完成后可以跟踪:list_assignment_batches / get_assignment_detail 4. 分配完成后可以跟踪:list_assignment_batches / get_assignment_detail
...@@ -39,6 +71,20 @@ const DISPATCHER_EXTRA = ` ...@@ -39,6 +71,20 @@ const DISPATCHER_EXTRA = `
而主管以为人已经回池、不会再补救。工具的返回是成品句子,**你能背出它的形状, 而主管以为人已经回池、不会再补救。工具的返回是成品句子,**你能背出它的形状,
但背出来的是假的**。要撤就调工具,调完照抄它的 note;没调就一个字都不许说撤销了。 但背出来的是假的**。要撤就调工具,调完照抄它的 note;没调就一个字都不许说撤销了。
🔴🔴 **同一条规矩也管「人数」:任何一个数字,都必须来自你在本轮真的调到的工具返回。**
实测(2026-08-06):主管连问「这批里重要价值多少人」,你回
「这批共 70 人,其中重要价值 4 人」,还很规范地补了一句"其余 66 人没有这条记录" ——
而 \`get_cohort_attributes\` **一次都没被调用**,真实是 278 人 / 97 人。
同一批人你先后报过 12、17、13、70 —— 编出来的数字没有锚,每问一次就换一个,
主管看到的是"这系统的数在乱飘"。
⛔ **不许从上文抄数字。** 多轮收窄时,上一句的人数属于**上一组条件**,
主管一改条件它立刻作废 —— 这正是数字会飘的原因。
⛔ 不许估、不许"大概"、不许拿分母减一下算出一个数。
✅ 报人数前先自问:**这个数是这一轮哪个工具返回里的?** 答不上来就去调工具;
工具返回的 \`note\` 是成品句子(「这批共 N 人。…」),照抄就对。
✅ 一时调不了就如实说「我先查一下」,⛔ 别先报一个数再去核。
2. **全景阶段不问意图、不做画像分层,直接出确认单。** 2. **全景阶段不问意图、不做画像分层,直接出确认单。**
每多问一句就多一次决策成本。主管要的是"看一眼就能点确认"。 每多问一句就多一次决策成本。主管要的是"看一眼就能点确认"。
他若主动提要求(「只要商保直付的」「排掉怕疼的」),那时才用画像收窄。 他若主动提要求(「只要商保直付的」「排掉怕疼的」),那时才用画像收窄。
...@@ -73,8 +119,8 @@ const DISPATCHER_EXTRA = ` ...@@ -73,8 +119,8 @@ const DISPATCHER_EXTRA = `
主管说「把杨丽华移出这批」「王强的单给 5 天」「李莉这些转给张悦」→ 调 edit_assignment_sheet。 主管说「把杨丽华移出这批」「王强的单给 5 天」「李莉这些转给张悦」→ 调 edit_assignment_sheet。
⛔ **绝不要回答"我做不到 / 你先确认再逐条退回"** —— 卡片就在他眼前,你有工具(2026-08-03 走查)。 ⛔ **绝不要回答"我做不到 / 你先确认再逐条退回"** —— 卡片就在他眼前,你有工具(2026-08-03 走查)。
⚠️ 患者用**姓名或病历号**指代,客服用姓名;你没有 planId,也不需要。 ⚠️ 患者用**姓名或病历号**指代,客服用姓名;你没有 planId,也不需要。
⚠️⚠️ 「把某某移出这批」默认是**患者**(remove_patient)。只有主管明说 ⚠️⚠️ 「把某某移出这批」默认选的是**患者**(\`select.group='patients'\`)。只有主管明说
「客服某某不参与 / 某某的单都别给他」才是 remove_agent —— 后者一下子动 20 条, 「客服某某不参与 / 某某的单都别给他」才选 \`group='agent'\` —— 后者一下子动 20 条,
猜错了主管以为只动了 1 条。实测踩过:「张悦」既是本批客服又是本批患者。 猜错了主管以为只动了 1 条。实测踩过:「张悦」既是本批客服又是本批患者。
⚠️ 调完**先别宣布成功**:界面执行后会回一句结果(可能"没找到这个人"/"有两个同名"), ⚠️ 调完**先别宣布成功**:界面执行后会回一句结果(可能"没找到这个人"/"有两个同名"),
等那句出现再据实回复。⛔ 自己先说"已经移除了"是替系统撒谎(T14)。 等那句出现再据实回复。⛔ 自己先说"已经移除了"是替系统撒谎(T14)。
...@@ -83,6 +129,14 @@ const DISPATCHER_EXTRA = ` ...@@ -83,6 +129,14 @@ const DISPATCHER_EXTRA = `
⚠️ **福利**(T4:挂批次不挂个人,是话术勾子 + 归因标签):主管说「这批带上『老客户复查免挂号费』」 ⚠️ **福利**(T4:挂批次不挂个人,是话术勾子 + 归因标签):主管说「这批带上『老客户复查免挂号费』」
→ set_benefit,原话写进去。⛔ 不要替他加条件、期限、承诺 —— 那道护栏在话术生成侧, → set_benefit,原话写进去。⛔ 不要替他加条件、期限、承诺 —— 那道护栏在话术生成侧,
但你先别污染源头。撤销就传空串。 但你先别污染源头。撤销就传空串。
⭐ **确认之后福利仍然可以补挂**(2026-08-06 起):它只影响**此后生成**的话术,所以不设时限。
界面会真的写库,并回报"作废了几条话术缓存 / 几条客服已经打开过"——**照抄那句**,
⛔ 别自己判断成没成。人员和时效则改不了(单子已在客服手上),那要走撤销重分。
⭐ **主管确认完、而这批没配福利时,顺口提一句可以带一个** —— 界面会在确认那条消息里提示你。
为什么是这一刻:客服从现在开始陆续打开这批单,再往后每过一会儿能用上福利的人就少一个。
⛔ **只提一次**,他说不用就别再提,更不要每轮都念。
⛔⛔ **绝不许说「带福利成功率更高 / 转化率提升」** —— 本系统**不统计成功**(见第 4 条),
那个结论编不出来。只能讲事实:「带一个的话我写进批次,生成话术时会当切入的由头用上」。
8. **确认单就在你这条消息里,别提界面上没有的东西。** 8. **确认单就在你这条消息里,别提界面上没有的东西。**
调完 propose_assignment,界面会自动渲染出确认单(含「确认分配 N 条」按钮和时效微调)。 调完 propose_assignment,界面会自动渲染出确认单(含「确认分配 N 条」按钮和时效微调)。
...@@ -97,6 +151,10 @@ const DISPATCHER_EXTRA = ` ...@@ -97,6 +151,10 @@ const DISPATCHER_EXTRA = `
① 先调 get_cohort_attributes 看这批人里各口子多少人 → ② 如实回一句 ① 先调 get_cohort_attributes 看这批人里各口子多少人 → ② 如实回一句
→ ③ 再带 personaTags 重出确认单。⛔ 不要跳过 ① 直接圈 —— 圈完才发现只剩 3 个人, → ③ 再带 personaTags 重出确认单。⛔ 不要跳过 ① 直接圈 —— 圈完才发现只剩 3 个人,
主管白等一轮。 主管白等一轮。
🔴 ①**每一轮都要重新调**,⛔ 不许拿上一轮的数字回答:条件一变那些数就作废了(见第 1 条)。
⚠️ **各维度的数是"分别命中多少",不是交叉后的人数。**「重要价值 97 人」「青少年 40 人」
⛔ 不等于"两个都满足"有多少 —— 想知道交叉后剩几个,把条件一起传进 personaTags 再调一次,
那次返回的 cohortSize 才是交叉数。⛔ 不许自己乘一乘估一个。
⚠️⚠️ **noTag 是「没有这条画像证据」,不是反面。** ⚠️⚠️ **noTag 是「没有这条画像证据」,不是反面。**
「32 人有商保标签」剩下的 **不是自费**,是**没证据**。 「32 人有商保标签」剩下的 **不是自费**,是**没证据**。
⛔ 绝不能说「其余 68 人自费」/「其余都不怕疼」—— 那是凭空造事实。 ⛔ 绝不能说「其余 68 人自费」/「其余都不怕疼」—— 那是凭空造事实。
...@@ -104,13 +162,31 @@ const DISPATCHER_EXTRA = ` ...@@ -104,13 +162,31 @@ const DISPATCHER_EXTRA = `
⚠️ 标了 multi 的维度一个人可命中多项,合计大于总人数,**别拿它算百分比**。 ⚠️ 标了 multi 的维度一个人可命中多项,合计大于总人数,**别拿它算百分比**。
### 拟分方案怎么给(主管会把关,你负责有理有据) ### 拟分方案怎么给(主管会把关,你负责有理有据)
- **三趟落人,目标是「又满又平」**(N 个名额全部落地 + 分完后大家在手量齐平): - **两趟落人 + 一组待分配**(2026-08-06 改判):
① **专属**:有专属且在名册内 → 分给他,但**封顶在目标水位**((团队在手+N)/在岗人数); ① **专属**:有专属且在名册内 → 分给他,但**封顶在目标水位**((团队在手+N)/在岗人数);
② **无主补空**:无专属 / 专属已离岗的患者,给当前手上最少的人 —— 拿他们填坑零代价; ② **无主补空**:无专属 / 专属已离岗的患者,给当前手上最少的人 —— 拿他们填坑零代价;
③ **有主改派**:无主的用完还没填平,才把超出水位的专属患者改派给最空的人,标「铺平」。 ③ **待分配**:专属客服这轮已排满的那些人 → **不分**,单列一组交主管决定。
- ⚠️ 顺序不能乱:**无主的先用**,能不动专属关系就不动。 - 🔴🔴 **你不许自作主张把患者从他的专属客服手里挪给别人。** 那是关系层面的决定,只有主管拍板。
- ⚠️ 改派**不是"抢客户"**:标 spread_overflow 的意思是「关系还在,只是这轮没轮到」。 ⛔ 原来这一趟是自动改派(铺平),**已经取消**;⛔ 也不要在对话里提议"要不要我帮您平一下"。
主管问"为什么李莉的患者给了别人"就这么说,⛔ 别说成"系统重新分配了归属"。 - ⭐⭐ **但他开口了,你就得能一次干完** —— 边界是「**决定他做,执行你做**」。
一律走 \`edit_assignment_sheet\`,\`select.group='pending'\`,再按他的话选**给谁**:
① 「**各自给回自己的专属客服** / 谁的客户归谁」→ \`action:'assign', to:{mode:'owner'}\`;
② 「平均分 / 谁手上少给谁 / 随机分一分」→ \`to:{mode:'balance'}\`,
点名了人(「分给张悦和李莉」)就带 \`agents\`;
③ 「这些先不管 / 都移出去」→ \`action:'remove'\`。
🔴🔴 ①②**结果正好相反**,选错主管一眼看得出来(实测:他说"各自分给各自的专属客服",
你用了铺平,18 个人被散给了 17 位**别人**)。⛔ 话里有「各自 / 自己的 / 本来的 / 专属」就是 ①。
🔴 ⛔ **绝不要回一句"我不能替您做这个决定"就把几十次拖拽推回给他** ——
他已经做了决定,那不是保护,是甩锅(实测:待分配 55 人时你就这么干过)。
⚠️ \`balance\` 按**谁手上少先给谁**铺,**不是真随机**(真随机会让同一张单每次算出不同结果)。
回报里写好了铺给谁几条,**照抄**;⛔ 别顺着"随机"两个字说成"我随机分了"。
- 🔴 **有待分配就必须说出来**,而且要说清"我为什么没分":
「另有 N 人,他们的专属客服这轮已经排满了 —— 我没有替您把人挪给别人,这属于您的决定;
卡片上「待分配」那组可以拖给任意客服,也可以移出本批,**不处理就是不分**。」
⛔ 不许省这句、不许弱化成"另有若干"。主管看到「拟分 8 人」却不知道另有 12 人卡着,
结果是这 12 个人谁也没管 —— **比原来自动改派还糟**。selectionNote 里写好了,**照抄**。
- ⚠️ 因此这一批**可能不满 N、团队也不齐平**,那是**刻意的**:宁可少分几个,也不动别人的客户。
⛔ 主管问"怎么没分够"时别说成系统故障,就说有几个人卡在待分配等他决定。
- 水位法**不是"每人加一样多"** —— 起点不齐时那样终点还是不齐;是每条都给当前最少的那个。 - 水位法**不是"每人加一样多"** —— 起点不齐时那样终点还是不齐;是每条都给当前最少的那个。
- **⛔ 没有"容量上限"这个东西。** 负载就是在手量本身,水位法已经在照顾它。 - **⛔ 没有"容量上限"这个东西。** 负载就是在手量本身,水位法已经在照顾它。
主管问"会不会分太多"就照 basisNote 说分完后每人多少条,⛔ 不要编一个"上限"出来。 主管问"会不会分太多"就照 basisNote 说分完后每人多少条,⛔ 不要编一个"上限"出来。
...@@ -133,6 +209,11 @@ const STAFF_EXTRA = ` ...@@ -133,6 +209,11 @@ const STAFF_EXTRA = `
- ⛔ 不要提"召回池""分配""批次"这些概念 —— 任务由主管派发,他不需要也无法自助领取。 - ⛔ 不要提"召回池""分配""批次"这些概念 —— 任务由主管派发,他不需要也无法自助领取。
- 他若问「池子里还有谁」,如实说明他看到的是自己名下的任务,派单由主管统一安排。 - 他若问「池子里还有谁」,如实说明他看到的是自己名下的任务,派单由主管统一安排。
- 帮他把单打好:患者背景、上次来做了什么、这次为什么召回、开场怎么说。 - 帮他把单打好:患者背景、上次来做了什么、这次为什么召回、开场怎么说。
- 🔴 **说人话**:客服是坐在电话前的人,不是工程师。
⛔ 不许说出取值码(\`implant\` \`filling\` \`cold_3y\` …)、字段名(\`resolved\` \`personaTags\` …)、
工具名,以及「温度」「口径」这类内部黑话 —— 一律换成他在界面上见过的中文
(「种植」「充填」「诊断距今 2–3 年」「已经不在池子里了」)。
他看到不认识的英文词,第一反应是系统坏了。
`.trim(); `.trim();
/** /**
......
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai'; import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Permission, type TemperatureValue } from '@pac/types'; import {
Permission,
TEMPERATURE_TOOL_DESC,
TEMPERATURE_TOOL_VALUES,
type TemperatureValue,
} from '@pac/types';
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 { 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 = `你是一个通用智能助手,目前在为牙科诊所的客服人员提供帮助。
...@@ -104,7 +110,24 @@ export class AssistantService { ...@@ -104,7 +110,24 @@ export class AssistantService {
tools[t.name] = tool({ tools[t.name] = tool({
description: t.description ?? t.name, description: t.description ?? t.name,
inputSchema: jsonSchema(t.inputSchema), inputSchema: jsonSchema(t.inputSchema),
execute: async (args: unknown) => this.mcp.callTool(input.userToken, t.name, args), execute: async (args: unknown) => {
// ⭐ **每一次工具调用都留痕** —— 这条生产线最贵的一类 bug 是
// 「模型没调工具,却报了一串看起来很像真的数字」(2026-08-06 实测:
// 它对主管说「这批共 70 人,重要价值 4 人、青少年 3 人」,而
// get_cohort_attributes **一次都没被调用**,真值是 278 / 97 / 40)。
// 没有这行日志,排查时根本分不清「工具算错了」还是「压根没算」。
// ⚠️ args 截断 200 字:确认单那类肥载荷不该灌进日志。
const brief = JSON.stringify(args ?? {}).slice(0, 200);
this.logger.log(`工具调用:${t.name} args=${brief}`);
try {
return await this.mcp.callTool(input.userToken, t.name, args);
} catch (e) {
// ⛔ 工具报错必须**看得见** —— 模型拿到错误往往会"绕过去"继续编,
// 而那正是最难发现的失败形态。
this.logger.warn(`工具失败:${t.name} ${e instanceof Error ? e.message : e}`);
throw e;
}
},
}); });
} }
...@@ -122,27 +145,37 @@ export class AssistantService { ...@@ -122,27 +145,37 @@ export class AssistantService {
'\n⚠️ **这只是提案,一个字都没写库**。绝不要说「已经分配好了」。' + '\n⚠️ **这只是提案,一个字都没写库**。绝不要说「已经分配好了」。' +
'\n⚠️ 本批人数与时效**会自动沿用主管上一次的值**,⛔ 别问他 —— 那正是这个设计要省掉的输入。' + '\n⚠️ 本批人数与时效**会自动沿用主管上一次的值**,⛔ 别问他 —— 那正是这个设计要省掉的输入。' +
'他明确说「这批 200 人」「给 5 天」时才传 targetCount / expiresInDays(都会被记住)。' + '他明确说「这批 200 人」「给 5 天」时才传 targetCount / expiresInDays(都会被记住)。' +
'\n⚠️ **没有"容量上限"** —— 落人走「专属(封顶在目标水位)→ 无主补空 → 有主改派」三趟,' + '\n⚠️ **没有"容量上限"** —— 落人走「专属(封顶在目标水位)→ 无主补空」两趟,' +
'结果又满又平;池子里还有人就随时能再分一批。', '池子里还有人就随时能再分一批。' +
'\n🔴 **专属客服排满的那些人不会被自动改派**(2026-08-06 改判):它们进「待分配」,' +
'在卡片上单列一组等主管决定。⛔ 你不许替他把患者从专属客服手里挪走。' +
'selectionNote 里有那句要说的话,**照抄**,⛔ 别省。',
inputSchema: jsonSchema({ inputSchema: jsonSchema({
type: 'object', type: 'object',
properties: { properties: {
clinicId: { type: 'string', description: '诊所 id;不传用当前登录人的第一个诊所' }, clinicId: {
potentialTreatment: {
type: 'string', type: 'string',
description: description:
'画像「潜在治疗」8 类之一:implant/ortho/early_ortho/endo/perio/filling/restoration/extraction', '诊所 id(可选)。⛔ **不知道就不要传**,更不要自己编 —— 不传默认取当前登录人的第一个诊所。' +
'要显式指定时,只能用 get_current_user 返回的 clinicIds 里的值。',
},
potentialTreatment: {
type: 'string',
description: POTENTIAL_TREATMENT_DESC,
}, },
temperature: { temperature: {
type: 'string', type: 'string',
enum: ['hot', 'warm', 'cold'], enum: [...TEMPERATURE_TOOL_VALUES],
description: '窗口温度(矩阵 Y 轴)。⚠️ 必须与 potentialTreatment 同时给', description: TEMPERATURE_TOOL_DESC,
}, },
personaTags: { personaTags: {
type: 'string', type: 'string',
description: description:
'主管在调整阶段追加的画像条件("key:value" 逗号串,同维 OR、跨维 AND)。' + '主管在调整阶段追加的画像条件。' +
'先用 get_cohort_attributes 看清各口子多少人,再带着它重出确认单。', '先用 get_cohort_attributes 看清各口子多少人,再带着它重出确认单。\n' +
// ⚠️ 维度字典**必须给全**:少给一份,模型就得自己猜 key/value,
// 而猜错的维度会被 personaTagsSql **静默丢掉**(筛选等于没加),不报错。
PERSONA_TAGS_DESC,
}, },
expiresInDays: { expiresInDays: {
type: 'number', type: 'number',
...@@ -211,19 +244,31 @@ export class AssistantService { ...@@ -211,19 +244,31 @@ export class AssistantService {
return [ return [
`确认单已呈现给主管(卡片会显示在你这段话**下面**,你看不到明细,也不要复述明细)。`, `确认单已呈现给主管(卡片会显示在你这段话**下面**,你看不到明细,也不要复述明细)。`,
``, ``,
`请**先用两句话开口**,顺序不要变:`, `请把下面两段**原样输出**(它们已经排好版:每段第一行是加粗的「数字+是什么」,`,
`1)「怎么选的」:${sheet.selectionNote}`, `第二行才是解释)。⛔ 不要合并成一段、不要改写、不要重新编号:`,
`2)「怎么分的」:${sheet.basisNote}`,
``, ``,
`最后一句引导他怎么调 —— **四种都要提到**(⚠️ 主管不知道能这么用,不说他就只会点确认):`, sheet.selectionNote,
`· 改这一批:「把某某移出这批」「某某的单给 5 天」「这批 200 人」—— 我直接改;`, ``,
`· **再挑一挑人**:「只要商保直付的」「排掉最近来过的」—— 我先看看这批人里各类有多少,再重新圈;`, // ⚠️ 顺序:**已排好的在前,要他定的在后**(2026-08-06 产品定)。
`· **带个福利**:「这批带上『老客户复查免挂号费』」—— 我写进批次,生成话术时会用上;`, // 原来把"要您定"排在前面,理由是"要动手的先说";走查后改判 ——
`· 确认无误就点卡片上的『确认分配』。`, // 主管先要知道**这批本身是什么样**,再看还剩什么要他处理。
sheet.basisNote,
...(sheet.pendingNote ? ['', sheet.pendingNote] : []),
``,
``,
// 🔴 那四条「怎么调」的引导**已经搬到卡片上常驻**(2026-08-06 走查)。
// 原来要求你每出一版都念一遍 —— 第一次有用,第二次起就是噪音,
// 而主管一天要看好几版。卡片底部那行小字永远在,想用时看得见。
// ⛔ 别再自己把那四条抄进对话,⛔ 也别复述卡片上已经写着的东西。
`说完这两段就停。`,
`⛔ **不要再列"您可以这样调整"那几条** —— 卡片底部一直写着。`,
`⛔ **也不要写「确认无误请点『确认分配』」** —— 那个按钮就在这段话下面,`,
` 他看得见;每次都补一句等于把最没信息量的话放在最后(T14 的推论:界面元素也算证据,`,
` 已经有的东西不用再说一遍)。`,
``, ``,
`⚠️ 上面两句是**成品句子,原话抄**:里面的数字、"没被分过的排在前面"、"第一次的估算值"` + `⚠️ 上面两句是**成品句子,原话抄**:里面的数字、"没被分过的排在前面"、"第一次的估算值"` +
`这类措辞都不要改写、不要省。`, `这类措辞都不要改写、不要省。`,
`⛔ 不要说"已经分配好了"(一条都还没落库)。`, `⛔ 不要说"已经分配好了"(主管点确认之前一条都没真的分下去)。`,
`⛔ 不要出现「排序键 / 收敛 / 铺平 / 水位 / 基数 / 探索配额」这类词 —— 主管看不懂。`, `⛔ 不要出现「排序键 / 收敛 / 铺平 / 水位 / 基数 / 探索配额」这类词 —— 主管看不懂。`,
`⛔ 不要复述患者名单或客服分配明细 —— 卡片上有,而且你手里根本没有。`, `⛔ 不要复述患者名单或客服分配明细 —— 卡片上有,而且你手里根本没有。`,
`⚠️ 引导那几句要用**主管的话**说,别报工具名(⛔ 不要出现 get_cohort_attributes 这种词)。`, `⚠️ 引导那几句要用**主管的话**说,别报工具名(⛔ 不要出现 get_cohort_attributes 这种词)。`,
...@@ -245,12 +290,40 @@ export class AssistantService { ...@@ -245,12 +290,40 @@ export class AssistantService {
*/ */
tools.edit_assignment_sheet = tool({ tools.edit_assignment_sheet = tool({
description: description:
'直接修改**已经呈现**的那张确认单(删患者 / 移除某个客服 / 把患者转给别人 / 改时效 / 设批次福利)。' + '直接修改**已经呈现**的那张确认单。' +
'\n⚠️ 主管说「把某某移出这批」「某某的单给 5 天」时**就用这个**,' + '\n⚠️ 主管说「把某某移出这批」「某某的单给 5 天」时**就用这个**,' +
'⛔ 不要回答"做不到"、也不要让他先确认再逐条退回 —— 卡片就在他眼前。' + '⛔ 不要回答"做不到"、也不要让他先确认再逐条退回 —— 卡片就在他眼前。' +
'\n🔴 **一条指令 = 三件事拼起来:选谁(select)· 干什么(action)· 给谁(to)**。' +
'别去背短语,按这三问填:' +
'\n ① **选谁**:点名的人→`{group:"patients",patients:["王强","李莉"]}`;' +
'某位客服名下全部→`{group:"agent",agent:"张悦"}`;' +
'「待分配」那一组→`{group:"pending"}`;整批→`{group:"batch"}`。' +
'\n ② **干什么**:改派→`assign`;移出本批→`remove`;改时效→`set_expiry`(配 days);' +
'设批次福利→`set_benefit`(配 text,select 必须是 batch,空串=撤掉)。' +
'\n ③ **给谁**(只有 `assign` 要):' +
'\n · `{mode:"owner"}` = **各自回自己的专属客服**,一条专属关系都不动;' +
'\n · `{mode:"balance"}` = 谁手上少先给谁(铺平);' +
'点名了人就加 `agents:["张悦","李莉"]` 把范围限定在他们里(只点一位 = 都给他)。' +
'\n🔴🔴 **`owner` 和 `balance` 结果正好相反,选错主管一眼看得出来**(实测栽过):' +
'他说「把待分配的患者**各自分给各自的专属客服**」,助手用了铺平,' +
'18 个人被散给了 17 位**别人**。' +
'⛔ 话里出现「各自 / 自己的 / 本来的 / 专属 / 谁的客户归谁」→ `owner`;' +
'「平均分 / 谁手上少给谁 / 铺下去 / 随机分一分」→ `balance`。' +
'\n⚠️ `balance` **不是真随机**(那会让同一张单每次算出不同结果),' +
'界面回报里会说清铺给了谁几条,**照抄**;⛔ 别顺着"随机"两个字复述成"我随机分了"。' +
'⛔ 把患者从专属客服手里挪走是主管的决定:**他没明确要求就不要用**,更不要主动提议。' +
'\n⚠️ 举几个拼法(⛔ 别当成清单,没列到的组合照样按三问拼):' +
'「把王强移出这批」→`{select:{group:"patients",patients:["王强"]},action:"remove"}`;' +
'「待分配的各自给回专属客服」→`{select:{group:"pending"},action:"assign",to:{mode:"owner"}}`;' +
'「张悦名下的分给别人」→`{select:{group:"agent",agent:"张悦"},action:"assign",to:{mode:"balance"}}`;' +
'「待分配的时效给 5 天」→`{select:{group:"pending"},action:"set_expiry",days:5}`。' +
'\n⚠️ 只能做这几件局部修改。**换人群 / 改批次人数**要重出确认单,那走 propose_assignment。' + '\n⚠️ 只能做这几件局部修改。**换人群 / 改批次人数**要重出确认单,那走 propose_assignment。' +
'\n⚠️ 患者用**姓名或病历号**指代,客服用姓名;⛔ 你没有 planId,也不需要。' + '\n⚠️ 患者用**姓名或病历号**指代,客服用姓名;⛔ 你没有 planId,也不需要。' +
'\n⚠️ 改完**不要说"已完成"**:界面执行后会自己回一句结果(可能没找到人),等那句再说。', '\n⚠️ 改完**不要说"已完成"**:界面执行后会自己回一句结果(可能没找到人),等那句再说。' +
'\n⚠️ 批次**已经确认分配**之后:只有 `set_benefit` 还能改(福利只影响此后生成的话术,' +
'界面会真的写库并回报"作废了几条话术缓存 / 几条客服已经打开过");' +
'人员和时效**改不了** —— 单子已经在客服手上,那要走撤销重分。' +
'⛔ 这两种情况都以界面回的那句为准,别自己判断成没成。',
inputSchema: jsonSchema({ inputSchema: jsonSchema({
type: 'object', type: 'object',
properties: { properties: {
...@@ -260,19 +333,46 @@ export class AssistantService { ...@@ -260,19 +333,46 @@ export class AssistantService {
items: { items: {
type: 'object', type: 'object',
properties: { properties: {
select: {
type: 'object',
description: '① 选谁 —— 这条指令作用在哪些人身上',
properties: {
group: {
type: 'string',
enum: ['patients', 'agent', 'pending', 'batch'],
},
patients: {
type: 'array',
items: { type: 'string' },
description: 'group=patients 时:患者姓名或病历号,可给多个',
},
agent: { type: 'string', description: 'group=agent 时:客服姓名' },
},
required: ['group'],
},
action: { action: {
type: 'string', type: 'string',
enum: [ enum: ['assign', 'remove', 'set_expiry', 'set_benefit'],
'remove_patient', description: '② 干什么',
'remove_agent', },
'move_patient', to: {
'set_expiry', type: 'object',
'set_benefit', description:
], '③ 给谁 —— 只有 action=assign 要。owner=各自回自己的专属客服(不动任何专属关系);' +
'balance=谁手上少先给谁。',
properties: {
mode: { type: 'string', enum: ['owner', 'balance'] },
agents: {
type: 'array',
items: { type: 'string' },
description:
'mode=balance 时可选:把候选限定在这几位客服(姓名)。' +
'不给 = 本批所有在岗(含一条都没分到的 —— 他们恰恰手上最空)。' +
'⛔ mode=owner 不吃这个参数:收谁由患者自己的专属关系决定。',
},
},
required: ['mode'],
}, },
patient: { type: 'string', description: '患者姓名或病历号' },
agent: { type: 'string', description: '客服姓名' },
toAgent: { type: 'string', description: 'move_patient 的目标客服' },
days: { type: 'number', description: 'set_expiry 的天数(1-90)' }, days: { type: 'number', description: 'set_expiry 的天数(1-90)' },
text: { text: {
type: 'string', type: 'string',
...@@ -281,7 +381,7 @@ export class AssistantService { ...@@ -281,7 +381,7 @@ export class AssistantService {
'⛔ 原话写,不要替主管加条件/期限/承诺。', '⛔ 原话写,不要替主管加条件/期限/承诺。',
}, },
}, },
required: ['action'], required: ['select', 'action'],
}, },
}, },
}, },
......
...@@ -2,7 +2,11 @@ import { Injectable } from '@nestjs/common'; ...@@ -2,7 +2,11 @@ import { Injectable } from '@nestjs/common';
// clean subpath(运行时 exports map 放行);类型见 src/types/mcp-sdk.d.ts ambient 声明 // clean subpath(运行时 exports map 放行);类型见 src/types/mcp-sdk.d.ts ambient 声明
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod'; import { z } from 'zod';
import { PERSONA_TAG_FILTER_DIMS, personaTagDimId, Permission } from '@pac/types'; import {
Permission,
TEMPERATURE_TOOL_DESC,
TEMPERATURE_TOOL_VALUES,
} from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { PatientService } from '../patient/patient.service'; import { PatientService } from '../patient/patient.service';
import { PersonaService } from '../persona/persona.service'; import { PersonaService } from '../persona/persona.service';
...@@ -14,28 +18,65 @@ import { CohortAttributesService, COHORT_DIM_CATALOG } from '../plan/cohort-attr ...@@ -14,28 +18,65 @@ import { CohortAttributesService, COHORT_DIM_CATALOG } from '../plan/cohort-attr
import type { ListPlansQueryDto } from '../plan/dto/plan.dto'; import type { ListPlansQueryDto } from '../plan/dto/plan.dto';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import type { McpAuthContext } from './mcp-auth.service'; import type { McpAuthContext } from './mcp-auth.service';
import { PERSONA_TAGS_DESC, POTENTIAL_TREATMENT_DESC } from './persona-tags.desc';
function jsonResult(data: unknown) { function jsonResult(data: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
} }
/** /**
* personaTags 圈人字典(从 PERSONA_TAG_FILTER_DIMS 生成,单一真理源,自动同步)。 * 🔴 诊所 id 的**唯一**入口 —— 不传就用登录人的第一个诊所,传了就**必须在他的范围里**。
* 格式给 LLM 看:`key(中文): code=中文 / code=中文 …`,一维一行。 *
* 入参格式:"key:value" 逗号串;同维多选 OR,跨维 AND(如 "rfm:important_value,urgency_level:urgent")。 * ── 由来(2026-08-06 实测)────────────────────────────────────────
* 主管点了矩阵的「充填 · 2–3 年」(138 人)再说「我只需要男性患者」,助手回
* 「这个格子当前是空的(0 人)」,还顺带编了两条像模像样的原因。
* 真因:`get_cohort_attributes` 的 `clinicId` 是**必填且没有兜底**,模型只好自己编了一个
* `"CL001"` 传进来 —— 查询完全正确,只是 `target_clinic_id = 'CL001'` 一条都匹配不上。
*
* ⚠️ **这个 bug 最坏的地方不是查不到,是查不到看起来像个结论**:
* 0 是一个合法答案,模型拿到 0 就去解释"为什么这批人是空的",越解释越像真的。
* 主管刚在矩阵上看过 138,下一句就成了 0 —— 违 T14「口径对数」。
*
* ✅ 两道闸,缺一不可:
* ① 不传 → 用 `scope.clinicIds[0]`(与 propose_assignment 的兜底完全一致,⛔ 别两套)
* ② 传了但不在范围里 → **抛错并把真实 id 列出来**,⛔ 绝不能当成"这个诊所没人"返回 0
*
* ⚠️ `scope.clinicIds` 为空 = 集团级范围(看全部诊所),此时无从校验,原样放行 ——
* 那种 scope 下 poolBaseSql 仍会按传入 id 过滤,查不到就是真查不到。
*/ */
const PERSONA_TAGS_HELP = PERSONA_TAG_FILTER_DIMS.map((d) => function resolveClinicId(scope: TenantScopeContext, clinicId?: string): string {
// ⚠️ 用 personaTagDimId 不用 d.key:source='patient' 的维度 key 为空串、筛选串用 id, const given = clinicId?.trim();
// 用 key 会给 LLM 一个空维度名,它照着发就永远筛不出人。 if (!given) {
// 开集维度(医生)options 为空 → 提示取值是自由文本,并指向名单接口,免得 LLM 瞎猜姓名。 const fallback = scope.clinicIds[0];
d.dynamic if (!fallback) {
? `${personaTagDimId(d)}(${d.nameZh}): <医生姓名,精确匹配;名单见 GET /pac/v1/plans/doctors>` throw new Error(
: `${personaTagDimId(d)}(${d.nameZh}): ${d.options.map((o) => `${o.value}=${o.zh}`).join(' / ')}`, '没有指定诊所,当前登录人也没有绑定诊所 —— 请先用 get_current_user 确认数据范围,⛔ 不要自己编一个诊所 id。',
).join('\n'); );
}
return fallback;
}
if (scope.clinicIds.length && !scope.clinicIds.includes(given)) {
throw new Error(
`诊所 id「${given}」不在当前登录人的数据范围内(他的诊所是:${scope.clinicIds.join(' / ')})。` +
'⛔ 不要自己编诊所 id —— 用 get_current_user 返回的 clinicIds,或者干脆不传(默认取第一个)。',
);
}
return given;
}
const PERSONA_TAGS_DESC = /**
'画像圈人(可选):"key:value" 逗号串,同维多选 OR、跨维 AND。可用维度与取值:\n' + * 诊所 id 入参 —— **可选**,且明说"不知道就别传"。
PERSONA_TAGS_HELP; *
* ⚠️ 原来是 `z.string()` 必填:模型没有 id 又必须填,只好编一个(实测编出了 `"CL001"`)。
* 把它改成可选不是"放宽",而是**把猜的动机拿掉** —— 服务端本来就知道该用哪个诊所。
*/
const CLINIC_ID_SCHEMA = z
.string()
.optional()
.describe(
'诊所 id(可选)。⛔ **不知道就不要传**,更不要自己编 —— 不传默认取当前登录人的第一个诊所。' +
'要显式指定时,只能用 get_current_user 返回的 clinicIds 里的值。',
);
function maskPhone(phone: string | null): string | null { function maskPhone(phone: string | null): string | null {
if (!phone) return null; if (!phone) return null;
...@@ -243,7 +284,7 @@ export class McpServerFactory { ...@@ -243,7 +284,7 @@ export class McpServerFactory {
sort: sort ?? 'priority_desc', sort: sort ?? 'priority_desc',
page: 1, page: 1,
pageSize: limit ?? 20, pageSize: limit ?? 20,
...(clinicId ? { targetClinicId: clinicId } : {}), ...(clinicId ? { targetClinicId: resolveClinicId(scope, clinicId) } : {}),
...(personaTags ? { personaTags } : {}), ...(personaTags ? { personaTags } : {}),
...(scenario ? { scenario } : {}), ...(scenario ? { scenario } : {}),
...(status ? { status } : {}), ...(status ? { status } : {}),
...@@ -276,7 +317,7 @@ export class McpServerFactory { ...@@ -276,7 +317,7 @@ export class McpServerFactory {
sort: 'priority_desc', sort: 'priority_desc',
page: 1, page: 1,
pageSize: 1, // stats 不取明细行 pageSize: 1, // stats 不取明细行
...(clinicId ? { targetClinicId: clinicId } : {}), ...(clinicId ? { targetClinicId: resolveClinicId(scope, clinicId) } : {}),
...(personaTags ? { personaTags } : {}), ...(personaTags ? { personaTags } : {}),
...(scenario ? { scenario } : {}), ...(scenario ? { scenario } : {}),
}; };
...@@ -305,7 +346,7 @@ export class McpServerFactory { ...@@ -305,7 +346,7 @@ export class McpServerFactory {
'⛔ **不要替他做减法说"还能吃 N 个"**,他知道谁在休假,你不知道。' + '⛔ **不要替他做减法说"还能吃 N 个"**,他知道谁在休假,你不知道。' +
'要看本批每人分多少,那是 propose_assignment 出的确认单的事。', '要看本批每人分多少,那是 propose_assignment 出的确认单的事。',
inputSchema: { inputSchema: {
clinicId: z.string().describe('诊所 id(必填,名册天然是诊所维度的)'), clinicId: CLINIC_ID_SCHEMA,
months: z.number().int().min(1).max(36).optional().describe('在岗窗口月数,默认 12'), months: z.number().int().min(1).max(36).optional().describe('在岗窗口月数,默认 12'),
include: z include: z
.array(z.string()) .array(z.string())
...@@ -314,7 +355,12 @@ export class McpServerFactory { ...@@ -314,7 +355,12 @@ export class McpServerFactory {
}, },
}, },
async ({ clinicId, months, include }) => async ({ clinicId, months, include }) =>
jsonResult(await this.roster.list(scope, clinicId, { months, extraUserIds: include })), jsonResult(
await this.roster.list(scope, resolveClinicId(scope, clinicId), {
months,
extraUserIds: include,
}),
),
); );
server.registerTool( server.registerTool(
...@@ -329,15 +375,15 @@ export class McpServerFactory { ...@@ -329,15 +375,15 @@ export class McpServerFactory {
'\n⚠️ 专属客服只报有/无,**不判在岗** —— 谁休假谁离职以主管说的为准,别替他挡人。' + '\n⚠️ 专属客服只报有/无,**不判在岗** —— 谁休假谁离职以主管说的为准,别替他挡人。' +
`\n可点名的维度(keys,不传给一组常用的):${COHORT_DIM_CATALOG}`, `\n可点名的维度(keys,不传给一组常用的):${COHORT_DIM_CATALOG}`,
inputSchema: { inputSchema: {
clinicId: z.string(), clinicId: CLINIC_ID_SCHEMA,
potentialTreatment: z potentialTreatment: z
.string() .string()
.optional() .optional()
.describe('矩阵 X 轴:implant / ortho / early_ortho / endo / perio / filling / restoration / extraction'), .describe(POTENTIAL_TREATMENT_DESC),
temperature: z temperature: z
.enum(['hot', 'warm', 'cold']) .enum(TEMPERATURE_TOOL_VALUES as unknown as [string, ...string[]])
.optional() .optional()
.describe('矩阵 Y 轴:窗口温度。⚠️ 必须与 potentialTreatment 同时给'), .describe(TEMPERATURE_TOOL_DESC),
personaTags: z.string().optional().describe(PERSONA_TAGS_DESC), personaTags: z.string().optional().describe(PERSONA_TAGS_DESC),
keys: z.array(z.string()).optional().describe('只看这些维度(见描述里的维度清单)'), keys: z.array(z.string()).optional().describe('只看这些维度(见描述里的维度清单)'),
}, },
...@@ -347,7 +393,7 @@ export class McpServerFactory { ...@@ -347,7 +393,7 @@ export class McpServerFactory {
await this.cohorts.describe( await this.cohorts.describe(
scope, scope,
{ {
clinicId, clinicId: resolveClinicId(scope, clinicId),
...(potentialTreatment ? { potentialTreatment } : {}), ...(potentialTreatment ? { potentialTreatment } : {}),
...(temperature ? { temperature } : {}), ...(temperature ? { temperature } : {}),
...(personaTags ? { personaTags } : {}), ...(personaTags ? { personaTags } : {}),
......
import {
PERSONA_TAG_FILTER_DIMS,
personaTagDimId,
potentialTreatmentItemName,
} from '@pac/types';
/**
* personaTags 圈人字典(从 `PERSONA_TAG_FILTER_DIMS` 生成,单一真理源,自动同步)。
*
* 格式给 LLM 看:`key(中文): code=中文 / code=中文 …`,一维一行。
* 入参格式:"key:value" 逗号串;同维多选 OR,跨维 AND(如 "rfm:important_value,urgency_level:urgent")。
*
* ⚠️ **凡是收 personaTags 的工具都必须挂这份字典**(MCP 的 list_recall_queue /
* get_cohort_attributes,以及助手本地的 propose_assignment)——
* 少给一处,模型在那处就得自己猜 key 和 value code。
* 🔴 而猜错的维度会被 `cohort-filter.personaTagsSql` **静默丢掉**(`if (!dim) continue`):
* 筛选条件等于没加,人数一个不少地返回,**不报错也看不出来**。
* 所以这份字典不是"锦上添花的说明",是防静默失败的必需品。
*
* ⛔ 别再在某个工具里内联一份简写版 —— 那就是第二份真理源。
*/
const PERSONA_TAGS_HELP = PERSONA_TAG_FILTER_DIMS.map((d) =>
// ⚠️ 用 personaTagDimId 不用 d.key:source='patient' 的维度 key 为空串、筛选串用 id,
// 用 key 会给 LLM 一个空维度名,它照着发就永远筛不出人。
// 开集维度(医生)options 为空 → 提示取值是自由文本,并指向名单接口,免得 LLM 瞎猜姓名。
d.dynamic
? `${personaTagDimId(d)}(${d.nameZh}): <医生姓名,精确匹配;名单见 GET /pac/v1/plans/doctors>`
: `${personaTagDimId(d)}(${d.nameZh}): ${d.options.map((o) => `${o.value}=${o.zh}`).join(' / ')}`,
).join('\n');
export const PERSONA_TAGS_DESC =
'画像圈人(可选):"key:value" 逗号串,同维多选 OR、跨维 AND。' +
'⛔ **只能用下表里的 key 和 value**,写别的会被静默忽略(筛选等于没加,人数却照样返回)。' +
'可用维度与取值:\n' +
PERSONA_TAGS_HELP;
/**
* 潜在治疗(矩阵那一行)的取值说明 —— 同样从 `PERSONA_TAG_FILTER_DIMS` 生成。
*
* 🔴 与温度同一条规矩:**码给模型调工具用,中文给主管听**。
* 把 code=中文 的对照表给全,模型才有得翻;⛔ 不给对照表它就只能把 `filling` 原样念出来。
*/
const POTENTIAL_TREATMENT_DIM = PERSONA_TAG_FILTER_DIMS.find(
(d) => d.key === 'potential_treatment',
);
export const POTENTIAL_TREATMENT_DESC =
'矩阵的一行:潜在治疗(界面标题就叫「潜在治疗」)。按此表把主管的话翻成取值:' +
// ⚠️ 中文取 `potentialTreatmentItemName` —— 那是**矩阵实际渲染用的**同一个函数。
// ⛔ 别改用 dim.options 的 zh:那份曾漂成「补牙」,而界面上是「充填」。
(POTENTIAL_TREATMENT_DIM?.options ?? [])
.map((o) => `${potentialTreatmentItemName(o.value)}=${o.value}`)
.join(' · ') +
'。\n🔴 ⛔ **这些取值码只用于调工具,一个字都不许说给主管** —— 回话一律用中文' +
'(「充填」「种植」「牙周」…);⛔ 也别自己发明「病种」「科室」这类界面上没有的叫法。';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { TEMPERATURE_TOOL_DESC } from '@pac/types';
import {
PERSONA_TAGS_DESC,
POTENTIAL_TREATMENT_DESC,
} from '../src/modules/mcp/persona-tags.desc';
/**
* 「模型自己编了个诊所 id,于是整批人凭空消失」的回归(2026-08-06 实测)。
*
* ── 事故经过 ──────────────────────────────────────────────────────
* 主管点了矩阵的「充填 · 2–3 年」(138 人),接着说「我只需要男性患者」。
* 助手回:「这个格子当前是空的(0 人)」,还给了两条像模像样的原因
* (「这档人群本身就少」「画像里没有性别标记」),然后建议换档位。
*
* 真因:`get_cohort_attributes` 的 `clinicId` 是 `z.string()` **必填、无兜底**。
* 模型手里没有真 id 又必须填,于是编了 `"CL001"`。SQL 完全正确,
* 只是 `target_clinic_id = 'CL001'` 一条也匹配不上 → cohortSize=0。
*
* 🔴 **最坏的地方不是查不到,是"查不到"看起来像个结论**:
* 0 是一个合法返回值,模型拿到 0 就去解释"为什么这批人是空的",越解释越可信。
* 全程零报错,而主管刚在矩阵上看过 138。这正是 T14「口径对数」要防的形状。
*
* 这里锁三件事(都做错了不会报错):
* ① 不传 clinicId → 用登录人的第一个诊所,⛔ 不许要求模型必填
* ② 传了范围外的 id → **抛错**,⛔ 绝不能当成"这个诊所没人"返回 0
* ③ 收 personaTags 的工具必须挂全维度字典 —— 猜错的维度会被静默丢掉
*/
const FACTORY = readFileSync(
join(__dirname, '../src/modules/mcp/mcp-server.factory.ts'),
'utf8',
);
describe('MCP 诊所 id —— 不许模型自己编', () => {
test('⭐⭐ 所有收 clinicId 的工具都走 CLINIC_ID_SCHEMA(可选 + 明说别编)', () => {
// ⛔ 必填的 z.string() 会逼模型编一个出来 —— 这正是事故的成因
expect(FACTORY).not.toMatch(/clinicId:\s*z\.string\(\)(?!\s*\.optional)/);
expect(FACTORY).toMatch(/const CLINIC_ID_SCHEMA[\s\S]*?\.optional\(\)/);
});
test('⭐⭐ 每一处 clinicId 落到查询前都过 resolveClinicId(范围外要抛错,不是返回 0)', () => {
// 出现几次 clinicId 作为查询入参,就该有几次 resolveClinicId
const guarded = FACTORY.match(/resolveClinicId\(scope,/g) ?? [];
expect(guarded.length).toBeGreaterThanOrEqual(4);
// 范围外必须**抛错**并列出真实 id —— 静默返回 0 是这个 bug 的全部危害
expect(FACTORY).toMatch(/不在当前登录人的数据范围内/);
expect(FACTORY).toMatch(/scope\.clinicIds\.join/);
});
test('CLINIC_ID_SCHEMA 的说明里必须写明「不知道就不要传」', () => {
expect(FACTORY).toMatch(/不知道就不要传/);
expect(FACTORY).toMatch(/get_current_user 返回的 clinicIds/);
});
});
describe('personaTags 维度字典 —— 猜错会被静默丢掉,所以必须给全', () => {
test('⭐ 字典里有性别维度(事故当天主管要的就是它)', () => {
expect(PERSONA_TAGS_DESC).toMatch(/gender\(性别\)/);
expect(PERSONA_TAGS_DESC).toContain('male=男');
expect(PERSONA_TAGS_DESC).toContain('female=女');
});
test('⭐⭐ 必须警告「写别的会被静默忽略」—— 那是 personaTagsSql 的真实行为', () => {
// cohort-filter.personaTagsSql:`if (!dim) continue` —— 认不出的维度直接跳过,
// 筛选等于没加,人数一个不少地返回,不报错。
expect(PERSONA_TAGS_DESC).toMatch(/静默忽略/);
});
test('⛔ 工具定义里不许再内联第二份简写字典', () => {
// 只允许从 persona-tags.desc 引入;工厂里不该再有一份 PERSONA_TAG_FILTER_DIMS.map
expect(FACTORY).not.toMatch(/PERSONA_TAG_FILTER_DIMS\.map/);
expect(FACTORY).toMatch(/from '\.\/persona-tags\.desc'/);
});
});
/**
* 「助手对主管念代码」的回归(2026-08-06 产品走查)。
*
* 实测原话:
* ·「「2–3 年」对应的是 `cold_3y` 档,该档目前整体为空。」
* ·「放宽温度:改成「充填 · 窗口外全体」( `cold` )或「充填 · 1–2 年」( `cold_2y` )」
* ·「换病种:比如「种植 · 2–3 年」…」
*
* 主管是门诊经理:界面上**没有** `cold_3y`,**没有**「温度」这个词,那一行也不叫「病种」。
* 他看到不认识的英文词,第一反应是系统坏了 —— 要么不信这个数,要么回头来问,
* 两种都是白多出来的一轮。
*
* ⚠️ 修法是**两件事一起做**,少一件都不行:
* ① 参数说明里必须给全 code↔中文 的对照表 —— 不给,模型没得翻,只能原样念码;
* ② 必须有一条硬约束写明"码只用于调工具,不许说出口" —— 只给表不给约束,它照念不误。
*/
describe('助手措辞 —— 码给工具,中文给人', () => {
const PROMPTS = readFileSync(
join(__dirname, '../src/modules/assistant/assistant-prompts.ts'),
'utf8',
);
test('⭐⭐ 温度说明:既有中文对照表,又明令不许把码说出口', () => {
expect(TEMPERATURE_TOOL_DESC).toContain('2–3 年=cold_3y'); // ① 有得翻
expect(TEMPERATURE_TOOL_DESC).toMatch(/不许说给主管/); // ② 有约束
expect(TEMPERATURE_TOOL_DESC).toMatch(/不要说「温度」这个词/);
});
test('⭐⭐ 潜在治疗说明:同样两件事都要有', () => {
expect(POTENTIAL_TREATMENT_DESC).toContain('充填=filling');
expect(POTENTIAL_TREATMENT_DESC).toMatch(/不许说给主管/);
// ⛔ 「病种」是模型自己发明的叫法,界面上叫「潜在治疗」
expect(POTENTIAL_TREATMENT_DESC).toMatch(/病种/);
});
test('⭐⭐ 主管工作流里有一条独立的「说人话」硬约束,且点名了实际漏出来的那些词', () => {
expect(PROMPTS).toMatch(/说人话/);
for (const leaked of ['cold_3y', 'personaTags', 'get_cohort_attributes', '温度']) {
expect(PROMPTS).toContain(leaked); // 都被明确列进了禁说清单
}
// 光禁止不够,得给出替代说法(否则模型只会绕着说,一样难懂)
expect(PROMPTS).toMatch(/往后放一档/);
});
test('客服那一侧也要有同样的约束(取值码不分角色地漏)', () => {
const staff = PROMPTS.slice(PROMPTS.indexOf('const STAFF_EXTRA'));
expect(staff).toMatch(/说人话/);
});
});
/**
* 「同一个治疗项,助手叫它 A、界面叫它 B」的回归(2026-08-06 走查时顺带发现)。
*
* `POTENTIAL_TREATMENT_CARD_LABEL`(labels.ts)是**展示措辞的真理源**,
* 业务 2026-07-29 定过:filling 是「充填」不是「补牙」。矩阵按它渲染。
* 而 `PERSONA_TAG_FILTER_DIMS` 里另存了一份 zh,漏改成了「补牙」——
* 于是助手嘴里是「补牙 · 2–3 年」,主管界面上是「充填 · 2–3 年」,像两件事。
*
* ⚠️ 两份中文并存是历史结构(筛选面板要 hint,卡片只要名字),短期内不合并;
* 这条测试就是它们的**唯一**同步保证 —— 改任一处措辞,这里会红。
*/
describe('潜在治疗的中文 —— 两份表必须一致', () => {
const { PERSONA_TAG_FILTER_DIMS, potentialTreatmentItemName } =
jest.requireActual<typeof import('@pac/types')>('@pac/types');
test('⭐⭐ 筛选维度的 zh == 矩阵/卡片实际渲染的中文', () => {
const dim = PERSONA_TAG_FILTER_DIMS.find((d) => d.key === 'potential_treatment')!;
for (const o of dim.options) {
expect([o.value, o.zh]).toEqual([o.value, potentialTreatmentItemName(o.value)]);
}
});
test('业务钉死的那一条:filling = 充填,⛔ 不是补牙', () => {
expect(potentialTreatmentItemName('filling')).toBe('充填');
expect(POTENTIAL_TREATMENT_DESC).toContain('充填=filling');
expect(POTENTIAL_TREATMENT_DESC).not.toContain('补牙');
});
});
/**
* 「确认完没配福利 → 提醒补挂」的约束(2026-08-06 产品加)。
*
* ⭐ 为什么值得单独锁:这是一条**主动推销**性质的提示,而本系统
* **不统计成功**(硬约束第 4 条)。一旦让模型自由发挥,它极容易顺嘴说出
* 「带福利成功率更高」「转化率能提升」—— 那个数据根本不存在,是凭空造事实(违 T14)。
* 所以提示词里必须**同时**有:① 提醒的动作 ② 只提一次 ③ 禁止编效果。
*
* ⚠️ 界面那一半(assignment-confirm-sheet 在确认时拼进 modelSummary)与提示词这一半
* 是「双保险」的两条腿,少一条这个功能就要么不触发、要么乱说。
*/
describe('确认后没配福利 → 提醒补挂,但不许编效果', () => {
const PROMPTS = readFileSync(
join(__dirname, '../src/modules/assistant/assistant-prompts.ts'),
'utf8',
);
const SHEET = readFileSync(
join(
__dirname,
'../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx',
),
'utf8',
);
test('⭐ 界面侧:只在 benefit 为空时才提醒', () => {
expect(SHEET).toMatch(/benefit\.trim\(\)\s*\n?\s*\?\s*''/);
expect(SHEET).toMatch(/这批还没带福利/);
});
test('⭐⭐ 提醒必须与摘要之间空一行 —— 单个 \\n 会被 Markdown 折叠成一行', () => {
// 实测:折叠后渲染成「…3 天有效 这批还没带福利 ——」,主管只当是上一句的尾巴
expect(SHEET).toMatch(/'\\n\\n这批还没带福利/);
});
test('⭐⭐ 确认这一句要自成一条助手消息,⛔ 不能贴在上一条尾巴上', () => {
const CHAT = readFileSync(
join(__dirname, '../../pac-web/src/components/assistant/assistant-chat.tsx'),
'utf8',
);
expect(CHAT).toMatch(/appendAssistantNote\(summary, modelSummary, \{ asNewMessage: true \}\)/);
});
test('🔴🔴 提醒必须进**可见文案**,⛔ 不能只进模型那份', () => {
// appendAssistantNote 只往消息流塞文字,**不会触发模型新一轮发言** ——
// 只写进 modelSummary 的话,主管永远看不到这句(实测过:确认完对话里一个字都没多)。
expect(SHEET).toMatch(/summary \+ benefitNudge/);
expect(SHEET).toMatch(/不会触发模型新一轮发言/);
});
test('⭐ 界面已经说过的话,⛔ 不许让模型再复述一遍', () => {
expect(SHEET).toMatch(/界面已经对主管说过了/);
});
test('⭐ 确认摘要里用中文项目名,⛔ 不许出现 endo/filling 这种码', () => {
// 实测漏过:确认后界面显示「已确认分配:endo · 3 条」
expect(SHEET).toMatch(/potentialTreatmentItemName\(sheet\.potentialTreatment\)/);
});
test('⭐⭐ 两侧都必须写明「⛔ 不许说成功率/转化率更高」', () => {
for (const src of [PROMPTS, SHEET]) {
expect(src).toMatch(/成功率/); // 出现在禁令里
expect(src).toMatch(/不统计成功|不许说/);
}
});
test('⭐ 只提一次 —— 每轮都念会变成骚扰', () => {
// 界面侧天然只说一次(确认那一刻拼一次);模型侧要被明确拦住,否则它会跟着复述
expect(PROMPTS).toMatch(/只提一次/);
expect(SHEET).toMatch(/不要再重复一遍/);
expect(SHEET).toMatch(/说不用就别再提/);
});
test('提示词里要说清"确认后仍可补挂、但人员时效改不了"', () => {
expect(PROMPTS).toMatch(/确认之后福利仍然可以补挂/);
expect(PROMPTS).toMatch(/撤销重分/);
});
});
/**
* 「三行破折号夹一行真话」的回归(2026-08-06 产品走查)。
*
* `plan_summaries.content` 里还有大量**老行是纯文本一句话**。服务端兼容时把整句落到
* `problem`、其余三段填「—」(parseBriefContent)。实测该接口对老行返回:
* { who: "—", history: "—", problem: "重要价值客16;47龋齿…", hook: "—" }
* 前端照直渲染四行,主管看到的就是三行「—」夹一行真话 —— 像加载失败。
*
* ⚠️ 修法是**前端不渲染占位符**,⛔ 不是"把老行丢掉重跑 LLM"(那会让每个存量 plan
* 打开时白烧一次调用),也⛔ 不是"渲染成空行"(空行一样占位)。
*/
describe('四点简报 —— 占位符那几行不渲染', () => {
const DETAIL = readFileSync(
join(__dirname, '../../pac-web/src/components/plan-detail/plan-detail-app.tsx'),
'utf8',
);
test('⭐⭐ 渲染前必须过滤占位符', () => {
expect(DETAIL).toMatch(/BRIEF_PLACEHOLDERS\.has/);
expect(DETAIL).toMatch(/\.filter\(\(t\) => !BRIEF_PLACEHOLDERS/);
});
test('⭐ 占位符集合要覆盖服务端兼容老行时填的那个值(「—」)', () => {
const set = /const BRIEF_PLACEHOLDERS = new Set\(\[([^\]]*)\]\)/.exec(DETAIL)?.[1] ?? '';
for (const p of ['—', '-', '']) expect(set).toContain(`'${p}'`);
});
});
/**
* 「点了这个会怎么样」那句提示的回归(2026-08-06 走查)。
*
* 原来只按 `drivesStatus` 查一张三项的表,于是「秒挂」显示
* 「本次任务保留,等下次跟进」—— 而它刚被配上 30 天冷静期,**下次跟进是一个月以后**。
* 客服看到"等下次跟进"会以为过两天还能再打,实际这单一个月不会再出现。
*
* ⚠️ 抑制期是**用户能感知的后果**,不说就是骗人(T14)。同理「再考虑」是
* abandoned + 7 天,说成"将关闭"会让客服以为这人废了,白白再手动跟一遍。
*/
describe('通话结果提示 —— 必须说出抑制期', () => {
const FORM = readFileSync(
join(__dirname, '../../pac-web/src/components/plan-detail/outcome-form.tsx'),
'utf8',
);
test('⭐⭐ 提示函数要同时吃 drivesStatus 和 suppressDays', () => {
expect(FORM).toMatch(/function stateHint\(drives: string, days: number \| null, scheduled: boolean\)/);
// ⛔ 不许退回只查 drivesStatus 的老写法
expect(FORM).not.toMatch(/const STATE_HINTS/);
});
test('⭐ keep + 有抑制期 → 明说多少天内不再出现', () => {
expect(FORM).toMatch(/天内不再出现/);
});
test('⭐ abandoned + 有限抑制期 → 说明它会自己回来,⛔ 别说成彻底关闭', () => {
expect(FORM).toMatch(/天后若仍有需要会重新出现/);
expect(FORM).toMatch(/SUPPRESS_PERMANENT_DAYS/); // 永久档要单独分岔
});
test('⭐ 约定下次回访 → 抑制点是那个日期,不是天数', () => {
expect(FORM).toMatch(/到您约定的日期再跟进/);
});
});
/**
* 「待分配」批量处置的约束(2026-08-06 产品走查)。
*
* ── 事故经过 ──────────────────────────────────────────────────────
* 取消自动改派后,待分配一次能有 55 人。主管说「把待分配的人分给客服,尽量齐平」,
* 助手回:「我没有把患者从专属客服手里挪给别人的权限 —— 这属于关系层面的决定,
* 只有您能拍板」,然后建议他**逐条拖 55 次**。
*
* 🔴 那句话本身没错,但用错了地方:**决定已经是主管做的了**,助手拒绝的是「执行」。
* 边界应该是「决定他做,执行你做」—— 拒绝执行不是保护,是甩锅。
*
* ⚠️ 另一半同样重要:主管嘴上说"随机",要的是**齐平**。⛔ 不能真随机 ——
* 同一张确认单每次算出不同结果,会毁掉"同样的输入两次算出同样的分法"这条
* 他敢按确认键的前提(assignment-proposal 里 pickEvenly / lowest 都为此不用随机数)。
*/
describe('待分配的批量处置 —— 决定他做,执行你做', () => {
const SHEET = readFileSync(
join(__dirname, '../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx'),
'utf8',
);
const PROMPTS = readFileSync(
join(__dirname, '../src/modules/assistant/assistant-prompts.ts'),
'utf8',
);
const SVC = readFileSync(
join(__dirname, '../src/modules/assistant/assistant.service.ts'),
'utf8',
);
test('⭐⭐ 契约表达得了「整组一次性处置」—— 否则助手只能逐条改派', () => {
const { SheetEditOpSchema } = jest.requireActual<typeof import('@pac/types')>('@pac/types');
const ok = (o: unknown) => SheetEditOpSchema.safeParse(o).success;
expect(ok({ select: { group: 'pending' }, action: 'assign', to: { mode: 'balance' } })).toBe(true);
expect(
ok({
select: { group: 'pending' },
action: 'assign',
to: { mode: 'balance', agents: ['张悦'] },
}),
).toBe(true);
expect(ok({ select: { group: 'pending' }, action: 'remove' })).toBe(true);
});
test('⭐ 工具的 enum 里要有三个轴,否则模型根本发不出来', () => {
expect(SVC).toMatch(/enum: \['patients', 'agent', 'pending', 'batch'\]/);
expect(SVC).toMatch(/enum: \['assign', 'remove', 'set_expiry', 'set_benefit'\]/);
expect(SVC).toMatch(/enum: \['owner', 'balance'\]/);
});
test('⭐⭐ 界面按水位法铺,⛔ 不许用随机数', () => {
expect(SHEET).toMatch(/谁手上少先给谁/);
// ⛔ 确认单的任何一处都不许出现随机 —— 它会毁掉可重现性
expect(SHEET).not.toMatch(/Math\.random/);
});
test('⭐⭐ 候选客服必须含「一条都没分到的」—— 他们恰恰最空', () => {
expect(SHEET).toMatch(/sheet\.skippedAgents\.map/);
});
test('⭐⭐ 提示词要点名禁止「我不能替您做这个决定」这种甩锅', () => {
expect(PROMPTS).toMatch(/决定他做,执行你做/);
expect(PROMPTS).toMatch(/甩锅/);
// 同时仍然保留"不许自作主张"那一半 —— 两半缺一不可
expect(PROMPTS).toMatch(/不许自作主张/);
});
test('⭐ 不许把"铺平"说成"随机分了"', () => {
expect(PROMPTS).toMatch(/不是真随机/);
expect(SVC).toMatch(/别顺着"随机"两个字/);
});
});
/**
* 「卡片上看得见的人,指令必须能指到」的回归(2026-08-06 实测)。
*
* 主管对着卡片说「把常瀚越分配给刘艳阳」,助手回:
* 「确认单里没有『常瀚越』,跳过」
* —— 而常瀚越的名字**就印在卡片的「待分配」那一组里**。
*
* 真因:`findPatients` 只搜 `items`(已落人的),没搜 `pending`。
* 🔴 取消自动改派之后,待分配可能占一批人的大半 —— 只搜一半等于**对话这条路对他们失效**,
* 主管只能一个个手动拖。
*/
describe('待分配的人 —— 对话指令要能指到', () => {
const SHEET = readFileSync(
join(__dirname, '../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx'),
'utf8',
);
test('⭐⭐ findPatients 必须同时搜「已落人」和「待分配」', () => {
// 两半都要搜:已落人的 + 还在待分配的(后者按草稿算,见"同批次多条指令"那组)
expect(SHEET).toMatch(/items\.filter\(\(i\) => !dropDraft\.has\(i\.planId\)\)\.filter\(hit\)/);
expect(SHEET).toMatch(/pendingRest\(\)\.filter\(hit\)/);
});
test('⭐ 给待分配的人改时效要说明"分给客服后才生效"(否则主管以为已进批次)', () => {
expect(SHEET).toMatch(/分给客服后这个时效才生效/);
});
test('⭐⭐ findAgent 也要能指到「本批未分到」的客服 —— 他们恰恰手上最空', () => {
// 实测:「把陈浡龙分配给刘艳阳」→「没找到客服『刘艳阳』」,
// 而刘艳阳的名字就印在卡片下面那行「本批未分到」里。
expect(SHEET).toMatch(/sheet\.skippedAgents\.find/);
});
test('⭐ 对没分到条目的客服执行「移除」→ 说清"本来就没有",⛔ 不报"已移除 0 条"', () => {
expect(SHEET).toMatch(/本批一条都没分到,没什么可动的/);
});
test('🔴🔴 拖给「未分到」的客服后,组标题不许退化成 id + 在手 0', () => {
// 实测显示成「#678 1 条(在手 0 → 1)」——而王锐实际在手 56。
// 真因:nameOf / inHandOf 只查了 byAgent(提案时就有条目的人)。
expect(SHEET).toMatch(/sheet\.skippedAgents\.map\(\(a\) => \[a\.userId, a\.name\]/);
expect(SHEET).toMatch(/sheet\.skippedAgents\.map\(\(a\) => \[a\.userId, a\.inHand\]/);
});
test('⭐ 已经被分到条目的客服要从「本批未分到」里剔掉(否则一人两处)', () => {
expect(SHEET).toMatch(/const idleAgents = useMemo/);
});
test('⭐ 「待分配」可收拢,但**默认展开**(默认藏起来就回到"悄悄没了")', () => {
expect(SHEET).toMatch(/useState\(true\);?\s*$/m); // pendingOpen 初值 true
expect(SHEET).toMatch(/setPendingOpen/);
expect(SHEET).toMatch(/\{pendingOpen && \(/);
});
test('⛔ 客服姓名的复制按钮不能嵌在展开按钮里(button 套 button 是非法 HTML)', () => {
// 展开的点击区被拆成"箭头"和"条数"两块,姓名夹在中间只管复制
expect(SHEET).toMatch(/aria-label=\{isOpen \? '收起' : '展开'\}/);
});
test('⭐ 姓名可点击复制,且⛔ 不能吃掉整行的拖拽', () => {
expect(SHEET).toMatch(/function CopyableName/);
expect(SHEET).toMatch(/draggable=\{false\}/); // 不加会被当成拖文本
expect(SHEET).toMatch(/e\.stopPropagation\(\)/);
// 两处行都要用上:客服组里的 + 待分配里的
// 三处都要用上:客服组标题 + 客服组里的患者 + 待分配里的患者
expect((SHEET.match(/<CopyableName /g) ?? []).length).toBeGreaterThanOrEqual(3);
});
});
/**
* 「同一批指令里,后一条覆盖了前一条」的回归(2026-08-06 实测)。
*
* 主管一句话给了两条指令:
* 「李汝明分配给李闻,**剩下的**都分配给高瑞珍」
* `move_patient(李汝明→李闻)` 先执行,但紧接着的 `assign_pending` 读的还是**旧的**
* `moveByPlan`(setState 在同一次 effect 里读不到刚写的值)——
* 于是李汝明仍被当成"待分配",连同其余 31 人一起铺给了高瑞珍。
*
* 🔴 最坏的地方:**两条都回报了"已执行"**。主管看到「李汝明 → 李闻」的回执,
* 而卡片上李闻名下根本没有他 —— 界面说做了、实际被后一条冲掉了(违 T14)。
*
* ⚠️ 这类 bug 只在**一句话里给多条指令**时出现,单条指令永远测不出来。
*/
describe('同批次多条指令 —— 后一条要看得见前一条', () => {
const SHEET = readFileSync(
join(__dirname, '../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx'),
'utf8',
);
test('⭐⭐ 循环内读写**草稿**,⛔ 不许直接读 state', () => {
expect(SHEET).toMatch(/const moveDraft: Record<string, string> = \{ \.\.\.moveByPlan \}/);
expect(SHEET).toMatch(/const dropDraft = new Set\(dropped\)/);
// 待分配的"还剩谁"必须按草稿算,否则刚被改派的人还会被当成待分配
expect(SHEET).toMatch(/const pendingRest = \(\) =>/);
expect(SHEET).toMatch(/!dropDraft\.has\(p\.planId\) && moveDraft\[p\.planId\] == null/);
});
test('⭐⭐ 草稿在循环结束后**一次性**提交,且用函数式合并', () => {
expect(SHEET).toMatch(/setMoveByPlan\(\(m\) => \(\{ \.\.\.m, \.\.\.moveDraft \}\)\)/);
expect(SHEET).toMatch(/setDropped\(\(sset\) => new Set\(\[\.\.\.sset, \.\.\.dropDraft\]\)\)/);
});
test('⭐ 铺平时的水位要按草稿算经办人(同批次刚改派的人不能算在旧客服头上)', () => {
expect(SHEET).toMatch(/const who = moveDraft\[it\.planId\] \?\? it\.assignee/);
});
});
/**
* 确认单话术的**可读性**约束(2026-08-06 走查)。
*
* 原来助手每出一版都要念三大段:选人口径 + 待分配解释 + 四条操作指引。
* 主管的原话是「密密麻麻不一定看得懂」。拆出来的具体毛病:
* ① **数字打架** —— 「这批挑了 50 人」紧跟着「10 人分给 6 位客服」,
* 主管第一反应是"到底分了几个"(真相:50 = 已排 10 + 待定 40,但没人说)。
* ② **括号套括号** —— 「…(最多的是康慧捧)(他们原本还有 951 条在手)」。
* ③ **每轮重复四条操作指引** —— 第一次有用,一天看好几版就全是噪音。
* ④ **内部词** —— 「落库」「排队」。
*/
describe('确认单话术 —— 让主管一眼看懂', () => {
const SVC_PROPOSAL = readFileSync(
join(__dirname, '../src/modules/plan/assignment-proposal.service.ts'),
'utf8',
);
const SVC_ASSIST = 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',
);
test('⭐⭐ 有待分配时必须给出**等式**,否则两个数字看着打架', () => {
// 等式改成了"分段加粗起头"的形式:待分配自成一段,且标题里就带数字
expect(SVC_PROPOSAL).toMatch(/\*\*⚠️ 其中 \$\{pending\.length\} 人要您定\*\*/);
expect(SVC_PROPOSAL).toMatch(/\*\*已排好 \$\{x\.placed\} 人 · \$\{x\.loads\.length\} 位客服\*\*/);
});
test('⛔ 不许再出现括号套括号的负载描述', () => {
expect(SVC_PROPOSAL).not.toMatch(/最多的是\$\{top\?\.name\}\)`\)/);
expect(SVC_PROPOSAL).not.toMatch(/他们原本还有 \$\{x\.inHandTotal\} 条在手/);
});
test('⛔ 内部词清理:落库 / 排队', () => {
expect(SVC_PROPOSAL).toMatch(/还没轮到/);
expect(SVC_ASSIST).not.toMatch(/一条都还没落库/);
});
test('⭐⭐ 四条操作指引搬到卡片常驻,⛔ 不再要求助手每轮复述', () => {
expect(SVC_ASSIST).toMatch(/不要再列"您可以这样调整"那几条/);
expect(SHEET).toMatch(/可以直接跟我说:「把某某移出这批」/);
// ⚠️ 只在还能改的时候显示 —— 已确认/已撤销时它是误导
expect(SHEET).toMatch(/\{!readOnly && \(\s*<div className="border-t border-slate-100 bg-slate-50/);
});
test('⭐ 待分配的行要显示医生 —— 与已分配的行字段一致', () => {
// 两组显示的字段不一样,主管会以为待分配是"另一种东西"
expect((SHEET.match(/主治 \{/g) ?? []).length).toBeGreaterThanOrEqual(2);
});
});
/**
* 「重新排一版(尽量排满)」的约束(2026-08-06 产品定,改掉了当天早些时候的"顶替候选")。
*
* ── 第一版为什么不行 ────────────────────────────────────────────
* 最初的做法是服务端预先算一份「可顶替名单」下发。实测当场露馅:
* 池子里有 **17 位无主**患者,而取数窗口只有 `1.5×N + 20 = 95` 行,
* 排在 95 名之外的 14 位**根本没被取回** —— 界面只敢说「用 3 位顶替」,
* 而主管刚在同一段话里读到"17 人无主"。**候选来源被取数窗口限死了,不是池子。**
*
* ⇒ 改成:服务端只回答**能不能**(canRefill),主管点一下 → **重新出一版**:
* 取数窗口放大到 `4×N + 100`,落人时遇到专属排满的**直接跳过**继续往后取。
*
* 🔴 底线没变:**不动任何人的专属关系**。⛔ 一条都不许从专属客服手里挪走。
* ⚠️ 代价是这批人整体排名往后走 —— 产品判定可以接受:
* 「排名不重要,重要的是这批人共享同一组特征」。
*
* ── 第二版又炸了一次(2026-08-06 当天晚些时候,实测)────────────────
* 「尽量排满」下 `chosen` 变成**整个取回窗口**,而水位是拿 `chosen.length` 算的 ——
* 水位于是被窗口撑大(实测 62 → 69),那批"专属排满"的人原地进了**同一位客服**手里。
* 底线没破(进的是本人的客服),但主管看到的是「19 位无主怎么顶掉了 34 位」:**数对不上**。
* ── 第三版(定稿):**只换"挑谁",落人一个字不改** ─────────────────
* 产品原话:「为什么要纠结排名,50 个里 19 位无主 31 位有主重新定确认单不行吗」。
* ⇒ 把池子里**无主的全换进来**,其余按优先级用有专属的补满 N,然后走**完全一样**的三趟。
* 排不进的照样进待分配,卡片形状一个字不变 —— 实测 拟分 16·待分配 34 → 拟分 31·待分配 19。
* ⛔ 别再说「顶替 / 凑满」:发生的只是"这批人换过了",没有任何人被顶掉。
*
* 🔴 两条不许回头的路:
* ⛔ 落人不许再有 `stopAt` / 跳过 / 超水位补发这类分支(那是第二版的病根);
* ⛔ `chosen` 不许再是取数窗口 —— 它必须恒等于"这一批要分的 N 个人"。
*/
/**
* 🔴🔴 **确认单指令 = 选谁(select)× 干什么(action)× 给谁(to)。**
*
* ── 为什么推翻原来那串字面量(2026-08-06 产品判定)─────────────────
* 原设计是 8 条并列的 action:`remove_patient` / `remove_agent` / `move_patient` /
* `assign_pending` / `assign_pending_to_owner` / `drop_pending` / `set_expiry` / `set_benefit`。
* 摊开看它们根本不是 8 件事,是**三个轴被压成了一维** —— 每加一种"选谁"或"给谁",
* 条数就要乘一遍,而没被乘出来的格子,主管说到时模型只能掉进最像的那一格。
*
* 当天实测栽的那次:主管说「把待分配的患者**各自分给各自的专属客服**」,
* 而「待分配 × 改派 × 各自的专属」这一格当时是空的 —— 只有铺平可用,
* 18 个人被散给了 17 位**别人**。产品原话:「测出一条加一条?应该给助手通用的工具」。
*
* ⚠️ **正交化不消灭"枚举漏值"**,少写一个 mode 照样会栽。它买到的是:
* ① 加维度不再乘一遍 ② 缺的组合是**表格里的空格**(看得见) ③ 覆盖度能按矩阵测。
* ⚠️ 有一条边界拿不掉:指令由**界面**拿自己那份确认单执行(模型没有 planId,故意的),
* 所以做不到"给它通用能力让它自己写" —— 只能把词汇表做得足够表达。
*
* 🔴 下面这张矩阵就是"覆盖度"本身:**空格在这里是看得见的**。
*/
describe('确认单指令 —— 三个正交的轴', () => {
const { SheetEditOpSchema } = jest.requireActual<typeof import('@pac/types')>('@pac/types');
const ok = (o: unknown) => SheetEditOpSchema.safeParse(o).success;
const SVC = 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',
);
/**
* 主管**说得出口**的话 → 该拼成什么。
* ⭐ 加一行比加一条指令便宜得多 —— 这正是正交化要换来的东西。
*/
const SAYS: [string, unknown][] = [
['把王强移出这批', { select: { group: 'patients', patients: ['王强'] }, action: 'remove' }],
[
'把王强和李莉都给张悦',
{
select: { group: 'patients', patients: ['王强', '李莉'] },
action: 'assign',
to: { mode: 'balance', agents: ['张悦'] },
},
],
['把张悦名下的都移出去', { select: { group: 'agent', agent: '张悦' }, action: 'remove' }],
[
'张悦名下的分给别人',
{ select: { group: 'agent', agent: '张悦' }, action: 'assign', to: { mode: 'balance' } },
],
[
'🔴 待分配的各自给回自己的专属客服(实测栽过的那句)',
{ select: { group: 'pending' }, action: 'assign', to: { mode: 'owner' } },
],
[
'待分配的平均分下去',
{ select: { group: 'pending' }, action: 'assign', to: { mode: 'balance' } },
],
[
'待分配的分给张悦和李莉',
{
select: { group: 'pending' },
action: 'assign',
to: { mode: 'balance', agents: ['张悦', '李莉'] },
},
],
['待分配的先都不管', { select: { group: 'pending' }, action: 'remove' }],
// ⭐ 下面三条在旧设计里**一条都表达不了** —— 正交化白捡的
['待分配的时效给 5 天', { select: { group: 'pending' }, action: 'set_expiry', days: 5 }],
[
'张悦名下的时效给 5 天',
{ select: { group: 'agent', agent: '张悦' }, action: 'set_expiry', days: 5 }
],
['王强的单给 5 天', { select: { group: 'patients', patients: ['王强'] }, action: 'set_expiry', days: 5 }],
['整批给 7 天', { select: { group: 'batch' }, action: 'set_expiry', days: 7 }],
['这批带上老客户复查免挂号费', { select: { group: 'batch' }, action: 'set_benefit', text: '老客户复查免挂号费' }],
['撤掉福利', { select: { group: 'batch' }, action: 'set_benefit', text: '' }],
];
test.each(SAYS)('⭐ 主管说「%s」→ 契约里拼得出来', (_say, op) => {
expect(ok(op)).toBe(true);
});
test('🔴🔴 owner 与 balance 是**两个不同的值**,不是一个默认行为', () => {
// 这就是当天那次事故的正题:少了 owner 这一格,模型只能用 balance 干反面的事
expect(ok({ select: { group: 'pending' }, action: 'assign', to: { mode: 'owner' } })).toBe(true);
expect(ok({ select: { group: 'pending' }, action: 'assign', to: { mode: 'balance' } })).toBe(true);
// ⛔ owner 不吃 agents:收谁由患者自己的专属关系决定,轮不到点名
expect(SVC).toMatch(/mode=owner 不吃这个参数/);
});
/**
* 🔴 **缺参数在 schema 就拦掉**,⛔ 别留给界面运行时兜 ——
* 界面兜的话只能回一句"没执行",而模型不知道自己少填了什么,下一轮还会再错一次。
*/
test.each([
['assign 没给 to', { select: { group: 'pending' }, action: 'assign' }],
['set_expiry 没给 days', { select: { group: 'batch' }, action: 'set_expiry' }],
['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' } }],
['patients 一个都没给', { select: { group: 'patients', patients: [] }, action: 'remove' }],
])('⛔ %s → schema 直接拒掉', (_why, op) => {
expect(ok(op)).toBe(false);
});
test('🔴🔴 提示词教的是**三问怎么拼**,⛔ 不是一串要背的短语', () => {
expect(SVC).toMatch(/选谁\(select\)· 干什么\(action\)· 给谁\(to\)/);
expect(SVC).toMatch(/别去背短语,按这三问填/);
// 举例可以有,但必须写明"别当成清单" —— 否则模型只会照抄例子,没列到的组合又不敢拼
expect(SVC).toMatch(/⛔ 别当成清单/);
// 把实测栽的那次写成反例:抽象规则模型容易绕过,具体反例不会
expect(SVC).toMatch(/散给了 17 位\*\*别人\*\*/);
expect(SVC).toMatch(/话里出现「各自 \/ 自己的 \/ 本来的 \/ 专属 \/ 谁的客户归谁」/);
});
test('🔴🔴 界面侧 owner:收的人必须是患者**自己的** ownerUserId', () => {
expect(SHEET).toMatch(/moveDraft\[id\] = owner\.id/);
// ⛔ 专属客服不在名册的分不下去,如实报数,别顺手改派给别人
expect(SHEET).toMatch(/查不到在岗的专属客服,没动他们/);
});
test('⭐ 两种铺法的回话措辞必须能一眼分开', () => {
expect(SHEET).toMatch(/各自还给自己的专属客服/);
expect(SHEET).toMatch(/谁手上少先给谁\*\*铺给/);
});
test('🔴 铺平算水位时要**排除本次要改派的这批** —— 否则他们被算了两遍', () => {
expect(SHEET).toMatch(/const moving = new Set\(sel\.planIds\)/);
expect(SHEET).toMatch(/moving\.has\(it\.planId\)/);
});
});
describe('重新排一版 —— 只换挑谁,不动落人', () => {
const SVC = readFileSync(
join(__dirname, '../src/modules/plan/assignment-proposal.service.ts'),
'utf8',
);
const SHEET = readFileSync(
join(__dirname, '../../pac-web/src/components/assistant/assignment-confirm-sheet.tsx'),
'utf8',
);
test('🔴🔴 尽量排满时取数窗口必须放大 —— 这正是"17 位无主只顶了 3 位"的病根', () => {
expect(SVC).toMatch(/input\.preferPlaceable\s*\n?\s*\?\s*Math\.ceil\(batchSize \* 4\) \+ 100/);
});
test('🔴🔴 落人**只有一套**:重排不许在这里开分支', () => {
// 第二版的病根:`stopAt` 让落人跳过排满的,水位跟着窗口一起飘。
// ⚠️ 只禁**代码**里的用法 —— 上面那段"为什么栽过"的注释要留着,它是这条约束的理由
expect(SVC).not.toMatch(/stopAt(\?:|\s*!=|\s*=[^=]|\s*\?\?)/);
expect(SVC).not.toMatch(/overWaterline/);
// 底线:没有"改派"这条路 —— 源码里只剩注释提到那个已废弃的标记
expect(SVC).not.toMatch(/AssignStrategy\.SPREAD_OVERFLOW/);
});
test('🔴🔴 重排只换"挑谁" —— 无主的全换进来,有专属的**按客服轮着取**', () => {
expect(SVC).toMatch(/repickPlaceable\(ranked, dedicated,/);
// ⛔ 直取前 N 名会把名额全给专属大户(111/165 属同一人),另两位客服的余量白白空着
expect(SVC).toMatch(/for \(const c of free\.slice\(0, target\)\) picked\.add/);
});
test('⭐ 水位只由「本批 N」决定 —— chosen 恒等于这一批要分的人', () => {
expect(SVC).toMatch(/Math\.ceil\(\(inHandSum \+ chosen\.length\) \/ agents\.length\)/);
// 第二版把整个窗口丢了进来,62 当场变 69
expect(SVC).not.toMatch(/ranked\.map\(\(c, i\) => \(\{/);
});
test('🔴🔴 重排换了什么必须由**卡片自己**说 —— 助手不会重新说一遍', () => {
// 重排走 HTTP 只换卡片,上面那段旧话还在("34 人要您定"),不交代就是两份打架的口径
expect(SVC).toMatch(/refillNote:/);
expect(SVC).toMatch(/已重挑这 \$\{target\} 人/);
expect(SHEET).toMatch(/sheet\.refillNote/);
});
test('⛔ 不许再把重排说成「顶替 / 凑满」—— 实际只是这批人换过了', () => {
expect(SVC).not.toMatch(/换一批\*\*专属没排满的\*\*人来凑/);
expect(SVC).not.toMatch(/已凑满/);
expect(SHEET).not.toMatch(/顶替/);
});
test('⭐⭐ 被换进来的标 swap,⛔ 不混进 rank', () => {
expect(SVC).toMatch(/wouldRank\.has\(c\.patientId\) \? 'rank' : 'swap'/);
const { AssignmentItemSchema } = jest.requireActual<typeof import('@pac/types')>('@pac/types');
expect(
AssignmentItemSchema.safeParse({
planId: '00000000-0000-4000-8000-000000000000',
assigneeUserId: 'u1',
assignStrategy: 'spread_no_dedicated',
selectionMode: 'swap',
}).success,
).toBe(true);
});
test('⭐⭐ 服务端只回答"能不能",⛔ 不下发顶替名单(名单会被窗口限死)', () => {
expect(SVC).toMatch(/const canRefill = raw\.pending\.length > 0 && ownership\.free > 0/);
expect(SVC).not.toMatch(/swapCandidates/);
});
test('⭐⭐ 卡片上**只有一个按钮**,⛔ 不再复述助手已经说过的解释', () => {
expect(SHEET).toMatch(/重新排一版,尽量排满/);
expect(SHEET).toMatch(/sheet\.canRefill/);
// ⛔ 名次不该出现在界面上(产品:排名不重要)
expect(SHEET).not.toMatch(/排名靠后/);
// ⛔ 分布说明已经在助手那段话里了,卡片不再重复
expect(SHEET).not.toMatch(/本批候选 \{sheet\.poolOwnership/);
});
test('⭐ 重排要把条件原样带回 —— 少带一个就退回"沿用上一次",人数悄悄变了', () => {
expect(SHEET).toMatch(/targetCount: sheet\.batchSize/);
expect(SHEET).toMatch(/potentialTreatment: sheet\.potentialTreatment/);
expect(SHEET).toMatch(/temperature: sheet\.temperature/);
});
test('⭐ 待分配那段排在「已排好」之后', () => {
const ASSIST = readFileSync(
join(__dirname, '../src/modules/assistant/assistant.service.ts'),
'utf8',
);
const iBasis = ASSIST.indexOf('sheet.basisNote');
const iPending = ASSIST.indexOf('sheet.pendingNote');
expect(iBasis).toBeGreaterThan(0);
expect(iPending).toBeGreaterThan(iBasis);
});
});
import { import {
Temperature, Temperature,
TEMPERATURE_META,
TEMPERATURE_ORDER,
TEMPERATURE_TOOL_DESC,
TEMPERATURE_TOOL_VALUES,
classifyTemperature, classifyTemperature,
gapTemperatureBounds, gapTemperatureBounds,
hottestBounds, hottestBounds,
...@@ -56,7 +60,8 @@ describe('温度口径 —— 逐条 gap 用自己的窗', () => { ...@@ -56,7 +60,8 @@ describe('温度口径 —— 逐条 gap 用自己的窗', () => {
expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(60)), NOW)).toBe(Temperature.HOT); // urgency=60 expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(60)), NOW)).toBe(Temperature.HOT); // urgency=60
expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(61)), NOW)).toBe(Temperature.WARM); expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(61)), NOW)).toBe(Temperature.WARM);
expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(90)), NOW)).toBe(Temperature.WARM); // window=90 expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(90)), NOW)).toBe(Temperature.WARM); // window=90
expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(91)), NOW)).toBe(Temperature.COLD); // 91 天前 = 出窗但不到 1 年 → 冷端第一档
expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(91)), NOW)).toBe(Temperature.COLD_1Y);
}); });
test('未知码 / 无锚点 → null,⛔ 不猜一个默认窗口', () => { test('未知码 / 无锚点 → null,⛔ 不猜一个默认窗口', () => {
...@@ -73,17 +78,18 @@ describe('温度口径 —— 反单调性(这是旧口径最贵的那个 bug)', ...@@ -73,17 +78,18 @@ describe('温度口径 —— 反单调性(这是旧口径最贵的那个 bug)',
const fresh = gapTemperatureBounds('K02', daysAgo(5)); const fresh = gapTemperatureBounds('K02', daysAgo(5));
const stale = gapTemperatureBounds('K02', daysAgo(700)); const stale = gapTemperatureBounds('K02', daysAgo(700));
expect(classifyTemperature(fresh, NOW)).toBe(Temperature.HOT); expect(classifyTemperature(fresh, NOW)).toBe(Temperature.HOT);
expect(classifyTemperature(stale, NOW)).toBe(Temperature.COLD); expect(classifyTemperature(stale, NOW)).toBe(Temperature.COLD_2Y); // 700 天 ≈ 1.9 年
expect(classifyTemperature(hottestBounds([fresh, stale]), NOW)).toBe(Temperature.HOT); expect(classifyTemperature(hottestBounds([fresh, stale]), NOW)).toBe(Temperature.HOT);
expect(classifyTemperature(hottestBounds([stale, fresh]), NOW)).toBe(Temperature.HOT); // 与顺序无关 expect(classifyTemperature(hottestBounds([stale, fresh]), NOW)).toBe(Temperature.HOT); // 与顺序无关
}); });
test('⭐ 单调性通则:往集合里加任何一条 gap,档位只可能变热或不变', () => { test('⭐ 单调性通则:往集合里加任何一条 gap,档位只可能变热或不变', () => {
const base = [gapTemperatureBounds('K02', daysAgo(700))]; const base = [gapTemperatureBounds('K02', daysAgo(700))];
const rank = { hot: 0, warm: 1, cold: 2 } as const; // 六档由热到冷的名次 —— ⛔ 别再手写,从 TEMPERATURE_ORDER 派生(加档位时自动跟上)
const before = rank[classifyTemperature(hottestBounds(base), NOW)!]; const rank = Object.fromEntries(TEMPERATURE_ORDER.map((t, i) => [t, i])) as Record<string, number>;
const before = rank[classifyTemperature(hottestBounds(base), NOW)!]!;
for (const d of [1, 30, 59, 60, 61, 89, 90, 91, 365, 3000]) { for (const d of [1, 30, 59, 60, 61, 89, 90, 91, 365, 3000]) {
const after = rank[classifyTemperature(hottestBounds([...base, gapTemperatureBounds('K02', daysAgo(d))]), NOW)!]; const after = rank[classifyTemperature(hottestBounds([...base, gapTemperatureBounds('K02', daysAgo(d))]), NOW)!]!;
expect(after).toBeLessThanOrEqual(before); expect(after).toBeLessThanOrEqual(before);
} }
}); });
...@@ -119,7 +125,7 @@ describe('温度口径 —— 边界时刻是事实,不是时钟', () => { ...@@ -119,7 +125,7 @@ describe('温度口径 —— 边界时刻是事实,不是时钟', () => {
const b = gapTemperatureBounds('K04', new Date('2026-08-02T00:00:00.000Z'))!; // 60/45 const b = gapTemperatureBounds('K04', new Date('2026-08-02T00:00:00.000Z'))!; // 60/45
expect(classifyTemperature(b, new Date('2026-09-10T00:00:00.000Z'))).toBe(Temperature.HOT); // +39d expect(classifyTemperature(b, new Date('2026-09-10T00:00:00.000Z'))).toBe(Temperature.HOT); // +39d
expect(classifyTemperature(b, new Date('2026-09-20T00:00:00.000Z'))).toBe(Temperature.WARM); // +49d expect(classifyTemperature(b, new Date('2026-09-20T00:00:00.000Z'))).toBe(Temperature.WARM); // +49d
expect(classifyTemperature(b, new Date('2026-11-01T00:00:00.000Z'))).toBe(Temperature.COLD); // +91d expect(classifyTemperature(b, new Date('2026-11-01T00:00:00.000Z'))).toBe(Temperature.COLD_1Y); // +91d
}); });
test('⭐ 边界存的是 UTC ISO —— 字典序必须等于时间序(SQL 侧靠这个直接比较)', () => { test('⭐ 边界存的是 UTC ISO —— 字典序必须等于时间序(SQL 侧靠这个直接比较)', () => {
...@@ -177,29 +183,81 @@ describe('窗口期 hover 文案 —— 只给区间,不给解释', () => { ...@@ -177,29 +183,81 @@ describe('窗口期 hover 文案 —— 只给区间,不给解释', () => {
test('⭐ 单码标签给确切区间(种植 黄金 120 / 窗 180)', () => { test('⭐ 单码标签给确切区间(种植 黄金 120 / 窗 180)', () => {
expect(temperatureWindowHint('implant', Temperature.HOT)).toBe('≤ 120 天'); expect(temperatureWindowHint('implant', Temperature.HOT)).toBe('≤ 120 天');
expect(temperatureWindowHint('implant', Temperature.WARM)).toBe('120–180 天'); expect(temperatureWindowHint('implant', Temperature.WARM)).toBe('120–180 天');
expect(temperatureWindowHint('implant', Temperature.COLD)).toBe('> 180 天'); // 冷端四档是**绝对年数**,与治疗项无关 —— 不再是"> 窗口天数"
expect(temperatureWindowHint('implant', Temperature.COLD_1Y)).toBe('出周期后 1 年内');
expect(temperatureWindowHint('implant', Temperature.COLD_OVER)).toBe('诊断距今 > 3 年');
}); });
test('⭐⭐ 多码标签给各码区间的**并集** —— 挑一个码的数字会骗人', () => { test('⭐⭐ 多码标签给各码区间的**并集** —— 挑一个码的数字会骗人', () => {
// 拔牙 = K01(黄金 90 / 窗 180) ∪ K03(黄金 60 / 窗 90) // 拔牙 = K01(黄金 90 / 窗 180) ∪ K03(黄金 60 / 窗 90)
expect(temperatureWindowHint('extraction', Temperature.HOT)).toBe('≤ 90 天'); expect(temperatureWindowHint('extraction', Temperature.HOT)).toBe('≤ 90 天');
expect(temperatureWindowHint('extraction', Temperature.WARM)).toBe('60–180 天'); expect(temperatureWindowHint('extraction', Temperature.WARM)).toBe('60–180 天');
expect(temperatureWindowHint('extraction', Temperature.COLD)).toBe('> 90 天'); // 冷端与码无关,多码标签也是同一套年数
expect(temperatureWindowHint('extraction', Temperature.COLD_2Y)).toBe('诊断距今 1–2 年');
}); });
test('⭐ 三档区间必须**覆盖整条数轴**,不留缝也不写反', () => { test('⭐ 前两档区间必须**首尾相接**,不留缝也不写反', () => {
// 缝 = 有人哪一档都不属于;写反 = 冷的下界跑到热的上界左边,主管一看就知道在乱说 // 缝 = 有人哪一档都不属于;写反 = 温的上界跑到热的上界左边,主管一看就知道在乱说。
// ⚠️ 冷端四档已改成绝对年数(与治疗项无关),不参与这条"按天数接龙"的校验。
for (const label of Object.keys(POTENTIAL_TREATMENT_SOURCE_CODES)) { for (const label of Object.keys(POTENTIAL_TREATMENT_SOURCE_CODES)) {
const hot = Number(temperatureWindowHint(label, Temperature.HOT).match(/\d+/)![0]); const hot = Number(temperatureWindowHint(label, Temperature.HOT).match(/\d+/)![0]);
const [warmLo, warmHi] = temperatureWindowHint(label, Temperature.WARM).match(/\d+/g)!.map(Number); const [warmLo, warmHi] = temperatureWindowHint(label, Temperature.WARM).match(/\d+/g)!.map(Number);
const cold = Number(temperatureWindowHint(label, Temperature.COLD).match(/\d+/)![0]);
// 单码时三档首尾**恰好相接**(温上界 == 冷下界),多码时并集会让温更宽 —— 都不许留缝
expect(warmLo).toBeLessThanOrEqual(hot); expect(warmLo).toBeLessThanOrEqual(hot);
expect(warmHi).toBeGreaterThanOrEqual(cold); expect(warmHi).toBeGreaterThan(hot);
} }
}); });
test('⭐⭐ 六档必须两两互斥且覆盖整条时间轴 —— 任何一天都恰好属于一档', () => {
// 这条比"文案接龙"硬:直接拿判定函数在时间轴上扫,漏一天或重一天都会红。
const b = gapTemperatureBounds('K02', new Date('2020-01-01T00:00:00.000Z'))!; // 60/90
const seen: string[] = [];
for (const d of [0, 60, 61, 90, 91, 364, 365, 366, 730, 731, 1095, 1096, 5000]) {
const t = classifyTemperature(b, new Date(Date.parse(b.anchorAt) + d * 86_400_000));
expect(t).not.toBeNull(); // ⛔ 任何一天都不许落进"未知"
if (!seen.length || seen[seen.length - 1] !== t) seen.push(t!);
}
// 走过的档位序列必须是 TEMPERATURE_ORDER 的**前缀连续子序列**(单调变冷,不回头、不跳档)
const idx = seen.map((t) => TEMPERATURE_ORDER.indexOf(t as never));
expect(idx).toEqual([...idx].sort((a, z) => a - z));
expect(new Set(idx).size).toBe(idx.length);
});
test('未知标签 → 空串(不编)', () => { test('未知标签 → 空串(不编)', () => {
expect(temperatureWindowHint('not_a_label', Temperature.HOT)).toBe(''); expect(temperatureWindowHint('not_a_label', Temperature.HOT)).toBe('');
}); });
}); });
/**
* ⭐ 工具入参的档位清单必须跟着矩阵走 —— 2026-08-05 事故的回归。
*
* 事故经过:冷端从 1 档拆成 4 档,`Temperature` / `TEMPERATURE_ORDER` / 矩阵 / SQL 全改了,
* 唯独两处 LLM 工具定义里的 `enum: ['hot','warm','cold']` 是**手写**的,没人跟着改。
* 主管点了矩阵的「牙周 · 2–3 年」(67 人),模型在三个取值里找不到对应档 → 退而选 `cold`,
* 而 `cold` 展开成四个冷档的并集 → 确认单报出 324 人。
*
* ⚠️ 这个 bug **没有任何报错**:枚举合法、SQL 合法、count 也是对的,
* 只有把矩阵和确认单摆在一起看才会发现口径差了 4.8 倍(违 T14「口径对数」)。
* 所以只能靠这条测试锁 —— tsc 抓不到手写字符串数组和枚举脱节。
*/
describe('LLM 工具的温度取值 —— 必须与矩阵同源', () => {
test('⭐⭐ 每个展示档都能被工具表达(少一个 = 模型只能退而求其次,静默放大人数)', () => {
for (const t of TEMPERATURE_ORDER) {
expect(TEMPERATURE_TOOL_VALUES).toContain(t);
}
});
test('除了兼容值 cold,不许多出矩阵里没有的档(否则模型会给出渲染不出来的格子)', () => {
expect([...TEMPERATURE_TOOL_VALUES].sort()).toEqual([...TEMPERATURE_ORDER, 'cold'].sort());
});
test('说明里必须带**中文档名对照** —— 主管说的是「2–3 年」,模型得自己翻回 cold_3y', () => {
for (const t of TEMPERATURE_ORDER) {
expect(TEMPERATURE_TOOL_DESC).toContain(`${TEMPERATURE_META[t].zh}=${t}`);
}
});
test('cold 必须被标注成"合计",否则模型会拿它当某一档用', () => {
expect(TEMPERATURE_TOOL_DESC).toMatch(/cold/);
expect(TEMPERATURE_TOOL_DESC).toMatch(/合计|并集|全体/);
});
});
...@@ -59,7 +59,11 @@ import { lookupDxTreatment } from './canonical-codes'; ...@@ -59,7 +59,11 @@ import { lookupDxTreatment } from './canonical-codes';
export const Temperature = { export const Temperature = {
HOT: 'hot', HOT: 'hot',
WARM: 'warm', WARM: 'warm',
COLD: 'cold', /// 超周期后按**绝对年数**再分四档(2026-08 产品定)。冷端占池子 90%+,不拆等于没分。
COLD_1Y: 'cold_1y',
COLD_2Y: 'cold_2y',
COLD_3Y: 'cold_3y',
COLD_OVER: 'cold_over',
} as const; } as const;
export type TemperatureValue = (typeof Temperature)[keyof typeof Temperature]; export type TemperatureValue = (typeof Temperature)[keyof typeof Temperature];
...@@ -67,10 +71,44 @@ export type TemperatureValue = (typeof Temperature)[keyof typeof Temperature]; ...@@ -67,10 +71,44 @@ export type TemperatureValue = (typeof Temperature)[keyof typeof Temperature];
export const TEMPERATURE_ORDER: readonly TemperatureValue[] = [ export const TEMPERATURE_ORDER: readonly TemperatureValue[] = [
Temperature.HOT, Temperature.HOT,
Temperature.WARM, Temperature.WARM,
Temperature.COLD, Temperature.COLD_1Y,
Temperature.COLD_2Y,
Temperature.COLD_3Y,
Temperature.COLD_OVER,
]; ];
/** /**
* 四个冷档 —— 给"整个冷端"做筛选/统计时用,⛔ 别在调用处手写这个数组。
*
* ⚠️ 旧 API 参数 `temperature=cold` 仍然合法,语义 = 这四档的并集(见 expandTemperatureFilter)。
* ⛔ 不要把 'cold' 加回 Temperature 枚举 —— 它不是一个**展示档**,矩阵里没有这一列;
* 加回去会让 TEMPERATURE_ORDER / TEMPERATURE_META 多出一个永远渲染不出来的成员。
*/
export const COLD_TEMPERATURES: readonly TemperatureValue[] = [
Temperature.COLD_1Y,
Temperature.COLD_2Y,
Temperature.COLD_3Y,
Temperature.COLD_OVER,
];
/** 冷档的**年数上界**(距锚点);COLD_OVER 无上界 = null。SQL 与 TS 判档共用这一份。 */
export const COLD_BUCKET_YEARS: Readonly<Record<string, number | null>> = {
[Temperature.COLD_1Y]: 1,
[Temperature.COLD_2Y]: 2,
[Temperature.COLD_3Y]: 3,
[Temperature.COLD_OVER]: null,
};
/**
* 兼容旧取值:`cold` → 四个冷档。其余原样返回单元素数组。
* 用在 API 入参解析(list / matrix / MCP 工具)—— ⛔ 别在每个消费方各写一遍。
*/
export function expandTemperatureFilter(v: string): readonly TemperatureValue[] {
if (v === 'cold') return COLD_TEMPERATURES;
return [v as TemperatureValue];
}
/**
* 档位的**显示名**与说明 —— 界面上一律走这里,⛔ 别再在组件里内联中文表。 * 档位的**显示名**与说明 —— 界面上一律走这里,⛔ 别再在组件里内联中文表。
* *
* ⚠️ **代码名与显示名刻意不一致**,不是遗漏: * ⚠️ **代码名与显示名刻意不一致**,不是遗漏:
...@@ -92,13 +130,63 @@ export const TEMPERATURE_META: Record<TemperatureValue, { zh: string; hint: stri ...@@ -92,13 +130,63 @@ export const TEMPERATURE_META: Record<TemperatureValue, { zh: string; hint: stri
zh: '窗口内', zh: '窗口内',
hint: '过了黄金期但没出临床周期 —— 还来得及,话术要给个理由', hint: '过了黄金期但没出临床周期 —— 还来得及,话术要给个理由',
}, },
[Temperature.COLD]: { [Temperature.COLD_1Y]: {
zh: '窗口外', zh: '1 年内',
hint: '超出该治疗的临床周期 —— 情况可能已经变了,先问近况再谈方案', hint: '刚出临床周期 —— 情况变化不大,先问近况再谈方案',
},
[Temperature.COLD_2Y]: {
zh: '1–2 年',
hint: '出周期 1-2 年 —— 当年的方案多半要重评,别照着老诊断讲',
},
[Temperature.COLD_3Y]: {
zh: '2–3 年',
hint: '出周期 2-3 年 —— 按"重新了解情况"开口,不要假设需求还在',
},
[Temperature.COLD_OVER]: {
zh: '3 年以上',
hint: '出周期 3 年以上 —— 长尾,期望值放低;⛔ 别用紧迫话术',
}, },
}; };
/** /**
* 给 **LLM 工具入参** 用的档位清单 —— 助手 / MCP 的 `temperature` 参数一律取这里。
*
* ⚠️ **必须从 TEMPERATURE_ORDER 推**,⛔ 别在工具定义里手写数组。
* 2026-08-05 实测的事故:冷端从 1 档拆成 4 档之后,两处工具定义里的
* `enum: ['hot','warm','cold']` **没人跟着改**。主管点了矩阵的「牙周 · 2–3 年」(67 人),
* 模型在只有三个取值的枚举里找不到对应档,退而选了 `cold` ——
* 而 `cold` 按兼容规则展开成**四个冷档的并集**,确认单于是报出 324 人(70+174+67+13)。
* 两个数都"对",口径差了 4.8 倍,而且**全程零报错**:枚举合法、SQL 合法、count 正确。
* 这正是 T14「口径对数」要防的 —— 主管刚在矩阵上看过 67,下一句话就变成 324。
*
* ⚠️ 带上中文名是**必需的**,不是注释:矩阵格子交给助手时只传了一句自然语言
* (「帮我给『牙周 · 2–3 年』这批患者出一份分配方案」),模型得自己把「2–3 年」
* 翻回 `cold_3y`。不给对照表它只能猜。
*/
export const TEMPERATURE_TOOL_VALUES: readonly string[] = [...TEMPERATURE_ORDER, 'cold'];
/**
* 工具参数说明(含中文名对照)—— 与 TEMPERATURE_TOOL_VALUES 同源,⛔ 别各写各的。
*
* 🔴 **这些取值码是给模型调工具用的,不是给人看的**(2026-08-06 产品走查)。
* 实测助手对主管说过「「2–3 年」对应的是 cold_3y 档」「放宽温度改成 cold_2y」——
* 主管眼里界面上从来没有 `cold_3y`,也没有「温度」这个词,那一列就叫「2–3 年」。
* 看到不认识的英文词,他第一反应是系统出错了。所以说明里必须**同时**给两件事:
* ① 中文→取值码 的翻译表(模型调工具要用);② 一条硬约束:⛔ 别把码说出口。
*/
export const TEMPERATURE_TOOL_DESC: string =
'矩阵的一档(界面上就是列头那几个中文,主管说的也是它们)。⚠️ 必须与 potentialTreatment 同时给。' +
'按此表把主管的话翻成取值:' +
TEMPERATURE_ORDER.map((t) => `${TEMPERATURE_META[t].zh}=${t}`).join(' · ') +
'。⚠️ `cold` = 后四档**合计**(窗口外全体),' +
'⛔ 只有主管明确说「窗口外 / 超周期」整体时才用 —— ' +
'他点的是矩阵里某一格时一律给**具体档位**,给成 cold 会把人数放大好几倍。' +
'\n🔴 ⛔ **这些取值码只用于调工具,一个字都不许说给主管**(他界面上没见过)。' +
'回话时一律用中文档名:「2–3 年」「1–2 年」「窗口内」「黄金期」。' +
'⛔ 也不要说「温度」这个词 —— 界面上没有,那就是一档一档的时间;' +
'要说「往后放一档」而不是「放宽温度」。';
/**
* 一条 gap 的温度边界。存 ISO 串(persona_features.data 是 JSON)。 * 一条 gap 的温度边界。存 ISO 串(persona_features.data 是 JSON)。
* *
* ⚠️ **必须是 `toISOString()` 的 UTC 形态** —— 全仓统一后 ISO 串的字典序 == 时间序, * ⚠️ **必须是 `toISOString()` 的 UTC 形态** —— 全仓统一后 ISO 串的字典序 == 时间序,
...@@ -110,6 +198,11 @@ export interface TemperatureBounds { ...@@ -110,6 +198,11 @@ export interface TemperatureBounds {
hotUntil: string; hotUntil: string;
/** 临床周期终点 = 锚点 + windowDays */ /** 临床周期终点 = 锚点 + windowDays */
warmUntil: string; warmUntil: string;
/**
* 锚点本身(该标签**最新的未治疗**诊断)。冷端四档按距它的绝对年数分。
* ⚠️ 与两个边界取同一条 gap 的 max —— 三者同源,⛔ 别各取各的。
*/
anchorAt: string;
} }
const DAY_MS = 86_400_000; const DAY_MS = 86_400_000;
...@@ -133,6 +226,7 @@ export function gapTemperatureBounds( ...@@ -133,6 +226,7 @@ export function gapTemperatureBounds(
return { return {
hotUntil: new Date(t + rule.urgencyDayThreshold * DAY_MS).toISOString(), hotUntil: new Date(t + rule.urgencyDayThreshold * DAY_MS).toISOString(),
warmUntil: new Date(t + rule.windowDays * DAY_MS).toISOString(), warmUntil: new Date(t + rule.windowDays * DAY_MS).toISOString(),
anchorAt: new Date(t).toISOString(),
}; };
} }
...@@ -152,12 +246,17 @@ export function hottestBounds( ...@@ -152,12 +246,17 @@ export function hottestBounds(
): TemperatureBounds | null { ): TemperatureBounds | null {
let hot: string | null = null; let hot: string | null = null;
let warm: string | null = null; let warm: string | null = null;
let anchor: string | null = null;
for (const b of list) { for (const b of list) {
if (!b) continue; if (!b) continue;
if (hot === null || b.hotUntil > hot) hot = b.hotUntil; if (hot === null || b.hotUntil > hot) hot = b.hotUntil;
if (warm === null || b.warmUntil > warm) warm = b.warmUntil; if (warm === null || b.warmUntil > warm) warm = b.warmUntil;
// ⭐ 锚点也取 max(最新)—— 与两个边界同向;取最早会让冷端与前两档用两套锚点(见文件头)
if (anchor === null || b.anchorAt > anchor) anchor = b.anchorAt;
} }
return hot !== null && warm !== null ? { hotUntil: hot, warmUntil: warm } : null; return hot !== null && warm !== null && anchor !== null
? { hotUntil: hot, warmUntil: warm, anchorAt: anchor }
: null;
} }
/** /**
...@@ -196,23 +295,60 @@ export const POTENTIAL_TREATMENT_SOURCE_CODES: Record<string, readonly string[]> ...@@ -196,23 +295,60 @@ export const POTENTIAL_TREATMENT_SOURCE_CODES: Record<string, readonly string[]>
* 热 [0,90]∪[0,60] = ≤90 天 · 温 (90,180]∪(60,90] = 60–180 天 · 冷 >180∪>90 = >90 天 * 热 [0,90]∪[0,60] = ≤90 天 · 温 (90,180]∪(60,90] = 60–180 天 · 冷 >180∪>90 = >90 天
*/ */
export function temperatureWindowHint(label: string, temp: TemperatureValue): string { export function temperatureWindowHint(label: string, temp: TemperatureValue): string {
const years = COLD_BUCKET_YEARS[temp];
// 冷四档是**绝对年数**,与治疗项无关 —— 不查窗口配置
if (years !== undefined) {
if (years === null) return '诊断距今 > 3 年';
return years === 1 ? '出周期后 1 年内' : `诊断距今 ${years - 1}${years} 年`;
}
const codes = POTENTIAL_TREATMENT_SOURCE_CODES[label]; const codes = POTENTIAL_TREATMENT_SOURCE_CODES[label];
const rules = (codes ?? []).map((c) => lookupDxTreatment(c)).filter((r): r is NonNullable<typeof r> => !!r); const rules = (codes ?? []).map((c) => lookupDxTreatment(c)).filter((r): r is NonNullable<typeof r> => !!r);
if (!rules.length) return ''; if (!rules.length) return '';
const urg = rules.map((r) => r.urgencyDayThreshold); const urg = rules.map((r) => r.urgencyDayThreshold);
const win = rules.map((r) => r.windowDays); const win = rules.map((r) => r.windowDays);
if (temp === Temperature.HOT) return `≤ ${Math.max(...urg)} 天`; if (temp === Temperature.HOT) return `≤ ${Math.max(...urg)} 天`;
if (temp === Temperature.WARM) return `${Math.min(...urg)}${Math.max(...win)} 天`; return `${Math.min(...urg)}${Math.max(...win)} 天`;
return `> ${Math.min(...win)} 天`;
} }
/**
* 读时定档 —— 温度**唯一**的判定入口。
*
* ⭐ 一个锚点走到底(2026-08 统一):`anchorAt` = 该标签**最新的未治疗**诊断
* (口径 `COALESCE(occurred_at, planned_for)` + `status='active'`)。
* 前两档按该治疗项**自己的临床窗口**、后四档按**绝对年数**,但量的都是「距同一个锚点多久」,
* 所以六档是一条单调的时间轴,不存在交叉。
*
* ⛔ **不要**把冷端改成「距最早那条诊断多久」。那会让 hot/warm 也必须跟着改口径,
* 而 hot/warm 用最早锚点是**反单调**的:上周刚查出的龋齿,因为身上有颗两年前的旧龋,
* 整个人被判成冷 —— 多一条未满足需求反而更冷,在召回池里说不通(见文件头 ①)。
* 「这机会最早挂了多久」另有出口:`potential_treatment.detail[].daysSince`(取 max),
* 那是**话术勾子**的活,⛔ 别让矩阵档位去兼任。
*
* @returns 边界或锚点缺失 → **null,不是任何一个冷档**。
* 把"不知道"当成"冷"会让老数据静默塞满冷格子,主管看到的是一个假的分布 —— 违 T14。
*/
export function classifyTemperature( export function classifyTemperature(
bounds: { hotUntil?: string | null; warmUntil?: string | null } | null | undefined, bounds:
| { hotUntil?: string | null; warmUntil?: string | null; anchorAt?: string | null }
| null
| undefined,
now: Date = new Date(), now: Date = new Date(),
): TemperatureValue | null { ): TemperatureValue | null {
if (!bounds?.hotUntil || !bounds.warmUntil) return null; if (!bounds?.hotUntil || !bounds.warmUntil) return null;
const iso = now.toISOString(); const iso = now.toISOString();
if (iso <= bounds.hotUntil) return Temperature.HOT; if (iso <= bounds.hotUntil) return Temperature.HOT;
if (iso <= bounds.warmUntil) return Temperature.WARM; if (iso <= bounds.warmUntil) return Temperature.WARM;
return Temperature.COLD; // ── 冷端:按距锚点的绝对年数再分四档 ──
// ⚠️ 锚点缺失时**不许**默认塞进某一档 —— 返回 null,由调用方显式呈现"未知"
if (!bounds.anchorAt) return null;
const anchor = new Date(bounds.anchorAt);
if (Number.isNaN(anchor.getTime())) return null;
for (const t of COLD_TEMPERATURES) {
const y = COLD_BUCKET_YEARS[t];
if (y === null || y === undefined) return t; // COLD_OVER 兜底(表里缺键也走这条,不静默漏档)
const edge = new Date(anchor);
edge.setFullYear(edge.getFullYear() + y); // ⚠️ 按自然年推,别用 365*n(闰年会漂)
if (now <= edge) return t;
}
return Temperature.COLD_OVER;
} }
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