Commit 943b0922 by luoqi

test(ai-script): 重写话术确定性逻辑 spec — 对齐三档重构后 API

f60f4cca/4fe0d973 重构删除旧 script-facts 8 函数,旧 spec 成孤儿(套件加载失败)。
按现架构重写:shared/script-facts(智能日期/FDI俗称)、shared/pii(去名留称呼)、
shared/disease-knowledge(病种 canonical 名)、tiers/stable/phrasing(稳健档文案,
含 jaw_cyst 双字典 key 回归点)、shared/fact-block(厚输入事实块:全名不进 prompt/
未成年拍片禁令/单一聚焦)。62 测锁行为。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
parent e1697a0d
/**
* draft-plan-script 确定性逻辑单元测试(话术三档重构后,f60f4cc/4fe0d97)。
*
* 原则:LLM 不该做的确定性活(日期格式 / FDI 查表 / 去名留称呼 / 病种映射 / 事实块拼装)
* 全在这些纯函数里,这里锁行为:
* - shared/script-facts 智能日期 + 牙位俗称(三档 + 实时教练共用)
* - shared/pii 去名留称呼单一源(患者 / 监护人 / 医生)
* - shared/disease-knowledge 病种 canonical 名(tier-agnostic)
* - tiers/stable/phrasing 稳健档病种口语文案(risks/advantages/复查时长)
* - shared/fact-block 标准/深度档厚输入事实块拼装
*/
import {
resolveAgeBranch,
resolveAgeGroup,
smartDateDisplay,
resolveSalutation,
pickPrimaryMissed,
canonicalMissedKey,
lookupKeyPoints,
lookupReviewDuration,
missedFromReason,
} 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();
});
fdiToFriendly,
toothFriendly,
} from '../src/modules/ai/calls/draft-plan-script/shared/script-facts';
import {
deidentifyDoctor,
callSalutation,
pickGuardian,
} from '../src/modules/ai/calls/draft-plan-script/shared/pii';
import {
diseaseLabelForSubKey,
resolveDiseaseLabel,
} from '../src/modules/ai/calls/draft-plan-script/shared/disease-knowledge';
import { resolveDisease } from '../src/modules/ai/calls/draft-plan-script/tiers/stable/phrasing';
import {
buildRichFactBlock,
buildPersonaGuide,
renderMedicalRecord,
renderTreatmentPlan,
} from '../src/modules/ai/calls/draft-plan-script/shared/fact-block';
import type {
ScriptContext,
ScriptMedicalRecord,
} from '../src/modules/ai/calls/draft-plan-script/shared/input.types';
// ═════════════════════════════════════════════════════════
// shared/script-facts — 智能日期
// ═════════════════════════════════════════════════════════
describe('script-facts | smartDateDisplay(今年→X月X号 / 去年→去年X月 / 更早→XXXX年X月)', () => {
const now = new Date('2026-06-02T00:00:00Z');
test('今年 → X月X号', () => {
expect(smartDateDisplay('2026-01-29T00:00:00Z', now)).toBe('1月29号');
});
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('转换层: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);
}
test('去年 → 去年X月(不带日)', () => {
expect(smartDateDisplay('2025-12-10', now)).toBe('去年12月');
});
test('前年及更早 → XXXX年X月', () => {
expect(smartDateDisplay('2024-03-15', now)).toBe('2024年3月');
});
test('跨年边界:now 在 1 月,上月即"去年"', () => {
expect(smartDateDisplay('2025-12-31', new Date('2026-01-05'))).toBe('去年12月');
});
test('接受 Date 对象输入', () => {
expect(smartDateDisplay(new Date('2026-03-08'), now)).toBe('3月8号');
});
test('空 / 无效输入 → null', () => {
expect(smartDateDisplay(null, now)).toBeNull();
expect(smartDateDisplay(undefined, now)).toBeNull();
expect(smartDateDisplay('not-a-date', now)).toBeNull();
});
});
// ═════════════════════════════════════════════════════════
// shared/script-facts — 牙位俗称(FDI → 患者口语)
// ═════════════════════════════════════════════════════════
describe('script-facts | fdiToFriendly(FDI → 俗称,粗粒度)', () => {
test('恒牙:象限定上下,位置定名称', () => {
expect(fdiToFriendly('11')).toBe('上门牙'); // 1x 上颌 / 中切
expect(fdiToFriendly('22')).toBe('上门牙'); // 侧切同归门牙
expect(fdiToFriendly('43')).toBe('下尖牙');
expect(fdiToFriendly('34')).toBe('下小磨牙'); // 前磨牙
expect(fdiToFriendly('26')).toBe('上大磨牙'); // 磨牙
expect(fdiToFriendly('47')).toBe('下大磨牙');
});
test('8 号位 → 智齿(不分上下)', () => {
expect(fdiToFriendly('18')).toBe('智齿');
expect(fdiToFriendly('48')).toBe('智齿');
});
test('乳牙数字记法(象限 5-8):现状"乳"仅拼在门牙,尖牙/磨牙不带', () => {
expect(fdiToFriendly('51')).toBe('上乳门牙');
expect(fdiToFriendly('83')).toBe('下尖牙');
expect(fdiToFriendly('75')).toBe('下小磨牙');
});
test('乳牙"象限+字母"记法(host):A/B 门牙、C 尖牙、D/E 磨牙', () => {
expect(fdiToFriendly('2A')).toBe('上乳门牙');
expect(fdiToFriendly('3C')).toBe('下乳尖牙');
expect(fdiToFriendly('4D')).toBe('下乳磨牙');
expect(fdiToFriendly('1e')).toBe('上乳磨牙'); // 小写兼容
});
test('全口标记 → 全口', () => {
expect(fdiToFriendly('*whole')).toBe('全口');
expect(fdiToFriendly('whole')).toBe('全口');
});
test('解析不出 → null(交 toothFriendly 兜底)', () => {
expect(fdiToFriendly('99')).toBeNull(); // 象限 9 不存在
expect(fdiToFriendly('X1')).toBeNull();
expect(fdiToFriendly('')).toBeNull();
});
});
describe('script-facts | toothFriendly(多牙位 "/" 分隔,去重保序,兜底原样)', () => {
test('多牙位数组 → "/" 连接', () => {
expect(toothFriendly(['11', '36'])).toBe('上门牙/下大磨牙');
});
test('同俗称去重(11/21 都是上门牙,只出一次)', () => {
expect(toothFriendly(['11', '21', '36'])).toBe('上门牙/下大磨牙');
});
test('字符串输入:分号 / 逗号 / 中文逗号 / 空格 都是分隔符', () => {
expect(toothFriendly('11;36')).toBe('上门牙/下大磨牙');
expect(toothFriendly('11,36')).toBe('上门牙/下大磨牙');
expect(toothFriendly('11 36')).toBe('上门牙/下大磨牙');
});
test('都解析不出 → 兜底原样返回', () => {
expect(toothFriendly('MB2')).toBe('MB2');
expect(toothFriendly(['X1', 'X2'])).toBe('X1/X2'); // 数组兜底 join('/')
});
test('部分解析得出 → 只保留能解析的俗称', () => {
expect(toothFriendly(['11', 'X9'])).toBe('上门牙');
});
});
// ═════════════════════════════════════════════════════════
// shared/pii — 去名留称呼
// ═════════════════════════════════════════════════════════
describe('pii | deidentifyDoctor(医生取姓,模板拼"{姓}医生")', () => {
test('全名 → 姓', () => {
expect(deidentifyDoctor('韩维')).toBe('韩');
});
test('已带"医生"后缀 → 去后缀避免模板重复', () => {
expect(deidentifyDoctor('韩医生')).toBe('韩');
expect(deidentifyDoctor('您的主治医生')).toBe('您的主治');
});
test('空 → 泛指"您的主治"', () => {
expect(deidentifyDoctor(null)).toBe('您的主治');
expect(deidentifyDoctor(' ')).toBe('您的主治');
});
});
describe('pii | callSalutation(姓级称呼,绝不出全名)', () => {
test('成人:姓 + 先生/女士', () => {
expect(callSalutation('张震校', '男', 35, null)).toBe('张先生');
expect(callSalutation('徐雅静', '女', 28, null)).toBe('徐女士');
expect(callSalutation('李四', 'M', 40, null)).toBe('李先生');
expect(callSalutation('王五', 'female', 40, null)).toBe('王女士');
});
test('成人性别未知 → 默认先生', () => {
expect(callSalutation('赵六', null, 30, null)).toBe('赵先生');
});
test('无名 → "您"', () => {
expect(callSalutation(null, '男', 30, null)).toBe('您');
expect(callSalutation('', null, null, null)).toBe('您');
});
test('未成年(≤12)有监护人姓名 → 监护人姓 + 妈妈女士/爸爸先生', () => {
expect(callSalutation('张小宝', '男', 8, { relationship: 'mother', name: '李梅' })).toBe('李女士');
expect(callSalutation('张小宝', '男', 8, { relationship: 'father', name: '王刚' })).toBe('王先生');
});
test('未成年无监护人姓名 → "{患者姓}家长"', () => {
expect(callSalutation('张小宝', '男', 8, null)).toBe('张家长');
expect(callSalutation('张小宝', '男', 8, { relationship: 'mother', name: '' })).toBe('张家长');
});
test('12/13 边界:12 走监护人径,13 走成人径', () => {
expect(callSalutation('张小宝', '男', 12, null)).toBe('张家长');
expect(callSalutation('张小宝', '男', 13, null)).toBe('张先生');
});
test('年龄未知 → 按成人处理', () => {
expect(callSalutation('张三', '男', null, null)).toBe('张先生');
});
});
describe('pii | pickGuardian(优先已建档,关系 妈妈>爸爸>祖辈)', () => {
test('有姓名优先于关系顺位', () => {
const g = pickGuardian([
{ relationship: 'mother', relatedPatient: null },
{ relationship: 'father', relatedPatient: { name: '王刚' } },
]);
expect(g).toEqual({ relationship: 'father', relationshipLabel: '爸爸', name: '王刚' });
});
test('同为已建档 → 妈妈 > 爸爸', () => {
const g = pickGuardian([
{ relationship: 'father', relatedPatient: { name: '王刚' } },
{ relationship: 'mother', relatedPatient: { name: '李梅' } },
]);
expect(g?.relationship).toBe('mother');
expect(g?.relationshipLabel).toBe('妈妈');
});
test('未知关系 → label 兜底"家长"', () => {
const g = pickGuardian([{ relationship: 'uncle', relatedPatient: { name: '张叔' } }]);
expect(g?.relationshipLabel).toBe('家长');
});
test('空关系列表 → null', () => {
expect(pickGuardian([])).toBeNull();
});
});
// ═════════════════════════════════════════════════════════
// shared/disease-knowledge — 病种 canonical 名(tier-agnostic)
// ═════════════════════════════════════════════════════════
describe('disease-knowledge | diseaseLabelForSubKey(subKey → 患者口径病种名)', () => {
test('已映射 subKey → 病种名', () => {
expect(diseaseLabelForSubKey('missing_tooth')).toBe('缺失牙');
expect(diseaseLabelForSubKey('perio_no_srp')).toBe('牙周炎');
});
test('带 @tooth 后缀 → 去后缀再查', () => {
expect(diseaseLabelForSubKey('caries_no_filling@16')).toBe('龋齿');
});
test('未映射 / 空 → null', () => {
expect(diseaseLabelForSubKey('unknown_key')).toBeNull();
expect(diseaseLabelForSubKey(null)).toBeNull();
expect(diseaseLabelForSubKey('')).toBeNull();
});
});
describe('disease-knowledge | resolveDiseaseLabel(触发感知)', () => {
test('lead=医生建议(无诊断)→ 用建议名,不套子场景诊断词', () => {
const label = resolveDiseaseLabel(
{ subKey: 'missing_tooth', leadTrigger: { type: 'recommendation', code: 'IMPLANT_RECOMMENDED' } },
'漏治-缺失牙',
);
expect(label).toBe('建议种植'); // 不是"缺失牙"
});
test('lead=诊断 → subKey 病种名,以诊断为准', () => {
const label = resolveDiseaseLabel(
{ subKey: 'missing_tooth', leadTrigger: { type: 'diagnosis', code: 'K08' } },
'漏治-缺失牙',
);
expect(label).toBe('缺失牙');
});
test('subKey 未映射 → fallbackLabel 兜底', () => {
expect(resolveDiseaseLabel({ subKey: null, leadTrigger: null }, '漏治-其他')).toBe('漏治-其他');
expect(resolveDiseaseLabel(null, '漏治-其他')).toBe('漏治-其他');
});
});
// ═════════════════════════════════════════════════════════
// tiers/stable/phrasing — 稳健档病种口语文案
// ═════════════════════════════════════════════════════════
describe('phrasing | resolveDisease(稳健档:label + risks/advantages/复查时长)', () => {
test('subKey 精确命中 → label + 文案齐全', () => {
const d = resolveDisease({ subKey: 'perio_no_srp' }, '漏治-牙周');
expect(d.label).toBe('牙周炎');
expect(d.risks.length).toBeGreaterThan(0);
expect(d.advantages.length).toBeGreaterThan(0);
expect(d.reviewDuration).toContain('牙周');
expect(d.ageFit?.青年).toBeTruthy();
});
test('jaw_cyst 双字典 key 分列(keypoints=囊肿 / duration=颌骨囊肿)→ risks 不静默丢失', () => {
const d = resolveDisease({ subKey: 'jaw_cyst' }, '漏治-颌骨囊肿');
expect(d.label).toBe('颌骨囊肿');
expect(d.risks.length).toBeGreaterThan(0); // 曾因 key 不一致丢 risks 的回归点
expect(d.reviewDuration).toContain('囊肿');
});
test('subKey 带 @tooth 后缀 → 去后缀命中', () => {
expect(resolveDisease({ subKey: 'caries_no_filling@16' }, 'x').label).toBe('龋齿');
});
test('lead=医生建议 → 病种名换建议名,风险/优势仍按子场景', () => {
const d = resolveDisease(
{ subKey: 'missing_tooth', leadTrigger: { type: 'recommendation', code: 'IMPLANT_RECOMMENDED' } },
'漏治-缺失牙',
);
expect(d.label).toBe('建议种植');
// 风险仍是缺失牙口径(种植/修复缺口的风险本就适用)
expect(d.risks.some((r) => r.includes('缺牙') || r.includes('牙齿'))).toBe(true);
});
test('无 subKey → 文本归一兜底(scenarioLabel/reason 含病种词)', () => {
const d = resolveDisease({ subKey: null, scenarioLabel: '漏治-缺失牙', reason: '缺失牙未修复' }, '其他');
expect(d.label).toBe('缺失牙');
expect(d.risks.length).toBeGreaterThan(0);
});
test('文本归一按 MISSED_PRIORITY 优先(儿牙早矫 > 缺失牙)', () => {
const d = resolveDisease({ subKey: null, reason: '儿牙早矫评估;另有缺失牙' }, '其他');
expect(d.label).toBe('儿牙早矫');
});
test('完全未命中 → label 用 fallback,risks 空,复查时长兜底"其他"', () => {
const d = resolveDisease({ subKey: null, reason: '不构成病种的描述' }, '漏治-其他');
expect(d.label).toBe('漏治-其他');
expect(d.risks).toEqual([]);
expect(d.reviewDuration).toBe('复查检查约30分钟');
});
});
// ═════════════════════════════════════════════════════════
// shared/fact-block — 厚输入事实块(标准/深度档)
// ═════════════════════════════════════════════════════════
function makeReason(overrides: Partial<ScriptContext['plan']['reasons'][number]> = {}) {
return {
scenarioLabel: '漏治-缺失牙',
subKey: 'missing_tooth',
dxCode: 'K08',
leadTrigger: { type: 'diagnosis', code: 'K08' },
toothPositions: ['11'],
reason: '缺失牙未修复',
priorityScore: 90,
triggerDoctor: '韩维',
triggerDate: '2026-05-01',
medicalRecord: null,
...overrides,
};
}
function makeInput(overrides: {
patient?: Partial<ScriptContext['patient']>;
plan?: Partial<ScriptContext['plan']>;
clinicalContext?: Partial<ScriptContext['clinicalContext']>;
agent?: ScriptContext['agent'];
personaHighlights?: ScriptContext['personaHighlights'];
} = {}): ScriptContext {
return {
patient: {
name: '张震校',
salutation: '张先生',
gender: '男',
age: 35,
guardian: null,
...overrides.patient,
},
clinicName: '测试口腔',
agent: overrides.agent,
plan: {
primaryScenarioLabel: '漏治-缺失牙',
primaryScenarioKey: 'missed_diagnosis',
priorityScore: 90,
goal: '邀约来院评估缺失牙修复方案',
reasons: [makeReason()],
...overrides.plan,
},
personaHighlights: overrides.personaHighlights ?? [],
clinicalContext: {
daysSinceLastVisit: 0,
lastVisit: null,
lastChiefComplaint: '牙疼来看',
pendingTreatments: [],
primaryDoctorName: '韩维',
recentTreatments: [],
completedTreatmentCount: 3,
contactHistory: [],
...overrides.clinicalContext,
},
};
}
describe('fact-block | buildRichFactBlock(朴素事实块,不出全名)', () => {
test('开场事实:称呼 / 自报家门 / 医生去名 / 主诉', () => {
const block = buildRichFactBlock(
makeInput({ agent: { name: '李莉', roleTitle: '客服主管' } }),
);
expect(block).toContain('称呼:张先生');
expect(block).toContain('我是测试口腔的客服主管李莉');
expect(block).toContain('韩医生'); // 去名留姓
expect(block).not.toContain('韩维'); // 全名不进 prompt
expect(block).not.toContain('张震校');
expect(block).toContain('牙疼来看');
});
test('agent 无名 → 退回通用"客服",不编名字', () => {
const block = buildRichFactBlock(makeInput());
expect(block).toContain('我是测试口腔的客服');
});
test('daysSinceLastVisit=0 → 最近就诊显示为今年具体日期(X月X号)', () => {
const block = buildRichFactBlock(makeInput());
expect(block).toMatch(/最近一次就诊[^:]*:\d{1,2}\d{1,2}号/);
});
test('无就诊天数 / 无触发日期 → 兜底"上次"', () => {
const block = buildRichFactBlock(
makeInput({
clinicalContext: { daysSinceLastVisit: null },
plan: { reasons: [makeReason({ triggerDate: null })] },
}),
);
expect(block).toMatch(/最近一次就诊[^:]*:上次/);
});
test('牙位渲染成俗称给 LLM 直接用', () => {
const block = buildRichFactBlock(makeInput());
expect(block).toContain('牙位:上门牙');
});
test('未成年:监护人触达说明 + 拍片硬禁令', () => {
const block = buildRichFactBlock(
makeInput({
patient: {
age: 8,
salutation: '李女士',
guardian: { relationship: 'mother', relationshipLabel: '妈妈', name: '李梅' },
},
}),
);
expect(block).toContain('打给妈妈');
expect(block).toContain('称孩子为"宝宝"');
expect(block).toContain('严禁出现"拍片');
});
test('年龄未知同样触发拍片禁令;成人(>18)不触发', () => {
expect(buildRichFactBlock(makeInput({ patient: { age: null } }))).toContain('严禁出现"拍片');
expect(buildRichFactBlock(makeInput())).not.toContain('严禁出现"拍片');
});
test('单一聚焦:reasons[0] 进"本次问题",其余进"其他可一并关心"', () => {
const block = buildRichFactBlock(
makeInput({
plan: {
reasons: [
makeReason(),
makeReason({ subKey: 'caries_no_filling', scenarioLabel: '漏治-龋齿', toothPositions: ['36'] }),
],
},
}),
);
expect(block).toContain('本次问题:缺失牙');
expect(block).toMatch(/其他可一并关心[\s\S]*龋齿\(下大磨牙\)/);
});
test('只有一条 reason → 不渲染"其他"小节', () => {
expect(buildRichFactBlock(makeInput())).not.toContain('其他可一并关心');
});
test('近期治疗渲染结构化要素(类目/术式/医生/时间)', () => {
const block = buildRichFactBlock(
makeInput({
clinicalContext: {
recentTreatments: [
{ category: 'periodontic', categoryLabel: '牙周', subtype: '龈上洁治', doctorName: '吴倩', date: '2026.04' },
],
},
}),
);
expect(block).toContain('2026.04 / 牙周 / 龈上洁治 / 吴倩医生');
});
test('熟络度信号:给原始事实(次数 + 天数),不贴新/老二分', () => {
const block = buildRichFactBlock(makeInput());
expect(block).toContain('已完成 3 次治疗');
expect(block).toContain('距上次就诊 0 天');
});
});
describe('fact-block | buildPersonaGuide(画像 → 话术指引,只渲染命中特征)', () => {
const highlights = [
{ key: 'lifecycle_stage', label: '生命周期', description: '沉睡客户(340天未到诊)' },
{ key: 'contraindication', label: '禁忌', description: '青霉素过敏' },
{ key: 'discount_anchor', label: '折扣锚', description: '历史最低 8 折' },
];
test('full 模式:⚠注意在前,含切入点特征', () => {
const guide = buildPersonaGuide(highlights, 'full');
expect(guide).toContain('切入点');
const warnIdx = guide.indexOf('⚠ 【禁忌】');
const lifecycleIdx = guide.indexOf('【生命周期】');
const discountIdx = guide.indexOf('【折扣锚】');
expect(warnIdx).toBeGreaterThan(-1);
expect(warnIdx).toBeLessThan(lifecycleIdx); // 禁忌排最前
expect(lifecycleIdx).toBeLessThan(discountIdx); // 定语气在切入点前
});
test('essential 模式:只留 ⚠安全 + 定语气,滤掉切入点(折扣等)', () => {
const guide = buildPersonaGuide(highlights, 'essential');
expect(guide).toContain('【禁忌】');
expect(guide).toContain('【生命周期】');
expect(guide).not.toContain('【折扣锚】');
expect(guide).not.toContain('切入点');
});
test('未知 key 忽略;全部未命中 → 空串(调用方不追加)', () => {
expect(buildPersonaGuide([{ key: 'nonsense', label: 'x', description: 'y' }])).toBe('');
expect(buildPersonaGuide([])).toBe('');
});
});
// ═════════════════════════════════════════════════════════
// shared/fact-block — 病历 / 治疗计划渲染
// ═════════════════════════════════════════════════════════
function emptyMr(): ScriptMedicalRecord {
return {
date: null,
doctorName: null,
chiefComplaint: null,
presentIllness: null,
pastHistory: null,
generalCondition: null,
examFindings: [],
diagnoses: [],
disposals: [],
doctorAdvice: null,
treatmentPlanText: null,
diagnosisText: null,
recommendations: [],
plannedTreatments: [],
};
}
describe('fact-block | renderMedicalRecord(SOAP 全字段,空字段略,牙位转俗称)', () => {
test('null / 全空病历 → 空串', () => {
expect(renderMedicalRecord(null)).toBe('');
expect(renderMedicalRecord(emptyMr())).toBe('');
});
test('命中字段逐行渲染,牙位转俗称', () => {
const out = renderMedicalRecord({
...emptyMr(),
date: '2026-05-01',
presentIllness: '右下后牙冷热刺激痛一周',
examFindings: [{ toothPosition: '46', message: '深龋近髓' }],
diagnoses: [{ code: 'K02', nameZh: '龋病', toothPosition: '46', fromImageAi: false }],
doctorAdvice: '建议尽快充填,避免累及牙髓',
});
expect(out).toContain('- 接诊日期:2026-05-01');
expect(out).toContain('- 现病史:右下后牙冷热刺激痛一周');
expect(out).toContain('- 检查所见:下大磨牙 深龋近髓');
expect(out).toContain('- 当次诊断:龋病(下大磨牙)');
expect(out).toContain('- 医嘱(原话):建议尽快充填');
expect(out).not.toContain('既往史'); // 空字段不渲染
});
});
describe('查表(渐进式只取命中那条)', () => {
test('key-points 命中', () => {
const kp = lookupKeyPoints('牙槽骨吸收');
expect(kp?.risks.length).toBeGreaterThan(0);
expect(kp?.advantages.length).toBeGreaterThan(0);
describe('fact-block | renderTreatmentPlan(结构化 planned 主,host 自由文本补)', () => {
test('subtype 优先,缺 subtype 用类目名;两来源 ";" 拼接', () => {
const out = renderTreatmentPlan({
...emptyMr(),
plannedTreatments: [
{ categoryLabel: '充填 / 嵌体', subtype: '充填', toothPosition: '46' },
{ categoryLabel: '牙周', subtype: null, toothPosition: null },
],
treatmentPlanText: '择期复查',
});
test('复查时长命中', () =>
expect(lookupReviewDuration('缺失牙')).toContain('复查检查约30分钟'));
test('复查时长兜底', () =>
expect(lookupReviewDuration(null)).toContain('复查检查约30分钟'));
expect(out).toBe('充填(下大磨牙)、牙周;择期复查');
});
test('两来源都空 → null(调用方不渲染该行)', () => {
expect(renderTreatmentPlan(emptyMr())).toBeNull();
});
});
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