Commit 7ff7bc19 by luoqi

feat(script): user prompt 人名脱敏 + 输出回填(PII 不出 PAC)

发给第三方 LLM 的 user_prompt 不再带真名;ScriptContext/input_snapshot(PAC 内部)不变。
- 新增 script-common/pii.ts:NAME_TOKEN(称呼/诊断医生/客服)+ realNames + detokenizeNames/Script
  · token 用「原样保留」约定 【】(同 【时间段1】),不能用 {{}}(会跟 {xxx}=要替换 占位冲突,
    被 LLM 吐成单括号 → 露生 token)
- prompt.ts:称呼/诊断医生/客服 emit token;监护人触达提示去全名(只留关系)
- orchestrator:LLM 输出在渲染/写库前 detokenizeScript 回填真名(流式 partial + 终态都回填)
- base-system.md:声明 【称呼】【诊断医生】【客服】 为系统回填占位,LLM 原样保留
- 验证:张震校 重生成 → prompt 无真名;输出"徐女士/韩维医生"正确回填;promptVersion v12

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent d8f03249
...@@ -119,7 +119,7 @@ function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput { ...@@ -119,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-v11'; // v11: 统一通话称呼(年龄+性别+监护人,修"9岁张先生");监护人触达提示;医生标签 最后一次就诊→诊断医生;v10: 病种知识走 disease-knowledge 单一访问源(subKey 优先+文本兜底),修 颌骨囊肿 拿不到风险/优势的 bug;v9: 自报家门用登录客服 岗位+姓名(agent);v8: 占位符统一({}=替换、【】=原样保留);v7: 清除 user prompt 污染;v6: 清 system 污染;v5: 还原原模板 '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: 还原原模板
@Injectable() @Injectable()
export class DraftPlanScriptCall export class DraftPlanScriptCall
......
import type { DraftPlanScriptInput } from './input.types'; import type { DraftPlanScriptInput } from './input.types';
import { smartDateDisplay } from './script-facts'; import { smartDateDisplay } from './script-facts';
import { resolveDisease } from './script-common/disease-knowledge'; import { resolveDisease } from './script-common/disease-knowledge';
import { NAME_TOKEN } from './script-common/pii';
/** /**
* Prompt 版本管理约定: * Prompt 版本管理约定:
...@@ -37,10 +38,9 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string ...@@ -37,10 +38,9 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
// 单一聚焦:只取 priorityScore 最高那条 reason;其他 reason 的内容**完全不进 prompt**(不泄漏其他项)。 // 单一聚焦:只取 priorityScore 最高那条 reason;其他 reason 的内容**完全不进 prompt**(不泄漏其他项)。
const top = [...plan.reasons].sort((a, b) => b.priorityScore - a.priorityScore)[0]; const top = [...plan.reasons].sort((a, b) => b.priorityScore - a.priorityScore)[0];
// 程序算好的确定性事实(LLM 不做年龄分支/日期格式/优先级/查表/称呼) // ⭐ PII 脱敏:人名(称呼/诊断医生/客服)在 user prompt 里用占位 token,生成后回填真名(见 pii.ts)。
// 真名只在 PAC 内部(ScriptContext/input_snapshot),不进发给第三方 LLM 的 payload。
const now = new Date(); const now = new Date();
// 称呼 = orchestrator 算好的统一通话称呼(年龄+性别+监护人 aware):未成年→监护人/家长,成人→先生女士
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)
...@@ -52,7 +52,6 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string ...@@ -52,7 +52,6 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
// 病种知识(单一访问源:subKey 优先 + 文本兜底);稳健档把 risks/advantages 注入模板槽 // 病种知识(单一访问源:subKey 优先 + 文本兜底);稳健档把 risks/advantages 注入模板槽
const disease = resolveDisease(top ?? null, plan.primaryScenarioLabel); const disease = resolveDisease(top ?? null, plan.primaryScenarioLabel);
const reviewDuration = disease.reviewDuration; const reviewDuration = disease.reviewDuration;
const doctor = top?.triggerDoctor ?? clinicalContext.primaryDoctorName ?? '您的主治医生';
const riskLines = disease.risks.length const riskLines = disease.risks.length
? disease.risks.map((r) => ` - ${r}`).join('\n') ? disease.risks.map((r) => ` - ${r}`).join('\n')
...@@ -66,11 +65,9 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string ...@@ -66,11 +65,9 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
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 = [genderText, patient.age != null ? `${patient.age}岁` : ''].filter(Boolean).join(','); const basics = [genderText, patient.age != null ? `${patient.age}岁` : ''].filter(Boolean).join(',');
// 监护人提示(未成年:打给家长,患者是孩子) // 监护人提示(未成年:打给家长,患者是孩子)。⚠️ 不放监护人真名(脱敏;名字对生成无价值,称呼用 token)
const guardianHint = patient.guardian const guardianHint = patient.guardian
? `本次电话打给${patient.guardian.relationshipLabel}${ ? `本次电话打给${patient.guardian.relationshipLabel}(称呼见{智能称呼}),沟通对象是家长,患者是孩子,话术里称孩子为"宝宝"`
patient.guardian.name ? `(${patient.guardian.name})` : ''
},沟通对象是家长,患者是孩子,话术里称孩子为"宝宝"`
: null; : null;
// 语气线索(熟客 vs 新客 → tone 选择;不念出来) // 语气线索(熟客 vs 新客 → tone 选择;不念出来)
const toneHint = const toneHint =
...@@ -78,20 +75,20 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string ...@@ -78,20 +75,20 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
? '老客户(之前在本诊所看过),语气可更熟络温和' ? '老客户(之前在本诊所看过),语气可更熟络温和'
: '首诊/新客户,语气专业可信为主'; : '首诊/新客户,语气专业可信为主';
// 自报家门:有登录客服名 → "我是X诊所的{岗位}{姓名}";无名 → 通用"客服"(不编名字) // 自报家门:有登录客服名 → "我是X诊所的{岗位}{客服token}";无名 → 通用"客服"(客服名也脱敏,回填)
const selfIntro = input.agent?.name const selfIntro = input.agent?.name
? `我是${clinicName}${input.agent.roleTitle}${input.agent.name}` ? `我是${clinicName}${input.agent.roleTitle}${NAME_TOKEN.agent}`
: `我是${clinicName}的客服`; : `我是${clinicName}的客服`;
// ⚠️ user prompt 只给"模板占位需要的值";用法说明全在 system 提示词,这里不重复、不放内部元数据。 // ⚠️ user prompt 只给"模板占位需要的值";用法说明全在 system 提示词,这里不重复、不放内部元数据。
return `# 本次回访患者信息(只能用以下事实,不要编造或推断额外信息) return `# 本次回访患者信息(只能用以下事实,不要编造或推断额外信息)
## 开场用 ## 开场用
- {智能称呼}:${salutation} - {智能称呼}:${NAME_TOKEN.callName}
- {自报家门}:${selfIntro} - {自报家门}:${selfIntro}
- {智能时间显示}:${dateDisplay} - {智能时间显示}:${dateDisplay}
- 那次主诉:${chiefComplaint ?? '无记录'} - 那次主诉:${chiefComplaint ?? '无记录'}
- {诊断医生}:${doctor}${guardianHint ? `\n- 触达说明:${guardianHint}` : ''} - {诊断医生}:${NAME_TOKEN.doctor}${guardianHint ? `\n- 触达说明:${guardianHint}` : ''}
## 本次应治未治(只讲这一个) ## 本次应治未治(只讲这一个)
- {应治未治项}:${disease.label} - {应治未治项}:${disease.label}
......
/**
* pii — 发给第三方 LLM 的 **user prompt 脱敏 + 输出回填**。
*
* 纪律(为什么这么做):
* - ScriptContext / agent_invocations.input_snapshot 是 **PAC 内部**(组装、审计、replay),
* 保留真名没问题(受保留期管控)。
* - **user prompt 是真正离开 PAC、发给第三方 LLM(DeepSeek/Gemini)的 payload** → 人名脱敏:
* prompt 里用占位 token,LLM 写出带 token 的话术,**生成后用真名回填**(给客服看)。
* - LLM 写句子结构不需要真名("{{诊断医生}}医生特意交代…" → 回填 "韩维医生…")。
*
* 落位:tier-agnostic(所有档发 LLM 前都该脱敏)→ 放 script-common。
* prompt 端 emit token;输出端 detokenizeNames(output, input) 回填(从 ScriptContext 真名)。
* token 派生与回填**同一处**(realNames),避免分叉。
*/
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.nameMasked, // 已是通话称呼(徐女士/张家长/张先生)
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;
}
...@@ -26,7 +26,10 @@ ...@@ -26,7 +26,10 @@
# 占位符约定(两种,含义不同) # 占位符约定(两种,含义不同)
- `{xxx}` = **要替换**的占位:用"本次回访患者信息"里给的同名值填进去(如 {智能称呼}{应治未治项}{诊断医生}{风险要点}{复查时长})。输出里不能再出现 `{}` - `{xxx}` = **要替换**的占位:用"本次回访患者信息"里给的同名值填进去(如 {智能称呼}{应治未治项}{诊断医生}{风险要点}{复查时长})。输出里不能再出现 `{}`
- `【xxx】` = **原样保留**的占位:不要替换、照抄进话术,客服打电话时手动填。只有这几个:【时间段1】【时间段2】【具体预约时间】【缺失牙位】。 - `【xxx】` = **原样保留**的占位:照抄进话术、**绝不替换/改写/删除括号**。两类:
- 客服手填:【时间段1】【时间段2】【具体预约时间】【缺失牙位】
- 系统回填(人名脱敏占位,原样保留,系统稍后填真名):【称呼】【诊断医生】【客服】
- ⚠️ 凡 user 给的值里出现 【…】,一律连括号原样写进话术,不要去掉括号、不要自己编姓名。
- 另:结束语的分支标签【预约成功】【预约不成功】也照常输出。 - 另:结束语的分支标签【预约成功】【预约不成功】也照常输出。
# 直接使用给定的事实 # 直接使用给定的事实
......
...@@ -6,6 +6,7 @@ import { planScenarioLabel, personaFeatureMeta, subLabelZh, treatmentCategoryNam ...@@ -6,6 +6,7 @@ import { planScenarioLabel, personaFeatureMeta, subLabelZh, treatmentCategoryNam
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { AiCallRunnerService } from '../ai-call-runner.service'; import { AiCallRunnerService } from '../ai-call-runner.service';
import { DraftPlanScriptCall } from '../calls/draft-plan-script/call'; import { DraftPlanScriptCall } from '../calls/draft-plan-script/call';
import { detokenizeScript } from '../calls/draft-plan-script/script-common/pii';
import type { import type {
DraftPlanScriptInput, DraftPlanScriptInput,
DraftPlanScriptOutput, DraftPlanScriptOutput,
...@@ -132,8 +133,9 @@ export class PlanScriptOrchestrator { ...@@ -132,8 +133,9 @@ export class PlanScriptOrchestrator {
evalMode: 'production', evalMode: 'production',
}); });
// ─── 3. 渲染 Markdown ─── // ─── 3. 回填人名 token(LLM 输出脱敏的反向)→ 渲染 Markdown ───
const content = renderMarkdown(result.output, { patientNameMasked: input.patient.nameMasked }); const finalOutput = detokenizeScript(result.output, input);
const content = renderMarkdown(finalOutput, { patientNameMasked: input.patient.nameMasked });
// ─── 4. 写 PlanScript(dryRun 时跳过) ─── // ─── 4. 写 PlanScript(dryRun 时跳过) ───
let planScriptId: string | null = null; let planScriptId: string | null = null;
...@@ -168,7 +170,7 @@ export class PlanScriptOrchestrator { ...@@ -168,7 +170,7 @@ export class PlanScriptOrchestrator {
cacheHit: result.cacheHit, cacheHit: result.cacheHit,
costYuan: result.costYuan, costYuan: result.costYuan,
content, content,
structured: result.output, structured: finalOutput,
}; };
} }
...@@ -224,10 +226,12 @@ export class PlanScriptOrchestrator { ...@@ -224,10 +226,12 @@ export class PlanScriptOrchestrator {
lastInvocationId = evt.invocationId; lastInvocationId = evt.invocationId;
yield { type: 'start', invocationId: evt.invocationId, modelId: evt.modelId, promptVersion: evt.promptVersion }; yield { type: 'start', invocationId: evt.invocationId, modelId: evt.modelId, promptVersion: evt.promptVersion };
} else if (evt.type === 'partial') { } else if (evt.type === 'partial') {
// 流式也回填人名 token(否则客服看到 {{诊断医生}} 闪现);partial-safe
const partial = detokenizeScript(evt.partial, input);
yield { yield {
type: 'partial', type: 'partial',
structured: evt.partial, structured: partial,
sections: renderSections(evt.partial, { patientNameMasked }), sections: renderSections(partial, { patientNameMasked }),
}; };
} else if (evt.type === 'done') { } else if (evt.type === 'done') {
lastStructured = evt.output; lastStructured = evt.output;
...@@ -247,8 +251,9 @@ export class PlanScriptOrchestrator { ...@@ -247,8 +251,9 @@ export class PlanScriptOrchestrator {
return; return;
} }
// 写库(dryRun 跳过) // 回填人名 token → 写库(dryRun 跳过)
const content = renderMarkdown(lastStructured, { patientNameMasked }); const finalStructured = detokenizeScript(lastStructured, input);
const content = renderMarkdown(finalStructured, { patientNameMasked });
let planScriptId: string | null = null; let planScriptId: string | null = null;
if (!options.dryRun) { if (!options.dryRun) {
const row = await this.prisma.planScript.upsert({ const row = await this.prisma.planScript.upsert({
...@@ -278,9 +283,9 @@ export class PlanScriptOrchestrator { ...@@ -278,9 +283,9 @@ export class PlanScriptOrchestrator {
planScriptId, planScriptId,
agentInvocationId: lastInvocationId ?? '', agentInvocationId: lastInvocationId ?? '',
source: lastSource, source: lastSource,
structured: lastStructured, structured: finalStructured,
content, content,
sections: renderSections(lastStructured, { patientNameMasked }), sections: renderSections(finalStructured, { patientNameMasked }),
costYuan: lastCost, costYuan: lastCost,
promptTokens: lastPromptTokens, promptTokens: lastPromptTokens,
completionTokens: lastCompletionTokens, 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