Commit 93b5ccab by luoqi

feat(ai): 话术自报家门改「我是{诊断医生}医生的助理X」,不再是客服身份

业务口径(2026-07-29):回访挂医生的名义,患者更愿意接、也更贴事实(话术全程就是
以诊断医生的关怀为主线);"客服"听起来像销售,开场就掉信任。

改动收在两处口径源,三档共用:
- agent-identity.ts:岗位称呼 staff/leader/admin 一律「助理」(患者不关心内部层级,
  原来 leader 报"客服主管"),无姓名兜底也从"客服"改"助理"。
- prompt 里的自报家门句:`我是{clinicName}的【回访客服】` → `我是{诊断医生}医生的【回访客服】`
  (稳健档 prompt / 标准+深度档 fact-block / 稳健档 LLM 失败兜底文案,三处全改)。
  医生姓取诊断医生,取不到时 deidentifyDoctor 给"您的主治" → "我是您的主治医生的助理X"。

占位符字面量 `【回访客服】` **故意不动** —— 线上 plan_scripts 已有正文都含这串,
改字面量等于老缓存回填不上、患者会看到光秃秃的占位符。它现在只是个 token,
回填出来已经是"助理X";老话术因此平滑变成「我是XX诊所的助理李倩」,不会出洋相。

诊所名不再进自报家门,但仍作为一行事实给 LLM(标注"只在需要提到诊所时用,别自己编
XX口腔")—— 原来它就是防编造用的,直接删会让 LLM 自己造诊所名。

三档 promptVersion 全 bump(inputHash 含 promptVersion → AI 缓存自然失效,新生成走新话术;
已生成的老话术不会自动重跑,要新措辞得点「重新生成」)。

657 tests / 42 suites 通过(agent-identity + script-facts 两个 spec 同步改口径)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 1e6103b0
/** /**
* agent-identity —— 「自报家门」客服身份的**单一源**(稳健 / 标准 / 深度三档共用)。 * agent-identity —— 「自报家门」身份的**单一源**(稳健 / 标准 / 深度三档共用)。
*
* ## 患者侧身份 = 「某医生的助理」,不是「客服」(业务口径 2026-07-29)
* 完整一句是「我是{诊断医生}医生的助理{姓名}」—— 拼装在各档 prompt 里,本文件只管后半截身份。
* 换法的理由是业务定的:回访电话挂医生的名义,患者更愿意接、也更接近事实(话术全程以
* 诊断医生的关怀为主线);"客服"听起来像销售,开场就掉信任。
* ⚠️ 岗位称呼不再区分 staff/leader —— 患者不关心内部层级,统一"助理"。
* *
* ## 为什么要占位符(2026-07 线上 bug) * ## 为什么要占位符(2026-07 线上 bug)
* 原设计把登录客服的姓名直接烤进 LLM 生成的话术正文(v9:"我是X诊所的客服主管晋芳辰")。 * 原设计把登录客服的姓名直接烤进 LLM 生成的话术正文(v9:"我是X诊所的客服主管晋芳辰")。
...@@ -22,25 +28,34 @@ ...@@ -22,25 +28,34 @@
* · 系统回填:【回访客服】(本文件)—— 对 LLM 的契约完全一致:原样输出、别自己编名字。 * · 系统回填:【回访客服】(本文件)—— 对 LLM 的契约完全一致:原样输出、别自己编名字。
*/ */
/** 自报家门占位符 —— LLM 原样输出,渲染期按当前登录客服回填 */ /**
* 自报家门占位符 —— LLM 原样输出,渲染期按当前登录人回填。
*
* ⚠️ 字面量仍是「回访客服」是**故意不动的**:线上 plan_scripts 里已有的正文都含这串,
* 改字面量 = 老缓存回填不上、患者会看到光秃秃的占位符。它现在只是个 token,
* 回填出来的内容已经是"助理X"(见 ROLE_TITLE_ZH)。
*/
export const AGENT_IDENTITY_PLACEHOLDER = '【回访客服】'; export const AGENT_IDENTITY_PLACEHOLDER = '【回访客服】';
/** 无姓名时的兜底称谓(宿主未下发 users 字典 / 匿名调用)—— 不编名字,只报岗位 */ /** 无姓名时的兜底称谓(宿主未下发 users 字典 / 匿名调用)—— 不编名字,只报岗位 */
export const AGENT_IDENTITY_FALLBACK = '客服'; export const AGENT_IDENTITY_FALLBACK = '助理';
/** 自报家门用的客服身份:岗位称呼 + 姓名(姓名可空) */ /** 自报家门用的身份:岗位称呼 + 姓名(姓名可空) */
export interface ScriptAgentIdentity { export interface ScriptAgentIdentity {
/** 宿主 users 字典里的姓名(如"李倩");字典缺失 → null,回填退回通用"客服" */ /** 宿主 users 字典里的姓名(如"李倩");字典缺失 → null,回填退回通用"助理" */
name: string | null; name: string | null;
/** 患者侧岗位称呼(员工/管理员→客服;主管→客服主管) */ /** 患者侧岗位称呼 —— 一律"助理"(见文件头:患者不关心内部层级) */
roleTitle: string; roleTitle: string;
} }
/** JWT role → 患者侧岗位称呼。宿主角色与患者称呼是两件事,别把 admin 报成"管理员"。 */ /**
* JWT role → 患者侧岗位称呼。宿主角色与患者称呼是两件事,别把 admin 报成"管理员"。
* 三种角色现在同值 —— 表保留是因为"将来某类角色要换称呼"仍是这里改,不是散到各档 prompt。
*/
const ROLE_TITLE_ZH: Record<string, string> = { const ROLE_TITLE_ZH: Record<string, string> = {
staff: '客服', staff: '助理',
leader: '客服主管', leader: '助理',
admin: '客服', admin: '助理',
}; };
/** /**
...@@ -58,15 +73,15 @@ export function resolveScriptAgent(user: { ...@@ -58,15 +73,15 @@ export function resolveScriptAgent(user: {
}; };
} }
/** 身份 → 落到话术里的那串字(有名字报"客服主管李倩";没名字只报"客服",绝不编) */ /** 身份 → 落到话术里的那串字(有名字报"助理李倩";没名字只报"助理",绝不编) */
export function agentIdentityText(agent: ScriptAgentIdentity | null | undefined): string { export function agentIdentityText(agent: ScriptAgentIdentity | null | undefined): string {
if (!agent?.name) return AGENT_IDENTITY_FALLBACK; if (!agent?.name) return AGENT_IDENTITY_FALLBACK;
return `${agent.roleTitle}${agent.name}`; return `${agent.roleTitle}${agent.name}`;
} }
/** /**
* 渲染期回填 —— 话术正文里的 {@link AGENT_IDENTITY_PLACEHOLDER} 换成当前登录客服 * 渲染期回填 —— 话术正文里的 {@link AGENT_IDENTITY_PLACEHOLDER} 换成当前登录
* agent 缺省(内部调用 / 无登录态)→ 换成通用"客服",**不留占位符给患者看到**。 * agent 缺省(内部调用 / 无登录态)→ 换成通用"助理",**不留占位符给患者看到**。
*/ */
export function renderAgentIdentity<T extends string | null | undefined>( export function renderAgentIdentity<T extends string | null | undefined>(
content: T, content: T,
......
...@@ -39,9 +39,11 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string { ...@@ -39,9 +39,11 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string {
const toothText = top?.toothPositions?.length ? toothFriendly(top.toothPositions) : null; const toothText = top?.toothPositions?.length ? toothFriendly(top.toothPositions) : null;
const goal = plan.goal?.trim() || null; const goal = plan.goal?.trim() || null;
// 自报家门:姓名**不进 prompt** —— 给占位符,LLM 原样输出,读详情时按当前登录客服回填。 // 自报家门:身份是「诊断医生的助理」不是客服(业务口径 2026-07-29,见 agent-identity.ts);
// (话术缓存是 per-plan 的、召回池共享,烤进人名会让后开的客服读到别人的名字。见 agent-identity.ts) // 姓名**不进 prompt** —— 给占位符,LLM 原样输出,读详情时按当前登录人回填
const selfIntro = `我是${clinicName}${AGENT_IDENTITY_PLACEHOLDER}`; // (话术缓存 per-plan、召回池共享,烤进人名会让后开的人读到别人的名字)。
// 医生姓取自诊断医生;取不到时 deidentifyDoctor 给"您的主治" → "我是您的主治医生的助理X"。
const selfIntro = `我是${doctorSurname}医生的${AGENT_IDENTITY_PLACEHOLDER}`;
const guardianHint = patient.guardian const guardianHint = patient.guardian
? `本次电话打给${patient.guardian.relationshipLabel}(称呼见上方"称呼"),沟通对象是家长,患者是孩子,话术里称孩子为"宝宝"` ? `本次电话打给${patient.guardian.relationshipLabel}(称呼见上方"称呼"),沟通对象是家长,患者是孩子,话术里称孩子为"宝宝"`
: null; : null;
...@@ -84,6 +86,7 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string { ...@@ -84,6 +86,7 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string {
## 开场用 ## 开场用
- 称呼:${salutation} - 称呼:${salutation}
- 自报家门:${selfIntro}(${AGENT_IDENTITY_PLACEHOLDER} 原样保留,别替换成具体姓名,也别自己编) - 自报家门:${selfIntro}(${AGENT_IDENTITY_PLACEHOLDER} 原样保留,别替换成具体姓名,也别自己编)
- 诊所名(**只在需要提到诊所时用这个**,别自己编"XX口腔";自报家门里不用带):${clinicName}
- 最近一次就诊(用于开场"自从…来过";诊断日见病历、可能更早):${lastVisitDisplay} - 最近一次就诊(用于开场"自从…来过";诊断日见病历、可能更早):${lastVisitDisplay}
- 那次主诉:${chiefComplaint ?? '无记录'} - 那次主诉:${chiefComplaint ?? '无记录'}
- 诊断医生:${doctorSurname}医生${guardianHint ? `\n- 触达说明:${guardianHint}` : ''} - 诊断医生:${doctorSurname}医生${guardianHint ? `\n- 触达说明:${guardianHint}` : ''}
......
...@@ -29,7 +29,7 @@ export interface ScriptContext { ...@@ -29,7 +29,7 @@ export interface ScriptContext {
guardian?: { relationship: string; relationshipLabel: string; name: string | null } | null; guardian?: { relationship: string; relationshipLabel: string; name: string | null } | null;
}; };
/** 诊所名(给 LLM 用作"我是X诊所的客服顾问",避免编造"XX口腔") */ /** 诊所名 —— 防 LLM 编造"XX口腔"的锚;⚠️ 自报家门里**不用**它(身份是"{诊断医生}医生的助理X") */
clinicName: string; clinicName: string;
/** ⚠️ 这里**没有** agent(回访客服)字段,是刻意的 —— 姓名不进 LLM 输入。 /** ⚠️ 这里**没有** agent(回访客服)字段,是刻意的 —— 姓名不进 LLM 输入。
......
...@@ -67,7 +67,7 @@ export class DeepPlanCall implements AiCall<ScriptContext, DeepPlanZ> { ...@@ -67,7 +67,7 @@ export class DeepPlanCall implements AiCall<ScriptContext, DeepPlanZ> {
export class DeepWriteCall implements AiCall<DeepWriteInput, DeepWriteZ> { export class DeepWriteCall implements AiCall<DeepWriteInput, DeepWriteZ> {
readonly kind = 'script' as const; readonly kind = 'script' as const;
readonly callKey = 'draft_plan_script_write'; readonly callKey = 'draft_plan_script_write';
readonly promptVersion = 'draft_plan_script@2026-06-16-deep-write-v18'; // v18: 低龄约束(<18 不主推种植)+ 替牙期(≤12)说「早期矫治」。v17: 高龄硬约束(≥75 缺牙禁推种植/手术)。v16: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v15: 去掉钦定段式(给自由度);v14: 禁内部代码/术语(K08等)说大白话 readonly promptVersion = 'draft_plan_script@2026-07-29-deep-write-v19'; // v19: 自报家门改「我是{诊断医生}医生的助理X」(业务口径:非客服身份)。v18: 低龄约束(<18 不主推种植)+ 替牙期(≤12)说「早期矫治」。v17: 高龄硬约束(≥75 缺牙禁推种植/手术)。v16: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v15: 去掉钦定段式(给自由度);v14: 禁内部代码/术语(K08等)说大白话
readonly defaultModelId = 'deepseek-v4-flash'; readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DeepWriteSchema; readonly outputSchema = DeepWriteSchema;
constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {} constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {}
......
...@@ -12,7 +12,7 @@ ...@@ -12,7 +12,7 @@
# 这三处按原样写,不要改 # 这三处按原样写,不要改
1. 引导预约句式(严格用此句式):「X医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」——“X医生”用给定的诊断医生姓替换;两个【时间段】原样保留给客服手填。 1. 引导预约句式(严格用此句式):「X医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」——“X医生”用给定的诊断医生姓替换;两个【时间段】原样保留给客服手填。
2. 时间一律占位:约成功用「我们【具体预约时间】见」;`【时间段1】【时间段2】【具体预约时间】` 原样保留,严禁替换成“周三上午”等具体时间、严禁加粗、严禁“已为您约好”式承诺。 2. 时间一律占位:约成功用「我们【具体预约时间】见」;`【时间段1】【时间段2】【具体预约时间】` 原样保留,严禁替换成“周三上午”等具体时间、严禁加粗、严禁“已为您约好”式承诺。
3. 自报家门:用给定的“自报家门”整串,其中 `【回访客服】` 原样保留(系统按登录客服回填)——别替换成具体姓名,也别自己编一个 3. 自报家门:用给定的“自报家门”整串,其中 `【回访客服】` 原样保留(系统按登录人回填成「助理X」)——别替换成具体姓名,也别自己编一个;身份是**医生的助理**,不要改写成“客服/顾问”
# 开场/结束段 # 开场/结束段
- 开场段:把这几件事自然说到、顺序你定——用患者称呼称呼并确认对方方便、自报家门、以诊断医生名义体现关怀、用“最近一次就诊”问近况。 - 开场段:把这几件事自然说到、顺序你定——用患者称呼称呼并确认对方方便、自报家门、以诊断医生名义体现关怀、用“最近一次就诊”问近况。
......
...@@ -112,9 +112,10 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string ...@@ -112,9 +112,10 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
const implantLast = patient.age != null && patient.age <= IMPLANT_LAST_AGE; const implantLast = patient.age != null && patient.age <= IMPLANT_LAST_AGE;
const earlyOrtho = patient.age != null && patient.age <= EARLY_ORTHO_MAX_AGE; const earlyOrtho = patient.age != null && patient.age <= EARLY_ORTHO_MAX_AGE;
// 自报家门:姓名**不进 prompt** —— 给 【回访客服】 占位符,LLM 原样输出,读详情时按当前登录客服回填。 // 自报家门:身份是「诊断医生的助理」不是客服(业务口径 2026-07-29,见 agent-identity.ts);
// (话术缓存是 per-plan、召回池共享,烤进人名会让后开的客服读到别人的名字。见 agent-identity.ts) // 姓名**不进 prompt** —— 给 【回访客服】 占位符,LLM 原样输出,读详情时按当前登录人回填
const selfIntro = `我是${clinicName}${AGENT_IDENTITY_PLACEHOLDER}`; // (话术缓存 per-plan、召回池共享,烤进人名会让后开的人读到别人的名字)。
const selfIntro = `我是${doctorSurname}医生的${AGENT_IDENTITY_PLACEHOLDER}`;
// 画像精简版(⚠安全 + 定语气):稳健档只灌"护栏"类,不灌切入点(避免诱导加推销),保持填空纪律。 // 画像精简版(⚠安全 + 定语气):稳健档只灌"护栏"类,不灌切入点(避免诱导加推销),保持填空纪律。
const persona = buildPersonaGuide(input.personaHighlights ?? [], 'essential'); const persona = buildPersonaGuide(input.personaHighlights ?? [], 'essential');
...@@ -125,6 +126,7 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string ...@@ -125,6 +126,7 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
## 开场用 ## 开场用
- {智能称呼}:${salutation} - {智能称呼}:${salutation}
- {自报家门}:${selfIntro}(${AGENT_IDENTITY_PLACEHOLDER} 原样保留,别替换成具体姓名,也别自己编) - {自报家门}:${selfIntro}(${AGENT_IDENTITY_PLACEHOLDER} 原样保留,别替换成具体姓名,也别自己编)
- 诊所名(**只在需要提到诊所时用这个**,别自己编"XX口腔";{自报家门} 里不用带):${clinicName}
- {智能时间显示}(最近一次就诊,用于开场"自从…来过"):${lastVisitDisplay} - {智能时间显示}(最近一次就诊,用于开场"自从…来过"):${lastVisitDisplay}
- 那次主诉(该问题诊断那次,可能更早):${chiefComplaint ?? '无记录'} - 那次主诉(该问题诊断那次,可能更早):${chiefComplaint ?? '无记录'}
- {诊断医生}:${doctorSurname}医生${guardianHint ? `\n- 触达说明:${guardianHint}` : ''} - {诊断医生}:${doctorSurname}医生${guardianHint ? `\n- 触达说明:${guardianHint}` : ''}
......
...@@ -5,10 +5,10 @@ ...@@ -5,10 +5,10 @@
# 占位符约定(两种,别搞混) # 占位符约定(两种,别搞混)
- `{xxx}` = **替换**:用"本次回访患者信息"里给的同名值填(如 {智能称呼}{应治未治项}{牙位}{诊断医生}{风险要点}{复查时长});输出里不能再出现 `{}` - `{xxx}` = **替换**:用"本次回访患者信息"里给的同名值填(如 {智能称呼}{应治未治项}{牙位}{诊断医生}{风险要点}{复查时长});输出里不能再出现 `{}`
- {智能称呼} / {诊断医生} 已是"姓+敬称",直接用;{牙位} 已是俗称(上门牙),没给就不提牙位。 - {智能称呼} / {诊断医生} 已是"姓+敬称",直接用;{牙位} 已是俗称(上门牙),没给就不提牙位。
- `【xxx】` = **原样保留**:【时间段1】【时间段2】【具体预约时间】(客服手填)、【回访客服】(系统按登录客服回填,别替换成人名、别自己编);结束语分支标签【预约成功】【预约不成功】照常输出。 - `【xxx】` = **原样保留**:【时间段1】【时间段2】【具体预约时间】(客服手填)、【回访客服】(系统按登录人回填成「助理X」,别替换成人名、别自己编);结束语分支标签【预约成功】【预约不成功】照常输出。
# 填空要点 # 填空要点
- 开场顺序固定:先用 {智能称呼} 称呼并确认对方 → 再 {自报家门}(内含【回访客服】,整串照抄) → 以 {诊断医生} 名义体现关怀 → 用 {智能时间显示} 问近况。 - 开场顺序固定:先用 {智能称呼} 称呼并确认对方 → 再 {自报家门}(内含【回访客服】,整串照抄;身份是**医生的助理**,别改写成"客服/顾问") → 以 {诊断医生} 名义体现关怀 → 用 {智能时间显示} 问近况。
- 健康提醒从 {风险要点} 里挑、检查说明用 {复查时长} 原文;给定值直接用,不改写、不重算。 - 健康提醒从 {风险要点} 里挑、检查说明用 {复查时长} 原文;给定值直接用,不改写、不重算。
- 引导预约严格用:「{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」 - 引导预约严格用:「{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」
- 告知应治未治、复查建议分成短句。 - 告知应治未治、复查建议分成短句。
......
...@@ -9,6 +9,7 @@ import { smartDateDisplay } from '../../shared/script-facts'; ...@@ -9,6 +9,7 @@ import { smartDateDisplay } from '../../shared/script-facts';
import { resolveDisease } from './phrasing'; import { resolveDisease } from './phrasing';
import { deidentifyDoctor } from '../../shared/pii'; import { deidentifyDoctor } from '../../shared/pii';
import { SCRIPT_SAFETY_RULES } from '../../shared/safety-rules'; import { SCRIPT_SAFETY_RULES } from '../../shared/safety-rules';
import { AGENT_IDENTITY_PLACEHOLDER } from '../../shared/agent-identity';
// 安全规则(禁词/承诺/加粗时间)走单一源 shared/safety-rules.ts,三档共用,不再各档内联。 // 安全规则(禁词/承诺/加粗时间)走单一源 shared/safety-rules.ts,三档共用,不再各档内联。
const safetyRules = SCRIPT_SAFETY_RULES; const safetyRules = SCRIPT_SAFETY_RULES;
...@@ -21,7 +22,8 @@ const safetyRules = SCRIPT_SAFETY_RULES; ...@@ -21,7 +22,8 @@ const safetyRules = SCRIPT_SAFETY_RULES;
* 不加粗具体时间、不承诺、时间一律用【时间段】/【具体预约时间】占位。 * 不加粗具体时间、不承诺、时间一律用【时间段】/【具体预约时间】占位。
*/ */
export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanScriptOutput { export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
const { patient, clinicName, plan, clinicalContext } = input; // clinicName 不进兜底开场 —— 自报家门是「{诊断医生}医生的助理X」,不带诊所名(同 prompt 口径)
const { patient, plan, clinicalContext } = input;
const salutation = patient.salutation; // 统一通话称呼(年龄+性别+监护人 aware,orchestrator 算好) const salutation = patient.salutation; // 统一通话称呼(年龄+性别+监护人 aware,orchestrator 算好)
// 漏诊项 = 聚焦项 reasons[0](orchestrator 已 priorityScore 降序,单一源)→ 转换层归一 // 漏诊项 = 聚焦项 reasons[0](orchestrator 已 priorityScore 降序,单一源)→ 转换层归一
const topReason = (plan.reasons ?? [])[0]; const topReason = (plan.reasons ?? [])[0];
...@@ -42,7 +44,7 @@ export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanSc ...@@ -42,7 +44,7 @@ export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanSc
return { return {
tone: 'warm', tone: 'warm',
opening: `• ${salutation}您好,我是${clinicName}的客服 opening: `• ${salutation}您好,我是${doctor}医生的${AGENT_IDENTITY_PLACEHOLDER}
${doctor}医生特意交代我来关注您的后续情况 ${doctor}医生特意交代我来关注您的后续情况
• 自从${dateDisplay}来过之后,您口腔情况怎么样?`, • 自从${dateDisplay}来过之后,您口腔情况怎么样?`,
informMissed: `• 之前${doctor}医生检查时,注意到您有${disease.label}的情况 informMissed: `• 之前${doctor}医生检查时,注意到您有${disease.label}的情况
...@@ -65,7 +67,7 @@ export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanSc ...@@ -65,7 +67,7 @@ export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanSc
* 改 system/prompt 文本 → bump 字母;改 schema → bump 日期。 * 改 system/prompt 文本 → bump 字母;改 schema → bump 日期。
*/ */
const DRAFT_PLAN_SCRIPT_PROMPT_VERSION = const DRAFT_PLAN_SCRIPT_PROMPT_VERSION =
'draft_plan_script@2026-06-08-4module-v30'; // v30: 低龄约束(<18 不主推种植,口径同召回 IMPLANT_LAST_AGE)+ 替牙期(≤12)矫治措辞统一说「早期矫治」(同 EARLY_ORTHO_MAX_AGE)。v29: 高龄硬约束(≥75 缺牙禁推种植/手术,落活动义齿;口径同召回目标标签 ELDERLY_PROSTHESIS_AGE)。v28: 自报家门去人名 → 占位【回访客服】(生成期占位/渲染期按登录人回填;修「话术缓存 per-plan + 召回池共享 → 后开的客服读到别人名字」线上 bug);input 去 agent 字段,同 plan 不同客服共享 AI 缓存。v27: schema 去硬长度约束(.min/.max → describe 软引导,修 qwen too_small 必失败);v26: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档,不诱导推销);v24: 治疗计划补 plannedTreatments(treatment_record planned 结构化;原只读常空的 emr.treatment_plan → 话术缺治疗计划);v23: common.md + 人群共性 SKILL 去 brace(原 {应治未治项}/{诊断医生}/{智能称呼} 改朴素措辞;稳健自身句位模板的 {} 不变,填空机制照常,行为基本不变);v22: 新老客改'熟络度'(recency为主+次数为辅,去二分标签,交LLM);v21: 开场日期改锚【最近一次就诊】(原误用诊断日,患者后来又来过会错位)+'来过之后'/告知'之前那次'区分;v20: schema describe 收口(去与模板矛盾的开场顺序/'下周'/负面例,只留段用途+关键约束)+ prompt/兜底 软化'下周'+ 修陈旧注释;v19: stable format.md 精简(去 common/机器闸重复、自查砍到高风险3条)+ 成人模板优化(去重/换标题/软化结束语);v18: base-common 精简合并去重 + 软化治疗方案口径(可点名/不报价不定细化/落点复查);v17: 目录重组(shared/+tiers/stable/)— base 拆 common+format、人群拆共性知识+稳健句位、病种文案归 stable phrasing、安全单一源 safety-rules、composer tier-aware;修开场顺序冲突。v16: 儿童模板复查段修复(删写死"3个月常规涂氟检查"→对齐本次{应治未治项}+用{复查时长},涂氟降级顺带);child SKILL 1.4.0;v15: user prompt 加"医生那次交代"(医嘱/建议/治疗计划,来自聚焦病历,仅引用不演绎);medicalRecord 补 recommendations;v14: user prompt 加 {牙位}(FDI→俗称)+ 本次目标(plan.goal)+ ≤18 禁拍片 belt;占位收口 {牙位}(删 【缺失牙位】);adult/child 模板带牙位;v13: 撤 token,人名去名留称呼(徐女士/韩医生 直接给,非 token);开场白先称呼确认对方再自报家门;v12: user prompt 人名脱敏(称呼/诊断医生/客服 用 token,生成后回填;监护人全名不进 prompt);v11: 统一通话称呼(年龄+性别+监护人,修"9岁张先生");监护人触达提示;医生标签 最后一次就诊→诊断医生;v10: 病种知识走 disease-knowledge 单一访问源(subKey 优先+文本兜底),修 颌骨囊肿 拿不到风险/优势的 bug;v9: 自报家门用登录客服 岗位+姓名(agent);v8: 占位符统一({}=替换、【】=原样保留);v7: 清除 user prompt 污染;v6: 清 system 污染;v5: 还原原模板 'draft_plan_script@2026-07-29-4module-v31'; // v31: 自报家门改「我是{诊断医生}医生的助理X」(业务口径:非客服身份;岗位称呼统一"助理",诊所名另给一行防编造)。v30: 低龄约束(<18 不主推种植,口径同召回 IMPLANT_LAST_AGE)+ 替牙期(≤12)矫治措辞统一说「早期矫治」(同 EARLY_ORTHO_MAX_AGE)。v29: 高龄硬约束(≥75 缺牙禁推种植/手术,落活动义齿;口径同召回目标标签 ELDERLY_PROSTHESIS_AGE)。v28: 自报家门去人名 → 占位【回访客服】(生成期占位/渲染期按登录人回填;修「话术缓存 per-plan + 召回池共享 → 后开的客服读到别人名字」线上 bug);input 去 agent 字段,同 plan 不同客服共享 AI 缓存。v27: schema 去硬长度约束(.min/.max → describe 软引导,修 qwen too_small 必失败);v26: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档,不诱导推销);v24: 治疗计划补 plannedTreatments(treatment_record planned 结构化;原只读常空的 emr.treatment_plan → 话术缺治疗计划);v23: common.md + 人群共性 SKILL 去 brace(原 {应治未治项}/{诊断医生}/{智能称呼} 改朴素措辞;稳健自身句位模板的 {} 不变,填空机制照常,行为基本不变);v22: 新老客改'熟络度'(recency为主+次数为辅,去二分标签,交LLM);v21: 开场日期改锚【最近一次就诊】(原误用诊断日,患者后来又来过会错位)+'来过之后'/告知'之前那次'区分;v20: schema describe 收口(去与模板矛盾的开场顺序/'下周'/负面例,只留段用途+关键约束)+ prompt/兜底 软化'下周'+ 修陈旧注释;v19: stable format.md 精简(去 common/机器闸重复、自查砍到高风险3条)+ 成人模板优化(去重/换标题/软化结束语);v18: base-common 精简合并去重 + 软化治疗方案口径(可点名/不报价不定细化/落点复查);v17: 目录重组(shared/+tiers/stable/)— base 拆 common+format、人群拆共性知识+稳健句位、病种文案归 stable phrasing、安全单一源 safety-rules、composer tier-aware;修开场顺序冲突。v16: 儿童模板复查段修复(删写死"3个月常规涂氟检查"→对齐本次{应治未治项}+用{复查时长},涂氟降级顺带);child SKILL 1.4.0;v15: user prompt 加"医生那次交代"(医嘱/建议/治疗计划,来自聚焦病历,仅引用不演绎);medicalRecord 补 recommendations;v14: user prompt 加 {牙位}(FDI→俗称)+ 本次目标(plan.goal)+ ≤18 禁拍片 belt;占位收口 {牙位}(删 【缺失牙位】);adult/child 模板带牙位;v13: 撤 token,人名去名留称呼(徐女士/韩医生 直接给,非 token);开场白先称呼确认对方再自报家门;v12: user prompt 人名脱敏(称呼/诊断医生/客服 用 token,生成后回填;监护人全名不进 prompt);v11: 统一通话称呼(年龄+性别+监护人,修"9岁张先生");监护人触达提示;医生标签 最后一次就诊→诊断医生;v10: 病种知识走 disease-knowledge 单一访问源(subKey 优先+文本兜底),修 颌骨囊肿 拿不到风险/优势的 bug;v9: 自报家门用登录客服 岗位+姓名(agent);v8: 占位符统一({}=替换、【】=原样保留);v7: 清除 user prompt 污染;v6: 清 system 污染;v5: 还原原模板
@Injectable() @Injectable()
export class DraftPlanScriptCall export class DraftPlanScriptCall
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
- **事实朴素取用**:患者信息以朴素中文标签直接给(称呼/本次问题/牙位/诊断医生/最近一次就诊…),自然用进话里;不写占位符、不留标签字样。 - **事实朴素取用**:患者信息以朴素中文标签直接给(称呼/本次问题/牙位/诊断医生/最近一次就诊…),自然用进话里;不写占位符、不留标签字样。
# 4 段通常覆盖(内容指引,可合并衔接、标题自起) # 4 段通常覆盖(内容指引,可合并衔接、标题自起)
- 打招呼、确认对方方便、自报家门(用给定的“自报家门”整串,含 `【回访客服】` 照抄不改),以诊断医生名义体现关怀,用“最近一次就诊”问近况。 - 打招呼、确认对方方便、自报家门(用给定的“自报家门”整串,含 `【回访客服】` 照抄不改;身份是**医生的助理**,不要改写成“客服/顾问”),以诊断医生名义体现关怀,用“最近一次就诊”问近况。
- 带出本次这一个问题(有牙位就带上):以“诊断医生上次检查发现”的口吻,结合病历讲清“隐患”+“趁早处理的好处”,温和提醒、非吓唬非推销。 - 带出本次这一个问题(有牙位就带上):以“诊断医生上次检查发现”的口吻,结合病历讲清“隐患”+“趁早处理的好处”,温和提醒、非吓唬非推销。
- 邀约来院复查本次问题(关怀口吻、不推销治疗、别写死分钟数)。 - 邀约来院复查本次问题(关怀口吻、不推销治疗、别写死分钟数)。
- 简短有温度的收尾:约成功就确认时间道别,没约成温和留口子(别写死“下周”这种绝对时间)。 - 简短有温度的收尾:约成功就确认时间道别,没约成温和留口子(别写死“下周”这种绝对时间)。
...@@ -17,4 +17,4 @@ ...@@ -17,4 +17,4 @@
2. 时间一律占位:约成功用「我们【具体预约时间】见」;`【时间段1】【时间段2】【具体预约时间】` 原样保留,严禁替换成“周三上午”等具体时间、严禁加粗、严禁“已为您约好”式承诺。 2. 时间一律占位:约成功用「我们【具体预约时间】见」;`【时间段1】【时间段2】【具体预约时间】` 原样保留,严禁替换成“周三上午”等具体时间、严禁加粗、严禁“已为您约好”式承诺。
# 占位 # 占位
只有这些需原样保留在话术里:`【时间段1】【时间段2】【具体预约时间】`(客服手填)、`【回访客服】`(自报家门,系统按登录客服回填 —— 别替换成具体姓名,也别自己编一个)。其余都是朴素事实,直接自然取用,输出里不要出现别的占位或标签。 只有这些需原样保留在话术里:`【时间段1】【时间段2】【具体预约时间】`(客服手填)、`【回访客服】`(自报家门,系统按登录人回填成「助理X」 —— 别替换成具体姓名,也别自己编一个)。其余都是朴素事实,直接自然取用,输出里不要出现别的占位或标签。
...@@ -22,7 +22,7 @@ import { type DeepDraft, draftOutputToDeep } from '../deep/types'; ...@@ -22,7 +22,7 @@ import { type DeepDraft, draftOutputToDeep } from '../deep/types';
* *
* callKey 仍用 'draft_plan_script'(同一逻辑调用),档位差异落 promptVersion → eval 可按版本切档对比。 * callKey 仍用 'draft_plan_script'(同一逻辑调用),档位差异落 promptVersion → eval 可按版本切档对比。
*/ */
const STANDARD_PROMPT_VERSION = 'draft_plan_script@2026-06-08-standard-v17'; // v17: 低龄约束(<18 不主推种植)+ 替牙期(≤12)说「早期矫治」。v16: 高龄硬约束(≥75 缺牙禁推种植/手术)。v15: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v14: schema 去硬长度/段数约束(.min/.max/.length → describe 软引导);v13: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档);v11: 病历补全(治疗计划=plannedTreatments 结构化 + 一般情况/处置/诊断说明,对齐页面 emr-soap);v10: format.md 去污染(删 tier 名/去模板对比/见 common.md 等解释性 meta,纯指令);流式输出(段数组 partial 边出边渲染);v9: 真去模板 — 4 固定角色字段 → 自由 sections[];v8: 去 {} 替换占位;v7: format 瘦身 + opening 放开 + closing 软化 const STANDARD_PROMPT_VERSION = 'draft_plan_script@2026-07-29-standard-v18'; // v18: 自报家门改「我是{诊断医生}医生的助理X」(业务口径:非客服身份)。v17: 低龄约束(<18 不主推种植)+ 替牙期(≤12)说「早期矫治」。v16: 高龄硬约束(≥75 缺牙禁推种植/手术)。v15: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v14: schema 去硬长度/段数约束(.min/.max/.length → describe 软引导);v13: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档);v11: 病历补全(治疗计划=plannedTreatments 结构化 + 一般情况/处置/诊断说明,对齐页面 emr-soap);v10: format.md 去污染(删 tier 名/去模板对比/见 common.md 等解释性 meta,纯指令);流式输出(段数组 partial 边出边渲染);v9: 真去模板 — 4 固定角色字段 → 自由 sections[];v8: 去 {} 替换占位;v7: format 瘦身 + opening 放开 + closing 软化
@Injectable() @Injectable()
export class StandardScriptCall implements AiCall<DraftPlanScriptInput, DeepDraft> { export class StandardScriptCall implements AiCall<DraftPlanScriptInput, DeepDraft> {
......
...@@ -741,7 +741,7 @@ function roleNameZh(role: UserRole): string { ...@@ -741,7 +741,7 @@ 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诊所的客服小王")。 /// 模拟登录的演示人名(2 brand × 3 role);话术自报家门会用到("我是韩医生的助理小王")。
const MOCK_NAMES: Record<string, Partial<Record<UserRole, string>>> = { const MOCK_NAMES: Record<string, Partial<Record<UserRole, string>>> = {
ruier: { staff: '小王', leader: '李莉', admin: '张敏' }, ruier: { staff: '小王', leader: '李莉', admin: '张敏' },
ruitai: { staff: '小陈', leader: '周琳', admin: '刘洋' }, ruitai: { staff: '小陈', leader: '周琳', admin: '刘洋' },
......
...@@ -13,19 +13,19 @@ import { ...@@ -13,19 +13,19 @@ import {
} from '../src/modules/ai/calls/draft-plan-script/shared/agent-identity'; } from '../src/modules/ai/calls/draft-plan-script/shared/agent-identity';
describe('resolveScriptAgent | JWT → 自报家门身份', () => { describe('resolveScriptAgent | JWT → 自报家门身份', () => {
test('主管 → 客服主管 + 字典姓名', () => { test('⭐ 患者侧身份一律「助理」—— 不是客服,也不暴露内部层级', () => {
expect( expect(
resolveScriptAgent({ resolveScriptAgent({
sub: 'u1', sub: 'u1',
role: 'leader', role: 'leader',
dictionary: { users: { u1: '李倩' } }, dictionary: { users: { u1: '李倩' } },
}), }),
).toEqual({ name: '李倩', roleTitle: '客服主管' }); ).toEqual({ name: '李倩', roleTitle: '助理' });
}); });
test('员工 / 管理员 → 患者侧一律称"客服"(不把 admin 报成"管理员")', () => { test('员工 / 管理员 → 同样是"助理"(不把 admin 报成"管理员")', () => {
expect(resolveScriptAgent({ sub: 'u1', role: 'staff' }).roleTitle).toBe('客服'); expect(resolveScriptAgent({ sub: 'u1', role: 'staff' }).roleTitle).toBe('助理');
expect(resolveScriptAgent({ sub: 'u1', role: 'admin' }).roleTitle).toBe('客服'); expect(resolveScriptAgent({ sub: 'u1', role: 'admin' }).roleTitle).toBe('助理');
}); });
test('字典缺失 / 空白名 → name=null,不编名字', () => { test('字典缺失 / 空白名 → name=null,不编名字', () => {
...@@ -35,62 +35,63 @@ describe('resolveScriptAgent | JWT → 自报家门身份', () => { ...@@ -35,62 +35,63 @@ describe('resolveScriptAgent | JWT → 自报家门身份', () => {
).toBeNull(); ).toBeNull();
}); });
test('未知角色 → 兜底"客服"', () => { test('未知角色 → 兜底"助理"', () => {
expect(resolveScriptAgent({ sub: 'u1', role: 'wat' }).roleTitle).toBe('客服'); expect(resolveScriptAgent({ sub: 'u1', role: 'wat' }).roleTitle).toBe('助理');
}); });
}); });
describe('agentIdentityText | 身份 → 落到话术里的字', () => { describe('agentIdentityText | 身份 → 落到话术里的字', () => {
test('有名字:岗位 + 姓名', () => { test('有名字:岗位 + 姓名', () => {
expect(agentIdentityText({ name: '李倩', roleTitle: '客服主管' })).toBe('客服主管李倩'); expect(agentIdentityText({ name: '李倩', roleTitle: '助理' })).toBe('助理李倩');
}); });
test('无名字:只报岗位,绝不编名字', () => { test('无名字:只报岗位,绝不编名字', () => {
expect(agentIdentityText({ name: null, roleTitle: '客服主管' })).toBe('客服'); expect(agentIdentityText({ name: null, roleTitle: '助理' })).toBe('助理');
expect(agentIdentityText(null)).toBe('客服'); expect(agentIdentityText(null)).toBe('助理');
expect(agentIdentityText(undefined)).toBe('客服'); expect(agentIdentityText(undefined)).toBe('助理');
}); });
}); });
describe('renderAgentIdentity | 渲染期回填', () => { describe('renderAgentIdentity | 渲染期回填', () => {
const content = `## 开场白\n您好张先生,我是本诊所的${AGENT_IDENTITY_PLACEHOLDER},耽误您两分钟。`; // 正文形如「我是{诊断医生}医生的【回访客服】」—— 占位符前后的字由 prompt 拼,这里只管回填
const content = `## 开场白\n您好张先生,我是薛医生的${AGENT_IDENTITY_PLACEHOLDER},耽误您两分钟。`;
test('按当前登录人回填', () => { test('按当前登录人回填', () => {
expect(renderAgentIdentity(content, { name: '李倩', roleTitle: '客服主管' })).toContain( expect(renderAgentIdentity(content, { name: '李倩', roleTitle: '助理' })).toContain(
'我是本诊所的客服主管李倩', '我是薛医生的助理李倩',
); );
}); });
test('⭐ 同一份缓存正文,两个客服各读各的名字(线上 bug 的回归锁)', () => { test('⭐ 同一份缓存正文,两个各读各的名字(线上 bug 的回归锁)', () => {
const a = renderAgentIdentity(content, { name: '晋芳辰', roleTitle: '客服主管' }); const a = renderAgentIdentity(content, { name: '晋芳辰', roleTitle: '助理' });
const b = renderAgentIdentity(content, { name: '李倩', roleTitle: '客服主管' }); const b = renderAgentIdentity(content, { name: '李倩', roleTitle: '助理' });
expect(a).toContain('晋芳辰'); expect(a).toContain('晋芳辰');
expect(a).not.toContain('李倩'); expect(a).not.toContain('李倩');
expect(b).toContain('李倩'); expect(b).toContain('李倩');
expect(b).not.toContain('晋芳辰'); expect(b).not.toContain('晋芳辰');
}); });
test('无登录身份 → 退回通用"客服";占位符不能漏给患者看到', () => { test('无登录身份 → 退回通用"助理";占位符不能漏给患者看到', () => {
const out = renderAgentIdentity(content); const out = renderAgentIdentity(content);
expect(out).toContain('我是本诊所的客服'); expect(out).toContain('我是薛医生的助理');
expect(out).not.toContain(AGENT_IDENTITY_PLACEHOLDER); expect(out).not.toContain(AGENT_IDENTITY_PLACEHOLDER);
}); });
test('多处出现全部替换', () => { test('多处出现全部替换', () => {
const twice = `${AGENT_IDENTITY_PLACEHOLDER}${AGENT_IDENTITY_PLACEHOLDER}`; const twice = `${AGENT_IDENTITY_PLACEHOLDER}${AGENT_IDENTITY_PLACEHOLDER}`;
expect(renderAgentIdentity(twice, { name: '李倩', roleTitle: '客服' })).toBe('客服李倩 … 客服李倩'); expect(renderAgentIdentity(twice, { name: '李倩', roleTitle: '助理' })).toBe('助理李倩 … 助理李倩');
}); });
test('不含占位符 / null 内容 → 原样返回', () => { test('不含占位符 / null 内容 → 原样返回', () => {
expect(renderAgentIdentity('没有占位符', { name: '李倩', roleTitle: '客服' })).toBe('没有占位符'); expect(renderAgentIdentity('没有占位符', { name: '李倩', roleTitle: '助理' })).toBe('没有占位符');
expect(renderAgentIdentity(null)).toBeNull(); expect(renderAgentIdentity(null)).toBeNull();
}); });
test('不碰客服手填的时间占位', () => { test('不碰客服手填的时间占位', () => {
const s = `李医生【时间段1】和【时间段2】有空;我是${AGENT_IDENTITY_PLACEHOLDER}`; const s = `李医生【时间段1】和【时间段2】有空;我是李医生的${AGENT_IDENTITY_PLACEHOLDER}`;
const out = renderAgentIdentity(s, { name: '李倩', roleTitle: '客服主管' }); const out = renderAgentIdentity(s, { name: '李倩', roleTitle: '助理' });
expect(out).toContain('【时间段1】'); expect(out).toContain('【时间段1】');
expect(out).toContain('【时间段2】'); expect(out).toContain('【时间段2】');
expect(out).toContain('我是客服主管李倩'); expect(out).toContain('我是李医生的助理李倩');
}); });
}); });
...@@ -378,18 +378,22 @@ describe('fact-block | buildRichFactBlock(朴素事实块,不出全名)', () => ...@@ -378,18 +378,22 @@ describe('fact-block | buildRichFactBlock(朴素事实块,不出全名)', () =>
test('开场事实:称呼 / 自报家门 / 医生去名 / 主诉', () => { test('开场事实:称呼 / 自报家门 / 医生去名 / 主诉', () => {
const block = buildRichFactBlock(makeInput()); const block = buildRichFactBlock(makeInput());
expect(block).toContain('称呼:张先生'); expect(block).toContain('称呼:张先生');
// ⭐ 自报家门给占位符,不给人名 —— 姓名在渲染期按登录客服回填(见 agent-identity.ts) // ⭐ 自报家门 = 「{诊断医生}医生的 + 占位符」,不给人名 —— 姓名在渲染期按登录人回填。
expect(block).toContain('我是测试口腔的【回访客服】'); // 身份是**医生的助理**不是客服(业务口径 2026-07-29,见 agent-identity.ts)
expect(block).toContain('我是韩医生的【回访客服】');
// 诊所名仍给,但只作"要提到诊所时用这个"的防编造锚,不再进自报家门
expect(block).toContain('测试口腔');
expect(block).not.toContain('我是测试口腔的');
expect(block).toContain('韩医生'); // 去名留姓 expect(block).toContain('韩医生'); // 去名留姓
expect(block).not.toContain('韩维'); // 全名不进 prompt expect(block).not.toContain('韩维'); // 全名不进 prompt
expect(block).not.toContain('张震校'); expect(block).not.toContain('张震校');
expect(block).toContain('牙疼来看'); expect(block).toContain('牙疼来看');
}); });
test('客服姓名不进 prompt(占位符是唯一形态)', () => { test('回访人姓名不进 prompt(占位符是唯一形态)', () => {
const block = buildRichFactBlock(makeInput()); const block = buildRichFactBlock(makeInput());
// 任何具体人名都不该出现在自报家门位置 —— 缓存 per-plan、召回池共享,烤人名会串号 // 任何具体人名都不该出现在自报家门位置 —— 缓存 per-plan、召回池共享,烤人名会串号
expect(block).not.toMatch(/我是测试口腔的客服(主管)?[一-龥]/); expect(block).not.toMatch(/我是韩医生的(助理|客服)(主管)?[一-龥]/);
}); });
test('daysSinceLastVisit=0 → 最近就诊显示为今年具体日期(X月X号)', () => { test('daysSinceLastVisit=0 → 最近就诊显示为今年具体日期(X月X号)', () => {
......
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