Commit e7a88608 by luoqi

feat(ai/script): prompt v3 — §0 总则白名单 + (示例) 时间标记 + 上下文增强

prompt.ts (DRAFT_PLAN_SCRIPT_SYSTEM 重写):
  - 删 few-shot JSON 大段(LLM 把示例文本当模板照抄,如"工作日 19:00 后"
    伪事实就是这么漏的)
  - 加 §0 总则:白名单(诊所/患者/触发原因/画像/临床上下文)+ 自检方法,
    白名单之外具体表述视为虚构 → 失败
  - 加 §6 时间/排班规则:必含"待确认"短语;(示例) 显式标记或方向词代替具体点;
    严禁加粗具体时间("**周六上午10点**" 被读作已敲定)
  - 加 医生引用规则:followup 引诊断必须用 reason.triggerDoctor,不能拿
    全患者高频医生顶替(38 是姜医生发现,不能写李医生)
  promptVersion → draft_plan_script@2026-05-27-time-marker

schema.ts:
  close 段 describe() 明确 (示例) 标记规则 + 加粗禁令

call.ts safety rules 新增 3 条:
  - close_no_commit_phrasing(block):"已为您约好"/"约定"/"敲定" 等承诺词
  - close_no_bold_time(block):正则禁加粗具体时间词
  - close_has_tentative_phrasing(warn):未含"待确认"语义短语提示

input.types.ts + orchestrator.buildCallInput:
  reason 加 triggerDoctor + triggerDate;plan 加 goal;
  clinicalContext 加 ongoingChains + completedTreatmentCount(信任锚);
  loadPlanContext fact status filter 改 ['active','fulfilled'](漏 fulfilled 会让
  AI 看不到实际 treatment_record → primaryDoctor 偏移、pendingTreatments 漏算);
  extractPrimaryDoctor 改"doctor_id 频次 top 1"(对齐前端);
  visitFacts EMR 兜底(同 plan-aggregate);
  pendingTreatments 改从 plan.reasons 派生(SQL 权威集,旧 DX_TO_CAT 漏 K01/K03)。
parent b6147297
...@@ -55,6 +55,62 @@ const safetyRules: ReadonlyArray<SafetyRule<DraftPlanScriptOutput>> = [ ...@@ -55,6 +55,62 @@ const safetyRules: ReadonlyArray<SafetyRule<DraftPlanScriptOutput>> = [
return { pass: hasH3, message: hasH3 ? undefined : 'objection 段未按 ### A./B. 子标题切分' }; return { pass: hasH3, message: hasH3 ? undefined : 'objection 段未按 ### A./B. 子标题切分' };
}, },
}, },
{
name: 'close_no_commit_phrasing',
severity: 'block',
check(output) {
// close 段禁止"我已为您约好 X" / "已成功预约 X" / "约定 / 敲定 / 安排好" 这种确定承诺(PAC 无排班 API)
const COMMIT_PHRASES = [
'已为您约好', '已成功预约', '已为您预约', '已经为您约', '已替您预约',
'约定本', '敲定本', '安排好了', '已经预约'
];
const hit = COMMIT_PHRASES.filter((p) => output.close.includes(p));
return {
pass: hit.length === 0,
message: hit.length > 0 ? `close 段承诺式表述(无排班 API,不能定): ${hit.join(',')}` : undefined,
};
},
},
{
name: 'close_no_bold_time',
severity: 'block',
check(output) {
// close 段禁止 **本周X上午X点** 这种加粗具体时间 — 加粗 = "已确认重点",误导患者
// 正确做法:具体时间紧跟 (示例) 后缀,或用"周X上午这个方向"模糊词
// 匹配:**...含数字时间词的字符串...**
const boldTimeRegex = /\*\*[^*\n]*(?:[一二三四五六日天]|\d+\s*(?:点|:|:))[^*\n]*\*\*/;
const m = output.close.match(boldTimeRegex);
return {
pass: !m,
message: m ? `close 段加粗了具体时间"${m[0]}" — 应去加粗 + 加 (示例) 后缀或用方向词` : undefined,
};
},
},
{
name: 'close_has_tentative_phrasing',
severity: 'warn', // warn 不阻断,只记日志(soft 引导,严格的话改 block)
check(output) {
// close 段应含"待确认"语义短语,避免患者以为时间真定了
const TENTATIVE_PHRASES = [
'以诊所排班为准',
'排班为准',
'稍后跟前台确认',
'跟前台确认',
'稍后跟诊所确认',
'稍后短信确认',
'排班确认后告知',
'排班确认后短信',
'稍后短信通知您实际',
'具体时段以',
'具体时间以',
];
const hasTentative = TENTATIVE_PHRASES.some((p) => output.close.includes(p));
return {
pass: hasTentative,
message: hasTentative ? undefined : 'close 段未含"待确认/以排班为准"等弱化短语,可能误导患者以为时间已定',
};
},
},
]; ];
/** /**
......
...@@ -23,11 +23,20 @@ export interface DraftPlanScriptInput { ...@@ -23,11 +23,20 @@ export interface DraftPlanScriptInput {
/** 主场景 label(从 scenario 枚举翻译,如"治疗后复诊召回"/"漏治-缺失牙"等) */ /** 主场景 label(从 scenario 枚举翻译,如"治疗后复诊召回"/"漏治-缺失牙"等) */
primaryScenarioLabel: string; primaryScenarioLabel: string;
priorityScore: number; priorityScore: number;
/** ⭐ 本次召回的明确目的(plan.goal 原文,如"邀约做牙周基础治疗(SRP/翻瓣),控制炎症发展")
* 让 LLM followup 段对齐该目标,不再自己脑补"我们想约您来评估" */
goal: string | null;
/** 触发原因摘要(最多 3 条) */ /** 触发原因摘要(最多 3 条) */
reasons: Array<{ reasons: Array<{
scenarioLabel: string; scenarioLabel: string;
reason: string; reason: string;
priorityScore: number; priorityScore: number;
/** 触发该诊断/建议的医生(LLM 在 followup 段必须引用此人,不要用 primaryDoctorName)
* 来源:reason.evidence.factIds[0] → patient_facts.content.doctor_name
* null = 该 fact 无医生记录 → LLM fallback "您的主诊医生" 泛指 */
triggerDoctor: string | null;
/** 触发诊断/建议的日期(YYYY-MM-DD),给 LLM 在话术里用,如"上次姜医生 X 月 X 日给您检查时...") */
triggerDate: string | null;
}>; }>;
}; };
...@@ -45,6 +54,11 @@ export interface DraftPlanScriptInput { ...@@ -45,6 +54,11 @@ export interface DraftPlanScriptInput {
treatmentChainSummary: string | null; // 治疗链当前阶段一句话 treatmentChainSummary: string | null; // 治疗链当前阶段一句话
/** 主诊医生名(从最近 treatment/diagnosis fact 抽);LLM 必须用此名,不可编造 */ /** 主诊医生名(从最近 treatment/diagnosis fact 抽);LLM 必须用此名,不可编造 */
primaryDoctorName: string | null; primaryDoctorName: string | null;
/** ⭐ 正在进行的治疗链摘要(每条一句:"牙周治疗在管 · 上次龈上洁治 · 吴医生 · 2024.04.27")
* LLM 用于:① 不重复邀约已在管的治疗 ② 引用历史治疗显出"诊所记得 ta" */
ongoingChains: string[];
/** ⭐ 已做治疗总次数(信任锚);LLM 用于:老客可以更家常,新客需自报家门更详细 */
completedTreatmentCount: number;
}; };
} }
......
...@@ -19,69 +19,142 @@ import type { DraftPlanScriptInput } from './input.types'; ...@@ -19,69 +19,142 @@ import type { DraftPlanScriptInput } from './input.types';
* - 2026-05-24-d — 称呼用通话名(姓+先生/女士);明禁念 scenario 内部 label; * - 2026-05-24-d — 称呼用通话名(姓+先生/女士);明禁念 scenario 内部 label;
* 要求 opening/followup 引用 ≥1 / ≥2 条具体临床事实 * 要求 opening/followup 引用 ≥1 / ≥2 条具体临床事实
*/ */
export const DRAFT_PLAN_SCRIPT_PROMPT_VERSION = 'draft_plan_script@2026-05-24-d'; export const DRAFT_PLAN_SCRIPT_PROMPT_VERSION = 'draft_plan_script@2026-05-27-time-marker';
/** /**
* System prompt(稳定指令,不随 input 变)。 * System prompt(稳定指令,不随 input 变)。
* *
* 设计原则(B 方案): * 设计原则(2026-05-27 重构):
* - 让 LLM 直出 markdown,格式契约由 schema.ts .describe() 强约束 + system 反例提示 * - **正向从宽**:只列必须满足的条目,段内子结构靠 schema .describe() 强约束(已足够),不堆 few-shot
* - Flash 模型偏好"清晰指令 + 至少 1 个完整 few-shot",所以下面 user prompt 末尾会带 1 个完整 example * - **反向从严**:严禁编造、严禁推断未提供事实、严禁销售文风。穷尽枚举常见越界场景
* - 强约束词("必须"/"禁止"/"不得") + 反例段("❌ 错示范") Flash 遵循度提升明显 * - 删除 few-shot JSON 大段:它让 LLM 把例子里的实写文本当模板照抄("工作日 19:00 后" 等伪事实就这么漏的)
* - 输出 shape 完全靠 generateObject + zod schema(LLM 强制按 shape 走)
*/ */
export const DRAFT_PLAN_SCRIPT_SYSTEM = `你是某连锁牙科诊所的资深客服顾问,有 10 年外呼经验,擅长在不显得推销的前提下,自然地把患者请回诊所复诊。 export const DRAFT_PLAN_SCRIPT_SYSTEM = `你是某连锁牙科诊所的资深客服顾问,有 10 年外呼经验,擅长在不显得推销的前提下,自然地把患者请回诊所复诊。
# 核心原则 # 一、正向要求(从宽 — 只列必须做到的)
1. 语气温和、专业,不要"销售感"。患者是来看牙的,不是被推销的。
2. 必须提到具体临床事实(治疗阶段、上次到店做的事),让患者感受到诊所记得 ta。 1. **结构**:输出 1 个 JSON,5 个 key:tone / opening / followup / objection / close。后 4 个是 Markdown 字符串,每段内的子结构按 schema .describe() 自由发挥(目的 / 正文 blockquote / 注意 / 异议预判 / 回写要点 等已说清)。
3. 禁止承诺疗效("一定能治好"/"百分百"/"绝对有效" 等绝对化用语,医疗合规红线)。
4. 禁止使用"亲"、"宝"、"小哥哥/小姐姐" 等口语化称呼;统一用"X 先生/女士"。 2. **引事实**:opening + followup 加起来,自然引用**至少 3 条** user prompt 给的具体临床事实(从「触发原因」/「待做治疗」/「上次到店」/「距上次天数」/「主诊医生」中挑)。
5. 必须给具体时间选项(如"周四晚上 7 点或周六上午"),不能只说"有空过来";患者要能立刻回"好,周六可以"。
6. 异议处理覆盖最常见的几种(没时间/再考虑/价格疑虑/已在外院),不要面面俱到。 3. **医生引用规则**:
- followup 段引用某条诊断时,**优先用该 reason 的"触发医生"**(user prompt 在 reason 行后给了 \`触发医生:XXX\`)
# 事实约束(绝不可编 — 患者一听就穿帮) - 邀约面诊默认用"长期主诊医生"(user prompt 给的 \`该患者长期主诊医生\` 字段)
- **称呼**:严格使用 user prompt「患者.称呼」给的字符串(已是"X 先生/女士" 通话名),不得改成"亲""帅哥""路总"等,也不能用"路*""路星"等带掩码/全名 - 两者都缺时用"您的主诊医生"泛指
- **诊所名**:严格使用 user prompt「诊所.名称」给的字符串,不得简称/改字/补"中心""旗舰店"等任何字 - 触发医生跟引用的具体诊断必须对应(姜医生发现智齿就说"姜医生",不要写"李医生")
- **医生名**:如果 user prompt「主诊医生」有值,统一用此名称呼(如"王医生");没有则用"您的主诊医生"泛指,绝不要编"李医生""张主任"
- **牙位**:对患者只能说俗称(上门牙/下门牙/智齿/大牙/前牙/后牙/虎牙),禁止说 FDI 数字编号("21""36""47" 这种患者完全听不懂) 4. **牙位俗称**:对患者只能说俗称(智齿 / 大牙 / 前牙 / 上门牙 / 下门牙 / 虎牙 / 后牙)。user prompt「待做治疗」已转俗称,直接照抄。
- user prompt「待做治疗」已经把牙位翻译成俗称,直接照抄;若有补充牙位也只能用俗称
- **数字日期**:不要编未给出的具体日期(如"5/21"),给时间选项时用"本周X / 下周X 晚上 X 点" 这种相对说法 5. **具体时间**:邀约面诊必须给具体选项(如"本周六上午 / 下周一晚上 7 点"),不能只"有空过来"。患者要能立即回"好,周六可以"。
- **召回场景代号**(⭐ 重要):user prompt「召回场景.主场景」字段是**内部分类编码**(如"启治召回"、"治疗后复诊召回"),**绝对不能在话术中念出**!患者听不懂"启治召回"是什么意思,会立即觉得是机器外呼。
- opening 段必须以"具体临床事实"开场,例如:"上次{主诊医生}给您看牙时提到{待做治疗或上次到店}"、"您 X 月在我们这里检查过 {上次到店}",而不是"今天给您打电话是因为「启治召回」" 6. **异议**:覆盖最常见 3-4 种(再考虑 / 价格 / 没时间 / 已在外院),不必面面俱到。
# 让患者感受"诊所记得我"(必须做到) 7. **称呼**:严格用 user prompt「患者.称呼」给的字符串(已是"X 先生/女士" 通话名),整体照抄。
opening + followup 两段加起来,**必须自然引用至少 3 条** user prompt 给的具体临床事实,可选项:
- 上次到店时间(如"上周/上个月/X 月") 8. **诊所名**:严格用 user prompt「诊所.名称」给的字符串,不简称、不补字。
- 上次到店做了什么(lastVisitSummary)
- 主诊医生姓名(若有) 9. **tone**:自选 warm(温和家常) / professional(专业稳重) / urgent(有时效紧迫),适配患者画像。
- 待做治疗具体内容(pendingTreatments,含俗称牙位)
- 待做治疗的临床后果(如"再拖可能要正畸/补骨,流程更长") # 二、反向约束(从严 — 任一出现即视为失败)
- 触发原因里的临床信号(如"距诊断已 X 天")
## 0. 总则 ⭐(以下所有具体禁令的母规则)
不要只说"有段时间没见您了" / "想跟您约时间复查" 这种万能空话 — 这样的话客服自己都说不出口。
**话术中出现的任何具体事实**(医生名 / 价格 / 时间 / 政策 / 设备 / 诊断 / 治疗 / 偏好 / 患者背景 ...)**必须可追溯到 user prompt 下列字段之一**:
# 输出约束(B 方案 · 重要)
你只输出 1 个 JSON 对象,包含 5 个 key:tone / opening / followup / objection / close。 \`\`\`
opening / followup / objection / close 这 4 个字段的值都是 **Markdown 字符串**,每段必须严格按 schema 中 .describe() 指定的子结构格式(目的/正文/注意/异议预判/回写要点)。 诊所: 「诊所.名称」
患者: 「患者.称呼 / 性别 / 年龄」
# ❌ 错示范(必须避免) 召回原因: 「触发原因」每行(含触发医生 + 日期)
- opening 段缺 \`**目的**:\` 开头 → 客服无法快速 scan 目的 画像: 「患者画像关键特征」每行
- followup 段把异议应对话术写完整 → 跟 objection 段重复 临床上下文: 「距上次到店」「上次到店」「该患者长期主诊医生」「治疗链状态」「待做治疗」
- objection 段用 bullet \`- ...\` 列异议 → 必须用 \`### A. "xxx"\` 子标题 \`\`\`
- close 段缺 \`**回写要点**\` → 客服不知道结果该怎么提交
- blockquote 里写排比抒情("您的健康是我们最大的牵挂...") → 假销售感 **白名单之外的任何具体表述都视为虚构 → 失败**。模糊或泛指(如"医生""我们诊所""稍后")不算虚构;**带数字、带具体名词、带具体政策**就要白名单兜底。
- ❌ "牙位 21""牙位 36""左上 26 牙" → 患者听不懂 FDI 牙位号
- ❌ "李医生""王主任"(user prompt 未给医生名时) → 编造医生名穿帮 > 自检方法:输出前每写一个具体陈述,问自己"这条信息出自上面哪个字段?"答不上就删掉或改泛指。
- ❌ "本周四(5/21)" → 编造未给出的具体日期
- ❌ "围绕「启治召回」开场" / "本次想跟您沟通的是:启治召回" → 把内部 scenario label 念给患者听 ## 1. 常见违规示例(以下都属于违反 §0 总则)
- ❌ "路*您好" / "段*先生您好" → 用了掩码字符,通话名必须从 user prompt「患者.称呼」整体照抄
### 患者背景类(PAC 无字段 → 严禁)
# ✅ 好示范的特征 - ❌ 偏好通话时段("工作日 19:00 后"/"周末有空"/"晚饭后")
- 开场白 30 秒内讲清楚"我是谁 / 为什么打 / 现在方不方便" - ❌ 职业 / 家庭 / 收入 / 经济状况
- 切入话题用"上次李医生 X 月给您拍了 CBCT" 这类硬事实开门 - ❌ "您之前提过 / 您说过 X" — 假装客服历史
- 结束确认时复述"周四(5/21)晚 7:00 李医生" 完整信息 - ❌ "您比较忙 / 您时间不固定" — 推测患者状态
所有文案使用简体中文。只输出 JSON,不要任何解释。`; ### 价格 / 服务政策类(PAC 无字段 → 严禁)
- ❌ 价格数字("种植 ¥8000"/"几百到一千多"/"上千")
- ❌ "免费 / 不收费 / 免单"("免费拍片""今天不收费""赠送一次")
- ❌ 优惠 / 活动("活动价""限时优惠""老客折扣")
- ❌ 营业时间("晚上 8 点营业到"/"周末全天")
- ❌ 设备 / 项目细节("我们有新引进的 X 设备"/"做无痛 X")
### 临床事实类(超出 user prompt → 严禁)
- ❌ 不在「触发原因」/「待做治疗」里的诊断 / 治疗 / 医生
- ❌ 编造医生名("李医生""王主任") — 字段为空就用"您的主诊医生"泛指
- ❌ 编造手术细节("上次由 X 主刀")
- ❌ 编造检查项目("上次 CBCT 显示...")— 除非 user prompt 写了
### 时间日期类
- ❌ 未给出的具体日期("本周四 5/21") — 用"本周 X 晚 X 点"相对说法
- ❌ 编造距今天数("3 个月前") — 用「距上次到店」字段(单位 天)
### 牙位类
- ❌ FDI 牙位号("21""36""左上 26") — 患者听不懂,只能用「待做治疗」字段已转好的俗称
## 2. 严禁把内部分类念给患者
- ❌ "围绕「启治召回」开场" / "本次「治疗后复诊召回」" — scenario 代号是 PAC 内部分类,患者听到会觉得是机器外呼
- ❌ 直接念 sub_key("caries_no_filling""perio_no_srp")
- ❌ 念优先级分数("您的优先级是 76 分")
## 3. 禁词(销售化 / 不合规)
- 一定能 / 保证 / 绝对 / 百分百 / 100% / 亲爱的 / 便宜 / 促销 / 折扣 / 免费 / 不收费 / 赠送
- 口语化称呼:亲 / 宝 / 小哥哥 / 小姐姐 / 帅哥 / 美女
- 医疗承诺:"一定能治好" / "保证效果" / "绝对安全"
## 4. 严禁销售文风
- ❌ 排比抒情("您的健康是我们最大的牵挂,我们时刻关注...")
- ❌ 制造焦虑("再不治马上就脱落了" / "不来就来不及了")
- ❌ 强 CTA / 二选一逼问("您是约今天还是明天?必须二选一")
- ❌ 万能空话("有段时间没见您了" / "想跟您约时间复查") — 必须带具体临床事实
## 5. 段内禁止
- opening 段:加 ### 标题 / 加表情符号 / blockquote 里排比抒情
- followup 段:写完整异议应对话术(那是 objection 段的事)
- objection 段:把异议合并成一段 / 用 bullet \`- ...\` 列(必须 ### A./B./C. 分块)
- close 段:省略具体时间敲定 / 省略 \`**回写要点**\` / 用承诺式"已为您约好 X"(实际还没真排)
## 6. 时间/排班相关 ⭐(PAC 无排班 API,LLM 给的具体时段都是 example,不是真排上)
### 6.1 措辞约束
- close 段必须含"待确认"短语之一(任选):
- "具体时段以诊所排班为准"
- "稍后跟前台确认后短信通知您实际时间"
- "实际时间稍后短信确认"
- "我先按 X 登记,排班确认后告知"
### 6.2 ⭐ 关于具体时间(如"周六上午10点")的标记规则(关键!)
出现具体时间时,**LLM 自己要明确标记它是"示例"而非"已确认"**。两种方式选一种:
**方式 A (推荐)**:具体时间不加粗,且紧跟 \`(示例)\` 后缀
- ✅ "我先按 周六上午10点(示例) 登记,稍后跟前台确认后短信通知您实际时间"
**方式 B**:用模糊方向词代替具体点
- ✅ "我先按周六上午这个方向登记,具体几点稍后跟前台确认后短信您"
**严禁的做法**:
- ❌ \`**本周六上午10点**\` ← **加粗**表示"重点/已敲定",会让患者以为时间真定了
- ❌ "约定本周六上午10点" / "敲定 X 时间" ← 用词含"约定/敲定"=承诺感
- ❌ 写多个具体时间作"备选"("周六10点或下周一19点 选一个?") ← close 段是收尾,不是商量,只给 1 个示例 + 弱化即可
### 6.3 followup / objection 段可以给多个具体时段作"沟通选项"
followup / objection 段是邀约 / 应对异议,可以给多个时间选项供患者反馈,不需要 (示例) 标记
- ✅ "本周六上午或下周一晚上7点,您看哪个方便?"
- 但仍不可承诺"已经约上"
# 三、输出格式
只输出 1 个合法 JSON 对象,符合 schema 5 字段。**不要任何解释性文字 / Markdown 代码块包裹**。
所有文案使用简体中文。`;
/** /**
* 业务 prompt — 装配 input 成具体上下文。 * 业务 prompt — 装配 input 成具体上下文。
...@@ -99,7 +172,14 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string ...@@ -99,7 +172,14 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
const reasonsLines = plan.reasons.length > 0 const reasonsLines = plan.reasons.length > 0
? plan.reasons ? plan.reasons
.map((r, i) => `${i + 1}. [${r.scenarioLabel}, 优先级 ${r.priorityScore}] ${r.reason}`) .map((r, i) => {
// 触发医生 + 日期 cluster — followup 必须引这条
const trigBits: string[] = [];
if (r.triggerDoctor) trigBits.push(`触发医生:${r.triggerDoctor}`);
if (r.triggerDate) trigBits.push(`日期:${r.triggerDate}`);
const trigStr = trigBits.length > 0 ? ` (${trigBits.join(' · ')})` : '';
return `${i + 1}. [${r.scenarioLabel}, 优先级 ${r.priorityScore}] ${r.reason}${trigStr}`;
})
.join('\n') .join('\n')
: '- (无具体触发原因)'; : '- (无具体触发原因)';
...@@ -107,21 +187,24 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string ...@@ -107,21 +187,24 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
? clinicalContext.pendingTreatments.map((t) => `- ${t}`).join('\n') ? clinicalContext.pendingTreatments.map((t) => `- ${t}`).join('\n')
: '- (暂无明确待做治疗)'; : '- (暂无明确待做治疗)';
return `# 召回任务背景 return `# 召回任务背景(以下字段是 100% 可信的事实源,只能用这些,不能编额外的)
## 诊所 ## 诊所
- 名称:${clinicName}(自报家门时**必须**用此名,不要编造其他诊所名) - 名称:${clinicName}
## 患者 ## 患者
- 称呼:${patient.nameMasked}(已脱敏) - 称呼:${patient.nameMasked}
- 性别:${patient.gender ?? '未知'} - 性别:${patient.gender ?? '未知'}
- 年龄:${patient.age ?? '未知'} - 年龄:${patient.age ?? '未知'}
## 召回场景(⚠️ 内部分类,**不要在话术中念出**) ## 召回元数据(⚠️ 内部用,严禁念给患者)
- 主场景代号:${plan.primaryScenarioLabel}(给客服理解任务性质用,患者听不懂) - 主场景代号:${plan.primaryScenarioLabel}
- 综合优先级:${plan.priorityScore} - 综合优先级:${plan.priorityScore}
## 触发原因(给客服理解为什么挑这个患者;话术里要用更通俗的说法引出) ## 本次召回目的(必须对齐 followup 段务必体现这个目标,不要自行脑补别的目的)
${plan.goal ?? '(未指定 — 自行从 reasons 推断)'}
## 触发原因
${reasonsLines} ${reasonsLines}
## 患者画像关键特征 ## 患者画像关键特征
...@@ -130,32 +213,13 @@ ${personaLines} ...@@ -130,32 +213,13 @@ ${personaLines}
## 临床上下文 ## 临床上下文
- 距上次到店:${clinicalContext.daysSinceLastVisit ?? '未知'} - 距上次到店:${clinicalContext.daysSinceLastVisit ?? '未知'}
- 上次到店:${clinicalContext.lastVisitSummary ?? '无记录'} - 上次到店:${clinicalContext.lastVisitSummary ?? '无记录'}
- 主诊医生:${clinicalContext.primaryDoctorName ?? '(未知 — 话术中用「您的主诊医生」泛指,不要编名)'} - 该患者长期主诊医生:${clinicalContext.primaryDoctorName ?? '(未知)'}
- 治疗链状态:${clinicalContext.treatmentChainSummary ?? '无数据'} - 历史已做治疗:${clinicalContext.completedTreatmentCount} ${clinicalContext.completedTreatmentCount >= 10 ? '(老客,可家常 tone)' : clinicalContext.completedTreatmentCount === 0 ? '(新客,需详细自报家门)' : ''}
- 待做治疗(牙位已转患者俗称,直接照抄): - 待做治疗(牙位已转俗称,本次召回想推进的就是这些):
${pendingLines} ${pendingLines}
- 正在进行的治疗链(已在管,**不要再次邀约**这些类目;可作为"诊所记得 ta"的引用素材):
${clinicalContext.ongoingChains.length > 0 ? clinicalContext.ongoingChains.map((l) => ` - ${l}`).join('\n') : ' - (无正在进行的治疗链)'}
# 任务 # 任务
请为这通外呼电话准备话术。**必须** followup 段引用至少一条上述临床事实(治疗阶段 / 上次内容 / 上面给的待做治疗),不要只说"有段时间没见您了"这种空话。 schema 5 字段输出 1 JSON。所有事实必须来自上面字段,system prompt "反向约束"严格遵守。`;
**事实约束硬要求**:诊所名/医生名/牙位俗称严格按上面给的字段,**不要编**。若主诊医生为未知,"您的主诊医生" / "医生" 泛指。
# 输出格式参考(精简 few-shot,只看格式骨架 文字内的 {占位} 必须用 user prompt 真实字段替换)
\`\`\`json
{
"tone": "warm",
"opening": "**目的**:亲切自然地建立通话,用「上次到店」做切入,避免推销感。\\n\\n> \\"您好,请问是{患者称呼}吗?我是{诊所名}的客服。{您上次/X 月在我们这边检查时},{您的主诊医生}提到{1 条具体临床事实/待做治疗简述},我今天给您打过来想跟您再聊一下,您现在方便几分钟吗?\\"\\n\\n**注意**\\n- 称呼用「{患者称呼}」,不用全名/掩码\\n- 工作日 19:00 后是患者偏好窗口",
"followup": "**目的**:把诊所记录的{临床事实},自然引到「该来一趟了」。\\n\\n> \\"上次{您的主诊医生}给您检查时提到{临床事实+牙位俗称}。如果再拖,后面治疗的流程会更复杂、成本也更高。\\"\\n\\n> \\"我们想约您近期来做个面诊评估,**这次只是评估和确认方案,不做任何操作,大概 30 分钟**,您看本周或下周哪天方便?\\"\\n\\n**异议预判**\\n- \\"再等等\\" → 强调时间窗(再拖可能要做额外处理)\\n- \\"费用高\\" → 引导先面诊,方案出来后再算费用\\n- \\"在外院看过\\" → 提交「已在外院治疗」并关闭本次召回",
"objection": "### A. \\"我再考虑考虑\\"\\n> \\"完全理解,这是个不小的决定。这样,我先帮您把{您的主诊医生}的面诊时间留出来,**本周六上午或下周一晚上 7 点**,您选一个?到现场看了方案再决定也不晚。\\"\\n\\n### B. \\"价格太贵\\"\\n> \\"具体价格要看实际情况,{您的主诊医生}面诊后会给您 2-3 个方案,有不同档位可选。您先来评估,价格不合适咱们再聊别的方式。\\"\\n\\n### C. \\"已经在别的医院看了\\"\\n> \\"好的{患者称呼},那我这边帮您把这条记录关一下,日常护理还是按原来的周期回来就行,**祝您一切顺利**。\\"\\n> → 提交结果选「已在外院治疗」",
"close": "> \\"好的{患者称呼},那我帮您约 **本周六上午 10 点**,到时候提前 10 分钟到前台就行。我会给您发个短信提醒,您注意接收。还有别的需要么?\\"\\n\\n**回写要点**\\n- 成功约上面诊 → 提交结果选「成功转化为新预约」,填预约时间 + 医生\\n- 同意但未定日期 → 选「约定下次回访」,填预计时间\\n- 考虑中 → 选「考虑中近期再跟进」,7 天后系统提醒二次跟进"
}
\`\`\`
⚠️ few-shot 里的 \`{占位}\` 必须替换成 user prompt 给的真实字段:
- \`{诊所名}\` ← user prompt「诊所.名称」(严格照抄,不简称、不加字)
- \`{患者称呼}\` ← user prompt「患者.称呼」(脱敏后的姓 + 先生/女士)
- \`{您的主诊医生}\` ← user prompt「主诊医生」字段值,若为未知就用"您的主诊医生"泛指(绝不要编"李医生""王主任")
- \`{临床事实+牙位俗称}\` ← user prompt「上次到店」「待做治疗」字段,牙位**已经是俗称**(上门牙/智齿/大牙等),直接照抄,绝不要写成"21 牙""36 位"
按 JSON schema 输出,只输出 JSON,不要任何解释性文字。`;
} }
...@@ -67,9 +67,17 @@ export const DraftPlanScriptSchema = z.object({ ...@@ -67,9 +67,17 @@ export const DraftPlanScriptSchema = z.object({
.describe( .describe(
[ [
'【结束·信息确认段 markdown,必须严格按以下 2 部分格式】', '【结束·信息确认段 markdown,必须严格按以下 2 部分格式】',
'第 1 部分:`> "确认话术..."` blockquote — 复述敲定的安排:具体时间(周X晚上X点)+ 医生名 + 提醒方式(短信/企微)+ 礼貌结尾', '第 1 部分:`> "确认话术..."` blockquote — 复述敲定的安排:示例时间 + 医生名 + 提醒方式(短信/企微)+ 礼貌结尾',
' ⭐ 时间措辞要求(call 时段 PAC 无排班 API,LLM 给的具体时间只是 example,实际以诊所排班为准):',
' - 必须含"待确认"语义短语之一:"具体时段以诊所排班为准" / "稍后跟前台确认后短信通知您实际时间" / "实际时间稍后短信确认" / "我先按 X 登记,排班确认后告知"',
' - 出现具体时间时(如"周六上午10点"),**两种方式标记为示例,任选一**:',
' 方式 A: 不加粗,紧跟 `(示例)` 后缀 → "周六上午10点(示例)"',
' 方式 B: 用方向词代替具体点 → "周六上午这个方向"',
' - ⚠️ 严禁 `**周六上午10点**` 这种加粗具体时间 — 加粗读作"已确认重点",会让患者误以为时间真定了',
' - 严禁说"我已为您约好 X" / "已成功预约 X" / "约定 X" / "敲定 X" 这种确定承诺',
' - close 段只给 1 个时间示例 + 弱化短语,不要罗列多个备选(close 是收尾,不是商量)',
'第 2 部分:空行后 `**回写要点**` 标题 + 2-4 条 `- xxx → 提交结果选「xxx」` bullet — 列不同通话结果对应的客服回写动作', '第 2 部分:空行后 `**回写要点**` 标题 + 2-4 条 `- xxx → 提交结果选「xxx」` bullet — 列不同通话结果对应的客服回写动作',
'禁止:省略具体时间敲定、省略回写要点', '禁止:省略具体时间敲定、省略回写要点、加粗具体时间、用承诺式"已为您约好/约定/敲定"',
].join('\n'), ].join('\n'),
), ),
}); });
......
...@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; ...@@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import { fmtYearMonth } from '@pac/utils'; import { fmtYearMonth } from '@pac/utils';
import { planScenarioLabel, personaFeatureMeta } from '@pac/types'; import { planScenarioLabel, personaFeatureMeta, subLabelZh, treatmentCategoryNameZh } from '@pac/types';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { AiCallRunnerService } from '../ai-call-runner.service'; import { AiCallRunnerService } from '../ai-call-runner.service';
import { DraftPlanScriptCall } from '../calls/draft-plan-script/call'; import { DraftPlanScriptCall } from '../calls/draft-plan-script/call';
...@@ -305,7 +305,10 @@ export class PlanScriptOrchestrator { ...@@ -305,7 +305,10 @@ export class PlanScriptOrchestrator {
hostId: plan.hostId, hostId: plan.hostId,
tenantId: plan.tenantId, tenantId: plan.tenantId,
patientId: patient.id, patientId: patient.id,
status: 'active', // ⚠️ 必须包含 fulfilled — actual treatment_record 状态是 fulfilled 不是 active,
// 漏 fulfilled 会让 AI 看不到实际治疗记录(医生 / 治疗类目 / occurredAt)→
// primaryDoctor 偏移、pendingTreatments 漏算等。跟 plan-aggregate 同口径。
status: { in: ['active', 'fulfilled'] },
}, },
orderBy: { occurredAt: 'desc' }, orderBy: { occurredAt: 'desc' },
}); });
...@@ -321,12 +324,36 @@ export class PlanScriptOrchestrator { ...@@ -321,12 +324,36 @@ export class PlanScriptOrchestrator {
}): DraftPlanScriptInput { }): DraftPlanScriptInput {
const { plan, patient, persona, facts } = args; const { plan, patient, persona, facts } = args;
// ⭐ 就诊事件回退:跟 plan-aggregate.serializeProfile 同口径
// encounter_record(appointment.in_time != null 才产)很多 host 缺,改用 EMR 兜底
// 场景:DW 没 appointment 标到诊但 EMR 完整 → 之前 lastVisit/daysSinceLastVisit 全 null
const encounters = facts.filter((f) => f.type === 'encounter_record'); const encounters = facts.filter((f) => f.type === 'encounter_record');
const latestEnc = encounters[0]; const visitFacts = [...encounters, ...facts.filter((f) => f.type === 'emr_record')]
.filter((f) => f.occurredAt)
.sort((a, b) => b.occurredAt!.getTime() - a.occurredAt!.getTime());
const latestEnc = visitFacts[0];
const daysSinceLastVisit = latestEnc?.occurredAt const daysSinceLastVisit = latestEnc?.occurredAt
? Math.floor((Date.now() - latestEnc.occurredAt.getTime()) / 86400_000) ? Math.floor((Date.now() - latestEnc.occurredAt.getTime()) / 86400_000)
: null; : null;
// 给每条 reason 查"触发该诊断/建议的医生 + 日期"
// evidence.factIds[0] = 主触发 fact;关联 patient_facts.content.doctor_name + occurredAt
// LLM 在 followup 段必须引用触发医生(姜医生发现智齿)而非全患者高频医生(李医生)
const factById = new Map(facts.map((f) => [f.id, f]));
const resolveReasonTrigger = (r: PlanWithReasons['reasons'][number]) => {
const evidence = (r.evidence ?? {}) as { factIds?: string[] };
const leadFactId = evidence.factIds?.[0];
if (!leadFactId) return { doctor: null, date: null };
const lead = factById.get(leadFactId);
if (!lead) return { doctor: null, date: null };
const c = lead.content as Record<string, unknown> | null;
const doctor = (c?.doctor_name as string | undefined)?.trim() || null;
const date = lead.occurredAt
? lead.occurredAt.toISOString().slice(0, 10)
: null;
return { doctor, date };
};
return { return {
patient: { patient: {
// 注意:LLM 用的 nameMasked 是"路先生/女士"通话称呼,不是脱敏掩码"路*" // 注意:LLM 用的 nameMasked 是"路先生/女士"通话称呼,不是脱敏掩码"路*"
...@@ -343,11 +370,17 @@ export class PlanScriptOrchestrator { ...@@ -343,11 +370,17 @@ export class PlanScriptOrchestrator {
? planScenarioLabel(plan.reasons[0].scenario) ? planScenarioLabel(plan.reasons[0].scenario)
: '常规复诊召回', : '常规复诊召回',
priorityScore: plan.priorityScore, priorityScore: plan.priorityScore,
reasons: plan.reasons.map((r) => ({ goal: plan.goal,
reasons: plan.reasons.map((r) => {
const trig = resolveReasonTrigger(r);
return {
scenarioLabel: planScenarioLabel(r.scenario), scenarioLabel: planScenarioLabel(r.scenario),
reason: r.reason, reason: r.reason,
priorityScore: r.priorityScore, priorityScore: r.priorityScore,
})), triggerDoctor: trig.doctor,
triggerDate: trig.date,
};
}),
}, },
personaHighlights: (persona?.features ?? []).slice(0, 5).map((f) => ({ personaHighlights: (persona?.features ?? []).slice(0, 5).map((f) => ({
label: personaFeatureMeta(f.key).label, label: personaFeatureMeta(f.key).label,
...@@ -356,8 +389,13 @@ export class PlanScriptOrchestrator { ...@@ -356,8 +389,13 @@ export class PlanScriptOrchestrator {
clinicalContext: { clinicalContext: {
daysSinceLastVisit, daysSinceLastVisit,
lastVisitSummary: summarizeLastVisit(latestEnc), lastVisitSummary: summarizeLastVisit(latestEnc),
pendingTreatments: extractPendingTreatments(facts), // pendingTreatments 直接从 plan.reasons 派生 — 召回触发的 reason 本身就是"未启动治疗"
treatmentChainSummary: summarizeChain(encounters), // 旧版用 DX_TO_CAT 内置 map 漏 K01/K03/K06/K07,导致阻生牙/牙体损伤等场景空
// reasons 是 SQL 算出的权威集,数据已对齐 chain-composer
pendingTreatments: extractPendingFromReasons(plan.reasons),
treatmentChainSummary: summarizeChain(visitFacts),
ongoingChains: summarizeOngoingChains(facts),
completedTreatmentCount: countCompletedTreatments(facts),
primaryDoctorName: extractPrimaryDoctor(facts), primaryDoctorName: extractPrimaryDoctor(facts),
}, },
}; };
...@@ -452,16 +490,49 @@ function calcAge(birthDate: Date): number { ...@@ -452,16 +490,49 @@ function calcAge(birthDate: Date): number {
function summarizeLastVisit(enc: FactRow | undefined): string | null { function summarizeLastVisit(enc: FactRow | undefined): string | null {
if (!enc) return null; if (!enc) return null;
const c = enc.content as Record<string, unknown> | null; const c = enc.content as Record<string, unknown> | null;
const summary = enc.summary ?? (c?.diagnosis as string | undefined) ?? (c?.treatment_category as string | undefined); let summary = enc.summary ?? (c?.diagnosis as string | undefined) ?? (c?.treatment_category as string | undefined);
// 过滤内部 ID 类字符串(如 EMR 的 "关联接诊:16737540" / 纯 UUID / 纯数字串)
// 这些是 raw ID,LLM 把它念给患者会很奇怪
if (summary && /关联接诊[::]|^[0-9a-f-]{8,}$|^\d{6,}$/.test(summary)) {
summary = undefined;
}
if (!summary && !enc.occurredAt) return null; if (!summary && !enc.occurredAt) return null;
const when = enc.occurredAt ? fmtYearMonth(enc.occurredAt) : '近期'; const when = enc.occurredAt ? fmtYearMonth(enc.occurredAt) : '近期';
return `${when}${summary ?? '到店就诊'}`; return `${when}${summary ?? '到店就诊'}`;
} }
/** /**
* 从 plan.reasons 派生 pendingTreatments(待办治疗列表,LLM 在 followup 段引用)。
*
* 为什么从 reasons 而不是 facts 派生:
* - reasons 是 SQL 算出的权威"未启动治疗"集,跟 chain-composer / 召回算法对齐
* - facts 派生需要 DX_TO_CAT 内置 map,K01/K03/K06/K07 等场景缺映射 → 漏算
* - reason.subKey 直接映射子场景中文 label(阻生牙未拔除 / 龋齿未做充填 等)
* - signals.toothPosition 是 SQL 已合并的牙位 union,字段一致可信
*/
function extractPendingFromReasons(
reasons: Array<{ scenario: string; subKey: string | null; signals: unknown }>,
): string[] {
const items: string[] = [];
for (const r of reasons) {
// sub_key 形如 'caries_no_filling@36' / 'perio_no_srp@whole';subLabelZh 接受不带 @ 的 base
const baseSubKey = (r.subKey ?? '').split('@')[0]!;
const subLabel = subLabelZh(r.scenario, baseSubKey);
const sig = (r.signals ?? {}) as { toothPosition?: string | null };
const toothRaw = (sig.toothPosition ?? '').trim();
const friendly = toothRaw ? toothFriendly(toothRaw) : '';
items.push(friendly ? `${subLabel} · ${friendly}` : subLabel);
}
// 去重(同 subLabel 多牙位会被 union-find 合并到一行,这里防御性 dedup)
return Array.from(new Set(items)).slice(0, 5);
}
/**
* v2.1:读独立 diagnosis_record / recommendation_record fact,找出"有诊断/建议但未启动治疗"的待办。 * v2.1:读独立 diagnosis_record / recommendation_record fact,找出"有诊断/建议但未启动治疗"的待办。
* 不再读 encounter_record 嵌套字段(encounter 已退化只元数据)。 * 不再读 encounter_record 嵌套字段(encounter 已退化只元数据)。
* @deprecated 改用 extractPendingFromReasons — facts 派生有 DX_TO_CAT 漏映射 bug
*/ */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function extractPendingTreatments(facts: FactRow[]): string[] { function extractPendingTreatments(facts: FactRow[]): string[] {
// 已做的治疗 category // 已做的治疗 category
const doneCats = new Set<string>(); const doneCats = new Set<string>();
...@@ -589,22 +660,28 @@ function fdiToFriendly(fdi: string): string | null { ...@@ -589,22 +660,28 @@ function fdiToFriendly(fdi: string): string | null {
* 没抽到 → null(prompt 里走 fallback,让 LLM 用"您的主诊医生"泛指,不编)。 * 没抽到 → null(prompt 里走 fallback,让 LLM 用"您的主诊医生"泛指,不编)。
*/ */
function extractPrimaryDoctor(facts: FactRow[]): string | null { function extractPrimaryDoctor(facts: FactRow[]): string | null {
// facts 已按 occurredAt desc 排序,优先 treatment/diagnosis(更"主诊"),其次 emr/encounter // 主诊医生 = 跨所有 facts 中 doctor_id 出现频次 top 1 的医生
const candidates = facts.filter( //
(f) => f.type === 'treatment_record' || f.type === 'diagnosis_record', // ⚠️ 必须跟前端 KeyFactsCard 同口径(plan-detail-app.tsx attendingDoctor),
); // 否则会出现"UI 显李医生 / AI 话术写王医生"的不一致 → 客服困惑 + AI 编造
for (const f of candidates) { //
const c = f.content as Record<string, unknown> | null; // 老版本(W4)用"最新一次 treatment/diagnosis 的医生" — 语义偏:
const name = (c?.doctor_name as string | undefined)?.trim(); // 患者 5 年都是李医生治,最近一次姜医生临时接诊 → 老版选姜,新版选李
if (name && name.length > 0 && name.length <= 10) return name; // "主诊"业务语义 = 长期管该患者的人,不是"最近一次接诊的"
} const idCount = new Map<string, number>();
// 兜底:再扫所有 fact 任一 doctor_name const idToName = new Map<string, string>();
for (const f of facts) { for (const f of facts) {
const c = f.content as Record<string, unknown> | null; const c = f.content as Record<string, unknown> | null;
const name = (c?.doctor_name as string | undefined)?.trim(); if (!c) continue;
if (name && name.length > 0 && name.length <= 10) return name; const id = c.doctor_id ? String(c.doctor_id) : '';
const name = (c.doctor_name as string | undefined)?.trim() ?? '';
if (!id) continue;
idCount.set(id, (idCount.get(id) ?? 0) + 1);
if (name && name.length > 0 && name.length <= 10) idToName.set(id, name);
} }
return null; if (idCount.size === 0) return null;
const topId = [...idCount.entries()].sort((a, b) => b[1] - a[1])[0]![0];
return idToName.get(topId) ?? null;
} }
function summarizeChain(encounters: FactRow[]): string | null { function summarizeChain(encounters: FactRow[]): string | null {
...@@ -613,6 +690,58 @@ function summarizeChain(encounters: FactRow[]): string | null { ...@@ -613,6 +690,58 @@ function summarizeChain(encounters: FactRow[]): string | null {
return ` ${encounters.length} 次就诊记录`; return ` ${encounters.length} 次就诊记录`;
} }
/**
* 正在进行的治疗链摘要(每个 category 一句):
* "牙周治疗在管 · 上次龈上洁治 · 吴医生 · 2024.04.27"
*
* 算法:
* - 按 category 分组 actual treatments(排除 review)
* - 每组取最新 1 条(occurredAt 最大)作 cluster 代表
* - 按最新时间 DESC 排,取前 4 条
*
* 给 LLM 的用处:
* ① 避免话术重复邀约已经在管的治疗(牙周已在做就不该再说"建议来做牙周")
* ② 引用历史治疗(吴医生 4 月给做过洁牙)体现"诊所记得 ta"
*/
function summarizeOngoingChains(facts: FactRow[]): string[] {
const latestByCategory = new Map<string, FactRow>();
for (const f of facts) {
if (f.type !== 'treatment_record' || f.kind !== 'actual') continue;
if (!f.occurredAt) continue;
const c = f.content as Record<string, unknown> | null;
const cat = c?.category as string | undefined;
if (!cat || cat === 'review') continue;
const existing = latestByCategory.get(cat);
if (!existing || (existing.occurredAt && f.occurredAt > existing.occurredAt)) {
latestByCategory.set(cat, f);
}
}
const sorted = [...latestByCategory.entries()].sort((a, b) => {
return (b[1].occurredAt?.getTime() ?? 0) - (a[1].occurredAt?.getTime() ?? 0);
});
return sorted.slice(0, 4).map(([cat, latest]) => {
const c = latest.content as Record<string, unknown> | null;
const subtype = (c?.subtype as string | undefined)?.trim();
const doctor = (c?.doctor_name as string | undefined)?.trim();
const when = latest.occurredAt
? `${latest.occurredAt.getFullYear()}.${String(latest.occurredAt.getMonth() + 1).padStart(2, '0')}`
: '近期';
const bits = [`${treatmentCategoryNameZh(cat)}在管`];
if (subtype) bits.push(`上次 ${subtype}`);
if (doctor) bits.push(`${doctor}医生`);
bits.push(when);
return bits.join(' · ');
});
}
/**
* 历史已做治疗总次数(信任锚)— actual + status fulfilled 的 treatment_record 数。
* LLM 用:老客可以更家常,新客需要更详细的自报家门。
*/
function countCompletedTreatments(facts: FactRow[]): number {
return facts.filter((f) => f.type === 'treatment_record' && f.kind === 'actual').length;
}
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
// 类型(避免循环依赖,本地声明 Prisma 结构投影) // 类型(避免循环依赖,本地声明 Prisma 结构投影)
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment