# 召回分配 · 设计教条

> 门诊经理（主管）主导的召回批次分配功能。本文是**讨论过程中沉淀的教条与依据**，不是需求文档。
> 需求未定稿前不开工 —— 教条先行，实现在后。

| | |
|---|---|
| **状态** | 讨论中（Discussion） |
| **起始** | 2026-08 |
| **参与** | 产品（luoqi） + Claude |
| **进入规则** | **教条必须经产品确认才能写入本文**；未确认的一律留在「待确认」区 |
| **关联** | 认领机制现状见 `apps/pac-service/src/modules/plan/claim-guard.ts`；召回打分见 `priority-scorer.ts` |
| **开发规划** | [plan-assignment-dev-plan.md](./plan-assignment-dev-plan.md) —— 分期计划、契约裁决、风险登记 |

---

## 一、要解决的问题

召回池成千上万条，**头部反复被看、长尾无人碰**，池子形同虚设。根因不是客服懒，是纯自助认领的结构性盲区：

- **责任真空** —— 认领零成本，没有任何后续义务
- **无目的** —— 客服各捞各的，运营意图无法落地
- **无法归因** —— 做了什么、效果如何，事后说不清

分配的本质：**把「要不要做」变成「做了没有」**。

---

## 二、教条（已确认）

### T1 · 分配是批次运营，不是工单派发

目标是「选一批人、配一套打法、看效果」，**从不追求把池子分完**。
因此「主管扛不住全量」不是缺陷 —— 从设计上就不打算分全量。

### T2 · 每一步都必须收敛人群规模

生产线：初选 → 精选 → 确认单 → 分配 → 跟踪。
**主管只做判断，不做筛选**；筛选是助手的活。主管的操作面必须从「万」级压到「十」级。

### T3 · 不使用无数据支撑的因素

客服的**态度、能力**没有数据，不进决策。
分配依据只用**客观事实**（专属客服、在岗、负载）与**主管的显式指定**。

> 推论：不为客服建立系统性的能力/态度评分 —— 一旦分数可见即成绩效工具，会诱导行为扭曲。

### T4 · 福利挂在批次上，不挂在个人上

同一批共享同一个福利，才能归因（这批的转化率 = 这个福利的效果）。
v1 **轻量**：不核销、不接宿主福利数据，福利就是**话术勾子** + 归因标签。

**落地方式：走 prompt 输入，不走占位符。**

- ❌ 不用 `AGENT_IDENTITY_PLACEHOLDER` 那套确定性占位符 —— 那是给 **PII 与缓存**用的
  （人名不进 LLM、换客服不用重生成）。福利是**内容**不是身份 token，硬插一句会打断口语流；
  且三档话术输出形态差异大（稳健=模板填空 / 标准=自由段落 / 深度=多段分析），
  占位符要在每档各实现一次。
- ✅ 作为**事实输入**进 prompt，由 LLM 自然融入，**三档通用**。
- ⚠️ **必须带护栏**：只能陈述福利原文所含的内容，
  **不得追加条件、期限、承诺，不得改写或夸大**。
  落点同「高龄义齿 / 低龄种植」那两条年龄约束 ——
  `shared/fact-block.ts`（标准 + 深度）与 `tiers/stable/prompt.ts`（稳健）各加一处。
- 没配福利 → 整段不生成，不留空钩子。

### T5 · 召回不在多，在于精 —— 宁缺毋滥

一次批次宁可只做 100 人做透，不做 1000 人做浅。
**池子里剩下的不是遗漏，是还没轮到。**

> 「100」就是 T22 那个 **N 的首次默认值**，之后沿用主管上一次用的数。
> ⚠️ 它**仍然是默认值**：没有任何数据证明 100 比 80 或 150 好，等 T20 沉淀出
> 「批次规模 × 完成率」再替换。⛔ 别给它加上下限 —— 这是主管对自己团队的判断。

### T22 · 分配只有两个基数：**本批人数 N** 和 **时效**，且都沿用主管上一次的值

（2026-08-03 产品定；同日两次改判，下面把弯路一并记下来，别再走一遍）

| 基数 | 含义 | 首次 | 之后 |
|---|---|---|---|
| **本批人数 N** | 这一轮推多少人 | `min(在岗人数 × 20, 候选总数)` | `min(上一次的 N, 候选总数)` |
| **时效** | 这批单子多久没动就自动回池 | 3 天 | 沿用上一次 |

⚠️ 首次那个 **20 不是容量上限**，只是"第一次没有任何历史时，一批推多大"的估法，
之后就再也不出现（沿用主管上次用的数）。

🔴 **与候选总数取小的那一步压低的是 `target`，⛔ 不回写基数。**
候选不够是**这一批的偶然事实**：主管点一次 44 人的小格子，若把 44 存成基数，
以后所有批次就永远是 44 人，而他完全看不出为什么变小了（界面只会显示"沿用上次"）。
所以确认单同时给两个数：`batchSize`（基数，被记住的）与 `target`（本批实际，= min 之后）。

⛔ **没有"容量"这第三个数。** 它被删过一次，别再加回来。

> **弯路记录。** 曾经把「每人在手容量」当唯一旋钮、批次规模 `= Σ(容量 − 在手)` 推出来。
> 结果 **第二批必然是 0** —— 第一批把所有人填到水位 20，再算就是 `Σ(20−20)`。
> 主管想连着圈两批人分，只能把容量往上棚，而容量会被记住 → 下周的"习惯"是个虚高的数。
> 根子上是**一个数当了两个用**：「这轮推多少」和「一个人最多压多少」在第一批恰好一致，第二批就打架。
>
> 拆开之后发现**上限那一半根本不需要**：真正要表达的是**负载**，而负载不需要阈值 ——
> `inHand` 本身就是负载，落人走水位法直接拿它排序。再挂一条"不强制的上限"比没有更糟：
> 主管会以为系统在拦，其实没拦（T14：别给假证据）。

### 落人 = 三趟：**专属（封顶水位）→ 无主补空 → 有主改派**

（2026-08-03 定案。同日先后试过三种，把弯路一并记下来。）

目标是**又满又平**：N 个名额全部落地（⛔ 不因为"他的专属满了"就把人丢掉）+ 分完后大家在手量齐平。

1. **专属**：该患者有专属客服且在名册内 → 分给他，**但封顶在目标水位**。
2. **无主补空**：无专属 / 专属已离岗的患者 → 给当前在手最少的人。
   ⭐ 顺序在这里：这些患者**没有关系要顾，拿他们填坑零代价**。
3. **有主改派**：无主的用完还没填平 → 才把超出水位的专属患者改派给最空的人，标 `spread_overflow`。

⚠️ **第二、三趟的先后不能颠倒**：两趟都是水位法、总量一样，但**被拆散的专属关系数不一样**。
合成一趟的话，系统会随机地把某个有主患者改派出去，而同时某个无主患者落给了别人。

**目标水位 = (团队现有在手 + N) / 在岗人数**（向上取整，至少 1）。
**不是新旋钮**，完全由 N 推出来；存在的唯一理由是给第一趟封顶。

> **为什么必须封顶**（本地实测，充填 · 窗口外）：池子 1,081 人里 **755 人（70%）挂在同一个客服名下**，
> 而 17 位在岗客服中有 **10 位名下一个患者都没有**，自由患者（真无主 57 + 专属已离岗 62）只有 119。
> 不封顶时一批 340 人的分布是 **248 / 34 / 31 / 2×14**；封顶后是 **每人 20，完全齐平**。

⚠️ 改派**不是"抢客户"**：`spread_overflow` 的语义是「关系还在，只是这轮没轮到」。
⛔ 助手不许说成"系统重新分配了归属"。

⚠️ **但「铺平」这个词不进主管界面**（2026-08-03 走查：主管看不懂）。
它是内部算法词。确认单上只标「**专属**」，没标的自然就是"不是他的老客户" ——
同一个信息，用主管的语言表达。⛔ 数据不受影响：`assignStrategy` 仍逐条落库，T20 反推照常。
（T15 要求的是"溢出转铺平必须可见"，不是"必须以这三个字出现在汇总行"。）

**确认单的患者明细必须给姓名 + 病历号，⛔ 不许只给 id 前缀。**
主管展开客服看的就是"这 20 个人是谁"，给一串 `#912e5dae` 等于让他对着十六进制猜（T14）。

### 确认单上能做什么（2026-08-03 定）

四件**局部**操作，判据是**要不要重跑服务端算法**：不要，才放到卡片上。

⚠️ 下面这张表里"卡片上做"的四件事，**助手也能替主管做**（同一套语义，见下节）。

| 卡片上做 | 回对话做 |
|---|---|
| 改批次时效（右上角「N 天后自动退回」） | 换人群（换治疗项 / 温度 / 画像条件） |
| 逐条改时效（1-7 天） | 改批次人数 N |
| 删掉某一条 | 按客服设本批名额 `maxThisBatch` |
| 把某一条**拖**给别的客服 | |
| 移除某个客服（连同他名下条目） | |

⚠️ 批次时效放在**卡片右上角**，与左边"拟分 N 人"同一层级 —— 它是"这一整批"的设置。
放在底部微调区会让人以为它跟条目上的逐条时效是一回事。
⚠️ 文案是「N 天**后自动退回**」而不是光一个「时效」：主管要知道到期会发生什么（单子回池），
不然这个数对他没有意义。

⚠️ 这不违 T13「微调只有两项」—— 那条防的是"卡片变成第二个筛选器"。
上面四件都是对**已经算好的结果**做局部修正，不产生新的人群、不重跑落人算法。

⚠️ 拖过的条目落库时 `assignStrategy` 改成 **`manual`**（主管指定）——
不改的话事后分析会把它算成算法的选择，而 T20 要反推的正是"算法选得准不准"。

### 「已分配」在池子口径里指什么（2026-08-03 确认）

矩阵格子 / 候选池 = **当前没人持有的活跃召回**：

| | 在池子里吗 |
|---|---|
| 已分配、**还在客服手上** | ❌ 不在（`assignee_user_id IS NULL` 已排除） |
| 分过但**已退回 / 到期回池** | ✅ **在**，只是排序上排到后面 |

第二行是**有意的**：退回是正常路径（T7），不能因为分过一次就永不再召。
⚠️ 但「没被分过的排在前面」这句话**只在池子里真有回池的人时才说** ——
本地实测池子 1094 人里回池的 0 人，说了等于让主管以为系统在防一个不存在的情况。
条件化之后：有回池的人才说「之前分过又退回的 N 人排在后面」，否则只说「优先级高的排在前面」。

### 助手能直接改确认单（`edit_assignment_sheet`，2026-08-03）

走查实录：主管说「把杨丽华移出这个确认单」，助手回「我这边没法把某一位患者单独剔除，
您先点确认、之后在批次详情里把她单条退回池子」—— 把界面能做的事推回给主管手工做，
而那张卡就在他眼前。

现在给了它一个本地工具，能做的**与卡片完全一致**，外加一件卡片上没有的：**设批次福利**（T4，落 `attributes.benefit.text`，
生成话术时作为事实输入进 prompt —— 那条链路本来就通，缺的只是"主管怎么把福利填进去"）。
⚠️ 福利在卡片上**只读**：在 400px 的卡里放自由文本框，主管十有八九会写成一段带承诺的话，
而"不得追加条件/期限/承诺"那道闸在话术生成侧，不在这里。让他口述、助手原话写进去。

⭐ **指令用姓名，不用 planId** —— 那些 id 从来没进过模型上下文（确认单走侧信道，故意的）。
模型负责"听懂"，**界面拿自己手里那份确认单去匹配**。这条不能破：一旦为了让模型"能指名道姓"
而把明细喂给它，就等于把当初走侧信道的理由（几百个 uuid、幻觉、成本）全部退回去。

⚠️ **结果由界面回一句进对话**，⛔ 模型不许自己宣布成功：它看不到卡片，
匹配不到 / 同名 / 客服不在本批都是界面才知道的事。那一句同时进模型上下文，
下一轮它才不会替系统撒谎（T14）。

🔴 **同名歧义一律不猜**：
- 两位同名患者 → 不执行，让主管用病历号指定；
- 同一个名字**既是本批客服又是本批患者**（实测「张悦」就是）→ 不执行，让他说清是哪一个。
  猜错的代价不对称：按客服理解一下子动 20 条，而主管以为只动了 1 条。
  提示词里也定死了默认按**患者**理解。

### 撤销整批（卡片上的入口，2026-08-03）

确认后的卡片终态多一个「撤销这批」，30 分钟内可点。

⚠️ 窗口按**确认那一刻**算，⛔ 不是提案生成时间 —— 主管可能盯着确认单改了二十分钟才点确认，
那二十分钟不该算进他的补救窗口。到点按钮自己消失，⛔ 别让他点了才被服务端拒。

⚠️ 弹窗必须**先说「客服已经打开过的收不回来」**：撤销几乎总是部分成功，
主管以为"点了就全回来了"的话，他会停止补救（比如不再逐条去打招呼），
而那几条其实还挂在别人手上。结果也要**如实报三个数**
（`revoked / skippedTouched / alreadyReleased`），⛔ 不能只弹一句「已撤销」。

⛔ **不显示批次号**：`#9a9fc222` 是 uuid 前 8 位，对主管没有任何意义（他不会拿它去查什么），
只是占位置 + 制造"我是不是该记下来"的负担。模型那边照旧拿得到完整 id
（走 `list_assignment_batches`），与界面显不显示无关。

⭐ **助手撤销时卡片要跟着变**：`revoke_assignment` 是 MCP 工具、推不了侧信道，
但前端本来就收得到它的 `tool_result` —— 拿这个当同步信号，按 assignmentId 精确匹配。
不同步的话主管会看到两句互相矛盾的话：卡片写着「已分配」、助手说「已撤回 12 条」。

> 🔴 **踩过的坑：`key={i}` + 「把确认单钉到消息末尾」= 卡片状态全丢。**
> 助手每追加一句文本，sheet 在排序后数组里的下标就变 → React 卸载重建组件 →
> 主管删的条、拖的改派、逐条时效、以及"能不能撤销"的确认时刻**全部清空**。
> 症状是"确认完撤销按钮压根不出现"，但真正丢的远不止那一个按钮。
> 现在确认单按 `requestId` 做 key。⛔ 凡是**带本地状态**的 block，key 都不能跟着位置走。

> 🔴🔴 **踩过的坑：注入给模型的那句话把批次号截成了 8 位。**
> 确认后往消息流注入的是 `已确认分配:批次 #${id.slice(0,8)} …` —— 界面和模型**共用这一份**。
> 于是模型手里**只有短号**，主管一说「撤销」，它拿短号调 `revoke_assignment`，
> 服务端按主键精确查，必然报「批次不存在」。而卡片上的撤销按钮握着完整 id 一直是好的，
> **测了卡片那条就以为整条路都通**。
> 修法两层：① 文本块加 `modelText`（界面显示一份、喂模型一份，后者带完整 uuid）；
> ② 服务端 `resolveAssignmentId` **兜底认短号**，还要能从「批次 #c02e1b80」这种整句里抠出 id
> （实测模型就这么传）——撞到多个**报错不猜**（撤销是写操作，猜错就是撤了另一批人的单）。
> ⛔ 别只做 ①：短号会出现在对话历史、截图、主管口述里，模型没法把它还原成 uuid。

> 🔴🔴🔴 **最危险的一次：助手把「已撤销」**背**了出来。**
> 主管说「撤销批次 #a7e1b6de」，助手回 **「已撤销批次:收回 9 条。」** ——
> 而库里那批仍是 `confirmed`，9 条工单**一条没动**，卡片也还写着「已分配」。
> 它**根本没调 `revoke_assignment`**。
> 根因是我们自己给的便利：工具返回值被设计成「成品句子、原话转述」，
> 模型见过几次形状后**不调工具也能生成得一模一样**（连条数都算对了）。
>
> 这比报错危险得多：**报错主管会重试，假成功会让他停止补救** ——
> 他以为 9 个人回池了，实际还挂在客服名下，3 天后到期才会暴露。
>
> ⇒ 前端在每轮结束时**对账**：主管这轮在要求撤销 + 助手自称撤成了 + 本轮没有
> `revoke_assignment` 的 `tool_result` ⇒ 当场把事实贴出来。
> ⚠️ 措辞只**陈述观察到的事实**（"这一轮没有调用撤销工具"），⛔ 不断言助手撒谎 ——
> 主管问「刚才那批撤了吗」时助手如实回顾**也**不调工具，界面分不出这两种，
> 两种都说得通的措辞才是诚实的。
> ⛔ 不许改成"界面帮他补调一次" —— 撤销是写操作，不能由界面替主管做决定。
>
> 📌 更一般的教训：**凡是把"成品话术"交给模型转述的工具，都要有独立的执行证据**。
> 话术越顺口，模型越容易在没执行的情况下把它说出来。

⚠️ 移除客服 = **他名下的条目一并移出本批**（患者留在池子里，下批再说），
必须有二次确认并把这句话写在弹窗里 —— 否则主管以为只是把人从名单上划掉，
回头发现少了 20 个患者。要把这些人交给别人：先拖走，或回对话让助手重出一版。

> 🔴🔴 **踩过两次的坑：z 层级。** 助手窗是 `fixed z-[60]`（fab 61、数据流粒子 62），
> 而 shadcn 给**所有**浮层的默认层级是 `z-50` —— 于是任何在助手窗里打开的浮层都在它底下：
>
> | | 症状 |
> |---|---|
> | `Select` / `Popover` / `DropdownMenu` / `HoverCard` | 点了有反应（DOM 里 listbox 确实打开），**一个选项都看不见** |
> | `Dialog` / `AlertDialog` | 更糟：Radix 给 body 设 `pointer-events:none`，弹窗看不见、**整页却已被锁住** —— 用户看到的就是**卡死** |
>
> 两次都**不报任何错**，只能实测发现。已在 `components/ui/*` 统一定死：
> **浮层 z ≥ 70，模态 z ≥ 80**，六个文件顶部都写了这条约定。⛔ 别把 z 调回 50。

> **弯路一**：曾把「客服不能接别人的专属患者」做成**硬约束**（给不下就 unplaced）。
> 结果在上面那份数据下，「满」和「平」不可能同时成立 ——
> 严格齐平的上限是每人 9 条、一批只有 153 人，而那 755 个患者要几十批才轮得完。
> 改成"优先级"而不是"禁止"之后两者都成立了。
>
> **弯路二**：`AssignStrategy.SPREAD_OVERFLOW` 在硬约束那一版里"不再产生"，
> 现在又回来了。⛔ 别再把它当成死枚举清理掉。

**两个基数都可以按客服精调**（`agentOverrides: userId → {maxThisBatch?, expiresInDays?}`）：

| | 改什么 | 会不会换人群 | 入口 |
|---|---|---|---|
| `maxThisBatch` | **这一批**最多给他几条（0 = 这轮不给） | 会（少的那部分由别人接走，总数不变；但名单变了） | 回对话说一句，助手重出确认单 |
| `expiresInDays` | 他名下每条任务的到期 | 不会 | 卡片上展开那一行直接改（1-7 天下拉） |

⚠️ 时效**落到每一条召回计划**（`followup_plans.assignment_expires_at` 逐行写），
不是只在批次头上挂一个 —— 所以按客服精调、乃至将来按单条精调，都不需要动表结构。
⚠️ 卡片上给**全部 1-7 天**而不是 3/5/7 三档：档位是我们拍的，
而主管对自己团队的节奏有判断（「明天就要」= 1 天）。⛔ 别用三个拍的档位限制他。

⛔ `maxThisBatch` **不是"容量"** —— 它是一次性名额，不是这个人的上限。
精调与基数一样会被沿用，所以卡片上必须标「精调」并在 basisNote 里点名 ——
一条上个月的临时精调如果静默沿用三个月，没人会发现。
时效精调落到 `followup_plans.assignment_expires_at`（写路径早已支持逐条覆盖）。

**为什么是"沿用上一次"而不是"每次问"**：确认单本来就是给主管调的 ——
他第一次把人数和时效调到顺手，系统记住，**第二次起零输入**。
这是「先出全景确认单、再让主管反馈调整」这个设计的兑现点：调一次，以后省一次。
⛔ 既不能做成每次弹窗问一遍（违 T13 直出不追问），也不能永远用系统默认（第一次的调整白费）。

查找顺序：**该主管在该诊所的上一次 → 该诊所任何人的上一次 → 首次默认**。
值存在 `plan_assignments.criteria` 这个 Json 快照里（零新列），读不到就当没有，⛔ 不抛错。
⚠️ 老快照里 N 这个数叫 `target`（那时它是推出来的结果），**两个键都要认** ——
不认的话改版后所有人的"沿用"都会静默退回默认值，而界面上只显示"首次默认"，看不出丢了东西。

**确认单上必须写清三件事**（T14：没有证据的不许写进结论）：
① 值本身（本批 100 人 / 3 天）；② **出处**（沿用 7-28 那次 / 首次默认 / 本次指定）；
③ **分配后的水位**（「分完后每人在手 5~6 条，最高 康慧捧」）——
⛔ 这里只报事实，**不做"是否超载"的判断**，那个没有数据支撑。

⚠️ 「本批未分到」的人仍要列出来，但**措辞不能写「已满」** —— 没有上限这回事了，
他没分到是因为手上本来就最多（水位法没轮到）或被精调成 0。写「已满(0)」会自相矛盾。

### T6a · 初选 X 轴 = 画像的「潜在治疗」8 类，不是 PAC 治疗类目

矩阵横轴用 `persona_features.potential_treatment`（**8 类业务机会**），
**不用** `focusCategory`（7 类 PAC 内部技术类目 restorative/surgical/…）。

| | `focusCategory`（❌ 弃用） | `potential_treatment`（✅ 采用） |
|---|---|---|
| 语义 | 该做哪类治疗（给排除闸/话术用） | **有需求但没做的机会**（业务视角） |
| K07 正畸 | 混成一个 `orthodontic` | **按年龄拆**：潜在正畸(13-40) / **潜在早矫(3-12)** |
| K01+K03 残根 | 散在 surgical / restorative | 合成**潜在拔牙** |

**同源保证**：潜在治疗 = 复用召回 gap 核心（`PotentialTreatmentSelector` → 共享 `buildGapCore`），
只是**去掉了召回时间门**。所以矩阵与召回池天然自洽，不会出现「矩阵有人、点进去召回池没有」。

> 两轴天然互补：X 轴（潜在治疗）已去时间门，Y 轴（窗口温度）正好把时间维度补回来。

### T6 · 温度 = 该治疗项目自身的临床时间周期

不是客户价值、不是意愿、不是末诊天数，而是：

| 档 | 定义 |
|---|---|
| 🔥 热 | 黄金期内（`daysSince ≤ urgencyDayThreshold`） |
| 🌡 温 | 周期内（`urgencyDayThreshold < daysSince ≤ windowDays`） |
| ❄️ 冷 | 超周期（`daysSince > windowDays`） |

每个治疗项目**用自己的周期尺度**，归一化后**横向可比**。
阈值复用 `DiagnosisTreatmentMap` 现成配置，不新增口径。

#### T6′ · 落地口径（2026-08-02 定稿，实现见 `packages/types/src/temperature.ts`）

上面三行是**语义**，落地时有两处必须写死，否则各人实现各人的：

**① 在「温度」上聚合，不在「天数」上聚合。**

一个患者的同一业务标签常有多条 gap（实测本地库 41% 的 K02 患者有多条诊断，
跨度均值 452 天，而 K02 窗口只有 90 天）。

- ❌ 先 `Math.max(daysSince)` 再判档 —— **反单调**：上周刚查出的龋齿，
  因为身上还有一颗两年前的旧龋，整个人被判成「冷」。**多一条未满足需求反而更冷**，
  在召回池里说不通。
- ✅ **逐条 gap 用自己 K 码的窗口判档，再取最热**。

> ⭐ 这一条顺带把开发规划 **Q-6「8 标签 ← K 码一对多，无解」解掉了**：
> `extraction ← K01(180/90) + K03(90/60)` 根本不需要为标签另定一套窗口 ——
> K01 那条按 K01 判、K03 那条按 K03 判，各归各的。
> **原判「无解」是因为默认了聚合必须发生在天数层**；换一层聚合，T6 那句
> 「复用 `DiagnosisTreatmentMap`，不新增口径」就真的做到了，
> `canonical-codes.ts` 那句「不允许任一处再硬编码窗口/临界」也不用破例。

**② 存边界时刻，不存天数、更不存档位。**

`persona_features.data.detail[]` 存 `hotUntil` / `warmUntil` 两个**时刻**
（= 锚点日期 + 该码的 `urgencyDayThreshold` / `windowDays`），档位在**读时**跟 `now` 比。

- ❌ 存 `daysSince`：它在 `VOLATILE_DATA_KEYS` 里被 `stripVolatile` 剔掉 →
  **时间流逝永远不触发重算** → 温度档永久冻结在画像算出来那一刻。
- ❌ 存档位：一样会过期，还得配一整套「到期重算」机械。
- ✅ 存边界时刻：由「事实日期 + 静态配置」推出，**不是易变键**
  （⛔ 别加进 `VOLATILE_DATA_KEYS`），语义变了才升版本；读时一比就出档，零维护。

> 同一套路本仓已用过两次：`visitRecencyRange`（存日期读时分档）、
> `applyLiveDays`（存 `signalOccurredAt` 读时算天数）。这是第三次，别再交一次学费。

> ⭐ **边界必须存在「稳定路径」上**：`data.temperature.<标签>.hotUntil`，
> ⛔ **不能塞进 `detail[]` 数组** —— 同一个标签在不同患者身上的下标不一样，
> 于是 `detail.0.hotUntil` 对谁都不成立，Prisma 的 json 路径过滤（以及任何索引）都用不上，
> **召回池列表就永远对不上矩阵**。挪成稳定路径之后：矩阵走原生 SQL、列表走 Prisma，
> 谓词字面等价，本地实测 **24 格逐格与列表 total 相等**。

**③ 「不知道」不等于「冷」。** 本次改动之前算出来的画像没有边界字段 →
`classifyTemperature` 返回 `null`，调用方必须显式呈现「温度未知」或排除出矩阵。
静默判成冷会让老数据塞满冷格子，主管看到一个**假的**分布还看不出哪里假（T14）。

**④ 不在温度层重复 `cooldownDays`（治疗考虑期）闸。**
入池下界是召回引擎的职责，矩阵是在**池子上**切分，池子已经过闸。
两层各判一次 = 两套口径，正是 T6a「同源保证」要防的事。

#### 实测量级（2026-08-02 本地 5,825 患者，**别拿错人群量收益**）

在召回池上（矩阵真正展示的人群，3,880 个「患者 × 标签」格位）：

```
翻档          51 / 3,880 = 1.31%
  变热        43
  变冷         8   ← 6 条是"恰好压线"(按毫秒比 vs 按整天截断)
                     2 条是 K06(60 天)被标签级近似当成 K05(90 天)误判成热,新口径改对了
```

⚠️ 拿**未过 gap 闸的原始诊断事实**去量会得到 40-70% 的夸张差值 —— **那是错的人群**。
这条改动真正的价值在 ②（解冻，在生产会持续累积）和 Q-6 的收口，**不在今天这 1.31%**。

### T7 · 客服退回必须说明原因

退回是**正常路径**不是异常。
退回率与原因分布是**主管调整分配策略的输入** —— 这是「必须填原因」的目的，不要当成无谓摩擦砍掉。

