Commit 4fe0d973 by luoqi

feat(script): 话术三档架构(稳健/标准/深度)+ 目录重组 + input_snapshot 结构化

目录重组:shared/(脊柱周边 + 病种名/安全单一源 + 厚事实块)+ tiers/{stable,standard,deep}/。
脊柱(AiCallRunner)恒定,策略可插(每档一套 prompt/schema/format,深度为 3 步 pipeline)。

- input_snapshot 全字段结构化:reasons[].toothPositions(FDI)/medicalRecord(SOAP 全字段,per-reason);
  recentTreatments/pendingTreatments/lastVisit 对象化;personaHighlights/guardian 补原始 key;
  聚焦单一源 reasons[0](orchestrator 单一排序,prompt/fallback 不再各自 sort)
- 标准档:去模板 4 段自由编排(标题由 LLM 起)+ 厚输入(病历/其他reason/近期治疗),病种只给名
- 深度档:3 步(规划→写多段→独立对抗校验)+ repair(≤1)+ 兜底;多段不定输出(ScriptSectionDto.id→string)
- user prompt 补 {牙位}/本次目标(plan.goal)/医生医嘱·建议·治疗计划;≤18 禁拍片 belt
- 儿童复查段修:对齐本次病种 + 用{复查时长},删写死"3个月常规涂氟检查"
- 安全单一源 safety-rules.ts(禁词/承诺/加粗时间 + machineSafetyScan);composer tier-aware
- runner generateObject 解析失败重试一次再兜底(flash 多段 JSON 偶发脆)
- controller +tier query;实时教练后端上下文改读结构化字段

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 6bfe4dc8
...@@ -24,6 +24,7 @@ interface CliArgs { ...@@ -24,6 +24,7 @@ interface CliArgs {
bustCache: boolean; bustCache: boolean;
dryRun: boolean; dryRun: boolean;
model?: string; model?: string;
tier?: 'stable' | 'standard' | 'deep';
help: boolean; help: boolean;
} }
...@@ -44,6 +45,7 @@ function parseArgs(argv: string[]): CliArgs { ...@@ -44,6 +45,7 @@ function parseArgs(argv: string[]): CliArgs {
else if (a.startsWith('--host=')) out.host = a.slice('--host='.length); else if (a.startsWith('--host=')) out.host = a.slice('--host='.length);
else if (a.startsWith('--tenant=')) out.tenant = a.slice('--tenant='.length); else if (a.startsWith('--tenant=')) out.tenant = a.slice('--tenant='.length);
else if (a.startsWith('--model=')) out.model = a.slice('--model='.length); else if (a.startsWith('--model=')) out.model = a.slice('--model='.length);
else if (a.startsWith('--tier=')) out.tier = a.slice('--tier='.length) as CliArgs['tier'];
} }
return out; return out;
} }
...@@ -127,6 +129,7 @@ async function bootstrap() { ...@@ -127,6 +129,7 @@ async function bootstrap() {
bustCache: args.bustCache, bustCache: args.bustCache,
dryRun: args.dryRun, dryRun: args.dryRun,
modelIdOverride: args.model, modelIdOverride: args.model,
tier: args.tier,
}); });
const elapsed = Date.now() - t0; const elapsed = Date.now() - t0;
......
...@@ -101,13 +101,27 @@ export class AiCallRunnerService { ...@@ -101,13 +101,27 @@ export class AiCallRunnerService {
// 类型说明:AI SDK v6 的 generateObject 是判别联合类型(基于 output mode), // 类型说明:AI SDK v6 的 generateObject 是判别联合类型(基于 output mode),
// TOutput 泛型在 runner 这层无法窄化,用 any 收口调用,runtime 由 Zod schema 校验。 // TOutput 泛型在 runner 这层无法窄化,用 any 收口调用,runtime 由 Zod schema 校验。
// 出参立刻 cast 回 TOutput,边界只在这一行。 // 出参立刻 cast 回 TOutput,边界只在这一行。
// ⚠️ JSON 解析失败重试:flash 等模型偶发吐不出合法 JSON(NoObjectGeneratedError),
// 温度采样下重试一次常能过;首次失败就兜底太急(深度档多段 schema 尤其明显)。
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await generateObject<any>({ let result: Awaited<ReturnType<typeof generateObject<any>>> | undefined;
model, const MAX_ATTEMPTS = 2;
schema: call.outputSchema, let lastErr: unknown;
system, for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
prompt, try {
}); // eslint-disable-next-line @typescript-eslint/no-explicit-any
result = await generateObject<any>({ model, schema: call.outputSchema, system, prompt });
break;
} catch (e) {
lastErr = e;
if (attempt < MAX_ATTEMPTS - 1) {
this.logger.warn(
`call=${call.callKey} generateObject 第 ${attempt + 1} 次失败,重试: ${(e as Error).message}`,
);
}
}
}
if (!result) throw lastErr;
const output = result.object as TOutput; const output = result.object as TOutput;
......
...@@ -4,8 +4,11 @@ import { InvocationRecorderService } from './core/invocation-recorder.service'; ...@@ -4,8 +4,11 @@ import { InvocationRecorderService } from './core/invocation-recorder.service';
import { PromptCacheService } from './core/prompt-cache.service'; import { PromptCacheService } from './core/prompt-cache.service';
import { SafetyGateService } from './core/safety-gate.service'; import { SafetyGateService } from './core/safety-gate.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/tiers/stable/stable.call';
import { DraftPlanScriptSkillRegistry } from './calls/draft-plan-script/skill-registry.service'; import { StandardScriptCall } from './calls/draft-plan-script/tiers/standard/standard.call';
import { DeepPlanCall, DeepWriteCall, DeepVerifyCall } from './calls/draft-plan-script/tiers/deep/calls';
import { DeepScriptStrategy } from './calls/draft-plan-script/tiers/deep/deep.strategy';
import { DraftPlanScriptSkillRegistry } from './calls/draft-plan-script/shared/skill-registry.service';
import { DraftPlanSummaryCall } from './calls/draft-plan-summary/call'; import { DraftPlanSummaryCall } from './calls/draft-plan-summary/call';
import { PlanScriptOrchestrator } from './orchestrators/plan-script.orchestrator'; import { PlanScriptOrchestrator } from './orchestrators/plan-script.orchestrator';
import { PlanSummaryOrchestrator } from './orchestrators/plan-summary.orchestrator'; import { PlanSummaryOrchestrator } from './orchestrators/plan-summary.orchestrator';
...@@ -37,9 +40,14 @@ import { PlanModule } from '../plan/plan.module'; ...@@ -37,9 +40,14 @@ import { PlanModule } from '../plan/plan.module';
SafetyGateService, SafetyGateService,
// harness // harness
AiCallRunnerService, AiCallRunnerService,
// AI calls // AI calls — 话术三档(投入档:stable / standard / deep),共用 runner/input/safety/fallback
DraftPlanScriptCall, DraftPlanScriptCall, // 稳健档
DraftPlanScriptSkillRegistry, // scan & cache draft-plan-script/skills/**​/SKILL.md StandardScriptCall, // 标准档
DeepPlanCall, // 深度档 步骤1 规划
DeepWriteCall, // 深度档 步骤2 写(多段)
DeepVerifyCall, // 深度档 步骤3 独立对抗校验
DeepScriptStrategy, // 深度档 3 步编排(plan→write→verify→repair→兜底)
DraftPlanScriptSkillRegistry, // scan & cache draft-plan-script/**​/skills/**​/SKILL.md
DraftPlanSummaryCall, DraftPlanSummaryCall,
// orchestrators // orchestrators
PlanScriptOrchestrator, PlanScriptOrchestrator,
......
import { Injectable, Logger } from '@nestjs/common';
import type { AiCall } from '../../ai-call.interface';
import type { SafetyRule } from '../../core/safety-gate.service';
import { DraftPlanScriptSchema } from './schema';
import type { DraftPlanScriptInput, DraftPlanScriptOutput } from './input.types';
import { buildDraftPlanScriptPrompt } from './prompt';
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 — 后置硬约束。
* LLM 偶尔会越过 schema 约束塞禁词,这里再补一道(防御性)。
* B 方案重写后,output 4 段都是 markdown 字符串,直接全文 join 扫禁词。
*/
// ⚠️ 单字符禁词务必避免(会误伤合法词):
// '亲' → 误命中 亲切 / 亲自 / 亲人 / 母亲 / 父亲 等
// '宝' → 误命中 宝贝 / 宝藏 / 宝石 等
// 只保留"销售化"的明确组合形式
const FORBIDDEN_PHRASES = [
'一定能', '保证', '绝对', '百分百', '100%',
'亲爱的', // 淘宝式称呼,误伤面比单字符 '亲' 小很多
'便宜', '促销', '折扣', '免费送',
];
const safetyRules: ReadonlyArray<SafetyRule<DraftPlanScriptOutput>> = [
{
name: 'no_forbidden_phrases',
severity: 'block',
check(output) {
const fullText = [output.opening, output.informMissed, output.reviewAdvice, output.closing].join('\n');
const hit = FORBIDDEN_PHRASES.filter((p) => fullText.includes(p));
return {
pass: hit.length === 0,
message: hit.length > 0 ? `命中禁词: ${hit.join(',')}` : undefined,
};
},
},
{
name: 'no_commit_phrasing',
severity: 'block',
check(output) {
// 结束/复查段禁止"我已为您约好 X" 这种确定承诺(PAC 无排班 API,时间用【占位】不写死)
const COMMIT_PHRASES = [
'已为您约好', '已成功预约', '已为您预约', '已经为您约', '已替您预约',
'约定本', '敲定本', '安排好了', '已经预约',
];
const text = [output.reviewAdvice, output.closing].join('\n');
const hit = COMMIT_PHRASES.filter((p) => text.includes(p));
return {
pass: hit.length === 0,
message: hit.length > 0 ? `承诺式表述(无排班 API,不能定): ${hit.join(',')}` : undefined,
};
},
},
{
name: 'no_bold_concrete_time',
severity: 'block',
check(output) {
// 禁止 **周X上午X点** 加粗具体时间(误导"已定");新结构应保留【时间段1】【具体预约时间】占位
const boldTimeRegex = /\*\*[^*\n]*(?:[一二三四五六日天]|\d+\s*(?:点|:|:))[^*\n]*\*\*/;
const text = [output.reviewAdvice, output.closing].join('\n');
const m = text.match(boldTimeRegex);
return { pass: !m, message: m ? `加粗了具体时间"${m[0]}" — 应保留【时间段】占位` : undefined };
},
},
// 注:≤18 岁禁拍片 由 prompt/base-system 约束(SafetyContext 不带 age,无法在此判定)
];
/**
* 降级 fallback —— LLM 失败 / safety 拒收时用。
* 用 input 直接拼一份 4 段 markdown 模板话术,保证客服一定有东西可用。
*
* ⚠️ fallback 文本本身也要过 safety rule(close_no_bold_time / close_has_tentative_phrasing)。
* 历史踩坑:close 段写 `**本周六上午 10 点**` 加粗时间,自己触发 close_no_bold_time block。
* 已改:不加粗 + (示例) 后缀 + 显式"以诊所排班为准"。
*/
function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
const { patient, clinicName, plan, clinicalContext } = input;
const salutation = patient.salutation; // 统一通话称呼(年龄+性别+监护人 aware,orchestrator 算好)
// 漏诊项 = PAC 应治未治 reason(取 priorityScore 最高的一条)→ 转换层归一
const topReason = [...(plan.reasons ?? [])].sort((a, b) => b.priorityScore - a.priorityScore)[0];
// 去名:韩维 → 韩(下面拼"韩医生");跟 prompt 同口径
const doctor = deidentifyDoctor(topReason?.triggerDoctor ?? clinicalContext.primaryDoctorName ?? null);
// 日期优先取"那次诊断"的(项目相关),否则退回最近一次就诊
const dateBasis = topReason?.triggerDate
? new Date(topReason.triggerDate)
: clinicalContext.daysSinceLastVisit != null
? new Date(Date.now() - clinicalContext.daysSinceLastVisit * 86400_000)
: null;
const dateDisplay = smartDateDisplay(dateBasis, new Date()) ?? '上次';
const disease = resolveDisease(topReason ?? null, plan.primaryScenarioLabel);
const risk = disease.risks[0] ?? '这个问题如果一直拖着,后面处理可能更复杂';
const adv = disease.advantages[0] ?? '趁现在早点处理会更省心';
const reviewDuration = disease.reviewDuration;
return {
tone: 'warm',
opening: `• ${salutation}您好,我是${clinicName}的客服
${doctor}医生特意交代我来关注您的后续情况
• 自从${dateDisplay}检查后,您口腔情况怎么样?`,
informMissed: `• 上次检查的时候,${doctor}医生注意到您有${disease.label}的情况
${risk}
${adv}
• 这个${doctor}医生也特别嘱咐我们提醒您一下`,
reviewAdvice: `• 最近方便的话,来院复查一下
• 让${doctor}医生帮您再仔细看看
${reviewDuration}
${doctor}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?`,
closing: `【预约成功】
• 好的,那我们【具体预约时间】见,祝您生活愉快
【预约不成功】
• 没关系,我下周再联系您,祝您生活愉快`,
};
}
/**
* promptVersion(base 版本;具体装配的 skill 组合见 agent_invocations.input_snapshot.skills_used 的 composeHash)。
* 改 system/prompt 文本 → bump 字母;改 schema → bump 日期。
*/
const DRAFT_PLAN_SCRIPT_PROMPT_VERSION =
'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
implements AiCall<DraftPlanScriptInput, DraftPlanScriptOutput>
{
private readonly logger = new Logger(DraftPlanScriptCall.name);
readonly kind = 'script' as const;
readonly callKey = 'draft_plan_script';
readonly promptVersion = DRAFT_PLAN_SCRIPT_PROMPT_VERSION;
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DraftPlanScriptSchema;
readonly safetyRules = safetyRules;
constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {}
buildPrompt(input: DraftPlanScriptInput) {
// composer 装配 system(base-system.md + 命中的 skill 正文);user prompt 按患者拼事实
const composed = composeSystem(input, this.skillRegistry.getAllSkills());
if (composed.matchedSkills.length === 0) {
this.logger.warn(
`compose 0 个 skill 命中(scenario=${composed.context.scenario}, ` +
`dx=${composed.context.diagnosisCodes.join(',')}, ` +
`pop=${composed.context.population}, rel=${composed.context.relationship}) — ` +
`system 回退仅 base 部分,可能效果下降`,
);
} else {
this.logger.debug(
`compose skills: ${composed.matchedSkills.map((s) => s.frontmatter.name).join(', ')} ` +
`(hash=${composed.composeHash})`,
);
}
return {
system: composed.systemPrompt,
prompt: buildDraftPlanScriptPrompt(input),
};
}
fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
return fallback(input);
}
}
/**
* disease-knowledge — 病种知识的**单一访问源**(tier-agnostic,按 subKey keyed)。
*
* 背景(收口):原先病种内容散在 3 处、靠中文 key 双跳访问,易漂:
* subKey ──SUBKEY_TO_MISSED──▶ 中文key ──┬─ MISSED_DIAGNOSIS_KEY_POINTS[exact]
* ├─ TREATMENT_DURATION[exact]
* └─ canonicalMissedKey[includes]
* 两套字典对同一病种用了**不同中文 key**(如 jaw_cyst:keypoints 用「囊肿」、duration 用「颌骨囊肿」),
* exact 查表时静默丢内容(jaw_cyst 拿不到 risks/advantages = 原 bug)。
*
* 本文件把"subKey → 内容"的解析**收成一处**:一个 SUBKEY_MAP 显式声明每个病种在两套字典里的
* 中文 key(消除不一致),对外只暴露 `diseaseKnowledgeForSubKey(subKey)` 一个访问器。
*
* 多档共用(铺路):
* - 稳健档:程序挑 risks 1-2 条 → 填 4 段模板槽(现状)。
* - 标准/深度档:把整段 risks/advantages 作为"规则事实"给 LLM,自由组织(后续接入)。
*
* 注:内容(MISSED_DIAGNOSIS_KEY_POINTS / TREATMENT_DURATION)暂仍存 script-facts.ts;
* 本文件只统一**访问**。内容后续可整体迁入 script-common/(见 ai-script-generation.md §八ter)。
*/
import {
MISSED_DIAGNOSIS_KEY_POINTS,
TREATMENT_DURATION,
missedFromReason,
lookupKeyPoints,
lookupReviewDuration,
type MissedKeyPoints,
} from '../script-facts';
export interface DiseaseKnowledge {
/** 患者口径病种名(话术里说的,如"缺失牙""错颌畸形(牙齿不齐)") */
label: string;
/** 不处理的风险(口语,稳健挑 1-2 / 标准深度全给) */
risks: string[];
/** 趁早处理的好处 */
advantages: string[];
/** 复查说明文案 */
reviewDuration: string;
/** 年龄适应性(可选,按年龄组取一句) */
ageFit?: MissedKeyPoints['ageFit'];
}
/**
* subKey → 内容定位(单一源)。
* `kp` = MISSED_DIAGNOSIS_KEY_POINTS 里的中文 key;`dur` = TREATMENT_DURATION 里的中文 key。
* 两者**刻意分列**:同病种两套字典用的中文 key 可能不同(如 jaw_cyst),在此对齐,杜绝静默丢内容。
*/
const SUBKEY_MAP: Record<string, { label: string; kp: string; dur: string }> = {
missing_tooth: { label: '缺失牙', kp: '缺失牙', dur: '缺失牙' },
caries_no_filling: { label: '龋齿', kp: '龋齿', dur: '龋齿' },
endo_no_rct: { label: '牙髓/根尖周炎', kp: '根尖周炎', dur: '根尖周炎' },
perio_no_srp: { label: '牙周炎', kp: '牙周炎', dur: '牙周炎' },
ortho_no_consult: { label: '错颌畸形(牙齿不齐)', kp: '错颌畸形', dur: '错颌畸形' },
hard_tissue_damage: { label: '牙体缺损', kp: '牙体损伤', dur: '牙体损伤' },
gum_alveolar_lesion: { label: '牙龈/牙槽问题', kp: '牙龈问题', dur: '牙龈问题' },
impacted_tooth: { label: '阻生牙', kp: '阻生牙', dur: '阻生牙' },
// ⭐ 修复:keypoints 字典里是「囊肿」,duration 字典里是「颌骨囊肿」—— 分列对齐,不再静默丢 risks
jaw_cyst: { label: '颌骨囊肿', kp: '囊肿', dur: '颌骨囊肿' },
development_eruption: { label: '牙齿萌出异常', kp: '恒牙萌出空间不足', dur: '恒牙萌出空间不足' },
};
/** subKey(可带 @tooth 后缀)→ 病种知识;未映射 → null(调用方用 label 文本兜底) */
export function diseaseKnowledgeForSubKey(
subKey: string | null | undefined,
): DiseaseKnowledge | null {
const base = (subKey ?? '').split('@')[0]!.trim();
const m = base ? SUBKEY_MAP[base] : undefined;
if (!m) return null;
const kp = MISSED_DIAGNOSIS_KEY_POINTS[m.kp];
return {
label: m.label,
risks: kp?.risks ?? [],
advantages: kp?.advantages ?? [],
reviewDuration: TREATMENT_DURATION[m.dur] ?? TREATMENT_DURATION['其他']!,
ageFit: kp?.ageFit,
};
}
/**
* reason → 病种知识(**话术 prompt + fallback 共用的唯一入口**)。
* subKey 优先(精确);未映射 → 文本归一兜底(老路径,处理无 subKey 的边界)。
* 后续标准/深度档也调此函数拿同一份病种知识(只是消费方式不同)。
*/
export function resolveDisease(
reason:
| { subKey?: string | null; dxCode?: string | null; reason?: string | null; scenarioLabel?: string | null }
| null
| undefined,
fallbackLabel: string,
): DiseaseKnowledge {
const dk = reason ? diseaseKnowledgeForSubKey(reason.subKey) : null;
if (dk) return dk;
// 兜底:无 subKey / 未映射 → 文本归一(canonicalMissedKey)
const m = reason ? missedFromReason(reason) : { label: fallbackLabel, key: null };
const kp = lookupKeyPoints(m.key);
return {
label: m.label || fallbackLabel,
risks: kp?.risks ?? [],
advantages: kp?.advantages ?? [],
reviewDuration: lookupReviewDuration(m.key),
ageFit: kp?.ageFit,
};
}
/**
* script-facts — 把"参考话术"提示词里**本该程序判断的确定性逻辑**提取成纯函数 + 字典。
*
* 设计原则(渐进式组合 + 提示词缓存):
* - LLM 不再做:年龄分支 / 智能日期格式 / 漏诊项优先级挑选 / 查 key-points 表 / 查复查时长 / 智能称呼。
* 这些全在这里算好,作为"已定事实"塞进 user prompt,LLM 只负责把事实润色成话术。
* - 静态铁律/模板 → system(base-system.md + population skill,前缀缓存);
* 本文件算出的"单个患者相关"事实 → user(动态,渐进式只给相关的那一条,不发全表)。
*
* 真理源:文案要点来自业务给的"漏诊项目关键要点配置 / 复查时长配置 / 年龄组配置"。
*/
// ─────────────────────────────────────────────────────────
// 年龄分支(儿童 ≤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 不该跑日期逻辑)
// ─────────────────────────────────────────────────────────
export function smartDateDisplay(visit: Date | string | null | undefined, now: Date): string | null {
if (!visit) return null;
const d = visit instanceof Date ? visit : new Date(visit);
if (Number.isNaN(d.getTime())) return null;
const m = d.getMonth() + 1;
if (d.getFullYear() === now.getFullYear()) return `${m}${d.getDate()}号`;
if (d.getFullYear() === now.getFullYear() - 1) return `去年${m}月`;
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}`;
}
// ─────────────────────────────────────────────────────────
// 漏诊项关键要点配置(成人模板"告知漏诊项目"小节2/3 灵活组合用)
// 渐进式披露:user prompt 只塞**命中那一个病种**的要点,不发全表。
// ─────────────────────────────────────────────────────────
export interface MissedKeyPoints {
/** 风险要点(口语化,2-4 条灵活组合) */
risks: string[];
/** 治疗优势要点(趁现在/早处理) */
advantages: string[];
/** 年龄适应性(可选,按 resolveAgeGroup 取一句) */
ageFit?: Partial<Record<'青年' | '中年' | '老年', string>>;
}
export const MISSED_DIAGNOSIS_KEY_POINTS: Record<string, MissedKeyPoints> = {
牙槽骨吸收: {
risks: [
'时间久了,牙齿容易松动,吃东西会不太舒服',
'牙缝可能慢慢变大,食物容易卡进去',
'如果放着不管,牙齿还有脱落的风险',
'可能需要拔牙再种,医生说这样治疗周期会更长',
'后面如果骨头吸收严重了,想要做修复就比较难了',
],
advantages: ['趁现在早处理,能稳住牙槽骨', '早一点介入,对牙齿稳定有帮助,也避免将来多花功夫'],
ageFit: { 青年: '年轻时治疗效果更好', 中年: '正是关键时期,及时治疗很重要', 老年: '虽然年龄大了,但治疗仍有意义' },
},
缺失牙: {
risks: [
'缺牙的地方如果不处理,旁边的牙齿可能会慢慢歪掉',
'上面的牙齿还可能伸长出来,位置就不对了',
'时间一长,吃东西也会不太舒服,容易咬不到位',
'食物卡得多了,还容易蛀牙、牙周炎',
'不修复的话骨头会萎缩,后面再想种牙就困难一些',
],
advantages: ['及时修复能恢复咀嚼、保护邻牙', '趁现在牙槽骨条件还不错,早点处理效果更好'],
ageFit: { 青年: '年轻时修复适应性强', 中年: '正值事业期,形象很重要', 老年: '晚年生活质量需要保障' },
},
残根: {
risks: ['长时间不处理可能引发反复炎症', '容易形成慢性牙龈肿痛', '可能影响邻牙健康', '后期拔除难度会增加'],
advantages: ['越早处理越简单', '避免引发急性疼痛', '减少对邻牙的影响'],
},
残冠: {
risks: ['容易堆积食物导致反复牙龈炎', '可能出现牙齿折裂扩大损伤', '影响咬合舒适度'],
advantages: ['及时处理避免二次损伤', '利于后续修复效果', '咬合功能更稳定'],
},
囊肿: {
risks: ['如果持续增大可能压迫周围组织', '容易导致骨吸收', '可能影响邻牙位置', '拖久了处理范围会更大'],
advantages: ['及早关注避免扩大范围', '早期处理恢复更快', '减少对周围骨组织的破坏'],
},
根尖周炎: {
risks: ['容易导致持续性咬牙痛', '可能形成小脓包或脸肿', '长期不处理会影响其他牙', '感染可能逐渐加重'],
advantages: ['越早处理越容易控制炎症', '避免发展成急性疼痛', '能更好地保住这颗牙'],
},
乳牙滞留: {
risks: ['可能影响恒牙正常萌出', '易导致牙列不齐', '恒牙可能被挤偏'],
advantages: ['及时处理更有利于恒牙发育', '减少以后矫正难度', '让牙列更整齐'],
},
楔状缺损: {
risks: ['越磨越深可能导致牙髓敏感', '长期易出现冷热敏感', '咬合压力不均会加重缺损'],
advantages: ['越早处理越容易控制敏感', '可以保护牙体结构', '改善咬合舒适度'],
},
龋齿: {
risks: ['如果放任不管会越变越深', '可能引发牙痛或感染', '可能需要更复杂的治疗'],
advantages: ['早处理范围小、恢复快', '能避免发展成根管问题', '保持牙齿长期稳定'],
},
阻生牙: {
risks: ['可能反复发炎肿痛', '挤压邻牙导致牙列不齐', '可能形成囊肿或引发感染'],
advantages: ['早观察早处理更安全', '减少发炎频率', '避免推挤邻牙'],
},
埋伏牙: {
risks: ['可能影响周围牙根结构', '容易引发局部炎症', '可能挤压造成牙列拥挤'],
advantages: ['早期监测干预更安全', '减少后期并发症', '利于牙列整体稳定'],
},
多生牙: {
risks: ['可能阻挡恒牙正常萌出', '会影响邻牙位置', '可能导致牙列拥挤'],
advantages: ['越早处理越不影响恒牙', '减少后期矫正难度', '让牙列发育更顺畅'],
},
// ── 补齐 PAC 子场景(应治未治)对应的口径 ──
牙周炎: {
risks: [
'牙龈容易出血、红肿,刷牙时尤其明显',
'时间久了牙槽骨吸收,牙齿会慢慢松动',
'牙缝可能变大,食物容易塞牙',
'不控制的话,后面可能要拔牙',
],
advantages: ['趁现在做基础治疗,能把炎症控制住', '早点干预,牙齿能保留得更久'],
ageFit: { 青年: '年轻时牙周恢复能力强', 中年: '正是该好好维护的时候', 老年: '维护好牙周,晚年吃东西更舒服' },
},
错颌畸形: {
risks: [
'牙齿排列不齐,刷牙容易刷不干净,易蛀牙、牙龈发炎',
'咬合不好,长期可能影响咀嚼和颞下颌关节',
'也会影响笑容和自信',
],
advantages: ['早点评估,矫治方案选择更多', '趁现在牙周条件好,矫正更稳'],
ageFit: { 青年: '年轻时矫正配合度和效果都更好', 中年: '成人也能做隐形矫正,不影响工作' },
},
牙体损伤: {
risks: ['缺损放着不管会越来越大', '可能出现冷热敏感或牙痛', '严重了可能伤到牙神经,要做根管'],
advantages: ['早修复范围小、花费少', '能保护剩余牙体,避免折裂'],
},
牙龈问题: {
risks: ['牙龈反复红肿出血', '可能慢慢退缩,牙根暴露敏感', '不处理会影响牙齿稳固'],
advantages: ['早处理容易控制', '保护牙龈和牙槽骨健康'],
},
恒牙萌出空间不足: {
risks: ['恒牙可能没地方长,容易长歪或拥挤', '将来矫正难度会更大'],
advantages: ['趁换牙期早干预,引导恒牙顺利萌出', '减少以后正畸的复杂程度'],
},
儿牙早矫: {
risks: ['不良习惯或颌骨发育问题越拖越难纠正', '可能影响恒牙排列和脸型发育'],
advantages: ['替牙期是早矫黄金期,效果好、周期短', '趁现在引导,省去将来复杂矫正'],
},
};
// ─────────────────────────────────────────────────────────
// 复查时长配置(非治疗时长)— "复查建议·检查说明"小节用
// ─────────────────────────────────────────────────────────
export const TREATMENT_DURATION: Record<string, string> = {
儿牙早矫: '复查检查约30-45分钟,医生会详细评估宝宝的恒牙萌出情况和间隙保持需求',
恒牙萌出空间不足: '复查检查约30分钟,医生会详细评估恒牙萌出情况',
缺失牙: '复查检查约30分钟,了解缺失牙位目前状况',
牙槽骨吸收: '复查检查约30-45分钟,需要仔细检查牙周健康状况',
牙周病: '复查检查约30-45分钟,评估牙周健康情况',
牙周炎: '复查检查约30-45分钟,评估牙周健康情况',
错颌畸形: '复查检查约30分钟,医生评估咬合与矫治方案',
牙体损伤: '复查检查约20-30分钟,查看牙体缺损情况',
根尖周炎: '复查检查约20-30分钟,查看牙髓和根尖情况',
牙龈问题: '复查检查约20-30分钟,评估牙龈健康',
阻生牙: '复查检查约20-30分钟,评估阻生牙位置',
颌骨囊肿: '复查检查约30分钟,评估囊肿范围',
龋齿: '复查检查约20-30分钟,查看牙齿状况',
其他: '复查检查约30分钟',
};
// ─────────────────────────────────────────────────────────
// ⭐ 转换层:PAC 召回 reason(应治未治)→ 漏诊项口径
// "漏诊项" 在 PAC = treatment_initiation_recall 的 reason(K00-K09 应治未治)。
// reason.subKey(子场景)→ 配置 key(查 key-points/复查时长)+ 患者口径 label。
// PAC 已按 priorityScore 给 reason 排序 → 主漏诊项 = 排第一的 reason(不另用业务硬优先级)。
// ─────────────────────────────────────────────────────────
const SUBKEY_TO_MISSED: Record<string, { key: string; label: string }> = {
missing_tooth: { key: '缺失牙', label: '缺失牙' },
caries_no_filling: { key: '龋齿', label: '龋齿' },
endo_no_rct: { key: '根尖周炎', label: '牙髓/根尖周炎' },
perio_no_srp: { key: '牙周炎', label: '牙周炎' },
ortho_no_consult: { key: '错颌畸形', label: '错颌畸形(牙齿不齐)' },
hard_tissue_damage: { key: '牙体损伤', label: '牙体缺损' },
gum_alveolar_lesion: { key: '牙龈问题', label: '牙龈/牙槽问题' },
impacted_tooth: { key: '阻生牙', label: '阻生牙' },
jaw_cyst: { key: '颌骨囊肿', label: '颌骨囊肿' },
development_eruption: { key: '恒牙萌出空间不足', label: '牙齿萌出异常' },
};
export interface MissedItem {
/** 患者口径漏诊项(话术里用,如"缺失牙""错颌畸形(牙齿不齐)") */
label: string;
/** 配置 key(查 key-points / 复查时长);null = 未映射,用 label 兜底 */
key: string | null;
}
/** 单条 PAC reason → 漏诊项(subKey 优先,缺失则用文本兜底归一) */
export function missedFromReason(reason: {
subKey?: string | null;
dxCode?: string | null;
reason?: string | null;
scenarioLabel?: string | null;
}): MissedItem {
// subKey 可能带 @tooth 后缀(如 caries_no_filling@36)→ 取前缀
const baseSub = (reason.subKey ?? '').split('@')[0]!.trim();
const mapped = baseSub ? SUBKEY_TO_MISSED[baseSub] : undefined;
if (mapped) return { label: mapped.label, key: mapped.key };
// 兜底:从 reason/scenarioLabel 文本归一
const text = `${reason.scenarioLabel ?? ''} ${reason.reason ?? ''}`;
const key = canonicalMissedKey(text);
return { label: key ?? (reason.scenarioLabel ?? '需复查的问题'), key };
}
// ─────────────────────────────────────────────────────────
// 漏诊项归一 + 优先级挑选
// 漏诊项文本(如"牙位:21,牙槽骨吸收")→ 命中配置 key(includes 关键词)
// 优先级:儿牙早矫 > 恒牙萌出空间不足 > 缺失牙 > 牙槽骨吸收 > 其余(配置出现顺序)
// ─────────────────────────────────────────────────────────
const MISSED_PRIORITY = ['儿牙早矫', '恒牙萌出空间不足', '缺失牙', '牙槽骨吸收'];
/** 把一条漏诊项文本归一到配置 key(找不到 → null,文案兜底用原文) */
export function canonicalMissedKey(missedText: string | null | undefined): string | null {
const s = (missedText ?? '').trim();
if (!s) return null;
const keys = [...new Set([...MISSED_PRIORITY, ...Object.keys(MISSED_DIAGNOSIS_KEY_POINTS), ...Object.keys(TREATMENT_DURATION)])];
for (const k of keys) if (s.includes(k)) return k;
return null;
}
/** 从多条漏诊项里按优先级挑**一个**主漏诊项(LLM 只聚焦这一个) */
export function pickPrimaryMissed(items: Array<string | null | undefined>): { raw: string; key: string | null } | null {
const cleaned = items.map((x) => (x ?? '').trim()).filter(Boolean);
if (cleaned.length === 0) return null;
// 先按优先级关键词找
for (const p of MISSED_PRIORITY) {
const hit = cleaned.find((x) => x.includes(p));
if (hit) return { raw: hit, key: p };
}
// 否则取第一条,尽量归一
const first = cleaned[0]!;
return { raw: first, key: canonicalMissedKey(first) };
}
export function lookupKeyPoints(key: string | null): MissedKeyPoints | null {
return key ? (MISSED_DIAGNOSIS_KEY_POINTS[key] ?? null) : null;
}
export function lookupReviewDuration(key: string | null): string {
return (key && TREATMENT_DURATION[key]) || TREATMENT_DURATION['其他']!;
}
/**
* disease-knowledge —— 病种 **canonical 身份**(tier-agnostic,三档共用)。
*
* 收口决策(2026-06):病种的**口语文案**(risks/advantages/复查时长)是**稳健模板**,
* 已移 `tiers/stable/phrasing.ts`。本文件只保留 tier-agnostic 的**病种名映射**
* (subKey → 患者口径病种名),标准/深度档拿它得到病种 canonical 名,措辞自行组织。
*/
/** subKey(去 @tooth 后缀)→ 患者口径病种名 */
const SUBKEY_LABEL: Record<string, string> = {
missing_tooth: '缺失牙',
caries_no_filling: '龋齿',
endo_no_rct: '牙髓/根尖周炎',
perio_no_srp: '牙周炎',
ortho_no_consult: '错颌畸形(牙齿不齐)',
hard_tissue_damage: '牙体缺损',
gum_alveolar_lesion: '牙龈/牙槽问题',
impacted_tooth: '阻生牙',
jaw_cyst: '颌骨囊肿',
development_eruption: '牙齿萌出异常',
};
/** subKey(可带 @tooth 后缀)→ 病种名;未映射 → null */
export function diseaseLabelForSubKey(subKey: string | null | undefined): string | null {
const base = (subKey ?? '').split('@')[0]!.trim();
return base ? (SUBKEY_LABEL[base] ?? null) : null;
}
/** reason → 病种名(subKey 优先,缺失用 fallbackLabel) */
export function resolveDiseaseLabel(
reason: { subKey?: string | null } | null | undefined,
fallbackLabel: string,
): string {
return diseaseLabelForSubKey(reason?.subKey) ?? fallbackLabel;
}
/**
* fact-block —— 厚输入事实块(标准 / 深度档共用的 user-prompt 素材)。
*
* 把"聚焦病历 + 其他 reason + 近期治疗 + 目标 + 称呼/医生/日期"拼成自然语言事实块。
* 各档的**输出指令**(去模板自由写 / 拆大纲 / 多段)在各自 system(format.md)里,user 只给事实。
* 病种**只给 canonical 名**(去文案模板),风险/好处/检查内容由 LLM 结合病历自供措辞。
*/
import type { DraftPlanScriptInput, ScriptMedicalRecord } from './input.types';
import { smartDateDisplay, toothFriendly } from './script-facts';
import { resolveDiseaseLabel } from './disease-knowledge';
import { deidentifyDoctor } from './pii';
export function buildRichFactBlock(input: DraftPlanScriptInput): string {
const { patient, clinicName, plan, clinicalContext } = input;
const now = new Date();
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 chiefComplaint = top?.medicalRecord?.chiefComplaint ?? clinicalContext.lastChiefComplaint ?? null;
const diseaseLabel = resolveDiseaseLabel(top ?? null, plan.primaryScenarioLabel);
const toothText = top?.toothPositions?.length ? toothFriendly(top.toothPositions) : null;
const goal = plan.goal?.trim() || null;
const selfIntro = input.agent?.name
? `我是${clinicName}${input.agent.roleTitle}${input.agent.name}`
: `我是${clinicName}的客服`;
const guardianHint = patient.guardian
? `本次电话打给${patient.guardian.relationshipLabel}(称呼见{智能称呼}),沟通对象是家长,患者是孩子,话术里称孩子为"宝宝"`
: null;
const mrBlock = renderMedicalRecord(top?.medicalRecord ?? null);
const others = plan.reasons.slice(1).map((r) => {
const t = r.toothPositions?.length ? `(${toothFriendly(r.toothPositions)})` : '';
return `${resolveDiseaseLabel(r, r.scenarioLabel)}${t}`;
});
const recent = clinicalContext.recentTreatments.map((t) =>
[`做过${t.categoryLabel}`, t.subtype ? `上次 ${t.subtype}` : '', t.doctorName ? `${t.doctorName}医生` : '', t.date ?? '']
.filter(Boolean)
.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
? '老客户(之前在本诊所看过),语气可更熟络温和'
: '首诊/新客户,语气专业可信为主';
const noXray = patient.age == null || patient.age <= 18;
return `# 本次回访患者信息(只能用以下事实,不要编造或推断额外信息)
## 开场用
- {智能称呼}:${salutation}
- {自报家门}:${selfIntro}
- {智能时间显示}:${dateDisplay}
- 那次主诉:${chiefComplaint ?? '无记录'}
- {诊断医生}:${doctorSurname}医生${guardianHint ? `\n- 触达说明:${guardianHint}` : ''}
## 本次应治未治(只讲这一个;病种只给名,风险/好处/检查内容你结合下方病历用自己的话说)
- {应治未治项}:${diseaseLabel}${toothText ? `\n- {牙位}:${toothText}(已是患者口语俗称,直接用)` : ''}
## 病历(诊断上下文 — 接地素材,可引用医生原话体现关怀;⚠️ 只引用、不演绎、不报价、不承诺;与本次无关的别提)
${mrBlock || '- (本次诊断无关联病历,按 {应治未治项} + 牙科常识温和提醒)'}
## 本次目标(内部参考 — 指导复查/邀约方向,不要逐字念给患者)
- ${goal ?? '邀约来院复查,让医生评估本次问题'}
${others.length ? `\n## 其他可一并关心的问题(以本次聚焦为主,自然可顺带;不展开)\n- ${others.join('、')}` : ''}${
recent.length ? `\n\n## 近期做过的治疗(引用以体现"诊所记得 ta",不要重复邀约已做过的)\n${recent.map((r) => `- ${r}`).join('\n')}` : ''
}
## 患者
- ${basics}
## 语气
- ${toneHint}${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**' : ''}`;
}
/** 聚焦病历 → 接地素材块(SOAP 关键字段;空字段略) */
export function renderMedicalRecord(mr: ScriptMedicalRecord | null): string {
if (!mr) return '';
const lines: string[] = [];
if (mr.date) lines.push(`- 接诊日期:${mr.date}`);
if (mr.presentIllness) lines.push(`- 现病史:${mr.presentIllness}`);
if (mr.pastHistory) lines.push(`- 既往史:${mr.pastHistory}`);
if (mr.examFindings.length) {
lines.push(
`- 检查所见:${mr.examFindings
.map((e) => (e.toothPosition ? `${toothFriendly(e.toothPosition)} ${e.message}` : e.message))
.join(';')}`,
);
}
if (mr.diagnoses.length) {
lines.push(
`- 当次诊断:${mr.diagnoses
.map((d) => `${d.nameZh ?? d.code ?? ''}${d.toothPosition ? `(${toothFriendly(d.toothPosition)})` : ''}`)
.filter(Boolean)
.join('、')}`,
);
}
if (mr.doctorAdvice) lines.push(`- 医嘱(原话):${mr.doctorAdvice}`);
if (mr.recommendations.length) {
lines.push(
`- 医生建议:${mr.recommendations
.map((r) => (r.toothPosition ? `${r.text}(${toothFriendly(r.toothPosition)})` : r.text))
.join(';')}`,
);
}
if (mr.treatmentPlanText) lines.push(`- 治疗计划:${mr.treatmentPlanText}`);
return lines.join('\n');
}
...@@ -23,8 +23,9 @@ export interface ScriptContext { ...@@ -23,8 +23,9 @@ export interface ScriptContext {
age: number | null; age: number | null;
/** 病历号(host 病历主键,如 "FY0A000922")— 供客服核对身份,可空 */ /** 病历号(host 病历主键,如 "FY0A000922")— 供客服核对身份,可空 */
medicalRecordNumber?: string | null; medicalRecordNumber?: string | null;
/** 监护人(未成年触达对象)— 让话术知道"打给家长、患者是孩子(称宝宝)";成人/无监护人 → null */ /** 监护人(未成年触达对象)— 让话术知道"打给家长、患者是孩子(称宝宝)";成人/无监护人 → null。
guardian?: { relationshipLabel: string; name: string | null } | null; * relationship=原始键('mother'/'father'/'grandparent',结构化判断用);relationshipLabel=中文显示("妈妈")。 */
guardian?: { relationship: string; relationshipLabel: string; name: string | null } | null;
}; };
/** 诊所名(给 LLM 用作"我是X诊所的客服顾问",避免编造"XX口腔") */ /** 诊所名(给 LLM 用作"我是X诊所的客服顾问",避免编造"XX口腔") */
...@@ -45,13 +46,19 @@ export interface ScriptContext { ...@@ -45,13 +46,19 @@ export interface ScriptContext {
/** ⭐ 本次召回的明确目的(plan.goal 原文,如"邀约做牙周基础治疗(SRP/翻瓣),控制炎症发展") /** ⭐ 本次召回的明确目的(plan.goal 原文,如"邀约做牙周基础治疗(SRP/翻瓣),控制炎症发展")
* 让 LLM followup 段对齐该目标,不再自己脑补"我们想约您来评估" */ * 让 LLM followup 段对齐该目标,不再自己脑补"我们想约您来评估" */
goal: string | null; goal: string | null;
/** 触发原因摘要(最多 3 条) */ /** 触发原因(最多 3 条)。⭐ 契约:**已按 priorityScore 降序**,`reasons[0] = 本次话术聚焦项**
* (单一聚焦,三档共用此选择;orchestrator 单一排序,消费方直接取 [0],不再各自 sort)。
* 其余 reasons 供标准/深度档"可一并邀约的其他牙问题"用。 */
reasons: Array<{ reasons: Array<{
scenarioLabel: string; scenarioLabel: string;
/** ⭐ 子场景 base key(去 @tooth 后缀,如 'caries_no_filling';skill composer 推 dxCode 用) */ /** ⭐ 子场景 base key(去 @tooth 后缀,如 'caries_no_filling';skill composer 推 dxCode 用) */
subKey: string | null; subKey: string | null;
/** ⭐ ICD-10 K-code(K00-K09,skill composer.applies.diagnosisCodePrefix 用) */ /** ⭐ ICD-10 K-code(K00-K09,skill composer.applies.diagnosisCodePrefix 用) */
dxCode: string | null; dxCode: string | null;
/** ⭐ 涉及牙位(FDI 号,结构化;来源 plan_reason.signals.toothPosition 拆分)。
* 空数组 = 全口/无具体牙位。俗称("上门牙")由消费方按需渲染,不在快照里拍平。
* 缺失牙位占位等场景从此取牙位,不再丢进散文。 */
toothPositions: string[];
reason: string; reason: string;
priorityScore: number; priorityScore: number;
/** 触发该诊断/建议的医生(LLM 在 followup 段必须引用此人,不要用 primaryDoctorName) /** 触发该诊断/建议的医生(LLM 在 followup 段必须引用此人,不要用 primaryDoctorName)
...@@ -60,37 +67,84 @@ export interface ScriptContext { ...@@ -60,37 +67,84 @@ export interface ScriptContext {
triggerDoctor: string | null; triggerDoctor: string | null;
/** 触发诊断/建议的日期(YYYY-MM-DD),给 LLM 在话术里用,如"上次姜医生 X 月 X 日给您检查时...") */ /** 触发诊断/建议的日期(YYYY-MM-DD),给 LLM 在话术里用,如"上次姜医生 X 月 X 日给您检查时...") */
triggerDate: string | null; triggerDate: string | null;
/** ⭐ 项目相关:该诊断**那次接诊**的主诉(emr illness_desc 原文)。 /** ⭐ 病历 —— 该诊断**那次接诊**的完整病历(SOAP 全字段)。每条 reason 各带自己那次接诊的病历;
* 单一聚焦时优于"最近一次主诉"—— 缺牙是哪次来发现的、那次为什么来,更贴合本应治未治项。可空 */ * reasons[0].medicalRecord = 聚焦诊断的病历。主诉(chiefComplaint)等从这里取,不再单列。null = 无关联病历。 */
triggerChiefComplaint?: string | null; medicalRecord: ScriptMedicalRecord | null;
}>; }>;
}; };
/** Persona 关键特征(只挑相关的,不全量) */ /** Persona 关键特征(只挑相关的,不全量) */
personaHighlights: Array<{ personaHighlights: Array<{
label: string; // 中文 label,如"客户价值" key: string; // 原始 feature_key,如"value"(结构化判断用)
description: string; // 短描述,如"高价值(累计消费 ¥35,000)" label: string; // 中文 label,如"客户价值"(显示用)
description: string; // persona 层 canonical 描述(design §5.2 故意不拆字段),如"高价值(累计消费 ¥35,000)"
}>; }>;
/** 临床事实摘要(只挑最近 / 跟召回相关的) */ /** 临床事实摘要(只挑最近 / 跟召回相关的) */
clinicalContext: { clinicalContext: {
daysSinceLastVisit: number | null; daysSinceLastVisit: number | null;
lastVisitSummary: string | null; // 上次到店做了什么(一句话) /** 上次到店(结构化:date=年月文本"2024.03" / summary=做了什么);整体可空。
* ⚠️ 原 lastVisitSummary 是拍平散文,已结构化;消费方按需拼"{date} — {summary}"。 */
lastVisit: { date: string | null; summary: string | null } | null;
/** 上次就诊主诉(emr_record.illness_desc 原文,自由文本直喂 LLM 当上下文;可空) */ /** 上次就诊主诉(emr_record.illness_desc 原文,自由文本直喂 LLM 当上下文;可空) */
lastChiefComplaint?: string | null; lastChiefComplaint?: string | null;
pendingTreatments: string[]; // 待做治疗(reason 派生,即"未启动治疗";牙位已转俗称) /** 待做治疗(reason 派生,即"未启动治疗")。结构化:subKey/label/FDI 牙位;俗称由消费方渲染,不拍平。 */
pendingTreatments: Array<{
subKey: string | null;
label: string; // 子场景中文 label,如"龋齿未做充填"
toothPositions: string[]; // FDI 号(结构化);空 = 全口/无具体牙位
}>;
/** 主治医生名(从最近 treatment/diagnosis fact 抽);LLM 必须用此名,不可编造 */ /** 主治医生名(从最近 treatment/diagnosis fact 抽);LLM 必须用此名,不可编造 */
primaryDoctorName: string | null; primaryDoctorName: string | null;
/** ⭐ 近期做过的治疗(每条一句:"做过牙周 · 上次龈上洁治 · 吴医生 · 2024.04") /** ⭐ 近期做过的治疗(结构化:每个 category 一条最新)。
* 来源 treatment_record(非治疗链);LLM 用于:① 不重复邀约已做过的 ② 引用历史显"诊所记得 ta"。 * 来源 treatment_record(非治疗链);消费方渲染成"做过牙周 · 上次龈上洁治 · 吴医生 · 2024.04"。
* LLM 用于:① 不重复邀约已做过的 ② 引用历史显"诊所记得 ta"。
* ⚠️ 治疗链(chain-composer)已废弃 → 这里只读"做过什么治疗",不含链/阶段概念。 */ * ⚠️ 治疗链(chain-composer)已废弃 → 这里只读"做过什么治疗",不含链/阶段概念。 */
recentTreatments: string[]; recentTreatments: Array<{
category: string; // canonical category,如"periodontic"
categoryLabel: string; // 中文,如"牙周"
subtype: string | null; // 术式子类,如"龈上洁治"
doctorName: string | null;
date: string | null; // 年月文本,如"2024.04"
}>;
/** ⭐ 已做治疗总次数(信任锚);LLM 用于:老客可以更家常,新客需自报家门更详细 */ /** ⭐ 已做治疗总次数(信任锚);LLM 用于:老客可以更家常,新客需自报家门更详细 */
completedTreatmentCount: number; completedTreatmentCount: number;
}; };
} }
/** /**
* ScriptMedicalRecord —— 一次接诊的**完整病历**(SOAP 全字段,结构化)。
* 挂在每个 reason 上(reason.medicalRecord):该诊断那次接诊的病历。字段集对齐前端"病历快读"(emr-soap-view)。
* ⚠️ 进 input_snapshot(全集),**不等于全进 user prompt** —— 自由文本编造风险高,各档按需引用:
* 稳健引用医嘱/主诉;标准/深度可取检查所见/处置等更多。
*/
export interface ScriptMedicalRecord {
date: string | null; // 接诊日期 YYYY-MM-DD
doctorName: string | null;
// S 主观(自由文本)
chiefComplaint: string | null; // illness_desc 主诉
presentIllness: string | null; // pre_illness 现病史
pastHistory: string | null; // past_history 既往史
generalCondition: string | null; // general_condition 一般情况
// O 客观(按牙位拆)
examFindings: Array<{ toothPosition: string | null; message: string }>; // exam_findings
// A 评估(同次接诊的诊断,即被召回的诊断所在)
diagnoses: Array<{
code: string | null;
nameZh: string | null;
toothPosition: string | null;
fromImageAi: boolean; // 影像 AI 抽出(非医生手写)
}>;
// P 计划
disposals: Array<{ toothPosition: string | null; message: string }>; // disposal 处置
doctorAdvice: string | null; // doctor_advice 医嘱
treatmentPlanText: string | null; // treatment_plan(host 自由文本字段,常空)
diagnosisText: string | null; // diagnosis_text(host 自由文本字段,常空)
/** 医生建议(同次接诊的 recommendation_record,如"建议拔除18/38/48")—— 常是召回理由本身 */
recommendations: Array<{ text: string; toothPosition: string | null }>;
}
/**
* AiCall 输出契约(B 方案 · 2026-05-24 重写)。 * AiCall 输出契约(B 方案 · 2026-05-24 重写)。
* *
* LLM 直接输出 4 段 Markdown 字符串 + tone tag。 * LLM 直接输出 4 段 Markdown 字符串 + tone tag。
...@@ -122,4 +176,13 @@ export interface DraftPlanScriptOutput { ...@@ -122,4 +176,13 @@ export interface DraftPlanScriptOutput {
/** 第四部分·结束回访语 markdown(预约成功 / 不成功 两种) */ /** 第四部分·结束回访语 markdown(预约成功 / 不成功 两种) */
closing: string; closing: string;
/** ⭐ 段落标题(标准/深度档:LLM 为 4 段各自起的小标题;稳健档不出 → UI 回退固定标题)。
* 设计:稳健档标题固定(开场白/告知应治未治/复查建议/结束回访语);标准档"标题不定",由 LLM 编排。 */
sectionTitles?: {
opening?: string;
informMissed?: string;
reviewAdvice?: string;
closing?: string;
};
} }
/**
* safety-rules —— 话术安全的**单一真理源**(tier-agnostic,三档共用机器闸 + prompt)。
*
* 收口决策(2026-06):此前禁词散在两处且不一致(base-system.md 一份 + call.ts FORBIDDEN_PHRASES 一份)。
* 现统一到本文件:
* - 机器闸:各档 AiCall.safetyRules = SCRIPT_SAFETY_RULES(本文件)
* - prompt:system 里的禁词块由 forbiddenWordsBlock() 生成,base-common.md 不再各写一份
* 改一处,机器闸 + 提示词同步。
*/
import type { SafetyRule } from '../../../core/safety-gate.service';
import type { DraftPlanScriptOutput } from './input.types';
// ⚠️ 单字符禁词务必避免(会误伤合法词):'亲'→亲切/母亲;'宝'→宝贝/宝宝。只保留"销售化"明确组合形式。
export const FORBIDDEN_PHRASES = [
'一定能', '保证', '绝对', '百分百', '100%',
'亲爱的', // 淘宝式称呼
'便宜', '促销', '折扣', '免费', '不收费', '赠送',
] as const;
// 承诺式表述(PAC 无排班 API,时间不能写死/承诺)
export const COMMIT_PHRASES = [
'已为您约好', '已成功预约', '已为您预约', '已经为您约', '已替您预约',
'约定本', '敲定本', '安排好了', '已经预约',
] as const;
// 加粗具体时间(误导"已定");新结构应保留【时间段】占位
const BOLD_TIME_REGEX = /\*\*[^*\n]*(?:[一二三四五六日天]|\d+\s*(?:点|:|:))[^*\n]*\*\*/;
/** 拼整篇 4 段文本(机器闸全文扫) */
function fullText(o: DraftPlanScriptOutput): string {
return [o.opening, o.informMissed, o.reviewAdvice, o.closing].join('\n');
}
/**
* 机器安全扫描(纯函数,任意文本)—— 深度档策略侧用(在 repair 决策点调,而非 runner per-call gate)。
* 返回问题列表;空 = 通过。
*/
export function machineSafetyScan(text: string): string[] {
const problems: string[] = [];
const f = FORBIDDEN_PHRASES.filter((p) => text.includes(p));
if (f.length) problems.push(`禁词: ${f.join(',')}`);
const c = COMMIT_PHRASES.filter((p) => text.includes(p));
if (c.length) problems.push(`承诺式表述: ${c.join(',')}`);
if (BOLD_TIME_REGEX.test(text)) problems.push('加粗了具体时间(应保留【时间段】占位)');
return problems;
}
/** prompt 用的禁词块(system 注入;与机器闸同源,避免漂移) */
export function forbiddenWordsBlock(): string {
return [
'# 禁词(整篇严禁出现)',
FORBIDDEN_PHRASES.join(' / '),
'以及:"一定能治好" / "保证效果" / "绝对安全" 等承诺;"亲 / 宝 / 帅哥 / 美女" 等淘宝式称呼。',
].join('\n');
}
/** 三档共用的机器安全规则(后置硬约束;LLM 偶尔越过 schema 塞禁词,这里兜底) */
export const SCRIPT_SAFETY_RULES: ReadonlyArray<SafetyRule<DraftPlanScriptOutput>> = [
{
name: 'no_forbidden_phrases',
severity: 'block',
check(output) {
const text = fullText(output);
const hit = FORBIDDEN_PHRASES.filter((p) => text.includes(p));
return { pass: hit.length === 0, message: hit.length ? `命中禁词: ${hit.join(',')}` : undefined };
},
},
{
name: 'no_commit_phrasing',
severity: 'block',
check(output) {
const text = [output.reviewAdvice, output.closing].join('\n');
const hit = COMMIT_PHRASES.filter((p) => text.includes(p));
return {
pass: hit.length === 0,
message: hit.length ? `承诺式表述(无排班 API,不能定): ${hit.join(',')}` : undefined,
};
},
},
{
name: 'no_bold_concrete_time',
severity: 'block',
check(output) {
const text = [output.reviewAdvice, output.closing].join('\n');
const m = text.match(BOLD_TIME_REGEX);
return { pass: !m, message: m ? `加粗了具体时间"${m[0]}" — 应保留【时间段】占位` : undefined };
},
},
// 注:≤18 岁禁拍片 由 prompt/base 约束(SafetyContext 不带 age,无法在此判定)
];
/**
* script-facts — 把"参考话术"提示词里**本该程序判断的确定性逻辑**提取成纯函数 + 字典。
*
* 设计原则(渐进式组合 + 提示词缓存):
* - LLM 不再做:年龄分支 / 智能日期格式 / 漏诊项优先级挑选 / 查 key-points 表 / 查复查时长 / 智能称呼。
* 这些全在这里算好,作为"已定事实"塞进 user prompt,LLM 只负责把事实润色成话术。
* - 静态铁律/模板 → system(base-system.md + population skill,前缀缓存);
* 本文件算出的"单个患者相关"事实 → user(动态,渐进式只给相关的那一条,不发全表)。
*
* 真理源:文案要点来自业务给的"漏诊项目关键要点配置 / 复查时长配置 / 年龄组配置"。
*/
// ─────────────────────────────────────────────────────────
// 年龄分支(儿童 ≤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 不该跑日期逻辑)
// ─────────────────────────────────────────────────────────
export function smartDateDisplay(visit: Date | string | null | undefined, now: Date): string | null {
if (!visit) return null;
const d = visit instanceof Date ? visit : new Date(visit);
if (Number.isNaN(d.getTime())) return null;
const m = d.getMonth() + 1;
if (d.getFullYear() === now.getFullYear()) return `${m}${d.getDate()}号`;
if (d.getFullYear() === now.getFullYear() - 1) return `去年${m}月`;
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 结构)
// 设计:粗粒度即可("上门牙"够,不需要"左上中切牙"),让客服讲话自然。
// 多牙位 "/" 分隔,去重保序。三档话术 + 实时教练共用。
//
// FDI 规则:第1位=象限(1-4 恒牙,5-8 乳牙);第2位=位置(1=中切,2=侧切,3=尖牙,
// 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 '智齿';
return null;
}
/** FDI 牙位串/数组 → 俗称("上门牙/智齿");都解析不出兜底原样返回 */
export function toothFriendly(tooth: string | string[]): string {
const parts = Array.isArray(tooth)
? tooth
: tooth.split(/[;,,;\s]+/).map((s) => s.trim()).filter(Boolean);
const friendly: string[] = [];
const seen = new Set<string>();
for (const p of parts) {
const label = fdiToFriendly(p);
if (label && !seen.has(label)) {
seen.add(label);
friendly.push(label);
}
}
const raw = Array.isArray(tooth) ? tooth.join('/') : tooth;
return friendly.length > 0 ? friendly.join('/') : raw;
}
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { import {
classifyPopulation, classifyPopulation,
type Skill, type Skill,
type SkillMatchContext, type SkillMatchContext,
type ScriptTier,
} from './skill.types'; } from './skill.types';
import type { DraftPlanScriptInput } from './input.types'; import type { DraftPlanScriptInput } from './input.types';
import { resolveScriptSkillsRoot } from './skill-registry.service'; import { resolveBaseCommonPath, resolveBaseFormatPath } from './skill-registry.service';
import { forbiddenWordsBlock } from './safety-rules';
/** /**
* SkillComposer — 纯函数式,把 input + 全 skills → matched skills + system prompt。 * SkillComposer — 纯函数式,把 input + 全 skills → matched skills + system prompt。
...@@ -34,18 +35,18 @@ export interface ComposedSystem { ...@@ -34,18 +35,18 @@ export interface ComposedSystem {
composeHash: string; composeHash: string;
} }
/** base-system.md 路径(跟 registry 共用 cwd-based 多路径 resolver) */ /** lazy load base —— common(共性,三档一份)+ format(每档一份),按档缓存 */
function resolveBaseSystemPath(): string { let cachedCommon: string | null = null;
return join(resolveScriptSkillsRoot(), 'base-system.md'); const cachedFormat: Partial<Record<ScriptTier, string>> = {};
} function loadBase(tier: ScriptTier): string {
if (cachedCommon === null) {
/** lazy load base system,只读 1 次缓存 */ cachedCommon = readFileSync(resolveBaseCommonPath(), 'utf-8').trim();
let cachedBase: string | null = null; }
function loadBaseSystem(): string { if (cachedFormat[tier] === undefined) {
if (cachedBase !== null) return cachedBase; cachedFormat[tier] = readFileSync(resolveBaseFormatPath(tier), 'utf-8').trim();
const raw = readFileSync(resolveBaseSystemPath(), 'utf-8').trim(); }
cachedBase = raw; // 顺序:共性定位/铁律 → 本档输出格式 → 禁词(单一源)
return raw; return `${cachedCommon}\n\n${cachedFormat[tier]}\n\n${forbiddenWordsBlock()}`;
} }
/** /**
...@@ -94,33 +95,40 @@ export function skillApplies(skill: Skill, ctx: SkillMatchContext): boolean { ...@@ -94,33 +95,40 @@ export function skillApplies(skill: Skill, ctx: SkillMatchContext): boolean {
return true; return true;
} }
/** skill 是否适用该档:tiers 空 = 全档共用(如人群共性知识);非空需包含当前档 */
export function skillTierOk(skill: Skill, tier: ScriptTier): boolean {
const t = skill.frontmatter.tiers;
return t.length === 0 || t.includes(tier);
}
/** /**
* Compose 主入口。 * Compose 主入口(tier-aware)。
* base = common(共性) + format(该档) + 禁词(单一源);
* skills = applies 命中 且 该档适用(tiers 过滤)。
*/ */
export function composeSystem( export function composeSystem(
input: DraftPlanScriptInput, input: DraftPlanScriptInput,
allSkills: readonly Skill[], allSkills: readonly Skill[],
tier: ScriptTier = 'stable',
): ComposedSystem { ): ComposedSystem {
const context = deriveContext(input); const context = deriveContext(input);
const matched = allSkills const matched = allSkills
.filter((s) => skillApplies(s, context)) .filter((s) => skillApplies(s, context) && skillTierOk(s, tier))
.sort( .sort(
(a, b) => (a, b) =>
(a.frontmatter.priority ?? 50) - (b.frontmatter.priority ?? 50), (a.frontmatter.priority ?? 50) - (b.frontmatter.priority ?? 50),
); );
const base = loadBaseSystem(); const base = loadBase(tier);
// 只拼 body — 内部 skill name/version 不进提示词(版本归因走 composeHash,见下) // 只拼 body — 内部 skill name/version 不进提示词(版本归因走 composeHash,见下)
const skillsBlock = matched.map((s) => s.body).join('\n\n---\n\n'); const skillsBlock = matched.map((s) => s.body).join('\n\n---\n\n');
const systemPrompt = skillsBlock const systemPrompt = skillsBlock
? `${base}\n\n# 本次话术模板\n\n${skillsBlock}` ? `${base}\n\n# 本次适用知识 / 模板\n\n${skillsBlock}`
: base; : base;
// composeHash = sha256(matched.name+version join)前 16 hex // composeHash = sha256(tier + matched.name+version join)前 16 hex
const hashSrc = matched const hashSrc = [tier, ...matched.map((s) => `${s.frontmatter.name}@${s.frontmatter.version}`)].join('|');
.map((s) => `${s.frontmatter.name}@${s.frontmatter.version}`)
.join('|');
const composeHash = createHash('sha256').update(hashSrc).digest('hex').slice(0, 16); const composeHash = createHash('sha256').update(hashSrc).digest('hex').slice(0, 16);
return { systemPrompt, matchedSkills: matched, context, composeHash }; return { systemPrompt, matchedSkills: matched, context, composeHash };
......
...@@ -20,12 +20,22 @@ import { ...@@ -20,12 +20,22 @@ import {
* 这跟 sync.service.ts 用 cwd 而非 __dirname 是同一个原因 — SWC dev 跟 tsc prod 的 * 这跟 sync.service.ts 用 cwd 而非 __dirname 是同一个原因 — SWC dev 跟 tsc prod 的
* 编译输出目录结构不同,__dirname 不能跨态稳定;cwd 在 dev/prod 都是 apps/pac-service 根。 * 编译输出目录结构不同,__dirname 不能跨态稳定;cwd 在 dev/prod 都是 apps/pac-service 根。
*/ */
export function resolveScriptSkillsRoot(): string { export function resolveScriptRoot(): string {
const override = process.env.PAC_SCRIPT_SKILLS_DIR; const override = process.env.PAC_SCRIPT_ROOT_DIR;
if (override) return override; if (override) return override;
const src = join(process.cwd(), 'src/modules/ai/calls/draft-plan-script/skills'); const src = join(process.cwd(), 'src/modules/ai/calls/draft-plan-script');
if (existsSync(src)) return src; if (existsSync(src)) return src;
return join(process.cwd(), 'dist/modules/ai/calls/draft-plan-script/skills'); return join(process.cwd(), 'dist/modules/ai/calls/draft-plan-script');
}
/** base 共性提示词路径(三档共用) */
export function resolveBaseCommonPath(): string {
return join(resolveScriptRoot(), 'shared/skills/_base/common.md');
}
/** 某档的输出格式提示词路径(每档不同) */
export function resolveBaseFormatPath(tier: 'stable' | 'standard' | 'deep'): string {
return join(resolveScriptRoot(), `tiers/${tier}/skills/_base/format.md`);
} }
/** /**
...@@ -81,7 +91,7 @@ export class DraftPlanScriptSkillRegistry implements OnModuleInit { ...@@ -81,7 +91,7 @@ export class DraftPlanScriptSkillRegistry implements OnModuleInit {
} }
private resolveSkillsRoot(): string { private resolveSkillsRoot(): string {
return resolveScriptSkillsRoot(); return resolveScriptRoot();
} }
/** 递归 scan,返回所有 SKILL.md 绝对路径 */ /** 递归 scan,返回所有 SKILL.md 绝对路径 */
......
...@@ -25,6 +25,9 @@ export const SkillFrontmatterSchema = z.object({ ...@@ -25,6 +25,9 @@ export const SkillFrontmatterSchema = z.object({
/** 跨维度排除 — 列出兼容的 population key(空数组 = 不限) */ /** 跨维度排除 — 列出兼容的 population key(空数组 = 不限) */
allowedPopulation: z.array(z.enum(['child', 'teen', 'adult', 'elder'])).default([]), allowedPopulation: z.array(z.enum(['child', 'teen', 'adult', 'elder'])).default([]),
/** 适用档位(空数组 = 全档共用,如人群共性知识);['stable'] = 仅稳健(如句位脚手架模板) */
tiers: z.array(z.enum(['stable', 'standard', 'deep'])).default([]),
/** SemVer,改文件 bump(给 promptVersion composeHash 用) */ /** SemVer,改文件 bump(给 promptVersion composeHash 用) */
version: z.string().default('0.1.0'), version: z.string().default('0.1.0'),
...@@ -71,6 +74,9 @@ export interface SkillMatchContext { ...@@ -71,6 +74,9 @@ export interface SkillMatchContext {
relationship: 'new' | 'returning'; relationship: 'new' | 'returning';
} }
/** 投入档(话术生成档位) */
export type ScriptTier = 'stable' | 'standard' | 'deep';
export const POPULATION_THRESHOLDS = { export const POPULATION_THRESHOLDS = {
CHILD_MAX: 13, CHILD_MAX: 13,
TEEN_MIN: 14, TEEN_MIN: 14,
......
你是一名专业的口腔医疗回访专员,代表医疗机构进行关怀性回访。回访目标是医疗关怀和复查提醒,不是销售推广。
# 回访定位和语调要求
✅ 定位:医疗关怀回访,不是销售回访
✅ 语调:温馨、专业、关怀,避免推销感
✅ 目标:健康提醒、复查建议,不是治疗推荐
✅ 态度:建议性而非推销性,尊重患者选择
# 核心原则(所有档共用)
✅ 只专注处理本次给定的那一个 {应治未治项},其他项目完全忽略
✅ 必须以医生名义体现医生的关怀和交代(用 {诊断医生} 医生)
✅ 必须用短句、便于客服与患者互动,不要单方面长篇
✅ 主动引导预约,给出具体时间选择(时间走【时间段】占位,不写死)
# 接地(只用给定事实,不编造)
所有事实只能来自"本次回访患者信息"里给定的字段;空缺就泛指或省略,**不得编造**(不杜撰医生名 / 诊断 / 价格 / 政策 / 设备 / 患者背景)。给定的值直接用,不要自己重算、改写或改格式。
# 绝对禁止事项
❌ 严禁提及费用、金钱、价格、优惠等任何经济内容
❌ 严禁给出具体治疗方案建议,只能建议复查检查
❌ 严禁使用推销性语言(如"机会难得"、"限时"、"特价"等)
❌ 如患者≤18岁,严禁提及拍片
❌ 严禁虚构任何患者信息
❌ 严禁处理 {应治未治项} 以外的任何其他项目
❌ 严禁说"给您建议"等机器人式语言
❌ 严禁说"您方便再预约",必须主动引导预约
❌ 严禁忽略医生交代的温度感
# 语言风格要求
话术必须口语化,适合医疗回访专员直接使用;语调温馨关怀,体现医生的人文关怀和专业交代;避免商业化和推销感、避免机器人式语言;重点强调健康维护而非治疗推荐;用短句便于互动。
# 输出格式
只输出 1 个合法 JSON 对象,简体中文,不要解释文字、不要代码块包裹。content 只放话术正文,每个短句单独成行、行首用 `•`,短句之间用 `\n` 换行。❌ content 内严禁出现 "═══ 第一部分…" 这种大标题 / 分隔符 / 表情符号 / `###` 标题。
---
name: population-adult-common
description: 成人(患者年龄 ≥13 岁,或年龄未知默认)回访的**共性沟通知识**(tier-agnostic,三档共用)。只讲"对谁说、什么语气、怎么称呼",不含固定句位模板。沟通对象=患者本人;医疗关怀导向、非推销。tone 默认 professional,熟客可 warm,急性可 urgent。
priority: 90
applies:
ageMin: 13
version: 1.0.0
---
# 成人沟通知识(≥13 岁 / 年龄未知默认)
- **沟通对象**:患者本人。
- **称呼**:用给定的 {智能称呼}(已是"姓+先生/女士",直接用;未知用"您好")。
- **语气**:默认 professional(专业稳重);老客户可 warm(熟络温和);急性/疼痛场景可 urgent。
- **打电话顺序**:先称呼 + 确认对方方便 → 再自报家门({自报家门}) → 以 {诊断医生} 医生名义体现关怀 → 问近况 → 自然带出本次 {应治未治项}。
- **关怀而非推销**:突出"医生发现/医生交代"的语气,不说"我们发现了…";个性化关怀用"趁现在/早一点",不提具体年龄/职业。
- **引导预约**:主动给时间选择,时间走【时间段】占位,不写死、不承诺。
---
name: population-child-common
description: 儿童(年龄 ≤12 岁,对象=家长)回访的**共性沟通知识**(tier-agnostic,三档共用)。只讲"对谁说、什么语气、怎么称呼、儿科红线",不含固定句位模板。沟通对象=家长;称患儿为"宝宝";tone 默认 warm。⚠️ ≤18 岁严禁提拍片。
priority: 90
applies:
ageMax: 12
version: 1.0.0
---
# 儿童沟通知识(≤12 岁,对象=家长)
- **沟通对象**:家长(不是孩子本人)。话术里称患儿为"宝宝"。
- **称呼**:用给定的 {智能称呼}(如"徐女士"/"宝宝妈妈"/"家长",直接用;未知用"您好")。
- **语气**:默认 warm(温和家常,适合与家长沟通)。
- **打电话顺序**:先称呼 + 确认家长方便 → 再自报家门({自报家门}) → 以 {诊断医生} 医生名义体现关怀 → 问宝宝近况 → 自然带出本次 {应治未治项}。
- **儿科红线**:⚠️ ≤18 岁**严禁提拍片**(拍片/X光/牙片一律不说)。
- **针对本次问题**:复查/邀约对齐本次 {应治未治项},不要框成"常规涂氟体检";涂氟、查蛀牙等只作顺带关怀。
- **引导预约**:主动给时间选择,时间走【时间段】占位,不写死、不承诺。
你是一名专业的口腔医疗回访专员,代表医疗机构进行关怀性回访。回访目标是医疗关怀和复查提醒,不是销售推广。请严格按照以下指令生成话术。
# 回访定位和语调要求
✅ 定位:医疗关怀回访,不是销售回访
✅ 语调:温馨、专业、关怀,避免推销感
✅ 目标:健康提醒、复查建议,不是治疗推荐
✅ 态度:建议性而非推销性,尊重患者选择
# 输出结构(4 模块,顺序固定,缺一不可)
输出 1 个 JSON:`tone` + 4 段 Markdown 字符串,顺序固定:
1. `opening` 第一部分·开场白
2. `informMissed` 第二部分·告知应治未治
3. `reviewAdvice` 第三部分·复查建议
4. `closing` 第四部分·结束回访语
- content 只放话术正文。每个短句单独成行,行首用 `•`,短句之间用 `\n` 换行。
- ❌ content 内严禁出现 "═══ 第一部分…" 这种大标题 / 分隔符 / "Title" / 表情符号 / `###` 标题。
# 严格执行要求 - 核心强制规则
🚨 4个模块必须全部包含,缺一不可!
✅ 必须严格按照以下4个模块顺序生成话术,每个模块都不能缺少
✅ 模块顺序固定:开场白 → 告知应治未治 → 复查建议 → 结束回访语
✅ 只专注处理本次给定的那一个 {应治未治项},其他项目完全忽略
✅ 必须在开场白中以医生名义,体现医生的关怀和交代
✅ 告知应治未治、复查建议必须分成短句,便于客服与患者互动
⚠️ 重要提醒:如果输出缺少任何一个模块,整个话术将被视为不合格!
# 占位符约定(两种,含义不同)
- `{xxx}` = **要替换**的占位:用"本次回访患者信息"里给的同名值填进去(如 {智能称呼}{应治未治项}{诊断医生}{风险要点}{复查时长})。输出里不能再出现 `{}`
- `【xxx】` = **原样保留**的占位:不要替换、照抄进话术,客服打电话时手动填。只有这几个:【时间段1】【时间段2】【具体预约时间】【缺失牙位】。
- 注:{智能称呼}/{诊断医生} 给的是"姓+敬称"(如 徐女士/韩医生),直接用即可,不是 `【】` 占位。
- 另:结束语的分支标签【预约成功】【预约不成功】也照常输出。
# 直接使用给定的事实
开场自报家门用{自报家门}、称呼用{智能称呼}、日期用{智能时间显示}、本次只讲{应治未治项}、健康提醒从{风险要点}挑、检查说明用{复查时长}原文、以{诊断医生}名义体现关怀。这些值直接用,不要自己重算、改写或改格式。所有事实只能来自给定的字段;空缺就泛指或省略,不得编造(不杜撰医生名 / 诊断 / 价格 / 政策 / 设备 / 患者背景)。
# 时间用占位
- 引导预约严格用「{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」
- 结束语·预约成功保留「我们【具体预约时间】见」。
- ⚠️【时间段1】【时间段2】【具体预约时间】原样保留占位,严禁替换成"周三上午"等具体时间。❌ 严禁加粗具体时间、严禁"已为您约好 / 敲定 X"承诺。
# 绝对禁止事项
❌ 严禁提及费用、金钱、价格、优惠等任何经济内容
❌ 严禁给出具体治疗方案建议,只能建议复查检查
❌ 严禁使用推销性语言(如"机会难得"、"限时"、"特价"等)
❌ 如患者≤18岁,严禁提及拍片
❌ 严禁虚构任何患者信息
❌ 严禁处理 {应治未治项} 以外的任何其他项目
❌ 严禁遗漏任何一个模块
❌ 严禁改变4个模块的顺序
❌ 严禁说"给您建议"等机器人式语言
❌ 严禁单方面长篇输出,必须用短句便于互动
❌ 严禁说"您方便再预约",必须主动引导预约
❌ 严禁忽略医生交代的温度感
# 禁词
一定能 / 保证 / 绝对 / 百分百 / 100% / 亲爱的 / 便宜 / 促销 / 折扣 / 免费 / 不收费 / 赠送;亲 / 宝 / 帅哥 / 美女;"一定能治好" / "保证效果" / "绝对安全"
# 语言风格要求
话术必须口语化,适合医疗回访专员直接使用
语调温馨关怀,体现医生的人文关怀和专业交代
避免商业化和推销感的表达,避免机器人式语言
重点强调健康维护而非治疗推荐
用短句便于客服与患者互动沟通
主动引导预约,给出具体时间选择
# 输出前自查
✅ 4个模块全部包含且顺序正确?
✅ 开场白以{诊断医生}医生名义,体现医生交代的关怀?
✅ 只专注 {应治未治项} 一个,没提其他项目?
✅ 告知应治未治、复查建议都分了短句?
✅ 称呼 / 日期 / 复查时长用的是给定的值?时间保留了【时间段】占位、没写死具体时间?
✅ 无费用 / 方案 / 推销 / 虚构?主动给了【时间段】预约选择?避免了机器人式语言?
# 输出格式
只输出 1 个合法 JSON 对象,简体中文,不要解释文字、不要代码块包裹。
import { Injectable } from '@nestjs/common';
import type { AiCall } from '../../../../ai-call.interface';
import type { ScriptContext } from '../../shared/input.types';
import { composeSystem } from '../../shared/skill-composer';
import { DraftPlanScriptSkillRegistry } from '../../shared/skill-registry.service';
import { DeepPlanSchema, DeepWriteSchema, DeepVerifySchema, type DeepPlanZ, type DeepWriteZ, type DeepVerifyZ } from './schema';
import { buildPlanPrompt, buildWritePrompt, buildVerifyPrompt } from './prompts';
import { draftOutputToDeep, type DeepWriteInput, type DeepVerifyInput } from './types';
import { stableTemplateFallback } from '../stable/stable.call';
/**
* 深度档 3 个 AiCall(都过 AiCallRunner,各自落 agent_invocations;由 DeepScriptStrategy 串联)。
* plan(规划)→ write(写,多段)→ verify(独立对抗校验)。
* 三者 callKey 各异(eval 可分别看);共享 ScriptContext / runner / 安全函数。
* write 用 composeSystem(...,'deep') 装配话术 system;plan/verify 是内部推理步,system 内联。
*/
const PLAN_SYSTEM = [
'你是资深口腔回访话术规划师。基于给定的患者事实,规划一通"医疗关怀回访"电话拆成几段、每段讲什么。',
'原则:医疗关怀非销售;只围绕本次应治未治项为主(可顺带提其他牙问题但不展开);每个要点都必须能追到给定事实,不编造。',
'不要写话术正文,只输出大纲 JSON(段数你定,3-7 段:通常 开场 → 切入本次问题 → (可选)顺带关心 → 复查邀约 → 结束)。',
].join('\n');
const VERIFY_SYSTEM = [
'你是严格的医疗话术审核员,任务是**对抗式校验**一份回访话术草稿,默认怀疑、宁严勿松。',
'只依据给定的"本次回访患者信息"判断:',
'① 接地:草稿每个具体说法(诊断/检查所见/医嘱/时间/牙位/医生)能否在给定事实里找到依据?找不到=编造。',
'② 安全:有无报价/费用、疗效承诺、写死具体时间(应保留【时间段】)、患者≤18 却提拍片?',
'全部通过 → pass=true、issues 空;任一不过 → pass=false 并逐条列出 issue(段、问题、修法)。只输出 JSON,不改写草稿。',
].join('\n');
@Injectable()
export class DeepPlanCall implements AiCall<ScriptContext, DeepPlanZ> {
readonly kind = 'script' as const;
readonly callKey = 'draft_plan_script_plan';
readonly promptVersion = 'draft_plan_script@2026-06-06-deep-plan-v1';
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DeepPlanSchema;
buildPrompt(ctx: ScriptContext) {
return { system: PLAN_SYSTEM, prompt: buildPlanPrompt(ctx) };
}
}
@Injectable()
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 defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DeepWriteSchema;
constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {}
buildPrompt(input: DeepWriteInput) {
const composed = composeSystem(input.ctx, this.skillRegistry.getAllSkills(), 'deep');
return { system: composed.systemPrompt, prompt: buildWritePrompt(input) };
}
// 写步失败/拒收 → 回退稳健模板(转多段),保证客服永远有东西可用
fallback(input: DeepWriteInput): DeepWriteZ {
return draftOutputToDeep(stableTemplateFallback(input.ctx));
}
}
@Injectable()
export class DeepVerifyCall implements AiCall<DeepVerifyInput, DeepVerifyZ> {
readonly kind = 'judge' as const;
readonly callKey = 'draft_plan_script_verify';
readonly promptVersion = 'draft_plan_script@2026-06-06-deep-verify-v1';
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DeepVerifySchema;
buildPrompt(input: DeepVerifyInput) {
return { system: VERIFY_SYSTEM, prompt: buildVerifyPrompt(input) };
}
}
import { Injectable, Logger } from '@nestjs/common';
import { AiCallRunnerService } from '../../../../ai-call-runner.service';
import type { AiCallContext } from '../../../../ai-call.interface';
import type { ScriptContext } from '../../shared/input.types';
import { machineSafetyScan } from '../../shared/safety-rules';
import { stableTemplateFallback } from '../stable/stable.call';
import { DeepPlanCall, DeepWriteCall, DeepVerifyCall } from './calls';
import { draftOutputToDeep, type DeepDraft, type DeepPlan, type DeepVerifyIssue } from './types';
export interface DeepScriptResult {
draft: DeepDraft;
source: 'agent' | 'template_fallback';
invocationId: string;
cacheHit: boolean;
costYuan: number;
promptTokens: number;
completionTokens: number;
fallbackReason?: string;
stepsRun: string[];
}
/**
* DeepScriptStrategy —— 深度档 3 步编排(脊柱仍是 AiCallRunner,每步各落 agent_invocations)。
* 规划(plan) → 写(write,多段) → 独立对抗校验(verify) → 不过则 repair(≤1 轮) → 仍不过则稳健兜底。
* 机器安全(禁词/承诺/加粗时间)在策略侧硬扫(machineSafetyScan),与 LLM 校验合并决定是否 repair。
*/
@Injectable()
export class DeepScriptStrategy {
private readonly logger = new Logger(DeepScriptStrategy.name);
private readonly MAX_REPAIR = 1;
constructor(
private readonly runner: AiCallRunnerService,
private readonly planCall: DeepPlanCall,
private readonly writeCall: DeepWriteCall,
private readonly verifyCall: DeepVerifyCall,
) {}
async run(ctx: ScriptContext, runCtx: AiCallContext): Promise<DeepScriptResult> {
const steps: string[] = [];
let cost = 0;
let promptTokens = 0;
let completionTokens = 0;
const acc = (r: { costYuan: number; promptTokens: number; completionTokens: number }) => {
cost += r.costYuan;
promptTokens += r.promptTokens;
completionTokens += r.completionTokens;
};
// ── 步骤1:规划(best-effort;失败用退化大纲,不阻断) ──
let plan: DeepPlan;
try {
const r = await this.runner.run(this.planCall, ctx, runCtx);
acc(r);
plan = r.output;
steps.push('plan');
} catch (err) {
this.logger.warn(`deep plan 失败,用退化大纲: ${(err as Error).message}`);
plan = degeneratePlan();
steps.push('plan:degenerate');
}
// ── 步骤2:写(有 fallback,必返回) ──
let w = await this.runner.run(this.writeCall, { ctx, plan }, runCtx);
acc(w);
let draft = w.output;
let invocationId = w.invocationId;
steps.push(w.source === 'template_fallback' ? 'write:fallback' : 'write');
if (w.source === 'template_fallback') {
return this.done(draft, 'template_fallback', invocationId, cost, promptTokens, completionTokens, steps, w.fallbackReason);
}
// ── 步骤3:独立对抗校验 + 机器扫 ──
const issues: DeepVerifyIssue[] = machineScanIssues(draft);
try {
const v = await this.runner.run(this.verifyCall, { ctx, draft }, runCtx);
acc(v);
steps.push('verify');
if (!v.output.pass) issues.push(...v.output.issues);
} catch (err) {
this.logger.warn(`deep verify 失败,仅依据机器扫: ${(err as Error).message}`);
steps.push('verify:skip');
}
// ── repair(≤1 轮) ──
if (issues.length > 0) {
this.logger.debug(`deep repair: ${issues.length} 个 issue`);
const w2 = await this.runner.run(this.writeCall, { ctx, plan, repairIssues: issues }, runCtx);
acc(w2);
draft = w2.output;
invocationId = w2.invocationId;
steps.push(w2.source === 'template_fallback' ? 'repair:fallback' : 'repair');
// 终检:机器闸仍不过 → 稳健兜底(对抗哲学:接地/安全宁兜底不放行)
const stillBad = machineSafetyScan(joinDraft(draft));
if (w2.source === 'template_fallback' || stillBad.length > 0) {
const fb = draftOutputToDeep(stableTemplateFallback(ctx));
return this.done(fb, 'template_fallback', invocationId, cost, promptTokens, completionTokens, steps, `repair 后仍不过: ${stillBad.join(';')}`);
}
}
return this.done(draft, 'agent', invocationId, cost, promptTokens, completionTokens, steps);
}
private done(
draft: DeepDraft,
source: 'agent' | 'template_fallback',
invocationId: string,
costYuan: number,
promptTokens: number,
completionTokens: number,
stepsRun: string[],
fallbackReason?: string,
): DeepScriptResult {
this.logger.log(`deep done source=${source} steps=[${stepsRun.join('→')}] cost=${costYuan.toFixed(4)}`);
return { draft, source, invocationId, cacheHit: false, costYuan, promptTokens, completionTokens, fallbackReason, stepsRun };
}
}
/** 把整篇草稿拼成纯文本(机器安全扫用) */
function joinDraft(draft: DeepDraft): string {
return draft.sections.map((s) => s.markdown).join('\n');
}
/** 机器扫 → issue 列表(回喂 writer repair) */
function machineScanIssues(draft: DeepDraft): DeepVerifyIssue[] {
return machineSafetyScan(joinDraft(draft)).map((p) => ({
section: '(机器安全闸)',
problem: p,
fix: '删除该表述 / 价格时间改成不承诺、用【时间段】占位',
}));
}
/** 规划失败时的退化大纲(4 段;写步仍能产出) */
function degeneratePlan(): DeepPlan {
return {
sections: [
{ key: 'opening', title: '开场问候', intent: '称呼+自报家门+医生关怀+问近况', points: ['用给定称呼/自报家门/诊断医生/智能时间'] },
{ key: 'missed', title: '切入本次问题', intent: '温和带出本次应治未治项', points: ['结合病历讲隐患与趁早处理的好处'] },
{ key: 'review', title: '复查邀约', intent: '邀约来院复查本次问题', points: ['用【时间段】引导预约'] },
{ key: 'close', title: '结束', intent: '预约成功/不成功两种收尾', points: ['简短有温度,时间用【具体预约时间】占位'] },
],
};
}
import type { ScriptContext } from '../../shared/input.types';
import { buildRichFactBlock } from '../../shared/fact-block';
import type { DeepPlan, DeepWriteInput, DeepVerifyInput } from './types';
/** 步骤1:规划 user prompt —— 厚事实块,产出大纲由 plan 的 system + schema 约束 */
export function buildPlanPrompt(ctx: ScriptContext): string {
return `${buildRichFactBlock(ctx)}
---
# 你的任务(本步:规划,不写话术)
基于以上事实,规划这通回访电话拆成几段、每段讲什么。要点须来自上面事实,不编造。`;
}
/** 步骤2:写 user prompt —— 厚事实块 + 上一步大纲(+ repair 反馈) */
export function buildWritePrompt(input: DeepWriteInput): string {
const outline = renderPlanOutline(input.plan);
const repair = input.repairIssues?.length
? `\n\n# ⚠️ 上一稿校验未过,按以下逐条修正后重写\n${input.repairIssues
.map((i) => `- [${i.section}] 问题:${i.problem} → 修:${i.fix}`)
.join('\n')}`
: '';
return `${buildRichFactBlock(input.ctx)}
---
# 本步大纲(按它逐段写,可微调措辞,不要新增事实)
${outline}${repair}`;
}
/** 步骤3:独立对抗校验 user prompt —— 事实 + 待验草稿(新开上下文,对抗) */
export function buildVerifyPrompt(input: DeepVerifyInput): string {
const draft = input.draft.sections
.map((s, i) => `### 第${i + 1}段:${s.title}\n${s.markdown}`)
.join('\n\n');
return `${buildRichFactBlock(input.ctx)}
---
# 你的任务(本步:对抗校验,不改写)
逐句核对下面这份话术草稿:
1. **接地**:每个说法能否追到上面"本次回访患者信息"里的事实?追不到 = 编造 → 记 issue。
2. **安全**:有无报价/费用、疗效承诺、写死具体时间(应保留【时间段】)、≤18 提拍片?有 = 越界 → 记 issue。
全部通过 → pass=true、issues 空;否则 pass=false 并逐条列出。
## 待校验草稿
${draft}`;
}
function renderPlanOutline(plan: DeepPlan): string {
return plan.sections
.map(
(s, i) =>
`${i + 1}. 【${s.title}】(${s.intent})\n${s.points.map((p) => ` - ${p}`).join('\n')}`,
)
.join('\n');
}
import { z } from 'zod';
/**
* 深度档 3 步各自的输出 schema(都过 AiCallRunner 的 generateObject 强约束)。
*/
// ── 步骤1:规划 ──
export const DeepPlanSchema = z.object({
sections: z
.array(
z.object({
key: z.string().min(1).max(24).describe('段标识(英文短,仅内部串联,如 opening/missed_11/review/close)'),
title: z.string().min(2).max(20).describe('中文小标题(自然口语,贴这通电话)'),
intent: z.string().min(4).max(60).describe('这段要达成什么(一句话)'),
points: z
.array(z.string().min(2).max(120))
.min(1)
.max(5)
.describe('要点(口语化,**每条都须来自给定患者信息/病历事实**,不编造)'),
}),
)
.min(3)
.max(7)
.describe('话术大纲:按这通电话需要拆几段(开场→切入本次问题→可顺带的其他关心→复查邀约→结束;段数你定,3-7 段)'),
});
export type DeepPlanZ = z.infer<typeof DeepPlanSchema>;
// ── 步骤2:写(多段不定) ──
export const DeepWriteSchema = z.object({
tone: z.enum(['warm', 'professional', 'urgent']).describe('整体语气'),
sections: z
.array(
z.object({
title: z.string().min(2).max(20).describe('段小标题(贴这段内容,自然口语,别用刻板模板名)'),
markdown: z
.string()
.min(20)
.max(900)
.describe('该段话术正文。短句分行、行首 `•`;接地病历、不编造;时间用【时间段】占位;无大标题/表情'),
}),
)
.min(3)
.max(7)
.describe('按规划写出的多段话术(段数跟随规划)'),
});
export type DeepWriteZ = z.infer<typeof DeepWriteSchema>;
// ── 步骤3:独立对抗校验 ──
export const DeepVerifySchema = z.object({
pass: z.boolean().describe('草稿每句都能追到给定事实、且无安全越界 → true;否则 false'),
issues: z
.array(
z.object({
section: z.string().min(1).max(30).describe('出问题的段标题或序号'),
problem: z.string().min(4).max(160).describe('问题:编造/接地不实(追不到事实)/安全越界(报价/承诺/写死时间/≤18拍片)'),
fix: z.string().min(4).max(160).describe('修法建议(回喂改写)'),
}),
)
.describe('逐条列出有问题的点;全部 OK 则空数组'),
});
export type DeepVerifyZ = z.infer<typeof DeepVerifySchema>;
# 输出结构 —— 深度档(多段不定 · 写步)
输出 1 个 JSON:`tone` + `sections`(数组,**段数跟随给定大纲**,不是固定 4 段)。每段 `{title, markdown}`:
- `title`:这段的小标题,自然口语、贴这段内容(别用"开场白/告知应治未治"这种刻板模板名)。
- `markdown`:该段话术正文,短句分行、行首 `•`
# 深度档写法
- **按大纲逐段写**:严格跟随"本步大纲"的段数与每段要点;可润色措辞,**不得新增大纲外的事实**
- **接地病历**:风险/好处/检查内容结合"病历(诊断上下文)"里医生的真实记录,用自己的话讲清;医生没记录的别编。
- **多段的价值**:可把"切入本次问题""可顺带关心的其他牙""复查邀约"等拆成各自独立段,讲深讲透;但仍以本次聚焦项为主。
- 仍要:只讲本次 {应治未治项} 为主;以 {诊断医生} 名义体现关怀;短句便于互动。
# 占位与时间
- `{智能称呼}{自报家门}{诊断医生}{牙位}{智能时间显示}` 用给定值替换,输出里不能再出现 `{}`
- `【时间段1】【时间段2】【具体预约时间】` 原样保留,客服手填;⚠️ 严禁写死"周三上午"等具体时间、严禁加粗时间、严禁"已为您约好"承诺。
- 引导预约用「{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」
# 开场/结束
- 第一段开场:先用 {智能称呼} 称呼并确认对方方便 → 自报家门 {自报家门} → 以 {诊断医生} 名义体现关怀 → 用 {智能时间显示} 问近况。
- 最后一段结束:预约成功 / 不成功两种,简短有温度;时间用【具体预约时间】占位。
/**
* 深度档类型 —— 3 步 pipeline(规划 → 写 → 独立对抗校验 + repair)的中间产物 + 输入契约。
*
* 共用脊柱(AiCallRunner,每步各落 agent_invocations)、输入(ScriptContext)、安全闸、兜底;
* 差异:多步编排(DeepScriptStrategy)+ 多段不定输出 + 逐句接地校验。
*/
import type { ScriptContext, DraftPlanScriptOutput } from '../../shared/input.types';
// ── 步骤1:规划(planner)产物 ──
export interface DeepPlanSection {
/** 段标识(英文短,如 'opening' / 'missed_11' / 'review' / 'close';仅内部串联) */
key: string;
/** 中文小标题(写步会再润色,这里给方向) */
title: string;
/** 这段要达成什么(一句) */
intent: string;
/** 要点(口语化,均须来自 ScriptContext 事实) */
points: string[];
}
export interface DeepPlan {
sections: DeepPlanSection[];
}
// ── 步骤2:写(writer)产物 = 深度档草稿(多段不定) ──
export interface DeepDraftSection {
title: string;
markdown: string;
}
export interface DeepDraft {
tone: 'warm' | 'professional' | 'urgent';
sections: DeepDraftSection[];
}
// ── 步骤3:独立对抗校验(verifier)产物 ──
export interface DeepVerifyIssue {
/** 出问题的段标题 / 序号 */
section: string;
/** 问题(接地不实 / 安全越界 / 编造) */
problem: string;
/** 修法建议(回喂 writer repair) */
fix: string;
}
export interface DeepVerify {
/** 全部接地 + 安全 → true */
pass: boolean;
issues: DeepVerifyIssue[];
}
// ── 各步 AiCall 输入(复合;runner 落 inputSnapshot 用) ──
export interface DeepWriteInput {
ctx: ScriptContext;
plan: DeepPlan;
/** repair 轮:把校验不过的点回喂 writer 修(≤1 轮) */
repairIssues?: DeepVerifyIssue[];
}
export interface DeepVerifyInput {
ctx: ScriptContext;
draft: DeepDraft;
}
/** 4 字段稳健输出 → 多段 DeepDraft(深度档兜底:写步失败回退稳健模板时用) */
export function draftOutputToDeep(out: DraftPlanScriptOutput): DeepDraft {
const t = out.sectionTitles;
return {
tone: out.tone,
sections: [
{ title: t?.opening ?? '开场白', markdown: out.opening },
{ title: t?.informMissed ?? '告知应治未治', markdown: out.informMissed },
{ title: t?.reviewAdvice ?? '复查建议', markdown: out.reviewAdvice },
{ title: t?.closing ?? '结束回访语', markdown: out.closing },
],
};
}
/**
* phrasing —— 稳健档**口语文案模板**(tier-specific:仅稳健档用)。
*
* 设计决策(2026-06):风险要点 / 治疗优势 / 复查时长 是**稳健模板**(为填 4 段模板槽而写的口语文案),
* 不是 tier-agnostic 知识。标准/深度档**去模板** —— 不用这些文案,病种只给 canonical label
* (shared/disease-knowledge),措辞由 LLM 接 病历(医生真实医嘱/检查/建议)自行组织。
*
* 本文件 = (原 script-facts 的病种文案字典)+(原 script-common/disease-knowledge 的访问逻辑)合并收口。
* 病种 canonical label 留 shared(diseaseLabelForSubKey);本文件只补 risks/advantages/reviewDuration 文案。
*
* resolveDisease 行为与重组前一致(稳健档输出不变):subKey 优先精确,文本兜底归一。
*/
import { diseaseLabelForSubKey } from '../../shared/disease-knowledge';
// ─────────────────────────────────────────────────────────
// 漏诊项关键要点配置(稳健档"告知应治未治"小节2/3 灵活组合用)
// 渐进式披露:user prompt 只塞**命中那一个病种**的要点,不发全表。
// ─────────────────────────────────────────────────────────
export interface MissedKeyPoints {
/** 风险要点(口语化,2-4 条灵活组合) */
risks: string[];
/** 治疗优势要点(趁现在/早处理) */
advantages: string[];
/** 年龄适应性(可选,按年龄组取一句) */
ageFit?: Partial<Record<'青年' | '中年' | '老年', string>>;
}
export const MISSED_DIAGNOSIS_KEY_POINTS: Record<string, MissedKeyPoints> = {
牙槽骨吸收: {
risks: [
'时间久了,牙齿容易松动,吃东西会不太舒服',
'牙缝可能慢慢变大,食物容易卡进去',
'如果放着不管,牙齿还有脱落的风险',
'可能需要拔牙再种,医生说这样治疗周期会更长',
'后面如果骨头吸收严重了,想要做修复就比较难了',
],
advantages: ['趁现在早处理,能稳住牙槽骨', '早一点介入,对牙齿稳定有帮助,也避免将来多花功夫'],
ageFit: { 青年: '年轻时治疗效果更好', 中年: '正是关键时期,及时治疗很重要', 老年: '虽然年龄大了,但治疗仍有意义' },
},
缺失牙: {
risks: [
'缺牙的地方如果不处理,旁边的牙齿可能会慢慢歪掉',
'上面的牙齿还可能伸长出来,位置就不对了',
'时间一长,吃东西也会不太舒服,容易咬不到位',
'食物卡得多了,还容易蛀牙、牙周炎',
'不修复的话骨头会萎缩,后面再想种牙就困难一些',
],
advantages: ['及时修复能恢复咀嚼、保护邻牙', '趁现在牙槽骨条件还不错,早点处理效果更好'],
ageFit: { 青年: '年轻时修复适应性强', 中年: '正值事业期,形象很重要', 老年: '晚年生活质量需要保障' },
},
残根: {
risks: ['长时间不处理可能引发反复炎症', '容易形成慢性牙龈肿痛', '可能影响邻牙健康', '后期拔除难度会增加'],
advantages: ['越早处理越简单', '避免引发急性疼痛', '减少对邻牙的影响'],
},
残冠: {
risks: ['容易堆积食物导致反复牙龈炎', '可能出现牙齿折裂扩大损伤', '影响咬合舒适度'],
advantages: ['及时处理避免二次损伤', '利于后续修复效果', '咬合功能更稳定'],
},
囊肿: {
risks: ['如果持续增大可能压迫周围组织', '容易导致骨吸收', '可能影响邻牙位置', '拖久了处理范围会更大'],
advantages: ['及早关注避免扩大范围', '早期处理恢复更快', '减少对周围骨组织的破坏'],
},
根尖周炎: {
risks: ['容易导致持续性咬牙痛', '可能形成小脓包或脸肿', '长期不处理会影响其他牙', '感染可能逐渐加重'],
advantages: ['越早处理越容易控制炎症', '避免发展成急性疼痛', '能更好地保住这颗牙'],
},
乳牙滞留: {
risks: ['可能影响恒牙正常萌出', '易导致牙列不齐', '恒牙可能被挤偏'],
advantages: ['及时处理更有利于恒牙发育', '减少以后矫正难度', '让牙列更整齐'],
},
楔状缺损: {
risks: ['越磨越深可能导致牙髓敏感', '长期易出现冷热敏感', '咬合压力不均会加重缺损'],
advantages: ['越早处理越容易控制敏感', '可以保护牙体结构', '改善咬合舒适度'],
},
龋齿: {
risks: ['如果放任不管会越变越深', '可能引发牙痛或感染', '可能需要更复杂的治疗'],
advantages: ['早处理范围小、恢复快', '能避免发展成根管问题', '保持牙齿长期稳定'],
},
阻生牙: {
risks: ['可能反复发炎肿痛', '挤压邻牙导致牙列不齐', '可能形成囊肿或引发感染'],
advantages: ['早观察早处理更安全', '减少发炎频率', '避免推挤邻牙'],
},
埋伏牙: {
risks: ['可能影响周围牙根结构', '容易引发局部炎症', '可能挤压造成牙列拥挤'],
advantages: ['早期监测干预更安全', '减少后期并发症', '利于牙列整体稳定'],
},
多生牙: {
risks: ['可能阻挡恒牙正常萌出', '会影响邻牙位置', '可能导致牙列拥挤'],
advantages: ['越早处理越不影响恒牙', '减少后期矫正难度', '让牙列发育更顺畅'],
},
// ── PAC 子场景(应治未治)对应口径 ──
牙周炎: {
risks: [
'牙龈容易出血、红肿,刷牙时尤其明显',
'时间久了牙槽骨吸收,牙齿会慢慢松动',
'牙缝可能变大,食物容易塞牙',
'不控制的话,后面可能要拔牙',
],
advantages: ['趁现在做基础治疗,能把炎症控制住', '早点干预,牙齿能保留得更久'],
ageFit: { 青年: '年轻时牙周恢复能力强', 中年: '正是该好好维护的时候', 老年: '维护好牙周,晚年吃东西更舒服' },
},
错颌畸形: {
risks: [
'牙齿排列不齐,刷牙容易刷不干净,易蛀牙、牙龈发炎',
'咬合不好,长期可能影响咀嚼和颞下颌关节',
'也会影响笑容和自信',
],
advantages: ['早点评估,矫治方案选择更多', '趁现在牙周条件好,矫正更稳'],
ageFit: { 青年: '年轻时矫正配合度和效果都更好', 中年: '成人也能做隐形矫正,不影响工作' },
},
牙体损伤: {
risks: ['缺损放着不管会越来越大', '可能出现冷热敏感或牙痛', '严重了可能伤到牙神经,要做根管'],
advantages: ['早修复范围小、花费少', '能保护剩余牙体,避免折裂'],
},
牙龈问题: {
risks: ['牙龈反复红肿出血', '可能慢慢退缩,牙根暴露敏感', '不处理会影响牙齿稳固'],
advantages: ['早处理容易控制', '保护牙龈和牙槽骨健康'],
},
恒牙萌出空间不足: {
risks: ['恒牙可能没地方长,容易长歪或拥挤', '将来矫正难度会更大'],
advantages: ['趁换牙期早干预,引导恒牙顺利萌出', '减少以后正畸的复杂程度'],
},
儿牙早矫: {
risks: ['不良习惯或颌骨发育问题越拖越难纠正', '可能影响恒牙排列和脸型发育'],
advantages: ['替牙期是早矫黄金期,效果好、周期短', '趁现在引导,省去将来复杂矫正'],
},
};
// ─────────────────────────────────────────────────────────
// 复查时长配置(非治疗时长)— "复查建议·检查说明"小节用
// ─────────────────────────────────────────────────────────
export const TREATMENT_DURATION: Record<string, string> = {
儿牙早矫: '复查检查约30-45分钟,医生会详细评估宝宝的恒牙萌出情况和间隙保持需求',
恒牙萌出空间不足: '复查检查约30分钟,医生会详细评估恒牙萌出情况',
缺失牙: '复查检查约30分钟,了解缺失牙位目前状况',
牙槽骨吸收: '复查检查约30-45分钟,需要仔细检查牙周健康状况',
牙周病: '复查检查约30-45分钟,评估牙周健康情况',
牙周炎: '复查检查约30-45分钟,评估牙周健康情况',
错颌畸形: '复查检查约30分钟,医生评估咬合与矫治方案',
牙体损伤: '复查检查约20-30分钟,查看牙体缺损情况',
根尖周炎: '复查检查约20-30分钟,查看牙髓和根尖情况',
牙龈问题: '复查检查约20-30分钟,评估牙龈健康',
阻生牙: '复查检查约20-30分钟,评估阻生牙位置',
颌骨囊肿: '复查检查约30分钟,评估囊肿范围',
龋齿: '复查检查约20-30分钟,查看牙齿状况',
其他: '复查检查约30分钟',
};
// ─────────────────────────────────────────────────────────
// subKey → 文案字典键(刻意分列:同病种两套字典中文 key 可能不同,如 jaw_cyst)
// ─────────────────────────────────────────────────────────
const SUBKEY_PHRASING: Record<string, { kp: string; dur: string }> = {
missing_tooth: { kp: '缺失牙', dur: '缺失牙' },
caries_no_filling: { kp: '龋齿', dur: '龋齿' },
endo_no_rct: { kp: '根尖周炎', dur: '根尖周炎' },
perio_no_srp: { kp: '牙周炎', dur: '牙周炎' },
ortho_no_consult: { kp: '错颌畸形', dur: '错颌畸形' },
hard_tissue_damage: { kp: '牙体损伤', dur: '牙体损伤' },
gum_alveolar_lesion: { kp: '牙龈问题', dur: '牙龈问题' },
impacted_tooth: { kp: '阻生牙', dur: '阻生牙' },
// 修复:keypoints 字典里是「囊肿」,duration 字典里是「颌骨囊肿」—— 分列对齐,不再静默丢 risks
jaw_cyst: { kp: '囊肿', dur: '颌骨囊肿' },
development_eruption: { kp: '恒牙萌出空间不足', dur: '恒牙萌出空间不足' },
};
// ── 文本兜底归一(处理无 subKey 的边界) ──
const MISSED_PRIORITY = ['儿牙早矫', '恒牙萌出空间不足', '缺失牙', '牙槽骨吸收'];
function canonicalMissedKey(missedText: string | null | undefined): string | null {
const s = (missedText ?? '').trim();
if (!s) return null;
const keys = [
...new Set([...MISSED_PRIORITY, ...Object.keys(MISSED_DIAGNOSIS_KEY_POINTS), ...Object.keys(TREATMENT_DURATION)]),
];
for (const k of keys) if (s.includes(k)) return k;
return null;
}
function lookupReviewDuration(key: string | null): string {
return (key && TREATMENT_DURATION[key]) || TREATMENT_DURATION['其他']!;
}
export interface DiseaseKnowledge {
/** 患者口径病种名(话术里说的,如"缺失牙") */
label: string;
/** 不处理的风险(口语,稳健挑 1-2) */
risks: string[];
/** 趁早处理的好处 */
advantages: string[];
/** 复查说明文案 */
reviewDuration: string;
/** 年龄适应性(可选) */
ageFit?: MissedKeyPoints['ageFit'];
}
/**
* reason → 病种文案(**稳健档 prompt + fallback 共用入口**)。
* label 走 shared(canonical);risks/advantages/reviewDuration 走本文件(稳健文案)。
* subKey 优先精确;未映射 → 文本归一兜底。行为与重组前 disease-knowledge.resolveDisease 一致。
*/
export function resolveDisease(
reason:
| { subKey?: string | null; dxCode?: string | null; reason?: string | null; scenarioLabel?: string | null }
| null
| undefined,
fallbackLabel: string,
): DiseaseKnowledge {
const base = (reason?.subKey ?? '').split('@')[0]!.trim();
const label = diseaseLabelForSubKey(base) ?? null;
const phrasing = base ? SUBKEY_PHRASING[base] : undefined;
if (label && phrasing) {
const kp = MISSED_DIAGNOSIS_KEY_POINTS[phrasing.kp];
return {
label,
risks: kp?.risks ?? [],
advantages: kp?.advantages ?? [],
reviewDuration: TREATMENT_DURATION[phrasing.dur] ?? TREATMENT_DURATION['其他']!,
ageFit: kp?.ageFit,
};
}
// 兜底:无 subKey / 未映射 → 文本归一
const text = `${reason?.scenarioLabel ?? ''} ${reason?.reason ?? ''}`;
const key = canonicalMissedKey(text);
const kp = key ? MISSED_DIAGNOSIS_KEY_POINTS[key] : null;
return {
label: label ?? key ?? fallbackLabel,
risks: kp?.risks ?? [],
advantages: kp?.advantages ?? [],
reviewDuration: lookupReviewDuration(key),
ageFit: kp?.ageFit,
};
}
import type { DraftPlanScriptInput } from './input.types'; import type { DraftPlanScriptInput } from '../../shared/input.types';
import { smartDateDisplay } from './script-facts'; import { smartDateDisplay, toothFriendly } from '../../shared/script-facts';
import { resolveDisease } from './script-common/disease-knowledge'; import { resolveDisease } from './phrasing';
import { deidentifyDoctor } from './script-common/pii'; import { deidentifyDoctor } from '../../shared/pii';
/** /**
* Prompt 版本管理约定: * Prompt 版本管理约定:
...@@ -35,8 +35,9 @@ import { deidentifyDoctor } from './script-common/pii'; ...@@ -35,8 +35,9 @@ import { deidentifyDoctor } from './script-common/pii';
export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string { export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string {
const { patient, clinicName, plan, clinicalContext } = input; const { patient, clinicName, plan, clinicalContext } = input;
// 单一聚焦:只取 priorityScore 最高那条 reason;其他 reason 的内容**完全不进 prompt**(不泄漏其他项)。 // 单一聚焦:reasons[0] 即聚焦项(orchestrator 已按 priorityScore 降序,单一源);
const top = [...plan.reasons].sort((a, b) => b.priorityScore - a.priorityScore)[0]; // 其他 reason 的内容**完全不进 prompt**(不泄漏其他项)。
const top = plan.reasons[0];
// ⭐ 去名留称呼:user prompt 给"姓+敬称"的**可用称呼**(徐女士/韩医生),去掉名(维/雅静全名不进)。 // ⭐ 去名留称呼:user prompt 给"姓+敬称"的**可用称呼**(徐女士/韩医生),去掉名(维/雅静全名不进)。
// 不是 token,LLM 直接用;客服也直接看这个称呼。真名(全名)在内部 ScriptContext,不进 prompt。 // 不是 token,LLM 直接用;客服也直接看这个称呼。真名(全名)在内部 ScriptContext,不进 prompt。
...@@ -51,10 +52,27 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string ...@@ -51,10 +52,27 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
// 项目相关:日期/主诉/医生优先取"该应治未治项那次诊断"的,而非泛泛"最近一次就诊" // 项目相关:日期/主诉/医生优先取"该应治未治项那次诊断"的,而非泛泛"最近一次就诊"
const projectDate = top?.triggerDate ? new Date(top.triggerDate) : lastVisitDate; const projectDate = top?.triggerDate ? new Date(top.triggerDate) : lastVisitDate;
const dateDisplay = smartDateDisplay(projectDate, now) ?? '上次'; const dateDisplay = smartDateDisplay(projectDate, now) ?? '上次';
const chiefComplaint = top?.triggerChiefComplaint ?? clinicalContext.lastChiefComplaint ?? null; const chiefComplaint = top?.medicalRecord?.chiefComplaint ?? clinicalContext.lastChiefComplaint ?? null;
// 病种知识(单一访问源: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;
// 牙位:聚焦项的 FDI → 患者俗称(如 "上门牙");全口/无具体牙位 → null(不放牙位槽)
const toothText = top?.toothPositions?.length ? toothFriendly(top.toothPositions) : null;
// 本次召回目标(plan.goal 原文,内部参考):指导复查/邀约方向,LLM 不要逐字念
const goal = plan.goal?.trim() || null;
// 医生那次的交代(医嘱/建议/治疗计划)—— 来自聚焦病历,可引用医生原话体现关怀;
// 处置/检查所见暂不进稳健档(噪声 + 编造风险),留全集给深度档。
const mr = top?.medicalRecord;
const doctorNoteLines: string[] = [];
if (mr?.doctorAdvice) doctorNoteLines.push(`- 医嘱(原话):${mr.doctorAdvice}`);
if (mr?.recommendations?.length) {
doctorNoteLines.push(
`- 医生建议:${mr.recommendations
.map((r) => (r.toothPosition ? `${r.text}(${toothFriendly(r.toothPosition)})` : r.text))
.join(';')}`,
);
}
if (mr?.treatmentPlanText) doctorNoteLines.push(`- 治疗计划:${mr.treatmentPlanText}`);
const riskLines = disease.risks.length const riskLines = disease.risks.length
? disease.risks.map((r) => ` - ${r}`).join('\n') ? disease.risks.map((r) => ` - ${r}`).join('\n')
...@@ -77,6 +95,8 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string ...@@ -77,6 +95,8 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
clinicalContext.completedTreatmentCount > 0 clinicalContext.completedTreatmentCount > 0
? '老客户(之前在本诊所看过),语气可更熟络温和' ? '老客户(之前在本诊所看过),语气可更熟络温和'
: '首诊/新客户,语气专业可信为主'; : '首诊/新客户,语气专业可信为主';
// ≤18 / 年龄未知 → 禁拍片 belt(青少年走成人模板时,成人模板含"拍片"句,这里硬提醒删)
const noXray = patient.age == null || patient.age <= 18;
// 自报家门:有登录客服名 → "我是X诊所的{岗位}{姓名}"(小王=简称,非全名,直接用);无名 → 通用"客服" // 自报家门:有登录客服名 → "我是X诊所的{岗位}{姓名}"(小王=简称,非全名,直接用);无名 → 通用"客服"
const selfIntro = input.agent?.name const selfIntro = input.agent?.name
...@@ -93,17 +113,24 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string ...@@ -93,17 +113,24 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
- 那次主诉:${chiefComplaint ?? '无记录'} - 那次主诉:${chiefComplaint ?? '无记录'}
- {诊断医生}:${doctorSurname}医生${guardianHint ? `\n- 触达说明:${guardianHint}` : ''} - {诊断医生}:${doctorSurname}医生${guardianHint ? `\n- 触达说明:${guardianHint}` : ''}
## 本次应治未治(只讲这一个) ## 本次应治未治
- {应治未治项}:${disease.label} - {应治未治项}:${disease.label}${toothText ? `\n- {牙位}:${toothText}(已是患者口语俗称,直接用` : ''}
- {风险要点}: - {风险要点}:
${riskLines} ${riskLines}
- {治疗优势}: - {治疗优势}:
${advLines} ${advLines}
- {复查时长}:${reviewDuration} - {复查时长}:${reviewDuration}${
doctorNoteLines.length
? `\n\n## 医生那次的交代(可引用医生原话体现"医生一直惦记着你";⚠️ 只引用、不演绎、不报价、不承诺,复查框架不变;与本次{应治未治项}无关的别提)\n${doctorNoteLines.join('\n')}`
: ''
}
## 本次目标(内部参考 — 指导复查/邀约方向,不要逐字念给患者)
- ${goal ?? '邀约来院复查,让医生评估本次问题'}
## 患者 ## 患者
- ${basics} - ${basics}
## 语气 ## 语气
- ${toneHint}`; - ${toneHint}${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**(删除模板里的拍片句)' : ''}`;
} }
# 输出结构(4 模块,顺序固定,缺一不可)—— 稳健档
输出 1 个 JSON:`tone` + 4 段 Markdown 字符串,顺序固定:
1. `opening` 第一部分·开场白
2. `informMissed` 第二部分·告知应治未治
3. `reviewAdvice` 第三部分·复查建议
4. `closing` 第四部分·结束回访语
# 严格执行要求 - 核心强制规则
🚨 4个模块必须全部包含,缺一不可!
✅ 模块顺序固定:开场白 → 告知应治未治 → 复查建议 → 结束回访语
✅ 告知应治未治、复查建议必须分成短句,便于客服与患者互动
⚠️ 如果输出缺少任何一个模块、或打乱顺序,整个话术将被视为不合格!
# 占位符约定(两种,含义不同)
- `{xxx}` = **要替换**的占位:用"本次回访患者信息"里给的同名值填进去(如 {智能称呼}{应治未治项}{牙位}{诊断医生}{风险要点}{复查时长})。输出里不能再出现 `{}`
- `【xxx】` = **原样保留**的占位:不要替换、照抄进话术,客服打电话时手动填。只有这几个:【时间段1】【时间段2】【具体预约时间】。
- 注:{智能称呼}/{诊断医生} 给的是"姓+敬称"(如 徐女士/韩医生),直接用即可,不是 `【】` 占位。
- 注:{牙位} 已是患者口语俗称(如"上门牙"),直接用;**若本次未给 {牙位}**(全口/无具体牙位),就不提牙位。
- 另:结束语的分支标签【预约成功】【预约不成功】也照常输出。
# 直接使用给定的事实(稳健档槽位)
⚠️ 开场顺序固定:**先用{智能称呼}称呼并确认对方方便 → 再自报家门用{自报家门}** → 以{诊断医生}名义体现关怀 → 用{智能时间显示}问近况。
本次只讲{应治未治项}、健康提醒从{风险要点}挑、检查说明用{复查时长}原文。以上值直接用,不要自己重算、改写或改格式、不要调换开场顺序。
# 时间用占位
- 引导预约严格用「{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」
- 结束语·预约成功保留「我们【具体预约时间】见」。
- ⚠️【时间段1】【时间段2】【具体预约时间】原样保留占位,严禁替换成"周三上午"等具体时间。❌ 严禁加粗具体时间、严禁"已为您约好 / 敲定 X"承诺。
# 输出前自查
✅ 4个模块全部包含且顺序正确?
✅ 开场白以{诊断医生}医生名义,体现医生交代的关怀?
✅ 只专注 {应治未治项} 一个,没提其他项目?
✅ 告知应治未治、复查建议都分了短句?
✅ 称呼 / 日期 / 复查时长用的是给定的值?时间保留了【时间段】占位、没写死具体时间?
✅ 无费用 / 方案 / 推销 / 虚构?主动给了【时间段】预约选择?避免了机器人式语言?
...@@ -5,6 +5,7 @@ priority: 100 ...@@ -5,6 +5,7 @@ priority: 100
applies: applies:
ageMin: 13 ageMin: 13
version: 1.3.0 version: 1.3.0
tiers: ['stable']
--- ---
# 成人应治未治话术模板(≥13 岁 / 年龄未知默认) # 成人应治未治话术模板(≥13 岁 / 年龄未知默认)
...@@ -22,13 +23,13 @@ tone 默认 professional(专业稳重);熟客可 warm;急性场景可 ur ...@@ -22,13 +23,13 @@ tone 默认 professional(专业稳重);熟客可 warm;急性场景可 ur
## ═══ 第二部分:告知应治未治 ═══ ## ═══ 第二部分:告知应治未治 ═══
[分成 4 个短句,便于互动沟通,语调温和提醒,非推销] [分成 4 个短句,便于互动沟通,语调温和提醒,非推销]
小节1 - 现状描述(短句1):用口语告诉患者上次检查时发现的问题,突出"温和提醒"和"医生发现"的语气,不要直接说"我们发现了……"。 小节1 - 现状描述(短句1):用口语告诉患者上次检查时发现的问题,突出"温和提醒"和"医生发现"的语气,不要直接说"我们发现了……"。**若本次给了 {牙位},自然带上**(如"您{牙位}这边有…"),让患者对得上是哪颗牙;未给则不提牙位。
✅ 推荐表达方式: ✅ 推荐表达方式:
• 上次来检查的时候,{诊断医生}医生注意到您有{应治未治项}的情况 • 上次来检查的时候,{诊断医生}医生注意到您{牙位}有{应治未治项}的情况
• 医生那次检查时提到,您有一点{应治未治项}的问题 • 医生那次检查时提到,您有一点{应治未治项}的问题
• 当时有观察到一些{应治未治项}的情况,医生是挺关注的 • 当时有观察到一些{应治未治项}的情况,医生是挺关注的
• 有一点{应治未治项}的表现,医生希望您留意一下 • 有一点{应治未治项}的表现,医生希望您留意一下
• 上次拍片的时候,看出来这边有点{应治未治项}的迹象(患者≤18 岁时删除此句,禁提拍片) • 上次拍片的时候,看出来这边有点{应治未治项}的迹象(⚠️ 仅当患者明确 >18 岁才可用此句;≤18 岁或年龄未知一律删除,禁提拍片)
小节2 - 健康提醒(短句2):灵活组合 3~4 个{风险要点},输出温和提醒。语气自然像人说话,每句突出一个重点,不堆砌、不用"如A、B、C"书面句式、不吓唬人。 小节2 - 健康提醒(短句2):灵活组合 3~4 个{风险要点},输出温和提醒。语气自然像人说话,每句突出一个重点,不堆砌、不用"如A、B、C"书面句式、不吓唬人。
✅ 示例: ✅ 示例:
......
...@@ -4,7 +4,8 @@ description: 儿童回访话术模板(年龄 ≤12 岁,对象=家长)。沿用 ...@@ -4,7 +4,8 @@ description: 儿童回访话术模板(年龄 ≤12 岁,对象=家长)。沿用
priority: 100 priority: 100
applies: applies:
ageMax: 12 ageMax: 12
version: 1.3.0 version: 1.4.0
tiers: ['stable']
--- ---
# 儿童话术模板(≤12 岁,对象=家长) # 儿童话术模板(≤12 岁,对象=家长)
...@@ -25,7 +26,7 @@ tone 默认 warm(温和家常,适合与家长沟通)。 ...@@ -25,7 +26,7 @@ tone 默认 warm(温和家常,适合与家长沟通)。
[分成短句,便于互动沟通] [分成短句,便于互动沟通]
(适用于本次应治未治项 = 乳牙过早缺失 / 恒牙萌出空间不足 / 儿牙早矫) (适用于本次应治未治项 = 乳牙过早缺失 / 恒牙萌出空间不足 / 儿牙早矫)
小节1 - 现状描述(短句1):现在宝宝有一颗乳牙已经脱落了,但是恒牙还没有长出来 小节1 - 现状描述(短句1):现在宝宝有一颗乳牙已经脱落了,但是恒牙还没有长出来
小节2 - 位置说明(短句2):这颗乳牙的位置在{缺失牙位}(保留{缺失牙位}占位,客服按 plan 里的牙位填左上/右上/左下/右下,不要自己编 小节2 - 位置说明(短句2):这颗乳牙的位置在{牙位}(直接用给定的 {牙位} 俗称,如"上门牙";若本次未给 {牙位} 则略过位置说明,不要自己编牙位
小节3 - 不治疗危害(短句3):如果咱们不做处理,这颗乳牙的位置和空间可能会丧失 小节3 - 不治疗危害(短句3):如果咱们不做处理,这颗乳牙的位置和空间可能会丧失
小节4 - 后果说明(短句4):将来恒牙萌出就不会在它该在的位置 小节4 - 后果说明(短句4):将来恒牙萌出就不会在它该在的位置
小节5 - 解决方案(短句5):所以我们要做一个装置来维持这个间隙,这个装置叫间隙保持器。到时候也请医生看一下。 小节5 - 解决方案(短句5):所以我们要做一个装置来维持这个间隙,这个装置叫间隙保持器。到时候也请医生看一下。
...@@ -33,10 +34,10 @@ tone 默认 warm(温和家常,适合与家长沟通)。 ...@@ -33,10 +34,10 @@ tone 默认 warm(温和家常,适合与家长沟通)。
## ═══ 第三部分:复查建议 ═══ ## ═══ 第三部分:复查建议 ═══
[通过短句说明复查重要性,有温度有引导性] [通过短句说明复查重要性,有温度有引导性]
小节1 - 复查时间(短句1):建议3个月左右带宝宝来院检查 小节1 - 复查重要性(短句1):方便的话带宝宝来院复查一下{应治未治项}的情况,让医生看看(针对本次问题,别框成常规体检)
小节2 - 检查内容(短句2):一方面做全面检查,看有没有蛀牙,有没有不良习惯 小节2 - 检查说明(短句2):用给定的 {复查时长} 原文说明这次检查看什么(如"评估宝宝恒牙萌出情况");若没给则说"医生会帮宝宝仔细检查一下"
小节3 - 预防措施(短句3):还要看看要不要给宝宝涂氟保护牙齿 小节3 - 专业建议(短句3):也请{诊断医生}医生再仔细看一下宝宝的情况
小节4 - 专业建议(短句4):也请{诊断医生}医生再仔细看一下宝宝的情况 小节4 - 预防关怀(短句4,可选):来的时候也可以顺便看看牙齿清洁、要不要涂氟保护一下(仅作顺带关怀,不替代本次{应治未治项})
小节5 - 引导预约(短句5):[有引导性,给出具体时间选择] 小节5 - 引导预约(短句5):[有引导性,给出具体时间选择]
• 如果方便的话您看最近有没有时间,我帮您预约一个儿牙专家的时间,您带宝宝过来看一看 • 如果方便的话您看最近有没有时间,我帮您预约一个儿牙专家的时间,您带宝宝过来看一看
• {诊断医生}医生【时间段1】和【时间段2】这两个时间段有空 • {诊断医生}医生【时间段1】和【时间段2】这两个时间段有空
......
import { Injectable, Logger } from '@nestjs/common';
import type { AiCall } from '../../../../ai-call.interface';
import { DraftPlanScriptSchema } from './schema';
import type { DraftPlanScriptInput, DraftPlanScriptOutput } from '../../shared/input.types';
import { buildDraftPlanScriptPrompt } from './prompt';
import { composeSystem } from '../../shared/skill-composer';
import { DraftPlanScriptSkillRegistry } from '../../shared/skill-registry.service';
import { smartDateDisplay } from '../../shared/script-facts';
import { resolveDisease } from './phrasing';
import { deidentifyDoctor } from '../../shared/pii';
import { SCRIPT_SAFETY_RULES } from '../../shared/safety-rules';
// 安全规则(禁词/承诺/加粗时间)走单一源 shared/safety-rules.ts,三档共用,不再各档内联。
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。
* 已改:不加粗 + (示例) 后缀 + 显式"以诊所排班为准"。
*/
export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
const { patient, clinicName, plan, clinicalContext } = input;
const salutation = patient.salutation; // 统一通话称呼(年龄+性别+监护人 aware,orchestrator 算好)
// 漏诊项 = 聚焦项 reasons[0](orchestrator 已 priorityScore 降序,单一源)→ 转换层归一
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
? new Date(Date.now() - clinicalContext.daysSinceLastVisit * 86400_000)
: null;
const dateDisplay = smartDateDisplay(dateBasis, new Date()) ?? '上次';
const disease = resolveDisease(topReason ?? null, plan.primaryScenarioLabel);
const risk = disease.risks[0] ?? '这个问题如果一直拖着,后面处理可能更复杂';
const adv = disease.advantages[0] ?? '趁现在早点处理会更省心';
const reviewDuration = disease.reviewDuration;
return {
tone: 'warm',
opening: `• ${salutation}您好,我是${clinicName}的客服
${doctor}医生特意交代我来关注您的后续情况
• 自从${dateDisplay}检查后,您口腔情况怎么样?`,
informMissed: `• 上次检查的时候,${doctor}医生注意到您有${disease.label}的情况
${risk}
${adv}
• 这个${doctor}医生也特别嘱咐我们提醒您一下`,
reviewAdvice: `• 最近方便的话,来院复查一下
• 让${doctor}医生帮您再仔细看看
${reviewDuration}
${doctor}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?`,
closing: `【预约成功】
• 好的,那我们【具体预约时间】见,祝您生活愉快
【预约不成功】
• 没关系,我下周再联系您,祝您生活愉快`,
};
}
/**
* promptVersion(base 版本;具体装配的 skill 组合见 agent_invocations.input_snapshot.skills_used 的 composeHash)。
* 改 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: 还原原模板
@Injectable()
export class DraftPlanScriptCall
implements AiCall<DraftPlanScriptInput, DraftPlanScriptOutput>
{
private readonly logger = new Logger(DraftPlanScriptCall.name);
readonly kind = 'script' as const;
readonly callKey = 'draft_plan_script';
readonly promptVersion = DRAFT_PLAN_SCRIPT_PROMPT_VERSION;
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DraftPlanScriptSchema;
readonly safetyRules = safetyRules;
constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {}
buildPrompt(input: DraftPlanScriptInput) {
// composer 装配 system(base-system.md + 命中的 skill 正文);user prompt 按患者拼事实
const composed = composeSystem(input, this.skillRegistry.getAllSkills(), 'stable');
if (composed.matchedSkills.length === 0) {
this.logger.warn(
`compose 0 个 skill 命中(scenario=${composed.context.scenario}, ` +
`dx=${composed.context.diagnosisCodes.join(',')}, ` +
`pop=${composed.context.population}, rel=${composed.context.relationship}) — ` +
`system 回退仅 base 部分,可能效果下降`,
);
} else {
this.logger.debug(
`compose skills: ${composed.matchedSkills.map((s) => s.frontmatter.name).join(', ')} ` +
`(hash=${composed.composeHash})`,
);
}
return {
system: composed.systemPrompt,
prompt: buildDraftPlanScriptPrompt(input),
};
}
fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
return stableTemplateFallback(input);
}
}
import type { DraftPlanScriptInput } from '../../shared/input.types';
import { buildRichFactBlock } from '../../shared/fact-block';
/**
* 标准档 user prompt —— 厚事实块(共享 buildRichFactBlock):病历全 + 其他reason + 近期治疗 + 目标,
* 病种只给名(去文案模板)。输出指令(去模板·4段标题不定·自由编排)在 system(format.standard + schema)。
*/
export function buildStandardScriptPrompt(input: DraftPlanScriptInput): string {
return buildRichFactBlock(input);
}
import { z } from 'zod';
/**
* 标准档输出 schema —— 与稳健档**同 4 字段形态**(opening/informMissed/reviewAdvice/closing),
* 但 `.describe` **放宽**:只说该段目的与底线,**不给句位/小节脚手架**,段内由 LLM 自由编排。
*
* UI/存储与稳健档解耦:orchestrator 的 renderMarkdown/sections 对两档一视同仁(都映射这 4 字段)。
*/
export const StandardScriptSchema = z.object({
tone: z
.enum(['warm', 'professional', 'urgent'])
.describe('整体语气标签:warm=温和家常 / professional=专业稳重 / urgent=有时效紧迫'),
// ⭐ 标准档"标题不定":4 段标题由 LLM 自起,贴这个患者/这通电话,别用刻板模板名
sectionTitles: z
.object({
opening: z.string().min(2).max(20).describe('开场段小标题(你起,自然口语,如"先打个招呼")'),
informMissed: z.string().min(2).max(20).describe('告知段小标题(你起,贴本次问题,如"上次发现的小情况")'),
reviewAdvice: z.string().min(2).max(20).describe('复查建议段小标题(你起)'),
closing: z.string().min(2).max(20).describe('结束段小标题(你起)'),
})
.describe('为下面 4 段各起一个简短自然的小标题;⚠️ 标准档标题不固定,由你定,**别用"开场白/告知应治未治/复查建议/结束回访语"这种刻板模板名**'),
opening: z
.string()
.min(50)
.max(600)
.describe(
[
'【开场白】先用 {智能称呼} 称呼并确认对方方便 → 自报家门 {自报家门} → 以 {诊断医生} 医生名义体现关怀 → 用 {智能时间显示} 问近况。',
'分短句(行首 `•`);自己组织,自然有温度;禁大标题/分隔符/表情。',
].join('\n'),
),
informMissed: z
.string()
.min(80)
.max(900)
.describe(
[
'【告知应治未治】只讲本次这一个 {应治未治项}(给了 {牙位} 就带上)。',
'以"{诊断医生}医生上次检查发现"的口吻,结合"病历(诊断上下文)"里医生的真实记录,用自己的话口语化讲清"不处理的隐患"+"趁早处理的好处";温和提醒、非吓唬、非推销。',
'不要套用现成模板句;医生没记录的别编。分短句(行首 `•`)。',
].join('\n'),
),
reviewAdvice: z
.string()
.min(80)
.max(900)
.describe(
[
'【复查建议】邀约来院复查本次问题(对齐"本次目标"方向,但用关怀口吻、不推销治疗);用自己的话说明这次复查大概看什么(别写死分钟数)。',
'引导预约严格用「{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」',
'⚠️【时间段】保留占位,禁写死具体时间。分短句(行首 `•`)。',
].join('\n'),
),
closing: z
.string()
.min(40)
.max(500)
.describe(
[
'【结束回访语】预约成功 / 不成功两种,简短有温度。',
'【预约成功】「我们【具体预约时间】见」+ 祝福;【预约不成功】「下周再联系您」+ 祝福。',
'禁承诺式"已为您约好";禁加粗具体时间。',
].join('\n'),
),
});
export type StandardScriptOutputZ = z.infer<typeof StandardScriptSchema>;
# 输出结构(4 模块,顺序固定)—— 标准档(去模板,段内自由编排)
输出 1 个 JSON:`tone` + 4 段 Markdown 字符串,顺序固定:
1. `opening` 开场白
2. `informMissed` 告知应治未治
3. `reviewAdvice` 复查建议
4. `closing` 结束回访语
# 标准档写法(和稳健档的区别)
- **不给固定句位/小节模板**:每段你自己组织短句、自己决定讲几句、怎么衔接,更贴这个患者。
- **病种措辞自供**:本次只给 {应治未治项} 病种名,**不给现成的风险/优势/复查话术**。风险与"趁早处理的好处"由你结合**下方"病历(诊断上下文)"里医生的真实记录**(检查所见/医嘱/建议)+ 牙科常识,用自己的话口语化讲清楚。
- **接地优先**:能引用医生那次的真实记录就引用(体现"医生一直惦记着你"),**不要脱离病历凭空演绎**;医生没记录的别编。
- 仍要:每段分短句(行首 `•`)、便于互动;只讲本次这一个 {应治未治项};以 {诊断医生} 名义体现关怀。
# 各段要点(轮廓,不是句位)
- **opening**:先用 {智能称呼} 称呼并确认对方方便 → 自报家门 {自报家门} → 以 {诊断医生} 医生名义体现关怀 → 用 {智能时间显示} 问近况。
- **informMissed**:自然带出本次 {应治未治项}(若给了 {牙位} 就带上),用医生发现的口吻;结合病历讲清"不处理的隐患"和"趁早处理的好处",温和提醒非吓唬、非推销。
- **reviewAdvice**:邀约来院复查本次问题(对齐"本次目标"方向,但用关怀口吻、不推销治疗);用自己的话说明这次复查大概看什么(**别写死分钟数**,可结合病历);引导预约严格用「{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」
- **closing**:预约成功/不成功两种,简短有温度;时间用【具体预约时间】占位。
# 时间用占位
⚠️【时间段1】【时间段2】【具体预约时间】原样保留,严禁替换成"周三上午"等具体时间;严禁加粗具体时间、严禁"已为您约好"承诺。
# 占位符约定
- `{xxx}`(如 {智能称呼}{应治未治项}{牙位}{诊断医生}{自报家门}{智能时间显示})= 用"本次回访患者信息"里给的同名值替换,输出里不能再出现 `{}`
- `【时间段1】【时间段2】【具体预约时间】` = 原样保留,客服手填。
- {智能称呼}/{诊断医生} 已是"姓+敬称",直接用。{牙位} 已是俗称;未给则不提牙位。
import { Injectable, Logger } from '@nestjs/common';
import type { AiCall } from '../../../../ai-call.interface';
import { StandardScriptSchema } from './schema';
import type { DraftPlanScriptInput, DraftPlanScriptOutput } from '../../shared/input.types';
import { buildStandardScriptPrompt } from './prompt';
import { composeSystem } from '../../shared/skill-composer';
import { DraftPlanScriptSkillRegistry } from '../../shared/skill-registry.service';
import { SCRIPT_SAFETY_RULES } from '../../shared/safety-rules';
import { stableTemplateFallback } from '../stable/stable.call';
/**
* StandardScriptCall —— 标准档(投入档之二)。
*
* 与稳健档共用脊柱(AiCallRunner)、输入(ScriptContext)、安全闸(SCRIPT_SAFETY_RULES)、
* 兜底(stableTemplateFallback);**差异**只在策略三件:
* - system:composeSystem(input, skills, 'standard') → base-common + format.standard + 人群共性知识
* (不含稳健句位模板;skill 按 tiers 过滤)
* - user:buildStandardScriptPrompt → 厚输入(病历全 + 其他reason + 近期治疗)、病种只给名(去文案模板)
* - schema:StandardScriptSchema(同 4 字段,describe 放宽,段内 LLM 自由编排)
*
* callKey 仍用 'draft_plan_script'(同一逻辑调用),档位差异落 promptVersion → eval 可按版本切档对比。
*/
const STANDARD_PROMPT_VERSION = 'draft_plan_script@2026-06-06-standard-v2'; // v2: 4 段标题不定(LLM 出 sectionTitles)
@Injectable()
export class StandardScriptCall implements AiCall<DraftPlanScriptInput, DraftPlanScriptOutput> {
private readonly logger = new Logger(StandardScriptCall.name);
readonly kind = 'script' as const;
readonly callKey = 'draft_plan_script';
readonly promptVersion = STANDARD_PROMPT_VERSION;
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = StandardScriptSchema;
readonly safetyRules = SCRIPT_SAFETY_RULES;
constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {}
buildPrompt(input: DraftPlanScriptInput) {
const composed = composeSystem(input, this.skillRegistry.getAllSkills(), 'standard');
if (composed.matchedSkills.length === 0) {
this.logger.warn(
`[standard] compose 0 个 skill 命中(scenario=${composed.context.scenario}, ` +
`dx=${composed.context.diagnosisCodes.join(',')}, pop=${composed.context.population}) — 仅 base`,
);
} else {
this.logger.debug(
`[standard] compose skills: ${composed.matchedSkills.map((s) => s.frontmatter.name).join(', ')} ` +
`(hash=${composed.composeHash})`,
);
}
return {
system: composed.systemPrompt,
prompt: buildStandardScriptPrompt(input),
};
}
// 标准档失败 → 回退到稳健模板(安全网,客服永远有东西可用)
fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
return stableTemplateFallback(input);
}
}
...@@ -5,11 +5,17 @@ import { fmtYearMonth } from '@pac/utils'; ...@@ -5,11 +5,17 @@ import { fmtYearMonth } from '@pac/utils';
import { planScenarioLabel, personaFeatureMeta, subLabelZh, treatmentCategoryNameZh } from '@pac/types'; import { planScenarioLabel, personaFeatureMeta, subLabelZh, treatmentCategoryNameZh } from '@pac/types';
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 type { AiCall, AiCallContext } from '../ai-call.interface';
import { DraftPlanScriptCall } from '../calls/draft-plan-script/tiers/stable/stable.call';
import { StandardScriptCall } from '../calls/draft-plan-script/tiers/standard/standard.call';
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 type { import type {
DraftPlanScriptInput, DraftPlanScriptInput,
DraftPlanScriptOutput, DraftPlanScriptOutput,
} from '../calls/draft-plan-script/input.types'; ScriptMedicalRecord,
} from '../calls/draft-plan-script/shared/input.types';
/** /**
* 流式事件 — 给前端 SSE 用。 * 流式事件 — 给前端 SSE 用。
...@@ -19,7 +25,7 @@ export type PlanScriptStreamEvent = ...@@ -19,7 +25,7 @@ export type PlanScriptStreamEvent =
| { type: 'start'; invocationId: string; modelId: string; promptVersion: string } | { type: 'start'; invocationId: string; modelId: string; promptVersion: string }
| { | {
type: 'partial'; type: 'partial';
structured: Partial<DraftPlanScriptOutput>; structured: unknown; // 稳健/标准=Partial<DraftPlanScriptOutput>;深度无 partial。前端只用 sections
sections: ScriptSectionDto[]; sections: ScriptSectionDto[];
} }
| { | {
...@@ -27,7 +33,7 @@ export type PlanScriptStreamEvent = ...@@ -27,7 +33,7 @@ export type PlanScriptStreamEvent =
planScriptId: string | null; planScriptId: string | null;
agentInvocationId: string; agentInvocationId: string;
source: 'agent' | 'template_fallback'; source: 'agent' | 'template_fallback';
structured: DraftPlanScriptOutput; structured: unknown; // 稳健/标准=DraftPlanScriptOutput;深度=DeepDraft。前端只用 sections/content
content: string; content: string;
sections: ScriptSectionDto[]; sections: ScriptSectionDto[];
costYuan: number; costYuan: number;
...@@ -45,7 +51,8 @@ export type PlanScriptStreamEvent = ...@@ -45,7 +51,8 @@ export type PlanScriptStreamEvent =
* - closing 结束回访语 * - closing 结束回访语
*/ */
export interface ScriptSectionDto { export interface ScriptSectionDto {
id: 'opening' | 'informMissed' | 'reviewAdvice' | 'closing'; /** 稳健/标准:固定 4 id(opening/informMissed/reviewAdvice/closing);深度:段数不定,id = s0/s1/…(string) */
id: string;
label: string; label: string;
durationHint: string; durationHint: string;
markdown: string; markdown: string;
...@@ -70,6 +77,8 @@ export interface PlanScriptGenerateOptions { ...@@ -70,6 +77,8 @@ export interface PlanScriptGenerateOptions {
dryRun?: boolean; dryRun?: boolean;
/** ⭐ 当前回访客服(自报家门用):{name 姓名, roleTitle 患者侧岗位称呼} */ /** ⭐ 当前回访客服(自报家门用):{name 姓名, roleTitle 患者侧岗位称呼} */
agent?: { name: string | null; roleTitle: string }; agent?: { name: string | null; roleTitle: string };
/** ⭐ 投入档(默认 stable)。stable=4段模板填空 / standard=4段去模板自由编排。深度档后续。 */
tier?: ScriptTier;
} }
export interface PlanScriptGenerateResult { export interface PlanScriptGenerateResult {
...@@ -80,7 +89,7 @@ export interface PlanScriptGenerateResult { ...@@ -80,7 +89,7 @@ export interface PlanScriptGenerateResult {
cacheHit: boolean; cacheHit: boolean;
costYuan: number; costYuan: number;
content: string; // 渲染后的 Markdown content: string; // 渲染后的 Markdown
structured: DraftPlanScriptOutput; // 结构化原始输出(给调试 / 前端做高级展示) structured: DraftPlanScriptOutput | DeepDraft; // 结构化原始输出(稳健/标准=4字段;深度=多段 DeepDraft)
} }
@Injectable() @Injectable()
...@@ -91,8 +100,17 @@ export class PlanScriptOrchestrator { ...@@ -91,8 +100,17 @@ export class PlanScriptOrchestrator {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly runner: AiCallRunnerService, private readonly runner: AiCallRunnerService,
private readonly call: DraftPlanScriptCall, private readonly call: DraftPlanScriptCall,
private readonly standardCall: StandardScriptCall,
private readonly deepStrategy: DeepScriptStrategy,
) {} ) {}
/** 按投入档选单 call 策略(稳健/标准;深度走 deepStrategy 多步,不在此) */
private resolveCall(
tier: ScriptTier | undefined,
): AiCall<DraftPlanScriptInput, DraftPlanScriptOutput> {
return tier === 'standard' ? this.standardCall : this.call;
}
/** /**
* 复用入口:给实时坐席辅助教练装配同一套 DraftPlanScriptInput(纯 DB 读,无副作用)。 * 复用入口:给实时坐席辅助教练装配同一套 DraftPlanScriptInput(纯 DB 读,无副作用)。
* 实时教练用它的 patient/plan/clinicalContext 拼 Qwen instructions,跟话术生成共享上下文。 * 实时教练用它的 patient/plan/clinicalContext 拼 Qwen instructions,跟话术生成共享上下文。
...@@ -117,25 +135,45 @@ export class PlanScriptOrchestrator { ...@@ -117,25 +135,45 @@ export class PlanScriptOrchestrator {
// ─── 1. 装配 input(纯 DB 读,不 pull) ─── // ─── 1. 装配 input(纯 DB 读,不 pull) ───
const { plan, patient, persona, facts, guardian } = await this.loadPlanContext(planId); const { plan, patient, persona, facts, guardian } = await this.loadPlanContext(planId);
const input = this.buildCallInput({ plan, patient, persona, facts, guardian, agent: options.agent }); const input = this.buildCallInput({ plan, patient, persona, facts, guardian, agent: options.agent });
const patientNameMasked = input.patient.salutation;
// ─── 2. 跑 AI 调用 ─── const runCtx: AiCallContext = {
const workflowRunId = randomUUID();
const result = await this.runner.run(this.call, input, {
hostId: plan.hostId, hostId: plan.hostId,
tenantId: plan.tenantId, tenantId: plan.tenantId,
workflowRunId, workflowRunId: randomUUID(),
linkedPatientId: patient.id, linkedPatientId: patient.id,
linkedPersonaId: persona?.id, linkedPersonaId: persona?.id,
linkedPlanId: plan.id, linkedPlanId: plan.id,
bustCache: options.bustCache ?? false, bustCache: options.bustCache ?? false,
modelIdOverride: options.modelIdOverride, modelIdOverride: options.modelIdOverride,
evalMode: 'production', evalMode: 'production',
}); };
// ─── 3. 渲染 Markdown(称呼/医生已是去名后的可用形式,无需回填)─── // ─── 2. 跑(按投入档选策略;深度走 3 步 pipeline,其余单 call)+ 渲染 ───
const content = renderMarkdown(result.output, { patientNameMasked: input.patient.salutation }); let content: string;
let source: 'agent' | 'template_fallback';
let invocationId: string;
let cacheHit: boolean;
let costYuan: number;
let structured: DraftPlanScriptOutput | DeepDraft;
if (options.tier === 'deep') {
const r = await this.deepStrategy.run(input, runCtx);
content = renderMarkdownFromSections(deepDraftToSections(r.draft), r.draft.tone, { patientNameMasked });
source = r.source;
invocationId = r.invocationId;
cacheHit = r.cacheHit;
costYuan = r.costYuan;
structured = r.draft;
} else {
const result = await this.runner.run(this.resolveCall(options.tier), input, runCtx);
content = renderMarkdown(result.output, { patientNameMasked });
source = result.source;
invocationId = result.invocationId;
cacheHit = result.cacheHit;
costYuan = result.costYuan;
structured = result.output;
}
// ─── 4. 写 PlanScript(dryRun 时跳过) ─── // ─── 3. 写 PlanScript(dryRun 时跳过) ───
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({
...@@ -146,30 +184,16 @@ export class PlanScriptOrchestrator { ...@@ -146,30 +184,16 @@ export class PlanScriptOrchestrator {
planId: plan.id, planId: plan.id,
content, content,
status: 'ready', status: 'ready',
source: result.source, source,
agentInvocationId: result.invocationId, agentInvocationId: invocationId,
},
update: {
content,
status: 'ready',
source: result.source,
agentInvocationId: result.invocationId,
}, },
update: { content, status: 'ready', source, agentInvocationId: invocationId },
select: { id: true }, select: { id: true },
}); });
planScriptId = row.id; planScriptId = row.id;
} }
return { return { planId: plan.id, planScriptId, agentInvocationId: invocationId, source, cacheHit, costYuan, content, structured };
planId: plan.id,
planScriptId,
agentInvocationId: result.invocationId,
source: result.source,
cacheHit: result.cacheHit,
costYuan: result.costYuan,
content,
structured: result.output,
};
} }
/** /**
...@@ -197,8 +221,72 @@ export class PlanScriptOrchestrator { ...@@ -197,8 +221,72 @@ export class PlanScriptOrchestrator {
return; return;
} }
const patientNameMasked = input.patient.salutation;
// ── 深度档:3 步 pipeline 不走 runner.stream(无逐字 partial);start → 跑完 → done ──
if (options.tier === 'deep') {
const runCtx: AiCallContext = {
hostId: plan.hostId,
tenantId: plan.tenantId,
workflowRunId: randomUUID(),
linkedPatientId: patient.id,
linkedPersonaId: persona?.id,
linkedPlanId: plan.id,
bustCache: true,
modelIdOverride: options.modelIdOverride,
evalMode: 'production',
};
yield {
type: 'start',
invocationId: '',
modelId: options.modelIdOverride ?? 'deepseek-v4-flash',
promptVersion: 'draft_plan_script@deep-pipeline',
};
let r;
try {
r = await this.deepStrategy.run(input, runCtx);
} catch (err) {
yield { type: 'error', message: err instanceof Error ? err.message : String(err) };
return;
}
const sections = deepDraftToSections(r.draft);
const content = renderMarkdownFromSections(sections, r.draft.tone, { patientNameMasked });
let planScriptId: string | null = null;
if (!options.dryRun) {
const row = await this.prisma.planScript.upsert({
where: { planId: plan.id },
create: {
hostId: plan.hostId,
tenantId: plan.tenantId,
planId: plan.id,
content,
status: 'ready',
source: r.source,
agentInvocationId: r.invocationId,
},
update: { content, status: 'ready', source: r.source, agentInvocationId: r.invocationId },
select: { id: true },
});
planScriptId = row.id;
}
yield {
type: 'done',
planScriptId,
agentInvocationId: r.invocationId,
source: r.source,
structured: r.draft,
content,
sections,
costYuan: r.costYuan,
promptTokens: r.promptTokens,
completionTokens: r.completionTokens,
fallbackReason: r.fallbackReason,
};
return;
}
const workflowRunId = randomUUID(); const workflowRunId = randomUUID();
const events = this.runner.stream(this.call, input, { const events = this.runner.stream(this.resolveCall(options.tier), input, {
hostId: plan.hostId, hostId: plan.hostId,
tenantId: plan.tenantId, tenantId: plan.tenantId,
workflowRunId, workflowRunId,
...@@ -209,8 +297,6 @@ export class PlanScriptOrchestrator { ...@@ -209,8 +297,6 @@ export class PlanScriptOrchestrator {
modelIdOverride: options.modelIdOverride, modelIdOverride: options.modelIdOverride,
evalMode: 'production', evalMode: 'production',
}); });
const patientNameMasked = input.patient.salutation;
let lastStructured: DraftPlanScriptOutput | null = null; let lastStructured: DraftPlanScriptOutput | null = null;
let lastInvocationId: string | null = null; let lastInvocationId: string | null = null;
let lastSource: 'agent' | 'template_fallback' = 'agent'; let lastSource: 'agent' | 'template_fallback' = 'agent';
...@@ -376,34 +462,46 @@ export class PlanScriptOrchestrator { ...@@ -376,34 +462,46 @@ export class PlanScriptOrchestrator {
// evidence.factIds[0] = 主触发 fact;关联 patient_facts.content.doctor_name + occurredAt // evidence.factIds[0] = 主触发 fact;关联 patient_facts.content.doctor_name + occurredAt
// LLM 在 followup 段必须引用触发医生(姜医生发现智齿)而非全患者高频医生(李医生) // LLM 在 followup 段必须引用触发医生(姜医生发现智齿)而非全患者高频医生(李医生)
const factById = new Map(facts.map((f) => [f.id, f])); const factById = new Map(facts.map((f) => [f.id, f]));
// encounter_external_id → 该次 EMR(取主诉 illness_desc 用) // EMR 两套关联键(对齐前端 emr-soap-view):
// 主键 emr_external_id ← diagnosis/treatment.source_encounter_external_id 指向它(parser 口径)
// 兜底 encounter_external_id(挂号号)
const emrByExternalId = new Map<string, FactRow>();
const emrByEncounter = new Map<string, FactRow>(); const emrByEncounter = new Map<string, FactRow>();
for (const f of facts) { for (const f of facts) {
if (f.type !== 'emr_record') continue; if (f.type !== 'emr_record') continue;
const enc = (f.content as Record<string, unknown> | null)?.encounter_external_id as string | undefined; const cc = f.content as Record<string, unknown> | null;
const extId = cc?.emr_external_id as string | undefined;
const enc = cc?.encounter_external_id as string | undefined;
if (extId && !emrByExternalId.has(extId)) emrByExternalId.set(extId, f);
if (enc && !emrByEncounter.has(enc)) emrByEncounter.set(enc, f); if (enc && !emrByEncounter.has(enc)) emrByEncounter.set(enc, f);
} }
/** 诊断 fact → 它那次接诊的 EMR fact(主键 emr_external_id,兜底挂号号) */
const emrForDiagnosis = (lead: FactRow): FactRow | undefined => {
const c = lead.content as Record<string, unknown> | null;
const encId = (c?.source_encounter_external_id ?? c?.encounter_external_id) as string | undefined;
if (!encId) return undefined;
return emrByExternalId.get(encId) ?? emrByEncounter.get(encId);
};
// ⭐ 单一聚焦:trigger 信息要"项目相关"——这个漏诊项**那次诊断**的医生/日期/主诉, // ⭐ 单一聚焦:trigger 信息要"项目相关"——这个漏诊项**那次诊断**的医生/日期/主诉,
// 不是泛泛的最近一次就诊(否则"缺牙2025-12诊断"却说"自从2026-03洁牙后"就错位) // 不是泛泛的最近一次就诊(否则"缺牙2025-12诊断"却说"自从2026-03洁牙后"就错位)
const resolveReasonTrigger = (r: PlanWithReasons['reasons'][number]) => { // 每条 reason 解析:触发医生/日期 + 该次接诊的完整病历(挂到 reason.medicalRecord)
const resolveReasonTrigger = (
r: PlanWithReasons['reasons'][number],
): { doctor: string | null; date: string | null; medicalRecord: ScriptMedicalRecord | null } => {
const evidence = (r.evidence ?? {}) as { factIds?: string[] }; const evidence = (r.evidence ?? {}) as { factIds?: string[] };
const leadFactId = evidence.factIds?.[0]; const leadFactId = evidence.factIds?.[0];
if (!leadFactId) return { doctor: null, date: null, chiefComplaint: null }; const lead = leadFactId ? factById.get(leadFactId) : undefined;
const lead = factById.get(leadFactId); if (!lead) return { doctor: null, date: null, medicalRecord: null };
if (!lead) return { doctor: null, date: null, chiefComplaint: null };
const c = lead.content as Record<string, unknown> | null; const c = lead.content as Record<string, unknown> | null;
const doctor = (c?.doctor_name as string | undefined)?.trim() || null; const doctor = (c?.doctor_name as string | undefined)?.trim() || null;
const date = lead.occurredAt ? lead.occurredAt.toISOString().slice(0, 10) : null; const date = lead.occurredAt ? lead.occurredAt.toISOString().slice(0, 10) : null;
// 该诊断所在接诊的主诉(项目相关) const medicalRecord = buildMedicalRecord(emrForDiagnosis(lead), facts);
const encId = return { doctor, date, medicalRecord };
((c?.source_encounter_external_id ?? c?.encounter_external_id) as string | undefined) || null;
const emr = encId ? emrByEncounter.get(encId) : undefined;
const chiefComplaint =
((emr?.content as Record<string, unknown> | undefined)?.illness_desc as string | undefined)?.trim() ||
null;
return { doctor, date, chiefComplaint };
}; };
// ⭐ reasons 单一排序(自包含,不依赖调用方 query 顺序):priorityScore DESC → reasons[0]=聚焦项
const sortedReasons = [...plan.reasons].sort((a, b) => b.priorityScore - a.priorityScore);
return { return {
patient: { patient: {
// 真名(数据;不直出 user prompt,走 token 回填)。ID 类不进(关联走 linked_patient_id 列) // 真名(数据;不直出 user prompt,走 token 回填)。ID 类不进(关联走 linked_patient_id 列)
...@@ -413,8 +511,10 @@ export class PlanScriptOrchestrator { ...@@ -413,8 +511,10 @@ export class PlanScriptOrchestrator {
gender: patient.gender, gender: patient.gender,
age: patientAge, age: patientAge,
medicalRecordNumber: patient.medicalRecordNumber ?? null, medicalRecordNumber: patient.medicalRecordNumber ?? null,
// 监护人(未成年):让话术知道"打给谁、患者是孩子",可空 // 监护人(未成年):让话术知道"打给谁、患者是孩子",可空。relationship=原始键(结构化)
guardian: guardian ? { relationshipLabel: guardian.relationshipLabel, name: guardian.name } : null, guardian: guardian
? { relationship: guardian.relationship, 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 客服中心",必须翻译成中文名
...@@ -429,33 +529,40 @@ export class PlanScriptOrchestrator { ...@@ -429,33 +529,40 @@ export class PlanScriptOrchestrator {
primaryScenarioKey: plan.reasons[0]?.scenario ?? null, primaryScenarioKey: plan.reasons[0]?.scenario ?? null,
priorityScore: plan.priorityScore, priorityScore: plan.priorityScore,
goal: plan.goal, goal: plan.goal,
reasons: plan.reasons.map((r) => { reasons: sortedReasons.map((r) => {
const trig = resolveReasonTrigger(r); const trig = resolveReasonTrigger(r);
// sub_key 形如 'caries_no_filling@36',base 去 @ 后缀 // sub_key 形如 'caries_no_filling@36',base 去 @ 后缀
const baseSubKey = (r.subKey ?? '').split('@')[0] || null; const baseSubKey = (r.subKey ?? '').split('@')[0] || null;
// 牙位结构化:优先 signals.toothPosition,兜底 sub_key 的 @后缀
const sig = (r.signals ?? {}) as { toothPosition?: string | null };
const toothPositions = splitToothPositions(
sig.toothPosition ?? (r.subKey ?? '').split('@')[1] ?? null,
);
return { return {
scenarioLabel: planScenarioLabel(r.scenario), scenarioLabel: planScenarioLabel(r.scenario),
subKey: baseSubKey, subKey: baseSubKey,
dxCode: subKeyToDxCode(baseSubKey), dxCode: subKeyToDxCode(baseSubKey),
toothPositions,
reason: r.reason, reason: r.reason,
priorityScore: r.priorityScore, priorityScore: r.priorityScore,
triggerDoctor: trig.doctor, triggerDoctor: trig.doctor,
triggerDate: trig.date, triggerDate: trig.date,
triggerChiefComplaint: trig.chiefComplaint, medicalRecord: trig.medicalRecord,
}; };
}), }),
}, },
personaHighlights: (persona?.features ?? []).slice(0, 5).map((f) => ({ personaHighlights: (persona?.features ?? []).slice(0, 5).map((f) => ({
key: f.key,
label: personaFeatureMeta(f.key).label, label: personaFeatureMeta(f.key).label,
description: f.description, description: f.description,
})), })),
clinicalContext: { clinicalContext: {
daysSinceLastVisit, daysSinceLastVisit,
lastVisitSummary: summarizeLastVisit(latestEnc), lastVisit: summarizeLastVisit(latestEnc),
lastChiefComplaint, lastChiefComplaint,
// pendingTreatments 直接从 plan.reasons 派生 — 召回触发的 reason 本身就是"未启动治疗" // pendingTreatments 直接从 plan.reasons 派生 — 召回触发的 reason 本身就是"未启动治疗"
// reasons 是 SQL 算出的权威集(旧版 DX_TO_CAT 内置 map 漏 K01/K03/K06/K07) // reasons 是 SQL 算出的权威集(旧版 DX_TO_CAT 内置 map 漏 K01/K03/K06/K07)
pendingTreatments: extractPendingFromReasons(plan.reasons), pendingTreatments: extractPendingFromReasons(sortedReasons),
// ⚠️ 治疗链(chain-composer)已废弃 → 不再给"链阶段/在管"摘要,只给"做过什么治疗" // ⚠️ 治疗链(chain-composer)已废弃 → 不再给"链阶段/在管"摘要,只给"做过什么治疗"
recentTreatments: summarizeRecentTreatments(facts), recentTreatments: summarizeRecentTreatments(facts),
completedTreatmentCount: countCompletedTreatments(facts), completedTreatmentCount: countCompletedTreatments(facts),
...@@ -477,18 +584,19 @@ function renderMarkdown( ...@@ -477,18 +584,19 @@ function renderMarkdown(
out: DraftPlanScriptOutput, out: DraftPlanScriptOutput,
meta: { patientNameMasked: string }, meta: { patientNameMasked: string },
): string { ): string {
const t = out.sectionTitles;
return `> 患者:${meta.patientNameMasked} · 语气:${toneLabel(out.tone)} return `> 患者:${meta.patientNameMasked} · 语气:${toneLabel(out.tone)}
## 开场白 ## ${t?.opening ?? '开场白'}
${out.opening} ${out.opening}
## 告知应治未治 ## ${t?.informMissed ?? '告知应治未治'}
${out.informMissed} ${out.informMissed}
## 复查建议 ## ${t?.reviewAdvice ?? '复查建议'}
${out.reviewAdvice} ${out.reviewAdvice}
## 结束回访语 ## ${t?.closing ?? '结束回访语'}
${out.closing} ${out.closing}
`; `;
} }
...@@ -510,34 +618,56 @@ function renderSections( ...@@ -510,34 +618,56 @@ function renderSections(
out: Partial<DraftPlanScriptOutput>, out: Partial<DraftPlanScriptOutput>,
_meta: { patientNameMasked: string }, _meta: { patientNameMasked: string },
): ScriptSectionDto[] { ): ScriptSectionDto[] {
// 标准/深度档:label 用 LLM 起的标题(out.sectionTitles);稳健档无 → 回退固定标题
const t = out.sectionTitles;
return [ return [
{ {
id: 'opening', id: 'opening',
label: '开场白', label: t?.opening ?? '开场白',
durationHint: '30 秒', durationHint: '30 秒',
markdown: out.opening ?? '', markdown: out.opening ?? '',
}, },
{ {
id: 'informMissed', id: 'informMissed',
label: '告知应治未治', label: t?.informMissed ?? '告知应治未治',
durationHint: '1–2 分钟', durationHint: '1–2 分钟',
markdown: out.informMissed ?? '', markdown: out.informMissed ?? '',
}, },
{ {
id: 'reviewAdvice', id: 'reviewAdvice',
label: '复查建议', label: t?.reviewAdvice ?? '复查建议',
durationHint: '1 分钟', durationHint: '1 分钟',
markdown: out.reviewAdvice ?? '', markdown: out.reviewAdvice ?? '',
}, },
{ {
id: 'closing', id: 'closing',
label: '结束回访语', label: t?.closing ?? '结束回访语',
durationHint: '30 秒', durationHint: '30 秒',
markdown: out.closing ?? '', markdown: out.closing ?? '',
}, },
]; ];
} }
/** 深度档多段草稿 → ScriptSectionDto[](id=s0/s1/…,label=LLM 起的标题) */
function deepDraftToSections(draft: DeepDraft): ScriptSectionDto[] {
return draft.sections.map((s, i) => ({
id: `s${i}`,
label: s.title,
durationHint: '',
markdown: s.markdown,
}));
}
/** 从 ScriptSectionDto[] 拼 PlanScript.content markdown(深度档/统一用) */
function renderMarkdownFromSections(
sections: ScriptSectionDto[],
tone: DraftPlanScriptOutput['tone'],
meta: { patientNameMasked: string },
): string {
const body = sections.map((s) => `## ${s.label}\n${s.markdown}`).join('\n\n');
return `> 患者:${meta.patientNameMasked} · 语气:${toneLabel(tone)}\n\n${body}\n`;
}
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
// 小工具(从 fact 提炼 LLM 真正需要的几条信息) // 小工具(从 fact 提炼 LLM 真正需要的几条信息)
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
...@@ -550,7 +680,101 @@ function calcAge(birthDate: Date): number { ...@@ -550,7 +680,101 @@ function calcAge(birthDate: Date): number {
return age; return age;
} }
function summarizeLastVisit(enc: FactRow | undefined): string | null { /**
* 病历 — 一次接诊的完整病历(SOAP 全字段,结构化),挂到每条 reason.medicalRecord。
* 字段集 + 同次诊断关联(source_encounter_external_id === emr_external_id)对齐前端 emr-soap-view。
*/
function buildMedicalRecord(
emr: FactRow | undefined,
facts: FactRow[],
): ScriptMedicalRecord | null {
if (!emr) return null;
const c = (emr.content as Record<string, unknown> | null) ?? {};
const emrExtId = String(c.emr_external_id ?? '');
// A 评估:同次接诊的诊断(被召回诊断所在)
const diagnoses = emrExtId
? facts
.filter(
(f) =>
f.type === 'diagnosis_record' &&
String((f.content as Record<string, unknown> | null)?.source_encounter_external_id ?? '') ===
emrExtId,
)
.map((f) => {
const dc = (f.content as Record<string, unknown> | null) ?? {};
return {
code: (dc.code as string | undefined)?.trim() || null,
nameZh: ((dc.name_zh ?? dc.name) as string | undefined)?.trim() || null,
toothPosition: (dc.tooth_position as string | undefined)?.trim() || null,
fromImageAi: String(dc.code_source ?? '') === 'image_ai',
};
})
: [];
// 医生建议(同次接诊 recommendation_record,如"建议拔除18/38/48")
const recommendations = emrExtId
? facts
.filter(
(f) =>
f.type === 'recommendation_record' &&
String((f.content as Record<string, unknown> | null)?.source_encounter_external_id ?? '') ===
emrExtId,
)
.map((f) => {
const rc = (f.content as Record<string, unknown> | null) ?? {};
return {
text: ((rc.name_zh ?? rc.name ?? rc.code) as string | undefined)?.trim() || '',
toothPosition: (rc.tooth_position as string | undefined)?.trim() || null,
};
})
.filter((r) => r.text)
: [];
const str = (v: unknown): string | null => {
const s = (v as string | undefined)?.trim?.();
return s && s !== 'null' ? s : null;
};
return {
date: emr.occurredAt ? emr.occurredAt.toISOString().slice(0, 10) : null,
doctorName: str(c.doctor_name),
chiefComplaint: str(c.illness_desc),
presentIllness: str(c.pre_illness),
pastHistory: str(c.past_history),
generalCondition: str(c.general_condition),
examFindings: parseEmrJsonArray(c.exam_findings),
diagnoses,
disposals: parseEmrJsonArray(c.disposal),
doctorAdvice: str(c.doctor_advice),
treatmentPlanText: str(c.treatment_plan),
diagnosisText: str(c.diagnosis_text),
recommendations,
};
}
/** 解析 host EMR 的 JSON 数组字段(exam_findings / disposal):字符串或 array 都接受;只保留有 message 的 */
function parseEmrJsonArray(raw: unknown): Array<{ toothPosition: string | null; message: string }> {
let arr: unknown[] = [];
if (Array.isArray(raw)) arr = raw;
else if (typeof raw === 'string' && raw.trim() && raw !== 'null') {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) arr = parsed;
} catch {
arr = [];
}
}
return arr
.map((x) => {
const o = (x ?? {}) as { toothPosition?: string; message?: string };
return {
toothPosition: (o.toothPosition ?? '').trim() || null,
message: (o.message ?? '').trim(),
};
})
.filter((x) => x.message);
}
function summarizeLastVisit(
enc: FactRow | undefined,
): DraftPlanScriptInput['clinicalContext']['lastVisit'] {
if (!enc) return null; if (!enc) return null;
const c = enc.content as Record<string, unknown> | null; const c = enc.content as Record<string, unknown> | null;
let summary = enc.summary ?? (c?.diagnosis as string | undefined) ?? (c?.treatment_category as string | undefined); let summary = enc.summary ?? (c?.diagnosis as string | undefined) ?? (c?.treatment_category as string | undefined);
...@@ -560,8 +784,11 @@ function summarizeLastVisit(enc: FactRow | undefined): string | null { ...@@ -560,8 +784,11 @@ function summarizeLastVisit(enc: FactRow | undefined): string | null {
summary = undefined; summary = undefined;
} }
if (!summary && !enc.occurredAt) return null; if (!summary && !enc.occurredAt) return null;
const when = enc.occurredAt ? fmtYearMonth(enc.occurredAt) : '近期'; // 结构化:date(年月)+ summary(做了什么);消费方按需拼"{date} — {summary}"
return `${when}${summary ?? '到店就诊'}`; return {
date: enc.occurredAt ? fmtYearMonth(enc.occurredAt) : null,
summary: summary ?? null,
};
} }
/** /**
...@@ -599,63 +826,35 @@ function subKeyToDxCode(subKey: string | null): string | null { ...@@ -599,63 +826,35 @@ function subKeyToDxCode(subKey: string | null): string | null {
*/ */
function extractPendingFromReasons( function extractPendingFromReasons(
reasons: Array<{ scenario: string; subKey: string | null; signals: unknown }>, reasons: Array<{ scenario: string; subKey: string | null; signals: unknown }>,
): string[] { ): DraftPlanScriptInput['clinicalContext']['pendingTreatments'] {
const items: string[] = []; const out: DraftPlanScriptInput['clinicalContext']['pendingTreatments'] = [];
const seen = new Set<string>();
for (const r of reasons) { for (const r of reasons) {
// sub_key 形如 'caries_no_filling@36' / 'perio_no_srp@whole';subLabelZh 接受不带 @ 的 base // sub_key 形如 'caries_no_filling@36' / 'perio_no_srp@whole';subLabelZh 接受不带 @ 的 base
const baseSubKey = (r.subKey ?? '').split('@')[0]!; const baseSubKey = (r.subKey ?? '').split('@')[0] || null;
const subLabel = subLabelZh(r.scenario, baseSubKey); const label = subLabelZh(r.scenario, baseSubKey ?? '');
const sig = (r.signals ?? {}) as { toothPosition?: string | null }; const sig = (r.signals ?? {}) as { toothPosition?: string | null };
const toothRaw = (sig.toothPosition ?? '').trim(); const toothPositions = splitToothPositions(sig.toothPosition);
const friendly = toothRaw ? toothFriendly(toothRaw) : ''; const dedup = `${label}|${toothPositions.join(',')}`;
items.push(friendly ? `${subLabel} · ${friendly}` : subLabel); if (seen.has(dedup)) continue;
seen.add(dedup);
out.push({ subKey: baseSubKey, label, toothPositions });
} }
// 去重(同 subLabel 多牙位会被 union-find 合并到一行,这里防御性 dedup) return out.slice(0, 5);
return Array.from(new Set(items)).slice(0, 5);
} }
/** /** FDI 牙位串("36;46" / "*whole")→ 结构化数组;全口/空 → []。@tooth 后缀也接受。 */
* v2.1:读独立 diagnosis_record / recommendation_record fact,找出"有诊断/建议但未启动治疗"的待办。 function splitToothPositions(raw: string | null | undefined): string[] {
* 不再读 encounter_record 嵌套字段(encounter 已退化只元数据)。 const s = (raw ?? '').trim();
* @deprecated 改用 extractPendingFromReasons — facts 派生有 DX_TO_CAT 漏映射 bug if (!s || s === '*whole' || s.toLowerCase() === 'whole') return [];
*/ return Array.from(
// eslint-disable-next-line @typescript-eslint/no-unused-vars new Set(
function extractPendingTreatments(facts: FactRow[]): string[] { s
// 已做的治疗 category .split(/[;,,;\s]+/)
const doneCats = new Set<string>(); .map((x) => x.trim())
for (const f of facts) { .filter((x) => x && x !== '*whole' && x.toLowerCase() !== 'whole'),
if (f.type !== 'treatment_record' || f.kind !== 'actual') continue; ),
const c = f.content as Record<string, unknown> | null; );
const cat = c?.category as string | undefined;
if (cat) doneCats.add(cat);
}
// 期望治疗 category 映射(跟 chain-composer / treatment-chain-status feature 一致)
const DX_TO_CAT: Record<string, string[]> = {
K02: ['restorative'],
K04: ['endodontic'],
K05: ['periodontic'],
K08: ['implant', 'prosthodontic'],
IMPLANT_RECOMMENDED: ['implant'],
CROWN_RECOMMENDED: ['prosthodontic'],
FILLING_RECOMMENDED: ['restorative'],
SRP_RECOMMENDED: ['periodontic'],
};
const items: string[] = [];
for (const f of facts) {
if (f.type !== 'diagnosis_record' && f.type !== 'recommendation_record') continue;
const c = f.content as Record<string, unknown> | null;
const code = (c?.code as string | undefined) ?? '';
const name = (c?.name_zh as string | undefined) ?? (c?.name as string | undefined) ?? code;
const tooth = (c?.tooth_position as string | undefined) ?? '';
const expectedCats = DX_TO_CAT[code];
if (!expectedCats) continue;
const fulfilled = expectedCats.some((cat) => doneCats.has(cat));
if (fulfilled) continue;
// 牙位转俗称(LLM 不能对患者说"牙位 21")
const friendly = tooth ? toothFriendly(tooth) : '';
items.push(friendly ? `${name}(${friendly})` : name);
}
return Array.from(new Set(items)).slice(0, 3);
} }
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
...@@ -740,7 +939,7 @@ function callSalutation( ...@@ -740,7 +939,7 @@ function callSalutation(
const JVS_DW_CLINIC_NAMES: Record<string, string> = { const JVS_DW_CLINIC_NAMES: Record<string, string> = {
c18cadf2d3cd4adda5527debd41356eb: '学前街医院', c18cadf2d3cd4adda5527debd41356eb: '学前街医院',
e83d432a38bb4f6284713b36db4e7497: '上海世纪公园诊所', e83d432a38bb4f6284713b36db4e7497: '上海世纪公园诊所',
dad2f04a120947e2b82b41cbd108f3f4: '通善口腔杭州高德诊所', dad2f04a120947e2b82b41cbd108f3f4: '杭州高德诊所',
'7d49539c7573490387c03e6496ff1a6c': '杭州大厦诊所', '7d49539c7573490387c03e6496ff1a6c': '杭州大厦诊所',
'66701845dd2342e19f9e9f576c4ffe9c': '北京朝阳公园诊所', '66701845dd2342e19f9e9f576c4ffe9c': '北京朝阳公园诊所',
}; };
...@@ -751,51 +950,6 @@ function resolveClinicName(clinicId: string | null): string { ...@@ -751,51 +950,6 @@ function resolveClinicName(clinicId: string | null): string {
} }
/** /**
* FDI 牙位号 → 患者俗称。
* 设计:粗粒度即可("上门牙"够,不需要"左上中切牙"),让客服讲话自然。
* 多牙位用"/"分隔,去重保序。
*
* FDI 规则:
* 第 1 位 = 象限(1-4 恒牙,5-8 乳牙)
* 第 2 位 = 位置(1=中切,2=侧切,3=尖牙,4-5=前磨,6-7=磨牙,8=智齿)
* 1x/2x 在上颌,3x/4x 在下颌
*/
function toothFriendly(fdiStr: string): string {
const parts = fdiStr.split(/[;,,;\s]+/).map((s) => s.trim()).filter(Boolean);
const friendly: string[] = [];
const seen = new Set<string>();
for (const p of parts) {
const label = fdiToFriendly(p);
if (label && !seen.has(label)) {
seen.add(label);
friendly.push(label);
}
}
// 兜底:都解析不出就原样返回(避免空字符串)
return friendly.length > 0 ? friendly.join('/') : fdiStr;
}
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 '智齿';
return null;
}
/**
* 从最近的 treatment/diagnosis fact 抽主治医生名。 * 从最近的 treatment/diagnosis fact 抽主治医生名。
* 没抽到 → null(prompt 里走 fallback,让 LLM 用"您的主治医生"泛指,不编)。 * 没抽到 → null(prompt 里走 fallback,让 LLM 用"您的主治医生"泛指,不编)。
*/ */
...@@ -832,7 +986,9 @@ function extractPrimaryDoctor(facts: FactRow[]): string | null { ...@@ -832,7 +986,9 @@ function extractPrimaryDoctor(facts: FactRow[]): string | null {
* 算法:按 category 分组 actual treatments(排除 review),每组取最新 1 条,时间 DESC 取前 4。 * 算法:按 category 分组 actual treatments(排除 review),每组取最新 1 条,时间 DESC 取前 4。
* 给 LLM 用处:① 不重复邀约已做过的治疗 ② 引用历史治疗体现"诊所记得 ta"。 * 给 LLM 用处:① 不重复邀约已做过的治疗 ② 引用历史治疗体现"诊所记得 ta"。
*/ */
function summarizeRecentTreatments(facts: FactRow[]): string[] { function summarizeRecentTreatments(
facts: FactRow[],
): DraftPlanScriptInput['clinicalContext']['recentTreatments'] {
const latestByCategory = new Map<string, FactRow>(); const latestByCategory = new Map<string, FactRow>();
for (const f of facts) { for (const f of facts) {
if (f.type !== 'treatment_record' || f.kind !== 'actual') continue; if (f.type !== 'treatment_record' || f.kind !== 'actual') continue;
...@@ -848,18 +1004,18 @@ function summarizeRecentTreatments(facts: FactRow[]): string[] { ...@@ -848,18 +1004,18 @@ function summarizeRecentTreatments(facts: FactRow[]): string[] {
const sorted = [...latestByCategory.entries()].sort((a, b) => { const sorted = [...latestByCategory.entries()].sort((a, b) => {
return (b[1].occurredAt?.getTime() ?? 0) - (a[1].occurredAt?.getTime() ?? 0); return (b[1].occurredAt?.getTime() ?? 0) - (a[1].occurredAt?.getTime() ?? 0);
}); });
// 结构化:category/label/subtype/医生/日期;散文("做过牙周 · ...")由消费方渲染
return sorted.slice(0, 4).map(([cat, latest]) => { return sorted.slice(0, 4).map(([cat, latest]) => {
const c = latest.content as Record<string, unknown> | null; const c = latest.content as Record<string, unknown> | null;
const subtype = (c?.subtype as string | undefined)?.trim(); return {
const doctor = (c?.doctor_name as string | undefined)?.trim(); category: cat,
const when = latest.occurredAt categoryLabel: treatmentCategoryNameZh(cat),
? `${latest.occurredAt.getFullYear()}.${String(latest.occurredAt.getMonth() + 1).padStart(2, '0')}` subtype: (c?.subtype as string | undefined)?.trim() || null,
: '近期'; doctorName: (c?.doctor_name as string | undefined)?.trim() || null,
const bits = [`做过${treatmentCategoryNameZh(cat)}`]; date: latest.occurredAt
if (subtype) bits.push(`上次 ${subtype}`); ? `${latest.occurredAt.getFullYear()}.${String(latest.occurredAt.getMonth() + 1).padStart(2, '0')}`
if (doctor) bits.push(`${doctor}医生`); : null,
bits.push(when); };
return bits.join(' · ');
}); });
} }
......
...@@ -81,11 +81,13 @@ export class PlansAggregateController { ...@@ -81,11 +81,13 @@ export class PlansAggregateController {
@Param('id') planId: string, @Param('id') planId: string,
@Query('bustCache') bustCache?: string, @Query('bustCache') bustCache?: string,
@Query('model') model?: string, @Query('model') model?: string,
@Query('tier') tier?: string,
@Body() body?: { dryRun?: boolean }, @Body() body?: { dryRun?: boolean },
) { ) {
const result = await this.planScript.generate(planId, { const result = await this.planScript.generate(planId, {
bustCache: bustCache === 'true' || bustCache === '1', bustCache: bustCache === 'true' || bustCache === '1',
modelIdOverride: model, modelIdOverride: model,
tier: parseScriptTier(tier),
dryRun: body?.dryRun ?? false, dryRun: body?.dryRun ?? false,
agent: this.resolveAgent(user), agent: this.resolveAgent(user),
}); });
...@@ -119,11 +121,16 @@ export class PlansAggregateController { ...@@ -119,11 +121,16 @@ export class PlansAggregateController {
@CurrentUser() user: AuthenticatedUser, @CurrentUser() user: AuthenticatedUser,
@Param('id') planId: string, @Param('id') planId: string,
@Query('model') model: string | undefined, @Query('model') model: string | undefined,
@Query('tier') tier: string | undefined,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
await this.pipeSse( await this.pipeSse(
res, res,
this.planScript.generateStream(planId, { modelIdOverride: model, agent: this.resolveAgent(user) }), this.planScript.generateStream(planId, {
modelIdOverride: model,
tier: parseScriptTier(tier),
agent: this.resolveAgent(user),
}),
); );
} }
...@@ -245,3 +252,8 @@ export class PlansAggregateController { ...@@ -245,3 +252,8 @@ export class PlansAggregateController {
} }
} }
} }
/** 投入档 query 解析:只接受白名单,非法/缺省 → undefined(orchestrator 默认 stable) */
function parseScriptTier(tier: string | undefined): 'stable' | 'standard' | 'deep' | undefined {
return tier === 'stable' || tier === 'standard' || tier === 'deep' ? tier : undefined;
}
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PlanScriptOrchestrator } from '../ai/orchestrators/plan-script.orchestrator'; import { PlanScriptOrchestrator } from '../ai/orchestrators/plan-script.orchestrator';
import { DraftPlanScriptSkillRegistry } from '../ai/calls/draft-plan-script/skill-registry.service'; import { DraftPlanScriptSkillRegistry } from '../ai/calls/draft-plan-script/shared/skill-registry.service';
import { composeSystem } from '../ai/calls/draft-plan-script/skill-composer'; import { composeSystem } from '../ai/calls/draft-plan-script/shared/skill-composer';
import type { DraftPlanScriptInput } from '../ai/calls/draft-plan-script/input.types'; import type { DraftPlanScriptInput } from '../ai/calls/draft-plan-script/shared/input.types';
import { toothFriendly } from '../ai/calls/draft-plan-script/shared/script-facts';
import { import {
REALTIME_COACH_ROLE_HEADER, REALTIME_COACH_ROLE_HEADER,
REALTIME_COACH_KNOWLEDGE_HEADER, REALTIME_COACH_KNOWLEDGE_HEADER,
...@@ -26,7 +27,7 @@ export class RealtimeCoachContextService { ...@@ -26,7 +27,7 @@ export class RealtimeCoachContextService {
async buildInstructions(planId: string): Promise<{ instructions: string; skills: string[] }> { async buildInstructions(planId: string): Promise<{ instructions: string; skills: string[] }> {
const input = await this.planScriptOrchestrator.buildScriptInputForPlan(planId); const input = await this.planScriptOrchestrator.buildScriptInputForPlan(planId);
const composed = composeSystem(input, this.skillRegistry.getAllSkills()); const composed = composeSystem(input, this.skillRegistry.getAllSkills(), 'stable');
// skill body 作为"专业知识"注入,但排除 scenario-*(那是"一步到位约到店"的召回剧本, // skill body 作为"专业知识"注入,但排除 scenario-*(那是"一步到位约到店"的召回剧本,
// 会盖过"抓主诉、循序渐进"的口径);保留 diagnosis / objection / relationship / population / safety。 // 会盖过"抓主诉、循序渐进"的口径);保留 diagnosis / objection / relationship / population / safety。
...@@ -53,14 +54,28 @@ export class RealtimeCoachContextService { ...@@ -53,14 +54,28 @@ export class RealtimeCoachContextService {
private buildPatientContextBlock(input: DraftPlanScriptInput): string { private buildPatientContextBlock(input: DraftPlanScriptInput): string {
const cc = input.clinicalContext; const cc = input.clinicalContext;
const persona = input.personaHighlights.map((h) => `${h.label}:${h.description}`).join(' · '); const persona = input.personaHighlights.map((h) => `${h.label}:${h.description}`).join(' · ');
// 结构化 → 散文(边界渲染;ScriptContext 里只存结构,这里拼成口语供教练 LLM 取用)
const pending = cc.pendingTreatments
.map((p) => (p.toothPositions.length ? `${p.label} · ${toothFriendly(p.toothPositions)}` : p.label))
.join('、');
const lastVisit = cc.lastVisit
? `${cc.lastVisit.date ?? '近期'} ${cc.lastVisit.summary ?? '到店就诊'}`
: '';
const recent = cc.recentTreatments
.map((t) =>
[`做过${t.categoryLabel}`, t.subtype ? `上次 ${t.subtype}` : '', t.doctorName ? `${t.doctorName}医生` : '', t.date ?? '']
.filter(Boolean)
.join(' · '),
)
.join(' / ');
return [ return [
'# 当前通话患者背景(真实数据,供你聊天时取用,不要编造、别照着推销)', '# 当前通话患者背景(真实数据,供你聊天时取用,不要编造、别照着推销)',
`- 诊所:${input.clinicName} · 称呼:${input.patient.salutation} · 年龄:${input.patient.age ?? '未知'}`, `- 诊所:${input.clinicName} · 称呼:${input.patient.salutation} · 年龄:${input.patient.age ?? '未知'}`,
persona ? `- 画像:${persona}` : '', persona ? `- 画像:${persona}` : '',
`- 上次就诊发现 / 待处理:${cc.pendingTreatments.join('、') || input.plan.reasons[0]?.reason || '(无)'}`, `- 上次就诊发现 / 待处理:${pending || input.plan.reasons[0]?.reason || '(无)'}`,
`- 主治医生:${cc.primaryDoctorName ?? '(未知)'} · 距上次到店:${cc.daysSinceLastVisit ?? '未知'} `, `- 主治医生:${cc.primaryDoctorName ?? '(未知)'} · 距上次到店:${cc.daysSinceLastVisit ?? '未知'} `,
cc.lastVisitSummary ? `- 上次到店:${cc.lastVisitSummary}` : '', lastVisit ? `- 上次到店:${lastVisit}` : '',
cc.recentTreatments.length ? `- 近期做过的治疗:${cc.recentTreatments.join(' / ')}` : '', recent ? `- 近期做过的治疗:${recent}` : '',
`- 老客/新客:已完成 ${cc.completedTreatmentCount} 次治疗`, `- 老客/新客:已完成 ${cc.completedTreatmentCount} 次治疗`,
] ]
.filter(Boolean) .filter(Boolean)
......
...@@ -250,24 +250,33 @@ metadata: { model, promptVersion, blocksUsed[], stepsRun[], tokens, costCents } ...@@ -250,24 +250,33 @@ metadata: { model, promptVersion, blocksUsed[], stepsRun[], tokens, costCents }
### 文件落位(收口) ### 文件落位(收口)
> ⭐ **核心**:`base` 和 `人群` **各自都拆成"共性部分(三档共用)+ 稳健模板部分(独占)"** —— 不是整块归稳健。
> 病种的**口语文案**(risks/advantages/duration)是**稳健模板**(为填槽而写),不是共性知识;
> 共性的病种只有**抽象临床要点**(规则形态),且其他档多半用 LLM 自带常识即可,未必需要。
``` ```
script-common/ ← tier-agnostic 知识(单一源,三档共用) calls/script/
knowledge/ shared/ ← 真·tier-agnostic(三档共用)
disease-knowledge.ts ← 病种规则,**按 subKey/canonical 码 keyed** script-context (input.types) ← 患者事实切片(✅ 已有)
{ label, risks[], advantages[], reviewCadence, ageFit? } safety-rules.ts ← 禁词/承诺/报价/拍片 **单一源**(机器闸 + 各档 base 同引)
(合并现 SUBKEY_TO_MISSED + MISSED_DIAGNOSIS_KEY_POINTS + TREATMENT_DURATION base-common.md ← base 的**共性**:角色定位(医疗关怀非销售)/语调/接地铁律/不承诺
三套双跳 → 单跳;顺手修 颌骨囊肿) population-common.md ← 人群的**共性沟通知识**:儿童→家长 / 老人→慢+复述 / 新老客语气
population-knowledge.ts ← 人群沟通规则(child/adolescent/adult/elderly)— 从 SKILL.md 抽出的"知识"部分 clinical-points.ts (可选) ← 抽象病种临床要点(规则,非口语);标准/深度可引用,未必需要
safety-rules.ts ← 禁词/承诺/拍片 **单一源**(机器闸 + prompt 同引) tiers/
tiers/ stable/ ← 稳健独占(全是"模板/格式")
stable/ base-format.md ← base 的**稳健部分**:4段结构 + 占位符约定({}/【】)+ 顺序固定
template.md ← 4段模板骨架(从 population SKILL.md 抽出的"模板"部分,**仅稳健**) population-template/{adult,child}.md ← 4段·句位脚手架(成人4句/儿童5句)
stable.call.ts ← StableScriptCall(AiCall) phrasing.ts ← 口语文案库:risks/advantages/duration(现 disease-knowledge 内容,落位纠正到此)
standard/ (future) ← 只引 knowledge,不引 template.md stable.call.ts
deep/ (future) standard/ deep/ (future) ← 各自 base-format;只吃 shared(context+safety+base-common+population-common),
临床措辞 LLM 自供
``` ```
**关键**:现 `population/adult|child/SKILL.md`**一拆为二** —— 沟通知识 → `population-knowledge`(共用);4段脚手架 → `stable/template.md`(独占)。 **两条拆分线**:
- **base** = `base-common.md`(定位/语调/安全/接地,共用)+ `tiers/*/base-format.md`(输出格式,每档不同)。
- **人群** = `population-common.md`(沟通知识,共用)+ `tiers/stable/population-template`(固定句位,稳健独占)。
- **病种** = (可选)`clinical-points` 抽象要点(共用)+ `tiers/stable/phrasing.ts` 口语文案(稳健独占)。
-`disease-knowledge.ts`(口语文案)落位纠正:它是**稳健 phrasing**,reorg 时移入 `tiers/stable/phrasing.ts`,不在 shared。
--- ---
......
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