# 召回分配 · 设计教条

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

| | |
|---|---|
| **状态** | 讨论中（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` 读时算天数）。这是第三次，别再交一次学费。

**③ 「不知道」不等于「冷」。** 本次改动之前算出来的画像没有边界字段 →
`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）。

---

## 四、关键数据事实（2026-08 生产实测，避免后人重新推导）

### 4.0 初选矩阵实测（本地 5,825 患者样本，8 × 3 全部有量）

| 潜在治疗 | 🔥热 | 🌡温 | ❄️冷 | 合计 |
|---|---|---|---|---|
| 潜在补牙 filling | 83 | 117 | 989 | 1,189 |
| 潜在拔牙 extraction | 101 | 130 | 652 | 883 |
| 潜在修复 restoration | 34 | 65 | 521 | 620 |
| 潜在种植 implant | 43 | 39 | 294 | 376 |
| 潜在牙周 perio | 17 | 11 | 345 | 373 |
| 潜在根管 endo | 16 | 21 | 169 | 206 |
| 潜在正畸 ortho | 25 | 38 | 89 | 152 |
| 潜在早矫 early_ortho | 19 | 19 | 43 | 81 |

> ⚠️ 用 `focusCategory` 口径时，这 81 个**早矫**机会会被埋进 152 个正畸里 ——
> 而两者的打法、话术、沟通对象（家长 vs 本人）完全不同。这是换 X 轴最直接的收益。

### 4.1 为什么温度选「临床窗口」而不是 RFM / 生命周期

三个候选维度在召回池里的真实分布：

| 维度 | 分布 | 结论 |
|---|---|---|
| `urgency_level` 急迫性 | **78% 打满 10** | ❌ 判定过宽，该分的没分开 |
| `lifecycle_stage` 生命周期 | **60.6% 成熟客**，流失客仅 0.9% | ❌ 阈值（末诊 540 天内均为成熟）跟牙科节奏不匹配 |
| `rfm` 价值分群 | 28% / 24% / 44% ✅ 均匀 | ⚠️ 但**不回答「现在有没有生意可做」** |
| **临床窗口** | 热 11% / 温 11% / 冷 78% | ✅ **偏斜但有意义** —— 池子本就是积压，78% 超窗是真相 |

> 窗口口径下热+温仍有 4.6 万条，**远超团队产能**，三档够用，不必再细分。

### 4.2 专属客服（分配第一因素）

```
存储    patients.preferences->'dedicatedCs' = { id, name }
来源    摄入时从宿主 current_task_director 落
覆盖    368,868 / 441,875 = 83.5%，涉及 1,092 个客服
```

⚠️ **但单诊所在岗客服仅 30-39 人** —— 说明专属客服里**大量已离职**。
分配时必须校验在岗，否则会把任务分给离职的人。

### 4.3 在岗客服的数据源

```
表    patient_return_visits.task_director_id / task_director_name
索引  (host_id, tenant_id, clinic_id, task_director_id, task_date DESC)
覆盖  166.7 万条回访，99.9% 带客服 id，2,196 个客服
```

⚠️ **判「在岗」必须用 `source_created_at`，不能用 `task_date`** ——
后者含未来排程（生产实测最远 2033 年、DW 侧甚至有 2121 年），会把早已离职的人误判为在岗。

⚠️ **客服与诊所是多对多**（实测 24% 跨诊所），名册里同一人会出现在多个诊所下，属正常。

### 4.35 现有实现调研结论（2026-08，四方向并行 + 交叉复核，逐条已自验）

| 结论 | 证据 |
|---|---|
| **`staff` 也持有 `PLAN_ASSIGN` / `PLAN_RECYCLE`** → **不能用它区分主管** | `enums/index.ts:37-46` |
| leader 相对 staff **只多 2 个权限**：`PLAN_VIEW_ALL` + `STATS_VIEW` | 同上 |
| **`STATS_VIEW` 是死权限** —— 全仓仅 3 处命中，零端点零组件 | — |
| **MCP 端点是 `@Public()`** → `PermissionsGuard` 短路；写工具必须在 handler 内自查 | `mcp.controller.ts:26-27` |
| **`McpAuthContext` 只有 `{ scope, permissions }`**，无 role | `mcp-auth.service.ts:7-10` → 契合 T19，够用 |
| 7 个 MCP 工具**全部只读**，无任何写工具 | `mcp-server.factory.ts` 7 处 `registerTool` |
| **`plan_event_logs` 只写不读** —— 5 个写入点，**读取点 0** | 效果跟踪将是它的第一个读场景 |
| **`plan_reasons.campaignId` 从未被写过**，且是 reason 级、supersede 不带 | 不能复用作分配批次 |
| ⚠️ **引擎丢归属不记账** —— `patch.assigneeUserId = null` 且无 `recordPlanEvent` | `plan-engine.service.ts:398` |
| ⚠️ **`recycle` 的 `reason` 被丢弃** —— controller 写作 `@Body() _dto`，service 签名无该参 | `plan.controller.ts:127` |
| **`RECYCLE_TIMEOUT_HOURS = 24` 模块级硬编码**，注释说应来自 `tenant.rules_config`（该配置不存在） | `plan.service.ts:50` |
| **PAC 无 users 表**；客服名册只有 `data/jvs-dw/users.json`（结构已按未来两表设计） | `mock-users.ts:4-25` |
| 前端**三处 assign 调用全传 `user.sub`**，从未传过别人 | rail:150 / list:171,190 |
| 前端**无法把 userId 翻成人名** —— `dictionary.users[sub]` 只覆盖当前登录人 | `lib/utils.ts:66-71` |
| `components/ui/` **无 table / checkbox / tooltip / accordion** | 确认单要用，需先补 |
| artifact iframe 是 `sandbox="allow-scripts"` + CSP `connect-src 'none'` | **卡片内不可能触发写** |

> ⚠️ 「引擎丢归属不记账」最危险：主管分了 50 单，引擎重算把一部分静默收回池子，**主管毫不知情**，
> 且退回率统计天生偏低。做分配前必须先补这条记账。

### 4.36 开发规划阶段的新发现（2026-08-02，对抗批判产出）

以下五条是**四份分层方案都漏掉、由批判环节抓出**的，开发时务必留意：

| # | 发现 | 后果 |
|---|---|---|
| **1** | `followup_plans` 还需第 6 列 **`assign_strategy`**（`dedicated`/`spread`/`manual`） | T20 要按「专属 vs 铺平」算完成率差。⚠️ **事后补不了** —— `patients.preferences.dedicatedCs` 是 upsert 覆盖的「当前值」，历史丢失，**分配当时不记就永久没了** |
| **2** | 撤销的「已动过」判据**不能只看 `plan_executions`** | 回写率仅 11%（4.5）。只看执行记录会把 89% **已打过电话**的单收走并清归属 —— 正是 `claim-guard.ts` 第 1 条要防的。⚠️ 规划建议叠加 `contactAttempts`，**实测该字段与 `plan_executions` 同源、同为 7 条，帮不上忙** → 最终以 **`view` 事件**为主，见 T21 |
| **3** | 归因列必须**无条件继承**，与 `carryAssignment` 解耦 | `plan-engine.service.ts:471` 的条件是 `latest?.status === 'assigned'`，而**退回后 plan 是 `status='active'`**（`plan.service.ts:672`）→ 走不到该分支，退回单的归因会**连分子带分母静默归零** |
| **4** | 「引擎丢归属不记账」实测是 **5 处**，不是 1 处 | 除 `plan-engine.service.ts:398`，还有升版本(`:471`/`:511`)、`closeStaleActivePlan(:139)`、`recycle-scheduler(:87)`、`plan.service.recycle(:670)` |
| **5** | `assign()` / `recycle()` **不校验 `scope.clinicIds`**；`plan_event_logs` / `plan_reason` **不在 `SCOPED_MODELS`** | 跨诊所越权 + 租户隔离扩展覆盖不到。与分配无关（现存问题），但分配会放大影响面 |

### 4.37 ID 空间验证（G0.1，已实测通过）

```
登录侧有行为的人   58        回访名册   2,198        重合 47（81%）
```

**同一 id 空间成立。** 但 11 个只在登录侧的回访数全为 0 →
**新入职 / 只做召回不做回访的客服，名册里查不到**。

→ 设计要求：名册之外仍须允许主管**显式指定**，并提示「该客服无近期回访记录」。

### 4.38 🔴 圈人关联画像必须按**患者**取当前版，不能按 `plan.persona_id`

2026-08-02 做温度轴时抓到的**静默归零**缺陷（当时已写进 `assignment-proposal.service`）：

```sql
-- ❌ 看着天经地义，实际是个定时炸弹
WHERE pe.id = fp.persona_id AND pe.superseded_at IS NULL
```

`followup_plans.persona_id` 指向**建 plan 那一刻**的画像版本；画像一升版本那行就被 supersede，
而 plan 的外键**不跟着走** → 条件恒为假 → 整个「潜在治疗」筛选变成 0 条，
**不报错、不告警**，主管只看到「这个格子没人了」。

**实测**（跑完一次全量 persona 重算之后）：

```
池子里还指向"活着的画像版本"的 plan       97 / 2,724
同一个「潜在种植」条件 · 按 persona_id       0 条
同一个「潜在种植」条件 · 按 patient_id     376 条   ← 列表页 buildListWhere 一直是这个口径
```

→ **一律按 `pe.patient_id = fp.patient_id AND pe.superseded_at IS NULL`**，与列表页同口径。
两处不一致正是 T6a「同源保证」明令要防的「矩阵上有人、点进去列表没有」。
回归护栏见 `tests/assignment-cohort-join.spec.ts`（单测拦不住 SQL 语义，退而锁源码形态）。

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