Commit aa5fdaf7 by luoqi

fix(plan): 归因继承加边界 —— 召回理由整组换掉 = 全新工单,不继承

产品指出:「该患者新的诊断等信号形成的工单升版本了是全新的,不应该继承」。核实后成立。

先分清此前被混在一起的**两套继承**:
  carryAssignment  客服相关(status/assignee/assignedAt/contactAttempts)—— 还挂在同一个人名下吗
  carryAttribution 批次归因(assignment_id/assigned_by/assign_strategy + 快照五列)—— 还是同一张工单吗

升版本的判据就是 (scenario, subKey) 集合变了 = 召回理由换了。理由**整组**换掉时,
新版本已是一张全新工单 —— 患者新长了颗龋齿,跟主管当初按「潜在种植」分下去的那单没关系,
不该继续算在那个批次头上。

判据取**临床缺口类型**交集(subKey 去牙位),不是 subKey 原文:
subKey 自带牙位(caries_no_filling@18;28),按原文比会把「又坏了一颗牙」误判成全新工单。
快照五列与 assignment_id **同生共死** —— 留下 selectionMode='explore' 却查不到批次的孤儿行,
会直接污染 T20 的因果分析(探索组样本本来就小)。

 为什么现在可以推翻 D-12 的「无条件继承」:那条其实是**给当时跟踪查询缺陷打的补丁** ——
那时 detail 过滤 `supersededAt: null`,旧行看不见,不继承就等于分母静默缩水。
上一个 commit 把跟踪改成「按患者取最新版、不过滤 superseded」之后,历史留在旧行上,
「无条件」不再必需,而且有害。已加回归锁住这个前提(旧行的归因绝不能被清)。

 与 T20′ 恰好咬合:不继承时旧行仍带 assignment_id 留在批次里且已 superseded →
跟踪取到的就是那条 superseded 行 → 记为 resolved(已处理)。
语义正确:**这个批次针对的需求确实没了**;新工单干净地回池等下一批。两边都不用打补丁。

️ 已知边界(刻意不做):批次目标需求消失但别的需求还在时({缺牙,龋齿}→{龋齿}),
交集非空仍继承。要判准需把 criteria.potentialTreatment 映射到 subKey —— 那是新口径,
得产品先定,当前样本量下不值得引入映射表(T14)。已写进教条 T20″。

顺带修掉单测里的一处**假绿**:mock 的 create/seed 都没带那五列快照,
断言拿到的永远是 undefined —— 「快照有没有被正确继承/清空」在单测里根本看不见,怎么写都过。

