Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
257dff38
Commit
257dff38
authored
Jul 22, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
Merge branch 'feat/data-ingestion' into test
parents
ee6d5ca5
1f20bae4
Pipeline
#3421
failed in 0 seconds
Changes
30
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
30 changed files
with
846 additions
and
307 deletions
+846
-307
apps/pac-docs/content/docs/data-extraction.mdx
+251
-0
apps/pac-docs/content/docs/meta.json
+1
-0
apps/pac-service/prisma/migrations/20260722153000_drop_execution_invalid_reason_and_abandon_other/migration.sql
+21
-0
apps/pac-service/prisma/schema.prisma
+7
-7
apps/pac-service/src/modules/ai/calls/draft-plan-script/shared/agent-identity.ts
+77
-0
apps/pac-service/src/modules/ai/calls/draft-plan-script/shared/fact-block.ts
+7
-5
apps/pac-service/src/modules/ai/calls/draft-plan-script/shared/input.types.ts
+4
-4
apps/pac-service/src/modules/ai/calls/draft-plan-script/tiers/deep/calls.ts
+1
-1
apps/pac-service/src/modules/ai/calls/draft-plan-script/tiers/deep/skills/_base/format.md
+3
-2
apps/pac-service/src/modules/ai/calls/draft-plan-script/tiers/stable/prompt.ts
+5
-5
apps/pac-service/src/modules/ai/calls/draft-plan-script/tiers/stable/skills/_base/format.md
+2
-2
apps/pac-service/src/modules/ai/calls/draft-plan-script/tiers/stable/stable.call.ts
+1
-1
apps/pac-service/src/modules/ai/calls/draft-plan-script/tiers/standard/skills/_base/format.md
+2
-2
apps/pac-service/src/modules/ai/calls/draft-plan-script/tiers/standard/standard.call.ts
+1
-1
apps/pac-service/src/modules/ai/orchestrators/plan-script.orchestrator.ts
+3
-8
apps/pac-service/src/modules/plan-aggregate/plan-aggregate.service.ts
+16
-3
apps/pac-service/src/modules/plan-aggregate/plans-aggregate.controller.ts
+50
-18
apps/pac-service/src/modules/plan/execution.service.ts
+1
-9
apps/pac-service/src/modules/plan/plan.controller.ts
+8
-2
apps/pac-service/src/modules/plan/plan.service.ts
+12
-3
apps/pac-service/src/modules/plan/recall-suppression.ts
+26
-15
apps/pac-service/tests/agent-identity.spec.ts
+96
-0
apps/pac-service/tests/recall-suppression.spec.ts
+64
-54
apps/pac-service/tests/script-facts.spec.ts
+6
-8
apps/pac-web/src/components/plan-detail/close-opportunity-dialog.tsx
+52
-26
apps/pac-web/src/components/plan-detail/execution-api.ts
+8
-40
apps/pac-web/src/components/plan-detail/outcome-form.tsx
+15
-5
apps/pac-web/src/components/plan-detail/plan-detail-app.tsx
+19
-23
packages/types/src/enums/index.ts
+85
-56
packages/types/src/schemas/plan.ts
+2
-7
No files found.
apps/pac-docs/content/docs/data-extraction.mdx
0 → 100644
View file @
257dff38
---
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` 当脏数据 | 那是"永久"哨兵值 |
apps/pac-docs/content/docs/meta.json
View file @
257dff38
...
@@ -17,6 +17,7 @@
...
@@ -17,6 +17,7 @@
"monitoring"
,
"monitoring"
,
"troubleshooting"
,
"troubleshooting"
,
"---参考---"
,
"---参考---"
,
"data-extraction"
,
"api"
,
"api"
,
"error-codes"
,
"error-codes"
,
"event-enums"
,
"event-enums"
,
...
...
apps/pac-service/prisma/migrations/20260722153000_drop_execution_invalid_reason_and_abandon_other/migration.sql
0 → 100644
View file @
257dff38
-- 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"
;
apps/pac-service/prisma/schema.prisma
View file @
257dff38
...
@@ -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
)
...
...
apps/pac-service/src/modules/ai/calls/draft-plan-script/shared/agent-identity.ts
0 → 100644
View file @
257dff38
/**
* 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
;
}
apps/pac-service/src/modules/ai/calls/draft-plan-script/shared/fact-block.ts
View file @
257dff38
...
@@ -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
}
`
:
''
}
...
...
apps/pac-service/src/modules/ai/calls/draft-plan-script/shared/input.types.ts
View file @
257dff38
...
@@ -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
:
{
...
...
apps/pac-service/src/modules/ai/calls/draft-plan-script/tiers/deep/calls.ts
View file @
257dff38
...
@@ -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-v1
5'
;
//
v15: 去掉钦定段式(给自由度);v14: 禁内部代码/术语(K08等)说大白话
readonly
promptVersion
=
'draft_plan_script@2026-06-16-deep-write-v1
6'
;
// 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
)
{}
...
...
apps/pac-service/src/modules/ai/calls/draft-plan-script/tiers/deep/skills/_base/format.md
View file @
257dff38
...
@@ -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.
自报家门:用给定的“自报家门”整串,其中
`【回访客服】`
原样保留(系统按登录客服回填)——别替换成具体姓名,也别自己编一个。
# 开场/结束段
# 开场/结束段
-
开场段:把这几件事自然说到、顺序你定——用患者称呼称呼并确认对方方便、自报家门、以诊断医生名义体现关怀、用“最近一次就诊”问近况。
-
开场段:把这几件事自然说到、顺序你定——用患者称呼称呼并确认对方方便、自报家门、以诊断医生名义体现关怀、用“最近一次就诊”问近况。
...
...
apps/pac-service/src/modules/ai/calls/draft-plan-script/tiers/stable/prompt.ts
View file @
257dff38
...
@@ -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
}
`
:
''
}
...
...
apps/pac-service/src/modules/ai/calls/draft-plan-script/tiers/stable/skills/_base/format.md
View file @
257dff38
...
@@ -5,10 +5,10 @@
...
@@ -5,10 +5,10 @@
# 占位符约定(两种,别搞混)
# 占位符约定(两种,别搞混)
-
`{xxx}`
=
**替换**
:用"本次回访患者信息"里给的同名值填(如 {智能称呼}{应治未治项}{牙位}{诊断医生}{风险要点}{复查时长});输出里不能再出现
`{}`
。
-
`{xxx}`
=
**替换**
:用"本次回访患者信息"里给的同名值填(如 {智能称呼}{应治未治项}{牙位}{诊断医生}{风险要点}{复查时长});输出里不能再出现
`{}`
。
-
{智能称呼} / {诊断医生} 已是"姓+敬称",直接用;{牙位} 已是俗称(上门牙),没给就不提牙位。
-
{智能称呼} / {诊断医生} 已是"姓+敬称",直接用;{牙位} 已是俗称(上门牙),没给就不提牙位。
-
`【xxx】`
=
**原样保留**
(客服手填):【时间段1】【时间段2】【具体预约时间】
;结束语分支标签【预约成功】【预约不成功】照常输出。
-
`【xxx】`
=
**原样保留**
:【时间段1】【时间段2】【具体预约时间】(客服手填)、【回访客服】(系统按登录客服回填,别替换成人名、别自己编)
;结束语分支标签【预约成功】【预约不成功】照常输出。
# 填空要点
# 填空要点
-
开场顺序固定:先用 {智能称呼} 称呼并确认对方 → 再 {自报家门} → 以 {诊断医生} 名义体现关怀 → 用 {智能时间显示} 问近况。
-
开场顺序固定:先用 {智能称呼} 称呼并确认对方 → 再 {自报家门}
(内含【回访客服】,整串照抄)
→ 以 {诊断医生} 名义体现关怀 → 用 {智能时间显示} 问近况。
-
健康提醒从 {风险要点} 里挑、检查说明用 {复查时长} 原文;给定值直接用,不改写、不重算。
-
健康提醒从 {风险要点} 里挑、检查说明用 {复查时长} 原文;给定值直接用,不改写、不重算。
-
引导预约严格用:「{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」
-
引导预约严格用:「{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」
-
告知应治未治、复查建议分成短句。
-
告知应治未治、复查建议分成短句。
...
...
apps/pac-service/src/modules/ai/calls/draft-plan-script/tiers/stable/stable.call.ts
View file @
257dff38
...
@@ -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-v2
7'
;
//
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-v2
8'
;
// 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
...
...
apps/pac-service/src/modules/ai/calls/draft-plan-script/tiers/standard/skills/_base/format.md
View file @
257dff38
...
@@ -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】【具体预约时间】`
(客服手填)、
`【回访客服】`
(自报家门,系统按登录客服回填 —— 别替换成具体姓名,也别自己编一个)。
其余都是朴素事实,直接自然取用,输出里不要出现别的占位或标签。
apps/pac-service/src/modules/ai/calls/draft-plan-script/tiers/standard/standard.call.ts
View file @
257dff38
...
@@ -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-v1
4'
;
//
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-v1
5'
;
// 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
>
{
...
...
apps/pac-service/src/modules/ai/orchestrators/plan-script.orchestrator.ts
View file @
257dff38
...
@@ -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
)
...
...
apps/pac-service/src/modules/plan-aggregate/plan-aggregate.service.ts
View file @
257dff38
...
@@ -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
);
...
...
apps/pac-service/src/modules/plan-aggregate/plans-aggregate.controller.ts
View file @
257dff38
...
@@ -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
;
...
...
apps/pac-service/src/modules/plan/execution.service.ts
View file @
257dff38
...
@@ -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
(),
}));
}));
...
...
apps/pac-service/src/modules/plan/plan.controller.ts
View file @
257dff38
...
@@ -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'
)
...
...
apps/pac-service/src/modules/plan/plan.service.ts
View file @
257dff38
...
@@ -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
(),
};
};
...
...
apps/pac-service/src/modules/plan/recall-suppression.ts
View file @
257dff38
import
{
import
{
CLOSE
_REASON_META
,
ABANDON
_REASON_META
,
Close
Reason
,
Abandon
Reason
,
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
)
{
...
...
apps/pac-service/tests/agent-identity.spec.ts
0 → 100644
View file @
257dff38
/**
* 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
(
'我是客服主管李倩'
);
});
});
apps/pac-service/tests/recall-suppression.spec.ts
View file @
257dff38
...
@@ -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
;
}
}
});
});
});
});
apps/pac-service/tests/script-facts.spec.ts
View file @
257dff38
...
@@ -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号)'
,
()
=>
{
...
...
apps/pac-web/src/components/plan-detail/close-opportunity-dialog.tsx
View file @
257dff38
'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
=
Close
Reason
;
export
type
CloseReasonKey
=
Abandon
Reason
;
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
?:
(
reason
s
:
CloseReasonKey
[]
,
note
?:
string
)
=>
void
;
})
{
})
{
const
[
reason
,
setReason
]
=
useState
<
CloseReasonKey
|
null
>
(
null
);
const
[
reason
s
,
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
);
setReason
s
([]
);
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
=
reason
s
.
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
?.(
reason
s
,
note
.
trim
()
?
note
.
trim
()
:
undefined
);
onOpenChange
(
false
);
onOpenChange
(
false
);
reset
();
reset
();
}
}
}
}
...
...
apps/pac-web/src/components/plan-detail/execution-api.ts
View file @
257dff38
...
@@ -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
,
};
}
apps/pac-web/src/components/plan-detail/outcome-form.tsx
View file @
257dff38
...
@@ -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
>
...
...
apps/pac-web/src/components/plan-detail/plan-detail-app.tsx
View file @
257dff38
...
@@ -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
Close
Reason
,
type
Abandon
Reason
,
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(不再经中译映射),原样提交
abandon
Other
,
abandon
Reasons
:
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
}
...
...
packages/types/src/enums/index.ts
View file @
257dff38
...
@@ -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(异步生成,状态机)
// =============================================================
// =============================================================
...
...
packages/types/src/schemas/plan.ts
View file @
257dff38
...
@@ -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
abandon
Other
:
z
.
string
(
).
optional
(),
abandon
Reasons
:
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
>
;
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment