# 召回分配 · 设计教条

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

| | |
|---|---|
| **状态** | 讨论中（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 · 不使用无数据支撑的因素

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

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

### 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` 一桶吞掉了。）

> 🔴 **`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();
```