962 tests passing(D-12 原三条守恒断言改用「理由有交集」的常见情形,仍然锁着;
新增四条锁「整组更换不继承」「旧行不丢」「牙位变化不算全新」「需求增加照样继承」)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 124d3b19
......@@ -563,6 +563,47 @@ export class PlanEngineService {
// 带着 assignment 漂出原认领人 scope 会变孤儿单,归新诊所客服重新认领)。
const clinicMoved = latest != null && latest.targetClinicId !== targetClinicId;
const carryAssignment = latest?.status === 'assigned' && !clinicMoved;
/**
* ⭐ **批次归因继承的边界:理由全换了就不是同一张工单了**(产品裁决 2026-08-02)。
*
* D-12 原文是「归因列**无条件**继承」,那是为了修另一个 bug(退回后 plan 是 `active`,
* 挂在 `carryAssignment` 上会让退回单的归因连分子带分母静默归零)。但「无条件」过头了:
* 升版本的判据就是 **(scenario, subKey) 集合变了 = 召回理由换了**,
* 而理由**整组**换掉时,新版本已经是一张**全新的工单**了 —— 患者新长了颗龋齿,
* 跟主管当初按「潜在种植」分下去的那一单没有关系,不该继续算在那个批次头上。
*
* 判据取**临床缺口类型**的交集,不是 subKey 原文 —— subKey 自带牙位
* (`caries_no_filling@18;28`),同一个需求多长一颗牙就会变字符串,
* 按原文比会把「又坏了一颗牙」误判成「全新工单」,那是矫枉过正。
*
* ⭐ 与跟踪口径(T20′)恰好咬合:不继承时旧版本仍带着 assignment_id 留在批次里、
* 且已 superseded → 跟踪按「按患者取最新版」拿到的就是那条 superseded 行 →
* 记为 **resolved(已处理)**。语义完全正确:**这个批次针对的需求确实没了**。
* 而新工单干干净净地回池,等下一个批次。两边都不用打补丁。
*
* ⚠️ **已知边界(刻意不做,不是漏了)**:批次目标需求消失、但**别的**需求还在时
* (如 {缺牙, 龋齿} → {龋齿}),交集非空 → 仍然继承,于是「种植需求其实已解决」
* 这件事在批次里看不出来。要判准它需要把批次的 `criteria.potentialTreatment`
* 映射到 subKey(implant→missing_tooth …)—— 那是**一套新口径**,得产品先定。
* 当前样本量下不值得为它引入一张映射表(T14:没数据支撑的精度是假精度)。
*/
const needKey = (sc: string, sk: string | null | undefined) =>
`${sc}|${(sk ?? '').split('@')[0]}`;
const sharesAnyNeed =
latest != null &&
(() => {
const oldNeeds = new Set(latest.reasons.map((r) => needKey(r.scenario, r.subKey)));
return usableHits.some((h) => oldNeeds.has(needKey(h.scenarioKey, h.subKey)));
})();
/** 归因继承 = 还是同一张工单(至少共享一个临床缺口类型) */
const carryAttribution = sharesAnyNeed;
if (latest?.assignmentId && !carryAttribution) {
this.logger.log(
`plan ${latest.id} 升版本且召回理由整组更换 批次归因**不继承**` +
`(批次 ${latest.assignmentId.slice(0, 8)} 的这一条按"需求已了"结账,新工单回池)`,
);
}
if (latest?.status === 'assigned' && clinicMoved) {
this.logger.log(
`plan ${latest.id} 升版本且跟进诊所重归属 ${latest.targetClinicId ?? '∅'} ${targetClinicId ?? '∅'},assigned 不继承(返池)`,
......@@ -646,21 +687,28 @@ export class PlanEngineService {
// 而 T20 的全部结论都建立在这个分母上。
// 归因是**历史事实**(这条单子来自哪个批次、谁分的、当时按什么策略落的人),
// 跟"现在还挂不挂在人名下"是两件事,不该被后者控制。
assignmentId: latest?.assignmentId ?? null,
assignedBy: latest?.assignedBy ?? null,
assignStrategy: latest?.assignStrategy ?? null,
releaseReason: latest?.releaseReason ?? null,
releaseNote: latest?.releaseNote ?? null,
// ⭐ 但「无条件」有一个边界(2026-08 裁决):**召回理由整组换掉时不继承** ——
// 那已经是一张全新工单了。见上方 carryAttribution 的整段说明。
// ⚠️ 判据是 carryAttribution,**不是** carryAssignment,两者别混:
// 前者问"还是同一张工单吗",后者问"还挂在同一个人名下吗"。
assignmentId: carryAttribution ? latest!.assignmentId : null,
assignedBy: carryAttribution ? latest!.assignedBy : null,
assignStrategy: carryAttribution ? latest!.assignStrategy : null,
releaseReason: carryAttribution ? latest!.releaseReason : null,
releaseNote: carryAttribution ? latest!.releaseNote : null,
// ⚠️⚠️ 决策快照五列同样**无条件继承**,而且漏了比归因列更隐蔽:
// 引擎重出版本是**数据变化触发**的 → 与患者活跃度相关 → 与完成率相关。
// 所以漏继承丢掉的不是随机的一批,是**系统性偏向活跃患者**的一批 ——
// 半年后拿到一列 70% 填充率的快照,看着还能用,算出来的结论是错的。
// ⭐ 今后**每加一列快照,这里必须同步加一行**(spec 有断言锁死)。
dedicatedCsAtAssign: latest?.dedicatedCsAtAssign ?? null,
dedicatedCsLastVisitAt: latest?.dedicatedCsLastVisitAt ?? null,
priorityScoreAtAssign: latest?.priorityScoreAtAssign ?? null,
sourceConfidenceAtAssign: latest?.sourceConfidenceAtAssign ?? null,
selectionMode: latest?.selectionMode ?? null,
// ⚠️ 快照列与 assignment_id **同生共死**,用同一个 carryAttribution 门控:
// 留下没有批次的孤儿快照(有 selectionMode='explore' 却查不到批次)会直接
// 污染 T20 的因果分析 —— 探索配额那组样本本来就小,混进无主行就废了。
dedicatedCsAtAssign: carryAttribution ? latest!.dedicatedCsAtAssign : null,
dedicatedCsLastVisitAt: carryAttribution ? latest!.dedicatedCsLastVisitAt : null,
priorityScoreAtAssign: carryAttribution ? latest!.priorityScoreAtAssign : null,
sourceConfidenceAtAssign: carryAttribution ? latest!.sourceConfidenceAtAssign : null,
selectionMode: carryAttribution ? latest!.selectionMode : null,
// ⚠️ 唯独 assignmentExpiresAt **跟随归属**,不无条件继承:
// 它不是归因,是"当前这次分配的截止时刻"。归属都没了还留着期限,
// 会让列自身失去自洽(非空 ⟺ 有一次在办的分配),超期口径全得靠调用方
......
......@@ -87,7 +87,7 @@ Y 轴 窗口温度 plan_reasons 566,365 条 ← daysSince 现成,读时
| **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** | 「已动过 / 不可撤销」判据 | **以 `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-12**<br/>*(2026-08-02 修订)* | 引擎新版本的字段继承 | 归因列**与 `carryAssignment` 解耦**(原案),但**不是无条件** —— ⭐ **召回理由整组换掉时不继承**(新版本已是另一张工单)。判据取「临床缺口类型」交集(subKey 去牙位),空集 → 不继承。⚠️ 原「无条件」其实是**给当时跟踪查询缺陷打的补丁**(那时 detail 过滤 `supersededAt: null`,旧行看不见,不继承就分母缩水);跟踪改成「按患者取最新版、不过滤 superseded」后,历史留在旧行上,「无条件」不再必需且有害。⚠️ 快照五列与 `assignment_id` **同生共死**(孤儿快照会污染 T20 因果分析) | ❌ 只改 `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 是**接口草案记法**,不是工具名 |
......
......@@ -373,6 +373,38 @@ plan_event_logs view 1,024 ✅ 唯一可用 —— 客服打开过详情页
`max(version)` 天然去重;关闭时最新版就是那条 superseded 行,正好是信号)。
回归见 `tests/assignment-tracking-invariants.spec.ts` 第三组。
##### T20″ · 归因继承的边界:理由整组换掉 = 全新工单,不继承(2026-08-02 产品裁决)
> 产品原话:「该患者新的诊断等信号形成的工单升版本了是全新的,不应该继承吧」。
先分清**两套继承**——它们此前被混在一起讨论过:
| 继承什么 | 条件 | 回答的问题 |
|---|---|---|
| **客服相关**`status` / `assignee_user_id` / `assigned_at` / `contact_attempts`) | `carryAssignment` = 旧版 assigned && 未换诊所 | 还挂在同一个人名下吗 |
| **批次归因**`assignment_id` / `assigned_by` / `assign_strategy` + 快照五列) | `carryAttribution` = **还是同一张工单吗** | 这单当初来自哪批 |
升版本的判据就是 **(scenario, subKey) 集合变了 = 召回理由换了**。理由**整组**换掉时,
新版本已是一张全新工单(患者新长了颗龋齿,跟主管当初按「潜在种植」分下去的那单没关系),
**不该继续算在那个批次头上**
- **判据取「临床缺口类型」交集,不是 subKey 原文** —— subKey 自带牙位
`caries_no_filling@18;28`),按原文比会把「又坏了一颗牙」误判成全新工单,属矫枉过正。
- **快照五列与 `assignment_id` 同生共死** —— 留下 `selectionMode='explore'` 却查不到批次的
孤儿行,会直接污染 T20 的因果分析(探索组样本本来就小)。
**与 T20′ 恰好咬合**:不继承时旧行仍带 `assignment_id` 留在批次里且已 superseded →
跟踪按「按患者取最新版」拿到的就是那条 superseded 行 → 记为 **`resolved`(已处理)**
语义完全正确:**这个批次针对的需求确实没了**;而新工单干净地回池,等下一个批次。两边都不用打补丁。
> ⚠️ **D-12「无条件继承」是给当时跟踪缺陷打的补丁**:那时 `detail` 过滤 `supersededAt: null`,
> 旧行看不见,不继承就等于分母缩水。跟踪修好之后,「无条件」不再必需,而且有害。
⚠️ **已知边界(刻意不做,不是漏了)**:批次目标需求消失、但**别的**需求还在时
`{缺牙, 龋齿}``{龋齿}`),交集非空 → 仍然继承,于是「种植需求其实已解决」在批次里看不出来。
要判准它需要把 `criteria.potentialTreatment` 映射到 subKey(implant→missing_tooth…)——
那是**一套新口径**,得产品先定;当前样本量下不值得为它引入一张映射表(T14:没数据支撑的精度是假精度)。
---
## 四、关键数据事实(2026-08 生产实测,避免后人重新推导)
......
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