Commit f60f4cca by luoqi

refactor(script): prompt 优化收口 — PII 单一源 + 接地软化 + 牙位/开场/熟络度修

system/user 提示词系统性打磨 + 单一源收口(三档共用):
- PII 收口:称呼派生(callSalutation/pickGuardian/nameSpokenForm)从 orchestrator
  归到 shared/pii.ts(与 deidentifyDoctor 同处,人名/称呼单一源);script-facts.ts
  退化为纯确定性渲染(smartDateDisplay + fdiToFriendly/toothFriendly),删死函数
  resolveSalutation/resolveAgeBranch/resolveAgeGroup。
- common.md:角色=要点提纲非照念;铁律收口(接地不编 first / 突出本次应治未治项 /
  医生名义 / 钱与方案软化"可点名不报价落点复查" / ≤18 禁拍片 / 主动约 / 口语短句),
  去反向示例词。
- population adult/child SKILL.md:瘦身去重,语气改"熟络度"(recency 为主+次数为辅,
  交 LLM 判断,去掉"新老客"二分退化标签)。
- fact-block(标准/深度共用):开场锚定最近一次就诊(非诊断日);熟络度 relationSignal;
  recentTreatments 结构化"用自己的话自然带";renderMedicalRecord。
- fdiToFriendly 补乳牙象限+字母记法(1A-4E),修儿童龋齿"1D/1E"原样泄漏给患者。
- 开场 bug 修:从 triggerDate(诊断日,可能更早)改为最近一次就诊;"检查后"→"来过之后"。
- stable tier:prompt/schema/phrasing/stable.call/format.md 配套(目标 + 牙位占位 +
  医嘱/建议/治疗计划 + ≤18 belt;预约不成功措辞软化);儿童模板第二部分改病种无关。
- deep tier calls / standard.call:版本对齐。

