Commit e96f34ca by luoqi

docs(分配): agent 文档对齐实现 —— §五规格表漂了四行

assignment-agent-flow.md:
- §五「确认前」表重写:之前列着 short_supply(已删)、highlight(2026-08-13 被
  narrow 取代,下文自己写了、表却没改)、expiry_default/anchor_nondefault(从未实现),
  却没有 narrow 和 batch_size_basis。真源是 GUIDANCE_KIND 那四个键。
- daily_overload 的选项从三个改成一个(不带数的两个已删)。
- 「引导节点是第三段」→ 它们嵌在各自所属的那一段里;顺带删掉那句
  「assistant-prompts 第 0.5 条与此冲突,以本文为准」——冲突早已不存在。
- 编号 → 中文标识;place_guidance → show_guidance。
- 动词 圈 → 选。
- 确认后那条 no_benefit 节点整节改写:福利前移到确认之前问,确认单里不做引导。
- 补两条文档里根本没有的硬要求:重跑必须原样带回人群条件(三处载体)、
  草稿态模型看不见要查 get_current_sheet。
- 修一处"拿已删的判据当理由":「重排后 short_supply 还会再兜一次」。

plan-assignment-doctrine.md:精调点名的出处由已删的 basisNote 改成
modelFacts.他单独设过的;批次名示例里的「窗口内」换成当前档位名。

assignment-agent-dev-plan.md:26(golden 跑批 runner)由「未做」改成已落,
并补 P7 一节记这一批实测改动与测试基建抓到的三件事。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 8b19ff33
......@@ -42,7 +42,8 @@
"stale-scan": "ts-node --transpile-only src/cli/stale-scan.cli.ts",
"stale-scan:prod": "node dist/cli/stale-scan.cli.js",
"openapi:dump": "ts-node --transpile-only src/cli/dump-openapi.cli.ts",
"refresh-clinic-names": "ts-node --transpile-only src/cli/refresh-clinic-names.cli.ts"
"refresh-clinic-names": "ts-node --transpile-only src/cli/refresh-clinic-names.cli.ts",
"golden": "ts-node --transpile-only tests/golden/run.ts"
},
"prisma": {
"seed": "ts-node --transpile-only prisma/seed.ts"
......
......@@ -216,6 +216,26 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> {
*/
export function sheetSnapshotFacts(s: SheetSnapshot): Record<string, unknown> {
return {
/**
* ⭐ **人群条件排在最前** —— 「这批人是谁」是重算的前提,⛔ 不能让它排在
* 一堆分配结果后面:模型读到「改成 200 人」时第一件要确认的就是这一格是什么。
* ⚠️ 中文措辞与 `modelFacts.怎么选的` 对齐(治疗项 / 时间档)——
* ⛔ 同一件事别起两个说法,否则模型会以为是两批人。
*/
这批人是谁: {
治疗项: zhTreatment(s.criteria.potentialTreatment ?? undefined),
时间档: s.criteria.temperature
? `${TEMPERATURE_SINCE_ZH} ${zhTemperature(s.criteria.temperature)}`
: null,
...(s.criteria.narrowedBy?.personaTags
? { 他追加的画像条件: s.criteria.narrowedBy.personaTags }
: {}),
...(s.criteria.narrowedBy?.minSpendYuan != null
? { 他设的消费下界: s.criteria.narrowedBy.minSpendYuan }
: {}),
符合条件的总人数: s.criteria.candidateTotal,
这一版估了多大: s.criteria.batchSize,
},
已经分下去了: s.confirmed,
// ⭐ 「已排好」而不是「已分配」:与卡片上那行字逐字一致(2026-08-13 定的措辞)
已排好: s.placed,
......
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { RefillProposalRequestSchema, SheetSnapshotSchema } from '@pac/types';
/**
* 分配这条线的**跨端契约** —— 谁传什么、谁压过谁、少传一格会怎样。
*
* ═══ 为什么要单独一份 ═══════════════════════════════════════════
* 2026-08-15 那天抓到的两个真 bug **都不在单测能覆盖的地方**:
* ① 「换无专属客服的患者补上」不回传 `narrowedBy` → 服务端退回整格取人
* (实测候选 2178 vs 296,差 1882 人);
* ② 确认单快照没带人群条件 → 模型重算时按全池算,圈出完全另一批人。
* 共同点:**两端各自都是对的**,schema 有这个字段、服务端也会用,
* 只是**发的那一头没填**。单测测不到"没填",因为没有哪一侧是错的。
*
* ═══ 为什么用"把源码当字符串读" ═══════════════════════════════
* 与 `mcp-clinic-scope.spec.ts` 同一套办法,理由也一样:
* 它让**纪律在源码上可数**("凡是能触发重算的载体都要带全人群条件")。
* ⚠️ 代价是脆:改个变量名就红。⛔ 所以断言只锁**字段名与去向**,
* ⛔ 不锁格式、不锁行号、不锁措辞。红了先看是不是真漏了,再改断言。
*/
const web = (p: string) => readFileSync(join(__dirname, '../../pac-web/src', p), 'utf8');
const svc = (p: string) => readFileSync(join(__dirname, '../src', p), 'utf8');
const CHAT_HOOK = web('components/assistant/use-assistant-chat.ts');
const SHEET = web('components/assistant/assignment-confirm-sheet.tsx');
const CONTROLLER = svc('modules/assistant/assistant.controller.ts');
const ASSISTANT = svc('modules/assistant/assistant.service.ts');
const ASSIGN_CTRL = svc('modules/plan/assignment.controller.ts');
describe('重算类请求 —— 人群条件必须原样带回', () => {
/**
* 🔴 三处载体都能触发"重出一版",三处都必须带全人群条件。
* ⛔ 少一处就是一条静默换人的路径 —— 而它不报错,界面只显示"重新排了一版"。
*/
test('🔴 refill 的 schema 收得下主管自己加的那一刀', () => {
const shape = RefillProposalRequestSchema.shape;
expect(Object.keys(shape)).toEqual(
expect.arrayContaining(['potentialTreatment', 'temperature', 'personaTags', 'minSpendYuan']),
);
});
test('🔴 前端点「补上」时真的把 narrowedBy 发出去了(⛔ schema 有 ≠ 传了)', () => {
// 取 refill({...}) 那一段
// ⚠️ 取到 `.then(` 为止 —— 非贪婪匹配到第一个 `}` 会在内联的三元 spread 处截断
const call = /\.refill\(\{[\s\S]*?\n \}\)/.exec(CHAT_HOOK)?.[0] ?? '';
expect(call).toContain('narrowedBy?.personaTags');
expect(call).toContain('narrowedBy?.minSpendYuan');
// ⚠️ 真源是 narrowedBy,⛔ 不许改成去 criteria 里翻(那儿混着矩阵两轴,分不出哪些是他加的)
expect(call).not.toMatch(/criteria\.(personaTags|minSpendYuan)/);
});
test('🔴 服务端 refill 把这两个条件透传下去(漏一个就是退回整格)', () => {
const handler = /async refill\([\s\S]*?\n \}/.exec(ASSIGN_CTRL)?.[0] ?? '';
for (const k of ['personaTags', 'minSpendYuan', 'potentialTreatment', 'temperature']) {
expect({ 参数: k, 透传: handler.includes(`body.${k}`) }).toEqual({ 参数: k, 透传: true });
}
});
test('🔴 确认单快照带着「这批人是谁」(模型重算的唯一依据)', () => {
const shape = SheetSnapshotSchema.shape.criteria.shape;
expect(Object.keys(shape)).toEqual(
expect.arrayContaining([
'potentialTreatment',
'temperature',
'narrowedBy',
'candidateTotal',
'batchSize',
]),
);
// 卡片得真的填 —— schema 要求了但填 null 一样白搭
const snap = /const snapshot = useMemo<SheetSnapshot>\([\s\S]*?\n \);/.exec(SHEET)?.[0] ?? '';
expect(snap).toContain('potentialTreatment: sheet.potentialTreatment');
expect(snap).toContain('narrowedBy: sheet.narrowedBy');
});
});
describe('确认单草稿态 —— 前端产、请求带、工具读', () => {
/**
* ⚠️ 这条链上任何一段断了,症状都一样:模型拿旧数说话,而**两边都不报错**。
* (确认之前那份状态只在浏览器里,服务端没有可查的东西。)
*/
test('🔴 三段都在:卡片挂 store → 请求体带 → 工具读', () => {
expect(SHEET).toContain('setSheetSnapshot(snapshot)');
expect(CHAT_HOOK).toMatch(/sheetState:\s*useAssistantStore\.getState\(\)\.sheetSnapshot/);
expect(CONTROLLER).toMatch(/SheetSnapshotSchema\.safeParse\(body\.sheetState\)/);
expect(ASSISTANT).toMatch(/tools\.get_current_sheet\s*=\s*tool\(/);
expect(ASSISTANT).toContain('sheetSnapshotFacts(input.sheetState)');
});
test('⭐ 解不出来就当没有 —— ⛔ 不许把半份快照喂给模型(缺的字段会被读成 0)', () => {
expect(CONTROLLER).toMatch(/safeParse\(body\.sheetState\)\.success/);
});
test('🔴 卡片卸载时只清自己那份(重出一版后旧卡片会把新的清掉)', () => {
expect(SHEET).toContain('sheetSnapshot?.requestId === snapshot.requestId');
});
});
describe('改单回声 —— 每一条改动路径都要发', () => {
/**
* 🔴 2026-08-15 之前**只有一条**路径发回声,另外几条全静默:
* 拖动改派、点 ×(明细行 / 待分配组)、右上角整批时效、每行单独时效、
* 连引导按钮里的「改时效」都不发 —— 而那颗按钮正是模型自己开出来的。
* ⇒ 这条测的是**纪律在源码上可数**:改状态的地方后面必须紧跟一个 echo。
* ⚠️ 窗口给 12 行:批量改单那条会在 `setX(...)` 与 `notes.push(...)` 之间夹一段注释,
* 6 行会把它误判成静默。⛔ 别再放宽 —— 真静默的点周围**几十行内**一个都没有,
* 窗口越大越容易借到隔壁按钮的 echo,那时这条测试就成了摆设。
*/
const MUTATORS = ['setMoveByPlan(', 'setDropped(', 'setExpiresInDays(', 'setDayByPlan('];
test('🔴 每个改状态的地方近旁都有 echo(⛔ 静默改动 = 模型拿旧数说话)', () => {
const lines = SHEET.split('\n');
const silent: string[] = [];
lines.forEach((ln, i) => {
if (!MUTATORS.some((m) => ln.includes(m))) return;
// ⚠️ 跳过声明本身(useState)与 effect 里的批量落盘(那处自己带 echo,在窗口内)
if (ln.includes('useState') || ln.includes('const [')) return;
const window = lines.slice(i, i + 12).join('\n');
/**
* ⚠️ `notes.push(` 也算数:批量改单(`edits` effect)把每条结果攒进 notes,
* 循环跑完统一 `echo(...notes)` —— 那是**一次操作多条指令**的正确形状,
* ⛔ 别为了让断言好写就逼它每条各发一次回声(主管会看到一串碎片)。
*/
if (!window.includes('echo(') && !window.includes('notes.push('))
silent.push(`第 ${i + 1} 行: ${ln.trim().slice(0, 60)}`);
});
expect(silent).toEqual([]);
});
test('⭐ 当前值只进 modelText —— ⛔ 界面不显示(卡片上就在他眼前,写两遍是读两遍)', () => {
expect(SHEET).toMatch(/onEditApplied\?\.\(text, `\$\{text\}\\n\$\{sheetSnapshotLine/);
});
});
describe('模型选择 —— 谁压过谁', () => {
/**
* ⚠️ 2026-08-15:我照着 call 上的 `defaultModelId` 报了一张"实际在跑什么模型"的表,
* **报错了** —— UI 每次都传 `model`,`modelIdOverride` 压过它,那几个 default 是死值。
* ⇒ 锁住这条优先级,并让"话术那条路真正跑哪个模型"在源码上一眼可见。
*/
test('🔴 override 压过 defaultModelId(⛔ 别调换,那会让 UI 的选择静默失效)', () => {
const runner = svc('modules/ai/ai-call-runner.service.ts');
expect(runner).toMatch(/ctx\.modelIdOverride \?\? call\.defaultModelId/);
});
test('⭐ 四个摘要类走同一个模型(裸键 qwen → QWEN_DEFAULT_MODEL)', () => {
for (const f of [
'modules/ai/calls/draft-recall-brief/call.ts',
'modules/ai/calls/draft-persona-summary/call.ts',
'modules/ai/calls/draft-recall-summary/call.ts',
'modules/ai/calls/draft-plan-summary/call.ts',
]) {
expect({ 文件: f, 模型: /defaultModelId = '([^']+)'/.exec(svc(f))?.[1] }).toEqual({
文件: f,
模型: 'qwen',
});
}
});
test('🔴 摘要 schema 不许有硬长度下界(qwen 写得短 too_small 整次报废)', () => {
// ⚠️ 先剥注释:那份 schema 的注释里就写着「⛔ 别把 .min(50) / .max(600) 加回来」,
// 连注释一起扫会把**禁令本身**判成违规(2026-08-15 第一版就这么红的)。
const schema = svc('modules/ai/calls/draft-plan-summary/schema.ts')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*/g, '');
// .min(2) 是"不许空串"的守卫,不是长度要求 —— 大于它的下界才是问题
for (const m of schema.matchAll(/\.min\((\d+)\)/g)) {
expect({ 下界: Number(m[1]), 允许: Number(m[1]) <= 2 }).toEqual({
下界: Number(m[1]),
允许: true,
});
}
expect(schema).not.toMatch(/\.max\(\d+\)/);
});
});
......@@ -17,6 +17,8 @@
* 模型多查一次不算错,该查的没查才算错。锁死顺序会让每次合理的优化都变成红。
*/
import type { SheetSnapshot } from '@pac/types';
export interface GoldenCase {
id: string;
/** 主管说的那句话 */
......@@ -27,8 +29,50 @@ export interface GoldenCase {
mustCall: string[];
/** ⛔ 这些工具一次都不许调 */
mustNotCall?: string[];
/**
* 🔴 **调了还不够,参数也得对** —— `{工具名: {参数名: 期望值}}`。
*
* ⚠️ 2026-08-15 加。那天最贵的两个 bug **都不是"没调工具",是"调了但少带参数"**:
* ①「换无专属客服的患者补上」没回传 `narrowedBy` → 悄悄退回整格取人
* (实测候选 2178 vs 296,差 1882 人);
* ② 重算时丢了治疗项/时间档 → 按全池重算,圈出完全另一批人。
* 两者的共同点:**工具调用序列完全正确**,只有参数少了一格,而且都不报错。
* ⇒ 只断"调没调"的用例集,对这一类是全盲的。
* ⚠️ 只写**少了就出事**的那几个参数,⛔ 别把整个入参锁死:
* 模型多传一个可选参数不算错,锁死会让每次合理的补充都变红。
*/
mustCallWith?: Record<string, Record<string, unknown>>;
/**
* 🔴 **先后**(子序列,⛔ 不是完整顺序)—— 中间插别的调用不算错,**顺序反了才算**。
*
* ⚠️ 2026-08-15 加。这条线有一处"位置即含义"的设计:可动手的事要**一件一件、
* 就地开放**(说完选人开放选人那组,说完分法开放分法那组)。实测四次里只对两次 ——
* 要么三组按钮全堆在正文之前,要么「待分配」被讲到了「换个条件选」前面。
* ⇒ 只断"调没调"对这一类**完全看不见**:三次调用一次不少,全错在先后。
* ⚠️ 仍然⛔ 不锁完整序列(多查一次不算错,见文件头)——只锁这几步的相对先后。
*/
mustCallInOrder?: Array<{ tool: string; args?: Record<string, unknown> }>;
/**
* 🔴 **就地开放** —— 这几步的紧邻前一个事件必须是**正文**。
*
* ⚠️ 与 `mustCallInOrder` 不是一回事:顺序对了也可能全错。实测抓到过
* 「三组按钮全堆在正文之前」(形状 `PGGG·S·`)—— 顺序完全正确,
* 但主管收到的是一排无头无尾的按钮,读到哪儿都动不了手。
* ⚠️ 手工测过四次只对两次 ⇒ 这条**天生要看通过率**,⛔ 别指望它常绿。
*/
mustCallAfterText?: Array<{ tool: string; args?: Record<string, unknown> }>;
/** ⛔ 回复里不许出现的词(只用于**会造成真实损失**的说法,⛔ 不锁措辞偏好) */
mustNotSay?: string[];
/**
* 🔴 **他眼前有没有一张确认单** —— 给了就随请求捎一份草稿快照(`sheetState`)。
*
* ⚠️ 2026-08-15 加。在此之前 `given: ['助手刚出过一版确认单']` 只是**一句文字**,
* 而那天新增的 `get_current_sheet` 会去查真实状态、并如实回答"现在没有确认单" ——
* 于是模型做了诚实的事(不去改一张不存在的单),两条用例当场变红。
* **红的是用例的世界模型,不是产品**:状态从此有了第二条来路,⛔ 文字兜不住了。
* ⚠️ 只写要**覆盖**的字段,其余由 runner 补默认值 —— 用例关心的从来不是那些数。
*/
sheet?: Partial<SheetSnapshot>;
/** 为什么有这条 —— ⚠️ 每条都要写,否则半年后没人敢删 */
why: string;
}
......@@ -74,6 +118,7 @@ export const GOLDEN_CASES: GoldenCase[] = [
id: 'narrow-needs-distribution-first',
say: '只要商保直付的',
given: ['助手刚出过一版确认单'],
sheet: {},
mustCall: ['get_cohort_attributes', 'propose_assignment'],
why: '不先看分布就重出,圈完才发现只剩 3 个人,主管白等一轮。',
},
......@@ -83,6 +128,7 @@ export const GOLDEN_CASES: GoldenCase[] = [
id: 'edit-not-repropose',
say: '把杨丽华移出这批',
given: ['助手刚出过一版确认单'],
sheet: {},
mustCall: ['edit_assignment_sheet'],
mustNotCall: ['propose_assignment'],
why: '动的是「怎么派」不是「这批人是谁」→ 局部改单。⛔ 回「我做不到 / 你先确认再逐条退回」是把界面能做的事推回给主管。',
......@@ -91,8 +137,22 @@ export const GOLDEN_CASES: GoldenCase[] = [
id: 'repropose-not-edit',
say: '这批改成 200 人',
given: ['助手刚出过一版确认单'],
sheet: {},
mustCall: ['propose_assignment'],
mustNotCall: ['edit_assignment_sheet'],
/**
* 🔴 **重算必须原样带回人群条件**(2026-08-15 golden 跑批当场抓到)。
* 快照第一版没带治疗项/时间档,模型的原话:「我这边只看到确认单本身……
* 刚才我按全部候选重算了一版,结果对不上……跟您眼前那批完全不是一回事」。
* ⇒ 要么白问主管一轮,要么静默换成另一批人。
*/
mustCallWith: {
propose_assignment: {
potentialTreatment: 'extraction',
temperature: 'cold_over',
targetCount: 200,
},
},
why: '改人数要重跑算法。用 edit 去凑 → 人群没变,主管以为条件生效了、其实没有(静默错)。',
},
......@@ -112,4 +172,79 @@ export const GOLDEN_CASES: GoldenCase[] = [
mustCall: ['get_current_user'],
why: '实测编出过 `"CL001"`。⚠️ 服务端已经不接受编造的 id(resolveClinicId 会拒),这条测的是**它会不会先去问自己能管哪几家**。',
},
// ══════════════════════════════════════════════════════════════
// 2026-08-15 新增 —— 全部来自当天实测抓到的事故,⛔ 不是设想出来的场景
// ══════════════════════════════════════════════════════════════
{
id: 'guidance-opens-in-place',
say: '帮我给「拔牙 · 3 年以上」这批患者出一份分配方案',
mustCall: ['propose_assignment', 'show_sheet'],
/**
* 🔴 可动手的事要**跟着它管的那段话就地开放**,而不是攒到最后一起开。
* 实测两种崩法:① 三组按钮全堆在正文之前(`show_sheet` 甚至跑到引导之前);
* ② 「待分配」讲在了「换个条件选」前面 —— 而选人那一档一动就是新的一版,
* 顺序反了,先讲的分法整个作废。
* ⚠️ 只锁**选人组在分法组之前**这一件事,⛔ 不锁完整序列:
* 模型多调一次 `get_agents` 之类不算错。
*/
mustCallInOrder: [
{ tool: 'propose_assignment' },
{ tool: 'show_guidance', args: { id: '换个条件选' } },
{ tool: 'show_guidance', args: { id: '待分配' } },
],
// 🔴 顺序对了还不够:每组按钮**前面必须有正文**(说完那段才开它)
mustCallAfterText: [
{ tool: 'show_guidance', args: { id: '换个条件选' } },
{ tool: 'show_guidance', args: { id: '待分配' } },
],
why: '「位置即含义」是这条线的设计(show_guidance 的描述里写着「它标记:到这里为止,这一类我讲完了」)。四次实测只对两次。',
},
{
id: 'must-read-live-sheet-after-manual-edit',
say: '这张单现在什么情况',
given: ['主管刚在卡片上点 × 把「朱亚萱」移出了本批'],
sheet: { placed: 119, dropped: 1, pending: 4 },
mustCall: ['get_current_sheet'],
mustNotCall: ['propose_assignment'],
/**
* 🔴 主管在卡片上做的改动**模型看不见** —— 确认之前那份状态只在浏览器里。
* 在 `get_current_sheet` 之前,它只能把对话里几行回声自己累加,
* 而手动拖动 / 点 × / 改时效这几条路当时连回声都没有:卡片上写着 3 天,它嘴上还说 1 天。
* ⛔ `mustNotCall: propose_assignment` —— 问"现在什么情况"是**看**,不是重出一版。
*/
why: '2026-08-15 实测:主管问「那 1 位为什么没轮到」,模型手里只有一个光秃秃的数字,答不上来。',
},
{
id: 'repropose-keeps-narrowing',
say: '这批改成 300 人',
given: ['助手出过一版,主管已经按「消费高于 ¥721」收窄过'],
sheet: {
criteria: {
potentialTreatment: 'extraction',
temperature: 'cold_over',
narrowedBy: { minSpendYuan: 721 },
candidateTotal: 296,
batchSize: 495,
},
},
mustCall: ['propose_assignment'],
/**
* 🔴 **这是当天最贵的那一类 bug 的模型侧版本**:重算时把主管自己加的那一刀丢了。
* 按钮那条路(「换无专属客服的患者补上」)当天实测差 1882 人(2178 vs 296),
* 而模型这条路一样丢得掉 —— 两边都不报错,主管只看到"重新排了一版"。
* ⚠️ 三个参数一个都不能少:治疗项、时间档、他那一刀。
*/
mustCallWith: {
propose_assignment: {
potentialTreatment: 'extraction',
temperature: 'cold_over',
minSpendYuan: 721,
targetCount: 300,
},
},
why: '重算丢条件 = 悄悄换成另一批人。收窄过的人群尤其致命:他刚圈掉的人全回来了。',
},
];
/**
* Golden set 跑批 —— **量的是通过率,不是通过/不通过**。
*
* ═══ 为什么必须是通过率 ═══════════════════════════════════════════
* 这一层的被测对象是**模型行为**,它天生带方差。2026-08-15 那天为了定一句话,
* 同一份提示词的四种写法各跑三遍才分得出好坏;而「引导节点逐组穿插」这条
* 在四次运行里只对了两次 —— **单跑一次什么都证明不了**,绿了也可能是运气。
* ⇒ 每条用例跑 N 轮,输出「N 轮里过了几轮」。改提示词/换模型前后各跑一次,比的是这个数。
* ⛔ 别把它接进 CI:它真的调模型(花钱、慢、有波动),红一次不代表代码坏了。
*
* ═══ 判的是动作,不是文字 ═══════════════════════════════════════
* 判据全部来自 `tests/golden/assignment-golden.ts`(那边有完整的理由):
* 工具调用集合 + 少数几个「说了就造成真实损失」的词。
* ⛔ 别加"措辞好不好"的断言:同一个意思十种说法都对,锁了只会让每次合理改写变红。
*
* ═══ 打谁 ═══════════════════════════════════════════════════════
* 打**真的 HTTP 端点**(默认 localhost:3101),不 mock 任何一层 ——
* 提示词装配、工具清单、MCP、取数全是线上那条路。
* ⚠️ 所以它要求本地服务在跑、数据库有数;跑之前先确认 `pnpm --filter @pac/service dev`。
*
* ⚠️ 它住在 `tests/` 而不是 `src/cli/`:用例数据在这儿,而主 tsconfig 的 rootDir 是 `src`,
* 从 src 反向 import tests 会 TS6059。放这边由 `tsconfig.typecheck.json` 一并类型检查
* (那份的 include 含 tests/),jest 也不会把它当用例收走(只收 `*.spec.ts`)。
*
* 用法:
* pnpm --filter @pac/service golden # 全部用例 × 3 轮
* pnpm --filter @pac/service golden -- --rounds 5
* pnpm --filter @pac/service golden -- --case propose-directly
* pnpm --filter @pac/service golden -- --out tests/golden/snapshots/before.json
*
* 环境变量(都有默认值,本地一般不用设):
* GOLDEN_BASE_URL / GOLDEN_TENANT / GOLDEN_CLINIC_ID / GOLDEN_MODEL
*/
import { writeFileSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import type { SheetSnapshot } from '@pac/types';
import { GOLDEN_CASES, type GoldenCase } from './assignment-golden';
const BASE = process.env.GOLDEN_BASE_URL ?? 'http://localhost:3101';
const TENANT = process.env.GOLDEN_TENANT ?? 'ruitai';
/** 拿来跑的那家诊所 —— 要有足够的候选人,否则 propose 出空提案,用例全部失真 */
const CLINIC = process.env.GOLDEN_CLINIC_ID ?? 'e83d432a38bb4f6284713b36db4e7497';
interface Turn {
/// ⚠️ 连**入参**一起收 —— 只收名字的话,"调了但少带一格参数"这一类整个测不到
calls: Array<{ tool: string; args: Record<string, unknown> }>;
/**
* ⚠️ 事件的**先后**(`'text'` / 工具名)—— 只记序列,不记内容。
* 「可动手的事就地开放」这条设计的判据是"每组按钮前面有没有正文",
* 而那件事**只存在于先后里**:三次调用一次不少,全堆在正文之前一样是错的
* (实测抓到过 `PGGG·S·` 这种形状)。⛔ 只看 calls 数组是看不见它的。
*/
seq: Array<'text' | string>;
text: string;
error: string | null;
}
/** 一轮的判定结果 —— ⚠️ 失败要带上"差在哪",否则跑完只看到一个 0/3 无从下手 */
interface RoundResult {
ok: boolean;
missing: string[];
forbidden: string[];
said: string[];
/// 参数对不上的:`工具.参数 期望X 实际Y`
badArgs: string[];
/// 先后错了的
outOfOrder: string[];
/// 该"讲完再开"却直接开了的
notInPlace: string[];
tools: string[];
error: string | null;
}
function arg(name: string, fallback?: string): string | undefined {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 ? process.argv[i + 1] : fallback;
}
async function login(): Promise<string> {
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' }),
});
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}`);
return token;
}
/**
* 把一条用例跑一轮。
*
* ⚠️ `given`("这一轮之前已经发生过什么")用**助手消息**表达 —— 那正是真实链路
* 注入上下文的方式:主管点确认、界面改单,都是 `appendAssistantNote` 往消息流
* 塞一条助手文本,模型下一轮就是从那里知道"刚才发生了什么"。
* ⛔ 别改成 system 追加:那会让用例走一条线上不存在的路。
*/
/**
* 用例声明 `sheet` 时随请求捎的那份草稿快照。
*
* ⚠️ 数字**故意平淡**:用例判的是动作,不是这几个数。真正起作用的只有
* 「有没有这张单」—— `get_current_sheet` 据此回"现在什么样"还是"根本没有"。
* ⛔ 别把它做成"每条用例自己编一套数":那会让用例开始依赖具体数字,
* 而那些数一改,红的是用例不是产品。
*/
const DEFAULT_SHEET: SheetSnapshot = {
requestId: 'golden-req',
confirmed: false,
placed: 120,
agents: 8,
pending: 0,
dropped: 0,
moved: 0,
expiresInDays: 1,
rowExpiryOverrides: 0,
benefit: null,
byAgent: [
{ name: '张悦', count: 15 },
{ name: '李莉', count: 15 },
],
/**
* ⚠️ 人群条件用**真实存在的一格**(拔牙 · 3 年以上):用例要测"重算带不带回条件",
* 而带回一个不存在的格子会圈出 0 人,测出来的是另一件事。
*/
criteria: {
potentialTreatment: 'extraction',
temperature: 'cold_over',
narrowedBy: null,
candidateTotal: 2521,
batchSize: 495,
},
};
async function runOnce(c: GoldenCase, token: string): Promise<Turn> {
const messages = [
...(c.given ?? []).map((g) => ({ role: 'assistant' as const, content: g })),
{ role: 'user' as const, content: c.say },
];
const res = await fetch(`${BASE}/pac/v1/assistant/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
messages,
activeClinicId: CLINIC,
// ⭐ 声明了 sheet 的用例才送 —— 没声明就是"他手上确实没有单",那也是要测的状态
...(c.sheet ? { sheetState: { ...DEFAULT_SHEET, ...c.sheet } } : {}),
...(process.env.GOLDEN_MODEL ? { model: process.env.GOLDEN_MODEL } : {}),
}),
});
if (!res.ok || !res.body) return { calls: [], seq: [], text: '', error: `HTTP ${res.status}` };
const calls: Turn['calls'] = [];
const seq: Turn['seq'] = [];
let text = '';
let error: string | null = null;
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buf = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += value;
let sep: number;
while ((sep = buf.indexOf('\n\n')) !== -1) {
const frame = buf.slice(0, sep);
buf = buf.slice(sep + 2);
const line = frame.split('\n').find((l) => l.startsWith('data: '));
if (!line) continue;
try {
const e = JSON.parse(line.slice(6)) as Record<string, unknown>;
if (e.type === 'tool_call') {
calls.push({
tool: String(e.tool),
args: (e.args ?? {}) as Record<string, unknown>,
});
seq.push(String(e.tool));
}
else if (e.type === 'text' || e.type === 'text-delta') {
const t = String(e.text ?? '');
text += t;
// ⚠️ 连续的正文只记一次 —— 记的是"这儿有段正文",不是它有多长
if (t.trim() && seq[seq.length - 1] !== 'text') seq.push('text');
}
else if (e.type === 'error') error = String(e.error);
} catch {
/* 半帧,忽略 */
}
}
}
return { calls, seq, text, error };
}
function judge(c: GoldenCase, t: Turn): RoundResult {
const tools = t.calls.map((x) => x.tool);
const called = new Set(tools);
const missing = c.mustCall.filter((x) => !called.has(x));
const forbidden = (c.mustNotCall ?? []).filter((x) => called.has(x));
/**
* ⚠️ `mustNotSay` **只在没调到工具时才判** —— 用例集里写着这条纪律:
* 工具真调了,它说「已撤销」就是事实;没调却这么说才是事故。
*/
const said = missing.length > 0 ? (c.mustNotSay ?? []).filter((w) => t.text.includes(w)) : [];
/**
* 参数判定 —— ⚠️ 只看用例点名的那几个键,其余**一律不管**。
* ⚠️ 同一个工具被调多次时,**任意一次对上就算过**(重出一版、再微调,都是合理的)。
*/
const badArgs: string[] = [];
for (const [tool, want] of Object.entries(c.mustCallWith ?? {})) {
const got = t.calls.filter((x) => x.tool === tool);
if (got.length === 0) continue; // 没调到 → 已经记在 missing 里,⛔ 别报两遍
for (const [k, v] of Object.entries(want)) {
if (!got.some((g) => JSON.stringify(g.args[k]) === JSON.stringify(v))) {
badArgs.push(`${tool}.${k} 期望 ${JSON.stringify(v)},实际 ${got.map((g) => JSON.stringify(g.args[k])).join(' / ')}`);
}
}
}
/**
* 先后判定 —— **子序列**:按用例给的次序在实际调用里往前找,找得到就继续。
* ⚠️ 中间插了别的调用不算错(多查一次是合理的),⛔ 只有"该在前面的跑到后面去了"才判红。
*/
const outOfOrder: string[] = [];
{
let cursor = 0;
for (const want of c.mustCallInOrder ?? []) {
const at = t.calls.findIndex(
(x, i) =>
i >= cursor &&
x.tool === want.tool &&
Object.entries(want.args ?? {}).every(
([k, v]) => JSON.stringify(x.args[k]) === JSON.stringify(v),
),
);
if (at < 0) {
outOfOrder.push(
`${want.tool}${want.args ? `(${Object.values(want.args).join(',')})` : ''} 没有出现在它该在的位置`,
);
break; // ⛔ 后面几步不再判:第一处错位之后的判定没有意义
}
cursor = at + 1;
}
}
/**
* 「就地开放」判定 —— 这一步的**紧邻前一个事件必须是正文**。
* ⚠️ 判的是"讲完这段才开这组",⛔ 不是"整轮里有没有正文":
* `PGGG·S·`(三组按钮全堆在开头、正文在最后)整轮正文一大段,照样是错的。
*/
const notInPlace: string[] = [];
for (const want of c.mustCallAfterText ?? []) {
const at = t.calls.findIndex(
(x) =>
x.tool === want.tool &&
Object.entries(want.args ?? {}).every(
([k, v]) => JSON.stringify(x.args[k]) === JSON.stringify(v),
),
);
if (at < 0) continue; // 没调到 → 已记在 missing
// 在 seq 里定位这次调用:数到第 at+1 个同名工具
let seen = 0;
const idx = t.seq.findIndex((e) => e === want.tool && seen++ === at);
if (idx <= 0 || t.seq[idx - 1] !== 'text') {
notInPlace.push(
`${want.tool}${want.args ? `(${Object.values(want.args).join(',')})` : ''} 前面不是正文(实际形状 ${t.seq
.map((e) => (e === 'text' ? '·' : e === 'show_guidance' ? 'G' : e === 'show_sheet' ? 'S' : 'T'))
.join('')}`,
);
}
}
return {
ok:
!t.error &&
missing.length === 0 &&
forbidden.length === 0 &&
said.length === 0 &&
badArgs.length === 0 &&
outOfOrder.length === 0 &&
notInPlace.length === 0,
missing,
forbidden,
said,
badArgs,
outOfOrder,
notInPlace,
tools,
error: t.error,
};
}
async function main(): Promise<void> {
const rounds = Number(arg('rounds', '3'));
const only = arg('case');
const out = arg('out');
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[] = [];
for (let i = 0; i < rounds; i++) {
// ⚠️ 串行 —— ⛔ 别并发:同一个 requestId 的幂等、以及模型侧限流都会让并发结果不可比
results.push(judge(c, await runOnce(c, token)));
}
const pass = results.filter((r) => r.ok).length;
const fails = results.filter((r) => !r.ok);
report.push({ id: c.id, pass, rounds, fails });
const bar = pass === rounds ? '✅' : pass === 0 ? '❌' : '⚠️ ';
console.log(`${bar} ${String(pass)}/${rounds} ${c.id}`);
for (const f of fails.slice(0, 1)) {
if (f.error) console.log(` 报错: ${f.error}`);
if (f.missing.length) console.log(` 没调到: ${f.missing.join(', ')}`);
if (f.forbidden.length) console.log(` 不该调却调了: ${f.forbidden.join(', ')}`);
if (f.said.length) console.log(` 不该说却说了: ${f.said.join(', ')}`);
for (const b of f.badArgs) console.log(` 参数不对: ${b}`);
for (const o of f.outOfOrder) console.log(` 先后不对: ${o}`);
for (const o of f.notInPlace) console.log(` 没就地开放: ${o}`);
console.log(` 实际调用: ${f.tools.join(' → ') || '(一个都没调)'}`);
}
}
const total = report.reduce((s, r) => s + r.pass, 0);
const max = report.reduce((s, r) => s + r.rounds, 0);
console.log(`\n合计 ${total}/${max}${((total / max) * 100).toFixed(0)}%)`);
if (out) {
mkdirSync(dirname(out), { recursive: true });
// ⚠️ 快照只落**判定结果**,⛔ 不落模型正文:正文每次都不一样,diff 出来全是噪音
writeFileSync(
out,
JSON.stringify(
{ rounds, total, max, cases: report.map(({ id, pass }) => ({ id, pass })) },
null,
2,
),
);
console.log(`快照 → ${out}`);
}
}
void main().catch((e: unknown) => {
console.error(e instanceof Error ? e.message : e);
process.exit(1);
});
{
"rounds": 3,
"total": 34,
"max": 36,
"cases": [
{
"id": "cohort-attributes-must-be-called",
"pass": 3
},
{
"id": "no-number-without-tool",
"pass": 3
},
{
"id": "revoke-must-actually-call",
"pass": 3
},
{
"id": "propose-directly",
"pass": 2
},
{
"id": "narrow-needs-distribution-first",
"pass": 2
},
{
"id": "edit-not-repropose",
"pass": 3
},
{
"id": "repropose-not-edit",
"pass": 3
},
{
"id": "explain-must-query",
"pass": 3
},
{
"id": "no-invented-clinic-id",
"pass": 3
},
{
"id": "guidance-opens-in-place",
"pass": 3
},
{
"id": "must-read-live-sheet-after-manual-edit",
"pass": 3
},
{
"id": "repropose-keeps-narrowing",
"pass": 3
}
]
}
\ No newline at end of file
......@@ -568,6 +568,14 @@ export function AssignmentConfirmSheet({
name: g.name ?? g.userId.slice(0, 8),
count: g.list.length,
})),
// 🔴 人群条件原样带上 —— 少了它,模型重算时不知道这批是哪一格的人(见 schema 上那段)
criteria: {
potentialTreatment: sheet.potentialTreatment,
temperature: sheet.temperature,
narrowedBy: sheet.narrowedBy,
candidateTotal: sheet.candidateTotal,
batchSize: sheet.batchSize,
},
}),
[
requestId,
......@@ -579,6 +587,7 @@ export function AssignmentConfirmSheet({
expiresInDays,
dayByPlan,
benefit,
sheet,
],
);
......@@ -743,13 +752,23 @@ export function AssignmentConfirmSheet({
}
};
/** 移除某个客服 = 他名下的条目**一并移出本批** */
/**
* 移除某个客服 = 他名下的条目**一并移出本批**。
*
* 🔴 **这是第七条改动路径,2026-08-15 补回声时漏掉了**(当天的契约测试抓到)。
* 我数了拖动改派 / 点 ×(两处)/ 整批时效 / 每行时效 / 引导按钮改时效 六条,
* 偏偏漏了这条 —— 而它一次移走的是**一整个客服名下的所有条目**,
* 静默起来比单点一个 × 严重得多:模型下一轮还以为那几条在他手上。
* ⚠️ 报的是**人名 + 条数**:主管点的是"移除这个人",回声里只说条数他对不上是谁。
*/
const removeAgent = (userId: string) => {
const g = groups.find((x) => x.userId === userId);
setDropped((s) => {
const n = new Set(s);
for (const it of items) if (it.assignee === userId) n.add(it.planId);
return n;
});
echo(`确认单已更新:把 ${g?.name ?? userId.slice(0, 8)} 名下的 ${g?.list.length ?? 0} 条移出本批。`);
setPendingRemove(null);
};
......
......@@ -5,7 +5,7 @@
| | |
|---|---|
| **状态** | P0–P5 已落,P6 部分(见下) |
| **状态** | P0–P6 已落;P7(2026-08-15 实测批次)见文末 |
| **起点** | 现有实现 —— 服务端 5 文件约 1600 行,前端 3 文件约 3100 行 |
| **原则** | 能用的保留;乱的整理;错的重写。⛔ 不为了整齐而重写已经正确的东西 |
......@@ -100,7 +100,7 @@
| # | 改什么 | 状态 |
|---|---|---|
| 25 | 提示词减法 | ✅ **已落**(见下) |
| 26 | golden set:断言工具调用序列 | 🟡 **用例已落,跑批 runner 未做** |
| 26 | golden set:断言工具调用序列 | **已落**(2026-08-15 补上 runner,见 P7) |
> ⚠️ 25 放最后的原因:前面每做一步就有一批规则被结构消化掉。
> **先删提示词等于删掉还在起作用的护栏。**
......@@ -125,9 +125,13 @@
⛔ 不判文字(除了「已撤销」这种说了就会让主管停止补救的词)。
-`golden-set-integrity.spec.ts` —— 防腐:用例引用的工具是否还存在、每条有没有写由来、
id 有没有重复、`mustNotSay` 有没有被拿去锁措辞偏好。**这条进 CI。**
-**跑批 runner 未做** —— 它要真的调模型(花钱、慢、有波动),且**刻意不进 CI**
用途是「改提示词 / 改工具 schema / 换模型前后各跑一次,比通过率」。
⚠️ 做之前要先想清楚:拿哪套凭据、用哪个诊所的数据、失败率多少算回归。
-**跑批 runner**(2026-08-15)—— [`tests/golden/run.ts`](../../apps/pac-service/tests/golden/run.ts)
`pnpm --filter @pac/service golden`。打**真 HTTP 端点**,不 mock 任何一层。
⚠️ 之前这条命令**写在用例文件的注释里、但根本不存在** —— 那 9 条用例从来没被跑过
(和 `basisNote` 同一个形状:只写不读)。
⚠️ 那三个"做之前要想清楚"的问题,答案是:mock-login 集团级 leader / `GOLDEN_CLINIC_ID`
可配(默认上海世纪公园,候选够多)/ **不设阈值**——输出的是通过率,改动前后各跑一次比数,
⛔ 不做成"低于 X% 就红"(这一层天生有方差,卡阈值只会让人去调阈值)。
### 依赖关系
......@@ -244,3 +248,41 @@ components/assistant/
| **P4** | 出第二版后,第一版卡片显示「已作废」且按钮禁用了吗? |
| **P5** | 每个写工具都能回答「模型怎么知道它做对了」吗? |
| **P6** | golden set 跑得起来吗?删提示词前后它的通过率变了吗? |
---
## P7 · 2026-08-15 实测批次(提示词分层 + 状态可见 + 测试基建)
> 这一批**不是按计划做的** —— 是一整天走查 + 跑批**逼出来的**。
> 每条都指得出实测事故,⛔ 没有一条是"顺手整理"。
### 做了什么
| # | 改什么 | 落点 |
|---|---|---|
| 27 | 提示词按「什么会让它变」**分五层**(装置 / 诚实 / 语气 / 角色 / 现场),现场段按 `选人 → 分人 → 他确认 → 确认之后` **四步分节** | `assistant-prompts.ts` |
| 28 | 次要业务线走 **pull**:系统提示词只留索引,模型用 `open_playbook` 按需取正文 | **新建** `assistant-playbooks.ts` |
| 29 | **`rosterCount`** —— 「在岗 N 人 × 每天几通 × 时效」里的 N 由算 `batchSize`**同一行**赋值 | `packages/types` + proposal |
| 30 | **`SheetSnapshot` + `get_current_sheet`(拉)+ 回声带当前值(推)** —— 草稿态服务端查不到,模型此前只能把对话里几行回声自己累加 | 三端 |
| 31 | 重算类载体**原样带回人群条件**(refill 的 `narrowedBy`、快照的 `criteria`) | 三处载体 |
| 32 | **契约测试** —— 测"两端各自都对、错在交接" | **新建** `tests/assignment-contract.spec.ts` |
| 33 | golden runner + 用例格式加三维:`mustCallWith`(参数)/ `mustCallInOrder`(先后)/ `mustCallAfterText`(就地开放) | `tests/golden/` |
### 测试基建抓到了什么(**这是它存在的理由**)
| 谁抓的 | 抓到什么 |
|---|---|
| golden 首跑 | 两条红 —— 诊断出**跑批器不够真**`given` 只是文字,而新增的 `get_current_sheet` 会如实回答"没有确认单"。⇒ 用例格式加 `sheet`**用例的世界模型会随产品过期** |
| golden 再跑 | `SheetSnapshot` **没带人群条件** —— 模型原话:「刚才我按全部候选重算了一版,结果对不上……跟您眼前那批完全不是一回事」。与 refill 丢 `narrowedBy` **是同一类错**,我修了那条却在新造的快照里又漏一次 |
| 契约测试首跑 | **第七条静默改动路径**`removeAgent`:移除某个客服 = 他名下条目一并移出)—— 补回声时我数了六条,偏偏漏了它,而它一次移走一整个客服名下的全部条目 |
### 三层要三种测法(⛔ 别指望一种覆盖)
| 层 | 用什么 | 判据 |
|---|---|---|
| 纯函数(落人 / 取数 / 判据 / 事实投影) | jest 单测 | 通过 / 不通过 |
| **契约**(谁传什么、谁压过谁) | 源码级断言(同 `mcp-clinic-scope`) | 同上 —— 它让**纪律在源码上可数** |
| **模型行为**(讲述顺序 / 就地开放 / 不编数) | golden 跑批 | **通过率**,⛔ 不是通过/不通过 |
> 🔴 当天为定一句话,同一份提示词的四种写法各跑三遍才分得出好坏;
> 「就地开放」手工测四次只对两次。**单跑一次什么都证明不了,绿了也可能是运气。**
......@@ -5,7 +5,7 @@
| | |
|---|---|
| **状态** | 定稿(Locked) |
| **状态** | 定稿(Locked)—— ⚠️ 但**会随实测改判**:2026-08-13/14/15 各改过一轮,§五那张表曾漂出四行。改代码时回来对一眼,⛔ 别把「Locked」读成「不会变」 |
| **性质** | Agent 行为规格 —— 实现按此对齐 |
| **⚠️ 立场** | **这是状态图,不是流程脚本。** 描述「有哪些状态、哪些是确定性的」,⛔ 不描述「模型必须按什么顺序调工具」。⛔ 不得据此做工具门禁(B1 / C4) |
......@@ -121,6 +121,21 @@
**代价不对称**:把「人群」误判成「派法」→ 主管以为条件生效了、其实没有(**静默错**);反过来只是多算一次。
**拿不准时一律走重跑。**
### 🔴 重跑时,人群条件必须**原样带回**(2026-08-15 补,当天栽了两次)
「重跑」不是"重新算一遍",是"**按同一批人**重新算一遍"。少带一格条件,圈出来的就是另一批人 ——
而两边都不报错,界面只显示「重新排了一版」。
| 必须带回 | 漏了会怎样 |
|---|---|
| 治疗项 · 时间档 | 按全池重算。实测:主管说「改成 200 人」,模型手里只有确认单本身(120 人、8 位客服、时效 1 天),不知道原来是哪一格 —— 它自己的话:「刚才我按全部候选重算了一版,结果对不上……跟您眼前那批完全不是一回事」 |
| 主管自己加的那几刀(`narrowedBy`) | **退回整格**。实测「换无专属客服的患者补上」漏传 `narrowedBy`:候选 2,178 vs 296,差 1,882 人 —— 他刚圈掉的人全回来了 |
**凡是能触发重算的载体都要带全这一段**,目前有三处:
`propose_assignment` 的入参 · `RefillProposalRequestSchema`(「补上」那颗按钮)·
`SheetSnapshot.criteria`(模型查当前确认单时的唯一依据)。
⚠️ 契约测试 `assignment-contract.spec.ts` 逐处锁着 —— 加第四处载体时,那份也要加一条。
### ⛔ 「怎么改这张单」不由模型讲
改单的操作方式(拖拽、点选、可改哪几项)写在**卡片的操作说明**里常驻。
......@@ -137,9 +152,20 @@
服务端产出**结构化事实**(⛔ 不是成品句子,G6),模型据此组织语言。
**呈现顺序锁定为:怎么选的 → 怎么派的 → 引导节点**(即「**已排好的在前,要他定的在后**」,2026-08-06 产品定)。
理由:主管要先知道**这批本身是什么样**,再看还剩什么要他处理。
⚠️ `assistant-prompts.ts` 第 0.5 条里那句「要他动手的那块永远排第一」与此冲突,**以本文为准**,实现时一并改掉。
**呈现顺序:怎么选的 → 怎么派的**,而**引导节点不是第三段** ——
它们**嵌在各自所属的那一段里**(2026-08-14 起):
| 节点 | 嵌在 | 键名 |
|---|---|---|
| `narrow` | 怎么选的 | `这一格还能怎么选` |
| `batch_size_basis` | 怎么选的 | `本批人数还能怎么调` |
| `pending` · `daily_overload` | 怎么派的 | `要你定的` |
> 🔴 **键嵌在哪一段里,就是它的位置说明** —— ⛔ 别再去提示词里加一条「这几条要讲在第 1 段」:
> 结构能表达的事就别写成规则(规则越多越不被遵守)。顶层从三段变两段之后,
> 每个可动手的东西都长在它要改的那一段上。
> ⚠️ 曾经这里写着「⚠️ `assistant-prompts.ts` 第 0.5 条与此冲突,以本文为准,实现时一并改掉」——
> **那个冲突早已不存在**(提示词 2026-08-14 重写成五层,没有编号条款了)。
### 事实① 怎么选的(**选谁**)
......@@ -195,17 +221,26 @@
### 确认前
| sev | 层 | key | 判定(程序) | 默认(no-op) | 选项(点击执行) |
|---|---|---|---|---|---|
| **1** | 需处置 | `pending` | `pending > 0` | 留着 = 这批不发给他们 | ① 各自归专属客服<br>② 铺平给在岗<br>**换无主患者补上**(见下)<br>④ 移出本批 |
| **2** | 需处置 | `daily_overload` | 分完后最重的那位 `手上总条数 > 15 × D` | 就按 D 天发,到期落回池子 | 延长时效 / 改每人每天几通 / 减少本批人数 |
| **2** | 需处置 | `short_supply` | 候选数 < N | 全给 | 换口径 / 放宽一档 |
| **3** | 需处置 | `highlight` | 画像维度偏离基线 ≥ 2×,取 Top 2–3 | 不特殊处理 | 见下 |
| **4** | 提示 | `expiry_default` | D 是默认值 | 用默认 | 改天数 |
| **4** | 提示 | `anchor_nondefault` | 口径非默认 | 保持 | 换口径重出 |
| sev | 层 | key | 给模型的标识 | 判定(程序) | 默认(no-op) | 选项(点击执行) |
|---|---|---|---|---|---|---|
| **1** | 需处置 | `pending` | 待分配 | `pending > 0` | 留着 = 这批不发给他们 | ① **换无专属客服的患者补上**(见下,带取人范围)<br>② 铺平给在岗<br>③ 各自归专属客服<br>④ 移出本批 |
| **2** | 需处置 | `daily_overload` | 打不完 | 分完后最重的那位 `手上总条数 > 15 × D` | 就按 D 天发,到期落回池子 | **只有一个:`时效改成 N 天`**(N 由程序算) |
| **3** | 需处置 | `narrow` | 换个条件选 | 候选 ≥ 50 且切完 ≥ 10 人;主管已收窄过 / 已重挑过则不出 | 就按这一格全部来 | 几刀 + 兜底(见下) |
| **4** | 需处置 | `batch_size_basis` | 本批人数 | `basis === 'default'` 且候选 > 基数 | 就按这个数发 | 改时效 / 改每天几通(**都带输入框、预填当前值**) |
**终止分支(不是引导节点)**`empty` —— `total == 0`,无法继续,只能放宽或换格。
> 🔴 **这张表 2026-08-15 重写过,因为它漂了四行。** 之前列着 `short_supply`(已删)、
> `highlight`(2026-08-13 被 `narrow` 取代,下文自己写了,表却没改)、
> `expiry_default` / `anchor_nondefault`(从未实现),却**没有** `narrow` 和 `batch_size_basis`。
> ⚠️ 真源是 `assignment-signals.ts` 的 `GUIDANCE_KIND` —— 那四个键就是全部。改代码时回来对一眼。
> 🔴 **`daily_overload` 的选项 2026-08-15 从三个砍到一个**(产品定)。删掉的
> 「改每人每天打几通」「减少本批人数」**一个数都不带**:既没算出目标值,也没有预填当前值的
> 输入框,点下去替主管说的那句话同样是空的(「减少一些」「按更少的通数算」)——
> 那个数只能由模型自己拍。⇒ **按钮不带数 ⇒ 严格弱于他自己说一句**(判据同 `cohort.widen` 删除时)。
> 这两件事仍然做得到:`batch_size_basis` 那两个输入框,以及常驻的「我自己说」。
#### `daily_overload` 是**工作量预估**,不是异常告警
N 按「在岗 × 每天 15 通 × 时效」算,**只算新增、⛔ 不扣在手** —— 所以只要谁手上还有东西,
......@@ -243,8 +278,14 @@ N 按「在岗 × 每天 15 通 × 时效」算,**只算新增、⛔ 不扣在
> 🔴 **措辞的要害:这是换个条件重新圈初选人群,⛔ 不是在已排好的那 N 个里再挑。**
> 原来标题写「这批 2663 人还能再筛一刀」,产品走查当场读成「在本批 495 人之后的进一步筛选」——
> 「再筛一刀」自带先后顺序的暗示,而「这批」在这条链路里同时指过候选集和本批。
> ⇒ 一律说**「这一格」**(他就是从矩阵格子点进来的),动词用**「圈」**不用「筛」,
> `why` 里明写「换的是这一格圈进来多少人,本批人数跟着重新估」。
> ⇒ 一律说**「这一格」**(他就是从矩阵格子点进来的),
> `why` 里明写「换的是这一格选进来多少人,本批人数跟着重新估」。
>
> ⭐ **动词是「选」**(2026-08-15 产品定;此前是「圈」,更早是被否掉的「筛」)。
> 理由不是文风:这条线的四步已经叫 `选人 → 分人 → 他确认 → 确认之后`
> (系统提示词、`propose_assignment` 描述、`modelFacts` 键名都用这套),
> 按钮上写「圈」等于给同一个动作起第二个名字 —— 主管读到的和模型讲的对不上。
> ⛔ 「筛」仍然不许用(自带"在已排好的人里再挑一遍"的先后暗示)。
| 刀 | 判据 | 实测(拔牙 · 3 年以上,候选 2,509) |
|---|---|---|
......@@ -252,13 +293,17 @@ N 按「在岗 × 每天 15 通 × 时效」算,**只算新增、⛔ 不扣在
| 转介绍达人 | 家庭型 ∪ 社交型 | 2 人 → ⛔ 不给(<10) |
| 权益身份 | 这批里**最多的那一项** | 儿牙会员 76 人(3%) |
| 获客渠道 | 同上 | 走入 804 人(32%) |
| 兜底 | —— | 「按别的条件」→ 模型去调 `get_cohort_attributes` |
| 兜底 | —— | 「按别的条件」→ 模型去调 `get_cohort_attributes` |
闸只剩两条:**候选集 ≥ 50****一刀切完 ≥ 10 人**
> 🔴 **⛔ 不给「最多的那一项」加占比闸。** 权益那种最高项只占 3% 的维度,一刀下去批次会从
> 495 人缩到 76 人 —— 但**人数就写在按钮上**,他点之前看得见(②可见可撤),
> 重排后 `short_supply` 还会再兜一次。加闸等于又替他判断了一次。
> 495 人缩到 76 人 —— 但**人数就写在按钮上**,他点之前看得见(②可见可撤),点完嫌少还能重来。
>
> ⚠️ 这条的理由 2026-08-15 修过一次:原文还写着「重排后 `short_supply` 还会再兜一次」——
> **那句话早就不成立了**(`short_supply` 加了 `narrowedBy` 闸之后恰恰在收窄出来的那一版不出,
> 而它现在整条已删)。⇒ **拿"另一条判据会兜住"当理由时,去看一眼那条判据当下的闸。**
> 结论没变,靠的是"点之前看得见 + 点完能重来"这一条,⛔ 不再靠任何兜底。
> 🔴 **消费的门槛是批内相对的**,文案必须写「这批的平均」。实测全库平均 ¥4,442,
> 而「拔牙 · 3 年以上」这一格只有 ¥720,差六倍;写成「高消费客户」他就会拿去跨格子比。
......@@ -280,21 +325,31 @@ N 按「在岗 × 每天 15 通 × 时效」算,**只算新增、⛔ 不扣在
每条节点带 `stage`**由程序定**,⛔ 不靠提示词让模型自己判断该讲在哪一段:
| stage | 改的是 | 节点 | 出现在 |
| stage | 改的是 | 节点 | 嵌在哪一段 |
|---|---|---|---|
| `cohort` | 圈进来哪些人 | `narrow` · `short_supply` | 第 1 段,紧接正文 |
| `size` | 这一批发多少 | `batch_size_basis` | 第 1 段,`cohort` **之后** |
| `dispatch` | 怎么派 | `pending` · `daily_overload` | 第 3 段「要您定的」 |
> 🔴 **`cohort` 与 `size` 必须是两个键**,虽然都讲在第 1 段。合成一个键时模型会拿
> 一个标题罩住两组按钮 —— 走查抓到「还能怎么圈:」下面挂着「改时效 / 改每天几通」,
> 而改时效根本不是圈人。⇒ **一个键只说一件事**。
| `cohort` | 选进来哪些人 | `narrow` | 「怎么选的」 |
| `size` | 这一批发多少 | `batch_size_basis` | 「怎么选的」,`cohort` **之后** |
| `dispatch` | 怎么派 | `pending` · `daily_overload` | 「怎么派的」 |
> 🔴 **`cohort` 与 `size` 必须是两个键**,虽然都嵌在同一段。合成一个键时模型会拿
> 一个标题罩住两组按钮 —— 走查抓到「还能怎么选:」下面挂着「改时效 / 改每天几通」,
> 而改时效根本不是选人。⇒ **一个键只说一件事**。
>
> 🔴 **开放的粒度是「一件」不是「一段」**(2026-08-15 实测)。工具描述里一度写着
> 「在**它所属的那一段**讲完之后开放」,而「怎么选的」这一段里**有两件**
> (换个条件选 / 本批人数)—— 给了段这个粒度,模型就按段攒,两排按钮叠在段末。
> 它的思考原文:「Both 换个条件选 and 本批人数 belong to 选人 section,
> so calling them together after that section seems fine」。⇒ 一件一件、就地开放。
> ⚠️ 顺序 `cohort → size`,⛔ 不许反:换人群会让 N 重新估一遍
> (实测候选 2509→312 后,本批 495 被压成 312),先调 N 那下就白调了。
⚠️ `编号``place_guidance` 的位置句柄)在三组之间**连着排** —— 各从 1 数起的话,
几个「1 号」指向不同的东西,模型调 `place_guidance 1` 会把按钮插到另一条的解释下面,且不报错。
⚠️ 句柄是**中文标识**`show_guidance``id`,取值域见 `GUIDANCE_KIND`),
**不是序号**(2026-08-14 改)。原来给的是编号,模型得自己维护「我讲的这段是第几条」
这套记账;标识让它按**意思**对上。
> 🔴 裸台实测:去掉编号、工具改成传标识之后,三条引导它只讲了一条也只调了一次 ——
> **标识不只是句柄,它承载「这是可以逐个处理的几件事」**。
> ⚠️ 工具名也从 `place_guidance` 改成了 `show_guidance`。
### 交互形态:点击为主,自由输入兜底
......@@ -319,15 +374,21 @@ N 按「在岗 × 每天 15 通 × 时效」算,**只算新增、⛔ 不扣在
**两条路收敛到同一个 `intent` 契约** —— 这样「按钮能做的、说话也能做」是结构保证,⛔ 不是提示词里的一句叮嘱。
### 确认后
### 确认后:**没有引导节点了**
| key | 判定 | 默认 | 选项 |
|---|---|---|---|
| `no_benefit` | 已确认 且 `benefit == null` | 不带 | 带一个(填原文) |
🔴 **2026-08-15 产品定:确认单里不做福利引导。** 此前这里有一条 `benefit_missing`
`stage: 'post_confirm'`),由界面在确认成功那一刻挂进这张单 —— 主管点完确认,
卡片里长出一块黄色引导。现在整条链路(节点、`postConfirm` 参数、`settleSheet` 合并、
只读态可点)都已删除。
⚠️ **福利引导刻意放在确认之后**:它只影响**此后生成**的话术,所以不设时限;
而客服从确认那一刻起陆续打开这批单,**每过一会儿能用上的人就少一个** —— 这是提醒的最佳时机。
**只提一次**,主管说不用就不再提。
**福利这件事整个前移到确认之前**:模型在他按下确认前问一句
(提示词 ⑤ 现场的「他确认」那一节:「要带现在说,不带就算了」)。
理由没变 —— 福利只影响**此后生成**的话术,而客服从确认那一刻起陆续打开这批单,
**每过一会儿能用上的人就少一个**;⇒ 与其确认后补救,不如在最后一个自然时机问掉。
⚠️ 代价是知情的:**点确认不触发模型发言**,所以确认之后现在一句提醒都没有。
这是取舍(③已经问过一次),⛔ 别用"前端拼一句成品话塞进对话"去填 ——
那是署助手的名而它根本没说过,被否掉过两次。
**绝不许说「带福利成功率更高 / 转化率提升」** —— 本系统不统计成功,那个结论编不出来(A2)。
---
......@@ -372,6 +433,20 @@ N 按「在岗 × 每天 15 通 × 时效」算,**只算新增、⛔ 不扣在
⛔ 不许静默变灰、不许直接消失 —— 主管要能看见自己出过几版、以及哪一版才是当前的。
4. 刷新页面丢草稿是可接受的(重新出一版即可),**暂不落库**
### 🔴 草稿态**模型看不见** —— 要查 `get_current_sheet`(2026-08-15 加)
确认之前,主管在卡片上做的改动(改派 / 移出 / 改时效 / 设福利)**只存在于浏览器里**
服务端手上只有最初生成的那一版提案。所以模型对"这张单现在什么样"只有两条来路:
| | 谁推给它 | 覆盖 |
|---|---|---|
| **推** | 每次改单往对话里追加一行回声,`modelText` 后缀带当前值 | 改动发生的那一刻 |
| **拉** | `get_current_sheet`(无参数,读随请求捎来的 `SheetSnapshot`) | 任何时候 |
⚠️ 在这之前它只能把对话里几行回声**自己累加**,而手动拖动 / 点 × / 改时效这几条路
**当时连回声都没有** —— 卡片上写着 3 天,它嘴上还说 1 天,两边都不报错。
⛔ 别去给服务端加"草稿回传"的写路径:多一份要维护的状态,只为省模型一次加法。
---
## 八、模型的自主权边界
......@@ -379,7 +454,7 @@ N 按「在岗 × 每天 15 通 × 时效」算,**只算新增、⛔ 不扣在
| ✅ 模型决定 | ⛔ 模型不许决定 |
|---|---|
| 理解自由输入(入口 C、节点外的话) | 哪个引导节点命中 |
| 要不要先查分布再 | 任何数字(A2) |
| 要不要先查分布再 | 任何数字(A2) |
| 判断动的是「派法」还是「人群」 | 谁分给谁 |
| 组织语言(G6) | 引导节点的默认值 |
| 拿不准时反问(B2) | 是否落库(A3) |
......@@ -410,6 +485,7 @@ N 按「在岗 × 每天 15 通 × 时效」算,**只算新增、⛔ 不扣在
| 候选为 0 | 放宽一档 / 换治疗项 / 不限时间档 |
| 草稿 `active`,有需处置的节点 | 逐条列出未处置的节点 + 各自的默认;并说明「都不动也可以直接确认」 |
| 草稿 `active`,无需处置的节点 | 直接确认 |
| 草稿 `active`**他刚在卡片上动过手** | 先 `get_current_sheet` 看现在什么样,⛔ 别拿出方案那一版的数去算 |
| 草稿 `superseded` | 提示这版已作废,当前有效的是最新那版 |
| 已确认,无福利 | 可补挂福利;否则等客服处理,随时可跟踪 |
| 已确认,有福利 | 等客服处理;可跟踪;限时内可撤销 |
......
......@@ -288,8 +288,11 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
而主管对自己团队的节奏有判断(「明天就要」= 1 天)。⛔ 别用三个拍的档位限制他。
`maxThisBatch` **不是"容量"** —— 它是一次性名额,不是这个人的上限。
精调与基数一样会被沿用,所以卡片上必须标「精调」并在 basisNote 里点名 ——
一条上个月的临时精调如果静默沿用三个月,没人会发现。
精调与基数一样会被沿用,所以必须**点名到人** —— 一条上个月的临时精调如果静默沿用三个月,没人会发现。
> ⚠️ **点名的出处 2026-08-14 换了**:原来写在 `basisNote` 里,而那个字段**只写不读**
> (服务端算一遍、随确认单发到浏览器、然后被丢掉)—— 也就是说这个能力早就不在了,
> 删掉 `basisNote` 只是让它显形。现在唯一的出处是 `modelFacts` 的 **`他单独设过的`**
> (卡片上也没有:精调只在确认时随请求回传)。⛔ 别再往 `basisNote` 里加东西,那个字段已删。
时效精调落到 `followup_plans.assignment_expires_at`(写路径早已支持逐条覆盖)。
**为什么是"沿用上一次"而不是"每次问"**:确认单本来就是给主管调的 ——
......@@ -1040,8 +1043,11 @@ artifact iframe 是 `sandbox="allow-scripts"` + CSP `connect-src 'none'`,**卡
> 「已处理」和「超期」同时成立:主管看到「薛玫 超期 3」以为她压着单没动,
> **实际上她打了电话、约好了下次** —— 干得最好的那个被指责了。
> ⭐ **批次的人话名字**(`label`,服务端唯一生成点)——「8/3 23:35 · 牙周治疗 · 窗口内 · 9 人 · 2 位客服」。
> 没有批次列表页时,这是主管指认一批的**唯一抓手**(「撤销今天下午牙周窗口内那批」)。
> ⭐ **批次的人话名字**(`label`,服务端唯一生成点)——「8/3 23:35 · 牙周治疗 · 三个月到半年 · 9 人 · 2 位客服」。
> 没有批次列表页时,这是主管指认一批的**唯一抓手**(「撤销今天下午牙周那批」)。
> ⚠️ 档位名用**当前显示名**(三个月内 / 三个月到半年 / …)——「黄金期 / 窗口内」是
> 2026-08-11 换掉的旧名,⛔ 别在示例里留旧名:列头写着一个词、例子写着另一个,
> 主管会以为是两种东西(换名的理由见本文 §档位那节)。
> ⛔ 不许让模型自己拼:措辞会在两轮之间漂,主管就对不上"上次说的那批"。
> ⚠️ 时间按**宿主时区**、精确到**分钟** —— 同一个矩阵格子一天可能分好几批。
>
......
......@@ -873,6 +873,29 @@ export const SheetSnapshotSchema = z.object({
benefit: z.string().nullable(),
/// 逐人:现在谁手上几条
byAgent: z.array(z.object({ name: z.string(), count: z.number().int() })),
/**
* 🔴 **这批人是谁** —— ⛔ 不许省(2026-08-15 golden 跑批当场抓到)。
*
* 快照第一版只带了"分得怎么样"(几条、几位客服、时效、福利),主管说
* 「这批改成 200 人」时模型要重跑 `propose_assignment`,却**不知道原来那一格是什么**。
* 它的原话:「我这边只看到确认单本身(120 人、8 位客服、时效 1 天),
* 没留下当时选的是哪个治疗项、哪个时间档 —— 刚才我按全部候选重算了一版,
* 结果对不上……跟您眼前那批完全不是一回事」。
* ⇒ 要么它回头问主管(白跑一轮),要么**不带条件重算**(静默换成另一批人,更糟)。
* ⚠️ 这与「换无专属客服的患者补上」丢 `narrowedBy` 是**同一类错**:
* 重算时把人群条件丢了,而两边都不报错。⛔ 凡是"能触发重算"的载体都要带全这一段。
*/
criteria: z.object({
potentialTreatment: z.string().nullable(),
temperature: z.string().nullable(),
/// 主管自己追加的那几刀(没加过则为 null)—— 重算时同样要原样带回
narrowedBy: z
.object({ personaTags: z.string().optional(), minSpendYuan: z.number().optional() })
.nullable(),
/// 这一格一共多少人 / 这一版估了多大 —— 「改成 200 人」够不够得着,看这两个数
candidateTotal: z.number().int(),
batchSize: z.number().int(),
}),
});
export type SheetSnapshot = z.infer<typeof SheetSnapshotSchema>;
......
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