Commit bbc530b9 by luoqi

merge: test → main —— 分配功能上生产

分配(生产首次上线)
· 初选矩阵 8 治疗项 × 6 时间档,点一格 = 把这批人交给助手
· 助手出确认单:谁分给谁、每人几条、谁待定;可移出/改派/改时限/挂福利
· 落人规则:有专属的回自己人手上,其余给当前手上最少的( 无容量上限)
· 确认 30 分钟内可撤销;批次跟踪 + 团队现在什么状态

口径
· 默认时限 3 天(它是乘数:本批人数 = 在岗 × 每天 15 通 × 时限)
· **到期不回池** —— 时限到了什么都不发生,单子留在原客服手上,只记为超期
· 超期 = 此刻还挂在客服手上 + 过了时限 + 没约下次;已回池的 不算

顺带
· 助手每轮往 agent_invocations 落一行(只落该调的工具调没调)
· 提示词与 21 个工具描述全面去黑话;修了助手画图用错品牌色(teal → #0032A0)
· 初选矩阵提速约 9 倍(标签预计算落库)
· 话术分渠道;客服名册按「在这儿干活」判在岗;文档站新增《分配助手》

🔴 上线必做两件(详见预案)
· 部署完**立刻**回填标签:397,712 行,不跑矩阵 48 格全是 0 且不报错
· 全量重算 plan **今晚不跑** —— 冷却期 14→90 天会关掉 32,371 条在跑的单(17.5%)
parents 9f006704 413a27d0
Pipeline #3582 failed in 0 seconds
...@@ -250,13 +250,29 @@ ps aux | grep -E "node.*(prisma|nest|next)" | grep -v grep | awk '{for(i=11;i<=N ...@@ -250,13 +250,29 @@ ps aux | grep -E "node.*(prisma|nest|next)" | grep -v grep | awk '{for(i=11;i<=N
ps aux | grep -E "node.*(prisma|nest|next)" | grep -v grep | awk '{print $2}' | xargs kill 2>/dev/null ps aux | grep -E "node.*(prisma|nest|next)" | grep -v grep | awk '{print $2}' | xargs kill 2>/dev/null
``` ```
### Backups (`scripts/backup-db.sh`) ### Backups
Two scripts, different jobs — don't confuse them:
| | |
|---|---|
| `scripts/backup-db.sh` | **ad-hoc / local.** Dump before a risky migration, etc. |
| `deploy/pac-backup.sh` | **the nightly cron on the test host** (`/root/pac-backup.sh`, `0 4 * * *`). Edit it here and copy it up — for months it existed only on the server. |
```bash ```bash
./scripts/backup-db.sh # ./backups, keep 7 ./scripts/backup-db.sh # ./backups, keep 7
BACKUP_DIR=/var/backups RETENTION=14 ./scripts/backup-db.sh BACKUP_DIR=/var/backups RETENTION=14 ./scripts/backup-db.sh
bash /root/pac-backup.sh --dry-run # on the host: check space, delete nothing
``` ```
> ⚠️ **The nightly script rotates *before* it dumps, and refuses to start when space is short.**
> It used to rotate afterwards, so the disk had to hold `KEEP+1` dumps at once — on 2026-08-11
> and 2026-08-15 it filled the disk and Postgres died mid-dump
> (`PANIC: could not write to file "pg_logical/replorigin_checkpoint.tmp": No space left on device`).
> The DB recovered on its own once the partial dump was deleted, so **nothing looked wrong by
> daylight** and it happened twice before anyone noticed. Don't reorder those two steps back.
Restore (test in a scratch DB regularly — an unverified backup is no backup): Restore (test in a scratch DB regularly — an unverified backup is no backup):
```bash ```bash
docker exec -i pac-postgres pg_restore -U pac -d pac \ docker exec -i pac-postgres pg_restore -U pac -d pac \
......
...@@ -44,7 +44,7 @@ buildContext(plan_reasons + facts + persona, tier) ...@@ -44,7 +44,7 @@ buildContext(plan_reasons + facts + persona, tier)
→ 落 plan_scripts(status=ready)+ agent_invocations(tier/model/token/cost/source) → 落 plan_scripts(status=ready)+ agent_invocations(tier/model/token/cost/source)
``` ```
**stable**:单次 AiCall 产固定 4 段 —— `opening`(开场)/ `informMissed`(告知应治未治,单项)/ `reviewAdvice`(复查建议,含`【时间段】`占位)/ `closing`(结束语,成功/失败两分支)+ `tone`。失败 → `stableTemplateFallback`(不调 LLM,从 `disease-knowledge` 拼 4 段)。 **stable**:单次 AiCall 产固定 4 段 —— `opening`(开场)/ `informMissed`(告知潜在治疗,单项)/ `reviewAdvice`(复查建议,含`【时间段】`占位)/ `closing`(结束语,成功/失败两分支)+ `tone`。失败 → `stableTemplateFallback`(不调 LLM,从 `disease-knowledge` 拼 4 段)。
**standard**:单次 AiCall,输出 `sections[{title, markdown}]`(固定 4 段但 LLM 自定标题/结构)。失败 → stable 模板转 sections。 **standard**:单次 AiCall,输出 `sections[{title, markdown}]`(固定 4 段但 LLM 自定标题/结构)。失败 → stable 模板转 sections。
......
...@@ -58,7 +58,7 @@ flowchart LR ...@@ -58,7 +58,7 @@ flowchart LR
|---|---|---| |---|---|---|
| **① transforms** =「**形态改造**」*(manifest,per-host)* | 拆行 / 派生 / 关键词分流 / 多列推断 / 行过滤 | **6 个白名单算子**(`project` / `split_json_array` / `derive` / `route_by_pattern` / `pick_first_nonzero` / `filter`),无副作用纯函数,**不允许任意 JS / eval** | | **① transforms** =「**形态改造**」*(manifest,per-host)* | 拆行 / 派生 / 关键词分流 / 多列推断 / 行过滤 | **6 个白名单算子**(`project` / `split_json_array` / `derive` / `route_by_pattern` / `pick_first_nonzero` / `filter`),无副作用纯函数,**不允许任意 JS / eval** |
| **② assembler** =「**词汇翻译**」*(manifest,per-host)* | 字段名翻译(宿主 → canonical)+ 枚举码翻译(闭集字典 + `_default` 兜底)+ `emits` 推断 | manifest + yaml 驱动;**名字虽叫 assembler,本质是翻译器** | | **② assembler** =「**词汇翻译**」*(manifest,per-host)* | 字段名翻译(宿主 → canonical)+ 枚举码翻译(闭集字典 + `_default` 兜底)+ `emits` 推断 | manifest + yaml 驱动;**名字虽叫 assembler,本质是翻译器** |
| **③ 事务合成** | 把 canonical 行写成 `patient_transactions`(append-only 账本) | `raw_payload` + `canonical_payload` 双留底;`source_event_id` 幂等键;`event_seq` 单调水位 | | **③ 事务合成** | 把 canonical 行写成 `patient_transactions`(append-only 账本) | `raw_payload` 留底(宿主原文,reparse 的输入);`source_event_id` 幂等键;`event_seq` 单调水位 |
| **④ parser + Zod** =「**类型 + 语义 + 校验**」 | 从 transaction 把扁平行**构造成带类型的 `fact.content`**:单位 / 牙位 / 文本归一、code 纠偏(如 K00 误标→K08)、`FactContentSchema` 强校验 | **唯一会抛错拦脏数据的强校验闸**,字段漂移即拒 | | **④ parser + Zod** =「**类型 + 语义 + 校验**」 | 从 transaction 把扁平行**构造成带类型的 `fact.content`**:单位 / 牙位 / 文本归一、code 纠偏(如 K00 误标→K08)、`FactContentSchema` 强校验 | **唯一会抛错拦脏数据的强校验闸**,字段漂移即拒 |
| **⑤ fact 落地** | 写 `patient_facts`(版本流,supersede 旧版) | 唯一写入口 `FactWriter`,evidence 反指 transaction | | **⑤ fact 落地** | 写 `patient_facts`(版本流,supersede 旧版) | 唯一写入口 `FactWriter`,evidence 反指 transaction |
......
...@@ -110,7 +110,7 @@ patient_facts.transactionIds → patient_transactions.id → rawPayload ...@@ -110,7 +110,7 @@ patient_facts.transactionIds → patient_transactions.id → rawPayload
**`patient_return_visits`** — 诊所回访任务记录(展示用)。唯一键 `(host_id, tenant_id, source_unit, external_id)`。**不是临床 fact、不进召回信号**;详情页"回访记录"按 `task_date` 倒序展示,避免客服重复外呼。 **`patient_return_visits`** — 诊所回访任务记录(展示用)。唯一键 `(host_id, tenant_id, source_unit, external_id)`。**不是临床 fact、不进召回信号**;详情页"回访记录"按 `task_date` 倒序展示,避免客服重复外呼。
**`patient_transactions`** — 操作账本(append-only)。`event_seq` BigInt 单调水位(供 persona / 游标消费);`source_event_id` 幂等键(Push 必带,Pull adapter 合成);`raw_payload`(宿主原文)+ `canonical_payload`(assembler 翻译后中间态,审计/replay)+ `payload_hash`;`clinic_id` 立柱。`action` × `subject_type` 为 PAC 归一化封闭集 —— **23 个 action × 16 个 subject_type**(`@pac/types` `Action` / `SubjectType`)。 **`patient_transactions`** — 操作账本(append-only)。`event_seq` BigInt 单调水位(供 persona / 游标消费);`source_event_id` 幂等键(Push 必带,Pull adapter 合成);`raw_payload`(宿主原文,唯一留底 + reparse 输入)+ `payload_hash`;`clinic_id` 立柱。`action` × `subject_type` 为 PAC 归一化封闭集 —— **23 个 action × 16 个 subject_type**(`@pac/types` `Action` / `SubjectType`)。
**`patient_facts`** — 事实单元(版本流)。`subject_id` 业务身份跨版本稳定,`(…, subject_id, version)` 单调递增;`kind`(`actual`/`planned`,2)× `type`(**16 个 FactType**)× `status`(`active`/`superseded`/`cancelled`/`fulfilled`/`expired`/`invalidated`,6);`content` JSONB(per type zod 校验,规则引擎只读这里);`transaction_ids` 证据链。一个 transaction 可产 0/1/N 个 fact。 **`patient_facts`** — 事实单元(版本流)。`subject_id` 业务身份跨版本稳定,`(…, subject_id, version)` 单调递增;`kind`(`actual`/`planned`,2)× `type`(**16 个 FactType**)× `status`(`active`/`superseded`/`cancelled`/`fulfilled`/`expired`/`invalidated`,6);`content` JSONB(per type zod 校验,规则引擎只读这里);`transaction_ids` 证据链。一个 transaction 可产 0/1/N 个 fact。
......
---
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",
"assignment-agent"
]
}
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
"---了解 PAC---", "---了解 PAC---",
"start", "start",
"---设计---", "---设计---",
"design",
"architecture", "architecture",
"algorithms", "algorithms",
"design-system", "design-system",
......
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -248,8 +248,23 @@ sql_source: ...@@ -248,8 +248,23 @@ sql_source:
# ARRAY JOIN 把 10 病种列 pivot 成 (code,牙位数组),splitByChar+arrayJoin 炸成每牙一行。 # ARRAY JOIN 把 10 病种列 pivot 成 (code,牙位数组),splitByChar+arrayJoin 炸成每牙一行。
# ⚠️ prod CH 23.8 不允许「多 JOIN + ARRAY JOIN 同层」→ 必须分层嵌套: # ⚠️ prod CH 23.8 不允许「多 JOIN + ARRAY JOIN 同层」→ 必须分层嵌套:
# s1(joins,无 array join)→ s2(array join,读 s1 子查询)→ 外层(无 join)。 # s1(joins,无 array join)→ s2(array join,读 s1 子查询)→ 外层(无 join)。
# ⚠️ cohort 注入(裸 patient_id,brand IN)会插到首个 GROUP BY 前 = po 子查询 → po 按 batch scope, # ⚠️ cohort 注入(裸 patient_id,brand IN)会插到**首个 GROUP BY 前** = 下方 e 子查询 →
# 外层 notEmpty(po.org) 恰好把输出过滤到 batch 患者(已在 prod CH 验证等价正确)。 # e 按 batch scope,INNER JOIN 恰好把输出过滤到 batch 患者。
# 🔴 所以那个 GROUP BY 里 **`patient_id` 必须留着**,⛔ 别"优化"成只按 id 分组 ——
# 注入会落空 → 每批全表扫 + 串批,而且**不报错**。
#
# 🔴 **诊所取自这张片自己那次病历(`ia.emr_id`),⛔ 不许再"借"**(2026-08-16 改)。
# 原来是 `any(organization_id)` 按**患者**取:随便挑该患者的一条病历,把那家诊所
# 贴到片子上。两个毛病 ——
# ① `any()` 语义就是"随便挑一条",患者跨诊所时挑中哪家是任意的、重跑还会变;
# ② 挑的维度根本不对:要答的是「**这张片在哪拍的**」,却按"这个人在哪看过"回答。
# 实测(2026-08-16 全量):160,436 条两版都能算出诊所的行里,**24,849 条(15.5%)挂错**,
# 牵涉 21,149 个患者。测试服上海世纪公园抓到三个样本,病历号前缀是 CQ/GZ(重庆/广州),
# 被贴成了上海 —— 而那家诊所在池患者 99.3% 是 SH 开头的。
# ⚠️ 改成精确 join 只少 879 行(0.5%):那些是片子挂着一个**在病历表里不存在**的 emr_id。
# 它们取不到诊所 → `transaction-synthesizer` 按「clinic 是立柱必填」跳过 → 不摄入。
# ⭐ 这是刻意的(产品定):宁可不要,也⛔ 不拿别的诊所顶上去装作知道。
# ⚠️ `e.patient_id = c.patient_id` 这一条也要留:片子的病历必须属于同一个患者。
# 病种→K 码映射留 manifest(host 形态);去重靠召回 (subKey,tooth) 聚类;code_source=image_ai 独立 subject。 # 病种→K 码映射留 manifest(host 形态);去重靠召回 (subKey,tooth) 聚类;code_source=image_ai 独立 subject。
image_finding_rows: | image_finding_rows: |
SELECT patient_id, brand, organization_id, emr_id, rq, code, code_source, tooth, SELECT patient_id, brand, organization_id, emr_id, rq, code, code_source, tooth,
...@@ -261,7 +276,7 @@ sql_source: ...@@ -261,7 +276,7 @@ sql_source:
-- 注:同 K 码跨影像列(K01 阻生+埋伏 / K03 三列)仍各列一条 fact,union-find 会重聚类。 -- 注:同 K 码跨影像列(K01 阻生+埋伏 / K03 三列)仍各列一条 fact,union-find 会重聚类。
replaceRegexpAll(replaceRegexpAll(cm.2, '[\[\] '']', ''), ',', ';') AS tooth replaceRegexpAll(replaceRegexpAll(cm.2, '[\[\] '']', ''), ',', ';') AS tooth
FROM ( FROM (
SELECT c.patient_id AS patient_id, c.brand AS brand, po.org AS organization_id, SELECT c.patient_id AS patient_id, c.brand AS brand, e.org AS organization_id,
ia.emr_id AS emr_id, ia.rq AS rq, ia.emr_id AS emr_id, ia.rq AS rq,
ia.cavity AS cavity, ia.impacted_tooth AS impacted_tooth, ia.embedded_tooth AS embedded_tooth, ia.cavity AS cavity, ia.impacted_tooth AS impacted_tooth, ia.embedded_tooth AS embedded_tooth,
ia.root_periodontitis AS root_periodontitis, ia.root_remnant AS root_remnant, ia.root_periodontitis AS root_periodontitis, ia.root_remnant AS root_remnant,
...@@ -269,12 +284,12 @@ sql_source: ...@@ -269,12 +284,12 @@ sql_source:
ia.cyst AS cyst, ia.tooth_loss AS tooth_loss, ia.retained_primary_tooth AS retained_primary_tooth ia.cyst AS cyst, ia.tooth_loss AS tooth_loss, ia.retained_primary_tooth AS retained_primary_tooth
FROM dw_group.fact_emr_image_analysis_out ia FROM dw_group.fact_emr_image_analysis_out ia
INNER JOIN dw_group.fact_client_out c ON c.file_num = ia.file_num AND c.brand = ia.brand INNER JOIN dw_group.fact_client_out c ON c.file_num = ia.file_num AND c.brand = ia.brand
LEFT JOIN ( INNER JOIN (
SELECT patient_id, brand, any(organization_id) AS org SELECT patient_id, brand, id, any(organization_id) AS org
FROM dw_group.fact_emr_treatment_out WHERE notEmpty(organization_id) FROM dw_group.fact_emr_treatment_out WHERE notEmpty(organization_id)
GROUP BY patient_id, brand GROUP BY patient_id, brand, id
) po ON po.patient_id = c.patient_id AND po.brand = c.brand ) e ON e.id = ia.emr_id AND e.brand = ia.brand AND e.patient_id = c.patient_id
WHERE c.last_visit_time IS NOT NULL AND notEmpty(po.org) WHERE c.last_visit_time IS NOT NULL
) s1 ) s1
ARRAY JOIN [('K02', cavity), ('K01', impacted_tooth), ('K01', embedded_tooth), ARRAY JOIN [('K02', cavity), ('K01', impacted_tooth), ('K01', embedded_tooth),
('K04', root_periodontitis), ('K03', root_remnant), ('K03', crown_remnant), ('K04', root_periodontitis), ('K03', root_remnant), ('K03', crown_remnant),
......
...@@ -23,6 +23,43 @@ module.exports = { ...@@ -23,6 +23,43 @@ module.exports = {
'^@pac/types/(.*)$': '<rootDir>/../../packages/types/src/$1', '^@pac/types/(.*)$': '<rootDir>/../../packages/types/src/$1',
}, },
transform: { transform: {
'^.+\\.ts$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.json' }], /**
* 🔴 `isolatedModules: true` —— **jest 不再做类型检查**,只做转译。
*
* ── 为什么(2026-08-06 实测)────────────────────────────────────
* 默认 ts-jest 会在**每个 worker 里各跑一个完整的 TypeScript program**,
* 而 `@pac/types` 在下面被映射到**源码**,于是每个 worker 都要把整个 types 包
* 连同 src 一起类型检查一遍。16 核机器上 jest 默认开 15 个 worker,
* 实测:15 个进程 × ~250MB,每个 75~100% CPU,**整机 16 核吃满**。
*
* 实测对比(同为热缓存,同一台 16 核 / 16GB):
* 默认 27.8s / CPU 时间 251s
* +isolatedModules ~10s
* +maxWorkers 50% 6.9s / CPU 时间 43s ← 现在这套
* 墙钟 4 倍,**CPU 时间 5.8 倍** —— 后者才是"跑测试时电脑卡"的直接原因。
*
* ⚠️⚠️ **代价:类型错误不会再让 jest 变红。**
* 类型安全**只能**靠单独那一趟:
* pnpm exec tsc --noEmit -p tsconfig.typecheck.json
* ⛔ 谁要把这一趟从流程里去掉,必须先把这里改回来 —— 否则两道闸同时没了。
* (那份 typecheck 配置本身就是为"tests/ 从来没被类型检查过"补的,见其注释。)
*/
/**
* ⚠️ ts-jest 会提示「isolatedModules 已废弃,请写到 tsconfig 里」—— **这里不能照做**。
* `apps/pac-service/tsconfig.json` 明确把 `isolatedModules` 设成了 **false**
* (它同时是 nest build / swc 用的那份,那个 flag 会限制 const enum 等写法),
* ts-jest 读到 false 就退回全量类型检查。
* 实测(2026-08-06):按提示挪进 tsconfig 后 → 18.1s / CPU 165s;
* 保留在这里 → **6.0s / CPU 43s**。⇒ 以实测为准,留在这里,忍受那条 WARN。
* ⛔ 想消 WARN 的话别去动 tsconfig 的 isolatedModules —— 那会改到编译产物。
*/
'^.+\\.ts$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.json', isolatedModules: true }],
}, },
/**
* ⚠️ 默认是 `cores - 1`(16 核 → 15 个 worker),本地开发时**整机没有余量**:
* 还并行跑着 nest --watch、next dev、docker 里的 Postgres/Redis。
* 50% 让出一半的核,墙钟只慢一点点,但机器还能用。
* ⚠️ CI 上单独跑没有别的负载,可以用 `--maxWorkers=100%` 覆盖回来。
*/
maxWorkers: '50%',
}; };
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
"start": "node dist/main.js", "start": "node dist/main.js",
"start:prod": "node dist/main.js", "start:prod": "node dist/main.js",
"lint": "eslint src", "lint": "eslint src",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit && tsc --noEmit -p tsconfig.typecheck.json",
"test": "jest --passWithNoTests", "test": "jest --passWithNoTests",
"clean": "rm -rf dist .turbo", "clean": "rm -rf dist .turbo",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
...@@ -22,11 +22,13 @@ ...@@ -22,11 +22,13 @@
"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",
"import-patient": "ts-node --transpile-only src/cli/import-patient.cli.ts", "import-patient": "ts-node --transpile-only src/cli/import-patient.cli.ts",
"recompute-persona": "ts-node --transpile-only src/cli/recompute-persona.cli.ts", "recompute-persona": "ts-node --transpile-only src/cli/recompute-persona.cli.ts",
"backfill-plan-labels": "ts-node --transpile-only src/cli/backfill-plan-labels.cli.ts",
"recompute-persona:prod": "node --max-old-space-size=8192 dist/cli/recompute-persona.cli.js", "recompute-persona:prod": "node --max-old-space-size=8192 dist/cli/recompute-persona.cli.js",
"recompute-plans": "ts-node --transpile-only src/cli/recompute-plans.cli.ts", "recompute-plans": "ts-node --transpile-only src/cli/recompute-plans.cli.ts",
"recompute-plans:prod": "node --max-old-space-size=8192 dist/cli/recompute-plans.cli.js", "recompute-plans:prod": "node --max-old-space-size=8192 dist/cli/recompute-plans.cli.js",
...@@ -41,7 +43,8 @@ ...@@ -41,7 +43,8 @@
"stale-scan": "ts-node --transpile-only src/cli/stale-scan.cli.ts", "stale-scan": "ts-node --transpile-only src/cli/stale-scan.cli.ts",
"stale-scan:prod": "node dist/cli/stale-scan.cli.js", "stale-scan:prod": "node dist/cli/stale-scan.cli.js",
"openapi:dump": "ts-node --transpile-only src/cli/dump-openapi.cli.ts", "openapi:dump": "ts-node --transpile-only src/cli/dump-openapi.cli.ts",
"refresh-clinic-names": "ts-node --transpile-only src/cli/refresh-clinic-names.cli.ts" "refresh-clinic-names": "ts-node --transpile-only src/cli/refresh-clinic-names.cli.ts",
"golden": "ts-node --transpile-only tests/golden/run.ts"
}, },
"prisma": { "prisma": {
"seed": "ts-node --transpile-only prisma/seed.ts" "seed": "ts-node --transpile-only prisma/seed.ts"
......
-- 门诊经理批次分配 —— 建 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");
-- 初选矩阵提速:把「这条依据能推出哪几个潜在治疗标签」预先算好落库。
--
-- 此前矩阵每次开页面都现推:plan_reasons → 展开 evidence.factIds → 回查 patient_facts
-- (1567 万行 / 18 GB)。最忙的诊所一次摊三万多次随机查、372 MB I/O,
-- 而这一切的唯一产出就是这个字符串。
-- 本地实测(15,884 条 plan):958 ms → 110 ms,磁盘读 119,861 → 0,24 格的数逐字未变。
--
-- ⚠️ **刻意可空**,三态有别:
-- NULL = 还没算过(回填 / 新写入的行会被刷新任务捞走)
-- '{}' = 算过了,这条依据推不出任何标签(K 码不在规则表里 / 年龄不落区间)
-- 非空 = 标签集合
-- ⛔ 别加 NOT NULL DEFAULT '{}' —— 那样「没算过」和「算过是空」就分不开,
-- 刷新任务再也找不到漏网的行,而漏了不会有任何报错。
-- AlterTable
ALTER TABLE "plan_reasons" ADD COLUMN "potential_labels" TEXT[];
-- 矩阵只关心「推得出标签」的那些依据;推不出的占比不低,跳过它们能少扫一截。
-- ⚠️ 用 IS NOT NULL 而不是 <> '{}':后者会把「还没算过(NULL)」也排除在外,
-- 而回填期间正需要能看见它们。
CREATE INDEX "plan_reasons_plan_id_potential_labels_idx"
ON "plan_reasons" ("plan_id")
WHERE "potential_labels" IS NOT NULL AND array_length("potential_labels", 1) > 0;
-- 「还没算标签的依据」专用索引。
--
-- 生成完 plan 之后要立刻补标签(不补就是 NULL,矩阵直接看不见这些人)。
-- 没有这个索引的话,每次补都要为找 NULL 扫一遍 plan_reasons(测试服 31 万行)——
-- 而绝大多数时候一条都没有,纯白扫。
-- ⚠️ 部分索引只收 NULL 行 ⇒ 平时几乎是空的,补完即回到 O(1)。
CREATE INDEX "plan_reasons_labels_missing_idx"
ON "plan_reasons" ("id")
WHERE "potential_labels" IS NULL;
-- 删除 patient_transactions.canonical_payload
--
-- 【为什么】它存 AssemblerEngine yaml 翻译后的中间态,设想用途是「审计回查」+「yaml 改后局部 replay」。
-- 2026-08-18 全仓库核查(含 pac-web / 原生 SQL / CLI / 测试 / test 分支):**1 处写、0 处读** ——
-- 两个设想用途都没有落地成代码,reparse 走的是 raw_payload 从头重跑。
-- 实测占用:测试服 patient_transactions 42 GB 中约 7.9 GB(按 subject_type 抽样外推)。
--
-- 【会不会丢证据】不会。canonical 是 raw_payload 经 field-mapping 推导出来的,
-- 需要时跑一次 reparse 即得 —— 那正是 reparse 做的事。真原始证据 raw_payload 原样保留。
--
-- 【执行成本】PostgreSQL 的 DROP COLUMN 只改系统目录、不重写表,42 GB 表上也是秒级、不锁长时间。
-- ⚠️ 但空间不会立刻还给操作系统:已有行里那部分空间转为表内可复用空间,由后续写入吸收
-- (效果 = 这张表在磁盘上停止增长一段时间)。要真正缩表需 VACUUM FULL / pg_repack,另行安排。
ALTER TABLE "patient_transactions" DROP COLUMN "canonical_payload";
/**
* 初选矩阵基准 —— 「改之前 / 改之后」用**同一把尺**量。
*
* ⚠️ 只量 SQL 执行时间(`EXPLAIN ANALYZE` 的 Execution Time),⛔ 不含 HTTP / 序列化:
* 那两段在改动前后是一样的,混进来只会稀释信号。
* ⚠️ 每档跑 N 轮取**最快**,⛔ 不取平均 —— 这台机器上跑着别的东西,
* 平均值被噪声主导(实测同一条件下 1388~2130ms 都出现过)。最快值才稳定可比。
*
* 用法:
* npx ts-node -T scripts/bench-matrix.ts # 跑全部诊所档位
* npx ts-node -T scripts/bench-matrix.ts --rounds 5
*/
import { PrismaClient } from '@prisma/client';
import { poolBaseSql } from '../src/modules/plan/cohort-filter';
import { planLabelAnchorsSql } from '../src/modules/plan/reason-temperature.sql';
const prisma = new PrismaClient();
const ROUNDS = Number(process.argv[process.argv.indexOf('--rounds') + 1]) || 3;
/** 把 Prisma.Sql 的 `?` 占位换成字面量 —— EXPLAIN 不吃参数化语句。 */
function inline(q: { sql: string; values: unknown[] }): string {
let i = 0;
return q.sql.replace(/\?/g, () => {
const v = q.values[i++];
return typeof v === 'string' ? `'${v.replace(/'/g, "''")}'` : String(v);
});
}
/**
* ⚠️ **sourceUnits 必须按真实值传** —— 它非空时 `poolBaseSql` 会拼 `AND p.source_unit IN (...)`,
* 走的是另一条 SQL 分支。2026-08-17 这里硬编 `[]` 导致本地永远测不到那一支,
* 把删掉 `JOIN patients p` 的改动一路放行到线上,矩阵直接 500
* (`missing FROM-clause entry for table "p"`)。⛔ 别再退回硬编。
*/
function matrixSql(
hostId: string,
tenantId: string,
clinicId: string,
sourceUnits: string[],
): string {
const scope = { hostId, tenantId, clinicIds: [], sourceUnits } as never;
const inner = inline(planLabelAnchorsSql(poolBaseSql(scope, clinicId)));
// 外层与 `CohortAttributesService.matrix` 同构(温度 CASE 简化成三档,量的是同一条 join 路径)
return `EXPLAIN (ANALYZE, BUFFERS)
WITH la AS (${inner}),
b AS (SELECT patient_id, label,
CASE WHEN NOW() <= hot_until THEN 'hot'
WHEN NOW() <= warm_until THEN 'warm' ELSE 'cold' END AS temp
FROM la)
SELECT label, temp, count(DISTINCT patient_id) AS n
FROM b GROUP BY GROUPING SETS ((label, temp), (temp));`;
}
async function run(sql: string) {
const rows = await prisma.$queryRawUnsafe<Array<Record<string, string>>>(sql);
const text = rows.map((r) => Object.values(r)[0]).join('\n');
const ms = Number(/Execution Time: ([\d.]+)/.exec(text)?.[1] ?? NaN);
const reads = [...text.matchAll(/read=(\d+)/g)].reduce((a, m) => a + Number(m[1]), 0);
// ⚠️ 并行查询里每个 worker 各报一段 JIT,这里取首段;⛔ 别拿它跟墙钟直接比大小
const jit = Number(/JIT[\s\S]*?Total (\d+\.?\d*) ms/.exec(text)?.[1] ?? 0);
return { ms, reads, jit };
}
/**
* 🔴 **结果快照** —— 性能改动必须同时证明「24 格一个数都没变」。
* ⛔ 只比耗时是不够的:把 join 改掉、把标签预计算,最容易的失败是**悄悄少算一批人**,
* 而那正是产品最不能接受的(「主管一对数就觉得系统在骗他」)。
*/
async function snapshot(sqlWithExplain: string) {
const plain = sqlWithExplain.replace(/^EXPLAIN \([^)]*\)\n/, '');
const rows = await prisma.$queryRawUnsafe<
Array<{ label: string | null; temp: string | null; n: bigint }>
>(plain);
return rows
.map((r) => `${r.label ?? '∑'}/${r.temp ?? '∑'}=${r.n}`)
.sort()
.join(' ');
}
(async () => {
const clinics = await prisma.$queryRaw<
Array<{ host_id: string; tenant_id: string; target_clinic_id: string; n: bigint }>
>`SELECT host_id, tenant_id, target_clinic_id, count(*) AS n
FROM followup_plans WHERE status='active' AND assignee_user_id IS NULL
GROUP BY 1,2,3 HAVING count(*) >= 10 ORDER BY 4 DESC LIMIT 5`;
// 真实存在的品牌命名空间 —— 拿来跑「sourceUnits 非空」那一支
const units = await prisma.$queryRaw<Array<{ source_unit: string }>>`
SELECT DISTINCT source_unit FROM patients WHERE source_unit IS NOT NULL LIMIT 3`;
const unitList = units.map((u) => u.source_unit);
console.log(` (另跑一遍 sourceUnits=${JSON.stringify(unitList)} 那一支)\n`);
console.log(`初选矩阵基准 · 每档取 ${ROUNDS} 轮最快值\n`);
console.log(' plan 数 最快耗时 磁盘读 JIT(首段)');
console.log(' ' + '─'.repeat(46));
const out: Array<Record<string, number>> = [];
const snaps: string[] = [];
for (const c of clinics) {
const sql = matrixSql(c.host_id, c.tenant_id, c.target_clinic_id, []);
// 🔴 同一诊所再跑一遍**带品牌过滤**的那一支 —— 只测一支等于没测
await run(matrixSql(c.host_id, c.tenant_id, c.target_clinic_id, unitList));
let best = { ms: Infinity, reads: 0, jit: 0 };
for (let r = 0; r < ROUNDS; r++) {
const x = await run(sql);
if (x.ms < best.ms) best = x;
}
const n = Number(c.n);
console.log(
` ${String(n).padStart(7)} ${best.ms.toFixed(0).padStart(6)} ms ` +
`${String(best.reads).padStart(7)} ${best.jit ? best.jit.toFixed(0) + ' ms' : '—'}`,
);
out.push({ plans: n, ms: best.ms, reads: best.reads, jit: best.jit });
snaps.push(`${n}: ${await snapshot(sql)}`);
}
console.log('\nJSON ' + JSON.stringify(out));
console.log('\n结果快照(改动后必须逐字相同):');
for (const s of snaps) console.log(' ' + s);
await prisma.$disconnect();
})().catch((e) => {
console.error(e);
process.exit(1);
});
/**
* Backfill Plan Labels CLI —— 回填 / 重算 `plan_reasons.potential_labels`。
*
* 初选矩阵靠这一列(改造后不再回查 `patient_facts`,958ms → 110ms)。
*
* 🔴 **上线后必须立刻跑一次**:迁移只是加了列,全表都是 NULL,而矩阵
* `unnest(potential_labels)` 遇到 NULL 什么都不出 ⇒ **这段时间矩阵会全是 0**。
* ⛔ 别指望夜间任务兜 —— 那要等到次日 03:30。
*
* ⚠️ **重算 plan 也能补齐,而且是安全的** —— ⛔ 别被"会释放客服"吓住(那是错的):
* `auto_release` 只在「本轮该患者 0 命中(信号真没了)」或「最后到诊诊所变了」时触发,
* 两者都是真实业务变化,不是重算这个动作造成的;这套逻辑本来就跟着增量同步每天在跑,
* 该释放的早已释放,再跑一次不会多释放任何人。
* ⇒ 日常维护**本来就靠原机制**:plan 生成收尾会调 `backfillMissing()`。
* 这个 CLI 只解决一件事:**上线那一刻的空窗** —— 迁移后全表 NULL,
* 等下一次增量同步(最长两小时)或夜间刷新(03:30)才自愈,
* 而 CLI 把这个窗口压到约一分钟。⛔ 别拿它当日常手段。
*
* Usage:
* pnpm backfill-plan-labels # 只补没算过的(上线后跑这个)
* pnpm backfill-plan-labels -- --all # 连算过的一起重算(改了标签规则后跑)
* pnpm backfill-plan-labels -- --check # 只报还差多少,不写库
*/
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { AppModule } from '../app.module';
import { PlanLabelService } from '../modules/plan/plan-label.service';
async function main(): Promise<void> {
const log = new Logger('backfill-plan-labels');
const argv = process.argv.slice(2);
const all = argv.includes('--all');
const checkOnly = argv.includes('--check');
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['error', 'warn', 'log'],
});
try {
const svc = app.get(PlanLabelService);
const before = await svc.countMissing();
log.log(`还没算过的依据:${before} 条`);
if (checkOnly) return;
const t0 = Date.now();
const written = all ? await svc.refreshAll() : await svc.backfillMissing();
const after = await svc.countMissing();
log.log(
`${all ? '全量重算' : '回填'}完成:写入 ${written} 条,` +
`耗时 ${((Date.now() - t0) / 1000).toFixed(1)}s,剩余未算 ${after} 条`,
);
// ⚠️ 回填完还有剩 = 有条写入路径没被覆盖到,值得当场查,⛔ 别等矩阵少人了才发现
if (!all && after > 0) {
log.error(`⚠️ 回填后仍有 ${after} 条没算过 —— 检查是否有新写入路径没接上补齐`);
process.exitCode = 1;
}
} finally {
await app.close();
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
import { ForbiddenException } from '@nestjs/common';
import type { TenantScopeContext } from './tenant-scope.decorator';
/**
* 🔴 **诊所 id 的唯一入口** —— 不传就用登录人的第一个诊所,传了就**必须在他的范围里**。
*
* ── 由来(2026-08-10 测试机实测的越权)──────────────────────────
* 北京朝阳公园的主管(`clinicIds = [66701845…]`)带着别家诊所的 id
* (`7d49539c…` 杭州大厦)请求 `/plans/assignments/workload`,拿到了
* **200 + 那家 26 位客服的姓名与负载**。`/agents`、`/plans/matrix` 同样敞着。
*
* ⚠️ 这三个都是**读**接口。写路径(创建批次)早就拦了
* (`plan-assignment.service` 里那句 `!scope.clinicIds.includes(dto.clinicId)`),
* 所以分不走别家的人 —— 但名册、负载、整个池子矩阵能随便看。
* ⛔ 别因为"写拦住了"就觉得读无所谓:名册是**员工姓名**,矩阵是**患者量分布**。
*
* ⚠️ 触发它的不是攻击,是**前端第一帧**:`visibleClinics` 在 `/auth/session` 回来之前
* 会回落到"字典里的全部诊所",于是工作台第一帧就带着别家 id 发了一次请求。
* 前端那边也修了,但 ⛔ **前端修好不等于这里可以不拦** ——
* 查询串是用户可改的,少了服务端这道闸,改个 URL 就越权。
*
* ⚠️ `scope.clinicIds` 为空 = **集团级范围**(看全部诊所),此时无从校验,原样放行。
* ⛔ 别把空数组当成"没有权限"去拒绝 —— 那会把集团角色全锁死。
*
* ⚠️ 抛 `ForbiddenException`(→ 10107),⛔ 不能"当成这个诊所没人"返回空:
* 0 是一个合法答案,静默返回空会让调用方(尤其是助手)拿着 0 去解释
* "为什么这批人是空的",越解释越像真的(违 T14「口径对数」)。
*/
export function resolveClinicId(scope: TenantScopeContext, clinicId?: string): string {
const given = clinicId?.trim();
if (!given) {
const fallback = scope.clinicIds[0];
if (!fallback) {
throw new ForbiddenException(
'没有指定诊所,当前登录人也没有绑定诊所 —— 请先确认数据范围,⛔ 不要自己编一个诊所 id。',
);
}
return fallback;
}
if (scope.clinicIds.length && !scope.clinicIds.includes(given)) {
throw new ForbiddenException(
`诊所 id「${given}」不在当前登录人的数据范围内(他的诊所是:${scope.clinicIds.join(' / ')})。`,
);
}
return given;
}
...@@ -13,7 +13,7 @@ import { ...@@ -13,7 +13,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import * as Sentry from '@sentry/nestjs'; import * as Sentry from '@sentry/nestjs';
import { ApiCode, describeApiCode } from '@pac/types'; import { ApiCode, describeApiCode } from '@pac/types';
import { ZodValidationException } from 'nestjs-zod'; import { ZodSerializationException, ZodValidationException } from 'nestjs-zod';
import { ZodError } from 'zod'; import { ZodError } from 'zod';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { ContractDriftError } from '../../modules/sync/assembler/field-mapper'; import { ContractDriftError } from '../../modules/sync/assembler/field-mapper';
...@@ -28,6 +28,9 @@ import { BizError } from '../errors/biz-error'; ...@@ -28,6 +28,9 @@ import { BizError } from '../errors/biz-error';
* - Truly uncaught Errors → HTTP 500 (so probes / LB still see dead instance). * - Truly uncaught Errors → HTTP 500 (so probes / LB still see dead instance).
* *
* Code mapping policy (priority highest → lowest): * Code mapping policy (priority highest → lowest):
* 0. ZodSerializationException → INTERNAL_ERROR (90000) + **打日志**(响应漂了是 PAC 自己的锅)
* ⚠️ 必须排最前:它继承 InternalServerErrorException,
* 被第 5 条吃掉的话就成了一句没有日志的「Internal Server Error」
* 1. BizError → use its explicit 5-digit code * 1. BizError → use its explicit 5-digit code
* 2. ZodValidationException → CLIENT_VALIDATION_FAILED (10002) * 2. ZodValidationException → CLIENT_VALIDATION_FAILED (10002)
* 3. PayloadTooLargeError → CLIENT_VALIDATION_FAILED (10002), HTTP 200 —— 裸 Error, * 3. PayloadTooLargeError → CLIENT_VALIDATION_FAILED (10002), HTTP 200 —— 裸 Error,
...@@ -50,7 +53,39 @@ export class AllExceptionsFilter implements ExceptionFilter { ...@@ -50,7 +53,39 @@ export class AllExceptionsFilter implements ExceptionFilter {
let msg = describeApiCode(ApiCode.INTERNAL_ERROR); let msg = describeApiCode(ApiCode.INTERNAL_ERROR);
let details: unknown; let details: unknown;
if (exception instanceof BizError) { if (exception instanceof ZodSerializationException) {
/**
* 🔴 **响应**没通过自己的 schema —— 这是 PAC 自己的 bug,不是调用方的。
*
* ⚠️ 必须排在下面的 `HttpException` 之前:`ZodSerializationException`
* 继承的是 `InternalServerErrorException`(⛔ **不是** `ZodValidationException`,
* 名字像但血缘不同)。不单独拦的话它掉进通用 HttpException 分支,
* 对外只剩一句「Internal Server Error / 90000」,
* **日志里一个字都没有**(那条分支不写日志、HTTP 也保持 200,
* 连"5xx"这条兜底日志都不会触发)——
* 2026-08-08 就因为这个,一处响应字段漂了,查了很久才定位。
* ⇒ 这里**必须把 issues 打出来**:哪个字段、期望什么、实际收到什么。
*/
code = ApiCode.INTERNAL_ERROR;
msg = describeApiCode(code);
const err = exception.getZodError();
const issues =
err instanceof ZodError
? err.issues.map((i) => ({
path: i.path.join('.'),
code: i.code,
message: i.message,
}))
: undefined;
this.logger.error(
`${req.method} ${req.url} → 响应不符合 schema(PAC 自身 bug):${JSON.stringify(issues)}`,
);
// ⚠️ details 只在非生产给出:字段路径属于内部结构,⛔ 别漏给宿主
if (process.env.NODE_ENV !== 'production') details = issues;
Sentry.captureException(exception, {
tags: { path: `${req.method} ${req.url}`, kind: 'zod-serialization' },
});
} else if (exception instanceof BizError) {
code = exception.code; code = exception.code;
msg = exception.msg; msg = exception.msg;
details = exception.details; details = exception.details;
......
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(', ')}`);
......
...@@ -35,8 +35,20 @@ export interface AppConfig { ...@@ -35,8 +35,20 @@ export interface AppConfig {
qwenDefaultModel: string; qwenDefaultModel: string;
/// 单次 LLM 调用上限(秒),防卡死(安全网,默认 180;深度档每步各自计时) /// 单次 LLM 调用上限(秒),防卡死(安全网,默认 180;深度档每步各自计时)
requestTimeoutSec: number; requestTimeoutSec: number;
/**
* 助手的**人设语气**(整块换掉系统提示词的 ③ 层,见 assistant-prompts 的 VOICE_DEFAULT)。
* 空 = 用默认那套。宿主 / 品牌要自己的说话方式时配这个,⛔ 不要去改代码里的默认块。
* ⚠️ 它只管**怎么说**:⛔ 不许写事实、工具名、界面部件名 —— 那些换语气的人不该有能力碰。
*/
assistantVoice: string;
/// 价格表(¥/M tokens)— 从 AI_PRICE_TABLE_JSON env 读;调价时改 env 重启即可 /// 价格表(¥/M tokens)— 从 AI_PRICE_TABLE_JSON env 读;调价时改 env 重启即可
priceTable: Record<string, { inHit: number; inMiss: number; out: number }>; priceTable: Record<string, { inHit: number; inMiss: number; out: number }>;
/**
* `agent_invocations` 里**肥字段**(inputSnapshot / prompt / outputText)的保留天数。
* 到期后清成元数据行,⛔ 不删行(成本与通过率曲线要长期可比)。失败行留 3 倍时长。
* 0 = 不清理 —— ⚠️ 只在排查期临时这么配,助手是按对话轮数写库的,不清会涨得很快。
*/
invocationRetentionDays: number;
}; };
alert: { webhookUrl: string }; alert: { webhookUrl: string };
cors: { origins: string[] }; cors: { origins: string[] };
...@@ -72,11 +84,13 @@ export function loadConfig(): AppConfig { ...@@ -72,11 +84,13 @@ export function loadConfig(): AppConfig {
geminiLiveModel: process.env.GEMINI_LIVE_MODEL ?? 'gemini-3.1-flash-live-preview', geminiLiveModel: process.env.GEMINI_LIVE_MODEL ?? 'gemini-3.1-flash-live-preview',
qwenApiKey: process.env.QWEN_API_KEY ?? process.env.DASHSCOPE_API_KEY ?? '', qwenApiKey: process.env.QWEN_API_KEY ?? process.env.DASHSCOPE_API_KEY ?? '',
qwenBaseUrl: process.env.QWEN_BASE_URL ?? 'https://dashscope.aliyuncs.com/compatible-mode/v1', qwenBaseUrl: process.env.QWEN_BASE_URL ?? 'https://dashscope.aliyuncs.com/compatible-mode/v1',
qwenDefaultModel: process.env.QWEN_DEFAULT_MODEL ?? 'qwen3.7-max', qwenDefaultModel: process.env.QWEN_DEFAULT_MODEL ?? 'qwen3.8-max',
// 单次 LLM 调用上限(秒)— 仅作"防永久挂起"安全网,不该卡正常请求。 // 单次 LLM 调用上限(秒)— 仅作"防永久挂起"安全网,不该卡正常请求。
// 取 180s:给 pro(慢·精细)+ 深度档单步留足余量;真挂起 3 分钟后失败 → 走模板兜底。 // 取 180s:给 pro(慢·精细)+ 深度档单步留足余量;真挂起 3 分钟后失败 → 走模板兜底。
requestTimeoutSec: Number(process.env.AI_REQUEST_TIMEOUT_SEC ?? 180), requestTimeoutSec: Number(process.env.AI_REQUEST_TIMEOUT_SEC ?? 180),
assistantVoice: process.env.PAC_ASSISTANT_VOICE ?? '',
priceTable: parsePriceTable(process.env.AI_PRICE_TABLE_JSON), priceTable: parsePriceTable(process.env.AI_PRICE_TABLE_JSON),
invocationRetentionDays: Number(process.env.AI_INVOCATION_RETENTION_DAYS ?? 30),
}, },
alert: { alert: {
webhookUrl: process.env.ALERT_WEBHOOK_URL ?? '', webhookUrl: process.env.ALERT_WEBHOOK_URL ?? '',
...@@ -118,6 +132,13 @@ function parsePriceTable(raw: string | undefined): Record<string, { inHit: numbe ...@@ -118,6 +132,13 @@ function parsePriceTable(raw: string | undefined): Record<string, { inHit: numbe
'gemini-2.5-flash': { inHit: 1.08, inMiss: 10.8, out: 64.8 }, 'gemini-2.5-flash': { inHit: 1.08, inMiss: 10.8, out: 64.8 },
// Qwen3.7-Max 是旗舰最贵档(不是便宜模型):标准价 ¥/M 直接填 // Qwen3.7-Max 是旗舰最贵档(不是便宜模型):标准价 ¥/M 直接填
'qwen3.7-max': { inHit: 1.2, inMiss: 12, out: 36 }, 'qwen3.7-max': { inHit: 1.2, inMiss: 12, out: 36 },
/**
* 🔴 **qwen3.8-max 没有单价** —— 2026-08-13 换成默认模型时查过,
* 公开渠道只查到"输出约 $6/M"这种口径不明的说法,⛔ 不据此编一个数填进来。
* ⇒ 现在它会命中 estimateCostYuan 的兜底(并打一条 warn,见那里)。
* 拿到控制台的真实单价后,⛔ 不用改代码 —— 设 env 即可:
* AI_PRICE_TABLE_JSON='{"qwen3.8-max":{"inHit":X,"inMiss":Y,"out":Z}}'
*/
}; };
if (!raw) return DEFAULT; if (!raw) return DEFAULT;
try { try {
......
...@@ -10,6 +10,7 @@ import { PromptCacheService } from './core/prompt-cache.service'; ...@@ -10,6 +10,7 @@ import { PromptCacheService } from './core/prompt-cache.service';
import { SafetyGateRejectError, SafetyGateService } from './core/safety-gate.service'; import { SafetyGateRejectError, SafetyGateService } from './core/safety-gate.service';
import { computeInputHash } from './core/hash.util'; import { computeInputHash } from './core/hash.util';
import type { AiCall, AiCallContext, AiCallResult } from './ai-call.interface'; import type { AiCall, AiCallContext, AiCallResult } from './ai-call.interface';
import { estimateCostYuan } from './core/cost';
/** /**
* 流式事件 — orchestrator / controller 转换成 SSE 后吐给客户端 * 流式事件 — orchestrator / controller 转换成 SSE 后吐给客户端
...@@ -460,12 +461,27 @@ export class AiCallRunnerService { ...@@ -460,12 +461,27 @@ export class AiCallRunnerService {
cachedInputTokens: number = 0, cachedInputTokens: number = 0,
): number { ): number {
const priceTable = this.config.get('ai', { infer: true }).priceTable; const priceTable = this.config.get('ai', { infer: true }).priceTable;
const p = priceTable[modelId] ?? priceTable['deepseek-v4-pro'] ?? { inHit: 0.5, inMiss: 3.6, out: 25 }; const { yuan, priceMissing } = estimateCostYuan(
// 防御:cached > prompt 不该发生,clamp priceTable,
const hit = Math.min(cachedInputTokens, promptTokens); modelId,
const miss = Math.max(0, promptTokens - hit); promptTokens,
const yuan = (hit * p.inHit + miss * p.inMiss + completionTokens * p.out) / 1_000_000; completionTokens,
return Math.max(0, yuan); cachedInputTokens,
);
if (priceMissing) {
/**
* 🔴 **价目表里没有这个模型 —— 必须吭声**(2026-08-13)。
* 原来这里静默回落到 `deepseek-v4-pro` 的价:换成 qwen 旗舰之后,
* 成本会被**低报约四倍**,而报表上看不出任何异常。
* ⚠️ 仍然回落(总比不记账好),但要留痕 —— 补价走
* `AI_PRICE_TABLE_JSON='{"<model>":{"inHit":X,"inMiss":Y,"out":Z}}'`,⛔ 不用改代码。
*/
this.logger.warn(
`价目表里没有 ${modelId} —— 本次成本按 deepseek-v4-pro 的价估算,数字**不准**。` +
`补价:AI_PRICE_TABLE_JSON`,
);
}
return yuan;
} }
} }
......
...@@ -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 { WecomWriteCall } 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,9 @@ import { PlanModule } from '../plan/plan.module'; ...@@ -53,6 +56,9 @@ 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 文件头)
WecomWriteCall,
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 +66,7 @@ import { PlanModule } from '../plan/plan.module'; ...@@ -60,6 +66,7 @@ import { PlanModule } from '../plan/plan.module';
DraftRecallBriefCall, DraftRecallBriefCall,
// orchestrators // orchestrators
PlanScriptOrchestrator, PlanScriptOrchestrator,
WecomScriptOrchestrator,
PlanSummaryOrchestrator, PlanSummaryOrchestrator,
RecallSummaryOrchestrator, RecallSummaryOrchestrator,
PersonaSummaryOrchestrator, PersonaSummaryOrchestrator,
...@@ -67,7 +74,10 @@ import { PlanModule } from '../plan/plan.module'; ...@@ -67,7 +74,10 @@ import { PlanModule } from '../plan/plan.module';
], ],
exports: [ exports: [
// 对外暴露 orchestrator(业务方调用入口)+ runner(高级使用 / eval CLI) // 对外暴露 orchestrator(业务方调用入口)+ runner(高级使用 / eval CLI)
// 助手每轮往 agent_invocations 落一行,复用同一个 recorder(⛔ 别再抄一份写库逻辑)
InvocationRecorderService,
PlanScriptOrchestrator, PlanScriptOrchestrator,
WecomScriptOrchestrator, // 企微话术(PlansAggregateController 注入)
PlanSummaryOrchestrator, PlanSummaryOrchestrator,
RecallSummaryOrchestrator, RecallSummaryOrchestrator,
PersonaSummaryOrchestrator, PersonaSummaryOrchestrator,
......
...@@ -37,7 +37,7 @@ export class DraftPersonaSummaryCall ...@@ -37,7 +37,7 @@ export class DraftPersonaSummaryCall
readonly kind = 'summary' as const; readonly kind = 'summary' as const;
readonly callKey = 'draft_persona_summary'; readonly callKey = 'draft_persona_summary';
readonly promptVersion = DRAFT_PERSONA_SUMMARY_PROMPT_VERSION; readonly promptVersion = DRAFT_PERSONA_SUMMARY_PROMPT_VERSION;
readonly defaultModelId = 'qwen'; // 这类一句话摘要统一走 Qwen(qwen3.7-max) readonly defaultModelId = 'qwen'; // 这类一句话摘要统一走 Qwen(裸键 → QWEN_DEFAULT_MODEL,现为 qwen3.8-max)
readonly outputSchema = DraftPersonaSummarySchema; readonly outputSchema = DraftPersonaSummarySchema;
readonly safetyRules = safetyRules; readonly safetyRules = safetyRules;
......
...@@ -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();
...@@ -29,10 +56,21 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string { ...@@ -29,10 +56,21 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string {
clinicalContext.daysSinceLastVisit != null clinicalContext.daysSinceLastVisit != null
? new Date(now.getTime() - clinicalContext.daysSinceLastVisit * 86400_000) ? new Date(now.getTime() - clinicalContext.daysSinceLastVisit * 86400_000)
: null; : null;
const lastVisitDisplay = /**
smartDateDisplay(lastVisitDate, now) ?? * ⛔ 拿不到末诊就说「上次」,**不许回落到诊断日**。
(top?.triggerDate ? smartDateDisplay(new Date(top.triggerDate), now) : null) ?? *
'上次'; * 原来这里有一段 `?? top.triggerDate` 的回落 —— 它把**诊断日**贴上「最近一次就诊」的标签,
* LLM 就会说"自从您[诊断日]来过之后",而患者在那之后完全可能又来过。
* 实测(2026-08-05 本地库):2,714 条在跑单里 **788 条(29%)** 患者末诊比诊断晚 30 天以上,
* 这两个日期差得很远,混用就是说错话。
*
* 当前数据每个患者都有就诊记录,所以那段回落一次都没触发过 —— 是颗**哑弹**:
* 遇到摄入不全的宿主(只有诊断没有接诊记录)就会炸,且不报错。
* 口径同「⛔ 不明确的不要输出」:宁可模糊说"上次",不拿另一个语义的日期冒充。
*
* 诊断日**另有出口** —— 病历块的「接诊日期」(见下方 mrBlock),两个时间各司其职。
*/
const lastVisitDisplay = smartDateDisplay(lastVisitDate, now) ?? '上次';
const chiefComplaint = top?.medicalRecord?.chiefComplaint ?? clinicalContext.lastChiefComplaint ?? null; const chiefComplaint = top?.medicalRecord?.chiefComplaint ?? clinicalContext.lastChiefComplaint ?? null;
const diseaseLabel = resolveDiseaseLabel(top ?? null, plan.primaryScenarioLabel); const diseaseLabel = resolveDiseaseLabel(top ?? null, plan.primaryScenarioLabel);
...@@ -105,7 +143,7 @@ ${others.length ? `\n## 其他可一并关心的问题(以本次聚焦为主,自 ...@@ -105,7 +143,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。
...@@ -188,7 +206,7 @@ export interface DraftPlanScriptOutput { ...@@ -188,7 +206,7 @@ export interface DraftPlanScriptOutput {
/** 第一部分·开场白 markdown(以医生名义 + 智能称呼 + 智能日期 + 自报家门) */ /** 第一部分·开场白 markdown(以医生名义 + 智能称呼 + 智能日期 + 自报家门) */
opening: string; opening: string;
/** 第二部分·告知应治未治 markdown(成人 4 句/儿童 5 句:现状/风险/关怀/专业建议,只讲单个应治未治项) */ /** 第二部分·告知潜在治疗 markdown(成人 4 句/儿童 5 句:现状/风险/关怀/专业建议,只讲单个应治未治项) */
informMissed: string; informMissed: string;
/** 第三部分·复查建议 markdown(成人 4 句/儿童 5 句:重要性/维护/复查时长/引导预约【时间段】) */ /** 第三部分·复查建议 markdown(成人 4 句/儿童 5 句:重要性/维护/复查时长/引导预约【时间段】) */
...@@ -198,7 +216,7 @@ export interface DraftPlanScriptOutput { ...@@ -198,7 +216,7 @@ export interface DraftPlanScriptOutput {
closing: string; closing: string;
/** ⭐ 段落标题(标准/深度档:LLM 为 4 段各自起的小标题;稳健档不出 → UI 回退固定标题)。 /** ⭐ 段落标题(标准/深度档:LLM 为 4 段各自起的小标题;稳健档不出 → UI 回退固定标题)。
* 设计:稳健档标题固定(开场白/告知应治未治/复查建议/结束回访语);标准档"标题不定",由 LLM 编排。 */ * 设计:稳健档标题固定(开场白/告知潜在治疗/复查建议/结束回访语);标准档"标题不定",由 LLM 编排。 */
sectionTitles?: { sectionTitles?: {
opening?: string; opening?: string;
informMissed?: string; informMissed?: string;
......
...@@ -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 };
......
...@@ -14,7 +14,7 @@ import { diseaseLabelForSubKey } from '../../shared/disease-knowledge'; ...@@ -14,7 +14,7 @@ import { diseaseLabelForSubKey } from '../../shared/disease-knowledge';
import { diagnosisCodeNameZh } from '@pac/types'; import { diagnosisCodeNameZh } from '@pac/types';
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
// 漏诊项关键要点配置(稳健档"告知应治未治"小节2/3 灵活组合用) // 漏诊项关键要点配置(稳健档"告知潜在治疗"小节2/3 灵活组合用)
// 渐进式披露:user prompt 只塞**命中那一个病种**的要点,不发全表。 // 渐进式披露:user prompt 只塞**命中那一个病种**的要点,不发全表。
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
export interface MissedKeyPoints { export interface MissedKeyPoints {
......
...@@ -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}` : ''}`;
} }
...@@ -2,7 +2,7 @@ import { z } from 'zod'; ...@@ -2,7 +2,7 @@ import { z } from 'zod';
import { ToneEnum, TONE_DESCRIBE } from '../../shared/tone'; import { ToneEnum, TONE_DESCRIBE } from '../../shared/tone';
/** /**
* 稳健档 4 段输出 schema(顺序固定:开场白 → 告知应治未治 → 复查建议 → 结束回访语)。 * 稳健档 4 段输出 schema(顺序固定:开场白 → 告知潜在治疗 → 复查建议 → 结束回访语)。
* *
* ⚠️ describe 会被注入 system,所以这里**只描述段用途 + 格式 + 关键硬约束**, * ⚠️ describe 会被注入 system,所以这里**只描述段用途 + 格式 + 关键硬约束**,
* 详细写法(开场顺序 / 句位 / 措辞)以 system 提示词(format.md + 人群句位模板)为单一源, * 详细写法(开场顺序 / 句位 / 措辞)以 system 提示词(format.md + 人群句位模板)为单一源,
...@@ -20,7 +20,7 @@ export const DraftPlanScriptSchema = z.object({ ...@@ -20,7 +20,7 @@ export const DraftPlanScriptSchema = z.object({
informMissed: z informMissed: z
.string() .string()
.describe('第二部分·告知应治未治(约 80-400 字)。**只讲本次一个 {应治未治项}**,温和提醒非推销;markdown `• ` 短句分行。'), .describe('第二部分·告知潜在治疗(约 80-400 字)。**只讲本次一个 {应治未治项}**,温和提醒非推销;markdown `• ` 短句分行。'),
reviewAdvice: z reviewAdvice: z
.string() .string()
......
# 输出结构(稳健档:固定 4 模块) # 输出结构(稳健档:固定 4 模块)
4 段 Markdown 字段,顺序固定、缺一不可、不可乱序: 4 段 Markdown 字段,顺序固定、缺一不可、不可乱序:
1. `opening` 开场白 2. `informMissed` 告知应治未治 3. `reviewAdvice` 复查建议 4. `closing` 结束回访语 1. `opening` 开场白 2. `informMissed` 告知潜在治疗 3. `reviewAdvice` 复查建议 4. `closing` 结束回访语
# 占位符约定(两种,别搞混) # 占位符约定(两种,别搞混)
- `{xxx}` = **替换**:用"本次回访患者信息"里给的同名值填(如 {智能称呼}{应治未治项}{牙位}{诊断医生}{风险要点}{复查时长});输出里不能再出现 `{}` - `{xxx}` = **替换**:用"本次回访患者信息"里给的同名值填(如 {智能称呼}{应治未治项}{牙位}{诊断医生}{风险要点}{复查时长});输出里不能再出现 `{}`
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
- 开场顺序固定:先用 {智能称呼} 称呼并确认对方 → 再 {自报家门}(内含【回访客服】,整串照抄;身份是**医生的助理**,别改写成"客服/顾问") → 以 {诊断医生} 名义体现关怀 → 用 {智能时间显示} 问近况。 - 开场顺序固定:先用 {智能称呼} 称呼并确认对方 → 再 {自报家门}(内含【回访客服】,整串照抄;身份是**医生的助理**,别改写成"客服/顾问") → 以 {诊断医生} 名义体现关怀 → 用 {智能时间显示} 问近况。
- 健康提醒从 {风险要点} 里挑、检查说明用 {复查时长} 原文;给定值直接用,不改写、不重算。 - 健康提醒从 {风险要点} 里挑、检查说明用 {复查时长} 原文;给定值直接用,不改写、不重算。
- 引导预约严格用:「{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」 - 引导预约严格用:「{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」
- 告知应治未治、复查建议分成短句。 - 告知潜在治疗、复查建议分成短句。
# 输出前自查(只查高风险) # 输出前自查(只查高风险)
- 没编造?医生 / 诊断 / 牙位 / 时间都来自给定事实。 - 没编造?医生 / 诊断 / 牙位 / 时间都来自给定事实。
......
...@@ -19,7 +19,7 @@ tiers: ['stable'] ...@@ -19,7 +19,7 @@ tiers: ['stable']
- •(熟客可加:{诊断医生}医生上次还和我提起您呢) - •(熟客可加:{诊断医生}医生上次还和我提起您呢)
- • 您自从{智能时间显示}来过之后,口腔情况怎么样? - • 您自从{智能时间显示}来过之后,口腔情况怎么样?
## 第二部分 · 告知应治未治(4 短句,温和提醒、非推销) ## 第二部分 · 告知潜在治疗(4 短句,温和提醒、非推销)
- **小节1 现状描述**:以"之前{诊断医生}医生检查时注意到…"的口吻(指**诊断那次**,可能比最近一次就诊更早;**别说"上次"**以免和开场的最近就诊混);给了 {牙位} 就自然带上(如"您{牙位}有…"),没给则不提牙位。别说"我们发现了…"。 - **小节1 现状描述**:以"之前{诊断医生}医生检查时注意到…"的口吻(指**诊断那次**,可能比最近一次就诊更早;**别说"上次"**以免和开场的最近就诊混);给了 {牙位} 就自然带上(如"您{牙位}有…"),没给则不提牙位。别说"我们发现了…"。
✅ 之前{诊断医生}医生给您检查时,注意到您{牙位}有{应治未治项}的情况 / {诊断医生}医生那次提到您有一点{应治未治项}的问题 ✅ 之前{诊断医生}医生给您检查时,注意到您{牙位}有{应治未治项}的情况 / {诊断医生}医生那次提到您有一点{应治未治项}的问题
- **小节2 健康提醒**:从 {风险要点} 灵活挑 3~4 条,每句一个重点,口语、不堆砌、不吓唬。 - **小节2 健康提醒**:从 {风险要点} 灵活挑 3~4 条,每句一个重点,口语、不堆砌、不吓唬。
......
...@@ -19,7 +19,7 @@ tiers: ['stable'] ...@@ -19,7 +19,7 @@ tiers: ['stable']
- •(熟客可加:{诊断医生}医生上次还和我提起宝宝呢) - •(熟客可加:{诊断医生}医生上次还和我提起宝宝呢)
- • 宝宝自从{智能时间显示}来过之后,牙齿/口腔情况怎么样? - • 宝宝自从{智能时间显示}来过之后,牙齿/口腔情况怎么样?
## 第二部分 · 告知应治未治(分短句,对家长温和提醒、非吓唬) ## 第二部分 · 告知潜在治疗(分短句,对家长温和提醒、非吓唬)
- **小节1 现状描述**:以"之前{诊断医生}医生检查时注意到宝宝…"的口吻带出本次 {应治未治项}(指诊断那次,可能早于最近一次就诊,**别说"上次"**以免和开场混);给了 {牙位} 就自然带上(如"宝宝{牙位}…"),没给则不提牙位。 - **小节1 现状描述**:以"之前{诊断医生}医生检查时注意到宝宝…"的口吻带出本次 {应治未治项}(指诊断那次,可能早于最近一次就诊,**别说"上次"**以免和开场混);给了 {牙位} 就自然带上(如"宝宝{牙位}…"),没给则不提牙位。
- **小节2 健康提醒**:从 {风险要点} 挑 1~2 条,用家长听得懂的话说"不处理可能会怎样"(口语、不堆砌、不吓唬)。 - **小节2 健康提醒**:从 {风险要点} 挑 1~2 条,用家长听得懂的话说"不处理可能会怎样"(口语、不堆砌、不吓唬)。
- **小节3 趁早关怀**:结合 {治疗优势},用"趁现在 / 趁换牙期早干预"说早处理的好处。 - **小节3 趁早关怀**:结合 {治疗优势},用"趁现在 / 趁换牙期早干预"说早处理的好处。
......
...@@ -21,7 +21,7 @@ export const StandardScriptSchema = z.object({ ...@@ -21,7 +21,7 @@ export const StandardScriptSchema = z.object({
// 不加 .min()/.max() 硬约束,长度/段数只作 describe 软引导(对中文偏严 + qwen3.7-max 简洁易误伤) // 不加 .min()/.max() 硬约束,长度/段数只作 describe 软引导(对中文偏严 + qwen3.7-max 简洁易误伤)
title: z title: z
.string() .string()
.describe('该段小标题(约 2-20 字):你自起、自然口语贴这通电话,别用"开场白/告知应治未治/复查建议/结束回访语"这类刻板模板名'), .describe('该段小标题(约 2-20 字):你自起、自然口语贴这通电话,别用"开场白/告知潜在治疗/复查建议/结束回访语"这类刻板模板名'),
markdown: z markdown: z
.string() .string()
.describe('该段正文(约 30-400 字):分短句、行首 `•`;接地病历不编造;具体时间一律用【时间段】占位;无大标题/分隔符/表情'), .describe('该段正文(约 30-400 字):分短句、行首 `•`;接地病历不编造;具体时间一律用【时间段】占位;无大标题/分隔符/表情'),
......
...@@ -76,7 +76,18 @@ export class DraftPlanSummaryCall ...@@ -76,7 +76,18 @@ export class DraftPlanSummaryCall
readonly kind = 'summary' as const; readonly kind = 'summary' as const;
readonly callKey = 'draft_plan_summary'; readonly callKey = 'draft_plan_summary';
readonly promptVersion = DRAFT_PLAN_SUMMARY_PROMPT_VERSION; readonly promptVersion = DRAFT_PLAN_SUMMARY_PROMPT_VERSION;
readonly defaultModelId = 'deepseek-v4-flash'; /**
* 🔴 2026-08-15 由 `deepseek-v4-flash` 改成裸键 `'qwen'`(产品定:摘要类统一同一个模型)。
*
* 此前它是**四个摘要里唯一的例外** —— 另外三个(召回简报 / 画像小结 / 召回小结)
* 都写着「这类一句话摘要统一走 Qwen」,只有它留在 DeepSeek 上,于是同一类活
* 跑着两个模型、两套价、两种风格,而**没有任何地方记着为什么**。
* ⚠️ 用裸键 `'qwen'` 而不是钉版本:跟另外三个一致,跟随 `QWEN_DEFAULT_MODEL`。
* (话术那个下拉是**型号选择器**,那里钉具体版本是刻意的,⛔ 别拿来类比。)
* ⚠️ 换过来顺带关掉了思考:qwen 那条 fetch 中间件对**无 tools** 的调用注入
* `enable_thinking:false`,而摘要走结构化输出、没有 tools。
*/
readonly defaultModelId = 'qwen';
readonly outputSchema = DraftPlanSummarySchema; readonly outputSchema = DraftPlanSummarySchema;
readonly safetyRules = safetyRules; readonly safetyRules = safetyRules;
......
...@@ -3,28 +3,37 @@ import { z } from 'zod'; ...@@ -3,28 +3,37 @@ import { z } from 'zod';
/** /**
* DraftPlanSummary 输出 schema。 * DraftPlanSummary 输出 schema。
* 3 段 Markdown 字符串,LLM 一次返回。 * 3 段 Markdown 字符串,LLM 一次返回。
*
* 🔴 **⛔ 别把 `.min(50)` / `.max(600)` 这类硬长度约束加回来**(2026-08-15 拆掉)。
*
* 这条 call 当天从 `deepseek-v4-flash` 换到 qwen,而 qwen 写得短 ——
* 硬下界会让它**必然 `too_small`**、整次调用报废。这不是推测:
* `draft_plan_script` 的 **v27** 就是为同一件事改的
* (「schema 去硬长度约束(.min/.max → describe 软引导,修 qwen too_small 必失败)」),
* 深度档 / 标准档两个 schema 的注释里也各留了一句同样的理由。
* ⚠️ 长度要求**没有取消**,只是搬进了 `.describe()`(「中文 200 字内」这些)——
* 它是软引导:写长了不报废,写短了也不报废,⛔ 而报废对主管就是"生成失败"。
* ⚠️ `.min(2)` 留着:那不是长度要求,是**不许空串**(口径同 `draft-recall-brief`:
* 没事实时要如实写"暂无",⛔ 不许交白卷)。
*/ */
export const DraftPlanSummarySchema = z.object({ export const DraftPlanSummarySchema = z.object({
onePage: z onePage: z
.string() .string()
.min(50) .min(2)
.max(600)
.describe( .describe(
'一页快读 — 中文 200 字内 Markdown。客服打电话前 30 秒扫一眼,必含:称呼/价值/上次到店/本次主要任务/风险提示。**禁止 bullet list**,用 2-3 个短段落。', '一页快读 — 中文 200 字内 Markdown。客服打电话前 30 秒扫一眼,必含:称呼/价值/上次到店/本次主要任务/风险提示。**禁止 bullet list**,用 2-3 个短段落。',
), ),
medicalRecord: z medicalRecord: z
.string() .string()
.min(80) .min(2)
.max(1500)
.describe( .describe(
'病历摘要 — 中文 500 字内 Markdown。按时间倒序整理就诊/治疗/付费/影像 4 大类事件。可以用 bullet list 或时间轴。重点突出"待做治疗"和"未闭环的随访"。', '病历摘要 — 中文 500 字内 Markdown。按时间倒序整理就诊/治疗/付费/影像 4 大类事件。可以用 bullet list 或时间轴。重点突出"待做治疗"和"未闭环的随访"。',
), ),
treatmentChain: z treatmentChain: z
.string() .string()
.min(80) .min(2)
.max(1500)
.describe( .describe(
'治疗链摘要 — 中文 500 字内 Markdown。按治疗链分节(每条链一个 `### 链名` 标题),讲清楚:当前阶段、下一步建议、若已闭环则一句话总结。**必须**引用具体牙位号 / 治疗节点。', '治疗链摘要 — 中文 500 字内 Markdown。按治疗链分节(每条链一个 `### 链名` 标题),讲清楚:当前阶段、下一步建议、若已闭环则一句话总结。**必须**引用具体牙位号 / 治疗节点。',
), ),
......
...@@ -16,32 +16,49 @@ const safetyRules: ReadonlyArray<SafetyRule<DraftRecallBriefOutput>> = [ ...@@ -16,32 +16,49 @@ const safetyRules: ReadonlyArray<SafetyRule<DraftRecallBriefOutput>> = [
name: 'no_forbidden_phrases', name: 'no_forbidden_phrases',
severity: 'block', severity: 'block',
check(output) { check(output) {
const hit = FORBIDDEN_PHRASES.filter((p) => output.summary.includes(p)); // 四段拼起来一起扫 —— ⛔ 别只扫 problem:禁词出现在 hook 里同样会被客服念出去
const all = [output.who, output.history, output.problem, output.hook].join(' ');
const hit = FORBIDDEN_PHRASES.filter((p) => all.includes(p));
return { pass: hit.length === 0, message: hit.length > 0 ? `命中禁词: ${hit.join(',')}` : undefined }; return { pass: hit.length === 0, message: hit.length > 0 ? `命中禁词: ${hit.join(',')}` : undefined };
}, },
}, },
]; ];
/** LLM 失败 / safety 拒收时:用召回原因拼一句最朴素的简报。 */ /**
* LLM 失败 / safety 拒收时:用召回原因拼**四段**最朴素的简报。
* ⚠️ 四段都要有内容 —— 空串会让界面出现没头没尾的空行(见 schema 注释)。
*/
function fallback(input: DraftRecallBriefInput): DraftRecallBriefOutput { function fallback(input: DraftRecallBriefInput): DraftRecallBriefOutput {
const r = input.reasons[0]; const r = input.reasons[0];
if (!r) return { summary: '暂无明确召回原因。' }; const who = input.persona.length > 0 ? input.persona.map((p) => p.value).slice(0, 2).join('、') : '—';
const history =
input.returnVisitHistory.length > 0
? `${input.returnVisitHistory[0]!.atText}${input.returnVisitHistory[0]!.type ?? '回访'}`
: '此前无回访记录';
if (!r) return { who, history, problem: '暂无明确召回原因', hook: '可约复查请医生评估' };
const cats = r.expectedCategories.join(' / '); const cats = r.expectedCategories.join(' / ');
const parts = [ const problem = [
r.tooth ? `${r.tooth}` : null, r.subLabel,
r.diagnosis, [r.tooth ? `${r.tooth}` : null, r.diagnosis, `${r.daysSinceText}`, cats ? `未启动 ${cats}` : null]
`${r.daysSinceText}前`, .filter(Boolean)
cats ? `未启动 ${cats}` : null, .join(' · '),
].filter(Boolean); ]
// 切入尾巴(第④问,按优先级):医生计划/建议/医嘱(带原话) > 洁牙锚点(带时间) > 通用复查邀约 .filter(Boolean)
.join(':');
// 切入(第④点,按优先级):本批福利 > 医生计划/建议/医嘱(带原话) > 洁牙锚点(带时间) > 通用复查邀约
const g0 = input.doctorGuidance.find((g) => g.verbatim) ?? input.doctorGuidance[0]; const g0 = input.doctorGuidance.find((g) => g.verbatim) ?? input.doctorGuidance[0];
const KIND_VERB = { plan: '已有计划', recommendation: '建议过', advice: '医嘱交代过' } as const; const KIND_VERB = { plan: '已有计划', recommendation: '建议过', advice: '医嘱交代过' } as const;
const entry = g0 const hook = input.benefitText?.trim()
? `;医生${KIND_VERB[g0.kind]}${g0.verbatim ? `「${g0.verbatim}」` : g0.label},可约复查跟进` ? `本批有「${input.benefitText.trim()},可约复查跟进`
: input.reviewAnchors.lastCleaningText : g0
? `;距上次洁牙/检查 ${input.reviewAnchors.lastCleaningText},可约复查切入` ? `医生${KIND_VERB[g0.kind]}${g0.verbatim ? `「${g0.verbatim}」` : g0.label},可约复查跟进`
: ';可约复查请医生评估'; : input.reviewAnchors.lastCleaningText
return { summary: `${r.subLabel}:${parts.join(' · ')}${entry}。` }; ? `距上次洁牙/检查 ${input.reviewAnchors.lastCleaningText},可约复查切入`
: '可约复查请医生评估';
return { who, history, problem, hook };
} }
@Injectable() @Injectable()
...@@ -51,7 +68,7 @@ export class DraftRecallBriefCall ...@@ -51,7 +68,7 @@ export class DraftRecallBriefCall
readonly kind = 'summary' as const; readonly kind = 'summary' as const;
readonly callKey = 'draft_recall_brief'; readonly callKey = 'draft_recall_brief';
readonly promptVersion = DRAFT_RECALL_BRIEF_PROMPT_VERSION; readonly promptVersion = DRAFT_RECALL_BRIEF_PROMPT_VERSION;
readonly defaultModelId = 'qwen'; // 这类一句话摘要统一走 Qwen(qwen3.7-max) readonly defaultModelId = 'qwen'; // 这类一句话摘要统一走 Qwen(裸键 → QWEN_DEFAULT_MODEL,现为 qwen3.8-max)
readonly outputSchema = DraftRecallBriefSchema; readonly outputSchema = DraftRecallBriefSchema;
readonly safetyRules = safetyRules; readonly safetyRules = safetyRules;
......
...@@ -51,9 +51,98 @@ export interface DraftRecallBriefInput { ...@@ -51,9 +51,98 @@ export interface DraftRecallBriefInput {
tooth: string | null; tooth: string | null;
atText: string | null; atText: string | null;
}>; }>;
/**
* 历史回访联系(第②点的事实依据)—— 最近几条,新到旧。
* ⚠️ 只给**已发生**的联系记录,⛔ 别把未来的预约回访塞进来:
* 第②点要回答"以前怎么联系的、结果如何",把还没发生的说成历史就是编。
* 空数组 = 从没联系过(新客),第②点如实说"此前无回访记录",⛔ 不许留白也不许编。
*/
returnVisitHistory: Array<{
/// 距今(如 2 个月前)。⚠️ 相邻两条可能落到同一个粗粒度描述上,故 outcomeText 必须能区分
atText: string;
type: string | null; // 回访类型(中文)
/** **这次回访是干什么的**(follow_content):半年洁牙提醒 / 种植潜客 / 术后复查… */
topicText: string | null;
/**
* **结果怎么样**(result):未接 / 诊后回访无不适 / 约时间 / 复查…
*
* 🔴 这个字段一开始**漏喂了**(2026-08-06 实测):没有主题的那条只能输出「没留下结果」,
* 而库里写着「诊后回访未接」,详情页「历史联系」卡据此说了「未接通」——
* 两处一对比,简报既含糊又像少了信息。
* ⚠️ 它比 `reachedText` 可信:实测同一条 status='已回访' 而 result='诊后回访未接'。
* 前者只说明任务被标记过,后者才是真的联系结果。⇒ **有它就以它为准**。
*/
resultText: string | null;
/**
* 联系上没有(已回访 / 未回访)—— **只在结果和主题都为空时**当兜底,且它并不可靠(见上)。
*
* ⛔⛔ **刻意不给 `taskStatus`(已完成 / 未完成)**,⛔ 别"补全"回来:
* ① 那是内部流程状态,客服打电话前用不上(产品 2026-08-06:
* 「回访任务已完成就不要说了,总结备注里的事实就行」);
* ② 两个状态一起喂会让摘要说「已联系且任务已完成」,而详情页「历史联系」卡
* 按 taskStatus 说「未完成」—— 两句都对却像自相矛盾(实测 2026-08-05 王利)。
* 不报流程状态,这个矛盾从根上就不存在。
*/
reachedText: string | null;
}>;
/**
* **关系亲密度** —— 第②句的另一半(产品 2026-08-04 评审原话:
* 「第二句话是他跟我们以前的关系亲密度是什么样的,**就是联系他多不多**」)。
*
* ⚠️ 上面的 `returnVisitHistory` 只给**最近 3 条**(喂全量会撑爆上下文,也没必要)——
* 所以"多不多"必须单独给个数,⛔ 别让模型去数那 3 条推总量,那必然低估。
* ⚠️ 只统计**已发生**的,与 returnVisitHistory 同一条判据(未来的预约回访不算联系过)。
*/
contactStats: {
/// 已发生的回访总条数;0 = 从没联系过
total: number;
/// 最早一次距今(如 3 年前);total=0 时为 null
sinceText: string | null;
};
/**
* 🔴 **被挂断过几次** —— 回访数据里**唯一有据**的"不好的体验"。0 = 没有记录。
*
* ── 为什么只有这一个(2026-08-07 全量查证)──────────────────────
* 产品要第②句带上「有没有过不好的体验」,而这一句的依据只看回访数据。实测:
* · `type` 只有三种:常规回访 / 咨询回访 / 术后回访 —— **没有投诉类**;
* · `result` 是自由文本(3 万行 6765 种取值),扫遍长尾:
* 投诉 / 不满 / 纠纷 / 态度 / 退费 / 抱怨 —— **一条都没有**;
* 真正的负面只有**「挂断」一种形态**(688 次 / 595 位患者,占有回访记录患者的 11.5%)。
* ⚠️ 第一遍扫的时候「无不适」被"不适"这个词接住了,假阳性 5816 条 ——
* 负面词表必须排除否定式(无不适 / 无异常 / 不疼),⛔ 别再照着直觉拍词表。
*
* ⭐ 「挂断」是**关系信号**不是接通率:患者接了、听出是谁、然后把电话挂了。
* 与 EXECUTION_OUTCOME 里的「秒挂」是同一件事(那边已判为不成功类 + 30 天抑制)。
* ⛔⛔ **没被挂断 ≠ 关系好**,所以 0 的时候第②句**什么都不说**,
* ⛔ 绝不许输出「没有不良记录」「关系良好」—— 那是拿"没证据"当"反面"用,
* 与 noTag 那条铁律是同一件事(违了就是凭空造事实)。
* ⛔ 也不许拿「未接」当不好的体验:没接通的原因太多(换号 / 在忙 / 拦截),那是推测不是事实。
*/
hangUpCount: number;
/**
* 本批次福利(第④点的最高优先勾子)—— 主管在分配时写的文案原文;没配 = null。
*
* ⚠️ 只在批次仍 `confirmed` 时给(与话术带福利同一个判据,⛔ 别另立标准)。
* ⛔ **只能说这段原文包含的内容**:不得追加条件/期限/名额,不得夸大 —— 与话术同一条硬约束。
*/
benefitText: string | null;
} }
/**
* 四点简报(2026-08-05 从一句话拆开)。
*
* ⚠️ **逻辑没变,只是输出形态变了** —— 四点各自回答的还是原来那四问,
* 输入要素、优先级(医生原话 > 复查锚点)、防编造约束全部照旧。
* ⚠️ 每点都**必须有内容**:没有事实时如实说"暂无",⛔ 不许空串
* (空串会让界面出现一个没头没尾的空行,比一句"暂无"更让人困惑)。
*/
export interface DraftRecallBriefOutput { export interface DraftRecallBriefOutput {
/** 一句话召回简报(中文,≤50 字最佳)*/ /** ① 患者是谁 —— 画像一句话(生命周期/价值/人群) */
summary: string; who: string;
/** ② 以前怎么联系的 —— 回访历史一句话;从没联系过就说"此前无回访记录" */
history: string;
/** ③ 解决什么 + 不处理的后果 —— 一句话 */
problem: string;
/** ④ 怎么开口 —— 切入勾子;优先级:批次福利 > 医生原话 > 复查锚点 */
hook: string;
} }
import { z } from 'zod'; import { z } from 'zod';
/** DraftRecallBrief 输出:一句话召回简报。 */ /**
* DraftRecallBrief 输出:**四点**召回简报(2026-08-05 从一句话拆开)。
*
* ⚠️ **判定逻辑一个字没改** —— 输入要素、优先级(批次福利 > 医生原话 > 复查锚点)、
* 防编造的几条硬约束全部照旧,变的只是"一句话"变成"四句各管一件事"。
* 拆开的理由:原来那一句要同时塞进"谁 + 缺口 + 后果 + 切入",长到 65-90 字,
* 客服扫一眼抓不住重点;四点各自成句,想看哪点看哪点。
*
* ⚠️ 四段都 `.min(2)` —— **不许空串**。没有事实时如实写"暂无 / 此前无回访记录",
* 空串会让界面出现一个没头没尾的空行,比一句"暂无"更让人困惑。
*/
export const DraftRecallBriefSchema = z.object({ export const DraftRecallBriefSchema = z.object({
summary: z who: z
.string() .string()
.min(6) .min(2)
.max(120) .max(40)
.describe( .describe(
'一句话中文召回简报(≤65 字最佳,最多 90 字,不带换行/列表/Markdown)。' + '① **患者是谁**(≤20 字最佳)。只用给定画像标签:生命周期(新客/熟客)、价值分群、人群细分。' +
'**站在患者立场讲"他为什么该来"**:把应治未治缺口翻译成患者能感知的影响/价值(如 缺牙久拖邻牙易移位、龋齿不补会伤神经),放句子主干、最突出;' + '⛔ 不编个人情节、不猜职业性格。例:"重要发展新客,累计消费 1.3 万"。',
'"患者是谁"(价值/熟客)一两词前置修饰;句尾给**切入建议**(低门槛复查/洁牙开口台阶,只能引用复查锚点给的事实,无锚点用不带时间数字的通用复查邀约),目标治疗动作随切入一笔带过。口吻为患者着想,不催单。' + ),
'严禁编造患者意愿/情绪:「应治未治」是缺口不是意愿,不能说"想做/有意向";不编个人情节、不编数值、不承诺疗效。' + history: z
'例:"47缺了4个月的牙一直空着,越拖邻牙越易移位;种植老客,距上次洁牙已8个月,可约洁牙检查顺带请医生评估种植。"', .string()
.min(2)
.max(80)
.describe(
'② **跟我们熟不熟 + 上次联系是什么事、结果怎么样**(≤40 字最佳)。' +
// 🔴 2026-08-04 产品评审:第二句要回答「他跟我们以前的关系亲密度是什么样的,
// 就是联系他多不多,然后有没有过不好的体验」。原来只写了"最近一次",少了"多不多"。
'\n🔴 **先给"熟不熟"再给"上次那件事"**,两件都要:' +
'\n · **熟不熟**看 `contactStats`:联系过几次、从多久前开始。' +
'例:"两年里联系过 7 次";只有 1 次就说"只联系过 1 次";' +
'`total=0` 说"此前无回访记录"。⛔ 别去数下面那几条回访自己算总数 —— 那里只给了最近 3 条。' +
'\n · **不好的体验**看 `hangUpCount`:>0 才说,如实说"被挂断过 N 次";' +
'⛔⛔ **等于 0 时什么都不说** —— 绝不许写"没有不良记录""关系良好""沟通顺畅":' +
'没有记录**不等于**关系好,那是拿"没证据"当"反面"用,是凭空造事实。' +
'⛔ 也不许把"未接"说成不好的体验(没接通的原因太多,那是推测)。' +
'\n · **上次那件事**用下面的回访历史,' +
'这次是干什么的(半年洁牙提醒 / 种植潜客…)+ 结果(未接 / 无不适 / 约了时间…),有几样说几样。' +
'🔴 ⛔ **不许说「没留下结果」「无记录」这种含糊话** —— 结果栏通常是有东西的' +
'(实测:库里写着"诊后回访未接"却被说成"没留下结果");有什么说什么。' +
'🔴 ⛔ **不许出现「备注」「记录里写」这类字眼** —— 那是数据存哪儿的说法,客服要的是事情本身;' +
'⛔「常规回访,备注半年洁牙提醒」 ✅「常规回访提醒他半年洁牙」。' +
'🔴 ⛔ **不许报流程状态**(「任务已完成 / 未完成 / 已归档」)—— 那是内部流转,' +
'客服打电话前用不上(产品 2026-08-06 走查点名去掉)。' +
'⚠️ 备注为空才退回「时间 + 类型」,没联系上就说"没联系上"。' +
'⛔ 没有记录时**如实写"此前无回访记录"**,不许留白、不许编"曾多次联系"。' +
'⛔ 不许把未来的预约回访说成历史。例:"6 月常规回访,当时约了拔牙"。',
),
problem: z
.string()
.min(2)
.max(70)
.describe(
'③ **解决什么 + 不处理的后果**(≤35 字最佳)。**站在患者立场讲"他为什么该来"**:' +
'把应治未治缺口翻译成患者能感知的影响(如 缺牙久拖邻牙易移位、龋齿不补会伤神经),' +
'带上牙位和拖了多久。' +
'⛔ 后果要客观,不夸大不吓唬(别下"会掉光""很危险"式结论)。' +
'⛔ **严禁编造患者意愿/情绪**:「应治未治」是缺口不是意愿,不能说"想做/有意向"。' +
'例:"47 缺牙 4 个月未修复,越拖邻牙越易移位"。',
),
hook: z
.string()
.min(2)
.max(70)
.describe(
'④ **怎么开口**(≤35 字最佳)。优先级**从高到低**,取到哪条用哪条,⛔ 不要堆叠:' +
'(a) 有**福利勾子**→ 围绕福利原文开口,**只能说原文包含的内容**,' +
'⛔ 不得追加条件/期限/名额、不得夸大;' +
'⛔ **不许出现「本批/这批/批次」** —— 那是主管派单的内部词,对患者说不通' +
'(实测出过"本批有全场 8 折优惠");福利就直接说福利本身;' +
'(b) 有**医生计划/建议/医嘱**→ 围绕医生真实交代展开,优先引用原话要点(加「」);' +
'(c) 都没有 → 用复查锚点(最近一次治疗 / 洁牙检查距今)给低门槛台阶,' +
'⛔ 只能引用锚点给的事实,没有锚点就用**不带时间数字**的通用复查邀约。' +
'目标治疗动作随切入一笔带过,口吻为患者着想,不催单。' +
'例:"医生计划「定期洁牙」,可约洁牙检查顺带评估牙周基础治疗"。',
), ),
}); });
...@@ -47,7 +47,7 @@ export class DraftRecallSummaryCall ...@@ -47,7 +47,7 @@ export class DraftRecallSummaryCall
readonly kind = 'summary' as const; readonly kind = 'summary' as const;
readonly callKey = 'draft_recall_summary'; readonly callKey = 'draft_recall_summary';
readonly promptVersion = DRAFT_RECALL_SUMMARY_PROMPT_VERSION; readonly promptVersion = DRAFT_RECALL_SUMMARY_PROMPT_VERSION;
readonly defaultModelId = 'qwen'; // 这类一句话摘要统一走 Qwen(qwen3.7-max) readonly defaultModelId = 'qwen'; // 这类一句话摘要统一走 Qwen(裸键 → QWEN_DEFAULT_MODEL,现为 qwen3.8-max)
readonly outputSchema = DraftRecallSummarySchema; readonly outputSchema = DraftRecallSummarySchema;
readonly safetyRules = safetyRules; readonly safetyRules = safetyRules;
......
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 { WecomWriteSchema, type WecomWriteZ } from './schema';
import { buildWecomWritePrompt } from './prompts';
/**
* 企微话术的**唯一** AiCall(2026-08 从 plan → write → verify 三步砍成一步,见 wecom.strategy 文件头)。
*
* ═══ 与电话档共用了什么、没共用什么 ═══════════════════════════
* 共用(直接 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);
}
/** ⚠️ 只剩 ctx —— 规划(plan)与修订(repairIssues)两条 2026-08 一起砍了,见 wecom.strategy 文件头 */
export interface WecomWriteInput {
ctx: ScriptContext;
}
@Injectable()
export class WecomWriteCall implements AiCall<WecomWriteInput, WecomWriteZ> {
readonly kind = 'script' as const;
readonly callKey = 'draft_wecom_script_write';
// ⚠️ 换 promptVersion 会让缓存整体失效(这是想要的):流程和形态都变了,旧缓存不该再命中
readonly promptVersion = 'draft_wecom_script@2026-08-05-single-v2';
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) };
}
}
import type { ScriptContext } from '../draft-plan-script/shared/input.types';
import { buildRichFactBlock, buildDeepExtensions } from '../draft-plan-script/shared/fact-block';
/**
* 企微话术的 user prompt。
*
* ⭐ **事实块直接复用电话档那一份**(buildRichFactBlock + buildDeepExtensions)——
* 患者是谁、哪颗牙、医生说了什么、上次什么时候来的…… 这些与"用电话还是企微说"完全无关。
* ⛔ 别复制一份改改:安全护栏(不报价/福利不得加码/高龄不主推种植)全在里面,
* 复制出去之后改了一处另一处会悄悄留在旧版本,而漏了哪条要等客服发出去才发现。
*
* ⚠️ 差异全部集中在**任务段**(--- 之后):电话是"拆几段讲",企微是"写成一条能直接发的消息"。
*/
function facts(ctx: ScriptContext): string {
const ext = buildDeepExtensions(ctx);
return ext ? `${buildRichFactBlock(ctx)}\n\n${ext}` : buildRichFactBlock(ctx);
}
/**
* 唯一一步:一次性写出整块正文。
*
* ⚠️ 2026-08 砍掉了 plan(要点顺序)与 repair(按 issue 回喂改写)两条分支 ——
* 企微稿是一整块几百字、没有分段结构,"先列大纲再写"收益极小;而修订那条要多花
* 一次调用和 30 秒,换来的东西客服自己过一遍就能发现(界面一直写着"请核对后使用")。
* 安全没有放松:机器硬扫(禁词/承诺/占位残留)仍在 wecom.strategy 里把关。
*/
export function buildWecomWritePrompt(input: { ctx: ScriptContext }): string {
return `${facts(input.ctx)}
---
# 你的任务
写**一条能直接发给患者微信的消息**,复制粘贴即可发送,不用再删改。
# 写的时候保持
- **层层递进**:从"想起他"到"点出问题"到"说清后果"到"给出路",句与句承上启下,
别并列罗列或跳跃。
- **后果说清但有分寸**:不处理的后果客观讲明(结合病历 + 牙科常识),让患者理解严重性;
但**不夸大、不吓唬**(别下"会掉光""很危险"式结论)、**不推销促单报价**。
- ⭐ **像微信不像公文**:适度用表情(全文 2-4 个,句末缓和语气),
⛔ 不要每句都带,⛔ 不用夸张促销类表情。
- ⛔ 除自报家门的 \`【回访客服】\` 外,**不许出现任何其他 \`【】\` 占位**;
也不要自己编具体时间("周三上午")—— 这条消息整段复制直发,
占位会原样进患者微信,编的时间到时对不上。约时间就写
"您方便的时候回我一下,我帮您安排"。`;
}
import { z } from 'zod';
import { ToneEnum, TONE_DESCRIBE } from '../draft-plan-script/shared/tone';
/**
* 企微话术的输出 schema —— **单次生成,只有一个**。
*
* ⚠️ 2026-08 砍掉了 `WecomPlanSchema`(规划大纲)与 `WecomVerifySchema`(LLM 自检):
* 企微稿是一整块几百字、没有分段结构,"先列大纲"收益极小;而客服拿到后本来就要
* 自己过一遍再发,LLM 自检是在给一个人本来就会做的事再花一次钱和 30 秒。
* 安全**没有放松** —— 机器硬扫(禁词/承诺/占位残留)保留在 wecom.strategy 里,
* 它不花钱、且拦的正是模型自己发现不了的东西。
*
* ⚠️ 与电话深度档最本质的差别就在这里:**没有 `sections[]`,只有一整块 `markdown`**。
* 电话稿分段是给「伴飞」逐段高亮用的;企微是**一条发出去的消息**,分段没有意义,
* 反而会诱导模型写成"第一段…第二段…"的汇报体,复制过去很怪。
*
* ⚠️ 同样不加 .min()/.max() 硬约束 —— 理由与电话档一致(见 tiers/deep/schema.ts):
* 硬长度约束对中文偏严,模型差一点就整体 fail 走兜底;形态靠 system + describe 引导。
*/
// ── 一次性写出整块正文 ──
export const WecomWriteSchema = z.object({
tone: ToneEnum.describe(TONE_DESCRIBE),
markdown: z
.string()
.describe(
'完整企微消息正文(约 120-400 字),**一整块、可直接复制发送**。' +
'⛔ 不要小标题、不要 `##`、不要分段编号、不要"第一/第二"。' +
'按微信阅读节奏用空行断成几个短自然段;接地病历不编造。' +
'⭐ 适度用**表情**(全文 2-4 个,放在句末缓和语气,如 😊 🦷 ~),' +
'这是微信而不是公文 —— 但⛔ 不要每句都带、不要用夸张促销类表情(🔥💰🉐)。' +
'⛔ 除自报家门的【回访客服】外,**不许出现任何其他【】占位符**(时间尤其不许):' +
'这条消息是整段复制直发的,占位符会原样进患者微信;' +
'要约时间就写"您方便的时候回我一下,我帮您安排"。',
),
});
export type WecomWriteZ = z.infer<typeof WecomWriteSchema>;
# 输出结构(一条消息,不分段)
输出 `tone` + `markdown``markdown`**一整条准备发到患者微信里的消息**
⛔ 不要 `sections`、不要小标题、不要 `##`、不要「第一/第二」、不要编号列表、不要表情符号。
⛔ 不要写任何给客服自己看的话(「以下话术供参考」「建议这样说」「话术如下」)——客服会**整段复制发送**,这些字会一起发给患者。
# 这是微信,不是电话
- **他一口气读完**:没有一来一回,你写的就是他看到的全部。所以不能有「您现在方便吗」「能听清吗」这类需要对方回话才成立的句子。
- **按阅读节奏断行**:用空行断成 3-5 个短自然段,每段 1-3 句。⛔ 别写成一大坨,微信里没人读得下去。
- **书面但不端着**:像医生助理认真打的一段字——比电话口语克制,比公文自然。不用「兹」「特此」,也不用「哈喽~」。
- **开头直接称呼 + 自报家门**,不用寒暄铺垫;**结尾留一个明确的下一步**,别用「随时联系我」这种空钩子。
# 自报家门按原样写,不要改
用给定的「自报家门」整串,其中 `【回访客服】` 原样保留(系统按登录人回填成「助理X」)——别替换成具体姓名,也别自己编一个;身份是**医生的助理**,不要改写成「客服/顾问」。
# ⛔⛔ 不许出现任何时间占位符
**不要写 `【时间段1】【时间段2】【具体预约时间】`,一个都不许出现。**
⛔ 也不要自己编具体时间(「周三上午」「本周末」),更不要「已为您约好」式承诺。
**为什么**:电话里客服是边说边填时间的;企微这条消息他是**整段复制直接发出去**的——
留个 `【时间段1】` 在里面,他要么忘了改直接发给患者(患者收到一句带方括号的乱码),
要么得先手动编辑一遍,而"可直接复制发送"当场就不成立了。
**那约时间怎么办**:把决定权交回给患者,用**不含任何具体时间**的邀约收尾,例如
「您方便的时候回我一下,我帮您安排李医生的号」「您看这周哪天方便,我这边给您留时间」。
⚠️ 说不清楚的就别说 —— 宁可只说「回我一下我帮您约」,也不要摆一个你并不知道的时间。
# 写的内容
- **病种措辞自供**:风险与「趁早处理的好处」结合下方病历(检查所见/医嘱/建议)+ 牙科常识,用自己的话讲清;医生没记录的别编。
- **把后果说清、有分寸**:不处理的后果要**客观说明**让患者理解严重性,但**不夸大、不吓唬**(别下「会掉光」「很危险」式结论)、**不推销促单报价**——为患者着想地讲,不是吓他/催他。
- **层层递进**:顺着给定的要点顺序推进,句与句承上启下,别并列罗列或跳跃重复。
- **事实朴素取用**:患者信息以朴素中文标签直接给(称呼/本次问题/牙位/诊断医生/最近一次就诊…),自然用进话里;除上面要求原样保留的 `【】` 外,不写占位符、不留标签字样。
import { Injectable, Logger } from '@nestjs/common';
import { AiCallRunnerService } from '../../ai-call-runner.service';
import type { AiCallContext } from '../../ai-call.interface';
import type { ScriptContext } from '../draft-plan-script/shared/input.types';
import { machineSafetyScan } from '../draft-plan-script/shared/safety-rules';
import { WecomWriteCall } from './calls';
import type { WecomWriteZ } from './schema';
export interface WecomScriptResult {
markdown: string;
tone: WecomWriteZ['tone'];
source: 'agent' | 'failed';
invocationId: string;
costYuan: number;
promptTokens: number;
completionTokens: number;
failReason?: string;
}
/**
* 企微话术 —— **单次流式生成**,没有工作流。
*
* ═══ 2026-08 从三步 pipeline 砍成一步 ═══════════════════════════════
* 原来是 规划大纲 → 撰写 → 安全自检 →(不过)修订,四次 LLM 调用、一轮 60 秒上下,
* 前端还得画一套"过程可见"的时间线陪着转。砍掉的理由:
* · 企微稿是**一整块几百字**,没有分段结构 —— "先列大纲"对单块文本收益极小
* · 客服拿到后**本来就要自己过一遍再发**(界面上一直写着"请核对后使用"),
* LLM 自检那一步是在给一个人本来就会做的事再花一次钱和 30 秒
* · 流式输出让"等"变成"看着它写",体感比一个转圈的白屏好得多
*
* ⚠️ **机器安全闸保留**(不是 LLM 那步)。它不花钱、不耗时,而且拦的是
* 模型自己发现不了的东西 —— 尤其**残留占位符**:企微是整段复制直发,
* `【时间段1】` 会原样进患者微信。实测模型会照抄电话档的习惯写出占位符,
* ⛔ 只靠 prompt 拦不住。
*
* ⚠️⚠️ **没有模板兜底,失败就是失败**(与电话档最大的行为差别)。
* 电话档失败回退模板 —— 客服正拿着电话,必须有东西能念,模板稿只是平淡不会出事。
* 企微产出是**一条要原样发给患者的消息**:给一份群发感的套话让他复制发出去,
* 患者收到后收不回来,那比"没有话术"更糟。⇒ 生成不出来就如实报失败,让客服自己写。
*/
@Injectable()
export class WecomScriptStrategy {
private readonly logger = new Logger(WecomScriptStrategy.name);
constructor(
private readonly runner: AiCallRunnerService,
private readonly writeCall: WecomWriteCall,
) {}
/**
* 流式:边写边推正文增量,写完过机器闸。
*
* ⭐ 推的是 `markdown` 的**增量**(不是整串重发)—— 前端直接追加,不必每帧重排整段。
* ⚠️ 机器闸在 `done` 之后才判:不过则**丢弃已推的正文**、回一个 failed。
* 看着"写了又没了"确实不好受,但让带占位符的稿子留在框里、客服一键复制发出去更糟。
*/
async *runStream(
ctx: ScriptContext,
runCtx: AiCallContext,
): AsyncGenerator<
| { kind: 'delta'; text: string }
| { kind: 'result'; result: WecomScriptResult }
> {
let pushed = ''; // 已推出去的正文,用来算增量
let last: { output: WecomWriteZ; invocationId: string; costYuan: number; promptTokens: number; completionTokens: number } | null = null;
try {
for await (const ev of this.runner.stream(this.writeCall, { ctx }, runCtx)) {
if (ev.type === 'partial') {
const md = (ev.partial as Partial<WecomWriteZ>).markdown ?? '';
// ⚠️ 只在**变长**时推增量:structured 流式偶发回吐更短的中间态,
// 直接 slice 会推出乱码片段。
if (md.length > pushed.length && md.startsWith(pushed)) {
yield { kind: 'delta', text: md.slice(pushed.length) };
pushed = md;
}
} else if (ev.type === 'done') {
last = {
output: ev.output,
invocationId: ev.invocationId,
costYuan: ev.costYuan,
promptTokens: ev.promptTokens,
completionTokens: ev.completionTokens,
};
}
}
} catch (err) {
if (runCtx.signal?.aborted) throw err;
yield { kind: 'result', result: this.fail(`生成失败: ${(err as Error).message}`) };
return;
}
if (!last) {
yield { kind: 'result', result: this.fail('生成失败: 模型没有返回结果') };
return;
}
const issues = machineScanIssues(last.output);
if (issues.length > 0) {
this.logger.warn(`wecom 机器安全闸未过(${issues.length}): ${issues.join('; ')}`);
yield {
kind: 'result',
result: this.fail(`未通过安全检查: ${issues.join(';')}`, last.invocationId, last),
};
return;
}
yield {
kind: 'result',
result: {
markdown: last.output.markdown,
tone: last.output.tone,
source: 'agent',
invocationId: last.invocationId,
costYuan: last.costYuan,
promptTokens: last.promptTokens,
completionTokens: last.completionTokens,
},
};
}
/** 非流式(重新生成端点 / 测试用)—— 与流式**同一条闸**,⛔ 别让两条路各判各的 */
async run(ctx: ScriptContext, runCtx: AiCallContext): Promise<WecomScriptResult> {
let result: WecomScriptResult | null = null;
for await (const ev of this.runStream(ctx, runCtx)) {
if (ev.kind === 'result') result = ev.result;
}
return result ?? this.fail('生成失败: 未产出结果');
}
private fail(
reason: string,
invocationId = '',
usage?: { costYuan: number; promptTokens: number; completionTokens: number },
): WecomScriptResult {
return {
markdown: '',
tone: 'warm',
source: 'failed',
invocationId,
costYuan: usage?.costYuan ?? 0,
promptTokens: usage?.promptTokens ?? 0,
completionTokens: usage?.completionTokens ?? 0,
failReason: reason,
};
}
}
/**
* 机器硬扫 —— 唯一保留的"检查",不花钱不耗时。
*
* ⚠️ 禁词/疗效承诺/加粗写死时间复用电话档同一个 `machineSafetyScan`:这些**与渠道无关**,
* 两处各写一份的话,改了其中一处另一处就悄悄留在旧规则上。
*/
function machineScanIssues(draft: WecomWriteZ): string[] {
const issues: string[] = machineSafetyScan(draft.markdown);
/**
* ⭐ 企微专有硬闸:**除【回访客服】外不许残留任何 `【】` 占位**。
*
* 电话稿留【时间段1】是对的(客服边打边填);企微这条消息是**整段复制直接发出去**的,
* 占位符会原样发到患者微信里 —— 要么客服忘了改直接发出去,要么他得先手动编辑一遍,
* 而"可直接复制发送"当场就不成立了。
* ⚠️ 只靠 prompt 拦不住(实测模型照着电话档的习惯写出来了),所以这里硬扫兜底。
*/
const leftovers = [...draft.markdown.matchAll(/【([^]*)】/g)]
.map((m) => m[0])
.filter((tag) => tag !== '【回访客服】');
if (leftovers.length) {
issues.push(`残留占位符 ${[...new Set(leftovers)].join('、')} —— 企微是整段复制直发,这会原样发给患者`);
}
return issues;
}
...@@ -72,11 +72,23 @@ export class AiProviderService { ...@@ -72,11 +72,23 @@ export class AiProviderService {
if (options?.body && typeof options.body === 'string') { if (options?.body && typeof options.body === 'string') {
try { try {
const b = JSON.parse(options.body) as Record<string, unknown>; const b = JSON.parse(options.body) as Record<string, unknown>;
b.enable_thinking = false;
// ⚠️ 仅"非工具调用"才强制 json_object(结构化输出框架 generateObject 用 json mode、无 tools)。 // ⚠️ 仅"非工具调用"才强制 json_object(结构化输出框架 generateObject 用 json mode、无 tools)。
// tool-calling / agent(streamText + tools)不能用 JSON mode:① 与工具调用互斥 // tool-calling / agent(streamText + tools)不能用 JSON mode:① 与工具调用互斥
// ② DashScope 还会硬性要求 messages 含 "json" 字样 → 400。带 tools 时跳过注入。 // ② DashScope 还会硬性要求 messages 含 "json" 字样 → 400。带 tools 时跳过注入。
const hasTools = Array.isArray(b.tools) && b.tools.length > 0; const hasTools = Array.isArray(b.tools) && b.tools.length > 0;
/**
* 🔴 2026-08-14:`enable_thinking = false` 由**无条件**改成**只在 json mode 那条路**。
*
* 上面那条实测(2026-06,qwen3.7-max:流式下 thinking 污染 content)跑的是
* **结构化输出**那条路 —— 而 agent 这条路从来没重新评估过。
* ⚠️ 证据就在旁边三行:`response_format` 的注入判了 `hasTools`,`enable_thinking` **没判**。
* 于是这几天拿 qwen 跟 DeepSeek 比"要不要边写边调工具",比的其实是
* **「便宜档 + 会思考」对「最高档 + 不许思考」**,比较本身不成立。
* ⚠️ agent 路开思考更慢、也更贵(reasoning 计入 output token),
* 而 `qwen3.8-max` 在 configuration 里**还没有单价** —— 账目会更不准,见那边的 🔴。
* ⛔ 别改回无条件关:json mode 那条路仍然要关(那个污染是真的)。
*/
if (!hasTools) b.enable_thinking = false;
if (!hasTools && b.response_format === undefined) { if (!hasTools && b.response_format === undefined) {
b.response_format = { type: 'json_object' }; // DashScope 认 json_object(json_schema 不强制) b.response_format = { type: 'json_object' }; // DashScope 认 json_object(json_schema 不强制)
} }
...@@ -107,7 +119,7 @@ export class AiProviderService { ...@@ -107,7 +119,7 @@ export class AiProviderService {
const canonical = modelId === 'gemini' ? this.geminiDefaultModel : modelId; const canonical = modelId === 'gemini' ? this.geminiDefaultModel : modelId;
return { model: this.google(canonical), provider: 'gemini', modelId: canonical }; return { model: this.google(canonical), provider: 'gemini', modelId: canonical };
} }
// qwen 前缀(含 qwen3.7-max / qwen-max / 裸键 qwen)→ DashScope 兼容端点 // qwen 前缀(含 qwen3.8-max / qwen-max / 裸键 qwen)→ DashScope 兼容端点
if (modelId.startsWith('qwen')) { if (modelId.startsWith('qwen')) {
const canonical = modelId === 'qwen' ? this.qwenDefaultModel : modelId; const canonical = modelId === 'qwen' ? this.qwenDefaultModel : modelId;
return { model: this.qwen(canonical), provider: 'qwen', modelId: canonical }; return { model: this.qwen(canonical), provider: 'qwen', modelId: canonical };
......
/**
* 成本估算 —— 纯函数,`AiCallRunner` 与助手共用**同一份**口径。
*
* ⚠️ 抽出来是因为它是**计价**逻辑:抄第二份必然会漂,而漂了之后报表上看不出任何异常
* (同一类 bug 2026-08-13 栽过一次:换 qwen 旗舰后成本被低报约四倍)。
*/
export interface ModelPrice {
/** ¥/M tokens —— 输入里命中 vendor prompt cache 的部分 */
inHit: number;
/** ¥/M tokens —— 输入里未命中的部分 */
inMiss: number;
/** ¥/M tokens —— 输出 */
out: number;
}
/** 价目表里查不到时的兜底价(与历史行为一致:按 deepseek-v4-pro 估)。 */
export const FALLBACK_PRICE: ModelPrice = { inHit: 0.5, inMiss: 3.6, out: 25 };
export interface CostResult {
yuan: number;
/** 价目表里没有这个模型 —— 调用方要吭声,⛔ 别静默 */
priceMissing: boolean;
}
export function estimateCostYuan(
priceTable: Record<string, ModelPrice>,
modelId: string,
promptTokens: number,
completionTokens: number,
cachedInputTokens = 0,
): CostResult {
const p = priceTable[modelId];
const price = p ?? priceTable['deepseek-v4-pro'] ?? FALLBACK_PRICE;
// 防御:cached > prompt 不该发生,clamp
const hit = Math.min(Math.max(0, cachedInputTokens), Math.max(0, promptTokens));
const miss = Math.max(0, promptTokens - hit);
const yuan =
(hit * price.inHit + miss * price.inMiss + Math.max(0, completionTokens) * price.out) /
1_000_000;
return { yuan: Math.max(0, yuan), priceMissing: !p };
}
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 done。
*
* ⚠️ 2026-08 改动:原来推的是「步骤」事件(规划/撰写/自检),配一套时间线组件。
* 工作流砍掉之后没有步骤可推了,改推 `delta`(正文增量)——
* 体感从"盯着三个勾等 60 秒"变成"看着它写"。
* ⚠️ 推的是**增量**不是全文:前端直接追加即可,不必每帧重排整段。
* ⚠️ 机器安全闸在 `done` 时判。不过则 `source='failed'` 且 `content` 为空 ——
* 前端必须据此**清掉已经流出来的正文**,⛔ 别把没过闸的稿子留在框里让人复制。
*/
async *generateStream(
planId: string,
options: { modelIdOverride?: string; signal?: AbortSignal } = {},
): AsyncGenerator<
| { type: 'delta'; text: string }
| { 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 === 'delta') {
yield { type: 'delta', text: ev.text };
} 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; 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 ?? '未知'}`,
);
}
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 } : {}),
};
}
}
/**
* playbooks —— **次要业务线的做法,按需取用**(`open_playbook`)。
*
* ═══ 这是提示词分层的第三种加载方式 ═══════════════════════════════
* 常驻 ①②③④⑤ 每轮都发。只配给**主职责**(今天是分配)。
* push `_guide` 随**返回值**下发(见 mcp/guides.ts):零往返、必然到达,
* 但只在**调完之后** —— 它答的是「这批数怎么读」。
* pull 本文件 模型**先发现、再主动取** —— 它答的是「这件活怎么干」,
* 而那件事在它决定动手**之前**就要知道,来不及等返回值。
*
* ═══ 为什么次要业务线必须是 pull ═══════════════════════════════════
* 🔴 常驻的成本不是"多几百字",是**每条线都常驻**之后的总和:分配、追踪、排班、复盘、盘点……
* 每加一条,其余所有会话都在为它白付上下文,而且规则越多每条被遵守的概率越低。
* ⇒ 主职责常驻(他多数时候就在干这个),其余留一个**索引**让模型自己发现。
*
* ═══ 索引在哪 ═══════════════════════════════════════════════════
* ⭐ **索引就是 `open_playbook` 的取值域本身** —— 每个取值旁边写着"他问什么时候用它"。
* ⛔ 别再往系统提示词里加一句「遇到 X 先去取做法」:那是第二份,而工具描述本来就是
* 模型在决定调不调时读的东西(同 `show_guidance` 的 `id`:取值的含义写在取值上)。
*
* ═══ 边界 ═══════════════════════════════════════════════════════
* ✅ 写**顺序和取舍**:先调哪个后调哪个、他要的到底是什么、什么时候该停。
* ⛔ 不写「这批数怎么读」—— 那在 `_guide` 里,它会随返回值自己到。写两份必然漂。
* ⛔ 不写工具的参数怎么填 —— 那在参数说明里。
* ⛔ 不写成品句子。护栏写成规范,⛔ 不写成台词(2026-08-08 那次照抄事故)。
*/
/** 一篇做法:`什么时候用` 进索引(工具的取值域),`正文` 只在取用时才发。 */
interface Playbook {
/** 他问什么算这一类 —— ⚠️ 这句会进工具描述,是模型**发现**它的唯一线索 */
什么时候用: string;
正文: string;
}
export const PLAYBOOKS: Readonly<Record<string, Playbook>> = {
分配追踪: {
什么时候用: '他问已经分下去的批次现在怎么样了、哪批有问题、谁堆着没动、某个人为什么被分给谁',
正文: `## 追一批已经分下去的活
顺序:先 list_assignment_batches 看所有批次的汇总,定位他问的那一批;再 get_assignment_detail 拿那一批的全貌。
问某个患者「为什么是他 / 为什么给了这个人」用 explain_assignment —— 那是查分配当时的决策快照,⛔ 别从画像自己推。
要整批收回用 revoke_assignment,且只在他明确要求时。
⚠️ 这几个工具的返回值里带 \`_guide\`:这批数最容易被误读的地方都写在那儿,读数之前先看它。
他要的往往不是一张表,是**下一步做什么** —— 哪几条该催、哪几条该撤回重分、谁手上堆着一直没动。
数报完给一句这个,⛔ 别停在一堆百分比上。`,
},
};
/** `open_playbook` 的取值域 —— 与上表同源,⛔ 别另写一份。 */
export const PLAYBOOK_TOPICS = Object.keys(PLAYBOOKS);
/**
* 取值域的说明 = **索引**:每个取值旁边就是"他问什么时候用它"。
*
* ⚠️ 生成的,⛔ 不是手写的常量:手写那份会和 `PLAYBOOKS` 漂开,而漂了不报错 ——
* 模型照着索引去取一篇不存在的,或者有一篇它永远发现不了。
*/
export const PLAYBOOK_INDEX = PLAYBOOK_TOPICS.map(
(k) => `${k} = ${PLAYBOOKS[k]!.什么时候用}`,
).join(';');
import { Body, Controller, Post, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common'; import { Body, Controller, Logger, Post, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { ModelMessage } from 'ai'; import type { ModelMessage } from 'ai';
import { AssistantService } from './assistant.service'; import { SheetSnapshotSchema } from '@pac/types';
import { AssistantService, MAX_TOOL_STEPS } 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';
/// multer 内存模式的最小文件形状(不引 @types/multer) /// multer 内存模式的最小文件形状(不引 @types/multer)
interface UploadedAudio { interface UploadedAudio {
...@@ -13,9 +18,52 @@ interface UploadedAudio { ...@@ -13,9 +18,52 @@ interface UploadedAudio {
mimetype: string; mimetype: string;
} }
/** 只认标准 uuid —— conversationId 直接进 `workflow_run_id`(uuid 列),⛔ 别让脏值打到数据库。 */
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
interface ChatBody { interface ChatBody {
messages: ModelMessage[]; messages: ModelMessage[];
model?: string; model?: string;
/**
* 这一串对话的 id —— 前端在**开始一段新对话时**现生成,此后每轮原样带上。
*
* 🔴 它是「哪几轮属于同一次对话」的**唯一可靠依据**。此前只能靠 `turnNo` 归 1 反推,
* 而那是启发式:两位主管并发聊天时行是交错的,前端一旦裁剪历史 turnNo 也会错位。
* ⚠️ 前端可改的入参 —— 只用于**归组**,⛔ 不参与任何鉴权或取数。
* 非 uuid 一律丢弃(服务端另生成),⛔ 不让它成为写库的注入面。
*/
conversationId?: string;
/**
* 主管当前正看着哪家诊所 —— 作 `propose_assignment` 的诊所兜底。
*
* 🔴 移交那句话里**没有诊所**(「帮我给「拔牙 · 3 年以上」这批患者出一份分配方案」),
* 模型无从得知;不传就回落 `scope.clinicIds[0]`,多诊所主管在第二家的矩阵上点一格,
* 确认单会按**第一家**出 —— 人数对不上而且不报错。
* ⚠️ 仍然要过越权闸(`resolveClinicId`):它是**前端可改**的入参,⛔ 不能当可信来源。
*/
activeClinicId?: string;
/**
* 眼前那张**还没确认**的确认单,此刻的样子 —— 模型用 `get_current_sheet` 取。
*
* 🔴 为什么由前端捎来:确认之前,改派 / 移出 / 时限 / 福利**只存在于卡片组件里**,
* 服务端手上只有最初那一版提案(见 `SheetSnapshotSchema` 上那段)。
* ⚠️ 与 `activeClinicId` 同一条纪律:前端可改的入参 ——
* 它只用来**告诉模型现在什么样**,⛔ 一个字都不许拿去写库。
* (真正落库走 `POST /plans/assignments`,那条路自己带全量数据。)
* ⚠️ 用 zod 现场解析而不是直接透传:字段少一个模型就会读到 `undefined` 并当成 0。
*/
sheetState?: unknown;
/**
* 详情页当前开着的那位患者 —— 与 `activeClinicId` 同一条旁路。
*
* 🔴 他在那一屏说「这通电话怎么开口」「这个患者为什么被召回」时**句子里没有名字**,
* 模型无从知道是谁(界面只拿姓名去预填例句文字,从没发给过它)。
* ⚠️ 它只作**指代的默认落点**告诉模型,⛔ **不注入到工具参数** ——
* 注入等于把"查另一位患者"这条路堵死,而他随时会问别人。
* ⚠️ 前端可改的入参:越权由各工具自己的 `assertPatientInScope` 挡,
* ⛔ 不因为"是我们自己发的"就当可信。
*/
activePatientId?: string;
} }
/** /**
...@@ -65,6 +113,8 @@ function extractHtmlField(jsonText: string): string | null { ...@@ -65,6 +113,8 @@ function extractHtmlField(jsonText: string): string | null {
@ApiBearerAuth('accessToken') @ApiBearerAuth('accessToken')
@Controller('assistant') @Controller('assistant')
export class AssistantController { export class AssistantController {
private readonly logger = new Logger(AssistantController.name);
constructor( constructor(
private readonly assistant: AssistantService, private readonly assistant: AssistantService,
private readonly transcriber: TranscribeService, private readonly transcriber: TranscribeService,
...@@ -119,7 +169,13 @@ export class AssistantController { ...@@ -119,7 +169,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 +201,24 @@ export class AssistantController { ...@@ -145,6 +201,24 @@ 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-prompts 的分层)——
// ⚠️ 控制器只**供料**,⛔ 不在这里拼提示词:企微那条路也走同一个装配。
// ⭐ 主管当前看的诊所 —— propose_assignment 的诊所兜底(见 DTO 上那段)
...(body.activeClinicId ? { activeClinicId: body.activeClinicId } : {}),
...(UUID_RE.test(body.conversationId ?? '') ? { conversationId: body.conversationId } : {}),
// ⚠️ 解不出来就当没有 —— ⛔ 别把半份快照喂给模型(缺的字段会被读成 0)
...(typeof body.activePatientId === 'string' && body.activePatientId
? { activePatientId: body.activePatientId }
: {}),
...(SheetSnapshotSchema.safeParse(body.sheetState).success
? { sheetState: SheetSnapshotSchema.parse(body.sheetState) }
: {}),
abortSignal: ac.signal, abortSignal: ac.signal,
}); });
...@@ -155,6 +229,19 @@ export class AssistantController { ...@@ -155,6 +229,19 @@ export class AssistantController {
case 'text': case 'text':
send({ type: 'text', text: (p.text as string) ?? (p.delta as string) ?? '' }); send({ type: 'text', text: (p.text as string) ?? (p.delta as string) ?? '' });
break; break;
/**
* ⭐ 思考也转发 —— 它跟正文走**同一条流**,按到达顺序穿插到消息里。
*
* 🔴 为什么值得摆出来:2026-08-14 裸台实测,模型"为什么这么调工具"全写在这儿 ——
* 「注意:show_guidance **一次只开放一件**」这句话直接指出了当轮只调一次的原因,
* 而它是**读了我们的工具描述之后**的结论,不是模型的毛病。
* ⇒ 没有它,归因只能靠猜,而猜的方向大概率是"模型不行"。
* ⚠️ 它是模型输出的一部分,最后随 messages 原样回传(DeepSeek 文档明确要求
* 思考内容参与后续轮次的上下文拼接)—— ⛔ 别在这里过滤掉。
*/
case 'reasoning-delta':
send({ type: 'reasoning', text: (p.text as string) ?? '' });
break;
case 'tool-input-start': case 'tool-input-start':
if (p.toolName === 'render_artifact') artToolIds.add(String(p.id)); if (p.toolName === 'render_artifact') artToolIds.add(String(p.id));
break; break;
...@@ -187,6 +274,22 @@ export class AssistantController { ...@@ -187,6 +274,22 @@ export class AssistantController {
error: p.error instanceof Error ? p.error.message : String(p.error), error: p.error instanceof Error ? p.error.message : String(p.error),
}); });
break; break;
case 'finish':
/**
* 🔴 **no silent cap** —— `stopWhen(MAX_TOOL_STEPS)` 到顶时,SDK 以
* `finishReason='tool-calls'` 收尾:模型还想继续调工具,被截断了。
*
* 不报出来的后果很隐蔽:它可能停在「我先查一下」之后就没了下文,
* 而主管以为查完了。⚠️ 这里只**陈述事实**(本轮到上限了),
* ⛔ 不替模型补话、⛔ 不自动续跑 —— 续不续是用户的决定。
*/
if (p.finishReason === 'tool-calls') {
this.logger.warn(
`助手单轮工具步数到顶(${MAX_TOOL_STEPS}),模型仍想继续调用 —— 本轮被截断`,
);
send({ type: 'step_limit', limit: MAX_TOOL_STEPS });
}
break;
case 'error': case 'error':
send({ type: 'error', error: String(p.error) }); send({ type: 'error', error: String(p.error) });
break; break;
......
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 { AssistantLabController } from './lab.controller';
import { AssistantService } from './assistant.service'; import { AssistantService } from './assistant.service';
import { McpClientService } from './mcp-client.service'; import { McpClientService } from './mcp-client.service';
import { TranscribeService } from './transcribe.service'; import { TranscribeService } from './transcribe.service';
...@@ -13,8 +15,10 @@ import { DictationGateway } from './dictation.gateway'; ...@@ -13,8 +15,10 @@ 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 取数
controllers: [AssistantController], imports: [AiModule, AuthModule, PlanModule],
// 🔬 AssistantLabController = 裸 agent 试验台,⛔ 与产品助手无代码关联(见该文件头)
controllers: [AssistantController, AssistantLabController],
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;
} }
/**
* guides —— 随工具**返回值**下发的解读规范(`_guide`)。
*
* ═══ 为什么不放在工具描述里 ═══════════════════════════════════════
* 工具描述是**常驻**的:每一次对话、每一轮,都要连同工具清单一起发给模型。
* 而「批次跟踪的数怎么读」只有约三成会话用得到 —— 其余七成在为它白付上下文。
*
* 挂在返回值上则是**按需加载最便宜的形态**:零往返(不用模型主动去取)、
* 必然到达(它一定会看到自己调的工具的返回)、用不到时零成本。
*
* ═══ 边界 ═══════════════════════════════════════════════════════
* ✅ 这里写「**怎么解读这批数**」——容易误读的地方、必须一起报的东西。
* ⛔ 「**怎么用这个工具**」(什么时候调、参数怎么填)仍然留在工具描述里 ——
* 那是模型在**决定调不调**的时候要看的,来不及等返回值。
* ⛔ ⛔ **不许在这里写"照抄下面这句"这类成品句子。**
* 2026-08-08 栽过:`get_assignment_detail` 的 `note` 字段混着给模型的指令,
* 模型照抄就把内部指令原样贴进了主管的对话框。护栏写成**规范**,不写成台词。
*
* [弥补模型] —— 模型变强后这些规范应当逐条复查是否还必要。
*/
/** 批次跟踪(`get_assignment_detail`)的解读规范 */
export const TRACKING_GUIDE = [
'「处理」不等于「成功」:progress 是处理率,只说「这单动过了」,不说「谈成了」。',
'「已出池·引擎判定需求已了」是引擎按客观事实判定召回需求没了,⛔ 不是「转化成功/成交」,⛔ 不要拿这些数算转化率。',
'报处理率必须带上「本批已跑天数」:跑了三个月的批次天然比跑了三天的好看,不带年龄直接比是耍流氓。',
'退回率永远给两个数:「退回 5 / 已处置 40 = 12.5%(另有 60 条未动)」——「没人动」和「动了但退回」是完全不同的信号,只报一个百分比会把前者藏起来。',
'分母小于 50 时直接说「样本量不足」,⛔ 不要输出百分比、⛔ 不要画图。',
'outcomes(通话成效)与 releaseReasons(退回原因)是两件不同的事,⛔ 绝不能混说:releaseReasons =「这单不该我做」,客服没打就还回去了,是**分配**问题;outcomes =「打了,结果这样」,客服做了事,是**召回效果**问题。说反了主管会去改错的东西。',
'outcomes.noOutcome(一次结果都没有)必须单独报出来,⛔ 不许算进「不成功」——那不是效果差,是根本没做/没记。',
'outcomes.success 含「约定下次回访」,⛔ 别说成「成交/转化了这么多」。',
'outcomes.records 是逐条明细 + 客服手写的电话纪要(notes)。主管问「哪个患者/为什么/客服怎么说的」,答案只在这里,⛔ 别只回聚合数。',
'notes 为 null =「没留纪要」(不是没打)。结果填了、纪要空着本身是信息:说明只点了个选项。',
'引用纪要时照原话,⛔ 别润色成「客户表示…」——主管要看的就是客服当时怎么写的。',
'recordsTruncated=true 时必须说明「只是最近的一部分」。',
'每条 record 里 outcome 只是其中一个字段,还有客服勾的子选项:abandonReasons(放弃原因,只有「放弃」才有)、inaccurateTreatments(客服说这条召回判断错了,调算法要看它)、scheduledNextAt(约的回访日)、channel(电话/企微/短信)。⛔ 只报 outcome 等于漏掉一半 ——「为什么放弃」的答案在 abandonReasons 里。',
'上面这些数用你自己的话讲,⛔ 别整段照搬工具返回的字符串。',
];
/** 批次列表(`list_assignment_batches`)的解读规范 */
export const BATCH_LIST_GUIDE = [
'「已处理」不等于「已成功」:含召回出池、被抑制、已结案三种,⛔ 别说成转化。',
'各批次的处理率不能直接横比,先看各自跑了多少天。',
'要看某一批为什么这样,用 get_assignment_detail 取明细,⛔ 不要从汇总数里推原因。',
];
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 {
TEMPERATURE_META,
planScenarioLabel,
potentialTreatmentCardLabel,
type TemperatureValue,
} from '@pac/types';
/**
* model-facing —— 服务层返回值 →「面向模型」的形状。
*
* ═══ 为什么要有这一层 ═══════════════════════════════════════════════
* 模型说出 `cold_3y` `implant` `inHandPending` 这类词,主管的第一反应是**系统坏了**。
* 原来的对策是在提示词里写两页「⛔ 不许说出取值码」——那是 P4 ③ 级(只拦得住预想到的),
* 而且实测拦不住:模型调工具**必须**用码当参数,回话时自然就带出来了。
*
* 真正的解法是**给它现成的中文**:返回值里既回显它传进来的条件(`criteriaZh`),
* 又把桶名换成中文键。它手里有话可说,就不会去说码。—— 这是 ① 级(结构上不可能)。
*
* ═══ 三条边界 ═══════════════════════════════════════════════════════
* ⛔ **只做翻译,不做业务判断。** 任何"要不要提醒""算不算成功"的判断都不属于这里。
* ⛔ **不改底层 service 的形状。** REST / 前端是另一个消费者,它们要的是稳定的 key。
* 翻译只发生在 MCP 边界上。
* ⛔ **不在这里生成成品句子。** 那是 P2 要消除的东西(G6:给事实不给句子)。
*
* 标签一律取自 `@pac/types` 的既有映射,⛔ 不在这里另立一套中文 —— 两份必然漂。
*/
// ─────────────────────────────────────────────────────────
// 单值翻译
// ─────────────────────────────────────────────────────────
/** 时间档取值码 → 矩阵上那一列的中文(`hot` → 「三个月内」) */
export function zhTemperature(code: string | null | undefined): string | null {
if (!code) return null;
return TEMPERATURE_META[code as TemperatureValue]?.zh ?? code;
}
/** 潜在治疗取值码 → 矩阵上那一行的中文(`implant` → 「种植」) */
export function zhTreatment(code: string | null | undefined): string | null {
if (!code) return null;
return potentialTreatmentCardLabel(code);
}
/** 召回场景取值码 → 中文(`treatment_initiation_recall` → 「潜在治疗」) */
export function zhScenario(code: string | null | undefined): string | null {
if (!code) return null;
return planScenarioLabel(code);
}
// ─────────────────────────────────────────────────────────
// 条件回显
// ─────────────────────────────────────────────────────────
/**
* 把模型传进来的筛选条件原样回一份中文 —— **这是本模块最关键的一个函数**。
*
* 模型必须用码调工具(`temperature: 'hot'`),但回话要说「三个月内」。
* 不给回显,它只能自己翻译;自己翻译就会翻错,或者干脆把码念出来。
* ⇒ 每个吃这些条件的工具,返回值里都带一份 `criteriaZh`。
*
* [弥补模型]
*/
export function criteriaZh(input: {
potentialTreatment?: string | null;
temperature?: string | null;
}): Record<string, string> {
const out: Record<string, string> = {};
const t = zhTreatment(input.potentialTreatment);
const temp = zhTemperature(input.temperature);
if (t) out['治疗项'] = t;
if (temp) out['时间档'] = temp;
return out;
}
// ─────────────────────────────────────────────────────────
// 桶名翻译
// ─────────────────────────────────────────────────────────
/**
* 批次处理进度的五个桶 → 中文键。
*
* ⚠️ 这几个词是提示词里被反复叮嘱的重灾区(`resolved` `suppressed` `inHandPending`
* `backToPool`),因为它们既是内部枚举、又必须报给主管看。换成中文键之后,
* 模型照着键名说就是对的。
*
* ⚠️ 中文键刻意写全「已出池」而不是「已完成」—— `resolved` 是**引擎判定召回需求没了**,
* ⛔ 不是「谈成了」。键名本身就该挡住这个误读(T14)。
*
* [弥补模型]
*/
export function zhProgressBuckets(p: {
done?: number;
resolved?: number;
suppressed?: number;
closed?: number;
inHandPending?: number;
backToPool?: number;
reassigned?: number;
ageDays?: number;
}): Record<string, number> {
const out: Record<string, number> = {};
if (p.done !== undefined) out['已处理'] = p.done;
if (p.resolved !== undefined) out['已出池·引擎判定需求已了'] = p.resolved;
if (p.suppressed !== undefined) out['客服已写回访结果'] = p.suppressed;
if (p.closed !== undefined) out['已结案'] = p.closed;
if (p.inHandPending !== undefined) out['还在客服手上没动'] = p.inHandPending;
if (p.backToPool !== undefined) out['退回或到期落回池子'] = p.backToPool;
if (p.reassigned !== undefined) out['已被后续批次挑走'] = p.reassigned;
if (p.ageDays !== undefined) out['本批已跑天数'] = p.ageDays;
return out;
}
/**
* 画像维度里的 `noTag` → 中文键。
*
* ⚠️ 「没有这条记录」而不是「无标签」:`noTag` 是**没有这条画像证据**,
* ⛔ 不是反面(「没有商保标签」≠「自费」)。这是提示词里最长的一条护栏,
* 键名写清楚就少一次误读。
*
* [弥补模型]
*/
export function zhCohortDim<T extends { id: string; nameZh: string; noTag: number; multi: boolean }>(
dim: T,
): Omit<T, 'noTag' | 'multi'> & { 没有这条记录的人数: number; 一人可命中多项: boolean } {
const { noTag, multi, ...rest } = dim;
return { ...rest, 没有这条记录的人数: noTag, 一人可命中多项: multi };
}
import {
PERSONA_TAG_FILTER_DIMS,
personaTagDimId,
potentialTreatmentItemName,
} from '@pac/types';
/**
* personaTags 圈人字典(从 `PERSONA_TAG_FILTER_DIMS` 生成,单一真理源,自动同步)。
*
* 格式给 LLM 看:`key(中文): code=中文 / code=中文 …`,一维一行。
* 入参格式:"key:value" 逗号串;同维多选 OR,跨维 AND(如 "rfm:important_value,urgency_level:urgent")。
*
* ⚠️ **凡是收 personaTags 的工具都必须挂这份字典**(MCP 的 list_recall_queue /
* get_cohort_attributes,以及助手本地的 propose_assignment)——
* 少给一处,模型在那处就得自己猜 key 和 value code。
* 🔴 而猜错的维度会被 `cohort-filter.personaTagsSql` **静默丢掉**(`if (!dim) continue`):
* 筛选条件等于没加,人数一个不少地返回,**不报错也看不出来**。
* 所以这份字典不是"锦上添花的说明",是防静默失败的必需品。
*
* ⛔ 别再在某个工具里内联一份简写版 —— 那就是第二份真理源。
*/
const PERSONA_TAGS_HELP = PERSONA_TAG_FILTER_DIMS.map((d) =>
// ⚠️ 用 personaTagDimId 不用 d.key:source='patient' 的维度 key 为空串、筛选串用 id,
// 用 key 会给 LLM 一个空维度名,它照着发就永远筛不出人。
// 开集维度(医生)options 为空 → 提示取值是自由文本,并指向名单接口,免得 LLM 瞎猜姓名。
d.dynamic
? `${personaTagDimId(d)}(${d.nameZh}): <医生姓名,精确匹配;名单见 GET /pac/v1/plans/doctors>`
: `${personaTagDimId(d)}(${d.nameZh}): ${d.options.map((o) => `${o.value}=${o.zh}`).join(' / ')}`,
).join('\n');
/**
* 🔴 2026-08-14 补上「码不许说出口」那一句 —— 此前它**只在系统提示词的公共层**里
* (以 `personaTags` 为例被点名)。公共层是主管和客服共用的,而这些码只有圈人这条线才有,
* 把它们摆进公共层等于让另一条线的人也读一遍与自己无关的词。
* ⇒ 约束挪到**产生这些码的那一格参数上**:模型读到码的同一眼就读到怎么说。
* `POTENTIAL_TREATMENT_DESC` / `TEMPERATURE_TOOL_DESC` 早就是这么写的,这里是漏的那一份。
*/
export const PERSONA_TAGS_DESC =
'画像圈人(可选):"key:value" 逗号串,同维多选 OR、跨维 AND。' +
'⛔ **只能用下表里的 key 和 value**,写别的会被静默忽略(筛选等于没加,人数却照样返回)。' +
'\n🔴 ⛔ **这些 key 和 value 只用于调工具,一个字都不许说给主管** —— ' +
'回话一律用下表右边的中文说法。' +
'\n可用维度与取值:\n' +
PERSONA_TAGS_HELP;
/**
* 潜在治疗(矩阵那一行)的取值说明 —— 同样从 `PERSONA_TAG_FILTER_DIMS` 生成。
*
* 🔴 与温度同一条规矩:**码给模型调工具用,中文给主管听**。
* 把 code=中文 的对照表给全,模型才有得翻;⛔ 不给对照表它就只能把 `filling` 原样念出来。
*/
const POTENTIAL_TREATMENT_DIM = PERSONA_TAG_FILTER_DIMS.find(
(d) => d.key === 'potential_treatment',
);
export const POTENTIAL_TREATMENT_DESC =
'矩阵的一行:潜在治疗(界面标题就叫「潜在治疗」)。按此表把主管的话翻成取值:' +
// ⚠️ 中文取 `potentialTreatmentItemName` —— 那是**矩阵实际渲染用的**同一个函数。
// ⛔ 别改用 dim.options 的 zh:那份曾漂成「补牙」,而界面上是「充填」。
(POTENTIAL_TREATMENT_DIM?.options ?? [])
.map((o) => `${potentialTreatmentItemName(o.value)}=${o.value}`)
.join(' · ') +
'。\n🔴 ⛔ **这些取值码只用于调工具,一个字都不许说给主管** —— 回话一律用中文' +
'(「充填」「种植」「牙周」…);⛔ 也别自己发明「病种」「科室」这类界面上没有的叫法。';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey } from '@pac/types'; import {
EXTRACTION_NAME_KEYWORDS,
PersonaFeatureKey,
gapTemperatureBounds,
hottestBounds,
type TemperatureBounds,
} from '@pac/types';
import type { import type {
FeatureExtractor, FeatureExtractor,
FeatureExtractorContext, FeatureExtractorContext,
...@@ -23,12 +29,18 @@ import { nextAgeBoundary } from './time-boundary'; ...@@ -23,12 +29,18 @@ 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 弃用原则,不进标签)。
* - "非已丢单"(sales_chance)= PAC 未摄入丢单数据 → 省略(注明,follow-up)。 * - "非已丢单"(sales_chance)= PAC 未摄入丢单数据 → 省略(注明,follow-up)。
*/ */
const EXTRACTION_NAME_KW = ['残根', '残冠', '无法保留', '不能保留']; /// ⚠️ 单一定义在 @pac/types —— 初选矩阵的 SQL 侧要用同一份(见 potential-label-rules.ts)。
/// ⛔ 别在这里另写一份:改了关键词而 SQL 没跟上,会出现「矩阵有这人、列表没有」且不报错。
const EXTRACTION_NAME_KW = EXTRACTION_NAME_KEYWORDS;
/** /**
* classifyGapToLabel 里的年龄闸(满这些岁数时,同一批 gap 的标签映射会变): * classifyGapToLabel 里的年龄闸(满这些岁数时,同一批 gap 的标签映射会变):
...@@ -89,9 +101,24 @@ export class PotentialTreatmentFeatureExtractor implements FeatureExtractor { ...@@ -89,9 +101,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 +127,22 @@ export class PotentialTreatmentFeatureExtractor implements FeatureExtractor { ...@@ -100,10 +127,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,6 +166,21 @@ export class PotentialTreatmentFeatureExtractor implements FeatureExtractor { ...@@ -127,6 +166,21 @@ export class PotentialTreatmentFeatureExtractor implements FeatureExtractor {
}; };
}); });
/**
* ⛔ **不再写 `temperature`(2026-08 停写)** —— 初选矩阵的温度已改成
* 「从召回单证据的锚点**读时**算」(见 plan/reason-temperature.sql.ts)。
*
* 为什么停:边界烤进画像 JSON 有两个硬伤 ——
* ① 改一次 `DiagnosisTreatmentMap` 的窗口天数就要全量重算画像(实测 4 小时 54 分),
* 而**不重算不会报错**,只是全按旧窗口判,静默失效。
* ② 画像回答「这个人有哪些潜在治疗」,矩阵要回答「引擎为什么召回他」——
* 两者本就不是一回事(实测 161 个格位画像有、召回单没有)。
*
* ⚠️ 存量 JSON 里的 `temperature` 键**暂时留着不清**:它已经没有读取方,
* 清掉要跑一次全量重算才能恢复。等新链路在测试环境跑稳再单独清理。
* ⛔ 在那之前也别去读它 —— 它从此不再更新,是**过期数据**。
*/
return { return {
key: this.key, key: this.key,
description: labels.join(' / '), description: labels.join(' / '),
......
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import { calcAge, maskName, maskPhone } from '@pac/utils'; import { calcAge, maskName, maskPhone } from '@pac/utils';
import { applyLiveDays, ApiCode, KIN_RELATIONSHIPS, resolveKinRelationship } from '@pac/types'; import {
applyLiveDays,
ApiCode,
focusOrderReasons,
KIN_RELATIONSHIPS,
resolveKinRelationship,
} from '@pac/types';
import { BizError } from '../../common/errors/biz-error'; import { BizError } from '../../common/errors/biz-error';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { ChainComposerService } from '../plan/engine/chain-composer.service'; import { ChainComposerService } from '../plan/engine/chain-composer.service';
...@@ -41,7 +47,13 @@ export class PlanAggregateService { ...@@ -41,7 +47,13 @@ export class PlanAggregateService {
id: planId, id: planId,
...(scope.sourceUnits.length ? { patient: { sourceUnit: { in: scope.sourceUnits } } } : {}), ...(scope.sourceUnits.length ? { patient: { sourceUnit: { in: scope.sourceUnits } } } : {}),
}, },
include: { reasons: { orderBy: { priorityScore: 'desc' } } }, include: {
reasons: { orderBy: { priorityScore: 'desc' } },
// ⭐ 批次意图:聚焦哪条 reason 要看"这单是通过哪一格分下来的"(见下方 focusOrderReasons)。
// 只取 status/criteria 两列 —— 不要 include 整个 assignment,它带 attributes(福利文案)等
// 与本处无关的字段,白白进内存。
assignment: { select: { status: true, criteria: true } },
},
}); });
if (!plan) throw new NotFoundException(`Plan ${planId} not found`); if (!plan) throw new NotFoundException(`Plan ${planId} not found`);
if (plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) { if (plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) {
...@@ -108,7 +120,20 @@ export class PlanAggregateService { ...@@ -108,7 +120,20 @@ export class PlanAggregateService {
} }
} }
const assembled = await this.assemble(scope, patient, plan, agent); /**
* ⭐ 聚焦项按**批次意图**重排 —— 主管点「补牙」这一格分下来的单,客服打开就该先看补牙,
* 哪怕这人身上缺牙的分更高(实测补牙格 49% 的单不是以补牙为主因)。
* ⚠️ 判据是 `status === 'confirmed'`:撤销后批次意图作废,回落"分最高"。
* 与话术带不带福利同一个判据(plan-script.orchestrator 的 benefit),⛔ 别另立标准。
* ⚠️ 前端拿到后**还会再排一次**,那边必须走同一个函数,否则这里白改(见 plan-detail-app)。
*/
const focusLabel =
plan.assignment?.status === 'confirmed'
? ((plan.assignment.criteria as { potentialTreatment?: string } | null)?.potentialTreatment ?? null)
: null;
const focused = { ...plan, reasons: focusOrderReasons(plan.reasons, focusLabel), focusLabel };
const assembled = await this.assemble(scope, patient, focused, agent);
return { ...assembled, currentPlanId }; return { ...assembled, currentPlanId };
} }
...@@ -147,6 +172,10 @@ export class PlanAggregateService { ...@@ -147,6 +172,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 +268,20 @@ export class PlanAggregateService { ...@@ -239,6 +268,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 +329,9 @@ export class PlanAggregateService { ...@@ -286,9 +329,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,9 +623,12 @@ function serializePlan(plan: { ...@@ -580,9 +623,12 @@ 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;
/// 批次意图(主管点的那一格)——⚠️ reasons **已按它排好序**,前端重排必须走同一个 focusOrderReasons
focusLabel?: string | null;
updatedAt: Date; updatedAt: Date;
reasons: Array<{ reasons: Array<{
id: string; id: string;
...@@ -614,11 +660,15 @@ function serializePlan(plan: { ...@@ -614,11 +660,15 @@ 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
recallFeedback: plan.recallFeedback ?? null, recallFeedback: plan.recallFeedback ?? null,
recallFeedbackNote: plan.recallFeedbackNote ?? null, recallFeedbackNote: plan.recallFeedbackNote ?? null,
/// 批次意图:这单是主管点哪一格分下来的;null=自助认领或批次已撤销(回落"分最高")
focusLabel: plan.focusLabel ?? null,
reasons: plan.reasons.map((r) => ({ reasons: plan.reasons.map((r) => ({
id: r.id, id: r.id,
scenario: r.scenario, scenario: r.scenario,
...@@ -689,7 +739,7 @@ function extractFactIds(evidence: unknown): string[] { ...@@ -689,7 +739,7 @@ function extractFactIds(evidence: unknown): string[] {
* *
* 后端写库时 plan-script.orchestrator.renderMarkdown 把 4 段拼成: * 后端写库时 plan-script.orchestrator.renderMarkdown 把 4 段拼成:
* > 患者:xxx · 语气:xxx * > 患者:xxx · 语气:xxx
* ## 开场白\n{opening}\n## 告知应治未治\n{informMissed}\n## 复查建议\n{reviewAdvice}\n## 结束回访语\n{closing} * ## 开场白\n{opening}\n## 告知潜在治疗\n{informMissed}\n## 复查建议\n{reviewAdvice}\n## 结束回访语\n{closing}
* 这里反向用 regex 按 H2 标题切回 4 段,前端拿到的 sections shape 跟 mockScript 完全一致。 * 这里反向用 regex 按 H2 标题切回 4 段,前端拿到的 sections shape 跟 mockScript 完全一致。
* *
* 设计决策:**前端单一消费 sections 接口**(mock / 真实 / 流式三路同 shape), * 设计决策:**前端单一消费 sections 接口**(mock / 真实 / 流式三路同 shape),
...@@ -716,15 +766,15 @@ function serializeScript(s: { ...@@ -716,15 +766,15 @@ function serializeScript(s: {
/** /**
* markdown H2 标题 → 段 id 的**解析键**。 * markdown H2 标题 → 段 id 的**解析键**。
* *
* ⚠️ 这里必须保持「告知应治未治」,别跟下面 SECTION_META 的展示名一起改成「告知潜在治疗」—— * ⚠️ 这里必须保持「告知潜在治疗」,别跟下面 SECTION_META 的展示名一起改成「告知潜在治疗」——
* 它匹配的是**已经落库的话术正文**(plan_scripts.markdown 里 AI 写下的 `## 告知应治未治`), * 它匹配的是**已经落库的话术正文**(plan_scripts.markdown 里 AI 写下的 `## 告知潜在治疗`),
* 以及 prompt 当前仍在输出的标题。改这里 = 存量话术全部解析不出 informMissed 段, * 以及 prompt 当前仍在输出的标题。改这里 = 存量话术全部解析不出 informMissed 段,
* 打开就是空白。展示名换词只需改 SECTION_META,解析键跟着 prompt 走。 * 打开就是空白。展示名换词只需改 SECTION_META,解析键跟着 prompt 走。
* 哪天真要改 prompt 输出的标题,这里得**同时认新旧两个键**再切。 * 哪天真要改 prompt 输出的标题,这里得**同时认新旧两个键**再切。
*/ */
const SECTION_HEAD_TO_ID: Record<string, 'opening' | 'informMissed' | 'reviewAdvice' | 'closing'> = { const SECTION_HEAD_TO_ID: Record<string, 'opening' | 'informMissed' | 'reviewAdvice' | 'closing'> = {
开场白: 'opening', 开场白: 'opening',
告知应治未治: 'informMissed', 告知潜在治疗: 'informMissed',
复查建议: 'reviewAdvice', 复查建议: 'reviewAdvice',
结束回访语: 'closing', 结束回访语: 'closing',
}; };
...@@ -740,7 +790,7 @@ const SECTION_META: Record< ...@@ -740,7 +790,7 @@ const SECTION_META: Record<
function parseScriptMarkdownToSections(md: string) { function parseScriptMarkdownToSections(md: string) {
// ⭐ 通用 H2 切分:每个 `## 标题` 起一段,到下一个 H2 之间为内容。 // ⭐ 通用 H2 切分:每个 `## 标题` 起一段,到下一个 H2 之间为内容。
// - 稳健档:4 个固定标题(开场白/告知应治未治/复查建议/结束回访语)→ 映射到已知 id + 固定 label。 // - 稳健档:4 个固定标题(开场白/告知潜在治疗/复查建议/结束回访语)→ 映射到已知 id + 固定 label。
// - 标准/深度档:自由标题(段数不定)→ id=`s{序号}`、label=原标题。 // - 标准/深度档:自由标题(段数不定)→ id=`s{序号}`、label=原标题。
// 旧实现只认稳健 4 固定标题 → 深度/标准的自由标题全 currentId=null、内容丢弃 → 刷新后空白 // 旧实现只认稳健 4 固定标题 → 深度/标准的自由标题全 currentId=null、内容丢弃 → 刷新后空白
// (plan_scripts 存了内容,但反 parse 解不出 → 前端"尚未生成参考话术")。本版按任意 H2 解析,三档通用。 // (plan_scripts 存了内容,但反 parse 解不出 → 前端"尚未生成参考话术")。本版按任意 H2 解析,三档通用。
......
...@@ -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 { 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,
AgentWorkloadResponseSchema,
RefillProposalRequestSchema,
SetAssignmentBenefitRequestSchema,
SetAssignmentBenefitResponseSchema,
} 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 AgentWorkloadResponseDto extends createZodDto(AgentWorkloadResponseSchema) {}
export class RevokeAssignmentResponseDto extends createZodDto(RevokeAssignmentResponseSchema) {}
export class RefillProposalRequestDto extends createZodDto(RefillProposalRequestSchema) {}
export class SetAssignmentBenefitRequestDto extends createZodDto(SetAssignmentBenefitRequestSchema) {}
export class SetAssignmentBenefitResponseDto extends createZodDto(SetAssignmentBenefitResponseSchema) {}
...@@ -77,7 +77,12 @@ import { buildGapCore, GAP_FLAGS_BY_PRIMARY, GAP_PRIMARY_GROUPS } from '../../.. ...@@ -77,7 +77,12 @@ import { buildGapCore, GAP_FLAGS_BY_PRIMARY, GAP_PRIMARY_GROUPS } from '../../..
/// 跟诊断 cooldown 不同锚点 — cooldown 锚"诊断日"按 K 码定长;本项锚"最近到诊"统一长度。 /// 跟诊断 cooldown 不同锚点 — cooldown 锚"诊断日"按 K 码定长;本项锚"最近到诊"统一长度。
/// 二者并存,各补各的洞:cooldown 给新诊断缓冲;本项拦"旧诊断 + 近期又来过"。 /// 二者并存,各补各的洞:cooldown 给新诊断缓冲;本项拦"旧诊断 + 近期又来过"。
/// 患者级、与具体信号无关;将来复购 scenario 应抽成共享 fragment 复用。 /// 患者级、与具体信号无关;将来复购 scenario 应抽成共享 fragment 复用。
const POST_VISIT_COOLDOWN_DAYS = 14; ///
/// 🔴 **90 天**(2026-08-11 产品定,原 14 天)。这一条不是文案改动,它直接决定池子大小:
/// 凡是最近三个月到过诊的人整批退出召回池 —— 而且引擎跑全量时会把这些人**已经在跑的单
/// 一起关掉**(0 命中 → supersede,连 assigned 的也关,见 plan-engine 的 staleRows 收尾),
/// 已认领的会补 auto_release 账。⛔ 改这个数之前先量一遍影响面,别在主管刚分完批次时改。
const POST_VISIT_COOLDOWN_DAYS = 90;
/// v3.0 打分上下文(每患者 active persona 投影) /// v3.0 打分上下文(每患者 active persona 投影)
interface PersonaScoreCtx { interface PersonaScoreCtx {
......
...@@ -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 },
}); });
......
This source diff could not be displayed because it is too large. You can view the blob instead.
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 { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { refreshLabelsSql, countMissingLabelsSql, LABEL_REFRESH_BATCH } from './plan-label.sql';
interface BatchResult {
seen: number;
written: number;
maxId: string | null;
}
/**
* `plan_reasons.potential_labels` 的刷新器 —— 初选矩阵的提速全靠这一列。
*
* 两种跑法:
* · `backfillMissing()` —— 只补没算过的(NULL)。上线回填、以及新写入行的兜底。
* · `refreshAll()` —— 连算过的一起重算。⚠️ 标签含年龄规则,**不重算就会逐日漂**。
*
* ⛔ 两者都**不在请求路径上**:它们扫全表,得由 CLI / 定时任务跑。
*/
@Injectable()
export class PlanLabelService {
private readonly logger = new Logger(PlanLabelService.name);
constructor(private readonly prisma: PrismaService) {}
/** 还有多少条没算过 —— 回填收尾核对、以及"刷新任务是不是在漏人"的体检项。 */
async countMissing(): Promise<number> {
const [row] = await this.prisma.$queryRaw<Array<{ n: number }>>(countMissingLabelsSql);
return row?.n ?? 0;
}
/** 只补没算过的。返回写了多少行。 */
async backfillMissing(batch = LABEL_REFRESH_BATCH): Promise<number> {
return this.run({ onlyMissing: true, batch, label: '回填' });
}
/** 全量重算(年龄会变)。返回写了多少行。 */
async refreshAll(batch = LABEL_REFRESH_BATCH): Promise<number> {
return this.run({ onlyMissing: false, batch, label: '重算' });
}
private async run(o: { onlyMissing: boolean; batch: number; label: string }): Promise<number> {
let afterId: string | null = null;
let written = 0;
let seenTotal = 0;
for (;;) {
const rows: BatchResult[] = await this.prisma.$queryRaw<BatchResult[]>(
refreshLabelsSql({ afterId, onlyMissing: o.onlyMissing, batch: o.batch }),
);
const r = rows[0];
const seen = r?.seen ?? 0;
if (seen === 0) break;
written += r?.written ?? 0;
seenTotal += seen;
/**
* 🔴 游标按 `maxId` 推进,⛔ 不按"写了几行" ——
* 全量重算时绝大多数行算出来跟原值一样、不会被写,此时 written=0
* 但**并不是做完了**。拿 written 当停止条件 = 第一批之后就静默罢工。
*/
afterId = r?.maxId ?? null;
if (!afterId) break;
if (seen < o.batch) break; // 最后一批
}
this.logger.log(`${o.label}完成:看过 ${seenTotal} 条,写入 ${written} 条`);
return written;
}
}
import { Prisma } from '@prisma/client';
import { labelCaseSql, AGE_YEARS_SQL } from './reason-temperature.sql';
/**
* `plan_reasons.potential_labels` 的**计算与刷新** —— 回填、夜间刷新、新写入三条路共用这一份。
*
* ═══ 为什么要预计算 ═══════════════════════════════════════════════
* 初选矩阵此前每次开页面都现推:`plan_reasons` → 展开 `evidence.factIds` →
* 回查 `patient_facts`(1567 万行 / 18 GB)。最忙的诊所一次摊三万多次随机查、372 MB I/O,
* **而这一切的唯一产出就是一个标签字符串**。
* 🔴 本地实测(15,884 条 plan):958 ms → 110 ms,磁盘读 119,861 → 0,24 格逐字未变。
*
* ═══ 🔴 标签规则只有一处 ═══════════════════════════════════════════
* 这里复用 `labelCaseSql`(矩阵此前用的同一个函数,由 `POTENTIAL_LABEL_RULES` 生成)——
* ⛔ **绝不在这里另写一份 CASE**:规则表是产品配置,抄第二份必然漂,
* 而漂了之后矩阵的数会**静默**不一样,没有任何报错(同 `enums` 里那条
* 「同名不同义是统计事故的标准配方」)。
*
* ═══ ⚠️ 年龄被冻结 ═══════════════════════════════════════════════
* 规则里有三条带年龄(K08>18 / K07 3~12 / K07 13~40)⇒ 标签不是纯函数。
* 实测受影响 5,551 人中「明天跨档 0 人、30 天内 16 人」(≈0.5 人/天)——
* 每晚刷一次误差可忽略,⛔ 但**不刷就会逐日漂且不报错**。
*/
/** 一次刷新的批量上限 —— ⚠️ 别一条 UPDATE 扫全表:31 万行会长时间持锁。 */
export const LABEL_REFRESH_BATCH = 5_000;
/**
* 计算并写回一批 `potential_labels`,**按 id 游标推进**。
*
* @param afterId 上一批处理到的最大 id(首批传 null)
* @param onlyMissing true = 只补 `IS NULL` 的(回填 / 补漏网);
* false = 连已算过的一起重算(夜间刷新,因为年龄会变)
* @returns 本批**看过**的行(`seen`)与实际写了几行(`written`)、本批最大 id(`maxId`)
*
* 🔴 **必须带游标** —— 只写 `ORDER BY id LIMIT n` 的话:
* · onlyMissing 那条路碰巧能走完(写完就不再是 NULL,下一批自然换人);
* · 而**全量刷新会原地打转** —— 每次都取同一批,永远推不到第二批。
* ⚠️ 更阴的是它不会报错,只是任务每晚白跑。同类「闸门永远追不上」的坑,
* 留痕清理那里刚踩过一次(那次是拿 `updatedAt` 当判据)。
*
* ⚠️ 全程 `LEFT JOIN` 到 fact —— 推不出标签的依据也要写 `'{}'`,
* ⛔ 不能只更新有标签的那些:否则它们永远停在 NULL,被每一轮重复捞出来。
*/
export function refreshLabelsSql(opts: {
afterId: string | null;
onlyMissing: boolean;
batch?: number;
}): Prisma.Sql {
const limit = opts.batch ?? LABEL_REFRESH_BATCH;
const gate = opts.onlyMissing ? Prisma.sql`AND pr.potential_labels IS NULL` : Prisma.empty;
const cursor = opts.afterId
? Prisma.sql`AND pr.id > ${opts.afterId}::uuid`
: Prisma.empty;
return Prisma.sql`
WITH target AS (
SELECT pr.id, fp.patient_id
FROM plan_reasons pr
JOIN followup_plans fp ON fp.id = pr.plan_id
WHERE fp.superseded_at IS NULL
${gate}
${cursor}
ORDER BY pr.id
LIMIT ${limit}
),
computed AS (
SELECT t.id,
COALESCE(
array_agg(DISTINCT lab.lbl) FILTER (WHERE lab.lbl IS NOT NULL),
'{}'::text[]
) AS labels
FROM target t
JOIN patients p ON p.id = t.patient_id
JOIN plan_reasons pr2 ON pr2.id = t.id
LEFT JOIN LATERAL jsonb_array_elements_text(pr2.evidence->'factIds') fid ON TRUE
LEFT JOIN patient_facts f
ON f.id = fid::uuid
AND f.status = 'active'
AND COALESCE(f.occurred_at, f.planned_for) IS NOT NULL
LEFT JOIN LATERAL (SELECT ${labelCaseSql('f', AGE_YEARS_SQL)} AS lbl) lab ON TRUE
GROUP BY t.id
),
upd AS (
UPDATE plan_reasons pr
SET potential_labels = c.labels
FROM computed c
WHERE pr.id = c.id
AND (pr.potential_labels IS DISTINCT FROM c.labels)
RETURNING pr.id
)
-- ⚠️ seen 与 written 必须**分开报**:全量刷新时绝大多数行算出来跟原值一样、
-- 不会被写,此时 written=0 ⛔ 不代表做完了。推进游标看的是 seen / maxId。
SELECT (SELECT count(*)::int FROM target) AS seen,
(SELECT count(*)::int FROM upd) AS written,
(SELECT max(id::text) FROM target) AS "maxId"`;
}
/** 还有多少条没算过 —— 回填收尾与告警用。 */
export const countMissingLabelsSql = Prisma.sql`
SELECT count(*)::int AS n
FROM plan_reasons pr
JOIN followup_plans fp ON fp.id = pr.plan_id
WHERE fp.superseded_at IS NULL AND pr.potential_labels IS NULL`;
import { import {
BadRequestException,
Body, Body,
Controller, Controller,
Get, Get,
...@@ -18,6 +19,7 @@ import { ...@@ -18,6 +19,7 @@ import {
TenantScope, TenantScope,
TenantScopeContext, TenantScopeContext,
} from '../../common/decorators/tenant-scope.decorator'; } from '../../common/decorators/tenant-scope.decorator';
import { resolveClinicId } from '../../common/decorators/resolve-clinic-id';
import { import {
AssignPlanRequestDto, AssignPlanRequestDto,
ListPlansQueryDto, ListPlansQueryDto,
...@@ -33,6 +35,7 @@ import { ...@@ -33,6 +35,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 +45,7 @@ export class PlanController { ...@@ -42,6 +45,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 +95,35 @@ export class PlanController { ...@@ -91,6 +95,35 @@ 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:六档同一条绝对时间轴(90/180 天 + 自然年)。' +
'`anchor=diagnosis`(默认,医生最后一次写下该诊断)/ `anchor=last_visit`(患者末诊)—— ' +
'⚠️ 出确认单时必须把同一个值带进 criteria,否则看到的和分到的是两批人。',
})
matrix(
@TenantScope() scope: TenantScopeContext,
@Query('clinicId') clinicId?: string,
) {
/**
* 不传 clinicId 用登录人的第一个诊所 —— 批次不跨诊所,矩阵天然是诊所维度的。
* 🔴 传了就**必须在范围里**:矩阵是那家诊所的**患者量分布**,
* 2026-08-10 实测这里也是敞的(带别家 id 能拿到完整矩阵)。
*/
return this.cohorts.matrix(scope, resolveClinicId(scope, clinicId), new Date());
}
@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 +157,21 @@ export class PlanController { ...@@ -124,12 +157,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';
...@@ -8,16 +13,50 @@ import { PlanEngineService } from './engine/plan-engine.service'; ...@@ -8,16 +13,50 @@ 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';
import { RecallDebugController } from './recall-debug/recall-debug.controller'; import { RecallDebugController } from './recall-debug/recall-debug.controller';
import { PlanLabelService } from './plan-label.service';
import { RecallDebugService } from './recall-debug/recall-debug.service'; import { RecallDebugService } from './recall-debug/recall-debug.service';
/** /**
* v2.1:plan 一期只跑潜在治疗新链召回(treatment_initiation_recall)。 * v2.1:plan 一期只跑潜在治疗新链召回(treatment_initiation_recall)。
* 链已完成召回(aftercare)留后续,文件已删。 * 链已完成召回(aftercare)留后续,文件已删。
*/ */
/**
* 🔴 **`AssignmentExpiryScheduler` 已删(2026-08-19 产品定)** —— ⛔ 别加回来。
*
* 它做的事是「时限一到,把单子从客服手上收回召回池」。产品改判,理由是**分配的语义**:
* · **主管分配的意思是有始有终** —— 他决定了这批人交给谁,那这批人就该在他手上走完;
* 回池等于把这个决定作废,再重新分一次。
* · **容许客服短时超期** —— 没按时打完是常态不是异常,给缓冲让他继续跟进。
* · **减少客服之间的调度** —— 回池再分会让同一批患者在人之间来回换手,
* 而换手本身有成本(客户关系断掉、新接手的人要重新熟悉)。
* ⇒ **时限到了什么都不发生**:状态不变、归属不变,只是从此算「超期」,
* 超期由主管在工作台上看见并处理,⛔ 不由系统替他收单。
*
* ⚠️ ⛔ **别用测试服的落人分布来给这条决定"补证据"**(我试过,被驳回):
* 那是种子数据跑出来的批次,它的 `assign_strategy` 分布说明不了真实运营会怎样。
* 这条决定站在**语义**上,不站在概率上 —— 而语义不会因为换一批数据就变。
*
* ⚠️ 连带成立的两件事(⛔ 别当成 bug 去"修"):
* ① 「在手」只增不减 —— 那是**真的**还压在他手上,产品要看的就是整体负载;
* ② 「最忙的那位」会常亮 —— 它本来就是工作量预估不是异常告警(见 assignment-signals)。
* ⚠️ 单子回池仍有两条路,都是**人主动做的**:客服退回、主管撤销(限时 30 分钟)。
*
* 原实现与那 10 条用例在 git 里:`git show HEAD~1 -- apps/pac-service/src/modules/plan/assignment-expiry.scheduler.ts`
*/
@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,
...@@ -25,7 +64,9 @@ import { RecallDebugService } from './recall-debug/recall-debug.service'; ...@@ -25,7 +64,9 @@ import { RecallDebugService } from './recall-debug/recall-debug.service';
ChainComposerService, ChainComposerService,
TreatmentInitiationRecallScenario, TreatmentInitiationRecallScenario,
RecallDebugService, RecallDebugService,
PlanLabelService,
], ],
exports: [PlanService, ExecutionService, ExecutionCallbackService, PlanEngineService, ChainComposerService], // MCP 的主管工具直接用这两个 service(条件注册,见 mcp-server.factory)
exports: [PlanService, PlanLabelService, 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++;
......
This source diff could not be displayed because it is too large. You can view the blob instead.
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