Commit 9de1af06 by luoqi

merge: main → test(话术确定性 spec 重写 + recall/canonical 测试修复)

冲突解法:
- script-facts.spec.ts:取 main 新写 spec(test 侧 05eafa61 删的孤儿已由重写替代)
- canonical-fact-layer.spec.ts:取 test 侧(1d8a663b 与 main 修复等价)
- recall-suppression.spec.ts:test 演进版为基底 + main 的 priority-scorer v3 因子测试块
  (替代 test 侧"GAP2 已删"墓碑注释)

合并后 pac-service 14 suites / 262 tests 全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
parents 384cb90a b0879852
Pipeline #3405 failed in 0 seconds
......@@ -6,6 +6,12 @@ import {
CLOSE_REASON_DEFAULT_SUPPRESS_DAYS,
} from '@pac/types';
import { resolveSnoozedUntil } from '../src/modules/plan/recall-suppression';
import {
computeConfidenceFactor,
computeFreshness,
calcPriority,
type PriorityInput,
} from '../src/modules/plan/engine/priority-scorer';
/**
* 召回闭环:熔断 / 拒绝 / 回访冷静期(snoozedUntil)。
......@@ -83,8 +89,100 @@ describe('召回抑制窗 snoozedUntil 计算', () => {
});
});
// 「优先级:considering 不再加权(GAP2)」测试块已删 —— computeLikelihoodBonus 在
// 7eb8dbe(三源信号统一标定)重构中移除,likelihood 并入 calcPriority,该行为由引擎侧覆盖。
/**
* 优先级 v3.0 三维模型(7eb8dbe 重构后)。
* 原 GAP2"considering 不加权":v3 scorer 不再消费 execution outcome(likelihood bonus 已删),
* 由设计保证 —— 这里改锁 v3 的确定性因子(置信度 / 新鲜度 / 三维加权)。
*/
describe('priority-scorer v3 | 确定性因子', () => {
test('置信度因子:直用来源置信度,夹在 [0.5, 1](防过罚)', () => {
expect(computeConfidenceFactor(1)).toBe(1); // 医生诊断
expect(computeConfidenceFactor(0.8)).toBe(0.8); // 建议
expect(computeConfidenceFactor(0.2)).toBe(0.5); // 下限地板
expect(computeConfidenceFactor(1.5)).toBe(1); // 上限
});
test('新鲜度:黄金窗内满权重,过窗线性衰减,2×窗见地板 0.4', () => {
expect(computeFreshness(0, 180)).toBe(1.0);
expect(computeFreshness(180, 180)).toBe(1.0); // 窗上界仍满
expect(computeFreshness(270, 180)).toBeCloseTo(0.7); // 过窗一半
expect(computeFreshness(360, 180)).toBeCloseTo(0.4); // 2×窗
expect(computeFreshness(3600, 180)).toBeCloseTo(0.4); // 地板不再降
});
const baseInput: PriorityInput = {
urgencyLevel: 'high',
daysSince: 0,
windowDays: 180,
primaryCode: 'K02',
toothCount: 1,
rfmSegment: null,
consultIntentMatch: false,
lifecycleStage: null,
hasTreatmentHistory: false,
isReferralChampion: false,
recentVisitGood: false,
confidence: 1,
};
test('三维加权:急迫×0.4 + 价值×0.3 + 意愿×0.3(缺省 rfm=5/意向=2/信任=4)', () => {
const r = calcPriority(baseInput);
// urgency high=7 → 2.8;K02 价值 2 → 0.6;意愿 5*0.375+2*0.375+4*0.25=3.625 → 1.0875
expect(r.breakdown.urgency).toBe(7);
expect(r.breakdown.value).toBe(2);
expect(r.breakdown.willingness).toBe(3.63);
expect(r.breakdown.base).toBe(4.49);
expect(r.score).toBe(45); // round(4.4875 × 10)
});
test('种植(K08)价值按牙数分档:单颗8 / 多颗9 / 半口+10', () => {
expect(calcPriority({ ...baseInput, primaryCode: 'K08', toothCount: 1 }).breakdown.value).toBe(8);
expect(calcPriority({ ...baseInput, primaryCode: 'K08', toothCount: 3 }).breakdown.value).toBe(9);
expect(calcPriority({ ...baseInput, primaryCode: 'K08', toothCount: 6 }).breakdown.value).toBe(10);
});
test('修复(K03)≥3颗提档:5 → 6;未映射病种价值兜底 3', () => {
expect(calcPriority({ ...baseInput, primaryCode: 'K03', toothCount: 2 }).breakdown.value).toBe(5);
expect(calcPriority({ ...baseInput, primaryCode: 'K03', toothCount: 3 }).breakdown.value).toBe(6);
expect(calcPriority({ ...baseInput, primaryCode: 'K99', toothCount: 1 }).breakdown.value).toBe(3);
});
test('信任基础:lifecycle 底分 + 治疗史/转介/近1年履约各+1,封顶 10', () => {
const r = calcPriority({
...baseInput,
lifecycleStage: 'mature', // 9
hasTreatmentHistory: true,
isReferralChampion: true,
recentVisitGood: true,
});
expect(r.breakdown.trustBase).toBe(10); // 9+3 → 封顶
});
test('老诊断 + 低置信来源:新鲜度与置信度因子相乘压低总分', () => {
const r = calcPriority({ ...baseInput, daysSince: 360, confidence: 0.3 });
expect(r.breakdown.freshness).toBe(0.4);
expect(r.breakdown.confidenceFactor).toBe(0.5);
expect(r.score).toBe(9); // round(4.4875 × 0.4 × 0.5 × 10)
});
test('urgency 缺失记 0;满配输入分数不破 100', () => {
expect(calcPriority({ ...baseInput, urgencyLevel: null }).breakdown.urgency).toBe(0);
const max = calcPriority({
...baseInput,
urgencyLevel: 'urgent',
primaryCode: 'K08',
toothCount: 8,
rfmSegment: 'important_value',
consultIntentMatch: true,
lifecycleStage: 'mature',
hasTreatmentHistory: true,
isReferralChampion: true,
recentVisitGood: true,
});
expect(max.score).toBeLessThanOrEqual(100);
expect(max.score).toBeGreaterThan(90);
});
});
describe('抑制策略单一真理源 EXECUTION_OUTCOME_META.suppressDays', () => {
test('每个 outcome 都声明了 suppressDays(number 或 null),无遗漏', () => {
......
/**
* 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 {
smartDateDisplay,
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号');
});
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('fact-block | renderTreatmentPlan(结构化 planned 主,host 自由文本补)', () => {
test('subtype 优先,缺 subtype 用类目名;两来源 ";" 拼接', () => {
const out = renderTreatmentPlan({
...emptyMr(),
plannedTreatments: [
{ categoryLabel: '充填 / 嵌体', subtype: '充填', toothPosition: '46' },
{ categoryLabel: '牙周', subtype: null, toothPosition: null },
],
treatmentPlanText: '择期复查',
});
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