Commit 1e0c5151 by luoqi

refactor(ai-script): 参考话术骨架 — 4模块 + 确定性逻辑提取(渐进式组合)

吸收业务"参考话术"提示词,把本该程序判断的确定性逻辑从 LLM 提取出来:
- script-facts.ts(新):年龄分支(≤12童/≥13成人/未知→成人)、智能日期(今年X月X号/
  去年X月/更早XXXX年X月,替代提示词里的 python)、智能称呼(姓+先生/女士·家长·您好)、
  漏诊项优先级挑选 + 归一、MISSED_DIAGNOSIS_KEY_POINTS / TREATMENT_DURATION 字典查表
  → LLM 不再做年龄分支/日期格式/优先级/查表;渐进式只塞命中那一条,不发全表
- schema/output:5段(opening/followup/objection/close)→ 4模块
  (opening 开场白 / informMissed 告知漏诊项目 / reviewAdvice 复查建议 / closing 结束回访语)
- orchestrator:section id/title + fullMarkdown 同步 4 模块
- safetyRules:精简为 no_forbidden_phrases / no_commit_phrasing / no_bold_concrete_time
  (≤18禁拍片改 prompt 约束,SafetyContext 不带 age);fallback 重写为 4 模块占位版
- 缓存/渐进式:静态铁律→system(前缀缓存),确定性事实→TS 算好塞 user(下一阶段注入)

