# 召回分配 · 设计教条

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

| | |
|---|---|
| **状态** | 讨论中（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 人做浅。
**池子里剩下的不是遗漏，是还没轮到。**

### 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 · 客服退回必须说明原因

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

### 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。矩阵是召回池的一个**视图模式**（列表 ⇄ 矩阵），不新开路由。

### 矩阵热力配色

**真正的温度渐变**，蓝 → 琥珀 → 橙（蓝橙互补，中点自然落在暖黄）：

| 档 | 色值 | 理由 |
|---|---|---|
| ❄️ 冷 | `#DBEAFE` 蓝 | 视觉上"退后" |
| 🌡 温 | `#FEF3C7` 琥珀黄 | 蓝橙渐变的自然中点 |
| 🔥 热 | `#FB923C` 橙 | **不用红** —— 红太冲，橙是"该动手了"而非"出事了" |

⚠️ **数量不参与配色**，只用数字表达 —— 否则「这格橙是因为热还是因为人多」分不清。

### 移交助手的动效

**用助手/桌宠现有的动效系统，不引任何动画库**（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 一侧。

### 主管实际会问的三类问题 → 三个 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 月；**最终以主管信息为准** | 数据只做默认值，主管说了算 |
| 容量上限 | **在手总量 20-50** | 不是每日增量 |
| 初选 X 轴用什么 | **画像的潜在治疗 8 类** | 见 T6a；`focusCategory` 是技术类目不是业务机会，且会把早矫埋进正畸 |
| 拟分 N 人怎么定 | **默认分满容量** | 第一性：容量上限本身已是「一个人同时能处理多少」的约束，不必再打折 |
| 明细默认展示多少 | **按客服折叠**，展开才看 | 兼顾「尽明细」与「一眼可确认」 |
| 全景是否先问意图 | **不问**，助手推导 | 每多问一句就多一次决策成本，与极致减负相悖 |
| 批次表叫什么 | **`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();
```
