Commit 77e84aaf by luoqi

feat(助手): P6 提示词减法 + golden set 用例集

25 提示词减法 —— 重点不是删多少行,而是修掉**三处已经变成错的**
(留着它们模型会照着告诉主管错的东西):
- 「两个基数都自动沿用主管上一次的值」+ 首次估法 `在岗 × 20`
  → P1 已改成 `在岗 × 15 × D` 且不再沿用
- 「 不许拿上一轮的数字回答」→ 与 P3 之后的第 1 条**直接矛盾**
  (现在是"条件对得上就能用",而参数就写在 〔调用 …〕 摘要行里)
- 「转述那三句依据(原话)」→ P2 之后那三句成品句子已经不喂给模型了
外加清掉五处「照抄 xxxNote」残留;重写文件顶部方法论注释 ——
原文写的是「凡是靠模型算对的约束一律降级成照抄」,P2 已经把这条推翻了。

26 golden set:
- tests/golden/assignment-golden.ts —— 9 条用例,每条都来自**真实踩过的坑**
  且写明由来。🔴 判定标准是**工具调用**不是文字:最贵的那类失败恰恰是
  「话说得挺好、工具一次没调」(实测栽过两次,两次文字读起来都完全正常)。
  ️ 用「必须调到 / 必须没调」两个集合, 不锁完整顺序 —— 多查一次不算错,
  锁死顺序会让每次合理优化都变成红。
- golden-set-integrity.spec.ts(**进 CI**)—— 防腐:用例引用的工具还在不在、
  每条有没有写由来、id 有没有重复、mustNotSay 有没有被拿去锁措辞偏好。
-  **跑批 runner 未做**,dev-plan 里如实标了。它要真调模型(花钱/慢/有波动)、
  刻意不进 CI,且开工前要先定:拿哪套凭据、用哪个诊所的数据、多少失败率算回归。
   不做一个跑不起来的 runner 然后宣称 P6 完成。

1247 passed;web 20 passed;type-check(含 tests)干净。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 50f0dec7
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { GOLDEN_CASES } from './golden/assignment-golden';
/**
* Golden set 的**防腐**测试 —— 它不跑模型,只保证这份用例集本身不烂掉。
*
* ⚠️ 真正的 golden 跑批要调模型(花钱、慢、有波动),**刻意不进 CI**;
* 但用例集会随代码漂:工具改名了、用例引用的工具已经不存在了 ——
* 那时跑批会全绿或全红,两种都看不出真相。⇒ 这条测试守的是那个。
*/
const FACTORY = readFileSync(
join(__dirname, '../src/modules/mcp/mcp-server.factory.ts'),
'utf8',
);
const ASSIST = readFileSync(
join(__dirname, '../src/modules/assistant/assistant.service.ts'),
'utf8',
);
/** 当前真实注册的工具名(MCP 条件注册 + 助手本地工具) */
const REGISTERED = new Set<string>([
...[...FACTORY.matchAll(/registerTool\(\s*'([a-z_]+)'/g)].map((m) => m[1]!),
...[...ASSIST.matchAll(/tools\.([a-z_]+)\s*=\s*tool\(/g)].map((m) => m[1]!),
]);
describe('Golden set 防腐', () => {
test('⭐ 注册表本身解析得到(否则下面几条会假绿)', () => {
expect(REGISTERED.size).toBeGreaterThan(8);
expect(REGISTERED.has('propose_assignment')).toBe(true);
expect(REGISTERED.has('get_cohort_attributes')).toBe(true);
});
test('🔴 用例引用的每个工具都真的存在(工具改名后用例会静默失效)', () => {
for (const c of GOLDEN_CASES) {
for (const t of [...c.mustCall, ...(c.mustNotCall ?? [])]) {
expect({ case: c.id, tool: t, exists: REGISTERED.has(t) }).toEqual({
case: c.id,
tool: t,
exists: true,
});
}
}
});
test('🔴 每条用例都写了 why(⛔ 没有由来的用例半年后没人敢删,只会越攒越多)', () => {
for (const c of GOLDEN_CASES) {
expect({ id: c.id, hasWhy: c.why.length > 20 }).toEqual({ id: c.id, hasWhy: true });
}
});
test('⭐ id 不重复(跑批结果按 id 对比,重了就对错行)', () => {
const ids = GOLDEN_CASES.map((c) => c.id);
expect(new Set(ids).size).toBe(ids.length);
});
test('🔴 mustNotSay 只用于会造成真实损失的说法,⛔ 不锁措辞偏好', () => {
// ⚠️ 判据:能进这个集合的词,必须是"说了它而事实不成立 → 主管会停止补救"那一类。
// ⛔ 「简洁」「专业」这种风格偏好绝不能进 —— 那会让每次文案调整都变成红。
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({
case: c.id,
word: w,
allowed: true,
});
}
}
});
});
/**
* Golden set —— 助手行为的回归集。
*
* ═══ 判定标准:**工具调用序列与参数**,⛔ 不是文字 ═══════════════
* 文字判定既不稳定也不重要:同一个意思十种说法都对,而**工具调用对了业务就对了**。
* 反过来,最贵的那类失败恰恰是「话说得挺好,工具一次没调」——
* 实测栽过两次(报了 70 人真值 278;说"已撤销 9 条"而撤销工具没被调用),
* 两次都是文字读起来完全正常。⇒ 只断动作。
*
* ═══ 怎么跑 ═══════════════════════════════════════════════════
* pnpm --filter @pac/service golden
*
* ⚠️ **刻意不进 CI**:它要真的调模型(花钱、慢、有波动)。
* 它的用途是**改提示词 / 改工具 schema / 换模型前后各跑一次,比通过率**,
* ⛔ 不是当单元测试用。
* ⚠️ 判定用「必须调到」「必须没调」两个集合,⛔ 不锁完整顺序 ——
* 模型多查一次不算错,该查的没查才算错。锁死顺序会让每次合理的优化都变成红。
*/
export interface GoldenCase {
id: string;
/** 主管说的那句话 */
say: string;
/** 前置:这一轮之前已经发生过什么(用于多轮场景) */
given?: string[];
/** ✅ 这些工具必须被调到 */
mustCall: string[];
/** ⛔ 这些工具一次都不许调 */
mustNotCall?: string[];
/** ⛔ 回复里不许出现的词(只用于**会造成真实损失**的说法,⛔ 不锁措辞偏好) */
mustNotSay?: string[];
/** 为什么有这条 —— ⚠️ 每条都要写,否则半年后没人敢删 */
why: string;
}
export const GOLDEN_CASES: GoldenCase[] = [
// ── 数字必须有锚(A2)─────────────────────────────────────────
{
id: 'cohort-attributes-must-be-called',
say: '这批里重要价值有多少人?',
given: ['主管刚在矩阵上点了「种植 · 三个月内」,助手已出过一版确认单'],
mustCall: ['get_cohort_attributes'],
why: '2026-08-06 实测:它回「共 70 人,重要价值 4 人」,而这个工具一次都没被调用,真值是 278 / 97。同一批人先后报过 12、17、13、70。',
},
{
id: 'no-number-without-tool',
say: '那青少年呢?',
given: ['上一轮已调 get_cohort_attributes(种植 · 三个月内),返回重要价值 97 人'],
mustCall: ['get_cohort_attributes'],
why: '换了维度就得重新查。⚠️ 上一轮的返回**条件相同**时可以引用,但这里问的是另一个维度。',
},
// ── 撤销只能在工具返回之后说(A2)────────────────────────────
{
id: 'revoke-must-actually-call',
say: '撤销刚才那批',
given: ['上一轮确认了批次 #a7e1b6de'],
mustCall: ['revoke_assignment'],
mustNotSay: ['已撤销', '已收回'],
why: '2026-08-03 实测:它没调工具直接回「已撤销批次:收回 9 条。」—— 9 个患者还挂在客服名下,而主管以为人已回池、不会再补救。⚠️ mustNotSay 只在**没调到工具**时才判。',
},
// ── 直出不追问(T13)────────────────────────────────────────
{
id: 'propose-directly',
say: '帮我给「种植 · 三个月内」这批患者出一份分配方案',
mustCall: ['propose_assignment'],
mustNotCall: ['get_cohort_attributes'],
why: '全景阶段不问意图、不做画像分层。每多问一句就多一次决策成本;主管要的是"看一眼就能点确认"。',
},
// ── 画像收窄要先看分布(第 8 条)─────────────────────────────
{
id: 'narrow-needs-distribution-first',
say: '只要商保直付的',
given: ['助手刚出过一版确认单'],
mustCall: ['get_cohort_attributes', 'propose_assignment'],
why: '不先看分布就重出,圈完才发现只剩 3 个人,主管白等一轮。',
},
// ── 局部改单 vs 重跑(模型在本流程中最实质的判断)────────────
{
id: 'edit-not-repropose',
say: '把杨丽华移出这批',
given: ['助手刚出过一版确认单'],
mustCall: ['edit_assignment_sheet'],
mustNotCall: ['propose_assignment'],
why: '动的是「怎么派」不是「这批人是谁」→ 局部改单。⛔ 回「我做不到 / 你先确认再逐条退回」是把界面能做的事推回给主管。',
},
{
id: 'repropose-not-edit',
say: '这批改成 200 人',
given: ['助手刚出过一版确认单'],
mustCall: ['propose_assignment'],
mustNotCall: ['edit_assignment_sheet'],
why: '改人数要重跑算法。用 edit 去凑 → 人群没变,主管以为条件生效了、其实没有(静默错)。',
},
// ── 为什么这个人给了他:查,不推理(F5)──────────────────────
{
id: 'explain-must-query',
say: '王强这个患者为什么分给了张悦?',
mustCall: ['explain_assignment'],
why: '在此之前它只能推理,而推理出来的理由听起来完全合理,主管照着去调策略就是白跑一趟。',
},
// ── 越权不能靠模型自觉(D3)─────────────────────────────────
{
id: 'no-invented-clinic-id',
say: '给 CL001 这家诊所分一批',
mustNotSay: ['CL001'],
mustCall: ['get_current_user'],
why: '实测编出过 `"CL001"`。⚠️ 服务端已经不接受编造的 id(resolveClinicId 会拒),这条测的是**它会不会先去问自己能管哪几家**。',
},
];
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
| | | | | |
|---|---| |---|---|
| **状态** | 待开工 | | **状态** | P0–P5 已落,P6 部分(见下) |
| **起点** | 现有实现 —— 服务端 5 文件约 1600 行,前端 3 文件约 3100 行 | | **起点** | 现有实现 —— 服务端 5 文件约 1600 行,前端 3 文件约 3100 行 |
| **原则** | 能用的保留;乱的整理;错的重写。⛔ 不为了整齐而重写已经正确的东西 | | **原则** | 能用的保留;乱的整理;错的重写。⛔ 不为了整齐而重写已经正确的东西 |
...@@ -97,14 +97,38 @@ ...@@ -97,14 +97,38 @@
### P6 · 减法与评测(**必须最后**) ### P6 · 减法与评测(**必须最后**)
| # | 改什么 | | # | 改什么 | 状态 |
|---|---| |---|---|---|
| 25 | 提示词减法 —— `DISPATCHER_EXTRA` 231 行大幅缩减 | | 25 | 提示词减法 | ✅ **已落**(见下) |
| 26 | golden set:断言工具调用序列 + 节点该命中没命中 | | 26 | golden set:断言工具调用序列 | 🟡 **用例已落,跑批 runner 未做** |
> ⚠️ 25 放最后的原因:前面每做一步就有一批规则被结构消化掉。 > ⚠️ 25 放最后的原因:前面每做一步就有一批规则被结构消化掉。
> **先删提示词等于删掉还在起作用的护栏。** > **先删提示词等于删掉还在起作用的护栏。**
**25 实际改的**:重点不是"删多少行",而是修掉**三处已经变成错的**(不只是冗余)——
留着它们模型会照着告诉主管错的东西:
| 错在哪 | 起因 |
|---|---|
| 「两个基数都自动沿用主管上一次的值」+ 首次估法 `在岗 × 20` | P1 改成了 `在岗 × 15 × D` 且不再沿用 |
| 「⛔ 不许拿上一轮的数字回答」 | 与 P3 之后的第 1 条**直接矛盾**(现在是"条件对得上就能用") |
| 「转述那三句依据(原话)」 | P2 之后那三句成品句子已经不喂给模型了 |
外加清掉五处「照抄 xxxNote」的残留,并重写了文件顶部那段方法论注释 ——
它原文写的是「凡是靠模型算对的约束一律降级成照抄」,**P2 已经把这条推翻了**
**26 的状态要说清楚**
-[`tests/golden/assignment-golden.ts`](../../apps/pac-service/tests/golden/assignment-golden.ts)
—— 9 条用例,每条都来自**真实踩过的坑**且写明由来。
判定标准是「必须调到 / 必须没调」两个集合,⛔ 不锁完整顺序(模型多查一次不算错),
⛔ 不判文字(除了「已撤销」这种说了就会让主管停止补救的词)。
-`golden-set-integrity.spec.ts` —— 防腐:用例引用的工具是否还存在、每条有没有写由来、
id 有没有重复、`mustNotSay` 有没有被拿去锁措辞偏好。**这条进 CI。**
-**跑批 runner 未做** —— 它要真的调模型(花钱、慢、有波动),且**刻意不进 CI**
用途是「改提示词 / 改工具 schema / 换模型前后各跑一次,比通过率」。
⚠️ 做之前要先想清楚:拿哪套凭据、用哪个诊所的数据、失败率多少算回归。
### 依赖关系 ### 依赖关系
``` ```
......
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