> 🔴 **2026-08-03 走查:这条教条此前是零实现。**
> 枚举齐了（8 个 `ReleaseReason` + `needNote`）、API 收得了、`releaseReasons` 分布报表也写了 ——
> 但**前端唯一的退回入口 `plansApi.recycle(planId)` 一个参数都没传**，也没有弹窗。
> 于是那张分布表拿到的永远是 null：主管只知道"退了 12 条"，不知道该改什么，
> 而"派多了 / 时效太紧 / 压根不该派给他"这三种的下一步动作完全不同。
>
> 现在：前端弹 `ReleaseReasonDialog`（8 个原因各带一句 desc —— 光看标题分不出「不是我的客户」
> 和「该由其他角色跟」，分不出就会随手点第一个，**假数据比没数据更难发现**）；
> 服务端对**分配来的单**（`assignment_id` 非空）强制要原因，`other` 强制要说明。
> ⚠️ 只卡分配来的：自助认领的单没有派单方，原因对谁都没用；
> **系统自动到期回收不走这条路**（scheduler 直接改库，记 `assignment_expired`），不会被卡死。
> ⚠️ 必填要在**服务端**拦 —— 只做前端的话，换个客户端（助手 / 脚本 / 企微）就绕过去了。

### T8 · 主管决策、助手辅助、客服执行

三者职责**不重叠**，各有否定边界：

| | 做什么 | **不做什么** |
|---|---|---|
| 主管 | 判断、取舍、拍板 | 不翻明细、不做算术 |
| 助手 | 筛选、计算、呈现 | **不替主管决定、不自动执行** |
| 客服 | 执行 | 不做选择 |

**判据：任何改变数据库状态的动作，只能由主管的「确认」触发。助手全程只读。**

### T9 · 精选分两阶段：全景不做画像分层，调整时才做

| 阶段 | 助手行为 | 画像能力 |
|---|---|---|
| **A · 全景** | 产能视图 + 承接意图 + 引导建议 | ❌ 不做画像分层，保持简洁 |
| **B · 调整** | 响应主管的精确追问 | ✅ **触发画像圈人** |

**画像圈人是主管调整阶段最有力的圈人手段** —— 主管说「只要商保直付的」「排掉怕疼的」，
助手当场用画像收窄。放在全景会把界面撑爆，放在调整则正好是主管要的精确回应。

### T10 · 助手的扩展因素走单一通用接口

以后要加权益身份、治疗史、家庭结构，只是多传一个 `key`，**不为每个因素开新接口**。
画像能力对助手是**一个可触发的动作**，不是一堆预置维度。

### T11 · 分配单有时效性，且必须在确认单上指明

分配不是永久归属，**带有效期**（如 3 天）。
时效依据 = 召回池存量 + 客服负载完成量 + 主管习惯，**综合预估后由助手建议、主管确认**。

### T12 · 认领与分配同构 —— 差别只在触发人

两者最终都是「单子落在某个客服名下」，区别仅是**谁触发的**（客服自己 vs 主管）。
因此：**退回 = 回池，复用现有认领逻辑**，不另造一套归属模型。
本期虽只做傻瓜式执行，但**保留认领**，为后续扩展留口。

### T13 · 全景确认单**直出**，不追问

设计目的是**极致减负**，**最好的情况是主管直接确认**。因此：

- **不问主管意图** —— 意图由**助手推导**（可用依据：初选的治疗项+温度、当前月份、池子存量），
  是助手给自己找依据的内部推理，**只影响建议措辞**，不占主管的注意力，也不要求主管事后反馈。
- **直出明细**，分三层：汇总 → 按客服（产能视图）→ 患者明细（按客服折叠，展开才看）。
- **页面可微调，且只微调两项**：指定客服、时效。
  微调项越多，「一次确认」就越不可能实现；换人群回到对话，由助手触发画像圈人（T9-B）。

### T14 · 助手不出没有证据的结果

**有理有据才能信服。** 没有数据支撑的东西，不许当依据写进确认单。

- 缺少的数据（如医生档期）→ **不用**，也不猜
- 首次无历史 → 用**行业通用 / 第一性**推出的默认值，**并标明是默认值**
- 有数据之后 → 反推真实习惯，替换默认值

> 例：时效「3 天」现在只能标注为「默认值（暂无历史结案数据，积累后按实际反推）」，
> **不能**写成「依据该诊所平均结案 2.4 天」—— 生产 `plan_executions` 仅 7 条，算不出来。

### T15 · 溢出默认转铺平

专属客服容量不足导致的溢出，**默认转铺平**，不需要主管做决定 —— 但要让他看见发生了什么，且能改。

---

## 三、生产线（当前共识）

```
① 初选   主管在矩阵上点一格（潜在治疗项目 × 温度）      ← 主管界面,与助手无关
② 精选   A 全景：产能视图 + 意图承接 + 引导建议          ← 助手登场,不做画像分层
        B 调整：主管追问 → 助手触发画像圈人精确响应
③ 定制   分配策略 + 福利 + 时效确认
④ 分配   主管确认 → 落到客服                          ← 唯一的写动作
⑤ 跟踪   在手/超期/退回率/退回原因/批次效果
```

**初选不经过助手** —— 矩阵是主管自己的界面，助手拿到的是已初选的人群。

### T16 · 认领在页面上不体现，客服只看得到「我的」

| 角色 | 左侧列表 | 能做什么 |
|---|---|---|
| **客服 staff** | 只有「我的」 | 执行、退回。**看不到池子，无从认领** |
| **主管 leader** | 「我的」+「召回池」 | 自己也干活；在召回池里发起分配 |

后端 `assign` 保留（见 T12，认领与分配同构），但**前端不给入口**。
将来要放开客服主动性，是加回一个入口的事，不用改模型。

**分配入口就近召回池** —— 矩阵是池子的一个视图模式（列表 ⇄ 矩阵切换），不新开路由、不换心智。
主管本质也是客服，也要执行，割裂成两个页面会把他劈成两个身份。

### T17 · 表设计保守立柱：会用来筛的才立柱，其余进 JSON

沿用 `plan_event_logs.details` 已确立的口径 ——
> 「只放**查询不按它过滤**的内容；要按它筛就该立柱。」

推论：
- 初筛条件（潜在治疗 / 温度）**不立柱** → 进 `criteria` JSON。将来会有别的初筛策略，
  为当前这一种立两根柱子，第二种来了就得加列或让字段闲置。
- 福利**不立柱** → 进 `attributes` JSON。**福利只是附加属性中的一种**，不是一等公民。
- 计数（人数 / 客服数）**不立柱** → 可从关联表 `COUNT` 出来，立柱等于埋一个会漂的冗余。

### T18 · 通用表不得业务化

`plan_event_logs` 是**所有 plan 事件的日志**，不为某个功能加专属外键
（曾提议加 `assignment_id`，已撤回）。批次归因走 `plan_id → followup_plans.assignment_id` 多跳一次，
换表职责干净。**下一个功能来了照样想加，几轮就成大杂烩。**

### T19 · 权限由 permission 控制，role 只给人看

**真正控制权限的是 `permission`（给代码看）**，`role` 是给人看的标签。

- 新增 `PLAN_DISPATCH`，授 leader + admin
- MCP 侧 `McpAuthContext` **已有 `permissions: string[]`**，工具直接查 `permissions.includes('plan:dispatch')`
- **不下传 role** —— `getCurrentUser()` 返回的是**能力**（能不能分配、能不能看全池），不是角色名
- 只有界面上要显示「主管」这类文案时，才补 role

### T21 · 撤销 ≠ 退回 —— 两个不同的人做的两件事

| | 谁做 | 做什么 | 粒度 |
|---|---|---|---|
| **撤销 revoke** | **主管** | 收回**整批**分配（分错人 / 条件填错） | 批次 |
| **退回 release** | **客服** | 退回**单条**（不是我的客户 / 没时间），**必须带原因**（T7） | 单条 |

⚠️ **撤销的「已动过」判据不能用 `plan_executions`**：

`plan_executions` 是**执行阶段**的产物（客服已接受并执行完才写），
而回写率仅 **11%**（4.5）—— **客服打了电话但没填表 = 没有 `plan_executions`**。
撤销时若把它当「没动过」收走，那通电话就永久蒸发了
（正是 `claim-guard.ts` 第 1 条要防的：执行必须有归属，否则统计里直接蒸发）。

**可用信号实测**（2026-08-02 生产）：

```
contact_attempts > 0      7     ❌ 与 plan_executions 同源(提交执行时才累加),帮不上忙
plan_executions           7     ❌ 同上
plan_event_logs view  1,024     ✅ 唯一可用 —— 客服打开过详情页 = 至少看过
```

→ **判据以 `view` 事件为主**：该客服 view 过 → 撤销时跳过或提示主管。
这是 PV/UV 埋点（本为统计而做）的意外收益。

### T20 · 跟踪的目的是**自优化**，不是找对照组

❌ **不用「分配单 vs 自认领单」做对比** —— 认领将被隐藏（T16），**没有对照组**；
且该框法把分配当成「待验证的假设」，与定位不符：分配是**既定的运营方式**，
问题不是「要不要用」，而是**「怎么越用越准」**。

✅ 对比维度是 **批次 vs 历史批次**（纵向）：

```
批次 N 执行 → 沉淀数据 → 反推规律 → 批次 N+1 的默认建议更准
```

**跟踪的产出不是报表，是下一次分配的输入。** 沉淀的执行数据越多，后续分配效果越好。

要反推的四样，**正好对应助手当前只能给默认值的四处**（见 T14）：

| 助手当前的默认值 | 沉淀后反推成 |
|---|---|
| 时效「3 天（默认）」 | 该诊所该治疗项的**实际结案中位数** |
| 容量「20-50」 | 各客服**实际能吃多少**（完成率开始下滑的拐点） |
| 「专属优先，溢出铺平」 | **专属 vs 铺平的完成率差**是否显著 |
| 福利「建议配 / 不必配」 | 带福利 vs 不带的**完成率差** |

因此核心报表是 **因素 × 完成率**，不是单批的进度条。
⚠️ 每行必须带 `n=`，**n 不足（如 <50）直接标「样本不足」，不画图不算率**（T14）。

#### T20′ · 「完成」= 池子状态，不是回写（2026-08-02 产品裁决）

**完成不需要等成功。** 只要知道**这个患者的召回还在不在池、有没有被抑制** ——
两者任一成立就说明这单已经被处理过了。**成功不成功先不统计。**

| 判据 | 含义 | 依赖回写吗 |
|---|---|---|
| **出池** `status='superseded'` | 引擎判定该患者已无活信号（治疗真做了 / 诊断没了） | ❌ **不依赖** |
| **被抑制** `snoozedUntil > now` | 客服写了回访结果（约下次 / 拒绝 / 放弃） | ✅ 需要 |

> ⭐ 两条是**互补不是重复**：前者接住不写回访的 **89%**，后者接住写了的 **11%**，
> 并集才是完整的「处理过」。这正是绕开 `plan_executions` 回写率困境的那把钥匙 ——
> 池子状态由引擎按 `patient_facts`（客观事实）算出来，客服写不写都成立。

**为什么现在可以不算成功、以后又不会抓瞎**：出池原因记在 `plan_event_logs`，
客观事实在 `patient_facts`（append-only）——**任何时候都能回过头重算**
「这批人后来到底做没做治疗」。不是放弃了这个数据，是它不需要现在算。

⚠️ **报处理率必须带批次年龄。** 跑了三个月的批次天然比跑了三天的好看，
不同年龄的批次直接比是耍流氓 → `progress.ageDays` 随数据一起给，助手原话转述。

##### 🔴 连带修掉的静默缺陷：跟踪查询**不许过滤 `supersededAt: null`**

引擎关闭走 `closeStaleActivePlan`：`status='superseded'` 且**不建后继版本**。
跟踪侧若照直觉写 `where: { supersededAt: null }`，这些行整个消失 ——

```
本地实测(60 条批次,其中 8 条已出池):
  旧口径 planned = 52     ← 批次凭空缩水 8 人,不报错不告警
  新口径 planned = 60
```

**缩掉的恰恰是唯一算得上成功的那批。** 批次跑得越好，数字缩得越厉害。
（D-12 的无条件继承只保住了**升版本**那条路 —— 后继行继承 `assignment_id`；
关闭这条路压根没有后继行，继承救不了。R12 当时只想到了前者。）

→ 正解：拿本批全部行、**按患者取最新版本**（升版本时新旧行都带 `assignment_id`，
取 `max(version)` 天然去重；关闭时最新版就是那条 superseded 行，正好是信号）。
回归见 `tests/assignment-tracking-invariants.spec.ts` 第三组。

##### T20″ · 归因继承的边界：看**客服碰过没**，不看召回理由变没变（2026-08-02 产品裁决）

先分清**两套继承**——它们此前被混在一起讨论过：

| 继承什么 | 条件 | 回答的问题 |
|---|---|---|
| **客服相关**（`status` / `assignee_user_id` / `assigned_at` / `contact_attempts`） | `carryAssignment` = 旧版 assigned && 未换诊所 | 还挂在同一个人名下吗 |
| **批次归因**（`assignment_id` / `assigned_by` / `assign_strategy` + 快照五列） | `carryAttribution` = **客服碰过没** | 这单的账结了吗 |

```
客服没碰过 → 这单还欠着 → 继承（理由怎么变都继承）
客服碰过了 → 批次这一条的账已落定 → 不继承，后面再冒出来的是新工单
```

> **批次分的是「人」不是「诊断」**：主管要知道的是「这 100 个人有多少被联系/处理了」。
> 一个人一次都没被联系过，账就还欠着 —— 临床理由怎么变都不改变这件事。

⛔ **曾经写错的一版判据：「召回理由整组换掉就不继承」。两个方向都会错**（产品当场指出）：

1. 召回**一会儿出现一会儿不出现，恰恰可能是客服处理过**造成的（患者来了、治了一部分、
   又诊断出新问题）→ 该结账的反而被当成"理由变了"继承走。
2. **同一种诊断再次出现，也不代表客服没处理过** → 该结账的同样继承了。

**理由的变化根本不携带「客服做没做事」的信息**，它只反映患者临床状态在动。拿它当判据是找错了轴。

⚠️ 「碰过没」**不新造判据，直接复用 T21 / D-11 那一套**（撤销整批时判「哪些单不能收」）：
以 **`view` 事件为主**（实测 view 1,024 条 vs `plan_executions` 7 条，回写率 11%），
外加行上三个现成强信号（`snoozedUntil` / `releaseReason` / `contactAttempts`）。
⛔ 别改成只看 `plan_executions` —— 那会把 89% 已经打过电话的单判成「没碰过」，
于是它们的归因被一直继承下去，**批次的账永远结不掉**。

⚠️ **性能**：`view` 事件只对**带 `assignment_id`** 的单查（批量路径一次 `groupBy` 预取）。
不收窄的话 `runAllForHost` 全量会为 44 万患者各查一次。

⭐ **与 T20′ 咬合**：不继承时旧行仍带着 `assignment_id` 留在批次里且已 superseded →
跟踪按「按患者取最新版」取到的就是那条旧行，它身上带着当时的处置（`releaseReason` / 已 view），
**批次的分子分母都不丢**。新工单干净地回池，等下一个批次。

> ⚠️ D-12「无条件继承」也是**给当时跟踪缺陷打的补丁**（那时 `detail` 过滤 `supersededAt: null`，
> 旧行看不见，不继承就分母缩水）。跟踪修好后它不再必需，且会把一张全新工单硬算进旧批次。

⚠️ **快照五列与 `assignment_id` 同生共死** —— 留下 `selectionMode='explore'` 却查不到批次的
孤儿行，会直接污染 T20 的因果分析（探索组样本本来就小）。

### 4.4 现有 MCP 工具的缺口

现有 7 个工具（`find_patient` / `get_patient_overview` / `get_persona` / `get_facts` /
`get_recall_plan` / `list_recall_queue` / `recall_queue_stats`）**全部只读、全部围绕患者**。

**一个关于「客服」的工具都没有** —— 而分配的一半是「给谁」。

### 4.5 认领机制的现存缺陷（分配的前置背景）

| 现象 | 数据 |
|---|---|
| 在手单沉淀 | 57 单，平均已持有 80.6 小时，**79% 已过回收时限** |
| 自动回收 | 代码存在但**默认关闭**，生产未配 `PAC_PLAN_AUTO_RECYCLE` |
| staff 自助返池 | **不支持**，只有 leader 能手动回收 |
| 执行回写率 | 65 个认领患者仅 7 条通话结果，**89% 未回写**；其中 5 条来自同一人 |

> `recycle_at` 字段被写入但无人读取。

---

## 五、接口设计（草案）

按「模型自主调 MCP」的形态（已定），需要新增：

```
getCurrentUser()                     判断当前登录人是主管还是客服 → 决定走哪条工作流
getActiveAgents(clinicId)            近 12 月在岗（按 source_created_at）
getAgentLoad(userIds[])              在手总量 + 剩余容量
getDedicatedCs(patientIds[])         专属客服
getPersonaFeatures(patientIds[], keys[])   ⭐ 唯一的可插拔扩展口
assignPlans(...)                     ⚠️ 第一个写工具，只能由主管确认触发
revokeAssignment(assignmentId)       撤销整批（限时），由助手辅助完成

—— 跟踪(见 五之四) ——
getAssignmentBatches({...})          批次列表 + 汇总
getAssignmentDetail(assignmentId)    单批全貌
getAgentWorkload({ userIds? })       ⭐ 与分配阶段的负载查询**合并为同一工具**
```

> `getAgentLoad` 与 `getAgentWorkload` **不并存** —— 分配问「还能吃多少」、跟踪问「压了多少」，
> 是同一份数据的两种读法；开两个必然口径漂移（一个算 assigned、一个算 assigned+active）。

见 **T10** —— 画像是助手的一个**可触发动作**（`getPersonaFeatures` 传 `keys`），不是预置维度。

> ✅ **已落地为 `get_cohort_attributes`（2026-08-02）**：`getDedicatedCs` + `getPersonaFeatures`
> 合成一个口，维度全集直接取 `PERSONA_TAG_FILTER_DIMS`（与列表页筛选面板、`list_recall_queue`
> 的 personaTags **同一张表**）——「以后要加权益身份、治疗史、家庭结构」是往那张表加一行的事，
> 这个工具一个字都不用改，T10 是真的成立而不只是口号。
>
> 三条落地时补出来的纪律：
> 1. ⭐ **人群取数必须与出确认单共用同一份 SQL**（`cohort-filter.ts`）——
>    各写一遍就会出现「分布说商保 32 人、提案只有 28 人」，主管当场不信且分不清哪边错。
>    本地实测已对数：分布 22 → 收窄 22 → 提案 `candidateTotal` 22。
> 2. ⭐ **`noTag`（没有这条画像证据）≠ 反面**。「32 人有商保标签」剩下的**不是自费**，
>    是院内没留痕。模型极容易说成「其余 68 人自费」——那是凭空造事实（T14），
>    而主管会拿它去定价。返回结构里单列 `noTag`，提示词再写死一遍，双保险。
> 3. ⭐ **隐藏维度可以点名，但默认不给**。教条举的「排掉怕疼的」正落在
>    `treatment_sensitivity`（`hidden: true`）上 —— hidden 的语义是「面板不展示」不是「不能用」；
>    但默认把 16 个维度全推给模型，它会挑一个不相干的开始发挥。

