Commit a1cd620d by luoqi

feat(ai-script): 漏诊项转换层 — PAC 应治未治 reason → 漏诊项口径

确认"漏诊项" = PAC treatment_initiation_recall 的 reason(K00-K09 应治未治):
- SUBKEY_TO_MISSED:10 个子场景 subKey(missing_tooth/perio_no_srp/ortho_no_consult…)
  → 漏诊项配置 key + 患者口径 label(missedFromReason,带 @tooth 后缀处理 + 文本兜底)
- 补齐 PAC 子场景对应的 key-points(牙周炎/错颌畸形/牙体损伤/牙龈问题/恒牙萌出空间不足/
  儿牙早矫)+ 复查时长,确保每个子场景都查得到
- 主漏诊项 = priorityScore 最高的 reason(用 PAC 6 因子排序,不另用业务硬优先级)
- fallback 改用 missedFromReason(plan.reasons 最高分那条)

script-facts 单测 25 通过(+5 转换层),typecheck 0。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 1e0c5151
...@@ -14,7 +14,7 @@ import { ...@@ -14,7 +14,7 @@ import {
resolveAgeBranch, resolveAgeBranch,
resolveSalutation, resolveSalutation,
smartDateDisplay, smartDateDisplay,
pickPrimaryMissed, missedFromReason,
lookupKeyPoints, lookupKeyPoints,
lookupReviewDuration, lookupReviewDuration,
} from './script-facts'; } from './script-facts';
...@@ -98,9 +98,11 @@ function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput { ...@@ -98,9 +98,11 @@ function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
: null, : null,
new Date(), new Date(),
) ?? '上次'; ) ?? '上次';
const missed = // 漏诊项 = PAC 应治未治 reason(取 priorityScore 最高的一条)→ 转换层归一
pickPrimaryMissed([...(clinicalContext.pendingTreatments ?? []), plan.primaryScenarioLabel]) ?? const topReason = [...(plan.reasons ?? [])].sort((a, b) => b.priorityScore - a.priorityScore)[0];
{ raw: plan.primaryScenarioLabel, key: null }; const missed = topReason
? missedFromReason(topReason)
: { label: plan.primaryScenarioLabel, key: null };
const kp = lookupKeyPoints(missed.key); const kp = lookupKeyPoints(missed.key);
const risk = kp?.risks[0] ?? '这个问题如果一直拖着,后面处理可能更复杂'; const risk = kp?.risks[0] ?? '这个问题如果一直拖着,后面处理可能更复杂';
const adv = kp?.advantages[0] ?? '趁现在早点处理会更省心'; const adv = kp?.advantages[0] ?? '趁现在早点处理会更省心';
...@@ -111,7 +113,7 @@ function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput { ...@@ -111,7 +113,7 @@ function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
opening: `• ${salutation}您好,我是${clinicName}的客服 opening: `• ${salutation}您好,我是${clinicName}的客服
${doctor}医生特意交代我来关注您的后续情况 ${doctor}医生特意交代我来关注您的后续情况
• 自从${dateDisplay}检查后,您口腔情况怎么样?`, • 自从${dateDisplay}检查后,您口腔情况怎么样?`,
informMissed: `• 上次检查的时候,${doctor}医生注意到您有${missed.raw}的情况 informMissed: `• 上次检查的时候,${doctor}医生注意到您有${missed.label}的情况
${risk} ${risk}
${adv} ${adv}
• 这个${doctor}医生也特别嘱咐我们提醒您一下`, • 这个${doctor}医生也特别嘱咐我们提醒您一下`,
......
...@@ -141,6 +141,42 @@ export const MISSED_DIAGNOSIS_KEY_POINTS: Record<string, MissedKeyPoints> = { ...@@ -141,6 +141,42 @@ export const MISSED_DIAGNOSIS_KEY_POINTS: Record<string, MissedKeyPoints> = {
risks: ['可能阻挡恒牙正常萌出', '会影响邻牙位置', '可能导致牙列拥挤'], risks: ['可能阻挡恒牙正常萌出', '会影响邻牙位置', '可能导致牙列拥挤'],
advantages: ['越早处理越不影响恒牙', '减少后期矫正难度', '让牙列发育更顺畅'], advantages: ['越早处理越不影响恒牙', '减少后期矫正难度', '让牙列发育更顺畅'],
}, },
// ── 补齐 PAC 子场景(应治未治)对应的口径 ──
牙周炎: {
risks: [
'牙龈容易出血、红肿,刷牙时尤其明显',
'时间久了牙槽骨吸收,牙齿会慢慢松动',
'牙缝可能变大,食物容易塞牙',
'不控制的话,后面可能要拔牙',
],
advantages: ['趁现在做基础治疗,能把炎症控制住', '早点干预,牙齿能保留得更久'],
ageFit: { 青年: '年轻时牙周恢复能力强', 中年: '正是该好好维护的时候', 老年: '维护好牙周,晚年吃东西更舒服' },
},
错颌畸形: {
risks: [
'牙齿排列不齐,刷牙容易刷不干净,易蛀牙、牙龈发炎',
'咬合不好,长期可能影响咀嚼和颞下颌关节',
'也会影响笑容和自信',
],
advantages: ['早点评估,矫治方案选择更多', '趁现在牙周条件好,矫正更稳'],
ageFit: { 青年: '年轻时矫正配合度和效果都更好', 中年: '成人也能做隐形矫正,不影响工作' },
},
牙体损伤: {
risks: ['缺损放着不管会越来越大', '可能出现冷热敏感或牙痛', '严重了可能伤到牙神经,要做根管'],
advantages: ['早修复范围小、花费少', '能保护剩余牙体,避免折裂'],
},
牙龈问题: {
risks: ['牙龈反复红肿出血', '可能慢慢退缩,牙根暴露敏感', '不处理会影响牙齿稳固'],
advantages: ['早处理容易控制', '保护牙龈和牙槽骨健康'],
},
恒牙萌出空间不足: {
risks: ['恒牙可能没地方长,容易长歪或拥挤', '将来矫正难度会更大'],
advantages: ['趁换牙期早干预,引导恒牙顺利萌出', '减少以后正畸的复杂程度'],
},
儿牙早矫: {
risks: ['不良习惯或颌骨发育问题越拖越难纠正', '可能影响恒牙排列和脸型发育'],
advantages: ['替牙期是早矫黄金期,效果好、周期短', '趁现在引导,省去将来复杂矫正'],
},
}; };
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
...@@ -152,11 +188,61 @@ export const TREATMENT_DURATION: Record<string, string> = { ...@@ -152,11 +188,61 @@ export const TREATMENT_DURATION: Record<string, string> = {
缺失牙: '复查检查约30分钟,了解缺失牙位目前状况', 缺失牙: '复查检查约30分钟,了解缺失牙位目前状况',
牙槽骨吸收: '复查检查约30-45分钟,需要仔细检查牙周健康状况', 牙槽骨吸收: '复查检查约30-45分钟,需要仔细检查牙周健康状况',
牙周病: '复查检查约30-45分钟,评估牙周健康情况', 牙周病: '复查检查约30-45分钟,评估牙周健康情况',
牙周炎: '复查检查约30-45分钟,评估牙周健康情况',
错颌畸形: '复查检查约30分钟,医生评估咬合与矫治方案',
牙体损伤: '复查检查约20-30分钟,查看牙体缺损情况',
根尖周炎: '复查检查约20-30分钟,查看牙髓和根尖情况',
牙龈问题: '复查检查约20-30分钟,评估牙龈健康',
阻生牙: '复查检查约20-30分钟,评估阻生牙位置',
颌骨囊肿: '复查检查约30分钟,评估囊肿范围',
龋齿: '复查检查约20-30分钟,查看牙齿状况', 龋齿: '复查检查约20-30分钟,查看牙齿状况',
其他: '复查检查约30分钟', 其他: '复查检查约30分钟',
}; };
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
// ⭐ 转换层:PAC 召回 reason(应治未治)→ 漏诊项口径
// "漏诊项" 在 PAC = treatment_initiation_recall 的 reason(K00-K09 应治未治)。
// reason.subKey(子场景)→ 配置 key(查 key-points/复查时长)+ 患者口径 label。
// PAC 已按 priorityScore 给 reason 排序 → 主漏诊项 = 排第一的 reason(不另用业务硬优先级)。
// ─────────────────────────────────────────────────────────
const SUBKEY_TO_MISSED: Record<string, { key: string; label: string }> = {
missing_tooth: { key: '缺失牙', label: '缺失牙' },
caries_no_filling: { key: '龋齿', label: '龋齿' },
endo_no_rct: { key: '根尖周炎', label: '牙髓/根尖周炎' },
perio_no_srp: { key: '牙周炎', label: '牙周炎' },
ortho_no_consult: { key: '错颌畸形', label: '错颌畸形(牙齿不齐)' },
hard_tissue_damage: { key: '牙体损伤', label: '牙体缺损' },
gum_alveolar_lesion: { key: '牙龈问题', label: '牙龈/牙槽问题' },
impacted_tooth: { key: '阻生牙', label: '阻生牙' },
jaw_cyst: { key: '颌骨囊肿', label: '颌骨囊肿' },
development_eruption: { key: '恒牙萌出空间不足', label: '牙齿萌出异常' },
};
export interface MissedItem {
/** 患者口径漏诊项(话术里用,如"缺失牙""错颌畸形(牙齿不齐)") */
label: string;
/** 配置 key(查 key-points / 复查时长);null = 未映射,用 label 兜底 */
key: string | null;
}
/** 单条 PAC reason → 漏诊项(subKey 优先,缺失则用文本兜底归一) */
export function missedFromReason(reason: {
subKey?: string | null;
dxCode?: string | null;
reason?: string | null;
scenarioLabel?: string | null;
}): MissedItem {
// subKey 可能带 @tooth 后缀(如 caries_no_filling@36)→ 取前缀
const baseSub = (reason.subKey ?? '').split('@')[0]!.trim();
const mapped = baseSub ? SUBKEY_TO_MISSED[baseSub] : undefined;
if (mapped) return { label: mapped.label, key: mapped.key };
// 兜底:从 reason/scenarioLabel 文本归一
const text = `${reason.scenarioLabel ?? ''} ${reason.reason ?? ''}`;
const key = canonicalMissedKey(text);
return { label: key ?? (reason.scenarioLabel ?? '需复查的问题'), key };
}
// ─────────────────────────────────────────────────────────
// 漏诊项归一 + 优先级挑选 // 漏诊项归一 + 优先级挑选
// 漏诊项文本(如"牙位:21,牙槽骨吸收")→ 命中配置 key(includes 关键词) // 漏诊项文本(如"牙位:21,牙槽骨吸收")→ 命中配置 key(includes 关键词)
// 优先级:儿牙早矫 > 恒牙萌出空间不足 > 缺失牙 > 牙槽骨吸收 > 其余(配置出现顺序) // 优先级:儿牙早矫 > 恒牙萌出空间不足 > 缺失牙 > 牙槽骨吸收 > 其余(配置出现顺序)
......
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
canonicalMissedKey, canonicalMissedKey,
lookupKeyPoints, lookupKeyPoints,
lookupReviewDuration, lookupReviewDuration,
missedFromReason,
} from '../src/modules/ai/calls/draft-plan-script/script-facts'; } from '../src/modules/ai/calls/draft-plan-script/script-facts';
describe('script-facts(确定性逻辑提取,替代 LLM 主观判断)', () => { describe('script-facts(确定性逻辑提取,替代 LLM 主观判断)', () => {
...@@ -52,6 +53,29 @@ describe('script-facts(确定性逻辑提取,替代 LLM 主观判断)', () => { ...@@ -52,6 +53,29 @@ describe('script-facts(确定性逻辑提取,替代 LLM 主观判断)', () => {
test('空 → null', () => expect(pickPrimaryMissed([null, ''])).toBeNull()); test('空 → null', () => expect(pickPrimaryMissed([null, ''])).toBeNull());
}); });
describe('转换层:PAC reason(应治未治)→ 漏诊项', () => {
test('subKey 映射(带 @tooth 后缀)', () => {
const m = missedFromReason({ subKey: 'missing_tooth@36', dxCode: 'K08' });
expect(m.key).toBe('缺失牙');
expect(m.label).toBe('缺失牙');
});
test('牙周 subKey → 牙周炎', () =>
expect(missedFromReason({ subKey: 'perio_no_srp@whole' }).key).toBe('牙周炎'));
test('正畸 subKey → 错颌畸形(有 key-points)', () => {
const m = missedFromReason({ subKey: 'ortho_no_consult' });
expect(m.key).toBe('错颌畸形');
expect(lookupKeyPoints(m.key)?.risks.length).toBeGreaterThan(0);
});
test('未知 subKey → 文本兜底归一', () =>
expect(missedFromReason({ subKey: 'xxx', scenarioLabel: '龋齿未做充填' }).key).toBe('龋齿'));
test('每个 PAC 子场景都有复查时长(非空)', () => {
for (const sub of ['missing_tooth', 'caries_no_filling', 'endo_no_rct', 'perio_no_srp', 'ortho_no_consult', 'hard_tissue_damage', 'gum_alveolar_lesion', 'impacted_tooth', 'jaw_cyst', 'development_eruption']) {
const m = missedFromReason({ subKey: sub });
expect(lookupReviewDuration(m.key).length).toBeGreaterThan(5);
}
});
});
describe('查表(渐进式只取命中那条)', () => { describe('查表(渐进式只取命中那条)', () => {
test('key-points 命中', () => { test('key-points 命中', () => {
const kp = lookupKeyPoints('牙槽骨吸收'); const kp = lookupKeyPoints('牙槽骨吸收');
......
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