Commit 6bfe4dc8 by luoqi

fix(script): 人名"去名留称呼"取代 token + 开场白顺序

撤掉上一版的 {{}}/【】 token 方案(LLM 看不懂 + 跟 {xxx} 占位冲突),改成
**直接给"姓+敬称"的可用称呼**(去全名,非 token):
- 称呼 徐女士 / 诊断医生 韩医生(deidentifyDoctor:韩维→韩,模板拼"韩医生")/ 客服 小王
  → LLM 直接用、客服直接看;全名(徐雅静/韩维)不进 user prompt
- 撤 pii.ts token/回填机制,orchestrator 去 detokenizeScript;base-system 去 token 占位声明
- 开场白顺序修正(打电话场景):先 称呼+确认对方 → 再 自报家门 → 医生交代 → 问近况
  (原来自报家门在前,不符合电话礼仪)
- call.ts fallback 医生同口径去名;promptVersion v13
- 验证:张震校 重生成 →「徐女士您好…我是…客服…韩医生特意交代…」顺序+去名正确

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 430c9503
......@@ -8,6 +8,7 @@ import { composeSystem } from './skill-composer';
import { DraftPlanScriptSkillRegistry } from './skill-registry.service';
import { smartDateDisplay } from './script-facts';
import { resolveDisease } from './script-common/disease-knowledge';
import { deidentifyDoctor } from './script-common/pii';
/**
* Safety rules — 后置硬约束。
......@@ -81,7 +82,8 @@ function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
const salutation = patient.salutation; // 统一通话称呼(年龄+性别+监护人 aware,orchestrator 算好)
// 漏诊项 = PAC 应治未治 reason(取 priorityScore 最高的一条)→ 转换层归一
const topReason = [...(plan.reasons ?? [])].sort((a, b) => b.priorityScore - a.priorityScore)[0];
const doctor = topReason?.triggerDoctor ?? clinicalContext.primaryDoctorName ?? '您的主治医生';
// 去名:韩维 → 韩(下面拼"韩医生");跟 prompt 同口径
const doctor = deidentifyDoctor(topReason?.triggerDoctor ?? clinicalContext.primaryDoctorName ?? null);
// 日期优先取"那次诊断"的(项目相关),否则退回最近一次就诊
const dateBasis = topReason?.triggerDate
? new Date(topReason.triggerDate)
......@@ -119,7 +121,7 @@ function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
* 改 system/prompt 文本 → bump 字母;改 schema → bump 日期。
*/
const DRAFT_PLAN_SCRIPT_PROMPT_VERSION =
'draft_plan_script@2026-06-05-4module-v12'; // 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-05-4module-v13'; // 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
......
import type { DraftPlanScriptInput } from './input.types';
import { smartDateDisplay } from './script-facts';
import { resolveDisease } from './script-common/disease-knowledge';
import { NAME_TOKEN } from './script-common/pii';
import { deidentifyDoctor } from './script-common/pii';
/**
* Prompt 版本管理约定:
......@@ -38,9 +38,12 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
// 单一聚焦:只取 priorityScore 最高那条 reason;其他 reason 的内容**完全不进 prompt**(不泄漏其他项)。
const top = [...plan.reasons].sort((a, b) => b.priorityScore - a.priorityScore)[0];
// ⭐ PII 脱敏:人名(称呼/诊断医生/客服)在 user prompt 里用占位 token,生成后回填真名(见 pii.ts)。
// 真名只在 PAC 内部(ScriptContext/input_snapshot),不进发给第三方 LLM 的 payload
// ⭐ 去名留称呼:user prompt 给"姓+敬称"的**可用称呼**(徐女士/韩医生),去掉名(维/雅静全名不进)。
// 不是 token,LLM 直接用;客服也直接看这个称呼。真名(全名)在内部 ScriptContext,不进 prompt
const now = new Date();
const salutation = patient.salutation; // 姓+性别敬称 / 家长(orchestrator 算好,姓级)
// 诊断医生去名:韩维 → "韩"(模板会拼成"韩医生");无名 → "您的主治"(→"您的主治医生")
const doctorSurname = deidentifyDoctor(top?.triggerDoctor ?? clinicalContext.primaryDoctorName ?? null);
const lastVisitDate =
clinicalContext.daysSinceLastVisit != null
? new Date(now.getTime() - clinicalContext.daysSinceLastVisit * 86400_000)
......@@ -75,20 +78,20 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
? '老客户(之前在本诊所看过),语气可更熟络温和'
: '首诊/新客户,语气专业可信为主';
// 自报家门:有登录客服名 → "我是X诊所的{岗位}{客服token}";无名 → 通用"客服"(客服名也脱敏,回填)
// 自报家门:有登录客服名 → "我是X诊所的{岗位}{姓名}"(小王=简称,非全名,直接用);无名 → 通用"客服"
const selfIntro = input.agent?.name
? `我是${clinicName}${input.agent.roleTitle}${NAME_TOKEN.agent}`
? `我是${clinicName}${input.agent.roleTitle}${input.agent.name}`
: `我是${clinicName}的客服`;
// ⚠️ user prompt 只给"模板占位需要的值";用法说明全在 system 提示词,这里不重复、不放内部元数据。
return `# 本次回访患者信息(只能用以下事实,不要编造或推断额外信息)
## 开场用
- {智能称呼}:${NAME_TOKEN.callName}
- {智能称呼}:${salutation}
- {自报家门}:${selfIntro}
- {智能时间显示}:${dateDisplay}
- 那次主诉:${chiefComplaint ?? '无记录'}
- {诊断医生}:${NAME_TOKEN.doctor}${guardianHint ? `\n- 触达说明:${guardianHint}` : ''}
- {诊断医生}:${doctorSurname}医生${guardianHint ? `\n- 触达说明:${guardianHint}` : ''}
## 本次应治未治(只讲这一个)
- {应治未治项}:${disease.label}
......
/**
* pii — 发给第三方 LLM 的 **user prompt 脱敏 + 输出回填**
* pii — 发给第三方 LLM 的人名"去名留称呼"
*
* 纪律(为什么这么做):
* - ScriptContext / agent_invocations.input_snapshot 是 **PAC 内部**(组装、审计、replay),
* 保留真名没问题(受保留期管控)。
* - **user prompt 是真正离开 PAC、发给第三方 LLM(DeepSeek/Gemini)的 payload** → 人名脱敏:
* prompt 里用占位 token,LLM 写出带 token 的话术,**生成后用真名回填**(给客服看)。
* - LLM 写句子结构不需要真名("{{诊断医生}}医生特意交代…" → 回填 "韩维医生…")。
* 纪律:
* - **不暴露真名(全名)**:徐雅静 / 韩维 这种全名不进 user prompt。
* - **但要有可用称呼**:给"姓 + 敬称"的自然称呼(徐女士 / 韩医生),LLM 直接用、客服也直接看。
* —— 不用 token(LLM 看不懂 + 回填复杂),直接给去名后的称呼。
* - 真名(全名)只在 PAC 内部 ScriptContext(深度档/内部可取),不进 prompt。
*
* 落位:tier-agnostic(所有档发 LLM 前都该脱敏)→ 放 script-common。
* prompt 端 emit token;输出端 detokenizeNames(output, input) 回填(从 ScriptContext 真名)。
* token 派生与回填**同一处**(realNames),避免分叉
* 称呼怎么来:
* - 患者/监护人:orchestrator 的 callSalutation 已产"姓+性别敬称 / 家长"(姓级,非全名)。
* - 医生:本文件 deidentifyDoctor —— 取姓(去名),模板拼成"X医生"
*/
import type { ScriptContext, DraftPlanScriptOutput } from '../input.types';
/** user prompt 里的人名占位 —— 用「原样保留」约定 `【】`(同 【时间段1】,LLM 可靠不替换),
* 生成后回填真名。⚠️ 不能用 {{}}:会跟 base-system 的 {xxx}=要替换 占位冲突,被 LLM 吐成单括号。 */
export const NAME_TOKEN = {
callName: '【称呼】',
doctor: '【诊断医生】',
agent: '【客服】',
} as const;
/** 从 ScriptContext 派生各 token 对应的真名(回填用;prompt 端 emit token、此处给真值)。 */
export function realNames(input: ScriptContext): { callName: string; doctor: string; agent: string | null } {
const top = [...(input.plan.reasons ?? [])].sort((a, b) => b.priorityScore - a.priorityScore)[0];
return {
callName: input.patient.salutation, // 已是通话称呼(徐女士/张家长/张先生)
doctor: top?.triggerDoctor ?? input.clinicalContext.primaryDoctorName ?? '您的主治医生',
agent: input.agent?.name ?? null,
};
}
/** 把 LLM 输出里的人名 token 回填成真名(找不到 token 则原样;fallback 文本无 token → no-op) */
export function detokenizeNames(text: string, input: ScriptContext): string {
const r = realNames(input);
let out = text.split(NAME_TOKEN.callName).join(r.callName).split(NAME_TOKEN.doctor).join(r.doctor);
if (r.agent) out = out.split(NAME_TOKEN.agent).join(r.agent);
else out = out.split(NAME_TOKEN.agent).join('客服'); // 无登录客服名 → 通用"客服"
return out;
}
/** 4 段输出回填真名(partial-safe:只处理已生成的 string 字段,供流式 + 终态共用) */
export function detokenizeScript<T extends Partial<DraftPlanScriptOutput>>(out: T, input: ScriptContext): T {
const f = (s: unknown) => (typeof s === 'string' ? detokenizeNames(s, input) : s);
return {
...out,
opening: f(out.opening),
informMissed: f(out.informMissed),
reviewAdvice: f(out.reviewAdvice),
closing: f(out.closing),
} as T;
/**
* 医生去名 → 姓(模板会拼成"{姓}医生")。
* "韩维" → "韩"(→ 韩医生);空 → "您的主治"(→ 您的主治医生)。
* 注:复姓(欧阳…)只取首字,极少见,暂接受。
*/
export function deidentifyDoctor(name: string | null | undefined): string {
const n = (name ?? '').trim();
if (!n) return '您的主治';
// 已是泛指(如"您的主治医生")→ 去掉"医生"后缀避免重复(模板会补)
if (n.endsWith('医生')) return n.slice(0, -2);
return n.charAt(0);
}
......@@ -26,10 +26,8 @@
# 占位符约定(两种,含义不同)
- `{xxx}` = **要替换**的占位:用"本次回访患者信息"里给的同名值填进去(如 {智能称呼}{应治未治项}{诊断医生}{风险要点}{复查时长})。输出里不能再出现 `{}`
- `【xxx】` = **原样保留**的占位:照抄进话术、**绝不替换/改写/删除括号**。两类:
- 客服手填:【时间段1】【时间段2】【具体预约时间】【缺失牙位】
- 系统回填(人名脱敏占位,原样保留,系统稍后填真名):【称呼】【诊断医生】【客服】
- ⚠️ 凡 user 给的值里出现 【…】,一律连括号原样写进话术,不要去掉括号、不要自己编姓名。
- `【xxx】` = **原样保留**的占位:不要替换、照抄进话术,客服打电话时手动填。只有这几个:【时间段1】【时间段2】【具体预约时间】【缺失牙位】。
- 注:{智能称呼}/{诊断医生} 给的是"姓+敬称"(如 徐女士/韩医生),直接用即可,不是 `【】` 占位。
- 另:结束语的分支标签【预约成功】【预约不成功】也照常输出。
# 直接使用给定的事实
......
......@@ -12,9 +12,9 @@ version: 1.3.0
tone 默认 professional(专业稳重);熟客可 warm;急性场景可 urgent。
## ═══ 第一部分:开场白 ═══
[温馨有温度的开场,以医生名义]
-您好,{自报家门}(直接用给定的「自报家门」开头,不要自己改岗位 / 姓名,也不要编"回访专员")
- 称呼用{智能称呼}(成人 = "{姓}先生" / "{姓}女士";性别未知或无称呼信息 = "您好")
[温馨有温度的开场。**打电话顺序:先称呼+确认对方 → 再自报家门 → 医生交代 → 问近况**]
-{智能称呼}您好,现在方便接听吗?(先用 {智能称呼} 称呼并确认对方;成人={智能称呼}已是"姓+先生/女士",直接用,未知="您好")
- • {自报家门}(直接用给定的「自报家门」,不要自己改岗位/姓名,也不要编"回访专员")
- • {诊断医生}医生特意交代我来关注您的后续情况
- • (如果是熟悉患者可说:{诊断医生}医生上次还和我提起您呢)
- • 您自从{智能时间显示}检查后,口腔情况怎么样?
......
......@@ -14,9 +14,9 @@ version: 1.3.0
tone 默认 warm(温和家常,适合与家长沟通)。
## ═══ 第一部分:开场白 ═══
[温馨有温度的开场,以医生名义]
-您好,{自报家门}(直接用给定的「自报家门」开头,不要自己改岗位 / 姓名,也不要编"回访专员")
- 称呼用{智能称呼}(儿童 = "宝宝妈妈"或"{姓名}的家长",如"乐乐家长";无称呼信息 = "您好");可先确认家长接听
[温馨有温度的开场。**打电话顺序:先称呼+确认家长 → 再自报家门 → 医生交代 → 问近况**]
-{智能称呼}您好,现在方便说话吗?(先用 {智能称呼} 称呼并确认家长接听;{智能称呼}=如"徐女士"/"宝宝妈妈"/"家长",直接用,未知="您好")
- • {自报家门}(直接用给定的「自报家门」,不要自己改岗位/姓名,也不要编"回访专员")
- • {诊断医生}医生特意交代我来关注宝宝的后续情况
- • (如果是熟悉患者可说:{诊断医生}医生上次还和我提起宝宝呢)
- • 宝宝自从{智能时间显示}检查后,口腔情况怎么样?
......@@ -25,7 +25,7 @@ tone 默认 warm(温和家常,适合与家长沟通)。
[分成短句,便于互动沟通]
(适用于本次应治未治项 = 乳牙过早缺失 / 恒牙萌出空间不足 / 儿牙早矫)
小节1 - 现状描述(短句1):现在宝宝有一颗乳牙已经脱落了,但是恒牙还没有长出来
小节2 - 位置说明(短句2):这颗乳牙的位置在【缺失牙位】(保留【缺失牙位】占位,客服按 plan 里的牙位填左上/右上/左下/右下,不要自己编)
小节2 - 位置说明(短句2):这颗乳牙的位置在{缺失牙位}(保留{缺失牙位}占位,客服按 plan 里的牙位填左上/右上/左下/右下,不要自己编)
小节3 - 不治疗危害(短句3):如果咱们不做处理,这颗乳牙的位置和空间可能会丧失
小节4 - 后果说明(短句4):将来恒牙萌出就不会在它该在的位置
小节5 - 解决方案(短句5):所以我们要做一个装置来维持这个间隙,这个装置叫间隙保持器。到时候也请医生看一下。
......
......@@ -6,7 +6,6 @@ import { planScenarioLabel, personaFeatureMeta, subLabelZh, treatmentCategoryNam
import { PrismaService } from '../../../prisma/prisma.service';
import { AiCallRunnerService } from '../ai-call-runner.service';
import { DraftPlanScriptCall } from '../calls/draft-plan-script/call';
import { detokenizeScript } from '../calls/draft-plan-script/script-common/pii';
import type {
DraftPlanScriptInput,
DraftPlanScriptOutput,
......@@ -133,9 +132,8 @@ export class PlanScriptOrchestrator {
evalMode: 'production',
});
// ─── 3. 回填人名 token(LLM 输出脱敏的反向)→ 渲染 Markdown ───
const finalOutput = detokenizeScript(result.output, input);
const content = renderMarkdown(finalOutput, { patientNameMasked: input.patient.salutation });
// ─── 3. 渲染 Markdown(称呼/医生已是去名后的可用形式,无需回填)───
const content = renderMarkdown(result.output, { patientNameMasked: input.patient.salutation });
// ─── 4. 写 PlanScript(dryRun 时跳过) ───
let planScriptId: string | null = null;
......@@ -170,7 +168,7 @@ export class PlanScriptOrchestrator {
cacheHit: result.cacheHit,
costYuan: result.costYuan,
content,
structured: finalOutput,
structured: result.output,
};
}
......@@ -226,12 +224,10 @@ export class PlanScriptOrchestrator {
lastInvocationId = evt.invocationId;
yield { type: 'start', invocationId: evt.invocationId, modelId: evt.modelId, promptVersion: evt.promptVersion };
} else if (evt.type === 'partial') {
// 流式也回填人名 token(否则客服看到 {{诊断医生}} 闪现);partial-safe
const partial = detokenizeScript(evt.partial, input);
yield {
type: 'partial',
structured: partial,
sections: renderSections(partial, { patientNameMasked }),
structured: evt.partial,
sections: renderSections(evt.partial, { patientNameMasked }),
};
} else if (evt.type === 'done') {
lastStructured = evt.output;
......@@ -251,9 +247,8 @@ export class PlanScriptOrchestrator {
return;
}
// 回填人名 token → 写库(dryRun 跳过)
const finalStructured = detokenizeScript(lastStructured, input);
const content = renderMarkdown(finalStructured, { patientNameMasked });
// 写库(dryRun 跳过)。称呼/医生已是去名后的可用形式,无需回填
const content = renderMarkdown(lastStructured, { patientNameMasked });
let planScriptId: string | null = null;
if (!options.dryRun) {
const row = await this.prisma.planScript.upsert({
......@@ -283,9 +278,9 @@ export class PlanScriptOrchestrator {
planScriptId,
agentInvocationId: lastInvocationId ?? '',
source: lastSource,
structured: finalStructured,
structured: lastStructured,
content,
sections: renderSections(finalStructured, { patientNameMasked }),
sections: renderSections(lastStructured, { patientNameMasked }),
costYuan: lastCost,
promptTokens: lastPromptTokens,
completionTokens: lastCompletionTokens,
......
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