Commit 9e2a2ce5 by luoqi

merge: docs/plan-assignment-doctrine → test(召回分配全量 + 企微话术)

主管侧批次分配从地基到闭环:三趟落人(专属封顶 → 无主补空 → 有主改派)、
确认单可局部修正、批次归因走 plan_event_logs 账本、通话成效四桶、撤销/退回
分离。企微话术(深度档单块可复制)+ 复制埋点。含路由遮蔽修复 ——
script-feedback / script-copy 此前被 script:regenerate 整个吞掉。

自动合并无冲突;删除的 plans-list-app / task-drawer 等来自 3cbb3899 的重构。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parents aeb6f9f5 1de76e9a
---
title: 召回分配
description: 把「要不要做」变成「做了没有」—— 批次分配的设计原则、流程与闭环。
icon: Users
---
## 问题
召回池成千上万条,**头部反复被看、长尾无人碰**,池子形同虚设。
根因不是客服不努力,是纯自助认领的三个结构性缺口:
| 缺口 | 表现 |
|---|---|
| 责任真空 | 认领零成本,领了不做没有后果 |
| 无目的 | 各捞各的,运营意图落不了地 |
| 无法归因 | 做了什么、效果如何,事后说不清 |
**分配要做的,是把「要不要做」变成「做了没有」。**
---
## 全流程
```mermaid
flowchart LR
A["① 初选<br/>主管点一格<br/>治疗项 × 时机"] --> B["② 精选<br/>助手挑人、排客服"]
B --> C["③ 确认单<br/>主管过目"]
C --> D["④ 分配<br/>确认落库"]
D --> E["⑤ 跟踪<br/>看效果、调下一批"]
E -.反哺.-> A
style A fill:#e0e7ff,stroke:#6366f1
style C fill:#fef3c7,stroke:#f59e0b
style D fill:#d1fae5,stroke:#10b981
```
**主管只在两处动手**:点一格(①)、点确认(④)。中间全是助手的活。
---
## 一、这是什么
### 分配是批次运营,不是工单派发
目标是「选一批人、配一套打法、看效果」,**从不追求把池子分完**。
所以"主管扛不住全量"不是缺陷 —— 设计上就不打算分全量。
### 宁缺毋滥
一批宁可只做 100 人做透,不做 1000 人做浅。
**池子里剩下的不是遗漏,是还没轮到。**
### 每一步都在收缩人群
从"万"级压到"十"级。**主管只做判断,不做筛选** —— 筛选是助手的活。
---
## 二、谁做什么
```mermaid
flowchart TB
L["👤 主管<br/>判断 · 取舍 · 拍板"]
A["🤖 助手<br/>筛选 · 计算 · 呈现"]
S["📞 客服<br/>执行"]
L -->|"点一格 / 提要求"| A
A -->|"确认单"| L
L ==>|"确认(唯一的写动作)"| S
S -.->|"结果 / 退回原因"| L
style L fill:#e0e7ff,stroke:#6366f1
style A fill:#f3e8ff,stroke:#a855f7
style S fill:#d1fae5,stroke:#10b981
```
| | 做什么 | **不做什么** |
|---|---|---|
| 主管 | 判断、取舍、拍板 | 不翻明细、不做算术 |
| 助手 | 筛选、计算、呈现 | **不替主管决定、不自动执行** |
| 客服 | 执行 | 不做选择 |
**任何改变数据的动作,只能由主管的「确认」触发。**
### 客服看不到池子
客服左侧只有「我的」,**无从认领**;主管才有「召回池」。
主管本质也是客服,也要执行 —— 所以分配入口就在召回池里,不新开页面、不把他劈成两个身份。
---
## 三、怎么选人
初选是一张矩阵,两个轴:
```mermaid
flowchart LR
subgraph X["X 轴 · 潜在治疗(8 类机会)"]
direction TB
X1["种植 / 正畸 / 早矫<br/>根管 / 牙周 / 充填<br/>修复 / 拔牙"]
end
subgraph Y["Y 轴 · 时机(3 档)"]
direction TB
Y1["🔥 黄金期<br/>🌡 窗口内<br/>❄️ 窗口外"]
end
X --> M["矩阵格子<br/>= 一批候选人"]
Y --> M
style M fill:#fef3c7,stroke:#f59e0b
```
**X 轴是「有需求但没做的机会」**,不是内部技术分类。按业务视角拆:正畸按年龄分成正畸与早矫,残根合成拔牙。
**Y 轴是该治疗项目自己的临床周期**,不是客户价值、不是意愿、不是多久没来。
每个项目用自己的尺度,归一化后横向可比 —— 种植的"过期"和补牙的"过期"不是一个天数。
### 不用没有数据支撑的因素
客服的**态度、能力**没有数据,不进决策。
分配只依据**客观事实**(专属客服、在岗、在手量)和**主管的显式指定**。
> 不给客服建能力评分 —— 分数一旦可见就成了绩效工具,会诱导行为扭曲。
### 画像圈人放在"调整"阶段
助手第一次出方案时**不做画像分层**,保持简洁。
主管追问「只要商保直付的」「排掉怕疼的」时,才当场用画像收窄 —— 那时候它正是主管要的精确回应。
---
## 四、怎么落到人头上
只有**两个基数**,都沿用主管上一次用的值:
| 基数 | 含义 | 首次 | 之后 |
|---|---|---|---|
| **本批人数** | 这一轮推多少人 | 在岗人数 × 20 | 沿用上次 |
| **时效** | 多久没动自动回池 | 3 天 | 沿用上次 |
> 没有第三个数。曾经有过"每人容量",删掉了 —— 一个数当两个用,第二批必然分不出来。
落人分**三趟**,目标是**又满又平**:名额全部落地,且分完大家在手量齐平。
```mermaid
flowchart TB
S(["N 个名额"]) --> T1
T1["① 专属优先<br/>有专属客服的 → 回到他手上"]
T1 -->|"但封顶在目标水位"| T2
T2["② 无主补空<br/>没有专属的 → 给当前最空的人"]
T2 -->|"还没填平?"| T3
T3["③ 有主改派<br/>超出水位的专属患者 → 改派给最空的人"]
T3 --> E(["每人在手量齐平"])
style T1 fill:#e0e7ff,stroke:#6366f1
style T2 fill:#dbeafe,stroke:#3b82f6
style T3 fill:#fef3c7,stroke:#f59e0b
style E fill:#d1fae5,stroke:#10b981
```
**顺序不能颠倒**:②③ 总量一样,但被拆散的老客户关系数不一样。先用"没有关系要顾"的人填坑,代价最小。
**为什么第一趟要封顶**(真实数据):某个格子 1,081 人里 **70% 挂在同一个客服名下**,而 17 位在岗客服中有 **10 位名下一个患者都没有**。
不封顶,一批 340 人分下去是 **248 / 34 / 31 / 14 / 14**;封顶后是**每人 20,完全齐平**。
> 改派**不是"抢客户"** —— 语义是「关系还在,只是这轮没轮到」。
---
## 五、确认单
助手把结果一次性摆出来,**直出,不追问**。最好的情况是主管看一眼就点确认。
**三层展开**:汇总 → 按客服 → 患者明细(折叠,展开才看)。
患者明细必须给**姓名和病历号** —— 给一串编号等于让主管对着乱码猜。
主管在卡片上能做四件**局部**修正,判据是「要不要重跑算法」:
| 卡片上直接做 | 回对话让助手做 |
|---|---|
| 改批次时效 · 改单条时效 | 换人群(换治疗项 / 时机 / 画像条件) |
| 删掉某一条 | 改本批人数 |
| 把某一条拖给别的客服 | 给某个客服设本批名额 |
| 移除某个客服 | 设本批福利 |
时效的文案是「**N 天后自动退回**」而不是光一个"时效" —— 主管要知道到期会发生什么,否则这个数对他没有意义。
### 福利挂在批次上,不挂在个人上
同一批共享同一个福利,才能归因(这批的效果 = 这个福利的效果)。
福利作为**事实**交给助手,由它自然融进话术。硬约束:**只能说福利原文包含的内容**,不得追加条件、期限、名额,不得夸大。
### 助手不出没有证据的结果
- 缺的数据(如医生档期)→ **不用,也不猜**
- 首次无历史 → 用默认值,**并标明这是默认值**
- 有数据之后 → 反推真实习惯,替换默认值
> 时效"3 天"现在只能写「默认值,暂无历史数据」,**不能**写成「依据平均结案 2.4 天」—— 那个数还算不出来。
---
## 六、闭环
一条单子的完整生命:
```mermaid
stateDiagram-v2
[*] --> 池子里
池子里 --> 在客服手上: 主管分配
在客服手上 --> 已处理: 联系到 / 约上了
在客服手上 --> 池子里: 客服退回(必须写原因)
在客服手上 --> 池子里: 到期自动退回
在客服手上 --> 池子里: 主管撤销整批
已处理 --> [*]
note right of 池子里
退回 / 到期的人会回到池子
只是排序上排到后面
end note
```
### 退回必须写原因
退回是**正常路径**不是异常。原因分布是主管调整下一批的输入 ——
「派多了」「时效太紧」「压根不该派给他」,这三种的下一步动作完全不同。
### 撤销 ≠ 退回
| | 谁做 | 做什么 | 粒度 |
|---|---|---|---|
| **撤销** | 主管 | 收回整批(分错人 / 条件填错) | 批次 |
| **退回** | 客服 | 退回单条(不是我的客户 / 没时间) | 单条 |
撤销**几乎总是部分成功** —— 客服已经打开过的单不会被收回(他可能已经联系了患者)。
所以结果如实报三个数,不能只说一句"已撤销"。
### 跟踪是为了自优化
**不做「分配 vs 自认领」的对照** —— 认领已经隐藏,没有对照组;
而且那种框法把分配当成"待验证的假设",与定位不符。分配是**既定的运营方式**,问题不是"要不要用",而是**"怎么越用越准"**。
主管能看到的:
| 指标 | 口径提醒 |
|---|---|
| 分了多少人 / 在手 / 已处理 | 「已处理」= 这单动过了,**不等于谈成了** |
| 客服主动退回 · 原因分布 | 「不该我做」,是**分配**问题 |
| 到期没人动 | **没处置**,是派多了 / 时效太紧 / 人不在岗 |
| 通话结果:成功 / 不成功及原因 | 打了之后的结果,是**召回效果**问题 |
| 一次结果都没有 | 最该先看的数 —— 不是效果差,是**根本没做** |
> **退回率永远给两个数**:「退回 5 / 已处置 40 = 12.5%(另有 60 条没人动)」。
> 没人动的数量本身就是信号,只报一个百分比会让人把前者误读成后者。
> **样本不足就直说**。上线初期成功记录会长期是 0 或个位数,
> 这时候必须写「已处置 12 人,暂无转化记录,样本量不足」,⛔ 不能渲染成「0.0% 转化率」。
---
## 一句话总结
> 主管点一格、看一眼、点确认;剩下的事系统做完,并且**回头说得清**。
{
"title": "产品设计",
"icon": "Lightbulb",
"pages": ["batch-assignment"]
}
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
"---了解 PAC---", "---了解 PAC---",
"start", "start",
"---设计---", "---设计---",
"design",
"architecture", "architecture",
"algorithms", "algorithms",
"design-system", "design-system",
......
...@@ -22,6 +22,7 @@ ...@@ -22,6 +22,7 @@
"cold-import": "ts-node --transpile-only src/cli/cold-import.cli.ts", "cold-import": "ts-node --transpile-only src/cli/cold-import.cli.ts",
"cold-import:prod": "node --max-old-space-size=8192 dist/cli/cold-import.cli.js", "cold-import:prod": "node --max-old-space-size=8192 dist/cli/cold-import.cli.js",
"reparse": "ts-node --transpile-only src/cli/reparse.cli.ts", "reparse": "ts-node --transpile-only src/cli/reparse.cli.ts",
"seed-assignment": "ts-node --transpile-only src/cli/seed-assignment.cli.ts",
"reparse:prod": "node --max-old-space-size=8192 dist/cli/reparse.cli.js", "reparse:prod": "node --max-old-space-size=8192 dist/cli/reparse.cli.js",
"sync-incremental": "ts-node --transpile-only src/cli/sync-incremental.cli.ts", "sync-incremental": "ts-node --transpile-only src/cli/sync-incremental.cli.ts",
"sync-incremental:prod": "node --max-old-space-size=4096 dist/cli/sync-incremental.cli.js", "sync-incremental:prod": "node --max-old-space-size=4096 dist/cli/sync-incremental.cli.js",
......
-- 门诊经理批次分配 —— 建 plan_assignments + followup_plans 六列归因
--
-- 【为什么这一份可以用普通 DDL,不必 CONCURRENTLY】
-- 20260728020000 那次踩过:Prisma 把整份 migration.sql 用**一次 simple query** 发给 PG,
-- PG 对「一个查询串里多条语句」隐式开事务块,而 CONCURRENTLY 禁止在事务块内跑 →
-- ERROR 25001,且 _prisma_migrations 留一条卡死记录(P3018)**堵死整条迁移流水线**。
-- 分界线是**语句条数,不是 Prisma 版本**。
-- 本次不需要它:唯一要在存量表上建的索引是 followup_plans(assignment_id),
-- 该表生产约 25 万行(秒级),不是 persona_features(7.66M / 5.4GB)也不是
-- patient_transactions(12–24M)。patient_facts 1300 万行本次**一个字都不动**。
--
-- 【为什么加六列不会重写 25 万行】
-- 六列**全部可空、全部无默认值** → PG 11+ 纯元数据操作(不 rewrite、不 full scan)。
-- 一旦给任何一列加 DEFAULT 或 NOT NULL,这个前提当场失效。
-- ⚠️ 存量**不回填**:NULL 即语义正确(= 上线前的自助认领单)。
-- 25 万行 UPDATE 会真正重写表 + 膨胀 + 触发 autovacuum,收益为零。
--
-- 【锁窗口】⚠️ 整份文件跑在**一个隐式事务**里 → 下面 ALTER 拿到的 ACCESS EXCLUSIVE
-- 会一直持有到 COMMIT,真实阻塞窗口 ≈ 整份迁移时长(本地实测 1–3 秒)。
-- **后人别往这份文件里追加慢语句**(回填、大表建索引),那会把阻塞窗口线性放大。
-- 首句 lock_timeout 是为了 R3:PG 锁队列近似 FIFO,一个**等待中**的 ACCESS EXCLUSIVE
-- 会挡住排在它后面的所有 SELECT —— 部署时若正好有慢查询压在 followup_plans 上,
-- 召回工作台会在 ALTER 真正拿到锁**之前**就整体停摆。设 10s 是把
-- 「全站排队几分钟」换成「迁移快速失败、重试即可」。
--
-- 【失败后怎么办】整份 SQL 在一个事务里,**不可能出现「表建了列没加」的半吊子 schema**。
-- npx prisma migrate resolve --rolled-back 20260802090000_add_plan_assignments
-- 然后避开 DW 08:00(沪) 落库后的增量 cron 窗口重试。
SET LOCAL lock_timeout = '10s';
-- ── plan_assignments ─────────────────────────────────────────
-- ⚠️ id 不给 DB 默认值:本仓 uuid 一律由 Prisma 客户端生成(@default(uuid())),
-- 与 plan_event_logs 等既有表一致。写 DEFAULT gen_random_uuid() 会造成永久 schema drift。
CREATE TABLE "plan_assignments" (
"id" UUID NOT NULL,
"host_id" UUID NOT NULL,
"tenant_id" TEXT NOT NULL,
"clinic_id" TEXT,
"created_by" TEXT NOT NULL,
"request_id" TEXT NOT NULL,
"criteria" JSONB NOT NULL,
"attributes" JSONB,
"expires_at" TIMESTAMPTZ(3) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'confirmed',
"revoked_at" TIMESTAMPTZ(3),
"revoked_by" TEXT,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "plan_assignments_pkey" PRIMARY KEY ("id")
);
-- 幂等键:同一 requestId 重复提交回查已有批次,不产生第二个批次
CREATE UNIQUE INDEX "plan_assignments_host_id_tenant_id_request_id_key"
ON "plan_assignments"("host_id", "tenant_id", "request_id");
-- 主管看「我分的那些批」
CREATE INDEX "plan_assignments_host_id_tenant_id_clinic_id_created_at_idx"
ON "plan_assignments"("host_id", "tenant_id", "clinic_id", "created_at");
CREATE INDEX "plan_assignments_created_by_created_at_idx"
ON "plan_assignments"("created_by", "created_at");
ALTER TABLE "plan_assignments"
ADD CONSTRAINT "plan_assignments_host_id_fkey"
FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- ── followup_plans 六列归因 ──────────────────────────────────
-- ⭐ **一条 ALTER 带六个 ADD COLUMN**(一次拿锁,不是六次抢锁)。
-- 拆成六条 ALTER 会让上面说的锁队列风险乘以六。
ALTER TABLE "followup_plans"
ADD COLUMN "assignment_id" UUID,
ADD COLUMN "assignment_expires_at" TIMESTAMPTZ(3),
ADD COLUMN "assigned_by" TEXT,
ADD COLUMN "release_reason" TEXT,
ADD COLUMN "release_note" TEXT,
ADD COLUMN "assign_strategy" TEXT;
CREATE INDEX "followup_plans_assignment_id_idx" ON "followup_plans"("assignment_id");
-- ⭐ ON DELETE RESTRICT 是**刻意的**:批次是审计对象,删批次会让 N 行归因永久失真。
-- NOT VALID + 单独 VALIDATE:后者只拿 SHARE UPDATE EXCLUSIVE(不挡读写),
-- 避免建约束时对 25 万行做一次带 ACCESS EXCLUSIVE 的全表校验。
-- 存量全是 NULL,校验必然通过。
ALTER TABLE "followup_plans"
ADD CONSTRAINT "followup_plans_assignment_id_fkey"
FOREIGN KEY ("assignment_id") REFERENCES "plan_assignments"("id")
ON DELETE RESTRICT ON UPDATE CASCADE
NOT VALID;
ALTER TABLE "followup_plans" VALIDATE CONSTRAINT "followup_plans_assignment_id_fkey";
-- 分配决策快照五列 —— 记录「当初为什么选了这个人」
--
-- 【为什么必须现在加】这五列记的东西**都会被就地覆盖,事后无法重建**:
-- · patients.preferences.dedicatedCs 摄入时 upsert 覆盖,只留当前值
-- · 「在岗」判定 "近 N 月有回访"是滚动窗口,今天在岗半年后变离岗
-- · priority_score 引擎在 reason 未变时**就地改分、不升版本**
-- · confidenceFactor 同上,且它是 score 里的 2× 乘子
-- · 探索配额标记 不记则随机探索配额白留(无法与正常入选区分)
-- 分配一旦开始跑,每一批没记的都是永久空洞。几十字节 vs 一次不可逆的信息丢失。
--
-- 【为什么不重写 25 万行】五列全部可空、全部无默认 → PG 11+ 纯元数据操作。
-- 与 20260802090000 同一条纪律:任何一列加 DEFAULT 或 NOT NULL,前提当场失效。
-- 存量 NULL 即语义正确(= 快照上线前分配的单 / 自助认领单),**不回填**。
--
-- 【为什么可以用普通 DDL】本次不建任何索引:这五列是**取数时读**的快照,
-- 不是筛选维度(要按它筛才该建索引 —— T17 同一条口径)。
-- 单条 ALTER 带五个 ADD COLUMN,一次拿锁。锁窗口与迁移 A 同量级(1-3 秒)。
--
-- 【失败后】整份 SQL 在一个隐式事务里,不会出现半吊子 schema。
-- npx prisma migrate resolve --rolled-back 20260802140000_add_assignment_decision_snapshot
SET LOCAL lock_timeout = '10s';
ALTER TABLE "followup_plans"
ADD COLUMN "dedicated_cs_at_assign" TEXT,
ADD COLUMN "dedicated_cs_last_visit_at" TIMESTAMPTZ(3),
ADD COLUMN "priority_score_at_assign" DOUBLE PRECISION,
ADD COLUMN "source_confidence_at_assign" DOUBLE PRECISION,
ADD COLUMN "selection_mode" TEXT;
-- 客服名册索引 —— 按 source_created_at 卡"近 N 月在岗"窗口
--
-- 【为什么现有索引不够】20260801150000 建的 roster 索引末列是 `task_date`,
-- 而名册判定**只能用 source_created_at**:task_date 含未来排程
-- (生产实测最远 2033 年,DW 侧甚至 2121 年),拿它卡窗口会把早已离职的人判成在岗。
-- 末列不匹配 ⇒ 按 source_created_at 过滤会退化成对 166.7 万行逐行 Filter。
-- ⛔ 不删旧索引:它另有 (patientId, taskDate) 之外的用途,且 task_date 本身仍是业务字段。
--
-- 【⚠️ 这份文件必须只有一条语句】
-- Prisma 把整份 migration.sql 用**一次 simple query** 发给 PG;PG 对"一个查询串里多条语句"
-- 隐式开事务块,而 CONCURRENTLY 明确禁止在事务块内跑:
-- ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block (25001)
-- 随后 _prisma_migrations 会留一条卡死记录(P3018)**堵死整条迁移流水线**(20260728020000 踩过)。
-- ⛔ 所以**不要**往这个文件里加 `SET LOCAL lock_timeout`、不要加注释以外的任何语句、
-- 更不要把它和迁移 A/B 合并。分界线是**语句条数**,不是 Prisma 版本。
--
-- 【为什么用 CONCURRENTLY】patient_return_visits 生产 166.7 万行,普通 CREATE INDEX 会
-- ACCESS EXCLUSIVE 锁全表;而回访表正被增量同步持续写入,锁上就是同步阻塞。
-- CONCURRENTLY 全程不锁写,代价是慢一倍且失败会留 INVALID 索引(重跑前需先 DROP)。
--
-- 【失败救援】CONCURRENTLY 失败会留下一个 INVALID 索引,必须先删再重来:
-- DROP INDEX CONCURRENTLY IF EXISTS "patient_return_visits_roster_source_idx";
-- npx prisma migrate resolve --rolled-back 20260802150000_add_return_visit_roster_source_idx
CREATE INDEX CONCURRENTLY IF NOT EXISTS "patient_return_visits_roster_source_idx" ON "patient_return_visits" ("host_id", "tenant_id", "clinic_id", "task_director_id", "source_created_at" DESC);
-- AlterTable
ALTER TABLE "plan_event_logs" ADD COLUMN "assignment_id" UUID;
-- AlterTable
ALTER TABLE "plan_executions" ALTER COLUMN "inaccurate_treatments" DROP DEFAULT;
-- CreateIndex
CREATE INDEX "plan_event_logs_assignment_id_event_idx" ON "plan_event_logs"("assignment_id", "event");
-- RenameIndex
ALTER INDEX "patient_return_visits_roster_idx" RENAME TO "patient_return_visits_host_id_tenant_id_clinic_id_task_dire_idx";
-- AlterTable
ALTER TABLE "plan_executions" ADD COLUMN "assignment_id" UUID;
-- CreateIndex
CREATE INDEX "plan_executions_assignment_id_plan_id_created_at_idx" ON "plan_executions"("assignment_id", "plan_id", "created_at");
-- plan_scripts 加渠道:phone(电话,分段) | wecom(企微,单块)
-- 存量行默认 phone —— 上线前的话术都是电话稿,不是"未知"。
ALTER TABLE "plan_scripts" ADD COLUMN "channel" TEXT NOT NULL DEFAULT 'phone';
-- 一 plan 一条 → 一 plan 每渠道一条。
-- 原 plan_id 唯一,加上 channel 后不可能产生重复,故直接换。
DROP INDEX IF EXISTS "plan_scripts_plan_id_key";
CREATE UNIQUE INDEX "plan_scripts_plan_id_channel_key" ON "plan_scripts"("plan_id", "channel");
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import type { Permission, AccessTokenPayload } from '@pac/types'; import type { Permission, AccessTokenPayload } from '@pac/types';
import { ROLE_PERMISSIONS } from '@pac/types';
import { PERMISSIONS_KEY } from '../decorators/permissions.decorator'; import { PERMISSIONS_KEY } from '../decorators/permissions.decorator';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
...@@ -25,7 +26,16 @@ export class PermissionsGuard implements CanActivate { ...@@ -25,7 +26,16 @@ export class PermissionsGuard implements CanActivate {
const user = req.user; const user = req.user;
if (!user) throw new ForbiddenException('No authenticated user'); if (!user) throw new ForbiddenException('No authenticated user');
const granted = new Set(user.permissions ?? []); // ⭐ 按 **role 现算**,不认 JWT 里那份 permissions 快照。
// JWT 的 permissions 是签发那一刻 resolvePermissions(role) 的结果(auth.service 是唯一产地,
// 从没有过"自定义权限集"这条路),access token 2h 不变 —— 于是**新增一个权限的那次发版**,
// 已登录的人会在 token 到期前一直按老清单判定:leader 拿不到 plan:dispatch,
// 前端按钮藏着、MCP 工具集静默少几个,**不报错不告警**,只能靠"让所有人重登"兜。
// 改成现算后 ROLE_PERMISSIONS 是唯一真理源(claim-guard.ts 早已这么写),
// JWT 那份降级为缓存,只在 role 不认识时兜底。
// ⚠️ 不会放大权限:role 本身仍来自签名过的 JWT,这里只是把 role→permissions 的映射
// 从"签发时固化"改成"检查时现算",改角色仍须重新签票。
const granted = new Set<string>(ROLE_PERMISSIONS[user.role] ?? user.permissions ?? []);
const missing = required.filter((p) => !granted.has(p)); const missing = required.filter((p) => !granted.has(p));
if (missing.length > 0) { if (missing.length > 0) {
throw new ForbiddenException(`Missing permissions: ${missing.join(', ')}`); throw new ForbiddenException(`Missing permissions: ${missing.join(', ')}`);
......
...@@ -8,6 +8,9 @@ import { DraftPlanScriptCall } from './calls/draft-plan-script/tiers/stable/stab ...@@ -8,6 +8,9 @@ import { DraftPlanScriptCall } from './calls/draft-plan-script/tiers/stable/stab
import { StandardScriptCall } from './calls/draft-plan-script/tiers/standard/standard.call'; import { StandardScriptCall } from './calls/draft-plan-script/tiers/standard/standard.call';
import { DeepPlanCall, DeepWriteCall, DeepVerifyCall } from './calls/draft-plan-script/tiers/deep/calls'; import { DeepPlanCall, DeepWriteCall, DeepVerifyCall } from './calls/draft-plan-script/tiers/deep/calls';
import { DeepScriptStrategy } from './calls/draft-plan-script/tiers/deep/deep.strategy'; import { DeepScriptStrategy } from './calls/draft-plan-script/tiers/deep/deep.strategy';
import { WecomPlanCall, WecomWriteCall, WecomVerifyCall } from './calls/draft-wecom-script/calls';
import { WecomScriptStrategy } from './calls/draft-wecom-script/wecom.strategy';
import { WecomScriptOrchestrator } from './orchestrators/wecom-script.orchestrator';
import { DraftPlanScriptSkillRegistry } from './calls/draft-plan-script/shared/skill-registry.service'; import { DraftPlanScriptSkillRegistry } from './calls/draft-plan-script/shared/skill-registry.service';
import { DraftPlanSummaryCall } from './calls/draft-plan-summary/call'; import { DraftPlanSummaryCall } from './calls/draft-plan-summary/call';
import { DraftRecallSummaryCall } from './calls/draft-recall-summary/call'; import { DraftRecallSummaryCall } from './calls/draft-recall-summary/call';
...@@ -53,6 +56,11 @@ import { PlanModule } from '../plan/plan.module'; ...@@ -53,6 +56,11 @@ import { PlanModule } from '../plan/plan.module';
DeepWriteCall, // 深度档 步骤2 写(多段) DeepWriteCall, // 深度档 步骤2 写(多段)
DeepVerifyCall, // 深度档 步骤3 独立对抗校验 DeepVerifyCall, // 深度档 步骤3 独立对抗校验
DeepScriptStrategy, // 深度档 3 步编排(plan→write→verify→repair→兜底) DeepScriptStrategy, // 深度档 3 步编排(plan→write→verify→repair→兜底)
// AI calls — 企微话术(只有深度档;一次性单块,⛔ 无模板兜底,见 wecom.strategy 文件头)
WecomPlanCall,
WecomWriteCall,
WecomVerifyCall,
WecomScriptStrategy,
DraftPlanScriptSkillRegistry, // scan & cache draft-plan-script/**​/skills/**​/SKILL.md DraftPlanScriptSkillRegistry, // scan & cache draft-plan-script/**​/skills/**​/SKILL.md
DraftPlanSummaryCall, DraftPlanSummaryCall,
DraftRecallSummaryCall, DraftRecallSummaryCall,
...@@ -60,6 +68,7 @@ import { PlanModule } from '../plan/plan.module'; ...@@ -60,6 +68,7 @@ import { PlanModule } from '../plan/plan.module';
DraftRecallBriefCall, DraftRecallBriefCall,
// orchestrators // orchestrators
PlanScriptOrchestrator, PlanScriptOrchestrator,
WecomScriptOrchestrator,
PlanSummaryOrchestrator, PlanSummaryOrchestrator,
RecallSummaryOrchestrator, RecallSummaryOrchestrator,
PersonaSummaryOrchestrator, PersonaSummaryOrchestrator,
...@@ -68,6 +77,7 @@ import { PlanModule } from '../plan/plan.module'; ...@@ -68,6 +77,7 @@ import { PlanModule } from '../plan/plan.module';
exports: [ exports: [
// 对外暴露 orchestrator(业务方调用入口)+ runner(高级使用 / eval CLI) // 对外暴露 orchestrator(业务方调用入口)+ runner(高级使用 / eval CLI)
PlanScriptOrchestrator, PlanScriptOrchestrator,
WecomScriptOrchestrator, // 企微话术(PlansAggregateController 注入)
PlanSummaryOrchestrator, PlanSummaryOrchestrator,
RecallSummaryOrchestrator, RecallSummaryOrchestrator,
PersonaSummaryOrchestrator, PersonaSummaryOrchestrator,
......
...@@ -17,6 +17,33 @@ import { deidentifyDoctor } from './pii'; ...@@ -17,6 +17,33 @@ import { deidentifyDoctor } from './pii';
import { AGENT_IDENTITY_PLACEHOLDER } from './agent-identity'; import { AGENT_IDENTITY_PLACEHOLDER } from './agent-identity';
import { DENTURE_FIRST_AGE, IMPLANT_LAST_AGE, EARLY_ORTHO_MAX_AGE } from '@pac/types'; import { DENTURE_FIRST_AGE, IMPLANT_LAST_AGE, EARLY_ORTHO_MAX_AGE } from '@pac/types';
/**
* 本批次福利 —— 作为**事实**给 LLM,让它自然融进话术(T4)。
*
* ⚠️⚠️ 护栏是这段的重点,不是可选的修饰:
* 模型拿到"8月种植体检免费"这一句,极容易顺手补成"限本月前20名""老客户专享"
* "可叠加其他折扣"——**那就是对患者做出了一个不存在的承诺**,患者按它上门,
* 前台兑现不了。这跟"高龄不主推种植""低龄不承诺能不能种"是同一类硬约束,
* 落点也放在一起。
*
* 没配福利 → 返回空串,**整段不生成**,不留空钩子(免得模型自己去编一个)。
*
* ⭐ **导出**:稳健档(tiers/stable/prompt)也用同一份。护栏文案只能有一处 ——
* 两处各写一份,改了其中一处另一处就悄悄留在旧版本上,而"哪一档漏了哪条禁令"
* 要等到客服照着念了才发现。
*/
export function benefitBlock(benefit?: { text: string } | null): string {
const text = benefit?.text?.trim();
if (!text) return '';
return `\n\n## 本次福利(硬约束)
- 本批次配了福利,原文如下(**只有这一句是真的**):
${text}
- 可以自然地融进话术,但**只能陈述这句话本身包含的内容**。
- ⛔ **不得追加任何条件、期限、名额、人群限定**(如"限本月""前20名""老客户专享")。
- ⛔ **不得改写金额/折扣/项目,不得夸大,不得暗示还能再优惠**。
- ⛔ 原文没写的一律不说;患者追问细节 → "具体以到院时前台说明为准"。`;
}
export function buildRichFactBlock(input: DraftPlanScriptInput): string { export function buildRichFactBlock(input: DraftPlanScriptInput): string {
const { patient, clinicName, plan, clinicalContext } = input; const { patient, clinicName, plan, clinicalContext } = input;
const now = new Date(); const now = new Date();
...@@ -105,7 +132,7 @@ ${others.length ? `\n## 其他可一并关心的问题(以本次聚焦为主,自 ...@@ -105,7 +132,7 @@ ${others.length ? `\n## 其他可一并关心的问题(以本次聚焦为主,自
## 患者 ## 患者
- ${basics} - ${basics}
- 熟络度:${relationSignal}(语气怎么拿捏见沟通知识,你按这信号判断)${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**' : ''}${dentureFirst ? `\n\n## 高龄沟通(约束)\n- 本患者 ${patient.age} 岁:缺牙修复**先讲活动义齿**(创伤小、周期短),种植可作为并行选项一起提,\n 但**不要主推手术、不要承诺能不能种** —— 统一落到"来院让医生按身体条件评估";措辞更耐心,可提示家属陪同。` : ''}${implantLast ? `\n\n## 低龄沟通(硬约束)\n- 本患者 ${patient.age} 岁,颌骨尚未发育完成:**不要主推种植**(种植体不随颌骨生长,未成年是相对禁忌)。\n 涉及缺牙先讲**间隙管理 / 正畸方向**,修复方式统一落到"来院让医生按发育情况评估",不承诺能不能种。` : ''}${earlyOrtho ? `\n\n## 矫治措辞(约束)\n- 本患者 ${patient.age} 岁处替牙期:涉及矫正一律说「**早期矫治**」(干预颌骨发育与间隙管理),\n 不要说成给恒牙列排齐的"正畸/戴牙套";具体做不做、做哪种,落到"来院让医生评估"。` : ''}`; - 熟络度:${relationSignal}(语气怎么拿捏见沟通知识,你按这信号判断)${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**' : ''}${dentureFirst ? `\n\n## 高龄沟通(约束)\n- 本患者 ${patient.age} 岁:缺牙修复**先讲活动义齿**(创伤小、周期短),种植可作为并行选项一起提,\n 但**不要主推手术、不要承诺能不能种** —— 统一落到"来院让医生按身体条件评估";措辞更耐心,可提示家属陪同。` : ''}${implantLast ? `\n\n## 低龄沟通(硬约束)\n- 本患者 ${patient.age} 岁,颌骨尚未发育完成:**不要主推种植**(种植体不随颌骨生长,未成年是相对禁忌)。\n 涉及缺牙先讲**间隙管理 / 正畸方向**,修复方式统一落到"来院让医生按发育情况评估",不承诺能不能种。` : ''}${earlyOrtho ? `\n\n## 矫治措辞(约束)\n- 本患者 ${patient.age} 岁处替牙期:涉及矫正一律说「**早期矫治**」(干预颌骨发育与间隙管理),\n 不要说成给恒牙列排齐的"正畸/戴牙套";具体做不做、做哪种,落到"来院让医生评估"。` : ''}${benefitBlock(input.benefit)}`;
} }
/** /**
......
...@@ -32,6 +32,24 @@ export interface ScriptContext { ...@@ -32,6 +32,24 @@ export interface ScriptContext {
/** 诊所名 —— 防 LLM 编造"XX口腔"的锚;⚠️ 自报家门里**不用**它(身份是"{诊断医生}医生的助理X") */ /** 诊所名 —— 防 LLM 编造"XX口腔"的锚;⚠️ 自报家门里**不用**它(身份是"{诊断医生}医生的助理X") */
clinicName: string; clinicName: string;
/**
* 本批次配的福利(来自 plan_assignments.attributes.benefit)。null = 没配 → **整段不生成**。
*
* ⭐ 走 prompt 输入而不是确定性占位符,是有意的(T4):
* 占位符那套(`AGENT_IDENTITY_PLACEHOLDER`)是给 **PII 与缓存**用的
* —— 人名不进 LLM、换客服不用重生成。福利是**内容**不是身份 token,
* 硬插一句会打断口语流;且三档输出形态差异大(稳健=模板填空 / 标准=自由段落 /
* 深度=多段分析),占位符要在每档各实现一次。作为事实输入则三档通用。
*
* ⚠️⚠️ **必须带护栏**:LLM 拿到一句"8月种植体检免费"极容易顺手补成
* "限本月前 20 名""老客户专享""可叠加折扣" —— 那就是**对患者做出不存在的承诺**。
* 护栏落点与"高龄义齿 / 低龄种植"那两条年龄约束同款,见 fact-block 与 stable/prompt。
*
* ⚠️ 话术缓存是 per-plan(planId @unique),福利一变必须作废缓存,
* 否则客服会照着上一个批次的福利念。见 plan-assignment.service.invalidateScripts。
*/
benefit?: { text: string } | null;
/** ⚠️ 这里**没有** agent(回访客服)字段,是刻意的 —— 姓名不进 LLM 输入。 /** ⚠️ 这里**没有** agent(回访客服)字段,是刻意的 —— 姓名不进 LLM 输入。
* 话术缓存是 per-plan(UNIQUE plan_id)、召回池又共享,烤进人名会让后开的客服读到别人的名字。 * 话术缓存是 per-plan(UNIQUE plan_id)、召回池又共享,烤进人名会让后开的客服读到别人的名字。
* 改成「生成期占位 `【回访客服】` → 渲染期按登录人回填」,见 shared/agent-identity.ts。 * 改成「生成期占位 `【回访客服】` → 渲染期按登录人回填」,见 shared/agent-identity.ts。
......
...@@ -53,8 +53,18 @@ export function machineSafetyScan(text: string): string[] { ...@@ -53,8 +53,18 @@ export function machineSafetyScan(text: string): string[] {
return problems; return problems;
} }
/** prompt 用的禁词块(system 注入;与机器闸同源,避免漂移) */ /**
export function forbiddenWordsBlock(): string { * prompt 用的禁词块(system 注入;与机器闸同源,避免漂移)。
*
* @param opts.timePlaceholders 是否保留【时间段】占位。
* ⭐ 电话档 `true`(客服边打边填);⛔ **企微档必须 `false`** ——
* 企微那条消息是整段复制直接发出去的,占位符会原样发给患者。
* ⚠️ 这个参数存在的唯一原因是:本块与企微的 format.md 会拼进**同一份 system**,
* 不参数化就等于给模型两条互相矛盾的指令(一边说"照旧保留"、一边说"一个都不许出现"),
* 而它照哪条做全看运气。
*/
export function forbiddenWordsBlock(opts: { timePlaceholders?: boolean } = {}): string {
const keepPlaceholders = opts.timePlaceholders ?? true;
return [ return [
'# 禁词(整篇严禁出现)', '# 禁词(整篇严禁出现)',
FORBIDDEN_PHRASES.join(' / '), FORBIDDEN_PHRASES.join(' / '),
...@@ -62,7 +72,10 @@ export function forbiddenWordsBlock(): string { ...@@ -62,7 +72,10 @@ export function forbiddenWordsBlock(): string {
'', '',
'# 说人话(患者听得懂)', '# 说人话(患者听得懂)',
'严禁把内部代码 / 专业术语原样念给患者:**不出现诊断代码(如 K08、K05.1)、英文或全大写下划线枚举(如 IMPLANT_RECOMMENDED)**。', '严禁把内部代码 / 专业术语原样念给患者:**不出现诊断代码(如 K08、K05.1)、英文或全大写下划线枚举(如 IMPLANT_RECOMMENDED)**。',
'一律翻成大白话:"K08" → "缺了一颗小磨牙";牙位/诊断说成患者能懂的位置和说法。占位标签【时间段】等照旧保留(那是给客服填的)。', '一律翻成大白话:"K08" → "缺了一颗小磨牙";牙位/诊断说成患者能懂的位置和说法。' +
(keepPlaceholders
? '占位标签【时间段】等照旧保留(那是给客服填的)。'
: '⛔ 本篇**不许出现任何 `【】` 占位标签**(除自报家门里的【回访客服】),尤其是时间占位。'),
].join('\n'); ].join('\n');
} }
......
...@@ -35,18 +35,27 @@ export interface ComposedSystem { ...@@ -35,18 +35,27 @@ export interface ComposedSystem {
composeHash: string; composeHash: string;
} }
/** lazy load base —— common(共性,三档一份)+ format(每档一份),按档缓存 */ /**
* lazy load base —— common(共性,各档一份)+ format(每档一份),**按解析后的路径缓存**。
*
* ⚠️ 缓存键从 tier 改成 path,是因为 `formatPath` 覆盖(企微渠道)会让"同一个 tier
* 对应两份 format" —— 还按 tier 缓存的话,先加载的那份会被另一个渠道复用,
* 表现是企微稿写出分段的电话格式(或反过来),而且不报错。
*/
let cachedCommon: string | null = null; let cachedCommon: string | null = null;
const cachedFormat: Partial<Record<ScriptTier, string>> = {}; const cachedFormat = new Map<string, string>();
function loadBase(tier: ScriptTier): string { function loadBase(tier: ScriptTier, formatPath?: string, timePlaceholders = true): string {
if (cachedCommon === null) { if (cachedCommon === null) {
cachedCommon = readFileSync(resolveBaseCommonPath(), 'utf-8').trim(); cachedCommon = readFileSync(resolveBaseCommonPath(), 'utf-8').trim();
} }
if (cachedFormat[tier] === undefined) { const path = formatPath ?? resolveBaseFormatPath(tier);
cachedFormat[tier] = readFileSync(resolveBaseFormatPath(tier), 'utf-8').trim(); let format = cachedFormat.get(path);
if (format === undefined) {
format = readFileSync(path, 'utf-8').trim();
cachedFormat.set(path, format);
} }
// 顺序:共性定位/铁律 → 本档输出格式 → 禁词(单一源) // 顺序:共性定位/铁律 → 本档输出格式 → 禁词(单一源)
return `${cachedCommon}\n\n${cachedFormat[tier]}\n\n${forbiddenWordsBlock()}`; return `${cachedCommon}\n\n${format}\n\n${forbiddenWordsBlock({ timePlaceholders })}`;
} }
/** /**
...@@ -106,10 +115,24 @@ export function skillTierOk(skill: Skill, tier: ScriptTier): boolean { ...@@ -106,10 +115,24 @@ export function skillTierOk(skill: Skill, tier: ScriptTier): boolean {
* base = common(共性) + format(该档) + 禁词(单一源); * base = common(共性) + format(该档) + 禁词(单一源);
* skills = applies 命中 且 该档适用(tiers 过滤)。 * skills = applies 命中 且 该档适用(tiers 过滤)。
*/ */
/**
* @param formatPath ⭐ **输出格式覆盖**(可选)—— 企微渠道用。
* `tier` 仍传 `'deep'`:它决定**挑哪些 skill**(人群共性、深度档知识),那部分与渠道无关;
* 但输出形态(电话=分段 sections / 企微=一整块可发送消息)完全不同,只换这一份 format.md。
* ⛔ 别为此往 `ScriptTier` 里加 `'wecom'` —— tier 是**质量档**,渠道是另一个维度,
* 混进同一个枚举之后"深度档企微"这种组合就表达不出来了。
*/
export function composeSystem( export function composeSystem(
input: DraftPlanScriptInput, input: DraftPlanScriptInput,
allSkills: readonly Skill[], allSkills: readonly Skill[],
tier: ScriptTier = 'stable', tier: ScriptTier = 'stable',
formatPath?: string,
/**
* 是否保留【时间段】占位。电话档 true;⛔ **企微必须 false** ——
* 不然禁词块里那句「占位标签照旧保留」会跟企微 format.md 的「一个都不许出现」
* 拼进同一份 system 打架,模型照哪条做全看运气。
*/
timePlaceholders = true,
): ComposedSystem { ): ComposedSystem {
const context = deriveContext(input); const context = deriveContext(input);
const matched = allSkills const matched = allSkills
...@@ -119,7 +142,7 @@ export function composeSystem( ...@@ -119,7 +142,7 @@ export function composeSystem(
(a.frontmatter.priority ?? 50) - (b.frontmatter.priority ?? 50), (a.frontmatter.priority ?? 50) - (b.frontmatter.priority ?? 50),
); );
const base = loadBase(tier); const base = loadBase(tier, formatPath, timePlaceholders);
// 只拼 body — 内部 skill name/version 不进提示词(版本归因走 composeHash,见下) // 只拼 body — 内部 skill name/version 不进提示词(版本归因走 composeHash,见下)
const skillsBlock = matched.map((s) => s.body).join('\n\n---\n\n'); const skillsBlock = matched.map((s) => s.body).join('\n\n---\n\n');
...@@ -127,8 +150,14 @@ export function composeSystem( ...@@ -127,8 +150,14 @@ export function composeSystem(
? `${base}\n\n# 本次适用知识 / 模板\n\n${skillsBlock}` ? `${base}\n\n# 本次适用知识 / 模板\n\n${skillsBlock}`
: base; : base;
// composeHash = sha256(tier + matched.name+version join)前 16 hex // composeHash = sha256(tier + format 覆盖 + matched.name@version)前 16 hex
const hashSrc = [tier, ...matched.map((s) => `${s.frontmatter.name}@${s.frontmatter.version}`)].join('|'); // ⚠️ formatPath 必须进哈希:同 tier 同 skills 但换了输出格式(电话/企微)是**两套 system**,
// 不进哈希的话两者算出同一个 composeHash → promptVersion 撞车 → eval 里两个渠道的效果混在一起。
const hashSrc = [
tier,
...(formatPath ? [`fmt:${formatPath.split('/').slice(-4).join('/')}`] : []),
...matched.map((s) => `${s.frontmatter.name}@${s.frontmatter.version}`),
].join('|');
const composeHash = createHash('sha256').update(hashSrc).digest('hex').slice(0, 16); const composeHash = createHash('sha256').update(hashSrc).digest('hex').slice(0, 16);
return { systemPrompt, matchedSkills: matched, context, composeHash }; return { systemPrompt, matchedSkills: matched, context, composeHash };
......
...@@ -2,7 +2,9 @@ import type { DraftPlanScriptInput } from '../../shared/input.types'; ...@@ -2,7 +2,9 @@ import type { DraftPlanScriptInput } from '../../shared/input.types';
import { smartDateDisplay, toothFriendly } from '../../shared/script-facts'; import { smartDateDisplay, toothFriendly } from '../../shared/script-facts';
import { resolveDisease } from './phrasing'; import { resolveDisease } from './phrasing';
import { deidentifyDoctor } from '../../shared/pii'; import { deidentifyDoctor } from '../../shared/pii';
import { renderTreatmentPlan, buildPersonaGuide } from '../../shared/fact-block'; // ⭐ benefitBlock 与标准/深度档**共用同一份**护栏文案 —— 两处各写一份必然漂,
// 而「哪一档漏了哪条禁令」要等到客服照着念了才会发现。
import { renderTreatmentPlan, buildPersonaGuide, benefitBlock } from '../../shared/fact-block';
import { AGENT_IDENTITY_PLACEHOLDER } from '../../shared/agent-identity'; import { AGENT_IDENTITY_PLACEHOLDER } from '../../shared/agent-identity';
import { DENTURE_FIRST_AGE, IMPLANT_LAST_AGE, EARLY_ORTHO_MAX_AGE } from '@pac/types'; import { DENTURE_FIRST_AGE, IMPLANT_LAST_AGE, EARLY_ORTHO_MAX_AGE } from '@pac/types';
...@@ -150,5 +152,5 @@ ${advLines} ...@@ -150,5 +152,5 @@ ${advLines}
- ${basics} - ${basics}
## 语气 ## 语气
- ${toneHint}${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**(删除模板里的拍片句)' : ''}${dentureFirst ? `\n\n## 高龄沟通(约束)\n- 本患者 ${patient.age} 岁:缺牙修复**先讲活动义齿**,种植可并行提及,\n 但**不要主推手术、不要承诺能不能种** —— 落到"来院让医生按身体条件评估";措辞更耐心,可提示家属陪同。` : ''}${implantLast ? `\n\n## 低龄沟通(硬约束)\n- 本患者 ${patient.age} 岁,颌骨尚未发育完成:**不要主推种植**(未成年相对禁忌)。缺牙先讲间隙管理 / 正畸方向,\n 修复方式落到"来院让医生按发育情况评估",不承诺能不能种。` : ''}${earlyOrtho ? `\n\n## 矫治措辞(约束)\n- 本患者 ${patient.age} 岁处替牙期:涉及矫正一律说「**早期矫治**」,不要说成给恒牙列排齐的"正畸/戴牙套"。` : ''}${persona ? `\n\n${persona}` : ''}`; - ${toneHint}${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**(删除模板里的拍片句)' : ''}${dentureFirst ? `\n\n## 高龄沟通(约束)\n- 本患者 ${patient.age} 岁:缺牙修复**先讲活动义齿**,种植可并行提及,\n 但**不要主推手术、不要承诺能不能种** —— 落到"来院让医生按身体条件评估";措辞更耐心,可提示家属陪同。` : ''}${implantLast ? `\n\n## 低龄沟通(硬约束)\n- 本患者 ${patient.age} 岁,颌骨尚未发育完成:**不要主推种植**(未成年相对禁忌)。缺牙先讲间隙管理 / 正畸方向,\n 修复方式落到"来院让医生按发育情况评估",不承诺能不能种。` : ''}${earlyOrtho ? `\n\n## 矫治措辞(约束)\n- 本患者 ${patient.age} 岁处替牙期:涉及矫正一律说「**早期矫治**」,不要说成给恒牙列排齐的"正畸/戴牙套"。` : ''}${benefitBlock(input.benefit)}${persona ? `\n\n${persona}` : ''}`;
} }
import { Injectable } from '@nestjs/common';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import type { AiCall } from '../../ai-call.interface';
import type { ScriptContext } from '../draft-plan-script/shared/input.types';
import { composeSystem } from '../draft-plan-script/shared/skill-composer';
import { DraftPlanScriptSkillRegistry } from '../draft-plan-script/shared/skill-registry.service';
import {
WecomPlanSchema,
WecomWriteSchema,
WecomVerifySchema,
type WecomPlanZ,
type WecomWriteZ,
type WecomVerifyZ,
} from './schema';
import { buildWecomPlanPrompt, buildWecomWritePrompt, buildWecomVerifyPrompt } from './prompts';
/**
* 企微话术的 3 个 AiCall(与电话深度档同构:plan → write → verify,各自落 agent_invocations)。
*
* ═══ 与电话档共用了什么、没共用什么 ═══════════════════════════
* 共用(直接 import,⛔ 不复制):
* · ScriptContext 输入契约、`buildRichFactBlock` 患者事实块(谁、哪颗牙、医生说了什么)
* · 安全护栏 `forbiddenWordsBlock` / 福利硬约束 / 自报家门占位 / 医生姓脱敏
* · 人群 skills(成人/儿童共性)与 `composeSystem` 装配逻辑
* —— 这些都跟"用电话还是企微说"无关。复制一份的代价是护栏有两处,
* 改了一处另一处会悄悄留在旧版本,而漏了哪条要等客服**已经发给患者**才发现。
* 没共用(本目录自己写):
* · 输出 schema:电话 `sections[]`(伴飞逐段高亮要它)→ 企微单块 `markdown`
* · format.md:电话是口语/分段/口头二选一 → 企微是书面/断行/可复制即发
* · verify 多一条⑤「可直接发送」(无小标题、无占位残留、无给客服看的话)
*
* ⚠️ 企微**只有深度档**(产品定):它是一条发出去就收不回的消息,
* 没有"边打边看着调整"的机会,所以不给低质量档。
*/
/**
* 本目录自己的输出格式(覆盖 tier 默认的那份)。
*
* ⛔ **不能用 `__dirname`**(踩过:ENOENT)—— SWC dev 的产物在 `dist/src/...`、
* tsc prod 在 `dist/...`,同一个 `__dirname` 在两态下指向不同层级。
* 照抄 `resolveScriptRoot` 那套 env → src → dist 的策略(理由见它的文件头注释):
* `cwd` 在 dev/prod 都是 apps/pac-service 根,才是稳的。
*/
function wecomFormatPath(): string {
const rel = 'modules/ai/calls/draft-wecom-script/skills/_base/format.md';
const src = join(process.cwd(), 'src', rel);
return existsSync(src) ? src : join(process.cwd(), 'dist', rel);
}
const PLAN_SYSTEM = [
'你是资深口腔回访话术规划师。基于给定的患者事实,规划一条**发到患者微信里**的医疗关怀消息:要讲哪几点、按什么顺序讲。',
'原则:医疗关怀非销售;以本次聚焦项(应治未治)为主线;每个要点都必须能追到给定事实,不编造。',
'',
'# 这是微信,不是电话',
'患者会**一口气读完**,没有一来一回。所以你排的是「讲的先后」,**不是分段** ——',
'最终产出是一整段连贯文字,不会有小标题。别按"开场/正文/结尾"去想,想的是"先说什么才能让下一句站得住"。',
'',
'# 规划方法:从果(目标)倒推到因',
'先定这条消息的"果" —— 让患者明白"该回来把 X 处理掉、早处理的好处 / 拖着的后果",从而愿意来复查;',
'再倒推"因" —— 为达成它,患者需要先知道什么、被打消什么顾虑。据此把链条拆成层层递进的要点。',
'',
'# 说明风险的分寸:把后果讲清,但不吓唬、不推销',
'"不处理会怎样"要**客观说明**(结合病历 + 牙科常识),让患者理解严重性;',
'但**不夸大、不制造恐慌、不下吓人结论**,也**不报价、不促单、不施压**。',
'',
'不要写正文,只输出要点 JSON。要点 3-6 条。',
].join('\n');
const VERIFY_SYSTEM = [
'你是严格的医疗话术审核员,任务是**对抗式校验**一条准备**发到患者微信**的消息,默认怀疑、宁严勿松。',
'依据给定的"本次回访患者信息"逐项判断:',
'① 接地:每个具体说法(诊断/检查所见/医嘱/时间/牙位/医生)能否在给定事实里找到依据?找不到=编造。',
'② 安全:有无报价/费用、疗效承诺、患者≤18 却提拍片?',
' ⛔ **出现任何时间占位符(【时间段1】【时间段2】【具体预约时间】)= 不合格** ——',
' 企微是整段复制直接发出去的,占位符会原样发给患者;而客服要先手动改一遍的话,',
' "可直接发送"就不成立了。⛔ 自己编具体时间("周三上午")同样不合格。',
' 正确写法是不含具体时间的邀约(「您方便的时候回我一下,我帮您安排」)。',
'③ 逻辑与分寸:围绕"让患者明白该回来处理"展开、层层递进、该说的没缺(点了问题要说后果、说了后果要给出路);',
' 有没有**吓唬/制造恐慌**或**推销/促单/施压**口吻?',
'④ 患者听得懂:有没有诊断代码(如 K08)、英文/内部枚举、生硬术语,或含糊其辞(没说清哪颗牙)?',
'⑤ ⭐**可直接发送**(企微专有):客服会整段复制发出去 ——',
' 有小标题 / `##` / 分段编号 / "第一第二" = 不合格;',
' 混进给客服自己看的话("以下话术供参考""建议这样说") = 不合格;',
' ⛔ 除自报家门里的 【回访客服】 外**残留任何 `【】` 占位或内部标签 = 不合格**;',
' 出现"您现在方便吗""能听清吗"这类**需要对方当场回话**才成立的电话句式 = 不合格。',
'①②③④⑤ 任一不过 → pass=false,并逐条列出 issue(位置、问题、修法);全部通过 → pass=true、issues 空。',
'另外给一组 **quality 质量评分**(1-5)—— **只评"好不好",跟 pass 无关**。',
'只输出 JSON,不改写草稿。',
].join('\n');
@Injectable()
export class WecomPlanCall implements AiCall<ScriptContext, WecomPlanZ> {
readonly kind = 'script' as const;
readonly callKey = 'draft_wecom_script_plan';
readonly promptVersion = 'draft_wecom_script@2026-08-04-plan-v1';
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = WecomPlanSchema;
buildPrompt(ctx: ScriptContext) {
return { system: PLAN_SYSTEM, prompt: buildWecomPlanPrompt(ctx) };
}
}
export interface WecomWriteInput {
ctx: ScriptContext;
plan: WecomPlanZ;
prevDraft?: WecomWriteZ;
repairIssues?: Array<{ section: string; problem: string; fix: string }>;
}
@Injectable()
export class WecomWriteCall implements AiCall<WecomWriteInput, WecomWriteZ> {
readonly kind = 'script' as const;
readonly callKey = 'draft_wecom_script_write';
readonly promptVersion = 'draft_wecom_script@2026-08-04-write-v1';
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = WecomWriteSchema;
constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {}
buildPrompt(input: WecomWriteInput) {
// tier 传 'deep' 只为**挑 skill**(人群共性 / 深度档知识,与渠道无关);
// 输出形态靠第 4 个参数换成企微那份 format.md。见 composeSystem 的注释。
const composed = composeSystem(
input.ctx,
this.skillRegistry.getAllSkills(),
'deep',
wecomFormatPath(),
false, // ⛔ 企微不留【时间段】占位(整段复制直发,占位会原样发给患者)
);
return { system: composed.systemPrompt, prompt: buildWecomWritePrompt(input) };
}
}
export interface WecomVerifyInput {
ctx: ScriptContext;
draft: WecomWriteZ;
}
@Injectable()
export class WecomVerifyCall implements AiCall<WecomVerifyInput, WecomVerifyZ> {
readonly kind = 'judge' as const;
readonly callKey = 'draft_wecom_script_verify';
readonly promptVersion = 'draft_wecom_script@2026-08-04-verify-v1';
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = WecomVerifySchema;
buildPrompt(input: WecomVerifyInput) {
return { system: VERIFY_SYSTEM, prompt: buildWecomVerifyPrompt(input) };
}
}
import type { ScriptContext } from '../draft-plan-script/shared/input.types';
import { buildRichFactBlock, buildDeepExtensions } from '../draft-plan-script/shared/fact-block';
import type { WecomPlanZ, WecomWriteZ } from './schema';
/**
* 企微话术的 user prompt。
*
* ⭐ **事实块直接复用电话档那一份**(buildRichFactBlock + buildDeepExtensions)——
* 患者是谁、哪颗牙、医生说了什么、上次什么时候来的…… 这些与"用电话还是企微说"完全无关。
* ⛔ 别复制一份改改:安全护栏(不报价/福利不得加码/高龄不主推种植)全在里面,
* 复制出去之后改了一处另一处会悄悄留在旧版本,而漏了哪条要等客服发出去才发现。
*
* ⚠️ 差异全部集中在**任务段**(--- 之后):电话是"拆几段讲",企微是"排个顺序、写成一条消息"。
*/
function facts(ctx: ScriptContext): string {
const ext = buildDeepExtensions(ctx);
return ext ? `${buildRichFactBlock(ctx)}\n\n${ext}` : buildRichFactBlock(ctx);
}
/** 步骤1:要点规划 —— ⚠️ 排的是**顺序**,不是段落 */
export function buildWecomPlanPrompt(ctx: ScriptContext): string {
return `${facts(ctx)}
---
# 你的任务(本步:排要点顺序,不写正文)
这条消息是**发到患者微信里**的,他会一口气读完。规划要讲哪几点、按什么顺序讲。
按"从果(目标)倒推到因"来排:先想清这条消息要达成的果(让患者明白该回来处理本次问题),
再倒推为达成它患者需要先被讲清什么,把要点排成层层递进的推进线。
后果要客观说清、但不吓唬不推销。要点 3-6 条。
⚠️ 你排的是**讲的先后**,**不是分段** —— 最终产出是一整段连贯文字,不会有小标题。
所以别按"开场/正文/结尾"这种结构去想,想的是"先说什么才能让下一句站得住"。`;
}
/** 步骤2:写 —— 一次性单块(+ repair:上一稿 + 逐条修正约束) */
export function buildWecomWritePrompt(input: {
ctx: ScriptContext;
plan: WecomPlanZ;
prevDraft?: WecomWriteZ;
repairIssues?: Array<{ section: string; problem: string; fix: string }>;
}): string {
if (input.repairIssues?.length) {
const prev = input.prevDraft?.markdown ?? '(上一稿缺失,按要点重写并满足下列修正)';
const issues = input.repairIssues
.map((it, i) => `${i + 1}. 【${it.section}】问题:${it.problem}\n 必须改成:${it.fix}`)
.join('\n');
return `${facts(input.ctx)}
---
# 本步任务:修订(不是重写)
上一稿没通过自检。**在上一稿基础上,严格逐条改掉下面每一处问题**,改完输出完整修订稿。
## 上一稿(待修订)
${prev}
## 必须修正的问题(逐条,缺一不可)
${issues}
## 修订铁律
- 上面每一条都必须改到位,**一条都不能漏**;改法以"必须改成"为准。
- **只动被点名的地方**,其余句子保持原样,不要顺手重写或新增事实。
- 修正不得引入新的违规:不报价/不承诺疗效/≤18 岁不提拍片。
- ⛔ **不许出现任何时间占位符**(【时间段1】等),也不要自己编具体时间 ——
这条消息是整段复制直发的,占位会原样发给患者。约时间用不含具体时间的邀约。
- 仍然是**一整块可直接发送的消息**,不许出现小标题或分段编号。`;
}
const outline = input.plan.points.map((p, i) => `${i + 1}. ${p.point}(${p.why})`).join('\n');
return `${facts(input.ctx)}
---
# 本步要点顺序(按它写成一条消息,可微调措辞,不要新增事实)
${outline}
# 写的时候保持
- **一条能直接发出去的微信消息**:复制粘贴即可发送,不用再删改。
- **层层递进**:顺着要点推进,句与句承上启下,别并列罗列或跳跃。
- **后果说清但有分寸**:不处理的后果客观讲明(结合病历 + 牙科常识),让患者理解严重性;
但**不夸大、不吓唬**(别下"会掉光""很危险"式结论)、**不推销促单报价**。`;
}
/** 步骤3:独立对抗校验 —— 新开上下文,默认怀疑 */
export function buildWecomVerifyPrompt(input: { ctx: ScriptContext; draft: WecomWriteZ }): string {
return `${facts(input.ctx)}
---
# 你的任务(本步:对抗校验,不改写)
逐句核对下面这条**准备发给患者微信**的消息:
1. **接地**:每个说法能否追到上面"本次回访患者信息"里的事实?追不到 = 编造 → 记 issue。
2. **安全**:有无报价/费用、疗效承诺、≤18 提拍片?有 = 越界 → 记 issue。
⛔ 出现**任何时间占位符**(【时间段1】等)或自己编的具体时间("周三上午")= 记 issue ——
这条消息整段复制直发,占位会原样发到患者微信里。
3. **逻辑与分寸**:围绕"让患者明白该回来处理"展开、层层递进、该说的没缺(点了问题要说后果、说了后果要给出路);
有没有**吓唬/制造恐慌**或**推销/促单/施压**口吻?任一不到位 → 记 issue。
4. **患者听得懂**:有无诊断代码(如 K08)、英文/内部枚举、生硬术语,或含糊其辞(没说清哪颗牙)?有 = 记 issue。
5. ⭐ **可直接发送**(企微专有,电话档没有这一条):
- 有没有**小标题 / 分段编号 / "第一第二" / \`##\`**?企微是一条消息,出现这些 = 记 issue;
- 有没有**给客服自己看的话**混进正文(如"以下话术供参考""建议这样说")?= 记 issue;
- ⛔ 除自报家门的 \`【回访客服】\` 外,**残留任何 \`【】\` 占位或内部标签** = 记 issue。
①②③④⑤ 全部通过 → pass=true、issues 空;任一不过 → pass=false 并逐条列出(位置、问题、修法)。
## 待校验消息
${input.draft.markdown}`;
}
import { z } from 'zod';
import { ToneEnum, TONE_DESCRIBE } from '../draft-plan-script/shared/tone';
/**
* 企微话术(深度档)三步的输出 schema。
*
* ⚠️ 与电话深度档最本质的差别就在这里:**没有 `sections[]`,只有一整块 `markdown`**。
* 电话稿分段是给「伴飞」逐段高亮用的;企微是**一条发出去的消息**,分段没有意义,
* 反而会诱导模型写成"第一段…第二段…"的汇报体,复制过去很怪。
*
* ⚠️ 同样不加 .min()/.max() 硬约束 —— 理由与电话档一致(见 tiers/deep/schema.ts):
* 硬长度约束对中文偏严,模型差一点就整体 fail 走兜底;形态靠 system + describe 引导。
*/
// ── 步骤1:要点规划(⚠️ 不是"分段",是**讲的顺序**)──
export const WecomPlanSchema = z.object({
points: z
.array(
z.object({
point: z.string().describe('这一点要讲什么(一句话,口语化,**必须能追到给定患者信息/病历事实**)'),
why: z.string().describe('为什么排在这个位置——承上启下:接住上一点的什么、为下一点铺什么'),
}),
)
.describe(
'要点**顺序**(3-6 条):从果(让患者明白该回来处理)倒推到因,排成层层递进的推进线。' +
'⚠️ 这是"讲的先后",**不是分段** —— 最终产出是一整段连贯文字,不许出现小标题。',
),
});
export type WecomPlanZ = z.infer<typeof WecomPlanSchema>;
// ── 步骤2:写(一次性单块)──
export const WecomWriteSchema = z.object({
tone: ToneEnum.describe(TONE_DESCRIBE),
markdown: z
.string()
.describe(
'完整企微消息正文(约 120-400 字),**一整块、可直接复制发送**。' +
'⛔ 不要小标题、不要 `##`、不要分段编号、不要"第一/第二"、不要表情符号。' +
'按微信阅读节奏用空行断成几个短自然段;接地病历不编造;时间用【时间段】占位。',
),
});
export type WecomWriteZ = z.infer<typeof WecomWriteSchema>;
// ── 步骤3:独立对抗校验 ──
export const WecomVerifySchema = z.object({
pass: z.boolean().describe('①接地②安全③逻辑与分寸④可直接发送(无标题/无占位残留/无客服自己看的话)全过 → true'),
issues: z
.array(
z.object({
section: z.string().describe('出问题的位置(可填"开头""结尾""整体")'),
problem: z.string().describe('问题:①接地不实②安全越界(报价/承诺疗效/写死时间/≤18拍片)③逻辑与分寸(跑题/不递进/没说后果/吓唬/推销施压)④不可直接发送(出现小标题、内部标签、给客服看的说明)'),
fix: z.string().describe('修法建议(回喂改写)'),
}),
)
.describe('逐条列出有问题的点;全部 OK 则空数组'),
quality: z
.object({
natural: z.number().describe('像微信里真人发的(1-5):不书面公文、不机器腔'),
warmth: z.number().describe('关怀温度(1-5):医疗关怀感,不冷淡也不推销'),
focus: z.number().describe('聚焦(1-5):紧扣本次问题,不发散'),
nonPushy: z.number().describe('不推销(1-5):邀约自然,不促单 / 不报价 / 不施压'),
sendable: z.number().describe('可直接发送度(1-5):复制粘贴就能发,不用再删改'),
overall: z.number().describe('综合质量分(1-5,可含半分)'),
})
.describe('质量细项打分(1-5);只评质量,不影响 pass/issues'),
});
export type WecomVerifyZ = z.infer<typeof WecomVerifySchema>;
# 输出结构(一条消息,不分段)
输出 `tone` + `markdown``markdown`**一整条准备发到患者微信里的消息**
⛔ 不要 `sections`、不要小标题、不要 `##`、不要「第一/第二」、不要编号列表、不要表情符号。
⛔ 不要写任何给客服自己看的话(「以下话术供参考」「建议这样说」「话术如下」)——客服会**整段复制发送**,这些字会一起发给患者。
# 这是微信,不是电话
- **他一口气读完**:没有一来一回,你写的就是他看到的全部。所以不能有「您现在方便吗」「能听清吗」这类需要对方回话才成立的句子。
- **按阅读节奏断行**:用空行断成 3-5 个短自然段,每段 1-3 句。⛔ 别写成一大坨,微信里没人读得下去。
- **书面但不端着**:像医生助理认真打的一段字——比电话口语克制,比公文自然。不用「兹」「特此」,也不用「哈喽~」。
- **开头直接称呼 + 自报家门**,不用寒暄铺垫;**结尾留一个明确的下一步**,别用「随时联系我」这种空钩子。
# 自报家门按原样写,不要改
用给定的「自报家门」整串,其中 `【回访客服】` 原样保留(系统按登录人回填成「助理X」)——别替换成具体姓名,也别自己编一个;身份是**医生的助理**,不要改写成「客服/顾问」。
# ⛔⛔ 不许出现任何时间占位符
**不要写 `【时间段1】【时间段2】【具体预约时间】`,一个都不许出现。**
⛔ 也不要自己编具体时间(「周三上午」「本周末」),更不要「已为您约好」式承诺。
**为什么**:电话里客服是边说边填时间的;企微这条消息他是**整段复制直接发出去**的——
留个 `【时间段1】` 在里面,他要么忘了改直接发给患者(患者收到一句带方括号的乱码),
要么得先手动编辑一遍,而"可直接复制发送"当场就不成立了。
**那约时间怎么办**:把决定权交回给患者,用**不含任何具体时间**的邀约收尾,例如
「您方便的时候回我一下,我帮您安排李医生的号」「您看这周哪天方便,我这边给您留时间」。
⚠️ 说不清楚的就别说 —— 宁可只说「回我一下我帮您约」,也不要摆一个你并不知道的时间。
# 写的内容
- **病种措辞自供**:风险与「趁早处理的好处」结合下方病历(检查所见/医嘱/建议)+ 牙科常识,用自己的话讲清;医生没记录的别编。
- **把后果说清、有分寸**:不处理的后果要**客观说明**让患者理解严重性,但**不夸大、不吓唬**(别下「会掉光」「很危险」式结论)、**不推销促单报价**——为患者着想地讲,不是吓他/催他。
- **层层递进**:顺着给定的要点顺序推进,句与句承上启下,别并列罗列或跳跃重复。
- **事实朴素取用**:患者信息以朴素中文标签直接给(称呼/本次问题/牙位/诊断医生/最近一次就诊…),自然用进话里;除上面要求原样保留的 `【】` 外,不写占位符、不留标签字样。
...@@ -195,11 +195,13 @@ export class PlanScriptOrchestrator { ...@@ -195,11 +195,13 @@ export class PlanScriptOrchestrator {
let planScriptId: string | null = null; let planScriptId: string | null = null;
if (!options.dryRun) { if (!options.dryRun) {
const row = await this.prisma.planScript.upsert({ const row = await this.prisma.planScript.upsert({
where: { planId: plan.id }, where: { planId_channel: { planId: plan.id, channel: 'phone' } },
create: { create: {
hostId: plan.hostId, hostId: plan.hostId,
tenantId: plan.tenantId, tenantId: plan.tenantId,
planId: plan.id, planId: plan.id,
channel: 'phone', // 显式写死:同表现在还存企微稿,别依赖列默认值
content, content,
status: 'ready', status: 'ready',
source, source,
...@@ -285,11 +287,13 @@ export class PlanScriptOrchestrator { ...@@ -285,11 +287,13 @@ export class PlanScriptOrchestrator {
let planScriptId: string | null = null; let planScriptId: string | null = null;
if (!options.dryRun) { if (!options.dryRun) {
const row = await this.prisma.planScript.upsert({ const row = await this.prisma.planScript.upsert({
where: { planId: plan.id }, where: { planId_channel: { planId: plan.id, channel: 'phone' } },
create: { create: {
hostId: plan.hostId, hostId: plan.hostId,
tenantId: plan.tenantId, tenantId: plan.tenantId,
planId: plan.id, planId: plan.id,
channel: 'phone', // 显式写死:同表现在还存企微稿,别依赖列默认值
content, content,
status: 'ready', status: 'ready',
source: r.source, source: r.source,
...@@ -404,11 +408,13 @@ export class PlanScriptOrchestrator { ...@@ -404,11 +408,13 @@ export class PlanScriptOrchestrator {
let planScriptId: string | null = null; let planScriptId: string | null = null;
if (!dryRun) { if (!dryRun) {
const row = await this.prisma.planScript.upsert({ const row = await this.prisma.planScript.upsert({
where: { planId: plan.id }, where: { planId_channel: { planId: plan.id, channel: 'phone' } },
create: { create: {
hostId: plan.hostId, hostId: plan.hostId,
tenantId: plan.tenantId, tenantId: plan.tenantId,
planId: plan.id, planId: plan.id,
channel: 'phone', // 显式写死:同表现在还存企微稿,别依赖列默认值
content, content,
status: 'ready', status: 'ready',
source, source,
...@@ -442,7 +448,12 @@ export class PlanScriptOrchestrator { ...@@ -442,7 +448,12 @@ export class PlanScriptOrchestrator {
private async loadPlanContext(planId: string) { private async loadPlanContext(planId: string) {
const plan = await this.prisma.followupPlan.findUnique({ const plan = await this.prisma.followupPlan.findUnique({
where: { id: planId }, where: { id: planId },
include: { reasons: { orderBy: { priorityScore: 'desc' }, take: 3 } }, include: {
reasons: { orderBy: { priorityScore: 'desc' }, take: 3 },
// ⭐ 本批次的福利要进话术(T4)。⚠️ 只认 **confirmed** 的批次 ——
// 撤销后的批次福利已不适用,带进去就是对患者念一个作废的优惠。
assignment: { select: { status: true, attributes: true, expiresAt: true } },
},
}); });
if (!plan) throw new NotFoundException(`Plan ${planId} not found`); if (!plan) throw new NotFoundException(`Plan ${planId} not found`);
...@@ -601,6 +612,12 @@ export class PlanScriptOrchestrator { ...@@ -601,6 +612,12 @@ export class PlanScriptOrchestrator {
// 临时:hardcoded jvs-dw 诊所字典(TODO #56 接 host 字典或新建 clinics 表) // 临时:hardcoded jvs-dw 诊所字典(TODO #56 接 host 字典或新建 clinics 表)
// ⚠️ 直接吐 UUID 进 prompt 会让 LLM 编造"XX 客服中心",必须翻译成中文名 // ⚠️ 直接吐 UUID 进 prompt 会让 LLM 编造"XX 客服中心",必须翻译成中文名
clinicName: resolveClinicName(plan.targetClinicId), clinicName: resolveClinicName(plan.targetClinicId),
// 福利:只有批次仍 confirmed 时才带。⚠️ 不判 expiresAt —— 时效是"客服什么时候该打完",
// 不是"福利什么时候失效";福利本身的有效期写在文案里(如"8月…"),由主管负责。
benefit:
plan.assignment?.status === 'confirmed'
? readBenefitText(plan.assignment.attributes)
: null,
plan: { plan: {
primaryScenarioLabel: plan.reasons[0] primaryScenarioLabel: plan.reasons[0]
? planScenarioLabel(plan.reasons[0].scenario) ? planScenarioLabel(plan.reasons[0].scenario)
...@@ -1085,7 +1102,8 @@ function countCompletedTreatments(facts: FactRow[]): number { ...@@ -1085,7 +1102,8 @@ function countCompletedTreatments(facts: FactRow[]): number {
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
type PlanWithReasons = Prisma.FollowupPlanGetPayload<{ type PlanWithReasons = Prisma.FollowupPlanGetPayload<{
include: { reasons: true }; // assignment:本批次的福利要进话术(T4);select 收窄,别把整个批次拖进来
include: { reasons: true; assignment: { select: { status: true; attributes: true; expiresAt: true } } };
}>; }>;
type PatientRow = Prisma.PatientGetPayload<{}>; type PatientRow = Prisma.PatientGetPayload<{}>;
...@@ -1095,3 +1113,14 @@ type PersonaWithFeatures = Prisma.PersonaGetPayload<{ ...@@ -1095,3 +1113,14 @@ type PersonaWithFeatures = Prisma.PersonaGetPayload<{
}>; }>;
type FactRow = Prisma.PatientFactGetPayload<{}>; type FactRow = Prisma.PatientFactGetPayload<{}>;
/**
* 批次福利文本。⚠️ 别在多处 `as any` 解这个 JSON —— 解错了不报错,只是福利段静默消失。
* (同名函数在 plan-assignment.service 里也有一份:那边是写路径、这边是读路径,
* 刻意各自持有,避免为一个三行函数在两个模块间建依赖。改口径时两处都要改。)
*/
function readBenefitText(attributes: unknown): { text: string } | null {
const a = attributes as { benefit?: { text?: string } } | null | undefined;
const t = a?.benefit?.text;
return typeof t === 'string' && t.trim() ? { text: t.trim() } : null;
}
import { Injectable, Logger } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../../../prisma/prisma.service';
import { PlanScriptOrchestrator } from './plan-script.orchestrator';
import { WecomScriptStrategy } from '../calls/draft-wecom-script/wecom.strategy';
export interface WecomScriptGenerateResult {
planId: string;
planScriptId: string | null;
agentInvocationId: string;
source: 'agent' | 'failed';
content: string;
costYuan: number;
failReason?: string;
}
/**
* 企微话术编排 —— 读 plan/persona/facts → 跑 3 步 → 写 `plan_scripts(channel='wecom')`。
*
* ⭐ **上下文装配整段复用电话档的 `buildScriptInputForPlan`**:
* 患者是谁、哪颗牙、医生说了什么、上次什么时候来的、福利是什么 —— 与渠道无关。
* ⛔ 别在这里另写一遍取数:那意味着两条链路对"患者事实"各有一套理解,
* 而它们一定会漂(一边加了字段另一边没加,表现是企微稿比电话稿少提一颗牙,还不报错)。
*
* ⚠️ 与电话档另一处不同:**失败不写 `ready`**。
* 电话档失败会落模板兜底稿(客服拿着电话必须有东西念);企微稿是要**原样发给患者**的,
* 落一份套话让他复制发出去比没有更糟 —— 所以失败就写 `failed`,前端显示"生成失败,请手写"。
*/
@Injectable()
export class WecomScriptOrchestrator {
private readonly logger = new Logger(WecomScriptOrchestrator.name);
constructor(
private readonly prisma: PrismaService,
private readonly planScripts: PlanScriptOrchestrator,
private readonly strategy: WecomScriptStrategy,
) {}
/**
* 流式生成 —— yield 步骤事件,最后落库并 yield done。
*
* ⭐ 事件形状与电话深度档一致(`{type:'step'|'done'}`),前端整套复用:
* ScriptDeepProcess 时间线、停止按钮、AIStamp。⛔ 不为企微另造一套交互。
*/
async *generateStream(
planId: string,
options: { modelIdOverride?: string; signal?: AbortSignal } = {},
): AsyncGenerator<
| { type: 'step'; step: string; status: 'running' | 'done'; detail?: unknown }
| { type: 'done'; content: string; source: 'agent' | 'failed'; invocationId: string; failReason?: string }
> {
const { ctx, plan } = await this.load(planId);
const runCtx = this.runCtx(plan, options);
let result: WecomScriptGenerateResult | null = null;
for await (const ev of this.strategy.runStream(ctx, runCtx)) {
if (ev.kind === 'step') {
yield { type: 'step', step: ev.step, status: ev.status, detail: ev.detail };
} else {
result = await this.persist(plan, ev.result);
}
}
yield {
type: 'done',
content: result?.content ?? '',
source: result?.source ?? 'failed',
invocationId: result?.agentInvocationId ?? '',
...(result?.failReason ? { failReason: result.failReason } : {}),
};
}
async generate(
planId: string,
options: { modelIdOverride?: string; signal?: AbortSignal } = {},
): Promise<WecomScriptGenerateResult> {
const { ctx, plan } = await this.load(planId);
const r = await this.strategy.run(ctx, this.runCtx(plan, options));
return this.persist(plan, r);
}
/**
* ⭐ 上下文装配整段复用电话档的 `buildScriptInputForPlan`(见类注释)。
* ⛔ 别在这里另写一遍取数:两条链路对"患者事实"各有一套理解就一定会漂
* (一边加了字段另一边没加,表现是企微稿比电话稿少提一颗牙,还不报错)。
*/
private async load(planId: string) {
const ctx = await this.planScripts.buildScriptInputForPlan(planId);
const plan = await this.prisma.followupPlan.findUniqueOrThrow({
where: { id: planId },
select: { id: true, hostId: true, tenantId: true, patientId: true },
});
return { ctx, plan };
}
private runCtx(
plan: { id: string; hostId: string; tenantId: string; patientId: string },
options: { modelIdOverride?: string; signal?: AbortSignal },
) {
return {
hostId: plan.hostId,
tenantId: plan.tenantId,
linkedPatientId: plan.patientId,
linkedPlanId: plan.id,
// 一次生成的 3 步(plan/write/verify)用同一个 runId 串起来,eval 里能按次回看
workflowRunId: randomUUID(),
bustCache: true,
modelIdOverride: options.modelIdOverride,
evalMode: 'production' as const,
signal: options.signal,
};
}
/**
* 落库 —— ⚠️ 失败**不写 ready**:企微稿是要原样发给患者的,
* 落一份套话让他复制发出去比没有更糟。失败就写 failed,前端显示"生成失败,请手写"。
*/
private async persist(
plan: { id: string; hostId: string; tenantId: string },
r: { markdown: string; source: 'agent' | 'failed'; invocationId: string; costYuan: number; stepsRun: string[]; failReason?: string },
): Promise<WecomScriptGenerateResult> {
const ok = r.source === 'agent' && r.markdown.trim().length > 0;
const row = await this.prisma.planScript.upsert({
where: { planId_channel: { planId: plan.id, channel: 'wecom' } },
create: {
hostId: plan.hostId,
tenantId: plan.tenantId,
planId: plan.id,
channel: 'wecom',
content: ok ? r.markdown : null,
status: ok ? 'ready' : 'failed',
source: ok ? 'agent' : null,
agentInvocationId: r.invocationId || null,
},
update: {
content: ok ? r.markdown : null,
status: ok ? 'ready' : 'failed',
source: ok ? 'agent' : null,
agentInvocationId: r.invocationId || null,
},
select: { id: true },
});
if (!ok) {
this.logger.warn(
`企微话术生成失败 plan=${plan.id}: ${r.failReason ?? '未知'};步骤=${r.stepsRun.join('→')}`,
);
}
return {
planId: plan.id,
planScriptId: row.id,
agentInvocationId: r.invocationId,
source: r.source,
content: ok ? r.markdown : '',
costYuan: r.costYuan,
...(r.failReason ? { failReason: r.failReason } : {}),
};
}
}
...@@ -5,6 +5,10 @@ import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagg ...@@ -5,6 +5,10 @@ import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagg
import type { ModelMessage } from 'ai'; import type { ModelMessage } from 'ai';
import { AssistantService } from './assistant.service'; import { AssistantService } from './assistant.service';
import { TranscribeService } from './transcribe.service'; import { TranscribeService } from './transcribe.service';
import { CurrentUser, type AuthenticatedUser } from '../../common/decorators/current-user.decorator';
import { TenantScope } from '../../common/decorators/tenant-scope.decorator';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { buildSystemExtra } from './assistant-prompts';
/// multer 内存模式的最小文件形状(不引 @types/multer) /// multer 内存模式的最小文件形状(不引 @types/multer)
interface UploadedAudio { interface UploadedAudio {
...@@ -119,7 +123,13 @@ export class AssistantController { ...@@ -119,7 +123,13 @@ export class AssistantController {
@Post('chat') @Post('chat')
@ApiOperation({ summary: '助手对话(SSE)— 模型自主调 PAC MCP 工具' }) @ApiOperation({ summary: '助手对话(SSE)— 模型自主调 PAC MCP 工具' })
async chat(@Req() req: Request, @Res() res: Response, @Body() body: ChatBody): Promise<void> { async chat(
@Req() req: Request,
@Res() res: Response,
@Body() body: ChatBody,
@CurrentUser() user: AuthenticatedUser,
@TenantScope() scope: TenantScopeContext,
): Promise<void> {
const token = (req.headers['authorization'] ?? '').replace(/^Bearer\s+/i, ''); const token = (req.headers['authorization'] ?? '').replace(/^Bearer\s+/i, '');
res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Content-Type', 'text/event-stream');
...@@ -145,6 +155,19 @@ export class AssistantController { ...@@ -145,6 +155,19 @@ export class AssistantController {
userToken: token, userToken: token,
modelId: body.model, modelId: body.model,
messages: body.messages ?? [], messages: body.messages ?? [],
// 只用于 MCP 工具清单的缓存分桶(见 mcpCapabilityKey)——
// 不传的话主管和客服会共用一份缓存,谁先进来谁的清单被全员复用。
permissions: user.permissions,
// ⭐ 侧信道:本地工具把确认单肥载荷直接推前端,不经模型上下文(见 AssistantChatInput)
onSideEvent: send,
scope,
// ⭐ 按能力切工作流约束(主管 / 客服两套)。
// ⚠️ 在此之前这个参数**从来没被 /assistant/chat 传过** ——
// 「一个助手,按角色切换工作流约束」这条既定取舍此前是零实现。
systemExtra: buildSystemExtra({
permissions: user.permissions,
userName: user.dictionary?.users?.[user.sub] ?? null,
}),
abortSignal: ac.signal, abortSignal: ac.signal,
}); });
......
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AiModule } from '../ai/ai.module'; import { AiModule } from '../ai/ai.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { PlanModule } from '../plan/plan.module';
import { AssistantController } from './assistant.controller'; import { AssistantController } from './assistant.controller';
import { AssistantService } from './assistant.service'; import { AssistantService } from './assistant.service';
import { McpClientService } from './mcp-client.service'; import { McpClientService } from './mcp-client.service';
...@@ -13,7 +14,8 @@ import { DictationGateway } from './dictation.gateway'; ...@@ -13,7 +14,8 @@ import { DictationGateway } from './dictation.gateway';
* 复用 AiModule 的 AiProviderService(provider 可切换);McpClientService 真连 PAC MCP 端点。 * 复用 AiModule 的 AiProviderService(provider 可切换);McpClientService 真连 PAC MCP 端点。
*/ */
@Module({ @Module({
imports: [AiModule, AuthModule], // PlanModule:本地工具 propose_assignment 用 AssignmentProposalService 取数
imports: [AiModule, AuthModule, PlanModule],
controllers: [AssistantController], controllers: [AssistantController],
providers: [AssistantService, McpClientService, TranscribeService, DictationGateway], providers: [AssistantService, McpClientService, TranscribeService, DictationGateway],
exports: [AssistantService], // 供 WeixinAibotModule 等其它入口复用同一个助手大脑 exports: [AssistantService], // 供 WeixinAibotModule 等其它入口复用同一个助手大脑
......
...@@ -21,8 +21,21 @@ export class McpClientService { ...@@ -21,8 +21,21 @@ export class McpClientService {
// 异常拓扑(反代/独立部署)时用 PAC_MCP_URL 覆盖。 // 异常拓扑(反代/独立部署)时用 PAC_MCP_URL 覆盖。
private readonly url = private readonly url =
process.env.PAC_MCP_URL ?? `http://127.0.0.1:${process.env.PORT ?? '3001'}/pac/v1/mcp`; process.env.PAC_MCP_URL ?? `http://127.0.0.1:${process.env.PORT ?? '3001'}/pac/v1/mcp`;
// 工具清单是静态的(6 个工具与租户无关,scope 只影响执行)→ 进程级缓存,省每轮 tools/list 往返。 /**
private toolsCache: McpToolDef[] | null = null; * 工具清单缓存 —— **按能力分桶**,不是一个全局清单。
*
* ⚠️ 原实现是 `McpToolDef[] | null` 单例。工具清单当时确实与调用人无关(全只读、全员一样),
* 但分配功能要按 `plan:dispatch` **条件注册**工具(主管多几个)——
* 单例缓存这时会当场串号:进程重启后第一个进来的若是客服,
* 缓存下客服的短清单,**全公司的主管在缓存失效前都拿不到分配工具**;
* 反过来更糟,客服会拿到主管的工具清单。
* 而且两种错法都**不报错**:模型只会说"我没有这个能力",或者调了工具被服务端拒绝。
*
* key 用调用方给的**能力指纹**(如 'dispatch' / 'basic')。
* ⚠️ 刻意不在本类里解 JWT 算指纹:这是个纯 HTTP 客户端,让它认识权限模型
* 就等于把权限判定散到第二个地方去。谁调谁负责给 key。
*/
private readonly toolsCache = new Map<string, McpToolDef[]>();
private async rpc(token: string, method: string, params?: unknown): Promise<Record<string, unknown>> { private async rpc(token: string, method: string, params?: unknown): Promise<Record<string, unknown>> {
const res = await fetch(this.url, { const res = await fetch(this.url, {
...@@ -42,11 +55,18 @@ export class McpClientService { ...@@ -42,11 +55,18 @@ export class McpClientService {
return (msg.result ?? {}) as Record<string, unknown>; return (msg.result ?? {}) as Record<string, unknown>;
} }
async listTools(token: string): Promise<McpToolDef[]> { /**
if (this.toolsCache) return this.toolsCache; * @param capabilityKey 能力指纹 —— **相同 key 的人必须拿到相同的工具清单**。
* 由调用方按权限算(见 assistant.service.ts 的 mcpCapabilityKey)。
* 传 `undefined` 会退回单一全局桶,只在确定工具清单与权限无关时才这么用。
*/
async listTools(token: string, capabilityKey = 'default'): Promise<McpToolDef[]> {
const hit = this.toolsCache.get(capabilityKey);
if (hit) return hit;
const r = await this.rpc(token, 'tools/list'); const r = await this.rpc(token, 'tools/list');
this.toolsCache = (r.tools as McpToolDef[]) ?? []; const tools = (r.tools as McpToolDef[]) ?? [];
return this.toolsCache; this.toolsCache.set(capabilityKey, tools);
return tools;
} }
/** 调工具 → 返回纯文本结果(MCP content[].text 拼接);isError 时抛出供 agent 看到。 */ /** 调工具 → 返回纯文本结果(MCP content[].text 拼接);isError 时抛出供 agent 看到。 */
......
...@@ -128,7 +128,11 @@ export class AuthController { ...@@ -128,7 +128,11 @@ export class AuthController {
hostId: user.hostId, hostId: user.hostId,
tenantId: user.tenantId, tenantId: user.tenantId,
role: user.role, role: user.role,
permissions: user.permissions, // ⭐ 按 role 现算,不回传 JWT 里那份快照 —— 与 PermissionsGuard 同源(见那里的长注释)。
// 前端 auth-store.loadSession 会 merge 这个字段,所以发版新增权限后**不必重登**:
// 下一次 loadSession 就把新权限带回来,与服务端判定口径一致。
// 回传 user.permissions 则前后端会一起停在旧清单上,merge 也救不了。
permissions: this.auth.resolvePermissions(user.role),
orgScope: user.orgScope, orgScope: user.orgScope,
clinicIds: scope.clinicIds, // 拦截器已按 host org 树展开 clinicIds: scope.clinicIds, // 拦截器已按 host org 树展开
sourceUnits: scope.sourceUnits, sourceUnits: scope.sourceUnits,
......
...@@ -53,7 +53,8 @@ export class PotentialTreatmentSelector { ...@@ -53,7 +53,8 @@ export class PotentialTreatmentSelector {
sig.type AS signal_type, sig.type AS signal_type,
${gap.toothOutput} AS tooth, ${gap.toothOutput} AS tooth,
sig.content->>'confidence' AS confidence, sig.content->>'confidence' AS confidence,
EXTRACT(DAY FROM ${now}::timestamptz - COALESCE(sig.occurred_at, sig.planned_for))::int AS days_since EXTRACT(DAY FROM ${now}::timestamptz - COALESCE(sig.occurred_at, sig.planned_for))::int AS days_since,
COALESCE(sig.occurred_at, sig.planned_for) AS anchor_at
FROM patients p FROM patients p
JOIN patient_facts sig ON sig.patient_id = p.id JOIN patient_facts sig ON sig.patient_id = p.id
${gap.lateralJoin} ${gap.lateralJoin}
...@@ -77,6 +78,7 @@ export class PotentialTreatmentSelector { ...@@ -77,6 +78,7 @@ export class PotentialTreatmentSelector {
nameZh: r.name_zh ?? null, nameZh: r.name_zh ?? null,
tooth: r.tooth ?? null, tooth: r.tooth ?? null,
daysSince: r.days_since, daysSince: r.days_since,
anchorAt: r.anchor_at,
signalType: r.signal_type === 'recommendation_record' ? 'recommendation' : 'diagnosis', signalType: r.signal_type === 'recommendation_record' ? 'recommendation' : 'diagnosis',
confidence: r.confidence confidence: r.confidence
? Number(r.confidence) ? Number(r.confidence)
...@@ -97,6 +99,14 @@ export interface PotentialGap { ...@@ -97,6 +99,14 @@ export interface PotentialGap {
nameZh: string | null; // 诊断中文名(K03 拆 拔牙/修复 用) nameZh: string | null; // 诊断中文名(K03 拆 拔牙/修复 用)
tooth: string | null; // 剩余未治牙位(';' 分隔;全口码为 null) tooth: string | null; // 剩余未治牙位(';' 分隔;全口码为 null)
daysSince: number; daysSince: number;
/**
* 信号发生时刻(诊断 occurred_at / 推荐 planned_for)—— **不可变锚点**。
*
* 与 `daysSince` 的分工同 `ReasonSignals.signalOccurredAt` / `daysSince` 那一对:
* 天数是算出来那一刻的快照(会陈旧),锚点是事实(不会)。窗口温度只认锚点 ——
* 见 `@pac/types` 的 `gapTemperatureBounds`,以及那里「存边界时刻不存天数」的整段理由。
*/
anchorAt: Date;
signalType: 'diagnosis' | 'recommendation'; signalType: 'diagnosis' | 'recommendation';
confidence: number; // 诊断 1.0 / 建议 0.8 confidence: number; // 诊断 1.0 / 建议 0.8
} }
...@@ -109,4 +119,5 @@ interface RawGapRow { ...@@ -109,4 +119,5 @@ interface RawGapRow {
tooth: string | null; tooth: string | null;
confidence: string | null; confidence: string | null;
days_since: number; days_since: number;
anchor_at: Date;
} }
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import type { AccessTokenPayload } from '@pac/types'; import type { AccessTokenPayload } from '@pac/types';
import { ROLE_PERMISSIONS } from '@pac/types';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { OrgTreeService } from '../auth/org-tree'; import { OrgTreeService } from '../auth/org-tree';
export interface McpAuthContext { export interface McpAuthContext {
scope: TenantScopeContext; scope: TenantScopeContext;
permissions: string[]; permissions: string[];
/**
* 登录人姓名(可空)。
* ⚠️ 加它**不违 T19**:T19 说的是"权限判定只认 permission,不下传 role" ——
* 姓名是**显示串**不是判据,助手要能说"张主管,这批要分给…"而不是对着一个 uuid 说话。
* ⛔ 但绝不要顺手把 role 也带下来:一旦模型看得见 role,它就会自己发明
* "leader 应该也能 X" 这类规则,而那不是权限模型说了算的。
*/
userName?: string;
} }
/** /**
...@@ -50,7 +59,18 @@ export class McpAuthService { ...@@ -50,7 +59,18 @@ export class McpAuthService {
sourceUnits, sourceUnits,
userId: payload.sub, userId: payload.sub,
}, },
permissions: payload.permissions ?? [], // ⭐ 按 role 现算,与 PermissionsGuard / GET /auth/session 同源(见 permissions.guard 长注释)。
// MCP 这条路尤其要现算:工具清单是按能力**条件注册**的,权限少一个 = 助手手里少几个工具,
// 模型只会说"我没有这个能力",既不报错也看不出是 token 过期。
permissions: ROLE_PERMISSIONS[payload.role] ?? payload.permissions ?? [],
// 宿主换票时把姓名放进 dictionary.users[sub];没有就留空,助手会退回"你"
...(resolveUserName(payload) ? { userName: resolveUserName(payload)! } : {}),
}; };
} }
} }
/** 从 JWT 字典里取当前登录人姓名(宿主换票时带;取不到返回 undefined) */
function resolveUserName(payload: AccessTokenPayload): string | undefined {
const n = payload.dictionary?.users?.[payload.sub];
return typeof n === 'string' && n.trim() ? n : undefined;
}
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey } from '@pac/types'; import {
PersonaFeatureKey,
gapTemperatureBounds,
hottestBounds,
type TemperatureBounds,
} from '@pac/types';
import type { import type {
FeatureExtractor, FeatureExtractor,
FeatureExtractorContext, FeatureExtractorContext,
...@@ -23,6 +28,10 @@ import { nextAgeBoundary } from './time-boundary'; ...@@ -23,6 +28,10 @@ import { nextAgeBoundary } from './time-boundary';
* 修复←K03(默认)· 拔牙←K01 + K03(name 含 残根/残冠/无法保留/不能保留) * 修复←K03(默认)· 拔牙←K01 + K03(name 含 残根/残冠/无法保留/不能保留)
* K00 发育 / K09 囊肿 不在业务 8 标签 → 不出此标签(召回仍覆盖)。 * K00 发育 / K09 囊肿 不在业务 8 标签 → 不出此标签(召回仍覆盖)。
* *
* ⭐ **本特征同时是初选矩阵的两根轴**:X 轴 = `data.types`(8 标签),
* Y 轴 = `data.detail[].hotUntil / warmUntil`(窗口温度边界,读时定档)。
* 温度为什么这么存、为什么逐条 gap 各判各的窗,见 `@pac/types` 的 temperature.ts 文件头。
*
* 业务 spec 对账: * 业务 spec 对账:
* - 置信度(病历100%/影像AI 70-90%/客服勾选 50-70%)= PAC diagnosis=1.0 / recommendation=0.8(已建模)。 * - 置信度(病历100%/影像AI 70-90%/客服勾选 50-70%)= PAC diagnosis=1.0 / recommendation=0.8(已建模)。
* - Step3 主诉意愿加分 = 排序事,消费方自算(score 弃用原则,不进标签)。 * - Step3 主诉意愿加分 = 排序事,消费方自算(score 弃用原则,不进标签)。
...@@ -89,9 +98,24 @@ export class PotentialTreatmentFeatureExtractor implements FeatureExtractor { ...@@ -89,9 +98,24 @@ export class PotentialTreatmentFeatureExtractor implements FeatureExtractor {
const age = ageYearsAt(ctx.patient.birthDate, ctx.now); const age = ageYearsAt(ctx.patient.birthDate, ctx.now);
// 按业务标签聚合:teeth 并集 / daysSince 取最大(最早需求)/ confidence 取最大 / 来源 // 按业务标签聚合:teeth 并集 / daysSince 取最大(最早需求)/ confidence 取最大 / 来源
//
// ⚠️⚠️ **两个方向相反的聚合并存,别把它们统一掉**:
// · `daysSince` 取 **max** = 「这个机会挂了多久」—— 话术勾子用(「您去年查出的龋齿一直没补」)
// · 温度边界取 **最热** = 「现在该不该打」—— 初选矩阵 Y 轴用
// 拿 max(daysSince) 去判温度会得到反单调的结论(多一条旧需求 = 更冷),
// 实测热少报 40-70%。整段推理见 @pac/types 的 temperature.ts 文件头。
const agg = new Map< const agg = new Map<
string, string,
{ zh: string; teeth: Set<string>; daysSince: number; confidence: number; hasDx: boolean; hasRec: boolean } {
zh: string;
teeth: Set<string>;
daysSince: number;
confidence: number;
hasDx: boolean;
hasRec: boolean;
/** 逐条 gap 按**自己 K 码**的窗口算出的边界,聚合时取最热 */
bounds: Array<TemperatureBounds | null>;
}
>(); >();
const factIds = new Set<string>(); const factIds = new Set<string>();
for (const g of gaps) { for (const g of gaps) {
...@@ -100,10 +124,22 @@ export class PotentialTreatmentFeatureExtractor implements FeatureExtractor { ...@@ -100,10 +124,22 @@ export class PotentialTreatmentFeatureExtractor implements FeatureExtractor {
factIds.add(g.factId); factIds.add(g.factId);
const cur = const cur =
agg.get(lbl.key) ?? agg.get(lbl.key) ??
{ zh: lbl.zh, teeth: new Set<string>(), daysSince: 0, confidence: 0, hasDx: false, hasRec: false }; {
zh: lbl.zh,
teeth: new Set<string>(),
daysSince: 0,
confidence: 0,
hasDx: false,
hasRec: false,
bounds: [] as Array<TemperatureBounds | null>,
};
for (const t of (g.tooth ?? '').split(';').map((s) => s.trim()).filter(Boolean)) cur.teeth.add(t); for (const t of (g.tooth ?? '').split(';').map((s) => s.trim()).filter(Boolean)) cur.teeth.add(t);
cur.daysSince = Math.max(cur.daysSince, g.daysSince); cur.daysSince = Math.max(cur.daysSince, g.daysSince);
cur.confidence = Math.max(cur.confidence, g.confidence); cur.confidence = Math.max(cur.confidence, g.confidence);
// ⭐ 用 g.primaryCode 而不是 g.code:挖 gap 时用的就是 lookupDxTreatment(primaryCode)
// (见 potential-treatment.selector),温度必须跟它同一条规则,否则 K08 与
// IMPLANT_RECOMMENDED 会各判各的窗,同一个标签内部就先自相矛盾了。
cur.bounds.push(gapTemperatureBounds(g.primaryCode, g.anchorAt));
if (g.signalType === 'diagnosis') cur.hasDx = true; if (g.signalType === 'diagnosis') cur.hasDx = true;
else cur.hasRec = true; else cur.hasRec = true;
agg.set(lbl.key, cur); agg.set(lbl.key, cur);
...@@ -127,11 +163,30 @@ export class PotentialTreatmentFeatureExtractor implements FeatureExtractor { ...@@ -127,11 +163,30 @@ export class PotentialTreatmentFeatureExtractor implements FeatureExtractor {
}; };
}); });
/**
* ⭐ 窗口温度的边界时刻,**按标签 key 建映射,不塞进 detail[]**。
*
* 为什么不放 detail[]:那是**数组**,同一个标签在不同患者身上的下标不一样,
* 于是筛选路径 `detail.0.hotUntil` 对谁都不成立 —— Prisma 的 json 路径过滤
* (以及任何索引)都要求**稳定路径**。挪到 `temperature.<key>.hotUntil` 之后,
* 召回池列表可以直接用现成的 Prisma where 按温度筛(`plan.service.buildListWhere`),
* 矩阵与列表因此共用同一个引擎、天然对得上数(T14 的「口径对数」)。
*
* **不是易变键** —— 同一批事实换个时刻重算值不变,所以 ⛔ 别加进
* persona-diff 的 VOLATILE_DATA_KEYS(加了就永远不会因为"新来一条诊断把温度顶热了"
* 而升版本,等于把这次修的冻结原样搬到另一层)。档位在**读时**由 classifyTemperature 现算。
*/
const temperature: Record<string, { hotUntil: string; warmUntil: string }> = {};
for (const k of keys) {
const hot = hottestBounds(agg.get(k)!.bounds);
if (hot) temperature[k] = hot;
}
return { return {
key: this.key, key: this.key,
description: labels.join(' / '), description: labels.join(' / '),
score: null, score: null,
data: { types: keys, labels, detail }, data: { types: keys, labels, detail, temperature },
evidence: { factIds: [...factIds] }, evidence: { factIds: [...factIds] },
}; };
} }
......
...@@ -147,6 +147,10 @@ export class PlanAggregateService { ...@@ -147,6 +147,10 @@ export class PlanAggregateService {
// W4:话术从 DB 加载(LLM 流式生成完会 upsert 到 plan_scripts) // W4:话术从 DB 加载(LLM 流式生成完会 upsert 到 plan_scripts)
// 没生成过 → script=null,前端走 mock 兜底 // 没生成过 → script=null,前端走 mock 兜底
const scriptRow = plan ? await this.loadPlanScript(plan.id) : null; const scriptRow = plan ? await this.loadPlanScript(plan.id) : null;
// ⚠️ 企微稿**独立一行**(channel='wecom'),与电话稿各有各的 status ——
// ⛔ 别在这里做"企微没有就回落电话稿":那份是口语分段的,复制发给患者很怪,
// 而客服不会注意到自己发错了东西。没有就是 null,前端如实显示。
const wecomRow = plan ? await this.loadPlanScript(plan.id, 'wecom') : null;
// ⭐ 落库正文里的自报家门是占位符【回访客服】,读出来按**当前登录人**回填 —— 缓存 per-plan、 // ⭐ 落库正文里的自报家门是占位符【回访客服】,读出来按**当前登录人**回填 —— 缓存 per-plan、
// 召回池共享,烤进人名会让后开的客服看到别人的名字(见 agent-identity.ts) // 召回池共享,烤进人名会让后开的客服看到别人的名字(见 agent-identity.ts)
const script = scriptRow const script = scriptRow
...@@ -239,6 +243,20 @@ export class PlanAggregateService { ...@@ -239,6 +243,20 @@ export class PlanAggregateService {
chains, chains,
facts: facts.map(serializeFact), facts: facts.map(serializeFact),
script: script ? serializeScript(script) : null, script: script ? serializeScript(script) : null,
/**
* 企微稿 —— ⛔ **不能走 `serializeScript`**(踩过:前端拿到 content 长度为 0)。
* 那个序列化器会把正文 `parseScriptMarkdownToSections` 拆成段、**并丢掉原文** ——
* 电话稿要的就是段,而企微稿**只有原文**,拆完就什么都不剩了。
* 这里原样透出 content(仍要回填【回访客服】占位:落库存的是占位,读时按登录人渲染)。
*/
wecomScript: wecomRow
? {
id: wecomRow.id,
status: wecomRow.status,
content: renderAgentIdentity(wecomRow.content, agent),
updatedAt: wecomRow.updatedAt.toISOString(),
}
: null,
recallHistory, recallHistory,
returnVisits: (patient.returnVisits ?? []).map((r) => ({ returnVisits: (patient.returnVisits ?? []).map((r) => ({
taskDate: r.taskDate ? r.taskDate.toISOString().slice(0, 10) : null, taskDate: r.taskDate ? r.taskDate.toISOString().slice(0, 10) : null,
...@@ -286,9 +304,9 @@ export class PlanAggregateService { ...@@ -286,9 +304,9 @@ export class PlanAggregateService {
* W4:加载该 plan 的最新 ready 话术(LLM 生成完会 upsert 进 plan_scripts)。 * W4:加载该 plan 的最新 ready 话术(LLM 生成完会 upsert 进 plan_scripts)。
* pending/failed 的不返回 — 前端走 mock 兜底,客服点"重新生成"再触发 LLM。 * pending/failed 的不返回 — 前端走 mock 兜底,客服点"重新生成"再触发 LLM。
*/ */
private loadPlanScript(planId: string) { private loadPlanScript(planId: string, channel: 'phone' | 'wecom' = 'phone') {
return this.prisma.planScript.findUnique({ return this.prisma.planScript.findUnique({
where: { planId }, where: { planId_channel: { planId, channel } },
}); });
} }
...@@ -580,6 +598,7 @@ function serializePlan(plan: { ...@@ -580,6 +598,7 @@ function serializePlan(plan: {
assigneeUserId: string | null; assigneeUserId: string | null;
assignedAt: Date | null; assignedAt: Date | null;
recycleAt: Date | null; recycleAt: Date | null;
assignmentExpiresAt: Date | null;
snoozedUntil: Date | null; snoozedUntil: Date | null;
recallFeedback: string | null; recallFeedback: string | null;
recallFeedbackNote: string | null; recallFeedbackNote: string | null;
...@@ -614,6 +633,8 @@ function serializePlan(plan: { ...@@ -614,6 +633,8 @@ function serializePlan(plan: {
assigneeUserId: plan.assigneeUserId, assigneeUserId: plan.assigneeUserId,
assignedAt: plan.assignedAt?.toISOString() ?? null, assignedAt: plan.assignedAt?.toISOString() ?? null,
recycleAt: plan.recycleAt?.toISOString() ?? null, recycleAt: plan.recycleAt?.toISOString() ?? null,
/// ⭐ 分配单到期时刻(客服手上这单还剩多久);⚠️ 与 recycleAt 是两个机制,见 schema 注释
assignmentExpiresAt: plan.assignmentExpiresAt?.toISOString() ?? null,
/// 召回冷静期 / 终态抑制窗到期时间(null=无抑制)— 详情页可渲染"已抑制至 / 下次回访 X" /// 召回冷静期 / 终态抑制窗到期时间(null=无抑制)— 详情页可渲染"已抑制至 / 下次回访 X"
snoozedUntil: plan.snoozedUntil?.toISOString() ?? null, snoozedUntil: plan.snoozedUntil?.toISOString() ?? null,
/// 召回反馈(plan 级)— 详情页标题栏拇指当前态;'up' | 'down' | null /// 召回反馈(plan 级)— 详情页标题栏拇指当前态;'up' | 'down' | null
......
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PlanEventType, PlanEventReason } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import { recordPlanEventsBulk, computeHeldSeconds } from './plan-event.recorder';
/**
* AssignmentExpiryScheduler —— 分配单到期,系统收回池子。
*
* ── 为什么这件事必须做,而且默认开 ────────────────────────────
* 时效是分配单的一部分(T11:不存在无限期批次),但**光写一个到期时刻不会让任何事发生**。
* 不收回的后果不是"多了几条过期单",是**容量口径整体失效**:
* 客服的「在手」只增不减,几批之后全员触顶,再分就分不下去 ——
* 而这在主管看来就是分配功能坏了。
*
* 产品定调(2026-08):到期自动回池**也是给主管减负** —— 让他不必去追"这单还要不要"。
* 主管在确认单上确认时效的那一下,就是对到期行为的预授权,不违 T8
* (T8 要防的是"助手替主管做决定",不是"主管定好的规则到点执行")。
*
* ── 与既有 RecycleSchedulerService 的关系:两条互不干扰的路 ────
* · RecycleScheduler 看 `recycle_at`,是**认领**的 24h 兜底,生产**未启用**
* · 本服务 看 `assignment_expires_at`,是**分配**的时效,默认启用
* 刻意不合并:两者的语义、开关、口径都不同,合了之后想单独关一边就得加分支,
* 而那个分支迟早写错。
*/
/** 关掉的方法:PAC_ASSIGNMENT_EXPIRY=off。默认开(产品已定)。 */
function isEnabled(): boolean {
return (process.env.PAC_ASSIGNMENT_EXPIRY ?? '').trim().toLowerCase() !== 'off';
}
/** 单轮上限 —— 防积压时一次性打爆事务;剩下的下一轮继续 */
const BATCH_LIMIT = 500;
@Injectable()
export class AssignmentExpiryScheduler implements OnModuleInit {
private readonly logger = new Logger(AssignmentExpiryScheduler.name);
constructor(private readonly prisma: PrismaService) {}
onModuleInit(): void {
this.logger.log(
isEnabled()
? '分配单到期自动回池:已启用(每 10 分钟扫一次);关闭设 PAC_ASSIGNMENT_EXPIRY=off'
: '分配单到期自动回池:已关闭(PAC_ASSIGNMENT_EXPIRY=off)—— 在手量将只增不减',
);
}
/**
* @param at 判定时刻(默认此刻)。显式可注入是为了让判据可测 ——
* 测试不必靠真实时钟凑时间差(高负载下事件循环被拖慢会越过阈值边界,产生间歇性假失败)。
* 同款做法见 `sync-incremental.scheduler.reapStaleRunningLocks`,那条是踩过之后立的规矩。
*/
@Cron(CronExpression.EVERY_10_MINUTES, { name: 'plan-assignment-expiry' })
async runExpiry(at?: Date): Promise<void> {
if (!isEnabled()) return;
const now = at ?? new Date();
const due = await this.prisma.followupPlan.findMany({
where: {
status: 'assigned',
assignmentExpiresAt: { not: null, lt: now },
supersededAt: null,
// ⭐⭐ 照抄 RecycleScheduler 的守卫,理由完全相同:
// 客服约了 6/10 回访、plan 已 snooze 到 6/10 —— 在那之前**绝不能**因到期被收走,
// 否则客服丢了已经对患者承诺过的回访关系,6/10 一到单子还会被别人从池里捞走。
// 回访日过后仍未处理,才允许收。
// ⚠️ 这条不是可选优化。漏了它,分配功能会主动破坏客服已经做出的承诺。
OR: [{ snoozedUntil: null }, { snoozedUntil: { lte: now } }],
},
select: {
id: true, hostId: true, tenantId: true, patientId: true,
assigneeUserId: true, assignedAt: true,
// ⭐ 账本要记「到期的是哪一批的单」—— 批次报表的「到期几条」全靠它。
// 此刻取是对的:assignment_id 只会被**下一次分配**覆盖,而这一刻还没发生。
assignmentId: true,
},
take: BATCH_LIMIT,
});
if (due.length === 0) return;
// 分片进事务:每片状态变更与账本同生共死,一片失败不影响其余
const CHUNK = 200;
let recycled = 0;
for (let i = 0; i < due.length; i += CHUNK) {
const chunk = due.slice(i, i + CHUNK);
try {
await this.prisma.$transaction(async (tx) => {
const res = await tx.followupPlan.updateMany({
// 带状态条件 → 并发下若客服刚好提交了执行/自己退回了,本次不生效(幂等)
where: { id: { in: chunk.map((p) => p.id) }, status: 'assigned' },
data: {
status: 'active',
assigneeUserId: null,
assignedAt: null,
recycleAt: null,
assignmentExpiresAt: null,
// ⛔ **不写 release_reason** —— 那一列只属于客服的处置。
// 到期是"客服压根没动",不是"客服判断不该我做";混进去退回率的分子分母一起脏。
// 到期的量单独从 plan_event_logs 按 reason 数。
// ⛔ **不清 assignment_id / assigned_by / assign_strategy** —— 批次归因是历史事实,
// 清了这批的分母就少一条,"分了 60 条其中 8 条到期没人动"就算不出来了。
// ⛔ **绝不动 snoozedUntil** —— 与退回同一条纪律。
},
});
recycled += res.count;
await recordPlanEventsBulk(
tx,
chunk.map((p) => ({
hostId: p.hostId,
tenantId: p.tenantId,
planId: p.id,
patientId: p.patientId,
event: PlanEventType.AUTO_RELEASE,
assigneeUserId: null, // 释放后无人归属
actorUserId: null, // 系统行为
// ⭐ 必须在清空 assignedAt **之前**算(上面 findMany 取的就是清空前的值)
heldSeconds: computeHeldSeconds(p.assignedAt, now),
reason: PlanEventReason.ASSIGNMENT_EXPIRED,
assignmentId: p.assignmentId,
})),
);
});
} catch (err) {
this.logger.error(
`分配到期回收失败(${chunk.length} 条): ${err instanceof Error ? err.message : err}`,
);
}
}
if (recycled > 0) {
this.logger.log(
`分配到期:${recycled} 条超期未处理的分配单已退回召回池(已记账本 reason=assignment_expired)` +
(due.length === BATCH_LIMIT ? `;本轮已达单轮上限 ${BATCH_LIMIT},剩余下一轮继续` : ''),
);
}
}
}
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodResponse } from 'nestjs-zod';
import { Permission } from '@pac/types';
import { RequirePermission } from '../../common/decorators/permissions.decorator';
import { TenantScope } from '../../common/decorators/tenant-scope.decorator';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../../common/decorators/current-user.decorator';
import { PlanAssignmentService } from './plan-assignment.service';
import { AgentRosterService } from './agent-roster.service';
import {
CreateAssignmentRequestDto,
CreateAssignmentResponseDto,
ListAssignmentsResponseDto,
AssignmentDetailResponseDto,
ListAgentsResponseDto,
RevokeAssignmentResponseDto,
} from './dto/plan-assignment.dto';
/**
* 批次分配端点 —— 门诊经理专用。
*
* ⛔ **刻意不改** `POST /plans/:id/assign` 与 `/plans/:id/recycle`:
* 前者维持 PLAN_ASSIGN(认领语义,staff 也有,前端虽撤了入口但后端保留 —— T12/T16),
* 后者维持 PLAN_RECYCLE(退回是客服路径)。
* 把批次能力塞进那两个端点会让"客服自助"与"主管派单"两条路的权限纠缠在一起。
*
* 路由挂在 `plans/assignments` 而不是顶层 `plan-assignments`:
* 它是 plan 的从属资源,跟 `plans/:id/executions` 同一层级心智。
*/
@ApiTags('plan-assignments')
@ApiBearerAuth('accessToken')
@Controller('plans/assignments')
export class AssignmentController {
constructor(
private readonly assignments: PlanAssignmentService,
private readonly roster: AgentRosterService,
) {}
/**
* 客服名册 + 在手负载 —— **一个端点**,不拆成两个。
* 分配问「还能吃多少」、跟踪问「压了多少」,是同一份数据的两种读法;
* 拆开必然口径漂移(一个算 assigned、一个算 assigned+active),而漂了不报错。
*
* 完整路径 **`GET /pac/v1/plans/assignments/agents`**(控制器前缀是 `plans/assignments`)。
* ⚠️ 别写成 `/plans/agents` —— 那个路径归 PlanController,会被它的裸 `@Get(':id')`
* 当成 planId='agents',报出来是 Prisma 的 uuid 解析错,看不出是路由问题(实测踩过)。
* ⚠️ 本方法必须声明在下面 `@Get(':id')` **之前**:同一控制器内 Nest 按声明顺序匹配,
* 放后面 'agents' 会被当成 assignmentId。
*/
@Get('agents')
@RequirePermission(Permission.PLAN_DISPATCH)
@ZodResponse({ status: 200, type: ListAgentsResponseDto })
@ApiOperation({ summary: '在岗客服名册 + 在手负载(近 N 月有回访记录者;名册外亦可指定)' })
async agents(
@TenantScope() scope: TenantScopeContext,
@Query('clinicId') clinicId: string,
@Query('months') months?: string,
@Query('include') include?: string,
) {
return this.roster.list(scope, clinicId, {
months: months ? Number(months) : undefined,
// 主管显式点名的人即使不在名册也要能查到 —— 名册是建议不是白名单
extraUserIds: include ? include.split(',').filter(Boolean) : undefined,
});
}
@Post()
@RequirePermission(Permission.PLAN_DISPATCH)
@ZodResponse({ status: 201, type: CreateAssignmentResponseDto })
@ApiOperation({
summary: '确认分配 —— 主管在确认单上点确认时调用(整条生产线唯一的写动作)',
description:
'幂等:requestId 由服务端在生成确认单时铸造,重复提交返回同一批次 + duplicate:true。' +
'部分计划在确认期间被他人认领时其余照落,逐条在 skipped 里说明原因;一条都落不上则不建批次。',
})
async create(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Body() dto: CreateAssignmentRequestDto,
) {
return this.assignments.create(
scope,
{ userId: user.sub, permissions: user.permissions },
dto,
);
}
@Get()
@RequirePermission(Permission.PLAN_DISPATCH)
@ZodResponse({ status: 200, type: ListAssignmentsResponseDto })
@ApiOperation({ summary: '我分的那些批 —— 批次列表 + 每批汇总(分了/在手/已退回/涉及几人)' })
async list(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Query('mine') mine?: string,
) {
// mine=1 → 只看自己发起的。默认看本 scope 全部(leader 之间要能互相看见,
// 否则"这个诊所这周分了多少"永远拼不出来)
return this.assignments.list(scope, mine === '1' ? user.sub : undefined);
}
@Post(':id/revoke')
@RequirePermission(Permission.PLAN_DISPATCH)
@ZodResponse({ status: 200, type: RevokeAssignmentResponseDto })
@ApiOperation({
summary: '撤销整批(限时)—— 分错人 / 条件填错时的补救',
description:
'⚠️ 撤销 ≠ 退回:这是**主管收回整批**,不是客服退单条。' +
'已被客服打开过(view 事件)的单**不收**并如实回报 —— 他可能已经联系了患者。' +
'超出撤销窗口后请走逐条退回(那条路会留下原因)。',
})
async revoke(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Param('id') id: string,
) {
return this.assignments.revoke(scope, { userId: user.sub, permissions: user.permissions }, id);
}
@Get(':id')
@RequirePermission(Permission.PLAN_DISPATCH)
@ZodResponse({ status: 200, type: AssignmentDetailResponseDto })
@ApiOperation({ summary: '单批全貌 —— 按客服拆 + 退回原因分布' })
async detail(@TenantScope() scope: TenantScopeContext, @Param('id') id: string) {
return this.assignments.detail(scope, id);
}
}
...@@ -28,6 +28,32 @@ export interface ClaimablePlan { ...@@ -28,6 +28,32 @@ export interface ClaimablePlan {
} }
/** /**
* 「这条单现在能不能分给某人」—— 单条 assign 与批量分配**共用同一个判据**。
*
* 为什么要提出来:教条七里那条待确认的产品决策「已被认领的单能否强制改派」
* 恰好就落在这一个函数上。收口成一处,将来产品拍板了只改这里 ——
* 散在单条与批量两处,必然只改一处然后两条路行为不一致(而且不会有人发现)。
*
* 返回 null = 可以分;返回字符串 = 不能分的原因码(与 AssignmentSkipped.reason 同域)。
* ⚠️ 刻意**返回原因而不抛异常**:批量场景要的是「其余照落 + 逐条说明」,
* 抛异常会让一条挡住整批。单条路径由调用方自己把原因翻成 BizError。
*/
export function assertAssignable(
plan: { status: string; assigneeUserId: string | null },
targetAssigneeUserId: string,
): 'terminal' | 'claimed_by_other' | null {
if (plan.status === 'completed' || plan.status === 'abandoned' || plan.status === 'superseded') {
return 'terminal';
}
// 已分给**别人** → 挡住(分给同一个人是幂等续期,放行)。
// 这就是「强制改派」那条待确认决策的落点:将来若允许,改成 return null 即可。
if (plan.status === 'assigned' && plan.assigneeUserId !== targetAssigneeUserId) {
return 'claimed_by_other';
}
return null;
}
/**
* 返池归属闸 —— staff 只能退**自己认领的**单(2026-07-29 放开 staff 返池时加)。 * 返池归属闸 —— staff 只能退**自己认领的**单(2026-07-29 放开 staff 返池时加)。
* *
* 为什么必须有:光把 PLAN_RECYCLE 给 staff,客服 A 就能把客服 B 手里的单退回池、 * 为什么必须有:光把 PLAN_RECYCLE 给 staff,客服 A 就能把客服 B 手里的单退回池、
......
import { Prisma } from '@prisma/client';
import {
PERSONA_TAG_FILTER_DIMS,
Temperature,
parsePersonaTags,
personaTagDimId,
visitRecencyRange,
type TemperatureValue,
} from '@pac/types';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
/**
* 「这批人是谁」的**唯一**取数口径 —— 出确认单(assignment-proposal)与看属性分布
* (cohort-attributes)共用同一份 SQL 片段。
*
* ⚠️⚠️ **为什么必须共用**:两处各写一遍,就会出现「属性分布说商保直付 32 人,
* 提案出来只有 28 人」。主管当场就不信了,而且分不清是哪一边错。
* 这正是 T6a「同源保证」和验证策略里「口径对数」那一条要防的事。
*
* ⚠️ 与列表页(`plan.service.buildListWhere`,Prisma 形态)是**两套实现、一套口径**:
* 列表页要分页/排序走 Prisma,这里要表达 `jsonb_array_elements` + 时刻比较走原生 SQL,
* Prisma 表达不了后者。两边的等价性靠 `tests/cohort-filter.spec.ts` + 本地实测对数守住。
*/
export interface CohortCriteria {
/** 批次不跨诊所 */
clinicId: string;
/** 矩阵 X 轴:画像「潜在治疗」8 类之一 */
potentialTreatment?: string;
/** 矩阵 Y 轴:窗口温度。⚠️ 必须与 potentialTreatment 同时给,见下方 assert */
temperature?: TemperatureValue;
/** 主管在调整阶段追加的画像条件(`key:value` 逗号串,与列表页同格式) */
personaTags?: string;
}
/**
* 温度必须挂在某个治疗项上 —— 「热」本身不是患者属性。
* 同一个人可能「潜在种植·热」而「潜在补牙·冷」,不指定治疗项的「热」是无意义的。
* ⛔ 不要"贴心地"给个默认治疗项,那会让主管拿到一批他没要的人。
*/
export function assertCohortCriteria(c: CohortCriteria): void {
if (c.temperature && !c.potentialTreatment) {
throw new Error('温度必须与潜在治疗项一起给 —— 同一个人可能对种植是热、对补牙是冷。');
}
}
/** 池子基线:未被认领、未 supersede、冷静期已过的活跃 plan(与列表页 view=pool 同义) */
export function poolBaseSql(scope: TenantScopeContext, clinicId: string): Prisma.Sql {
return Prisma.sql`
fp.host_id = ${scope.hostId}::uuid
AND fp.tenant_id = ${scope.tenantId}
AND fp.target_clinic_id = ${clinicId}
AND fp.status = 'active'
AND fp.assignee_user_id IS NULL
AND fp.superseded_at IS NULL
-- 冷静期/约定回访未到 → 不该进池,更不该被分
AND (fp.snoozed_until IS NULL OR fp.snoozed_until <= NOW())
${scope.sourceUnits.length ? Prisma.sql`AND p.source_unit IN (${Prisma.join(scope.sourceUnits)})` : Prisma.empty}
`;
}
/**
* 画像必须按**患者**取当前版。
*
* 🔴 ⛔ 绝不能写成 `pe.id = fp.persona_id AND pe.superseded_at IS NULL`:
* `followup_plans.persona_id` 指向建 plan 那一刻的画像版本,画像一升版本那行就被 supersede,
* 而 plan 的外键不跟着走 → 条件恒为假 → 筛选静默变 0 条,不报错不告警。
* 实测见教条 §4.38(全量重算后 2,724 条池子里只剩 97 条还指向活版本)。
*/
const CURRENT_PERSONA = Prisma.sql`pe.patient_id = fp.patient_id AND pe.superseded_at IS NULL`;
/** X 轴:潜在治疗(data->'types' 是字符串数组,与 persona-tag-filters 同口径用 @> 命中) */
function treatmentSql(potentialTreatment: string): Prisma.Sql {
return Prisma.sql`
AND EXISTS (
SELECT 1 FROM personas pe
JOIN persona_features pf ON pf.persona_id = pe.id AND pf.key = 'potential_treatment'
WHERE ${CURRENT_PERSONA}
AND (pf.data #> '{types}') @> ${JSON.stringify([potentialTreatment])}::jsonb
)`;
}
/**
* Y 轴:窗口温度 —— 读时拿 `now` 跟存好的两个边界时刻比,**不做任何天数运算**。
* 口径与算法见 `@pac/types` 的 temperature.ts(为什么存边界不存天数/档位)。
*
* ⚠️ `hotUntil IS NOT NULL` 是必须的:本次改动之前算出来的老画像没有边界字段,
* 它们是「温度未知」,⛔ 不许掉进任何一档(尤其不许默认掉进冷),否则主管看到假分布。
* 调用方应另外把"未知"的条数显式报出来(见 cohort-attributes 的 unknownTemperature)。
*/
function temperatureSql(
potentialTreatment: string,
temperature: TemperatureValue,
now: Date,
): Prisma.Sql {
// ⭐ 稳定路径 `temperature.<标签>.hotUntil`(不是 detail 数组下标)——
// 同一个谓词列表页用 Prisma 也能表达,两边因此天然对得上数。
const hotUntil = Prisma.sql`(pf.data #>> ${[`temperature`, potentialTreatment, 'hotUntil']}::text[])::timestamptz`;
const warmUntil = Prisma.sql`(pf.data #>> ${[`temperature`, potentialTreatment, 'warmUntil']}::text[])::timestamptz`;
const phase =
temperature === Temperature.HOT
? Prisma.sql`${now} <= ${hotUntil}`
: temperature === Temperature.WARM
? Prisma.sql`${now} > ${hotUntil} AND ${now} <= ${warmUntil}`
: Prisma.sql`${now} > ${warmUntil}`;
return Prisma.sql`
AND EXISTS (
SELECT 1 FROM personas pe
JOIN persona_features pf ON pf.persona_id = pe.id AND pf.key = 'potential_treatment'
WHERE ${CURRENT_PERSONA}
AND pf.data #> ${['temperature', potentialTreatment]}::text[] IS NOT NULL
AND ${phase}
)`;
}
/**
* 主管追加的画像条件 —— 同一维度多选 = OR,跨维度 = AND(与列表页语义完全一致)。
* 维度/取值以 `PERSONA_TAG_FILTER_DIMS` 为单一真理源,非法 key/value 静默丢弃。
*/
function personaTagsSql(personaTags: string, now: Date): Prisma.Sql {
// ⚠️ 按**维度 id** 分组而不是 persona 特征 key —— visit_recency 一个特征出三个可筛字段,
// 按 key 分组会把它们串成一条 OR,筛出错的人(列表页踩过,这里照抄结论)。
const byDim = new Map<string, { dim: (typeof PERSONA_TAG_FILTER_DIMS)[number]; values: string[] }>();
for (const { key: dimId, value } of parsePersonaTags(personaTags)) {
const dim = PERSONA_TAG_FILTER_DIMS.find((d) => personaTagDimId(d) === dimId);
if (!dim) continue;
if (!dim.dynamic && !dim.options.some((o) => o.value === value)) continue;
const cur = byDim.get(dimId) ?? { dim, values: [] };
cur.values.push(value);
byDim.set(dimId, cur);
}
const frags: Prisma.Sql[] = [];
for (const { dim, values } of byDim.values()) {
// ── 事实派生属性:直接查 patient_profiles,不穿 personas → persona_features ──
if (dim.source === 'patient') {
if (dim.patientField === 'lastVisitAt') {
// ⭐ 存的是日期,区间按 now 现算(与 visitRecencyRange 同一个函数)——
// 跟温度存边界时刻是同一条道理:存死分档会过期。
const ranges = values
.map((v) => visitRecencyRange(v, now))
.filter((r): r is { gte?: Date; lt?: Date } => !!r);
if (ranges.length === 0) continue;
const ors = ranges.map((r) =>
Prisma.sql`(${r.gte ? Prisma.sql`pp.last_visit_at >= ${r.gte}` : Prisma.sql`TRUE`}
AND ${r.lt ? Prisma.sql`pp.last_visit_at < ${r.lt}` : Prisma.sql`TRUE`})`,
);
frags.push(Prisma.sql`AND EXISTS (
SELECT 1 FROM patient_profiles pp
WHERE pp.patient_id = fp.patient_id AND (${Prisma.join(ors, ' OR ')}))`);
} else {
const col =
dim.patientField === 'lastVisitDoctor'
? Prisma.sql`pp.last_visit_doctor`
: Prisma.sql`pp.preferred_doctor`;
frags.push(Prisma.sql`AND EXISTS (
SELECT 1 FROM patient_profiles pp
WHERE pp.patient_id = fp.patient_id AND ${col} IN (${Prisma.join(values)}))`);
}
continue;
}
const ors = values.map((v) =>
dim.isArray
? Prisma.sql`(pf.data #> ${`{${dim.dataPath}}`}::text[]) @> ${JSON.stringify([v])}::jsonb`
: Prisma.sql`pf.data ->> ${dim.dataPath} = ${v}`,
);
frags.push(Prisma.sql`AND EXISTS (
SELECT 1 FROM personas pe
JOIN persona_features pf ON pf.persona_id = pe.id AND pf.key = ${dim.key}
WHERE ${CURRENT_PERSONA} AND (${Prisma.join(ors, ' OR ')}))`);
}
return frags.length ? Prisma.sql`${Prisma.join(frags, ' ')}` : Prisma.empty;
}
/**
* 完整的人群过滤片段 —— 调用方拼在 `FROM followup_plans fp JOIN patients p ON p.id = fp.patient_id WHERE …` 之后。
*/
export function cohortWhereSql(
scope: TenantScopeContext,
c: CohortCriteria,
now: Date,
): Prisma.Sql {
assertCohortCriteria(c);
return Prisma.sql`
${poolBaseSql(scope, c.clinicId)}
${c.potentialTreatment ? treatmentSql(c.potentialTreatment) : Prisma.empty}
${c.temperature && c.potentialTreatment ? temperatureSql(c.potentialTreatment, c.temperature, now) : Prisma.empty}
${c.personaTags ? personaTagsSql(c.personaTags, now) : Prisma.empty}
`;
}
import { ForbiddenException } from '@nestjs/common';
import { Permission } from '@pac/types';
/**
* 分配写路径的两道通用闸 —— **独立于传输层**(REST / MCP / 将来别的入口都能用)。
*
* ── 为什么现在就写,而 v1 的写路径明明只有 REST ──────────────────
* MCP 写工具(`assign_plans`)最终一定要实现,只是分期到 S4 等鉴权补齐。
* 但 MCP 端点现在是 `@Public()`(`permissions.guard.ts` 对 public 直接 return true),
* 也就是说**框架级的 @RequirePermission 在那条路上完全不生效**,
* 护栏只能退化成 handler 内自查。等到那时才动手,写路径的形状很可能已经把自查挤不进去了。
*
* 所以这两个 helper 现在就落地、现在就被 REST 路径调用(与 guard 叠加,幂等),
* S4 挂 MCP 工具时直接复用同一份判据 —— 而不是在第二个地方重写一遍权限逻辑。
*/
/** 调用方的最小身份形状(REST 从 JWT 来,MCP 从 McpAuthContext 来,两边都能满足) */
export interface DispatchActor {
userId: string;
permissions: readonly string[];
}
/**
* 权限自查。
*
* ⚠️ 与 `@RequirePermission(PLAN_DISPATCH)` **重复是有意的**:
* 装饰器只在 Nest 的 guard 链上生效,`@Public()` 的端点直接短路 ——
* 而 MCP 恰好就是 `@Public()`。把判据同时放进 service,
* 就不存在"换个入口进来护栏就没了"这回事。
*/
export function requirePermission(actor: DispatchActor, permission: Permission): void {
if (!actor.permissions.includes(permission)) {
throw new ForbiddenException(`缺少权限:${permission}`);
}
}
/**
* 合成身份前缀 —— 这些 id 不对应任何真人。
* 目前只有企微机器人:`weixin-aibot.service.mintToken` 会造一个 `role='staff'` + 全池 scope
* 的 token,给群里任何 @ 机器人的人用。读没问题(本来就是给客服查患者的),
* **写绝对不行** —— 那等于让"谁在群里说话"决定几百条单子的归属,而且账本上的
* `created_by` 会指向一个查无此人的 id,事后追责追不到人。
*/
const SYNTHETIC_ID_PREFIXES = ['wx:'] as const;
/**
* 拒绝合成身份发起写操作。
*
* 教条六·已定取舍写的是「企微通道的合成身份本期不管(demo 用途)」——
* 那句话的前提是**当时还没有写路径**。有了写路径,"不管"就必须落成"硬拒",
* 否则就是把一个已知的身份伪造面留在最危险的位置上。
* 将来做真身份映射时,把映射成功的真 id 传进来即可自然通过。
*/
export function rejectSyntheticIdentity(actor: DispatchActor): void {
if (SYNTHETIC_ID_PREFIXES.some((p) => actor.userId.startsWith(p))) {
throw new ForbiddenException(
'当前身份为渠道合成身份(非真实登录用户),不能发起分配。请在 PAC 工作台登录后操作。',
);
}
}
import { createZodDto } from 'nestjs-zod';
import {
AssignmentDetailResponseSchema,
CreateAssignmentRequestSchema,
CreateAssignmentResponseSchema,
ListAssignmentsResponseSchema,
ListAgentsResponseSchema,
RevokeAssignmentResponseSchema,
} from '@pac/types';
export class CreateAssignmentRequestDto extends createZodDto(CreateAssignmentRequestSchema) {}
export class CreateAssignmentResponseDto extends createZodDto(CreateAssignmentResponseSchema) {}
export class ListAssignmentsResponseDto extends createZodDto(ListAssignmentsResponseSchema) {}
export class AssignmentDetailResponseDto extends createZodDto(AssignmentDetailResponseSchema) {}
export class ListAgentsResponseDto extends createZodDto(ListAgentsResponseSchema) {}
export class RevokeAssignmentResponseDto extends createZodDto(RevokeAssignmentResponseSchema) {}
...@@ -94,6 +94,9 @@ export class ExecutionService { ...@@ -94,6 +94,9 @@ export class ExecutionService {
contactAttempts: true, contactAttempts: true,
targetClinicId: true, targetClinicId: true,
assigneeUserId: true, assigneeUserId: true,
// ⭐ 批次归因:这次执行算在哪一批头上(见下方落库处的判据)
assignmentId: true,
assignmentExpiresAt: true,
}, },
}); });
if (!plan) throw new NotFoundException(`Plan ${planId} not found`); if (!plan) throw new NotFoundException(`Plan ${planId} not found`);
...@@ -179,6 +182,14 @@ export class ExecutionService { ...@@ -179,6 +182,14 @@ export class ExecutionService {
? (input.inaccurateTreatments ?? []) ? (input.inaccurateTreatments ?? [])
: [], : [],
scheduledNextAt: input.scheduledNextAt ? new Date(input.scheduledNextAt) : null, scheduledNextAt: input.scheduledNextAt ? new Date(input.scheduledNextAt) : null,
/**
* ⭐ 批次归因 —— 判据是**在办期**(`assignment_expires_at != null`),
* 那一列的自洽性就是「非空 ⟺ 有一次在办的分配」(退回/到期/撤销都会清空它)。
* ⛔ **不要**改成 `plan.assignmentId` 直接落:它在退回之后**仍然留着**
* (那是退回率的分母,刻意不清)。此时客服自己从池子里捞回来打的这通电话,
* 会被算进一个早就结束的批次 —— 那批的"成功数"凭空多一个,且不报错。
*/
assignmentId: plan.assignmentExpiresAt != null ? plan.assignmentId : null,
}, },
select: { id: true }, select: { id: true },
}); });
......
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import { PlanEventType } from '@pac/types'; import { PlanEventType } from '@pac/types';
import type { PlanEventReasonValue } from '@pac/types';
/** /**
* PlanEventLog 的**唯一写入口**。 * PlanEventLog 的**唯一写入口**。
...@@ -31,6 +32,37 @@ import { PlanEventType } from '@pac/types'; ...@@ -31,6 +32,37 @@ import { PlanEventType } from '@pac/types';
*/ */
export type PlanEventLogWriter = Pick<Prisma.TransactionClient, 'planEventLog'>; export type PlanEventLogWriter = Pick<Prisma.TransactionClient, 'planEventLog'>;
/**
* 批量写事件(每日全量重算 / 批量分配用)。
*
* 为什么要有:上面那句「本函数是唯一写入口」不能只是口号 —— 批量场景逐条 `create` 会打出
* 上千次往返,写的人一定会绕过去直接 `createMany`,当场破功。给一个批量口子,
* 纪律才守得住(**并且它内部就是 createMany,没有性能理由再绕**)。
*
* ⚠️ 调用方负责分片:PG 一条语句的 bind 变量上限 32767,本表每行 ~9 个变量 → 单批别超 3000。
*/
export function recordPlanEventsBulk(
tx: PlanEventLogWriter,
inputs: PlanEventInput[],
): Promise<unknown> {
if (inputs.length === 0) return Promise.resolve(null);
return tx.planEventLog.createMany({
data: inputs.map((input) => ({
hostId: input.hostId,
tenantId: input.tenantId,
planId: input.planId,
patientId: input.patientId,
event: input.event,
assigneeUserId: input.assigneeUserId ?? null,
actorUserId: input.actorUserId ?? null,
heldSeconds: input.heldSeconds ?? null,
reason: input.reason ?? null,
assignmentId: input.assignmentId ?? null,
details: input.details ?? undefined,
})),
});
}
export interface PlanEventInput { export interface PlanEventInput {
hostId: string; hostId: string;
tenantId: string; tenantId: string;
...@@ -43,8 +75,20 @@ export interface PlanEventInput { ...@@ -43,8 +75,20 @@ export interface PlanEventInput {
actorUserId?: string | null; actorUserId?: string | null;
/** 归属持续秒数 —— 见 computeHeldSeconds 的注释 */ /** 归属持续秒数 —— 见 computeHeldSeconds 的注释 */
heldSeconds?: number | null; heldSeconds?: number | null;
/** 简短原因(立柱,可直接过滤):auto_release='timeout';feedback='up'|'down' */ /**
reason?: string | null; * 简短原因(立柱,可直接过滤)。
* ⚠️ 取值必须来自 [[PlanEventReasonValue]](系统原因 ∪ 客服退回原因)—— 不收裸 string,
* 否则这一列会变成第二个「随手写字符串」的地方,而它正是退回原因分布的唯一数据源。
*/
reason?: PlanEventReasonValue | null;
/**
* 这条事件发生在**哪一批**的执行过程中(见 schema 里 `assignment_id` 的整段说明)。
*
* ⚠️ 只有 assign / release / auto_release 该传。
* ⛔ **claim 绝不能传** —— 自认领是从池子里自己捞的,而 plan 上那个 assignment_id
* 可能是上一批留下的陈迹(退回/到期都刻意不清它),顶上去等于给旧批次凭空加人。
*/
assignmentId?: string | null;
/** 事件专属细节(不按它查询的内容,如反馈文字) */ /** 事件专属细节(不按它查询的内容,如反馈文字) */
details?: Prisma.InputJsonObject | null; details?: Prisma.InputJsonObject | null;
} }
...@@ -61,6 +105,7 @@ export function recordPlanEvent(tx: PlanEventLogWriter, input: PlanEventInput): ...@@ -61,6 +105,7 @@ export function recordPlanEvent(tx: PlanEventLogWriter, input: PlanEventInput):
actorUserId: input.actorUserId ?? null, actorUserId: input.actorUserId ?? null,
heldSeconds: input.heldSeconds ?? null, heldSeconds: input.heldSeconds ?? null,
reason: input.reason ?? null, reason: input.reason ?? null,
assignmentId: input.assignmentId ?? null,
// undefined 才让 Prisma 落 NULL;传 null 会被当成 JSON null 值 // undefined 才让 Prisma 落 NULL;传 null 会被当成 JSON null 值
details: input.details ?? undefined, details: input.details ?? undefined,
}, },
......
import { import {
BadRequestException,
Body, Body,
Controller, Controller,
Get, Get,
...@@ -33,6 +34,7 @@ import { ...@@ -33,6 +34,7 @@ import {
} from './dto/plan.dto'; } from './dto/plan.dto';
import { PlanService } from './plan.service'; import { PlanService } from './plan.service';
import { PlanEngineService } from './engine/plan-engine.service'; import { PlanEngineService } from './engine/plan-engine.service';
import { CohortAttributesService } from './cohort-attributes.service';
import { resolveScriptAgent } from '../ai/calls/draft-plan-script/shared/agent-identity'; import { resolveScriptAgent } from '../ai/calls/draft-plan-script/shared/agent-identity';
@ApiTags('plan') @ApiTags('plan')
...@@ -42,6 +44,7 @@ export class PlanController { ...@@ -42,6 +44,7 @@ export class PlanController {
constructor( constructor(
private readonly plans: PlanService, private readonly plans: PlanService,
private readonly engine: PlanEngineService, private readonly engine: PlanEngineService,
private readonly cohorts: CohortAttributesService,
) {} ) {}
@Get() @Get()
...@@ -91,6 +94,28 @@ export class PlanController { ...@@ -91,6 +94,28 @@ export class PlanController {
return this.plans.doctorOptions(scope); return this.plans.doctorOptions(scope);
} }
/**
* 初选矩阵:8 潜在治疗 × 3 温度 + 「待重算」列。
*
* ⚠️ 同 `doctors`,必须放在 `@Get(':id')` **之前**,否则 'matrix' 会被当成 planId。
* ⚠️ 权限是 `PLAN_DISPATCH` 不是 `PLAN_VIEW_OWN` —— 矩阵是**召回池**的视图,
* 而 T16 明写客服看不到池子。给成 VIEW_OWN 等于从后门把池子露给客服。
*/
@Get('matrix')
@RequirePermission(Permission.PLAN_DISPATCH)
@ApiOperation({
summary: '初选矩阵(潜在治疗 × 窗口温度),按患者去重',
description:
'口径是**去重患者数**,不是 plan 条数(列表页按 plan 分页)。' +
'温度定义见 packages/types/temperature.ts:逐条 gap 用自己 K 码的窗口判档、取最热。',
})
matrix(@TenantScope() scope: TenantScopeContext, @Query('clinicId') clinicId?: string) {
// 不传 clinicId 用登录人的第一个诊所 —— 批次不跨诊所,矩阵天然是诊所维度的
const cid = clinicId ?? scope.clinicIds[0];
if (!cid) throw new BadRequestException('当前登录人没有绑定诊所,无法出矩阵');
return this.cohorts.matrix(scope, cid);
}
@Get(':id') @Get(':id')
@ZodResponse({ status: 200, type: PlanDetailResponseDto }) @ZodResponse({ status: 200, type: PlanDetailResponseDto })
@RequirePermission(Permission.PLAN_VIEW_OWN) @RequirePermission(Permission.PLAN_VIEW_OWN)
...@@ -124,12 +149,21 @@ export class PlanController { ...@@ -124,12 +149,21 @@ export class PlanController {
@TenantScope() scope: TenantScopeContext, @TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser, @CurrentUser() user: AuthenticatedUser,
@Param('id') id: string, @Param('id') id: string,
@Body() _dto: RecyclePlanRequestDto, @Body() dto: RecyclePlanRequestDto,
) { ) {
// 能不能返**别人**的单,看有没有 PLAN_VIEW_ALL(leader/admin 有,staff 没有)—— // 能不能返**别人**的单,看有没有 PLAN_VIEW_ALL(leader/admin 有,staff 没有)——
// staff 现在也能返池,但只能退自己认领的,校验在 service 里(见 recycle 注释)。 // staff 现在也能返池,但只能退自己认领的,校验在 service 里(见 recycle 注释)。
const canRecycleOthers = user.permissions.includes(Permission.PLAN_VIEW_ALL); const canRecycleOthers = user.permissions.includes(Permission.PLAN_VIEW_ALL);
await this.plans.recycle(scope, id, user.sub, canRecycleOthers); // ⚠️ 这里原本写的是 `@Body() _dto` —— 下划线,收了就扔。退回原因在链路第一步蒸发,
// 「客服为什么不接这单」永远统计不出来。透传下去别再丢。
await this.plans.recycle(
scope,
id,
user.sub,
canRecycleOthers,
dto.releaseReason,
dto.releaseNote,
);
return { ok: true as const }; return { ok: true as const };
} }
......
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PlanController } from './plan.controller'; import { PlanController } from './plan.controller';
import { PlanService } from './plan.service'; import { PlanService } from './plan.service';
import { AssignmentController } from './assignment.controller';
import { PlanAssignmentService } from './plan-assignment.service';
import { AgentRosterService } from './agent-roster.service';
import { AssignmentProposalService } from './assignment-proposal.service';
import { CohortAttributesService } from './cohort-attributes.service';
import { ExecutionService } from './execution.service'; import { ExecutionService } from './execution.service';
import { ExecutionCallbackService } from './execution-callback.service'; import { ExecutionCallbackService } from './execution-callback.service';
import { RecycleSchedulerService } from './recycle-scheduler.service'; import { RecycleSchedulerService } from './recycle-scheduler.service';
import { AssignmentExpiryScheduler } from './assignment-expiry.scheduler';
import { PlanEngineService } from './engine/plan-engine.service'; import { PlanEngineService } from './engine/plan-engine.service';
import { ChainComposerService } from './engine/chain-composer.service'; import { ChainComposerService } from './engine/chain-composer.service';
import { TreatmentInitiationRecallScenario } from './engine/scenarios/treatment-initiation-recall.scenario'; import { TreatmentInitiationRecallScenario } from './engine/scenarios/treatment-initiation-recall.scenario';
...@@ -15,17 +21,29 @@ import { RecallDebugService } from './recall-debug/recall-debug.service'; ...@@ -15,17 +21,29 @@ import { RecallDebugService } from './recall-debug/recall-debug.service';
* 链已完成召回(aftercare)留后续,文件已删。 * 链已完成召回(aftercare)留后续,文件已删。
*/ */
@Module({ @Module({
controllers: [PlanController, RecallDebugController], // ⚠️⚠️ **AssignmentController 必须排在 PlanController 之前**,顺序不是随意的。
// 两者的路由前缀都是 `plans`,而 PlanController 有一条裸 `@Get(':id')`(plan.controller:94)。
// Nest 按**注册顺序**匹配 → 反过来的话 `GET /plans/assignments` 会被当成
// `GET /plans/:id`(id='assignments'),然后去查一个不存在的 plan,
// 报的还是 Prisma 的 uuid 解析错(90000),完全看不出是路由撞了。
// 实测踩过一次。同类先例见 plan.controller:80 的 `doctors` 那条注释。
controllers: [AssignmentController, PlanController, RecallDebugController],
providers: [ providers: [
PlanService, PlanService,
PlanAssignmentService,
AgentRosterService,
AssignmentProposalService,
CohortAttributesService,
ExecutionService, ExecutionService,
ExecutionCallbackService, ExecutionCallbackService,
RecycleSchedulerService, RecycleSchedulerService,
AssignmentExpiryScheduler,
PlanEngineService, PlanEngineService,
ChainComposerService, ChainComposerService,
TreatmentInitiationRecallScenario, TreatmentInitiationRecallScenario,
RecallDebugService, RecallDebugService,
], ],
exports: [PlanService, ExecutionService, ExecutionCallbackService, PlanEngineService, ChainComposerService], // MCP 的主管工具直接用这两个 service(条件注册,见 mcp-server.factory)
exports: [PlanService, PlanAssignmentService, AgentRosterService, AssignmentProposalService, CohortAttributesService, ExecutionService, ExecutionCallbackService, PlanEngineService, ChainComposerService],
}) })
export class PlanModule {} export class PlanModule {}
import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { PlanEventType } from '@pac/types'; import { PlanEventType, PlanEventReason } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { recordPlanEvent, computeHeldSeconds } from './plan-event.recorder'; import { recordPlanEvent, computeHeldSeconds } from './plan-event.recorder';
...@@ -70,6 +70,8 @@ export class RecycleSchedulerService implements OnModuleInit { ...@@ -70,6 +70,8 @@ export class RecycleSchedulerService implements OnModuleInit {
select: { select: {
id: true, hostId: true, tenantId: true, patientId: true, id: true, hostId: true, tenantId: true, patientId: true,
assigneeUserId: true, assignedAt: true, assigneeUserId: true, assignedAt: true,
// 账本记「回收的是哪一批的单」——口径与 AssignmentExpiryScheduler 一致
assignmentId: true,
}, },
// 单轮上限:防积压时一次性打爆事务;剩下的下一轮继续 // 单轮上限:防积压时一次性打爆事务;剩下的下一轮继续
take: 500, take: 500,
...@@ -84,7 +86,15 @@ export class RecycleSchedulerService implements OnModuleInit { ...@@ -84,7 +86,15 @@ export class RecycleSchedulerService implements OnModuleInit {
await tx.followupPlan.updateMany({ await tx.followupPlan.updateMany({
// 带 status 条件 → 并发下若已被人工返池/结案则本次不生效(幂等) // 带 status 条件 → 并发下若已被人工返池/结案则本次不生效(幂等)
where: { id: p.id, status: 'assigned' }, where: { id: p.id, status: 'assigned' },
data: { status: 'active', assigneeUserId: null, assignedAt: null, recycleAt: null }, // ⚠️ 只清归属四件套 + 在办期限;**assignment_id / assigned_by / assign_strategy 保留** ——
// 它们是批次归因(历史事实),清了批次的分母就少一条。口径与 PlanService.recycle 一致。
data: {
status: 'active',
assigneeUserId: null,
assignedAt: null,
recycleAt: null,
assignmentExpiresAt: null,
},
}); });
await recordPlanEvent(tx, { await recordPlanEvent(tx, {
hostId: p.hostId, hostId: p.hostId,
...@@ -95,7 +105,8 @@ export class RecycleSchedulerService implements OnModuleInit { ...@@ -95,7 +105,8 @@ export class RecycleSchedulerService implements OnModuleInit {
assigneeUserId: null, assigneeUserId: null,
actorUserId: null, // 系统行为,无操作人 actorUserId: null, // 系统行为,无操作人
heldSeconds: computeHeldSeconds(p.assignedAt, now), heldSeconds: computeHeldSeconds(p.assignedAt, now),
reason: 'timeout', reason: PlanEventReason.TIMEOUT,
assignmentId: p.assignmentId,
}); });
}); });
recycled++; recycled++;
......
...@@ -302,7 +302,7 @@ export function normalizeDatetime(s: string, timezone: string): string { ...@@ -302,7 +302,7 @@ export function normalizeDatetime(s: string, timezone: string): string {
* 简化版:对常用东亚时区直接映射;真要严谨可用 Intl.DateTimeFormat 推导,但 * 简化版:对常用东亚时区直接映射;真要严谨可用 Intl.DateTimeFormat 推导,但
* v1 不处理 DST(中国大陆不实行;若接欧美 host 再迭代)。 * v1 不处理 DST(中国大陆不实行;若接欧美 host 再迭代)。
*/ */
function timezoneToOffsetSuffix(tz: string): string { export function timezoneToOffsetSuffix(tz: string): string {
const fixed: Record<string, string> = { const fixed: Record<string, string> = {
'Asia/Shanghai': '+08:00', 'Asia/Shanghai': '+08:00',
'Asia/Hong_Kong': '+08:00', 'Asia/Hong_Kong': '+08:00',
......
...@@ -138,6 +138,9 @@ export class WeixinAibotService implements OnModuleInit, OnModuleDestroy { ...@@ -138,6 +138,9 @@ export class WeixinAibotService implements OnModuleInit, OnModuleDestroy {
userToken: token, userToken: token,
messages: history, messages: history,
systemExtra: this.wxSystemExtra, systemExtra: this.wxSystemExtra,
// ⚠️ 刻意不传 permissions → 走 'basic' 桶。企微这条路用的是 mintToken 造的**合成身份**
// (见 mintToken),不是真人登录态;给它主管工具清单等于把分配能力挂到一个
// 谁都能在群里 @ 出来的机器人上。真身份映射做完之前,这里就该是最小权限。
abortSignal: ac.signal, abortSignal: ac.signal,
}); });
......
...@@ -19,7 +19,14 @@ import { currentTenant } from '../common/tenant-context'; ...@@ -19,7 +19,14 @@ import { currentTenant } from '../common/tenant-context';
const logger = new Logger('TenantGuard'); const logger = new Logger('TenantGuard');
/** 带 tenant_id 的 14 张表(模型名)。 */ /**
* 带 tenant_id 的 16 张表(模型名)。
*
* ⚠️ 新建带 tenant_id 的表时**必须登记到这里** —— 漏了不会报错,只是少一层兜底,
* 而这一层正是"service 忘了加 tenantId"时唯一还站着的东西。
* 2026-08 补登 PlanEventLog + PlanAssignment:前者此前只写不读,分配的批次跟踪
* 是它的第一个读场景 —— 正好是纵深防御该兜底的时候。
*/
const SCOPED_MODELS = new Set<string>([ const SCOPED_MODELS = new Set<string>([
'Patient', 'Patient',
'PatientTransaction', 'PatientTransaction',
...@@ -33,10 +40,15 @@ const SCOPED_MODELS = new Set<string>([ ...@@ -33,10 +40,15 @@ const SCOPED_MODELS = new Set<string>([
'PlanGenerationLog', 'PlanGenerationLog',
'PlanScript', 'PlanScript',
'PlanSummary', 'PlanSummary',
'PlanEventLog',
'PlanAssignment',
'AgentInvocation', 'AgentInvocation',
'SyncLog', 'SyncLog',
]); ]);
/// ⛔ PlanEventLog / PlanAssignment **不进** SOURCE_UNIT_MODELS —— 两表都没有 source_unit 列,
/// 加进去会让每次查询都注入一个不存在的字段(Prisma 直接抛错)。
/// 品牌隔离对它们由 scope.clinicIds + 关联 plan/patient 覆盖。
/** 同时带 source_unit 列的模型(品牌粒度过滤可直接打在自身)。 */ /** 同时带 source_unit 列的模型(品牌粒度过滤可直接打在自身)。 */
const SOURCE_UNIT_MODELS = new Set<string>(['Patient', 'PatientFact', 'PatientReturnVisit']); const SOURCE_UNIT_MODELS = new Set<string>(['Patient', 'PatientFact', 'PatientReturnVisit']);
......
...@@ -113,17 +113,20 @@ export class SyncIncrementalSchedulerService implements OnModuleInit { ...@@ -113,17 +113,20 @@ export class SyncIncrementalSchedulerService implements OnModuleInit {
* 正在跑的真锁(例如运维手动触发的 CLI 与本进程并存),误清会把在跑的同步" orphan"掉。 * 正在跑的真锁(例如运维手动触发的 CLI 与本进程并存),误清会把在跑的同步" orphan"掉。
* *
* 幂等:被标 failed 只是让并发锁释放;数据侧不受影响(游标没推进,下次增量靠 48h 回看窗补齐)。 * 幂等:被标 failed 只是让并发锁释放;数据侧不受影响(游标没推进,下次增量靠 48h 回看窗补齐)。
*
* @param processStartedAt 回收判据的时间界(默认本进程启动时刻)。显式传入是为了让判据可测 ——
* 测试不必靠真实时钟凑时间差(高负载下事件循环被拖慢会越过阈值边界,产生间歇性假失败)。
*/ */
private async reapStaleRunningLocks(): Promise<void> { private async reapStaleRunningLocks(processStartedAt: Date = PROCESS_STARTED_AT): Promise<void> {
try { try {
const stale = await this.prisma.syncLog.findMany({ const stale = await this.prisma.syncLog.findMany({
where: { status: SyncStatus.RUNNING, startedAt: { lt: PROCESS_STARTED_AT } }, where: { status: SyncStatus.RUNNING, startedAt: { lt: processStartedAt } },
select: { id: true, hostId: true, startedAt: true, triggeredBy: true }, select: { id: true, hostId: true, startedAt: true, triggeredBy: true },
}); });
if (stale.length === 0) return; if (stale.length === 0) return;
const { count } = await this.prisma.syncLog.updateMany({ const { count } = await this.prisma.syncLog.updateMany({
where: { status: SyncStatus.RUNNING, startedAt: { lt: PROCESS_STARTED_AT } }, where: { status: SyncStatus.RUNNING, startedAt: { lt: processStartedAt } },
data: { data: {
status: SyncStatus.FAILED, status: SyncStatus.FAILED,
endedAt: new Date(), endedAt: new Date(),
......
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
/**
* 「画像必须按患者取当前版」的**全仓**护栏。
*
* 🔴 拦的是一个**静默归零**的写法:按 `pe.id = fp.persona_id AND pe.superseded_at IS NULL`
* 关联画像看着天经地义,实际上 `followup_plans.persona_id` 指向的是**建 plan 那一刻**的
* 画像版本,画像一升版本那行就被 supersede,而 plan 的外键不跟着走 —— 条件恒为假,
* 筛选变成 0 条,**不报错、不告警**,主管只看到"这个格子没人了"。
*
* 2026-08-02 本地实测(跑完一次全量 persona 重算之后):
* ```
* 池子里还指向"活着的画像版本"的 plan 97 / 2,724
* 同一个「潜在种植」条件 · 按 persona_id 0 条
* 同一个「潜在种植」条件 · 按 patient_id 376 条 ← 列表页一直是这个口径
* ```
* 两处不一致 = T6a「同源保证」明令要防的「矩阵上有人、点进去列表没有」。
*
* ⚠️ 故意**扫整个 src**,不是盯某一个文件:这个写法太顺手,下一个人会在别处再写一遍。
* 生成出来的 SQL 长什么样另有 `cohort-filter.spec.ts` 逐条断言,那一层比文本匹配强;
* 这一层只负责"别处也不许冒出来"。
*/
const SRC_ROOT = join(__dirname, '../src');
function walk(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const p = join(dir, name);
if (statSync(p).isDirectory()) return walk(p);
return p.endsWith('.ts') ? [p] : [];
});
}
/** 只看代码不看注释 —— 注释里正写着这条反面教材,不剥掉护栏会先抓住它自己 */
const stripComments = (s: string) =>
s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
describe('圈人取数 —— 画像必须按患者取当前版', () => {
test('⭐⭐ 全仓 ⛔ 不许出现 `pe.id = fp.persona_id`(升一次画像版本就静默归零)', () => {
const offenders = walk(SRC_ROOT).filter((f) =>
/\bpe\.id\s*=\s*fp\.persona_id/.test(stripComments(readFileSync(f, 'utf-8'))),
);
expect(offenders.map((f) => f.replace(SRC_ROOT, 'src'))).toEqual([]);
});
test('⭐ 人群取数的关联口径收口在 cohort-filter 一处 —— 出确认单与看分布共用', () => {
// 各写一遍就会出现「属性分布说商保 32 人,提案只有 28 人」,主管当场不信,
// 而且分不清是哪一边错。
const proposal = readFileSync(join(SRC_ROOT, 'modules/plan/assignment-proposal.service.ts'), 'utf-8');
const attrs = readFileSync(join(SRC_ROOT, 'modules/plan/cohort-attributes.service.ts'), 'utf-8');
expect(proposal).toContain('cohortWhereSql');
expect(attrs).toContain('cohortWhereSql');
});
});
import { AssignmentExpiryScheduler } from '../src/modules/plan/assignment-expiry.scheduler';
import type { PrismaService } from '../src/prisma/prisma.service';
/**
* 分配单到期自动回池回归。
*
* 产品定调:到期回池是给主管减负(他不必再去追"这单还要不要")。
* 但它是**系统主动把单从客服手里收走**,三条红线错一条都会造成静默的数据/信任损失:
* ① 约好回访的单绝不能被收(snoozedUntil 守卫)—— 收了就是系统主动毁客服对患者的承诺
* ② 不写 release_reason —— 那列只属于客服的处置,到期混进去退回率分子分母一起脏
* ③ 不清批次归因三列 —— 清了这批的分母就少一条
*/
const NOW = new Date('2026-08-10T03:00:00Z');
function makeService(rows: Array<Record<string, unknown>>) {
const captured: {
where?: Record<string, unknown>;
data?: Record<string, unknown>;
updateWhere?: Record<string, unknown>;
} = {};
const events: Array<Record<string, unknown>> = [];
const findMany = jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
captured.where = where;
return rows;
});
const tx = {
followupPlan: {
updateMany: jest.fn(
async (args: { where: Record<string, unknown>; data: Record<string, unknown> }) => {
captured.data = args.data;
captured.updateWhere = args.where;
return { count: rows.length };
},
),
},
planEventLog: {
createMany: jest.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => {
events.push(...data);
return { count: data.length };
}),
},
};
const prisma = {
followupPlan: { findMany },
$transaction: jest.fn(async (fn: (t: typeof tx) => Promise<unknown>) => fn(tx)),
} as unknown as PrismaService;
return { svc: new AssignmentExpiryScheduler(prisma), captured, events, findMany, tx };
}
const PLAN = {
id: 'p1',
hostId: 'h1',
tenantId: 't1',
patientId: 'pat1',
assigneeUserId: 'u-staff',
assignedAt: new Date(NOW.getTime() - 3 * 86400_000), // 3 天前分的
};
describe('AssignmentExpiryScheduler', () => {
const OLD = process.env.PAC_ASSIGNMENT_EXPIRY;
afterEach(() => {
if (OLD === undefined) delete process.env.PAC_ASSIGNMENT_EXPIRY;
else process.env.PAC_ASSIGNMENT_EXPIRY = OLD;
});
test('⭐⭐ 红线①:查询条件必须带 snoozedUntil 守卫(约好回访的单不能被收)', async () => {
delete process.env.PAC_ASSIGNMENT_EXPIRY;
const { svc, captured } = makeService([PLAN]);
await svc.runExpiry(NOW);
// 客服约了 6/10 回访、plan snooze 到 6/10 —— 到期也不能收,
// 收了客服就丢了已对患者承诺的回访关系,而且单子还会被别人从池里捞走
expect(captured.where?.OR).toEqual([
{ snoozedUntil: null },
{ snoozedUntil: { lte: expect.any(Date) } },
]);
// 只收已过期的 assigned
expect(captured.where?.status).toBe('assigned');
expect(captured.where?.assignmentExpiresAt).toMatchObject({ not: null });
expect(captured.where?.supersededAt).toBeNull();
});
test('⭐⭐ 红线②:**不写 release_reason** —— 到期不是客服的处置', async () => {
const { svc, captured } = makeService([PLAN]);
await svc.runExpiry(NOW);
// 退回(客服看了判断"不该我做")与到期(客服压根没动)是两件事。
// 混进同一列,退回率的分子分母一起虚高,而"到期未动"这个数本身才是主管要的信号。
expect(captured.data).not.toHaveProperty('releaseReason');
expect(captured.data).not.toHaveProperty('releaseNote');
});
test('⭐⭐ 红线③:**不清批次归因三列**,但清在办期限', async () => {
const { svc, captured } = makeService([PLAN]);
await svc.runExpiry(NOW);
expect(captured.data).not.toHaveProperty('assignmentId');
expect(captured.data).not.toHaveProperty('assignedBy');
expect(captured.data).not.toHaveProperty('assignStrategy');
expect(captured.data).toMatchObject({
status: 'active',
assigneeUserId: null,
assignmentExpiresAt: null,
});
});
test('⭐ 红线④:绝不动 snoozedUntil(与退回同一条纪律)', async () => {
const { svc, captured } = makeService([PLAN]);
await svc.runExpiry(NOW);
expect(captured.data).not.toHaveProperty('snoozedUntil');
});
test('落账本:auto_release + reason=assignment_expired + 持有时长算得出来', async () => {
const { svc, events } = makeService([PLAN]);
await svc.runExpiry(NOW);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
event: 'auto_release',
reason: 'assignment_expired',
assigneeUserId: null,
actorUserId: null, // 系统行为
});
// 时间界注入 → 精确 3 天,不给宽容区间也不会 flake
expect(events[0]!.heldSeconds).toBe(3 * 86400);
});
test('并发安全:updateMany 的 where 带 status 条件(客服刚提交执行则本次不生效)', async () => {
const { svc, captured } = makeService([PLAN]);
await svc.runExpiry(NOW);
expect(captured.updateWhere?.status).toBe('assigned');
});
test('无到期单 → 不发任何写(纯净轮次零副作用)', async () => {
const { svc, tx } = makeService([]);
await svc.runExpiry(NOW);
expect(tx.followupPlan.updateMany).not.toHaveBeenCalled();
expect(tx.planEventLog.createMany).not.toHaveBeenCalled();
});
test('⭐ 开关 off → 一行都不碰(连查询都不发)', async () => {
process.env.PAC_ASSIGNMENT_EXPIRY = 'off';
const { svc, findMany } = makeService([PLAN]);
await svc.runExpiry(NOW);
expect(findMany).not.toHaveBeenCalled();
});
test('⭐ 默认是**开**的 —— 与 PAC_PLAN_AUTO_RECYCLE(默认关)相反,别搞混', async () => {
delete process.env.PAC_ASSIGNMENT_EXPIRY;
const { svc, findMany } = makeService([PLAN]);
await svc.runExpiry(NOW);
expect(findMany).toHaveBeenCalled();
// 不收回的后果不是"多几条过期单",是容量口径整体失效:在手只增不减,几批后全员触顶
});
});
import { classifyPlanProgress, type PlanProgress } from '../src/modules/plan/plan-assignment.service';
/**
* 「处理了没」的判据回归(产品裁决 2026-08-02)。
*
* 口径:**看池子状态,不等回写** ——
* 这个患者的召回还在不在池(出池 = 引擎判定无活信号)、有没有被抑制(客服写了回访结果),
* 两者任一成立就算处理过了。⚠️ 是**处理**不是**成功**。
*
* 🔴 这里最贵的一条是「五桶穷尽」:少一个分支,done + pending 就加不出 planned,
* 主管一对数发现少了人,而少的那几个**永远查不出去哪了**。
*/
const NOW = new Date('2026-08-02T12:00:00.000Z');
const future = new Date('2026-09-01T00:00:00.000Z');
const past = new Date('2026-07-01T00:00:00.000Z');
const p = (over: Partial<Parameters<typeof classifyPlanProgress>[0]>) =>
classifyPlanProgress(
{ status: 'active', assigneeUserId: null, snoozedUntil: null, ...over },
NOW,
);
describe('处理判据 —— 出池(零回写依赖的那一半)', () => {
test('⭐⭐ superseded = 引擎判定该患者已无活信号 → **已处理**', () => {
// 这是整套口径的基石:治疗真做了 / 诊断没了 → closeStaleActivePlan 把它 supersede。
// 客服写不写回访结果都成立 —— 生产回写率只有 11%,靠回写等于 89% 的活白干。
expect(p({ status: 'superseded' })).toBe('resolved');
});
test('⭐ superseded 优先于一切 —— 就算还挂在客服名下也算出池', () => {
expect(p({ status: 'superseded', assigneeUserId: 'a1' })).toBe('resolved');
});
test('completed / abandoned → 已结案', () => {
expect(p({ status: 'completed' })).toBe('closed');
expect(p({ status: 'abandoned' })).toBe('closed');
});
});
describe('处理判据 —— 抑制(有回写的那一半)', () => {
test('⭐⭐ snoozedUntil 在未来 = 客服写了回访结果 → **已处理**', () => {
expect(p({ status: 'assigned', assigneeUserId: 'a1', snoozedUntil: future })).toBe('suppressed');
});
test('⭐⭐ 抑制**优先于在手** —— 约了下次回访,单子还在他名下,但这一单确实处理过了', () => {
// 排成 inHandPending 会让「已处理」少算一大块,而那正是唯一有回写的那部分。
expect(p({ status: 'assigned', assigneeUserId: 'a1', snoozedUntil: future })).not.toBe('inHandPending');
});
test('⭐ 抑制**过期了**就不算处理了(到点该重新浮现)', () => {
expect(p({ status: 'assigned', assigneeUserId: 'a1', snoozedUntil: past })).toBe('inHandPending');
expect(p({ status: 'active', assigneeUserId: null, snoozedUntil: past })).toBe('backToPool');
});
});
describe('处理判据 —— 未处理的两种', () => {
test('还在客服手上、没写任何结果 → 在手未处理', () => {
expect(p({ status: 'assigned', assigneeUserId: 'a1' })).toBe('inHandPending');
});
test('⭐ 退回 / 到期 / 撤销放回 → 回池(= 没处理成)', () => {
expect(p({ status: 'active', assigneeUserId: null })).toBe('backToPool');
});
});
describe('⭐⭐ 五桶必须穷尽 —— 加不出 planned 就是静默丢人', () => {
test('任何 (status × assignee × snoozed) 组合都落进恰好一个桶', () => {
const statuses = ['active', 'assigned', 'completed', 'abandoned', 'superseded', '未来新增的状态'];
const assignees = [null, 'a1'];
const snoozes = [null, future, past];
const buckets: PlanProgress[] = ['resolved', 'suppressed', 'closed', 'inHandPending', 'backToPool'];
for (const status of statuses) {
for (const assigneeUserId of assignees) {
for (const snoozedUntil of snoozes) {
const got = classifyPlanProgress({ status, assigneeUserId, snoozedUntil }, NOW);
// ⛔ 兜底必须存在:哪天有人加了个新 status,不能让它静默消失
expect(buckets).toContain(got);
}
}
}
});
test('⭐ done + inHandPending + backToPool 恒等于总数', () => {
const rows = [
{ status: 'superseded', assigneeUserId: null, snoozedUntil: null },
{ status: 'superseded', assigneeUserId: 'a1', snoozedUntil: null },
{ status: 'assigned', assigneeUserId: 'a1', snoozedUntil: future },
{ status: 'completed', assigneeUserId: 'a1', snoozedUntil: null },
{ status: 'assigned', assigneeUserId: 'a2', snoozedUntil: null },
{ status: 'active', assigneeUserId: null, snoozedUntil: null },
{ status: 'active', assigneeUserId: null, snoozedUntil: past },
];
const c = { resolved: 0, suppressed: 0, closed: 0, inHandPending: 0, backToPool: 0 };
for (const r of rows) c[classifyPlanProgress(r, NOW)]++;
const done = c.resolved + c.suppressed + c.closed;
expect(done + c.inHandPending + c.backToPool).toBe(rows.length);
expect(done).toBe(4); // 2 出池 + 1 抑制 + 1 结案
expect(c.inHandPending).toBe(1);
expect(c.backToPool).toBe(2);
});
});
import { Temperature } from '@pac/types';
import { assertCohortCriteria, cohortWhereSql } from '../src/modules/plan/cohort-filter';
/**
* 人群取数口径回归。
*
* 这一层的错误全都是**静默**的:SQL 照跑、不报错,只是圈出来的人不对。
* 所以这里逐条锁"生成的 SQL 里必须/不许出现什么" —— 便宜,且拦得住最贵的那几种改法。
*/
const SCOPE = {
hostId: '00000000-0000-0000-0000-000000000001',
tenantId: 't1',
sourceUnits: [] as string[],
clinicIds: ['c1'],
userId: 'u1',
} as never;
const NOW = new Date('2026-08-02T00:00:00.000Z');
/** Prisma.Sql 的 strings 拼起来 = 去掉参数占位的 SQL 骨架 */
const sqlOf = (...args: Parameters<typeof cohortWhereSql>) => cohortWhereSql(...args).strings.join('?');
describe('人群取数 —— 池子基线', () => {
test('⭐ 只圈**未被认领**的活跃 plan,且冷静期已过', () => {
const s = sqlOf(SCOPE, { clinicId: 'c1' }, NOW);
expect(s).toContain("fp.status = 'active'");
expect(s).toContain('fp.assignee_user_id IS NULL');
expect(s).toContain('fp.superseded_at IS NULL');
// ⚠️ 约好 6/10 回访的患者不能被卷进批次
expect(s).toContain('fp.snoozed_until');
});
test('⭐ 批次不跨诊所:诊所是硬条件不是可选过滤', () => {
expect(sqlOf(SCOPE, { clinicId: 'c1' }, NOW)).toContain('fp.target_clinic_id');
});
});
describe('人群取数 —— 画像必须按患者取当前版', () => {
const all = sqlOf(
SCOPE,
{ clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT, personaTags: 'rfm:important_value' },
NOW,
);
test('⭐⭐ ⛔ 一处都不许出现 `pe.id = fp.persona_id`(升一次画像版本就静默归零)', () => {
// 教条 §4.38:plan.persona_id 指向建 plan 那刻的版本,画像升版本后该条件恒为假。
expect(all).not.toMatch(/pe\.id\s*=\s*fp\.persona_id/);
});
test('⭐ 三处(治疗项/温度/画像标签)全部按 patient_id + 当前版关联', () => {
expect(all.match(/pe\.patient_id = fp\.patient_id/g)?.length).toBe(3);
expect(all.match(/pe\.superseded_at IS NULL/g)?.length).toBe(3);
});
});
describe('人群取数 —— 温度', () => {
test('⭐⭐ 温度必须挂在治疗项上:光给温度直接拒', () => {
// 同一个人可能「潜在种植·热」而「潜在补牙·冷」,不指定治疗项的"热"是无意义的。
// ⛔ 尤其不许"贴心地"给个默认治疗项 —— 那会让主管拿到一批他没要的人。
expect(() => assertCohortCriteria({ clinicId: 'c1', temperature: Temperature.HOT })).toThrow(/必须与潜在治疗项/);
expect(() => assertCohortCriteria({ clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT })).not.toThrow();
});
test('⭐⭐ 老画像(无边界)⛔ 不许掉进任何一档 —— 尤其不许默认掉进冷', () => {
for (const t of [Temperature.HOT, Temperature.WARM, Temperature.COLD]) {
const s = sqlOf(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant', temperature: t }, NOW);
// 边界键整个不存在时 `#>` 返回 NULL,这条把它挡在三档之外
expect(s).toContain('IS NOT NULL');
}
});
test('⭐⭐ 走**稳定路径** temperature.<标签>,⛔ 不走 detail 数组下标', () => {
// detail 是数组,同一标签在不同患者身上下标不同 → `detail.0.hotUntil` 对谁都不成立,
// 于是列表页(Prisma json 路径过滤)根本表达不了这个谓词,矩阵与列表就再也对不上数。
const s = sqlOf(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT }, NOW);
expect(s).not.toContain('jsonb_array_elements');
expect(s).toContain("pf.data #>>");
});
test('⭐ 三档互斥且穷尽(有边界的人必进且只进一档)—— 否则矩阵加不出总数', () => {
const of = (t: (typeof Temperature)[keyof typeof Temperature]) =>
cohortWhereSql(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant', temperature: t }, NOW);
// 参数里能看出各档比的是哪个边界:热/冷各一个比较,温两个
const cmp = (s: ReturnType<typeof of>) => s.strings.join('?').match(/<=|>/g)?.length ?? 0;
expect(cmp(of(Temperature.HOT))).toBeGreaterThanOrEqual(1);
expect(cmp(of(Temperature.WARM))).toBeGreaterThan(cmp(of(Temperature.COLD)));
// 各档引用的边界键:hot→hotUntil / cold→warmUntil / warm→两者
const keysOf = (t: (typeof Temperature)[keyof typeof Temperature]) =>
of(t).values.flat().filter((v) => v === 'hotUntil' || v === 'warmUntil');
expect(keysOf(Temperature.HOT)).toEqual(['hotUntil']);
expect(keysOf(Temperature.COLD)).toEqual(['warmUntil']);
expect(keysOf(Temperature.WARM).sort()).toEqual(['hotUntil', 'warmUntil']);
});
test('⛔ 温度**不做任何天数运算** —— 天数是时钟,边界才是事实', () => {
const s = sqlOf(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT }, NOW);
expect(s).not.toContain('daysSince');
expect(s).not.toMatch(/EXTRACT\s*\(\s*DAY/i);
});
});
describe('人群取数 —— 画像标签', () => {
test('⭐ 跨维度 AND:两个维度各出一个 EXISTS,不合并成 OR', () => {
const s = sqlOf(SCOPE, { clinicId: 'c1', personaTags: 'rfm:important_value,gender:male' }, NOW);
expect(s.match(/AND EXISTS \(/g)?.length).toBe(2);
});
test('⭐ 非法维度 / 非法取值静默丢弃(与列表页同行为,不报错)', () => {
const bogus = sqlOf(SCOPE, { clinicId: 'c1', personaTags: 'not_a_dim:x,rfm:not_a_value' }, NOW);
const none = sqlOf(SCOPE, { clinicId: 'c1' }, NOW);
expect(bogus).toBe(none);
});
test('⭐ 数组维度走 @> 包含,标量维度走等值 —— 用错索引吃不到,生产实测会退化到 20 秒', () => {
expect(sqlOf(SCOPE, { clinicId: 'c1', personaTags: 'entitlement_status:medical' }, NOW)).toContain('@>');
expect(sqlOf(SCOPE, { clinicId: 'c1', personaTags: 'gender:male' }, NOW)).toContain('pf.data ->>');
});
test('⭐ 「上次到诊」走 patient_profiles 的日期区间,不穿画像表', () => {
const s = sqlOf(SCOPE, { clinicId: 'c1', personaTags: 'last_visit_bucket:0_3m' }, NOW);
expect(s).toContain('patient_profiles');
expect(s).toContain('pp.last_visit_at');
});
});
import { EXECUTION_OUTCOME_META, EXECUTION_OUTCOME_GROUP_META } from '@pac/types';
/**
* 执行结果的**归类**与**状态机**是两件事,这个 spec 锁的就是它们的分离。
*
* 🔴 `scheduled_next`(约定下次回访)是唯一一个 `group` 与 `drivesStatus` **故意不一致**的项:
* group: 'close' —— 统计口径:约到下次是**有效推进**,不该跟"未接通/秒挂"一起算「保持」
* drivesStatus: 'keep' —— 状态机:工单必须留着并 snooze 到回访日
* 看着像笔误,所以极可能被后来的人"顺手改一致"。而改成 'completed' 的后果是:
* 客服刚答应了患者"那天再联系您",这单却当场出池,**约好的回访再也不会发生**,且不报错。
*/
describe('执行结果分类 —— 归类口径 ≠ 状态机', () => {
test('⭐⭐ 约定下次回访:算「成功」但**不结案**', () => {
const m = EXECUTION_OUTCOME_META.scheduled_next;
expect(m.group).toBe('close'); // 统计上算成功
expect(m.drivesStatus).toBe('keep'); // ⛔ 绝不能是 completed
});
test('⭐ 转化新预约:算「成功」且结案(顶栏「预约」快速记录落的就是它)', () => {
const m = EXECUTION_OUTCOME_META.success_appointed;
expect(m.group).toBe('close');
expect(m.drivesStatus).toBe('completed');
});
test('「保持」组里只剩真正没进展的(未接通 / 秒挂)', () => {
const keep = Object.entries(EXECUTION_OUTCOME_META)
.filter(([, m]) => m.group === 'keep' && !m.hiddenInForm)
.map(([k]) => k)
.sort();
expect(keep).toEqual(['no_answer', 'quick_hangup']);
});
test('⛔ 归到「成功」组的,要么结案、要么留单 —— 但**绝不能**是 abandoned', () => {
// abandoned 是"放弃",出现在成功组等于把放弃算成成功,统计当场反向
for (const [key, m] of Object.entries(EXECUTION_OUTCOME_META)) {
if (m.group === 'close') {
expect([key, m.drivesStatus]).not.toEqual([key, 'abandoned']);
}
}
});
test('每个 outcome 的 group 都必须是已登记的组(拼错会静默从面板消失)', () => {
const groups = new Set(Object.keys(EXECUTION_OUTCOME_GROUP_META));
for (const [key, m] of Object.entries(EXECUTION_OUTCOME_META)) {
expect([key, groups.has(m.group)]).toEqual([key, true]);
}
});
});
import {
Permission,
ROLE_PERMISSIONS,
UserRole,
ReleaseReason,
RELEASE_REASON_META,
ReleaseReasonSchema,
releaseReasonsForForm,
PlanEventReason,
ABANDON_REASON_META,
RECALL_FEEDBACK_OPTIONS,
} from '@pac/types';
/**
* 召回分配 · 地基枚举回归(P0.1 + P0.2)。
*
* 这个文件锁的都是「改错了不会有任何编译错误」的地方 —— 全是运行时语义,
* 类型系统救不了,只能靠断言。
*/
describe('PLAN_DISPATCH —— 主管判据', () => {
test('⭐ 红线:staff 永远不能有 plan:dispatch', () => {
// 加给 staff 就跟 PLAN_ASSIGN 一样废掉 —— 那条权限 staff 也有,
// 正是"不能用它区分主管"的原因,新权限不能重蹈覆辙。
expect(ROLE_PERMISSIONS[UserRole.STAFF]).not.toContain(Permission.PLAN_DISPATCH);
});
test('leader / admin 都有', () => {
expect(ROLE_PERMISSIONS[UserRole.LEADER]).toContain(Permission.PLAN_DISPATCH);
expect(ROLE_PERMISSIONS[UserRole.ADMIN]).toContain(Permission.PLAN_DISPATCH);
});
test('⭐ 现有权限里没有任何一条能替代它当主管判据', () => {
// PLAN_ASSIGN:staff 也有(自助认领语义)→ 区分不了
expect(ROLE_PERMISSIONS[UserRole.STAFF]).toContain(Permission.PLAN_ASSIGN);
// PLAN_RECYCLE:同上
expect(ROLE_PERMISSIONS[UserRole.STAFF]).toContain(Permission.PLAN_RECYCLE);
// 于是 leader 相对 staff 只多这几条,PLAN_DISPATCH 是其中唯一为分配而立的
const extra = ROLE_PERMISSIONS[UserRole.LEADER].filter(
(p) => !ROLE_PERMISSIONS[UserRole.STAFF].includes(p),
);
expect(extra).toContain(Permission.PLAN_DISPATCH);
});
});
describe('ReleaseReason —— 退回原因', () => {
test('枚举与 zod schema 值域必须一致(加了枚举忘了改 schema → 服务端 400)', () => {
expect([...ReleaseReasonSchema.options].sort()).toEqual([...Object.values(ReleaseReason)].sort());
});
test('每个值都在 META 里登记,且 META 无孤儿键', () => {
expect(Object.keys(RELEASE_REASON_META).sort()).toEqual([...Object.values(ReleaseReason)].sort());
for (const k of Object.values(ReleaseReason)) {
expect(RELEASE_REASON_META[k].labelZh.length).toBeGreaterThan(0);
}
});
test('⭐⭐ 红线:退回原因绝不能带抑制窗', () => {
// 退回 = "换个人来做",不是"别做了"。照抄 ABANDON_REASON_META 的 suppressDays
// 会把被退回的患者静默压 30~90 天 —— 池子里凭空少一批人,且没有任何报错。
// TS 类型里已经不给这个字段,这里再从运行时锁一道(防有人改类型时顺手加回来)。
for (const k of Object.values(ReleaseReason)) {
expect(RELEASE_REASON_META[k]).not.toHaveProperty('suppressDays');
}
// 对照:放弃原因是有抑制窗的,两者语义不同不能混用
expect(ABANDON_REASON_META.wrong_number.suppressDays).toBeGreaterThan(0);
});
test('⭐ 与召回反馈值域不重叠 —— 同名不同义是统计事故的标准配方', () => {
// RECALL_FEEDBACK_OPTIONS.bad_timing = "召回时机不对(太早/太晚)",冲算法去的;
// 退回的"时效太紧"冲派单去的。故本枚举刻意不设 bad_timing。
const feedbackValues = RECALL_FEEDBACK_OPTIONS.map((o) => o.value as string);
for (const k of Object.values(ReleaseReason)) {
if (k === ReleaseReason.OTHER) continue; // other 是通用兜底键,允许同名
expect(feedbackValues).not.toContain(k);
}
});
test('other 必须填说明', () => {
expect(RELEASE_REASON_META.other.needNote).toBe(true);
// 其余的都不强制填,否则退回摩擦过大,客服会改走"关闭机会"污染放弃统计
const needNote = Object.values(ReleaseReason).filter((k) => RELEASE_REASON_META[k].needNote);
expect(needNote).toEqual([ReleaseReason.OTHER]);
});
test('每个原因都指向一根可调杠杆(否则原因分布只是好看的饼图)', () => {
for (const k of Object.values(ReleaseReason)) {
const { lever } = RELEASE_REASON_META[k];
expect(lever).toBeTruthy();
// 只有 other 允许无法归因
if (k !== ReleaseReason.OTHER) expect(lever).not.toBe('none');
}
});
test('表单清单过滤 hidden,且顺序 = 声明顺序', () => {
const shown = releaseReasonsForForm();
expect(shown).toEqual(Object.keys(RELEASE_REASON_META).filter(
(k) => !RELEASE_REASON_META[k as ReleaseReason].hidden,
));
expect(shown.length).toBe(8);
});
});
describe('PlanEventReason —— plan_event_logs.reason 取值登记', () => {
test('存量取值仍在(改名会让历史事件读不出来)', () => {
// 这三个生产库里已有数据,枚举里的字面量**不能改**
expect(PlanEventReason.UP).toBe('up');
expect(PlanEventReason.DOWN).toBe('down');
expect(PlanEventReason.TIMEOUT).toBe('timeout');
});
test('引擎补记账要用的两个新原因已登记', () => {
expect(PlanEventReason.CLINIC_MOVED).toBe('clinic_moved');
expect(PlanEventReason.SIGNALS_CLEARED).toBe('signals_cleared');
});
test('⭐ 系统原因与退回原因不得撞值 —— 撞了聚合就串了', () => {
// 两者共用 reason 一列:auto_release 事件带系统原因,release 事件带退回原因。
// 若有同值,「退回原因分布」的 SQL 即使按 event 过滤对了,人读也会混。
const sys = Object.values(PlanEventReason) as string[];
for (const r of Object.values(ReleaseReason)) {
expect(sys).not.toContain(r as string);
}
});
});
...@@ -26,6 +26,16 @@ function makePrismaMock(opts: { ...@@ -26,6 +26,16 @@ function makePrismaMock(opts: {
count: Array.isArray(where?.id?.in) ? where.id.in.length : 0, count: Array.isArray(where?.id?.in) ? where.id.in.length : 0,
})); }));
const create = jest.fn().mockResolvedValue({ id: 'new-plan' }); const create = jest.fn().mockResolvedValue({ id: 'new-plan' });
// 归属账本(2026-08 补记账):收集写入内容供断言
const events: Array<Record<string, unknown>> = [];
const eventCreate = jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
events.push(data);
return data;
});
const eventCreateMany = jest.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => {
events.push(...data);
return { count: data.length };
});
const findFirst = jest.fn().mockResolvedValue(opts.latestPlan ?? null); const findFirst = jest.fn().mockResolvedValue(opts.latestPlan ?? null);
const findMany = jest.fn().mockImplementation(async ({ where }) => { const findMany = jest.fn().mockImplementation(async ({ where }) => {
// runAllForHost 关闭闸:status IN ('active','assigned') // runAllForHost 关闭闸:status IN ('active','assigned')
...@@ -50,15 +60,23 @@ function makePrismaMock(opts: { ...@@ -50,15 +60,23 @@ function makePrismaMock(opts: {
// 批量路径日志一次性 createMany // 批量路径日志一次性 createMany
createMany: jest.fn().mockResolvedValue({ count: 0 }), createMany: jest.fn().mockResolvedValue({ count: 0 }),
}, },
planEventLog: { create: eventCreate, createMany: eventCreateMany },
// ⚠️ tx 里必须复用**同一批** spy —— 关闭 plan 现在跑在事务内(要与归属账本同生共死),
// 若这里另起 jest.fn(),外层对 update/updateMany 的调用断言会全部落空。
$transaction: jest $transaction: jest
.fn() .fn()
.mockImplementation(async (cb: (tx: unknown) => Promise<unknown>) => .mockImplementation(async (cb: (tx: unknown) => Promise<unknown>) =>
cb({ cb({
followupPlan: { update: jest.fn().mockResolvedValue({}), create: jest.fn().mockResolvedValue({}) }, followupPlan: { update, updateMany, create },
planEventLog: { create: eventCreate, createMany: eventCreateMany },
}), }),
), ),
}; };
return { prisma, spies: { update, updateMany, create, findFirst, findMany } }; return {
prisma,
events,
spies: { update, updateMany, create, findFirst, findMany, eventCreate, eventCreateMany },
};
} }
function makeScenario(hits: ScenarioHit[]) { function makeScenario(hits: ScenarioHit[]) {
......
...@@ -144,6 +144,18 @@ describe('PlanEventType / PLAN_EVENT_META — 扩展性约束', () => { ...@@ -144,6 +144,18 @@ describe('PlanEventType / PLAN_EVENT_META — 扩展性约束', () => {
); );
}); });
test('⭐⭐ 复制话术不算「处理过患者」—— 复制 ≠ 联系了患者', () => {
// 复制完可能压根没发出去。算进 HUMAN_TOUCH_EVENTS 的话,「处理过的患者」
// 会静默膨胀成"复制一下也算" —— 与 view 同一类错误,而它是 filter 派生的,
// 改错**不会有任何编译错误**。
expect(PLAN_EVENT_META.script_copy.byHuman).toBe(false);
expect(HUMAN_TOUCH_EVENTS).not.toContain(PlanEventType.SCRIPT_COPY);
});
test('⭐ 复制话术不参与归属区间(可能发生在未认领的单上)', () => {
expect(PLAN_EVENT_META.script_copy.holdsPatient).toBe(false);
});
test('归属类事件的 holdsPatient 口径正确(算归属区间用)', () => { test('归属类事件的 holdsPatient 口径正确(算归属区间用)', () => {
expect(PLAN_EVENT_META.claim.holdsPatient).toBe(true); expect(PLAN_EVENT_META.claim.holdsPatient).toBe(true);
expect(PLAN_EVENT_META.assign.holdsPatient).toBe(true); expect(PLAN_EVENT_META.assign.holdsPatient).toBe(true);
......
import { Test } from '@nestjs/testing';
import { ReleaseReason } from '@pac/types';
import { PlanService } from '../src/modules/plan/plan.service';
import { PrismaService } from '../src/prisma/prisma.service';
/**
* 召回分配 P0.6 / P0.7 回归 —— 退回原因落库 + 写路径的诊所硬边界。
*
* 两条都属于「不修就会静默出错」的类型:
* · 退回原因原本在 controller 就被丢掉(`@Body() _dto`),客服填了等于没填,
* 而退回原因分布是主管调整分配策略的唯一输入(T7)。
* · assign/recycle 的 findFirst 不校验 targetClinicId,A 诊所 leader 拿到 B 诊所的
* planId 就能跨诊所写入 —— 读路径挡着(他看不到),写路径不挡。
*/
const HOST = 'h1';
const TENANT = 't1';
function scopeWith(clinicIds: string[]) {
return { hostId: HOST, tenantId: TENANT, sourceUnits: [] as string[], clinicIds } as never;
}
const BASE_PLAN = {
id: 'p1',
hostId: HOST,
tenantId: TENANT,
patientId: 'pat1',
status: 'assigned',
assigneeUserId: 'u-me',
assignedAt: new Date(Date.now() - 3600_000),
targetClinicId: 'clinic-a',
};
/**
* findFirst 按 where 真实过滤 —— 只建模本用例关心的两维:id 与 clinicBoundary 的 AND/OR。
* (不建模就等于把"边界有没有生效"这件事假设掉了,而那正是要测的东西。)
*/
function makePrisma(plan: Record<string, unknown> | null = BASE_PLAN) {
const updates: Record<string, unknown>[] = [];
const events: Record<string, unknown>[] = [];
const tx = {
followupPlan: {
update: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
updates.push(data);
return {};
}),
},
planEventLog: {
create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
events.push(data);
return data;
}),
},
};
const findFirst = jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
if (!plan) return null;
const and = where.AND as Array<{ OR: Array<Record<string, unknown>> }> | undefined;
if (and?.length) {
const allowed = and[0]!.OR.flatMap((c) => {
const t = c.targetClinicId as { in?: string[] } | null;
return t === null ? [null] : (t?.in ?? []);
});
if (!allowed.includes(plan.targetClinicId as string | null)) return null;
}
return plan;
});
const prisma = {
followupPlan: { findFirst, findMany: jest.fn(async () => []) },
$transaction: jest.fn(async (fn: (t: typeof tx) => Promise<unknown>) => fn(tx)),
};
return { prisma: prisma as unknown as PrismaService, updates, events, findFirst };
}
async function buildService(prisma: PrismaService): Promise<PlanService> {
const mod = await Test.createTestingModule({
providers: [PlanService, { provide: PrismaService, useValue: prisma }],
})
.useMocker(() => ({}))
.compile();
return mod.get(PlanService);
}
describe('recycle —— 退回原因必须落到账本(T7)', () => {
test('⭐ 带结构化原因 → 落进 plan_event_logs.reason,事件类型是 release', async () => {
const { prisma, events } = makePrisma();
const svc = await buildService(prisma);
await svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.OVER_CAPACITY);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
event: 'release',
reason: 'over_capacity',
assigneeUserId: null,
});
});
test('文字说明进 details,不占 reason 列(reason 要能直接 GROUP BY)', async () => {
const { prisma, events } = makePrisma();
const svc = await buildService(prisma);
await svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.OTHER, ' 号码是空号 ');
expect(events[0]).toMatchObject({ reason: 'other', details: { note: '号码是空号' } });
});
test('⭐ other 不写说明 → **服务端**拒绝(不能只信前端)', async () => {
const { prisma } = makePrisma();
const svc = await buildService(prisma);
await expect(
svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.OTHER),
).rejects.toThrow(/必须填写说明/);
// 空白串也算没填
await expect(
svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.OTHER, ' '),
).rejects.toThrow(/必须填写说明/);
});
test('不填原因仍可退(自助认领的单不强制)—— reason 落 null', async () => {
const { prisma, events } = makePrisma();
const svc = await buildService(prisma);
await svc.recycle(scopeWith([]), 'p1', 'u-me', true);
expect(events[0]).toMatchObject({ event: 'release', reason: null });
});
test('⭐⭐ 红线:退回**绝不动 snoozedUntil**(动了池子里会凭空少一批人)', async () => {
const { prisma, updates } = makePrisma();
const svc = await buildService(prisma);
await svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.RECENTLY_CONTACTED);
expect(updates).toHaveLength(1);
// 「最近刚联系过」最像该压一压的原因 —— 正因为像,才要在这里钉死
expect(updates[0]).not.toHaveProperty('snoozedUntil');
expect(updates[0]).toMatchObject({ status: 'active', assigneeUserId: null });
});
test('⭐ 退回原因同时落主表当前值(列表页要直接显示,不必回查事件流)', async () => {
const { prisma, updates } = makePrisma();
const svc = await buildService(prisma);
await svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.NOT_MY_PATIENT);
expect(updates[0]).toMatchObject({ releaseReason: 'not_my_patient', releaseNote: null });
});
test('⭐⭐ 红线:退回**不清 assignment_id / assigned_by / assign_strategy** —— 那是退回率的分母', async () => {
const { prisma, updates } = makePrisma();
const svc = await buildService(prisma);
await svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.OVER_CAPACITY);
// 「这批分了 60 条,退回 5 条」全靠 assignment_id 还在;清了就只剩分子
expect(updates[0]).not.toHaveProperty('assignmentId');
expect(updates[0]).not.toHaveProperty('assignedBy');
expect(updates[0]).not.toHaveProperty('assignStrategy');
// 但「在办期限」要随归属一起结束(列的自洽:非空 ⟺ 有一次在办的分配)
expect(updates[0]).toMatchObject({ assignmentExpiresAt: null });
});
});
describe('assign —— 归属列与批次列的分工', () => {
test('⭐ 记 assignedBy;⛔ 不碰 assignmentId / assignStrategy(那是批量写入的活)', async () => {
const { prisma, updates } = makePrisma({ ...BASE_PLAN, status: 'active', assigneeUserId: null });
const svc = await buildService(prisma);
await svc.assign(scopeWith([]), 'p1', 'u-staff', 'u-leader');
expect(updates[0]).toMatchObject({ assigneeUserId: 'u-staff', assignedBy: 'u-leader' });
// 单条认领/指派不属于任何批次 —— 瞎写会造出归属不到任何批次的"孤儿归因"
expect(updates[0]).not.toHaveProperty('assignmentId');
expect(updates[0]).not.toHaveProperty('assignStrategy');
});
test('⭐ 重新有人接手 → 清掉上一次的退回原因(否则主表自相矛盾)', async () => {
const { prisma, updates } = makePrisma({
...BASE_PLAN,
status: 'active',
assigneeUserId: null,
releaseReason: 'over_capacity',
});
const svc = await buildService(prisma);
await svc.assign(scopeWith([]), 'p1', 'u-staff', 'u-staff');
// 不清的话会同时显示「已分配给 u-staff」和「因手上排满被退回」
expect(updates[0]).toMatchObject({ releaseReason: null, releaseNote: null });
});
test('自认领时 assignedBy == assignee(主表上就能分出"自己领的"与"主管派的")', async () => {
const { prisma, updates } = makePrisma({ ...BASE_PLAN, status: 'active', assigneeUserId: null });
const svc = await buildService(prisma);
await svc.assign(scopeWith([]), 'p1', 'u-me', 'u-me');
expect(updates[0]).toMatchObject({ assigneeUserId: 'u-me', assignedBy: 'u-me' });
});
});
describe('写路径诊所硬边界(F4)', () => {
test('⭐ 跨诊所 assign → 找不到,不是"分成功了"', async () => {
// scope 只有 clinic-b,plan 属于 clinic-a
const { prisma } = makePrisma();
const svc = await buildService(prisma);
await expect(svc.assign(scopeWith(['clinic-b']), 'p1', 'u-other', 'u-leader')).rejects.toThrow(
/not found/i,
);
});
test('⭐ 跨诊所 recycle → 同样拒绝', async () => {
const { prisma } = makePrisma();
const svc = await buildService(prisma);
await expect(svc.recycle(scopeWith(['clinic-b']), 'p1', 'u-leader', true)).rejects.toThrow(
/not found/i,
);
});
test('本诊所的单照常放行(边界不能误伤)', async () => {
const { prisma, events } = makePrisma();
const svc = await buildService(prisma);
await svc.recycle(scopeWith(['clinic-a']), 'p1', 'u-me', true);
expect(events).toHaveLength(1);
});
test('⭐ 集团池(targetClinicId=null)窄 scope 也能操作 —— 它不属于任何诊所', async () => {
const { prisma, events } = makePrisma({ ...BASE_PLAN, targetClinicId: null });
const svc = await buildService(prisma);
await svc.recycle(scopeWith(['clinic-b']), 'p1', 'u-me', true);
expect(events).toHaveLength(1);
});
test('集团级 scope(clinicIds 为空)不加任何过滤', async () => {
const { prisma, findFirst } = makePrisma();
const svc = await buildService(prisma);
await svc.recycle(scopeWith([]), 'p1', 'u-me', true);
expect((findFirst.mock.calls[0]![0] as { where: Record<string, unknown> }).where.AND)
.toBeUndefined();
});
});
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* 🔴 路由遮蔽 —— `xxx:verb` 风格的路径会把同前缀的字面量路由**整个吞掉**。
*
* Express 把 `:id/script:regenerate` 里的 `:regenerate` 当**路径参数**,
* 模式实际是「字面量 `script` + 参数」。于是:
* POST /plans/x/script-feedback → 命中 script:regenerate(参数 = `-feedback`)
* POST /plans/x/script-copy → 同上(参数 = `-copy`)
*
* 实测(2026-08-04):话术 👍👎 反馈按钮**从来没生效过** —— 每点一次实际在重新生成话术
* (真花 AI 钱 + 覆盖已有稿),而前端拿到 200、界面一切正常,**不会有任何报错**。
*
* 治标是声明顺序(字面量在前),治本是把 `:verb` 改成 `/verb`。在改之前,这个 spec
* 锁住顺序 —— 它是纯文本检查,因为这个 bug 恰恰不会在类型或运行时暴露。
*/
const CONTROLLER = join(
__dirname,
'../src/modules/plan-aggregate/plans-aggregate.controller.ts',
);
/** 取出所有 @Post/@Get 装饰器里的路径,按**声明顺序** */
function declaredRoutes(): Array<{ method: string; path: string; at: number }> {
const src = readFileSync(CONTROLLER, 'utf-8');
const out: Array<{ method: string; path: string; at: number }> = [];
const re = /@(Post|Get)\(\s*'([^']+)'\s*\)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(src)) !== null) {
out.push({ method: m[1]!, path: m[2]!, at: m.index });
}
return out;
}
/** `a` 会不会把 `b` 吞掉:a 形如 `前缀:参数`,而 b 以同一个前缀开头 */
function shadows(a: string, b: string): boolean {
const i = a.indexOf(':', a.indexOf('/') + 1); // 跳过开头的 :id
if (i === -1) return false;
const literal = a.slice(0, i); // 如 ':id/script'
if (!literal.includes('/')) return false;
// b 必须以同一段字面量开头,且后面还有内容(那部分会被当成参数吃掉)
return b !== a && b.startsWith(literal) && b.length > literal.length && !b.slice(literal.length).startsWith('/');
}
describe('路由遮蔽 —— 字面量路由必须声明在 `:verb` 路由之前', () => {
test('⭐⭐ 任何被 `:verb` 路由遮蔽的字面量路由,都必须声明得更早', () => {
const routes = declaredRoutes();
const problems: string[] = [];
for (const colonRoute of routes) {
if (!shadows(colonRoute.path, colonRoute.path)) {
// colonRoute 自身含 `:verb` 才有遮蔽能力
}
for (const literal of routes) {
if (literal.method !== colonRoute.method) continue;
if (!shadows(colonRoute.path, literal.path)) continue;
if (literal.at > colonRoute.at) {
problems.push(
`${literal.method} '${literal.path}' 声明在 '${colonRoute.path}' 之后 → 会被它整个吞掉(永远进不来)`,
);
}
}
}
expect(problems).toEqual([]);
});
test('⭐ 已知的两条:script-feedback / script-copy 都排在 script:regenerate 之前', () => {
const routes = declaredRoutes();
const at = (p: string) => routes.find((r) => r.path === p)?.at ?? -1;
expect(at(':id/script-feedback')).toBeGreaterThan(-1);
expect(at(':id/script-copy')).toBeGreaterThan(-1);
expect(at(':id/script-feedback')).toBeLessThan(at(':id/script:regenerate'));
expect(at(':id/script-copy')).toBeLessThan(at(':id/script:regenerate'));
});
});
import { benefitBlock } from '../src/modules/ai/calls/draft-plan-script/shared/fact-block';
/**
* 福利进话术的护栏回归。
*
* 🔴 这是整个分配功能里**唯一一条「做错了会对患者做出虚假承诺」**的路:
* 模型拿到一句「8 月种植体检免费」,极容易顺手补成「限本月前 20 名」「老客户专享」
* 「可叠加其他折扣」—— 患者按它上门,前台兑现不了。
*
* 护栏本身是提示词,单测拦不住 LLM 的自由发挥;这里锁的是**护栏必须在场**、
* 以及**没配福利时整段不出现**(留个空钩子模型就会自己编一个)。
*/
describe('福利进话术 —— 护栏必须在场', () => {
const block = benefitBlock({ text: '8 月种植体检免费' });
test('⭐ 福利原文原样出现,且标明「只有这一句是真的」', () => {
expect(block).toContain('8 月种植体检免费');
expect(block).toContain('只有这一句是真的');
});
test('⭐⭐ 四条禁令一条都不能少 —— 少哪条模型就会在哪条上自由发挥', () => {
// 每一条都对应一种真实会发生的编造:
expect(block).toContain('不得追加任何条件、期限、名额、人群限定'); // "限本月前20名"
expect(block).toContain('不得改写金额/折扣/项目'); // 把"免费"说成"5折"或反之
expect(block).toContain('不得夸大'); // "史上最低"
expect(block).toContain('原文没写的一律不说'); // 兜底
});
test('⭐ 追问细节要有出口,否则模型会现编一个条款', () => {
expect(block).toContain('以到院时前台说明为准');
});
test('标成硬约束,与「高龄不主推种植」同级 —— 不是可选修饰', () => {
expect(block).toContain('本次福利(硬约束)');
});
});
describe('福利进话术 —— 没配就整段不生成', () => {
test('⭐⭐ 不留空钩子:留了模型会自己编一个福利出来', () => {
for (const empty of [undefined, null, { text: '' }, { text: ' ' }]) {
expect(benefitBlock(empty)).toBe('');
}
});
});
describe('两档共用同一份护栏', () => {
test('⭐ 稳健档与标准/深度档用的是**同一个函数** —— 各写一份必然漂', () => {
// 这条断言看着废,实际拦的是"有人为了图省事在 stable/prompt 里再抄一份"。
// 抄了之后改护栏只会改到一处,而"哪一档漏了哪条禁令"要等客服念出去才发现。
const stableSrc = require('node:fs').readFileSync(
require('node:path').join(__dirname, '../src/modules/ai/calls/draft-plan-script/tiers/stable/prompt.ts'),
'utf-8',
) as string;
expect(stableSrc).toContain('benefitBlock');
expect(stableSrc).not.toContain('本次福利(硬约束)'); // ⛔ 不许内联再写一份
});
});
...@@ -10,8 +10,20 @@ import { SyncIncrementalSchedulerService } from '../src/queues/sync-incremental. ...@@ -10,8 +10,20 @@ import { SyncIncrementalSchedulerService } from '../src/queues/sync-incremental.
* 修复:service 启动时把 startedAt 早于本进程启动的 running 行标 failed。 * 修复:service 启动时把 startedAt 早于本进程启动的 running 行标 failed。
* 判据安全性:sync 只在本进程 cron 或一次性 CLI 里跑,都不可能比本进程更早启动还活着; * 判据安全性:sync 只在本进程 cron 或一次性 CLI 里跑,都不可能比本进程更早启动还活着;
* 本进程启动后新建的 running(可能是并存的手动 CLI 真锁)绝不碰。 * 本进程启动后新建的 running(可能是并存的手动 CLI 真锁)绝不碰。
*
* 本用例**不碰真实时钟**:时间界(进程启动时刻)作参数注入 reapStaleRunningLocks,
* 行的 startedAt 全部相对固定基准构造。此前用 `Date.now() - 1000` 造时间差,判据里的界
* 却是模块加载时刻 —— 机器有重负载(如并行 next build)时,模块加载到用例执行的间隔
* 会超过 1s,行反而落到界之后不再算僵尸,间歇性假失败。
*/ */
/** 固定时间基准:代表"本进程启动时刻",不取真实时钟 */
const PROCESS_STARTED_AT = new Date('2026-07-23T02:00:00.000Z');
/** 早于进程启动 ms 毫秒 → 应被回收的僵尸锁 */
const before = (ms: number) => new Date(PROCESS_STARTED_AT.getTime() - ms);
/** 晚于进程启动 ms 毫秒 → 可能是并存 CLI 的真锁,绝不碰 */
const after = (ms: number) => new Date(PROCESS_STARTED_AT.getTime() + ms);
function makeService(rows: Array<{ id: string; hostId: string; startedAt: Date; triggeredBy: string }>) { function makeService(rows: Array<{ id: string; hostId: string; startedAt: Date; triggeredBy: string }>) {
const findMany = jest.fn().mockImplementation(async ({ where }) => { const findMany = jest.fn().mockImplementation(async ({ where }) => {
const lt: Date = where.startedAt.lt; const lt: Date = where.startedAt.lt;
...@@ -33,15 +45,16 @@ function makeService(rows: Array<{ id: string; hostId: string; startedAt: Date; ...@@ -33,15 +45,16 @@ function makeService(rows: Array<{ id: string; hostId: string; startedAt: Date;
return { svc, findMany, updateMany }; return { svc, findMany, updateMany };
} }
// 触达 private 方法(纯编排,无需走 onModuleInit 以免顺带注册 cron) // 触达 private 方法(纯编排,无需走 onModuleInit 以免顺带注册 cron);时间界显式注入
const reap = (svc: SyncIncrementalSchedulerService) => const reap = (svc: SyncIncrementalSchedulerService, processStartedAt = PROCESS_STARTED_AT) =>
(svc as unknown as { reapStaleRunningLocks: () => Promise<void> }).reapStaleRunningLocks(); (
svc as unknown as { reapStaleRunningLocks: (at: Date) => Promise<void> }
).reapStaleRunningLocks(processStartedAt);
describe('reapStaleRunningLocks', () => { describe('reapStaleRunningLocks', () => {
test('回收进程启动前的 running 行 → 标 failed', async () => { test('回收进程启动前的 running 行 → 标 failed', async () => {
const old = new Date(Date.now() - 3_600_000); // 1h 前
const { svc, updateMany } = makeService([ const { svc, updateMany } = makeService([
{ id: 'zombie', hostId: 'h1', startedAt: old, triggeredBy: 'sync:jvs-dw:x' }, { id: 'zombie', hostId: 'h1', startedAt: before(3_600_000), triggeredBy: 'sync:jvs-dw:x' },
]); ]);
await reap(svc); await reap(svc);
expect(updateMany).toHaveBeenCalledTimes(1); expect(updateMany).toHaveBeenCalledTimes(1);
...@@ -53,14 +66,24 @@ describe('reapStaleRunningLocks', () => { ...@@ -53,14 +66,24 @@ describe('reapStaleRunningLocks', () => {
}); });
test('⭐ 只回收 startedAt < 进程启动时刻的行 —— where 用 lt,不用 gte', async () => { test('⭐ 只回收 startedAt < 进程启动时刻的行 —— where 用 lt,不用 gte', async () => {
// 判据锁:where 必须是 { startedAt: { lt: <某时刻> } },否则会误杀本进程启动后的真锁 // 判据锁:where 必须是 { startedAt: { lt: 进程启动时刻 } },否则会误杀本进程启动后的真锁
const { svc, findMany, updateMany } = makeService([ const { svc, findMany, updateMany } = makeService([
{ id: 'zombie', hostId: 'h1', startedAt: new Date(Date.now() - 1000), triggeredBy: 't' }, { id: 'zombie', hostId: 'h1', startedAt: before(1000), triggeredBy: 't' },
]); ]);
await reap(svc); await reap(svc);
expect(findMany.mock.calls[0][0].where.startedAt).toHaveProperty('lt'); expect(findMany.mock.calls[0][0].where.startedAt).toHaveProperty('lt', PROCESS_STARTED_AT);
expect(findMany.mock.calls[0][0].where.startedAt).not.toHaveProperty('gte'); expect(findMany.mock.calls[0][0].where.startedAt).not.toHaveProperty('gte');
expect(updateMany.mock.calls[0][0].where.startedAt).toHaveProperty('lt'); expect(updateMany.mock.calls[0][0].where.startedAt).toHaveProperty('lt', PROCESS_STARTED_AT);
});
test('⭐ 进程启动后新建的 running 绝不碰(并存 CLI 的真锁)', async () => {
// 边界行为锁:startedAt >= 界的行不进回收集合,连 updateMany 都不该发
const { svc, updateMany } = makeService([
{ id: 'live-cli-lock', hostId: 'h1', startedAt: after(1000), triggeredBy: 'cli:manual' },
{ id: 'boundary', hostId: 'h2', startedAt: PROCESS_STARTED_AT, triggeredBy: 'cli:manual' },
]);
await reap(svc);
expect(updateMany).not.toHaveBeenCalled();
}); });
test('无僵尸锁 → 不发 updateMany(纯净启动零副作用)', async () => { test('无僵尸锁 → 不发 updateMany(纯净启动零副作用)', async () => {
......
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
...@@ -5,13 +5,14 @@ import { useRouter } from 'next/navigation'; ...@@ -5,13 +5,14 @@ import { useRouter } from 'next/navigation';
import { Inbox, RefreshCw } from 'lucide-react'; import { Inbox, RefreshCw } from 'lucide-react';
import { Permission } from '@pac/types'; import { Permission } from '@pac/types';
import { Can } from '@/components/can'; import { Can } from '@/components/can';
import { useHasPermission } from '@/hooks/use-permission';
import { plansApi } from '@/components/plans/plans-api'; import { plansApi } from '@/components/plans/plans-api';
import { PatientPickerRail } from '@/components/plans/patient-picker-rail'; import { PatientPickerRail } from '@/components/plans/patient-picker-rail';
import { IdentityCluster } from '@/components/identity-cluster'; import { IdentityCluster } from '@/components/identity-cluster';
import { isEmbedded } from '@/lib/embed'; import { isEmbedded } from '@/lib/embed';
/** /**
* /plans — 工作台入口解析器(原列表页已弃用,组件保留在 plans-list-app.tsx 未挂载)。 * /plans — 工作台入口解析器(原独立列表页已废除,左栏 patient-picker-rail 取代了它)。
* *
* 进来直接落到详情工作台,选人规则(纯规则): * 进来直接落到详情工作台,选人规则(纯规则):
* ① 我的「进行中」第一个(优先续上手头工作) * ① 我的「进行中」第一个(优先续上手头工作)
...@@ -29,6 +30,8 @@ export default function PlansEntryPage() { ...@@ -29,6 +30,8 @@ export default function PlansEntryPage() {
function EntryResolver() { function EntryResolver() {
const router = useRouter(); const router = useRouter();
/// 主管判据(T16):只有他有召回池,客服的落地兜底不能滑进池子
const canDispatch = useHasPermission(Permission.PLAN_DISPATCH);
const [phase, setPhase] = useState<'resolving' | 'empty'>('resolving'); const [phase, setPhase] = useState<'resolving' | 'empty'>('resolving');
useEffect(() => { useEffect(() => {
...@@ -42,12 +45,17 @@ function EntryResolver() { ...@@ -42,12 +45,17 @@ function EntryResolver() {
router.replace(`/plans/${mine.items[0].id}`); router.replace(`/plans/${mine.items[0].id}`);
return; return;
} }
// ② 召回池第一个 // ② 召回池第一个 —— ⭐ **仅主管**。
const pool = await plansApi.list({ view: 'pool', sort: 'priority_desc', page: 1, pageSize: 1 }); // 客服没有召回池入口(T16),这里若不门控就是一条隐式的认领通道:
if (cancelled) return; // 他的「我的」一空,系统就自动把他送进池子里某个患者的详情页,
if (pool.items[0]) { // 而那个人根本不是分给他的。tab 藏了没用,路径也得堵。
router.replace(`/plans/${pool.items[0].id}`); if (canDispatch) {
return; const pool = await plansApi.list({ view: 'pool', sort: 'priority_desc', page: 1, pageSize: 1 });
if (cancelled) return;
if (pool.items[0]) {
router.replace(`/plans/${pool.items[0].id}`);
return;
}
} }
setPhase('empty'); setPhase('empty');
} catch { } catch {
...@@ -57,7 +65,7 @@ function EntryResolver() { ...@@ -57,7 +65,7 @@ function EntryResolver() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [router]); }, [router, canDispatch]);
if (phase === 'resolving') { if (phase === 'resolving') {
return ( return (
...@@ -89,10 +97,15 @@ function EntryResolver() { ...@@ -89,10 +97,15 @@ function EntryResolver() {
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400"> <span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
<Inbox className="h-7 w-7" /> <Inbox className="h-7 w-7" />
</span> </span>
<div className="text-[15px] font-medium text-slate-700">当前没有待跟进的召回任务</div> <div className="text-[15px] font-medium text-slate-700">
{canDispatch ? '当前没有待跟进的召回任务' : '暂无分配给你的任务'}
</div>
{/* ⭐ 客服看到的空态不能提"召回池"——他没有那个入口,提了只会让他去找一个不存在的地方。
主管的空态照旧(他确实两边都能看)。 */}
<p className="max-w-sm text-[12.5px] leading-relaxed text-slate-500"> <p className="max-w-sm text-[12.5px] leading-relaxed text-slate-500">
我的任务与召回池都是空的。可以在左侧调整筛选(视图 / 诊所 / 筛选标签)自行查找, {canDispatch
或等召回引擎下次重算后刷新。 ? '我的任务与召回池都是空的。可以在左侧调整筛选(视图 / 诊所 / 筛选标签)自行查找,或等召回引擎下次重算后刷新。'
: '手头的任务都处理完了。新任务由主管统一派发,稍后刷新看看。'}
</p> </p>
<button <button
type="button" type="button"
......
...@@ -229,3 +229,56 @@ body { ...@@ -229,3 +229,56 @@ body {
::-webkit-scrollbar-corner { ::-webkit-scrollbar-corner {
background: transparent; background: transparent;
} }
/* ── Radix 浮层的出现/消失 ────────────────────────────────────────
⚠️ shadcn 官方那套 `animate-in fade-in-0 zoom-in-95` 来自 tailwindcss-animate 插件,
**本仓没装**(内网拉包本身是风险)—— 那些 class 写了也不生效,
popover.tsx 里原先就挂着一串,实际一帧动画都没有。这里用原生 keyframes 补上。
⚠️⚠️ 必须经 `@theme` 注册成 `--animate-*`,**不能只写一条 `.animate-overlayIn` 类**:
浮层要靠 `data-[state=open]:animate-overlayIn` 触发,而 Tailwind 的 variant
只作用在**它自己生成的 utility** 上 —— 手写的同名 class 拿不到 variant,
实测算出来 animation-name: none(class 在、样式在,就是没被 variant 选中)。 */
@keyframes overlayIn {
from { opacity: 0; transform: scale(0.96) translateY(-2px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes overlayOut {
from { opacity: 1; transform: scale(1) translateY(0); }
to { opacity: 0; transform: scale(0.96) translateY(-2px); }
}
@theme {
--animate-overlayIn: overlayIn 0.14s cubic-bezier(0.22, 1, 0.36, 1);
--animate-overlayOut: overlayOut 0.1s ease-in;
}
/* ── 人群移交:数据流粒子 ──────────────────────────────────────────
点中矩阵某一格 → 一串小方块沿一条弧线飞向助手,像数据被"抽走"。
走 CSS Motion Path(offset-path):曲线一次算好塞进 --pac-stream-path,
每颗粒子只改 offset-distance —— 位置由合成器算,不占主线程,
与本仓「不引动画库」的既有取舍一致(宠物那 31 个 keyframes 也是这么来的)。
⚠️ 粒子是 position:fixed + left/top:0,所以 path 里的坐标**就是视口坐标**,
调用方直接把 getBoundingClientRect() 的值塞进去即可,不用换算。 */
@keyframes pacStreamFly {
0% { offset-distance: 0%; opacity: 0; scale: 0.5; }
15% { opacity: 1; scale: 1; }
80% { opacity: 1; scale: 1; }
100% { offset-distance: 100%; opacity: 0; scale: 0.4; }
}
.pac-stream-dot {
position: fixed;
left: 0;
top: 0;
width: 7px;
height: 7px;
border-radius: 2px;
pointer-events: none;
z-index: 62; /* 压过助手窗(60)与 FAB(61):粒子要能飞到脸上 */
offset-path: var(--pac-stream-path);
offset-rotate: 0deg; /* 保持正方向,不让方块跟着切线转 —— 转起来像碎屑不像数据 */
animation: pacStreamFly var(--pac-stream-dur, 620ms) cubic-bezier(0.4, 0, 0.35, 1) forwards;
}
@media (prefers-reduced-motion: reduce) {
/* 无障碍:不飞,直接不出现(移交本身仍照常发生,只是没有这段演出) */
.pac-stream-dot { display: none; }
}
...@@ -6,7 +6,7 @@ import { usePlanSyncStore } from '@/stores/plan-sync-store'; ...@@ -6,7 +6,7 @@ import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { PetBody, type PetBodyPose } from '@/components/pet/pet-body'; import { PetBody, type PetBodyPose } from '@/components/pet/pet-body';
import { PetFab } from '@/components/pet/pet-widget'; import { PetFab } from '@/components/pet/pet-widget';
const POSES: PetBodyPose[] = ['idle', 'think', 'celebrate', 'sleep', 'walk', 'fall', 'sit', 'happy', 'brush', 'rinse', 'floss', 'shine', 'chomp', 'fly', 'vet_exam', 'vet_scale', 'vet_fill', 'vet_polish', 'vet_fluoride', 'climb', 'magnify']; const POSES: PetBodyPose[] = ['idle', 'think', 'celebrate', 'sleep', 'walk', 'fall', 'sit', 'happy', 'brush', 'rinse', 'floss', 'shine', 'chomp', 'fly', 'vet_exam', 'vet_scale', 'vet_fill', 'vet_polish', 'vet_fluoride', 'absorb', 'climb', 'magnify'];
/** /**
* 宠物实验室(dev 调试页,无需登录)— 上:各姿态静态陈列(直接喂 PetBody); * 宠物实验室(dev 调试页,无需登录)— 上:各姿态静态陈列(直接喂 PetBody);
......
...@@ -102,6 +102,9 @@ export function AssistantFab({ ...@@ -102,6 +102,9 @@ export function AssistantFab({
<button <button
ref={btnRef} ref={btnRef}
type="button" type="button"
// ⭐ 数据流粒子的终点靠这个属性现查(cohort-stream)。钮可拖动 + 位置记 localStorage,
// 所以终点必须**播放时取实时 rect**,不能写死右下角。
data-assistant-fab
title={open ? '收起助手' : '打开助手(可拖动)'} title={open ? '收起助手' : '打开助手(可拖动)'}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
......
...@@ -5,7 +5,9 @@ import { Permission } from '@pac/types'; ...@@ -5,7 +5,9 @@ import { Permission } from '@pac/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useHasPermission } from '@/hooks/use-permission'; import { useHasPermission } from '@/hooks/use-permission';
import { usePlanSyncStore } from '@/stores/plan-sync-store'; import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { useAssistantStore } from '@/stores/assistant-store';
import { AssistantFab } from './assistant-fab'; import { AssistantFab } from './assistant-fab';
import { CohortStream } from './cohort-stream';
import { AssistantChat } from './assistant-chat'; import { AssistantChat } from './assistant-chat';
/** /**
...@@ -18,8 +20,16 @@ import { AssistantChat } from './assistant-chat'; ...@@ -18,8 +20,16 @@ import { AssistantChat } from './assistant-chat';
*/ */
export function AssistantWidget() { export function AssistantWidget() {
const allowed = useHasPermission(Permission.AGENT_INVOKE); const allowed = useHasPermission(Permission.AGENT_INVOKE);
const [open, setOpen] = useState(false); // ⭐ open 提到 store:业务代码(「移交助手」)要能从外面把它打开。
const [maximized, setMaximized] = useState(false); // 最大化:居中放大窗 // 原来是组件内 useState,外部完全够不着 —— 那正是移交入口此前无处落地的原因。
// ⚠️ 收起仍然是 CSS 隐藏**不卸载**(见文件头注释),别顺手改成条件渲染:
// 改了主管每收一次助手就丢一次对话,而且看不出任何异常。
const open = useAssistantStore((s) => s.open);
const setOpen = useAssistantStore((s) => s.setOpen);
// ⭐ 最大化也提到 store(与 open 同理):移交完成后要能从外部直接开大窗。
// ⚠️ 仍然只是显隐/尺寸,**不卸载**对话(见文件头)。
const maximized = useAssistantStore((s) => s.maximized);
const setMaximized = useAssistantStore((s) => s.setMaximized);
// 窗口落点 + 尺寸:打开瞬间按"钮的当前位置"计算(拖钮的意义所在),并夹紧在视窗内 // 窗口落点 + 尺寸:打开瞬间按"钮的当前位置"计算(拖钮的意义所在),并夹紧在视窗内
const [winRect, setWinRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null); const [winRect, setWinRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
...@@ -38,14 +48,41 @@ export function AssistantWidget() { ...@@ -38,14 +48,41 @@ export function AssistantWidget() {
}; };
// 场景化开场建议(规则,非 AI):有当前患者 → 围绕该患者;否则兜底通用 // 场景化开场建议(规则,非 AI):有当前患者 → 围绕该患者;否则兜底通用
const current = usePlanSyncStore((s) => s.current); const current = usePlanSyncStore((s) => s.current);
/**
* ⚠️ 主管多三条 —— 原来四条全是**客服**的活(捋事实、想开场白),
* 主管看不出"我还能在这儿分配和看批次"。占位符同理(见 assistant-chat)。
* ⚠️ 仍然保留患者类的两条:T16 主管本质也是客服,他一样会点开某个患者看。
* ⛔ 别写「转化率怎么样」这类 —— 系统只报**处理率**,例句先许愿会逼着模型去圆。
*
* 三条各对一个工具,顺序 = 主管的实际动线(分 → 看全局 → 看单批):
* 1. propose_assignment 出方案
* 2. list_assignment_batches 横向总览(表格:markdown 表已支持,见 assistant-chat 的 remarkGfm)
* 3. get_assignment_detail 单批下钻
*
* ⛔ 第 2、3 条里点名的指标**每一个都得是工具真返回的**,否则例句就是许愿:
* 分配数=planned、未动过=untouched、已处理=progress.done、成功=outcomes.success、
* 超期=expired、退回+原因=released/releaseReasons、不成功+原因=outcomes.failed/byOutcome。
* ⚠️ 写「未动过」不写「曝光」、写「已处理」不写「完成」—— 都是服务端的原词,
* 换个说法会把口径带偏(「已处理」≠ 谈成了,这条 note 服务端每次都在喊)。
*/
const canDispatch = useHasPermission(Permission.PLAN_DISPATCH);
const leaderExamples = [
'帮我给「种植 · 窗口内」这批患者出一份分配方案',
'帮我跟踪下我分配的任务有没什么问题',
'最近一批分配出份报表:分配数、未动过、已处理、成功、超期,退回和不成功要带原因',
];
const examples = current?.patientName const examples = current?.patientName
? [ ? [
`${current.patientName}为什么被召回?帮我捋一下关键事实`, `${current.patientName}为什么被召回?帮我捋一下关键事实`,
`查一下${current.patientName}的画像和潜在治疗`, `查一下${current.patientName}的画像和潜在治疗`,
`这通电话怎么开口比较好?给我两句开场`, ...(canDispatch ? leaderExamples : ['这通电话怎么开口比较好?给我两句开场']),
'今日推荐:挑几个该优先跟进的患者,并说明理由', '今日推荐:挑几个该优先跟进的患者,并说明理由',
] ]
: ['今日推荐:挑几个该优先跟进的患者,并说明理由', '现在召回池里优先级最高的是谁?']; : [
...(canDispatch ? leaderExamples : []),
'今日推荐:挑几个该优先跟进的患者,并说明理由',
'现在召回池里优先级最高的是谁?',
];
if (!allowed) return null; if (!allowed) return null;
...@@ -69,7 +106,7 @@ export function AssistantWidget() { ...@@ -69,7 +106,7 @@ export function AssistantWidget() {
setOpen(false); setOpen(false);
setMaximized(false); // 收起时还原尺寸,下次打开仍是小窗 setMaximized(false); // 收起时还原尺寸,下次打开仍是小窗
}} }}
onToggleMaximize={() => setMaximized((m) => !m)} onToggleMaximize={() => setMaximized(!maximized)}
maximized={maximized} maximized={maximized}
examples={examples} examples={examples}
/> />
...@@ -77,6 +114,8 @@ export function AssistantWidget() { ...@@ -77,6 +114,8 @@ export function AssistantWidget() {
{/* 助手入口(静态版):固定右下角,点击开/关助手窗;只在 AI 思考/说话、成约、切患者时原地反馈。 {/* 助手入口(静态版):固定右下角,点击开/关助手窗;只在 AI 思考/说话、成约、切患者时原地反馈。
完整桌宠(漫游/物理/狩猎)保留在 /pet-lab 演示页,不进工作区(老板反馈"做得太过")。 */} 完整桌宠(漫游/物理/狩猎)保留在 /pet-lab 演示页,不进工作区(老板反馈"做得太过")。 */}
<AssistantFab open={open} onToggle={(r) => (open ? setOpen(false) : openAt(r))} /> <AssistantFab open={open} onToggle={(r) => (open ? setOpen(false) : openAt(r))} />
{/* 数据流舞台:挂在这里是因为它要的两个端点(格子 / FAB)都在同一屏,且与助手同生命周期 */}
<CohortStream />
</> </>
); );
} }
'use client';
import { useEffect, useState } from 'react';
import { onPetEvent } from '@/lib/pet-events';
/**
* CohortStream — 「人群移交」的**舞台层**:一串数据流粒子从矩阵格子飞进助手。
*
* 三层架构里它属于**身体/舞台**那一层(同 pet-body):
* 业务侧只发一个语义事件 `cohort_handoff{ count, from }`,**不描述怎么飞**;
* 飞法、粒子数、时长全在这里。换成传送带、换成粒子聚合,业务代码一行不动。
*
* ⭐ 两个端点都**在播放的那一刻现取**,不预存:
* 起点 = 事件带来的格子矩形(浮层随后就关了,不现取就没了);
* 终点 = 助手 FAB 的实时位置 —— 那颗钮**可以被主管拖到任意角落**并记进 localStorage,
* 写死右下角的话,拖过钮的人看到的就是粒子飞向空气。
*/
/** 单颗粒子的飞行时长。⚠️ 与 assistant-store 的 HANDOFF_FLIGHT_MS 是同一件事,那边引这里 */
export const STREAM_FLIGHT_MS = 620;
/** 粒子数与出发间隔 —— 末颗落点 ≈ (N-1)*STAGGER + FLIGHT */
const DOTS = 10;
const STAGGER_MS = 45;
/** 整段演出总时长(末颗落地) */
export const STREAM_TOTAL_MS = (DOTS - 1) * STAGGER_MS + STREAM_FLIGHT_MS;
interface Flight {
id: number;
path: string;
}
export function CohortStream() {
const [flights, setFlights] = useState<Flight[]>([]);
useEffect(() => {
let seq = 0;
return onPetEvent((e) => {
if (e.type !== 'cohort_handoff' || !e.payload.from) return;
const from = e.payload.from;
// 终点:助手 FAB 的**当前**位置(可拖动,所以必须现查)
const fab = document.querySelector('[data-assistant-fab]')?.getBoundingClientRect();
if (!fab) return; // 没挂助手(无 AGENT_INVOKE 权限)→ 不演,静默跳过
const x0 = from.x + from.w / 2;
const y0 = from.y + from.h / 2;
const x1 = fab.left + fab.width / 2;
const y1 = fab.top + fab.height / 2;
// 控制点:取中点再往"垂直于连线"的方向抬一点 → 一条自然的弧,而不是直线飞
// ⚠️ 抬的方向跟着走向翻转,否则从左上飞和从右下飞会一个上凸一个下凹,看着像两套动画
const mx = (x0 + x1) / 2;
const my = (y0 + y1) / 2;
const dist = Math.hypot(x1 - x0, y1 - y0);
const lift = Math.min(120, dist * 0.22);
const cx = mx + ((y1 - y0) / (dist || 1)) * lift;
const cy = my - ((x1 - x0) / (dist || 1)) * lift;
const id = ++seq;
setFlights((f) => [...f, { id, path: `path("M ${x0} ${y0} Q ${cx} ${cy} ${x1} ${y1}")` }]);
// 自清理:动画是 forwards,不删节点会一直堆在 DOM 里
window.setTimeout(() => setFlights((f) => f.filter((x) => x.id !== id)), STREAM_TOTAL_MS + 120);
});
}, []);
if (flights.length === 0) return null;
return (
<>
{flights.map((f) =>
Array.from({ length: DOTS }, (_, i) => (
<span
key={`${f.id}-${i}`}
aria-hidden
className="pac-stream-dot bg-brand-600"
style={
{
'--pac-stream-path': f.path,
'--pac-stream-dur': `${STREAM_FLIGHT_MS}ms`,
animationDelay: `${i * STAGGER_MS}ms`,
// 首尾两颗淡一点、小一点 → 队列有头有尾,不是一坨方块平移
opacity: i === 0 || i === DOTS - 1 ? 0.55 : 1,
} as React.CSSProperties
}
/>
)),
)}
</>
);
}
...@@ -133,6 +133,7 @@ export function PetBody({ ...@@ -133,6 +133,7 @@ export function PetBody({
const flossing = pose === 'floss'; // 牙线自理:头顶拉线锯冠缝 const flossing = pose === 'floss'; // 牙线自理:头顶拉线锯冠缝
const shining = pose === 'shine'; // 闪亮微笑:咧嘴 + 牙面 ding const shining = pose === 'shine'; // 闪亮微笑:咧嘴 + 牙面 ding
const chomping = pose === 'chomp'; // 咬合操:嘴快速张合 const chomping = pose === 'chomp'; // 咬合操:嘴快速张合
const absorbing = pose === 'absorb'; // 吸入:一批患者被"吸"进来(移交人群时演)
const flying = pose === 'fly'; // 超人:红披风直线飞 const flying = pose === 'fly'; // 超人:红披风直线飞
const morphing = pose === 'morph'; // 超人变身:原地蓄力 + 披风展开 + 光环 const morphing = pose === 'morph'; // 超人变身:原地蓄力 + 披风展开 + 光环
// 牙医组合:统一戴额镜,按步骤换手里的器械 // 牙医组合:统一戴额镜,按步骤换手里的器械
...@@ -158,10 +159,20 @@ export function PetBody({ ...@@ -158,10 +159,20 @@ export function PetBody({
falling ? 'pac-pet-falling' : '', falling ? 'pac-pet-falling' : '',
happy ? 'pac-pet-wiggle' : '', happy ? 'pac-pet-wiggle' : '',
flying ? 'pac-pet-flylean' : '', flying ? 'pac-pet-flylean' : '',
absorbing ? 'pac-pet-absorb' : '',
].join(' ')} ].join(' ')}
aria-hidden aria-hidden
> >
<style>{PET_CSS}</style> <style>{PET_CSS}</style>
{/* 吸入:三个数字流从右侧被吸进嘴里(移交人群)。⚠️ keyframes 在 PET_CSS 里,
不在 globals.css —— 宠物的 29 个动画都注入在这个 <style> 里 */}
{absorbing && (
<g className="pac-pet-absorb-flow" fill="#0d9488" fontSize="7" fontWeight="700">
<text x="52" y="30">1</text>
<text x="56" y="36">0</text>
<text x="52" y="42">1</text>
</g>
)}
{/* 超人红披风:画在身体之后(置于身后),朝后(facing 由外层翻转)飘动;变身时从领口展开 */} {/* 超人红披风:画在身体之后(置于身后),朝后(facing 由外层翻转)飘动;变身时从领口展开 */}
{(flying || morphing) && ( {(flying || morphing) && (
<g className={morphing ? 'pac-pet-cape-unfurl' : 'pac-pet-cape'}> <g className={morphing ? 'pac-pet-cape-unfurl' : 'pac-pet-cape'}>
...@@ -275,6 +286,9 @@ export function PetBody({ ...@@ -275,6 +286,9 @@ export function PetBody({
<path d="M28 31 Q32 35.5 36 31" stroke="#334155" strokeWidth="1.8" strokeLinecap="round" fill="none" /> <path d="M28 31 Q32 35.5 36 31" stroke="#334155" strokeWidth="1.8" strokeLinecap="round" fill="none" />
) : rinsing ? ( ) : rinsing ? (
<path d="M30.5 32.5 L33.5 32.5" stroke="#334155" strokeWidth="1.6" strokeLinecap="round" fill="none" /> <path d="M30.5 32.5 L33.5 32.5" stroke="#334155" strokeWidth="1.6" strokeLinecap="round" fill="none" />
) : absorbing ? (
// 吸入时张成小圆嘴(像在吸)
<ellipse cx="32" cy="32.6" rx="2.6" ry="3.2" fill="#334155" />
) : chomping ? ( ) : chomping ? (
<g> <g>
<ellipse className="pac-pet-chomp-open" cx="32" cy="32.5" rx="4" ry="3.2" fill="#334155" /> <ellipse className="pac-pet-chomp-open" cx="32" cy="32.5" rx="4" ry="3.2" fill="#334155" />
...@@ -465,6 +479,10 @@ const PET_CSS = ` ...@@ -465,6 +479,10 @@ const PET_CSS = `
.pac-pet-bubble { animation: pacPetBubble 1.1s ease-in-out infinite; } .pac-pet-bubble { animation: pacPetBubble 1.1s ease-in-out infinite; }
.pac-pet-rinse-b { animation: pacPetRinse 1.6s ease-out infinite; } .pac-pet-rinse-b { animation: pacPetRinse 1.6s ease-out infinite; }
.pac-pet-floss { animation: pacPetFloss 0.5s ease-in-out infinite; } .pac-pet-floss { animation: pacPetFloss 0.5s ease-in-out infinite; }
.pac-pet-absorb { animation: pacPetAbsorb 0.8s ease-in-out infinite; }
.pac-pet-absorb-flow text { animation: pacPetAbsorbFlow 0.9s ease-in infinite; }
.pac-pet-absorb-flow text:nth-child(2) { animation-delay: 0.3s; }
.pac-pet-absorb-flow text:nth-child(3) { animation-delay: 0.6s; }
.pac-pet-chomp-open { animation: pacPetChomp 0.5s steps(1) infinite; } .pac-pet-chomp-open { animation: pacPetChomp 0.5s steps(1) infinite; }
.pac-pet-chomp-closed { animation: pacPetChomp 0.5s steps(1) infinite; animation-delay: -0.25s; } .pac-pet-chomp-closed { animation: pacPetChomp 0.5s steps(1) infinite; animation-delay: -0.25s; }
.pac-pet-shieldfx { animation: pacPetShield 2.2s ease-in-out infinite; transform-origin: 32px 34px; } .pac-pet-shieldfx { animation: pacPetShield 2.2s ease-in-out infinite; transform-origin: 32px 34px; }
...@@ -500,6 +518,13 @@ const PET_CSS = ` ...@@ -500,6 +518,13 @@ const PET_CSS = `
@keyframes pacPetRinse { 0% { opacity: 0; transform: translateY(3px) scale(0.5); } 30% { opacity: 1; } 100% { opacity: 0; transform: translateY(-12px) scale(1.15); } } @keyframes pacPetRinse { 0% { opacity: 0; transform: translateY(3px) scale(0.5); } 30% { opacity: 1; } 100% { opacity: 0; transform: translateY(-12px) scale(1.15); } }
@keyframes pacPetFloss { 0%,100% { transform: translateY(0); } 50% { transform: translateY(3px); } } @keyframes pacPetFloss { 0%,100% { transform: translateY(0); } 50% { transform: translateY(3px); } }
@keyframes pacPetChomp { 0%,49.9% { opacity: 1; } 50%,100% { opacity: 0; } } @keyframes pacPetChomp { 0%,49.9% { opacity: 1; } 50%,100% { opacity: 0; } }
/* 吸入:身体轻微前倾 + 数字流被吸向嘴部 */
@keyframes pacPetAbsorb { 0%,100% { transform: scale(1); } 45% { transform: scale(1.04) translateX(1px); } }
@keyframes pacPetAbsorbFlow {
0% { opacity: 0; transform: translateX(10px) scale(1); }
25% { opacity: 1; }
100% { opacity: 0; transform: translateX(-20px) scale(0.4); }
}
@keyframes pacPetShield { 0%,100% { opacity: 0.55; transform: scale(0.985); } 50% { opacity: 0.95; transform: scale(1.015); } } @keyframes pacPetShield { 0%,100% { opacity: 0.55; transform: scale(0.985); } 50% { opacity: 0.95; transform: scale(1.015); } }
@keyframes pacPetBlink { 0%,91%,97%,100% { transform: scaleY(0); } 93.5% { transform: scaleY(1); } } @keyframes pacPetBlink { 0%,91%,97%,100% { transform: scaleY(0); } 93.5% { transform: scaleY(1); } }
@keyframes pacPetJump { 0%,100% { transform: translateY(0); } 35% { transform: translateY(-7px); } 55% { transform: translateY(0); } 70% { transform: translateY(-3px); } } @keyframes pacPetJump { 0%,100% { transform: translateY(0); } 35% { transform: translateY(-7px); } 55% { transform: translateY(0); } 70% { transform: translateY(-3px); } }
......
...@@ -17,7 +17,8 @@ export type PetPose = ...@@ -17,7 +17,8 @@ export type PetPose =
| 'vet_scale' | 'vet_scale'
| 'vet_fill' | 'vet_fill'
| 'vet_polish' | 'vet_polish'
| 'vet_fluoride'; | 'vet_fluoride'
| 'absorb';
/** gesture → 一次性姿态映射(greet/think 只有台词/基线,没有专属一次性姿态)。 */ /** gesture → 一次性姿态映射(greet/think 只有台词/基线,没有专属一次性姿态)。 */
const ONE_SHOT_POSE: Partial<Record<string, PetPose>> = { const ONE_SHOT_POSE: Partial<Record<string, PetPose>> = {
...@@ -31,6 +32,7 @@ const ONE_SHOT_POSE: Partial<Record<string, PetPose>> = { ...@@ -31,6 +32,7 @@ const ONE_SHOT_POSE: Partial<Record<string, PetPose>> = {
vet_fill: 'vet_fill', vet_fill: 'vet_fill',
vet_polish: 'vet_polish', vet_polish: 'vet_polish',
vet_fluoride: 'vet_fluoride', vet_fluoride: 'vet_fluoride',
absorb: 'absorb',
}; };
const SLEEP_AFTER_MS = 300_000; // 无操作 5 分钟 → 打盹 const SLEEP_AFTER_MS = 300_000; // 无操作 5 分钟 → 打盹
...@@ -128,6 +130,24 @@ export function usePetBrain(opts: { calm?: boolean } = {}) { ...@@ -128,6 +130,24 @@ export function usePetBrain(opts: { calm?: boolean } = {}) {
setPose(basePose()); setPose(basePose());
return; return;
} }
if (e.type === 'cohort_handoff') {
// ⭐ **这里是唯一决定「怎么演」的地方**(三层架构的大脑层)。
// 业务侧只发语义事件,换演法只改这一处。
const { count, treatment, temperature } = e.payload;
// 从矩阵点进来会带温度档,气泡就说得更具体一点(「正在挑 44 位种植治疗·热…」);
// 从列表侧「移交助手」进来没有温度,退回到只说治疗项。⛔ 别在这里编一个默认档位。
const what = treatment
? temperature
? `${treatment}·${temperature}`
: treatment
: '';
play({
gesture: 'absorb',
bubble: what ? `正在挑 ${count}${what}…` : `正在挑 ${count} 位…`,
ttlMs: 2400,
});
return;
}
if (e.type === 'ai_thinking_start') thinking.current = true; if (e.type === 'ai_thinking_start') thinking.current = true;
if (e.type === 'ai_thinking_end') thinking.current = false; if (e.type === 'ai_thinking_end') thinking.current = false;
settle(); settle();
......
...@@ -164,7 +164,12 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) { ...@@ -164,7 +164,12 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
goal: real.plan?.goal ?? null, goal: real.plan?.goal ?? null,
assignee: { assignee: {
id: real.plan?.assigneeUserId ?? 'u_unknown', id: real.plan?.assigneeUserId ?? 'u_unknown',
name: real.plan?.assigneeUserId ?? '(未分配)', // ⭐ 姓名由服务端解析后下发(assigneeName)。前端翻不了 —— PAC 没有 users 表,
// dictionary.users 只覆盖当前登录人,所以这里长期显示的是一串 uuid。
// 服务端也查不到时退回 id 前 8 位(⛔ 不显示完整 uuid:400px 卡片会被撑爆)。
name:
real.plan?.assigneeName ??
(real.plan?.assigneeUserId ? `#${real.plan.assigneeUserId.slice(0, 8)}` : '(未分配)'),
role: (real.plan?.recommendedRole as UserRole) ?? UserRole.STAFF, role: (real.plan?.recommendedRole as UserRole) ?? UserRole.STAFF,
}, },
/// 认领人**原始值**(null = 无人认领)—— 认领闸判定必须用这个,不能用上面的 /// 认领人**原始值**(null = 无人认领)—— 认领闸判定必须用这个,不能用上面的
...@@ -172,6 +177,9 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) { ...@@ -172,6 +177,9 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
assigneeUserId: real.plan?.assigneeUserId ?? null, assigneeUserId: real.plan?.assigneeUserId ?? null,
assignedAt: real.plan?.assignedAt ? new Date(real.plan.assignedAt) : now, assignedAt: real.plan?.assignedAt ? new Date(real.plan.assignedAt) : now,
recycleAt: real.plan?.recycleAt ? new Date(real.plan.recycleAt) : null, recycleAt: real.plan?.recycleAt ? new Date(real.plan.recycleAt) : null,
assignmentExpiresAt: real.plan?.assignmentExpiresAt
? new Date(real.plan.assignmentExpiresAt)
: null,
snoozedUntil: real.plan?.snoozedUntil ? new Date(real.plan.snoozedUntil) : null, snoozedUntil: real.plan?.snoozedUntil ? new Date(real.plan.snoozedUntil) : null,
recallFeedback: (real.plan?.recallFeedback ?? null) as 'up' | 'down' | null, recallFeedback: (real.plan?.recallFeedback ?? null) as 'up' | 'down' | null,
recallFeedbackNote: real.plan?.recallFeedbackNote ?? null, recallFeedbackNote: real.plan?.recallFeedbackNote ?? null,
...@@ -214,6 +222,12 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) { ...@@ -214,6 +222,12 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
sections: real.script.sections, sections: real.script.sections,
} as typeof mockScript) } as typeof mockScript)
: EMPTY_SCRIPT, : EMPTY_SCRIPT,
/**
* 企微话术 —— **原样透传,不做 mock 兜底**(与电话档的关键差别)。
* 电话稿没生成时给一份 mock 让页面不空;企微稿是要**原样发给患者**的,
* 给假数据的风险是客服直接复制发出去 —— 所以没有就是 null,视图显示"尚未生成"。
*/
wecomScript: real.wecomScript ?? null,
// 召回历史(患者级)— 后端 plan-aggregate 透出;无则空数组 // 召回历史(患者级)— 后端 plan-aggregate 透出;无则空数组
recallHistory: real.recallHistory ?? [], recallHistory: real.recallHistory ?? [],
// 诊所回访记录(5 试点,展示用)— 后端透出;无则空数组 // 诊所回访记录(5 试点,展示用)— 后端透出;无则空数组
......
...@@ -356,6 +356,8 @@ export const mockPlan = { ...@@ -356,6 +356,8 @@ export const mockPlan = {
assigneeUserId: 'usr_csliu' as string | null, assigneeUserId: 'usr_csliu' as string | null,
assignedAt: NOW, assignedAt: NOW,
recycleAt: new Date(NOW.getTime() + 4 * 3600_000) as Date | null, recycleAt: new Date(NOW.getTime() + 4 * 3600_000) as Date | null,
// 分配单时效:mock 给 2 天后到期(真实值 1-7 天,由主管在确认单上定)
assignmentExpiresAt: new Date(NOW.getTime() + 2 * 24 * 3600_000) as Date | null,
/// 召回冷静期 / 终态抑制窗到期时间(null=无抑制)— 头部"下次回访 / 已暂缓至 X"角标 /// 召回冷静期 / 终态抑制窗到期时间(null=无抑制)— 头部"下次回访 / 已暂缓至 X"角标
snoozedUntil: null as Date | null, snoozedUntil: null as Date | null,
/// 召回反馈(plan 级)— 标题栏拇指当前态 + 文字 /// 召回反馈(plan 级)— 标题栏拇指当前态 + 文字
......
...@@ -52,6 +52,16 @@ const OUTCOME_GROUPS = ( ...@@ -52,6 +52,16 @@ const OUTCOME_GROUPS = (
})) }))
.filter((grp) => grp.options.length > 0); .filter((grp) => grp.options.length > 0);
/**
* 「触达方式」选择器 —— 暂时隐藏(2026-08-04 产品走查)。置 `true` 即恢复。
*
* ⚠️ 隐藏的只是**选择器**:`channel` 仍然照常提交(取 `defaultChannel`,即 'phone'),
* 服务端那一列 NOT NULL,不传会 400。
* ⚠️ 于是这段时间落库的 channel **全是 'phone'** —— 触达方式的分布在此期间**不可用**,
* ⛔ 别拿它出报表(会得出"全都是打电话"的假结论)。要恢复分析就得先把这个选择器放回来。
*/
const CHANNEL_PICKER_VISIBLE: boolean = false;
const CHANNELS = [ const CHANNELS = [
{ {
value: 'phone', value: 'phone',
...@@ -150,6 +160,7 @@ export function OutcomeForm({ ...@@ -150,6 +160,7 @@ export function OutcomeForm({
本任务已{plan.status === 'abandoned' ? '放弃' : plan.status === 'superseded' ? '被新版本替代' : '结案'},不可再提交执行;如需重新跟进,等召回重新生成。 本任务已{plan.status === 'abandoned' ? '放弃' : plan.status === 'superseded' ? '被新版本替代' : '结案'},不可再提交执行;如需重新跟进,等召回重新生成。
</div> </div>
)} )}
{CHANNEL_PICKER_VISIBLE && (
<div className="flex-none"> <div className="flex-none">
<div className="text-[10.5px] font-semibold text-slate-500 uppercase tracking-wider mb-1.5"> <div className="text-[10.5px] font-semibold text-slate-500 uppercase tracking-wider mb-1.5">
触达方式 <span className="text-rose-500">*</span> 触达方式 <span className="text-rose-500">*</span>
...@@ -186,6 +197,7 @@ export function OutcomeForm({ ...@@ -186,6 +197,7 @@ export function OutcomeForm({
})} })}
</div> </div>
</div> </div>
)}
<div className="flex-none"> <div className="flex-none">
<div className="text-[10.5px] font-semibold text-slate-500 uppercase tracking-wider mb-1.5"> <div className="text-[10.5px] font-semibold text-slate-500 uppercase tracking-wider mb-1.5">
......
...@@ -81,8 +81,13 @@ export type PlanDetailData = { ...@@ -81,8 +81,13 @@ export type PlanDetailData = {
recommendedRole: string | null; recommendedRole: string | null;
recommendedChannel: string | null; recommendedChannel: string | null;
assigneeUserId: string | null; assigneeUserId: string | null;
/// 承接人姓名 —— 服务端解析后下发(前端翻不了:PAC 无 users 表,
/// dictionary.users 只覆盖当前登录人)。查不到为 null,由 adapt-data 退回 id 前缀。
assigneeName?: string | null;
assignedAt: string | null; assignedAt: string | null;
recycleAt: string | null; recycleAt: string | null;
/// ⭐ 分配单到期时刻(客服手上这单还剩多久);⚠️ 与 recycleAt 是两个机制,见 @pac/types 的注释
assignmentExpiresAt: string | null;
/// 召回冷静期 / 终态抑制窗到期时间(execution 回写按 outcome 算)。 /// 召回冷静期 / 终态抑制窗到期时间(execution 回写按 outcome 算)。
/// active+未到期 → 不进召回池(到点浮现);UI 头部"下次回访 / 已暂缓至 X"角标 /// active+未到期 → 不进召回池(到点浮现);UI 头部"下次回访 / 已暂缓至 X"角标
snoozedUntil: string | null; snoozedUntil: string | null;
...@@ -224,6 +229,17 @@ export type PlanDetailData = { ...@@ -224,6 +229,17 @@ export type PlanDetailData = {
markdown: string; markdown: string;
}>; }>;
} | null; } | null;
/**
* 企微话术 —— **一整块**,不分段(所以没有 `sections`)。
* ⚠️ 与 `script` 是两条独立生成链、各有各的 status;⛔ 别互相回落
* (电话稿是口语分段的,复制发给患者很怪,而客服不会注意到自己发错了)。
*/
wecomScript?: {
id: string;
status: string; // ready / pending / failed
content: string | null;
updatedAt: string;
} | null;
/// 召回历史(患者级,跨所有 plan 版本,最近 8 条)。 /// 召回历史(患者级,跨所有 plan 版本,最近 8 条)。
/// 用途:plan 再次被召回时,客服看到"上次召回结果 / 为什么被暂缓"(终态 outcome 即原因)。 /// 用途:plan 再次被召回时,客服看到"上次召回结果 / 为什么被暂缓"(终态 outcome 即原因)。
recallHistory?: Array<{ recallHistory?: Array<{
......
'use client';
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
/**
* 「预约 / 跟进」跳宿主**之前**的快速记录 —— 吸附在按钮上的小浮层。
*
* ═══ 为什么要有它 ═══════════════════════════════════════════
* 这两个按钮跳走之后,客服在宿主那边把事办完就不回来了 —— 这正是
* `plan_executions` 回写率只有 **11%** 的根源(生产 65 个认领单只有 7 条执行结果)。
* 回写率低到这个程度,"成功率""退回率"这些数就全是猜的。
* ⇒ 把一次**必答**的极简输入挪到跳转之前:人还在 PAC 里、手还在键盘上,那一刻才录得到。
*
* ⚠️ **先落库、后跳转**。⛔ 反过来的话:写失败了人已经跳走,他会以为记过了,
* 而这次改动的全部意义就是那条记录。
* ⚠️ 只收**一个**必填字段。多一个都不行 —— 这是拦在人和他真正想做的事之间的一道门,
* 门越重,他越会去找绕开的路(而绕开的代价就是又回到 11%)。
*/
export function QuickLogPopover({
kind,
disabled,
children,
onConfirm,
}: {
kind: 'appointment' | 'follow_up';
disabled?: boolean;
/** 触发器 = 顶栏那颗按钮本体(样式不在这里管) */
children: React.ReactNode;
/** 落库 + 跳转。返回 false = 没落库(此时**不要**跳转,浮层保持打开让他重试) */
onConfirm: (payload: { notes?: string; scheduledNextAt?: string }) => Promise<boolean>;
}) {
const [open, setOpen] = useState(false);
const [notes, setNotes] = useState('');
const [date, setDate] = useState('');
const [time, setTime] = useState('10:00');
const [busy, setBusy] = useState(false);
const isAppt = kind === 'appointment';
// ⚠️ 必填校验放在**提交按钮的 disabled** 上,⛔ 不要提交后再报错 ——
// 浮层只有一个输入框,报错文案会把它撑成两倍高,而人已经知道自己没填。
const ready = isAppt ? notes.trim().length > 0 : date.length > 0 && time.length > 0;
const reset = () => {
setNotes('');
setDate('');
setTime('10:00');
};
const confirm = async () => {
if (!ready || busy) return;
setBusy(true);
try {
const ok = await onConfirm(
isAppt
? { notes: notes.trim() }
: // datetime-local 的形态('2026-08-10T10:00'),由上层统一转 ISO
{ scheduledNextAt: `${date}T${time}` },
);
if (ok) {
setOpen(false);
reset();
}
} finally {
setBusy(false);
}
};
return (
<Popover
open={open}
onOpenChange={(o) => {
if (disabled) return;
setOpen(o);
if (!o) reset();
}}
>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent align="end" className="w-64 p-3">
<div className="space-y-2">
<div>
<p className="text-[12.5px] font-medium text-slate-800">
{isAppt ? '已约到' : '约好下次'}
</p>
{/* ⚠️ 后果必须写在这里。「预约」落的是 success_appointed —— 它会**把这单结案**,
人从「我的」里消失。不写清楚就是让他在不知情的情况下关掉一个工单。 */}
<p className="mt-0.5 text-[10.5px] leading-snug text-slate-400">
{isAppt
? '记为「转化新预约」并结案,随后打开预约页'
: '记为「约定下次回访」,工单留到那天再浮现'}
</p>
</div>
{isAppt ? (
<textarea
autoFocus
rows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="约的什么、什么时候"
className="w-full resize-none rounded-md border border-slate-200 px-2 py-1.5 text-[12.5px] outline-none placeholder:text-slate-300 focus:border-brand-400"
/>
) : (
<div className="flex items-center gap-1.5">
<input
autoFocus
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="min-w-0 flex-1 rounded-md border border-slate-200 px-2 py-1.5 text-[12.5px] outline-none focus:border-brand-400"
/>
<input
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
className="w-[86px] flex-none rounded-md border border-slate-200 px-2 py-1.5 text-[12.5px] outline-none focus:border-brand-400"
/>
</div>
)}
<div className="flex items-center justify-end gap-1.5 pt-0.5">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-md px-2 py-1 text-[11.5px] text-slate-500 hover:bg-slate-50"
>
取消
</button>
<button
type="button"
disabled={!ready || busy}
onClick={confirm}
className={cn(
'inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-[11.5px] font-medium text-white transition-colors',
ready && !busy ? 'bg-brand-600 hover:bg-brand-700' : 'cursor-not-allowed bg-slate-200',
)}
>
{busy && <Loader2 className="h-3 w-3 animate-spin" />}
记录并前往
</button>
</div>
</div>
</PopoverContent>
</Popover>
);
}
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