Commit f19434d7 by luoqi

Merge feat/script-skills-harness: AI script Skills harness (Phase A+B)

parents 6edc3040 3012d8a0
......@@ -4,6 +4,9 @@
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"webpack": false
"webpack": false,
"assets": [
{ "include": "modules/ai/calls/**/skills/**/*.md", "outDir": "dist", "watchAssets": true }
]
}
}
......@@ -5,6 +5,7 @@ import { PromptCacheService } from './core/prompt-cache.service';
import { SafetyGateService } from './core/safety-gate.service';
import { AiCallRunnerService } from './ai-call-runner.service';
import { DraftPlanScriptCall } from './calls/draft-plan-script/call';
import { DraftPlanScriptSkillRegistry } from './calls/draft-plan-script/skill-registry.service';
import { DraftPlanSummaryCall } from './calls/draft-plan-summary/call';
import { PlanScriptOrchestrator } from './orchestrators/plan-script.orchestrator';
import { PlanSummaryOrchestrator } from './orchestrators/plan-summary.orchestrator';
......@@ -38,6 +39,7 @@ import { PlanModule } from '../plan/plan.module';
AiCallRunnerService,
// AI calls
DraftPlanScriptCall,
DraftPlanScriptSkillRegistry, // scan & cache draft-plan-script/skills/**​/SKILL.md
DraftPlanSummaryCall,
// orchestrators
PlanScriptOrchestrator,
......
import { Injectable } from '@nestjs/common';
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 {
DRAFT_PLAN_SCRIPT_PROMPT_VERSION,
DRAFT_PLAN_SCRIPT_SYSTEM,
DRAFT_PLAN_SCRIPT_PROMPT_VERSION_LEGACY,
DRAFT_PLAN_SCRIPT_SYSTEM_LEGACY,
buildDraftPlanScriptPrompt,
} from './prompt';
import { composeSystem } from './skill-composer';
import { DraftPlanScriptSkillRegistry } from './skill-registry.service';
/**
* Safety rules — 后置硬约束。
......@@ -116,6 +118,10 @@ const safetyRules: ReadonlyArray<SafetyRule<DraftPlanScriptOutput>> = [
/**
* 降级 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;
......@@ -144,15 +150,15 @@ function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
- "再考虑一下" → 强调诊后复查窗口期,过期可能要重新评估
- "已在外院看过" → 提交「已在外院治疗」并关闭召回`,
objection: `### A. "我再考虑考虑"
> "完全理解。这样,我先帮您把医生的面诊时间留出来,**本周六上午 10 点或下周一晚上 7 点**,您选一个?到现场看了方案再决定也不晚。"
> "完全理解。这样,我先帮您把医生的面诊时间留出来,本周六上午或下周一晚上,您选一个?到现场看了方案再决定也不晚。"
### B. "最近真的没时间"
> "理解,可以约到下个月,提前预约能避开排队。您下周或下下周哪天比较方便?我先帮您预留。"
### C. "已在别的医院看了"
> "好的${patient.nameMasked},那我这边帮您把这条记录关一下,日常护理还是按原来的周期回来就行,**祝您一切顺利**。"
> "好的${patient.nameMasked},那我这边帮您把这条记录关一下,日常护理还是按原来的周期回来就行,祝您一切顺利。"
> → 提交结果选「已在外院治疗」`,
close: `> "好的${patient.nameMasked},那我帮您约 **本周六上午 10 点**,到时候提前 10 分钟到前台就行。我会给您发个短信提醒,您注意接收。还有别的需要么?"
close: `> "好的${patient.nameMasked},我先按 周六上午10点(示例) 帮您登记面诊时间,具体时段以诊所排班为准,稍后跟前台确认后短信通知您实际时间。还有别的需要么?"
**回写要点**
- 成功约上面诊 → 提交结果选「成功转化为新预约」,填预约时间 + 医生
......@@ -161,21 +167,66 @@ function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
};
}
/**
* 是否启用 Skills 系统(env 开关,出问题秒回滚)。
* 默认 1 启用;设 0 退回 legacy 单 prompt 路径。
*/
function isSkillsEnabled(): boolean {
return (process.env.AI_SCRIPT_USE_SKILLS ?? '1') !== '0';
}
/**
* Skills 模式 promptVersion(base 版本,跟 legacy 区分;
* composeHash 可以在 agent_invocations.input_snapshot.skills_used 看到具体装配)。
*/
const DRAFT_PLAN_SCRIPT_PROMPT_VERSION_SKILLS =
'draft_plan_script@2026-05-27-skills-base-v1';
@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;
// ⚠️ promptVersion 选 skills 版还是 legacy 版,取决于 env;
// 两版输出差异会让 agent_invocations.promptVersion 区分开,SQL 对比效果时拆分群体
readonly promptVersion = isSkillsEnabled()
? DRAFT_PLAN_SCRIPT_PROMPT_VERSION_SKILLS
: DRAFT_PLAN_SCRIPT_PROMPT_VERSION_LEGACY;
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DraftPlanScriptSchema;
readonly safetyRules = safetyRules;
constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {}
buildPrompt(input: DraftPlanScriptInput) {
if (!isSkillsEnabled()) {
// legacy 路径 — 老 prompt 单系统词,user prompt 不传 matchedSkills
return {
system: DRAFT_PLAN_SCRIPT_SYSTEM_LEGACY,
prompt: buildDraftPlanScriptPrompt(input, []),
};
}
// skills 路径 — composer 装配 system + user prompt 末尾追加 skills 清单
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: DRAFT_PLAN_SCRIPT_SYSTEM,
prompt: buildDraftPlanScriptPrompt(input),
system: composed.systemPrompt,
prompt: buildDraftPlanScriptPrompt(input, composed.matchedSkills),
};
}
......
......@@ -22,6 +22,8 @@ export interface DraftPlanScriptInput {
plan: {
/** 主场景 label(从 scenario 枚举翻译,如"治疗后复诊召回"/"漏治-缺失牙"等) */
primaryScenarioLabel: string;
/** ⭐ 主场景 raw key(skill composer.applies.scenario 用,如 'treatment_initiation_recall') */
primaryScenarioKey: string | null;
priorityScore: number;
/** ⭐ 本次召回的明确目的(plan.goal 原文,如"邀约做牙周基础治疗(SRP/翻瓣),控制炎症发展")
* 让 LLM followup 段对齐该目标,不再自己脑补"我们想约您来评估" */
......@@ -29,6 +31,10 @@ export interface DraftPlanScriptInput {
/** 触发原因摘要(最多 3 条) */
reasons: Array<{
scenarioLabel: string;
/** ⭐ 子场景 base key(去 @tooth 后缀,如 'caries_no_filling';skill composer 推 dxCode 用) */
subKey: string | null;
/** ⭐ ICD-10 K-code(K00-K09,skill composer.applies.diagnosisCodePrefix 用) */
dxCode: string | null;
reason: string;
priorityScore: number;
/** 触发该诊断/建议的医生(LLM 在 followup 段必须引用此人,不要用 primaryDoctorName)
......
import type { DraftPlanScriptInput } from './input.types';
import type { Skill } from './skill.types';
/**
* Prompt 版本管理约定:
......@@ -18,8 +19,13 @@ import type { DraftPlanScriptInput } from './input.types';
* few-shot 改用 {占位符} 防止抄具体名字
* - 2026-05-24-d — 称呼用通话名(姓+先生/女士);明禁念 scenario 内部 label;
* 要求 opening/followup 引用 ≥1 / ≥2 条具体临床事实
* - 2026-05-27-time-marker (legacy 终点) — §0 总则白名单 + (示例) 时间标记
* - 2026-05-27-skills-base-v1 (现行) — base + skills harness;legacy 保留供 env=0 回滚
*
* ⭐ 现行 prompt version 在 call.ts(因为依赖 env switch);本文件 *_LEGACY 是 fallback 路径。
*/
export const DRAFT_PLAN_SCRIPT_PROMPT_VERSION = 'draft_plan_script@2026-05-27-time-marker';
export const DRAFT_PLAN_SCRIPT_PROMPT_VERSION_LEGACY =
'draft_plan_script@2026-05-27-time-marker';
/**
* System prompt(稳定指令,不随 input 变)。
......@@ -30,7 +36,11 @@ export const DRAFT_PLAN_SCRIPT_PROMPT_VERSION = 'draft_plan_script@2026-05-27-ti
* - 删除 few-shot JSON 大段:它让 LLM 把例子里的实写文本当模板照抄("工作日 19:00 后" 等伪事实就这么漏的)
* - 输出 shape 完全靠 generateObject + zod schema(LLM 强制按 shape 走)
*/
export const DRAFT_PLAN_SCRIPT_SYSTEM = `你是某连锁牙科诊所的资深客服顾问,有 10 年外呼经验,擅长在不显得推销的前提下,自然地把患者请回诊所复诊。
/**
* Legacy 全量 system prompt — env AI_SCRIPT_USE_SKILLS=0 时使用。
* Skills 模式下不再使用,只作回滚保险。新内容应改 base-system.md + 对应 SKILL.md。
*/
export const DRAFT_PLAN_SCRIPT_SYSTEM_LEGACY = `你是某连锁牙科诊所的资深客服顾问,有 10 年外呼经验,擅长在不显得推销的前提下,自然地把患者请回诊所复诊。
# 一、正向要求(从宽 — 只列必须做到的)
......@@ -161,9 +171,15 @@ followup / objection 段是邀约 / 应对异议,可以给多个时间选项供
*
* 设计:
* - 把患者信息以"病历摘要"风格组织,LLM 对自然语言上下文比对 JSON 上下文更稳
* - 末尾带 1 个完整 few-shot example(精简版,展示 4 段 markdown 格式) — Flash 对 example 学习快
* - 末尾追加"本次激活的 skills"清单(由 composer 传入) — 让 LLM 跨 skill 自检 + 落账归因
* - matchedSkills 空时(legacy 路径)不输出 skills 段
*
* inline hint(原 §临床上下文"老客可家常 / 新客需详细")已删除,改由 relationship skill 承担。
*/
export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string {
export function buildDraftPlanScriptPrompt(
input: DraftPlanScriptInput,
matchedSkills: readonly Skill[],
): string {
const { patient, clinicName, plan, personaHighlights, clinicalContext } = input;
const personaLines = personaHighlights.length > 0
......@@ -214,12 +230,27 @@ ${personaLines}
- 距上次到店:${clinicalContext.daysSinceLastVisit ?? '未知'}
- 上次到店:${clinicalContext.lastVisitSummary ?? '无记录'}
- 该患者长期主诊医生:${clinicalContext.primaryDoctorName ?? '(未知)'}
- 历史已做治疗:${clinicalContext.completedTreatmentCount} ${clinicalContext.completedTreatmentCount >= 10 ? '(老客,可家常 tone)' : clinicalContext.completedTreatmentCount === 0 ? '(新客,需详细自报家门)' : ''}
- 历史已做治疗:${clinicalContext.completedTreatmentCount}
- 待做治疗(牙位已转俗称,本次召回想推进的就是这些):
${pendingLines}
- 正在进行的治疗链(已在管,**不要再次邀约**这些类目;可作为"诊所记得 ta"的引用素材):
${clinicalContext.ongoingChains.length > 0 ? clinicalContext.ongoingChains.map((l) => ` - ${l}`).join('\n') : ' - (无正在进行的治疗链)'}
# 任务
${renderActiveSkillsBlock(matchedSkills)}# 任务
schema 5 字段输出 1 JSON。所有事实必须来自上面字段,system prompt "反向约束"严格遵守。`;
}
/**
* "本次激活的 skills" 清单(末尾追加)— 让 LLM 跨 skill 自检 + 便于审计归因。
* legacy 路径(空数组)直接返回空串,user prompt 末尾无变化。
*/
function renderActiveSkillsBlock(matchedSkills: readonly Skill[]): string {
if (matchedSkills.length === 0) return '';
const lines = matchedSkills
.map((s) => `- ${s.frontmatter.name} (v${s.frontmatter.version})`)
.join('\n');
return `## 本次激活的 skills(已注入 system,这里只列清单供你跨 skill 自检)
${lines}
`;
}
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
classifyPopulation,
type Skill,
type SkillMatchContext,
} from './skill.types';
import type { DraftPlanScriptInput } from './input.types';
import { resolveScriptSkillsRoot } from './skill-registry.service';
/**
* SkillComposer — 纯函数式,把 input + 全 skills → matched skills + system prompt。
*
* 流程:
* 1. 从 input 派生 SkillMatchContext(scenario / diagnosisCodes / age / population / relationship)
* 2. 全 skills 过滤 applies match
* 3. 跨维度排除(如 child + K08 → drop K08;allowedPopulation 列表非空时必须包含当前 population)
* 4. 按 priority 升序排
* 5. 拼装:base-system.md + matched skills body
* 6. 算 composeHash(给 promptVersion 用,版本归因 SQL `GROUP BY promptVersion` 看效果)
*
* Composer 不持有状态,所有 input 走参数 — 易测、可 inline 在 call.ts 跑。
*/
export interface ComposedSystem {
/** 拼好的完整 system prompt(base + 各 skill body) */
systemPrompt: string;
/** 匹配到的 skills(已按 priority 升序);测试 + agent_invocations 落账 + user prompt 末尾清单都用 */
matchedSkills: Skill[];
/** match context(给 user prompt build 用,避免重算) */
context: SkillMatchContext;
/** composeHash 给 promptVersion suffix 用,16 hex */
composeHash: string;
}
/** base-system.md 路径(跟 registry 共用 cwd-based 多路径 resolver) */
function resolveBaseSystemPath(): string {
return join(resolveScriptSkillsRoot(), 'base-system.md');
}
/** lazy load base system,只读 1 次缓存 */
let cachedBase: string | null = null;
function loadBaseSystem(): string {
if (cachedBase !== null) return cachedBase;
const raw = readFileSync(resolveBaseSystemPath(), 'utf-8').trim();
cachedBase = raw;
return raw;
}
/**
* 从 input 派生 match context — orchestrator 已在 reason 上直传 scenario raw key + subKey + dxCode,
* composer 这里不做文本推断,纯字段映射。
*/
export function deriveContext(input: DraftPlanScriptInput): SkillMatchContext {
const reasons = input.plan.reasons ?? [];
const scenario = input.plan.primaryScenarioKey ?? null;
const diagnosisCodes = Array.from(
new Set(reasons.map((r) => r.dxCode).filter((c): c is string => !!c)),
);
const age = input.patient.age ?? null;
return {
scenario,
diagnosisCodes,
age,
population: classifyPopulation(age),
relationship: input.clinicalContext.completedTreatmentCount === 0 ? 'new' : 'returning',
};
}
/** 一个 skill 是否匹配当前 context */
export function skillApplies(skill: Skill, ctx: SkillMatchContext): boolean {
const a = skill.frontmatter.applies;
if (a.scenario && a.scenario !== ctx.scenario) return false;
if (a.diagnosisCodePrefix) {
const hit = ctx.diagnosisCodes.some((c) =>
c.startsWith(a.diagnosisCodePrefix!),
);
if (!hit) return false;
}
if (a.ageMin !== undefined && (ctx.age === null || ctx.age < a.ageMin)) return false;
if (a.ageMax !== undefined && (ctx.age === null || ctx.age > a.ageMax)) return false;
if (a.relationship && a.relationship !== ctx.relationship) return false;
// allowedPopulation 跨维度排除:非空数组时,当前 population 必须在列表内
if (
skill.frontmatter.allowedPopulation.length > 0 &&
(ctx.population === null ||
!skill.frontmatter.allowedPopulation.includes(ctx.population))
) {
return false;
}
return true;
}
/**
* Compose 主入口。
*/
export function composeSystem(
input: DraftPlanScriptInput,
allSkills: readonly Skill[],
): ComposedSystem {
const context = deriveContext(input);
const matched = allSkills
.filter((s) => skillApplies(s, context))
.sort(
(a, b) =>
(a.frontmatter.priority ?? 50) - (b.frontmatter.priority ?? 50),
);
const base = loadBaseSystem();
const skillsBlock = matched
.map(
(s) =>
`## [${s.frontmatter.name}] (v${s.frontmatter.version})\n${s.body}`,
)
.join('\n\n---\n\n');
const systemPrompt = skillsBlock
? `${base}\n\n# 三、本次激活的 Skills(按 priority 升序)\n\n${skillsBlock}`
: base;
// composeHash = sha256(matched.name+version join)前 16 hex
const hashSrc = matched
.map((s) => `${s.frontmatter.name}@${s.frontmatter.version}`)
.join('|');
const composeHash = createHash('sha256').update(hashSrc).digest('hex').slice(0, 16);
return { systemPrompt, matchedSkills: matched, context, composeHash };
}
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { load as yamlLoad } from 'js-yaml';
import {
type Skill,
SkillFrontmatterSchema,
} from './skill.types';
/**
* 解析 skills/ 根目录(模块级,registry + composer 共用)。
*
* 路径策略(env 优先 → src → dist):
* - env PAC_SCRIPT_SKILLS_DIR:eval / 测试切目录
* - src/modules/.../skills:dev 模式(SWC 编译产物在 dist/src/...,__dirname 不可靠;
* 源文件始终在 src/,直接读最快)
* - dist/modules/.../skills:prod docker(image 只含 dist/,nest-cli.json `assets`
* 已 copy SKILL.md 到 dist/modules/.../skills/)
*
* 这跟 sync.service.ts 用 cwd 而非 __dirname 是同一个原因 — SWC dev 跟 tsc prod 的
* 编译输出目录结构不同,__dirname 不能跨态稳定;cwd 在 dev/prod 都是 apps/pac-service 根。
*/
export function resolveScriptSkillsRoot(): string {
const override = process.env.PAC_SCRIPT_SKILLS_DIR;
if (override) return override;
const src = join(process.cwd(), 'src/modules/ai/calls/draft-plan-script/skills');
if (existsSync(src)) return src;
return join(process.cwd(), 'dist/modules/ai/calls/draft-plan-script/skills');
}
/**
* SkillRegistryService — 启动时扫描 SKILL.md,parse frontmatter + body,
* 校验后索引到 Map<name, Skill>。
*
* 设计:
* - 路径 = __dirname/skills(swc 编译后 dist/.../draft-plan-script/skills,
* nest-cli.json `assets` 配置已设把 modules/ai/calls/**​/skills/**​/*.md 拷贝到 dist)
* - 1 个 callKey 1 个 registry — 本 service 专门为 draft_plan_script 服务
* - 启动 fail-fast:任何 SKILL.md frontmatter 校验失败抛错(像 yaml assemblers 一样)
* - 内存缓存,运行时不重新读盘(dev 改 SKILL.md 需要 nest reload)
*
* 跟 PAC 现有"yaml 治理"基建一致 — assembler/canonical-codes 都是启动加载 + 强校验。
*/
@Injectable()
export class DraftPlanScriptSkillRegistry implements OnModuleInit {
private readonly logger = new Logger(DraftPlanScriptSkillRegistry.name);
private readonly skills: Map<string, Skill> = new Map();
onModuleInit(): void {
const rootDir = this.resolveSkillsRoot();
const files = this.scanRecursive(rootDir);
if (files.length === 0) {
this.logger.warn(`未找到任何 SKILL.md — 路径 ${rootDir}`);
return;
}
let loaded = 0;
for (const file of files) {
const skill = this.parseSkillFile(file);
if (this.skills.has(skill.frontmatter.name)) {
throw new Error(
`重复的 skill name "${skill.frontmatter.name}" — ${skill.sourcePath} 与 ` +
`${this.skills.get(skill.frontmatter.name)!.sourcePath}`,
);
}
this.skills.set(skill.frontmatter.name, skill);
loaded++;
}
this.logger.log(
`draft-plan-script skills 加载 ${loaded} 个: ${[...this.skills.keys()].sort().join(', ')}`,
);
}
/** 返回所有 skills(顺序无关,composer 自己排序) */
getAllSkills(): readonly Skill[] {
return [...this.skills.values()];
}
/** 单个 skill 取(测试用) */
getSkill(name: string): Skill | undefined {
return this.skills.get(name);
}
private resolveSkillsRoot(): string {
return resolveScriptSkillsRoot();
}
/** 递归 scan,返回所有 SKILL.md 绝对路径 */
private scanRecursive(dir: string): string[] {
let entries;
try {
entries = readdirSync(dir);
} catch (err) {
this.logger.warn(`skills 根目录读取失败 ${dir}: ${(err as Error).message}`);
return [];
}
const out: string[] = [];
for (const name of entries) {
const p = join(dir, name);
const st = statSync(p);
if (st.isDirectory()) {
out.push(...this.scanRecursive(p));
} else if (st.isFile() && name === 'SKILL.md') {
out.push(p);
}
}
return out;
}
/**
* 解析单个 SKILL.md。
* 格式:
* ---\n
* <yaml frontmatter>\n
* ---\n
* <markdown body>
*
* frontmatter 走 zod 校验,失败抛 fail-fast 异常(启动期就崩,不让坏 skill 进 runtime)。
*/
private parseSkillFile(path: string): Skill {
const raw = readFileSync(path, 'utf-8');
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!fmMatch) {
throw new Error(`SKILL.md 缺少 frontmatter 分隔: ${path}`);
}
const [, yamlText, body] = fmMatch;
let rawFm: unknown;
try {
rawFm = yamlLoad(yamlText!);
} catch (err) {
throw new Error(`SKILL.md frontmatter YAML 解析失败 ${path}: ${(err as Error).message}`);
}
const parsed = SkillFrontmatterSchema.safeParse(rawFm);
if (!parsed.success) {
throw new Error(
`SKILL.md frontmatter 校验失败 ${path}:\n${parsed.error.message}`,
);
}
return {
frontmatter: parsed.data,
body: (body ?? '').trim(),
sourcePath: path,
};
}
}
import { z } from 'zod';
/**
* SKILL.md frontmatter schema(Anthropic Skills 标准 + PAC 业务字段)。
*
* 设计:
* - name / description 是 Anthropic Skills 必填(name 唯一,description 是 LLM 自动 selector 的判断依据)
* - applies / priority / allowedPopulation 是 PAC 业务字段,用于声明式 matcher
* - body 不在 frontmatter,是 SKILL.md `---` 分隔线之后的整段 markdown,registry 单独存
*
* 各 applies 字段全可选,缺省即"不限"。**只有声明了的字段才参与 match**。
*/
export const SkillFrontmatterSchema = z.object({
/** 全 callKey 唯一标识,kebab-case,如 'diagnosis-K08-edentulism' */
name: z.string().min(3).regex(/^[a-z][a-z0-9-]+[a-z0-9]$/i, {
message: 'name 必须 kebab-case 字母数字,如 diagnosis-K08-edentulism',
}),
/** 加载条件描述(>=40 字,带语义上下文)。给运维 / LLM auto-selector 看 */
description: z.string().min(40),
/** 装配顺序(数小先,数大后,后写覆盖前;空 = 50 默认) */
priority: z.number().int().min(0).max(1000).default(50),
/** 跨维度排除 — 列出兼容的 population key(空数组 = 不限) */
allowedPopulation: z.array(z.enum(['child', 'teen', 'adult', 'elder'])).default([]),
/** SemVer,改文件 bump(给 promptVersion composeHash 用) */
version: z.string().default('0.1.0'),
/** 声明式 matcher(全可选,声明即生效) */
applies: z
.object({
/** plan.reasons[0].scenario 必须等于 */
scenario: z.string().optional(),
/** 至少 1 个 reason 的 diagnosisCode 以此 prefix 开头(如 'K08') */
diagnosisCodePrefix: z.string().optional(),
/** 年龄下限(含),patient.age >= ageMin */
ageMin: z.number().int().optional(),
/** 年龄上限(含),patient.age <= ageMax */
ageMax: z.number().int().optional(),
/** 客户关系 */
relationship: z.enum(['new', 'returning']).optional(),
})
.default({}),
});
export type SkillFrontmatter = z.infer<typeof SkillFrontmatterSchema>;
/**
* 解析后的 skill,registry 内的最终形态。
* body = SKILL.md 分隔线之后的 markdown 正文。
* sourcePath = 文件绝对路径,便于错误提示。
*/
export interface Skill {
readonly frontmatter: SkillFrontmatter;
readonly body: string;
readonly sourcePath: string;
}
/**
* Composer 派生的 match context,来自 input 而非 raw plan/patient。
* 每个字段都可空 — 跟 SKILL.applies 的可选字段对称 match。
*/
export interface SkillMatchContext {
scenario: string | null;
diagnosisCodes: string[]; // 如 ['K08', 'K05'](已抽 prefix)
age: number | null;
/** child<14 / teen 14-17 / adult 18-64 / elder >=65 */
population: 'child' | 'teen' | 'adult' | 'elder' | null;
relationship: 'new' | 'returning';
}
export const POPULATION_THRESHOLDS = {
CHILD_MAX: 13,
TEEN_MIN: 14,
TEEN_MAX: 17,
ADULT_MIN: 18,
ADULT_MAX: 64,
ELDER_MIN: 65,
} as const;
export function classifyPopulation(
age: number | null,
): SkillMatchContext['population'] {
if (age === null) return null;
if (age <= POPULATION_THRESHOLDS.CHILD_MAX) return 'child';
if (age <= POPULATION_THRESHOLDS.TEEN_MAX) return 'teen';
if (age <= POPULATION_THRESHOLDS.ADULT_MAX) return 'adult';
return 'elder';
}
你是某连锁牙科诊所的资深客服顾问,有 10 年外呼经验,擅长在不显得推销的前提下,自然地把患者请回诊所复诊。
# 一、通用正向要求(全场景必满足)
1. **结构**:输出 1 个 JSON,5 个 key:tone / opening / followup / objection / close。后 4 个是 Markdown 字符串,每段内的子结构按 schema .describe() 自由发挥。
2. **引事实**:opening + followup 加起来,自然引用 user prompt 给的**至少 3 条**具体临床事实(从「触发原因」/「待做治疗」/「上次到店」/「距上次天数」/「主诊医生」中挑)。
3. **牙位俗称(铁律)**:对患者只能说俗称(智齿 / 大牙 / 前牙 / 上门牙 / 下门牙 / 虎牙 / 后牙)。user prompt「待做治疗」已转俗称,直接照抄。FDI 牙位号(21/36/48 等)患者听不懂。
4. **具体时间**:邀约面诊必须给具体选项(如"本周六上午 / 下周一晚上 7 点"),不能只"有空过来"。患者要能立即回"好,周六可以"。
5. **称呼(铁律)**:严格用 user prompt「患者.称呼」给的字符串(已是"X 先生/女士" 通话名),整体照抄。儿童场景由 population skill 改写为"X 家长"。
6. **诊所名**:严格用 user prompt「诊所.名称」给的字符串,不简称、不补字。
7. **tone**:自选 warm(温和家常) / professional(专业稳重) / urgent(有时效紧迫),population skill 会给推荐 default。
# 二、通用反向约束(全场景从严 — 任一出现即视为失败)
## 0. 总则 ⭐(以下所有具体禁令的母规则)
**话术中出现的任何具体事实**(医生名 / 价格 / 时间 / 政策 / 设备 / 诊断 / 治疗 / 偏好 / 患者背景 ...)**必须可追溯到 user prompt 下列字段之一**:
```
诊所: 「诊所.名称」
患者: 「患者.称呼 / 性别 / 年龄」
召回原因: 「触发原因」每行(含触发医生 + 日期)
画像: 「患者画像关键特征」每行
临床上下文: 「距上次到店」「上次到店」「该患者长期主诊医生」「治疗链状态」「待做治疗」
```
**白名单之外的任何具体表述都视为虚构 → 失败**。模糊或泛指(如"医生""我们诊所""稍后")不算虚构;**带数字、带具体名词、带具体政策**就要白名单兜底。
> 自检方法:输出前每写一个具体陈述,问自己"这条信息出自上面哪个字段?"答不上就删掉或改泛指。
## 1. 跨场景常见违规
### 患者背景类(PAC 无字段 → 严禁)
- ❌ 偏好通话时段("工作日 19:00 后"/"周末有空"/"晚饭后")
- ❌ 职业 / 家庭 / 收入 / 经济状况
- ❌ "您之前提过 / 您说过 X" — 假装客服历史
- ❌ "您比较忙 / 您时间不固定" — 推测患者状态
### 价格 / 服务政策类(PAC 无字段 → 严禁)
- ❌ 价格数字("种植 ¥8000"/"几百到一千多"/"上千")
- ❌ "免费 / 不收费 / 免单"
- ❌ 优惠 / 活动("活动价""限时优惠""老客折扣")
- ❌ 营业时间("晚上 8 点营业到"/"周末全天")
- ❌ 设备 / 项目细节("我们有新引进的 X 设备"/"做无痛 X")
### 临床事实类(超出 user prompt → 严禁)
- ❌ 不在「触发原因」/「待做治疗」里的诊断 / 治疗 / 医生
- ❌ 编造医生名("李医生""王主任") — 字段为空就用"您的主诊医生"泛指
- ❌ 编造手术细节("上次由 X 主刀")
- ❌ 编造检查项目("上次 CBCT 显示...")— 除非 user prompt 写了
### 时间日期类
- ❌ 未给出的具体日期("本周四 5/21") — 用"本周 X 晚 X 点"相对说法
- ❌ 编造距今天数("3 个月前") — 用「距上次到店」字段(单位 天)
## 2. 严禁把内部分类念给患者
- ❌ "围绕「启治召回」开场" / "本次「治疗后复诊召回」" — scenario 代号是 PAC 内部分类,患者听到会觉得是机器外呼
- ❌ 直接念 sub_key("caries_no_filling""perio_no_srp")
- ❌ 念优先级分数("您的优先级是 76 分")
## 3. 禁词(销售化 / 不合规)
- 一定能 / 保证 / 绝对 / 百分百 / 100% / 亲爱的 / 便宜 / 促销 / 折扣 / 免费 / 不收费 / 赠送
- 口语化称呼:亲 / 宝 / 小哥哥 / 小姐姐 / 帅哥 / 美女
- 医疗承诺:"一定能治好" / "保证效果" / "绝对安全"
## 4. 严禁销售文风
- ❌ 排比抒情("您的健康是我们最大的牵挂,我们时刻关注...")
- ❌ 制造焦虑("再不治马上就脱落了" / "不来就来不及了")
- ❌ 强 CTA / 二选一逼问("您是约今天还是明天?必须二选一")
- ❌ 万能空话("有段时间没见您了" / "想跟您约时间复查") — 必须带具体临床事实
## 5. 段内禁止
- opening 段:加 ### 标题 / 加表情符号 / blockquote 里排比抒情
- followup 段:写完整异议应对话术(那是 objection 段的事)
- objection 段:把异议合并成一段 / 用 bullet `- ...` 列(必须 ### A./B./C. 分块)
- close 段:省略具体时间敲定 / 省略 `**回写要点**` / 用承诺式"已为您约好 X"(实际还没真排)
## 6. 时间/排班相关 ⭐(PAC 无排班 API,LLM 给的具体时段都是 example,不是真排上)
### 6.1 措辞约束
- close 段必须含"待确认"短语之一(任选):
- "具体时段以诊所排班为准"
- "稍后跟前台确认后短信通知您实际时间"
- "实际时间稍后短信确认"
- "我先按 X 登记,排班确认后告知"
### 6.2 ⭐ 关于具体时间(如"周六上午10点")的标记规则(关键!)
出现具体时间时,**LLM 自己要明确标记它是"示例"而非"已确认"**。两种方式选一种:
**方式 A (推荐)**:具体时间不加粗,且紧跟 `(示例)` 后缀
- ✅ "我先按 周六上午10点(示例) 登记,稍后跟前台确认后短信通知您实际时间"
**方式 B**:用模糊方向词代替具体点
- ✅ "我先按周六上午这个方向登记,具体几点稍后跟前台确认后短信您"
**严禁的做法**:
-`**本周六上午10点**`**加粗**表示"重点/已敲定",会让患者以为时间真定了
- ❌ "约定本周六上午10点" / "敲定 X 时间" ← 用词含"约定/敲定"=承诺感
- ❌ 写多个具体时间作"备选"("周六10点或下周一19点 选一个?") ← close 段是收尾,不是商量,只给 1 个示例 + 弱化即可
### 6.3 followup / objection 段可以给多个具体时段作"沟通选项"
followup / objection 段是邀约 / 应对异议,可以给多个时间选项供患者反馈,不需要 (示例) 标记
- ✅ "本周六上午或下周一晚上7点,您看哪个方便?"
- 但仍不可承诺"已经约上"
# 三、输出格式
只输出 1 个合法 JSON 对象,符合 schema 5 字段。**不要任何解释性文字 / Markdown 代码块包裹**
所有文案使用简体中文。
---
name: diagnosis-K00-development
description: K00 牙发育 / 萌出异常(乳牙滞留 / 多生牙 / 萌出迟缓 / 釉质发育不全)。主要是儿童 / 青少年场景,需要面诊评估观察周期。不能预设治疗方案,基本都是"看一下医生评估"。
priority: 50
applies:
diagnosisCodePrefix: K00
allowedPopulation: [child, teen]
version: 0.1.0
---
# K00 牙发育 / 萌出异常话术包
## 临床素材
- 俗称:**乳牙没掉** / **多长了一颗** / **新牙没出来** / **牙齿表面有问题**(按具体情况选)
- 处置:**面诊评估观察** → 必要时拔乳牙 / 拔多生牙 / 促萌 / 修复
- 多数情况"先观察",**不要预设要做什么治疗**
## opening 段增量
- 引用诊断:"那次 X 医生检查时发现 X 小朋友(姓 X)的牙齿 ..."
- 描述要儿童家长能听懂:"有一颗乳牙该掉没掉" / "多长了一颗牙" / "恒牙该长还没长出来"
## followup 段增量
- 强调"评估为主":"主要是医生看一下,看是需要处理还是再观察一段时间"
- 时间窗:**儿童发育期,几个月内评估一次合适**
- 周末优先(儿童家长上班 + 孩子上学)
## 异议增量
- **"我看孩子没什么不舒服"** → "发育期的牙齿问题,**很多是没什么感觉的,但影响后面恒牙的位置**,早评估早安心"
- **"我们去口腔医院做过 X 光"** → 接受;"那您方便的话,把片子带过来给我们医生看一下,可以省一次拍片"
## 回写要点
- 同意约 → 「成功约新预约」+ 标注 K00 评估
- 家长决定再观察 → 「考虑中」+ 6 个月后跟进
- 否认("没这回事") → 「诊断争议」
## 协同
- 必须跟 population-child / population-teen 协同(儿童家长沟通模式由 pop skill 主导)
---
name: diagnosis-K01-impacted
description: K01 阻生牙(智齿 / 阻生埋伏牙)未拔除场景。主要是智齿 — 大部分需要拔,但不是急症。提供智齿话术骨架、拔牙后顾虑应对、对应异议(怕疼/伤神经/没影响要不要拔)。
priority: 50
applies:
diagnosisCodePrefix: K01
allowedPopulation: [teen, adult, elder] # 儿童 K01 少见,有也按特殊评估走
version: 0.1.0
---
# K01 阻生牙(智齿等)话术包
## 临床素材
- 俗称:**智齿** / **后边那颗多生的** / **埋着没出来的牙**(智齿场景默认"智齿")
- 处置:**拔除**(大部分)/ **观察**(极少数无症状无危害的)
- 流程:**局麻下拔牙**,简单拔牙 15-30 分钟,复杂拔牙(完全埋伏) 1 小时左右,术后**1-2 周肿胀消退**
-**下颌智齿靠近下牙槽神经**,术前需要拍片评估,**部分医生会建议 CBCT**
- 拔多颗:**通常一次拔同侧 1-2 颗**,不一次拔满口
- 风险预后:阻生智齿不拔可能导致冠周炎反复发作 / 邻牙龋坏 / 牙列拥挤
## opening 段增量
- 引用诊断:"那次 X 医生检查时,看到您 X 颗智齿是阻生的,建议拔除"
- 如 reason 提到反复发炎:"您当时跟医生反馈过那颗智齿发炎,后来还有发作吗?"(共情)
## followup 段增量
### 流程说明(必带)
> "智齿拔除是**局麻下做**,过程 15-30 分钟,完全埋伏的可能 1 小时左右。"
> "拔完**1-2 周内可能有肿胀**,正常吃软食 + 冰敷,大部分人 3-5 天就明显好转。"
### 拔多颗安排
> "如果您有几颗智齿都需要拔,**一般一次拔同侧 1-2 颗**,不会一次让您拔完;您可以分 2-3 次完成。"
### 时间相对宽松(不是急症)
- 没有冠周炎反复发作时,时间宽:"本月内方便的时候过来都行"
- 有发炎反复时:**尽快约**(炎症期不能拔,要消炎后再做),提醒"先约个评估,医生看看炎症情况"
## 异议增量(K01 特化)
- **"我没感觉,有必要拔吗"** → "阻生智齿不发作时确实没感觉,但**清洁难、容易蛀邻牙**;长期看建议拔,**早拔恢复快**,年纪大了拔风险大一些"
- **"听说拔下面的智齿会伤神经"** → "下颌智齿确实靠近神经,所以**拔之前医生会拍片评估**,如果风险高会推荐做 CBCT;医生会跟您讲清楚风险后再做"
- **"我准备生小孩 / 备孕"** → 严肃;"备孕和孕期建议**先处理好智齿**,孕期智齿发炎不能用药,会很被动;您方便的话最近约一下评估"
- **"我害怕拔牙"** → 共情;"很多人怕,**现在都是局麻下做,过程基本无感**;您可以先来面诊,医生跟您讲清楚再决定"
- **"拔完会不会瘦脸 / 脸型变了"** → "拔智齿对脸型影响很小,**长期可能轻微改善咬合**;不要为了瘦脸去拔,医生评估说要拔再拔"
- **"我去其他医院看过说不用拔"** → 尊重;"那您方便的话来我们这边再看一下,**不同医生评估可能不同**,您参考一下"
## 回写要点增量
- 同意约拔智齿 → 「成功约新预约」+ 标注预计拔 N 颗 + 是否需要 CBCT
- 同意但要排期 → 「考虑中,1-2 周跟进」
- 拒绝 / 决定不拔 → 「明确拒绝(K01)」+ 标注"建议下次有反复时再回访"
- 备孕 / 孕期场景 → 「成功约新预约」+ 标记紧急度提高
## 老人(elder)交叉
- 老人 K01 拔牙风险高,**不主动建议拔**,改"评估是否一定要处理"
- 由 population-elder skill 主导节奏
---
name: diagnosis-K02-caries
description: K02 龋齿(蛀牙)未做充填场景。提供龋齿临床事实素材、补牙(充填)话术骨架、对应异议(不疼/小窝沟/可以等)、回写要点、儿童成人差异提示。当 plan.reasons 中含 dxCode=K02 时加载。
priority: 50
applies:
diagnosisCodePrefix: K02
version: 0.1.0
---
# K02 龋齿(蛀牙)话术包
## 临床素材
- 俗称:**蛀牙** / **虫牙** / **龋齿**(三选一,按患者口语习惯,默认"蛀牙")
- 牙位俗称:大牙(后磨牙) / 小磨牙(前磨牙) / 门牙 / 虎牙(尖牙)— 不念 FDI 数字
- 治疗:**补牙 / 充填 / 树脂修复** — 简单龋"补牙"够用,深龋可能要"治神经/根管"(交由 K04 skill 接管)
- 多颗龋:可合并表达"上次发现有几颗都需要补"
- 进展时间:龋齿一旦发现就在继续发展,**不补就一定变深**,这是科普共识可说
## opening 段增量
- 引用诊断时务必用俗称:"那次姜医生检查发现您 X 颗大牙有蛀牙,需要补一下"(而非"K02 龋齿 36 牙")
- 多颗龋:"上次发现您有 N 处需要补的"(具体颗数从 reason 数量)
## followup 段增量
### 降门槛(K02 特化)
- "补牙是基础治疗,流程很快,**单颗一般 20-30 分钟**就好"
- "局麻下做,不疼"(❌ 不能说"绝对不疼",医疗承诺禁;改"一般患者反馈基本无感")
- 多颗可分次也可一次:"如果时间允许,一次可以补 2-3 颗,效率高一些;不方便也可以分两次"
### 引用上次发现的事实(必带)
> "您上次姜医生检查时已经发现需要补的(N 颗),如果再拖,蛀的深度会加深,处理起来会更复杂"
## 异议增量(K02 特化)
- **"我又不疼,有必要补吗"** → 龋齿不疼≠没事,**蛀到神经才痛,那就要做根管不是简单补牙了**(科普,温和不恐吓)
- **"小一点的窝沟,自己注意就行"** → 龋齿是细菌侵蚀,刷牙清不掉已经形成的洞,会持续扩大
- **"我去年才补过那颗,怎么又坏了"** → 可能是邻牙新发或原补料脱落,需要面诊确认
- **"补一颗多少钱"** → 不报价(base §1.2 禁),引导:"补牙的费用要根据具体的龋洞深浅和材料定,医生面诊后给您明细"
## 回写要点增量
- 同意约补牙 → 「成功约新预约」+ 标注预计颗数
- 同意但要排期 → 「考虑中,1-2 周跟进」
- 否认("我没蛀牙") → 「诊断争议,回诊所核实」
## 儿童成人差异(交叉 population)
- 儿童 K02(乳牙龋):**不能直接套成人话术**;乳牙龋可能选择"暂观察等换牙"或"窝沟封闭",由 population-child skill 改写
- 老人 K02:可能跟"修复/义齿"叠加,治疗复杂度高,建议先约面诊综合方案
---
name: diagnosis-K03-hard-tissue
description: K03 牙体硬组织疾病(非龋损伤 — 磨损 / 楔状缺损 / 酸蚀 / 牙隐裂)。需要修复,但常被患者忽视(不疼)。提供"为什么要修复"科普骨架、异议(不疼不修)、跟 K02 龋齿区分。
priority: 50
applies:
diagnosisCodePrefix: K03
version: 0.1.0
---
# K03 牙体硬组织损伤(非龋)话术包
## 临床素材
- 俗称(按具体类型):
- 磨损 / 牙齿磨平了 / 咬合面磨损
- 楔状缺损 / 牙颈部凹陷 / 牙齿根部缺口
- 酸蚀 / 牙齿表面发黄发软
- 牙隐裂 / 牙齿裂纹 / 咬硬东西就疼
- 处置:**树脂充填修复 / 嵌体 / 牙冠**(看损伤深度)
-**K03 不是龋齿**,患者经常以为"不蛀就没事",**需要科普 "非龋损伤也要修"**
- 病因消除:磨损要查夜磨牙 / 楔状缺损要查刷牙习惯,**单纯修复不解决根因会反复**
## opening 段增量
- 引用诊断:"那次 X 医生检查发现您 X 颗牙的牙齿表面有 [磨损 / 缺损 / 酸蚀] 的情况"
- ⚠️ 不要笼统说"龋齿",这是 K02;K03 需要明说"不是蛀牙,是另一种损伤"
## followup 段增量
### 区分龋齿(必带,K03 特化)
> "这种**不是蛀牙(K02),是另一种损伤**,可能是 [咬合磨损 / 刷牙过重导致的牙颈缺损 / 饮食酸蚀] 等;治疗也是修复,但**原因不一样,要处理根因不会反复**。"
### 不痛≠没事(K03 特化)
> "这种损伤**早期通常不痛**,但如果不修,**会持续加深**,最后可能伤到牙神经,那就要做根管;早修便宜得多也简单得多。"
### 时间宽松
"本周末或下周方便的时候约一下,半小时左右"
## 异议增量(K03 特化)
- **"不疼啊,有必要修吗"** → "K03 损伤的特点就是**早期不痛**,**深到神经才痛**,那时候要做根管;早修一次就好,拖大了步骤会多"
- **"我又不是蛀牙"** → "对,这个**不是蛀牙**,是磨损 / 缺损 / 酸蚀 / 裂纹类的非龋损伤;不蛀不代表不用修,**该补还是要补**"
- **"我以前牙齿就这样"** → "可能是**长期慢性形成的**,您之前没在意没发现;**现在发现了趁早处理**,等加深处理就麻烦"
- **"是不是夜里磨牙的问题"** → "**有可能,磨损 / 隐裂常跟夜磨牙有关**;您来面诊医生会评估,严重的可能需要**配夜磨牙垫**保护"
## 回写要点增量
- 同意约修复 → 「成功约新预约」+ 标注 K03(非龋损伤,需区分 K02)
- 同意 + 怀疑夜磨牙 → 标注"可能需要 OS 垫"
- 拒绝 → 「明确拒绝」+ 标注"科普「非龋损伤也要修」,3 个月后再回访"
---
name: diagnosis-K04-endo
description: K04 牙髓 / 根尖周疾病(根管治疗适应症)。提供根管治疗话术骨架、对应异议(根管很贵/很疼/做完要不要戴冠)、术后注意、儿童不适用警示。当 plan.reasons 中含 dxCode=K04 时加载。
priority: 50
applies:
diagnosisCodePrefix: K04
allowedPopulation: [teen, adult, elder] # 儿童乳牙根管走特殊术式,不套此 skill
version: 0.1.0
---
# K04 牙髓 / 根尖周疾病话术包
## 临床素材
- 俗称:**牙神经发炎** / **牙髓炎** / **根尖发炎**(看患者反馈选)
- 治疗:**根管治疗** / **抽神经**(后者更口语化,前者更专业,看 tone 选)
- 流程:**至少 2-3 次复诊**(扩根 → 冲洗 → 充填,有时需上中间药),不能 1 次完成
-**后续戴冠**:根管做完牙容易脆裂,通常建议做牙冠保护;这条单独有意识带出,患者常忽略
- 进展:**急性发作会剧痛**(夜间痛、咬东西痛、冷热刺激痛),拖久可能要拔除
## opening 段增量
- 引用诊断:"那次 X 医生检查发现您 X 颗牙的神经已经发炎了,需要做根管"(不用"K04")
- 如 reason 提到剧痛/急性发作,可加:"您当时跟医生反馈过痛,后来情况怎么样?"(共情切入)
## followup 段增量
### 治疗安排说明(必带)
> "根管治疗一般要 2-3 次复诊才能做完,**每次大约 1 小时**。第一次扩根、清理感染,中间可能要等几天,最后封填。"
> "做完根管,医生通常会建议**戴一个牙冠**保护牙齿,因为治疗后的牙比较脆容易裂。"
### 时间紧迫性(K04 特化)
K04 比 K02 紧迫,但仍**不能恐吓**:
- ✅ "牙髓发炎一旦开始,自己不会好,建议尽快约,免得急性发作很难受"
- ❌ "不来就拔了" / "再拖就没救了"(base §4 禁)
## 异议增量(K04 特化)
- **"听说根管很贵/不便宜"** → 不报价(base §1.2),引导"具体费用面诊后医生根据牙位和复杂度报"
- **"听说很疼"** → "现在根管都是**局麻下做**,治疗过程中是不疼的,**治疗后 1-2 天可能有酸胀感**,正常"
- **"做完能用多久"** → 不承诺(base §3 禁),改"保护得好+按时戴冠+定期复查,使用很多年没问题"
- **"我直接拔了重种行不行"** → "您这颗牙根管治好的话,**保留自己的牙比种植效果更自然**,医生会给您建议"
- **"非要戴牙冠吗"** → "治疗后的牙没有神经供应,**变脆容易裂**;戴冠是医生从专业角度的建议,具体看您牙的情况"
## 回写要点增量
- 同意约根管 → 「成功约新预约」+ 标注牙位 + 预计 2-3 次
- 接受根管但纠结牙冠 → 「考虑中,术后阶段再沟通牙冠」
- 不接受根管,选择拔除 → 「明确拒绝(K04),转介拔牙咨询」
## 儿童差异
- 乳牙根管(乳牙活髓切断 / 牙髓摘除)是**特殊术式**,流程跟成人不同
- frontmatter allowedPopulation 已排除 child,儿童 K04 不应套此 skill;命中时由 population-child skill 主导改写
---
name: diagnosis-K05-perio
description: K05 牙周炎 / 牙周组织疾病(SRP 基础治疗适应症)。提供牙周治疗话术骨架、解释为何要做(刷牙刷不掉牙石)、对应异议(不疼为啥要做/牙齿会变松/反复发)、复查节奏、维护期沟通。当 plan.reasons 含 dxCode=K05 时加载。
priority: 50
applies:
diagnosisCodePrefix: K05
version: 0.1.0
---
# K05 牙周炎话术包
## 临床素材
- 俗称:**牙周病** / **牙龈发炎** / **牙石问题**(口语化,患者熟悉)
- 治疗:**牙周基础治疗 / 龈上洁治 / 龈下刮治(SRP) / 龈下根面平整**
- ⭐ 全口病 vs 局部:K05 多为**全口或多区段**,牙位为空("whole")时不要尝试找具体牙位
- 流程:**分 2-4 次**(分象限/分次完成,牙周分次做出血少恢复好),每次 30-60 分钟
-**维护期**:基础治疗只是起点,**之后每 3-6 个月要复查 + 维护洁治**,这是终身的;不维护会复发
- 风险预后:不治疗会持续骨吸收 → 牙松动 → 牙齿脱落,**40 岁后失牙第一原因是牙周不是龋齿**
## opening 段增量
- 引用诊断:"那次 X 医生检查发现您**有牙周病的情况**,建议做牙周基础治疗"(避免说"很严重"除非 reason 明确写了)
- 自然引"出血/口臭"症状:"您平时刷牙是不是有时候会出血?有口气问题?"(共情,如果患者反馈有就顺着说)
## followup 段增量
### 解释为何要做(必带,患者最常迷茫)
> "牙周病主要是牙龈下面的**牙石**,普通刷牙清不到,需要医生用专业器械分次清理,叫**牙周基础治疗**或 **SRP**。"
> "做完后,**牙龈出血会改善,口气也会变好**,长期能保住您的牙齿。"
### 流程说明(必带)
> "牙周治疗一般要分**2-4 次**完成,每次 30-60 分钟,分区做出血会少很多。"
> "做完后**每 3-6 个月要回来维护一次**,跟洗牙差不多,这是控制牙周病的关键。"
### 时间相对宽松
K05 不像 K04 急,可宽时间窗:"本周或下周哪天方便?"
## 异议增量(K05 特化)
- **"我又不疼,牙没事"** → 牙周病的特点就是**早期不疼**,等到痛了已经牙松了,**早治才能保住牙**
- **"不就是洗个牙吗?医院洗一次就行"** → 普通洗牙清的是**牙龈上面**的牙石,牙周病的牙石**藏在牙龈下面**,要专业 SRP 才能清理
- **"做完牙会不会松"** → "**做之前**牙石把牙龈撑住所以感觉不松,清理后短期可能感觉到松,但**真实情况就是这样**,医生会评估能不能保住"(老实说,不忽悠)
- **"听说牙周治疗会反复"** → "复发主要是**没有坚持维护**;基础治疗 + 3-6 个月维护洁治,可以稳定很多年"
- **"年纪大了无所谓"** → 不接受这个 framing,"老人保牙咀嚼力直接影响身体健康,**有牙能吃饭比任何保健品都重要**"
## 回写要点增量
- 同意约牙周基础 → 「成功约新预约」+ 标注预计 2-4 次
- 同意做但要排期 → 「考虑中,1-2 周跟进」
- 拒绝/觉得没必要 → 「明确拒绝(K05)」+ 标注"建议下次主诊面谈再次科普"
## 长期维护强调(close 段)
- close 段务必带"做完之后还要回来维护,我们会按时提醒您",建立长期关系认知
## 老人(elder)交叉
- 老人 K05 高发,可强调"咀嚼力↔身体健康";由 population-elder skill 协同节奏放慢
---
name: diagnosis-K06-gum-alveolar
description: K06 牙龈 / 牙槽嵴疾患(牙龈增生 / 牙龈瘤 / 牙槽嵴病变)。多需要外科或综合处置,常跟 K05 牙周炎叠加。可能需要病理活检,谨慎科普避免恐慌。
priority: 50
applies:
diagnosisCodePrefix: K06
version: 0.1.0
---
# K06 牙龈 / 牙槽嵴疾患话术包
## 临床素材
- 俗称(按具体类型):
- 牙龈增生 / 牙龈长了一块 / 牙龈鼓出来
- 牙龈瘤 / 牙龈上的肿块(⚠️ 不说"瘤"字面让患者紧张,改"牙龈上的肿物")
- 牙槽骨问题 / 牙床问题
- 处置:**面诊评估** → 必要时**手术切除 + 病理检查**
-**K06 常需要病理活检** — 切下来送病理,绝大多数是良性,**话术里不要主动提"癌"**,但也不能拍胸脯说"绝对没事"
## opening 段增量
- 引用诊断:"那次 X 医生检查时发现您牙龈上有 [一块增生 / 一个肿物 / 不太正常的地方]"
- ⚠️ 措辞中性,**不渲染严重,也不假装没事**
## followup 段增量
### 强调"评估为主"
> "这种情况**建议来面诊一下**,医生看一下具体的大小、性质,**绝大多数是良性的**,但要面诊才能确定;有些可能需要做一个小处理 + 送检查"
### 不要主动提"病理 / 活检 / 肿瘤 / 癌"等词
- 患者会脑补;让医生面诊时再讲
- 仅当患者主动问"会不会是 X"时如实回答"绝大多数是良性的,具体面诊医生看"
### 时间偏紧
- K06 不是急症,但**优于普通龋齿排队**;"近期方便的时候约一下,本周或下周都行"
## 异议增量(K06 特化)
- **"我看着没什么大事"** → "您看着确实可能是良性的,**很多 K06 都是良性的**,但**需要医生面诊评估**,确认一下性质;面诊半小时左右"
- **"会不会是肿瘤"** → 不回避也不夸大;"绝大多数是良性增生,但**确实要面诊确认**,如果需要可以**取一小块化验**;您不用过度担心,但建议来评估"
- **"我朋友说就是上火"** → "**牙龈增生有些跟炎症 / 上火有关**,有些跟其他因素有关;**真正确定要医生面诊**,不能凭外观判断"
- **"我前段时间洗过牙就好了一些"** → "**洗牙能改善牙龈炎症**,但 K06 这种增生 / 肿物**单靠洗牙不够**,还是要面诊评估"
## 回写要点增量
- 同意约面诊 → 「成功约新预约」+ 标注 K06 评估
- 决定再观察 → 「考虑中,2-4 周跟进」(K06 比 K02 优先,缩短跟进周期)
- 拒绝 → 「明确拒绝」+ 标注"建议向患者再次科普 K06 评估必要性"
- 提到"反复增大 / 出血 / 疼痛" → 标注"紧急面诊建议"
## 跟 K05 协同
- K06 + K05 同时命中是高发组合(牙龈炎/牙周炎 + 增生),按 K05 主导,K06 作为"另外还有一块需要单独看的"
---
name: diagnosis-K07-ortho
description: K07 颌面发育异常(错颌畸形 / 正畸适应症)。长周期项目(1-3 年),儿童青少年是黄金窗,成人也能做但更慢。不报价,不承诺时长,引导面诊评估。
priority: 50
applies:
diagnosisCodePrefix: K07
version: 0.1.0
---
# K07 颌面发育 / 错颌畸形(正畸)话术包
## 临床素材
- 俗称:**牙齿不齐** / **地包天** / **龅牙** / **牙缝大** / **咬合不好** / **戴牙套**(矫正俗称)
- 处置:**正畸 / 矫正 / 戴牙套 / 隐形矫正**
- 流程:**1-3 年周期**,**每月复诊 1 次**;包含:面诊评估 → 拍片取模 → 方案设计 → 戴矫治器 → 月复诊 → 保持期
-**黄金窗 8-12 岁**(儿童乳牙换牙期 / 牙列发育期);**14-17 岁次之**;成人也能做但骨头硬移动慢
- 价格区间极大(几千到十万),**严禁报价**
- 方案差异:**金属托槽 / 陶瓷托槽 / 隐形矫正(隐适美 / 时代天使等)**,适应症医生评估
- ⚠️ "戴牙套不好看 / 影响吃饭 / 周期长" — 患者最大三个顾虑
## opening 段增量
- 引用诊断:"那次 X 医生检查时,提到您 [牙齿排列不齐 / 咬合的问题],建议考虑正畸"
- 儿童家长场景(由 population-child 主导):"X 小朋友的牙齿现在 X 阶段,医生提到现在是矫正比较合适的时机"
## followup 段增量
### 强调"先评估"(必带,正畸不能盲做)
> "正畸是个**长期项目**,**先来面诊评估**,医生看一下您的具体情况、需要做什么方案、大概多长时间;评估这一步不收治疗费(⚠️ 政策无字段不主动说免费)"
### 黄金窗强调(儿童 / 青少年场景)
> "8-12 岁是儿童矫正的**黄金时期**,牙齿移动快、效果好;[X 小朋友] 现在的年纪正好;如果再晚一些处理会更复杂一些。"
### 成人场景(adult)
- 不要让成人觉得"我太晚了":
> "成年人也完全可以做,**只是周期可能比儿童长一些**;现在很多成年人在矫正"
### 不报价、不承诺时长
- ❌ "矫正大概 1 万 / 2 万"
- ❌ "您这种情况大概 18 个月"
- ✅ "**具体方案 + 价格 + 周期**,医生面诊评估后给您一个完整的方案讲解"
## 异议增量(K07 特化)
- **"我都成年了还能矫吗"** → "完全可以,**成人正畸越来越常见**;只是周期可能稍长,效果一样好"
- **"戴牙套丑 / 影响我工作"** → "现在有**隐形矫正**(透明牙套)的方案,从外观几乎看不出来;具体哪种适合您,医生会评估"
- **"听说很贵 / 大几万"** → 不报价;"正畸的费用跨度比较大,**金属、陶瓷、隐形差别不小**,**面诊医生根据您的具体情况给方案和价格**"
- **"周期太长了,要 2 年"** → "正畸是慢工细活,**确实需要 1-3 年**,具体时间面诊评估;一旦做完,牙齿排齐**一辈子受益**;您可以来听一下医生讲方案再决定"
- **"我朋友说要拔牙才能矫"** → "**部分情况确实要拔牙腾空间**,但**也有不拔牙的方案**;医生面诊评估您的牙弓骨条件后给具体建议"
- **"我们家小朋友说不想戴"** → 共情;"很多小朋友刚开始抗拒,**戴 1-2 个月就习惯了**;您方便的话先带 X 小朋友来认识医生 + 看一下牙套样子,**不一定要立刻开始**"
## 回写要点增量
- 同意约面诊评估 → 「成功约新预约」+ 标注 K07 正畸评估
- 同意但要看时间 → 「考虑中,2 周跟进」
- 决定不做 → 「明确拒绝」+ 标注原因(费用/周期/外观)
- 已在外院做 → 「已在外院」+ 关闭
## 老人(elder)交叉
- 老人正畸罕见,本 skill 中标(K07 任意年龄都加载)+ population-elder skill 主导节奏
- 老人正畸通常是修复前预先排齐,**主要是修复方案设计**,不是单纯美观;话术由 population-elder 改写为"先看一下整体修复方案"
## 协同
- 儿童 K07 + population-child:由 population-child 主导改写称谓 / CTA,本 skill 提供专业素材
---
name: diagnosis-K08-edentulism
description: K08 牙列缺损 / 牙列缺失场景。提供种植 / 义齿 / 牙桥话术骨架、对应异议(价格/做完能用多久/年纪大了还种吗)、不报价铁律、儿童禁用警示(乳牙脱落非疾病)。当 plan.reasons 含 dxCode=K08 时加载。
priority: 50
applies:
diagnosisCodePrefix: K08
allowedPopulation: [teen, adult, elder] # 儿童 K08 多为乳牙脱落,非疾病召回应在 SQL 层就排除
version: 0.1.0
---
# K08 缺牙(缺失修复)话术包
## 临床素材
- 俗称:**缺牙** / **缺一颗** / **掉的那颗**(直接 + 口语)
- 治疗:**种植牙** / **烤瓷桥(牙桥)** / **活动义齿**(三种主流方案,适用条件不同医生定)
- 时间窗:**缺牙 3-6 个月内启动修复最佳**,拖太久邻牙倾斜、对颌伸长,后期种植难度+费用都上去
-**绝对不能报价**:种植牙价格区间极大(几千到几万),含品牌/位置/骨量/上下结构差异,**任何价格暗示都会出大事**
- 缺牙不补的危害:邻牙倾斜、对颌牙伸长、咬合错位、咀嚼偏侧 → **不仅是少颗牙的事**
## opening 段增量
- 引用诊断:"那次 X 医生检查时,看到您 X 颗牙缺了,提醒您考虑做修复"
- 时间:如 reason.triggerDate 较久(>180 天),可加"算下来已经 X 个多月了"
- 单颗 vs 多颗:1 颗用"那颗",2-3 颗用"那几颗",4+ 颗考虑"半口/全口"叫法
## followup 段增量
### 修复方案给方向不给细节(必带)
> "缺牙的修复一般有**几种方案**:种植牙、烤瓷桥、活动义齿,具体哪种适合您,医生**面诊评估骨条件**后会给您建议。"
> "**这次只是面诊评估,不需要做任何操作**,医生看一下情况,跟您说几种方案的优缺点。"
### 时间窗紧迫(K08 特化,但温和)
> "缺牙时间越长,**旁边的牙会慢慢倒过来**,对面的牙会**长长**,后面再修复需要先处理这些,会麻烦一些。早一点评估好处理。"
- ❌ 不能恐吓"再拖就种不了"
### 不报价铁律 ⭐
- ❌ "种植大概 X 千 / X 万" — 严禁
- ❌ "我们这种植性价比高" — 严禁
- ❌ "活动义齿便宜些" — 严禁
- ✅ "具体方案和费用,医生**面诊后会给您一个明细**,看您选哪种方案,种植和义齿差别不小"
## 异议增量(K08 特化)
- **"我都这么大年纪了还种啥"** → "**保持咀嚼力对老年人健康很重要**,医生会根据您的骨条件评估能不能做,做不了也有义齿的方案;先看一下不亏"
- **"种了能用几年"** → "保养得好,**配合定期复查,可以使用很多年**;具体面诊医生会跟您讲护理"(不给具体年数承诺)
- **"听说挺贵的"** → "种植牙费用确实跨度比较大,**面诊医生会按您牙位/骨头条件给具体方案和价格**,有不同选择;您先来评估,不评估就没有具体数"
- **"做了会不会疼"** → "种植是局麻下做,**过程中不疼**,术后 1-2 天可能有肿胀,正常;现在有微创术式,不舒服感会更少"
- **"我先用活动义齿凑合"** → 尊重选择;"活动义齿是个方案,但**长期咀嚼舒适度种植会好很多**,您可以面诊时听医生比较"
- **"我去隔壁牙科看过价格"** → "好的;我们这边面诊评估和方案是免费的(⚠️ 仅当诊所政策允许,无字段时不主动提),您方便的话来对比一下医生方案"
## 回写要点增量
- 同意约面诊评估 → 「成功约新预约」+ 标注 K08 面诊
- 考虑中 → 「考虑中,1-2 周跟进」
- 决定不做 → 「明确拒绝」+ 标记原因(费用/年龄/外院)
- 已在外院做了/在做 → 「已在外院治疗」+ 关闭召回
## 老人(elder)交叉强化
- K08 + elder 极常见组合,population-elder skill 会强调节奏 + 家属同意,本 skill 保留临床素材主导
- 异议"年纪大了还种啥"是高频,本 skill 已 cover
---
name: diagnosis-K09-jaw-cyst
description: K09 颌骨囊肿 / 颌骨其他疾病。少见但高风险场景,需要外科会诊 / 手术 / 病理活检。话术高度谨慎,不在电话里详细科普,核心目的"约最近时间面诊外科"。
priority: 60
applies:
diagnosisCodePrefix: K09
allowedPopulation: [teen, adult, elder] # 儿童 K09 极罕见,有也走儿科专项
version: 0.1.0
---
# K09 颌骨囊肿 / 颌骨疾病话术包
## ⭐ 核心定位
**K09 是 K00-K09 中风险最高的场景**:可能需要手术、可能需要病理。话术必须**高度谨慎**:
-**不在电话里详细科普方案**(医生面诊讲)
-**不主动提"癌 / 肿瘤 / 切除手术"**(吓患者)
-**不淡化**("没什么大事" — 不负责任)
-**核心目的:把患者请来面诊,医生当面讲**
- ✅ 强调"**外科 / 颌面外科** 医生面诊"(让患者知道严肃)
- ✅ 时间紧迫感比 K01-K08 高,**优先安排**
## 临床素材
- 俗称:**颌骨问题** / **下巴(上颌)有囊状的东西** / **拍片发现的影像问题**
- 处置:**外科 / 颌面外科面诊****手术摘除 + 病理检查**(大部分)
- 紧迫度:**亚急性**,不是急救,但**不能拖几个月**
## opening 段增量
- 引用诊断:"那次 X 医生检查时发现您 [颌骨上有囊状的影像 / 下巴有不太正常的地方],建议您**尽快回来由外科医生面诊**"
- ⭐ "外科医生面诊"措辞要说出来 — 让患者知道这不是普通牙问题
## followup 段增量
### 措辞模板(必带,严格)
> "这种情况建议**近期回来由我们外科医生面诊评估**,看一下具体的范围、性质,**绝大多数是良性的**,但**确实需要面诊确定**,可能需要做一些处理。"
> "时间上**尽量这 1-2 周内**约一下,**不影响您日常,但也不要拖太久**。"
### 时间安排
- 工作日上午医生集中接外科会诊:"周一到周五上午可以专门安排外科,您看哪天方便?"
- 给具体方向 + (示例):"您看 X 上午这个方向行不行,我先帮您按 [周二上午(示例)] 登记,具体时段诊所确认后短信告诉您"
### 不在电话里讲具体方案
- ❌ "可能要做手术摘除"
- ❌ "可能要送病理"
- ✅ "**具体方案医生面诊讲**,这边电话里不能给您细看"
## 异议增量(K09 特化)
- **"是不是很严重 / 是不是肿瘤"** → 不夸大也不淡化;"绝大多数颌骨囊肿是**良性的**,但**确实需要面诊确认**;您**不必过度担心**,也**建议尽快来面诊**"
- **"我看着 / 我感觉没什么"** → "K09 大部分**早期没有感觉**,**主要靠影像发现**;但因为发现了,**就该面诊评估**,这是负责任的处理方式"
- **"我去综合医院看吧"** → 尊重 + 给选择;"完全可以,综合医院的口腔颌面外科也合适;您**方便来我们这边**也可以,我们这边有 X 医生(⚠️ 仅 reason.triggerDoctor 有时填,否则改为"我们外科医生")"
- **"我想跟家里人商量一下"** → 配合(老人场景常见);"完全可以,**建议尽快**商量 + 来面诊;您和家人商量好了告诉我们,这边帮您留外科时间"
- **"上次拍的片子能不能再看一下"** → "可以,**带上片子来面诊**,医生会综合看;**面诊比单看片子更准**"
## 回写要点增量
- 同意约面诊 → 「成功约新预约」+ **标注高优先级 K09 + 外科**
- 决定再观察 → 「考虑中,**1 周后必须再联系**」(K09 跟进窗很短)
- 决定去其他医院 → 「已在外院 / 转外院」+ **标注 K09 转出,关注后续**
- 完全拒绝面诊 → 「明确拒绝」+ 升级标记"K09 拒绝面诊,主管核查后人工跟进"
## 客服执行特别注意
- ⚠️ **K09 不能像 K02 那样随便结案**,任何"考虑中"都要 1 周内复联
- ⚠️ **不要在通话末尾说"祝您一切顺利"** — 显轻飘,改"那您先和家人商量,我们等您消息"
## 老人(elder)交叉
- K09 + 老人,population-elder skill 主导节奏,本 skill 提供素材;**家人参与决策必须主动提**
---
name: playbook-objection-bank
description: 异议应对总库,scenario × population 跨场景共用的高频异议(再考虑 / 价格 / 没时间 / 已在外院 / 不打扰)+ 客服回复范式。LLM 在 objection 段必须按 ### A./B./C. 子标题分块,挑 3-4 个最适合本次召回的异议覆盖。本 skill 全场景加载(priority 200,装配在最后)。
priority: 200
applies: {} # 全场景加载,跨 scenario × diagnosis × population
version: 0.1.0
---
# 异议应对总库(playbook)
## ⭐ 客服外呼最常见的 8 种异议(按出现频率排序)
### 1. "我再考虑考虑"(出现率 ~40%)
**底层动机**:还没下决心 / 怕被推销 / 想给自己留缓冲
**应对范式**:
- ✅ "完全理解,这种决定确实要想清楚。这样,我先帮您把医生时间留到 [周 X 上午或周 Y 晚上](示例),您想好告诉我们,如果不来我帮您取消就行,没关系。"
- ✅ "好的,您慢慢想;**X 月底之前**告诉我们都行(给一个软窗口)"
- ❌ "您再考虑就晚了" / "今天定下来还能 X" / "下次价格可能变"(全是销售套路)
### 2. "最近真的没时间 / 工作太忙"(出现率 ~25%)
**底层动机**:时间确实紧 / 也可能是软拒绝
**应对范式**:
- ✅ "理解,**周末或者工作日晚上**(到 8 点前)我们都开,您看哪个时段方便?"
- ✅ "如果近期实在不行,**下个月也可以**;您方便了告诉我们,我们提前帮您留个时间"
- ❌ "您再忙也得有健康" / "X 病不能等"(说教)
### 3. "听说挺贵的 / 多少钱"(出现率 ~20%)
**底层动机**:价格敏感 / 想砍价
**应对范式**:
- ✅ "[X 治疗类]的费用,**医生面诊后会给您一个明细**,根据牙位 / 方案 / 材料不同会有差异;您来评估之后,自己看着选合适的方案"
- ✅ "我这边电话里给不到准确的报价,这个由医生定,**面诊评估不收费**(⚠️ 仅当政策允许时,无字段不主动说免费)"
- ❌ "我们家性价比高" / "我们打折活动" / "X 千就够"(base §1 严禁报价/活动)
### 4. "已经在别的医院 / 已经在做了"(出现率 ~15%)
**底层动机**:已选择其他诊所
**应对范式**:
- ✅ "好的 [X 先生],那您方便的话我帮您把我们这边的召回记录关一下,**祝您治疗顺利**" + 回写「已在外院」
- ✅ "如果您后续想参考一下其他医生的方案,**也欢迎来我们这边对比**;不来也没关系"
- ❌ 不追问"哪家医院 / 多少钱" — 不合规也不礼貌
- ❌ 不拉踩其他医院
### 5. "不要打了 / 别再联系我"(出现率 ~5%,但必须严肃对待)
**底层动机**:不希望被联系
**应对范式**:
- ✅ "**好的 [X 先生],我立刻帮您加入不打扰名单**,后续不会再联系您;**祝您一切顺利**"
- ✅ 立即结束通话,**回写「不打扰」标记**
- ❌ 不解释 / 不挽留 / 不再次推销(任何挽留都让人更烦)
- ❌ 不说"我们是为您好"
### 6. "我不记得在你们诊所看过 / 怎么有我电话"(出现率 ~5%,新客高发)
**底层动机**:对诊所无印象 / 警惕陌生电话
**应对范式**:
- ✅ "我看到我们这边登记您 [X 月 X 日](具体引 reason 触发日期)到过 [X 诊所名] 做过 [X 检查/治疗];可能是 [家人帮约 / 公司体检 / 团购] ?"(给具体场景帮患者回忆)
- ✅ 如确认从未到过:"那可能是登记错了,我帮您从我们系统里删掉" + 回写「不打扰」
- ❌ 不强辩 "您一定来过"
- ❌ 不催"您仔细想想"
### 7. "我没那么严重 / 不疼,有必要吗"(出现率 ~20%,多见于 K02/K05/K07)
**底层动机**:无症状 → 觉得不必要
**应对范式**:
- ✅ "[K02/K05/K07 类] 的特点确实是**早期不疼**,但**会持续发展**;**早处理代价小**,等出现明显症状代价会大一些"
- ✅ 给具体临床事实佐证:"医生 X 月 X 日看到 X 牙的 X 问题"(从 reason 拿)
- ❌ 不恐吓"再不来就晚了" / "您再拖就没救了"
- ❌ 不用一般化恐吓("X 病很严重") — 要具体到这个患者
### 8. "我跟家人商量一下"(出现率 ~10%,老人 / 儿童家长高发)
**底层动机**:重大决策需要家庭参与
**应对范式**:
- ✅ "完全理解,**重要决定**要跟家人商量;您和家人商量好了告诉我们,我们这边帮您留好时间"
- ✅ 老人场景主动提:"**家人方便的话可以一起来听一下医生方案**"
- ❌ 不催"今天能不能定" — 会让患者立刻挂电话
---
## 用法指引
LLM 在生成 objection 段时:
1. **挑 3-4 个**最适合本次召回(scenario / population / dx)的异议
2. **按 ### A./B./C. 子标题分块**(base §5 已强制)
3. **每个异议下面 1 段 blockquote `> "..."`** 给客服话术
4. 可选 `> → 提交结果选「xxx」` 一行,指明客服回写动作
5. 跟 scenario / population skill 内已 cover 的具体异议**避免重复**;那些是细分,这里是通用兜底
### 优先级建议(按 scenario × population)
- **启治召回 + 成人**:1.再考虑 / 2.没时间 / 3.价格 / 7.不严重
- **启治召回 + 老人**:1.再考虑 / 8.跟家人商量 / 7.不严重 / 4.已在外院
- **启治召回 + 儿童家长**:1.再考虑 / 7.不严重 / 8.跟家人商量(儿童特化版异议见 population-child skill)
- **启治召回 + 新客**:6.不记得诊所 / 4.已在外院 / 1.再考虑
- **K08/K07 + 任意**:3.价格(高频)+ 1.再考虑 + 2.没时间
- **K05/K06/K09 + 任意**:7.不严重 + 8.跟家人商量 + 1.再考虑
### 跟 K-code / population skill 的协同
- diagnosis SKILL.md 内的"异议增量"是**该 dx 特化** 的具体异议(如 K08 "做了能用几年" / K04 "听说很疼")
- 本 playbook 是**跨场景通用兜底** + 优先级指引
- 异议选 3-4 个时,**优先用 dx 特化**,通用兜底补缺
---
name: playbook-safety-self-check
description: Safety 规则的"描述版" — 让 LLM 在生成前就自检 6 条 close 段约束 + 禁词 + 承诺式表述,主动规避后置 safety gate 命中(命中要走 fallback 损失质量)。本 skill 全场景加载(priority 250,装配在最末)。机器规则继续 TS(call.ts safetyRules),不挪 yaml。
priority: 250
applies: {}
version: 0.1.0
---
# Safety 自检清单(LLM 输出前主动核查)
⚠️ 以下规则是**后置硬约束**,LLM 输出后会用代码扫描;**命中 block 类规则 → 整段输出作废 → 走 fallback 模板**(质量下降)。
**所以每次输出前,**自己**先按这个清单核查一遍**,避免命中。
## 1. 全段:禁词扫描(block — 命中即作废)
任一段(opening/followup/objection/close)含以下任一词,**整次输出失败**:
- `一定能``保证``绝对``百分百``100%`(医疗承诺)
- `亲爱的`(销售化称呼)
- `便宜``促销``折扣``免费送`(销售化)
→ 检查方法:全文 Ctrl+F 扫一遍,有则改泛指或删除。
## 2. close 段:不能加粗具体时间(block — 命中即作废)
`**周六上午10点**` / `**本周五下午**` / `**明天上午9点**`
- 检测正则:`/\*\*[^*\n]*(?:周[一二三四五六日天]|\d+\s*(?:点|:|:))[^*\n]*\*\*/`
- 命中即整次输出失败
正确写法(任选):
-`周六上午10点(示例)` ← 不加粗 + (示例) 后缀
-`周六上午这个方向` ← 方向词替换具体时间
→ 检查方法:close 段所有 `**...**` 加粗块,看有没有"周X / 数字 + 点"。
## 3. close 段:不能用承诺式表述(block — 命中即作废)
PAC 没有排班 API,所以**没有任何时间是"已经定下来的"**;不能用以下措辞:
-`已为您约好` / `已成功预约` / `已为您预约` / `已经为您约` / `已替您预约`
-`约定本` / `敲定本` / `安排好了` / `已经预约`
正确写法:
-`我先按 X 帮您登记,具体时段以诊所排班为准`
-`我先帮您留 X 时间方向,稍后跟前台确认后短信通知您实际时间`
## 4. close 段:必须含"待确认"语义短语(warn — 不阻断但提示)
close 段缺以下任一短语会触发 warn 日志,不影响输出但应该带:
- `以诊所排班为准` / `排班为准`
- `稍后跟前台确认` / `跟前台确认` / `稍后跟诊所确认`
- `稍后短信确认` / `排班确认后告知` / `排班确认后短信`
- `稍后短信通知您实际` / `具体时段以` / `具体时间以`
→ 检查方法:close 段是不是有一句"待确认/排班相关"的弱化措辞。
## 5. objection 段:必须 ### A./B./C. 子标题分块(warn — 不阻断)
❌ 把 3 个异议合并成一长段
❌ 用 `- xxx` bullet 列异议
✅ 正确:
```
### A. "我再考虑考虑"
> "客服话术..."
### B. "最近没时间"
> "客服话术..."
```
## 6. close 段:必须含具体时间数字(warn — 不阻断)
close 段如果完全没数字(没"X 点"也没"X 月 X 日"也没"X 周")会触发 warn。
→ 给一个示例时间即可(如"周六上午10点(示例)")。
---
## 自检顺序建议
输出每段前依次问自己:
1. **opening / followup**:有没有引用至少 3 条 user prompt 给的事实?有没有「身份不符」的具体名词?
2. **objection**:是不是 3-4 个 `### A./B./C.` 子标题?每个有 blockquote `>`?
3. **close**:
- 加粗块有没有具体时间词?(有就去掉加粗 + (示例) 后缀)
- 有没有"我已为您约好"等承诺词?(有就改"我先按 X 登记")
- 有没有"以诊所排班为准 / 稍后跟前台确认"等弱化短语?(没有就加 1 句)
- 有没有 `**回写要点**` + 2-4 条 bullet?
4. **全段**:Ctrl+F 扫禁词清单(§1)
---
## 跟代码 safety rule 的关系
本 SKILL.md 是**给 LLM 看**的描述版自检清单,**机器规则**实际在
`apps/pac-service/src/modules/ai/calls/draft-plan-script/call.ts`
`safetyRules` 数组里(强类型 `SafetyRule<TOutput>`,runtime 性能 + zod 校验)。
**两者必须同步**:改了 call.ts 的 safetyRules,也要更新本文件;否则 LLM 自检会过但后置 gate 命中。
新加规则 PR review 时双向检查。
---
name: population-adult
description: 患者年龄 18-64 岁成年人(baseline 主流)。沟通对象患者本人,直接专业,时间紧凑,异议偏价格 / 时间平衡。tone 默认 professional;熟客可切 warm。当 patient.age 在 18-64 时加载。此 skill 较薄,大部分依赖 base + diagnosis + relationship。
priority: 100
applies:
ageMin: 18
ageMax: 64
version: 0.1.0
---
# 成年人 (18-64) 沟通包(baseline)
## 对话对象
- 患者本人,**直接专业**
- 不需要"找家长"等额外路径
## 称呼模式
- ✅ "X 先生" / "X 女士"(base 「患者.称呼」原样照用,不改写)
## tone 默认
- **professional**(专业稳重) — 默认
- 熟客(relationship-returning 接管时)可切 **warm**
- 高紧迫场景(K04 急性发作、K09 颌骨等)可切 **urgent**
## 沟通节奏
- **紧凑高效**:成年人通话耐心有限,3-5 分钟内讲清核心
- 避免冗长寒暄,1-2 句切入正题
- 时间选项给具体且**贴合白领时段**:工作日早上 / 晚上 / 周末
- ⚠️ **不要默认"工作日 19:00 后"**(base §1.1 禁:无字段不能假设偏好时段)
- ✅ "本周末或下周工作日晚上,您看哪个方便?"(给选项让患者自选)
## opening 段增量
- 简洁直接:"X 先生您好,我是 X 诊所客服,主要是想跟您同步一下上次到店时医生发现的情况"
- 不要"亲切寒暄"3 句以上
## followup 段增量
- 信息密度高,**说清楚 + 给选项** 即可,不要绕弯
- 多颗治疗 / 复杂方案:1-2 句提骨架 + "具体面诊医生跟您细说"
## 异议增量(成年人特化)
- **"我最近工作忙"** → "理解;您看周末或下班后,哪个时段方便?"
- **"我看看时间再回复"** → "好的;一般近期处理对治疗会更顺一些,您方便了告诉我们一下,我们留好时间"
- **"我去其他诊所看看"** → 尊重;"那您看好之后,有需要参考医生方案或者比对一下的话也欢迎来"
## 这个 skill 故意保持薄
- 成年人是 baseline,大部分话术由 base + diagnosis + relationship skill 决定
- 这里只**校准 tone / 节奏 / 称呼** 三件事
- 不要在此 skill 加太多场景化内容(否则跟 diagnosis skill 重叠)
---
name: population-child
description: 患者年龄小于 14 岁的儿童场景。沟通对象切换为家长(不是患儿本人),称呼模式 / CTA / 临床禁忌完全不同。给定 tone=warm 默认,强调亲和力。当 patient.age <= 13 时加载;此 skill 应主导改写 opening / followup 段的称呼和 CTA。
priority: 100
applies:
ageMax: 13
version: 0.1.0
---
# 儿童 (<14) 沟通包
## ⭐ 对话对象切换(铁律)
- **接通后第一句话先确认是否家长接听**,不直接对孩子讲临床方案
- ✅ "您好,是 X 小朋友的家长吗?"
- ✅ 如不是家长接听:"那您方便帮忙转告家长,或者告诉我家长方便的时间我们再回拨?"
- ❌ 不能直接念诊断给孩子("您家小朋友 K02 龋齿" — 孩子听不懂,家长不在场也无法决策)
## 称呼模式(改写 base 「患者.称呼」)
- ✅ "X 小朋友的家长您好"
- ✅ "X 妈妈您好" / "X 爸爸您好"(性别字段如标注则用)
- ❌ 不用"X 先生/女士"(儿童本人称谓)— 整体替换为家长称谓
- ❌ 不用"亲""宝"(base §3 禁词)
## tone 默认
**warm** — 语速慢,关怀向,叙述带温度
## 临床禁忌(K-code 交叉)
- **K08 缺牙**:儿童缺牙多为**乳牙脱落换牙**,**不是疾病召回**,应在 SQL 层已排除。如仍触发,优先讲"换牙过程中观察",**不要套成人种植/义齿话术**
- **K04 根管**:儿童乳牙根管是**乳牙活髓切断 / 牙髓摘除**,**特殊术式**,不能用成人"根管 2-3 次复诊"骨架(diagnosis-K04 frontmatter 已排除 child)
- **K07 正畸**:儿童 8-12 岁是**黄金正畸窗口**,可主动建议"是不是顺便约一下正畸科医生评估,牙列发育期处理更轻松"
- **K02 龋齿**:乳牙龋可选"暂观察等换牙"或"窝沟封闭"或"补",由医生面诊定,客服只引导面诊不预判
## opening 段增量
- 自报家门后:"主要是想跟您聊聊您家小朋友(姓 X)上次到店的情况"
- 引用诊断:"那次 X 医生检查时,提到 X 小朋友的 ..."
## followup 段增量(降门槛改写)
- "**面诊评估不需要做什么**,医生看一眼,跟您说明白现在是什么情况,后续要不要处理、什么时候处理"
- "儿童牙齿很多情况是**观察 + 定期复查**,不一定都要立刻处理"
- 时间偏好:**周末 / 寒暑假**(默认家长上班 + 孩子上学日难安排)
- ✅ "您看这周末或下周末方便带 X 小朋友过来吗"
## 异议增量(儿童家长特化)
- **"孩子说不疼,不想去"** → "孩子的感觉是这样,但**乳牙问题影响后面的恒牙**,**早看早安心**;一般来现场玩一下不会很抗拒,我们医生也熟悉跟小朋友沟通"
- **"上学没时间"** → "完全理解,可以约**周末或者放学之后**;一般 30 分钟左右"
- **"我跟孩子他爸再商量下"** → "好的,您方便商量好了告诉我们,我们这边帮 X 小朋友把检查时间留着"
- **"孩子怕看牙"** → "我们诊所有小朋友专用的椅子(⚠️ 仅当确实有时说,无字段时改"医生很有经验,看小朋友比较多");可以**第一次只是过来认识一下医生**,不做操作"
- **"是不是想让我们种牙/做牙套"** → 安抚:"小朋友这个年纪不会做种植,**主要是检查 + 必要时做基础处理**;具体医生评估后给您讲"
## 回写要点增量
- 同意约 → 「成功约新预约」+ 标注"儿童 + 家长陪同"
- 家长要商量 → 「考虑中,3-5 天家长联系」
- 拒绝(觉得没必要)→ 「明确拒绝」+ 标注"建议下次复查再次科普"
- 监护人电话不通 → 「家长未联系上,改期回拨」
## 客服执行注意点
- 儿童名字念全名("张 XX 小朋友")而非姓 + 称谓("张小朋友"听着像通称)— 加深个性化
- 如能从 reason 拿到孩子年龄,可在自然语境引用("X 岁正好是换牙的阶段")
---
name: population-elder
description: 患者年龄 65 岁以上老年场景。慢节奏 / 大字 / 复述确认必带;家属可能参与决策(建议提"是否需要跟家人商量");牙周 / 缺牙高发,口齿沟通可能慢。tone warm 默认。当 patient.age >= 65 时加载。
priority: 100
applies:
ageMin: 65
version: 0.1.0
---
# 老年 (>=65) 沟通包
## 对话对象
- 患者本人,但**家人可能参与决策**
- 大方案(种植 / 多颗治疗 / 手术)默认主动提:"您是不是要跟家里人商量一下?"
- 不假设独居 / 不假设需要陪同(PAC 无字段);**保守 + 尊重**
## 称呼模式
- ✅ "X 先生" / "X 女士" 不变(尊重而非长辈化的"X 阿姨/叔叔",除非诊所政策明确)
- ❌ 不用"老人家""大爷大妈"(显冒犯)
## tone 默认
- **warm**(温和,语速明显放慢)— 默认
- ❌ 不能切 urgent(让老人焦虑)
## 沟通节奏(老年特化关键)
- **每句话稍短**,避免一口气讲长串信息
- **关键信息复述一次**:"我再跟您确认一下,X 月 X 号上午,对吗"
- **关键名词补一句解释**:"做牙周治疗,就是清理牙龈下面的牙石,跟洗牙类似但更深入"
- **听不清主动应对**:不催促,愿意重复;"没事您慢慢说" / "我再说一遍您听"
- **时间选项尽量** **白天 + 工作日**(老人晚上出门不便)
- ✅ "上午 10 点左右"(老人喜欢)
- ❌ "晚上 8 点"(强烈不建议默认)
## 临床场景特点(老年高发)
- **K08 缺牙**:老年极高发,本 skill + diagnosis-K08 协同,强调"咀嚼力关系到吃饭吃得下↔身体健康"
- **K05 牙周**:老年高发,本 skill + K05 协同,强调"早治才能保住自己的牙"
- 多基础病(无字段假设):**不主动问、不主动建议**,只在患者主动提时回应"是的,有基础病的话医生面诊时会综合评估方案"
## opening 段增量
- 自报家门**完整**(老人对陌生电话警惕):"您好,我是 X 诊所(完整诊所名)的客服,我姓 X(可以编个 default 姓 — ⚠️ 不,客服自己姓不能编;改为"我们诊所客服")"
- ⭐ 正确:"X 先生您好,这里是 X 诊所,我是诊所的客服,主要是想跟您聊聊上次到店医生发现的情况"
- 给老人**主动权**:"您现在方便讲两句吗?不方便我们可以约个时间再回您"
## followup 段增量
### 关怀向引诊断
- "那次 X 医生检查时,提到您 X 牙的情况;我们这边想跟您约个时间回来看看"
- 自然提家属:"您是不是要跟家里人**商量一下时间**?方便的话可以让家人陪您来"
### 大方案给方向不细节
- 老年人需要消化时间,**不要在电话里讲完整方案**;留 hook:"具体什么方案合适,您来面诊医生跟您讲清楚,您也可以带家人一起来听"
## 异议增量(老年特化)
- **"我都这岁数了,无所谓"** → 不接 framing;"您能吃饭吃得下,**身体才好**;牙的事情真不能凑合;您来听一下医生怎么说,不做也没关系"
- **"我儿子(女儿)不在身边"** → 共情;"那您方便联系上家人后,告诉他/她一声,看哪天能来陪您?我们这边帮您留好时间"
- **"听说做这个老人不安全"** → "现在的技术很成熟,**医生面诊时会**评估您的身体情况能不能做,做不了医生不会勉强;先看一下不亏"
- **"我家人说让我先观察"** → 尊重;"好的,您和家人商量好任何时候告诉我们都行;**身体感觉不舒服**随时来"
- **"我听不清楚 / 您说慢一点"** → 立刻减慢;"好的我说慢一些"(此条由 LLM tone 控制即可,不需要枚举所有变体)
## 回写要点增量
- 同意约 → 「成功约新预约」+ 标注"老年 + 询家属同行"
- 家人未确定 → 「考虑中,3-5 天家人沟通后回复」
- 拒绝 → 「明确拒绝」+ 标注"建议下次复查/体检季再回访"
- 沟通障碍(听不清/反复跑题) → 「需要重新沟通」+ 标注"建议家属代沟通"
## ⚠️ 老年人特别禁忌
-**不能逼问**("您今天能不能定下来" — 老人需要消化)
-**不能制造紧迫感**("再不来就严重了" — 老人本就焦虑健康)
-**不能跨过家属直接定方案**("您来就行,不用跟家人说" — 不合伦理)
---
name: population-teen
description: 患者年龄 14-17 岁青少年场景。半自主决策,家长同决策但患者本人有发言权。正畸黄金窗末段、运动牙外伤高发。tone 偏轻松不刻意亲昵,沟通对象优先患者本人,价格 / 大方案 cc 家长。当 patient.age 在 14-17 时加载。
priority: 100
applies:
ageMin: 14
ageMax: 17
version: 0.1.0
---
# 青少年 (14-17) 沟通包
## 对话对象
- **优先接通患者本人**,半自主沟通日常 / 复查类事项
- **大方案(种植 / 正畸 / 多颗治疗)必须 cc 家长**,患者表态后跟进:"那这个您打算跟爸妈商量一下吗?"
- 监护人电话如有(目前 PAC 无字段)优先用监护人;否则患者本人 → 转家长
## 称呼模式
- ✅ 直接姓 + 名("张 XX 您好" — 比"先生/女士"自然)
- ✅ 性别明确可叫"小张 / 张同学"
- ❌ 不用"亲""宝"
- ❌ 不用"小朋友"(已显幼态,青少年抵触)
## tone 默认
**warm**(温和)但**不刻意亲昵**;偶尔可稍轻松("您最近学业紧张吗")— **避免油腻 / 自来熟**
## 临床场景特点
- **K07 正畸**:14-17 是末段黄金窗(早一点更轻松,但仍可做);如 reason 含 K07,加强"现在做时间合适"
- **K01 阻生牙(智齿)**:18 岁前不一定要拔,医生评估;不主动建议手术
- **运动牙外伤**:青少年高发场景(K00/K03),通常急性来过 → 现在召回看后续修复 / 复查
- **K08 缺牙**:罕见(除非外伤),按外伤后修复方向沟通,**多为临时修复**(种植要骨发育完成,通常 18+)
## opening 段增量
- 直入主题:"上次 X 医生检查时提到..."
- 加学业共情:"我知道您可能学业比较紧,所以提前给您约个方便的时间"(只在 reason 反映过来过或有上次到诊)
## followup 段增量
- 时间倾向:**周末 / 假期 / 放学后**
- 强调便利性:"我们诊所周末也开,不耽误您上学"
- 大方案铺垫:"这个方案具体的医生面诊会跟您讲,**也建议家长一起来听一下**"
## 异议增量
- **"我有自习 / 补课"** → 共情;"那您方便周末吗?或者您看哪天放学后比较空"
- **"我跟我妈商量下"** → 鼓励;"好的,您和家长商量好了告诉我们,这边帮您把时间预留"
- **"现在不想做"** → 不强迫;"理解,您方便的时候随时告诉我们 / 我们到时候再跟进"
- **"贵吗"** → 不报价;"具体方案的费用要看医生面诊后的明细;您和家长一起来看一下方案"
## 回写要点
- 同意约 → 「成功约新预约」+ 标注"青少年,大方案 cc 家长"
- 患者同意 + 待家长 → 「考虑中,3-5 天家长跟进」
- 拒绝 → 「明确拒绝」+ 原因
---
name: relationship-new-customer
description: 新客(历史已做治疗次数 = 0)。患者**不熟悉诊所**,不认识医生,无可引用的上次治疗。opening 要详细自报家门 + 建立信任,不能"好久不见"。素材库换成"诊断后未启动"而非"上次治疗回访"。当 clinicalContext.completedTreatmentCount === 0 时加载。
priority: 80
applies:
relationship: new
version: 0.1.0
---
# 新客沟通包(0 次历史治疗)
## ⭐ 核心差异
- **没有"上次治疗"可引** — base "引事实 ≥3 条" 改为引"上次到店检查 / 触发诊断 / 待办治疗"
- **不认识医生** — 不要说"X 医生还是您熟悉的医生",可说"那次帮您检查的是 X 医生"
- **不熟悉诊所** — 自报家门完整,可包含基本定位("我们诊所位于 XX,是您之前来过的那家")
- **没有"好久不见"** — base 默认 followup 中"距上次到店 X 天"是首次到店之后的间隔,不是治疗后
## opening 段增量(改写)
### 自报家门完整版(必带)
- "X 先生您好,这里是 X 诊所(完整名称),我是诊所的客服"
- "您前段时间到我们诊所做过一次检查,这次主要是想跟您同步一下当时医生的检查情况"
### 引"那次到店"而非"上次治疗"
- ✅ "那次 X 医生给您做了检查,发现 X 问题,建议您 X 时候回来处理"
- ❌ "您上次治疗后,医生建议..."(没治疗过,这句话假)
## followup 段增量
### 降门槛加倍(必带)
新客对诊所没信任,所有"邀约面诊"门槛要加倍降低:
- "**这次就是面诊评估**,医生看一下情况,跟您讲清楚,不需要做任何操作"
- "您面诊之后,要不要做、什么时候做,都由您决定"
- 可适度叠加诊所信任 token:"我们医生在 X 领域**有 10+ 年经验**"(⚠️ 仅当 reason 触发医生 + 资质有字段时,无字段不可加)
### 不假设熟悉度
- ❌ "您应该知道我们诊所的 X 医生"
- ✅ "那次帮您检查的是 X 医生"
## tone 默认覆盖
- 默认 **professional**(新客对诊所的第一印象需要专业感)
- 老人新客可切 warm(由 population-elder 接管)
## 异议增量(新客特化)
- **"我都没去过你们诊所"** → 立即核实;"我看到我们这边登记您 X 月 X 日到过,可能是您家人帮约的?或者您当时是不是 ..."(不强辩,温和澄清)
- **"我只是去看了一下,没打算治疗"** → 尊重;"完全理解,所以这次也只是同步一下医生的检查情况,您之后是不是要处理由您定"
- **"我已经在别的医院在治了"** → 立即收口;"那好的,X 先生,祝您治疗顺利"+ 主动结束 + 标记"已在外院"
- **"你是从哪里拿到我电话的"** → 真实:"是您之前到我们诊所登记时留的联系方式;您不希望再被联系的话,我帮您加到不打扰名单"
- **"我考虑下"** → 不催;"好的,需要的时候随时联系我们就行"
## 回写要点增量
- 同意约面诊 → 「成功约新预约(新客转化)」
- 礼貌拒绝 → 「明确拒绝」+ 标记"新客首次召回拒绝,1 个月内不再回访"
- 不希望被联系 → 立即「加入不打扰」
- 已在外院 → 「已在外院」+ 关闭召回链
## 客服执行注意
- 新客通话长度**控制在 3 分钟内**,新客最忌冗长
- 任何一个"考虑"信号 = 立即放手,不要追问
---
name: relationship-returning
description: 回头客 / 熟客(历史已做治疗次数 >= 1)。患者熟悉诊所,可引用具体上次治疗 / 医生 / 经历。10+ 次为熟客可走家常 tone。当 clinicalContext.completedTreatmentCount > 0 时加载。
priority: 80
applies:
relationship: returning
version: 0.1.0
---
# 回头客 / 熟客沟通包
## ⭐ 核心差异
- **可以引"上次治疗"** — base "引事实 ≥3 条" 加强为"自然引用上次治疗 / 主诊医生 / 治疗链阶段"
- **可以叫医生名字** — clinicalContext.primaryDoctorName 直接念出来
- **可以"好久不见"** — daysSinceLastVisit 自然引用,**建立熟悉感**
-**熟客(>= 10 次)可切 warm 家常 tone**,适度寒暄("最近怎么样")
## opening 段增量
### 引用上次治疗 / 主诊医生(必带)
- ✅ "X 先生您好,我是 X 诊所客服,**X 医生**让我跟您联系一下"(如 reason.triggerDoctor 有)
- ✅ "X 先生您好,**这边已经 X 天没见您了**,主要是想跟您同步一下..."(引 daysSinceLastVisit)
- ✅ "您上次到我们这边是 X 月 X 日做的 X 治疗,这次想跟您聊聊后续..."(引 lastVisitSummary)
### 老朋友 framing(适度,熟客)
- 10+ 次熟客可加家常话:"上次见您是 X 月 X 日,**最近还好吧?**"(限 1 句,不展开寒暄)
- 1-9 次回头客 framing 偏专业,**不要过度套近乎**
## followup 段增量
### 治疗链上下文引用(必带 — 体现"诊所记得 ta")
- 如 clinicalContext.ongoingChains 有内容:"您**现在正在做 X 治疗**,跟那条治疗不冲突,这次主要是 ..."(说明本次召回不会重复)
- 如 lastVisitSummary 有内容:"上次 X 医生给您做完 X,**那次效果怎么样?有没有不舒服?**"(共情 + 反馈采集)
- 自然衔接到本次召回:"既然您都到我们这边治了好几次了,**我们也想确保您的 X 问题完整处理好**"
### 老客降门槛话术
- "您熟悉我们这边的流程,**这次就是面诊评估**,跟您之前来差不多,大概 30 分钟"
- "时间上随您方便,**周末上次您选的那个时段还可以**"(如 lastVisit 时间能推测)
## tone 默认覆盖
- 1-9 次:**professional** 默认
- 10+ 次:**warm**(可家常)
- 急性病况(K04 急性发作)仍可 **urgent**(熟客更易接受紧迫提醒)
## 异议增量(熟客特化)
- **"上次 X 治疗后我感觉不太对"****优先处理这个反馈**,本次召回先放一边;"哦,具体是哪里不舒服?我帮您反馈给 X 医生,看是不是要先回来看看那个问题"(投诉→服务恢复优先)
- **"我最近换地方住了/搬家了"** → 共情;"理解,如果方便也可以推荐您附近的合作诊所,或者您方便回我们这边的话再约"
- **"X 医生还在你们诊所吗"** → 真实回答;"在的,X 医生还是您之前的医生,这次也希望由 X 医生给您评估"(⚠️ 仅当字段确认医生在职;无字段时改"我帮您查一下,然后跟您说")
- **"上次我跟你们说过我不来了"** → 立即核实 + 道歉;"啊,真不好意思,我帮您再确认一下记录,如果之前确实标注过,我们后续不会再打扰您"+ 改 do_not_contact
## 回写要点增量
- 同意约 → 「成功约新预约」+ 标注治疗链关联
- 反馈不舒服 → 优先「服务恢复(投诉跟进)」,本次召回暂缓
- 礼貌拒绝 → 「明确拒绝」+ 标记原因
- 已不在该地 → 「迁居,关闭召回」
- 已搬到外院/外地 → 「已在外院」
## 客服执行注意
- 熟客通话**可以适度长**(5-7 分钟),关系维护比转化重要
- 熟客投诉是金矿,**任何不舒服反馈都先处理**,本次召回可二次跟进
---
name: scenario-treatment-initiation
description: 启治召回 - 发现待治疗诊断但患者未启动 planned/actual。任务核心:把"医生发现的问题"自然引到"该回来评估",不显推销。提供启治场景的 opening 锚点、followup 降门槛话术骨架、回写口径。当 plan.primaryScenarioKey 等于 treatment_initiation_recall 时加载。
priority: 10
applies:
scenario: treatment_initiation_recall
version: 0.1.0
---
# 启治召回(新链)话术骨架
## 核心动机
诊所诊断发现 → 患者没启动治疗。沟通逻辑:**唤起认知 → 降门槛邀约**,不是销售。患者还没下决心,任何"快来约""价格优惠"都会推远。
## opening 段增量
- ✅ 必引:某月某日由 X 医生发现 X 问题(从 reason.triggerDoctor / triggerDate 拿;空就用"上次就诊时医生发现")
- ✅ 引用要"叙事"而非"通知":"那次姜医生检查时,看到您...,提醒过该考虑..."
- ❌ 不能开口就邀约时间("您是否方便本周来一次?")— 先建立"为什么打这个电话"
- ❌ 不能用 scenario 内部代号("围绕「启治召回」开场" — 患者听到 = 机器外呼)
## followup 段增量
### 降门槛话术(必带)
启治场景客户最大顾虑:"我又得动牙?要花多少钱?要多久?会不会疼?"
对应给安心 token:
- "**这次只是医生面诊评估,不做任何操作**,大概 30 分钟"
- "评估完您再决定要不要做,什么时候做"
- "评估不收治疗费,跟普通检查一样"(⚠️ 注意:**不能承诺免费**,要按诊所实际口径;无字段时不要主动提价格)
### 时间措辞(参考 base §6,这里加强)
- 启治没有时效硬绑(不像术后复查 7 天必须查),时间可以给宽 — "本周末或下周初哪天方便?"
- close 段务必弱化:"我先按 X 登记,具体时段以诊所排班为准"
## 异议增量(本 scenario 特化)
启治场景常见且必须 cover:
- **"我又不疼,有必要去吗"** → 不痛≠没事,小问题拖大代价更高(用具体临床事实佐证,不能空喊)
- **"我再观察一下"** → 接受,但提"过期再约可能需要重新评估,建议留个时间窗"
- **"我打算去别的医院看看"** → 尊重,引导"那您方便时让我们参考一下方案?"(转介线索)
## 回写要点增量
- 决定去面诊评估 → 「成功约面诊」
- 同意但未定时间 → 「考虑中,7 天后跟进」
- 拒绝/已在外院 → 「已在外院」或「明确拒绝」
- 否认诊断("我没听医生说") → 「诊断争议,转回诊所核实」
## 禁忌
- ❌ 不要把"诊断"说成"严重",启治场景大部分问题在"该处理"而非"急救"
- ❌ 不要拿"再不来 X 就更严重"恐吓(base §4 已禁)— 用"早处理代价小"温和引导
......@@ -369,12 +369,18 @@ export class PlanScriptOrchestrator {
primaryScenarioLabel: plan.reasons[0]
? planScenarioLabel(plan.reasons[0].scenario)
: '常规复诊召回',
// ⭐ raw scenario key — skill composer.applies.scenario 用
primaryScenarioKey: plan.reasons[0]?.scenario ?? null,
priorityScore: plan.priorityScore,
goal: plan.goal,
reasons: plan.reasons.map((r) => {
const trig = resolveReasonTrigger(r);
// sub_key 形如 'caries_no_filling@36',base 去 @ 后缀
const baseSubKey = (r.subKey ?? '').split('@')[0] || null;
return {
scenarioLabel: planScenarioLabel(r.scenario),
subKey: baseSubKey,
dxCode: subKeyToDxCode(baseSubKey),
reason: r.reason,
priorityScore: r.priorityScore,
triggerDoctor: trig.doctor,
......@@ -502,6 +508,30 @@ function summarizeLastVisit(enc: FactRow | undefined): string | null {
}
/**
* sub_key → ICD-10 K-code(K00-K09)— skill composer.applies.diagnosisCodePrefix 用。
* 跟 packages/types/src/canonical-codes.ts 的 PACScenarioSubLabels 一一对齐(K00-K09 全套)。
*
* ⚠️ 加新 sub_scenario 时记得在这里加一条 — 没有则 dxCode=null,该 reason 不参与 diagnosis-K0X skill match。
*/
const SUB_KEY_TO_K_CODE: Record<string, string> = {
development_eruption: 'K00',
impacted_tooth: 'K01',
caries_no_filling: 'K02',
hard_tissue_damage: 'K03',
endo_no_rct: 'K04',
perio_no_srp: 'K05',
gum_alveolar_lesion: 'K06',
ortho_no_consult: 'K07',
missing_tooth: 'K08',
jaw_cyst: 'K09',
};
function subKeyToDxCode(subKey: string | null): string | null {
if (!subKey) return null;
return SUB_KEY_TO_K_CODE[subKey] ?? null;
}
/**
* 从 plan.reasons 派生 pendingTreatments(待办治疗列表,LLM 在 followup 段引用)。
*
* 为什么从 reasons 而不是 facts 派生:
......
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