Commit 1f20bae4 by luoqi

feat(recall): 话术自报家门去人名 + 关闭机会闭环 + 执行表字段清理

三件事,因为改动在 plan.service / execution.service / types 里交织,合成一次提交。

## 1. fix:话术自报家门串名(线上 bug)

现象:登录人是李倩,话术却写"我是本诊所的客服主管晋芳辰"。
根因:登录客服姓名被烤进 LLM 生成的正文,而 plan_scripts 的 UNIQUE 是 plan_id
     (一个 plan 一份话术、不分人),召回池又是共享的 —— 谁先打开触发生成,
     姓名就固化给后面所有人看。线上 44 条话术 100% 中招,是必然不是偶发。

解法:姓名不进 LLM,改「生成期占位 → 渲染期回填」
  - 新增 shared/agent-identity.ts 作单一源(占位符 + resolveScriptAgent + renderAgentIdentity)
  - 稳健/标准/深度三档 prompt 与 format.md 统一输出 【回访客服】(沿用 v8 立的
    `{}`=填空 / `【】`=原样保留 的约定,归第二类)
  - 四个出口渲染回填:详情聚合、strict Zod 详情、同步重生成、SSE 流式
  - DraftPlanScriptInput 去掉 agent 字段

副作用(正向):input_hash 不再含人名 → 同一 plan 不同客服共享 AI 缓存;
工单转派后名字自动跟着换,不用重生成。

三档实跑验证模型都保留了占位符;浏览器验证同一份缓存对两个登录人显示各自姓名。

️ 存量 44 条仍是旧正文,需部署后逐条重生成(dist/cli/ai-gen-script.cli.js)。

## 2. refactor:删 plan_executions 两个死字段

  - invalid_reason  从没被写过(pac-web 零引用),读回路径也没带它
  - abandon_other   写得进没人读,且信息恒冗余 —— 放弃原因是固定复选框无自由文本,
                    该列只可能取到字面量"其他",而 abandon_reasons 已含 'other'

生产 0 行受影响(执行总数 2,两列全空)。

## 3. feat:宿主回访模式的「关闭机会」闭环

瑞尔侧回访在宿主系统做,PAC 只提供关闭入口做闭环。**刻意不引入新概念**:
不加 closed outcome、不加 close_reasons 列、不加 plan 状态 —— 关闭就是
outcome='abandoned' + abandon_reasons,跟 PAC 自带表单同一条路、同一列。
分叉只在展示层(ABANDON_REASON_META.shownIn,两个面板各渲染各的子集)。

  - AbandonReason 扩成两种模式的并集;近义项不合并(两模式按 host 配置二选一、
    运行时不共存,同一 host 只出现自己那套 key,统计不会碎)
  - 关闭弹窗改多选无上限;「其他」说明写 notes,不另立列
  - 原因级抑制窗:各原因 suppressDays 取 max(每个原因独立成立,取最严的);
    未配的沿用 outcome 默认 60d,行为跟改造前一致
  - 「识别不准确」不再双写 recall_feedback(该表留给拇指控件那条路)
  - outcome-form 的中文硬编码数组 + 中译映射表删除,key/label 改为同源
    (那张表里「已转介他人→treated_elsewhere」是语义错位)

## 文档

新增「取数说明」(pac-docs)—— 给 DW 团队自助查生产 PG:召回池筛选条件、
关闭操作落表、数据范围过滤(品牌/诊所/时间)、字段字典、常用 SQL。

