Commit f1b0b4e8 by luoqi

docs(design): 三处裁决 + 阶段性开放规划 + 开发起点

## 三处裁决(产品 2026-08-02)

1) MCP 写工具:**最终必须实现,但分期** —— 原规划 D-4 建议「v1 不做」,
   改为「v1 不注册、但设计不得堵死」。MCP 现为 @Public(PermissionsGuard 短路),
   护栏会退化成 handler 自查 + 模型自填布尔,故 v1 写路径走 REST;
   requirePermission / rejectSyntheticIdentity 两个 helper S1 就写好,S4 直接挂。

2) 矩阵留第一刀 —— 规划建议「分两刀、矩阵推后」的理由**已证伪**:
   原称「温度轴需 persona 全量重算,挂钟不受人日控制」,生产实测两个轴数据全现成
   (X 轴 590,427 条特征 / Y 轴 566,365 条 signals),矩阵直接跑通 25 秒零重算。
   25 秒对交互不可接受 → 转为明确的性能任务(索引/物化),不是排期依赖。

3) 撤销判据以 view 事件为主 —— 规划建议叠加 contactAttempts,实测该字段
   **与 plan_executions 同源、同为 7 条**,帮不上忙;可用信号只有 view(已 1,024 条)。

## 新增 T21:撤销 ≠ 退回

撤销=主管收回整批,退回=客服退单条(必须带原因)。两者被混谈过,现分开定义。
撤销的「已动过」判据不能用 plan_executions —— 那是执行阶段产物而回写率仅 11%,
客服打了电话没填表就会被当成「没动过」收走,那通电话永久蒸发。
今天刚上的 PV/UV 埋点成了唯一可用信号,属意外收益。

## 新增两节(上下文压缩后的接续入口)

- 七之二 阶段性开放规划:S1 闭环 → S2 完备 → S3 自优化 → S4 MCP 写 → S5 客服主动性,
  每阶段标「开放给谁 + 技术前提」;三条跨阶段硬约束(assign_strategy 必须 S1 立柱、
  写路径不得堵死 S4、S3 不能在 n<50 时提前)。
- 七之三 开发起点:本地环境已就绪的清单、Gate 0 未闭合项、
  以及「第一件事不是写新功能而是修 F1-F5 前置洞」。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent bb019d32