⚠️ `assignPlans` 是**第一个写工具**。MCP 端点为 `@Public()`（`PermissionsGuard` 短路），
必须在 handler 内自查 `permissions.includes('plan:dispatch')`。

---

## 五之二、表结构（草案）

### 新建 `plan_assignments`

命名沿用 `plan_*` 家族（已有 reasons / scripts / summaries / executions / event_logs / generation_logs）。
语义 = **一次分配动作**（一个批次）。

```
id, host_id, tenant_id, clinic_id
created_by            发起分配的主管(宿主侧 user id)
created_at / updated_at
criteria      Json    初筛条件快照 { potentialTreatment, temperature, ... }  ← T17 不立柱
attributes    Json?   附加属性 { benefit?: { text } }                        ← T17 福利只是其中一种
expires_at            时效(T11);单条可覆盖
status                confirmed | revoked
revoked_at / revoked_by
```

**不设** `planned_count` / `agent_count` —— 可从 `followup_plans` COUNT 出来（T17）。

### `followup_plans` 加列

```
assignment_id           FK → plan_assignments   null = 自助认领
assignment_expires_at   该单时效(继承批次,允许单条改)
assigned_by             谁分的(现有只记 assignee_user_id「分给谁」,缺「谁分的」)
release_reason          退回原因(结构化,可统计分布 → T7)
release_note            退回文字说明
```

⚠️ **`release_reason` ≠ `recall_feedback`** —— 两者语义完全不同，是独立字段：

| 字段 | 回答什么 | 谁填 |
|---|---|---|
| `recall_feedback` | **这条召回准不准**（算法对不对） | 客服对系统的反馈 |
| `release_reason` | **我为什么不接这单**（人不合适 / 时机不对 / 信息不足） | 客服对任务的处置 |

只是**存储模式**相同：主表存当前值（就地覆盖），全量历史留在 `plan_event_logs` 的通用 `reason` 列
（该列现已在存 `auto_release='timeout'` / `feedback='up'|'down'`，存退回原因属同类用法，不算业务化）。

### `plan_event_logs` —— 不动

见 T18。曾提议加 `assignment_id`，**已撤回**。

### 连带要修的三个既有洞

| 洞 | 影响 |
|---|---|
| 引擎丢归属不记账（`plan-engine.service.ts:398`） | 分配单被静默收回，主管无感知；退回率偏低 |
| `recycle` 的 `reason` 被丢弃（`plan.controller.ts:127`） | **T7「退回必须说明原因」现在落不了地** |
| `RECYCLE_TIMEOUT_HOURS` 硬编码 | T11 时效不可配 |

> 自动回收当前**未启用**（`PAC_PLAN_AUTO_RECYCLE` 未配），`recycle_at` 保持现状不动；
> 分配时效走新的 `assignment_expires_at`，两条路不互相干扰。

---

## 五之三、交互（草案）

### 页面结构

见 T16。矩阵是召回池的一个**视图模式** —— tab 行的「**分配**」主色按钮**浮层展开**，
不新开路由、也不替换左栏内容（挑格子时列表还在原处，关掉就回到原位置，不丢滚动位置与筛选）。

**点一格之后，左栏一动不动**（2026-08-03 产品走查）。初选的去向只有一个：助手。

> 原实现顺手把左栏也按这一格筛了，并在顶上挂一条「初选 充填 · 窗口外 ✕」的告示。撤掉，两个理由：
> ① **人群的证据在确认单里**，那才是主管要审的东西。左栏再给一份，两处口径一旦不一致
>（列表按 plan 分页、矩阵按患者去重，本来就不是一个数）主管不知道该信哪个；
> ② 左栏是他**挑患者**的地方，被上一次初选筛住会让他以为池子空了 ——
> 而那条告示要他先看懂、再点掉，才回得到整池。
>
> 服务端的 `potentialTreatment` / `temperature` 两个列表参数**保留**（`buildListWhere` 未动），
> 只是前端不再自动带上；将来若要「按初选看列表」，是加回一个显式入口的事。

### 矩阵三档的叫法

**列头用临床说法，不用温度比喻**（2026-08-02 产品定）：

| code（不改） | 显示名 | 判据 |
|---|---|---|
| `hot` | **黄金期** | `daysSince ≤ urgencyDayThreshold` |
| `warm` | **窗口内** | `urgencyDayThreshold < daysSince ≤ windowDays` |
| `cold` | **窗口外** | `daysSince > windowDays` |

⚠️ **代码名与显示名刻意不一致**：枚举值、API 参数 `temperature=`、
`persona_features.data.temperature` 的 JSON 路径都仍是 `hot/warm/cold`。
⛔ 别顺手把枚举也改了 —— 那要同时动 API 契约、SQL 谓词、已落库的 JSON 路径和前端 query，
收益只是「看着顺眼」。显示名的单一真理源是 `TEMPERATURE_META`。

### 矩阵配色 = claude design 的 1b「连续色带」

（2026-08-03 产品出稿，源文件 `分配矩阵.dc.html` 的 1b 方案）

三列共用**一条** `amber → emerald → sky` 的横向渐变，格子透明浮在这张面上 ——
列与列之间没有缝，整片读起来是一条「由烫到冷」的轴，**只用色相区分窗口**。

> 中途曾一度**撤掉全部配色**（理由：列头改成临床说法后，颜色成了第二套更弱的编码）。
> 产品出稿后恢复，但**换了一种色**：不是原来那套「温度比喻」的冷暖渐变，
> 而是与三个列头一一对应的三个色相。色相与文字是同一信息的两种编码 ——
> 这是**刻意的冗余**（色盲用户靠文字、扫视靠颜色），不是重复。

⛔ **数量绝不参与配色**（这条纪律自始至终没变）：渐变只由**列的位置**决定。
否则「这格暖是因为在黄金期、还是因为人多」分不清 —— 人数已经用数字表达了。
⛔ hover / 选中用**内描边**（`outline` + 负 offset），⛔ 不许换底色 ——
换底色会在渐变面上戳一个洞，整片「连续」的观感当场破掉。


### 移交助手的动效

**用助手/桌宠现有的动效系统，不引任何动画库**（web 端只有 Tailwind v4 + 原生 `@keyframes`，
`globals.css` 已有 6 个自定义动画）。

`lib/pet-events.ts` 是一套三层单向架构：

```
感知层  业务代码 emitPetEvent(语义事件),不关心宠物怎么演
大脑层  pet-brain 订阅 → 产出 DirectorScript
身体层  pet-body 纯展示,按姿态渲染
```

`use-assistant-chat.ts` 已在 `import { emitPetEvent }` —— 助手对话本身就在往这条总线发事件。

所以点格子应该是：

```ts
emitPetEvent({ type: 'cohort_handoff', payload: { count, treatment, temperature } })
```

演法由 `pet-brain` 决定（数字流吸入 → `think` 姿态 → 出确认单）。
**业务代码不关心怎么演**，将来换演法不用动分配功能的代码。

需在**受限动作词表**里加一个姿态（如 `absorb`）—— 词表受限是刻意的，
注释写明「LLM 导演也只能从这里选，不许自由发挥」。

### 确认单

**在助手聊天里**，且**必须是原生 React 组件，不能用 `render_artifact`** ——
artifact iframe 是 `sandbox="allow-scripts"` + CSP `connect-src 'none'`，**卡片内不可能发起写请求**。

> 分岔记牢：**只读展示用 artifact，可交互用原生组件。**

---

## 五之四、跟踪统计（草案）

**也在助手里** —— 主管对话提问，助手查 MCP，用 `render_artifact` 出卡片/图表。
跟踪是**纯只读展示**，正落在「只读用 artifact、可交互用原生组件」的 artifact 一侧。

> 🔴🔴🔴 **归因必须走账本，⛔ 不能数 `followup_plans.assignment_id`。**（2026-08-04 落地）
>
> 那一列**会被下一次分配覆盖**：一条单退回 / 到期回池后被后面的批次挑走，
> 旧批次就少一个人 —— **批次跑得越久（退回重分越多）缩得越厉害**，且不报任何错。
> 实测 `c02e1b80` 分过 9 条，重分之后 `planned` 显示 **0**。
> 与 `supersededAt` 那个坑同构：**批次跑得越有进展，数字反而越小。**
>
> ⇒ `plan_event_logs` 加一列 `assignment_id`（**不建明细表** —— append-only 的账本
> 本来就是明细，缺的只是"属于哪一批"这个维度）。这张表对 `details Json` 自己写着
> 「要按它筛就该立柱」，而我们要 `groupBy` 出报表，按它自己的规矩就得是列。
> 稀疏也不是新先例：`heldSeconds` / `reason` / `assigneeUserId` 本来就只对部分事件有值。
>
> ⚠️ 曾想用**时间窗**从账本里圈一批（`created_at BETWEEN …`）—— **那是启发式不是键**，
> 同一主管连着分两批、两个主管同时分就糊了。归因指标不能这么算。
>
> ⚠️ 写入点四个，**漏一个就少算且不报错**：`create`(assign) / `recycle`(release) /
> `AssignmentExpiryScheduler` + `revoke`(auto_release)。回归里锁着。
> ⛔ **claim 不写** —— 自认领是从池子里自己捞的，而 plan 上那个 `assignment_id`
> 可能是上一批的陈迹（退回/到期都刻意不清它），填进去等于给一个早就结束的批次凭空加人。
>
> ⚠️ **口径分两源，别混**：`planned/agents/released/expired` 走账本（历史事实，永不变）；
> `inHand/done` 走 `followup_plans`（当前状态）。
> ⚠️ planned 改成账本口径后，五桶就加不出 planned 了 —— 补 **`reassigned`**
> （已被后续批次挑走）这一桶让穷尽性重新成立。不补的话主管一对数就发现少人，
> 而少的那几个永远查不出去哪了。
> ⚠️ **老批次**（本列上线前）账本里没批次号 → 回落到 `followup_plans` 现算（偏小），
> ⛔ 但**到期数没有任何可回落的源**，只能给 0：拿 `backToPool` 顶替会把退回算成到期。