291 tests passed;service + web 双端 typecheck 干净。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 2e31eeb7
Pipeline #3422 failed in 0 seconds
---
title: 取数说明
description: 瑞尔召回数据自助取数 —— 涉及哪些表、召回池怎么筛、关闭操作落在哪、数据范围怎么过滤。
icon: Database
---
直连生产 PG 捞召回数据用。只讲数据库这一层。
## 零、范围
库 `pac`,建议只读账号。瑞尔的数据固定在:
```sql
host_id = '902b9698-0b38-4521-95a2-6e56f8d9efbc'
tenant_id = 'ruier-grp'
```
时间列都是 `timestamptz`,**存 UTC**,转北京时间用 `col at time zone 'Asia/Shanghai'`。
---
## 一、表关系
```
followup_plans 召回工单(一行 = 一个患者的一次召回任务)
├─ plan_reasons 1:N 召回理由(命中了哪些临床信号)
├─ plan_executions 1:N 执行记录(客服每次操作一行,关闭也在这)
└─ patients N:1 患者主数据
└─ patient_profiles 1:1 合规标记(勿打扰 / 已故)
```
⚠️ `followup_plans` 是**版本化**的:患者数据变化会重算生成新版工单,旧版留档并打上
`superseded_at`。**查当前态一律加 `superseded_at is null`**,漏了会重复计数。
---
## 二、数据范围怎么过滤
三个维度,按需组合:
### 品牌 — `patients.source_unit`
| 值 | 患者数 | 说明 |
|---|---|---|
| `瑞尔` | 349,385 | **PAC 业务实际服务的范围** |
| `瑞泰` | 57,707 | 历史带入的少量数据,分析时建议排除 |
```sql
join patients pt on pt.id = p.patient_id and pt.source_unit = '瑞尔'
```
### 诊所 — `target_clinic_id` / `executor_clinic_id`
值就是**你们侧的诊所 id**(32 位 hex),直接对得上。两列语义不同:
| 列 | 在哪张表 | 含义 |
|---|---|---|
| `target_clinic_id` | `followup_plans` | 工单**归属**诊所(该患者该由谁召回) |
| `executor_clinic_id` | `plan_executions` | 实际**执行**诊所(代办场景两者会不一致) |
```sql
-- 按归属诊所(注意带上 NULL)
and (p.target_clinic_id in ('1f765bce...', '...') or p.target_clinic_id is null)
```
⚠️ `target_clinic_id` 为 `NULL` 表示**集团统一池**(不属于任何诊所),用 `in (...)` 会把它漏掉。
### 时间
```sql
and p.created_at >= '2026-07-01' -- 工单生成时间
and e.created_at >= '2026-07-01' -- 执行/关闭发生时间
```
---
## 三、召回池怎么筛
「召回池」= 工作台待认领列表,当前约 23.5 万条。四个条件的交集:
```sql
select p.id, p.priority_score, p.target_clinic_id, p.created_at,
pt.medical_record_number, pt.name
from followup_plans p
join patients pt on pt.id = p.patient_id
where p.host_id = '902b9698-0b38-4521-95a2-6e56f8d9efbc'
and p.tenant_id = 'ruier-grp'
and pt.source_unit = '瑞尔'
and p.superseded_at is null -- ① 只看当前版本
and p.status = 'active' -- ② 未结案
and p.assignee_user_id is null -- ③ 未被认领
and (p.snoozed_until is null
or p.snoozed_until <= now()) -- ④ 抑制期已过
order by p.priority_score desc;
```
| 条件 | 说明 |
|---|---|
| ② `status='active'` | 未结案。认领后变 `assigned`,结案后 `completed` / `abandoned` |
| ③ `assignee_user_id is null` | 池 = 无主工单;有主的进"我的",出池 |
| ④ `snoozed_until` | 冷静期。未到期的不露面,到点自动回池 |
---
## 四、关闭操作怎么捞
回访在你们系统里做,PAC 只提供「关闭机会」做闭环。一次关闭在一个事务里写两处:
| 表 | 写入 |
|---|---|
| `plan_executions` 新增一行 | `outcome='abandoned'`、`channel='other'`、`abandon_reasons` = 勾选的原因(多选)、`notes` = 「其他」的说明 |
| `followup_plans` 更新 | `status='abandoned'`、`snoozed_until` = 冷静期、`contact_attempts` +1 |
**捞关闭记录的判据:`outcome='abandoned' and channel='other'`。**
### 关闭原因字典(`abandon_reasons`,多选)
| 值 | 中文 | 冷静期 |
|---|---|---|
| `no_intent` | 客户无意愿 | 60 天 |
| `inaccurate` | 机会识别不准确 | 14 天 |
| `competitor` | 选择竞品机构 | 永久 |
| `price` | 价格因素 | 60 天 |
| `treated` | 已完成治疗 | 14 天 |
| `unreachable` | 无法联系客户 | 30 天 |
| `other` | 其他(说明写在 `notes`) | 60 天 |
多选时冷静期取**最大值**,落到 `followup_plans.snoozed_until`。"永久" = 36500 天(哨兵值,不是 `NULL`)。
### 常用查询
**关闭原因分布**
```sql
select unnest(e.abandon_reasons) as reason, count(*) as n
from plan_executions e
where e.host_id = '902b9698-0b38-4521-95a2-6e56f8d9efbc'
and e.outcome = 'abandoned' and e.channel = 'other'
and e.created_at >= '2026-07-01'
group by 1 order by n desc;
```
**按诊所 × 客服的关闭量**
```sql
select e.executor_clinic_id, e.operator_user_id, count(*) as 关闭数,
count(*) filter (where 'inaccurate' = any(e.abandon_reasons)) as 识别不准
from plan_executions e
where e.host_id = '902b9698-0b38-4521-95a2-6e56f8d9efbc'
and e.outcome = 'abandoned' and e.channel = 'other'
group by 1, 2 order by 关闭数 desc;
```
**关闭明细**
```sql
select e.created_at at time zone 'Asia/Shanghai' as 关闭时间,
pt.medical_record_number as 病历号,
p.target_clinic_id as 归属诊所, e.executor_clinic_id as 执行诊所,
e.operator_user_id as 操作人,
e.abandon_reasons as 关闭原因, e.notes as 说明,
r.sub_key as 病种, r.reason as 召回理由
from plan_executions e
join followup_plans p on p.id = e.plan_id
join patients pt on pt.id = p.patient_id
left join lateral (
select sub_key, reason from plan_reasons
where plan_id = p.id order by priority_score desc limit 1
) r on true
where e.host_id = '902b9698-0b38-4521-95a2-6e56f8d9efbc'
and e.outcome = 'abandoned' and e.channel = 'other'
order by e.created_at desc;
```
**「识别不准确」反查**(哪类病种被判得最不准)
```sql
select r.scenario, r.sub_key, count(*) as 被判不准
from plan_executions e
join plan_reasons r on r.plan_id = e.plan_id
where e.host_id = '902b9698-0b38-4521-95a2-6e56f8d9efbc'
and 'inaccurate' = any(e.abandon_reasons)
group by 1, 2 order by 被判不准 desc;
```
---
## 五、字段说明
### followup_plans
| 字段 | 说明 |
|---|---|
| `patient_id` | → `patients.id` |
| `target_clinic_id` | 归属诊所;`NULL` = 集团统一池 |
| `priority_score` | 优先级分 = 该工单下 `plan_reasons.priority_score` 的 **MAX** |
| `status` | `active` 待处理 / `assigned` 处理中 / `completed` 完成 / `abandoned` 放弃(含关闭)/ `superseded` 已被新版取代 |
| `contact_attempts` | 累计触达次数(患者级) |
| `snoozed_until` | 冷静期截止,未到期不进池 |
| `assignee_user_id` / `assigned_at` | 经办人 / 认领时间 |
| `recall_feedback` | 客服对"这条召回准不准"的反馈(`up`/`down`),独立于关闭操作 |
| `superseded_at` | 被新版取代的时间;当前版为 `NULL` |
| `created_at` | 工单生成时间 |
### plan_reasons
一个工单 N 条理由,每条一个独立临床缺口。
| 字段 | 说明 |
|---|---|
| `scenario` | `treatment_aftercare_recall` 疗效保障 / `treatment_initiation_recall` 启治召回 |
| `sub_key` | 子规则,如 `missing_tooth` / `caries_no_filling` / `impacted_tooth`。**按病种切用这个** |
| `reason` | 自然语言的"为什么召回"(含牙位、日期、医嘱) |
| `signals` | JSON,结构化信号(牙位 / 诊断码 / 触发日期) |
### plan_executions
不可变,只增不改。
| 字段 | 说明 |
|---|---|
| `executor_clinic_id` | 执行诊所 |
| `operator_user_id` | 操作人(你们侧 user id) |
| `channel` | `phone` / `wecom` / `sms` / `other`。**关闭固定 `other`** |
| `outcome` | 结果。关闭恒为 `abandoned` |
| `abandon_reasons` | `text[]`,关闭原因,见上方字典 |
| `notes` | 说明 / 通话纪要 |
| `created_at` | 提交时间 |
### patients / patient_profiles
| 字段 | 说明 |
|---|---|
| `medical_record_number` | **病历号**,跟你们系统对号用这个 |
| `external_id` | PAC 内部 id,对不上业务系统,别用 |
| `source_unit` | 品牌,`瑞尔` / `瑞泰` |
| `patient_profiles.do_not_contact` | 勿打扰;为 true 则不再生成新工单 |
| `patient_profiles.deceased` | 已故;同为硬过滤 |
---
## 六、容易踩的坑
| 坑 | 正确做法 |
|---|---|
| 忘了 `superseded_at is null` | 查当前态一律加,否则同一患者多版工单重复计数 |
| 用 `target_clinic_id in (...)` | 会漏掉集团池,要 `or target_clinic_id is null` |
| 拿 `followup_plans` 数关闭量 | 关闭数查 `plan_executions`,一个工单可能有多次操作 |
| 用 `external_id` 对号 | 用 `medical_record_number` |
| 按 `created_at` 直接当北京时间 | 差 8 小时,要 `at time zone 'Asia/Shanghai'` |
| 把 36500 天后的 `snoozed_until` 当脏数据 | 那是"永久"哨兵值 |
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
"monitoring", "monitoring",
"troubleshooting", "troubleshooting",
"---参考---", "---参考---",
"data-extraction",
"api", "api",
"error-codes", "error-codes",
"event-enums", "event-enums",
......
-- plan_executions 字段整理(2026-07-22)
--
-- 删两列,都是「只进不出」的死字段:
--
-- invalid_reason — **从没被写过**。pac-web 全项目零引用(SubmitExecutionBody 里就没这字段),
-- 后端有写入分支但入参恒 undefined;读回路径 plan.service 的 serializer 也没带它。
-- 配套的 outcome `marked_invalid` 已是 hiddenInForm 历史值,新建表单选不到。
--
-- abandon_other — 写得进、没人读,且**信息恒冗余**。放弃原因 UI 是 6 个固定复选框、
-- 无自由文本输入,5 个映射到 enum,只有「其他」落空 → 该列只可能取到
-- 字面量"其他",而 abandon_reasons 里此时已含 'other'。
-- 补充说明本就该走 notes(通话纪要)。
--
-- abandon_reasons 暂留(入口窄:仅 outcome='refused';宿主回访模式下表单隐藏)。
-- 后续若做「结案原因收敛到 close_reason」,连同它一起处理。
--
-- 数据核对:生产 0 行受影响(执行总数 2,两列全空);本地 2 行 abandon_other='其他',
-- 对应 abandon_reasons 均已含 'other',删除无信息损失。
ALTER TABLE "plan_executions" DROP COLUMN "invalid_reason";
ALTER TABLE "plan_executions" DROP COLUMN "abandon_other";
...@@ -1206,18 +1206,18 @@ model PlanExecution { ...@@ -1206,18 +1206,18 @@ model PlanExecution {
/// pending_info (已联系,需进一步确认信息后回复) 产品收集 /// pending_info (已联系,需进一步确认信息后回复) 产品收集
/// sms_sent (电话未接,已发短信跟进) 产品收集 /// sms_sent (电话未接,已发短信跟进) 产品收集
/// proactive_sms (主动短信沟通,未发起电话) 产品收集 /// proactive_sms (主动短信沟通,未发起电话) 产品收集
/// marked_invalid (标记为无效, invalid_reason) 产品收集 /// marked_invalid (标记为无效;历史值,新建表单已不展示) 产品收集
outcome String outcome String
/// 本次电话纪要(客服手填的自由文本) /// 本次电话纪要(客服手填的自由文本)
notes String? @db.Text notes String? @db.Text
/// 产品收集:outcome=marked_invalid 时的无效原因(自由文本 / 应用层 enum 视情况) /// 放弃原因(多选, AbandonReason 应用层 enum)—— **两种回访模式共用这一列**:
invalidReason String? @map("invalid_reason") /// · 未配宿主回访 PAC 自带通话结果表单(outcome='refused')
/// · 已配宿主回访 详情页「关闭机会」弹窗(outcome='abandoned',面向宿主的一套文案)
/// 放弃原因(多选, AbandonReason 应用层 enum) /// PAC 不为「关闭」另立概念//状态,分叉只在展示层(ABANDON_REASON_META.shownIn)
/// :原配套的 `abandon_other`("其他"自由文本)已删 —— UI 是固定复选框、无文本输入,
/// 它只可能取到字面量"其他",信息已被 abandonReasons 'other' 覆盖(2026-07)
abandonReasons String[] @map("abandon_reasons") abandonReasons String[] @map("abandon_reasons")
/// 放弃原因 - "其他" 自由文本
abandonOther String? @map("abandon_other")
/// outcome=scheduled_next ,下次回访时间 /// outcome=scheduled_next ,下次回访时间
scheduledNextAt DateTime? @map("scheduled_next_at") @db.Timestamptz(3) scheduledNextAt DateTime? @map("scheduled_next_at") @db.Timestamptz(3)
......
/**
* agent-identity —— 「自报家门」客服身份的**单一源**(稳健 / 标准 / 深度三档共用)。
*
* ## 为什么要占位符(2026-07 线上 bug)
* 原设计把登录客服的姓名直接烤进 LLM 生成的话术正文(v9:"我是X诊所的客服主管晋芳辰")。
* 但 `plan_scripts` 的 UNIQUE 是 **plan_id** —— 一个 plan 只有一份话术、不分人;而召回池是**共享**的,
* 未认领工单谁都能打开。结果:晋芳辰先打开触发生成 → 姓名固化进正文 → 李倩后打开读到同一份缓存,
* 话术里自报家门成了别人的名字。线上 44 条话术 100% 中招,是必然而非偶发。
*
* ## 解法:姓名不进 LLM,改「生成期占位 → 渲染期回填」
* - **生成期**:prompt 只给占位符 {@link AGENT_IDENTITY_PLACEHOLDER},LLM 原样输出,落库正文不含人名。
* - **渲染期**:内容出服务前按**当前登录人**替换({@link renderAgentIdentity})。谁看都对。
*
* 顺带两个好处:
* 1. `input_hash` 不再含人名 → 同一 plan 不同客服命中同一 AI 缓存(原来每人各烧一次 LLM)。
* 2. 工单回收/转派后话术里的名字自动跟着换,不用重新生成。
*
* ## 占位符约定(沿用 v8 立的规矩)
* `{xxx}` = prompt 内**填空**(LLM 替换掉,不进输出)
* `【xxx】` = **原样保留到输出**;其消费者有两类 ——
* · 客服手填:【时间段1】【时间段2】【具体预约时间】(诊所无排班接口,时间不写死)
* · 系统回填:【回访客服】(本文件)—— 对 LLM 的契约完全一致:原样输出、别自己编名字。
*/
/** 自报家门占位符 —— LLM 原样输出,渲染期按当前登录客服回填 */
export const AGENT_IDENTITY_PLACEHOLDER = '【回访客服】';
/** 无姓名时的兜底称谓(宿主未下发 users 字典 / 匿名调用)—— 不编名字,只报岗位 */
export const AGENT_IDENTITY_FALLBACK = '客服';
/** 自报家门用的客服身份:岗位称呼 + 姓名(姓名可空) */
export interface ScriptAgentIdentity {
/** 宿主 users 字典里的姓名(如"李倩");字典缺失 → null,回填退回通用"客服" */
name: string | null;
/** 患者侧岗位称呼(员工/管理员→客服;主管→客服主管) */
roleTitle: string;
}
/** JWT role → 患者侧岗位称呼。宿主角色与患者称呼是两件事,别把 admin 报成"管理员"。 */
const ROLE_TITLE_ZH: Record<string, string> = {
staff: '客服',
leader: '客服主管',
admin: '客服',
};
/**
* 登录用户 → 自报家门身份。
* 取最小形状(不依赖 AuthenticatedUser 具名类型)便于 ai 层复用 + 单测。
*/
export function resolveScriptAgent(user: {
sub: string;
role: string;
dictionary?: { users?: Record<string, string> };
}): ScriptAgentIdentity {
return {
name: user.dictionary?.users?.[user.sub]?.trim() || null,
roleTitle: ROLE_TITLE_ZH[user.role] ?? AGENT_IDENTITY_FALLBACK,
};
}
/** 身份 → 落到话术里的那串字(有名字报"客服主管李倩";没名字只报"客服",绝不编) */
export function agentIdentityText(agent: ScriptAgentIdentity | null | undefined): string {
if (!agent?.name) return AGENT_IDENTITY_FALLBACK;
return `${agent.roleTitle}${agent.name}`;
}
/**
* 渲染期回填 —— 话术正文里的 {@link AGENT_IDENTITY_PLACEHOLDER} 换成当前登录客服。
* agent 缺省(内部调用 / 无登录态)→ 换成通用"客服",**不留占位符给患者看到**。
*/
export function renderAgentIdentity<T extends string | null | undefined>(
content: T,
agent?: ScriptAgentIdentity | null,
): T {
if (!content || !content.includes(AGENT_IDENTITY_PLACEHOLDER)) return content;
return content.split(AGENT_IDENTITY_PLACEHOLDER).join(agentIdentityText(agent)) as T;
}
...@@ -7,12 +7,14 @@ ...@@ -7,12 +7,14 @@
* *
* ⭐ 占位约定(标准/深度=去模板档):事实用**朴素中文标签**直接给(称呼/本次问题/牙位…), * ⭐ 占位约定(标准/深度=去模板档):事实用**朴素中文标签**直接给(称呼/本次问题/牙位…),
* LLM 自然取用,**不用 `{}` 替换占位**(那是稳健模板档的填空机制,去模板档用了反而诱导模板化)。 * LLM 自然取用,**不用 `{}` 替换占位**(那是稳健模板档的填空机制,去模板档用了反而诱导模板化)。
* 仅保留 `【时间段1/2】【具体预约时间】` = 客服手填的**输出占位**(诊所无排班接口,时间不写死)。 * 仅保留 `【】` 输出占位:`【时间段1/2】【具体预约时间】`(客服手填,诊所无排班接口)、
* `【回访客服】`(系统渲染期按登录人回填,见 agent-identity.ts)。两类对 LLM 的契约一样:原样输出。
*/ */
import type { DraftPlanScriptInput, ScriptMedicalRecord } from './input.types'; import type { DraftPlanScriptInput, ScriptMedicalRecord } from './input.types';
import { smartDateDisplay, toothFriendly } from './script-facts'; import { smartDateDisplay, toothFriendly } from './script-facts';
import { resolveDiseaseLabel } from './disease-knowledge'; import { resolveDiseaseLabel } from './disease-knowledge';
import { deidentifyDoctor } from './pii'; import { deidentifyDoctor } from './pii';
import { AGENT_IDENTITY_PLACEHOLDER } from './agent-identity';
export function buildRichFactBlock(input: DraftPlanScriptInput): string { export function buildRichFactBlock(input: DraftPlanScriptInput): string {
const { patient, clinicName, plan, clinicalContext } = input; const { patient, clinicName, plan, clinicalContext } = input;
...@@ -36,9 +38,9 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string { ...@@ -36,9 +38,9 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string {
const toothText = top?.toothPositions?.length ? toothFriendly(top.toothPositions) : null; const toothText = top?.toothPositions?.length ? toothFriendly(top.toothPositions) : null;
const goal = plan.goal?.trim() || null; const goal = plan.goal?.trim() || null;
const selfIntro = input.agent?.name // 自报家门:姓名**不进 prompt** —— 给占位符,LLM 原样输出,读详情时按当前登录客服回填。
? `我是${clinicName}${input.agent.roleTitle}${input.agent.name}` // (话术缓存是 per-plan 的、召回池共享,烤进人名会让后开的客服读到别人的名字。见 agent-identity.ts)
: `我是${clinicName}的客服`; const selfIntro = `我是${clinicName}${AGENT_IDENTITY_PLACEHOLDER}`;
const guardianHint = patient.guardian const guardianHint = patient.guardian
? `本次电话打给${patient.guardian.relationshipLabel}(称呼见上方"称呼"),沟通对象是家长,患者是孩子,话术里称孩子为"宝宝"` ? `本次电话打给${patient.guardian.relationshipLabel}(称呼见上方"称呼"),沟通对象是家长,患者是孩子,话术里称孩子为"宝宝"`
: null; : null;
...@@ -71,7 +73,7 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string { ...@@ -71,7 +73,7 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string {
## 开场用 ## 开场用
- 称呼:${salutation} - 称呼:${salutation}
- 自报家门:${selfIntro} - 自报家门:${selfIntro}(${AGENT_IDENTITY_PLACEHOLDER} 原样保留,别替换成具体姓名,也别自己编)
- 最近一次就诊(用于开场"自从…来过";诊断日见病历、可能更早):${lastVisitDisplay} - 最近一次就诊(用于开场"自从…来过";诊断日见病历、可能更早):${lastVisitDisplay}
- 那次主诉:${chiefComplaint ?? '无记录'} - 那次主诉:${chiefComplaint ?? '无记录'}
- 诊断医生:${doctorSurname}医生${guardianHint ? `\n- 触达说明:${guardianHint}` : ''} - 诊断医生:${doctorSurname}医生${guardianHint ? `\n- 触达说明:${guardianHint}` : ''}
......
...@@ -32,10 +32,10 @@ export interface ScriptContext { ...@@ -32,10 +32,10 @@ export interface ScriptContext {
/** 诊所名(给 LLM 用作"我是X诊所的客服顾问",避免编造"XX口腔") */ /** 诊所名(给 LLM 用作"我是X诊所的客服顾问",避免编造"XX口腔") */
clinicName: string; clinicName: string;
/** ⭐ 当前回访客服(自报家门用:岗位角色 + 姓名,如 客服主管/李莉) /** ⚠️ 这里**没有** agent(回访客服)字段,是刻意的 —— 姓名不进 LLM 输入
* 来源:登录用户 JWT(role→患者侧称呼 + dictionary.users[sub]→姓名) * 话术缓存是 per-plan(UNIQUE plan_id)、召回池又共享,烤进人名会让后开的客服读到别人的名字
* name 为空 → prompt 退回通用"客服",不编名字。 */ * 改成「生成期占位 `【回访客服】` → 渲染期按登录人回填」,见 shared/agent-identity.ts。
agent?: { name: string | null; roleTitle: string }; * 副作用(正向):input_hash 不含人名 → 同 plan 不同客服命中同一 AI 缓存。 */
/** Plan 信息 */ /** Plan 信息 */
plan: { plan: {
......
...@@ -67,7 +67,7 @@ export class DeepPlanCall implements AiCall<ScriptContext, DeepPlanZ> { ...@@ -67,7 +67,7 @@ export class DeepPlanCall implements AiCall<ScriptContext, DeepPlanZ> {
export class DeepWriteCall implements AiCall<DeepWriteInput, DeepWriteZ> { export class DeepWriteCall implements AiCall<DeepWriteInput, DeepWriteZ> {
readonly kind = 'script' as const; readonly kind = 'script' as const;
readonly callKey = 'draft_plan_script_write'; readonly callKey = 'draft_plan_script_write';
readonly promptVersion = 'draft_plan_script@2026-06-16-deep-write-v15'; // v15: 去掉钦定段式(给自由度);v14: 禁内部代码/术语(K08等)说大白话 readonly promptVersion = 'draft_plan_script@2026-06-16-deep-write-v16'; // v16: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v15: 去掉钦定段式(给自由度);v14: 禁内部代码/术语(K08等)说大白话
readonly defaultModelId = 'deepseek-v4-flash'; readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DeepWriteSchema; readonly outputSchema = DeepWriteSchema;
constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {} constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {}
......
...@@ -7,11 +7,12 @@ ...@@ -7,11 +7,12 @@
- **把后果说清、有分寸**:不处理的后果要**客观说明**让患者理解严重性,但**不夸大、不吓唬**(别下“会掉光”“很危险”式吓人结论)、**不推销促单报价**——为患者着想地讲,不是吓他/催他。 - **把后果说清、有分寸**:不处理的后果要**客观说明**让患者理解严重性,但**不夸大、不吓唬**(别下“会掉光”“很危险”式吓人结论)、**不推销促单报价**——为患者着想地讲,不是吓他/催他。
- **层层递进**:顺着大纲推进,段与段承上启下,别并列罗列或跳跃重复。 - **层层递进**:顺着大纲推进,段与段承上启下,别并列罗列或跳跃重复。
- **按大纲讲透**:段怎么分由大纲定,你只管把大纲让你单独开段的内容(如某颗牙、复查)展开讲深讲透;以本次聚焦项为主线,顺带项点到为止。 - **按大纲讲透**:段怎么分由大纲定,你只管把大纲让你单独开段的内容(如某颗牙、复查)展开讲深讲透;以本次聚焦项为主线,顺带项点到为止。
- **事实朴素取用**:患者信息以朴素中文标签直接给(称呼/本次问题/牙位/诊断医生/最近一次就诊…),自然用进话里;不写占位符、不留标签字样。 - **事实朴素取用**:患者信息以朴素中文标签直接给(称呼/本次问题/牙位/诊断医生/最近一次就诊…),自然用进话里;除下方明确要求原样保留的 `【】` 外,不写占位符、不留标签字样。
# 这处按原样写,不要改 # 这处按原样写,不要改
1. 引导预约句式(严格用此句式):「X医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」——“X医生”用给定的诊断医生姓替换;两个【时间段】原样保留给客服手填。 1. 引导预约句式(严格用此句式):「X医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」——“X医生”用给定的诊断医生姓替换;两个【时间段】原样保留给客服手填。
2. 时间一律占位:约成功用「我们【具体预约时间】见」;`【时间段1】【时间段2】【具体预约时间】` 原样保留,严禁替换成“周三上午”等具体时间、严禁加粗、严禁“已为您约好”式承诺。 2. 时间一律占位:约成功用「我们【具体预约时间】见」;`【时间段1】【时间段2】【具体预约时间】` 原样保留,严禁替换成“周三上午”等具体时间、严禁加粗、严禁“已为您约好”式承诺。
3. 自报家门:用给定的“自报家门”整串,其中 `【回访客服】` 原样保留(系统按登录客服回填)——别替换成具体姓名,也别自己编一个。
# 开场/结束段 # 开场/结束段
- 开场段:把这几件事自然说到、顺序你定——用患者称呼称呼并确认对方方便、自报家门、以诊断医生名义体现关怀、用“最近一次就诊”问近况。 - 开场段:把这几件事自然说到、顺序你定——用患者称呼称呼并确认对方方便、自报家门、以诊断医生名义体现关怀、用“最近一次就诊”问近况。
......
...@@ -3,6 +3,7 @@ import { smartDateDisplay, toothFriendly } from '../../shared/script-facts'; ...@@ -3,6 +3,7 @@ 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'; import { renderTreatmentPlan, buildPersonaGuide } from '../../shared/fact-block';
import { AGENT_IDENTITY_PLACEHOLDER } from '../../shared/agent-identity';
/** /**
* Prompt 版本管理约定: * Prompt 版本管理约定:
...@@ -105,10 +106,9 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string ...@@ -105,10 +106,9 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
// ≤18 / 年龄未知 → 禁拍片 belt(青少年走成人模板时,成人模板含"拍片"句,这里硬提醒删) // ≤18 / 年龄未知 → 禁拍片 belt(青少年走成人模板时,成人模板含"拍片"句,这里硬提醒删)
const noXray = patient.age == null || patient.age <= 18; const noXray = patient.age == null || patient.age <= 18;
// 自报家门:有登录客服名 → "我是X诊所的{岗位}{姓名}"(小王=简称,非全名,直接用);无名 → 通用"客服" // 自报家门:姓名**不进 prompt** —— 给 【回访客服】 占位符,LLM 原样输出,读详情时按当前登录客服回填。
const selfIntro = input.agent?.name // (话术缓存是 per-plan、召回池共享,烤进人名会让后开的客服读到别人的名字。见 agent-identity.ts)
? `我是${clinicName}${input.agent.roleTitle}${input.agent.name}` const selfIntro = `我是${clinicName}${AGENT_IDENTITY_PLACEHOLDER}`;
: `我是${clinicName}的客服`;
// 画像精简版(⚠安全 + 定语气):稳健档只灌"护栏"类,不灌切入点(避免诱导加推销),保持填空纪律。 // 画像精简版(⚠安全 + 定语气):稳健档只灌"护栏"类,不灌切入点(避免诱导加推销),保持填空纪律。
const persona = buildPersonaGuide(input.personaHighlights ?? [], 'essential'); const persona = buildPersonaGuide(input.personaHighlights ?? [], 'essential');
...@@ -118,7 +118,7 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string ...@@ -118,7 +118,7 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
## 开场用 ## 开场用
- {智能称呼}:${salutation} - {智能称呼}:${salutation}
- {自报家门}:${selfIntro} - {自报家门}:${selfIntro}(${AGENT_IDENTITY_PLACEHOLDER} 原样保留,别替换成具体姓名,也别自己编)
- {智能时间显示}(最近一次就诊,用于开场"自从…来过"):${lastVisitDisplay} - {智能时间显示}(最近一次就诊,用于开场"自从…来过"):${lastVisitDisplay}
- 那次主诉(该问题诊断那次,可能更早):${chiefComplaint ?? '无记录'} - 那次主诉(该问题诊断那次,可能更早):${chiefComplaint ?? '无记录'}
- {诊断医生}:${doctorSurname}医生${guardianHint ? `\n- 触达说明:${guardianHint}` : ''} - {诊断医生}:${doctorSurname}医生${guardianHint ? `\n- 触达说明:${guardianHint}` : ''}
......
...@@ -5,10 +5,10 @@ ...@@ -5,10 +5,10 @@
# 占位符约定(两种,别搞混) # 占位符约定(两种,别搞混)
- `{xxx}` = **替换**:用"本次回访患者信息"里给的同名值填(如 {智能称呼}{应治未治项}{牙位}{诊断医生}{风险要点}{复查时长});输出里不能再出现 `{}` - `{xxx}` = **替换**:用"本次回访患者信息"里给的同名值填(如 {智能称呼}{应治未治项}{牙位}{诊断医生}{风险要点}{复查时长});输出里不能再出现 `{}`
- {智能称呼} / {诊断医生} 已是"姓+敬称",直接用;{牙位} 已是俗称(上门牙),没给就不提牙位。 - {智能称呼} / {诊断医生} 已是"姓+敬称",直接用;{牙位} 已是俗称(上门牙),没给就不提牙位。
- `【xxx】` = **原样保留**(客服手填):【时间段1】【时间段2】【具体预约时间】;结束语分支标签【预约成功】【预约不成功】照常输出。 - `【xxx】` = **原样保留**:【时间段1】【时间段2】【具体预约时间】(客服手填)、【回访客服】(系统按登录客服回填,别替换成人名、别自己编);结束语分支标签【预约成功】【预约不成功】照常输出。
# 填空要点 # 填空要点
- 开场顺序固定:先用 {智能称呼} 称呼并确认对方 → 再 {自报家门} → 以 {诊断医生} 名义体现关怀 → 用 {智能时间显示} 问近况。 - 开场顺序固定:先用 {智能称呼} 称呼并确认对方 → 再 {自报家门}(内含【回访客服】,整串照抄) → 以 {诊断医生} 名义体现关怀 → 用 {智能时间显示} 问近况。
- 健康提醒从 {风险要点} 里挑、检查说明用 {复查时长} 原文;给定值直接用,不改写、不重算。 - 健康提醒从 {风险要点} 里挑、检查说明用 {复查时长} 原文;给定值直接用,不改写、不重算。
- 引导预约严格用:「{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」 - 引导预约严格用:「{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」
- 告知应治未治、复查建议分成短句。 - 告知应治未治、复查建议分成短句。
......
...@@ -65,7 +65,7 @@ export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanSc ...@@ -65,7 +65,7 @@ export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanSc
* 改 system/prompt 文本 → bump 字母;改 schema → bump 日期。 * 改 system/prompt 文本 → bump 字母;改 schema → bump 日期。
*/ */
const DRAFT_PLAN_SCRIPT_PROMPT_VERSION = const DRAFT_PLAN_SCRIPT_PROMPT_VERSION =
'draft_plan_script@2026-06-08-4module-v27'; // v27: schema 去硬长度约束(.min/.max → describe 软引导,修 qwen too_small 必失败);v26: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档,不诱导推销);v24: 治疗计划补 plannedTreatments(treatment_record planned 结构化;原只读常空的 emr.treatment_plan → 话术缺治疗计划);v23: common.md + 人群共性 SKILL 去 brace(原 {应治未治项}/{诊断医生}/{智能称呼} 改朴素措辞;稳健自身句位模板的 {} 不变,填空机制照常,行为基本不变);v22: 新老客改'熟络度'(recency为主+次数为辅,去二分标签,交LLM);v21: 开场日期改锚【最近一次就诊】(原误用诊断日,患者后来又来过会错位)+'来过之后'/告知'之前那次'区分;v20: schema describe 收口(去与模板矛盾的开场顺序/'下周'/负面例,只留段用途+关键约束)+ prompt/兜底 软化'下周'+ 修陈旧注释;v19: stable format.md 精简(去 common/机器闸重复、自查砍到高风险3条)+ 成人模板优化(去重/换标题/软化结束语);v18: base-common 精简合并去重 + 软化治疗方案口径(可点名/不报价不定细化/落点复查);v17: 目录重组(shared/+tiers/stable/)— base 拆 common+format、人群拆共性知识+稳健句位、病种文案归 stable phrasing、安全单一源 safety-rules、composer tier-aware;修开场顺序冲突。v16: 儿童模板复查段修复(删写死"3个月常规涂氟检查"→对齐本次{应治未治项}+用{复查时长},涂氟降级顺带);child SKILL 1.4.0;v15: user prompt 加"医生那次交代"(医嘱/建议/治疗计划,来自聚焦病历,仅引用不演绎);medicalRecord 补 recommendations;v14: user prompt 加 {牙位}(FDI→俗称)+ 本次目标(plan.goal)+ ≤18 禁拍片 belt;占位收口 {牙位}(删 【缺失牙位】);adult/child 模板带牙位;v13: 撤 token,人名去名留称呼(徐女士/韩医生 直接给,非 token);开场白先称呼确认对方再自报家门;v12: user prompt 人名脱敏(称呼/诊断医生/客服 用 token,生成后回填;监护人全名不进 prompt);v11: 统一通话称呼(年龄+性别+监护人,修"9岁张先生");监护人触达提示;医生标签 最后一次就诊→诊断医生;v10: 病种知识走 disease-knowledge 单一访问源(subKey 优先+文本兜底),修 颌骨囊肿 拿不到风险/优势的 bug;v9: 自报家门用登录客服 岗位+姓名(agent);v8: 占位符统一({}=替换、【】=原样保留);v7: 清除 user prompt 污染;v6: 清 system 污染;v5: 还原原模板 'draft_plan_script@2026-06-08-4module-v28'; // v28: 自报家门去人名 → 占位【回访客服】(生成期占位/渲染期按登录人回填;修「话术缓存 per-plan + 召回池共享 → 后开的客服读到别人名字」线上 bug);input 去 agent 字段,同 plan 不同客服共享 AI 缓存。v27: schema 去硬长度约束(.min/.max → describe 软引导,修 qwen too_small 必失败);v26: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档,不诱导推销);v24: 治疗计划补 plannedTreatments(treatment_record planned 结构化;原只读常空的 emr.treatment_plan → 话术缺治疗计划);v23: common.md + 人群共性 SKILL 去 brace(原 {应治未治项}/{诊断医生}/{智能称呼} 改朴素措辞;稳健自身句位模板的 {} 不变,填空机制照常,行为基本不变);v22: 新老客改'熟络度'(recency为主+次数为辅,去二分标签,交LLM);v21: 开场日期改锚【最近一次就诊】(原误用诊断日,患者后来又来过会错位)+'来过之后'/告知'之前那次'区分;v20: schema describe 收口(去与模板矛盾的开场顺序/'下周'/负面例,只留段用途+关键约束)+ prompt/兜底 软化'下周'+ 修陈旧注释;v19: stable format.md 精简(去 common/机器闸重复、自查砍到高风险3条)+ 成人模板优化(去重/换标题/软化结束语);v18: base-common 精简合并去重 + 软化治疗方案口径(可点名/不报价不定细化/落点复查);v17: 目录重组(shared/+tiers/stable/)— base 拆 common+format、人群拆共性知识+稳健句位、病种文案归 stable phrasing、安全单一源 safety-rules、composer tier-aware;修开场顺序冲突。v16: 儿童模板复查段修复(删写死"3个月常规涂氟检查"→对齐本次{应治未治项}+用{复查时长},涂氟降级顺带);child SKILL 1.4.0;v15: user prompt 加"医生那次交代"(医嘱/建议/治疗计划,来自聚焦病历,仅引用不演绎);medicalRecord 补 recommendations;v14: user prompt 加 {牙位}(FDI→俗称)+ 本次目标(plan.goal)+ ≤18 禁拍片 belt;占位收口 {牙位}(删 【缺失牙位】);adult/child 模板带牙位;v13: 撤 token,人名去名留称呼(徐女士/韩医生 直接给,非 token);开场白先称呼确认对方再自报家门;v12: user prompt 人名脱敏(称呼/诊断医生/客服 用 token,生成后回填;监护人全名不进 prompt);v11: 统一通话称呼(年龄+性别+监护人,修"9岁张先生");监护人触达提示;医生标签 最后一次就诊→诊断医生;v10: 病种知识走 disease-knowledge 单一访问源(subKey 优先+文本兜底),修 颌骨囊肿 拿不到风险/优势的 bug;v9: 自报家门用登录客服 岗位+姓名(agent);v8: 占位符统一({}=替换、【】=原样保留);v7: 清除 user prompt 污染;v6: 清 system 污染;v5: 还原原模板
@Injectable() @Injectable()
export class DraftPlanScriptCall export class DraftPlanScriptCall
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
- **事实朴素取用**:患者信息以朴素中文标签直接给(称呼/本次问题/牙位/诊断医生/最近一次就诊…),自然用进话里;不写占位符、不留标签字样。 - **事实朴素取用**:患者信息以朴素中文标签直接给(称呼/本次问题/牙位/诊断医生/最近一次就诊…),自然用进话里;不写占位符、不留标签字样。
# 4 段通常覆盖(内容指引,可合并衔接、标题自起) # 4 段通常覆盖(内容指引,可合并衔接、标题自起)
- 打招呼、确认对方方便、自报家门,以诊断医生名义体现关怀,用“最近一次就诊”问近况。 - 打招呼、确认对方方便、自报家门(用给定的“自报家门”整串,含 `【回访客服】` 照抄不改),以诊断医生名义体现关怀,用“最近一次就诊”问近况。
- 带出本次这一个问题(有牙位就带上):以“诊断医生上次检查发现”的口吻,结合病历讲清“隐患”+“趁早处理的好处”,温和提醒、非吓唬非推销。 - 带出本次这一个问题(有牙位就带上):以“诊断医生上次检查发现”的口吻,结合病历讲清“隐患”+“趁早处理的好处”,温和提醒、非吓唬非推销。
- 邀约来院复查本次问题(关怀口吻、不推销治疗、别写死分钟数)。 - 邀约来院复查本次问题(关怀口吻、不推销治疗、别写死分钟数)。
- 简短有温度的收尾:约成功就确认时间道别,没约成温和留口子(别写死“下周”这种绝对时间)。 - 简短有温度的收尾:约成功就确认时间道别,没约成温和留口子(别写死“下周”这种绝对时间)。
...@@ -17,4 +17,4 @@ ...@@ -17,4 +17,4 @@
2. 时间一律占位:约成功用「我们【具体预约时间】见」;`【时间段1】【时间段2】【具体预约时间】` 原样保留,严禁替换成“周三上午”等具体时间、严禁加粗、严禁“已为您约好”式承诺。 2. 时间一律占位:约成功用「我们【具体预约时间】见」;`【时间段1】【时间段2】【具体预约时间】` 原样保留,严禁替换成“周三上午”等具体时间、严禁加粗、严禁“已为您约好”式承诺。
# 占位 # 占位
只有 `【时间段1】【时间段2】【具体预约时间】` 需原样保留在话术里(客服手填);其余都是朴素事实,直接自然取用,输出里不要出现别的占位或标签。 只有这些需原样保留在话术里:`【时间段1】【时间段2】【具体预约时间】`(客服手填)、`【回访客服】`(自报家门,系统按登录客服回填 —— 别替换成具体姓名,也别自己编一个)。其余都是朴素事实,直接自然取用,输出里不要出现别的占位或标签。
...@@ -22,7 +22,7 @@ import { type DeepDraft, draftOutputToDeep } from '../deep/types'; ...@@ -22,7 +22,7 @@ import { type DeepDraft, draftOutputToDeep } from '../deep/types';
* *
* callKey 仍用 'draft_plan_script'(同一逻辑调用),档位差异落 promptVersion → eval 可按版本切档对比。 * callKey 仍用 'draft_plan_script'(同一逻辑调用),档位差异落 promptVersion → eval 可按版本切档对比。
*/ */
const STANDARD_PROMPT_VERSION = 'draft_plan_script@2026-06-08-standard-v14'; // v14: schema 去硬长度/段数约束(.min/.max/.length → describe 软引导);v13: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档);v11: 病历补全(治疗计划=plannedTreatments 结构化 + 一般情况/处置/诊断说明,对齐页面 emr-soap);v10: format.md 去污染(删 tier 名/去模板对比/见 common.md 等解释性 meta,纯指令);流式输出(段数组 partial 边出边渲染);v9: 真去模板 — 4 固定角色字段 → 自由 sections[];v8: 去 {} 替换占位;v7: format 瘦身 + opening 放开 + closing 软化 const STANDARD_PROMPT_VERSION = 'draft_plan_script@2026-06-08-standard-v15'; // v15: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v14: schema 去硬长度/段数约束(.min/.max/.length → describe 软引导);v13: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档);v11: 病历补全(治疗计划=plannedTreatments 结构化 + 一般情况/处置/诊断说明,对齐页面 emr-soap);v10: format.md 去污染(删 tier 名/去模板对比/见 common.md 等解释性 meta,纯指令);流式输出(段数组 partial 边出边渲染);v9: 真去模板 — 4 固定角色字段 → 自由 sections[];v8: 去 {} 替换占位;v7: format 瘦身 + opening 放开 + closing 软化
@Injectable() @Injectable()
export class StandardScriptCall implements AiCall<DraftPlanScriptInput, DeepDraft> { export class StandardScriptCall implements AiCall<DraftPlanScriptInput, DeepDraft> {
......
...@@ -91,8 +91,6 @@ export interface PlanScriptGenerateOptions { ...@@ -91,8 +91,6 @@ export interface PlanScriptGenerateOptions {
modelIdOverride?: string; modelIdOverride?: string;
/** 测试模式:不写 PlanScript 表(只看输出) */ /** 测试模式:不写 PlanScript 表(只看输出) */
dryRun?: boolean; dryRun?: boolean;
/** ⭐ 当前回访客服(自报家门用):{name 姓名, roleTitle 患者侧岗位称呼} */
agent?: { name: string | null; roleTitle: string };
/** ⭐ 投入档(默认 stable)。stable=4段模板填空 / standard=4段去模板自由编排。深度档后续。 */ /** ⭐ 投入档(默认 stable)。stable=4段模板填空 / standard=4段去模板自由编排。深度档后续。 */
tier?: ScriptTier; tier?: ScriptTier;
/** 取消信号(SSE 客户端断连 → controller abort)→ 透传到 runner 取消在途 LLM(尤其深度 3-4 步) */ /** 取消信号(SSE 客户端断连 → controller abort)→ 透传到 runner 取消在途 LLM(尤其深度 3-4 步) */
...@@ -145,7 +143,7 @@ export class PlanScriptOrchestrator { ...@@ -145,7 +143,7 @@ export class PlanScriptOrchestrator {
): Promise<PlanScriptGenerateResult> { ): Promise<PlanScriptGenerateResult> {
// ─── 1. 装配 input(纯 DB 读,不 pull) ─── // ─── 1. 装配 input(纯 DB 读,不 pull) ───
const { plan, patient, persona, facts, guardian, returnVisits } = await this.loadPlanContext(planId); const { plan, patient, persona, facts, guardian, returnVisits } = await this.loadPlanContext(planId);
const input = this.buildCallInput({ plan, patient, persona, facts, guardian, returnVisits, agent: options.agent }); const input = this.buildCallInput({ plan, patient, persona, facts, guardian, returnVisits });
const patientNameMasked = input.patient.salutation; const patientNameMasked = input.patient.salutation;
const runCtx: AiCallContext = { const runCtx: AiCallContext = {
hostId: plan.hostId, hostId: plan.hostId,
...@@ -235,7 +233,7 @@ export class PlanScriptOrchestrator { ...@@ -235,7 +233,7 @@ export class PlanScriptOrchestrator {
plan = ctx.plan; plan = ctx.plan;
patient = ctx.patient; patient = ctx.patient;
persona = ctx.persona; persona = ctx.persona;
input = this.buildCallInput({ ...ctx, agent: options.agent }); input = this.buildCallInput({ ...ctx });
} catch (err) { } catch (err) {
yield { type: 'error', message: err instanceof Error ? err.message : String(err) }; yield { type: 'error', message: err instanceof Error ? err.message : String(err) };
return; return;
...@@ -502,7 +500,6 @@ export class PlanScriptOrchestrator { ...@@ -502,7 +500,6 @@ export class PlanScriptOrchestrator {
patient: PatientRow; patient: PatientRow;
persona: PersonaWithFeatures | null; persona: PersonaWithFeatures | null;
facts: FactRow[]; facts: FactRow[];
agent?: { name: string | null; roleTitle: string };
/** 监护人(未成年触达对象)— loadPlanContext 查好传入;成人/无 → null */ /** 监护人(未成年触达对象)— loadPlanContext 查好传入;成人/无 → null */
guardian?: { relationship: string; relationshipLabel: string; name: string | null } | null; guardian?: { relationship: string; relationshipLabel: string; name: string | null } | null;
/** 历史联系(诊所回访记录,最近 5 条)— loadPlanContext 查好传入 */ /** 历史联系(诊所回访记录,最近 5 条)— loadPlanContext 查好传入 */
...@@ -515,7 +512,7 @@ export class PlanScriptOrchestrator { ...@@ -515,7 +512,7 @@ export class PlanScriptOrchestrator {
result: string | null; result: string | null;
}>; }>;
}): DraftPlanScriptInput { }): DraftPlanScriptInput {
const { plan, patient, persona, facts, agent, guardian, returnVisits } = args; const { plan, patient, persona, facts, guardian, returnVisits } = args;
const patientAge = patient.birthDate ? calcAge(patient.birthDate) : null; const patientAge = patient.birthDate ? calcAge(patient.birthDate) : null;
// ⭐ "最近一次就诊"= 患者实际到店的最新一次。口径要全:encounter/emr 之外, // ⭐ "最近一次就诊"= 患者实际到店的最新一次。口径要全:encounter/emr 之外,
...@@ -604,8 +601,6 @@ export class PlanScriptOrchestrator { ...@@ -604,8 +601,6 @@ export class PlanScriptOrchestrator {
// 临时:hardcoded jvs-dw 诊所字典(TODO #56 接 host 字典或新建 clinics 表) // 临时:hardcoded jvs-dw 诊所字典(TODO #56 接 host 字典或新建 clinics 表)
// ⚠️ 直接吐 UUID 进 prompt 会让 LLM 编造"XX 客服中心",必须翻译成中文名 // ⚠️ 直接吐 UUID 进 prompt 会让 LLM 编造"XX 客服中心",必须翻译成中文名
clinicName: resolveClinicName(plan.targetClinicId), clinicName: resolveClinicName(plan.targetClinicId),
// 当前回访客服(自报家门);无登录身份 → undefined,prompt 退回通用"客服"
agent,
plan: { plan: {
primaryScenarioLabel: plan.reasons[0] primaryScenarioLabel: plan.reasons[0]
? planScenarioLabel(plan.reasons[0].scenario) ? planScenarioLabel(plan.reasons[0].scenario)
......
...@@ -7,6 +7,8 @@ import { PrismaService } from '../../prisma/prisma.service'; ...@@ -7,6 +7,8 @@ import { PrismaService } from '../../prisma/prisma.service';
import { ChainComposerService } from '../plan/engine/chain-composer.service'; import { ChainComposerService } from '../plan/engine/chain-composer.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { toothSet } from '../sync/pipeline/parsers/tooth-position.util'; import { toothSet } from '../sync/pipeline/parsers/tooth-position.util';
import { renderAgentIdentity } from '../ai/calls/draft-plan-script/shared/agent-identity';
import type { ScriptAgentIdentity } from '../ai/calls/draft-plan-script/shared/agent-identity';
/** /**
* PlanAggregateService — Plan 详情聚合服务。 * PlanAggregateService — Plan 详情聚合服务。
...@@ -27,7 +29,12 @@ export class PlanAggregateService { ...@@ -27,7 +29,12 @@ export class PlanAggregateService {
* 按 planId 查 → 反查 patient → 同 assemble。 * 按 planId 查 → 反查 patient → 同 assemble。
* (app)/plans/[planId] 路由 + PlansAggregateController 的入口。 * (app)/plans/[planId] 路由 + PlansAggregateController 的入口。
*/ */
async getPlanDetailByPlanId(scope: TenantScopeContext, planId: string) { async getPlanDetailByPlanId(
scope: TenantScopeContext,
planId: string,
/** 当前登录客服 —— 话术里的【回访客服】占位按它回填;缺省 → 通用"客服" */
agent?: ScriptAgentIdentity,
) {
const plan = await this.prisma.followupPlan.findFirst({ const plan = await this.prisma.followupPlan.findFirst({
// 集团模型:by-id 也按品牌圈(plan→patient.sourceUnit) // 集团模型:by-id 也按品牌圈(plan→patient.sourceUnit)
where: { where: {
...@@ -86,7 +93,7 @@ export class PlanAggregateService { ...@@ -86,7 +93,7 @@ export class PlanAggregateService {
} }
} }
const assembled = await this.assemble(scope, patient, plan); const assembled = await this.assemble(scope, patient, plan, agent);
return { ...assembled, currentPlanId }; return { ...assembled, currentPlanId };
} }
...@@ -104,12 +111,18 @@ export class PlanAggregateService { ...@@ -104,12 +111,18 @@ export class PlanAggregateService {
}; };
}>, }>,
plan: Prisma.FollowupPlanGetPayload<{ include: { reasons: true } }> | null, plan: Prisma.FollowupPlanGetPayload<{ include: { reasons: true } }> | null,
agent?: ScriptAgentIdentity,
) { ) {
const persona = await this.loadCurrentPersona(patient.id); const persona = await this.loadCurrentPersona(patient.id);
const facts = await this.loadActiveFacts(scope, patient.id); const facts = await this.loadActiveFacts(scope, patient.id);
// W4:话术从 DB 加载(LLM 流式生成完会 upsert 到 plan_scripts) // W4:话术从 DB 加载(LLM 流式生成完会 upsert 到 plan_scripts)
// 没生成过 → script=null,前端走 mock 兜底 // 没生成过 → script=null,前端走 mock 兜底
const script = plan ? await this.loadPlanScript(plan.id) : null; const scriptRow = plan ? await this.loadPlanScript(plan.id) : null;
// ⭐ 落库正文里的自报家门是占位符【回访客服】,读出来按**当前登录人**回填 —— 缓存 per-plan、
// 召回池共享,烤进人名会让后开的客服看到别人的名字(见 agent-identity.ts)
const script = scriptRow
? { ...scriptRow, content: renderAgentIdentity(scriptRow.content, agent) }
: null;
// v2.1:chain-composer 读独立 diagnosis_record / treatment_record / recommendation_record, // v2.1:chain-composer 读独立 diagnosis_record / treatment_record / recommendation_record,
// 传所有 facts,内部按 type 分组(encounter_record 已只元数据) // 传所有 facts,内部按 type 分组(encounter_record 已只元数据)
const chains = this.chainComposer.compose(facts); const chains = this.chainComposer.compose(facts);
......
...@@ -22,11 +22,17 @@ import { ...@@ -22,11 +22,17 @@ import {
} from '../../common/decorators/current-user.decorator'; } from '../../common/decorators/current-user.decorator';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { PlanScriptOrchestrator } from '../ai/orchestrators/plan-script.orchestrator'; import { PlanScriptOrchestrator } from '../ai/orchestrators/plan-script.orchestrator';
import type { PlanScriptStreamEvent } from '../ai/orchestrators/plan-script.orchestrator';
import { PlanSummaryOrchestrator } from '../ai/orchestrators/plan-summary.orchestrator'; import { PlanSummaryOrchestrator } from '../ai/orchestrators/plan-summary.orchestrator';
import { RecallSummaryOrchestrator } from '../ai/orchestrators/recall-summary.orchestrator'; import { RecallSummaryOrchestrator } from '../ai/orchestrators/recall-summary.orchestrator';
import { PersonaSummaryOrchestrator } from '../ai/orchestrators/persona-summary.orchestrator'; import { PersonaSummaryOrchestrator } from '../ai/orchestrators/persona-summary.orchestrator';
import { RecallBriefOrchestrator } from '../ai/orchestrators/recall-brief.orchestrator'; import { RecallBriefOrchestrator } from '../ai/orchestrators/recall-brief.orchestrator';
import { PlanAggregateService } from './plan-aggregate.service'; import { PlanAggregateService } from './plan-aggregate.service';
import {
renderAgentIdentity,
resolveScriptAgent,
} from '../ai/calls/draft-plan-script/shared/agent-identity';
import type { ScriptAgentIdentity } from '../ai/calls/draft-plan-script/shared/agent-identity';
const ScriptFeedbackSchema = z.object({ const ScriptFeedbackSchema = z.object({
feedback: z.enum(['up', 'down']), feedback: z.enum(['up', 'down']),
...@@ -71,8 +77,13 @@ export class PlansAggregateController { ...@@ -71,8 +77,13 @@ export class PlansAggregateController {
summary: 'Plan 详情聚合(plan+patient+profile+persona+chains+facts)', summary: 'Plan 详情聚合(plan+patient+profile+persona+chains+facts)',
description: '前端 (app)/plans/[planId] 用本端点;PlanController.detail 走 strict Zod 给外部。', description: '前端 (app)/plans/[planId] 用本端点;PlanController.detail 走 strict Zod 给外部。',
}) })
getFull(@TenantScope() scope: TenantScopeContext, @Param('id') planId: string) { getFull(
return this.demo.getPlanDetailByPlanId(scope, planId); @TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Param('id') planId: string,
) {
// ⭐ 话术里的【回访客服】按**当前登录人**回填(缓存是 per-plan、池子共享,不能烤人名)
return this.demo.getPlanDetailByPlanId(scope, planId, resolveScriptAgent(user));
} }
// 回访历史「一句话摘要」get-or-generate(详情页回访历史卡进卡片即调: // 回访历史「一句话摘要」get-or-generate(详情页回访历史卡进卡片即调:
...@@ -122,8 +133,8 @@ export class PlansAggregateController { ...@@ -122,8 +133,8 @@ export class PlansAggregateController {
modelIdOverride: model, modelIdOverride: model,
tier: parseScriptTier(tier), tier: parseScriptTier(tier),
dryRun: body?.dryRun ?? false, dryRun: body?.dryRun ?? false,
agent: this.resolveAgent(user),
}); });
const agent = resolveScriptAgent(user);
return { return {
planId: result.planId, planId: result.planId,
planScriptId: result.planScriptId, planScriptId: result.planScriptId,
...@@ -131,7 +142,8 @@ export class PlansAggregateController { ...@@ -131,7 +142,8 @@ export class PlansAggregateController {
source: result.source, source: result.source,
cacheHit: result.cacheHit, cacheHit: result.cacheHit,
costYuan: result.costYuan, costYuan: result.costYuan,
content: result.content, // 落库的是占位符正文,返回给调用方的是回填后的(structured 保持原始,便于调试看 LLM 真实输出)
content: renderAgentIdentity(result.content, agent),
structured: result.structured, structured: result.structured,
}; };
} }
...@@ -158,23 +170,17 @@ export class PlansAggregateController { ...@@ -158,23 +170,17 @@ export class PlansAggregateController {
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
await this.pipeSse(res, (signal) => await this.pipeSse(res, (signal) =>
this.planScript.generateStream(planId, { renderAgentInStream(
modelIdOverride: model, this.planScript.generateStream(planId, {
tier: parseScriptTier(tier), modelIdOverride: model,
agent: this.resolveAgent(user), tier: parseScriptTier(tier),
signal, signal,
}), }),
resolveScriptAgent(user),
),
); );
} }
/** 当前登录客服 → 自报家门用的 {岗位称呼, 姓名}。
* roleTitle:患者侧称呼(员工/管理员→客服;主管→客服主管);name:dictionary.users[sub](无则 null)。 */
private resolveAgent(user: AuthenticatedUser): { name: string | null; roleTitle: string } {
const name = user.dictionary?.users?.[user.sub]?.trim() || null;
const roleTitle = ({ staff: '客服', leader: '客服主管', admin: '客服' } as Record<string, string>)[user.role] ?? '客服';
return { name, roleTitle };
}
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
// Summary — 流式重生成(1 次产 3 段:onePage / medicalRecord / treatmentChain) // Summary — 流式重生成(1 次产 3 段:onePage / medicalRecord / treatmentChain)
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
...@@ -296,6 +302,32 @@ export class PlansAggregateController { ...@@ -296,6 +302,32 @@ export class PlansAggregateController {
} }
} }
/**
* SSE 流的【回访客服】回填 —— 落库存占位符,推给前端的每一帧都换成当前登录客服。
*
* 只动 `partial` / `done` 的展示字段(sections.markdown / content);`structured` 保持 LLM 原始输出
* 不动(调试时要能看到模型到底吐了什么)。partial 是**全量快照**(非增量 delta),所以逐帧替换安全,
* 不存在占位符被切在两帧之间的问题。
*/
async function* renderAgentInStream(
stream: AsyncIterable<PlanScriptStreamEvent>,
agent: ScriptAgentIdentity,
): AsyncGenerator<PlanScriptStreamEvent, void, void> {
for await (const evt of stream) {
if (evt.type === 'partial' || evt.type === 'done') {
const sections = evt.sections.map((s) => ({
...s,
markdown: renderAgentIdentity(s.markdown, agent),
}));
yield evt.type === 'done'
? { ...evt, sections, content: renderAgentIdentity(evt.content, agent) }
: { ...evt, sections };
continue;
}
yield evt;
}
}
/** 投入档 query 解析:只接受白名单,非法/缺省 → undefined(orchestrator 默认 stable) */ /** 投入档 query 解析:只接受白名单,非法/缺省 → undefined(orchestrator 默认 stable) */
function parseScriptTier(tier: string | undefined): 'stable' | 'standard' | 'deep' | undefined { function parseScriptTier(tier: string | undefined): 'stable' | 'standard' | 'deep' | undefined {
return tier === 'stable' || tier === 'standard' || tier === 'deep' ? tier : undefined; return tier === 'stable' || tier === 'standard' || tier === 'deep' ? tier : undefined;
......
...@@ -52,11 +52,7 @@ export interface SubmitExecutionInput { ...@@ -52,11 +52,7 @@ export interface SubmitExecutionInput {
outcome: string; outcome: string;
notes?: string; notes?: string;
abandonReasons?: string[]; abandonReasons?: string[];
abandonOther?: string;
scheduledNextAt?: string; scheduledNextAt?: string;
invalidReason?: string;
/** 关闭机会来源(宿主回访模式)—— 据 CLOSE_REASON_META 覆写抑制窗 */
closeReason?: string;
/** 显式覆盖执行诊所;不传则用 plan.targetClinicId 或 scope 第一个 clinicId */ /** 显式覆盖执行诊所;不传则用 plan.targetClinicId 或 scope 第一个 clinicId */
executorClinicId?: string; executorClinicId?: string;
} }
...@@ -147,7 +143,7 @@ export class ExecutionService { ...@@ -147,7 +143,7 @@ export class ExecutionService {
breakerTripped, breakerTripped,
scheduledNextAt: input.scheduledNextAt, scheduledNextAt: input.scheduledNextAt,
now, now,
closeReason: input.closeReason, abandonReasons: input.abandonReasons,
}); });
// ─── 4. 事务:写 execution + 更新 plan ─── // ─── 4. 事务:写 execution + 更新 plan ───
...@@ -162,9 +158,7 @@ export class ExecutionService { ...@@ -162,9 +158,7 @@ export class ExecutionService {
channel: input.channel, channel: input.channel,
outcome: input.outcome, outcome: input.outcome,
notes: input.notes ?? null, notes: input.notes ?? null,
invalidReason: input.invalidReason ?? null,
abandonReasons: input.abandonReasons ?? [], abandonReasons: input.abandonReasons ?? [],
abandonOther: input.abandonOther ?? null,
scheduledNextAt: input.scheduledNextAt ? new Date(input.scheduledNextAt) : null, scheduledNextAt: input.scheduledNextAt ? new Date(input.scheduledNextAt) : null,
}, },
select: { id: true }, select: { id: true },
...@@ -247,9 +241,7 @@ export class ExecutionService { ...@@ -247,9 +241,7 @@ export class ExecutionService {
channel: e.channel, channel: e.channel,
outcome: e.outcome as ExecutionOutcome, outcome: e.outcome as ExecutionOutcome,
notes: e.notes, notes: e.notes,
invalidReason: e.invalidReason,
abandonReasons: e.abandonReasons, abandonReasons: e.abandonReasons,
abandonOther: e.abandonOther,
scheduledNextAt: e.scheduledNextAt?.toISOString() ?? null, scheduledNextAt: e.scheduledNextAt?.toISOString() ?? null,
createdAt: e.createdAt.toISOString(), createdAt: e.createdAt.toISOString(),
})); }));
......
...@@ -33,6 +33,7 @@ import { ...@@ -33,6 +33,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 { resolveScriptAgent } from '../ai/calls/draft-plan-script/shared/agent-identity';
@ApiTags('plan') @ApiTags('plan')
@ApiBearerAuth('accessToken') @ApiBearerAuth('accessToken')
...@@ -76,8 +77,13 @@ export class PlanController { ...@@ -76,8 +77,13 @@ export class PlanController {
@Get(':id') @Get(':id')
@ZodResponse({ status: 200, type: PlanDetailResponseDto }) @ZodResponse({ status: 200, type: PlanDetailResponseDto })
@RequirePermission(Permission.PLAN_VIEW_OWN) @RequirePermission(Permission.PLAN_VIEW_OWN)
detail(@TenantScope() scope: TenantScopeContext, @Param('id') id: string) { detail(
return this.plans.detail(scope, id); @TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Param('id') id: string,
) {
// 话术里的【回访客服】按当前登录人回填(见 agent-identity.ts)
return this.plans.detail(scope, id, resolveScriptAgent(user));
} }
@Post(':id/assign') @Post(':id/assign')
......
...@@ -26,6 +26,8 @@ import type { ...@@ -26,6 +26,8 @@ import type {
SubmitExecutionRequestDto, SubmitExecutionRequestDto,
} from './dto/plan.dto'; } from './dto/plan.dto';
import { ExecutionService } from './execution.service'; import { ExecutionService } from './execution.service';
import { renderAgentIdentity } from '../ai/calls/draft-plan-script/shared/agent-identity';
import type { ScriptAgentIdentity } from '../ai/calls/draft-plan-script/shared/agent-identity';
/** /**
* PlanService — Plan 维度 CRUD(list / detail / assign / recycle / recompute / submitExecution) * PlanService — Plan 维度 CRUD(list / detail / assign / recycle / recompute / submitExecution)
...@@ -368,7 +370,12 @@ export class PlanService { ...@@ -368,7 +370,12 @@ export class PlanService {
// detail // detail
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
async detail(scope: TenantScopeContext, planId: string): Promise<PlanDetailResponse> { async detail(
scope: TenantScopeContext,
planId: string,
/** 当前登录客服 —— 话术【回访客服】占位按它回填;缺省 → 通用"客服" */
agent?: ScriptAgentIdentity,
): Promise<PlanDetailResponse> {
const plan = await this.prisma.followupPlan.findFirst({ const plan = await this.prisma.followupPlan.findFirst({
// 集团模型:by-id 取 plan 也按品牌圈(plan→patient.sourceUnit);命中不到→下方 NotFound // 集团模型:by-id 取 plan 也按品牌圈(plan→patient.sourceUnit);命中不到→下方 NotFound
where: { where: {
...@@ -414,7 +421,10 @@ export class PlanService { ...@@ -414,7 +421,10 @@ export class PlanService {
plan: serializePlan(plan), plan: serializePlan(plan),
patient: serializePatient(patient), patient: serializePatient(patient),
persona: persona ? serializePersona(persona) : null, persona: persona ? serializePersona(persona) : null,
script: scriptRow ? serializeScript(scriptRow) : null, // 落库正文的自报家门是占位符,读出来按当前登录人回填(见 agent-identity.ts)
script: scriptRow
? serializeScript({ ...scriptRow, content: renderAgentIdentity(scriptRow.content, agent) })
: null,
summaries: summariesRows.map(serializeSummary), summaries: summariesRows.map(serializeSummary),
executions: executions.map(serializeExecution), executions: executions.map(serializeExecution),
evidence: computePlanEvidence(plan, persona), evidence: computePlanEvidence(plan, persona),
...@@ -704,7 +714,6 @@ function serializeExecution(e: PlanExecutionRow): import('@pac/types').PlanExecu ...@@ -704,7 +714,6 @@ function serializeExecution(e: PlanExecutionRow): import('@pac/types').PlanExecu
outcome: e.outcome as import('@pac/types').ExecutionOutcome, outcome: e.outcome as import('@pac/types').ExecutionOutcome,
notes: e.notes, notes: e.notes,
abandonReasons: e.abandonReasons, abandonReasons: e.abandonReasons,
abandonOther: e.abandonOther,
scheduledNextAt: e.scheduledNextAt?.toISOString() ?? null, scheduledNextAt: e.scheduledNextAt?.toISOString() ?? null,
createdAt: e.createdAt.toISOString(), createdAt: e.createdAt.toISOString(),
}; };
......
import { import {
CLOSE_REASON_META, ABANDON_REASON_META,
CloseReason, AbandonReason,
EXECUTION_OUTCOME_META, EXECUTION_OUTCOME_META,
ExecutionOutcome, ExecutionOutcome,
BREAKER_SUPPRESS_DAYS, BREAKER_SUPPRESS_DAYS,
...@@ -14,14 +14,19 @@ const DAY_MS = 86_400_000; ...@@ -14,14 +14,19 @@ const DAY_MS = 86_400_000;
* 优先级(高 → 低): * 优先级(高 → 低):
* 1. 熔断(breakerTripped):连续未接通累计达上限强制 abandoned → 固定短冷静期(30d), * 1. 熔断(breakerTripped):连续未接通累计达上限强制 abandoned → 固定短冷静期(30d),
* 不是"拒绝",换天再试 / 后续可接换渠道。优先于 outcome 自身策略。 * 不是"拒绝",换天再试 / 后续可接换渠道。优先于 outcome 自身策略。
* 2. 关闭机会覆写(closeReason → CLOSE_REASON_META.suppressDaysOverride): * 2. 放弃原因细分(abandonReasons → ABANDON_REASON_META.suppressDays):
* 弱语义关闭(识别不准/已治/其他)统一 14d 短档 —— 等 DW 同步 + 排除闸接手, * 同一个 `abandoned` 结果,原因不同该压的时长差很远 —— 「机会识别不准确」是**我们**的
* 不套 outcome 的重抑制(如 marked_invalid 自带永久,识别不准是 PAC 的错,不罚患者)。 * 信号有问题(14d,等 DW 同步 + 排除闸接手,不该罚患者);「迁居外地 / 投诉」则该永久。
* **多选取 max**:每个勾中的原因都独立成立,取最严的那个才不会把该压死的放出来。
* 没配 suppressDays 的原因(如「明确不需要治疗」)沿用下面 outcome 的默认档,一起参与 max。
* 3. outcome 固定抑制窗(EXECUTION_OUTCOME_META.suppressDays): * 3. outcome 固定抑制窗(EXECUTION_OUTCOME_META.suppressDays):
* 明确拒绝/近期不考虑=90d、成功转化/客服放弃=60d、外院/无效=永久。 * 明确拒绝/近期不考虑=90d、成功转化/客服放弃=60d、外院/无效=永久。
* 4. keep 类无固定抑制窗但带了 scheduledNextAt(约定回访/考虑中等)→ 到该时间前 snooze。 * 4. keep 类无固定抑制窗但带了 scheduledNextAt(约定回访/考虑中等)→ 到该时间前 snooze。
* 5. 其余(no_answer / sms_sent 等)→ null,不 snooze,维持现状很快再试。 * 5. 其余(no_answer / sms_sent 等)→ null,不 snooze,维持现状很快再试。
* *
* 注:宿主回访模式的「关闭机会」不是独立分支 —— 它就是 outcome='abandoned' + abandonReasons,
* 走上面第 2 条。PAC 侧不为它另立概念,分叉只在展示层(ABANDON_REASON_META.shownIn)。
*
* @returns snoozedUntil Date,或 null(不抑制) * @returns snoozedUntil Date,或 null(不抑制)
*/ */
export function resolveSnoozedUntil(params: { export function resolveSnoozedUntil(params: {
...@@ -29,24 +34,30 @@ export function resolveSnoozedUntil(params: { ...@@ -29,24 +34,30 @@ export function resolveSnoozedUntil(params: {
breakerTripped: boolean; breakerTripped: boolean;
scheduledNextAt?: string | null; scheduledNextAt?: string | null;
now: Date; now: Date;
closeReason?: string | null; /** 放弃原因(多选);仅 outcome 自带抑制窗时参与 —— 见上方第 2 条 */
abandonReasons?: string[] | null;
}): Date | null { }): Date | null {
const { outcome, breakerTripped, scheduledNextAt, now, closeReason } = params; const { outcome, breakerTripped, scheduledNextAt, now, abandonReasons } = params;
if (breakerTripped) { if (breakerTripped) {
return new Date(now.getTime() + BREAKER_SUPPRESS_DAYS * DAY_MS); return new Date(now.getTime() + BREAKER_SUPPRESS_DAYS * DAY_MS);
} }
const closeOverride = closeReason
? CLOSE_REASON_META[closeReason as CloseReason]?.suppressDaysOverride
: undefined;
if (closeOverride != null) {
return new Date(now.getTime() + closeOverride * DAY_MS);
}
const meta = EXECUTION_OUTCOME_META[outcome as ExecutionOutcome]; const meta = EXECUTION_OUTCOME_META[outcome as ExecutionOutcome];
if (meta?.suppressDays != null) { if (meta?.suppressDays != null) {
return new Date(now.getTime() + meta.suppressDays * DAY_MS); // 原因级细分:每项 effective = 自己的 suppressDays ?? outcome 默认档,多选取 max。
// ⚠️ max 的种子**不能**是 outcome 默认档 —— 否则比默认档短的原因(识别不准 14d)
// 单独勾选时会被 60d 盖掉,短档形同虚设。没有已知原因时才退回默认档。
const known = (abandonReasons ?? []).filter((r) => ABANDON_REASON_META[r as AbandonReason]);
const days = known.length
? Math.max(
...known.map(
(r) => ABANDON_REASON_META[r as AbandonReason].suppressDays ?? meta.suppressDays!,
),
)
: meta.suppressDays;
return new Date(now.getTime() + days * DAY_MS);
} }
if (scheduledNextAt) { if (scheduledNextAt) {
......
/**
* agent-identity —— 自报家门「生成期占位 / 渲染期回填」。
*
* 背景(2026-07 线上 bug):话术缓存 UNIQUE 是 plan_id、召回池又是共享的,
* 姓名烤进正文 → 晋芳辰先打开触发生成,李倩后打开读到同一份缓存,自报家门成了别人的名字。
* 这里锁住修复后的契约:正文只存占位符,姓名在读出来的那一刻按当前登录人填。
*/
import {
AGENT_IDENTITY_PLACEHOLDER,
agentIdentityText,
renderAgentIdentity,
resolveScriptAgent,
} from '../src/modules/ai/calls/draft-plan-script/shared/agent-identity';
describe('resolveScriptAgent | JWT → 自报家门身份', () => {
test('主管 → 客服主管 + 字典姓名', () => {
expect(
resolveScriptAgent({
sub: 'u1',
role: 'leader',
dictionary: { users: { u1: '李倩' } },
}),
).toEqual({ name: '李倩', roleTitle: '客服主管' });
});
test('员工 / 管理员 → 患者侧一律称"客服"(不把 admin 报成"管理员")', () => {
expect(resolveScriptAgent({ sub: 'u1', role: 'staff' }).roleTitle).toBe('客服');
expect(resolveScriptAgent({ sub: 'u1', role: 'admin' }).roleTitle).toBe('客服');
});
test('字典缺失 / 空白名 → name=null,不编名字', () => {
expect(resolveScriptAgent({ sub: 'u1', role: 'staff' }).name).toBeNull();
expect(
resolveScriptAgent({ sub: 'u1', role: 'staff', dictionary: { users: { u1: ' ' } } }).name,
).toBeNull();
});
test('未知角色 → 兜底"客服"', () => {
expect(resolveScriptAgent({ sub: 'u1', role: 'wat' }).roleTitle).toBe('客服');
});
});
describe('agentIdentityText | 身份 → 落到话术里的字', () => {
test('有名字:岗位 + 姓名', () => {
expect(agentIdentityText({ name: '李倩', roleTitle: '客服主管' })).toBe('客服主管李倩');
});
test('无名字:只报岗位,绝不编名字', () => {
expect(agentIdentityText({ name: null, roleTitle: '客服主管' })).toBe('客服');
expect(agentIdentityText(null)).toBe('客服');
expect(agentIdentityText(undefined)).toBe('客服');
});
});
describe('renderAgentIdentity | 渲染期回填', () => {
const content = `## 开场白\n您好张先生,我是本诊所的${AGENT_IDENTITY_PLACEHOLDER},耽误您两分钟。`;
test('按当前登录人回填', () => {
expect(renderAgentIdentity(content, { name: '李倩', roleTitle: '客服主管' })).toContain(
'我是本诊所的客服主管李倩',
);
});
test('⭐ 同一份缓存正文,两个客服各读各的名字(线上 bug 的回归锁)', () => {
const a = renderAgentIdentity(content, { name: '晋芳辰', roleTitle: '客服主管' });
const b = renderAgentIdentity(content, { name: '李倩', roleTitle: '客服主管' });
expect(a).toContain('晋芳辰');
expect(a).not.toContain('李倩');
expect(b).toContain('李倩');
expect(b).not.toContain('晋芳辰');
});
test('无登录身份 → 退回通用"客服";占位符不能漏给患者看到', () => {
const out = renderAgentIdentity(content);
expect(out).toContain('我是本诊所的客服');
expect(out).not.toContain(AGENT_IDENTITY_PLACEHOLDER);
});
test('多处出现全部替换', () => {
const twice = `${AGENT_IDENTITY_PLACEHOLDER}${AGENT_IDENTITY_PLACEHOLDER}`;
expect(renderAgentIdentity(twice, { name: '李倩', roleTitle: '客服' })).toBe('客服李倩 … 客服李倩');
});
test('不含占位符 / null 内容 → 原样返回', () => {
expect(renderAgentIdentity('没有占位符', { name: '李倩', roleTitle: '客服' })).toBe('没有占位符');
expect(renderAgentIdentity(null)).toBeNull();
});
test('不碰客服手填的时间占位', () => {
const s = `李医生【时间段1】和【时间段2】有空;我是${AGENT_IDENTITY_PLACEHOLDER}`;
const out = renderAgentIdentity(s, { name: '李倩', roleTitle: '客服主管' });
expect(out).toContain('【时间段1】');
expect(out).toContain('【时间段2】');
expect(out).toContain('我是客服主管李倩');
});
});
...@@ -2,8 +2,9 @@ import { ...@@ -2,8 +2,9 @@ import {
EXECUTION_OUTCOME_META, EXECUTION_OUTCOME_META,
SUPPRESS_PERMANENT_DAYS, SUPPRESS_PERMANENT_DAYS,
BREAKER_SUPPRESS_DAYS, BREAKER_SUPPRESS_DAYS,
CLOSE_REASON_META, ABANDON_REASON_META,
CLOSE_REASON_DEFAULT_SUPPRESS_DAYS, abandonReasonsFor,
WEAK_SIGNAL_SUPPRESS_DAYS,
} from '@pac/types'; } from '@pac/types';
import { resolveSnoozedUntil } from '../src/modules/plan/recall-suppression'; import { resolveSnoozedUntil } from '../src/modules/plan/recall-suppression';
import { import {
...@@ -197,78 +198,87 @@ describe('抑制策略单一真理源 EXECUTION_OUTCOME_META.suppressDays', () = ...@@ -197,78 +198,87 @@ describe('抑制策略单一真理源 EXECUTION_OUTCOME_META.suppressDays', () =
}); });
}); });
describe('关闭机会 closeReason 覆写(CLOSE_REASON_META.suppressDaysOverride)', () => { describe('放弃原因细分抑制窗(ABANDON_REASON_META.suppressDays,多选取 max)', () => {
const NOW = new Date('2026-06-01T00:00:00Z'); const NOW = new Date('2026-06-01T00:00:00Z');
const daysFromNow = (d: Date | null) => const daysFromNow = (d: Date | null) =>
d == null ? null : Math.round((d.getTime() - NOW.getTime()) / 86_400_000); d == null ? null : Math.round((d.getTime() - NOW.getTime()) / 86_400_000);
const snooze = (abandonReasons?: string[]) =>
daysFromNow(
resolveSnoozedUntil({ outcome: 'abandoned', breakerTripped: false, now: NOW, abandonReasons }),
);
test('识别不准确 → marked_invalid 自带永久被覆写成 14d 默认档', () => { test('没勾原因 → outcome 默认档(客服放弃 60d),行为跟改造前一致', () => {
const meta = CLOSE_REASON_META.inaccurate; expect(snooze()).toBe(60);
const r = resolveSnoozedUntil({ expect(snooze([])).toBe(60);
outcome: meta.outcome,
breakerTripped: false,
now: NOW,
closeReason: 'inaccurate',
});
expect(daysFromNow(r)).toBe(CLOSE_REASON_DEFAULT_SUPPRESS_DAYS);
}); });
test('已完成治疗 / 其他原因 → abandoned 自带 60d 被覆写成 14d', () => { test('⭐ 弱语义原因单独勾选 → 短档 14d,不被 outcome 的 60d 盖掉', () => {
for (const reason of ['treated', 'other'] as const) { // 「识别不准确」是 PAC 自己的信号有问题,不该按「患者拒绝」的重档罚人。
const r = resolveSnoozedUntil({ // 这条锁住 max 的种子不能是 outcome 默认档(否则 14d 永远生效不了)。
outcome: CLOSE_REASON_META[reason].outcome, expect(snooze(['inaccurate'])).toBe(WEAK_SIGNAL_SUPPRESS_DAYS);
breakerTripped: false, expect(snooze(['treated'])).toBe(WEAK_SIGNAL_SUPPRESS_DAYS);
now: NOW,
closeReason: reason,
});
expect(daysFromNow(r)).toBe(CLOSE_REASON_DEFAULT_SUPPRESS_DAYS);
}
}); });
test('无法联系客户 → 覆写成熔断口径 30d', () => { test('多选取 max —— 每个勾中的原因都独立成立,取最严的', () => {
const r = resolveSnoozedUntil({ // 同时勾「识别不准确(14d)」+「选择竞品机构(永久)」:人确实去别家了,
outcome: CLOSE_REASON_META.unreachable.outcome, // 不能因为信号也有问题就 14 天后再召回。
breakerTripped: false, expect(snooze(['inaccurate', 'competitor'])).toBe(SUPPRESS_PERMANENT_DAYS);
now: NOW, expect(snooze(['unreachable', 'inaccurate'])).toBe(30);
closeReason: 'unreachable', });
});
expect(daysFromNow(r)).toBe(30); test('没配 suppressDays 的原因 → 参与 max 时用 outcome 默认档', () => {
expect(snooze(['no_intent'])).toBe(60); // 关闭弹窗侧
expect(snooze(['patient_refused'])).toBe(60); // PAC 表单侧
expect(snooze(['inaccurate', 'no_intent'])).toBe(60); // max(14, 60)
}); });
test('强语义原因无覆写 → 沿用 outcome 自带窗(拒绝 90d / 竞品永久)', () => { test('未知 key 被忽略;全是未知 → 退回 outcome 默认档', () => {
expect( expect(snooze(['bogus'])).toBe(60);
daysFromNow(resolveSnoozedUntil({ expect(snooze(['bogus', 'inaccurate'])).toBe(WEAK_SIGNAL_SUPPRESS_DAYS);
outcome: CLOSE_REASON_META.no_intent.outcome,
breakerTripped: false, now: NOW, closeReason: 'no_intent',
})),
).toBe(90);
expect(
daysFromNow(resolveSnoozedUntil({
outcome: CLOSE_REASON_META.competitor.outcome,
breakerTripped: false, now: NOW, closeReason: 'competitor',
})),
).toBe(SUPPRESS_PERMANENT_DAYS);
}); });
test('未知 / 空 closeReason → 不影响原有路径', () => { test('熔断优先级仍最高', () => {
const r = resolveSnoozedUntil({ const r = resolveSnoozedUntil({
outcome: 'refused', breakerTripped: false, now: NOW, closeReason: 'bogus', outcome: 'abandoned', breakerTripped: true, now: NOW, abandonReasons: ['competitor'],
}); });
expect(daysFromNow(r)).toBe(90); expect(daysFromNow(r)).toBe(BREAKER_SUPPRESS_DAYS);
const r2 = resolveSnoozedUntil({ outcome: 'refused', breakerTripped: false, now: NOW });
expect(daysFromNow(r2)).toBe(90);
}); });
test('熔断优先级仍高于 closeReason 覆写', () => { test('原因只对有固定抑制窗的 outcome 生效(keep 类不受影响)', () => {
const r = resolveSnoozedUntil({ const r = resolveSnoozedUntil({
outcome: 'abandoned', breakerTripped: true, now: NOW, closeReason: 'treated', outcome: 'no_answer', breakerTripped: false, now: NOW, abandonReasons: ['competitor'],
}); });
expect(daysFromNow(r)).toBe(BREAKER_SUPPRESS_DAYS); expect(r).toBeNull();
});
});
describe('两种回访模式共用一套原因 —— PAC 侧不为「关闭」另立概念', () => {
test('shownIn 分流:两个面板各取各的子集,other 两边都有', () => {
const form = abandonReasonsFor('form');
const close = abandonReasonsFor('close');
expect(form).toContain('wrong_number');
expect(form).not.toContain('inaccurate');
expect(close).toContain('inaccurate');
expect(close).not.toContain('wrong_number');
expect(form).toContain('other');
expect(close).toContain('other');
});
test('hidden 的历史值不进任何面板', () => {
expect(abandonReasonsFor('form')).not.toContain('not_interested');
expect(abandonReasonsFor('close')).not.toContain('not_interested');
});
test('没有 closed outcome —— 关闭就是 abandoned,模型层零分叉', () => {
expect(EXECUTION_OUTCOME_META).not.toHaveProperty('closed');
expect(EXECUTION_OUTCOME_META.abandoned.drivesStatus).toBe('abandoned');
}); });
test('每个 closeReason 的 outcome 都是合法 EXECUTION_OUTCOME_META 键', () => { test('每个 AbandonReason 都在 META 里有文案 + 归属面板', () => {
for (const meta of Object.values(CLOSE_REASON_META)) { for (const [key, meta] of Object.entries(ABANDON_REASON_META)) {
expect(EXECUTION_OUTCOME_META[meta.outcome]).toBeDefined(); expect(typeof meta.labelZh).toBe('string');
expect(['form', 'close', 'both']).toContain(meta.shownIn);
void key;
} }
}); });
}); });
...@@ -339,7 +339,6 @@ function makeInput(overrides: { ...@@ -339,7 +339,6 @@ function makeInput(overrides: {
patient?: Partial<ScriptContext['patient']>; patient?: Partial<ScriptContext['patient']>;
plan?: Partial<ScriptContext['plan']>; plan?: Partial<ScriptContext['plan']>;
clinicalContext?: Partial<ScriptContext['clinicalContext']>; clinicalContext?: Partial<ScriptContext['clinicalContext']>;
agent?: ScriptContext['agent'];
personaHighlights?: ScriptContext['personaHighlights']; personaHighlights?: ScriptContext['personaHighlights'];
} = {}): ScriptContext { } = {}): ScriptContext {
return { return {
...@@ -352,7 +351,6 @@ function makeInput(overrides: { ...@@ -352,7 +351,6 @@ function makeInput(overrides: {
...overrides.patient, ...overrides.patient,
}, },
clinicName: '测试口腔', clinicName: '测试口腔',
agent: overrides.agent,
plan: { plan: {
primaryScenarioLabel: '漏治-缺失牙', primaryScenarioLabel: '漏治-缺失牙',
primaryScenarioKey: 'missed_diagnosis', primaryScenarioKey: 'missed_diagnosis',
...@@ -378,20 +376,20 @@ function makeInput(overrides: { ...@@ -378,20 +376,20 @@ function makeInput(overrides: {
describe('fact-block | buildRichFactBlock(朴素事实块,不出全名)', () => { describe('fact-block | buildRichFactBlock(朴素事实块,不出全名)', () => {
test('开场事实:称呼 / 自报家门 / 医生去名 / 主诉', () => { test('开场事实:称呼 / 自报家门 / 医生去名 / 主诉', () => {
const block = buildRichFactBlock( const block = buildRichFactBlock(makeInput());
makeInput({ agent: { name: '李莉', roleTitle: '客服主管' } }),
);
expect(block).toContain('称呼:张先生'); expect(block).toContain('称呼:张先生');
expect(block).toContain('我是测试口腔的客服主管李莉'); // ⭐ 自报家门给占位符,不给人名 —— 姓名在渲染期按登录客服回填(见 agent-identity.ts)
expect(block).toContain('我是测试口腔的【回访客服】');
expect(block).toContain('韩医生'); // 去名留姓 expect(block).toContain('韩医生'); // 去名留姓
expect(block).not.toContain('韩维'); // 全名不进 prompt expect(block).not.toContain('韩维'); // 全名不进 prompt
expect(block).not.toContain('张震校'); expect(block).not.toContain('张震校');
expect(block).toContain('牙疼来看'); expect(block).toContain('牙疼来看');
}); });
test('agent 无名 → 退回通用"客服",不编名字', () => { test('客服姓名不进 prompt(占位符是唯一形态)', () => {
const block = buildRichFactBlock(makeInput()); const block = buildRichFactBlock(makeInput());
expect(block).toContain('我是测试口腔的客服'); // 任何具体人名都不该出现在自报家门位置 —— 缓存 per-plan、召回池共享,烤人名会串号
expect(block).not.toMatch(/我是测试口腔的客服(主管)?[一-龥]/);
}); });
test('daysSinceLastVisit=0 → 最近就诊显示为今年具体日期(X月X号)', () => { test('daysSinceLastVisit=0 → 最近就诊显示为今年具体日期(X月X号)', () => {
......
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { CLOSE_REASON_META, CloseReason } from '@pac/types'; import { ABANDON_REASON_META, AbandonReason, abandonReasonsFor } from '@pac/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
...@@ -14,15 +14,20 @@ import { ...@@ -14,15 +14,20 @@ import {
/** /**
* 关闭机会弹窗 —— 宿主回访模式下(顶栏有「回访」按钮)替代通话结果区的关闭入口。 * 关闭机会弹窗 —— 宿主回访模式下(顶栏有「回访」按钮)替代通话结果区的关闭入口。
* 原因清单 / 说明必填 / outcome 映射 全部来自 CLOSE_REASON_META(@pac/types 单一真理源); *
* 提交行为由调用方 onConfirm 承接(execution 通道 + 需要时双写召回反馈)。 * ⭐ 这只是**展示层的一张皮**:落库仍是 outcome='abandoned' + abandon_reasons,
* 跟 PAC 自带表单同一套机制、同一列。PAC 侧不存在「关闭」这个概念,
* 分叉只在文案(ABANDON_REASON_META.shownIn='close' 的那几项)。
*
* **多选,无上限** —— 真实关闭常是复合原因(如「价格因素 + 选择竞品机构」),
* 逼客服挑一个主因只会让数据失真。
*/ */
const CLOSE_REASONS = (Object.keys(CLOSE_REASON_META) as CloseReason[]).map((key) => ({ const CLOSE_REASONS = abandonReasonsFor('close').map((key) => ({
key, key,
...CLOSE_REASON_META[key], ...ABANDON_REASON_META[key],
})); }));
export type CloseReasonKey = CloseReason; export type CloseReasonKey = AbandonReason;
export function CloseOpportunityDialog({ export function CloseOpportunityDialog({
open, open,
...@@ -31,18 +36,22 @@ export function CloseOpportunityDialog({ ...@@ -31,18 +36,22 @@ export function CloseOpportunityDialog({
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
/** 确认关闭(reason + 其他原因时的说明)。未接后端前由调用方决定提示行为。 */ /** 确认关闭(原因多选 + 需要说明时的文字)。 */
onConfirm?: (reason: CloseReasonKey, note?: string) => void; onConfirm?: (reasons: CloseReasonKey[], note?: string) => void;
}) { }) {
const [reason, setReason] = useState<CloseReasonKey | null>(null); const [reasons, setReasons] = useState<CloseReasonKey[]>([]);
const [note, setNote] = useState(''); const [note, setNote] = useState('');
const selected = CLOSE_REASONS.find((r) => r.key === reason); const toggle = (key: CloseReasonKey) =>
const needNote = selected?.needNote === true; setReasons((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
const canConfirm = !!reason && (!needNote || note.trim().length > 0);
const selected = CLOSE_REASONS.filter((r) => reasons.includes(r.key));
// 勾中任一 needNote 项(目前只有「其他」)→ 说明必填
const needNote = selected.some((r) => r.needNote === true);
const canConfirm = reasons.length > 0 && (!needNote || note.trim().length > 0);
const reset = () => { const reset = () => {
setReason(null); setReasons([]);
setNote(''); setNote('');
}; };
...@@ -61,17 +70,17 @@ export function CloseOpportunityDialog({ ...@@ -61,17 +70,17 @@ export function CloseOpportunityDialog({
<div className="space-y-3"> <div className="space-y-3">
<div className="text-[12.5px] font-medium text-slate-900"> <div className="text-[12.5px] font-medium text-slate-900">
选择关闭原因 <span className="text-rose-600">*</span> 选择关闭原因(可多选) <span className="text-rose-600">*</span>
</div> </div>
{/* 原因选卡:两列;「其他原因」独占整行 */} {/* 原因选卡:两列;「其他原因」独占整行 */}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{CLOSE_REASONS.map((r) => { {CLOSE_REASONS.map((r) => {
const active = reason === r.key; const active = reasons.includes(r.key);
return ( return (
<button <button
key={r.key} key={r.key}
type="button" type="button"
onClick={() => setReason(r.key)} onClick={() => toggle(r.key)}
className={cn( className={cn(
'flex items-center gap-2 rounded-md border px-3 py-2.5 text-left text-[12.5px] transition-colors', 'flex items-center gap-2 rounded-md border px-3 py-2.5 text-left text-[12.5px] transition-colors',
r.key === 'other' && 'col-span-2', r.key === 'other' && 'col-span-2',
...@@ -82,11 +91,22 @@ export function CloseOpportunityDialog({ ...@@ -82,11 +91,22 @@ export function CloseOpportunityDialog({
> >
<span <span
className={cn( className={cn(
'flex h-3.5 w-3.5 flex-none items-center justify-center rounded-full border', 'flex h-3.5 w-3.5 flex-none items-center justify-center rounded-[3px] border',
active ? 'border-teal-600' : 'border-slate-300', active ? 'border-teal-600 bg-teal-600' : 'border-slate-300',
)} )}
> >
{active && <span className="h-2 w-2 rounded-full bg-teal-600" />} {active && (
<svg viewBox="0 0 12 12" className="h-2.5 w-2.5 text-white" aria-hidden>
<path
d="M2.5 6.2l2.3 2.3 4.7-5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</span> </span>
{r.labelZh} {r.labelZh}
</button> </button>
...@@ -109,12 +129,18 @@ export function CloseOpportunityDialog({ ...@@ -109,12 +129,18 @@ export function CloseOpportunityDialog({
</div> </div>
)} )}
{selected && ( {selected.length > 0 && (
<div className="rounded-md border border-teal-100 bg-teal-50/60 px-3 py-2"> <div className="rounded-md border border-teal-100 bg-teal-50/60 px-3 py-2">
<div className="text-[11.5px] font-semibold text-teal-800">已选关闭原因</div> <div className="text-[11.5px] font-semibold text-teal-800">
<div className="mt-0.5 text-[12px] text-slate-700"> 已选关闭原因 · {selected.length}
{selected.labelZh} · {selected.desc}
</div> </div>
<ul className="mt-0.5 space-y-0.5">
{selected.map((r) => (
<li key={r.key} className="text-[12px] text-slate-700">
{r.labelZh} · {r.desc}
</li>
))}
</ul>
</div> </div>
)} )}
</div> </div>
...@@ -127,8 +153,8 @@ export function CloseOpportunityDialog({ ...@@ -127,8 +153,8 @@ export function CloseOpportunityDialog({
size="sm" size="sm"
disabled={!canConfirm} disabled={!canConfirm}
onClick={() => { onClick={() => {
if (!reason) return; if (reasons.length === 0) return;
onConfirm?.(reason, needNote ? note.trim() : undefined); onConfirm?.(reasons, note.trim() ? note.trim() : undefined);
onOpenChange(false); onOpenChange(false);
reset(); reset();
}} }}
......
...@@ -13,53 +13,21 @@ export interface SubmitExecutionBody { ...@@ -13,53 +13,21 @@ export interface SubmitExecutionBody {
channel: string; channel: string;
outcome: string; outcome: string;
notes?: string; notes?: string;
/**
* 放弃原因(多选,AbandonReason enum key)—— PAC 通话结果表单 / 宿主关闭弹窗**共用**。
* 两个面板各渲染 ABANDON_REASON_META 的一个子集(shownIn),提交的是同一套 key、进同一列。
* 服务端据此算抑制窗(各原因 suppressDays 取 max)。
*
* 注:原先这里有张「中文 label → enum」的映射表,已删 —— UI 现在直接拿 META 的 key 渲染,
* key 和 label 同源就不会再漂(那张表里「已转介他人 → treated_elsewhere」就是漂出来的语义错位)。
*/
abandonReasons?: string[]; abandonReasons?: string[];
abandonOther?: string;
scheduledNextAt?: string; scheduledNextAt?: string;
/** 关闭机会来源(宿主回访模式弹窗)—— 服务端据此覆写抑制窗 */
closeReason?: string;
} }
/**
* 中文 → 后端 AbandonReason enum 的映射。
* UI 给客服显示中文便于选,后端要 enum 入库。匹配不上的归 'other' 并把原文塞 abandonOther。
*/
const ABANDON_REASON_MAP: Record<string, string> = {
'号码空号 / 错号': 'wrong_number',
'已转介他人': 'treated_elsewhere',
'明确不需要治疗': 'patient_refused',
'迁居外地': 'out_of_service_area',
'投诉 / 不愿打扰': 'do_not_contact_request',
};
export function submitExecution(planId: string, body: SubmitExecutionBody) { export function submitExecution(planId: string, body: SubmitExecutionBody) {
return api.post<SubmitExecutionResult>( return api.post<SubmitExecutionResult>(
`/pac/v1/plans/${encodeURIComponent(planId)}/executions`, `/pac/v1/plans/${encodeURIComponent(planId)}/executions`,
body, body,
); );
} }
/**
* 把 UI 的中文原因数组转换成 backend 的 (enum 数组, abandonOther 字符串) 二元组。
*/
export function adaptAbandonReasons(zhReasons: string[]): {
abandonReasons: string[];
abandonOther: string | undefined;
} {
const mapped: string[] = [];
const otherTexts: string[] = [];
for (const z of zhReasons) {
const code = ABANDON_REASON_MAP[z];
if (code) {
mapped.push(code);
} else {
// '其他' 或自定义文案,统一打成 'other' enum + 原文留底
if (!mapped.includes('other')) mapped.push('other');
otherTexts.push(z);
}
}
return {
abandonReasons: mapped,
abandonOther: otherTexts.length > 0 ? otherTexts.join('; ') : undefined,
};
}
...@@ -4,6 +4,8 @@ import { useState } from 'react'; ...@@ -4,6 +4,8 @@ import { useState } from 'react';
import { import {
EXECUTION_OUTCOME_META, EXECUTION_OUTCOME_META,
EXECUTION_OUTCOME_GROUP_META, EXECUTION_OUTCOME_GROUP_META,
ABANDON_REASON_META,
abandonReasonsFor,
type ExecutionOutcome, type ExecutionOutcome,
type ExecutionOutcomeGroup, type ExecutionOutcomeGroup,
} from '@pac/types'; } from '@pac/types';
...@@ -81,7 +83,13 @@ const STATE_HINTS: Record<string, string> = { ...@@ -81,7 +83,13 @@ const STATE_HINTS: Record<string, string> = {
keep: '本次任务保留,等下次跟进', keep: '本次任务保留,等下次跟进',
}; };
const ABANDON_REASONS = ['号码空号 / 错号', '已转介他人', '明确不需要治疗', '迁居外地', '投诉 / 不愿打扰', '其他']; // 放弃原因:取 ABANDON_REASON_META 的 form 子集(跟宿主关闭弹窗共用一套 key、各显示各的文案)。
// 原本这里是写死的中文数组 + 一张中译 enum 的映射表,已删 —— 映射表里「已转介他人→treated_elsewhere」
// 之类的语义错位就是那么来的;现在 key 和 label 同源,不会再漂。
const ABANDON_REASONS = abandonReasonsFor('form').map((key) => ({
key,
labelZh: ABANDON_REASON_META[key].labelZh,
}));
export function OutcomeForm({ export function OutcomeForm({
plan, plan,
...@@ -271,20 +279,22 @@ export function OutcomeForm({ ...@@ -271,20 +279,22 @@ export function OutcomeForm({
<div className="grid grid-cols-2 gap-1"> <div className="grid grid-cols-2 gap-1">
{ABANDON_REASONS.map((r) => ( {ABANDON_REASONS.map((r) => (
<label <label
key={r} key={r.key}
className="flex items-center gap-1.5 px-1.5 py-1 rounded bg-white border border-rose-100 text-[10.5px] cursor-pointer" className="flex items-center gap-1.5 px-1.5 py-1 rounded bg-white border border-rose-100 text-[10.5px] cursor-pointer"
> >
<input <input
type="checkbox" type="checkbox"
checked={abandonReasons.includes(r)} checked={abandonReasons.includes(r.key)}
onChange={() => onChange={() =>
setAbandonReasons( setAbandonReasons(
abandonReasons.includes(r) ? abandonReasons.filter((x) => x !== r) : [...abandonReasons, r], abandonReasons.includes(r.key)
? abandonReasons.filter((x) => x !== r.key)
: [...abandonReasons, r.key],
) )
} }
className="accent-rose-600 scale-90" className="accent-rose-600 scale-90"
/> />
<span className="text-slate-700 truncate">{r}</span> <span className="text-slate-700 truncate">{r.labelZh}</span>
</label> </label>
))} ))}
</div> </div>
......
...@@ -41,8 +41,8 @@ import { ...@@ -41,8 +41,8 @@ import {
diagnosisCodeNameZh, diagnosisCodeNameZh,
EXECUTION_OUTCOME_META, EXECUTION_OUTCOME_META,
RECALL_FEEDBACK_OPTIONS, RECALL_FEEDBACK_OPTIONS,
CLOSE_REASON_META, ABANDON_REASON_META,
type CloseReason, type AbandonReason,
type ExecutionOutcome, type ExecutionOutcome,
} from '@pac/types'; } from '@pac/types';
import { AIStamp, Chip, PriorityBar, SidebarCard, tone } from './shared'; import { AIStamp, Chip, PriorityBar, SidebarCard, tone } from './shared';
...@@ -71,7 +71,7 @@ import type { AdaptedFact } from './adapt-data'; ...@@ -71,7 +71,7 @@ import type { AdaptedFact } from './adapt-data';
import { useScriptStream } from './use-script-stream'; import { useScriptStream } from './use-script-stream';
import { useSummaryStream } from './use-summary-stream'; import { useSummaryStream } from './use-summary-stream';
// import { RealtimeCoach } from '@/components/realtime-coach'; // 暂隐藏(功能未上线) // import { RealtimeCoach } from '@/components/realtime-coach'; // 暂隐藏(功能未上线)
import { submitExecution, adaptAbandonReasons } from './execution-api'; import { submitExecution } from './execution-api';
/// 话术生成模型(具体型号,直传后端 AiProviderService.resolve) /// 话术生成模型(具体型号,直传后端 AiProviderService.resolve)
export type ScriptModel = 'deepseek-v4-pro' | 'deepseek-v4-flash' | 'gemini-3.5-flash' | 'qwen3.7-max'; export type ScriptModel = 'deepseek-v4-pro' | 'deepseek-v4-flash' | 'gemini-3.5-flash' | 'qwen3.7-max';
...@@ -262,13 +262,12 @@ export function PlanDetailApp({ ...@@ -262,13 +262,12 @@ export function PlanDetailApp({
abandonReasons: string[]; abandonReasons: string[];
}) => { }) => {
try { try {
const { abandonReasons, abandonOther } = adaptAbandonReasons(formData.abandonReasons);
const result = await submitExecution(plan.id, { const result = await submitExecution(plan.id, {
channel: formData.channel, channel: formData.channel,
outcome: formData.outcome, outcome: formData.outcome,
notes: formData.notes || undefined, notes: formData.notes || undefined,
abandonReasons: abandonReasons.length > 0 ? abandonReasons : undefined, // 表单直接给 enum key(不再经中译映射),原样提交
abandonOther, abandonReasons: formData.abandonReasons.length > 0 ? formData.abandonReasons : undefined,
scheduledNextAt: formData.scheduledNextAt scheduledNextAt: formData.scheduledNextAt
? new Date(formData.scheduledNextAt).toISOString() ? new Date(formData.scheduledNextAt).toISOString()
: undefined, : undefined,
...@@ -315,29 +314,25 @@ export function PlanDetailApp({ ...@@ -315,29 +314,25 @@ export function PlanDetailApp({
const hasHostReturnVisit = !!openReturnVisit; const hasHostReturnVisit = !!openReturnVisit;
const [closeOpen, setCloseOpen] = useState(false); const [closeOpen, setCloseOpen] = useState(false);
// 关闭机会提交 —— 复用 execution 通道(channel='other'):outcome 按 CLOSE_REASON_META 映射, // 关闭机会提交 —— **不是新机制**:走 execution 通道(channel='other'),
// closeReason 让服务端覆写抑制窗;「识别不准确」额外双写召回反馈(down),补上拇指控件隐藏后的数据流。 // outcome='abandoned' + abandonReasons,跟 PAC 自带表单同一条路、同一列。
// · 抑制窗由服务端按已选原因算(ABANDON_REASON_META.suppressDays 取 max),前端不参与
// · 「识别不准确」**不再**双写 recall_feedback —— 放弃原因本身就是这条反馈的载体,
// 双写会让同一件事出现在两处、口径还可能打架(recall_feedback 保留给拇指控件那条路)
// 成功后自动跳下一位:①我的进行中 → ②召回池(与 /plans 入口解析器同一规则)→ ③没人则回 /plans 空态。 // 成功后自动跳下一位:①我的进行中 → ②召回池(与 /plans 入口解析器同一规则)→ ③没人则回 /plans 空态。
const router = useRouter(); const router = useRouter();
const submitClose = async (reason: CloseReason, note?: string) => { const submitClose = async (reasons: AbandonReason[], note?: string) => {
const meta = CLOSE_REASON_META[reason]; const labels = reasons.map((r) => ABANDON_REASON_META[r].labelZh).join('、');
const notes = `[关闭机会] ${meta.labelZh}${note ? `:${note}` : ''}`;
try { try {
const result = await submitExecution(plan.id, { const result = await submitExecution(plan.id, {
channel: 'other', channel: 'other',
outcome: meta.outcome, outcome: 'abandoned',
notes, notes: note?.trim() || undefined,
closeReason: reason, abandonReasons: reasons,
}); });
setPlanOverride({ status: result.planStatus, contactAttempts: result.contactAttempts }); setPlanOverride({ status: result.planStatus, contactAttempts: result.contactAttempts });
usePlanSyncStore.getState().notify(plan.id, result.planStatus, meta.outcome); usePlanSyncStore.getState().notify(plan.id, result.planStatus, 'abandoned');
if (meta.recallFeedbackDown) { showToast('emerald', '机会已关闭', labels);
// 反馈失败不阻断关闭主流程(数据流尽力而为)
await plansApi
.submitRecallFeedback(plan.id, 'down', `机会识别不准确(关闭机会)${note ? `:${note}` : ''}`)
.catch(() => undefined);
}
showToast('emerald', '机会已关闭', meta.labelZh);
// pageSize 2 + 排除本单:防状态回写与列表查询的竞态把刚关的单又选回来 // pageSize 2 + 排除本单:防状态回写与列表查询的竞态把刚关的单又选回来
try { try {
const mine = await plansApi.list({ view: 'mine', status: 'assigned', sort: 'priority_desc', page: 1, pageSize: 2 }); const mine = await plansApi.list({ view: 'mine', status: 'assigned', sort: 'priority_desc', page: 1, pageSize: 2 });
...@@ -436,7 +431,8 @@ export function PlanDetailApp({ ...@@ -436,7 +431,8 @@ export function PlanDetailApp({
/> />
</HeaderSlotPortal> </HeaderSlotPortal>
{/* 关闭机会弹窗 —— 原因映射 / 抑制覆写见 CLOSE_REASON_META(@pac/types) */} {/* 关闭机会弹窗 —— 只是展示层的皮:落库 outcome='abandoned' + abandon_reasons,
原因清单/抑制窗见 ABANDON_REASON_META(@pac/types,shownIn='close' 子集) */}
<CloseOpportunityDialog <CloseOpportunityDialog
open={closeOpen} open={closeOpen}
onOpenChange={setCloseOpen} onOpenChange={setCloseOpen}
......
...@@ -528,6 +528,11 @@ export type ExecutionOutcomeTone = 'emerald' | 'amber' | 'sky' | 'slate' | 'rose ...@@ -528,6 +528,11 @@ export type ExecutionOutcomeTone = 'emerald' | 'amber' | 'sky' | 'slate' | 'rose
/// 用大值而非 Infinity,方便落库 / 算 cutoff;100 年足够覆盖业务生命周期。 /// 用大值而非 Infinity,方便落库 / 算 cutoff;100 年足够覆盖业务生命周期。
export const SUPPRESS_PERMANENT_DAYS = 36500; export const SUPPRESS_PERMANENT_DAYS = 36500;
/// 「弱语义」放弃原因的短抑制档(识别不准 / 已完成治疗):问题出在数据侧不在患者侧,
/// 一个 DW 同步周期 + 排除闸接手绰绰有余,不该按「患者拒绝」的重档罚人。
/// 介于「再考虑 7d」与「明确拒绝 90d」之间。
export const WEAK_SIGNAL_SUPPRESS_DAYS = 14;
/// 熔断(连续未接通累计达上限强制 abandoned)的抑制天数 —— 不是"拒绝",是"暂时联系不上", /// 熔断(连续未接通累计达上限强制 abandoned)的抑制天数 —— 不是"拒绝",是"暂时联系不上",
/// 短冷静期后允许重新进池(后续可接换渠道策略)。 /// 短冷静期后允许重新进池(后续可接换渠道策略)。
export const BREAKER_SUPPRESS_DAYS = 30; export const BREAKER_SUPPRESS_DAYS = 30;
...@@ -613,61 +618,6 @@ export const RECALL_FEEDBACK_OPTIONS = [ ...@@ -613,61 +618,6 @@ export const RECALL_FEEDBACK_OPTIONS = [
{ value: 'not_worth', labelZh: '不值得召', hint: '价值低 / 不是真机会' }, { value: 'not_worth', labelZh: '不值得召', hint: '价值低 / 不是真机会' },
] as const; ] as const;
// =============================================================
// 关闭机会(宿主回访模式)— 关闭原因封闭集 + 到执行结果的映射
// =============================================================
/// 弱语义关闭原因的默认抑制档:一个 DW 同步周期 + 排除闸接手绰绰有余,
/// 又不至于把真机会闷死(介于「再考虑 7d」与「拒绝 90d」之间)。
export const CLOSE_REASON_DEFAULT_SUPPRESS_DAYS = 14;
export const CloseReason = {
NO_INTENT: 'no_intent',
INACCURATE: 'inaccurate',
COMPETITOR: 'competitor',
PRICE: 'price',
TREATED: 'treated',
UNREACHABLE: 'unreachable',
OTHER: 'other',
} as const;
export type CloseReason = (typeof CloseReason)[keyof typeof CloseReason];
export const CloseReasonSchema = z.enum([
'no_intent',
'inaccurate',
'competitor',
'price',
'treated',
'unreachable',
'other',
]);
/// 关闭原因单一真理源 —— 关闭走现有 execution 通道(channel='other'),不另起机制:
/// outcome — 映射到的执行结果(状态机 / 触达账本 / 熔断口径全继承)
/// suppressDaysOverride — 覆写 outcome 自带抑制窗;未设则用 EXECUTION_OUTCOME_META.suppressDays。
/// 弱语义项(识别不准/已治/其他)统一 14d 默认档;无法联系取熔断口径 30d
/// recallFeedbackDown — 该原因本质是「召回不准」→ 前端提交时双写 recall-feedback(down),
/// 宿主模式下反馈数据流不因拇指控件隐藏而断
/// needNote — 必填文字说明
export const CLOSE_REASON_META: Record<
CloseReason,
{
labelZh: string;
desc: string;
outcome: ExecutionOutcome;
suppressDaysOverride?: number;
recallFeedbackDown?: boolean;
needNote?: boolean;
}
> = {
no_intent: { labelZh: '客户无意愿', desc: '客户明确表示没有治疗意愿', outcome: 'refused' },
inaccurate: { labelZh: '机会识别不准确', desc: '识别的治疗机会与患者实际情况不符', outcome: 'marked_invalid', suppressDaysOverride: CLOSE_REASON_DEFAULT_SUPPRESS_DAYS, recallFeedbackDown: true },
competitor: { labelZh: '选择竞品机构', desc: '客户已选择或倾向其他机构治疗', outcome: 'external_treatment' },
price: { labelZh: '价格因素', desc: '客户因价格原因暂不考虑治疗', outcome: 'refused' },
treated: { labelZh: '已完成治疗', desc: '客户已完成该机会对应的治疗', outcome: 'abandoned', suppressDaysOverride: CLOSE_REASON_DEFAULT_SUPPRESS_DAYS },
unreachable: { labelZh: '无法联系客户', desc: '多次尝试后仍无法联系到客户', outcome: 'abandoned', suppressDaysOverride: 30 },
other: { labelZh: '其他原因(需填写说明)', desc: '需要进一步说明具体原因', outcome: 'abandoned', suppressDaysOverride: CLOSE_REASON_DEFAULT_SUPPRESS_DAYS, needNote: true },
};
export const ExecutionChannel = { export const ExecutionChannel = {
PHONE: 'phone', PHONE: 'phone',
WECOM: 'wecom', WECOM: 'wecom',
...@@ -677,13 +627,33 @@ export const ExecutionChannel = { ...@@ -677,13 +627,33 @@ export const ExecutionChannel = {
export type ExecutionChannel = (typeof ExecutionChannel)[keyof typeof ExecutionChannel]; export type ExecutionChannel = (typeof ExecutionChannel)[keyof typeof ExecutionChannel];
export const ExecutionChannelSchema = z.enum(['phone', 'wecom', 'sms', 'other']); export const ExecutionChannelSchema = z.enum(['phone', 'wecom', 'sms', 'other']);
/**
* 放弃原因 —— **两种回访模式共用的一套 key**(执行结果 outcome='abandoned' 时的细分原因)。
*
* PAC 不为「关闭机会」另立概念:宿主回访模式(host.actionUrls.OPEN_RETURN_VISIT 已配)下
* 回访在宿主侧做,PAC 只提供一个「关闭」弹窗做闭环 —— 它落库的仍是 `abandoned` + abandon_reasons,
* 只是**换了一套面向宿主的文案**。模型层零分叉,分叉只发生在展示层(见 shownIn)。
*
* 两种模式按 host 配置**二选一、运行时不共存**,所以近义项(no_intent / patient_refused)
* 刻意不合并 —— 同一个 host 的数据里只会出现自己那一套 key,统计不会碎;
* 硬合并反而要牺牲某一边的文案。
*/
export const AbandonReason = { export const AbandonReason = {
// ── PAC 自带通话结果表单(未配宿主回访)──
WRONG_NUMBER: 'wrong_number', WRONG_NUMBER: 'wrong_number',
PATIENT_REFUSED: 'patient_refused', PATIENT_REFUSED: 'patient_refused',
OUT_OF_SERVICE_AREA: 'out_of_service_area', OUT_OF_SERVICE_AREA: 'out_of_service_area',
TREATED_ELSEWHERE: 'treated_elsewhere', TREATED_ELSEWHERE: 'treated_elsewhere',
NOT_INTERESTED: 'not_interested', NOT_INTERESTED: 'not_interested',
DO_NOT_CONTACT_REQUEST: 'do_not_contact_request', // 自动加 DNC DO_NOT_CONTACT_REQUEST: 'do_not_contact_request', // 自动加 DNC(⚠️ 尚未实现,见 TODO)
// ── 宿主回访模式的「关闭机会」弹窗 ──
NO_INTENT: 'no_intent',
INACCURATE: 'inaccurate',
COMPETITOR: 'competitor',
PRICE: 'price',
TREATED: 'treated',
UNREACHABLE: 'unreachable',
// ── 两边共用 ──
OTHER: 'other', OTHER: 'other',
} as const; } as const;
export type AbandonReason = (typeof AbandonReason)[keyof typeof AbandonReason]; export type AbandonReason = (typeof AbandonReason)[keyof typeof AbandonReason];
...@@ -694,9 +664,68 @@ export const AbandonReasonSchema = z.enum([ ...@@ -694,9 +664,68 @@ export const AbandonReasonSchema = z.enum([
'treated_elsewhere', 'treated_elsewhere',
'not_interested', 'not_interested',
'do_not_contact_request', 'do_not_contact_request',
'no_intent',
'inaccurate',
'competitor',
'price',
'treated',
'unreachable',
'other', 'other',
]); ]);
/// 该原因在哪个面板展示:'form'=PAC 通话结果表单 / 'close'=宿主关闭弹窗 / 'both'=两边都有。
/// 分流只影响 UI 渲染,不影响落库(都是 abandon_reasons)。
export type AbandonReasonShownIn = 'form' | 'close' | 'both';
/**
* 放弃原因单一真理源 —— 文案 + 展示分流 + 抑制窗。
*
* labelZh / desc — 卡片标题 + 说明(desc 只有关闭弹窗渲染,表单是紧凑复选框)
* shownIn — 展示分流(见上)
* suppressDays — 该原因的抑制窗;**不填 = 沿用 outcome 自带的**(abandoned=60d)。
* 多选时取所有已选原因的 **max** —— 每个原因独立成立,该取最严的那个。
* needNote — 必填文字说明(写进 notes,不另立列)
* hidden — 历史值,任何面板都不展示(仅供翻译老数据)
*/
export const ABANDON_REASON_META: Record<
AbandonReason,
{
labelZh: string;
desc?: string;
shownIn: AbandonReasonShownIn;
suppressDays?: number;
needNote?: boolean;
hidden?: boolean;
}
> = {
// ── PAC 自带表单 ──
wrong_number: { labelZh: '号码空号 / 错号', shownIn: 'form', suppressDays: 30 }, // 等 DW 补新号
patient_refused: { labelZh: '明确不需要治疗', shownIn: 'form' }, // 60d(outcome 默认)
out_of_service_area: { labelZh: '迁居外地', shownIn: 'form', suppressDays: SUPPRESS_PERMANENT_DAYS },
treated_elsewhere: { labelZh: '已转介他人', shownIn: 'form', suppressDays: SUPPRESS_PERMANENT_DAYS },
do_not_contact_request: { labelZh: '投诉 / 不愿打扰', shownIn: 'form', suppressDays: SUPPRESS_PERMANENT_DAYS },
not_interested: { labelZh: '不感兴趣', shownIn: 'form', hidden: true }, // 历史值,从没被写过
// ── 宿主关闭弹窗(文案按宿主口径)──
no_intent: { labelZh: '客户无意愿', desc: '客户明确表示没有治疗意愿', shownIn: 'close' }, // 60d
inaccurate: { labelZh: '机会识别不准确', desc: '识别的治疗机会与患者实际情况不符', shownIn: 'close', suppressDays: WEAK_SIGNAL_SUPPRESS_DAYS },
competitor: { labelZh: '选择竞品机构', desc: '客户已选择或倾向其他机构治疗', shownIn: 'close', suppressDays: SUPPRESS_PERMANENT_DAYS },
price: { labelZh: '价格因素', desc: '客户因价格原因暂不考虑治疗', shownIn: 'close' }, // 60d
treated: { labelZh: '已完成治疗', desc: '客户已完成该机会对应的治疗', shownIn: 'close', suppressDays: WEAK_SIGNAL_SUPPRESS_DAYS },
unreachable: { labelZh: '无法联系客户', desc: '多次尝试后仍无法联系到客户', shownIn: 'close', suppressDays: 30 },
// ── 共用 ──
other: { labelZh: '其他原因(需填写说明)', desc: '需要进一步说明具体原因', shownIn: 'both', needNote: true },
};
/// 取某面板要展示的原因清单(过滤 hidden;顺序 = META 声明顺序)
export function abandonReasonsFor(panel: 'form' | 'close'): AbandonReason[] {
return (Object.keys(ABANDON_REASON_META) as AbandonReason[]).filter((k) => {
const m = ABANDON_REASON_META[k];
return !m.hidden && (m.shownIn === panel || m.shownIn === 'both');
});
}
// ============================================================= // =============================================================
// Plan Scripts / Summaries(异步生成,状态机) // Plan Scripts / Summaries(异步生成,状态机)
// ============================================================= // =============================================================
......
...@@ -3,7 +3,6 @@ import { ...@@ -3,7 +3,6 @@ import {
AbandonReasonSchema, AbandonReasonSchema,
AssetSourceSchema, AssetSourceSchema,
AssetStatusSchema, AssetStatusSchema,
CloseReasonSchema,
ExecutionChannelSchema, ExecutionChannelSchema,
ExecutionOutcomeSchema, ExecutionOutcomeSchema,
PlanStatusSchema, PlanStatusSchema,
...@@ -113,7 +112,6 @@ export const PlanExecutionSchema = z.object({ ...@@ -113,7 +112,6 @@ export const PlanExecutionSchema = z.object({
outcome: ExecutionOutcomeSchema, outcome: ExecutionOutcomeSchema,
notes: z.string().nullable(), notes: z.string().nullable(),
abandonReasons: z.array(z.string()), abandonReasons: z.array(z.string()),
abandonOther: z.string().nullable(),
scheduledNextAt: z.string().nullable(), scheduledNextAt: z.string().nullable(),
/// 召回反馈已迁到 plan 级(FollowupPlan.recallFeedback),不再随执行 /// 召回反馈已迁到 plan 级(FollowupPlan.recallFeedback),不再随执行
createdAt: z.string().describe('提交时间 ≈ 通话结束时间'), createdAt: z.string().describe('提交时间 ≈ 通话结束时间'),
...@@ -234,12 +232,9 @@ export const SubmitExecutionRequestSchema = z.object({ ...@@ -234,12 +232,9 @@ export const SubmitExecutionRequestSchema = z.object({
channel: ExecutionChannelSchema, channel: ExecutionChannelSchema,
outcome: ExecutionOutcomeSchema, outcome: ExecutionOutcomeSchema,
notes: z.string().optional(), notes: z.string().optional(),
abandonReasons: z.array(AbandonReasonSchema).max(20).optional(), /// 放弃原因(多选,无上限)—— PAC 表单 / 宿主关闭弹窗共用;说明写进 notes
abandonOther: z.string().optional(), abandonReasons: z.array(AbandonReasonSchema).optional(),
scheduledNextAt: z.string().optional(), scheduledNextAt: z.string().optional(),
/// 关闭机会来源(宿主回访模式弹窗):服务端据 CLOSE_REASON_META 覆写抑制窗;
/// outcome 仍由前端按同一张表派生提交(两端同源,无漂移面)
closeReason: CloseReasonSchema.optional(),
}); });
export type SubmitExecutionRequest = z.infer<typeof SubmitExecutionRequestSchema>; export type SubmitExecutionRequest = z.infer<typeof SubmitExecutionRequestSchema>;
......
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