Commit bb019d32 by luoqi

docs(design): 召回分配设计教条 + 开发规划 —— 需求未定稿前不开工的沉淀

新开 docs/design/(与 docs/adr/ 平级):ADR 记「一个已定的架构决策」,
design 记「一个还在长的产品教条集」。设计冻结后可整体提升成 ADR。

## plan-assignment-doctrine.md — 设计教条(21 条 T1-T20)

讨论过程中逐条经产品确认才写入的教条,以及**为什么这么定**的依据。
关键几条:
- T1  分配是批次运营,不是工单派发 —— 从不追求把池子分完
- T6a 初选 X 轴用画像的「潜在治疗」8 类,不用 focusCategory
      (后者会把 81 个早矫机会埋进 152 个正畸里,而两者话术/沟通对象完全不同)
- T6  温度 = 该治疗项目自身的临床周期(黄金/周期内/超周期),各用自己尺度归一化后横向可比
- T13 全景确认单直出不追问,意图由助手推导;只微调「指定客服 / 时效」两项
- T14 助手不出没有证据的结果 —— 缺数据就标默认值,有数据后反推替换
- T17 保守立柱:会用来筛的才立柱,其余进 JSON(沿用 plan_event_logs.details 已立的口径)
- T18 通用表不得业务化(曾提议给 plan_event_logs 加 assignment_id,已撤回)
- T19 权限由 permission 控制,role 只给人看
- T20 跟踪的目的是自优化,不是找对照组(认领将隐藏 → 没有对照组)

第四节「关键数据事实」把生产实测数字钉住,避免后人重新推导 ——
含为什么温度选临床窗口而非 RFM/生命周期(三者分布对比)、专属客服 83.5% 覆盖
但在岗仅 30-39 人、task_date 有 2033/2121 年脏数据等。

## plan-assignment-dev-plan.md — 开发规划

四层并行方案 → 双路对抗批判 → 汇总(7 agent)。含 15 条跨层契约裁决、
Gate 0、前置修复、六阶段计划(MVS ≈ 19.25 人日)、风险登记、验证与回滚策略。

批判环节抓出四份分层方案**都漏掉**的五条,已同步回教条 §4.36:
- assign_strategy 必须立柱 —— dedicatedCs 是 upsert 覆盖的当前值,
  分配当时不记就永久没了,事后反推不出来
- 撤销判据不能只看 plan_executions —— 回写率仅 11%,会把已打过电话的单静默收走
- 归因列须无条件继承,与 carryAssignment 解耦 —— 退回后 plan 是 active,
  走不到那个分支,归因会连分子带分母静默归零
- 「引擎丢归属不记账」实测是 5 处不是 1 处
- assign/recycle 不校验 scope.clinicIds;plan_event_logs 不在 SCOPED_MODELS

G0.1(go/no-go)已实测闭合:JWT.sub 与 task_director_id 重合 47/58 = 81%,
同一 id 空间成立。但 11 个只在登录侧的回访数全为 0 → 新入职/只做召回不做回访的
客服名册里查不到,故名册之外仍须允许主管显式指定。

## 两处规划与教条冲突,已列入待产品裁决