> 🔴 **退回原因也必须走账本 —— 主表那一列会被重分清空。**
> `followup_plans.release_reason` 是**当前值**，而 assign 的 SQL 里有 `release_reason = NULL`。
> 于是一条退过的单被后面的批次挑走之后：`released` 还是 1（账本口径），
> `releaseReasons` 却变成 `[]` —— **两个数当场对不上**，且不报错；
> 主管看到"退了 1 条"却没有任何原因，会以为是客服没填。（实测复现过。）
> ⚠️ `agentStats` 的**名册**同理要从账本 assign 事件建，⛔ 不能按当前 `plans` 建 ——
> 被挑走的人会从所有人名下蒸发，「各人之和 === planned」这条老不变量当场破，
> 而它正是本仓最早锁的两条之一。
> ⚠️ release 事件自身的 `assigneeUserId` 是 **null**（释放后无人归属），
> 归人只能按 `planId` 回查 assign 事件里的原始承接人。
> ⚠️ 同一 plan 可能有**多条 assign**（退回→自认领→再退回），去重后再计数。

> ⭐ **到期 ≠ 退回，两个数必须分开报。** 主管的下一步动作**相反**：
> 退回多 → 分配策略不对（派给了不该派的人）；到期多 → 派多了 / 时效太紧 / 人不在岗。
> 合成一个"回池率"两种病都看不出来。
> （枚举注释早就写清了「到期是**没处置**、退回是**处置**」，所以到期刻意不写
> `followup_plans.release_reason` —— 但此前**任何接口都没把到期数报出来**，
> 全被 `backToPool` 一桶吞掉了。）

> ⭐ **通话成效(完成 / 成功 / 不成功及原因)—— 2026-08-04 落地。**
> 数据源是 `plan_executions`,而那张表原本只有 `plan_id`。⇒ 同样立 `assignment_id`
> （理由与 `plan_event_logs` 一字不差:`followup_plans.assignment_id` 会被下一次分配覆盖；
> 时间窗是启发式不是键）。
> ⚠️ 落这一列的判据是 **`assignment_expires_at != null`**（「非空 ⟺ 有一次在办的分配」），
> ⛔ **不能**只看 `plan.assignment_id` 非空——它在退回后**仍然留着**（那是退回率的分母），
> 此时客服自己从池子里捞回来打的电话会被算进一个早就结束的批次。
>
> ⚠️ 每个患者**只取最近一次**执行（`DISTINCT ON (plan_id) … ORDER BY created_at DESC`），
> ⛔ 不是数执行条数：打了三次才约上是**一个**成功，不是三个。
>
> 🔴🔴 **`outcomes` 与 `releaseReasons` 是两件事，绝不能合并或互相顶替：**
> | | 含义 | 主管该改什么 |
> |---|---|---|
> | `releaseReasons` | 「这单**不该我做**」——**没打**就还回去了 | 分配策略（派给了不该派的人） |
> | `outcomes` | 「打了，结果这样」——**做了事** | 话术与人群 |
> 说反了他会去改错的东西，方向完全相反。
>
> ⚠️ **`noOutcome`（一次结果都没有）必须单独成桶**，⛔ 不许并进「不成功」——
> 那不是效果差，是**根本没做/没记**。混进去会把执行问题读成召回问题。
> ⚠️ 四桶穷尽：`success + failed + keep + noOutcome === planned`。
> ⛔ 这里**不掺 `reassigned`**（踩过）：「现在归谁」与「做没做」是**正交**维度——
> 被后面批次挑走的人若在本批打过电话，那条执行仍算本批成效，再减一次是重复扣减，
> `noOutcome` 会被压成 0，而"没人做"这个最该报的信号就此消失。
> ⚠️ `success` **含约定下次回访**，⛔ 别说成"成交了这么多"。

> 🔴 **`overdue` 必须排除"约了下次回访"的单。**
> 到期回收器刻意跳过 `snoozedUntil` 在未来的单（客服约了 6/10 回访，那之前绝不能收走，
> 否则毁掉他对患者的承诺）—— 于是这类单会一直"超期"下去。
> 而 `progress` 那边已经把它算作 **suppressed（已处理）**。不加守卫，同一条单
> 「已处理」和「超期」同时成立：主管看到「薛玫 超期 3」以为她压着单没动，
> **实际上她打了电话、约好了下次** —— 干得最好的那个被指责了。

> ⭐ **批次的人话名字**（`label`，服务端唯一生成点）——「8/3 23:35 · 牙周治疗 · 窗口内 · 9 人 · 2 位客服」。
> 没有批次列表页时，这是主管指认一批的**唯一抓手**（「撤销今天下午牙周窗口内那批」）。
> ⛔ 不许让模型自己拼：措辞会在两轮之间漂，主管就对不上"上次说的那批"。
> ⚠️ 时间按**宿主时区**、精确到**分钟** —— 同一个矩阵格子一天可能分好几批。
>
> 🔴 做这个时才发现 **`temperature` 一直没进 `criteria` 快照**：它参与了圈人
> （`cohort-filter.temperatureSql`），却没随确认单下发。而 schema 对 `criteria` 的承诺是
> 「批次要能解释**当时是按什么圈的**」。矩阵 8×3，「牙周·黄金期」和「牙周·窗口内」
> 是两批完全不同的人，落库后**分不出来**；T20 想反推"哪种格子效果好"直接答不了。
> 已补（proposal → 快照）。⚠️ 老批次补不回来，标签里那一段就**不出现** ——
> ⛔ 不许默认填一个，会让主管以为那批是按"窗口内"圈的。

### 主管实际会问的三类问题 → 三个 MCP 工具

```
getAssignmentBatches({ since?, status?, agentUserId? })
  → 批次列表 + 每批汇总(分了多少/已动/退回/超期/转化)
  答:「我分的那些批,哪批出问题了」

getAssignmentDetail(assignmentId)
  → 单批全貌:按客服拆 + 退回原因分布 + 明细
  答:「这批具体怎么样」

getAgentWorkload({ userIds? })
  → 每人:在手 / 超期 / 本周完成 / 退回率
  答:「团队现在什么状态」
```

⚠️ **`getAgentWorkload` 与分配阶段的负载查询合并成同一个工具** ——
分配时问「还能吃多少」、跟踪时问「手上压了多少」，是同一份数据的两种读法。
开两个必然口径漂移（一个算 assigned、一个算 assigned+active）。合并符合 T10。

### 三个必须先定的口径

| 问题 | 结论 |
|---|---|
| **退回率的分母** | **两个都出**：「退回 5 / 已处置 40 = 12.5%（另有 60 未动）」。未动的数量本身是信号 |
| **「超期」按批次还是单条** | 按**单条**（`assignment_expires_at` 允许单条覆盖），但展示时提示「本批 3 天，其中 5 条单独设了 7 天」 |
| **转化率初期无数据** | 按 T14 **明说样本不足**，不画 0% 图表 |

> 助手工作流约束里要写死这条：生产 `plan_executions` 仅 7 条、`success_appointed` **0 条**，
> 上线初期分子会长期是 0 或个位数。模型会很自然地把 `0/12` 渲染成「0.0% 转化率」，
> 主管看了会以为功能没用 —— 必须输出「已处置 12 人，暂无转化记录，样本量不足以计算转化率」。

### 数据来源：全部现有表 + 一个新增关联，**不需要新建统计表**

```
plan_assignments        批次本身:时效、福利、初筛条件、状态
followup_plans          .assignment_id → 分了哪些、现在什么状态、超期没
plan_event_logs         经 plan_id join → 认领/退回/反馈全流水
plan_executions         经 plan_id → 通话结果、转化
patient_transactions    经 patient_id → 客观新预约(canonical_payload.createdAt)
```

> 这将是 `plan_event_logs` 的**第一个读场景**（现状：5 个写入点、0 个读取点）。

### 呈现

复用现有 artifact 能力（已注入 Tailwind + Chart.js）：

- **批次列表** → 表格卡片，超期/退回率异常的行标红
- **单批详情** → 漏斗图（分了 N → 触达 N → 约成 N）+ 退回原因饼图
- **团队负载** → 横向条形图，超容量标红
- **因素 × 完成率** → 见 T20，带 `n=`，样本不足的不画

⚠️ artifact 的 CSP 是 `connect-src 'none'`，**数据必须内联**：助手要先取全数据再产卡片。

---

## 六、已定的取舍（记录「为什么」，比结论更重要）