本地 tsc + build 通过;CLI dry-run 多档重生验证称呼/开场/牙位/熟络度均正确。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent b3455a80
......@@ -17,12 +17,15 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string {
const top = plan.reasons[0];
const salutation = patient.salutation;
const doctorSurname = deidentifyDoctor(top?.triggerDoctor ?? clinicalContext.primaryDoctorName ?? null);
// ⭐ 开场"自从…来过之后"锚【最近一次就诊】(可能晚于诊断日);诊断日更早,归病历/告知用,不放开场
const lastVisitDate =
clinicalContext.daysSinceLastVisit != null
? new Date(now.getTime() - clinicalContext.daysSinceLastVisit * 86400_000)
: null;
const projectDate = top?.triggerDate ? new Date(top.triggerDate) : lastVisitDate;
const dateDisplay = smartDateDisplay(projectDate, now) ?? '上次';
const lastVisitDisplay =
smartDateDisplay(lastVisitDate, now) ??
(top?.triggerDate ? smartDateDisplay(new Date(top.triggerDate), now) : null) ??
'上次';
const chiefComplaint = top?.medicalRecord?.chiefComplaint ?? clinicalContext.lastChiefComplaint ?? null;
const diseaseLabel = resolveDiseaseLabel(top ?? null, plan.primaryScenarioLabel);
......@@ -43,19 +46,21 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string {
return `${resolveDiseaseLabel(r, r.scenarioLabel)}${t}`;
});
// 历史治疗:给结构化要素(类目/术式/医生/时间),口语化交给 LLM —— 别照搬这串,自然带一句即可
const recent = clinicalContext.recentTreatments.map((t) =>
[`做过${t.categoryLabel}`, t.subtype ? `上次 ${t.subtype}` : '', t.doctorName ? `${t.doctorName}医生` : '', t.date ?? '']
[t.date, t.categoryLabel, t.subtype, t.doctorName ? `${t.doctorName}医生` : '']
.filter(Boolean)
.join(' · '),
.join(' / '),
);
const g = (patient.gender ?? '').trim().toUpperCase();
const genderText = g === '男' || g === 'M' ? '男' : g === '女' || g === 'F' ? '女' : '';
const basics = [genderText, patient.age != null ? `${patient.age}岁` : ''].filter(Boolean).join(',');
const toneHint =
clinicalContext.completedTreatmentCount > 0
? '老客户(之前在本诊所看过),语气可更熟络温和'
: '首诊/新客户,语气专业可信为主';
// 熟络度信号(给原始事实,不贴"新/老"二分 —— 召回患者基本都有历史,二分会全判"老客"):
// 完成治疗次数(关系深浅,辅)+ 距上次就诊天数(还熟不熟,主)。tone 交 LLM 按 recency 判断。
const relationSignal = `已完成 ${clinicalContext.completedTreatmentCount} 次治疗;距上次就诊 ${
clinicalContext.daysSinceLastVisit ?? '未知'
} `;
const noXray = patient.age == null || patient.age <= 18;
return `# 本次回访患者信息(只能用以下事实,不要编造或推断额外信息)
......@@ -63,7 +68,7 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string {
## 开场用
- {智能称呼}:${salutation}
- {自报家门}:${selfIntro}
- {智能时间显示}:${dateDisplay}
- {智能时间显示}(最近一次就诊,用于开场"自从…来过";诊断日见病历/可能更早):${lastVisitDisplay}
- 那次主诉:${chiefComplaint ?? '无记录'}
- {诊断医生}:${doctorSurname}医生${guardianHint ? `\n- 触达说明:${guardianHint}` : ''}
......@@ -76,14 +81,13 @@ ${mrBlock || '- (本次诊断无关联病历,按 {应治未治项} + 牙科常
## 本次目标(内部参考 — 指导复查/邀约方向,不要逐字念给患者)
- ${goal ?? '邀约来院复查,让医生评估本次问题'}
${others.length ? `\n## 其他可一并关心的问题(以本次聚焦为主,自然可顺带;不展开)\n- ${others.join('、')}` : ''}${
recent.length ? `\n\n## 近期做过的治疗(引用以体现"诊所记得 ta",不要重复邀约已做过的)\n${recent.map((r) => `- ${r}`).join('\n')}` : ''
recent.length ? `\n\n## 近期做过的治疗(下面是结构化要素,**用你自己的话自然带一句**体现"诊所记得 ta",别照搬格式;不要重复邀约已做过的)\n${recent.map((r) => `- ${r}`).join('\n')}` : ''
}
## 患者
- ${basics}
## 语气
- ${toneHint}${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**' : ''}`;
- 熟络度:${relationSignal}
tone 你判断:最近来过+有历史→可熟络 warm;很久没来(沉睡,如距上次 >1 ) 有历史也别太自来熟、先重新拉近;关系浅→ professional;急性/疼痛→ urgent${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**' : ''}`;
}
/** 聚焦病历 → 接地素材块(SOAP 关键字段;空字段略) */
......
/**
* pii — 发给第三方 LLM 的人名"去名留称呼"
* pii — 人名"去名留称呼"的**单一源**(患者 / 监护人 / 医生)
*
* 纪律:
* - **不暴露真名(全名)**:徐雅静 / 韩维 这种全名不进 user prompt。
* - **但要有可用称呼**:给"姓 + 敬称"的自然称呼(徐女士 / 韩医生),LLM 直接用、客服也直接看。
* —— 不用 token(LLM 看不懂 + 回填复杂),直接给去名后的称呼。
* - 真名(全名)只在 PAC 内部 ScriptContext(深度档/内部可取),不进 prompt。
*
* 称呼怎么来:
* - 患者/监护人:orchestrator 的 callSalutation 已产"姓+性别敬称 / 家长"(姓级,非全名)。
* - 医生:本文件 deidentifyDoctor —— 取姓(去名),模板拼成"X医生"
* 本文件统一产"称呼"(姓级):
* - 患者 / 监护人:callSalutation(年龄 + 性别 + 监护人 aware)、pickGuardian(挑监护人)。
* - 医生:deidentifyDoctor(取姓,模板拼"X医生")
*/
/**
......@@ -24,3 +23,67 @@ export function deidentifyDoctor(name: string | null | undefined): string {
if (n.endsWith('医生')) return n.slice(0, -2);
return n.charAt(0);
}
/**
* 患者通话称呼 — "姓 + 先生/女士"(性别未知默认先生,亚洲诊所语境;无名 → "您")。
* 只取姓(去名),不出全名。
*/
function nameSpokenForm(name: string | null, gender: string | null): string {
const surname = (name ?? '').charAt(0);
if (!surname) return '您';
if (gender === '男' || gender === 'M' || gender === 'male') return `${surname}先生`;
if (gender === '女' || gender === 'F' || gender === 'female') return `${surname}女士`;
return `${surname}先生`;
}
const RELATIONSHIP_LABEL_ZH: Record<string, string> = {
mother: '妈妈',
father: '爸爸',
grandparent: '祖辈',
};
/**
* 从关系行里挑监护人:优先已建档(有姓名),关系优先 妈妈 > 爸爸 > 祖辈。
*/
export function pickGuardian(
rels: Array<{ relationship: string; relatedPatient: { name: string | null } | null }>,
): { relationship: string; relationshipLabel: string; name: string | null } | null {
const order = ['mother', 'father', 'grandparent'];
const sorted = [...rels].sort((a, b) => {
const an = a.relatedPatient?.name ? 0 : 1;
const bn = b.relatedPatient?.name ? 0 : 1;
if (an !== bn) return an - bn; // 有姓名优先
return order.indexOf(a.relationship) - order.indexOf(b.relationship);
});
const g = sorted[0];
if (!g) return null;
return {
relationship: g.relationship,
relationshipLabel: RELATIONSHIP_LABEL_ZH[g.relationship] ?? '家长',
name: g.relatedPatient?.name ?? null,
};
}
/**
* 通话称呼(单一源:年龄 + 性别 + 监护人 aware)— 姓级,绝不出全名。
* - 未成年(≤12,打给监护人):妈妈→"{监护人姓}女士"、爸爸→"{监护人姓}先生";无监护人姓名 → "{患者姓}家长"。
* - 成人:姓 + 先生/女士。
*/
export function callSalutation(
name: string | null,
gender: string | null,
age: number | null,
guardian: { relationship: string; name: string | null } | null,
): string {
const surname = (name ?? '').charAt(0);
if (age != null && age <= 12) {
const gName = (guardian?.name ?? '').trim();
if (gName) {
const gs = gName.charAt(0);
if (guardian!.relationship === 'mother') return `${gs}女士`;
if (guardian!.relationship === 'father') return `${gs}先生`;
}
return surname ? `${surname}家长` : '您';
}
return nameSpokenForm(name, gender);
}
/**
* script-facts — 把"参考话术"提示词里**本该程序判断的确定性逻辑**提取成纯函数 + 字典
* script-facts — 确定性渲染 helper(**不含 PII**):智能日期 + 牙位俗称
*
* 设计原则(渐进式组合 + 提示词缓存):
* - LLM 不再做:年龄分支 / 智能日期格式 / 漏诊项优先级挑选 / 查 key-points 表 / 查复查时长 / 智能称呼。
* 这些全在这里算好,作为"已定事实"塞进 user prompt,LLM 只负责把事实润色成话术。
* - 静态铁律/模板 → system(base-system.md + population skill,前缀缓存);
* 本文件算出的"单个患者相关"事实 → user(动态,渐进式只给相关的那一条,不发全表)。
*
* 真理源:文案要点来自业务给的"漏诊项目关键要点配置 / 复查时长配置 / 年龄组配置"。
* 设计原则:LLM 不该做的确定性活(日期格式、FDI→俗称查表)在这里算好,作为事实塞 user prompt。
* ⚠️ 人名/称呼(PII)派生不在本文件 —— 见 shared/pii.ts(callSalutation / deidentifyDoctor);
* 病种文案见 tiers/stable/phrasing.ts。
*/
// ─────────────────────────────────────────────────────────
// 年龄分支(儿童 ≤12 / 成人 ≥13;未知 → 成人)
// ─────────────────────────────────────────────────────────
export type AgeBranch = 'child' | 'adult';
export function resolveAgeBranch(age: number | null | undefined): AgeBranch {
if (typeof age === 'number' && Number.isFinite(age)) return age <= 12 ? 'child' : 'adult';
return 'adult'; // 未知 / 模糊 → 默认成人漏诊模板
}
/** 年龄组(成人 key-points 的"年龄适应性"用) */
export function resolveAgeGroup(age: number | null | undefined): '青年' | '中年' | '老年' | null {
if (typeof age !== 'number' || !Number.isFinite(age)) return null;
if (age >= 60) return '老年';
if (age >= 36) return '中年';
if (age >= 18) return '青年';
return null; // 儿童/青少年走儿童模板,这里不给成人年龄适应性
}
// ─────────────────────────────────────────────────────────
// 智能日期显示(今年→X月X号 / 去年→去年X月 / 更早→XXXX年X月)
// —— 替代提示词里那段 python datetime 函数(LLM 不该跑日期逻辑)
// ─────────────────────────────────────────────────────────
......@@ -43,27 +20,6 @@ export function smartDateDisplay(visit: Date | string | null | undefined, now: D
return `${d.getFullYear()}${m}月`;
}
// ─────────────────────────────────────────────────────────
// 智能称呼(成人→{姓}先生/女士 · 儿童→{姓名}家长 · 未知→您好)
// nameMasked 已脱敏(如"侯*"),取首字作姓;脱敏掉首字则回退"您好"
// ─────────────────────────────────────────────────────────
export function resolveSalutation(params: {
nameMasked: string | null | undefined;
gender: string | null | undefined;
branch: AgeBranch;
}): string {
const { nameMasked, gender, branch } = params;
const surname = (nameMasked ?? '').trim().charAt(0);
if (branch === 'child') {
return surname ? `${surname}家长` : '您好';
}
const g = (gender ?? '').trim();
if (!surname || (g !== '男' && g !== '女' && g.toUpperCase() !== 'M' && g.toUpperCase() !== 'F')) {
return '您好';
}
const honorific = g === '男' || g.toUpperCase() === 'M' ? '先生' : '女士';
return `${surname}${honorific}`;
}
// ─────────────────────────────────────────────────────────
// 牙位俗称渲染(FDI → 患者口语;边界渲染器,ScriptContext 里只存 FDI 结构)
......@@ -74,20 +30,34 @@ export function resolveSalutation(params: {
// 4-5=前磨,6-7=磨牙,8=智齿);1x/2x 上颌,3x/4x 下颌。
// ─────────────────────────────────────────────────────────
export function fdiToFriendly(fdi: string): string | null {
if (fdi === '*whole' || fdi.toLowerCase() === 'whole') return '全口';
const m = /^([1-8])([1-8])$/.exec(fdi);
if (!m) return null;
const q = Number(m[1]);
const t = Number(m[2]);
const upper = q === 1 || q === 2 || q === 5 || q === 6;
const isPrimary = q >= 5; // 乳牙
const where = upper ? '上' : '下';
const baby = isPrimary ? '乳' : '';
if (t === 1 || t === 2) return `${where}${baby}门牙`;
if (t === 3) return `${where}尖牙`;
if (t === 4 || t === 5) return `${where}小磨牙`;
if (t === 6 || t === 7) return `${where}大磨牙`;
if (t === 8) return '智齿';
const s = fdi.trim();
if (s === '*whole' || s.toLowerCase() === 'whole') return '全口';
// ① FDI 数字记法:第1位象限(1-4 恒牙,5-8 乳牙),第2位位置
const m = /^([1-8])([1-8])$/.exec(s);
if (m) {
const q = Number(m[1]);
const t = Number(m[2]);
const upper = q === 1 || q === 2 || q === 5 || q === 6;
const baby = q >= 5 ? '乳' : '';
const where = upper ? '上' : '下';
if (t === 1 || t === 2) return `${where}${baby}门牙`;
if (t === 3) return `${where}尖牙`;
if (t === 4 || t === 5) return `${where}小磨牙`;
if (t === 6 || t === 7) return `${where}大磨牙`;
if (t === 8) return '智齿';
return null;
}
// ② 乳牙"象限+字母"记法(host):1-4 象限 + A-E(A中切/B侧切/C尖牙/D第一乳磨/E第二乳磨)
const d = /^([1-4])([A-Ea-e])$/.exec(s);
if (d) {
const q = Number(d[1]);
const tooth = d[2]!.toUpperCase();
const where = q === 1 || q === 2 ? '上' : '下';
if (tooth === 'A' || tooth === 'B') return `${where}乳门牙`;
if (tooth === 'C') return `${where}乳尖牙`;
if (tooth === 'D' || tooth === 'E') return `${where}乳磨牙`;
return null;
}
return null;
}
......
你是一名专业的口腔医疗回访专员,代表医疗机构进行关怀性回访。回访目标是医疗关怀和复查提醒,不是销售推广。
# 回访定位和语调要求
✅ 定位:医疗关怀回访,不是销售回访
✅ 语调:温馨、专业、关怀,避免推销感
✅ 目标:健康提醒、复查建议,不是治疗推荐
✅ 态度:建议性而非推销性,尊重患者选择
# 核心原则(所有档共用)
✅ 只专注处理本次给定的那一个 {应治未治项},其他项目完全忽略
✅ 必须以医生名义体现医生的关怀和交代(用 {诊断医生} 医生)
✅ 必须用短句、便于客服与患者互动,不要单方面长篇
✅ 主动引导预约,给出具体时间选择(时间走【时间段】占位,不写死)
# 接地(只用给定事实,不编造)
所有事实只能来自"本次回访患者信息"里给定的字段;空缺就泛指或省略,**不得编造**(不杜撰医生名 / 诊断 / 价格 / 政策 / 设备 / 患者背景)。给定的值直接用,不要自己重算、改写或改格式。
# 绝对禁止事项
❌ 严禁提及费用、金钱、价格、优惠等任何经济内容
❌ 严禁给出具体治疗方案建议,只能建议复查检查
❌ 严禁使用推销性语言(如"机会难得"、"限时"、"特价"等)
❌ 如患者≤18岁,严禁提及拍片
❌ 严禁虚构任何患者信息
❌ 严禁处理 {应治未治项} 以外的任何其他项目
❌ 严禁说"给您建议"等机器人式语言
❌ 严禁说"您方便再预约",必须主动引导预约
❌ 严禁忽略医生交代的温度感
# 语言风格要求
话术必须口语化,适合医疗回访专员直接使用;语调温馨关怀,体现医生的人文关怀和专业交代;避免商业化和推销感、避免机器人式语言;重点强调健康维护而非治疗推荐;用短句便于互动。
# 输出格式
只输出 1 个合法 JSON 对象,简体中文,不要解释文字、不要代码块包裹。content 只放话术正文,每个短句单独成行、行首用 `•`,短句之间用 `\n` 换行。❌ content 内严禁出现 "═══ 第一部分…" 这种大标题 / 分隔符 / 表情符号 / `###` 标题。
你为口腔诊所客服回访电话产出**要点提纲**:关键内容、短句,供客服参考着引导对话,不是逐字照念的稿子。目的是医疗关怀 + 复查提醒,不是销售。
# 铁律
- **严格尊重事实**:只用"本次回访患者信息"里给的事实;没给或不明确的**一律不编**——医生名 / 诊断 / 牙位 / 时间 / 价格 / 病史都不许杜撰,编错会当场被患者戳穿,宁可泛指或省略。给定值直接用,不改写、不重算。
- **突出本次问题**:以给定的 {应治未治项} 为主线讲透;其他项目不展开、不必逐一交代。
- **医生名义**:以 {诊断医生} 医生的关怀与交代为口吻,有温度。
- **钱与方案**:不谈费用 / 价格 / 优惠、不报价;可点出本次该处理什么(如"该补上""该做牙周基础治疗"),但不替医生定细化方案,落点是"来院复查、请医生评估"。
- **≤18 岁**:整篇不提拍片。
- **主动约**:引导预约、给时间选择;时间用【时间段】占位,不写死、不承诺。
- **口语短句**:像真人说话、一来一回;不长篇,不用书面语、机器人腔、推销/促单口吻。
# 输出
1 个合法 JSON,简体中文,无解释文字、无代码块。每条短句单独成行、行首 `•`、用 `\n` 换行;不要大标题 / `###` / `═══` / 表情。
---
name: population-adult-common
description: 成人(患者年龄 ≥13 岁,或年龄未知默认)回访的**共性沟通知识**(tier-agnostic,三档共用)。只讲"对谁说、什么语气、怎么称呼",不含固定句位模板。沟通对象=患者本人;医疗关怀导向、非推销。tone 默认 professional,熟客可 warm,急性可 urgent。
description: 成人(患者年龄 ≥13 岁,或年龄未知默认)回访的**共性沟通知识**(tier-agnostic,三档共用)。只讲人群独有的:对谁说、什么语气、怎么称呼。沟通对象=患者本人;tone 默认 professional,熟客可 warm,急性可 urgent。
priority: 90
applies:
ageMin: 13
version: 1.0.0
version: 1.2.0
---
# 成人沟通知识(≥13 岁 / 年龄未知默认)
- **沟通对象**:患者本人。
- **称呼**:用给定的 {智能称呼}(已是"姓+先生/女士",直接用;未知用"您好")。
- **语气**:默认 professional(专业稳重);老客户可 warm(熟络温和);急性/疼痛场景可 urgent。
- **打电话顺序**:先称呼 + 确认对方方便 → 再自报家门({自报家门}) → 以 {诊断医生} 医生名义体现关怀 → 问近况 → 自然带出本次 {应治未治项}。
- **关怀而非推销**:突出"医生发现/医生交代"的语气,不说"我们发现了…";个性化关怀用"趁现在/早一点",不提具体年龄/职业。
- **引导预约**:主动给时间选择,时间走【时间段】占位,不写死、不承诺。
# 成人沟通(≥13 岁 / 年龄未知默认)
- 沟通对象=患者本人;称呼用 {智能称呼}(姓 + 先生/女士)。
- 语气按"熟络度"判断(看 user 给的 完成治疗次数 + 距上次就诊):最近来过的熟客可 warm 熟络;久未到店(沉睡)即使有历史也先重新拉近、别太自来熟;急性/疼痛可 urgent;其余 professional。
- 突出"{诊断医生}医生发现 / 交代"的口吻,不说"我们发现了…";关怀用"趁现在/早一点",别拿患者年龄、职业说事。
---
name: population-child-common
description: 儿童(年龄 ≤12 岁,对象=家长)回访的**共性沟通知识**(tier-agnostic,三档共用)。只讲"对谁说、什么语气、怎么称呼、儿科红线",不含固定句位模板。沟通对象=家长;称患儿为"宝宝";tone 默认 warm。⚠️ ≤18 岁严禁提拍片
description: 儿童(年龄 ≤12 岁,对象=家长)回访的**共性沟通知识**(tier-agnostic,三档共用)。只讲人群独有的:对谁说(家长)、称患儿"宝宝"、语气、儿童病种 pitfall。tone 默认 warm
priority: 90
applies:
ageMax: 12
version: 1.0.0
version: 1.2.0
---
# 儿童沟通知识(≤12 岁,对象=家长)
- **沟通对象**:家长(不是孩子本人)。话术里称患儿为"宝宝"。
- **称呼**:用给定的 {智能称呼}(如"徐女士"/"宝宝妈妈"/"家长",直接用;未知用"您好")。
- **语气**:默认 warm(温和家常,适合与家长沟通)。
- **打电话顺序**:先称呼 + 确认家长方便 → 再自报家门({自报家门}) → 以 {诊断医生} 医生名义体现关怀 → 问宝宝近况 → 自然带出本次 {应治未治项}。
- **儿科红线**:⚠️ ≤18 岁**严禁提拍片**(拍片/X光/牙片一律不说)。
- **针对本次问题**:复查/邀约对齐本次 {应治未治项},不要框成"常规涂氟体检";涂氟、查蛀牙等只作顺带关怀。
- **引导预约**:主动给时间选择,时间走【时间段】占位,不写死、不承诺。
# 儿童沟通(≤12 岁,对象=家长)
- 沟通对象=家长(不是孩子);话术里称患儿为"宝宝";称呼用 {智能称呼}(如 X 女士 / 宝宝妈妈 / 家长)。
- 语气默认 warm(温和家常,适合对家长)。熟络度看 user 给的 完成治疗次数 + 距上次就诊:最近常带宝宝来的可更熟络;很久没来的先重新拉近、别太自来熟。
- 复查/邀约对齐本次 {应治未治项},别框成"常规涂氟体检";涂氟、查蛀牙只作顺带关怀。
- ⚠️ 儿科红线:整篇不提拍片(拍片/X光/牙片一律不说)。
......@@ -45,7 +45,7 @@ export class DeepPlanCall implements AiCall<ScriptContext, DeepPlanZ> {
export class DeepWriteCall implements AiCall<DeepWriteInput, DeepWriteZ> {
readonly kind = 'script' as const;
readonly callKey = 'draft_plan_script_write';
readonly promptVersion = 'draft_plan_script@2026-06-06-deep-write-v1';
readonly promptVersion = 'draft_plan_script@2026-06-06-deep-write-v5';
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DeepWriteSchema;
constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {}
......
......@@ -13,8 +13,9 @@ import { deidentifyDoctor } from '../../shared/pii';
* WHERE call_key='draft_plan_script' GROUP BY prompt_version ORDER BY started_at DESC`
* 即可对比版本效果。
*
* ⭐ 现行 promptVersion 常量在 call.ts。system prompt 由 skill-composer 装配
* (base-system.md + 命中的 SKILL.md),本文件只负责 user prompt(按患者拼事实)。
* ⭐ 现行 promptVersion 常量在 stable.call.ts。system prompt 由 skill-composer 装配
* (shared/_base/common.md + tiers/stable/_base/format.md + 命中的人群 SKILL.md),
* 本文件只负责 user prompt(按患者拼事实槽)。
*
* 历史:
* - 2026-05-17-a — 初版,5 字段
......@@ -45,13 +46,17 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
const salutation = patient.salutation; // 姓+性别敬称 / 家长(orchestrator 算好,姓级)
// 诊断医生去名:韩维 → "韩"(模板会拼成"韩医生");无名 → "您的主治"(→"您的主治医生")
const doctorSurname = deidentifyDoctor(top?.triggerDoctor ?? clinicalContext.primaryDoctorName ?? null);
// ⭐ 开场"自从…来过之后近况如何"锚【最近一次就诊】(可能晚于诊断日:缺牙早诊断、后来又来洁过牙);
// 诊断日(triggerDate)更早、归"告知段"用,不放开场,否则"自从2021诊断后"会忽略患者2023又来过。
const lastVisitDate =
clinicalContext.daysSinceLastVisit != null
? new Date(now.getTime() - clinicalContext.daysSinceLastVisit * 86400_000)
: null;
// 项目相关:日期/主诉/医生优先取"该应治未治项那次诊断"的,而非泛泛"最近一次就诊"
const projectDate = top?.triggerDate ? new Date(top.triggerDate) : lastVisitDate;
const dateDisplay = smartDateDisplay(projectDate, now) ?? '上次';
const lastVisitDisplay =
smartDateDisplay(lastVisitDate, now) ??
(top?.triggerDate ? smartDateDisplay(new Date(top.triggerDate), now) : null) ??
'上次';
// 主诉取"该应治未治项那次诊断"的(项目相关),非最近一次
const chiefComplaint = top?.medicalRecord?.chiefComplaint ?? clinicalContext.lastChiefComplaint ?? null;
// 病种知识(单一访问源:subKey 优先 + 文本兜底);稳健档把 risks/advantages 注入模板槽
const disease = resolveDisease(top ?? null, plan.primaryScenarioLabel);
......@@ -86,15 +91,14 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
const g = (patient.gender ?? '').trim().toUpperCase();
const genderText = g === '男' || g === 'M' ? '男' : g === '女' || g === 'F' ? '女' : '';
const basics = [genderText, patient.age != null ? `${patient.age}岁` : ''].filter(Boolean).join(',');
// 监护人提示(未成年:打给家长,患者是孩子)。⚠️ 不放监护人真名(脱敏;名字对生成无价值,称呼用 token)
// 监护人提示(未成年:打给家长,患者是孩子)。⚠️ 不放监护人真名(脱敏;名字对生成无价值,称呼走 {智能称呼})
const guardianHint = patient.guardian
? `本次电话打给${patient.guardian.relationshipLabel}(称呼见{智能称呼}),沟通对象是家长,患者是孩子,话术里称孩子为"宝宝"`
: null;
// 语气线索(熟客 vs 新客 → tone 选择;不念出来)
const toneHint =
clinicalContext.completedTreatmentCount > 0
? '老客户(之前在本诊所看过),语气可更熟络温和'
: '首诊/新客户,语气专业可信为主';
// 熟络度信号(给原始事实,不贴"新/老"二分 —— 召回患者基本都有历史)。recency 为主判断 tone(不念出来)
const toneHint = `已完成 ${clinicalContext.completedTreatmentCount} 次治疗、距上次就诊 ${
clinicalContext.daysSinceLastVisit ?? '未知'
} 最近来过可熟络温和;很久没来(沉睡)有历史也别太自来熟、先重新拉近;关系浅则专业可信为主`;
// ≤18 / 年龄未知 → 禁拍片 belt(青少年走成人模板时,成人模板含"拍片"句,这里硬提醒删)
const noXray = patient.age == null || patient.age <= 18;
......@@ -109,12 +113,12 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
## 开场用
- {智能称呼}:${salutation}
- {自报家门}:${selfIntro}
- {智能时间显示}:${dateDisplay}
- 那次主诉:${chiefComplaint ?? '无记录'}
- {智能时间显示}(最近一次就诊,用于开场"自从…来过"):${lastVisitDisplay}
- 那次主诉(该问题诊断那次,可能更早):${chiefComplaint ?? '无记录'}
- {诊断医生}:${doctorSurname}医生${guardianHint ? `\n- 触达说明:${guardianHint}` : ''}
## 本次应治未治
- {应治未治项}:${disease.label}${toothText ? `\n- {牙位}:${toothText}(已是患者口语俗称,直接用` : ''}
- {应治未治项}:${disease.label}${toothText ? `\n- {牙位}:${toothText}` : ''}
- {风险要点}:
${riskLines}
- {治疗优势}:
......
import { z } from 'zod';
/**
* DraftPlanScript AiCall 的 Zod 输出 schema
* 稳健档 4 段输出 schema(顺序固定:开场白 → 告知应治未治 → 复查建议 → 结束回访语)
*
* 4 模块结构(2026-06-02 重构,对齐业务"参考话术"提示词):
* 开场白 → 告知应治未治 → 复查建议 → 结束回访语(顺序固定,缺一不可)
*
* 设计:per-段 markdown 字段(等价业务要的 {scripts:[{title,content}]},但带 zod 强校验 +
* 流式 + 安全闸,更稳)。LLM 只把"程序算好的事实"(称呼/智能日期/单个应治未治项/风险要点/
* 复查时长 —— 见 script-facts.ts)润色成话术,不做年龄分支/日期格式/优先级/查表等确定性判断。
* 段内"短句"用 markdown bullet(`• ...`)分行,便于客服与患者互动。
* ⚠️ 各段短句**数量与具体结构**(成人 4 句 / 儿童 5 句等)以 system prompt 里匹配到的
* 人群模板(population skill)为准,本 schema 只描述模块用途与通用格式,不写死句数。
* ⚠️ describe 会被注入 system,所以这里**只描述段用途 + 格式 + 关键硬约束**,
* 详细写法(开场顺序 / 句位 / 措辞)以 system 提示词(format.md + 人群句位模板)为单一源,
* 不在 describe 里复述,避免与模板冲突(历史教训:describe 写反了开场顺序、写死了"下周")。
*/
export const DraftPlanScriptSchema = z.object({
tone: z
.enum(['warm', 'professional', 'urgent'])
.describe('整体语气标签:warm=温和家常 / professional=专业稳重 / urgent=有时效紧迫'),
.describe('整体语气:warm=温和家常 / professional=专业稳重 / urgent=有时效紧迫'),
opening: z
.string()
.min(50)
.max(600)
.describe(
[
'【第一部分·开场白 — 以医生名义,有温度】',
'用 `• ` bullet 分 3-4 句,内容必须包含(顺序):',
'1. 自报家门:用「{诊所}的{岗位角色}{岗位姓名}」(岗位角色严禁写"回访专员")',
'2. 智能称呼:user 给的 {称呼}(已算好,直接用,不要自己改)',
'3. 以「{诊断医生}医生特意交代」体现医生关怀',
'4. 用 user 给的 {智能日期} 问近况:「自从{智能日期}检查后,口腔情况怎么样?」',
'禁止:加大标题/═══分隔符、加表情、写成抒情排比',
].join('\n'),
),
.describe('第一部分·开场白。markdown,`• ` 短句分行。内容/顺序按系统提示词的模板,不要大标题/分隔符/表情。'),
informMissed: z
.string()
.min(80)
.max(900)
.describe(
[
'【第二部分·告知应治未治 — 只讲 user 给的那一个 {应治未治项},严禁提其他项】',
'用 `• ` bullet 分短句(**句数与结构以系统提示词里匹配的人群模板为准**:成人 4 句 / 儿童 5 句),每句一个重点,口语化,温和提醒非推销:',
'现状描述:以「{诊断医生}医生上次检查注意到您有{应治未治项}的情况」表达,不要说"我们发现了"',
'健康提醒:从 user 给的 {风险要点} 里灵活挑 1-2 条口语说,不堆砌、不吓唬、不用"如A、B、C"书面句式',
'个人化关怀:用 user 给的 {治疗优势要点},以"趁现在/早一点"口吻;禁止提具体年龄/职业',
'专业建议:体现「{诊断医生}医生也特别嘱咐提醒您」,禁止"建议您关注一下"这类书面语',
].join('\n'),
),
.describe('第二部分·告知应治未治。**只讲本次一个 {应治未治项}**,温和提醒非推销;markdown `• ` 短句分行。'),
reviewAdvice: z
.string()
.min(80)
.max(900)
.describe(
[
'【第三部分·复查建议 — 有温度有引导,主动约】',
'用 `• ` bullet 分短句(**句数与结构以系统提示词里匹配的人群模板为准**:成人 4 句 / 儿童 5 句):',
'核心:复查重要性 +「让{诊断医生}医生帮您再仔细看看」+(成人)直接用 {复查时长} 原文',
'引导预约必须严格用「{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」',
'⚠️【时间段1】【时间段2】保留占位结构,严禁替换成"周三上午"等具体时间(PAC 无排班 API)',
].join('\n'),
),
.describe('第三部分·复查建议。引导预约用【时间段1】【时间段2】占位、**不写死具体时间**;markdown `• ` 短句分行。'),
closing: z
.string()
.min(40)
.max(500)
.describe(
[
'【第四部分·结束回访语 — 简单有温度,含两种情况】',
'分两块,各用 `• ` bullet:',
'【预约成功】:「好的,那我们【具体预约时间】见」+「祝您生活愉快」(【具体预约时间】保留占位,不写死)',
'【预约不成功】:「没关系,我下周再联系您」+「祝您生活愉快」',
'禁止:承诺式"已为您约好/敲定 X";禁止加粗具体时间',
].join('\n'),
),
.describe('第四部分·结束回访语。含【预约成功】/【预约不成功】两种;时间用【具体预约时间】占位、不承诺、不写死。'),
});
export type DraftPlanScriptOutputZ = z.infer<typeof DraftPlanScriptSchema>;
# 输出结构(4 模块,顺序固定,缺一不可)—— 稳健档
输出 1 个 JSON:`tone` + 4 段 Markdown 字符串,顺序固定:
1. `opening` 第一部分·开场白
2. `informMissed` 第二部分·告知应治未治
3. `reviewAdvice` 第三部分·复查建议
4. `closing` 第四部分·结束回访语
# 输出结构(稳健档:固定 4 模块)
4 段 Markdown 字段,顺序固定、缺一不可、不可乱序:
1. `opening` 开场白 2. `informMissed` 告知应治未治 3. `reviewAdvice` 复查建议 4. `closing` 结束回访语
# 严格执行要求 - 核心强制规则
🚨 4个模块必须全部包含,缺一不可!
✅ 模块顺序固定:开场白 → 告知应治未治 → 复查建议 → 结束回访语
✅ 告知应治未治、复查建议必须分成短句,便于客服与患者互动
⚠️ 如果输出缺少任何一个模块、或打乱顺序,整个话术将被视为不合格!
# 占位符约定(两种,别搞混)
- `{xxx}` = **替换**:用"本次回访患者信息"里给的同名值填(如 {智能称呼}{应治未治项}{牙位}{诊断医生}{风险要点}{复查时长});输出里不能再出现 `{}`
- {智能称呼} / {诊断医生} 已是"姓+敬称",直接用;{牙位} 已是俗称(上门牙),没给就不提牙位。
- `【xxx】` = **原样保留**(客服手填):【时间段1】【时间段2】【具体预约时间】;结束语分支标签【预约成功】【预约不成功】照常输出。
# 占位符约定(两种,含义不同)
- `{xxx}` = **要替换**的占位:用"本次回访患者信息"里给的同名值填进去(如 {智能称呼}{应治未治项}{牙位}{诊断医生}{风险要点}{复查时长})。输出里不能再出现 `{}`
- `【xxx】` = **原样保留**的占位:不要替换、照抄进话术,客服打电话时手动填。只有这几个:【时间段1】【时间段2】【具体预约时间】。
- 注:{智能称呼}/{诊断医生} 给的是"姓+敬称"(如 徐女士/韩医生),直接用即可,不是 `【】` 占位。
- 注:{牙位} 已是患者口语俗称(如"上门牙"),直接用;**若本次未给 {牙位}**(全口/无具体牙位),就不提牙位。
- 另:结束语的分支标签【预约成功】【预约不成功】也照常输出。
# 填空要点
- 开场顺序固定:先用 {智能称呼} 称呼并确认对方 → 再 {自报家门} → 以 {诊断医生} 名义体现关怀 → 用 {智能时间显示} 问近况。
- 健康提醒从 {风险要点} 里挑、检查说明用 {复查时长} 原文;给定值直接用,不改写、不重算。
- 引导预约严格用:「{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」
- 告知应治未治、复查建议分成短句。
# 直接使用给定的事实(稳健档槽位)
⚠️ 开场顺序固定:**先用{智能称呼}称呼并确认对方方便 → 再自报家门用{自报家门}** → 以{诊断医生}名义体现关怀 → 用{智能时间显示}问近况。
本次只讲{应治未治项}、健康提醒从{风险要点}挑、检查说明用{复查时长}原文。以上值直接用,不要自己重算、改写或改格式、不要调换开场顺序。
# 时间用占位
- 引导预约严格用「{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」
- 结束语·预约成功保留「我们【具体预约时间】见」。
- ⚠️【时间段1】【时间段2】【具体预约时间】原样保留占位,严禁替换成"周三上午"等具体时间。❌ 严禁加粗具体时间、严禁"已为您约好 / 敲定 X"承诺。
# 输出前自查
✅ 4个模块全部包含且顺序正确?
✅ 开场白以{诊断医生}医生名义,体现医生交代的关怀?
✅ 只专注 {应治未治项} 一个,没提其他项目?
✅ 告知应治未治、复查建议都分了短句?
✅ 称呼 / 日期 / 复查时长用的是给定的值?时间保留了【时间段】占位、没写死具体时间?
✅ 无费用 / 方案 / 推销 / 虚构?主动给了【时间段】预约选择?避免了机器人式语言?
# 输出前自查(只查高风险)
- 没编造?医生 / 诊断 / 牙位 / 时间都来自给定事实。
- 时间是【时间段】占位、没写死、没承诺?
- 只讲本次一个 {应治未治项}?4 段齐全、顺序对?
---
name: population-adult
description: 成人应治未治回访话术模板(患者年龄 ≥13 岁,或年龄未知默认走此模板)。沿用业务"成人漏诊话术模板"原文(漏诊=PAC 应治未治)。沟通对象患者本人,医疗关怀导向,4 模块,告知/复查各 4 短句。tone 默认 professional;熟客可 warm。仅 frontmatter 作开发溯源,body 不含 PAC 内部说明
description: 成人应治未治回访话术模板(患者年龄 ≥13 岁,或年龄未知默认走此模板)。稳健档句位脚手架:4 模块、告知/复查各 4 短句,填空 + 润色。沟通对象患者本人。语气走人群共性知识、开场顺序走输出格式,本文件不重复
priority: 100
applies:
ageMin: 13
version: 1.3.0
version: 1.4.0
tiers: ['stable']
---
# 成人应治未治话术模板(≥13 岁 / 年龄未知默认)
tone 默认 professional(专业稳重);熟客可 warm;急性场景可 urgent
句位脚手架:每段按下面小节填空 + 润色;✅ 是可选措辞范例,挑一句自然化,不要全堆上
## ═══ 第一部分:开场白 ═══
[温馨有温度的开场。**打电话顺序:先称呼+确认对方 → 再自报家门 → 医生交代 → 问近况**]
- • {智能称呼}您好,现在方便接听吗?(先用 {智能称呼} 称呼并确认对方;成人={智能称呼}已是"姓+先生/女士",直接用,未知="您好")
- • {自报家门}(直接用给定的「自报家门」,不要自己改岗位/姓名,也不要编"回访专员")
## 第一部分 · 开场白
- • {智能称呼}您好,现在方便接听吗?
- • {自报家门}
- • {诊断医生}医生特意交代我来关注您的后续情况
- • (如果是熟悉患者可说:{诊断医生}医生上次还和我提起您呢)
- • 您自从{智能时间显示}检查后,口腔情况怎么样?
## ═══ 第二部分:告知应治未治 ═══
[分成 4 个短句,便于互动沟通,语调温和提醒,非推销]
小节1 - 现状描述(短句1):用口语告诉患者上次检查时发现的问题,突出"温和提醒"和"医生发现"的语气,不要直接说"我们发现了……"。**若本次给了 {牙位},自然带上**(如"您{牙位}这边有…"),让患者对得上是哪颗牙;未给则不提牙位。
✅ 推荐表达方式:
• 上次来检查的时候,{诊断医生}医生注意到您{牙位}有{应治未治项}的情况
• 医生那次检查时提到,您有一点{应治未治项}的问题
• 当时有观察到一些{应治未治项}的情况,医生是挺关注的
• 有一点{应治未治项}的表现,医生希望您留意一下
• 上次拍片的时候,看出来这边有点{应治未治项}的迹象(⚠️ 仅当患者明确 >18 岁才可用此句;≤18 岁或年龄未知一律删除,禁提拍片)
小节2 - 健康提醒(短句2):灵活组合 3~4 个{风险要点},输出温和提醒。语气自然像人说话,每句突出一个重点,不堆砌、不用"如A、B、C"书面句式、不吓唬人。
✅ 示例:
• 这个问题如果一直拖着,可能会出现牙齿松动、牙缝变大的情况
• 时间久了,可能会影响咬合,吃东西也不太舒服,甚至可能会导致牙齿脱落
• 如果没处理,后期可能需要更复杂的治疗
小节3 - 个人化关怀(短句3):结合{治疗优势}进行个性化表达。❌ 禁止提及具体年龄、职业状态等,✅ 推荐以"趁现在""早关注"等口语表达代替。
✅ 示例:
• 趁现在问题还不严重,早点稳住会更好
• 其实早一点处理,比以后复杂时省事也省心
• 趁现在牙槽骨条件还不错,早点处理效果更好
• 早一点介入,对牙齿稳定有帮助,也避免将来多花功夫
小节4 - 专业建议(短句4):口语化表达,体现{诊断医生}医生的关心与交代,传达温馨提醒感,避免生硬。
✅ 推荐句式示例:
• 这个情况,{诊断医生}医生也特别嘱咐我们提醒您一下
• {诊断医生}医生说,这个问题早点看看会比较安心
• 上次{诊断医生}医生也提到,最好别拖太久
• {诊断医生}医生还是希望您尽早来院检查一下情况
• 医生的意思是,这个问题别忽略了,早点关注会更好
❌ 禁止使用:"建议您关注一下这个问题"、"医生建议您处理该问题"等书面语言。
## ═══ 第三部分:复查建议 ═══
[通过短句说明复查重要性,有温度有引导性]
小节1 - 复查重要性(短句1):如果方便的话您看最近有没有时间来院复查一下
小节2 - 健康维护(短句2):让{诊断医生}医生帮您再仔细看看
小节3 - 检查说明(短句3):{复查时长}
小节4 - 引导预约(短句4):请严格使用如下标准格式:
✅ {诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个时间比较方便?
⚠️ 禁止将时间段直接替换为"周三上午"或"周五下午",必须保留"【时间段】"结构。
## ═══ 第四部分:结束回访语 ═══
[简单有温度的套话]
- •(熟客可加:{诊断医生}医生上次还和我提起您呢)
- • 您自从{智能时间显示}来过之后,口腔情况怎么样?
## 第二部分 · 告知应治未治(4 短句,温和提醒、非推销)
- **小节1 现状描述**:以"之前{诊断医生}医生检查时注意到…"的口吻(指**诊断那次**,可能比最近一次就诊更早;**别说"上次"**以免和开场的最近就诊混);给了 {牙位} 就自然带上(如"您{牙位}有…"),没给则不提牙位。别说"我们发现了…"。
✅ 之前{诊断医生}医生给您检查时,注意到您{牙位}有{应治未治项}的情况 / {诊断医生}医生那次提到您有一点{应治未治项}的问题
- **小节2 健康提醒**:从 {风险要点} 灵活挑 3~4 条,每句一个重点,口语、不堆砌、不吓唬。
✅ 这个问题一直拖着,可能牙齿松动、牙缝变大 / 时间久了影响咬合,吃东西也不舒服
- **小节3 个人化关怀**:结合 {治疗优势},用"趁现在/早一点"口吻;不提具体年龄、职业。
✅ 趁现在问题还不严重,早点稳住更好 / 早一点处理,比以后复杂时省事省心
- **小节4 专业建议**:体现 {诊断医生} 医生的关心与交代,口语不生硬(别用"建议您…"这类书面腔)。
✅ 这个情况{诊断医生}医生也特别嘱咐我们提醒您 / {诊断医生}医生说,早点看看会比较安心
## 第三部分 · 复查建议(4 短句,有温度有引导)
- 小节1:方便的话您看最近有没有时间来院复查一下
- 小节2:让 {诊断医生} 医生帮您再仔细看看
- 小节3:{复查时长}
- 小节4 引导预约(严格用此格式):{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个时间比较方便?
## 第四部分 · 结束回访语
【预约成功】
• 好的,那我们【具体预约时间】见
• 那不打扰您了,祝您生活愉快
- • 好的,那我们【具体预约时间】见
- • 那不打扰您了,祝您生活愉快
【预约不成功】
• 好的,那我下个星期再跟您联系
• 好的那不打扰您了,祝您生活愉快
- • 好的,那您先考虑一下,后续我再跟您联系
-那不打扰您了,祝您生活愉快
---
name: population-child
description: 儿童回访话术模板(年龄 ≤12 岁,对象=家长)。沿用业务"儿童话术模版"原文。重点:儿牙早矫 / 恒牙萌出空间不足 / 乳牙过早缺失 / 间隙保持器。4 模块,告知 5 短句、复查 5 短句。tone=warm 默认。≤18 岁严禁提拍片。仅 frontmatter 作开发溯源,body 不含 PAC 内部说明
description: 儿童应治未治回访话术模板(年龄 ≤12 岁,对象=家长)。稳健档句位脚手架,4 模块,填空+润色。第二部分**病种无关**(由 {应治未治项}/{风险要点}/{治疗优势}/{牙位} 驱动,适配龋齿/萌出异常/早矫等;间隙保持器仅作"乳牙早失"的范例)。语气走人群共性知识、开场顺序走输出格式,本文件不重复
priority: 100
applies:
ageMax: 12
version: 1.4.0
version: 1.5.0
tiers: ['stable']
---
# 儿童话术模板(≤12 岁,对象=家长)
# 儿童应治未治话术模板(≤12 岁,对象=家长)
你是一名专业的口腔医疗回访专员,专门负责儿童的回访工作。请严格按照以下标准化话术模板进行儿童早矫患者回访。⚠️ ≤18 岁严禁提拍片
句位脚手架:每段按下面小节填空 + 润色;话术里称患儿"宝宝",对家长说
tone 默认 warm(温和家常,适合与家长沟通)。
## ═══ 第一部分:开场白 ═══
[温馨有温度的开场。**打电话顺序:先称呼+确认家长 → 再自报家门 → 医生交代 → 问近况**]
- • {智能称呼}您好,现在方便说话吗?(先用 {智能称呼} 称呼并确认家长接听;{智能称呼}=如"徐女士"/"宝宝妈妈"/"家长",直接用,未知="您好")
- • {自报家门}(直接用给定的「自报家门」,不要自己改岗位/姓名,也不要编"回访专员")
## 第一部分 · 开场白
- • {智能称呼}您好,现在方便说话吗?
- • {自报家门}
- • {诊断医生}医生特意交代我来关注宝宝的后续情况
- (如果是熟悉患者可说:{诊断医生}医生上次还和我提起宝宝呢)
- • 宝宝自从{智能时间显示}检查后,口腔情况怎么样?
-(熟客可加:{诊断医生}医生上次还和我提起宝宝呢)
- • 宝宝自从{智能时间显示}来过之后,牙齿/口腔情况怎么样?
## ═══ 第二部分:告知牙齿问题-健康提醒 ═══
[分成短句,便于互动沟通]
(适用于本次应治未治项 = 乳牙过早缺失 / 恒牙萌出空间不足 / 儿牙早矫)
小节1 - 现状描述(短句1):现在宝宝有一颗乳牙已经脱落了,但是恒牙还没有长出来
小节2 - 位置说明(短句2):这颗乳牙的位置在{牙位}(直接用给定的 {牙位} 俗称,如"上门牙";若本次未给 {牙位} 则略过位置说明,不要自己编牙位)
小节3 - 不治疗危害(短句3):如果咱们不做处理,这颗乳牙的位置和空间可能会丧失
小节4 - 后果说明(短句4):将来恒牙萌出就不会在它该在的位置
小节5 - 解决方案(短句5):所以我们要做一个装置来维持这个间隙,这个装置叫间隙保持器。到时候也请医生看一下。
(若本次应治未治项是其他儿童项目如龋齿:用{风险要点}挑 1-2 条口语化告诉家长 +{治疗优势}一条 + 体现医生交代)
## 第二部分 · 告知应治未治(分短句,对家长温和提醒、非吓唬)
- **小节1 现状描述**:以"之前{诊断医生}医生检查时注意到宝宝…"的口吻带出本次 {应治未治项}(指诊断那次,可能早于最近一次就诊,**别说"上次"**以免和开场混);给了 {牙位} 就自然带上(如"宝宝{牙位}…"),没给则不提牙位。
- **小节2 健康提醒**:从 {风险要点} 挑 1~2 条,用家长听得懂的话说"不处理可能会怎样"(口语、不堆砌、不吓唬)。
- **小节3 趁早关怀**:结合 {治疗优势},用"趁现在 / 趁换牙期早干预"说早处理的好处。
- **小节4 专业建议**:体现 {诊断医生} 医生的交代与关心(口语、不生硬)。
✅ 若本次是"乳牙过早缺失 / 恒牙萌出空间不足":可顺势说"做个间隙保持器帮宝宝把牙缝空间留住、等恒牙顺利长出来",到时也请医生看一下。
## ═══ 第三部分:复查建议 ═══
[通过短句说明复查重要性,有温度有引导性]
小节1 - 复查重要性(短句1):方便的话带宝宝来院复查一下{应治未治项}的情况,让医生看看(针对本次问题,别框成常规体检)
小节2 - 检查说明(短句2):用给定的 {复查时长} 原文说明这次检查看什么(如"评估宝宝恒牙萌出情况");若没给则说"医生会帮宝宝仔细检查一下"
小节3 - 专业建议(短句3):也请{诊断医生}医生再仔细看一下宝宝的情况
小节4 - 预防关怀(短句4,可选):来的时候也可以顺便看看牙齿清洁、要不要涂氟保护一下(仅作顺带关怀,不替代本次{应治未治项})
小节5 - 引导预约(短句5):[有引导性,给出具体时间选择]
• 如果方便的话您看最近有没有时间,我帮您预约一个儿牙专家的时间,您带宝宝过来看一看
• {诊断医生}医生【时间段1】和【时间段2】这两个时间段有空
⚠️ 保留"【时间段】"结构,禁止替换成具体时间
## 第三部分 · 复查建议(分短句,针对本次问题,非常规体检)
- 小节1:方便的话带宝宝来院复查一下 {应治未治项} 的情况,让医生看看
- 小节2:用 {复查时长} 原文说这次检查看什么;没给则说"医生会帮宝宝仔细检查一下"
- 小节3:也请 {诊断医生} 医生再仔细看看宝宝的情况
- 小节4(可选):来的时候也可以顺便看看牙齿清洁、要不要涂氟保护一下(仅顺带,不替代本次问题)
- 小节5 引导预约:{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便带宝宝过来?
## ═══ 第四部分:结束回访语 ═══
## 第四部分 · 结束回访语
【预约成功】
• 好的,那我们【具体预约时间】见
• 那不打扰您了,祝您生活愉快
- • 好的,那我们【具体预约时间】见
- • 那不打扰您了,祝您生活愉快
【预约不成功】
• 好的,那我下个星期再跟您联系
• 您平时要观察孩子的牙齿情况,如果有问题随时联系我
• 好的那就不打扰您了,祝您生活愉快
- • 好的,那您先考虑一下,后续我再跟您联系
- • 平时也留意一下宝宝的牙齿,有问题随时联系我
- • 那不打扰您了,祝您生活愉快
......@@ -17,9 +17,8 @@ const safetyRules = SCRIPT_SAFETY_RULES;
* 降级 fallback —— LLM 失败 / safety 拒收时用。
* 用 input 直接拼一份 4 段 markdown 模板话术,保证客服一定有东西可用。
*
* ⚠️ fallback 文本本身也要过 safety rule(close_no_bold_time / close_has_tentative_phrasing)。
* 历史踩坑:close 段写 `**本周六上午 10 点**` 加粗时间,自己触发 close_no_bold_time block。
* 已改:不加粗 + (示例) 后缀 + 显式"以诊所排班为准"。
* ⚠️ fallback 文本本身也要过 safety rule(no_bold_concrete_time / no_commit_phrasing):
* 不加粗具体时间、不承诺、时间一律用【时间段】/【具体预约时间】占位。
*/
export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
const { patient, clinicName, plan, clinicalContext } = input;
......@@ -28,12 +27,13 @@ export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanSc
const topReason = (plan.reasons ?? [])[0];
// 去名:韩维 → 韩(下面拼"韩医生");跟 prompt 同口径
const doctor = deidentifyDoctor(topReason?.triggerDoctor ?? clinicalContext.primaryDoctorName ?? null);
// 日期优先取"那次诊断"的(项目相关),否则退回最近一次就诊
const dateBasis = topReason?.triggerDate
? new Date(topReason.triggerDate)
: clinicalContext.daysSinceLastVisit != null
// 开场"自从…来过"锚【最近一次就诊】(可能晚于诊断日),无则退回诊断日/泛指
const dateBasis =
clinicalContext.daysSinceLastVisit != null
? new Date(Date.now() - clinicalContext.daysSinceLastVisit * 86400_000)
: null;
: topReason?.triggerDate
? new Date(topReason.triggerDate)
: null;
const dateDisplay = smartDateDisplay(dateBasis, new Date()) ?? '上次';
const disease = resolveDisease(topReason ?? null, plan.primaryScenarioLabel);
const risk = disease.risks[0] ?? '这个问题如果一直拖着,后面处理可能更复杂';
......@@ -44,8 +44,8 @@ export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanSc
tone: 'warm',
opening: `• ${salutation}您好,我是${clinicName}的客服
${doctor}医生特意交代我来关注您的后续情况
• 自从${dateDisplay}检查后,您口腔情况怎么样?`,
informMissed: `• 上次检查的时候,${doctor}医生注意到您有${disease.label}的情况
• 自从${dateDisplay}来过之后,您口腔情况怎么样?`,
informMissed: `• 之前${doctor}医生检查时,注意到您有${disease.label}的情况
${risk}
${adv}
• 这个${doctor}医生也特别嘱咐我们提醒您一下`,
......@@ -56,7 +56,7 @@ export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanSc
closing: `【预约成功】
• 好的,那我们【具体预约时间】见,祝您生活愉快
【预约不成功】
• 没关系,我下周再联系您,祝您生活愉快`,
• 没关系,您先考虑一下,后续我再跟您联系,祝您生活愉快`,
};
}
......@@ -65,7 +65,7 @@ export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanSc
* 改 system/prompt 文本 → bump 字母;改 schema → bump 日期。
*/
const DRAFT_PLAN_SCRIPT_PROMPT_VERSION =
'draft_plan_script@2026-06-06-4module-v17'; // v17: 目录重组(shared/+tiers/stable/)— base 拆 common+format、人群拆共性知识+稳健句位、病种文案归 stable phrasing、安全单一源 safety-rules、composer tier-aware;修开场顺序冲突。v16: 儿童模板复查段修复(删写死"3个月常规涂氟检查"→对齐本次{应治未治项}+用{复查时长},涂氟降级顺带);child SKILL 1.4.0;v15: user prompt 加"医生那次交代"(医嘱/建议/治疗计划,来自聚焦病历,仅引用不演绎);medicalRecord 补 recommendations;v14: user prompt 加 {牙位}(FDI→俗称)+ 本次目标(plan.goal)+ ≤18 禁拍片 belt;占位收口 {牙位}(删 【缺失牙位】);adult/child 模板带牙位;v13: 撤 token,人名去名留称呼(徐女士/韩医生 直接给,非 token);开场白先称呼确认对方再自报家门;v12: user prompt 人名脱敏(称呼/诊断医生/客服 用 token,生成后回填;监护人全名不进 prompt);v11: 统一通话称呼(年龄+性别+监护人,修"9岁张先生");监护人触达提示;医生标签 最后一次就诊→诊断医生;v10: 病种知识走 disease-knowledge 单一访问源(subKey 优先+文本兜底),修 颌骨囊肿 拿不到风险/优势的 bug;v9: 自报家门用登录客服 岗位+姓名(agent);v8: 占位符统一({}=替换、【】=原样保留);v7: 清除 user prompt 污染;v6: 清 system 污染;v5: 还原原模板
'draft_plan_script@2026-06-06-4module-v22'; // v22: 新老客改'熟络度'(recency为主+次数为辅,去二分标签,交LLM);v21: 开场日期改锚【最近一次就诊】(原误用诊断日,患者后来又来过会错位)+'来过之后'/告知'之前那次'区分;v20: schema describe 收口(去与模板矛盾的开场顺序/'下周'/负面例,只留段用途+关键约束)+ prompt/兜底 软化'下周'+ 修陈旧注释;v19: stable format.md 精简(去 common/机器闸重复、自查砍到高风险3条)+ 成人模板优化(去重/换标题/软化结束语);v18: base-common 精简合并去重 + 软化治疗方案口径(可点名/不报价不定细化/落点复查);v17: 目录重组(shared/+tiers/stable/)— base 拆 common+format、人群拆共性知识+稳健句位、病种文案归 stable phrasing、安全单一源 safety-rules、composer tier-aware;修开场顺序冲突。v16: 儿童模板复查段修复(删写死"3个月常规涂氟检查"→对齐本次{应治未治项}+用{复查时长},涂氟降级顺带);child SKILL 1.4.0;v15: user prompt 加"医生那次交代"(医嘱/建议/治疗计划,来自聚焦病历,仅引用不演绎);medicalRecord 补 recommendations;v14: user prompt 加 {牙位}(FDI→俗称)+ 本次目标(plan.goal)+ ≤18 禁拍片 belt;占位收口 {牙位}(删 【缺失牙位】);adult/child 模板带牙位;v13: 撤 token,人名去名留称呼(徐女士/韩医生 直接给,非 token);开场白先称呼确认对方再自报家门;v12: user prompt 人名脱敏(称呼/诊断医生/客服 用 token,生成后回填;监护人全名不进 prompt);v11: 统一通话称呼(年龄+性别+监护人,修"9岁张先生");监护人触达提示;医生标签 最后一次就诊→诊断医生;v10: 病种知识走 disease-knowledge 单一访问源(subKey 优先+文本兜底),修 颌骨囊肿 拿不到风险/优势的 bug;v9: 自报家门用登录客服 岗位+姓名(agent);v8: 占位符统一({}=替换、【】=原样保留);v7: 清除 user prompt 污染;v6: 清 system 污染;v5: 还原原模板
@Injectable()
export class DraftPlanScriptCall
......
......@@ -20,7 +20,7 @@ import { stableTemplateFallback } from '../stable/stable.call';
*
* callKey 仍用 'draft_plan_script'(同一逻辑调用),档位差异落 promptVersion → eval 可按版本切档对比。
*/
const STANDARD_PROMPT_VERSION = 'draft_plan_script@2026-06-06-standard-v2'; // v2: 4 段标题不定(LLM 出 sectionTitles)
const STANDARD_PROMPT_VERSION = 'draft_plan_script@2026-06-06-standard-v6'; // v3: base-common 精简;v2: 4段标题不定(sectionTitles)
@Injectable()
export class StandardScriptCall implements AiCall<DraftPlanScriptInput, DraftPlanScriptOutput> {
......
......@@ -11,6 +11,7 @@ import { StandardScriptCall } from '../calls/draft-plan-script/tiers/standard/st
import { DeepScriptStrategy } from '../calls/draft-plan-script/tiers/deep/deep.strategy';
import type { DeepDraft } from '../calls/draft-plan-script/tiers/deep/types';
import type { ScriptTier } from '../calls/draft-plan-script/shared/skill.types';
import { callSalutation, pickGuardian } from '../calls/draft-plan-script/shared/pii';
import type {
DraftPlanScriptInput,
DraftPlanScriptOutput,
......@@ -439,11 +440,17 @@ export class PlanScriptOrchestrator {
const { plan, patient, persona, facts, agent, guardian } = args;
const patientAge = patient.birthDate ? calcAge(patient.birthDate) : null;
// ⭐ 就诊事件回退:跟 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 visitFacts = [...encounters, ...facts.filter((f) => f.type === 'emr_record')]
// ⭐ "最近一次就诊"= 患者实际到店的最新一次。口径要全:encounter/emr 之外,
// **实际治疗(actual treatment_record)也是到店**——很多 host 治疗有日期但缺 encounter/emr fact,
// 只看 encounter/emr 会把"最近就诊"低估到更早(诊断那次),开场"自从…来过"就错位。
// 故并入 actual treatment_record 取三者最新。(诊断日另走 triggerDate,不在此。)
const visitFacts = facts
.filter(
(f) =>
f.type === 'encounter_record' ||
f.type === 'emr_record' ||
(f.type === 'treatment_record' && f.kind === 'actual'),
)
.filter((f) => f.occurredAt)
.sort((a, b) => b.occurredAt!.getTime() - a.occurredAt!.getTime());
const latestEnc = visitFacts[0];
......@@ -857,79 +864,7 @@ function splitToothPositions(raw: string | null | undefined): string[] {
);
}
// ─────────────────────────────────────────────
// 事实漂移防护(W4 修)— 诊所/牙位/医生/称呼 必须用真实信息,不让 LLM 编
// ─────────────────────────────────────────────
/**
* 通话称呼 — "姓 + 先生/女士"(性别未知用"您")。
*
* 跟 UI 列表的 maskName 区分:
* - UI 列表展示 → maskName("路星") = "路*"(脱敏)
* - 客服通话 → nameSpokenForm("路星","男") = "路先生"(真实可念)
* LLM 的 input.patient.nameMasked 字段用本函数产物(命名是历史,语义已转通话名)。
*/
function nameSpokenForm(name: string | null, gender: string | null): string {
if (!name) return '您';
const surname = name.charAt(0);
if (!surname) return '您';
if (gender === '男' || gender === 'M' || gender === 'male') return `${surname}先生`;
if (gender === '女' || gender === 'F' || gender === 'female') return `${surname}女士`;
return surname ? `${surname}先生` : '您'; // 性别未知默认先生(亚洲诊所语境),可调
}
const RELATIONSHIP_LABEL_ZH: Record<string, string> = {
mother: '妈妈',
father: '爸爸',
grandparent: '祖辈',
};
/**
* 从关系行里挑监护人:优先已建档(有姓名),关系优先 妈妈 > 爸爸 > 祖辈。
*/
function pickGuardian(
rels: Array<{ relationship: string; relatedPatient: { name: string | null } | null }>,
): { relationship: string; relationshipLabel: string; name: string | null } | null {
const order = ['mother', 'father', 'grandparent'];
const sorted = [...rels].sort((a, b) => {
const an = a.relatedPatient?.name ? 0 : 1;
const bn = b.relatedPatient?.name ? 0 : 1;
if (an !== bn) return an - bn; // 有姓名优先
return order.indexOf(a.relationship) - order.indexOf(b.relationship);
});
const g = sorted[0];
if (!g) return null;
return {
relationship: g.relationship,
relationshipLabel: RELATIONSHIP_LABEL_ZH[g.relationship] ?? '家长',
name: g.relatedPatient?.name ?? null,
};
}
/**
* 通话称呼(单一源:年龄 + 性别 + 监护人 aware)。
* - 未成年(≤12,打给监护人):妈妈→"{监护人姓}女士"、爸爸→"{监护人姓}先生";
* 无监护人姓名 → "{患者姓}家长"。
* - 成人:姓 + 先生/女士(性别未知默认先生)。
*/
function callSalutation(
name: string | null,
gender: string | null,
age: number | null,
guardian: { relationship: string; name: string | null } | null,
): string {
const surname = (name ?? '').charAt(0);
if (age != null && age <= 12) {
const gName = (guardian?.name ?? '').trim();
if (gName) {
const gs = gName.charAt(0);
if (guardian!.relationship === 'mother') return `${gs}女士`;
if (guardian!.relationship === 'father') return `${gs}先生`;
}
return surname ? `${surname}家长` : '您';
}
return nameSpokenForm(name, gender);
}
// 称呼/监护人(PII 称呼派生)统一在 shared/pii.ts(callSalutation / pickGuardian),本文件 import。
/**
......
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