Commit 7115d68c by luoqi

feat(ai-script + aggregate): 话术按业务规格对齐 + 去提示词污染 + 详情页数据透出

话术(draft-plan-script):
- 删 AI_SCRIPT_USE_SKILLS 开关与 legacy system prompt(只剩 skills 路径)
- 术语 漏诊→应治未治 全量对齐;base/成人/儿童 skill 逐字还原业务原模板
- 清除 system + user prompt 污染(去 PAC 内部说明/解释/溯源/内部元数据/金额/FDI/方案词)
- 占位符统一:{xxx}=填值替换、【xxx】=原样保留(时间段/具体预约时间/缺失牙位)
- 停用 14 个旧 skill(诊断 K00-K09/场景/关系/异议库/safety-rules)— 不在业务规格内且与新结构冲突
- composer:age 未知默认成人;skill 拼接去内部 name/version
- 自报家门用登录客服 岗位+姓名(input.agent;controller resolveAgent;mockLogin 配演示人名)

聚合(plan-aggregate):
- parseScriptMarkdownToSections 标题/ID 对齐新 4 段(修"刷新后话术空白")
- 透出 病历号 medicalRecordNumber、专属客服 dedicatedCs、persona feature.data(保险等)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent a18083f6
...@@ -3,11 +3,7 @@ import type { AiCall } from '../../ai-call.interface'; ...@@ -3,11 +3,7 @@ import type { AiCall } from '../../ai-call.interface';
import type { SafetyRule } from '../../core/safety-gate.service'; import type { SafetyRule } from '../../core/safety-gate.service';
import { DraftPlanScriptSchema } from './schema'; import { DraftPlanScriptSchema } from './schema';
import type { DraftPlanScriptInput, DraftPlanScriptOutput } from './input.types'; import type { DraftPlanScriptInput, DraftPlanScriptOutput } from './input.types';
import { import { buildDraftPlanScriptPrompt } from './prompt';
DRAFT_PLAN_SCRIPT_PROMPT_VERSION_LEGACY,
DRAFT_PLAN_SCRIPT_SYSTEM_LEGACY,
buildDraftPlanScriptPrompt,
} from './prompt';
import { composeSystem } from './skill-composer'; import { composeSystem } from './skill-composer';
import { DraftPlanScriptSkillRegistry } from './skill-registry.service'; import { DraftPlanScriptSkillRegistry } from './skill-registry.service';
import { import {
...@@ -92,7 +88,7 @@ function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput { ...@@ -92,7 +88,7 @@ function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
const salutation = resolveSalutation({ nameMasked: patient.nameMasked, gender: patient.gender, branch }); const salutation = resolveSalutation({ nameMasked: patient.nameMasked, gender: patient.gender, branch });
// 漏诊项 = PAC 应治未治 reason(取 priorityScore 最高的一条)→ 转换层归一 // 漏诊项 = PAC 应治未治 reason(取 priorityScore 最高的一条)→ 转换层归一
const topReason = [...(plan.reasons ?? [])].sort((a, b) => b.priorityScore - a.priorityScore)[0]; const topReason = [...(plan.reasons ?? [])].sort((a, b) => b.priorityScore - a.priorityScore)[0];
const doctor = topReason?.triggerDoctor ?? clinicalContext.primaryDoctorName ?? '您的主医生'; const doctor = topReason?.triggerDoctor ?? clinicalContext.primaryDoctorName ?? '您的主医生';
// 日期优先取"那次诊断"的(项目相关),否则退回最近一次就诊 // 日期优先取"那次诊断"的(项目相关),否则退回最近一次就诊
const dateBasis = topReason?.triggerDate const dateBasis = topReason?.triggerDate
? new Date(topReason.triggerDate) ? new Date(topReason.triggerDate)
...@@ -129,19 +125,11 @@ function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput { ...@@ -129,19 +125,11 @@ function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
} }
/** /**
* 是否启用 Skills 系统(env 开关,出问题秒回滚)。 * promptVersion(base 版本;具体装配的 skill 组合见 agent_invocations.input_snapshot.skills_used 的 composeHash)。
* 默认 1 启用;设 0 退回 legacy 单 prompt 路径。 * 改 system/prompt 文本 → bump 字母;改 schema → bump 日期。
*/
function isSkillsEnabled(): boolean {
return (process.env.AI_SCRIPT_USE_SKILLS ?? '1') !== '0';
}
/**
* Skills 模式 promptVersion(base 版本,跟 legacy 区分;
* composeHash 可以在 agent_invocations.input_snapshot.skills_used 看到具体装配)。
*/ */
const DRAFT_PLAN_SCRIPT_PROMPT_VERSION_SKILLS = const DRAFT_PLAN_SCRIPT_PROMPT_VERSION =
'draft_plan_script@2026-06-02-4module-v3'; // v3: 4 模块重构 + 确定性事实注入 + 单一聚焦(漏诊项=应治未治) 'draft_plan_script@2026-06-02-4module-v9'; // v9: 自报家门用登录客服 岗位+姓名(agent);v8: 占位符统一({}=替换、【】=原样保留);v7: 清除 user prompt 污染(去内部元数据/其他应治未治项/金额/FDI/方案词/冗余说明,只留模板占位值);v6: 清 system 污染;v5: 还原原模板
@Injectable() @Injectable()
export class DraftPlanScriptCall export class DraftPlanScriptCall
...@@ -150,11 +138,7 @@ export class DraftPlanScriptCall ...@@ -150,11 +138,7 @@ export class DraftPlanScriptCall
private readonly logger = new Logger(DraftPlanScriptCall.name); private readonly logger = new Logger(DraftPlanScriptCall.name);
readonly kind = 'script' as const; readonly kind = 'script' as const;
readonly callKey = 'draft_plan_script'; readonly callKey = 'draft_plan_script';
// ⚠️ promptVersion 选 skills 版还是 legacy 版,取决于 env; readonly promptVersion = DRAFT_PLAN_SCRIPT_PROMPT_VERSION;
// 两版输出差异会让 agent_invocations.promptVersion 区分开,SQL 对比效果时拆分群体
readonly promptVersion = isSkillsEnabled()
? DRAFT_PLAN_SCRIPT_PROMPT_VERSION_SKILLS
: DRAFT_PLAN_SCRIPT_PROMPT_VERSION_LEGACY;
readonly defaultModelId = 'deepseek-v4-flash'; readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DraftPlanScriptSchema; readonly outputSchema = DraftPlanScriptSchema;
readonly safetyRules = safetyRules; readonly safetyRules = safetyRules;
...@@ -162,15 +146,7 @@ export class DraftPlanScriptCall ...@@ -162,15 +146,7 @@ export class DraftPlanScriptCall
constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {} constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {}
buildPrompt(input: DraftPlanScriptInput) { buildPrompt(input: DraftPlanScriptInput) {
if (!isSkillsEnabled()) { // composer 装配 system(base-system.md + 命中的 skill 正文);user prompt 按患者拼事实
// legacy 路径 — 老 prompt 单系统词
return {
system: DRAFT_PLAN_SCRIPT_SYSTEM_LEGACY,
prompt: buildDraftPlanScriptPrompt(input),
};
}
// skills 路径 — composer 装配 system(含 skill 正文);user prompt 不再重列 skills 名字
const composed = composeSystem(input, this.skillRegistry.getAllSkills()); const composed = composeSystem(input, this.skillRegistry.getAllSkills());
if (composed.matchedSkills.length === 0) { if (composed.matchedSkills.length === 0) {
this.logger.warn( this.logger.warn(
......
...@@ -20,6 +20,11 @@ export interface DraftPlanScriptInput { ...@@ -20,6 +20,11 @@ export interface DraftPlanScriptInput {
/** 诊所名(给 LLM 用作"我是X诊所的客服顾问",避免编造"XX口腔") */ /** 诊所名(给 LLM 用作"我是X诊所的客服顾问",避免编造"XX口腔") */
clinicName: string; clinicName: string;
/** ⭐ 当前回访客服(自报家门用:岗位角色 + 姓名,如 客服主管/李莉)。
* 来源:登录用户 JWT(role→患者侧称呼 + dictionary.users[sub]→姓名)。
* name 为空 → prompt 退回通用"客服",不编名字。 */
agent?: { name: string | null; roleTitle: string };
/** Plan 信息 */ /** Plan 信息 */
plan: { plan: {
/** 主场景 label(从 scenario 枚举翻译,如"治疗后复诊召回"/"漏治-缺失牙"等) */ /** 主场景 label(从 scenario 枚举翻译,如"治疗后复诊召回"/"漏治-缺失牙"等) */
...@@ -41,12 +46,12 @@ export interface DraftPlanScriptInput { ...@@ -41,12 +46,12 @@ export interface DraftPlanScriptInput {
priorityScore: number; priorityScore: number;
/** 触发该诊断/建议的医生(LLM 在 followup 段必须引用此人,不要用 primaryDoctorName) /** 触发该诊断/建议的医生(LLM 在 followup 段必须引用此人,不要用 primaryDoctorName)
* 来源:reason.evidence.factIds[0] → patient_facts.content.doctor_name * 来源:reason.evidence.factIds[0] → patient_facts.content.doctor_name
* null = 该 fact 无医生记录 → LLM fallback "您的主医生" 泛指 */ * null = 该 fact 无医生记录 → LLM fallback "您的主医生" 泛指 */
triggerDoctor: string | null; triggerDoctor: string | null;
/** 触发诊断/建议的日期(YYYY-MM-DD),给 LLM 在话术里用,如"上次姜医生 X 月 X 日给您检查时...") */ /** 触发诊断/建议的日期(YYYY-MM-DD),给 LLM 在话术里用,如"上次姜医生 X 月 X 日给您检查时...") */
triggerDate: string | null; triggerDate: string | null;
/** ⭐ 项目相关:该诊断**那次接诊**的主诉(emr illness_desc 原文)。 /** ⭐ 项目相关:该诊断**那次接诊**的主诉(emr illness_desc 原文)。
* 单一聚焦时优于"最近一次主诉"—— 缺牙是哪次来发现的、那次为什么来,更贴合本漏诊项。可空 */ * 单一聚焦时优于"最近一次主诉"—— 缺牙是哪次来发现的、那次为什么来,更贴合本应治未治项。可空 */
triggerChiefComplaint?: string | null; triggerChiefComplaint?: string | null;
}>; }>;
}; };
...@@ -65,7 +70,7 @@ export interface DraftPlanScriptInput { ...@@ -65,7 +70,7 @@ export interface DraftPlanScriptInput {
lastChiefComplaint?: string | null; lastChiefComplaint?: string | null;
pendingTreatments: string[]; // 待做治疗(简短描述,牙位已转患者俗称,如"缺失牙(下门牙)") pendingTreatments: string[]; // 待做治疗(简短描述,牙位已转患者俗称,如"缺失牙(下门牙)")
treatmentChainSummary: string | null; // 治疗链当前阶段一句话 treatmentChainSummary: string | null; // 治疗链当前阶段一句话
/** 主医生名(从最近 treatment/diagnosis fact 抽);LLM 必须用此名,不可编造 */ /** 主医生名(从最近 treatment/diagnosis fact 抽);LLM 必须用此名,不可编造 */
primaryDoctorName: string | null; primaryDoctorName: string | null;
/** ⭐ 正在进行的治疗链摘要(每条一句:"牙周治疗在管 · 上次龈上洁治 · 吴医生 · 2024.04.27") /** ⭐ 正在进行的治疗链摘要(每条一句:"牙周治疗在管 · 上次龈上洁治 · 吴医生 · 2024.04.27")
* LLM 用于:① 不重复邀约已在管的治疗 ② 引用历史治疗显出"诊所记得 ta" */ * LLM 用于:① 不重复邀约已在管的治疗 ② 引用历史治疗显出"诊所记得 ta" */
...@@ -96,10 +101,10 @@ export interface DraftPlanScriptOutput { ...@@ -96,10 +101,10 @@ export interface DraftPlanScriptOutput {
/** 第一部分·开场白 markdown(以医生名义 + 智能称呼 + 智能日期 + 自报家门) */ /** 第一部分·开场白 markdown(以医生名义 + 智能称呼 + 智能日期 + 自报家门) */
opening: string; opening: string;
/** 第二部分·告知漏诊项目 markdown(4 短句:现状/风险/关怀/专业建议,只讲单个漏诊项) */ /** 第二部分·告知应治未治 markdown(成人 4 句/儿童 5 句:现状/风险/关怀/专业建议,只讲单个应治未治项) */
informMissed: string; informMissed: string;
/** 第三部分·复查建议 markdown(4 短句:重要性/维护/复查时长/引导预约【时间段】) */ /** 第三部分·复查建议 markdown(成人 4 句/儿童 5 句:重要性/维护/复查时长/引导预约【时间段】) */
reviewAdvice: string; reviewAdvice: string;
/** 第四部分·结束回访语 markdown(预约成功 / 不成功 两种) */ /** 第四部分·结束回访语 markdown(预约成功 / 不成功 两种) */
......
...@@ -11,167 +11,22 @@ import { ...@@ -11,167 +11,22 @@ import {
/** /**
* Prompt 版本管理约定: * Prompt 版本管理约定:
* - 格式 "{callKey}@{date}-{letter}" * - 格式 "{callKey}@{date}-{letter}"
* - 改 prompt 文本 / system 指令 / few-shot examples → 必须 bump letter(a→b→c...) * - 改 prompt 文本 / system 指令 → 必须 bump letter(a→b→c...)
* - 改 schema 字段 → 必须 bump date(影响输入输出形态) * - 改 schema 字段 → 必须 bump date(影响输入输出形态)
* - bump 后,AgentInvocation.promptVersion 字段会落新版本, * - bump 后,AgentInvocation.promptVersion 字段会落新版本,
* SQL: `SELECT prompt_version, avg(judge_score), avg(cost_yuan) FROM agent_invocations * SQL: `SELECT prompt_version, avg(judge_score), avg(cost_yuan) FROM agent_invocations
* WHERE call_key='draft_plan_script' GROUP BY prompt_version ORDER BY started_at DESC` * WHERE call_key='draft_plan_script' GROUP BY prompt_version ORDER BY started_at DESC`
* 即可对比版本效果。 * 即可对比版本效果。
* *
* 历史: * ⭐ 现行 promptVersion 常量在 call.ts。system prompt 由 skill-composer 装配
* - 2026-05-17-a — 初版,deepseek-v4-pro,5 字段(opening/keyMessage/followup/objectionHandling/callToAction) * (base-system.md + 命中的 SKILL.md),本文件只负责 user prompt(按患者拼事实)。
* - 2026-05-24-b — B 方案重写,deepseek-v4-flash,4 段直出 markdown
* + few-shot demo,对齐前端 mockScript 4 段格式
* - 2026-05-24-c — 事实漂移修:补医生名/牙位俗称/诊所名硬约束
* few-shot 改用 {占位符} 防止抄具体名字
* - 2026-05-24-d — 称呼用通话名(姓+先生/女士);明禁念 scenario 内部 label;
* 要求 opening/followup 引用 ≥1 / ≥2 条具体临床事实
* - 2026-05-27-time-marker (legacy 终点) — §0 总则白名单 + (示例) 时间标记
* - 2026-05-27-skills-base-v1 (现行) — base + skills harness;legacy 保留供 env=0 回滚
* *
* ⭐ 现行 prompt version 在 call.ts(因为依赖 env switch);本文件 *_LEGACY 是 fallback 路径。 * 历史:
*/ * - 2026-05-17-a — 初版,5 字段
export const DRAFT_PLAN_SCRIPT_PROMPT_VERSION_LEGACY = * - 2026-05-24-b/c/d — B 方案 4 段 markdown + 事实漂移修
'draft_plan_script@2026-05-27-time-marker'; * - 2026-05-27-skills-base-v1 — 引入 base + skills harness,删除单块 legacy system
* - 2026-06-02-4module-v3 (现行) — 4 模块重构 + 确定性事实注入 + 单一聚焦
/**
* System prompt(稳定指令,不随 input 变)。
*
* 设计原则(2026-05-27 重构):
* - **正向从宽**:只列必须满足的条目,段内子结构靠 schema .describe() 强约束(已足够),不堆 few-shot
* - **反向从严**:严禁编造、严禁推断未提供事实、严禁销售文风。穷尽枚举常见越界场景
* - 删除 few-shot JSON 大段:它让 LLM 把例子里的实写文本当模板照抄("工作日 19:00 后" 等伪事实就这么漏的)
* - 输出 shape 完全靠 generateObject + zod schema(LLM 强制按 shape 走)
*/
/**
* Legacy 全量 system prompt — env AI_SCRIPT_USE_SKILLS=0 时使用。
* Skills 模式下不再使用,只作回滚保险。新内容应改 base-system.md + 对应 SKILL.md。
*/ */
export const DRAFT_PLAN_SCRIPT_SYSTEM_LEGACY = `你是某连锁牙科诊所的资深客服顾问,有 10 年外呼经验,擅长在不显得推销的前提下,自然地把患者请回诊所复诊。
# 一、正向要求(从宽 — 只列必须做到的)
1. **结构**:输出 1 个 JSON,5 个 key:tone / opening / followup / objection / close。后 4 个是 Markdown 字符串,每段内的子结构按 schema .describe() 自由发挥(目的 / 正文 blockquote / 注意 / 异议预判 / 回写要点 等已说清)。
2. **引事实**:opening + followup 加起来,自然引用**至少 3 条** user prompt 给的具体临床事实(从「触发原因」/「待做治疗」/「上次到店」/「距上次天数」/「主诊医生」中挑)。
3. **医生引用规则**:
- followup 段引用某条诊断时,**优先用该 reason 的"触发医生"**(user prompt 在 reason 行后给了 \`触发医生:XXX\`)
- 邀约面诊默认用"长期主诊医生"(user prompt 给的 \`该患者长期主诊医生\` 字段)
- 两者都缺时用"您的主诊医生"泛指
- 触发医生跟引用的具体诊断必须对应(姜医生发现智齿就说"姜医生",不要写"李医生")
4. **牙位俗称**:对患者只能说俗称(智齿 / 大牙 / 前牙 / 上门牙 / 下门牙 / 虎牙 / 后牙)。user prompt「待做治疗」已转俗称,直接照抄。
5. **具体时间**:邀约面诊必须给具体选项(如"本周六上午 / 下周一晚上 7 点"),不能只"有空过来"。患者要能立即回"好,周六可以"。
6. **异议**:覆盖最常见 3-4 种(再考虑 / 价格 / 没时间 / 已在外院),不必面面俱到。
7. **称呼**:严格用 user prompt「患者.称呼」给的字符串(已是"X 先生/女士" 通话名),整体照抄。
8. **诊所名**:严格用 user prompt「诊所.名称」给的字符串,不简称、不补字。
9. **tone**:自选 warm(温和家常) / professional(专业稳重) / urgent(有时效紧迫),适配患者画像。
# 二、反向约束(从严 — 任一出现即视为失败)
## 0. 总则 ⭐(以下所有具体禁令的母规则)
**话术中出现的任何具体事实**(医生名 / 价格 / 时间 / 政策 / 设备 / 诊断 / 治疗 / 偏好 / 患者背景 ...)**必须可追溯到 user prompt 下列字段之一**:
\`\`\`
诊所: 「诊所.名称」
患者: 「患者.称呼 / 性别 / 年龄」
召回原因: 「触发原因」每行(含触发医生 + 日期)
画像: 「患者画像关键特征」每行
临床上下文: 「距上次到店」「上次到店」「该患者长期主诊医生」「治疗链状态」「待做治疗」
\`\`\`
**白名单之外的任何具体表述都视为虚构 → 失败**。模糊或泛指(如"医生""我们诊所""稍后")不算虚构;**带数字、带具体名词、带具体政策**就要白名单兜底。
> 自检方法:输出前每写一个具体陈述,问自己"这条信息出自上面哪个字段?"答不上就删掉或改泛指。
## 1. 常见违规示例(以下都属于违反 §0 总则)
### 患者背景类(PAC 无字段 → 严禁)
- ❌ 偏好通话时段("工作日 19:00 后"/"周末有空"/"晚饭后")
- ❌ 职业 / 家庭 / 收入 / 经济状况
- ❌ "您之前提过 / 您说过 X" — 假装客服历史
- ❌ "您比较忙 / 您时间不固定" — 推测患者状态
### 价格 / 服务政策类(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 成具体上下文。
...@@ -179,31 +34,16 @@ followup / objection 段是邀约 / 应对异议,可以给多个时间选项供 ...@@ -179,31 +34,16 @@ followup / objection 段是邀约 / 应对异议,可以给多个时间选项供
* 设计: * 设计:
* - 把患者信息以"病历摘要"风格组织,LLM 对自然语言上下文比对 JSON 上下文更稳 * - 把患者信息以"病历摘要"风格组织,LLM 对自然语言上下文比对 JSON 上下文更稳
* - skills 正文已直接注入 system(composeSystem),user prompt 不再重列名字清单(冗余,已删) * - skills 正文已直接注入 system(composeSystem),user prompt 不再重列名字清单(冗余,已删)
* * - 能程序算的事实(称呼/日期/主漏诊项/复查时长/风险优势)全在 script-facts.ts 算好,
* inline hint(原 §临床上下文"老客可家常 / 新客需详细")已删除,改由 relationship skill 承担。 * LLM 只润色,不做年龄分支/日期格式/优先级/查表等确定性判断
*/ */
export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string { export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string {
const { patient, clinicName, plan, personaHighlights, clinicalContext } = input; const { patient, clinicName, plan, clinicalContext } = input;
const personaLines = personaHighlights.length > 0 // 单一聚焦:只取 priorityScore 最高那条 reason;其他 reason 的内容**完全不进 prompt**(不泄漏其他项)。
? personaHighlights.map((p) => `- ${p.label}:${p.description}`).join('\n') const top = [...plan.reasons].sort((a, b) => b.priorityScore - a.priorityScore)[0];
: '- (暂无关键画像特征)';
// ⭐ 单一聚焦(对齐业务提示词"只处理最高优先级漏诊项,其他完全忽略"): // 程序算好的确定性事实(LLM 不做年龄分支/日期格式/优先级/查表/称呼)
// 只把 priorityScore 最高的那条 reason 喂进去;其他 reason **不展示**(LLM 看不到 → 不会提)。
const sortedReasons = [...plan.reasons].sort((a, b) => b.priorityScore - a.priorityScore);
const top = sortedReasons[0];
const otherCount = Math.max(0, sortedReasons.length - 1);
const topReasonLine = top
? `${top.reason}${
[top.triggerDoctor ? `触发医生:${top.triggerDoctor}` : '', top.triggerDate ? `日期:${top.triggerDate}` : '']
.filter(Boolean)
.join(' · ')
.replace(/^(.+)$/, ' ($1)')
}`
: '(无具体触发原因)';
// ⭐ 程序算好的确定性事实(渐进式组合:LLM 不做年龄分支/日期格式/优先级/查表/称呼)
const now = new Date(); const now = new Date();
const branch = resolveAgeBranch(patient.age); const branch = resolveAgeBranch(patient.age);
const salutation = resolveSalutation({ nameMasked: patient.nameMasked, gender: patient.gender, branch }); const salutation = resolveSalutation({ nameMasked: patient.nameMasked, gender: patient.gender, branch });
...@@ -211,62 +51,56 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string ...@@ -211,62 +51,56 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
clinicalContext.daysSinceLastVisit != null clinicalContext.daysSinceLastVisit != null
? new Date(now.getTime() - clinicalContext.daysSinceLastVisit * 86400_000) ? new Date(now.getTime() - clinicalContext.daysSinceLastVisit * 86400_000)
: null; : null;
// ⭐ 项目相关:日期/主诉/医生优先取"该漏诊项**那次诊断**"的,而非泛泛"最近一次就诊" // 项目相关:日期/主诉/医生优先取"该应治未治项那次诊断"的,而非泛泛"最近一次就诊"
// (top = priorityScore 最高那条 reason,见上方单一聚焦块)
const projectDate = top?.triggerDate ? new Date(top.triggerDate) : lastVisitDate; const projectDate = top?.triggerDate ? new Date(top.triggerDate) : lastVisitDate;
const dateDisplay = smartDateDisplay(projectDate, now) ?? '上次'; const dateDisplay = smartDateDisplay(projectDate, now) ?? '上次';
const chiefComplaint = top?.triggerChiefComplaint ?? clinicalContext.lastChiefComplaint ?? null; const chiefComplaint = top?.triggerChiefComplaint ?? clinicalContext.lastChiefComplaint ?? null;
const missed = top ? missedFromReason(top) : { label: plan.primaryScenarioLabel, key: null }; const missed = top ? missedFromReason(top) : { label: plan.primaryScenarioLabel, key: null };
const kp = lookupKeyPoints(missed.key); const kp = lookupKeyPoints(missed.key);
const reviewDuration = lookupReviewDuration(missed.key); const reviewDuration = lookupReviewDuration(missed.key);
const doctor = top?.triggerDoctor ?? clinicalContext.primaryDoctorName ?? '您的主诊医生'; const doctor = top?.triggerDoctor ?? clinicalContext.primaryDoctorName ?? '您的主治医生';
const mrn = patient.medicalRecordNumber ?? null;
return `# 召回任务背景(以下字段是 100% 可信的事实源,只能用这些,不能编额外的)
## ⭐ 程序已算好的事实(直接用,**不要自己改 / 不要自己推断或重新格式化**) const riskLines = kp ? kp.risks.map((r) => ` - ${r}`).join('\n') : ' - (按常识温和提醒,不吓唬人)';
- 智能称呼(开场用这个):${salutation} const advLines = kp ? kp.advantages.map((a) => ` - ${a}`).join('\n') : ' - 趁现在早点处理会更省心';
- 智能日期(开场"自从X检查后"用这个 — 是**该漏诊项那次诊断**的日期,非泛泛最近):${dateDisplay}
- 那次就诊主诉(该漏诊项那次来为什么看,可作开场关怀上下文,别照念医学词):${chiefComplaint ?? '无记录'}
- 主漏诊项(本次**只讲这一个**,严禁提其他):${missed.label}
- 接诊/主诊医生(以此人名义体现关怀):${doctor}
- 复查时长(复查建议·检查说明 直接用原文):${reviewDuration}
- 风险要点(告知漏诊·健康提醒 从中挑 1-2 条口语化,不堆砌):
${kp ? kp.risks.map((r) => ` - ${r}`).join('\n') : ' - (按常识温和提醒,不吓唬人)'}
- 治疗优势(告知漏诊·个人化关怀 挑 1 条,用"趁现在/早一点"口吻):
${kp ? kp.advantages.map((a) => ` - ${a}`).join('\n') : ' - 趁现在早点处理会更省心'}
## 诊所 // 患者基本信息(只给话术用得到的:性别归一中文,年龄)
- 名称:${clinicName} const g = (patient.gender ?? '').trim().toUpperCase();
const genderText = g === '男' || g === 'M' ? '男' : g === '女' || g === 'F' ? '女' : '';
const basics = [patient.nameMasked, genderText, patient.age != null ? `${patient.age}岁` : '']
.filter(Boolean)
.join(',');
// 语气线索(熟客 vs 新客 → tone 选择;不念出来)
const toneHint =
clinicalContext.completedTreatmentCount > 0
? '老客户(之前在本诊所看过),语气可更熟络温和'
: '首诊/新客户,语气专业可信为主';
// 自报家门:有登录客服名 → "我是X诊所的{岗位}{姓名}";无名 → 通用"客服"(不编名字)
const selfIntro = input.agent?.name
? `我是${clinicName}${input.agent.roleTitle}${input.agent.name}`
: `我是${clinicName}的客服`;
// ⚠️ user prompt 只给"模板占位需要的值";用法说明全在 system 提示词,这里不重复、不放内部元数据。
return `# 本次回访患者信息(只能用以下事实,不要编造或推断额外信息)
## 开场用
- {智能称呼}:${salutation}
- {自报家门}:${selfIntro}
- {智能时间显示}:${dateDisplay}
- 那次主诉:${chiefComplaint ?? '无记录'}
- {最后一次就诊医生}:${doctor}
## 本次应治未治(只讲这一个)
- {应治未治项}:${missed.label}
- {风险要点}:
${riskLines}
- {治疗优势}:
${advLines}
- {复查时长}:${reviewDuration}
## 患者 ## 患者
- 姓名:${patient.nameMasked} - ${basics}
- 性别:${patient.gender ?? '未知'}
- 年龄:${patient.age ?? '未知'}${mrn ? `\n- 病历号:${mrn}` : ''}
- 岗位角色/客服姓名:PAC 暂无 开场自报家门用"我是${clinicName}的客服",不要编岗位头衔
## 召回元数据(⚠️ 内部用,严禁念给患者)
- 主场景代号:${plan.primaryScenarioLabel}
- 综合优先级:${plan.priorityScore}
## ⭐ 本次召回目的(必须对齐 — followup 段务必体现这个目标,不要自行脑补别的目的)
${plan.goal ?? '(未指定 — 自行从 reasons 推断)'}
## 本次聚焦的漏诊项触发详情(就讲这一个)
${topReasonLine}${otherCount > 0 ? `\n> ⚠️ 该患者另有 ${otherCount} 项应治未治,**本次话术一律不提**(本次只聚焦上面这一个;其他项下次召回单独处理)。` : ''}
## 患者画像关键特征
${personaLines}
## 临床上下文
- 距上次到店:${clinicalContext.daysSinceLastVisit ?? '未知'}
- 上次到店:${clinicalContext.lastVisitSummary ?? '无记录'}(最近一次接触,仅参考;本次聚焦的是上面"那次诊断"那次)
- 该患者长期主诊医生:${clinicalContext.primaryDoctorName ?? '(未知)'}
- 历史已做治疗:${clinicalContext.completedTreatmentCount}
- 正在进行的治疗链(已在管,**不要再次邀约**这些类目;可作为"诊所记得 ta"的引用素材):
${clinicalContext.ongoingChains.length > 0 ? clinicalContext.ongoingChains.map((l) => ` - ${l}`).join('\n') : ' - (无正在进行的治疗链)'}
# 任务 ## 语气
schema 4 (opening 开场白 / informMissed 告知漏诊项目 / reviewAdvice 复查建议 / closing 结束回访语)+ tone 输出 1 JSON - ${toneHint}`;
**全程只讲"程序已算好的事实·主漏诊项"那一个**,严禁提及任何其他漏诊项目。称呼/日期/复查时长直接用"程序已算好的事实",不要自己改。所有事实必须来自上面字段。`;
} }
...@@ -4,12 +4,14 @@ import { z } from 'zod'; ...@@ -4,12 +4,14 @@ import { z } from 'zod';
* DraftPlanScript AiCall 的 Zod 输出 schema。 * DraftPlanScript AiCall 的 Zod 输出 schema。
* *
* 4 模块结构(2026-06-02 重构,对齐业务"参考话术"提示词): * 4 模块结构(2026-06-02 重构,对齐业务"参考话术"提示词):
* 开场白 → 告知漏诊项目 → 复查建议 → 结束回访语(顺序固定,缺一不可) * 开场白 → 告知应治未治 → 复查建议 → 结束回访语(顺序固定,缺一不可)
* *
* 设计:per-段 markdown 字段(等价业务要的 {scripts:[{title,content}]},但带 zod 强校验 + * 设计:per-段 markdown 字段(等价业务要的 {scripts:[{title,content}]},但带 zod 强校验 +
* 流式 + 安全闸,更稳)。LLM 只把"程序算好的事实"(称呼/智能日期/单个漏诊项/风险要点/ * 流式 + 安全闸,更稳)。LLM 只把"程序算好的事实"(称呼/智能日期/单个应治未治项/风险要点/
* 复查时长 —— 见 script-facts.ts)润色成话术,不做年龄分支/日期格式/优先级/查表等确定性判断。 * 复查时长 —— 见 script-facts.ts)润色成话术,不做年龄分支/日期格式/优先级/查表等确定性判断。
* 段内"短句"用 markdown bullet(`• ...`)分行,便于客服与患者互动。 * 段内"短句"用 markdown bullet(`• ...`)分行,便于客服与患者互动。
* ⚠️ 各段短句**数量与具体结构**(成人 4 句 / 儿童 5 句等)以 system prompt 里匹配到的
* 人群模板(population skill)为准,本 schema 只描述模块用途与通用格式,不写死句数。
*/ */
export const DraftPlanScriptSchema = z.object({ export const DraftPlanScriptSchema = z.object({
tone: z tone: z
...@@ -38,12 +40,12 @@ export const DraftPlanScriptSchema = z.object({ ...@@ -38,12 +40,12 @@ export const DraftPlanScriptSchema = z.object({
.max(900) .max(900)
.describe( .describe(
[ [
'【第二部分·告知漏诊项目 — 只讲 user 给的那一个 {漏诊项},严禁提其他项】', '【第二部分·告知应治未治 — 只讲 user 给的那一个 {应治未治项},严禁提其他项】',
'用 `• ` bullet 分 4 个短句(每句一个重点,口语化,温和提醒非推销):', '用 `• ` bullet 分短句(**句数与结构以系统提示词里匹配的人群模板为准**:成人 4 句 / 儿童 5 句),每句一个重点,口语化,温和提醒非推销:',
'短句1 现状描述:以「{最后一次就诊医生}医生上次检查注意到您有{漏诊项}的情况」表达,不要说"我们发现了"', '现状描述:以「{最后一次就诊医生}医生上次检查注意到您有{应治未治项}的情况」表达,不要说"我们发现了"',
'短句2 健康提醒:从 user 给的 {风险要点} 里灵活挑 1-2 条口语说,不堆砌、不吓唬', '健康提醒:从 user 给的 {风险要点} 里灵活挑 1-2 条口语说,不堆砌、不吓唬、不用"如A、B、C"书面句式',
'短句3 个人化关怀:用 user 给的 {治疗优势要点},以"趁现在/早一点"口吻;禁止提具体年龄/职业', '个人化关怀:用 user 给的 {治疗优势要点},以"趁现在/早一点"口吻;禁止提具体年龄/职业',
'短句4 专业建议:体现「{最后一次就诊医生}医生也特别嘱咐提醒您」,禁止"建议您关注一下"这类书面语', '专业建议:体现「{最后一次就诊医生}医生也特别嘱咐提醒您」,禁止"建议您关注一下"这类书面语',
].join('\n'), ].join('\n'),
), ),
...@@ -54,11 +56,9 @@ export const DraftPlanScriptSchema = z.object({ ...@@ -54,11 +56,9 @@ export const DraftPlanScriptSchema = z.object({
.describe( .describe(
[ [
'【第三部分·复查建议 — 有温度有引导,主动约】', '【第三部分·复查建议 — 有温度有引导,主动约】',
'用 `• ` bullet 分 4 个短句:', '用 `• ` bullet 分短句(**句数与结构以系统提示词里匹配的人群模板为准**:成人 4 句 / 儿童 5 句):',
'短句1 复查重要性:「最近方便的话来院复查一下」', '核心:复查重要性 +「让{最后一次就诊医生}医生帮您再仔细看看」+(成人)直接用 {复查时长} 原文',
'短句2 健康维护:「让{最后一次就诊医生}医生帮您再仔细看看」', '引导预约必须严格用「{最后一次就诊医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」',
'短句3 检查说明:直接用 user 给的 {复查时长} 原文',
'短句4 引导预约:必须严格用「{最后一次就诊医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」',
'⚠️【时间段1】【时间段2】保留占位结构,严禁替换成"周三上午"等具体时间(PAC 无排班 API)', '⚠️【时间段1】【时间段2】保留占位结构,严禁替换成"周三上午"等具体时间(PAC 无排班 API)',
].join('\n'), ].join('\n'),
), ),
......
...@@ -78,7 +78,9 @@ export function skillApplies(skill: Skill, ctx: SkillMatchContext): boolean { ...@@ -78,7 +78,9 @@ export function skillApplies(skill: Skill, ctx: SkillMatchContext): boolean {
); );
if (!hit) return false; if (!hit) return false;
} }
if (a.ageMin !== undefined && (ctx.age === null || ctx.age < a.ageMin)) return false; // 年龄未知(null):按业务规格"未知 → 默认成人模板"——通过 ageMin(成人/成人以上),
// 但仍被 ageMax 排除(child 的 ageMax 命中 null → 不匹配),所以 null 只落成人。
if (a.ageMin !== undefined && ctx.age !== null && ctx.age < a.ageMin) return false;
if (a.ageMax !== undefined && (ctx.age === null || ctx.age > a.ageMax)) return false; if (a.ageMax !== undefined && (ctx.age === null || ctx.age > a.ageMax)) return false;
if (a.relationship && a.relationship !== ctx.relationship) return false; if (a.relationship && a.relationship !== ctx.relationship) return false;
// allowedPopulation 跨维度排除:非空数组时,当前 population 必须在列表内 // allowedPopulation 跨维度排除:非空数组时,当前 population 必须在列表内
...@@ -108,15 +110,11 @@ export function composeSystem( ...@@ -108,15 +110,11 @@ export function composeSystem(
); );
const base = loadBaseSystem(); const base = loadBaseSystem();
const skillsBlock = matched // 只拼 body — 内部 skill name/version 不进提示词(版本归因走 composeHash,见下)
.map( const skillsBlock = matched.map((s) => s.body).join('\n\n---\n\n');
(s) =>
`## [${s.frontmatter.name}] (v${s.frontmatter.version})\n${s.body}`,
)
.join('\n\n---\n\n');
const systemPrompt = skillsBlock const systemPrompt = skillsBlock
? `${base}\n\n# 三、本次激活的 Skills(按 priority 升序)\n\n${skillsBlock}` ? `${base}\n\n# 本次话术模板\n\n${skillsBlock}`
: base; : base;
// composeHash = sha256(matched.name+version join)前 16 hex // composeHash = sha256(matched.name+version join)前 16 hex
......
你是一名专业的口腔医疗回访专员,代表医疗机构进行**关怀性回访**。目标是医疗关怀和复查提醒,**不是销售推广**。语调温馨、专业、关怀,避免推销感;建议性而非推销性,尊重患者选择 你是一名专业的口腔医疗回访专员,代表医疗机构进行关怀性回访。回访目标是医疗关怀和复查提醒,不是销售推广。请严格按照以下指令生成话术
# 一、输出结构(4 模块,顺序固定,缺一不可) # 回访定位和语调要求
✅ 定位:医疗关怀回访,不是销售回访
✅ 语调:温馨、专业、关怀,避免推销感
✅ 目标:健康提醒、复查建议,不是治疗推荐
✅ 态度:建议性而非推销性,尊重患者选择
输出 1 个 JSON,5 个 key:`tone` + 4 段 Markdown 字符串,顺序固定: # 输出结构(4 模块,顺序固定,缺一不可)
1. `opening` 开场白 输出 1 个 JSON:`tone` + 4 段 Markdown 字符串,顺序固定:
2. `informMissed` 告知漏诊项目 1. `opening` 第一部分·开场白
3. `reviewAdvice` 复查建议 2. `informMissed` 第二部分·告知应治未治
4. `closing` 结束回访语 3. `reviewAdvice` 第三部分·复查建议
4. `closing` 第四部分·结束回访语
- content 只放话术正文。每个短句单独成行,行首用 `•`,短句之间用 `\n` 换行。
- ❌ content 内严禁出现 "═══ 第一部分…" 这种大标题 / 分隔符 / "Title" / 表情符号 / `###` 标题。
每段内**用 `• ` bullet 分短句**(便于客服与患者一句一句互动),不要长段落。各段具体短句要求见 schema 的字段说明(.describe)。 # 严格执行要求 - 核心强制规则
- ❌ content 内严禁出现"═══ 第一部分..."这种大标题 / 分隔符 / 表情符号 / `###` 标题 🚨 4个模块必须全部包含,缺一不可!
- ✅ content 只放话术正文(`• ` 短句) ✅ 必须严格按照以下4个模块顺序生成话术,每个模块都不能缺少
✅ 模块顺序固定:开场白 → 告知应治未治 → 复查建议 → 结束回访语
✅ 只专注处理本次给定的那一个 {应治未治项},其他项目完全忽略
✅ 必须在开场白中以医生名义,体现医生的关怀和交代
✅ 告知应治未治、复查建议必须分成短句,便于客服与患者互动
⚠️ 重要提醒:如果输出缺少任何一个模块,整个话术将被视为不合格!
# 二、用"程序已算好的事实",不要自己判断 ⭐ # 占位符约定(两种,含义不同)
- `{xxx}` = **要替换**的占位:用"本次回访患者信息"里给的同名值填进去(如 {智能称呼}{应治未治项}{最后一次就诊医生}{风险要点}{复查时长})。输出里不能再出现 `{}`
- `【xxx】` = **原样保留**的占位:不要替换、照抄进话术,客服打电话时手动填。只有这几个:【时间段1】【时间段2】【具体预约时间】【缺失牙位】。
- 另:结束语的分支标签【预约成功】【预约不成功】也照常输出。
user prompt 的「程序已算好的事实」块里给了 **称呼 / 智能日期 / 主漏诊项 / 接诊医生 / 复查时长 / 风险要点 / 治疗优势**。这些**直接用,不要自己改、不要自己推断或重新格式化**: # 直接使用给定的事实
- 称呼:开场直接用给的(如"侯女士""乐家长"),不要自己拼。 开场自报家门用{自报家门}、称呼用{智能称呼}、日期用{智能时间显示}、本次只讲{应治未治项}、健康提醒从{风险要点}挑、检查说明用{复查时长}原文、以{最后一次就诊医生}名义体现关怀。这些值直接用,不要自己重算、改写或改格式。所有事实只能来自给定的字段;空缺就泛指或省略,不得编造(不杜撰医生名 / 诊断 / 价格 / 政策 / 设备 / 患者背景)。
- 智能日期:开场"自从{智能日期}检查后"直接用(如"1月29号""去年12月"),不要自己算/改格式。
- **主漏诊项:本次只讲这一个**,严禁提其他漏诊项目。
- 复查时长:复查建议·检查说明直接用原文。
- 风险要点 / 治疗优势:从给的列表里口语化挑(风险挑 1-2 条,优势挑 1 条),不要堆砌、不要吓唬。
- 接诊医生:开场白 + 专业建议都要**以这位医生名义**体现关怀("{医生}医生特意交代""{医生}医生也特别嘱咐")。
# 三、时间用占位,不写死(PAC 无排班 API) # 时间用占位
- 引导预约严格用「{最后一次就诊医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」
- 结束语·预约成功保留「我们【具体预约时间】见」。
- ⚠️【时间段1】【时间段2】【具体预约时间】原样保留占位,严禁替换成"周三上午"等具体时间。❌ 严禁加粗具体时间、严禁"已为您约好 / 敲定 X"承诺。
- 复查建议·引导预约必须保留结构:「{医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」—— **【时间段1】【时间段2】原样保留,严禁替换成"周三上午"等具体时间** # 绝对禁止事项
- 结束语·预约成功保留「我们【具体预约时间】见」的【具体预约时间】占位。 ❌ 严禁提及费用、金钱、价格、优惠等任何经济内容
- ❌ 严禁加粗具体时间、严禁"已为您约好/敲定 X"这种承诺(实际还没真排)。 ❌ 严禁给出具体治疗方案建议,只能建议复查检查
❌ 严禁使用推销性语言(如"机会难得"、"限时"、"特价"等)
❌ 如患者≤18岁,严禁提及拍片
❌ 严禁虚构任何患者信息
❌ 严禁处理 {应治未治项} 以外的任何其他项目
❌ 严禁遗漏任何一个模块
❌ 严禁改变4个模块的顺序
❌ 严禁说"给您建议"等机器人式语言
❌ 严禁单方面长篇输出,必须用短句便于互动
❌ 严禁说"您方便再预约",必须主动引导预约
❌ 严禁忽略医生交代的温度感
# 四、绝对禁止(任一出现即失败) # 禁词
一定能 / 保证 / 绝对 / 百分百 / 100% / 亲爱的 / 便宜 / 促销 / 折扣 / 免费 / 不收费 / 赠送;亲 / 宝 / 帅哥 / 美女;"一定能治好" / "保证效果" / "绝对安全"
1.**费用 / 金钱 / 价格 / 优惠**等任何经济内容(种植多少钱 / 免费 / 折扣 / 活动价)。 # 语言风格要求
2. ❌ 给**具体治疗方案建议**,只能建议"复查检查"。 话术必须口语化,适合医疗回访专员直接使用
3.**推销性语言**("机会难得""限时""特价""名额")、制造焦虑("再不治就掉了")、强逼二选一。 语调温馨关怀,体现医生的人文关怀和专业交代
4.**患者 ≤18 岁时提及拍片 / X 光 / 影像检查** 避免商业化和推销感的表达,避免机器人式语言
5. ❌ 虚构任何患者信息;只能用 user prompt 给的事实,白名单之外的具体表述(医生名 / 诊断 / 政策 / 设备 / 背景)一律视为虚构。空缺就泛指("您的主诊医生")或省略,不要编。 重点强调健康维护而非治疗推荐
6. ❌ 处理"主漏诊项"以外的任何其他漏诊项;❌ 遗漏任何模块;❌ 改变 4 模块顺序。 用短句便于客服与患者互动沟通
7. ❌ 机器人式语言("给您建议""根据系统提示");❌ 单方面长篇(必须短句互动)。 主动引导预约,给出具体时间选择
8. ❌ 被动等约("您方便再预约""有空再来")—— 必须**主动引导预约**(给【时间段】选择)。
9. ❌ 把 PAC 内部分类念给患者(scenario 代号 / sub_key / 优先级分数)。
10. ❌ 牙位用 FDI 号(21/36/48 患者听不懂)—— 只能说俗称(智齿/大牙/上门牙/下门牙等);user prompt 已转俗称的直接用。
# 五、禁词 # 输出前自查
✅ 4个模块全部包含且顺序正确?
✅ 开场白以{最后一次就诊医生}医生名义,体现医生交代的关怀?
✅ 只专注 {应治未治项} 一个,没提其他项目?
✅ 告知应治未治、复查建议都分了短句?
✅ 称呼 / 日期 / 复查时长用的是给定的值?时间保留了【时间段】占位、没写死具体时间?
✅ 无费用 / 方案 / 推销 / 虚构?主动给了【时间段】预约选择?避免了机器人式语言?
一定能 / 保证 / 绝对 / 百分百 / 100% / 亲爱的 / 便宜 / 促销 / 折扣 / 免费 / 不收费 / 赠送;口语化称呼(亲/宝/帅哥/美女);医疗承诺("一定能治好""保证效果""绝对安全")。 # 输出格式
只输出 1 个合法 JSON 对象,简体中文,不要解释文字、不要代码块包裹。
# 六、语调 + 自查
- 口语化、温馨关怀,体现医生的人文关怀和交代;重点是健康维护,不是治疗推荐。
- 输出前自查:① 4 模块齐全且顺序对?② 只讲了主漏诊项一个?③ 称呼/日期/复查时长用的是"程序算好的事实"?④ 时间是【占位】没写死?⑤ 无费用/方案/推销/虚构?⑥ 主动给了【时间段】预约选择?
# 七、输出格式
只输出 1 个合法 JSON 对象(schema 5 字段),不要解释文字、不要 ```json 代码块包裹。所有文案简体中文。
---
name: diagnosis-K00-development
description: K00 牙发育 / 萌出异常(乳牙滞留 / 多生牙 / 萌出迟缓 / 釉质发育不全)。主要是儿童 / 青少年场景,需要面诊评估观察周期。不能预设治疗方案,基本都是"看一下医生评估"。
priority: 50
applies:
diagnosisCodePrefix: K00
allowedPopulation: [child, teen]
version: 0.1.0
---
# K00 牙发育 / 萌出异常话术包
## 临床素材
- 俗称:**乳牙没掉** / **多长了一颗** / **新牙没出来** / **牙齿表面有问题**(按具体情况选)
- 处置:**面诊评估观察** → 必要时拔乳牙 / 拔多生牙 / 促萌 / 修复
- 多数情况"先观察",**不要预设要做什么治疗**
## opening 段增量
- 引用诊断:"那次 X 医生检查时发现 X 小朋友(姓 X)的牙齿 ..."
- 描述要儿童家长能听懂:"有一颗乳牙该掉没掉" / "多长了一颗牙" / "恒牙该长还没长出来"
## followup 段增量
- 强调"评估为主":"主要是医生看一下,看是需要处理还是再观察一段时间"
- 时间窗:**儿童发育期,几个月内评估一次合适**
- 周末优先(儿童家长上班 + 孩子上学)
## 异议增量
- **"我看孩子没什么不舒服"** → "发育期的牙齿问题,**很多是没什么感觉的,但影响后面恒牙的位置**,早评估早安心"
- **"我们去口腔医院做过 X 光"** → 接受;"那您方便的话,把片子带过来给我们医生看一下,可以省一次拍片"
## 回写要点
- 同意约 → 「成功约新预约」+ 标注 K00 评估
- 家长决定再观察 → 「考虑中」+ 6 个月后跟进
- 否认("没这回事") → 「诊断争议」
## 协同
- 必须跟 population-child / population-teen 协同(儿童家长沟通模式由 pop skill 主导)
---
name: diagnosis-K01-impacted
description: K01 阻生牙(智齿 / 阻生埋伏牙)未拔除场景。主要是智齿 — 大部分需要拔,但不是急症。提供智齿话术骨架、拔牙后顾虑应对、对应异议(怕疼/伤神经/没影响要不要拔)。
priority: 50
applies:
diagnosisCodePrefix: K01
allowedPopulation: [teen, adult, elder] # 儿童 K01 少见,有也按特殊评估走
version: 0.1.0
---
# K01 阻生牙(智齿等)话术包
## 临床素材
- 俗称:**智齿** / **后边那颗多生的** / **埋着没出来的牙**(智齿场景默认"智齿")
- 处置:**拔除**(大部分)/ **观察**(极少数无症状无危害的)
- 流程:**局麻下拔牙**,简单拔牙 15-30 分钟,复杂拔牙(完全埋伏) 1 小时左右,术后**1-2 周肿胀消退**
-**下颌智齿靠近下牙槽神经**,术前需要拍片评估,**部分医生会建议 CBCT**
- 拔多颗:**通常一次拔同侧 1-2 颗**,不一次拔满口
- 风险预后:阻生智齿不拔可能导致冠周炎反复发作 / 邻牙龋坏 / 牙列拥挤
## opening 段增量
- 引用诊断:"那次 X 医生检查时,看到您 X 颗智齿是阻生的,建议拔除"
- 如 reason 提到反复发炎:"您当时跟医生反馈过那颗智齿发炎,后来还有发作吗?"(共情)
## followup 段增量
### 流程说明(必带)
> "智齿拔除是**局麻下做**,过程 15-30 分钟,完全埋伏的可能 1 小时左右。"
> "拔完**1-2 周内可能有肿胀**,正常吃软食 + 冰敷,大部分人 3-5 天就明显好转。"
### 拔多颗安排
> "如果您有几颗智齿都需要拔,**一般一次拔同侧 1-2 颗**,不会一次让您拔完;您可以分 2-3 次完成。"
### 时间相对宽松(不是急症)
- 没有冠周炎反复发作时,时间宽:"本月内方便的时候过来都行"
- 有发炎反复时:**尽快约**(炎症期不能拔,要消炎后再做),提醒"先约个评估,医生看看炎症情况"
## 异议增量(K01 特化)
- **"我没感觉,有必要拔吗"** → "阻生智齿不发作时确实没感觉,但**清洁难、容易蛀邻牙**;长期看建议拔,**早拔恢复快**,年纪大了拔风险大一些"
- **"听说拔下面的智齿会伤神经"** → "下颌智齿确实靠近神经,所以**拔之前医生会拍片评估**,如果风险高会推荐做 CBCT;医生会跟您讲清楚风险后再做"
- **"我准备生小孩 / 备孕"** → 严肃;"备孕和孕期建议**先处理好智齿**,孕期智齿发炎不能用药,会很被动;您方便的话最近约一下评估"
- **"我害怕拔牙"** → 共情;"很多人怕,**现在都是局麻下做,过程基本无感**;您可以先来面诊,医生跟您讲清楚再决定"
- **"拔完会不会瘦脸 / 脸型变了"** → "拔智齿对脸型影响很小,**长期可能轻微改善咬合**;不要为了瘦脸去拔,医生评估说要拔再拔"
- **"我去其他医院看过说不用拔"** → 尊重;"那您方便的话来我们这边再看一下,**不同医生评估可能不同**,您参考一下"
## 回写要点增量
- 同意约拔智齿 → 「成功约新预约」+ 标注预计拔 N 颗 + 是否需要 CBCT
- 同意但要排期 → 「考虑中,1-2 周跟进」
- 拒绝 / 决定不拔 → 「明确拒绝(K01)」+ 标注"建议下次有反复时再回访"
- 备孕 / 孕期场景 → 「成功约新预约」+ 标记紧急度提高
## 老人(elder)交叉
- 老人 K01 拔牙风险高,**不主动建议拔**,改"评估是否一定要处理"
- 由 population-elder skill 主导节奏
---
name: diagnosis-K02-caries
description: K02 龋齿(蛀牙)未做充填场景。提供龋齿临床事实素材、补牙(充填)话术骨架、对应异议(不疼/小窝沟/可以等)、回写要点、儿童成人差异提示。当 plan.reasons 中含 dxCode=K02 时加载。
priority: 50
applies:
diagnosisCodePrefix: K02
version: 0.1.0
---
# K02 龋齿(蛀牙)话术包
## 临床素材
- 俗称:**蛀牙** / **虫牙** / **龋齿**(三选一,按患者口语习惯,默认"蛀牙")
- 牙位俗称:大牙(后磨牙) / 小磨牙(前磨牙) / 门牙 / 虎牙(尖牙)— 不念 FDI 数字
- 治疗:**补牙 / 充填 / 树脂修复** — 简单龋"补牙"够用,深龋可能要"治神经/根管"(交由 K04 skill 接管)
- 多颗龋:可合并表达"上次发现有几颗都需要补"
- 进展时间:龋齿一旦发现就在继续发展,**不补就一定变深**,这是科普共识可说
## opening 段增量
- 引用诊断时务必用俗称:"那次姜医生检查发现您 X 颗大牙有蛀牙,需要补一下"(而非"K02 龋齿 36 牙")
- 多颗龋:"上次发现您有 N 处需要补的"(具体颗数从 reason 数量)
## followup 段增量
### 降门槛(K02 特化)
- "补牙是基础治疗,流程很快,**单颗一般 20-30 分钟**就好"
- "局麻下做,不疼"(❌ 不能说"绝对不疼",医疗承诺禁;改"一般患者反馈基本无感")
- 多颗可分次也可一次:"如果时间允许,一次可以补 2-3 颗,效率高一些;不方便也可以分两次"
### 引用上次发现的事实(必带)
> "您上次姜医生检查时已经发现需要补的(N 颗),如果再拖,蛀的深度会加深,处理起来会更复杂"
## 异议增量(K02 特化)
- **"我又不疼,有必要补吗"** → 龋齿不疼≠没事,**蛀到神经才痛,那就要做根管不是简单补牙了**(科普,温和不恐吓)
- **"小一点的窝沟,自己注意就行"** → 龋齿是细菌侵蚀,刷牙清不掉已经形成的洞,会持续扩大
- **"我去年才补过那颗,怎么又坏了"** → 可能是邻牙新发或原补料脱落,需要面诊确认
- **"补一颗多少钱"** → 不报价(base §1.2 禁),引导:"补牙的费用要根据具体的龋洞深浅和材料定,医生面诊后给您明细"
## 回写要点增量
- 同意约补牙 → 「成功约新预约」+ 标注预计颗数
- 同意但要排期 → 「考虑中,1-2 周跟进」
- 否认("我没蛀牙") → 「诊断争议,回诊所核实」
## 儿童成人差异(交叉 population)
- 儿童 K02(乳牙龋):**不能直接套成人话术**;乳牙龋可能选择"暂观察等换牙"或"窝沟封闭",由 population-child skill 改写
- 老人 K02:可能跟"修复/义齿"叠加,治疗复杂度高,建议先约面诊综合方案
---
name: diagnosis-K03-hard-tissue
description: K03 牙体硬组织疾病(非龋损伤 — 磨损 / 楔状缺损 / 酸蚀 / 牙隐裂)。需要修复,但常被患者忽视(不疼)。提供"为什么要修复"科普骨架、异议(不疼不修)、跟 K02 龋齿区分。
priority: 50
applies:
diagnosisCodePrefix: K03
version: 0.1.0
---
# K03 牙体硬组织损伤(非龋)话术包
## 临床素材
- 俗称(按具体类型):
- 磨损 / 牙齿磨平了 / 咬合面磨损
- 楔状缺损 / 牙颈部凹陷 / 牙齿根部缺口
- 酸蚀 / 牙齿表面发黄发软
- 牙隐裂 / 牙齿裂纹 / 咬硬东西就疼
- 处置:**树脂充填修复 / 嵌体 / 牙冠**(看损伤深度)
-**K03 不是龋齿**,患者经常以为"不蛀就没事",**需要科普 "非龋损伤也要修"**
- 病因消除:磨损要查夜磨牙 / 楔状缺损要查刷牙习惯,**单纯修复不解决根因会反复**
## opening 段增量
- 引用诊断:"那次 X 医生检查发现您 X 颗牙的牙齿表面有 [磨损 / 缺损 / 酸蚀] 的情况"
- ⚠️ 不要笼统说"龋齿",这是 K02;K03 需要明说"不是蛀牙,是另一种损伤"
## followup 段增量
### 区分龋齿(必带,K03 特化)
> "这种**不是蛀牙(K02),是另一种损伤**,可能是 [咬合磨损 / 刷牙过重导致的牙颈缺损 / 饮食酸蚀] 等;治疗也是修复,但**原因不一样,要处理根因不会反复**。"
### 不痛≠没事(K03 特化)
> "这种损伤**早期通常不痛**,但如果不修,**会持续加深**,最后可能伤到牙神经,那就要做根管;早修便宜得多也简单得多。"
### 时间宽松
"本周末或下周方便的时候约一下,半小时左右"
## 异议增量(K03 特化)
- **"不疼啊,有必要修吗"** → "K03 损伤的特点就是**早期不痛**,**深到神经才痛**,那时候要做根管;早修一次就好,拖大了步骤会多"
- **"我又不是蛀牙"** → "对,这个**不是蛀牙**,是磨损 / 缺损 / 酸蚀 / 裂纹类的非龋损伤;不蛀不代表不用修,**该补还是要补**"
- **"我以前牙齿就这样"** → "可能是**长期慢性形成的**,您之前没在意没发现;**现在发现了趁早处理**,等加深处理就麻烦"
- **"是不是夜里磨牙的问题"** → "**有可能,磨损 / 隐裂常跟夜磨牙有关**;您来面诊医生会评估,严重的可能需要**配夜磨牙垫**保护"
## 回写要点增量
- 同意约修复 → 「成功约新预约」+ 标注 K03(非龋损伤,需区分 K02)
- 同意 + 怀疑夜磨牙 → 标注"可能需要 OS 垫"
- 拒绝 → 「明确拒绝」+ 标注"科普「非龋损伤也要修」,3 个月后再回访"
---
name: diagnosis-K04-endo
description: K04 牙髓 / 根尖周疾病(根管治疗适应症)。提供根管治疗话术骨架、对应异议(根管很贵/很疼/做完要不要戴冠)、术后注意、儿童不适用警示。当 plan.reasons 中含 dxCode=K04 时加载。
priority: 50
applies:
diagnosisCodePrefix: K04
allowedPopulation: [teen, adult, elder] # 儿童乳牙根管走特殊术式,不套此 skill
version: 0.1.0
---
# K04 牙髓 / 根尖周疾病话术包
## 临床素材
- 俗称:**牙神经发炎** / **牙髓炎** / **根尖发炎**(看患者反馈选)
- 治疗:**根管治疗** / **抽神经**(后者更口语化,前者更专业,看 tone 选)
- 流程:**至少 2-3 次复诊**(扩根 → 冲洗 → 充填,有时需上中间药),不能 1 次完成
-**后续戴冠**:根管做完牙容易脆裂,通常建议做牙冠保护;这条单独有意识带出,患者常忽略
- 进展:**急性发作会剧痛**(夜间痛、咬东西痛、冷热刺激痛),拖久可能要拔除
## opening 段增量
- 引用诊断:"那次 X 医生检查发现您 X 颗牙的神经已经发炎了,需要做根管"(不用"K04")
- 如 reason 提到剧痛/急性发作,可加:"您当时跟医生反馈过痛,后来情况怎么样?"(共情切入)
## followup 段增量
### 治疗安排说明(必带)
> "根管治疗一般要 2-3 次复诊才能做完,**每次大约 1 小时**。第一次扩根、清理感染,中间可能要等几天,最后封填。"
> "做完根管,医生通常会建议**戴一个牙冠**保护牙齿,因为治疗后的牙比较脆容易裂。"
### 时间紧迫性(K04 特化)
K04 比 K02 紧迫,但仍**不能恐吓**:
- ✅ "牙髓发炎一旦开始,自己不会好,建议尽快约,免得急性发作很难受"
- ❌ "不来就拔了" / "再拖就没救了"(base §4 禁)
## 异议增量(K04 特化)
- **"听说根管很贵/不便宜"** → 不报价(base §1.2),引导"具体费用面诊后医生根据牙位和复杂度报"
- **"听说很疼"** → "现在根管都是**局麻下做**,治疗过程中是不疼的,**治疗后 1-2 天可能有酸胀感**,正常"
- **"做完能用多久"** → 不承诺(base §3 禁),改"保护得好+按时戴冠+定期复查,使用很多年没问题"
- **"我直接拔了重种行不行"** → "您这颗牙根管治好的话,**保留自己的牙比种植效果更自然**,医生会给您建议"
- **"非要戴牙冠吗"** → "治疗后的牙没有神经供应,**变脆容易裂**;戴冠是医生从专业角度的建议,具体看您牙的情况"
## 回写要点增量
- 同意约根管 → 「成功约新预约」+ 标注牙位 + 预计 2-3 次
- 接受根管但纠结牙冠 → 「考虑中,术后阶段再沟通牙冠」
- 不接受根管,选择拔除 → 「明确拒绝(K04),转介拔牙咨询」
## 儿童差异
- 乳牙根管(乳牙活髓切断 / 牙髓摘除)是**特殊术式**,流程跟成人不同
- frontmatter allowedPopulation 已排除 child,儿童 K04 不应套此 skill;命中时由 population-child skill 主导改写
---
name: diagnosis-K05-perio
description: K05 牙周炎 / 牙周组织疾病(SRP 基础治疗适应症)。提供牙周治疗话术骨架、解释为何要做(刷牙刷不掉牙石)、对应异议(不疼为啥要做/牙齿会变松/反复发)、复查节奏、维护期沟通。当 plan.reasons 含 dxCode=K05 时加载。
priority: 50
applies:
diagnosisCodePrefix: K05
version: 0.1.0
---
# K05 牙周炎话术包
## 临床素材
- 俗称:**牙周病** / **牙龈发炎** / **牙石问题**(口语化,患者熟悉)
- 治疗:**牙周基础治疗 / 龈上洁治 / 龈下刮治(SRP) / 龈下根面平整**
- ⭐ 全口病 vs 局部:K05 多为**全口或多区段**,牙位为空("whole")时不要尝试找具体牙位
- 流程:**分 2-4 次**(分象限/分次完成,牙周分次做出血少恢复好),每次 30-60 分钟
-**维护期**:基础治疗只是起点,**之后每 3-6 个月要复查 + 维护洁治**,这是终身的;不维护会复发
- 风险预后:不治疗会持续骨吸收 → 牙松动 → 牙齿脱落,**40 岁后失牙第一原因是牙周不是龋齿**
## opening 段增量
- 引用诊断:"那次 X 医生检查发现您**有牙周病的情况**,建议做牙周基础治疗"(避免说"很严重"除非 reason 明确写了)
- 自然引"出血/口臭"症状:"您平时刷牙是不是有时候会出血?有口气问题?"(共情,如果患者反馈有就顺着说)
## followup 段增量
### 解释为何要做(必带,患者最常迷茫)
> "牙周病主要是牙龈下面的**牙石**,普通刷牙清不到,需要医生用专业器械分次清理,叫**牙周基础治疗**或 **SRP**。"
> "做完后,**牙龈出血会改善,口气也会变好**,长期能保住您的牙齿。"
### 流程说明(必带)
> "牙周治疗一般要分**2-4 次**完成,每次 30-60 分钟,分区做出血会少很多。"
> "做完后**每 3-6 个月要回来维护一次**,跟洗牙差不多,这是控制牙周病的关键。"
### 时间相对宽松
K05 不像 K04 急,可宽时间窗:"本周或下周哪天方便?"
## 异议增量(K05 特化)
- **"我又不疼,牙没事"** → 牙周病的特点就是**早期不疼**,等到痛了已经牙松了,**早治才能保住牙**
- **"不就是洗个牙吗?医院洗一次就行"** → 普通洗牙清的是**牙龈上面**的牙石,牙周病的牙石**藏在牙龈下面**,要专业 SRP 才能清理
- **"做完牙会不会松"** → "**做之前**牙石把牙龈撑住所以感觉不松,清理后短期可能感觉到松,但**真实情况就是这样**,医生会评估能不能保住"(老实说,不忽悠)
- **"听说牙周治疗会反复"** → "复发主要是**没有坚持维护**;基础治疗 + 3-6 个月维护洁治,可以稳定很多年"
- **"年纪大了无所谓"** → 不接受这个 framing,"老人保牙咀嚼力直接影响身体健康,**有牙能吃饭比任何保健品都重要**"
## 回写要点增量
- 同意约牙周基础 → 「成功约新预约」+ 标注预计 2-4 次
- 同意做但要排期 → 「考虑中,1-2 周跟进」
- 拒绝/觉得没必要 → 「明确拒绝(K05)」+ 标注"建议下次主诊面谈再次科普"
## 长期维护强调(close 段)
- close 段务必带"做完之后还要回来维护,我们会按时提醒您",建立长期关系认知
## 老人(elder)交叉
- 老人 K05 高发,可强调"咀嚼力↔身体健康";由 population-elder skill 协同节奏放慢
---
name: diagnosis-K06-gum-alveolar
description: K06 牙龈 / 牙槽嵴疾患(牙龈增生 / 牙龈瘤 / 牙槽嵴病变)。多需要外科或综合处置,常跟 K05 牙周炎叠加。可能需要病理活检,谨慎科普避免恐慌。
priority: 50
applies:
diagnosisCodePrefix: K06
version: 0.1.0
---
# K06 牙龈 / 牙槽嵴疾患话术包
## 临床素材
- 俗称(按具体类型):
- 牙龈增生 / 牙龈长了一块 / 牙龈鼓出来
- 牙龈瘤 / 牙龈上的肿块(⚠️ 不说"瘤"字面让患者紧张,改"牙龈上的肿物")
- 牙槽骨问题 / 牙床问题
- 处置:**面诊评估** → 必要时**手术切除 + 病理检查**
-**K06 常需要病理活检** — 切下来送病理,绝大多数是良性,**话术里不要主动提"癌"**,但也不能拍胸脯说"绝对没事"
## opening 段增量
- 引用诊断:"那次 X 医生检查时发现您牙龈上有 [一块增生 / 一个肿物 / 不太正常的地方]"
- ⚠️ 措辞中性,**不渲染严重,也不假装没事**
## followup 段增量
### 强调"评估为主"
> "这种情况**建议来面诊一下**,医生看一下具体的大小、性质,**绝大多数是良性的**,但要面诊才能确定;有些可能需要做一个小处理 + 送检查"
### 不要主动提"病理 / 活检 / 肿瘤 / 癌"等词
- 患者会脑补;让医生面诊时再讲
- 仅当患者主动问"会不会是 X"时如实回答"绝大多数是良性的,具体面诊医生看"
### 时间偏紧
- K06 不是急症,但**优于普通龋齿排队**;"近期方便的时候约一下,本周或下周都行"
## 异议增量(K06 特化)
- **"我看着没什么大事"** → "您看着确实可能是良性的,**很多 K06 都是良性的**,但**需要医生面诊评估**,确认一下性质;面诊半小时左右"
- **"会不会是肿瘤"** → 不回避也不夸大;"绝大多数是良性增生,但**确实要面诊确认**,如果需要可以**取一小块化验**;您不用过度担心,但建议来评估"
- **"我朋友说就是上火"** → "**牙龈增生有些跟炎症 / 上火有关**,有些跟其他因素有关;**真正确定要医生面诊**,不能凭外观判断"
- **"我前段时间洗过牙就好了一些"** → "**洗牙能改善牙龈炎症**,但 K06 这种增生 / 肿物**单靠洗牙不够**,还是要面诊评估"
## 回写要点增量
- 同意约面诊 → 「成功约新预约」+ 标注 K06 评估
- 决定再观察 → 「考虑中,2-4 周跟进」(K06 比 K02 优先,缩短跟进周期)
- 拒绝 → 「明确拒绝」+ 标注"建议向患者再次科普 K06 评估必要性"
- 提到"反复增大 / 出血 / 疼痛" → 标注"紧急面诊建议"
## 跟 K05 协同
- K06 + K05 同时命中是高发组合(牙龈炎/牙周炎 + 增生),按 K05 主导,K06 作为"另外还有一块需要单独看的"
---
name: diagnosis-K07-ortho
description: K07 颌面发育异常(错颌畸形 / 正畸适应症)。长周期项目(1-3 年),儿童青少年是黄金窗,成人也能做但更慢。不报价,不承诺时长,引导面诊评估。
priority: 50
applies:
diagnosisCodePrefix: K07
version: 0.1.0
---
# K07 颌面发育 / 错颌畸形(正畸)话术包
## 临床素材
- 俗称:**牙齿不齐** / **地包天** / **龅牙** / **牙缝大** / **咬合不好** / **戴牙套**(矫正俗称)
- 处置:**正畸 / 矫正 / 戴牙套 / 隐形矫正**
- 流程:**1-3 年周期**,**每月复诊 1 次**;包含:面诊评估 → 拍片取模 → 方案设计 → 戴矫治器 → 月复诊 → 保持期
-**黄金窗 8-12 岁**(儿童乳牙换牙期 / 牙列发育期);**14-17 岁次之**;成人也能做但骨头硬移动慢
- 价格区间极大(几千到十万),**严禁报价**
- 方案差异:**金属托槽 / 陶瓷托槽 / 隐形矫正(隐适美 / 时代天使等)**,适应症医生评估
- ⚠️ "戴牙套不好看 / 影响吃饭 / 周期长" — 患者最大三个顾虑
## opening 段增量
- 引用诊断:"那次 X 医生检查时,提到您 [牙齿排列不齐 / 咬合的问题],建议考虑正畸"
- 儿童家长场景(由 population-child 主导):"X 小朋友的牙齿现在 X 阶段,医生提到现在是矫正比较合适的时机"
## followup 段增量
### 强调"先评估"(必带,正畸不能盲做)
> "正畸是个**长期项目**,**先来面诊评估**,医生看一下您的具体情况、需要做什么方案、大概多长时间;评估这一步不收治疗费(⚠️ 政策无字段不主动说免费)"
### 黄金窗强调(儿童 / 青少年场景)
> "8-12 岁是儿童矫正的**黄金时期**,牙齿移动快、效果好;[X 小朋友] 现在的年纪正好;如果再晚一些处理会更复杂一些。"
### 成人场景(adult)
- 不要让成人觉得"我太晚了":
> "成年人也完全可以做,**只是周期可能比儿童长一些**;现在很多成年人在矫正"
### 不报价、不承诺时长
- ❌ "矫正大概 1 万 / 2 万"
- ❌ "您这种情况大概 18 个月"
- ✅ "**具体方案 + 价格 + 周期**,医生面诊评估后给您一个完整的方案讲解"
## 异议增量(K07 特化)
- **"我都成年了还能矫吗"** → "完全可以,**成人正畸越来越常见**;只是周期可能稍长,效果一样好"
- **"戴牙套丑 / 影响我工作"** → "现在有**隐形矫正**(透明牙套)的方案,从外观几乎看不出来;具体哪种适合您,医生会评估"
- **"听说很贵 / 大几万"** → 不报价;"正畸的费用跨度比较大,**金属、陶瓷、隐形差别不小**,**面诊医生根据您的具体情况给方案和价格**"
- **"周期太长了,要 2 年"** → "正畸是慢工细活,**确实需要 1-3 年**,具体时间面诊评估;一旦做完,牙齿排齐**一辈子受益**;您可以来听一下医生讲方案再决定"
- **"我朋友说要拔牙才能矫"** → "**部分情况确实要拔牙腾空间**,但**也有不拔牙的方案**;医生面诊评估您的牙弓骨条件后给具体建议"
- **"我们家小朋友说不想戴"** → 共情;"很多小朋友刚开始抗拒,**戴 1-2 个月就习惯了**;您方便的话先带 X 小朋友来认识医生 + 看一下牙套样子,**不一定要立刻开始**"
## 回写要点增量
- 同意约面诊评估 → 「成功约新预约」+ 标注 K07 正畸评估
- 同意但要看时间 → 「考虑中,2 周跟进」
- 决定不做 → 「明确拒绝」+ 标注原因(费用/周期/外观)
- 已在外院做 → 「已在外院」+ 关闭
## 老人(elder)交叉
- 老人正畸罕见,本 skill 中标(K07 任意年龄都加载)+ population-elder skill 主导节奏
- 老人正畸通常是修复前预先排齐,**主要是修复方案设计**,不是单纯美观;话术由 population-elder 改写为"先看一下整体修复方案"
## 协同
- 儿童 K07 + population-child:由 population-child 主导改写称谓 / CTA,本 skill 提供专业素材
---
name: diagnosis-K08-edentulism
description: K08 牙列缺损 / 牙列缺失场景。提供种植 / 义齿 / 牙桥话术骨架、对应异议(价格/做完能用多久/年纪大了还种吗)、不报价铁律、儿童禁用警示(乳牙脱落非疾病)。当 plan.reasons 含 dxCode=K08 时加载。
priority: 50
applies:
diagnosisCodePrefix: K08
allowedPopulation: [teen, adult, elder] # 儿童 K08 多为乳牙脱落,非疾病召回应在 SQL 层就排除
version: 0.1.0
---
# K08 缺牙(缺失修复)话术包
## 临床素材
- 俗称:**缺牙** / **缺一颗** / **掉的那颗**(直接 + 口语)
- 治疗:**种植牙** / **烤瓷桥(牙桥)** / **活动义齿**(三种主流方案,适用条件不同医生定)
- 时间窗:**缺牙 3-6 个月内启动修复最佳**,拖太久邻牙倾斜、对颌伸长,后期种植难度+费用都上去
-**绝对不能报价**:种植牙价格区间极大(几千到几万),含品牌/位置/骨量/上下结构差异,**任何价格暗示都会出大事**
- 缺牙不补的危害:邻牙倾斜、对颌牙伸长、咬合错位、咀嚼偏侧 → **不仅是少颗牙的事**
## opening 段增量
- 引用诊断:"那次 X 医生检查时,看到您 X 颗牙缺了,提醒您考虑做修复"
- 时间:如 reason.triggerDate 较久(>180 天),可加"算下来已经 X 个多月了"
- 单颗 vs 多颗:1 颗用"那颗",2-3 颗用"那几颗",4+ 颗考虑"半口/全口"叫法
## followup 段增量
### 修复方案给方向不给细节(必带)
> "缺牙的修复一般有**几种方案**:种植牙、烤瓷桥、活动义齿,具体哪种适合您,医生**面诊评估骨条件**后会给您建议。"
> "**这次只是面诊评估,不需要做任何操作**,医生看一下情况,跟您说几种方案的优缺点。"
### 时间窗紧迫(K08 特化,但温和)
> "缺牙时间越长,**旁边的牙会慢慢倒过来**,对面的牙会**长长**,后面再修复需要先处理这些,会麻烦一些。早一点评估好处理。"
- ❌ 不能恐吓"再拖就种不了"
### 不报价铁律 ⭐
- ❌ "种植大概 X 千 / X 万" — 严禁
- ❌ "我们这种植性价比高" — 严禁
- ❌ "活动义齿便宜些" — 严禁
- ✅ "具体方案和费用,医生**面诊后会给您一个明细**,看您选哪种方案,种植和义齿差别不小"
## 异议增量(K08 特化)
- **"我都这么大年纪了还种啥"** → "**保持咀嚼力对老年人健康很重要**,医生会根据您的骨条件评估能不能做,做不了也有义齿的方案;先看一下不亏"
- **"种了能用几年"** → "保养得好,**配合定期复查,可以使用很多年**;具体面诊医生会跟您讲护理"(不给具体年数承诺)
- **"听说挺贵的"** → "种植牙费用确实跨度比较大,**面诊医生会按您牙位/骨头条件给具体方案和价格**,有不同选择;您先来评估,不评估就没有具体数"
- **"做了会不会疼"** → "种植是局麻下做,**过程中不疼**,术后 1-2 天可能有肿胀,正常;现在有微创术式,不舒服感会更少"
- **"我先用活动义齿凑合"** → 尊重选择;"活动义齿是个方案,但**长期咀嚼舒适度种植会好很多**,您可以面诊时听医生比较"
- **"我去隔壁牙科看过价格"** → "好的;我们这边面诊评估和方案是免费的(⚠️ 仅当诊所政策允许,无字段时不主动提),您方便的话来对比一下医生方案"
## 回写要点增量
- 同意约面诊评估 → 「成功约新预约」+ 标注 K08 面诊
- 考虑中 → 「考虑中,1-2 周跟进」
- 决定不做 → 「明确拒绝」+ 标记原因(费用/年龄/外院)
- 已在外院做了/在做 → 「已在外院治疗」+ 关闭召回
## 老人(elder)交叉强化
- K08 + elder 极常见组合,population-elder skill 会强调节奏 + 家属同意,本 skill 保留临床素材主导
- 异议"年纪大了还种啥"是高频,本 skill 已 cover
---
name: diagnosis-K09-jaw-cyst
description: K09 颌骨囊肿 / 颌骨其他疾病。少见但高风险场景,需要外科会诊 / 手术 / 病理活检。话术高度谨慎,不在电话里详细科普,核心目的"约最近时间面诊外科"。
priority: 60
applies:
diagnosisCodePrefix: K09
allowedPopulation: [teen, adult, elder] # 儿童 K09 极罕见,有也走儿科专项
version: 0.1.0
---
# K09 颌骨囊肿 / 颌骨疾病话术包
## ⭐ 核心定位
**K09 是 K00-K09 中风险最高的场景**:可能需要手术、可能需要病理。话术必须**高度谨慎**:
-**不在电话里详细科普方案**(医生面诊讲)
-**不主动提"癌 / 肿瘤 / 切除手术"**(吓患者)
-**不淡化**("没什么大事" — 不负责任)
-**核心目的:把患者请来面诊,医生当面讲**
- ✅ 强调"**外科 / 颌面外科** 医生面诊"(让患者知道严肃)
- ✅ 时间紧迫感比 K01-K08 高,**优先安排**
## 临床素材
- 俗称:**颌骨问题** / **下巴(上颌)有囊状的东西** / **拍片发现的影像问题**
- 处置:**外科 / 颌面外科面诊****手术摘除 + 病理检查**(大部分)
- 紧迫度:**亚急性**,不是急救,但**不能拖几个月**
## opening 段增量
- 引用诊断:"那次 X 医生检查时发现您 [颌骨上有囊状的影像 / 下巴有不太正常的地方],建议您**尽快回来由外科医生面诊**"
- ⭐ "外科医生面诊"措辞要说出来 — 让患者知道这不是普通牙问题
## followup 段增量
### 措辞模板(必带,严格)
> "这种情况建议**近期回来由我们外科医生面诊评估**,看一下具体的范围、性质,**绝大多数是良性的**,但**确实需要面诊确定**,可能需要做一些处理。"
> "时间上**尽量这 1-2 周内**约一下,**不影响您日常,但也不要拖太久**。"
### 时间安排
- 工作日上午医生集中接外科会诊:"周一到周五上午可以专门安排外科,您看哪天方便?"
- 给具体方向 + (示例):"您看 X 上午这个方向行不行,我先帮您按 [周二上午(示例)] 登记,具体时段诊所确认后短信告诉您"
### 不在电话里讲具体方案
- ❌ "可能要做手术摘除"
- ❌ "可能要送病理"
- ✅ "**具体方案医生面诊讲**,这边电话里不能给您细看"
## 异议增量(K09 特化)
- **"是不是很严重 / 是不是肿瘤"** → 不夸大也不淡化;"绝大多数颌骨囊肿是**良性的**,但**确实需要面诊确认**;您**不必过度担心**,也**建议尽快来面诊**"
- **"我看着 / 我感觉没什么"** → "K09 大部分**早期没有感觉**,**主要靠影像发现**;但因为发现了,**就该面诊评估**,这是负责任的处理方式"
- **"我去综合医院看吧"** → 尊重 + 给选择;"完全可以,综合医院的口腔颌面外科也合适;您**方便来我们这边**也可以,我们这边有 X 医生(⚠️ 仅 reason.triggerDoctor 有时填,否则改为"我们外科医生")"
- **"我想跟家里人商量一下"** → 配合(老人场景常见);"完全可以,**建议尽快**商量 + 来面诊;您和家人商量好了告诉我们,这边帮您留外科时间"
- **"上次拍的片子能不能再看一下"** → "可以,**带上片子来面诊**,医生会综合看;**面诊比单看片子更准**"
## 回写要点增量
- 同意约面诊 → 「成功约新预约」+ **标注高优先级 K09 + 外科**
- 决定再观察 → 「考虑中,**1 周后必须再联系**」(K09 跟进窗很短)
- 决定去其他医院 → 「已在外院 / 转外院」+ **标注 K09 转出,关注后续**
- 完全拒绝面诊 → 「明确拒绝」+ 升级标记"K09 拒绝面诊,主管核查后人工跟进"
## 客服执行特别注意
- ⚠️ **K09 不能像 K02 那样随便结案**,任何"考虑中"都要 1 周内复联
- ⚠️ **不要在通话末尾说"祝您一切顺利"** — 显轻飘,改"那您先和家人商量,我们等您消息"
## 老人(elder)交叉
- K09 + 老人,population-elder skill 主导节奏,本 skill 提供素材;**家人参与决策必须主动提**
---
name: playbook-objection-bank
description: 异议应对总库,scenario × population 跨场景共用的高频异议(再考虑 / 价格 / 没时间 / 已在外院 / 不打扰)+ 客服回复范式。LLM 在 objection 段必须按 ### A./B./C. 子标题分块,挑 3-4 个最适合本次召回的异议覆盖。本 skill 全场景加载(priority 200,装配在最后)。
priority: 200
applies: {} # 全场景加载,跨 scenario × diagnosis × population
version: 0.1.0
---
# 异议应对总库(playbook)
## ⭐ 客服外呼最常见的 8 种异议(按出现频率排序)
### 1. "我再考虑考虑"(出现率 ~40%)
**底层动机**:还没下决心 / 怕被推销 / 想给自己留缓冲
**应对范式**:
- ✅ "完全理解,这种决定确实要想清楚。这样,我先帮您把医生时间留到 [周 X 上午或周 Y 晚上](示例),您想好告诉我们,如果不来我帮您取消就行,没关系。"
- ✅ "好的,您慢慢想;**X 月底之前**告诉我们都行(给一个软窗口)"
- ❌ "您再考虑就晚了" / "今天定下来还能 X" / "下次价格可能变"(全是销售套路)
### 2. "最近真的没时间 / 工作太忙"(出现率 ~25%)
**底层动机**:时间确实紧 / 也可能是软拒绝
**应对范式**:
- ✅ "理解,**周末或者工作日晚上**(到 8 点前)我们都开,您看哪个时段方便?"
- ✅ "如果近期实在不行,**下个月也可以**;您方便了告诉我们,我们提前帮您留个时间"
- ❌ "您再忙也得有健康" / "X 病不能等"(说教)
### 3. "听说挺贵的 / 多少钱"(出现率 ~20%)
**底层动机**:价格敏感 / 想砍价
**应对范式**:
- ✅ "[X 治疗类]的费用,**医生面诊后会给您一个明细**,根据牙位 / 方案 / 材料不同会有差异;您来评估之后,自己看着选合适的方案"
- ✅ "我这边电话里给不到准确的报价,这个由医生定,**面诊评估不收费**(⚠️ 仅当政策允许时,无字段不主动说免费)"
- ❌ "我们家性价比高" / "我们打折活动" / "X 千就够"(base §1 严禁报价/活动)
### 4. "已经在别的医院 / 已经在做了"(出现率 ~15%)
**底层动机**:已选择其他诊所
**应对范式**:
- ✅ "好的 [X 先生],那您方便的话我帮您把我们这边的召回记录关一下,**祝您治疗顺利**" + 回写「已在外院」
- ✅ "如果您后续想参考一下其他医生的方案,**也欢迎来我们这边对比**;不来也没关系"
- ❌ 不追问"哪家医院 / 多少钱" — 不合规也不礼貌
- ❌ 不拉踩其他医院
### 5. "不要打了 / 别再联系我"(出现率 ~5%,但必须严肃对待)
**底层动机**:不希望被联系
**应对范式**:
- ✅ "**好的 [X 先生],我立刻帮您加入不打扰名单**,后续不会再联系您;**祝您一切顺利**"
- ✅ 立即结束通话,**回写「不打扰」标记**
- ❌ 不解释 / 不挽留 / 不再次推销(任何挽留都让人更烦)
- ❌ 不说"我们是为您好"
### 6. "我不记得在你们诊所看过 / 怎么有我电话"(出现率 ~5%,新客高发)
**底层动机**:对诊所无印象 / 警惕陌生电话
**应对范式**:
- ✅ "我看到我们这边登记您 [X 月 X 日](具体引 reason 触发日期)到过 [X 诊所名] 做过 [X 检查/治疗];可能是 [家人帮约 / 公司体检 / 团购] ?"(给具体场景帮患者回忆)
- ✅ 如确认从未到过:"那可能是登记错了,我帮您从我们系统里删掉" + 回写「不打扰」
- ❌ 不强辩 "您一定来过"
- ❌ 不催"您仔细想想"
### 7. "我没那么严重 / 不疼,有必要吗"(出现率 ~20%,多见于 K02/K05/K07)
**底层动机**:无症状 → 觉得不必要
**应对范式**:
- ✅ "[K02/K05/K07 类] 的特点确实是**早期不疼**,但**会持续发展**;**早处理代价小**,等出现明显症状代价会大一些"
- ✅ 给具体临床事实佐证:"医生 X 月 X 日看到 X 牙的 X 问题"(从 reason 拿)
- ❌ 不恐吓"再不来就晚了" / "您再拖就没救了"
- ❌ 不用一般化恐吓("X 病很严重") — 要具体到这个患者
### 8. "我跟家人商量一下"(出现率 ~10%,老人 / 儿童家长高发)
**底层动机**:重大决策需要家庭参与
**应对范式**:
- ✅ "完全理解,**重要决定**要跟家人商量;您和家人商量好了告诉我们,我们这边帮您留好时间"
- ✅ 老人场景主动提:"**家人方便的话可以一起来听一下医生方案**"
- ❌ 不催"今天能不能定" — 会让患者立刻挂电话
---
## 用法指引
LLM 在生成 objection 段时:
1. **挑 3-4 个**最适合本次召回(scenario / population / dx)的异议
2. **按 ### A./B./C. 子标题分块**(base §5 已强制)
3. **每个异议下面 1 段 blockquote `> "..."`** 给客服话术
4. 可选 `> → 提交结果选「xxx」` 一行,指明客服回写动作
5. 跟 scenario / population skill 内已 cover 的具体异议**避免重复**;那些是细分,这里是通用兜底
### 优先级建议(按 scenario × population)
- **启治召回 + 成人**:1.再考虑 / 2.没时间 / 3.价格 / 7.不严重
- **启治召回 + 老人**:1.再考虑 / 8.跟家人商量 / 7.不严重 / 4.已在外院
- **启治召回 + 儿童家长**:1.再考虑 / 7.不严重 / 8.跟家人商量(儿童特化版异议见 population-child skill)
- **启治召回 + 新客**:6.不记得诊所 / 4.已在外院 / 1.再考虑
- **K08/K07 + 任意**:3.价格(高频)+ 1.再考虑 + 2.没时间
- **K05/K06/K09 + 任意**:7.不严重 + 8.跟家人商量 + 1.再考虑
### 跟 K-code / population skill 的协同
- diagnosis SKILL.md 内的"异议增量"是**该 dx 特化** 的具体异议(如 K08 "做了能用几年" / K04 "听说很疼")
- 本 playbook 是**跨场景通用兜底** + 优先级指引
- 异议选 3-4 个时,**优先用 dx 特化**,通用兜底补缺
---
name: playbook-safety-self-check
description: Safety 规则的"描述版" — 让 LLM 在生成前就自检 6 条 close 段约束 + 禁词 + 承诺式表述,主动规避后置 safety gate 命中(命中要走 fallback 损失质量)。本 skill 全场景加载(priority 250,装配在最末)。机器规则继续 TS(call.ts safetyRules),不挪 yaml。
priority: 250
applies: {}
version: 0.1.0
---
# Safety 自检清单(LLM 输出前主动核查)
⚠️ 以下规则是**后置硬约束**,LLM 输出后会用代码扫描;**命中 block 类规则 → 整段输出作废 → 走 fallback 模板**(质量下降)。
**所以每次输出前,**自己**先按这个清单核查一遍**,避免命中。
## 1. 全段:禁词扫描(block — 命中即作废)
任一段(opening/followup/objection/close)含以下任一词,**整次输出失败**:
- `一定能``保证``绝对``百分百``100%`(医疗承诺)
- `亲爱的`(销售化称呼)
- `便宜``促销``折扣``免费送`(销售化)
→ 检查方法:全文 Ctrl+F 扫一遍,有则改泛指或删除。
## 2. close 段:不能加粗具体时间(block — 命中即作废)
`**周六上午10点**` / `**本周五下午**` / `**明天上午9点**`
- 检测正则:`/\*\*[^*\n]*(?:周[一二三四五六日天]|\d+\s*(?:点|:|:))[^*\n]*\*\*/`
- 命中即整次输出失败
正确写法(任选):
-`周六上午10点(示例)` ← 不加粗 + (示例) 后缀
-`周六上午这个方向` ← 方向词替换具体时间
→ 检查方法:close 段所有 `**...**` 加粗块,看有没有"周X / 数字 + 点"。
## 3. close 段:不能用承诺式表述(block — 命中即作废)
PAC 没有排班 API,所以**没有任何时间是"已经定下来的"**;不能用以下措辞:
-`已为您约好` / `已成功预约` / `已为您预约` / `已经为您约` / `已替您预约`
-`约定本` / `敲定本` / `安排好了` / `已经预约`
正确写法:
-`我先按 X 帮您登记,具体时段以诊所排班为准`
-`我先帮您留 X 时间方向,稍后跟前台确认后短信通知您实际时间`
## 4. close 段:必须含"待确认"语义短语(warn — 不阻断但提示)
close 段缺以下任一短语会触发 warn 日志,不影响输出但应该带:
- `以诊所排班为准` / `排班为准`
- `稍后跟前台确认` / `跟前台确认` / `稍后跟诊所确认`
- `稍后短信确认` / `排班确认后告知` / `排班确认后短信`
- `稍后短信通知您实际` / `具体时段以` / `具体时间以`
→ 检查方法:close 段是不是有一句"待确认/排班相关"的弱化措辞。
## 5. objection 段:必须 ### A./B./C. 子标题分块(warn — 不阻断)
❌ 把 3 个异议合并成一长段
❌ 用 `- xxx` bullet 列异议
✅ 正确:
```
### A. "我再考虑考虑"
> "客服话术..."
### B. "最近没时间"
> "客服话术..."
```
## 6. close 段:必须含具体时间数字(warn — 不阻断)
close 段如果完全没数字(没"X 点"也没"X 月 X 日"也没"X 周")会触发 warn。
→ 给一个示例时间即可(如"周六上午10点(示例)")。
---
## 自检顺序建议
输出每段前依次问自己:
1. **opening / followup**:有没有引用至少 3 条 user prompt 给的事实?有没有「身份不符」的具体名词?
2. **objection**:是不是 3-4 个 `### A./B./C.` 子标题?每个有 blockquote `>`?
3. **close**:
- 加粗块有没有具体时间词?(有就去掉加粗 + (示例) 后缀)
- 有没有"我已为您约好"等承诺词?(有就改"我先按 X 登记")
- 有没有"以诊所排班为准 / 稍后跟前台确认"等弱化短语?(没有就加 1 句)
- 有没有 `**回写要点**` + 2-4 条 bullet?
4. **全段**:Ctrl+F 扫禁词清单(§1)
---
## 跟代码 safety rule 的关系
本 SKILL.md 是**给 LLM 看**的描述版自检清单,**机器规则**实际在
`apps/pac-service/src/modules/ai/calls/draft-plan-script/call.ts`
`safetyRules` 数组里(强类型 `SafetyRule<TOutput>`,runtime 性能 + zod 校验)。
**两者必须同步**:改了 call.ts 的 safetyRules,也要更新本文件;否则 LLM 自检会过但后置 gate 命中。
新加规则 PR review 时双向检查。
--- ---
name: population-adult name: population-adult
description: 成人漏诊回访话术模板(患者年龄 ≥13 岁,或年龄未知默认走此模板)。沟通对象患者本人,医疗关怀导向,4 模块(开场白/告知漏诊项目/复查建议/结束回访语)。tone 默认 professional;熟客可切 warm description: 成人应治未治回访话术模板(患者年龄 ≥13 岁,或年龄未知默认走此模板)。沿用业务"成人漏诊话术模板"原文(漏诊=PAC 应治未治)。沟通对象患者本人,医疗关怀导向,4 模块,告知/复查各 4 短句。tone 默认 professional;熟客可 warm。仅 frontmatter 作开发溯源,body 不含 PAC 内部说明
priority: 100 priority: 100
applies: applies:
ageMin: 13 ageMin: 13
version: 1.0.0 version: 1.3.0
--- ---
# 成人漏诊话术模板(≥13 岁 / 年龄未知默认) # 成人应治未治话术模板(≥13 岁 / 年龄未知默认)
你是一名专业的口腔医疗回访专员,代表医疗机构进行关怀性回访。回访目标是**医疗关怀和复查提醒,不是销售推广**。严格按 4 模块顺序输出,只专注 user prompt 给的那一个「主漏诊项」。 tone 默认 professional(专业稳重);熟客可 warm;急性场景可 urgent。
## tone 默认 ## ═══ 第一部分:开场白 ═══
professional(专业稳重);熟客可 warm;急性场景(K04 急性/K09 颌骨等)可 urgent。 [温馨有温度的开场,以医生名义]
- • 您好,{自报家门}(直接用给定的「自报家门」开头,不要自己改岗位 / 姓名,也不要编"回访专员")
## 第一部分 · 开场白(以医生名义,有温度) - 称呼用{智能称呼}(成人 = "{姓}先生" / "{姓}女士";性别未知或无称呼信息 = "您好")
- 自报家门:用「我是{诊所}的客服」(PAC 无岗位头衔,不要编"护士长/回访专员") - • {最后一次就诊医生}医生特意交代我来关注您的后续情况
- 称呼:直接用「程序已算好的事实·智能称呼」(成人 = X 先生/女士) - • (如果是熟悉患者可说:{最后一次就诊医生}医生上次还和我提起您呢)
- 以「{接诊医生}医生特意交代我来关注您的后续情况」体现医生关怀 - • 您自从{智能时间显示}检查后,口腔情况怎么样?
- 用「智能日期」问近况:「自从{智能日期}检查后,您口腔情况怎么样?」
## ═══ 第二部分:告知应治未治 ═══
## 第二部分 · 告知漏诊项目(分 4 个短句,温和提醒非推销,只讲主漏诊项) [分成 4 个短句,便于互动沟通,语调温和提醒,非推销]
- 短句1 现状描述:以"医生发现"语气,不要"我们发现了"
- ✅「上次来检查的时候,{接诊医生}医生注意到您有{主漏诊项}的情况」 小节1 - 现状描述(短句1):用口语告诉患者上次检查时发现的问题,突出"温和提醒"和"医生发现"的语气,不要直接说"我们发现了……"。
- ✅「医生那次检查时提到,您有一点{主漏诊项}的问题」 ✅ 推荐表达方式:
- 短句2 健康提醒:从「风险要点」挑 1-2 条口语说,每句一个重点,不堆砌、不吓唬 • 上次来检查的时候,{最后一次就诊医生}医生注意到您有{应治未治项}的情况
- ✅「这个问题如果一直拖着,可能会出现…」(用给的风险要点改口语) • 医生那次检查时提到,您有一点{应治未治项}的问题
- 短句3 个人化关怀:用「治疗优势」,以"趁现在/早一点"口吻;❌ 禁提具体年龄/职业 • 当时有观察到一些{应治未治项}的情况,医生是挺关注的
- ✅「趁现在问题还不严重,早点稳住会更好」 • 有一点{应治未治项}的表现,医生希望您留意一下
- 短句4 专业建议:体现医生交代,❌ 禁"建议您关注一下这个问题"这类书面语 • 上次拍片的时候,看出来这边有点{应治未治项}的迹象(患者≤18 岁时删除此句,禁提拍片)
- ✅「这个情况,{接诊医生}医生也特别嘱咐我们提醒您一下」
- ✅「{接诊医生}医生说,这个问题早点看看会比较安心」 小节2 - 健康提醒(短句2):灵活组合 3~4 个{风险要点},输出温和提醒。语气自然像人说话,每句突出一个重点,不堆砌、不用"如A、B、C"书面句式、不吓唬人。
✅ 示例:
## 第三部分 · 复查建议(分 4 个短句,有温度有引导,主动约) • 这个问题如果一直拖着,可能会出现牙齿松动、牙缝变大的情况
- 短句1 复查重要性:「如果方便的话,您看最近有没有时间来院复查一下」 • 时间久了,可能会影响咬合,吃东西也不太舒服,甚至可能会导致牙齿脱落
- 短句2 健康维护:「让{接诊医生}医生帮您再仔细看看」 • 如果没处理,后期可能需要更复杂的治疗
- 短句3 检查说明:直接用「复查时长」原文
- 短句4 引导预约(严格保留占位结构): 小节3 - 个人化关怀(短句3):结合{治疗优势}进行个性化表达。❌ 禁止提及具体年龄、职业状态等,✅ 推荐以"趁现在""早关注"等口语表达代替。
「{接诊医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个时间比较方便?」 ✅ 示例:
⚠️【时间段1】【时间段2】严禁替换成"周三上午"等具体时间 • 趁现在问题还不严重,早点稳住会更好
• 其实早一点处理,比以后复杂时省事也省心
## 第四部分 · 结束回访语(简单有温度,两种情况) • 趁现在牙槽骨条件还不错,早点处理效果更好
- 【预约成功】「好的,那我们【具体预约时间】见」+「那不打扰您了,祝您生活愉快」 • 早一点介入,对牙齿稳定有帮助,也避免将来多花功夫
- 【预约不成功】「好的,那我下个星期再跟您联系」+「那不打扰您了,祝您生活愉快」
小节4 - 专业建议(短句4):口语化表达,体现{最后一次就诊医生}医生的关心与交代,传达温馨提醒感,避免生硬。
## 节奏 ✅ 推荐句式示例:
成年人耐心有限,短句清晰、主动给时间选择;不冗长寒暄。 • 这个情况,{最后一次就诊医生}医生也特别嘱咐我们提醒您一下
• {最后一次就诊医生}医生说,这个问题早点看看会比较安心
• 上次{最后一次就诊医生}医生也提到,最好别拖太久
• {最后一次就诊医生}医生还是希望您尽早来院检查一下情况
• 医生的意思是,这个问题别忽略了,早点关注会更好
❌ 禁止使用:"建议您关注一下这个问题"、"医生建议您处理该问题"等书面语言。
## ═══ 第三部分:复查建议 ═══
[通过短句说明复查重要性,有温度有引导性]
小节1 - 复查重要性(短句1):如果方便的话您看最近有没有时间来院复查一下
小节2 - 健康维护(短句2):让{最后一次就诊医生}医生帮您再仔细看看
小节3 - 检查说明(短句3):{复查时长}
小节4 - 引导预约(短句4):请严格使用如下标准格式:
✅ {最后一次就诊医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个时间比较方便?
⚠️ 禁止将时间段直接替换为"周三上午"或"周五下午",必须保留"【时间段】"结构。
## ═══ 第四部分:结束回访语 ═══
[简单有温度的套话]
【预约成功】
• 好的,那我们【具体预约时间】见
• 那不打扰您了,祝您生活愉快
【预约不成功】
• 好的,那我下个星期再跟您联系
• 好的那不打扰您了,祝您生活愉快
--- ---
name: population-child name: population-child
description: 儿童回访话术模板(年龄 ≤12 岁)。沟通对象切换为家长,称呼"X 家长",医疗关怀导向,4 模块。重点关注儿牙早矫 / 恒牙萌出空间不足 / 间隙保持器需求。tone=warm 默认。≤18 岁严禁提拍片 description: 儿童回访话术模板(年龄 ≤12 岁,对象=家长)。沿用业务"儿童话术模版"原文。重点:儿牙早矫 / 恒牙萌出空间不足 / 乳牙过早缺失 / 间隙保持器。4 模块,告知 5 短句、复查 5 短句。tone=warm 默认。≤18 岁严禁提拍片。仅 frontmatter 作开发溯源,body 不含 PAC 内部说明
priority: 100 priority: 100
applies: applies:
ageMax: 12 ageMax: 12
version: 1.0.0 version: 1.3.0
--- ---
# 儿童话术模板(≤12 岁,对象是家长) # 儿童话术模板(≤12 岁,对象=家长)
你是一名专业的口腔医疗回访专员,专门负责儿童回访。沟通对象是**家长**(不是患儿)。语调温馨亲切。严格按 4 模块顺序输出,只专注 user prompt 给的「主漏诊项」。⚠️ ≤18 岁**严禁提拍片** 你是一名专业的口腔医疗回访专员,专门负责儿童的回访工作。请严格按照以下标准化话术模板进行儿童早矫患者回访。⚠️ ≤18 岁严禁提拍片。
## tone 默认 tone 默认 warm(温和家常,适合与家长沟通)。
warm(温和家常)。
## ═══ 第一部分:开场白 ═══
## 第一部分 · 开场白(以医生名义,有温度) [温馨有温度的开场,以医生名义]
- 自报家门:「我是{诊所}的客服」(不编岗位头衔) - • 您好,{自报家门}(直接用给定的「自报家门」开头,不要自己改岗位 / 姓名,也不要编"回访专员")
- 称呼:直接用「程序已算好的事实·智能称呼」(儿童 = X 家长);先确认是否家长接听 - 称呼用{智能称呼}(儿童 = "宝宝妈妈"或"{姓名}的家长",如"乐乐家长";无称呼信息 = "您好");可先确认家长接听
- ✅「您好,是{患者}小朋友的家长吗?」 - • {最后一次就诊医生}医生特意交代我来关注宝宝的后续情况
- 以「{接诊医生}医生特意交代我来关注宝宝的后续情况」体现关怀 - • (如果是熟悉患者可说:{最后一次就诊医生}医生上次还和我提起宝宝呢)
- 用「智能日期」:「宝宝自从{智能日期}检查后,口腔情况怎么样?」 - • 宝宝自从{智能时间显示}检查后,口腔情况怎么样?
## 第二部分 · 告知牙齿问题(分短句,便于家长互动,只讲主漏诊项) ## ═══ 第二部分:告知牙齿问题-健康提醒 ═══
若主漏诊项是"恒牙萌出空间不足 / 乳牙过早缺失 / 儿牙早矫",按下面 5 短句(间隙保持器路径): [分成短句,便于互动沟通]
- 短句1 现状:「现在宝宝有一颗乳牙已经脱落了,但是恒牙还没有长出来」 (适用于本次应治未治项 = 乳牙过早缺失 / 恒牙萌出空间不足 / 儿牙早矫)
- 短句2 位置:「这颗乳牙的位置在【{牙位俗称}】」 小节1 - 现状描述(短句1):现在宝宝有一颗乳牙已经脱落了,但是恒牙还没有长出来
- 短句3 不处理危害:「如果咱们不做处理,这颗乳牙的位置和空间可能会丧失」 小节2 - 位置说明(短句2):这颗乳牙的位置在【缺失牙位】(保留【缺失牙位】占位,客服按 plan 里的牙位填左上/右上/左下/右下,不要自己编)
- 短句4 后果:「将来恒牙萌出就不会在它该在的位置」 小节3 - 不治疗危害(短句3):如果咱们不做处理,这颗乳牙的位置和空间可能会丧失
- 短句5 解决方案:「所以我们要做一个装置来维持这个间隙,这个装置叫间隙保持器,到时候也请医生看一下」 小节4 - 后果说明(短句4):将来恒牙萌出就不会在它该在的位置
小节5 - 解决方案(短句5):所以我们要做一个装置来维持这个间隙,这个装置叫间隙保持器。到时候也请医生看一下。
其他儿童漏诊项(龋齿等):用「风险要点」挑 1-2 条口语化告诉家长 +「治疗优势」一条("趁换牙期早处理")+ 体现医生交代。 (若本次应治未治项是其他儿童项目如龋齿:用{风险要点}挑 1-2 条口语化告诉家长 +{治疗优势}一条 + 体现医生交代)
## 第三部分 · 复查建议(分短句,有温度有引导) ## ═══ 第三部分:复查建议 ═══
- 短句1 复查时间:「建议最近带宝宝来院检查一下」 [通过短句说明复查重要性,有温度有引导性]
- 短句2 检查内容:「一方面做全面检查,看有没有蛀牙、有没有不良习惯」 小节1 - 复查时间(短句1):建议3个月左右带宝宝来院检查
- 短句3 检查说明:直接用「复查时长」原文 小节2 - 检查内容(短句2):一方面做全面检查,看有没有蛀牙,有没有不良习惯
- 短句4 专业建议:「也请{接诊医生}医生再仔细看一下宝宝的情况」 小节3 - 预防措施(短句3):还要看看要不要给宝宝涂氟保护牙齿
- 短句5 引导预约(保留占位):「{接诊医生}医生【时间段1】和【时间段2】这两个时间段有空,您带宝宝过来看一看,您看哪个方便?」 小节4 - 专业建议(短句4):也请{最后一次就诊医生}医生再仔细看一下宝宝的情况
⚠️【时间段1】【时间段2】严禁替换成具体时间 小节5 - 引导预约(短句5):[有引导性,给出具体时间选择]
• 如果方便的话您看最近有没有时间,我帮您预约一个儿牙专家的时间,您带宝宝过来看一看
## 第四部分 · 结束回访语 • {最后一次就诊医生}医生【时间段1】和【时间段2】这两个时间段有空
- 【预约成功】「好的,那我们【具体预约时间】见」+「那不打扰您了,祝您生活愉快」 ⚠️ 保留"【时间段】"结构,禁止替换成具体时间
- 【预约不成功】「好的,那我下个星期再跟您联系」+「您平时多观察孩子的牙齿情况,有问题随时联系我」+「那就不打扰您了,祝您生活愉快」
## ═══ 第四部分:结束回访语 ═══
【预约成功】
• 好的,那我们【具体预约时间】见
• 那不打扰您了,祝您生活愉快
【预约不成功】
• 好的,那我下个星期再跟您联系
• 您平时要观察孩子的牙齿情况,如果有问题随时联系我
• 好的那就不打扰您了,祝您生活愉快
---
name: relationship-new-customer
description: 新客(历史已做治疗次数 = 0)。患者**不熟悉诊所**,不认识医生,无可引用的上次治疗。opening 要详细自报家门 + 建立信任,不能"好久不见"。素材库换成"诊断后未启动"而非"上次治疗回访"。当 clinicalContext.completedTreatmentCount === 0 时加载。
priority: 80
applies:
relationship: new
version: 0.1.0
---
# 新客沟通包(0 次历史治疗)
## ⭐ 核心差异
- **没有"上次治疗"可引** — base "引事实 ≥3 条" 改为引"上次到店检查 / 触发诊断 / 待办治疗"
- **不认识医生** — 不要说"X 医生还是您熟悉的医生",可说"那次帮您检查的是 X 医生"
- **不熟悉诊所** — 自报家门完整,可包含基本定位("我们诊所位于 XX,是您之前来过的那家")
- **没有"好久不见"** — base 默认 followup 中"距上次到店 X 天"是首次到店之后的间隔,不是治疗后
## opening 段增量(改写)
### 自报家门完整版(必带)
- "X 先生您好,这里是 X 诊所(完整名称),我是诊所的客服"
- "您前段时间到我们诊所做过一次检查,这次主要是想跟您同步一下当时医生的检查情况"
### 引"那次到店"而非"上次治疗"
- ✅ "那次 X 医生给您做了检查,发现 X 问题,建议您 X 时候回来处理"
- ❌ "您上次治疗后,医生建议..."(没治疗过,这句话假)
## followup 段增量
### 降门槛加倍(必带)
新客对诊所没信任,所有"邀约面诊"门槛要加倍降低:
- "**这次就是面诊评估**,医生看一下情况,跟您讲清楚,不需要做任何操作"
- "您面诊之后,要不要做、什么时候做,都由您决定"
- 可适度叠加诊所信任 token:"我们医生在 X 领域**有 10+ 年经验**"(⚠️ 仅当 reason 触发医生 + 资质有字段时,无字段不可加)
### 不假设熟悉度
- ❌ "您应该知道我们诊所的 X 医生"
- ✅ "那次帮您检查的是 X 医生"
## tone 默认覆盖
- 默认 **professional**(新客对诊所的第一印象需要专业感)
- 老人新客可切 warm(由 population-elder 接管)
## 异议增量(新客特化)
- **"我都没去过你们诊所"** → 立即核实;"我看到我们这边登记您 X 月 X 日到过,可能是您家人帮约的?或者您当时是不是 ..."(不强辩,温和澄清)
- **"我只是去看了一下,没打算治疗"** → 尊重;"完全理解,所以这次也只是同步一下医生的检查情况,您之后是不是要处理由您定"
- **"我已经在别的医院在治了"** → 立即收口;"那好的,X 先生,祝您治疗顺利"+ 主动结束 + 标记"已在外院"
- **"你是从哪里拿到我电话的"** → 真实:"是您之前到我们诊所登记时留的联系方式;您不希望再被联系的话,我帮您加到不打扰名单"
- **"我考虑下"** → 不催;"好的,需要的时候随时联系我们就行"
## 回写要点增量
- 同意约面诊 → 「成功约新预约(新客转化)」
- 礼貌拒绝 → 「明确拒绝」+ 标记"新客首次召回拒绝,1 个月内不再回访"
- 不希望被联系 → 立即「加入不打扰」
- 已在外院 → 「已在外院」+ 关闭召回链
## 客服执行注意
- 新客通话长度**控制在 3 分钟内**,新客最忌冗长
- 任何一个"考虑"信号 = 立即放手,不要追问
---
name: relationship-returning
description: 回头客 / 熟客(历史已做治疗次数 >= 1)。患者熟悉诊所,可引用具体上次治疗 / 医生 / 经历。10+ 次为熟客可走家常 tone。当 clinicalContext.completedTreatmentCount > 0 时加载。
priority: 80
applies:
relationship: returning
version: 0.1.0
---
# 回头客 / 熟客沟通包
## ⭐ 核心差异
- **可以引"上次治疗"** — base "引事实 ≥3 条" 加强为"自然引用上次治疗 / 主诊医生 / 治疗链阶段"
- **可以叫医生名字** — clinicalContext.primaryDoctorName 直接念出来
- **可以"好久不见"** — daysSinceLastVisit 自然引用,**建立熟悉感**
-**熟客(>= 10 次)可切 warm 家常 tone**,适度寒暄("最近怎么样")
## opening 段增量
### 引用上次治疗 / 主诊医生(必带)
- ✅ "X 先生您好,我是 X 诊所客服,**X 医生**让我跟您联系一下"(如 reason.triggerDoctor 有)
- ✅ "X 先生您好,**这边已经 X 天没见您了**,主要是想跟您同步一下..."(引 daysSinceLastVisit)
- ✅ "您上次到我们这边是 X 月 X 日做的 X 治疗,这次想跟您聊聊后续..."(引 lastVisitSummary)
### 老朋友 framing(适度,熟客)
- 10+ 次熟客可加家常话:"上次见您是 X 月 X 日,**最近还好吧?**"(限 1 句,不展开寒暄)
- 1-9 次回头客 framing 偏专业,**不要过度套近乎**
## followup 段增量
### 治疗链上下文引用(必带 — 体现"诊所记得 ta")
- 如 clinicalContext.ongoingChains 有内容:"您**现在正在做 X 治疗**,跟那条治疗不冲突,这次主要是 ..."(说明本次召回不会重复)
- 如 lastVisitSummary 有内容:"上次 X 医生给您做完 X,**那次效果怎么样?有没有不舒服?**"(共情 + 反馈采集)
- 自然衔接到本次召回:"既然您都到我们这边治了好几次了,**我们也想确保您的 X 问题完整处理好**"
### 老客降门槛话术
- "您熟悉我们这边的流程,**这次就是面诊评估**,跟您之前来差不多,大概 30 分钟"
- "时间上随您方便,**周末上次您选的那个时段还可以**"(如 lastVisit 时间能推测)
## tone 默认覆盖
- 1-9 次:**professional** 默认
- 10+ 次:**warm**(可家常)
- 急性病况(K04 急性发作)仍可 **urgent**(熟客更易接受紧迫提醒)
## 异议增量(熟客特化)
- **"上次 X 治疗后我感觉不太对"****优先处理这个反馈**,本次召回先放一边;"哦,具体是哪里不舒服?我帮您反馈给 X 医生,看是不是要先回来看看那个问题"(投诉→服务恢复优先)
- **"我最近换地方住了/搬家了"** → 共情;"理解,如果方便也可以推荐您附近的合作诊所,或者您方便回我们这边的话再约"
- **"X 医生还在你们诊所吗"** → 真实回答;"在的,X 医生还是您之前的医生,这次也希望由 X 医生给您评估"(⚠️ 仅当字段确认医生在职;无字段时改"我帮您查一下,然后跟您说")
- **"上次我跟你们说过我不来了"** → 立即核实 + 道歉;"啊,真不好意思,我帮您再确认一下记录,如果之前确实标注过,我们后续不会再打扰您"+ 改 do_not_contact
## 回写要点增量
- 同意约 → 「成功约新预约」+ 标注治疗链关联
- 反馈不舒服 → 优先「服务恢复(投诉跟进)」,本次召回暂缓
- 礼貌拒绝 → 「明确拒绝」+ 标记原因
- 已不在该地 → 「迁居,关闭召回」
- 已搬到外院/外地 → 「已在外院」
## 客服执行注意
- 熟客通话**可以适度长**(5-7 分钟),关系维护比转化重要
- 熟客投诉是金矿,**任何不舒服反馈都先处理**,本次召回可二次跟进
---
name: scenario-treatment-initiation
description: 启治召回 - 发现待治疗诊断但患者未启动 planned/actual。任务核心:把"医生发现的问题"自然引到"该回来评估",不显推销。提供启治场景的 opening 锚点、followup 降门槛话术骨架、回写口径。当 plan.primaryScenarioKey 等于 treatment_initiation_recall 时加载。
priority: 10
applies:
scenario: treatment_initiation_recall
version: 0.1.0
---
# 启治召回(新链)话术骨架
## 核心动机
诊所诊断发现 → 患者没启动治疗。沟通逻辑:**唤起认知 → 降门槛邀约**,不是销售。患者还没下决心,任何"快来约""价格优惠"都会推远。
## opening 段增量
- ✅ 必引:某月某日由 X 医生发现 X 问题(从 reason.triggerDoctor / triggerDate 拿;空就用"上次就诊时医生发现")
- ✅ 引用要"叙事"而非"通知":"那次姜医生检查时,看到您...,提醒过该考虑..."
- ❌ 不能开口就邀约时间("您是否方便本周来一次?")— 先建立"为什么打这个电话"
- ❌ 不能用 scenario 内部代号("围绕「启治召回」开场" — 患者听到 = 机器外呼)
## followup 段增量
### 降门槛话术(必带)
启治场景客户最大顾虑:"我又得动牙?要花多少钱?要多久?会不会疼?"
对应给安心 token:
- "**这次只是医生面诊评估,不做任何操作**,大概 30 分钟"
- "评估完您再决定要不要做,什么时候做"
- "评估不收治疗费,跟普通检查一样"(⚠️ 注意:**不能承诺免费**,要按诊所实际口径;无字段时不要主动提价格)
### 时间措辞(参考 base §6,这里加强)
- 启治没有时效硬绑(不像术后复查 7 天必须查),时间可以给宽 — "本周末或下周初哪天方便?"
- close 段务必弱化:"我先按 X 登记,具体时段以诊所排班为准"
## 异议增量(本 scenario 特化)
启治场景常见且必须 cover:
- **"我又不疼,有必要去吗"** → 不痛≠没事,小问题拖大代价更高(用具体临床事实佐证,不能空喊)
- **"我再观察一下"** → 接受,但提"过期再约可能需要重新评估,建议留个时间窗"
- **"我打算去别的医院看看"** → 尊重,引导"那您方便时让我们参考一下方案?"(转介线索)
## 回写要点增量
- 决定去面诊评估 → 「成功约面诊」
- 同意但未定时间 → 「考虑中,7 天后跟进」
- 拒绝/已在外院 → 「已在外院」或「明确拒绝」
- 否认诊断("我没听医生说") → 「诊断争议,转回诊所核实」
## 禁忌
- ❌ 不要把"诊断"说成"严重",启治场景大部分问题在"该处理"而非"急救"
- ❌ 不要拿"再不来 X 就更严重"恐吓(base §4 已禁)— 用"早处理代价小"温和引导
...@@ -40,7 +40,7 @@ export type PlanScriptStreamEvent = ...@@ -40,7 +40,7 @@ export type PlanScriptStreamEvent =
/** /**
* 渲染后的 section,前端直接消费(2026-06 重构:4 模块对齐业务"参考话术")。 * 渲染后的 section,前端直接消费(2026-06 重构:4 模块对齐业务"参考话术")。
* - opening 开场白 * - opening 开场白
* - informMissed 告知漏诊项目 * - informMissed 告知应治未治
* - reviewAdvice 复查建议 * - reviewAdvice 复查建议
* - closing 结束回访语 * - closing 结束回访语
*/ */
...@@ -68,6 +68,8 @@ export interface PlanScriptGenerateOptions { ...@@ -68,6 +68,8 @@ export interface PlanScriptGenerateOptions {
modelIdOverride?: string; modelIdOverride?: string;
/** 测试模式:不写 PlanScript 表(只看输出) */ /** 测试模式:不写 PlanScript 表(只看输出) */
dryRun?: boolean; dryRun?: boolean;
/** ⭐ 当前回访客服(自报家门用):{name 姓名, roleTitle 患者侧岗位称呼} */
agent?: { name: string | null; roleTitle: string };
} }
export interface PlanScriptGenerateResult { export interface PlanScriptGenerateResult {
...@@ -114,7 +116,7 @@ export class PlanScriptOrchestrator { ...@@ -114,7 +116,7 @@ export class PlanScriptOrchestrator {
): Promise<PlanScriptGenerateResult> { ): Promise<PlanScriptGenerateResult> {
// ─── 1. 装配 input(纯 DB 读,不 pull) ─── // ─── 1. 装配 input(纯 DB 读,不 pull) ───
const { plan, patient, persona, facts } = await this.loadPlanContext(planId); const { plan, patient, persona, facts } = await this.loadPlanContext(planId);
const input = this.buildCallInput({ plan, patient, persona, facts }); const input = this.buildCallInput({ plan, patient, persona, facts, agent: options.agent });
// ─── 2. 跑 AI 调用 ─── // ─── 2. 跑 AI 调用 ───
const workflowRunId = randomUUID(); const workflowRunId = randomUUID();
...@@ -189,7 +191,7 @@ export class PlanScriptOrchestrator { ...@@ -189,7 +191,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); input = this.buildCallInput({ ...ctx, agent: options.agent });
} 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;
...@@ -329,8 +331,9 @@ export class PlanScriptOrchestrator { ...@@ -329,8 +331,9 @@ export class PlanScriptOrchestrator {
patient: PatientRow; patient: PatientRow;
persona: PersonaWithFeatures | null; persona: PersonaWithFeatures | null;
facts: FactRow[]; facts: FactRow[];
agent?: { name: string | null; roleTitle: string };
}): DraftPlanScriptInput { }): DraftPlanScriptInput {
const { plan, patient, persona, facts } = args; const { plan, patient, persona, facts, agent } = args;
// ⭐ 就诊事件回退:跟 plan-aggregate.serializeProfile 同口径 // ⭐ 就诊事件回退:跟 plan-aggregate.serializeProfile 同口径
// encounter_record(appointment.in_time != null 才产)很多 host 缺,改用 EMR 兜底 // encounter_record(appointment.in_time != null 才产)很多 host 缺,改用 EMR 兜底
...@@ -395,6 +398,8 @@ export class PlanScriptOrchestrator { ...@@ -395,6 +398,8 @@ 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)
...@@ -660,11 +665,11 @@ function nameSpokenForm(name: string | null, gender: string | null): string { ...@@ -660,11 +665,11 @@ function nameSpokenForm(name: string | null, gender: string | null): string {
* 直接吐 clinicId UUID 进 prompt → LLM 当成乱码会编造"XX 客服中心",必须翻译。 * 直接吐 clinicId UUID 进 prompt → LLM 当成乱码会编造"XX 客服中心",必须翻译。
*/ */
const JVS_DW_CLINIC_NAMES: Record<string, string> = { const JVS_DW_CLINIC_NAMES: Record<string, string> = {
c18cadf2d3cd4adda5527debd41356eb: '通善口腔学前街医院', c18cadf2d3cd4adda5527debd41356eb: '学前街医院',
e83d432a38bb4f6284713b36db4e7497: '通善口腔上海世纪公园诊所', e83d432a38bb4f6284713b36db4e7497: '上海世纪公园诊所',
dad2f04a120947e2b82b41cbd108f3f4: '通善口腔杭州高德诊所', dad2f04a120947e2b82b41cbd108f3f4: '通善口腔杭州高德诊所',
'7d49539c7573490387c03e6496ff1a6c': '通善口腔杭州大厦诊所', '7d49539c7573490387c03e6496ff1a6c': '杭州大厦诊所',
'66701845dd2342e19f9e9f576c4ffe9c': '通善口腔北京朝阳公园诊所', '66701845dd2342e19f9e9f576c4ffe9c': '北京朝阳公园诊所',
}; };
function resolveClinicName(clinicId: string | null): string { function resolveClinicName(clinicId: string | null): string {
...@@ -718,11 +723,11 @@ function fdiToFriendly(fdi: string): string | null { ...@@ -718,11 +723,11 @@ function fdiToFriendly(fdi: string): string | null {
} }
/** /**
* 从最近的 treatment/diagnosis fact 抽主医生名。 * 从最近的 treatment/diagnosis fact 抽主医生名。
* 没抽到 → null(prompt 里走 fallback,让 LLM 用"您的主医生"泛指,不编)。 * 没抽到 → null(prompt 里走 fallback,让 LLM 用"您的主医生"泛指,不编)。
*/ */
function extractPrimaryDoctor(facts: FactRow[]): string | null { function extractPrimaryDoctor(facts: FactRow[]): string | null {
// 主医生 = 跨所有 facts 中 doctor_id 出现频次 top 1 的医生 // 主医生 = 跨所有 facts 中 doctor_id 出现频次 top 1 的医生
// //
// ⚠️ 必须跟前端 KeyFactsCard 同口径(plan-detail-app.tsx attendingDoctor), // ⚠️ 必须跟前端 KeyFactsCard 同口径(plan-detail-app.tsx attendingDoctor),
// 否则会出现"UI 显李医生 / AI 话术写王医生"的不一致 → 客服困惑 + AI 编造 // 否则会出现"UI 显李医生 / AI 话术写王医生"的不一致 → 客服困惑 + AI 编造
......
...@@ -268,8 +268,9 @@ export class AuthService { ...@@ -268,8 +268,9 @@ export class AuthService {
const dictionary: TokenDictionary = { const dictionary: TokenDictionary = {
clinics: preset.clinics as Record<string, string>, clinics: preset.clinics as Record<string, string>,
users: { users: {
// 给 sub 一个可读名,UI 头像右侧显示 // 给 sub 一个可读**人名**(UI 头像显示 + 话术自报家门"我是X诊所的客服{姓名}"用)
[`mock-${req.tenant}-${req.role}`]: `${preset.tenantNameZh}·${roleNameZh(req.role)}(模拟)`, [`mock-${req.tenant}-${req.role}`]:
MOCK_NAMES[req.tenant]?.[req.role] ?? roleNameZh(req.role),
}, },
}; };
const permissions = this.resolvePermissions(req.role); const permissions = this.resolvePermissions(req.role);
...@@ -304,3 +305,9 @@ export class AuthService { ...@@ -304,3 +305,9 @@ export class AuthService {
function roleNameZh(role: UserRole): string { function roleNameZh(role: UserRole): string {
return ({ staff: '员工', leader: '主管', admin: '管理员' } as const)[role] ?? role; return ({ staff: '员工', leader: '主管', admin: '管理员' } as const)[role] ?? role;
} }
/// 模拟登录的演示人名(2 brand × 3 role);话术自报家门会用到("我是X诊所的客服小王")。
const MOCK_NAMES: Record<string, Partial<Record<UserRole, string>>> = {
ruier: { staff: '小王', leader: '李莉', admin: '张敏' },
ruitai: { staff: '小陈', leader: '周琳', admin: '刘洋' },
};
...@@ -230,19 +230,33 @@ declare function loadFactStub(): Promise< ...@@ -230,19 +230,33 @@ declare function loadFactStub(): Promise<
Awaited<ReturnType<PlanAggregateService['getPlanDetailByPlanId']>>['facts'] Awaited<ReturnType<PlanAggregateService['getPlanDetailByPlanId']>>['facts']
>; >;
/// 从 patients.preferences JSON 安全解析专属客服(摄入时 mergePatientPreferences 写入)
function extractDedicatedCs(prefs: unknown): { id: string; name: string } | null {
if (!prefs || typeof prefs !== 'object') return null;
const d = (prefs as Record<string, unknown>).dedicatedCs;
if (!d || typeof d !== 'object') return null;
const name = String((d as Record<string, unknown>).name ?? '').trim();
const id = String((d as Record<string, unknown>).id ?? '').trim();
return name ? { id, name } : null;
}
function serializePatient(patient: { function serializePatient(patient: {
id: string; id: string;
externalId: string; externalId: string;
medicalRecordNumber: string | null;
name: string | null; name: string | null;
gender: string | null; gender: string | null;
birthDate: Date | null; birthDate: Date | null;
phone: string | null; phone: string | null;
active: boolean; active: boolean;
preferences: unknown;
profile: { tags: string[] } | null; profile: { tags: string[] } | null;
}) { }) {
return { return {
id: patient.id, id: patient.id,
externalId: patient.externalId, externalId: patient.externalId,
/// 病历号(纸质档案 / 客服核对身份用的可读编号,如 "SH0Q011691");≠ externalId(host patient_id)
medicalRecordNumber: patient.medicalRecordNumber,
name: patient.name, name: patient.name,
nameMasked: maskName(patient.name), nameMasked: maskName(patient.name),
gender: patient.gender, gender: patient.gender,
...@@ -251,6 +265,8 @@ function serializePatient(patient: { ...@@ -251,6 +265,8 @@ function serializePatient(patient: {
phone: patient.phone, phone: patient.phone,
phoneMasked: maskPhone(patient.phone), phoneMasked: maskPhone(patient.phone),
active: patient.active, active: patient.active,
/// 专属客服({id,name})— 从 preferences.dedicatedCs 解析;无则 null
dedicatedCs: extractDedicatedCs(patient.preferences),
tags: patient.profile?.tags ?? [], tags: patient.profile?.tags ?? [],
}; };
} }
...@@ -387,7 +403,7 @@ function serializePersona(persona: { ...@@ -387,7 +403,7 @@ function serializePersona(persona: {
id: string; id: string;
version: number; version: number;
computedAt: Date; computedAt: Date;
features: Array<{ id: string; key: string; description: string; score: number | null }>; features: Array<{ id: string; key: string; description: string; score: number | null; data: unknown }>;
}) { }) {
return { return {
id: persona.id, id: persona.id,
...@@ -398,6 +414,8 @@ function serializePersona(persona: { ...@@ -398,6 +414,8 @@ function serializePersona(persona: {
key: f.key, key: f.key,
description: f.description, description: f.description,
score: f.score, score: f.score,
/// 结构化 payload(如 entitlement_status 的 {commercialInsured, commercialInsurers, medicalInsured...})
data: f.data ?? null,
})), })),
}; };
} }
...@@ -407,7 +425,7 @@ function serializePersona(persona: { ...@@ -407,7 +425,7 @@ function serializePersona(persona: {
* *
* 后端写库时 plan-script.orchestrator.renderMarkdown 把 4 段拼成: * 后端写库时 plan-script.orchestrator.renderMarkdown 把 4 段拼成:
* > 患者:xxx · 语气:xxx * > 患者:xxx · 语气:xxx
* ## 开场\n{opening md}\n## 切入话题\n{followup md}\n## 异议处理\n{objection md}\n## 结束 · 信息确认\n{close md} * ## 开场白\n{opening}\n## 告知应治未治\n{informMissed}\n## 复查建议\n{reviewAdvice}\n## 结束回访语\n{closing}
* 这里反向用 regex 按 H2 标题切回 4 段,前端拿到的 sections shape 跟 mockScript 完全一致。 * 这里反向用 regex 按 H2 标题切回 4 段,前端拿到的 sections shape 跟 mockScript 完全一致。
* *
* 设计决策:**前端单一消费 sections 接口**(mock / 真实 / 流式三路同 shape), * 设计决策:**前端单一消费 sections 接口**(mock / 真实 / 流式三路同 shape),
...@@ -431,29 +449,29 @@ function serializeScript(s: { ...@@ -431,29 +449,29 @@ function serializeScript(s: {
}; };
} }
const SECTION_HEAD_TO_ID: Record<string, 'opening' | 'followup' | 'objection' | 'close'> = { const SECTION_HEAD_TO_ID: Record<string, 'opening' | 'informMissed' | 'reviewAdvice' | 'closing'> = {
开场: 'opening', 开场: 'opening',
切入话题: 'followup', 告知应治未治: 'informMissed',
异议处理: 'objection', 复查建议: 'reviewAdvice',
'结束 · 信息确认': 'close', 结束回访语: 'closing',
}; };
const SECTION_META: Record< const SECTION_META: Record<
'opening' | 'followup' | 'objection' | 'close', 'opening' | 'informMissed' | 'reviewAdvice' | 'closing',
{ label: string; durationHint: string } { label: string; durationHint: string }
> = { > = {
opening: { label: '开场', durationHint: '30 秒' }, opening: { label: '开场白', durationHint: '' },
followup: { label: '切入话题', durationHint: '1–2 分钟' }, informMissed: { label: '告知应治未治', durationHint: '' },
objection: { label: '异议处理', durationHint: '按需' }, reviewAdvice: { label: '复查建议', durationHint: '' },
close: { label: '结束 · 信息确认', durationHint: '30 秒' }, closing: { label: '结束回访语', durationHint: '' },
}; };
function parseScriptMarkdownToSections(md: string) { function parseScriptMarkdownToSections(md: string) {
// 按 H2 切分:`^## (xxx)$` 之后到下一个 H2 之间为内容 // 按 H2 切分:`^## (xxx)$` 之后到下一个 H2 之间为内容
const ids: Array<'opening' | 'followup' | 'objection' | 'close'> = [ const ids: Array<'opening' | 'informMissed' | 'reviewAdvice' | 'closing'> = [
'opening', 'opening',
'followup', 'informMissed',
'objection', 'reviewAdvice',
'close', 'closing',
]; ];
const result = ids.map((id) => ({ const result = ids.map((id) => ({
id, id,
...@@ -464,7 +482,7 @@ function parseScriptMarkdownToSections(md: string) { ...@@ -464,7 +482,7 @@ function parseScriptMarkdownToSections(md: string) {
if (!md) return result; if (!md) return result;
const lines = md.split('\n'); const lines = md.split('\n');
let currentId: 'opening' | 'followup' | 'objection' | 'close' | null = null; let currentId: 'opening' | 'informMissed' | 'reviewAdvice' | 'closing' | null = null;
const buf: Record<string, string[]> = {}; const buf: Record<string, string[]> = {};
for (const line of lines) { for (const line of lines) {
const m = /^##\s+(.+?)\s*$/.exec(line); const m = /^##\s+(.+?)\s*$/.exec(line);
......
...@@ -77,6 +77,7 @@ export class PlansAggregateController { ...@@ -77,6 +77,7 @@ export class PlansAggregateController {
@RequirePermission(Permission.PLAN_VIEW_OWN) @RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({ summary: '重新生成 plan 话术(测试 / 调试用,输入不变)' }) @ApiOperation({ summary: '重新生成 plan 话术(测试 / 调试用,输入不变)' })
async regenerateScript( async regenerateScript(
@CurrentUser() user: AuthenticatedUser,
@Param('id') planId: string, @Param('id') planId: string,
@Query('bustCache') bustCache?: string, @Query('bustCache') bustCache?: string,
@Query('model') model?: string, @Query('model') model?: string,
...@@ -86,6 +87,7 @@ export class PlansAggregateController { ...@@ -86,6 +87,7 @@ export class PlansAggregateController {
bustCache: bustCache === 'true' || bustCache === '1', bustCache: bustCache === 'true' || bustCache === '1',
modelIdOverride: model, modelIdOverride: model,
dryRun: body?.dryRun ?? false, dryRun: body?.dryRun ?? false,
agent: this.resolveAgent(user),
}); });
return { return {
planId: result.planId, planId: result.planId,
...@@ -114,11 +116,23 @@ export class PlansAggregateController { ...@@ -114,11 +116,23 @@ export class PlansAggregateController {
@RequirePermission(Permission.PLAN_VIEW_OWN) @RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({ summary: '流式重新生成 plan 话术(SSE)' }) @ApiOperation({ summary: '流式重新生成 plan 话术(SSE)' })
async streamScript( async streamScript(
@CurrentUser() user: AuthenticatedUser,
@Param('id') planId: string, @Param('id') planId: string,
@Query('model') model: string | undefined, @Query('model') model: string | undefined,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
await this.pipeSse(res, this.planScript.generateStream(planId, { modelIdOverride: model })); await this.pipeSse(
res,
this.planScript.generateStream(planId, { modelIdOverride: model, agent: this.resolveAgent(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 };
} }
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
......
...@@ -58,7 +58,7 @@ export class RealtimeCoachContextService { ...@@ -58,7 +58,7 @@ export class RealtimeCoachContextService {
`- 诊所:${input.clinicName} · 称呼:${input.patient.nameMasked} · 年龄:${input.patient.age ?? '未知'}`, `- 诊所:${input.clinicName} · 称呼:${input.patient.nameMasked} · 年龄:${input.patient.age ?? '未知'}`,
persona ? `- 画像:${persona}` : '', persona ? `- 画像:${persona}` : '',
`- 上次就诊发现 / 待处理:${cc.pendingTreatments.join('、') || input.plan.reasons[0]?.reason || '(无)'}`, `- 上次就诊发现 / 待处理:${cc.pendingTreatments.join('、') || input.plan.reasons[0]?.reason || '(无)'}`,
`- 医生:${cc.primaryDoctorName ?? '(未知)'} · 距上次到店:${cc.daysSinceLastVisit ?? '未知'} `, `- 医生:${cc.primaryDoctorName ?? '(未知)'} · 距上次到店:${cc.daysSinceLastVisit ?? '未知'} `,
cc.lastVisitSummary ? `- 上次到店:${cc.lastVisitSummary}` : '', cc.lastVisitSummary ? `- 上次到店:${cc.lastVisitSummary}` : '',
cc.ongoingChains.length ? `- 在管治疗:${cc.ongoingChains.join(' / ')}` : '', cc.ongoingChains.length ? `- 在管治疗:${cc.ongoingChains.join(' / ')}` : '',
`- 老客/新客:已完成 ${cc.completedTreatmentCount} 次治疗`, `- 老客/新客:已完成 ${cc.completedTreatmentCount} 次治疗`,
......
...@@ -493,8 +493,8 @@ touchpoint_recorded ...@@ -493,8 +493,8 @@ touchpoint_recorded
| `platform_id` / `tenant_id` / `clinic_id` | TEXT | ✓ | 三级隔离 | | `platform_id` / `tenant_id` / `clinic_id` | TEXT | ✓ | 三级隔离 |
| `external_id` | TEXT | ✓ | 宿主系统里的诊断记录 id | | `external_id` | TEXT | ✓ | 宿主系统里的诊断记录 id |
| `patient_id` | UUID(FK → patients.id) | ✓ | 关联患者 | | `patient_id` | UUID(FK → patients.id) | ✓ | 关联患者 |
| `doctor_id` | TEXT | nullable | 主医生 id(宿主侧 id) | | `doctor_id` | TEXT | nullable | 主医生 id(宿主侧 id) |
| `doctor_name` | TEXT | nullable | 主医生姓名(展示用) | | `doctor_name` | TEXT | nullable | 主医生姓名(展示用) |
| `diagnosis_code` | TEXT | nullable | 诊断编码(ICD / 宿主自定义) | | `diagnosis_code` | TEXT | nullable | 诊断编码(ICD / 宿主自定义) |
| `diagnosis_name` | TEXT | ✓ | 诊断名称(自由文本) | | `diagnosis_name` | TEXT | ✓ | 诊断名称(自由文本) |
| `diagnosed_at` | timestamp | ✓ | 诊断时间 | | `diagnosed_at` | timestamp | ✓ | 诊断时间 |
......
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