Commit 93a11bdd by luoqi

feat(ai): 深度档话术 — 从果推因/层层递进 + 说人话(禁内部代码)+ 验证补强

规划改为从果(目标)倒推到因、层层递进、逻辑清晰(不钦定固定段式,给自由度),段数 3-6;
后果客观说明但不吓唬不推销。机器闸新增内部代码/术语拦截(K08、UPPER_SNAKE 枚举),
prompt 要求一律说大白话。verify 增 ③逻辑与分寸 + ④患者听得懂(代码/术语/含糊)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent b130b9f9
......@@ -26,6 +26,12 @@ export const COMMIT_PHRASES = [
// 加粗具体时间(误导"已定");新结构应保留【时间段】占位
const BOLD_TIME_REGEX = /\*\*[^*\n]*(?:[一二三四五六日天]|\d+\s*(?:点|:|:))[^*\n]*\*\*/;
// 内部代码/术语泄漏到患者话术(患者听不懂):
// - 诊断代码:K 开头 + 2 位数字(可带小数),如 K08 / K05.1(本域诊断码 K00–K14)
// - 内部枚举:全大写下划线,如 IMPLANT_RECOMMENDED / TREATMENT_INITIATION
// 保留占位标签【…】不算(那是给客服手填的,不在此列)。
const CLINICAL_CODE_REGEX = /K\d{2}(?:\.\d+)?|[A-Z]{2,}_[A-Z0-9_]+/;
/** 拼整篇 4 段文本(机器闸全文扫) */
function fullText(o: DraftPlanScriptOutput): string {
return [o.opening, o.informMissed, o.reviewAdvice, o.closing].join('\n');
......@@ -42,6 +48,8 @@ export function machineSafetyScan(text: string): string[] {
const c = COMMIT_PHRASES.filter((p) => text.includes(p));
if (c.length) problems.push(`承诺式表述: ${c.join(',')}`);
if (BOLD_TIME_REGEX.test(text)) problems.push('加粗了具体时间(应保留【时间段】占位)');
const code = text.match(CLINICAL_CODE_REGEX);
if (code) problems.push(`出现内部代码/术语"${code[0]}"(患者听不懂)— 改用大白话,如"缺了一颗小磨牙"`);
return problems;
}
......@@ -51,6 +59,10 @@ export function forbiddenWordsBlock(): string {
'# 禁词(整篇严禁出现)',
FORBIDDEN_PHRASES.join(' / '),
'以及:"一定能治好" / "保证效果" / "绝对安全" 等承诺;"亲 / 宝 / 帅哥 / 美女" 等淘宝式称呼。',
'',
'# 说人话(患者听得懂)',
'严禁把内部代码 / 专业术语原样念给患者:**不出现诊断代码(如 K08、K05.1)、英文或全大写下划线枚举(如 IMPLANT_RECOMMENDED)**。',
'一律翻成大白话:"K08" → "缺了一颗小磨牙";牙位/诊断说成患者能懂的位置和说法。占位标签【时间段】等照旧保留(那是给客服填的)。',
].join('\n');
}
......@@ -86,6 +98,14 @@ export const SCRIPT_SAFETY_RULES: ReadonlyArray<SafetyRule<DraftPlanScriptOutput
return { pass: !m, message: m ? `加粗了具体时间"${m[0]}" — 应保留【时间段】占位` : undefined };
},
},
{
name: 'no_clinical_codes',
severity: 'block',
check(output) {
const m = fullText(output).match(CLINICAL_CODE_REGEX);
return { pass: !m, message: m ? `出现内部代码/术语"${m[0]}"(患者听不懂)— 改用大白话` : undefined };
},
},
// 注:≤18 岁禁拍片 由 prompt/base 约束(SafetyContext 不带 age,无法在此判定)
];
......@@ -129,4 +149,12 @@ export const SCRIPT_SAFETY_RULES_SECTIONS: ReadonlyArray<
return { pass: !m, message: m ? `加粗了具体时间"${m[0]}" — 应保留【时间段】占位` : undefined };
},
},
{
name: 'no_clinical_codes',
severity: 'block',
check(o) {
const m = sectionsText(o).match(CLINICAL_CODE_REGEX);
return { pass: !m, message: m ? `出现内部代码/术语"${m[0]}"(患者听不懂)— 改用大白话` : undefined };
},
},
];
......@@ -18,16 +18,36 @@ import { stableTemplateFallback } from '../stable/stable.call';
const PLAN_SYSTEM = [
'你是资深口腔回访话术规划师。基于给定的患者事实,规划一通"医疗关怀回访"电话:拆成几段、每段讲什么。',
'原则:医疗关怀非销售;以本次聚焦项(应治未治)为主线,其他牙问题 / 历史联系 / 价值维系可一并关心(值得就单独开段讲);每个要点都必须能追到给定事实,不编造。',
'不要写话术正文,只输出大纲 JSON。**段数和结构由你按这通电话实际要覆盖的来定**(一般 3-7 段):料多(多颗牙 / 历史联系 / 高价值维系)就多开段讲透,料少就精简——不必套固定段式。',
'',
'# 规划方法:从果(目标)倒推到因',
'先定这通电话的"果"——目标是让患者明白"该回来把 X 处理掉、早处理的好处 / 拖着的后果",从而愿意来复查;',
'再倒推"因"——为达成这个目标,患者需要先知道什么、被打消什么顾虑:当前是什么缺口、为什么重要、不处理会怎样、来了能解决什么。',
'据此把这条"因 → 果"的链条拆成层层递进的段落。',
'',
'# 结构:层层递进、逻辑层次清晰',
'段与段是递进关系,不是并列罗列——前一段为后一段铺垫、后一段顺前一段往下推。**具体分几段、每段讲什么、怎么排,由你按这通电话的料自己定,不必套固定段式。**',
'每段的 intent 写清它承上启下做什么,让审核能看出递进逻辑。',
'',
'# 说明风险的分寸:把后果讲清,但不吓唬、不推销',
'"不处理会怎样"要**客观说明**(结合病历 + 牙科常识,如 缺牙久拖邻牙移位 / 龋齿不补伤神经),让患者理解严重性;',
'但**不夸大、不制造恐慌、不下吓人结论**(别说"会掉光""很危险"这类),也**不报价、不促单、不施压**——目的是让患者为自己着想地决定来,不是被吓来或被推销来。',
'',
'不要写话术正文,只输出大纲 JSON。**段数由这通电话实际要覆盖的料来定,控制在 3-6 段**:料多(多颗牙 / 历史联系 / 高价值维系)就多开段讲透但不超 6 段,料少就精简到 3 段——不必套固定段式。',
].join('\n');
const VERIFY_SYSTEM = [
'你是严格的医疗话术审核员,任务是**对抗式校验**一份回访话术草稿,默认怀疑、宁严勿松。',
'只依据给定的"本次回访患者信息"判断:',
'依据给定的"本次回访患者信息"逐项判断:',
'① 接地:草稿每个具体说法(诊断/检查所见/医嘱/时间/牙位/医生)能否在给定事实里找到依据?找不到=编造。',
'② 安全:有无报价/费用、疗效承诺、写死具体时间(应保留【时间段】)、患者≤18 却提拍片?',
'全部通过 → pass=true、issues 空;任一不过 → pass=false 并逐条列出 issue(段、问题、修法)。',
'另外给一组 **quality 质量评分**(1-5:口语自然 / 关怀温度 / 聚焦 / 不推销 + overall 综合)—— **只评"好不好",跟 pass 无关**(接地+安全才决定 pass;写得平庸但合规,pass 仍可 true,quality 给低分即可)。',
'③ 逻辑与分寸:',
' - 从果推因:是否围绕"让患者明白该回来处理"展开,而不是东拉西扯;',
' - 层层递进:段与段是否层层递进、承上启下,还是并列罗列、跳跃、重复;',
' - 逻辑层次清晰:有没有该说的没说(如点了问题却没说不处理的后果、说了后果却没给到诊解决的出路);',
' - 把后果说清但不越界:不处理的后果是否客观说明了;有没有**吓唬/制造恐慌**(如"会掉光""很危险"式吓人结论)或**推销/促单/施压**口吻。',
'④ 患者听得懂:有没有患者听不懂的东西——**诊断代码(如 K08)、英文/内部枚举、生硬专业术语**,或含糊其辞("上小磨牙或下小磨牙"这种没说清哪颗)?有 = 记 issue,要求改成大白话/说清。',
'①②③④ 任一不过 → pass=false,并逐条列出 issue(段、问题、修法);全部通过 → pass=true、issues 空。',
'另外给一组 **quality 质量评分**(1-5:口语自然 / 关怀温度 / 聚焦 / 不推销 + overall 综合)—— **只评"好不好",跟 pass 无关**。',
'只输出 JSON,不改写草稿。',
].join('\n');
......@@ -35,7 +55,7 @@ const VERIFY_SYSTEM = [
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-08-deep-plan-v7'; // v7: schema 去硬约束(.min/.max → describe);v2: 去 {} 替换占位(朴素 labeled facts)
readonly promptVersion = 'draft_plan_script@2026-06-16-deep-plan-v9'; // v9: 去掉钦定段式(给自由度),只留从果推因/层层递进原则;v8: 从果推因+后果不吓唬+3-6段
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DeepPlanSchema;
buildPrompt(ctx: ScriptContext) {
......@@ -47,7 +67,7 @@ export class DeepPlanCall implements AiCall<ScriptContext, DeepPlanZ> {
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-08-deep-write-v12'; // v12: schema 去硬约束(.min/.max → describe);v11: repair 喂上一稿 + 逐条强约束(严格按自检 fix 改)
readonly promptVersion = 'draft_plan_script@2026-06-16-deep-write-v15'; // v15: 去掉钦定段式(给自由度);v14: 禁内部代码/术语(K08等)说大白话
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DeepWriteSchema;
constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {}
......@@ -65,7 +85,7 @@ export class DeepWriteCall implements AiCall<DeepWriteInput, DeepWriteZ> {
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-08-deep-verify-v7'; // v7: schema 去硬约束(.min/.max/.int → describe);v2: 去 {} 替换占位(随 fact-block)
readonly promptVersion = 'draft_plan_script@2026-06-16-deep-verify-v10'; // v10: 递进检查去钦定段式(给自由度);v9: 加④患者听得懂(无代码/术语/含糊)
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DeepVerifySchema;
buildPrompt(input: DeepVerifyInput) {
......
......@@ -14,7 +14,9 @@ export function buildPlanPrompt(ctx: ScriptContext): string {
---
# 你的任务(本步:规划,不写话术)
基于以上事实,规划这通回访电话拆成几段、每段讲什么。要点须来自上面事实,不编造。`;
基于以上事实,规划这通回访电话拆成几段、每段讲什么。要点须来自上面事实,不编造。
按"从果(目标)倒推到因"来排:先想清这通电话要达成的果(让患者明白该回来处理本次问题),再倒推为达成它患者需要先被讲清什么,据此把内容排成**层层递进、逻辑清晰**的段落(每段 intent 写明承上启下;具体怎么分段由你定,不套固定段式)。后果要客观说清、但不吓唬不推销。段数 3-6。`;
}
/** 步骤2:写 user prompt —— 深度厚事实块 + 上一步大纲(+ repair:上一稿 + 逐条修正约束) */
......@@ -52,7 +54,11 @@ ${issues}
---
# 本步大纲(按它逐段写,可微调措辞,不要新增事实)
${outline}`;
${outline}
# 写的时候保持
- **层层递进、逻辑层次清晰**:顺着大纲推进,段与段承上启下,别并列罗列或跳跃。
- **后果说清但有分寸**:不处理的后果客观讲明(结合病历 + 牙科常识),让患者理解严重性;但**不夸大、不吓唬(别下"会掉光""很危险"式吓人结论)、不推销促单报价**——为患者着想地讲,不是吓他/催他。`;
}
/** 步骤3:独立对抗校验 user prompt —— 事实 + 待验草稿(新开上下文,对抗) */
......@@ -67,7 +73,13 @@ export function buildVerifyPrompt(input: DeepVerifyInput): string {
逐句核对下面这份话术草稿:
1. **接地**:每个说法能否追到上面"本次回访患者信息"里的事实?追不到 = 编造 → 记 issue。
2. **安全**:有无报价/费用、疗效承诺、写死具体时间(应保留【时间段】)、≤18 提拍片?有 = 越界 → 记 issue。
全部通过 → pass=true、issues 空;否则 pass=false 并逐条列出。
3. **逻辑与分寸**:
- 从果推因:是否围绕"让患者明白该回来处理本次问题"展开,没跑题;
- 层层递进:段与段是否层层递进、承上启下,而非并列罗列 / 跳跃 / 重复;
- 逻辑层次清晰:该说的有没有缺(点了问题没说后果、说了后果没给到诊出路);
- 把后果说清但不越界:不处理的后果是否客观说明了?有没有**吓唬/制造恐慌**("会掉光""很危险"式)或**推销/促单/施压**口吻?以上任一不到位 → 记 issue。
4. **患者听得懂**:有无诊断代码(如 K08)、英文/内部枚举、生硬术语,或含糊其辞("上小磨牙或下小磨牙"没说清哪颗)?有 = 记 issue,要求改成大白话/说清。
①②③④ 全部通过 → pass=true、issues 空;任一不过 → pass=false 并逐条列出(段、问题、修法)。
## 待校验草稿
${draft}`;
......
......@@ -16,13 +16,13 @@ export const DeepPlanSchema = z.object({
z.object({
key: z.string().describe('段标识(英文短,仅内部串联,如 opening/missed_11/review/close)'),
title: z.string().describe('中文小标题(自然口语,贴这通电话,约 2-20 字)'),
intent: z.string().describe('这段要达成什么(一句话)'),
intent: z.string().describe('这段要达成什么(一句话);写明承上启下——为上一段铺垫了什么、为下一段引出什么(体现层层递进)'),
points: z
.array(z.string())
.describe('要点(1-5 条,口语化,**每条都须来自给定患者信息/病历事实**,不编造)'),
}),
)
.describe('话术大纲:按这通电话需要拆几段(开场→切入本次问题→可顺带的其他关心→复查邀约→结束;段数你定,3-7 段)'),
.describe('话术大纲:从果(让患者明白该回来处理)倒推到因,排成层层递进、逻辑清晰的段落(怎么分段由你定,不套固定段式;后果客观说明不吓唬不推销;段数 3-6)'),
});
export type DeepPlanZ = z.infer<typeof DeepPlanSchema>;
......@@ -38,18 +38,22 @@ export const DeepWriteSchema = z.object({
.describe('该段话术正文(约 20-400 字)。短句分行、行首 `•`;接地病历、不编造;时间用【时间段】占位;无大标题/表情'),
}),
)
.describe('按规划写出的多段话术(段数跟随规划,通常 3-7 段)'),
.describe('按规划写出的多段话术(段数跟随规划,3-6 段;层层递进、逻辑清晰)'),
});
export type DeepWriteZ = z.infer<typeof DeepWriteSchema>;
// ── 步骤3:独立对抗校验 ──
export const DeepVerifySchema = z.object({
pass: z.boolean().describe('草稿每句都能追到给定事实、且无安全越界 → true;否则 false'),
pass: z.boolean().describe('草稿①接地(每句可追到事实)②安全(无越界)③逻辑与分寸(从果推因/层层递进/逻辑清晰/后果说清但不吓唬不推销)全过 → true;任一不过 → false'),
issues: z
.array(
z.object({
section: z.string().describe('出问题的段标题或序号'),
problem: z.string().describe('问题:编造/接地不实(追不到事实)/安全越界(报价/承诺/写死时间/≤18拍片)'),
section: z.string().describe('出问题的段标题或序号(逻辑类问题可填"整体结构")'),
problem: z
.string()
.describe(
'问题:①接地不实(编造/追不到事实)②安全越界(报价/承诺/写死时间/≤18拍片)③逻辑与分寸(跑题/并列罗列不递进/逻辑层次缺漏/没说后果/吓唬制造恐慌/推销促单施压)',
),
fix: z.string().describe('修法建议(回喂改写)'),
}),
)
......
......@@ -4,6 +4,8 @@
# 怎么写
- **按大纲逐段写**:严格跟随“本步大纲”的段数与每段要点;可润色措辞,**不得新增大纲外的事实**
- **病种措辞自供**:风险与“趁早处理的好处”结合下方病历(检查所见/医嘱/建议)+ 牙科常识,用自己的话讲清;医生没记录的别编。
- **把后果说清、有分寸**:不处理的后果要**客观说明**让患者理解严重性,但**不夸大、不吓唬**(别下“会掉光”“很危险”式吓人结论)、**不推销促单报价**——为患者着想地讲,不是吓他/催他。
- **层层递进**:顺着大纲推进,段与段承上启下,别并列罗列或跳跃重复。
- **按大纲讲透**:段怎么分由大纲定,你只管把大纲让你单独开段的内容(如某颗牙、复查)展开讲深讲透;以本次聚焦项为主线,顺带项点到为止。
- **事实朴素取用**:患者信息以朴素中文标签直接给(称呼/本次问题/牙位/诊断医生/最近一次就诊…),自然用进话里;不写占位符、不留标签字样。
......
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