| 议题 | 结论 | 理由 |
|---|---|---|
| 傻瓜式执行 vs 客服主动性 | **本期只做傻瓜式执行** | 突出主管管理、更可控；主动性留作后续迭代，派单这条路没走完不碰 |
| 矩阵放 v1 还是 v2 | **v1**（初选是流水线起点） | 产品定位是批次运营，初选是入口不是优化项 |
| 助手编排：模型自主 vs 后端固定 | **模型自主调 MCP（A 方案）** | 保持与现有助手一致的交互形态 |
| 助手个数 | **一个助手，按角色切换工作流约束** | 不为主管另起一个助手 |
| 温度是否按治疗项目分档 | **是**，但用各自窗口归一化 | 归一化后档位横向可比；原始天数不可比 |
| 精选是否再切窗口 | **否** | 初选已经选定治疗项+窗口档，精选再切是重复 |
| 诊所是否作为精选维度 | **否** | 实际是单诊所场景，登录时已确定 |
| 批次是否跨诊所 | **不跨** | 福利政策与效果归因都按诊所走 |
| 福利是否核销 | **v1 不核销** | 先验证「带福利批次转化是否更高」，再谈打通卡券系统 |
| **助手要不要校验专属客服在岗** | **不校验** | 在岗数据目前不够精确；**由主管在确认单上自行说明**，不让助手拿半准的数据挡人 |
| **「在岗」怎么判** | **近似即可**：默认按 `source_created_at` 近 12 月；**最终以主管信息为准** | 数据只做默认值，主管说了算 |
| 容量上限 | **不设**（这个概念已删）—— 负载即 `inHand`，落人走水位法 | 阈值没有数据支撑，且会让第二批分不出来；见 T22 |
| 初选 X 轴用什么 | **画像的潜在治疗 8 类** | 见 T6a；`focusCategory` 是技术类目不是业务机会，且会把早矫埋进正畸 |
| 拟分 N 人怎么定 | **主管的基数,沿用上一次**（首次 100） | 「这轮推多少」是运营选择,不该由任何存量上限推导 —— 那样第二批恒为 0。见 T22 |
| 明细默认展示多少 | **按客服折叠**，展开才看 | 兼顾「尽明细」与「一眼可确认」 |
| 全景是否先问意图 | **不问**，助手推导 | 每多问一句就多一次决策成本，与极致减负相悖 |
| 批次表叫什么 | **`plan_assignments`** | 沿用 `plan_*` 家族（已有 6 张）；语义 = 一次分配动作 |
| 主管判据用什么 | **新增 `PLAN_DISPATCH` permission** | `PLAN_ASSIGN` staff 也有、`STATS_VIEW` 是死权限，都不能用；见 T19 |
| 认领入口是否保留 | **前端撤掉，后端保留** | 见 T16 / T12；将来放开只是加回一个入口 |
| 确认单渲染方式 | **原生 React 组件** | artifact iframe CSP `connect-src 'none'`，无法交互写入 |
| 移交动效怎么做 | **走 `emitPetEvent` 现有总线** | 不引动画库；业务代码不关心怎么演 |
| 企微通道的合成身份 | **本期不管**（demo 用途） | `mintToken` 造 `role='staff'` + 全池 scope，写工具上线前需另行处理 |
| **MCP 写工具** | **最终必须实现，但分期** —— v1 写路径走 REST，MCP 只读 | MCP 端点是 `@Public()`（`PermissionsGuard` 短路），护栏不到位。**设计上不得堵死**：`requirePermission` / `rejectSyntheticIdentity` 两个 helper 现在就写好，待 MCP 鉴权补齐即可挂上 |
| **矩阵放第一刀还是第二刀** | **第一刀**（不改原定） | 规划曾以「温度轴需 persona 全量重算」为由建议推迟，**该理由已被证伪** —— 2026-08-02 生产实测：X 轴 590,427 条特征、Y 轴 566,365 条 signals **全部现成**，矩阵直接跑通 **25 秒零重算**。⚠️ 但 25 秒对交互不可接受 → 转为一个**明确的性能任务**（索引/物化），不是排期依赖 |
| **撤销的「已动过」判据** | **以 `view` 事件为主** | 见 T21。`plan_executions` / `contactAttempts` 同源且仅 7 条，不可用 |
| 分配可撤销 | **要**，且**由助手辅助完成** | 撤销也是对话里说一句的事，不另做界面 |
| 退回后去哪 | **回池**，复用现有认领逻辑 | 见 T12，不另造归属模型 |

---

## 七、待确认（**未经确认，不算教条**）

- 已被认领的单能否强制改派（现后端拦住 —— 「Plan 已分配给 X；回收后再分配」，建议保留该摩擦）
- 分配单**到期后**的行为：自动回池 / 提醒主管 / 两者皆有
- 全景阶段主管意图的**捕获方式**：助手主动问 / 预设选项 / 可跳过
- 时效性建议的**具体算法**（召回池存量 + 负载完成量 + 主管习惯，如何加权）
- 批次的规模建议上限

---

## 七之二、阶段性开放规划（能力按阶段放开，**不是一次做完**）

每一阶段**独立可交付、独立可验证**。「开放给谁」是产品视角，「技术前提」是工程视角。

| 阶段 | 开放的能力 | 开放给谁 | 技术前提 |
|---|---|---|---|
| **S1 · 闭环**<br/>(MVS，≈19 人日) | 初选矩阵 → 助手全景确认单 → 主管确认分配 → 客服执行/退回 → 基础跟踪 | 主管 + 客服 | 表结构 + REST 写路径 + 助手读工具 + 前端 T16 改造 |
| **S2 · 完备** | 撤销整批 · 福利进话术 · 调整阶段画像圈人 · 矩阵性能优化 | 同上 | S1 已上线并跑过真实批次 |
| **S3 · 自优化** | 用沉淀数据**替换默认值**（时效/容量/专属策略/福利效果） | 主管（体现在助手建议里） | 需 **n≥50** 的执行样本（T20/T14）；当前生产仅 7 条 |
| **S4 · MCP 写** | 助手可直接执行分配（外部 agent 亦可） | 助手 / 外部 agent | **MCP 鉴权补齐**（现为 `@Public()`，`PermissionsGuard` 短路） |
| **S5 · 客服主动性** | 客服在余量内自助认领（余量 + 任务相关性） | 客服 | 分配这条路跑顺；见 T16 「加回一个入口的事」 |

### 三条跨阶段的硬约束

1. **S1 就要把 `assign_strategy` 立柱** —— 见 §4.36 第 1 条，**分配当时不记就永久没了**，
   S3 的自优化届时无米下炊。
2. **S1 的写路径设计不得堵死 S4** —— `requirePermission` / `rejectSyntheticIdentity`
   两个 helper 在 S1 就写好，S4 直接复用。
3. **S3 不能提前** —— 样本不足时助手必须按 T14 标「默认值」，
   不许拿 n<50 的数据反推、更不许画成图表。

---

## 七之三、开发起点（**上下文压缩后从这里接续**）

### 环境已就绪

```
本地库    已清空重建，host=jvs-dw（appId pac_jvs-dw_lgvktyt7）
数据      5,825 患者 / 315,101 facts / 5,825 画像 / 50,748 特征
          2,724 活跃 plan / 4,595 召回理由 / 34,735 回访(101 客服，近12月在岗 28)
专属客服  5,427 / 5,825 = 93.2%
矩阵      8 × 3 全部有量（见 §4.0），本地可直接开发验证，**不需连远程**
```

⚠️ 本地 CH 缺 `fact_complex_cases_out`，重建环境时需手工建同构空表（见文末附录，**必须含 `is_del` / `case_stage`**）。

### 开工前必须闭合

见 [开发规划 · 第二节 Gate 0](./plan-assignment-dev-plan.md)：
- **G0.1 已实测通过** ✅（见 §4.37）
- **G0.2 契约表评审**（D-1 ~ D-15）—— 未做，**开工前必须过一遍**
- **G0.3 产品决策清点** —— 见本文第七节剩余项

### 第一件事

**不是写新功能，是修前置洞** —— 见 [开发规划 · 第三节](./plan-assignment-dev-plan.md)的 F1-F5。
其中 F1（引擎丢归属不记账，**实测 5 处**）和 F4（`assign`/`recycle` 不校验 `scope.clinicIds`，跨诊所越权）
必须在任何分配代码之前落地，否则分配一上线就会出现「主管分的单静默消失」和「跨诊所越权」。

---

## 八、本期明确不做

- 规则自动分配（16 个客服 / 7 条执行记录，样本量支撑不了规则调优）
- 客服能力 / 态度评分（见 T3）
- 抢单模式（限时先到先得 —— 制造焦虑，与召回业务气质不符）
- 强制接受（不能退回 —— 会让分配退化成甩锅通道，一轮即失信）
- KPI 直接挂钩分配量（会诱导主管乱分冲量）

---

## 附 · 本地开发数据

分配功能开发**不需要连远程测试/生产**。本地已备：

```
库      清空重建（37 迁移全应用），host=jvs-dw 重建
数据    朝阳公园诊所 + since=2024-06-01 → 5,825 患者 / 30.1 万 facts / 3.47 万回访
来源    本地 ClickHouse（localhost:8123，全量旧拷贝）
```

⚠️ 本地 CH 缺 `fact_complex_cases_out`（旧拷贝，manifest 后加的表）。需手工建同构空表，
**必须含 `is_del` / `case_stage` 两列**，否则 transforms 报 `Unknown expression identifier`：

```sql
CREATE TABLE dw_group.fact_complex_cases_out (
  customer_id Nullable(Int64), brand Nullable(String),
  is_del Nullable(Int8), case_stage Nullable(Int8),
  created_gmt_at Nullable(Date), updated_gmt_at Nullable(DateTime)
) ENGINE = MergeTree ORDER BY tuple();
```