- MCP 是否注册写工具(教条定 A 方案走 MCP;规划因 @Public 短路建议写路径只走 REST)
- 矩阵是否放第一刀(取舍表定 v1;规划建议分两刀,因温度轴需 persona 全量重算)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 229b7985
# 召回分配 · 开发规划(v1)
| | |
|---|---|
| **状态** | 待评审(Gate 0 未闭合) |
| **日期** | 2026-08-02 |
| **教条** | [plan-assignment-doctrine.md](./plan-assignment-doctrine.md) —— T1-T20,**冲突处以教条为准** |
| **产出方式** | 四层并行方案 → 双路对抗批判 → 汇总(7 agent) |
> 依据:`/Users/luoqi/gitlab/pac/docs/design/plan-assignment-doctrine.md`(T1–T20 + 五之二/三/四 + 六·已定取舍)
> 输入:data / service / mcp / web 四份方案 + 挑刺报告 + 排期审查
> 本文是**开发文档**,不是讨论稿。教条冲突处以教条为准;四份方案冲突处由本文第 1 节的契约表裁决。
> 所有 `file:line` 已于 2026-08-02 工作树实测复核。
---
## 〇、一句话目标与范围边界
### 目标
**把召回池的「要不要做」变成「做了没有」** —— 门诊经理在召回池发起一次批次分配,助手直出确认单,主管一次确认落到 N 个客服名下;客服只看得到自己的单,可执行、可带原因退回;主管能回头看这批的结果,并让下一批更准。
### 本期做(v1)
| # | 范围 | 教条 |
|---|---|---|
| 1 | 批次实体 `plan_assignments` + `followup_plans` 归因列 | T17 / 五之二 |
| 2 | 主管确认触发的**批量分配写入**(唯一写动作,走 REST) | T8 |
| 3 | 助手全景直出**原生确认单**(微调仅「指定客服 / 时效」两项) | T13 |
| 4 | 分配时效 `assignment_expires_at`(写入 + 超期展示) | T11 |
| 5 | 客服退回带**结构化原因** | T7 |
| 6 | 前端 T16 改造:客服只有「我的」,认领入口全撤 | T16 |
| 7 | `PLAN_DISPATCH` 权限 + MCP 按能力过滤工具 | T19 |
| 8 | 在岗名册 + 负载(合并为**一个**端点/工具) | 五之四 |
| 9 | 批次跟踪(批次列表 + 单批详情,带 `n=` 与样本不足口径) | T20 |
| 10 | 初选矩阵(8 潜在治疗 × 3 温度)+ 移交动效 | T6a / T6 / 五之三 |
| 11 | 撤销整批(限时) | 六·已定取舍 |
| 12 | 福利(批次属性 + 归因标签 + 话术勾子) | T4 |
### 本期明确不做
- 教条八已列的五项(规则自动分配 / 能力评分 / 抢单 / 强制接受 / KPI 挂钩)
- **MCP 写工具**`assign_plans` / `revoke_assignment`)—— 见 D-4,与 T8 冲突,v1 不注册
- 分配单**到期后的自动回池 cron** —— 教条七·待确认,且擦 T8;v1 只做「写入 + 超期标红」
- 补建 `followup_plans` 的 partial UNIQUE(一患者一条活动 plan)—— 独立数据清理项,见 R9
- 企微通道的合成身份真身份映射(六·已定取舍:本期不管)
- 小屏(<lg)矩阵形态
- 福利核销 / 接宿主卡券(T4 已定 v1 不核销)
### 交付切法(重要)
v1 内**分两刀**,第一刀就是 MVS:
```
第一刀 MVS ── 端到端闭环,不含矩阵可视化,入口用现有「潜在治疗」标签筛
第二刀 ── 补矩阵(含温度轴新口径)+ 撤销 + 福利进话术 + 调整阶段画像圈人
```
⚠️ **这与教条六「矩阵放 v1」不冲突**(两刀都在 v1 内),但**需产品认可分刀**。理由:温度轴的技术链条含一次 persona 全量重算,**挂钟时间不受人日控制**;绑在第一刀会让整个交付被跑批窗口挟持。见 Q-1。
---
## 一、跨层契约表(**开工前必须评审通过,之后是唯一真理源**)
四份子方案在这 12 处互相冲突,不裁决就必然返工。以下为**总规划师裁决**,理由从简,冲突原案见括号。
| # | 契约项 | **定** | 被否的方案与理由 |
|---|---|---|---|
| **D-1** | 确认单机制 | 本地工具 `propose_assignment` → 后端展开肥载荷 → **侧信道** `onSideEvent` 推前端 → Block `kind:'assignment_sheet'` | ❌ web 的「从 `tool_call.args` 一次性带全」:`args`**模型生成的**`assistant.controller.ts:174` 转发 `p.input`),等于要模型逐字吐 200 个 uuid,幻觉率 100% 且 ≈12k token |
| **D-2** | 幂等键 | 列名 `request_id`**服务端在 propose 时铸造**,随确认单下发、确认时原样回传;`@@unique([hostId, tenantId, requestId])` | ❌ service 的「前端组件生成」:挡不住模型/SSE 重试。⚠️ data 层的 model 里**根本没有这一列**,必须补进迁移 A |
| **D-3** | 写端点 | `POST /pac/v1/plans/assignments`(新建 `assignment.controller.ts``@Controller('plans/assignments')`) | service 的 `POST /plans/assignments` 与 mcp/web 的 `/pac/v1/plan-assignments` 合并 |
| **D-4** | 写路径通道 | **只走 REST + `@RequirePermission(PLAN_DISPATCH)`**;v1 **不注册** MCP 写工具 | ❌ mcp §2.4/§2.5:MCP 是 `@Public()``permissions.guard.ts:16` `if (isPublic) return true` 已实测),护栏退化成 handler 自查 + 一个模型自填的 `confirmedByUser` 布尔。为一个**本期不存在的外部 agent** 打开 T8 唯一的洞,不划算。撤销同理走原生组件 + REST。⭐ `requirePermission` / `rejectSyntheticIdentity` 两个 helper **现在就写好**,第二刀/v2 直接用 |
| **D-5** | `followup_plans` 新增列数 | **6 列**(不是 4 也不是 5) | data 的 5 列 + 新增第 6 列 `assign_strategy`,见 D-6 |
| **D-6** | 「专属命中 vs 溢出铺平」怎么记 | **立柱** `assign_strategy TEXT``dedicated` / `spread` / `manual`) | 四份方案**全都没记**。T20 要按它分组算完成率 → 「会用来筛」→ 按 T17 就该立柱。⚠️ 事后从 `patients.preferences.dedicatedCs` 反推**不可行**:那列是 upsert 覆盖的「当前值」,历史丢失。**分配当时不记就永久没了** |
| **D-7** | `ReleaseReason` 值域 | 取 **data 层的 8 个**`not_my_patient` / `over_capacity` / `agent_unavailable` / `needs_other_role` / `deadline_too_tight` / `recently_contacted` / `patient_info_missing` / `other`)。⛔ **类型里不定义 `suppressDays`** | ❌ service 的 6 个:其中 `bad_timing``RECALL_FEEDBACK_OPTIONS.bad_timing``enums/index.ts:641`**同名不同义**。值域仍需产品过目,见 Q-3 |
| **D-8** | 时效入参形状 | 收 **`expiresInDays: number`**(相对天数),服务端按 `hosts.pullConfig.timezone` 转绝对时刻 | ❌ mcp 的 `expiresAt: z.string().datetime()` 必填:强制模型算 ISO 时刻,正是本仓 `4549817` 修过的「纯日期被当 UTC 零点偏 8 小时」 |
| **D-9** | 名册 / 负载 | **一个** REST 端点 `GET /pac/v1/plans/agents?clinicId=&withWorkload=`,MCP 工具 `get_agents` 与前端共用 | ❌ service 的两端点方案:五之四明写「开两个必然口径漂移」 |
| **D-10** | 撤销授权判据 | `batch.createdBy === actorUserId \|\| permissions.includes(PLAN_VIEW_ALL)`,在 service 顶部一次性校验,**不逐行调 `assertCanRecycle`** | ❌ mcp 的 `confirmedByUser: boolean`:模型自己填,拦不住任何东西 |
| **D-11** | 「已动过 / 不可撤销」判据 | `EXISTS(plan_executions)` **OR** `contactAttempts > 0` **OR** `EXISTS(plan_event_logs WHERE event='view' AND actorUserId = assignee)` | ❌ 单用 `EXISTS(plan_executions)`:4.5 实测**执行回写率仅 11%**(65 认领 / 7 条结果),会把 89% 已打过电话的单收走并清归属,那通电话永久蒸发 —— 正是 `claim-guard.ts:1-14` 第 1 条要防的 |
| **D-12** | 引擎新版本的字段继承 | **归因五列(`assignment_id` / `assignment_expires_at` / `assigned_by` / `release_reason` / `release_note` / `assign_strategy`)无条件继承,与 `carryAssignment` 解耦** | ❌ 只改 `carryAssignment`(data D1 / service N2 / mcp 都是这个):实测 `plan-engine.service.ts:471` `const carryAssignment = latest?.status === 'assigned' && !clinicMoved` —— **退回后 plan 是 `status='active'`**`plan.service.ts:672`),走不到这个分支,退回单的归因会连分子带分母一起静默归零 |
| **D-13** | 服务文件命名 | 统一 **`apps/pac-service/src/modules/plan/plan-assignment.service.ts`** + `agent-roster.service.ts` | service 的 `assignment.service.ts` 撞名 |
| **D-14** | UI 原语 | **零新依赖**:checkbox 用原生 `<input type="checkbox" className="... accent-brand-600">``patient-picker-rail.tsx:518-523` 已是此写法),折叠用受控 `useState<Set<string>>`,tooltip 用已装的 `hover-card` | ❌ mcp 「必须补 checkbox / collapsible」:Radix accordion **未安装**,内网拉包本身是风险 |
| **D-15** | MCP 工具命名 | 全 snake_case,与现有 7 个(`mcp-server.factory.ts` 7 处 `registerTool`)一致:`get_current_user` / `get_agents` / `get_cohort_attributes` / `list_assignment_batches` / `get_assignment_detail` | 教条五的 camelCase 是**接口草案记法**,不是工具名 |
---
## 二、开工前必须闭合(Gate 0 · go/no-go)
这三件**不占开发工时,但占日历**,且第一件不通过整个方案要重排。
### G0.1 ✅ `JWT.sub` 与 `task_director_id` 是同一 ID 空间 —— **已实测通过**
> 2026-08-02 于生产只读验证。原判为 🔴 go/no-go,**现已闭合**。
**为什么曾是 go/no-go**:分配落到 `followup_plans.assignee_user_id`(唯一写入方是
`plan.controller.ts:116``user.sub`,契约为「宿主给什么就是什么」),
而名册来自 `patient_return_visits.task_director_id`(DW 列)。
两者若非同一 id 空间 → 主管分完 50 单,客服的「我的」全是空的
`view='mine'` 只是 `where.assigneeUserId = scope.userId`),**不报错、不告警**
**实测结果**
```
登录侧有行为的人(assignee ∪ actor ∪ operator) 58
回访名册人数(task_director_id) 2,198
两边都有 47 ← 81% 重合
只在登录侧 11
```
**81% 重合 → 同一 id 空间成立**(若为两套 id,重合率会接近 0)。
⚠️ **但暴露一个覆盖缺口**:那 11 个只在登录侧的(`5960/5961/5963/5971` 连号新号段、
`9``1513``admin-luoqi`)回访数**全为 0** —— 即**新入职 / 只做召回不做回访的客服,
名册里查不到**
**设计要求**`get_agents` 名册之外仍须允许主管**显式指定**
并提示「该客服无近期回访记录」。不能因为不在名册就分不了。
`5961` 即前述「晋芳辰」,认领过 7 个患者却零回访记录,是活生生的例子。)
### G0.2 契约表评审(第 1 节)
一次会,把 D-1 ~ D-15 过一遍。**`packages/types/src/enums/index.ts` 是四方争抢点**(权限 + ReleaseReason 都插在同一区域),必须约定:P0.1 与 P0.2 合一个 PR,其余三层等它 merge 再拉分支。
> 工时 0.25 人日(评审)。
### G0.3 产品决策清点
见第 6 节。**Q-1 / Q-2 / Q-3 卡开发起跑,其余可并行。**
---
## 三、前置修复:四个既有洞
教条五之二列了三个,实测**只有一个半是真前置**,另有两个是四份方案都漏掉的。
### F1 · 引擎丢归属不记账 —— **实测是 5 处,不是教条说的 1 处**
| 位置 | 现状 | 修法 |
|---|---|---|
| `plan-engine.service.ts:392-401` | 诊所重归属 → `patch.assigneeUserId = null` 等四件套,`:425` 的条件 tx 内**无记账** | 同一 tx 内、**仅当 `latest.assigneeUserId != null`** 时补 `recordPlanEvent``heldSeconds` 必须在 patch 生效**前**`latest.assignedAt` 算 |
| `plan-engine.service.ts:471` + `:511-515` | 升版本 + `clinicMoved``carryAssignment=false`,旧 assigned 版本被 supersede(`:492-495`),新版本空归属 | `:490` 的 tx 内、supersede 旧版**之前**记账。⚠️ `planId`**旧版** `latest.id` |
| `plan-engine.service.ts:139-154` `closeStaleActivePlan` | 信号清零 → assigned 也直接 supersede;`select` 只取 `{id, status}``:146`),`update` 裸调**无 tx** | ① `select``hostId/tenantId/patientId/assigneeUserId/assignedAt` ② 包 tx ③ 有 assignee 才记账 |
| `recycle-scheduler.service.ts:87` | 清三件套 | 补 `assignedBy: null` / `assignmentExpiresAt: null`**`assignmentId` 保留** |
| `plan.service.ts:670-678` `recycle` | 同上 | 同上 |
**事件类型复用 `auto_release`,靠 `reason` 区分,不新增 `PlanEventType`。**
⚠️ 但 **`reason` 列必须同步枚举化**(四份方案都漏):该列现已在存 `'timeout'` / `'up'` / `'down'`,本次要再加 `clinic_moved` / `signals_cleared` / `revoked` / 8 个 release reason。`PlanEventType` 的注释原则是「新增事件必须登记,不要在调用处随手写字符串」—— 往 `reason` 里塞 8 个未登记字符串是在同一条纪律上换了一列违反。**新增 `PlanEventReason` 枚举**,同文件同风格,0.1 人日。
> ⚠️ **一次性事件洪峰**:`runAllForHost` 是全量。某次口径变更导致大面积诊所重归属 → 一次性写出大量 `auto_release`。加计数日志,别让运维当 bug。
**位置**:P0(无前置,可最先做)。纯可观测性,不依赖任何新列。
### F2 · `recycle` 的 `reason` 被丢弃 —— T7 落不了地
链路三处(已实测):
- `packages/types/src/schemas/plan.ts:242-245` `RecyclePlanRequestSchema = { reason?: string }` → 换成 `{ releaseReason?: ReleaseReasonSchema, releaseNote?: string }`
- `apps/pac-service/src/modules/plan/plan.controller.ts:127` `@Body() _dto: RecyclePlanRequestDto`**就是这个洞**(下划线),改 `@Body() dto` 并透传
- `plan.service.ts:639` 签名加两参 → `:670-678` update 写两列 → `:680-689` `recordPlanEvent``reason: releaseReason``details: note ? { note } : null`
**条件必填,不是全局必填**`plan.assignmentId != null && !releaseReason → BadRequestException`。落点同 `execution.service.ts:126-129`(选 `inaccurate` 必须勾具体治疗)—— **服务端也拦,不只信前端**
⚠️⚠️ **退回 ≠ 放弃:一律不动 `snoozedUntil`**。照抄 `ABANDON_REASON_META` 那套 `suppressDays` 会把被退回的患者静默压 60~90 天,正好是 T5「池子里剩下的不是遗漏,是还没轮到」的反面。**`RELEASE_REASON_META` 的 TS 类型里根本不定义 `suppressDays` 字段**,用类型系统拦住。
**位置**:P0(无前置)。技术上不是分配的前置,但**必须与分配写入同批上线** —— 滞后一个迭代 = 第一批批次的退回原因分布永远是空的,而**第一批数据是最贵的**(T20:跟踪的产出是下一次分配的输入)。
### F3 · `RECYCLE_TIMEOUT_HOURS` 硬编码 —— **降级,不做 env 化**
教条五之二写「T11 时效不可配」是**误判**:T11 的可配性由新的 `assignment_expires_at` 承担,且同节自己也定了「两条路不互相干扰」。加上自动回收生产未启用(`PAC_PLAN_AUTO_RECYCLE` 未配)—— env 化一个**没人读的常量**是纯噪音。
**降级为 0.05 人日改两处注释**`plan.service.ts:50`(「后续接 tenant 配置」)与 `schema.prisma:1024`(「= assignedAt + tenant.rules_config.recycleTimeoutHours」)—— **PAC 全仓没有 `Tenant` 模型**,那句话会持续误导后来人。删掉它。
**位置**:随手做,不占关键路径。
### F4 🔴(新发现,四份方案都漏)· `assign()` / `recycle()` 不校验 `scope.clinicIds`
实测 `plan.service.ts:572-577``findFirst` where 只有 `id` + `patient.sourceUnit`**没有 `targetClinicId`**;而列表侧 `buildListWhere:311-316` 明确做了诊所硬边界(注释:「clinic 隔离:orgScope→clinicIds 是硬边界,**始终生效**」)。
**后果**:诊所 A 的 leader 传 `clinicId=B` + 一批 B 的 planId,就能把 B 诊所的单分给自己诊所的客服。他**看不到**那些单(列表被挡),但**批量写入不受挡**。多租户隔离在写路径上比读路径弱一档 —— 正好是最不该弱的地方。
**这是既有洞,但分配功能会把它从「知道 planId 才能利用」放大成「批量、有 UI」。**
**修法**:在 `assign()` / `recycle()` / 新的批量绑定闸里统一加
`...(scope.clinicIds.length ? { targetClinicId: { in: [...scope.clinicIds, null] } } : {})`
**位置**:P0(必须先于 P2 批量写入)。
### F5(新发现)· `plan_event_logs` / `plan_reason` 不在 `SCOPED_MODELS`
实测 `apps/pac-service/src/prisma/tenant-guard.extension.ts:23-38` 的 14 张表里没有 `PlanEventLog`。而跟踪功能是它的**第一个读场景** —— 正好是纵深防御该兜底的时候。
**修法**:P1.1 一并登记 `PlanEventLog` + `PlanAssignment``SOURCE_UNIT_MODELS``:41`**不加**(两表都无 `source_unit` 列)。
---
## 四、分阶段计划
每阶段**独立可交付、独立可验证**。人日按熟悉本库的开发者估。
### 阶段总览
```
Gate 0 ──► P0 地基(四路并行) ──► P1 表与不变式(同一次发布) ──┬─► P2 写路径 ──┐
├─► P3 助手侧 ──┼─► P5 跟踪
└─► P4 前端 ────┘
═══════ 到此为 MVS ═══════════╝
P6 第二刀(矩阵/撤销/福利话术/画像圈人)
```
---
### P0 · 地基(无前置,四路并行)· 3.95 人日
| 任务 | 改动点 | 人日 |
|---|---|---:|
| **P0.1** 权限 | `packages/types/src/enums/index.ts:15-33``PLAN_DISPATCH: 'plan:dispatch'``:52-63` `ROLE_PERMISSIONS[LEADER]` 加(ADMIN 走 `:64` `Object.values` 自动获得);⛔ **不加给 STAFF**(4.35:staff 也持有 `PLAN_ASSIGN`,加给 staff 等于把新权限也废掉);`apps/pac-web/src/stores/auth-store.ts:104-117` **补 merge `permissions`**(实测缺失,`/auth/session` 是返回它的) | 0.5 |
| **P0.2** ReleaseReason | 同文件 **766 行**`abandonReasonsFor` 之后)加 `ReleaseReason` + `RELEASE_REASON_META`(含 `group` / `lever` / `hidden?`**无 `suppressDays`**)+ `ReleaseReasonSchema` + `releaseReasonsForForm()`;同时加 `PlanEventReason` 枚举(F1 尾) | 0.5 |
| **P0.3** 删死代码 | `components/plans/plans-list-app.tsx`(1053) / `use-plans-list.ts`(65) / `use-plan-counts.ts`(44) / `components/plan-detail/task-drawer.tsx`(287) / `use-my-tasks.ts`(75);顺手改 `plans/page.tsx:14` 指向已删文件的注释 | 0.25 |
| **P0.4** toolsCache 分桶 | `apps/pac-service/src/modules/assistant/mcp-client.service.ts:25` `toolsCache: McpToolDef[] \| null``Map<string, McpToolDef[]>`,key = 能力指纹(`'dispatch'` / `'basic'`),由调用方传入(`McpClientService` 不该解 JWT);改 `listTools(token, capabilityKey)``assistant.service.ts:64` 调用点 | 0.25 |
| **P0.5** 引擎记账 5 处 | 见 F1;含单测 | 1.25 |
| **P0.6** recycle 收原因 | 见 F2;含单测 | 1.0 |
| **P0.7** clinic scope 硬边界 | 见 F4;`plan.service.ts:572-577` / `:645` 附近 + 测试 | 0.3 |
| — | 顺手:改 F3 两处过期注释 | 0.05 |
**⚠️ P0.1 与 P0.2 改同一文件同区域,必须同一个 PR。** P0.4 是 P3.5 条件注册的**安全前置**(不分桶就会串号:进程重启后第一个进来的若是 staff,全公司 leader 都拿 staff 清单)。
**交付物**:可上线的独立版本(无新功能,但引擎不再静默丢账、退回能记原因、客服返池带原因)。
**验收(可证伪)**
- P0.1 ① 一个 leader 账号**不重登**`/auth/session` 返回后 `useHasPermission(PLAN_DISPATCH)` 变 true ② 单测断言 `ROLE_PERMISSIONS[STAFF]` 不含 `PLAN_DISPATCH`
- P0.3 `pnpm build` + `tsc --noEmit` 通过,`grep -rn "plans-list-app\|use-my-tasks\|task-drawer" apps/pac-web/src` 零命中
- P0.4 集成测试:staff token 与 leader token 各调一次 `listTools`,返回工具数**不同**
- P0.5 构造三种触发(诊所重归属 / 升版本 clinicMoved / 信号清零),每种在 `plan_event_logs` 留一条 `auto_release``reason` 可区分、`heldSeconds` 非 null;⭐ 断言「无 assignee 时**不**写事件」(别制造洪峰)
- P0.6 带 `releaseReason='other'` 无 note → **服务端 400**;带合法 reason → `followup_plans.release_reason``plan_event_logs.reason` 同值;⭐ 断言 `snoozedUntil` **未被修改**
- P0.7 用 A 诊所 leader 的 token 对 B 诊所的 planId 调 assign → **404/403**
---
### P1 · 表与不变式(⚠️ P1.1 与 P1.2 **必须同一次发布**)· 1.35 人日
#### P1.1 迁移 A + schema(0.75 人日)
**新建** `apps/pac-service/prisma/migrations/<YYYYMMDDHHMMSS>_add_plan_assignments/migration.sql`
**Prisma schema 改动**`apps/pac-service/prisma/schema.prisma`):
| 位置 | 改动 |
|---|---|
| 插入 **1502**`PlanEventLog``}` 之后、`// 横切支撑` 分节头之前) | 新增 `model PlanAssignment` |
| **155** 之后 | `Host``planAssignments PlanAssignment[]` |
| `model FollowupPlan` **1045 后** | 加 6 列(见下) |
| **1060 后** | `assignment PlanAssignment? @relation(..., onDelete: Restrict)` ⚠️ **必须显式写 `Restrict`** —— Prisma 对可选关系默认 `SetNull`,将来任何一次误删批次会把 N 行归因静默置空且不报错 |
| **1068 后** | `@@index([assignmentId])` |
| `tenant-guard.extension.ts:23-38` | `SCOPED_MODELS``'PlanAssignment'` + `'PlanEventLog'`(F5) |
**`plan_assignments` 列**(相对 data 层方案的增量已标 ⭐):
```
id / host_id / tenant_id / clinic_id?
created_by 主管(宿主 user id, TEXT —— PAC 不立 users 表)
request_id ⭐ 幂等键(D-2),服务端铸造
criteria Json 初筛条件快照(T17 不立柱)
attributes Json? { benefit?: { text } }(T17)
expires_at NOT NULL(T11:不存在无限期批次)
status confirmed | revoked ⭐ 撤销推迟到第二刀,但三列现在建齐
revoked_at? / revoked_by?
created_at / updated_at
@@unique([host_id, tenant_id, request_id]) ⭐
@@index([host_id, tenant_id, clinic_id, created_at DESC])
@@index([created_by, created_at DESC])
```
**不设** `planned_count` / `agent_count`(T17:从 `followup_plans` COUNT);不设 `source_unit`(品牌隔离已由 `scope.clinicIds` + patient.sourceUnit 双重覆盖);不设 `name`(加了就得管重名管改名)。
**`followup_plans` 新增 6 列**(全部可空、全部无默认值 —— 这是迁移不重写 25 万行的前提):
| 列 | 类型 | 注释要点 |
|---|---|---|
| `assignment_id` | `UUID?` | **null = 自助认领**,不是「未知」。⚠️ 退回时**不清空**(退回率的分母) |
| `assignment_expires_at` | `TIMESTAMPTZ(3)?` | 生效时效;⚠️ 与 `recycle_at` 是两条互不干扰的路,注释互指 |
| `assigned_by` | `TEXT?` | 谁分的 |
| `release_reason` | `TEXT?` | ⚠️ ≠ `recall_feedback`(1044);⚠️ 不写抑制窗 |
| `release_note` | `TEXT?` `@db.Text` | `other` 时必填 |
| ⭐ `assign_strategy` | `TEXT?` | `dedicated` / `spread` / `manual`(D-6,T20 反推「专属 vs 铺平」的唯一数据源) |
**迁移 SQL 形态 —— 单文件、多语句、全部普通 DDL,⛔ 不用 CONCURRENTLY**
复盘 `migrations/20260728020000_persona_features_tag_filter_indexes/migration.sql` 的文件头:Prisma 把整份 SQL 用一次 simple query 发给 PG → 隐式事务块 → `CREATE INDEX CONCURRENTLY``ERROR 25001`,且 `_prisma_migrations` 会留一条卡死记录(P3018)**堵死整条流水线**。分界线是**语句条数**,不是版本。
本次不需要它:唯一要建索引的存量表是 `followup_plans` **25 万行**(秒级),不是 `persona_features`(7.66M / 5.4GB)或 `patient_transactions`(12–24M)。`patient_facts` 1300 万行**本次一个字都不动**
语句顺序:
```sql
1. SET LOCAL lock_timeout = '10s'; -- ⭐ 见 R3
2. CREATE TABLE "plan_assignments" (...);
3. CREATE INDEX ×2 (plan_assignments);
4. ALTER TABLE plan_assignments ADD CONSTRAINT ..._host_id_fkey ...;
5. ALTER TABLE "followup_plans"
ADD COLUMN IF NOT EXISTS "assignment_id" UUID,
... 6 ADD COLUMN; -- ⭐ 必须一条 ALTER 带 6 个 ADD(一次锁,不是六次抢锁)
6. CREATE UNIQUE INDEX ... plan_assignments(host_id, tenant_id, request_id);
7. CREATE INDEX IF NOT EXISTS followup_plans_assignment_id_idx ON followup_plans(assignment_id);
8. ALTER TABLE followup_plans ADD CONSTRAINT ..._assignment_id_fkey ... NOT VALID;
9. ALTER TABLE followup_plans VALIDATE CONSTRAINT ..._assignment_id_fkey;
```
**文件头必须写清四件事**:① 为什么不用 CONCURRENTLY(引 `20260728020000` 的结论)② 6 列全可空无默认 → PG 11+ 元数据操作,不重写 25 万行 ③ **整份文件跑在一个隐式事务里,`ACCESS EXCLUSIVE` 持有到 COMMIT,真实阻塞窗口 ≈ 整份迁移时长 1–3 秒,后人别往里追加慢语句** ④ 失败后的救援:`npx prisma migrate resolve --rolled-back <name>`
**存量不回填**:6 列全可空,老数据 NULL 即语义正确(= 上线前的自助认领单)。25 万行 UPDATE 会真正重写表 + 膨胀 + 触发 autovacuum,收益为零。
#### P1.2 引擎无条件继承 + 守恒断言(0.6 人日)
`plan-engine.service.ts:499-520``create` 里,归因六列**与 `carryAssignment` 解耦、无条件继承**(D-12):
```
status/assigneeUserId/assignedAt/recycleAt/contactAttempts ← 仍由 carryAssignment 控制
assignmentId/assignmentExpiresAt/assignedBy/
releaseReason/releaseNote/assignStrategy ← ⭐ 无条件 latest?.xxx ?? null
```
丢归属时(`:396-401``recycle-scheduler:87``plan.service:674`**`assignment_id` 保留不清**,清的是归属四件套。
**验收 🔴(不绿则 P2 不许上生产)**
1.`status='assigned'` + `assignment_id=X` 的 plan → 触发升版本 → 新版本 `assignment_id` **仍等于 X**
2. ⭐ 造 `status='active'`**退回后的状态**)+ `assignment_id=X` + `release_reason='over_capacity'` 的 plan → 触发升版本 → 新版本三者**全部守恒**(只测 assigned 会漏掉这一支,四份方案都漏了)
3. 本地 38 迁移全应用的库上 `prisma migrate dev`**零 drift**
4. `plan_assignments``SCOPED_MODELS` 里,且有一条越权读被拦的测试
**交付物**:可上线的独立版本(表存在、不变式成立,但还没有任何写入方)。
---
### P2 · 写路径 · 3.1 人日
**新建**
`apps/pac-service/src/modules/plan/plan-assignment.service.ts`
`apps/pac-service/src/modules/plan/assignment.controller.ts`
`plan.module.ts:17-31` 注册。
#### 端点
```
POST /pac/v1/plans/assignments @RequirePermission(PLAN_DISPATCH)
GET /pac/v1/plans/assignments @RequirePermission(PLAN_DISPATCH)
GET /pac/v1/plans/assignments/:id @RequirePermission(PLAN_DISPATCH)
POST /pac/v1/plans/assignments/:id/revoke @RequirePermission(PLAN_DISPATCH) ← 第二刀
```
**不改** `POST /plans/:id/assign`(维持 `PLAN_ASSIGN`,T12/T16:后端保留认领)与 `POST /plans/:id/recycle`(维持 `PLAN_RECYCLE`,退回是客服路径)。
#### 入参
```ts
{
requestId: string, // D-2,来自 propose 下发,不由前端/模型生成
clinicId: string, // 批次不跨诊所
criteria: Json, // 初筛条件快照 + ⭐ 收敛规则(orderBy/limit),见 Q-2
attributes?: { benefit?: { text: string } },
expiresInDays: number, // D-8,服务端按 host 时区转绝对时刻
items: Array<{ planId, assigneeUserId, assignStrategy, expiresInDays? }> // ⭐ assignStrategy 逐条带
}
```
**`items` 是 `(planId, assigneeUserId)` 对,不是 `{assigneeUserId, planIds[]}` 分组** —— T15 溢出转铺平后归属是逐条算的,分组形状让「其中 5 条单独设 7 天」无处安放。
#### 事务与并发
单个 `$transaction(fn, { maxWait: 10_000, timeout: 30_000 })`
1. **三道闸**`requirePermission``rejectSyntheticIdentity``scope.userId.startsWith('wx:')` 直接拒绝,六·已定取舍「写工具上线前需另行处理」—— 这就是那个处理)→ **scope 绑定**:一次 `findMany({ where: { id: { in }, hostId, tenantId, patient: { sourceUnit: { in } }, targetClinicId: { in: [...scope.clinicIds, null] } } })`**数量对不上整体拒绝**
2. **按 `patientId` 去重**(🔴 `schema.prisma:1069` 注释里那条 partial UNIQUE **在 38 份迁移里根本不存在**,已逐份 grep 确认 —— 不能当既有保障用,见 R9)
3.`plan_assignments` 头行
4.`assigneeUserId` 分组,每组一条**带条件 `updateMany`**`where: { id: { in: chunk }, status: { in: ['active'] }, assigneeUserId: null, supersededAt: null }` —— **where 里带状态条件 = 并发安全**`recycle-scheduler.service.ts:85` 已在用此技巧);`count` 与 chunk 长度差 = 被抢走的
5. 事件一次 `createMany` —— ⚠️ **必须在 `plan-event.recorder.ts` 里加 `recordPlanEventsBulk(tx, inputs[])`**,该文件头(`:5`)宣称自己是「PlanEventLog 的**唯一写入口**」,在别处写 createMany 当场破功
6. `applied.length === 0` → 抛错回滚(**不留空批次**
**不能循环调 `PlanService.assign()`**`plan.service.ts:567`):每条自带 `findFirst` + 独立 `$transaction`,500 条 = 1500 次往返必炸,且它写不了新列。
**不让单条 assign 委托批量**:那会给每次认领造一条垃圾批次行。
**共用部分提取到 `claim-guard.ts`**(现有 66 行,不新建):把 `plan.service.ts:586-591` 提成 `assertAssignable(plan, targetAssigneeUserId)`。教条七的「已被认领的单能否强制改派」**正好落在这一个函数上**,将来产品定了只改这一处。
硬护栏 `items.length <= 500`(技术护栏,**不是**批次规模业务上限,见 Q-2)。chunk ≤ 500(bind-var 32767 上限,每行 ~7 变量 → 3500,安全)。
返回 `{ assignmentId, duplicate, assigned, skipped: [{planId, reason}] }`,助手照着说人话。
#### 幂等
`requestId` 冲突(P2002)→ **不抛错**,按 `requestId` 回查已有批次原样返回 + `duplicate: true`。抛错会让模型以为失败、改个参数重试一次,那才是真正的重复分配。
**验收**
1. 500 items 一次调用 < 5s 且不抛事务超时
2.`requestId` 调两次 → 只产生一个批次,第二次 `duplicate:true`
3. 批内含 1 条已被他人认领 → `assigned=N-1` + `skipped` 有一条 `claimed_by_other`**其余照落**
4. 同患者两条活动 plan 落进同一批 → 去重后只落 1 条
5. 传一个不属于本 scope(跨诊所)的 planId → **整批拒绝**
6. `applied=0` → 事务回滚,**库里没有空批次**
7. `expiresInDays=3` + host 时区 Asia/Shanghai → 落库的 `expires_at` 是当地日末,不是 UTC 零点
---
### P3 · 助手侧 · 4.3 人日
| 任务 | 改动点 | 人日 |
|---|---|---:|
| **P3.1** `get_current_user` | `mcp-server.factory.ts` **`:84` 之前**新增注册块(工具顺序影响模型默认注意力,身份判定放最前);返回 `{ userId, displayName, capabilities: { canDispatch, canViewAllPlans, canExecute }, clinicIds, tenantId }` —— **返回能力不返回 role**(T19)。⚠️ `displayName` 需扩 `mcp-auth.service.ts:7-10``McpAuthContext``userName?`(人名是显示串不是权限判据,不违 T19) | 0.4 |
| **P3.2** 名册 + 负载 | **迁移 B**(独立目录,**单语句 + CONCURRENTLY**):`patient_return_visits``(host_id, tenant_id, clinic_id, task_director_id, source_created_at DESC)` —— 实测现有索引末列是 `task_date``schema.prisma:447`),按 `source_created_at` 卡窗会退化成对 166.7 万行过滤。**加不删**(旧索引另有 patientId 路径用途)。**新建** `agent-roster.service.ts` + `GET /pac/v1/plans/agents`(D-9)+ MCP `get_agents` | 1.25 |
| **P3.3** systemExtra | **新建** `apps/pac-service/src/modules/assistant/assistant-prompts.ts` 导出 `buildSystemExtra({permissions, cohort?})``assistant.controller.ts:16-19` `ChatBody``context?``:122` 签名加 `@CurrentUser()``:144-149` 调用点传 `systemExtra`。⚠️ **实测 `/assistant/chat` 从来没传过 `systemExtra`** —— 全仓只有 `weixin-aibot.service.ts:140` 在传,「按角色切换工作流约束」目前是**零实现** | 1.0 |
| **P3.4** `propose_assignment` | 本地工具(`assistant.service.ts:76-91` `render_artifact` 旁);`AssistantChatInput``onSideEvent?`,controller 传 `send``:130-132` 已定义);后端展开:cohort → planIds → 按 agent 分桶 → 补姓名/优先级/专属客服 → **铸造 `requestId`** → 补默认值注记;**返回给模型的只有一句短话**(「确认单已呈现给主管,等待其确认。不要重复生成,也不要声称已完成分配。」)。⭐ 含**收敛规则**实现(Q-2) | 1.25 |
| **P3.5** 工具门控 | `mcp-server.factory.ts:80 build(ctx)` 里按 `permissions.includes(PLAN_DISPATCH)` 条件注册;⭐ **同时给 `list_recall_queue`(`:164`)的 `view` 参数加权限分流:staff 只能 `mine`** —— 实测 `plan.service.ts:293` 只对 `view='all'` 校验 `PLAN_VIEW_ALL``view='pool'` 无校验,staff 可以问助手「召回池里还有谁」绕开 T16 | 0.4 |
**P3.3 提示词正文的核心方法论**(必须写进代码注释):
> **T14 与 T20 不能只写在提示词里 —— 必须「提示词 + 工具返回值」双保险。**
> LLM 做除法和阈值比较不可靠,而这两条恰恰全是除法和阈值。工具返回里直接带 `sufficient: boolean` 和**成品句子** `note`,提示词的任务从「让模型算对」降级成「让模型照抄」。
必须写进 systemExtra 的六条硬约束:
1. 「你**全程只读**。唯一改变数据的动作是主管在确认单上点确认,那由界面完成。**绝不要说『已经分配好了』**,正确说法是『确认单已呈现,请过目』」(T8 最容易破的地方)
2. 「全景阶段**不问意图、不做画像分层**,直接出确认单」(T13 / T9-A)
3. 「凡是没有历史数据支撑的建议值(时效 / 容量 / 专属优先),**必须当场标明是默认值**;工具返回的 `capacityNote` / `rosterNote` **原话抄进去**」(T14)
4.`sufficient=false`**照抄 note,不输出百分比、不画图、不出 0.0%**」+ 背景事实(全生产 `plan_executions` 仅 7 条、`success_appointed` 0 条)(T20 / 五之四)
5. 「退回率永远给两个数:『退回 5 / 已处置 40 = 12.5%(另有 60 未动)』」(五之四)
6. 「在岗/专属客服是**近似值**,原样转述 `rosterNote`,不说成『系统确认在职』,不替主管挡人」(六·已定取舍:不校验在岗)
**`get_agents` 返回口径**(写死,否则两处会漂):
- 在手 `inHand` = `followup_plans WHERE assigneeUserId=X AND status='assigned' AND supersededAt IS NULL`
- 名册 = `patient_return_visits GROUP BY task_director_id WHERE source_created_at >= now - N months` —— ⛔ **绝不能用 `task_date`**`schema.prisma:406` 注释与 4.3 都明说含未来排程,实测最远 2033 年)
- ⚠️ **不返回 `remaining`**(C3):容量「20-50」是**区间**,返回 `capacityRange: [20,50]` + `inHand` + `capacityBasis: 'default'`,减法留给主管。返回一个精确的 `remaining: 38` 会让模型说「李莉还能吃 38 个」—— 那是用默认值做完了减法,T14 的免责声明救不了
**验收**
- P3.2 生产量级(166.7 万行)`EXPLAIN ANALYZE`**新索引**,< 500ms;⭐ 造一条 `task_date=2033` 的记录,验证该客服**不**出现在名册里
- P3.3 对话验收:给 `n=12、转化 0` 的批次 → 输出必须含「样本量不足」,**不得出现「0.0%」**;给时效建议 → 必须含「默认值」
- P3.4 侧信道验收:tool result 回到模型的内容 **< 100 token**,而前端收到的 payload 含完整 planId 列表
- P3.5 staff token 调 `list_recall_queue({view:'pool'})` → 拒绝或降级为 `mine`
---
### P4 · 前端 · 4.75 人日
#### P4.1 列表页 T16 改造(0.75)· 13 处
| # | 位置 | 改法 |
|---|---|---|
| 1 | `patient-picker-rail.tsx:39-42` `VIEW_TABS` | pool 项加 `requires: Permission.PLAN_DISPATCH` |
| 2 | `:197` `.filter((t) => t.v !== 'all' \|\| canViewAll)` | 改按 `requires` 过滤(现在只过滤 `all`,pool 对所有人可见 = 违 T16) |
| 3 | `:53` `canViewAll` | 删(改用 `canDispatch = useHasPermission(PLAN_DISPATCH)`)—— 否则 eslint no-unused-vars 红 |
| 4 🔴 | `:109-123` 自动回落 effect | **加 `if (!canDispatch) return;`** —— **最易漏**:staff 的「我的」为空时现逻辑 `setView('pool')` 把他直接扔进召回池,tab 藏了但 view 还是 pool,**T16 当场破功** |
| 5-7 | `:143-159` `claim()` / `:312-316` `onClaim` prop / `:683-704` `PatientRow` 形参 | 整段删 |
| 8 | `:778-807` 动作按钮 IIFE | 二选一降为一选一:只剩 `onRecycle`;grid 叠层机制(`:788-806` hover 不重排)保留 |
| 9 | `plans/page.tsx:46` pool 兜底 | 包 `if (canDispatch)` —— 否则是隐式认领入口 |
| 10 | `plans/page.tsx:92-96` 空态文案 | staff「暂无分配给你的任务,等主管派单」;leader 保留 + 「去召回池分配」 |
| 11 | `plan-detail-app.tsx:220-228` `gateCheck` | `:223` 文案「在左侧列表点该患者的『认领』接手」指向不存在的入口,改掉;`:225``${assigneeUserId}` 换人名 |
| 12 | `plan-detail-app.tsx:449` 关闭机会后 pool 兜底 | 同 9 |
| 13 | `plans-api.ts:66-70` `assign` | **保留** + 注释「前端无入口,留给后续放开主动性(T16/T12)」 |
顺带改 `plan-sync-store.ts:9-11` 的注释(说「认领入口只在列表页」已不成立)。
#### P4.2 assistant-store(0.5)· 硬缺口,四份方案里只有 web 发现
**新建** `apps/pac-web/src/stores/assistant-store.ts`(zustand ~30 行,仿 `plan-sync-store.ts` 的 seq 模式):`{ open, setOpen, pending: {text, seq} | null, ask(text) }`
现状:`AssistantWidget``open` 是组件内 `useState``assistant-widget.tsx:21`),外部无法打开;`useAssistantChat()``AssistantChat` 内实例化(`assistant-chat.tsx:440`),`send` 不外露。**没有它,「移交助手」根本无处落地。**
⚠️ `AssistantWidget` 收起是 CSS 隐藏不卸载(`:63` `invisible`),store 化后**保持这个特性**,别改成条件渲染(会清空主管的对话)。
#### P4.3 客服姓名(0.75)· 确认单的**硬依赖**
现状实测:`lib/utils.ts:66-72` `userDisplayName` 只接受当前登录用户;`dictionary.users` 的两个填充点(`auth.service.ts:597` / `:656-658`**都只塞登录人自己一条**;后果是 `adapt-data.ts:165-168``assignee.name` 直接 `= assigneeUserId`,详情页「承接人」显示一串 uuid。
**双管齐下**
- **A(主路,治「显示」)** 凡返回 userId 的 payload 同时返回服务端解析好的姓名(`assigneeName` / `assignedByName` / 确认单每个客服的 `name`)。与站内既有做法同源(诊所名走 `auth.service.ts:125` `host.clinicNames` 派生;场景标签后端预翻译)。前端加 `agentDisplayName(id, name?)`,无 name 回落 id 前 8 位(**不回落完整 uuid**,400px 卡片会撑爆)
- **B(辅路,治「选人」)** 下拉候选走 P3.2 的 `GET /plans/agents`;前端 `stores/agent-dict-store.ts` 内存缓存一次。⭐ **直接改造 `DoctorPicker`**`patient-picker-rail.tsx:366-378, 601-659`),不重写
❌ 明确否掉:把 1,092 个客服塞进 JWT `dictionary.users`(token 几十 KB + 每次 base64 解码 + localStorage);前端逐个 `GET /users/:id`(20 行 = 20 请求)。
⚠️ **不做在岗校验、不打红叉**(六·已定取舍)—— 只在名字旁给中性灰标「近 12 月无回访记录」,主管自己判断。⚠️ 客服与诊所多对多(24% 跨诊所),下拉按 userId 去重。
#### P4.4 确认单(2.0)
| 改动 | 位置 |
|---|---|
| `Block` 联合加 `{ kind:'assignment_sheet'; sheet; state:'pending'\|'confirmed'\|'cancelled' }` | `use-assistant-chat.ts:29-32` |
| `onEvent``case 'assignment_sheet'`(upsert,与 `upsertArtifact` `:62-86` 同构) | `use-assistant-chat.ts:165-232` |
| `BlockView` 加分支 | `assistant-chat.tsx:124-129` |
| `TOOL_META` 补新工具中文 label | `assistant-chat.tsx:53-62` |
| **新建** `components/assistant/assignment-confirm-sheet.tsx` | 三层:汇总 → 按客服卡片列表(**不是 table**,400px 宽 `assistant-widget.tsx:27`)→ 患者明细(按客服折叠) |
| **新建** `components/plans/assignments-api.ts` | 对齐 `plans-api.ts:12-70` |
**微调只有两项**(T13:指定客服 / 时效)。多加一项直接违 T13,**评审按这条卡**。时效必须带「默认值」标注,文案照抄教条:「3 天(默认值,暂无历史结案数据,积累后按实际反推)」。溢出转铺平要**显式可见**(T15):被铺平的组打角标「专属容量不足,已转铺平」,可点开改(并写回 `assignStrategy`)。
底部「确认分配」按 `<Can perm={PLAN_DISPATCH}>` 包住(`components/can.tsx:24` 支持 fallback)。
🔴 **确认成功后必须往消息流注入一条 assistant 文本块**
```
已确认分配:批次 #a1b2c3 · 潜在种植 🔥热 · 3 位客服 · 共 60 条 · 3 天有效 · 福利「8 月种植体检免费」
```
因为 `toApiMessage``use-assistant-chat.ts:88-97`**只回传文本块**(注释原文:「工具块不回传,模型自行重新决策」)—— 不注入的话,下一轮主管说「刚才那批改成 5 天」,模型完全不知道有过「那批」,会重新提议一次。**改动很小但极易漏,漏了会被当成模型能力问题。**
确认成功后卡片切终态(批次 id + 时间,按钮禁用)。
#### P4.5 移交入口 + 动效(0.75)
⭐ MVS 阶段发射点挂在召回池的「**移交助手**」按钮上(矩阵来了只换调用处,正好验证三层架构):
| 位置 | 改动 |
|---|---|
| `lib/pet-events.ts:16-27` `PetGesture` | 加 `\| 'absorb'`,注释跟在 `:15` 那句「受限词表,LLM 导演也只能从这里选」下 |
| `lib/pet-events.ts:29-40` `PetEvent` | 加 `\| { type:'cohort_handoff'; payload:{ count, treatment, temperature? } }` |
| `components/pet/pet-brain.ts:8-20` + `:22-34` | `PetPose` / `ONE_SHOT_POSE` 各加 `absorb` |
| `pet-brain.ts:112-136` `onPetEvent` | 加分支 `play({ gesture:'absorb', bubble:\`正在挑 ${count} 位…\`, ttlMs: 2400 })` —— **这是唯一决定「怎么演」的地方** |
| `components/pet/pet-body.tsx:136-151` + `:456` `PET_CSS` | 加 `absorbing` flag + `@keyframes pacPetAbsorb`(⚠️ 宠物的 29 个 keyframes 在 `PET_CSS` 模板串里,**不在 `globals.css`**,通过 `:164``<style>` 注入 SVG) |
| 业务侧 | `emitPetEvent({...})` + `assistantStore.ask(...)`**仅两行** |
⚠️ `AssistantWidget``AGENT_INVOKE` 门控(`:20, :50` 无权限 `return null`),且**只挂在 `plans/layout.tsx:74`**`/plans/[planId]`),空工作台(`plans/page.tsx:72-108`)没有。验收清单写明:完整体验前提是同时具备 `AGENT_INVOKE` + `PLAN_DISPATCH`;空工作台的移交入口要么补挂 widget,要么不提供。
**验收**
- P4.1 🔴 **staff 账号「我的」为空时,左栏停在「我的」空态,不自动跳到召回池**;staff 全页面搜不到任何「认领」按钮
- P4.4 ① 微调「指定客服」后确认,落库的是**卡片当前值**不是模型原提案 ② 确认后**再问「刚才那批」模型能答上来** ③ 按钮变终态不可再点
- P4.5 `/pet-lab` 可独立预览 `absorb` 姿态
---
### P5 · 跟踪(MVS 版)· 1.8 人日
**MCP 只读工具** `list_assignment_batches` + `get_assignment_detail``mcp-server.factory.ts` 新增注册块,仅 `PLAN_DISPATCH` 可见)。
**数据源全部现有表**(五之四已定):`plan_assignments` / `followup_plans.assignment_id` / `plan_event_logs`(经 planId join)/ `plan_executions`
**MVS 不查 `patient_transactions`**(转化率):五之四已说初期无数据按 T14 明说样本不足 —— 那就**别查**,直接输出「暂无转化记录」。省 0.5 人日,且不会因为一个必然为 0 的分子拖慢查询。
**必须写死的三条口径**
1. 每个比率带 `{ value, n, numerator, denominator, sufficient, note }``note`**给模型直接抄的成品句子**
2. 退回率两个数都出(五之四)
3. ⚠️ 聚合 `plan_event_logs.reason`**必须同时过滤 `event='release'`** —— 否则 `'timeout'`(auto_release)和 `'up'/'down'`(feedback)会混进退回原因分布。`@@index([hostId, tenantId, event, createdAt])``schema.prisma:1499`)正好是这个形状
4. ⚠️ **T18**:绝不给 `plan_event_logs``assignment_id`。归因走 `plan_id → followup_plans.assignment_id` 多跳一次。**写 SQL 时会很想加那一列,code review 盯死**
✅ 索引已核实无需担心:`schema.prisma:1493` 已有 `@@index([planId, createdAt])`,覆盖「按 planId 批量拉 + 按 event 过滤」。**从风险清单移除**(mcp 层曾列为待实测项)。
**福利在 MVS 的落地(降级方案,需产品确认 Q-4)**:批次福利文本作为**独立静态字段**展示在客服的 plan 详情页(原文照搬,不经 LLM),客服自己念。0.3 人日,**零合规风险、零重生成成本**,T4 的归因目的完全达成。福利进 prompt 推到第二刀。
**验收**:主管问一句拿到批次列表;每个比率行带 `n=``n<50` 的行**不出百分比不画图**;⭐ 退回原因分布的 SQL 里能看到 `event='release'` 过滤。
---
### ⭐ 最小可用切片(MVS)= P0 + P1 + P2 + P3 + P4 + P5
#### 端到端剧本(必须一次连续跑通,中途不改数据库、不看日志)
```
① 初选 主管在召回池按「潜在治疗:种植」筛(现有 personaTags 能力,零开发)
→ 点「移交助手」(emitPetEvent + assistantStore.ask)
② 精选 助手全景直出:产能视图 + 拟分方案(get_agents 名册+负载)
③ 确认 propose_assignment → 侧信道 → 原生确认单三层卡片
微调仅两项(指定客服 / 时效),铺平角标可见
④ 分配 点确认 → POST /pac/v1/plans/assignments
→ 落批次 + N 条归属 + N 条 assign 事件 → 注入文本块补记忆
⑤ 执行 切 staff 账号 → 「我的」看到单(左栏只有「我的」,无认领入口)→ 提交执行结果
⑥ 退回 客服退回 → 必填结构化 releaseReason → 回池,snoozedUntil 不动
⑦ 跟踪 切回主管问「我分的那批怎么样」→ list_assignment_batches
→ 分了 60 / 已动 40 / 退回 5 / 超期 8 / 转化「样本量不足」
```
**被砍掉的只有「矩阵可视化」这一个视觉入口。** 生产线七环全在,T1 / T5 / T7 / T8 / T13 / T14 / T16 / T20 全部可验证。价值来自 ④+⑦(把「要不要做」变成「做了没有」),不是 ①。
#### MVS 工时
| 阶段 | 人日 |
|---|---:|
| Gate 0 | 0.5 |
| P0 地基 | 3.95 |
| P1 表与不变式 | 1.35 |
| P2 写路径 | 3.1 |
| P3 助手侧 | 4.3 |
| P4 前端 | 4.75 |
| P5 跟踪 | 1.8 |
| **合计** | **≈ 19.75 人日** |
三道并行(后端 / MCP-助手 / 前端)下,**关键路径挂钟约 9–10 个工作日**(P0.5 → P1.1 → P1.2 → P2.1 → P2.2 → P4.4 → P5.1),加 20% 联调余量 **≈ 12 个工作日**
> ⚠️ 四份子方案报的 8.0 + 10.5 + 8.5 + 1.7 ≈ 28.7 人日,其中约 8 人日是重复计入(跟踪三查询、名册负载、确认单、引擎记账、recycle reason、权限枚举各被报了 2–4 次)。
---
### P6 · 第二刀(v1 补齐)· ≈ 10 人日
| 任务 | 人日 | 说明 |
|---|---:|---|
| **P6.1 🔴 温度轴口径 + 数据改造** | 2.5 | 见下,**链条最长且含跑批挂钟** |
| P6.2 `GET /plans/matrix` + `temperature` 参数 | 1.0 | 口径必须是**去重患者数**且与 `buildListWhere``plan.service.ts:258`)同 scope,否则矩阵数与点进去的列表数对不上 → 违 T14 |
| P6.3 矩阵组件 + 配色 + 视图切换 | 1.5 | 见下 |
| P6.4 撤销(REST + 原生确认组件) | 1.25 | D-4 / D-10 / D-11 |
| P6.5 `get_cohort_attributes`(T9-B 调整阶段画像圈人) | 1.0 | 合并 `getDedicatedCs` + `getPersonaFeatures`(T10:不为每个因素开新接口);⚠️ 不 join 名册、不返回 `stillActive`(六·已定取舍:不校验在岗) |
| P6.6 福利进 prompt + 护栏 + 话术失效规则 | 1.25 | 见下 |
| P6.7 转化率 + 「因素 × 完成率」报表 | 1.5 | T20 的真正交付物 |
#### P6.1 温度轴 —— **四份方案全部踢皮球的孤儿依赖**
web 说「后端加 query 参数」、mcp 说「属初选矩阵工作流,不是 MCP 层的活」、data/service 只字未提。**没有 owner。**
而且现有数据**算不出来**(已实测复核):
1. `daysSince` 的唯一存放处是 `persona_features.data.detail[].daysSince``potential-treatment.feature.ts:126`
2. `persona-diff.ts:57``daysSince` 登记在 `VOLATILE_DATA_KEYS``stripVolatile()` 在变更检测前剔掉它,注释原话:「这类键的变化不代表患者变了,只代表『时间过去了』,不该升版本」
3.**时间流逝永远不会触发重算,温度档永久冻结在画像计算那一刻**。4.1 那张「热 11% / 温 11% / 冷 78%」是本地刚重建库(画像刚算完)的理想态,生产跑几个月后会整体往「冷」漂
4. `PotentialGap``potential-treatment.selector.ts:93-102`**没有日期锚**,只有 `daysSince: number`
**加上一个未在教条里出现的口径洞**:8 个业务标签 ≠ K 码,阈值**一对多**且已在聚合时被 `Math.max` 跨码合并 —— `extraction ← K01(180/90) + K03(90/60)``perio ← K05(120/90) + K06(120/60)`,还有 `*_RECOMMENDED` 各自的窗口(`canonical-codes.ts:236-257`)。**「潜在拔牙」这一格该用哪套阈值判热/温/冷,无解。**
**技术链条**
```
温度口径拍板(8 标签 × 窗口映射) ← ❓ 产品/口径决策,教条里没有(Q-1)
└→ selector 输出锚点日期(不是天数) + extractor 存 anchorAt
└→ 迁移 C:persona_features 温度偏索引(7.66M 行 / 5.4GB) ← ⚠️ 单语句 + CONCURRENTLY,独立目录
└→ 全量 persona 重算(挂钟窗口,不受人日控制)
└→ P6.2 matrix 端点
```
**锚点是事实、天数是时钟** —— 存 `anchorAt`(诊断日期)而不是 `daysSince`,可以进 diff(不进 `VOLATILE_DATA_KEYS`)、可以在 SQL 里现算、不会冻结。这是这条链的关键设计。
#### P6.3 矩阵组件要点
- **新建** `apps/pac-web/src/components/plans/pool-matrix.tsx`;挂在 `patient-picker-rail.tsx:196-215` tab 行右侧的「列表 ⇄ 矩阵」切换(仅 `canDispatch && view==='pool'`);**不新开路由**(T16)
- 行标签用 `packages/types/src/labels.ts:93-106``potentialTreatmentCardLabel()`,⛔ 别自己写中文表
- 配色三档**恰好等于 Tailwind 默认调色板**`bg-blue-100` / `bg-amber-100` / `bg-orange-400`,无需新 token(`globals.css:12` 的规矩:别在组件里写死十六进制)。⚠️ **底色按列固定,与数量完全无关**(五之三)—— 实现上列头决定 class,cell 不参与计算,⛔ 别写任何 `count > N ? ... : ...`。⚠️ 别用 `brand-*`(品牌蓝 `#0032A0``#DBEAFE` 深太多,混用会让「冷」像选中态)
- rail 是 `w-[320px]` 固定(`:194`),矩阵模式提到 `w-[420px]`(lg+ 在 flex 流内、<lg 是 fixed 抽屉,两种都安全);`<lg` 不提供矩阵(入口加 `lg:` 前缀藏掉)
- **X 轴零开发**`packages/types/src/persona-tag-filters.ts:92-109``potential_treatment` 维度 8 个 value 与 T6a 完全一致,点格子直接塞 `filters.personaTags`
- **Y 轴走 query 参数,不加进 `PERSONA_TAG_FILTER_DIMS`** —— 那张表是客服筛选标签 popover 的渲染源(`patient-picker-rail.tsx:410` 遍历它),加进去温度就会出现在客服面板(违 T16);且温度是初选轴不是精选标签(六·已定取舍:精选不再切窗口)
#### P6.6 福利进 prompt —— 🔴 唯一一条「做错了会对患者做出虚假承诺」的项
T4 已把落点写死:`shared/fact-block.ts`(标准 + 深度)与 `tiers/stable/prompt.ts`(稳健)**各加一处**,护栏「不得追加条件、期限、承诺,不得改写或夸大」,没配福利整段不生成。
⚠️ 但**四份方案对福利的处理全部止步于存储与展示**,且现有机制会直接打脸 —— 实测 `schema.prisma:1203` **`planId String @unique`**(一个 plan 一份话术),而 `agent-identity.ts` 文件头已记录:召回池是共享的,未认领工单谁都能打开,`POST :id/view` 就是为此埋的点。
**三段后果,一段比一段严重**
1. 分配**之前**若有人点开过这条 plan,话术已 `status='ready'` 落库 → **福利段永远不出现**,T4 的归因目的失效
2. plan 在批次 A(福利「8月种植体检免费」)→ 退回 → 进批次 B(福利「正畸首付 5 折」)→ 话术缓存**还是 A 的文案**,客服照着念 = **对患者做了一个不存在的承诺**
3. 撤销批次后话术里的福利仍在
**必做**:定义话术失效规则 —— `plan_assignments.attributes.benefit` 变化(进批次 / 换批次 / 撤销)必须把 `plan_scripts``pending` 重生成,**写进 `plan-assignment.service` 的事务里**,否则一定漏。
⚠️ **成本提醒**:一批 100 人 = 100 次 LLM 重生成的钱与延迟,四份方案都没算。
---
## 五、风险登记
| # | 级 | 风险 | 位置 / 证据 | 缓解 | 归属阶段 |
|---|---|---|---|---|---|
| **R1** | 🔴 | **ID 空间不一致**`JWT.sub``task_director_id` → 分完单客服「我的」全空,**不报错** | `auth.service.ts:537` / `schemas/auth.ts:27` / 本地是自证循环 | **Gate 0 硬闸**,不通过不开工 | G0.1 |
| **R2** | 🔴 | **supersede 吞归因**`carryAssignment` 只在 `status='assigned'` 为真(`plan-engine.service.ts:471`),而**退回后是 `active`**`plan.service.ts:672`)→ 退回单的分子分母一起静默归零 | 实测 | D-12 无条件继承 + **对 `active` 入参也断言**的守恒测试 | P1.2 |
| **R3** | 🟠 | **锁队列雪崩**:PG 锁队列近似 FIFO,一个等待中的 `ACCESS EXCLUSIVE` 会挡住其后所有 SELECT。部署时若有慢查询压在 `followup_plans` 上 → 召回工作台在 ALTER 拿到锁**之前**就全停 | `20260728020000` 文件头 | ① 迁移首句 `SET LOCAL lock_timeout='10s'`(把「全站排队几分钟」换成「迁移快速失败」)② 避开 DW 08:00(沪) 落库后的增量 cron 窗口 ③ 部署前扫 `pg_stat_activity` | P1.1 |
| **R4** | 🟠 | **迁移 B/C 混进 A**`ERROR 25001` + `P3018` **堵死整条迁移流水线** | 同上 | 三份**独立目录**:A 多语句普通 DDL,B/C 各**单语句 + CONCURRENTLY**。上线 checklist 里写死 | P1 / P3.2 / P6.1 |
| **R5** | 🟠 | **撤销/到期误收已作业单**`EXISTS(plan_executions)` 判「已动过」会漏 89%(4.5 实测:65 认领 / 7 条结果),客服打完的电话永久蒸发 | 4.5 + `claim-guard.ts:1-14` | D-11 三条件 OR | P6.4 |
| **R6** | 🟠 | **MCP `@Public()` 绕过权限**`permissions.guard.ts:16` `if (isPublic) return true` 已实测,`@RequirePermission` 在 MCP 路径完全不生效 | 实测 | D-4 写路径不走 MCP + 只读工具条件注册 + `requirePermission` helper 备着 | P3.5 |
| **R7** | 🟠 | **上线当天 leader 无 `plan:dispatch`**:JWT 的 `permissions` 是签发时固化的(`auth.service.ts:534`),2 小时内所有 leader 都是 staff 待遇,MCP 工具集静默降级,**无任何错误提示** | 实测 | ① `auth-store.ts:104-117` merge `permissions` ② 上线步骤写明「发版后强制重登 / 临时缩短 access token TTL」③ `get_current_user` 返回 `canDispatch:false` 时助手说「登录态可能过期,请重新登录」而非「你没有权限」 | P0.1 + 上线 |
| **R8** | 🟠 | **福利话术缓存**`plan_scripts.planId @unique``schema.prisma:1203`),换批次不重生成 = 对患者做虚假承诺 | 实测 | P6.6 失效规则;**MVS 期福利不进 LLM**(Q-4 降级) | P5 / P6.6 |
| **R9** | 🟡 | **schema 注释承诺的 partial UNIQUE 不存在**`schema.prisma:1069``data-model.mdx:91` 都写着「一患者只一条活动 plan」,但 38 份迁移里**没有任何一句创建它**(逐份 grep 确认) | 实测 | ⛔ 分配逻辑**不依赖**它去重,批量预检自己按 `patientId` 去重。本次**不修**(补索引前得先查存量违例) | P2.1 |
| **R10** | 🟡 | **`reason` 列成大杂烩**`auto_release` 下将混 `timeout` / `clinic_moved` / `signals_cleared` / `assignment_expired`,全靠自由 TEXT 区分,与 `PlanEventType` 注释「不要在调用处随手写字符串」同一纪律 | — | 新增 `PlanEventReason` 枚举(F1 尾)0.1 人日 | P0.2 |
| **R11** | 🟡 | **事件洪峰**`runAllForHost` 全量 44 万患者,补记账后一次口径变更可能一次性写出大量 `auto_release` | — | 加计数日志 + 上线公告 | P0.5 |
| **R12** | 🟡 | **`assignment_id` 就地覆盖导致历史批次规模漂移**:plan 在批次 N 被分 → 退回 → 进批次 N+1,批次 N 的 COUNT 悄悄少 1 | — | ⛔ **否掉 data 层 R2 的「写进 `plan_event_logs.details` 离线校正」** —— 它的用法 `WHERE details->>'assignmentId'=X``schema.prisma:1484` 自己引用的规则「要按它筛就该立柱」就该立柱,而立柱正是 T18 撤回的东西,且无索引 + 混着高频 `view` 事件 = 全表 jsonb 扫。✅ 正解就是 D-12(无条件继承后 COUNT 不漂,根本不需要补丁)。⚠️ 「同一 plan 跨批次」这一支仍会漂 —— 需产品确认可接受(Q-5) | P1.2 |
| **R13** | 🟡 | **`release_reason` 与 `recall_feedback` 聚合串了** → 统计全废 | 五之二 | 命名 DTO 用 `releaseReason` 不用 `reason`;单测覆盖 | P0.6 |
| **R14** | 🟡 | **`cohortId` 下一轮丢失**`toApiMessage``use-assistant-chat.ts:88-97`)只回传文本块 → 第 2 轮主管说「只要商保的」时模型手里没有 cohort,只能重新圈人(可能圈到不同的人)+ 重复消耗 `stepCountIs(8)` | 实测 | 助手回复里带一句可见文本「当前人群:潜在种植·热,383 人」;MVS 期人群条件本身是几十 token 的明文,直接复述即可 | P3.3 |
| **R15** | 🟡 | **`render_artifact` token 账**:五之四要求「数据必须内联」,100 条患者明细要模型逐条**生成**进 HTML,`stepCountIs(8)` 撑不住 | — | systemExtra 写死:artifact 只画**聚合**(漏斗/饼图/条形图),患者明细走 markdown 表格且不进 artifact | P5 |
| **R16** | 🟢 | **专属客服 ≠ 名册**`schema.prisma:426` 注释实测「近 12 月 367 万对回访,`task_director_id``current_task_director`**23.1%** 相同」 | 实测 | 「专属优先」的命中率可能远低于 4.2 的 83.5% 覆盖率所暗示的。**确认单上把命中/未命中数量显式呈现**,别让主管以为「专属优先」大部分时候在起作用 | P3.4 |
| **R17** | 🟢 | 文档漂移:`data-model.mdx` 表清单(20-34 行)**已经漏了 `plan_event_logs`** | — | 顺手补两行 | P1.1 |
| **R18** | 🟢 | 撤销限时是 UX 摩擦不是安全边界(leader 本就有 `PLAN_RECYCLE` + `PLAN_VIEW_ALL`,可逐条 recycle 绕过) | — | 写进注释,避免后人基于「30 分钟后锁死」设计别的东西 | P6.4 |
---
## 六、验证策略
### 分层
| 层 | 验什么 | 不验什么 |
|---|---|---|
| **单测(mock Prisma)** | 纯逻辑:权限矩阵、枚举完备性、时区转换、收敛规则的取数排序、`sufficient` 阈值判定、提示词 builder 的分支 | ⛔ **并发正确性**(见下) |
| **集成测试(真 PG)** | 带条件 `updateMany` 的并发语义、事务回滚、唯一键冲突、守恒断言 | 大数据量性能 |
| **本地真实数据集** | 端到端剧本、口径对数、查询计划 | 生产量级性能 |
| **测试服** | 生产量级索引效果、ID 空间、迁移锁窗口 | — |
### 🔴 E1 · 缺 DB 测试床(四份方案都没提)
实测 `apps/pac-service/tests/` 的 50 个 spec **全部 mock Prisma**(唯一提到 `PrismaClient``deploy-managed-guard.spec.ts` 也不连库)。而 P2 排了 1.0 人日的「并发抢占 / 部分失败」测试 —— **「带条件 `updateMany` + 判 count」的正确性只能在真 PG 上验证**,mock 里 `count` 是你自己填的,测的是你的假设不是 PG 的行为。
**二选一,必须现在选**
- **A(推荐)** 建 testcontainers / 本地库的集成测试床 —— **独立工作项 +1.5 人日**,一次性投入,之后所有并发/事务改动都受益
- **B** 承认这块无法自动化,把 P2.3 从「单测」改成**手写联调 checklist**(两个浏览器 tab 同时点、模拟抢占),工时不变但产出是文档不是测试
> 本规划**按 A 计**,已把 1.5 人日列在下方「未计入 MVS 的可选项」。若选 B,MVS 工时不变但回归成本长期偏高。
### 本地真实数据集验证(5,825 患者 / 2,724 plan)
| 验什么 | 怎么验 | 通过标准 |
|---|---|---|
| 迁移零 drift | 38 迁移全应用的库上 `prisma migrate dev` | 无 drift 输出 |
| 端到端剧本 | 第 4 节 MVS 剧本七步 | 一次跑通,中途不改库不看日志 |
| **口径对数** | 矩阵/筛选出的人数 vs 点进去列表的条数 | ⚠️ 矩阵按**患者去重**、列表按 **plan** 分页,口径不一致主管一眼发现 → hover 里注明「N 位患者」而非「N 条」,且逐格对数 |
| 名册 `source_created_at` | 造一条 `task_date=2033` 的记录 | 该客服**不**出现在名册里 |
| 引擎守恒 | 对已分配 + 已退回的 plan 各触发一次重算 | 归因六列守恒 |
| 助手样本量口径 | 造 `n=12、转化 0` 的批次问助手 | 输出含「样本量不足」,**无「0.0%」** |
⚠️ 本地库缺 `fact_complex_cases_out`(教条附录),需手工建同构空表(**必须含 `is_del` / `case_stage` 两列**)。
### 测试服验证(生产量级)
| 验什么 | 通过标准 |
|---|---|
| **G0.1 ID 空间**(最先) | `task_director_id` 与真实 JWT sub 匹配 |
| 迁移 A 锁窗口 | 全程 < 3 秒;`lock_timeout` 生效可验(故意压一条长事务) |
| 迁移 B 后名册查询 | 166.7 万行 `EXPLAIN ANALYZE` 走新索引,< 500ms |
| `source_created_at` 回填完整性 | ⚠️ 该列是刚加的(`20260801150000` / commit `2516596`),**存量 166.7 万行是否已回填完整需先查**,否则名册会静默少人 |
| 批量 500 items | < 5s,不抛事务超时 |
| 跟踪查询 | `plan_event_logs` 的第一个读场景,`EXPLAIN``(planId, createdAt)` |
### 回滚策略(E4,四份方案都没写)
**结论:代码可回滚,schema 不回滚。** 必须在上线单里写明:
- 迁移失败 → `npx prisma migrate resolve --rolled-back <name>` 后重试(整份 SQL 在一个隐式事务里,**不可能出现「表建了列没加」的半吊子 schema**
- 上线后回退代码 → 🔴 **P1.1 与 P1.2 必须一起回**。只回 P1.2 会让旧引擎在下次重算时把所有已分配单的归因抹掉
- 已产生的批次数据:保留(`plan_assignments` 与新列对旧代码无害,旧代码只是不读它们);重新上线后归因仍在
---
## 七、仍待产品决策(**不擅自定,列清楚**)
按「卡不卡开发起跑」排序。
| # | 议题 | 为什么必须产品定 | 选项 / 我的倾向 | 卡谁 |
|---|---|---|---|---|
| **Q-1** 🔴 | **v1 分两刀是否可接受**(第一刀不含矩阵可视化) | 教条六已定「矩阵放 v1」,本规划提议 v1 内分两刀 | ✅ 建议接受。理由:温度轴含一次 persona 全量重算,**挂钟时间不受人日控制**,绑第一刀会让交付被跑批窗口挟持;MVS 的价值来自 ④+⑦ 不是 ① | 全案排期 |
| **Q-2** 🔴 | **「1,189 人的格子 → 一批 100 人」的收敛规则** | 四份方案**全都没有**:data 存 criteria、service 收 items、mcp 明写「本工具不做任何圈人」、web 只渲染。**从 1,189 收到 ~100、并决定「是哪 100」的那一步没有任何一层实现它**。且取舍表内部打架:「拟分 N 人 = 默认分满容量」× 30 人在岗 × 50 = 1,500,正是 T5 说的「1000 人做浅」 | ① 取 N 的**排序键**(默认 `priorityScore desc`?需论证它就是分配的正确排序)② 批次规模上限(教条七·待确认)③「默认分满容量」与「一批 100 人」二选一,或把容量默认值取**区间下界 20** | P3.4 提案展开 |
| **Q-3** 🟠 | **`ReleaseReason` 值域定稿** | D-6 建议取 data 层 8 个(按「主管的一根杠杆」分类,直接服务 T20 反推);service 层给了另一套 6 个 | 8 个见 D-6。⭐ **可以先落结构 + `other` 一个键开工**`hidden?` 字段已预留),值域后补 —— 不要让它卡 P0 | P0.2(可解耦) |
| **Q-4** 🟠 | **MVS 期福利的落地方式降级** | T4 明写福利要进 prompt(`shared/fact-block.ts` + `tiers/stable/prompt.ts`),但撞上 `plan_scripts.planId @unique` 的缓存问题(R8),最坏会**对患者做虚假承诺** | 建议 MVS 期降级为「独立静态字段,客服自己念,不经 LLM」—— 零合规风险、零重生成成本,T4 的归因目的完全达成;P6.6 再做 prompt 融入 + 失效规则 | P5 |
| **Q-5** 🟠 | **批次规模统计的漂移是否可接受** | 一个 plan 在批次 N 被分 → 退回 → 进批次 N+1 → `assignment_id` 翻成 N+1,批次 N 的 COUNT 悄悄少 1 | ① 接受(简单,符合「主表存当前值」的既有模式)② 不接受 → 需要一张 `plan_assignment_members` 关联表(+0.5 人日 + 一列迁移)。⛔ 「写进 `plan_event_logs.details`」的补丁方案已被否(R12) | P1.1 建表 |
| **Q-6** 🟠 | **温度的 8 标签 × 窗口映射** | 教条 T6 说「阈值复用 `DiagnosisTreatmentMap` 现成配置,不新增口径」,但实测**一对多**`extraction ← K01(180/90) + K03(90/60)``perio ← K05(120/90) + K06(120/60)`,且聚合时已 `Math.max` 跨码合并 | 建议按业务标签定义**标签级**窗口表(8 行常量),并明写「这是新口径,不是 `DiagnosisTreatmentMap` 的复用」—— 否则 `canonical-codes.ts:196` 那句「不允许任一处再硬编码窗口」会被违反 | P6.1(第二刀起跑线) |
| **Q-7** 🟡 | **分配单到期后的行为**(教条七·待确认) | ① 自动回池 ② 只提醒 ③ 两者皆有。⚠️ ①/③ 会擦 T8 的判据(「任何改变数据库状态的动作只能由主管确认触发」)。可辩护的解释是「主管在确认单上确认了时效 = 预授权」,但**这属于产品解释权,不该由实现认定** | 建议 **MVS 只做 ②**(写入 + 超期标红),①/③ 推到 v2。若选 ①/③,需在确认单文案上把到期行为显式写出来,把预授权变成看得见的;且**必须落 `release_reason='expired'`**(否则 T20 的分母不干净)、**必须照抄 `recycle-scheduler.service.ts:64-68` 的 `snoozedUntil` 守卫**(约好 6/10 回访的单不能被收走) | 不卡 MVS |
| **Q-8** 🟡 | **撤销时限** | 建议可配 env、默认 **30 分钟**,并按 T14 在助手话术里标明是默认值 | 语义是「手滑/分错人」的补救,不是「改主意重新调度」(改主意应走退回 + 重分)。⚠️ 它是 UX 摩擦不是安全边界(R18) | P6.4 |
| **Q-9** 🟡 | **已被认领的单能否强制改派**(教条七·待确认) | 当前后端拦住(`plan.service.ts:589-591`),教条建议保留该摩擦 | 建议保持拦住 → 进 `skipped(reason:'claimed_by_other')`。⭐ 该判据已收口到 `claim-guard.assertAssignable` **一个函数**,将来改只改这一处 | 不卡 |
| **Q-10** 🟢 | 矩阵在 `<lg` 小屏是否提供 | 建议**不提供**(入口加 `lg:` 藏掉)—— 8×3 在 375px 抽屉里做不出可读密度,主管基本桌面办公。若要求移动端可用,需另做纵向堆叠版 +1.0 人日 | 不卡 |
---
## 八、未计入 MVS 的可选投入
| 项 | 人日 | 说明 |
|---|---:|---|
| **DB 集成测试床(testcontainers)** | 1.5 | 见第 6 节 E1。选 B(手写联调 checklist)则为 0,但长期回归成本偏高 |
| `followup_plans` partial UNIQUE 补建 | 1.0 | 需先查存量违例(R9)。独立数据清理项 |
| `assignment_expires_at` partial 索引 | 0.2 | ⚠️ **若 `get_agents(withWorkload)` 的 `overdue` 走全表扫,这条要提到 MVS** —— data 层「唯一用途是到期 cron,而 cron 待定」的论证站不住,因为 `overdue` 是 v1 同步工具、跑在 LLM 工具调用里,每次主管问「团队什么状态」就对 25 万行全表过滤 × N 个客服。**P3.2 联调时实测查询计划,超 300ms 就补** |
| 企微合成身份真身份映射 | ≥1.0 | 六·已定取舍:本期不管。写路径已由 `rejectSyntheticIdentity` 硬拒 |
---
## 九、五条会咬人的排期提醒
1. **P1.1 与 P1.2 之间不许发一个版本。** 中间那个版本会让每日重算静默抹掉批次归因,**不报错、无告警**,等发现时历史已经花了。
2. **迁移 A / B / C 必须是三个目录。** B/C 混进 A 会 `25001` + `P3018` 堵死流水线。
3. **`packages/types/src/enums/index.ts` 是四方争抢点**(权限 + ReleaseReason + PlanEventReason 都插在同一区域)。P0.1/P0.2 合一个 PR,其余三层等它 merge 再拉分支。
4. **迁移 A 里一次把 `request_id`(D-2)、`assign_strategy`(D-6)、`status/revoked_at/revoked_by`(撤销推迟但列先建)全带上** —— 否则第二刀要再取一次 `followup_plans``ACCESS EXCLUSIVE`
5. **口径决策(Q-2 / Q-3 / Q-6)最早启动** —— 它们不占开发人日却卡三条并行道;Q-6 尤其要早,它是第二刀矩阵的链条起点,**且教条里根本没有**
---
## 附录 · 本文引用的实测事实(2026-08-02 工作树)
| 事实 | 出处 |
|---|---|
| 迁移目录 **38 个**(不是四份方案里说的 37) | `apps/pac-service/prisma/migrations/` |
| `daysSince``VOLATILE_DATA_KEYS` 里,时间流逝不触发重算 | `persona-diff.ts:57` + `:81-90` |
| `PotentialGap` 无日期锚,只有 `daysSince: number` | `potential-treatment.selector.ts:93-102` |
| 8 标签 ← K 码是一对多,窗口阈值冲突 | `potential-treatment.feature.ts:53-81` + `canonical-codes.ts:236-257` |
| `carryAssignment` 只在 `status==='assigned'` 为真 | `plan-engine.service.ts:471` |
| 退回后 plan 是 `status='active'` | `plan.service.ts:672` |
| `assign()``findFirst``targetClinicId` 过滤 | `plan.service.ts:572-577` |
| `sub = user.userId` = 宿主侧 user id | `auth.service.ts:537` + `schemas/auth.ts:27` |
| `PermissionsGuard``@Public()` 直接 `return true` | `permissions.guard.ts:16` |
| `plan_scripts.planId @unique`(一 plan 一话术) | `schema.prisma:1203` |
| `plan_event_logs` 已有 `@@index([planId, createdAt])` + `([hostId,tenantId,event,createdAt])` | `schema.prisma:1493, 1499` |
| `SCOPED_MODELS` 14 张表,无 `PlanEventLog` / `PlanReason` / `PlanAssignment` | `tenant-guard.extension.ts:23-38` |
| `auth-store.loadSession` 不 merge `permissions` | `apps/pac-web/src/stores/auth-store.ts:104-117` |
| `toolsCache``McpToolDef[] \| null`,进程级、与 token 无关 | `mcp-client.service.ts:25` |
| `plan.controller.ts` recycle 的 `@Body() _dto`(reason 被丢) | `plan.controller.ts:127` |
| `RECYCLE_TIMEOUT_HOURS = 24`,注释指向不存在的 `tenant.rules_config` | `plan.service.ts:50` |
| `patient_return_visits` 名册索引末列是 `task_date` | `schema.prisma:447` |
| ⭐ `task_director_id``current_task_director` 近 12 月**仅 23.1% 相同** | `schema.prisma:420-426` 注释 |
| MCP 现有 7 个 `registerTool`,全只读 | `mcp-server.factory.ts:84,97,120,133,151,164,209` |
| `data/jvs-dw/users.json` **当前工作树下不存在**(mock 名册为空) | `mock-users.ts:26-40` |
| `STAFF` 持有 `PLAN_ASSIGN` + `PLAN_RECYCLE`;LEADER 只多 `PLAN_VIEW_ALL` + `STATS_VIEW` | `enums/index.ts:36-63` |
**已修正的引用错误**(四份子方案里出现的,实现时按此为准):
1. service 层称「`tests/plan-event-log.spec.ts` 有断言『`HUMAN_TOUCH_EVENTS` 精确等于四个』」—— 实际是 `arrayContaining([CLAIM, ASSIGN, FEEDBACK])`。真正会拦住新增枚举的是同文件的 `Object.keys(PLAN_EVENT_META).sort() === Object.values(PlanEventType).sort()`。结论(别新增事件类型)仍成立,但理由要改对。
2. data 层多处写「37 份迁移」—— 实际 38。
3. mcp 层把 `plan_event_logs` 的索引列为「待实测风险」—— 已核实覆盖,从风险清单移除。
\ No newline at end of file
# 召回分配 · 设计教条
> 门诊经理(主管)主导的召回批次分配功能。本文是**讨论过程中沉淀的教条与依据**,不是需求文档。
> 需求未定稿前不开工 —— 教条先行,实现在后。
| | |
|---|---|
| **状态** | 讨论中(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` 现成配置,不新增口径。
### 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
### 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 > 0` 与 view 事件 |
| **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.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`),不是预置维度。
⚠️ `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,写工具上线前需另行处理 |
| 分配可撤销 | **要**,且**由助手辅助完成** | 撤销也是对话里说一句的事,不另做界面 |
| 退回后去哪 | **回池**,复用现有认领逻辑 | 见 T12,不另造归属模型 |
---
## 七、待确认(**未经确认,不算教条**)
- **【规划阶段新增】MCP 是否注册写工具** —— 教条曾定「走 A 方案,模型自主调 MCP」;
但规划指出 MCP 端点是 `@Public()``PermissionsGuard` 短路),护栏会退化成
「handler 自查 + 模型自填的 `confirmedByUser` 布尔」,等于为一个**本期尚不存在的外部 agent**
打开 T8 唯一的洞。规划建议:**写路径只走 REST + `@RequirePermission`,MCP 只保留读工具**
⚠️ 与已定取舍冲突,**待产品裁决**
- **【规划阶段新增】矩阵是否放第一刀** —— 取舍表定「矩阵放 v1」;规划建议 v1 内分两刀,
矩阵放第二刀,理由是温度轴需一次 persona 全量重算,**挂钟时间不受人日控制**
绑第一刀会让交付被跑批窗口挟持。第一刀改用现有「潜在治疗」标签筛做入口先跑通闭环。
⚠️ 与已定取舍冲突,**待产品裁决**
- 已被认领的单能否强制改派(现后端拦住 —— 「Plan 已分配给 X;回收后再分配」,建议保留该摩擦)
- 分配单**到期后**的行为:自动回池 / 提醒主管 / 两者皆有
- 全景阶段主管意图的**捕获方式**:助手主动问 / 预设选项 / 可跳过
- 时效性建议的**具体算法**(召回池存量 + 负载完成量 + 主管习惯,如何加权)
- 批次的规模建议上限
---
## 八、本期明确不做
- 规则自动分配(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();
```
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment