# 分配 Agent · 改造规划

> 把现有助手改造到 [assignment-agent-flow.md](./assignment-agent-flow.md) 定义的行为，
> 并按 [agent-doctrine.md](./agent-doctrine.md) 的教条整理代码组织。

| | |
|---|---|
| **状态** | P0–P6 已落；P7（2026-08-15 实测批次）见文末 |
| **起点** | 现有实现 —— 服务端 5 文件约 1600 行，前端 3 文件约 3100 行 |
| **原则** | 能用的保留；乱的整理；错的重写。⛔ 不为了整齐而重写已经正确的东西 |

---

## 一、不动的部分

这几处已符合教条，**重写等于倒退**：

| 保留 | 依据 |
|---|---|
| `mcp-auth.service.ts` 整条鉴权链路 | D1–D3 全中，无服务账号，身份随每次调用传递 |
| MCP 工具的 scope 下推 + `resolveClinicId` | D3 |
| 侧信道机制本身 | E1 |
| 条件注册 + 能力分桶缓存 | D4 |
| `assertRevokeActuallyHappened` 前端对账 | C2 在线埋点 |
| `streamText` 自主循环（含 8 步上限） | B1 |
| 代码里那些带日期的踩坑注释 | 它们是**证据**，⛔ 不许在重构中丢掉 |

---

## 二、分阶段

### P0 · 独立小改（不依赖架构改动，可立刻做）

| # | 改什么 | 落点 | 收益 |
|---|---|---|---|
| 1 | 工具返回值里的取值码 → 中文 | `mcp-server.factory.ts` + `plan.service` 返回映射 | **直接删掉提示词里两页「不许说出取值码」** |
| 2 | 长描述 → 返回值 `_guide` | `get_assignment_detail`（35 行）、`list_assignment_batches` | F10，工具清单瘦身，常驻成本归零 |
| 3 | `stopWhen(8)` 到顶时 log + 告知前端 | `assistant.service.ts` | 现在是静默截断 |
| 4 | `SYSTEM_PROMPT` 改掉「为牙科诊所的客服人员提供帮助」 | `assistant.service.ts` | 主管也在用，与 `DISPATCHER_EXTRA` 打架 |

### P1 · 引导节点（核心新增）

| # | 改什么 | 落点 |
|---|---|---|
| 5 | `Signal` 类型 + 6 个判定器 + 需处置/提示分层 | **新建** `plan/assignment-signals.ts` |
| 6 | **基数改造**：每人每日 15 × 在岗 × D，⛔ 不再沿用上次 | `assignment-proposal.service.ts` |
| 7 | `AssignmentProposal` 加 `signals[]` | `packages/types` |
| 8 | ~~画像亮点基线（同治疗项池子的分布）~~ → **`narrowFacts` 一次查完，不比基线**（2026-08-13） | `cohort-attributes.service` |
| 9 | **intent 契约**：节点选项 → intent；`edit_assignment_sheet` 拆成意图级工具，与按钮共用同一组 intent | **新建** `plan/assignment-intents.ts` |
| 10 | 确认单卡片渲染节点区 + 选项按钮 | `assignment-confirm-sheet.tsx` |

> ⚠️ 第 10 项有现实障碍：该文件已 **1540 行**，加节点区之前必须先按 §三 拆分。

### P2 · 事实与措辞分离

| # | 改什么 |
|---|---|
| 11 | `selectionNote` / `basisNote` / `pendingNote` 成品句子 → **结构化事实对象**（两段） |
| 12 | 删掉 `propose_assignment` 返回值里那 25 行指令 |
| 13 | 散落各工具描述里的措辞禁令 → **一处写作规范** |
| 14 | 删掉 `###` 骨架规则 —— 结构由 `signals` 决定，不再靠模型自觉 |

**这一步一次性消掉三类问题的根**：背成品句子、复述卡片数字、改写措辞。

### P3 · 上下文与历史

| # | 改什么 |
|---|---|
| 15 | `toApiMessage` **分层回传**工具调用（名字 + 关键参数 + 结果摘要） |
| 16 | 小数据给原文：候选 ≤50 人时明细进上下文，⛔ 不必一律走侧信道 |

> ⚠️ 15 是本轮唯一会显著改变模型行为的改动，建议单独上、单独观察。

### P4 · 草稿状态机（可与 P1 并行）

| # | 改什么 |
|---|---|
| 17 | `active / superseded / confirmed / cancelled`，**新版生成即作废旧版** |
| 18 | 作废卡片显示「已作废」+ 确认按钮禁用（⛔ 不许静默变灰、⛔ 不许消失） |
| 19 | edit 作用于唯一 `active`，删掉「倒着扫消息找最后一张」 |

### P5 · 工具补齐

| # | 改什么 | 依据 | 状态 |
|---|---|---|---|
| 20 | `explain_assignment(patientId)` —— 「为什么这个人给了张悦」 | F5 | ✅ **已落** |
| 21 | `validate(draft)` | F5 | ⏸ **暂缓** —— 草稿不落库（§七 4），服务端拿不到它；卡片已在本地校验。等草稿真要落库时再做 |
| 22 | 兜底 `query_cohort(枚举化 filters)` | F8 | ⬜ 未做 |
| 23 | 各 list 工具补 count 模式 | F9 | 🟡 **已有一半** —— `recall_queue_stats` / `list_recall_queue` 就是这个模式；缺的是把它变成通例 |
| 24 | 测 `get_persona`/`get_facts`/`get_recall_plan` 使用率 → 可能折成 `overview(sections)` | 同质化 | ⬜ 未做（要先有线上调用统计） |

> ⭐ **20 的依据全在库里**：`followup_plans` 上有一组「分配当时的决策快照」列
> （`assign_strategy` / `dedicated_cs_at_assign` / `dedicated_cs_last_visit_at` /
> `priority_score_at_assign` / `selection_mode`），schema 注释写的正是
> 「为了半年后回答'当初为什么选了这个人'」。⛔ 不拿"现在"的值去解释"当时"的决定 ——
> 专属客服会被摄入覆盖、优先级分会被引擎就地改分，两者都不留痕。

### P6 · 减法与评测（**必须最后**）

| # | 改什么 | 状态 |
|---|---|---|
| 25 | 提示词减法 | ✅ **已落**（见下） |
| 26 | golden set：断言工具调用序列 | ✅ **已落**（2026-08-15 补上 runner，见 P7） |

> ⚠️ 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**（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% 就红"（这一层天生有方差，卡阈值只会让人去调阈值）。

### 依赖关系

```
   P0  ──────────────────────────────▶ 完全独立，随时做
   P4  ──────────────────────────────▶ 独立，可与 P1 并行
   P1 ──▶ P2 ──▶ P6
   P3  ──────────────────────────────▶ 独立，单独观察
   P5  ──────────────────────────────▶ 独立，按需加
```

**建议起步**：P0 + P4（都独立、都不碰模型行为、都能立刻验证），然后 P1 → P2 连着做。
**`render_artifact` 整个后置** —— 实验性质，不阻塞主线。

---

## 三、代码组织

### 现状问题

| | |
|---|---|
| **职责混杂** | `assistant.service.ts` 457 行里混了三件事：MCP 工具适配、本地工具定义、streamText 循环。其中约一半是内联的工具描述字符串 |
| **描述内联拼接** | `tool({ description: '...' + '...' + ... })` 几十行拼接，改一个字要在里面找 |
| **同类内容三处散落** | 成品句子在 proposal service、解读规范在 tool description、措辞禁令在 prompt |
| **前端超大文件** | `assignment-confirm-sheet.tsx` 1540 行、`assistant-chat.tsx` 1007 行 |
| **类型分散** | `Block` 在 hook 里、`AssignmentProposal`/`SheetEditOp` 在 types |

### 目标结构 · 服务端

