Commit 80702b0d by luoqi

refactor(script): 病种知识单一访问源 + 修颌骨囊肿 bug(稳健档铺路)

- 新增 script-common/disease-knowledge.ts:病种知识的 tier-agnostic 单一访问源
  · diseaseKnowledgeForSubKey(subKey):subKey → {label,risks,advantages,reviewDuration}
  · resolveDisease(reason):subKey 优先 + 文本兜底(prompt + fallback 共用唯一入口)
  · SUBKEY_MAP 显式声明每病种在两套字典的中文 key → 收掉"双跳 + exact/includes 混用"脆弱性
- 修 bug:jaw_cyst 的 keypoints 字典 key 是「囊肿」、duration 是「颌骨囊肿」,
  原 exact 查表静默丢 risks/advantages → 现分列对齐,颌骨囊肿召回拿回风险/优势要点
- prompt.ts / call.ts(fallback)改用 resolveDisease,删散落的 missedFromReason+lookupKeyPoints+lookupReviewDuration 三连
- 为标准/深度档铺路:同一份病种知识,后续档直接 resolveDisease 拿规则自由组织
- promptVersion bump v10

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 158facda
......@@ -6,14 +6,8 @@ import type { DraftPlanScriptInput, DraftPlanScriptOutput } from './input.types'
import { buildDraftPlanScriptPrompt } from './prompt';
import { composeSystem } from './skill-composer';
import { DraftPlanScriptSkillRegistry } from './skill-registry.service';
import {
resolveAgeBranch,
resolveSalutation,
smartDateDisplay,
missedFromReason,
lookupKeyPoints,
lookupReviewDuration,
} from './script-facts';
import { resolveAgeBranch, resolveSalutation, smartDateDisplay } from './script-facts';
import { resolveDisease } from './script-common/disease-knowledge';
/**
* Safety rules — 后置硬约束。
......@@ -96,20 +90,17 @@ function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
? new Date(Date.now() - clinicalContext.daysSinceLastVisit * 86400_000)
: null;
const dateDisplay = smartDateDisplay(dateBasis, new Date()) ?? '上次';
const missed = topReason
? missedFromReason(topReason)
: { label: plan.primaryScenarioLabel, key: null };
const kp = lookupKeyPoints(missed.key);
const risk = kp?.risks[0] ?? '这个问题如果一直拖着,后面处理可能更复杂';
const adv = kp?.advantages[0] ?? '趁现在早点处理会更省心';
const reviewDuration = lookupReviewDuration(missed.key);
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}医生注意到您有${missed.label}的情况
informMissed: `• 上次检查的时候,${doctor}医生注意到您有${disease.label}的情况
${risk}
${adv}
• 这个${doctor}医生也特别嘱咐我们提醒您一下`,
......@@ -129,7 +120,7 @@ function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
* 改 system/prompt 文本 → bump 字母;改 schema → bump 日期。
*/
const DRAFT_PLAN_SCRIPT_PROMPT_VERSION =
'draft_plan_script@2026-06-02-4module-v9'; // v9: 自报家门用登录客服 岗位+姓名(agent);v8: 占位符统一({}=替换、【】=原样保留);v7: 清除 user prompt 污染(去内部元数据/其他应治未治项/金额/FDI/方案词/冗余说明,只留模板占位值);v6: 清 system 污染;v5: 还原原模板
'draft_plan_script@2026-06-05-4module-v10'; // v10: 病种知识走 disease-knowledge 单一访问源(subKey 优先+文本兜底),修 颌骨囊肿 拿不到风险/优势的 bug;v9: 自报家门用登录客服 岗位+姓名(agent);v8: 占位符统一({}=替换、【】=原样保留);v7: 清除 user prompt 污染;v6: 清 system 污染;v5: 还原原模板
@Injectable()
export class DraftPlanScriptCall
......
import type { DraftPlanScriptInput } from './input.types';
import {
resolveAgeBranch,
resolveSalutation,
smartDateDisplay,
missedFromReason,
lookupKeyPoints,
lookupReviewDuration,
} from './script-facts';
import { resolveAgeBranch, resolveSalutation, smartDateDisplay } from './script-facts';
import { resolveDisease } from './script-common/disease-knowledge';
/**
* Prompt 版本管理约定:
......@@ -55,13 +49,17 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
const projectDate = top?.triggerDate ? new Date(top.triggerDate) : lastVisitDate;
const dateDisplay = smartDateDisplay(projectDate, now) ?? '上次';
const chiefComplaint = top?.triggerChiefComplaint ?? clinicalContext.lastChiefComplaint ?? null;
const missed = top ? missedFromReason(top) : { label: plan.primaryScenarioLabel, key: null };
const kp = lookupKeyPoints(missed.key);
const reviewDuration = lookupReviewDuration(missed.key);
// 病种知识(单一访问源:subKey 优先 + 文本兜底);稳健档把 risks/advantages 注入模板槽
const disease = resolveDisease(top ?? null, plan.primaryScenarioLabel);
const reviewDuration = disease.reviewDuration;
const doctor = top?.triggerDoctor ?? clinicalContext.primaryDoctorName ?? '您的主治医生';
const riskLines = kp ? kp.risks.map((r) => ` - ${r}`).join('\n') : ' - (按常识温和提醒,不吓唬人)';
const advLines = kp ? kp.advantages.map((a) => ` - ${a}`).join('\n') : ' - 趁现在早点处理会更省心';
const riskLines = disease.risks.length
? disease.risks.map((r) => ` - ${r}`).join('\n')
: ' - (按常识温和提醒,不吓唬人)';
const advLines = disease.advantages.length
? disease.advantages.map((a) => ` - ${a}`).join('\n')
: ' - 趁现在早点处理会更省心';
// 患者基本信息(只给话术用得到的:性别归一中文,年龄)
const g = (patient.gender ?? '').trim().toUpperCase();
......@@ -91,7 +89,7 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
- {最后一次就诊医生}:${doctor}
## 本次应治未治(只讲这一个)
- {应治未治项}:${missed.label}
- {应治未治项}:${disease.label}
- {风险要点}:
${riskLines}
- {治疗优势}:
......
/**
* 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,
};
}
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