骨架验证:script-facts 单测 20 通过,service typecheck 0 错 + build 通过。
下一阶段(待续):base-system/成人+儿童 SKILL.md 正文重写、prompt.ts 注入计算事实、
orchestrator 补字段(漏诊项/岗位/上次处置/主诉/就诊日期)、页面 4 段、live 验证。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent f05fe163
......@@ -10,6 +10,14 @@ import {
} from './prompt';
import { composeSystem } from './skill-composer';
import { DraftPlanScriptSkillRegistry } from './skill-registry.service';
import {
resolveAgeBranch,
resolveSalutation,
smartDateDisplay,
pickPrimaryMissed,
lookupKeyPoints,
lookupReviewDuration,
} from './script-facts';
/**
* Safety rules — 后置硬约束。
......@@ -31,7 +39,7 @@ const safetyRules: ReadonlyArray<SafetyRule<DraftPlanScriptOutput>> = [
name: 'no_forbidden_phrases',
severity: 'block',
check(output) {
const fullText = [output.opening, output.followup, output.objection, output.close].join('\n');
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,
......@@ -40,79 +48,34 @@ const safetyRules: ReadonlyArray<SafetyRule<DraftPlanScriptOutput>> = [
},
},
{
name: 'close_has_concrete_time',
severity: 'warn', // warn 不阻断,只记日志
check(output) {
// 启发式:结束段 markdown 必须含数字(具体时间点 / 日期)
const hasDigit = /\d/.test(output.close);
return { pass: hasDigit, message: hasDigit ? undefined : 'close 段未含具体时间数字' };
},
},
{
name: 'objection_uses_h3_blocks',
severity: 'warn',
check(output) {
// 启发式:objection 段必须按 ### A./B./C. 子标题切分
const hasH3 = /^###\s+[A-D]\./m.test(output.objection);
return { pass: hasH3, message: hasH3 ? undefined : 'objection 段未按 ### A./B. 子标题切分' };
},
},
{
name: 'close_no_commit_phrasing',
name: 'no_commit_phrasing',
severity: 'block',
check(output) {
// close 段禁止"我已为您约好 X" / "已成功预约 X" / "约定 / 敲定 / 安排好" 这种确定承诺(PAC 无排班 API)
// 结束/复查段禁止"我已为您约好 X" 这种确定承诺(PAC 无排班 API,时间用【占位】不写死)
const COMMIT_PHRASES = [
'已为您约好', '已成功预约', '已为您预约', '已经为您约', '已替您预约',
'约定本', '敲定本', '安排好了', '已经预约'
'约定本', '敲定本', '安排好了', '已经预约',
];
const hit = COMMIT_PHRASES.filter((p) => output.close.includes(p));
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 ? `close 段承诺式表述(无排班 API,不能定): ${hit.join(',')}` : undefined,
message: hit.length > 0 ? `承诺式表述(无排班 API,不能定): ${hit.join(',')}` : undefined,
};
},
},
{
name: 'close_no_bold_time',
name: 'no_bold_concrete_time',
severity: 'block',
check(output) {
// close 段禁止 **本周X上午X点** 这种加粗具体时间 — 加粗 = "已确认重点",误导患者
// 正确做法:具体时间紧跟 (示例) 后缀,或用"周X上午这个方向"模糊词
// 匹配:**...含数字时间词的字符串...**
// 禁止 **周X上午X点** 加粗具体时间(误导"已定");新结构应保留【时间段1】【具体预约时间】占位
const boldTimeRegex = /\*\*[^*\n]*(?:[一二三四五六日天]|\d+\s*(?:点|:|:))[^*\n]*\*\*/;
const m = output.close.match(boldTimeRegex);
return {
pass: !m,
message: m ? `close 段加粗了具体时间"${m[0]}" — 应去加粗 + 加 (示例) 后缀或用方向词` : undefined,
};
},
},
{
name: 'close_has_tentative_phrasing',
severity: 'warn', // warn 不阻断,只记日志(soft 引导,严格的话改 block)
check(output) {
// close 段应含"待确认"语义短语,避免患者以为时间真定了
const TENTATIVE_PHRASES = [
'以诊所排班为准',
'排班为准',
'稍后跟前台确认',
'跟前台确认',
'稍后跟诊所确认',
'稍后短信确认',
'排班确认后告知',
'排班确认后短信',
'稍后短信通知您实际',
'具体时段以',
'具体时间以',
];
const hasTentative = TENTATIVE_PHRASES.some((p) => output.close.includes(p));
return {
pass: hasTentative,
message: hasTentative ? undefined : 'close 段未含"待确认/以排班为准"等弱化短语,可能误导患者以为时间已定',
};
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,无法在此判定)
];
/**
......@@ -125,45 +88,41 @@ const safetyRules: ReadonlyArray<SafetyRule<DraftPlanScriptOutput>> = [
*/
function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
const { patient, clinicName, plan, clinicalContext } = input;
const dayPart = clinicalContext.daysSinceLastVisit
? `已经 ${clinicalContext.daysSinceLastVisit} 天没见您了`
: '有段时间没见您了';
const lastSummary = clinicalContext.lastVisitSummary ?? '复查相关安排';
const branch = resolveAgeBranch(patient.age);
const salutation = resolveSalutation({ nameMasked: patient.nameMasked, gender: patient.gender, branch });
const doctor = clinicalContext.primaryDoctorName ?? '您的主诊医生';
const dateDisplay =
smartDateDisplay(
clinicalContext.daysSinceLastVisit != null
? new Date(Date.now() - clinicalContext.daysSinceLastVisit * 86400_000)
: null,
new Date(),
) ?? '上次';
const missed =
pickPrimaryMissed([...(clinicalContext.pendingTreatments ?? []), plan.primaryScenarioLabel]) ??
{ raw: plan.primaryScenarioLabel, key: null };
const kp = lookupKeyPoints(missed.key);
const risk = kp?.risks[0] ?? '这个问题如果一直拖着,后面处理可能更复杂';
const adv = kp?.advantages[0] ?? '趁现在早点处理会更省心';
const reviewDuration = lookupReviewDuration(missed.key);
return {
tone: 'warm',
opening: `**目的**:亲切自然地建立通话,围绕「${plan.primaryScenarioLabel}」开场,避免推销感。
> "${patient.nameMasked}您好,我是${clinicName}的客服。${dayPart},今天给您打电话,主要是想跟您同步上次的${lastSummary},您现在方便聊几分钟吗?"
**注意**
- 称呼用「${patient.nameMasked}」,不用全名
- 若不方便,主动询问合适回拨时间`,
followup: `**目的**:把诊所记录的临床事实,自然引到「该回来做点什么」。
> "根据病历记录,本次想跟您沟通的是:${plan.primaryScenarioLabel}。复查不需要太久,大概 20 分钟就能完成。"
> "我们这边可以帮您安排医生面诊评估,**这次只是评估和确认方案,不做任何操作**,您看本周末或下周初哪个时间方便?"
**异议预判**
- "最近没时间" → 提供更灵活的时间窗(早班/晚班)
- "再考虑一下" → 强调诊后复查窗口期,过期可能要重新评估
- "已在外院看过" → 提交「已在外院治疗」并关闭召回`,
objection: `### A. "我再考虑考虑"
> "完全理解。这样,我先帮您把医生的面诊时间留出来,本周六上午或下周一晚上,您选一个?到现场看了方案再决定也不晚。"
### B. "最近真的没时间"
> "理解,可以约到下个月,提前预约能避开排队。您下周或下下周哪天比较方便?我先帮您预留。"
### C. "已在别的医院看了"
> "好的${patient.nameMasked},那我这边帮您把这条记录关一下,日常护理还是按原来的周期回来就行,祝您一切顺利。"
> → 提交结果选「已在外院治疗」`,
close: `> "好的${patient.nameMasked},我先按 周六上午10点(示例) 帮您登记面诊时间,具体时段以诊所排班为准,稍后跟前台确认后短信通知您实际时间。还有别的需要么?"
**回写要点**
- 成功约上面诊 → 通话结果选「转化新预约」,填预约时间 + 医生
- 同意但未定日期 → 选「约定下次回访」,填预计时间
- 考虑中 → 选「再考虑(冷静期7天)」,7 天后系统自动重新浮现`,
opening: `• ${salutation}您好,我是${clinicName}的客服
${doctor}医生特意交代我来关注您的后续情况
• 自从${dateDisplay}检查后,您口腔情况怎么样?`,
informMissed: `• 上次检查的时候,${doctor}医生注意到您有${missed.raw}的情况
${risk}
${adv}
• 这个${doctor}医生也特别嘱咐我们提醒您一下`,
reviewAdvice: `• 最近方便的话,来院复查一下
• 让${doctor}医生帮您再仔细看看
${reviewDuration}
${doctor}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?`,
closing: `【预约成功】
• 好的,那我们【具体预约时间】见,祝您生活愉快
【预约不成功】
• 没关系,我下周再联系您,祝您生活愉快`,
};
}
......
......@@ -86,15 +86,15 @@ export interface DraftPlanScriptOutput {
/** 整体语气标签(给客服参考) */
tone: 'warm' | 'professional' | 'urgent';
/** 开场段 markdown:**目的** + > 通话片段 + **注意** bullet */
/** 第一部分·开场白 markdown(以医生名义 + 智能称呼 + 智能日期 + 自报家门) */
opening: string;
/** 切入话题段 markdown:**目的** + 1-2 段 > 对话 + **异议预判** bullet */
followup: string;
/** 第二部分·告知漏诊项目 markdown(4 短句:现状/风险/关怀/专业建议,只讲单个漏诊项) */
informMissed: string;
/** 异议处理段 markdown:3-4 个 ### A./B./C./D. 子标题 + > 应对 */
objection: string;
/** 第三部分·复查建议 markdown(4 短句:重要性/维护/复查时长/引导预约【时间段】) */
reviewAdvice: string;
/** 结束·信息确认段 markdown:> 确认话术 + **回写要点** bullet */
close: string;
/** 第四部分·结束回访语 markdown(预约成功 / 不成功 两种) */
closing: string;
}
......@@ -3,13 +3,13 @@ import { z } from 'zod';
/**
* DraftPlanScript AiCall 的 Zod 输出 schema。
*
* 设计原则(B 方案 · 2026-05-24):
* - 4 段直出 markdown,每段一个字段,LLM 自由写 markdown 但格式契约由 .describe() 强约束
* - generateObject 会把 schema 转 JSON Schema 注入 prompt;LLM 强制按此 shape 输出
* - 流式时每段是 string 字段,partial 阶段 LLM 还没写完的段为 undefined → orchestrator 透 ''
* - .describe() 写清楚段内 markdown 子结构格式(目的 / 正文 / 注意 / 异议预判 / 回写要点)
* 4 模块结构(2026-06-02 重构,对齐业务"参考话术"提示词):
* 开场白 → 告知漏诊项目 → 复查建议 → 结束回访语(顺序固定,缺一不可)
*
* 跟前端 mock-data.ts mockScript.sections 完全对齐(4 段 id),前端零改动接 4 段。
* 设计:per-段 markdown 字段(等价业务要的 {scripts:[{title,content}]},但带 zod 强校验 +
* 流式 + 安全闸,更稳)。LLM 只把"程序算好的事实"(称呼/智能日期/单个漏诊项/风险要点/
* 复查时长 —— 见 script-facts.ts)润色成话术,不做年龄分支/日期格式/优先级/查表等确定性判断。
* 段内"短句"用 markdown bullet(`• ...`)分行,便于客服与患者互动。
*/
export const DraftPlanScriptSchema = z.object({
tone: z
......@@ -18,66 +18,62 @@ export const DraftPlanScriptSchema = z.object({
opening: z
.string()
.min(60)
.max(700)
.min(50)
.max(600)
.describe(
[
'【开场段 markdown,必须严格按以下 3 部分格式】',
'第 1 部分:`**目的**:xxx` — 一句话讲清开场目的(15-40 字)',
'第 2 部分:空行后 `> "通话片段..."` blockquote — 客服开场白完整一段(80-200 字),包含:自报家门(诊所+岗位)+ 来电原因(必须提具体临床事实)+ 礼貌征询是否方便',
'第 3 部分:空行后 `**注意**` 标题 + 2-3 条 `- xxx` bullet — 客服执行注意点(称呼/时段/口吻)',
'禁止:加 ### 标题、加表情符号、blockquote 里使用排比抒情体',
'【第一部分·开场白 — 以医生名义,有温度】',
'用 `• ` bullet 分 3-4 句,内容必须包含(顺序):',
'1. 自报家门:用「{诊所}的{岗位角色}{岗位姓名}」(岗位角色严禁写"回访专员")',
'2. 智能称呼:user 给的 {称呼}(已算好,直接用,不要自己改)',
'3. 以「{最后一次就诊医生}医生特意交代」体现医生关怀',
'4. 用 user 给的 {智能日期} 问近况:「自从{智能日期}检查后,口腔情况怎么样?」',
'禁止:加大标题/═══分隔符、加表情、写成抒情排比',
].join('\n'),
),
followup: z
informMissed: z
.string()
.min(120)
.max(1000)
.min(80)
.max(900)
.describe(
[
'【切入话题段 markdown,必须严格按以下 3 部分格式】',
'第 1 部分:`**目的**:xxx` — 一句话讲本段任务(15-40 字)',
'第 2 部分:空行后 1-2 段 `> "..."` blockquote — 先讲临床事实+风险(治疗阶段/牙位号/上次诊断),再给具体行动选项(必须含"这次只是评估,30 分钟"这类降低门槛话术,可加粗关键信息)',
'第 3 部分:空行后 `**异议预判**` 标题 + 2-4 条 `- "trigger" → action` bullet — 列患者可能的反对+对应处理思路',
'禁止:在本段写完整异议应对话术(那是 objection 段的事)',
'【第二部分·告知漏诊项目 — 只讲 user 给的那一个 {漏诊项},严禁提其他项】',
'用 `• ` bullet 分 4 个短句(每句一个重点,口语化,温和提醒非推销):',
'短句1 现状描述:以「{最后一次就诊医生}医生上次检查注意到您有{漏诊项}的情况」表达,不要说"我们发现了"',
'短句2 健康提醒:从 user 给的 {风险要点} 里灵活挑 1-2 条口语说,不堆砌、不吓唬',
'短句3 个人化关怀:用 user 给的 {治疗优势要点},以"趁现在/早一点"口吻;禁止提具体年龄/职业',
'短句4 专业建议:体现「{最后一次就诊医生}医生也特别嘱咐提醒您」,禁止"建议您关注一下"这类书面语',
].join('\n'),
),
objection: z
reviewAdvice: z
.string()
.min(150)
.max(1400)
.min(80)
.max(900)
.describe(
[
'【异议处理段 markdown,必须严格按以下格式】',
'3-4 个子条目,每个子条目:',
' - `### A. "我再考虑考虑"` 子标题(A/B/C/D 顺序)',
' - 紧跟一行 blockquote `> "应对话术..."`(30-100 字,要给具体时间选项或具体方案)',
' - 可选追加一行 `> → 提交结果选「xxx」` 指明客服回写动作',
'常见异议优先覆盖:再考虑 / 价格贵 / 没时间 / 已在外院 / 治疗冲突等',
'禁止:把所有异议合并成一个长段、用 bullet 列举(必须 ### 子标题分块)',
'【第三部分·复查建议 — 有温度有引导,主动约】',
'用 `• ` bullet 分 4 个短句:',
'短句1 复查重要性:「最近方便的话来院复查一下」',
'短句2 健康维护:「让{最后一次就诊医生}医生帮您再仔细看看」',
'短句3 检查说明:直接用 user 给的 {复查时长} 原文',
'短句4 引导预约:必须严格用「{最后一次就诊医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」',
'⚠️【时间段1】【时间段2】保留占位结构,严禁替换成"周三上午"等具体时间(PAC 无排班 API)',
].join('\n'),
),
close: z
closing: z
.string()
.min(80)
.max(700)
.min(40)
.max(500)
.describe(
[
'【结束·信息确认段 markdown,必须严格按以下 2 部分格式】',
'第 1 部分:`> "确认话术..."` blockquote — 复述敲定的安排:示例时间 + 医生名 + 提醒方式(短信/企微)+ 礼貌结尾',
' ⭐ 时间措辞要求(call 时段 PAC 无排班 API,LLM 给的具体时间只是 example,实际以诊所排班为准):',
' - 必须含"待确认"语义短语之一:"具体时段以诊所排班为准" / "稍后跟前台确认后短信通知您实际时间" / "实际时间稍后短信确认" / "我先按 X 登记,排班确认后告知"',
' - 出现具体时间时(如"周六上午10点"),**两种方式标记为示例,任选一**:',
' 方式 A: 不加粗,紧跟 `(示例)` 后缀 → "周六上午10点(示例)"',
' 方式 B: 用方向词代替具体点 → "周六上午这个方向"',
' - ⚠️ 严禁 `**周六上午10点**` 这种加粗具体时间 — 加粗读作"已确认重点",会让患者误以为时间真定了',
' - 严禁说"我已为您约好 X" / "已成功预约 X" / "约定 X" / "敲定 X" 这种确定承诺',
' - close 段只给 1 个时间示例 + 弱化短语,不要罗列多个备选(close 是收尾,不是商量)',
'第 2 部分:空行后 `**回写要点**` 标题 + 2-4 条 `- xxx → 提交结果选「xxx」` bullet — 列不同通话结果对应的客服回写动作',
'禁止:省略具体时间敲定、省略回写要点、加粗具体时间、用承诺式"已为您约好/约定/敲定"',
'【第四部分·结束回访语 — 简单有温度,含两种情况】',
'分两块,各用 `• ` bullet:',
'【预约成功】:「好的,那我们【具体预约时间】见」+「祝您生活愉快」(【具体预约时间】保留占位,不写死)',
'【预约不成功】:「没关系,我下周再联系您」+「祝您生活愉快」',
'禁止:承诺式"已为您约好/敲定 X";禁止加粗具体时间',
].join('\n'),
),
});
......
......@@ -38,15 +38,14 @@ export type PlanScriptStreamEvent =
| { type: 'error'; message: string };
/**
* 渲染后的 section,前端直接消费(B 方案:4 段对齐前端 mockScript)。
* - opening 开场
* - followup 切入话题
* - objection 异议处理
* - close 结束·信息确认
* 跟 apps/pac-web/.../mock-data.ts mockScript.sections id 一致,前端零改动消费。
* 渲染后的 section,前端直接消费(2026-06 重构:4 模块对齐业务"参考话术")。
* - opening 开场白
* - informMissed 告知漏诊项目
* - reviewAdvice 复查建议
* - closing 结束回访语
*/
export interface ScriptSectionDto {
id: 'opening' | 'followup' | 'objection' | 'close';
id: 'opening' | 'informMissed' | 'reviewAdvice' | 'closing';
label: string;
durationHint: string;
markdown: string;
......@@ -431,17 +430,17 @@ function renderMarkdown(
): string {
return `> 患者:${meta.patientNameMasked} · 语气:${toneLabel(out.tone)}
## 开场
## 开场
${out.opening}
## 切入话题
${out.followup}
## 告知漏诊项目
${out.informMissed}
## 异议处理
${out.objection}
## 复查建议
${out.reviewAdvice}
## 结束 · 信息确认
${out.close}
## 结束回访语
${out.closing}
`;
}
......@@ -465,27 +464,27 @@ function renderSections(
return [
{
id: 'opening',
label: '开场',
label: '开场',
durationHint: '30 秒',
markdown: out.opening ?? '',
},
{
id: 'followup',
label: '切入话题',
id: 'informMissed',
label: '告知漏诊项目',
durationHint: '1–2 分钟',
markdown: out.followup ?? '',
markdown: out.informMissed ?? '',
},
{
id: 'objection',
label: '异议处理',
durationHint: '按需',
markdown: out.objection ?? '',
id: 'reviewAdvice',
label: '复查建议',
durationHint: '1 分钟',
markdown: out.reviewAdvice ?? '',
},
{
id: 'close',
label: '结束 · 信息确认',
id: 'closing',
label: '结束回访语',
durationHint: '30 秒',
markdown: out.close ?? '',
markdown: out.closing ?? '',
},
];
}
......
import {
resolveAgeBranch,
resolveAgeGroup,
smartDateDisplay,
resolveSalutation,
pickPrimaryMissed,
canonicalMissedKey,
lookupKeyPoints,
lookupReviewDuration,
} from '../src/modules/ai/calls/draft-plan-script/script-facts';
describe('script-facts(确定性逻辑提取,替代 LLM 主观判断)', () => {
describe('年龄分支', () => {
test('≤12 → child', () => expect(resolveAgeBranch(8)).toBe('child'));
test('=12 边界 → child', () => expect(resolveAgeBranch(12)).toBe('child'));
test('≥13 → adult', () => expect(resolveAgeBranch(13)).toBe('adult'));
test('未知 → adult(默认成人漏诊模板)', () => expect(resolveAgeBranch(null)).toBe('adult'));
test('年龄组分档', () => {
expect(resolveAgeGroup(44)).toBe('中年');
expect(resolveAgeGroup(25)).toBe('青年');
expect(resolveAgeGroup(70)).toBe('老年');
expect(resolveAgeGroup(8)).toBeNull();
});
});
describe('智能日期显示', () => {
const now = new Date('2026-06-02T00:00:00Z');
test('今年 → X月X号', () => expect(smartDateDisplay('2026-01-29T00:00:00Z', now)).toBe('1月29号'));
test('去年 → 去年X月', () => expect(smartDateDisplay('2025-12-10', now)).toBe('去年12月'));
test('更早 → XXXX年X月', () => expect(smartDateDisplay('2023-03-01', now)).toBe('2023年3月'));
test('空 → null', () => expect(smartDateDisplay(null, now)).toBeNull());
});
describe('智能称呼', () => {
test('成人男 → 姓+先生', () =>
expect(resolveSalutation({ nameMasked: '侯*', gender: '男', branch: 'adult' })).toBe('侯先生'));
test('成人女 → 姓+女士', () =>
expect(resolveSalutation({ nameMasked: '侯琴', gender: '女', branch: 'adult' })).toBe('侯女士'));
test('儿童 → 姓+家长', () =>
expect(resolveSalutation({ nameMasked: '乐*', gender: '男', branch: 'child' })).toBe('乐家长'));
test('性别未知 → 您好', () =>
expect(resolveSalutation({ nameMasked: '侯琴', gender: null, branch: 'adult' })).toBe('您好'));
});
describe('漏诊项归一 + 优先级', () => {
test('文本含关键词 → 命中 key', () =>
expect(canonicalMissedKey('牙位:21,牙槽骨吸收')).toBe('牙槽骨吸收'));
test('优先级:儿牙早矫 > 牙槽骨吸收', () =>
expect(pickPrimaryMissed(['牙槽骨吸收', '儿牙早矫'])?.key).toBe('儿牙早矫'));
test('单条取该条', () =>
expect(pickPrimaryMissed(['牙位:21,牙槽骨吸收'])?.key).toBe('牙槽骨吸收'));
test('空 → null', () => expect(pickPrimaryMissed([null, ''])).toBeNull());
});
describe('查表(渐进式只取命中那条)', () => {
test('key-points 命中', () => {
const kp = lookupKeyPoints('牙槽骨吸收');
expect(kp?.risks.length).toBeGreaterThan(0);
expect(kp?.advantages.length).toBeGreaterThan(0);
});
test('复查时长命中', () =>
expect(lookupReviewDuration('缺失牙')).toContain('复查检查约30分钟'));
test('复查时长兜底', () =>
expect(lookupReviewDuration(null)).toContain('复查检查约30分钟'));
});
});
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
......@@ -577,7 +577,7 @@ export const EXECUTION_OUTCOME_META: Record<
// ── 不成功(give_up;明确拒绝/外院 = 终态 abandoned,再考虑 = keep+7天非终态)──
refused: { group: 'give_up', labelZh: '明确拒绝', tone: 'rose', drivesStatus: 'abandoned', suppressDays: 90 },
external_treatment: { group: 'give_up', labelZh: '已在外院治疗', tone: 'rose', drivesStatus: 'abandoned', suppressDays: SUPPRESS_PERMANENT_DAYS },
declined_recent: { group: 'give_up', labelZh: '再考虑(冷静期7天)', tone: 'amber', drivesStatus: 'keep', suppressDays: 7 }, // 非终态:7天冷静期后自动浮现(plan 仍「进行中」)
declined_recent: { group: 'give_up', labelZh: '再考虑', tone: 'rose', drivesStatus: 'abandoned', suppressDays: 7 }, // 非终态:7天冷静期后自动浮现(plan 仍「进行中」)
// ── 保持(keep,留工单「进行中」)──
no_answer: { group: 'keep', labelZh: '未接通', tone: 'slate', drivesStatus: 'keep', suppressDays: null },
quick_hangup: { group: 'keep', labelZh: '秒挂', tone: 'slate', drivesStatus: 'keep', suppressDays: null }, // 接通秒挂,算保持,很快再试
......
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