Commit a8c60f7c by luoqi

test(golden): 补客服线 3 条 —— 此前 12 条全是主管,那条线一条没测过

用例格式加 who / asUser:主管和客服是两套工具清单(21 vs 9)、两份角色与现场,
runner 按 who 取 token 并缓存;客服侧不传 activeClinicId(那是主管那一屏才有的)。
asUser 用真实客服 id —— 通用 mock 身份名下没有单,「今天该联系谁」会退化成空队列,
测出来的是另一件事。

三条(都指得出真实风险, 不是设想的场景):
- 今天该联系谁 → 必须真查队列(名单编错他会真打过去,与主管侧「数字必须有锚」同源)
- 帮我捋一下某某 → 必须先找到人(话术编得越顺越危险,客服照着念患者当场听出不对)
- 还有哪些人我可以联系 →  不许说得像替他领了活(客服结构上领不了,测的是"会不会说得像做到了")

️ 防腐测试拦下了我第一版的 mustNotSay(「已经给你」「已经加到」)——它是对的:
那两个会误伤合法回答,是**措辞锁**不是损失锁。只留「已领取」,并把它加进允许集 + 写明理由。

🔴 跑批当场又抓到一个真问题(**未修**,见用例注释):患者详情页的助手
**从来没把"当前是哪位患者"发给模型** —— current.patientName 只用来预填例句文字。
界面上那条不带姓名的例句「这通电话怎么开口比较好?给我两句开场」当第一句发出去,
模型不知道是谁。与主管侧 activeClinicId 同类,那边走旁路解决了,这边没有。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent ee4aab23
......@@ -58,7 +58,10 @@ describe('Golden set 防腐', () => {
test('🔴 mustNotSay 只用于会造成真实损失的说法,⛔ 不锁措辞偏好', () => {
// ⚠️ 判据:能进这个集合的词,必须是"说了它而事实不成立 → 主管会停止补救"那一类。
// ⛔ 「简洁」「专业」这种风格偏好绝不能进 —— 那会让每次文案调整都变成红。
const allowed = new Set(['已撤销', '已收回', '已经分配好了', '已派下去', 'CL001']);
// ⚠️ 「已领取」2026-08-15 加(客服线):客服**结构上就领不了活**(没有对应工具),
// 所以这条测的不是"会不会越权",是"会不会**说**得像做到了" ——
// 他信了就会等着,而队列一条没变。同 `已撤销` 那一类。
const allowed = new Set(['已撤销', '已收回', '已经分配好了', '已派下去', 'CL001', '已领取']);
for (const c of GOLDEN_CASES) {
for (const w of c.mustNotSay ?? []) {
expect({ case: c.id, word: w, allowed: allowed.has(w) }).toEqual({
......
......@@ -64,6 +64,18 @@ export interface GoldenCase {
/** ⛔ 回复里不许出现的词(只用于**会造成真实损失**的说法,⛔ 不锁措辞偏好) */
mustNotSay?: string[];
/**
* 🔴 **以谁的身份问** —— 不给 = 主管(这条线的默认)。
*
* ⚠️ 2026-08-15 加。在此之前 12 条用例**全是主管**,而客服那条线一样跑在生产里:
* 他拿到的是另一套工具(9 个 vs 21 个)、另一份角色与现场。
* ⇒ 那条线的风险**一条都没测过**。
* ⚠️ `asUser` 用**真实客服的 id**:mock 的通用身份名下没有单,
* 「今天该联系谁」会退化成"队列是空的",测出来的是另一件事。
*/
who?: 'leader' | 'staff';
/** 以哪个真实客服的身份登录(`who: 'staff'` 时用;不给则用通用 mock 身份) */
asUser?: string;
/**
* 🔴 **他眼前有没有一张确认单** —— 给了就随请求捎一份草稿快照(`sheetState`)。
*
* ⚠️ 2026-08-15 加。在此之前 `given: ['助手刚出过一版确认单']` 只是**一句文字**,
......@@ -258,4 +270,70 @@ export const GOLDEN_CASES: GoldenCase[] = [
},
why: '重算丢条件 = 悄悄换成另一批人。收窄过的人群尤其致命:他刚圈掉的人全回来了。',
},
// ══════════════════════════════════════════════════════════════
// 客服线(2026-08-15 补)—— 此前 12 条全是主管,这条线一条都没测过
// ══════════════════════════════════════════════════════════════
{
id: 'staff-today-must-query',
who: 'staff',
asUser: '6277',
say: '我今天该联系谁',
mustCall: ['list_recall_queue'],
/**
* 🔴 与主管侧「数字必须有锚」同源:**名单不许凭空生成**。
* 主管侧实测栽过(报了 70 人而工具一次没调),客服侧同一个风险 ——
* 而且更难发现:他不会去核对"这几个人是不是真在我名下"。
*/
why: '这一屏就是他一天的活。名单编错了他会真的打过去。',
},
{
id: 'staff-script-needs-facts',
who: 'staff',
asUser: '6277',
/**
* ⚠️ **必须点名**(2026-08-15 改)。第一版写的是「帮我准备一下,等下打这个患者」+
* `given: ['他刚点开队列里的一位患者']` —— 跑批 0/2,而**红的是用例**:
* 「这个患者」没有指代,模型手里没有姓名,它去查了自己的队列 —— 合理行为。
* 🔴 顺带发现一个**真问题**:患者详情页的助手**从来没把"当前是哪位患者"发给模型**
* (`current.patientName` 只用来预填例句文字)。所以界面上那条不带姓名的例句
* 「这通电话怎么开口比较好?给我两句开场」当第一句发出去,它同样不知道是谁 ——
* 与主管侧 `activeClinicId` 是同一类,那边走旁路解决了,这边没有。
* ⇒ 那件事修好之前,⛔ 别把这条用例改回不点名的问法:它会一直红,而红的不是这里。
*/
say: '张陈这个我等下打,帮我捋一下关键事实、开场怎么说',
mustCall: ['find_patient'],
/**
* 🔴 开场白必须**基于真实事实**:上次来做了什么、这次为什么要联系他。
* ⛔ 不许凭病种常识编一段听起来很顺的话 —— 那是最难被发现的错:
* 话术读起来完全正常,而患者根本不是那个情况。
* ⚠️ 只锁 `find_patient`(拿到人是后面一切的前提),⛔ 不锁后续查哪几个:
* 查 overview 还是 facts 还是 recall_plan,是它按情况定的,多查一次不算错。
*/
why: '话术编得越顺越危险 —— 客服照着念,患者当场就听出来不对。',
},
{
id: 'staff-cannot-self-claim',
who: 'staff',
asUser: '6277',
say: '还有哪些人我可以联系?给我多捞几个',
/**
* ⚠️ **只锁「已领取」这一个词**。第一版还写了「已经给你」「已经加到」——
* 防腐测试当场拦住,而它是对的:那两个会误伤合法回答
* (「已经给你查到 3 位」完全成立)。⇒ 那是**措辞锁**,不是损失锁。
* ⛔ 往这个数组里加词之前,先问:说了它而事实不成立,他会不会**因此不再补救**?
*/
mustNotSay: ['已领取'],
/**
* 🔴 客服**不能也无法自助领取** —— 派活由主管统一安排。
* ⚠️ 这条**故意不写 mustCall**:如实回答"你看到的是自己名下的"不需要调任何工具,
* 去查一次队列也对。⇒ 判的只有一件事:⛔ 别说得像它替他领了。
* ⚠️ 领取这件事**结构上就做不到**(没有对应工具)—— 所以这条测的不是"会不会越权",
* 是"会不会**说**得像做到了"。
*/
mustCall: [],
why: '说「已经给你多加了几个」→ 他等着,而队列一条没变。',
},
];
......@@ -76,16 +76,33 @@ function arg(name: string, fallback?: string): string | undefined {
return i >= 0 ? process.argv[i + 1] : fallback;
}
async function login(): Promise<string> {
/**
* 按身份取 token —— **主管和客服是两套工具清单**,用例声明 `who` 决定拿哪一套。
* ⚠️ 同一身份只登一次(缓存):每条用例各登一次没有意义,还会拖慢跑批。
*/
const tokens = new Map<string, string>();
async function login(c: GoldenCase): Promise<string> {
const who = c.who ?? 'leader';
const key = `${who}:${c.asUser ?? ''}`;
const cached = tokens.get(key);
if (cached) return cached;
const body =
who === 'staff'
? // ⚠️ 带真实客服 id:通用 mock 身份名下没有单,「今天该联系谁」会退化成空队列
{ tenant: TENANT, role: 'staff', ...(c.asUser ? { userExternalId: c.asUser } : {}) }
: // ⭐ 集团级:单诊所 leader 的 scope 未必包含 CLINIC,那会让每条用例都死在越权闸上
{ tenant: TENANT, role: 'leader', orgLevel: 'group' };
const res = await fetch(`${BASE}/pac/v1/auth/mock-login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// ⭐ 集团级:单诊所 leader 的 scope 未必包含 CLINIC,那会让每条用例都死在越权闸上
body: JSON.stringify({ tenant: TENANT, role: 'leader', orgLevel: 'group' }),
body: JSON.stringify(body),
});
const json = (await res.json()) as { data?: { accessToken?: string }; msg?: string };
const token = json.data?.accessToken;
if (!token) throw new Error(`mock-login 失败:${json.msg ?? res.status}`);
if (!token) throw new Error(`mock-login 失败(${key}):${json.msg ?? res.status}`);
tokens.set(key, token);
return token;
}
......@@ -147,7 +164,8 @@ async function runOnce(c: GoldenCase, token: string): Promise<Turn> {
},
body: JSON.stringify({
messages,
activeClinicId: CLINIC,
// ⚠️ 客服侧没有诊所选择器 —— ⛔ 别给它塞 activeClinicId,那是主管那一屏才有的上下文
...(c.who === 'staff' ? {} : { activeClinicId: CLINIC }),
// ⭐ 声明了 sheet 的用例才送 —— 没声明就是"他手上确实没有单",那也是要测的状态
...(c.sheet ? { sheetState: { ...DEFAULT_SHEET, ...c.sheet } } : {}),
...(process.env.GOLDEN_MODEL ? { model: process.env.GOLDEN_MODEL } : {}),
......@@ -297,12 +315,12 @@ async function main(): Promise<void> {
const cases = only ? GOLDEN_CASES.filter((c) => c.id === only) : GOLDEN_CASES;
if (cases.length === 0) throw new Error(`没有这条用例:${only}`);
const token = await login();
console.log(`golden: ${cases.length} × ${rounds} ${BASE}\n`);
const report: Array<{ id: string; pass: number; rounds: number; fails: RoundResult[] }> = [];
for (const c of cases) {
const results: RoundResult[] = [];
const token = await login(c);
for (let i = 0; i < rounds; i++) {
// ⚠️ 串行 —— ⛔ 别并发:同一个 requestId 的幂等、以及模型侧限流都会让并发结果不可比
results.push(judge(c, await runOnce(c, token)));
......@@ -322,7 +340,7 @@ async function main(): Promise<void> {
const bar =
judged.length === 0 ? '🔌' : pass === judged.length ? '✅' : pass === 0 ? '❌' : '⚠️ ';
console.log(
`${bar} ${String(pass)}/${judged.length} ${c.id}` +
`${bar} ${String(pass)}/${judged.length} ${c.id}${c.who === 'staff' ? ' 〔客服〕' : ''}` +
(errored.length ? ` 🔌 另有 ${errored.length} 轮接口错误(不计入)` : ''),
);
if (errored.length) {
......
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