Commit d8f03249 by luoqi

fix(script): 统一通话称呼(监护人 aware)+ 修"9岁张先生" + 医生标签改准

- 统一称呼单一源 callSalutation(年龄+性别+监护人):未成年→监护人("徐女士")/家长,
  成人→先生/女士;header + 开场白 + fallback 共用,不再各算一遍
  · 修 bug:头部 `患者:张先生`(nameSpokenForm 无儿童分支)→ 9岁现为"徐女士"
- 监护人进 ScriptContext:orchestrator 查 patient_relations(妈妈>爸爸>祖辈,优先已建档)
  → guardian + 触达提示"打给家长、患者是孩子(称宝宝)",称呼真正打给监护人
- user prompt basics 去掉称呼(原"徐女士,男,9岁"矛盾)→ 只留性别+年龄
- {最后一次就诊医生} → {诊断医生}(prompt + base-system.md + 人群模板 + schema),
  标签对齐实际喂的 triggerDoctor(触发诊断的医生)
- promptVersion bump v11

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 80702b0d
...@@ -6,7 +6,7 @@ import type { DraftPlanScriptInput, DraftPlanScriptOutput } from './input.types' ...@@ -6,7 +6,7 @@ import type { DraftPlanScriptInput, DraftPlanScriptOutput } from './input.types'
import { buildDraftPlanScriptPrompt } from './prompt'; import { 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 { resolveAgeBranch, resolveSalutation, smartDateDisplay } from './script-facts'; import { smartDateDisplay } from './script-facts';
import { resolveDisease } from './script-common/disease-knowledge'; import { resolveDisease } from './script-common/disease-knowledge';
/** /**
...@@ -78,8 +78,7 @@ const safetyRules: ReadonlyArray<SafetyRule<DraftPlanScriptOutput>> = [ ...@@ -78,8 +78,7 @@ const safetyRules: ReadonlyArray<SafetyRule<DraftPlanScriptOutput>> = [
*/ */
function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput { function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
const { patient, clinicName, plan, clinicalContext } = input; const { patient, clinicName, plan, clinicalContext } = input;
const branch = resolveAgeBranch(patient.age); const salutation = patient.nameMasked; // 统一通话称呼(年龄+性别+监护人 aware,orchestrator 算好)
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 ?? '您的主治医生';
...@@ -120,7 +119,7 @@ function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput { ...@@ -120,7 +119,7 @@ function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
* 改 system/prompt 文本 → bump 字母;改 schema → bump 日期。 * 改 system/prompt 文本 → bump 字母;改 schema → bump 日期。
*/ */
const DRAFT_PLAN_SCRIPT_PROMPT_VERSION = const DRAFT_PLAN_SCRIPT_PROMPT_VERSION =
'draft_plan_script@2026-06-05-4module-v10'; // v10: 病种知识走 disease-knowledge 单一访问源(subKey 优先+文本兜底),修 颌骨囊肿 拿不到风险/优势的 bug;v9: 自报家门用登录客服 岗位+姓名(agent);v8: 占位符统一({}=替换、【】=原样保留);v7: 清除 user prompt 污染;v6: 清 system 污染;v5: 还原原模板 'draft_plan_script@2026-06-05-4module-v11'; // v11: 统一通话称呼(年龄+性别+监护人,修"9岁张先生");监护人触达提示;医生标签 最后一次就诊→诊断医生;v10: 病种知识走 disease-knowledge 单一访问源(subKey 优先+文本兜底),修 颌骨囊肿 拿不到风险/优势的 bug;v9: 自报家门用登录客服 岗位+姓名(agent);v8: 占位符统一({}=替换、【】=原样保留);v7: 清除 user prompt 污染;v6: 清 system 污染;v5: 还原原模板
@Injectable() @Injectable()
export class DraftPlanScriptCall export class DraftPlanScriptCall
......
...@@ -13,11 +13,14 @@ ...@@ -13,11 +13,14 @@
export interface ScriptContext { export interface ScriptContext {
/** 患者信息(已脱敏:nameMasked, phone 不传) */ /** 患者信息(已脱敏:nameMasked, phone 不传) */
patient: { patient: {
/** 通话称呼(单一源:年龄+性别+监护人 aware)— 未成年=监护人称呼/家长,成人=先生/女士 */
nameMasked: string; nameMasked: string;
gender: string | null; gender: string | null;
age: number | null; age: number | null;
/** 病历号(host 病历主键,如 "FY0A000922")— 话术里供客服核对身份用,可空 */ /** 病历号(host 病历主键,如 "FY0A000922")— 话术里供客服核对身份用,可空 */
medicalRecordNumber?: string | null; medicalRecordNumber?: string | null;
/** 监护人(未成年触达对象)— 让话术知道"打给家长、患者是孩子(称宝宝)";成人/无监护人 → null */
guardian?: { relationshipLabel: string; name: string | null } | null;
}; };
/** 诊所名(给 LLM 用作"我是X诊所的客服顾问",避免编造"XX口腔") */ /** 诊所名(给 LLM 用作"我是X诊所的客服顾问",避免编造"XX口腔") */
......
import type { DraftPlanScriptInput } from './input.types'; import type { DraftPlanScriptInput } from './input.types';
import { resolveAgeBranch, resolveSalutation, smartDateDisplay } from './script-facts'; import { smartDateDisplay } from './script-facts';
import { resolveDisease } from './script-common/disease-knowledge'; import { resolveDisease } from './script-common/disease-knowledge';
/** /**
...@@ -39,8 +39,8 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string ...@@ -39,8 +39,8 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
// 程序算好的确定性事实(LLM 不做年龄分支/日期格式/优先级/查表/称呼) // 程序算好的确定性事实(LLM 不做年龄分支/日期格式/优先级/查表/称呼)
const now = new Date(); const now = new Date();
const branch = resolveAgeBranch(patient.age); // 称呼 = orchestrator 算好的统一通话称呼(年龄+性别+监护人 aware):未成年→监护人/家长,成人→先生女士
const salutation = resolveSalutation({ nameMasked: patient.nameMasked, gender: patient.gender, branch }); const salutation = patient.nameMasked;
const lastVisitDate = const lastVisitDate =
clinicalContext.daysSinceLastVisit != null clinicalContext.daysSinceLastVisit != null
? new Date(now.getTime() - clinicalContext.daysSinceLastVisit * 86400_000) ? new Date(now.getTime() - clinicalContext.daysSinceLastVisit * 86400_000)
...@@ -61,12 +61,17 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string ...@@ -61,12 +61,17 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
? disease.advantages.map((a) => ` - ${a}`).join('\n') ? disease.advantages.map((a) => ` - ${a}`).join('\n')
: ' - 趁现在早点处理会更省心'; : ' - 趁现在早点处理会更省心';
// 患者基本信息(只给话术用得到的:性别归一中文,年龄) // 患者基本信息(只给话术用得到的:性别 + 年龄)。⚠️ 不含称呼 —— 称呼是"通话对象"(可能是监护人),
// 跟患者性别/年龄是两回事;混在一起会出现"徐女士,男,9岁"这类矛盾。
const g = (patient.gender ?? '').trim().toUpperCase(); const g = (patient.gender ?? '').trim().toUpperCase();
const genderText = g === '男' || g === 'M' ? '男' : g === '女' || g === 'F' ? '女' : ''; const genderText = g === '男' || g === 'M' ? '男' : g === '女' || g === 'F' ? '女' : '';
const basics = [patient.nameMasked, genderText, patient.age != null ? `${patient.age}岁` : ''] const basics = [genderText, patient.age != null ? `${patient.age}岁` : ''].filter(Boolean).join(',');
.filter(Boolean) // 监护人提示(未成年:打给家长,患者是孩子)
.join(','); const guardianHint = patient.guardian
? `本次电话打给${patient.guardian.relationshipLabel}${
patient.guardian.name ? `(${patient.guardian.name})` : ''
},沟通对象是家长,患者是孩子,话术里称孩子为"宝宝"`
: null;
// 语气线索(熟客 vs 新客 → tone 选择;不念出来) // 语气线索(熟客 vs 新客 → tone 选择;不念出来)
const toneHint = const toneHint =
clinicalContext.completedTreatmentCount > 0 clinicalContext.completedTreatmentCount > 0
...@@ -86,7 +91,7 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string ...@@ -86,7 +91,7 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
- {自报家门}:${selfIntro} - {自报家门}:${selfIntro}
- {智能时间显示}:${dateDisplay} - {智能时间显示}:${dateDisplay}
- 那次主诉:${chiefComplaint ?? '无记录'} - 那次主诉:${chiefComplaint ?? '无记录'}
- {最后一次就诊医生}:${doctor} - {诊断医生}:${doctor}${guardianHint ? `\n- 触达说明:${guardianHint}` : ''}
## 本次应治未治(只讲这一个) ## 本次应治未治(只讲这一个)
- {应治未治项}:${disease.label} - {应治未治项}:${disease.label}
......
...@@ -28,7 +28,7 @@ export const DraftPlanScriptSchema = z.object({ ...@@ -28,7 +28,7 @@ export const DraftPlanScriptSchema = z.object({
'用 `• ` bullet 分 3-4 句,内容必须包含(顺序):', '用 `• ` bullet 分 3-4 句,内容必须包含(顺序):',
'1. 自报家门:用「{诊所}的{岗位角色}{岗位姓名}」(岗位角色严禁写"回访专员")', '1. 自报家门:用「{诊所}的{岗位角色}{岗位姓名}」(岗位角色严禁写"回访专员")',
'2. 智能称呼:user 给的 {称呼}(已算好,直接用,不要自己改)', '2. 智能称呼:user 给的 {称呼}(已算好,直接用,不要自己改)',
'3. 以「{最后一次就诊医生}医生特意交代」体现医生关怀', '3. 以「{诊断医生}医生特意交代」体现医生关怀',
'4. 用 user 给的 {智能日期} 问近况:「自从{智能日期}检查后,口腔情况怎么样?」', '4. 用 user 给的 {智能日期} 问近况:「自从{智能日期}检查后,口腔情况怎么样?」',
'禁止:加大标题/═══分隔符、加表情、写成抒情排比', '禁止:加大标题/═══分隔符、加表情、写成抒情排比',
].join('\n'), ].join('\n'),
...@@ -42,10 +42,10 @@ export const DraftPlanScriptSchema = z.object({ ...@@ -42,10 +42,10 @@ export const DraftPlanScriptSchema = z.object({
[ [
'【第二部分·告知应治未治 — 只讲 user 给的那一个 {应治未治项},严禁提其他项】', '【第二部分·告知应治未治 — 只讲 user 给的那一个 {应治未治项},严禁提其他项】',
'用 `• ` bullet 分短句(**句数与结构以系统提示词里匹配的人群模板为准**:成人 4 句 / 儿童 5 句),每句一个重点,口语化,温和提醒非推销:', '用 `• ` bullet 分短句(**句数与结构以系统提示词里匹配的人群模板为准**:成人 4 句 / 儿童 5 句),每句一个重点,口语化,温和提醒非推销:',
'现状描述:以「{最后一次就诊医生}医生上次检查注意到您有{应治未治项}的情况」表达,不要说"我们发现了"', '现状描述:以「{诊断医生}医生上次检查注意到您有{应治未治项}的情况」表达,不要说"我们发现了"',
'健康提醒:从 user 给的 {风险要点} 里灵活挑 1-2 条口语说,不堆砌、不吓唬、不用"如A、B、C"书面句式', '健康提醒:从 user 给的 {风险要点} 里灵活挑 1-2 条口语说,不堆砌、不吓唬、不用"如A、B、C"书面句式',
'个人化关怀:用 user 给的 {治疗优势要点},以"趁现在/早一点"口吻;禁止提具体年龄/职业', '个人化关怀:用 user 给的 {治疗优势要点},以"趁现在/早一点"口吻;禁止提具体年龄/职业',
'专业建议:体现「{最后一次就诊医生}医生也特别嘱咐提醒您」,禁止"建议您关注一下"这类书面语', '专业建议:体现「{诊断医生}医生也特别嘱咐提醒您」,禁止"建议您关注一下"这类书面语',
].join('\n'), ].join('\n'),
), ),
...@@ -57,8 +57,8 @@ export const DraftPlanScriptSchema = z.object({ ...@@ -57,8 +57,8 @@ export const DraftPlanScriptSchema = z.object({
[ [
'【第三部分·复查建议 — 有温度有引导,主动约】', '【第三部分·复查建议 — 有温度有引导,主动约】',
'用 `• ` bullet 分短句(**句数与结构以系统提示词里匹配的人群模板为准**:成人 4 句 / 儿童 5 句):', '用 `• ` bullet 分短句(**句数与结构以系统提示词里匹配的人群模板为准**:成人 4 句 / 儿童 5 句):',
'核心:复查重要性 +「让{最后一次就诊医生}医生帮您再仔细看看」+(成人)直接用 {复查时长} 原文', '核心:复查重要性 +「让{诊断医生}医生帮您再仔细看看」+(成人)直接用 {复查时长} 原文',
'引导预约必须严格用「{最后一次就诊医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」', '引导预约必须严格用「{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」',
'⚠️【时间段1】【时间段2】保留占位结构,严禁替换成"周三上午"等具体时间(PAC 无排班 API)', '⚠️【时间段1】【时间段2】保留占位结构,严禁替换成"周三上午"等具体时间(PAC 无排班 API)',
].join('\n'), ].join('\n'),
), ),
......
...@@ -25,15 +25,15 @@ ...@@ -25,15 +25,15 @@
⚠️ 重要提醒:如果输出缺少任何一个模块,整个话术将被视为不合格! ⚠️ 重要提醒:如果输出缺少任何一个模块,整个话术将被视为不合格!
# 占位符约定(两种,含义不同) # 占位符约定(两种,含义不同)
- `{xxx}` = **要替换**的占位:用"本次回访患者信息"里给的同名值填进去(如 {智能称呼}{应治未治项}{最后一次就诊医生}{风险要点}{复查时长})。输出里不能再出现 `{}` - `{xxx}` = **要替换**的占位:用"本次回访患者信息"里给的同名值填进去(如 {智能称呼}{应治未治项}{诊断医生}{风险要点}{复查时长})。输出里不能再出现 `{}`
- `【xxx】` = **原样保留**的占位:不要替换、照抄进话术,客服打电话时手动填。只有这几个:【时间段1】【时间段2】【具体预约时间】【缺失牙位】。 - `【xxx】` = **原样保留**的占位:不要替换、照抄进话术,客服打电话时手动填。只有这几个:【时间段1】【时间段2】【具体预约时间】【缺失牙位】。
- 另:结束语的分支标签【预约成功】【预约不成功】也照常输出。 - 另:结束语的分支标签【预约成功】【预约不成功】也照常输出。
# 直接使用给定的事实 # 直接使用给定的事实
开场自报家门用{自报家门}、称呼用{智能称呼}、日期用{智能时间显示}、本次只讲{应治未治项}、健康提醒从{风险要点}挑、检查说明用{复查时长}原文、以{最后一次就诊医生}名义体现关怀。这些值直接用,不要自己重算、改写或改格式。所有事实只能来自给定的字段;空缺就泛指或省略,不得编造(不杜撰医生名 / 诊断 / 价格 / 政策 / 设备 / 患者背景)。 开场自报家门用{自报家门}、称呼用{智能称呼}、日期用{智能时间显示}、本次只讲{应治未治项}、健康提醒从{风险要点}挑、检查说明用{复查时长}原文、以{诊断医生}名义体现关怀。这些值直接用,不要自己重算、改写或改格式。所有事实只能来自给定的字段;空缺就泛指或省略,不得编造(不杜撰医生名 / 诊断 / 价格 / 政策 / 设备 / 患者背景)。
# 时间用占位 # 时间用占位
- 引导预约严格用「{最后一次就诊医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」 - 引导预约严格用「{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」
- 结束语·预约成功保留「我们【具体预约时间】见」。 - 结束语·预约成功保留「我们【具体预约时间】见」。
- ⚠️【时间段1】【时间段2】【具体预约时间】原样保留占位,严禁替换成"周三上午"等具体时间。❌ 严禁加粗具体时间、严禁"已为您约好 / 敲定 X"承诺。 - ⚠️【时间段1】【时间段2】【具体预约时间】原样保留占位,严禁替换成"周三上午"等具体时间。❌ 严禁加粗具体时间、严禁"已为您约好 / 敲定 X"承诺。
...@@ -64,7 +64,7 @@ ...@@ -64,7 +64,7 @@
# 输出前自查 # 输出前自查
✅ 4个模块全部包含且顺序正确? ✅ 4个模块全部包含且顺序正确?
✅ 开场白以{最后一次就诊医生}医生名义,体现医生交代的关怀? ✅ 开场白以{诊断医生}医生名义,体现医生交代的关怀?
✅ 只专注 {应治未治项} 一个,没提其他项目? ✅ 只专注 {应治未治项} 一个,没提其他项目?
✅ 告知应治未治、复查建议都分了短句? ✅ 告知应治未治、复查建议都分了短句?
✅ 称呼 / 日期 / 复查时长用的是给定的值?时间保留了【时间段】占位、没写死具体时间? ✅ 称呼 / 日期 / 复查时长用的是给定的值?时间保留了【时间段】占位、没写死具体时间?
......
...@@ -15,8 +15,8 @@ tone 默认 professional(专业稳重);熟客可 warm;急性场景可 ur ...@@ -15,8 +15,8 @@ tone 默认 professional(专业稳重);熟客可 warm;急性场景可 ur
[温馨有温度的开场,以医生名义] [温馨有温度的开场,以医生名义]
- • 您好,{自报家门}(直接用给定的「自报家门」开头,不要自己改岗位 / 姓名,也不要编"回访专员") - • 您好,{自报家门}(直接用给定的「自报家门」开头,不要自己改岗位 / 姓名,也不要编"回访专员")
- 称呼用{智能称呼}(成人 = "{姓}先生" / "{姓}女士";性别未知或无称呼信息 = "您好") - 称呼用{智能称呼}(成人 = "{姓}先生" / "{姓}女士";性别未知或无称呼信息 = "您好")
- • {最后一次就诊医生}医生特意交代我来关注您的后续情况 - • {诊断医生}医生特意交代我来关注您的后续情况
- • (如果是熟悉患者可说:{最后一次就诊医生}医生上次还和我提起您呢) - • (如果是熟悉患者可说:{诊断医生}医生上次还和我提起您呢)
- • 您自从{智能时间显示}检查后,口腔情况怎么样? - • 您自从{智能时间显示}检查后,口腔情况怎么样?
## ═══ 第二部分:告知应治未治 ═══ ## ═══ 第二部分:告知应治未治 ═══
...@@ -24,7 +24,7 @@ tone 默认 professional(专业稳重);熟客可 warm;急性场景可 ur ...@@ -24,7 +24,7 @@ tone 默认 professional(专业稳重);熟客可 warm;急性场景可 ur
小节1 - 现状描述(短句1):用口语告诉患者上次检查时发现的问题,突出"温和提醒"和"医生发现"的语气,不要直接说"我们发现了……"。 小节1 - 现状描述(短句1):用口语告诉患者上次检查时发现的问题,突出"温和提醒"和"医生发现"的语气,不要直接说"我们发现了……"。
✅ 推荐表达方式: ✅ 推荐表达方式:
• 上次来检查的时候,{最后一次就诊医生}医生注意到您有{应治未治项}的情况 • 上次来检查的时候,{诊断医生}医生注意到您有{应治未治项}的情况
• 医生那次检查时提到,您有一点{应治未治项}的问题 • 医生那次检查时提到,您有一点{应治未治项}的问题
• 当时有观察到一些{应治未治项}的情况,医生是挺关注的 • 当时有观察到一些{应治未治项}的情况,医生是挺关注的
• 有一点{应治未治项}的表现,医生希望您留意一下 • 有一点{应治未治项}的表现,医生希望您留意一下
...@@ -43,22 +43,22 @@ tone 默认 professional(专业稳重);熟客可 warm;急性场景可 ur ...@@ -43,22 +43,22 @@ tone 默认 professional(专业稳重);熟客可 warm;急性场景可 ur
• 趁现在牙槽骨条件还不错,早点处理效果更好 • 趁现在牙槽骨条件还不错,早点处理效果更好
• 早一点介入,对牙齿稳定有帮助,也避免将来多花功夫 • 早一点介入,对牙齿稳定有帮助,也避免将来多花功夫
小节4 - 专业建议(短句4):口语化表达,体现{最后一次就诊医生}医生的关心与交代,传达温馨提醒感,避免生硬。 小节4 - 专业建议(短句4):口语化表达,体现{诊断医生}医生的关心与交代,传达温馨提醒感,避免生硬。
✅ 推荐句式示例: ✅ 推荐句式示例:
• 这个情况,{最后一次就诊医生}医生也特别嘱咐我们提醒您一下 • 这个情况,{诊断医生}医生也特别嘱咐我们提醒您一下
• {最后一次就诊医生}医生说,这个问题早点看看会比较安心 • {诊断医生}医生说,这个问题早点看看会比较安心
• 上次{最后一次就诊医生}医生也提到,最好别拖太久 • 上次{诊断医生}医生也提到,最好别拖太久
• {最后一次就诊医生}医生还是希望您尽早来院检查一下情况 • {诊断医生}医生还是希望您尽早来院检查一下情况
• 医生的意思是,这个问题别忽略了,早点关注会更好 • 医生的意思是,这个问题别忽略了,早点关注会更好
❌ 禁止使用:"建议您关注一下这个问题"、"医生建议您处理该问题"等书面语言。 ❌ 禁止使用:"建议您关注一下这个问题"、"医生建议您处理该问题"等书面语言。
## ═══ 第三部分:复查建议 ═══ ## ═══ 第三部分:复查建议 ═══
[通过短句说明复查重要性,有温度有引导性] [通过短句说明复查重要性,有温度有引导性]
小节1 - 复查重要性(短句1):如果方便的话您看最近有没有时间来院复查一下 小节1 - 复查重要性(短句1):如果方便的话您看最近有没有时间来院复查一下
小节2 - 健康维护(短句2):让{最后一次就诊医生}医生帮您再仔细看看 小节2 - 健康维护(短句2):让{诊断医生}医生帮您再仔细看看
小节3 - 检查说明(短句3):{复查时长} 小节3 - 检查说明(短句3):{复查时长}
小节4 - 引导预约(短句4):请严格使用如下标准格式: 小节4 - 引导预约(短句4):请严格使用如下标准格式:
✅ {最后一次就诊医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个时间比较方便? ✅ {诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个时间比较方便?
⚠️ 禁止将时间段直接替换为"周三上午"或"周五下午",必须保留"【时间段】"结构。 ⚠️ 禁止将时间段直接替换为"周三上午"或"周五下午",必须保留"【时间段】"结构。
## ═══ 第四部分:结束回访语 ═══ ## ═══ 第四部分:结束回访语 ═══
......
...@@ -17,8 +17,8 @@ tone 默认 warm(温和家常,适合与家长沟通)。 ...@@ -17,8 +17,8 @@ tone 默认 warm(温和家常,适合与家长沟通)。
[温馨有温度的开场,以医生名义] [温馨有温度的开场,以医生名义]
- • 您好,{自报家门}(直接用给定的「自报家门」开头,不要自己改岗位 / 姓名,也不要编"回访专员") - • 您好,{自报家门}(直接用给定的「自报家门」开头,不要自己改岗位 / 姓名,也不要编"回访专员")
- 称呼用{智能称呼}(儿童 = "宝宝妈妈"或"{姓名}的家长",如"乐乐家长";无称呼信息 = "您好");可先确认家长接听 - 称呼用{智能称呼}(儿童 = "宝宝妈妈"或"{姓名}的家长",如"乐乐家长";无称呼信息 = "您好");可先确认家长接听
- • {最后一次就诊医生}医生特意交代我来关注宝宝的后续情况 - • {诊断医生}医生特意交代我来关注宝宝的后续情况
- • (如果是熟悉患者可说:{最后一次就诊医生}医生上次还和我提起宝宝呢) - • (如果是熟悉患者可说:{诊断医生}医生上次还和我提起宝宝呢)
- • 宝宝自从{智能时间显示}检查后,口腔情况怎么样? - • 宝宝自从{智能时间显示}检查后,口腔情况怎么样?
## ═══ 第二部分:告知牙齿问题-健康提醒 ═══ ## ═══ 第二部分:告知牙齿问题-健康提醒 ═══
...@@ -36,10 +36,10 @@ tone 默认 warm(温和家常,适合与家长沟通)。 ...@@ -36,10 +36,10 @@ tone 默认 warm(温和家常,适合与家长沟通)。
小节1 - 复查时间(短句1):建议3个月左右带宝宝来院检查 小节1 - 复查时间(短句1):建议3个月左右带宝宝来院检查
小节2 - 检查内容(短句2):一方面做全面检查,看有没有蛀牙,有没有不良习惯 小节2 - 检查内容(短句2):一方面做全面检查,看有没有蛀牙,有没有不良习惯
小节3 - 预防措施(短句3):还要看看要不要给宝宝涂氟保护牙齿 小节3 - 预防措施(短句3):还要看看要不要给宝宝涂氟保护牙齿
小节4 - 专业建议(短句4):也请{最后一次就诊医生}医生再仔细看一下宝宝的情况 小节4 - 专业建议(短句4):也请{诊断医生}医生再仔细看一下宝宝的情况
小节5 - 引导预约(短句5):[有引导性,给出具体时间选择] 小节5 - 引导预约(短句5):[有引导性,给出具体时间选择]
• 如果方便的话您看最近有没有时间,我帮您预约一个儿牙专家的时间,您带宝宝过来看一看 • 如果方便的话您看最近有没有时间,我帮您预约一个儿牙专家的时间,您带宝宝过来看一看
• {最后一次就诊医生}医生【时间段1】和【时间段2】这两个时间段有空 • {诊断医生}医生【时间段1】和【时间段2】这两个时间段有空
⚠️ 保留"【时间段】"结构,禁止替换成具体时间 ⚠️ 保留"【时间段】"结构,禁止替换成具体时间
## ═══ 第四部分:结束回访语 ═══ ## ═══ 第四部分:结束回访语 ═══
......
...@@ -98,8 +98,8 @@ export class PlanScriptOrchestrator { ...@@ -98,8 +98,8 @@ export class PlanScriptOrchestrator {
* 实时教练用它的 patient/plan/clinicalContext 拼 Qwen instructions,跟话术生成共享上下文。 * 实时教练用它的 patient/plan/clinicalContext 拼 Qwen instructions,跟话术生成共享上下文。
*/ */
async buildScriptInputForPlan(planId: string): Promise<DraftPlanScriptInput> { async buildScriptInputForPlan(planId: string): Promise<DraftPlanScriptInput> {
const { plan, patient, persona, facts } = await this.loadPlanContext(planId); const { plan, patient, persona, facts, guardian } = await this.loadPlanContext(planId);
return this.buildCallInput({ plan, patient, persona, facts }); return this.buildCallInput({ plan, patient, persona, facts, guardian });
} }
/** /**
...@@ -115,8 +115,8 @@ export class PlanScriptOrchestrator { ...@@ -115,8 +115,8 @@ export class PlanScriptOrchestrator {
options: PlanScriptGenerateOptions = {}, options: PlanScriptGenerateOptions = {},
): 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, guardian } = await this.loadPlanContext(planId);
const input = this.buildCallInput({ plan, patient, persona, facts, agent: options.agent }); const input = this.buildCallInput({ plan, patient, persona, facts, guardian, agent: options.agent });
// ─── 2. 跑 AI 调用 ─── // ─── 2. 跑 AI 调用 ───
const workflowRunId = randomUUID(); const workflowRunId = randomUUID();
...@@ -304,6 +304,21 @@ export class PlanScriptOrchestrator { ...@@ -304,6 +304,21 @@ export class PlanScriptOrchestrator {
}); });
if (!patient) throw new NotFoundException(`Patient ${plan.patientId} not found`); if (!patient) throw new NotFoundException(`Patient ${plan.patientId} not found`);
const patientAge = patient.birthDate ? calcAge(patient.birthDate) : null;
// 监护人(未成年触达打给监护人):优先 妈妈 > 爸爸 > 祖辈,优先已建档(有姓名)
const guardian =
patientAge != null && patientAge <= 12
? pickGuardian(
await this.prisma.patientRelation.findMany({
where: {
patientId: patient.id,
relationship: { in: ['mother', 'father', 'grandparent'] },
},
include: { relatedPatient: { select: { name: true } } },
}),
)
: null;
const persona = await this.prisma.persona.findFirst({ const persona = await this.prisma.persona.findFirst({
where: { patientId: patient.id, supersededAt: null }, where: { patientId: patient.id, supersededAt: null },
include: { features: true }, include: { features: true },
...@@ -323,7 +338,7 @@ export class PlanScriptOrchestrator { ...@@ -323,7 +338,7 @@ export class PlanScriptOrchestrator {
orderBy: { occurredAt: 'desc' }, orderBy: { occurredAt: 'desc' },
}); });
return { plan, patient, persona, facts }; return { plan, patient, persona, facts, guardian };
} }
private buildCallInput(args: { private buildCallInput(args: {
...@@ -332,8 +347,11 @@ export class PlanScriptOrchestrator { ...@@ -332,8 +347,11 @@ export class PlanScriptOrchestrator {
persona: PersonaWithFeatures | null; persona: PersonaWithFeatures | null;
facts: FactRow[]; facts: FactRow[];
agent?: { name: string | null; roleTitle: string }; agent?: { name: string | null; roleTitle: string };
/** 监护人(未成年触达对象)— loadPlanContext 查好传入;成人/无 → null */
guardian?: { relationship: string; relationshipLabel: string; name: string | null } | null;
}): DraftPlanScriptInput { }): DraftPlanScriptInput {
const { plan, patient, persona, facts, agent } = args; const { plan, patient, persona, facts, agent, guardian } = args;
const patientAge = patient.birthDate ? calcAge(patient.birthDate) : null;
// ⭐ 就诊事件回退:跟 plan-aggregate.serializeProfile 同口径 // ⭐ 就诊事件回退:跟 plan-aggregate.serializeProfile 同口径
// encounter_record(appointment.in_time != null 才产)很多 host 缺,改用 EMR 兜底 // encounter_record(appointment.in_time != null 才产)很多 host 缺,改用 EMR 兜底
...@@ -388,12 +406,14 @@ export class PlanScriptOrchestrator { ...@@ -388,12 +406,14 @@ export class PlanScriptOrchestrator {
return { return {
patient: { patient: {
// 注意:LLM 用的 nameMasked 是"路先生/女士"通话称呼,不是脱敏掩码"路*" // ⭐ 通话称呼(单一源,年龄+性别+监护人 aware):未成年→打给监护人(妈妈→"X女士"),
// (后者是 UI 列表展示用,客服通话不能直接念"路星") // 无监护人姓名→"X家长";成人→"X先生/女士"。header/开场白共用此值,不再各算一遍。
nameMasked: nameSpokenForm(patient.name, patient.gender), nameMasked: callSalutation(patient.name, patient.gender, patientAge, guardian ?? null),
gender: patient.gender, gender: patient.gender,
age: patient.birthDate ? calcAge(patient.birthDate) : null, age: patientAge,
medicalRecordNumber: patient.medicalRecordNumber ?? null, medicalRecordNumber: patient.medicalRecordNumber ?? null,
// 监护人(未成年):让话术知道"打给谁、患者是孩子",可空
guardian: guardian ? { relationshipLabel: guardian.relationshipLabel, name: guardian.name } : null,
}, },
// 临时:hardcoded jvs-dw 诊所字典(TODO #56 接 host 字典或新建 clinics 表) // 临时:hardcoded jvs-dw 诊所字典(TODO #56 接 host 字典或新建 clinics 表)
// ⚠️ 直接吐 UUID 进 prompt 会让 LLM 编造"XX 客服中心",必须翻译成中文名 // ⚠️ 直接吐 UUID 进 prompt 会让 LLM 编造"XX 客服中心",必须翻译成中文名
...@@ -658,6 +678,59 @@ function nameSpokenForm(name: string | null, gender: string | null): string { ...@@ -658,6 +678,59 @@ function nameSpokenForm(name: string | null, gender: string | null): string {
return surname ? `${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);
}
/** /**
* 临时 jvs-dw 诊所字典(TODO #56:接 host clinic 字典或建 PAC clinics 表)。 * 临时 jvs-dw 诊所字典(TODO #56:接 host clinic 字典或建 PAC clinics 表)。
......
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