......@@ -48,16 +48,25 @@
- 小屏(<lg)矩阵形态
- 福利核销 / 接宿主卡券(T4 已定 v1 不核销)
### 交付切法(重要)
### 交付切法
v1 内**分两刀**,第一刀就是 MVS:
⚠️ **原规划建议「v1 内分两刀、矩阵放第二刀」,该建议已被证伪并撤销(2026-08-02)。**
原理由是「温度轴的技术链条含一次 persona 全量重算,挂钟时间不受人日控制」。生产实测:
```
第一刀 MVS ── 端到端闭环,不含矩阵可视化,入口用现有「潜在治疗」标签筛
第二刀 ── 补矩阵(含温度轴新口径)+ 撤销 + 福利进话术 + 调整阶段画像圈人
X 轴 潜在治疗 persona_features 590,427 条 ← 早已算好,画像常规特征
Y 轴 窗口温度 plan_reasons 566,365 条 ← daysSince 现成,读时与阈值比对
全量矩阵直接跑通 25 秒 / 零重算
```
⚠️ **这与教条六「矩阵放 v1」不冲突**(两刀都在 v1 内),但**需产品认可分刀**。理由:温度轴的技术链条含一次 persona 全量重算,**挂钟时间不受人日控制**;绑在第一刀会让整个交付被跑批窗口挟持。见 Q-1。
**两个轴的数据生产上全部现成,不需要任何重算,也没有跑批窗口。**
**矩阵留在第一刀**(与教条六·已定取舍一致)。
→ 但 **25 秒对交互不可接受** → 转为一个**明确的性能任务**(索引 / 物化视图),
属于 S1 内部的工程项,**不是排期依赖**
阶段划分改以**能力开放**为轴,见 [教条 · 七之二 阶段性开放规划](./plan-assignment-doctrine.md)(S1-S5)。
---
......@@ -70,14 +79,14 @@ v1 内**分两刀**,第一刀就是 MVS:
| **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-4** | 写路径通道 | **v1 走 REST + `@RequirePermission(PLAN_DISPATCH)`;MCP 写工具分期到 S4,设计不得堵死** | ⚠️ **已产品裁决(2026-08-02)**:MCP 写工具**最终必须实现**,原案「v1 不做」改为「v1 不注册、但预留」。MCP 现为 `@Public()``permissions.guard.ts:16` `if (isPublic) return true` 实测),护栏会退化成 handler 自查 + 模型自填布尔 → 待 MCP 鉴权补齐(S4)再挂。⭐ `requirePermission` / `rejectSyntheticIdentity` 两个 helper **S1 就写好** |
| **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-11** | 「已动过 / 不可撤销」判据 | **以 `plan_event_logs` 的 `view` 事件为主** | ⚠️ **原案已部分证伪(2026-08-02 实测)**:原建议 `EXISTS(plan_executions) OR contactAttempts > 0 OR view`。实测 `contact_attempts > 0` **仅 7 条,与 `plan_executions` 同源**(提交执行时才累加),帮不上忙。可用信号只有 **`view` 事件(已 1,024 条)**。理由不变:回写率仅 11%,只看执行记录会把 89% 已打过电话的单静默收走。⚠️ 另注意 **撤销(主管收整批) ≠ 退回(客服退单条)**,见教条 T21 |
| **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 **未安装**,内网拉包本身是风险 |
......
......@@ -219,6 +219,31 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
- **不下传 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),**没有对照组**
......@@ -333,7 +358,7 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
| # | 发现 | 后果 |
|---|---|---|
| **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 事件 |
| **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`** | 跨诊所越权 + 租户隔离扩展覆盖不到。与分配无关(现存问题),但分配会放大影响面 |
......@@ -597,6 +622,9 @@ patient_transactions 经 patient_id → 客观新预约(canonical_payload.cre
| 确认单渲染方式 | **原生 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,不另造归属模型 |
......@@ -604,15 +632,6 @@ patient_transactions 经 patient_id → 客观新预约(canonical_payload.cre
## 七、待确认(**未经确认,不算教条**)
- **【规划阶段新增】MCP 是否注册写工具** —— 教条曾定「走 A 方案,模型自主调 MCP」;
但规划指出 MCP 端点是 `@Public()``PermissionsGuard` 短路),护栏会退化成
「handler 自查 + 模型自填的 `confirmedByUser` 布尔」,等于为一个**本期尚不存在的外部 agent**
打开 T8 唯一的洞。规划建议:**写路径只走 REST + `@RequirePermission`,MCP 只保留读工具**
⚠️ 与已定取舍冲突,**待产品裁决**
- **【规划阶段新增】矩阵是否放第一刀** —— 取舍表定「矩阵放 v1」;规划建议 v1 内分两刀,
矩阵放第二刀,理由是温度轴需一次 persona 全量重算,**挂钟时间不受人日控制**
绑第一刀会让交付被跑批窗口挟持。第一刀改用现有「潜在治疗」标签筛做入口先跑通闭环。
⚠️ 与已定取舍冲突,**待产品裁决**
- 已被认领的单能否强制改派(现后端拦住 —— 「Plan 已分配给 X;回收后再分配」,建议保留该摩擦)
- 分配单**到期后**的行为:自动回池 / 提醒主管 / 两者皆有
- 全景阶段主管意图的**捕获方式**:助手主动问 / 预设选项 / 可跳过
......@@ -621,6 +640,58 @@ patient_transactions 经 patient_id → 客观新预约(canonical_payload.cre
---
## 七之二、阶段性开放规划(能力按阶段放开,**不是一次做完**)
每一阶段**独立可交付、独立可验证**。「开放给谁」是产品视角,「技术前提」是工程视角。
| 阶段 | 开放的能力 | 开放给谁 | 技术前提 |
|---|---|---|---|
| **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 条执行记录,样本量支撑不了规则调优)
......
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