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: '重算丢条件 = 悄悄换成另一批人。收窄过的人群尤其致命:他刚圈掉的人全回来了。',
},
];
{
"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 跑批 | **通过率**,⛔ 不是通过/不通过 |
> 🔴 当天为定一句话,同一份提示词的四种写法各跑三遍才分得出好坏;
> 「就地开放」手工测四次只对两次。**单跑一次什么都证明不了,绿了也可能是运气。**
......@@ -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