```
modules/assistant/
  assistant.service.ts        只剩：装配工具 + 跑循环
  assistant.controller.ts     只剩：SSE 转发
  tools/
    index.ts                  工具注册表（唯一清单）
    propose-assignment.tool.ts
    draft-intents.tool.ts     P1 拆分后的意图级工具
    render-artifact.tool.ts
  prompts/
    system.ts                 通用人设
    writing-style.ts          ⭐ P2 新增：唯一的写作规范
    dispatcher.ts             角色约束（P6 后大幅缩减）
    staff.ts

modules/mcp/
  model-facing.ts             ✅ P0 已落：取值码 / 桶名 → 中文（只翻译，不判断）
  guides.ts                   ✅ P0 已落：随返回值下发的解读规范（`_guide`）
                              ⚠️ 放这里而不是 assistant/ —— 与发出它们的工具同址

modules/plan/
  assignment-proposal.service.ts   只出提案
  assignment-signals.ts            ⭐ P1：引导节点判定
  assignment-facts.ts              ⭐ P2：两段结构化事实
  assignment-intents.ts            ⭐ P1：intent 契约与执行
```

### 目标结构 · 前端

```
components/assistant/
  assistant-widget.tsx
  assistant-chat.tsx          只剩消息流编排
  chat/
    markdown.tsx              从 assistant-chat 拆出
    tool-step.tsx
    artifact.tsx
  confirm-sheet/
    index.tsx                 从 1540 行拆
    signals.tsx               ⭐ P1：引导节点区
    summary-bar.tsx
    agent-group.tsx
    patient-row.tsx
```

### 规范

1. **一个工具一个文件**，导出 `{ name, description, inputSchema, execute }`；描述用顶部常量，⛔ 不内联拼接。
2. **工具描述里只写「怎么用这个工具」**；「怎么解读结果」走 `_guide`（F10）。
3. **⛔ 工具返回值里不许写给模型的指令。** 已经栽过一次：`get_assignment_detail` 的 `note` 字段混着内部指令，模型照抄贴给了主管。P2 之后返回值只有事实。
4. **跨端共享的形状一律定义在 `packages/types`**；前端只保留纯 UI 概念（如 `Block`）。
5. **文件行数软上限**：服务端 300 行 / 前端组件 250 行。超了先问「是不是混了两件事」，再拆。
6. **踩坑注释保留并统一格式**：
   ```ts
   // 🔴 2026-08-06 实测：<现象> → <结论>
   ```
   带日期、现象、结论三要素。**这些是证据不是啰嗦**，重构时随代码迁移，⛔ 不许顺手删。
7. **每条护栏标注归属**（C3）：`// [弥补模型]` 或 `// [业务要求]`。前者随模型变强要复查，后者永久保留。

### 术语表（代码与文档统一）

| 中文 | 代码标识 | 说明 |
|---|---|---|
| 引导节点 | `Signal` | ⛔ 不叫「灯」 |
| 确认单 | `sheet` | UI 侧的叫法 |
| 草稿 | `draft` + `state` | 数据侧的叫法，含 `active/superseded/confirmed/cancelled` |
| 动作契约 | `AssignmentIntent` | ⭐ 按钮与模型工具**共用同一个** |
| 事实两段 | `facts.selection` / `facts.dispatch` | 怎么选的 / 怎么派的 |
| 待分配 | `pending` | |
| 解读规范 | `_guide` | 随工具返回值下发 |

---

## 四、验收

每个阶段完成时至少要能回答：

| | |
|---|---|
| **P0** | 提示词里「不许说出取值码」那两页删干净了吗？ |
| **P1** | 每个引导节点都能构造出触发用例吗？默认全部是 no-op 吗？ |
| **P2** | `propose_assignment` 的返回值里还有指令性文字吗？（应为零） |
| **P3** | 模型能在第 2 轮引用第 1 轮的工具结果吗？上下文有没有变胖？ |
| **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 跑批 | **通过率**，⛔ 不是通过/不通过 |

> 🔴 当天为定一句话，同一份提示词的四种写法各跑三遍才分得出好坏；
> 「就地开放」手工测四次只对两次。**单跑一次什么都证明不了，绿了也可能是运气。**
