import { detailFor } from './allergy-detail.js'; import { clinicianWordingProblems } from './clinician-guard.js'; import { ALLERGY_CLASS_WORDS, ALLERGY_WORDING, CHILD_WORDING, ENGINE_WORDING, INTERACTION_WORDING, LAB_WORDING, MONITORING_WORDING, OLDER_WORDING, type ClinicianWordingEntry } from './clinician-wording-rules.js'; import type { CheckFinding, CheckInput } from './engine.js'; import { normalizeText } from './normalize.js'; import { MONITORING_RULES } from './rules.js'; import { CHECK_SOURCES, SPECIFIC_REFERENCES, type CheckSourceId } from './sources.js'; /** * CHK-5 (clinicianWording): renders the draft clinician wording (clinician-wording-rules.ts) for a finding from the * facts the engine recorded. Each text (before its "Basis:" list of source names) is checked by clinician-guard.ts with * the medicine names masked; a text with a problem is not shown (null), so no instruction ever reaches a clinician. */ const joinNames = (names: string[]) => (names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`); const number = (value: number) => Math.round(value).toLocaleString('en-GB'); export const clinicianDate = (iso: string) => new Date(`${iso.slice(0, 10)}T00:00:00Z`) .toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }); const fillAll = (text: string, values: Record) => text.replace(/\{(\w+)\}/g, (whole, key: string) => values[key] ?? whole); /** The sources an explanation rests on: the entry's own basis or the rule's, then its specific references for these ingredients. */ function basis(entry: { basis?: CheckSourceId[] } | null, finding: CheckFinding, ingredientKeys: string[] | null): string { const ids = [...(entry?.basis ?? finding.source_ids)]; for (const reference of SPECIFIC_REFERENCES[finding.ruleId] ?? []) { if (reference.appliesTo && ingredientKeys && !reference.appliesTo.some((key) => ingredientKeys.includes(key))) continue; if (!ids.includes(reference.sourceId)) ids.push(reference.sourceId); } return ids.length ? ` Basis: ${ids.map((id) => CHECK_SOURCES[id].name).join('; ')}.` : ''; } function ageText(facts: Record): string { const range = facts.ageRange as { min: number; max: number } | null | undefined; if (range && range.min !== range.max) return `age ${range.min} to ${range.max}`; const age = (facts.age as number | null | undefined) ?? range?.min; return age === null || age === undefined ? 'an age not recorded' : `age ${age}`; } function resultText(facts: Record): string { const code = String(facts.code ?? ''); const unit = facts.unit && !['egfr', 'inr'].includes(code) ? ` ${String(facts.unit)}` : ''; const how = facts.calculated ? ', calculated from creatinine with CKD-EPI 2021' : ''; return `${String(facts.test)} ${String(facts.printed)}${unit}${how} (${clinicianDate(String(facts.observedAt))})`; } function reactionText(allergy: string, input: CheckInput): string { const detail = detailFor(allergy, input.allergyDetails); if (!detail || (!detail.reaction && !detail.reactionKind)) return '; no reaction recorded'; const when = [detail.onset && detail.onset !== 'unknown' ? `${detail.onset} onset` : '', detail.year ? String(detail.year) : ''].filter(Boolean).join(', '); return `; recorded reaction: ${detail.reaction ?? detail.reactionKind}${when ? ` (${when})` : ''}`; } function entryFor(finding: CheckFinding): { title: string; why: string; entry: ClinicianWordingEntry | null } | null { const id = finding.ruleId; if (INTERACTION_WORDING[id]) return { title: '{a} with {b}', why: INTERACTION_WORDING[id].why, entry: INTERACTION_WORDING[id] }; if (OLDER_WORDING[id]) return { title: '{a} at {age}', why: OLDER_WORDING[id].why, entry: OLDER_WORDING[id] }; if (CHILD_WORDING[id]) return { title: '{a} at {age}', why: CHILD_WORDING[id].why, entry: CHILD_WORDING[id] }; if (LAB_WORDING[id]) return { title: '', why: LAB_WORDING[id].why, entry: LAB_WORDING[id] }; if (id.startsWith('dose-max-')) return { ...ENGINE_WORDING['dose-max'], entry: null }; if (id.startsWith('duplicate-')) return { ...ENGINE_WORDING.duplicate, entry: null }; if (id === 'paracetamol-lower-maximum') return { ...ENGINE_WORDING['paracetamol-lower-maximum'], entry: null }; return null; } /** A finding's clinician title and explanation, or null when it has none or it fails the wording check. */ export function clinicianWording(finding: CheckFinding, facts: Record, input: CheckInput): { title: string; why: string } | null { const names = finding.medicines.map((medicine) => medicine.name); const values: Record = { a: names[0] ?? '', b: names[1] ?? '', medicines: joinNames(names), age: ageText(facts) }; let title: string; let why: string; let entry: ClinicianWordingEntry | null = null; if (finding.kind === 'allergy') { const allergy = String(facts.allergy ?? ''); const ingredients = (facts.ingredients as string[] | undefined) ?? []; const sameName = ingredients.length === 1 && normalizeText(ingredients[0]) === normalizeText(values.a); const kind = (facts.matchKind as 'ingredient' | 'class' | 'cross' | undefined) ?? 'ingredient'; // "Penicillin allergy" as recorded reads "a recorded Penicillin allergy", never "allergy allergy". Object.assign(values, { allergy: allergy.replace(/\s+allerg(?:y|ies)$/i, ''), class: ALLERGY_CLASS_WORDS[finding.ruleId] ?? '', reaction: reactionText(allergy, input), subject: sameName || !ingredients.length ? `${values.a} is` : `${values.a} contains ${joinNames(ingredients.map((name) => name.toLowerCase()))},` }); title = ALLERGY_WORDING.title; why = `${ALLERGY_WORDING[kind]}${facts.readAs ? ` The allergy entry was read as ${String(facts.readAs)}.` : ''}`; } else { const found = entryFor(finding); if (!found) return null; entry = found.entry; why = found.why; title = found.title; if (finding.kind === 'lab') { Object.assign(values, { a: String(facts.a ?? ''), b: String(facts.b ?? ''), result: resultText(facts), threshold: String(facts.threshold ?? ''), test: String(facts.test) }); if (!facts.a && entry?.whyAlone) why = entry.whyAlone; title = facts.a ? '{a} and the latest {test} result' : 'The latest {test} result'; if (facts.crossing === 'unknown') why = `The result was printed as a bound ("${String(facts.printed)}"); this applies to the highest-risk value it allows. ${why}`; if (facts.implausible) why = `The result (${String(facts.printed)}) is outside the range the engine treats as plausible and may have been entered wrongly. ${why}`; } else if (finding.kind === 'dose' && finding.ruleId.startsWith('dose-max-')) { const parts = (facts.parts as Array<{ ingredient: string; total: number; max: number }> | undefined) ?? []; values.amounts = joinNames(parts.map((part) => `about ${number(part.total)} mg of ${part.ingredient} a day, above the adult label maximum of ${number(part.max)} mg in 24 hours`)); } else if (finding.kind === 'duplicate') { const total = facts.total as number | null; const max = facts.max as number | null; Object.assign(values, { ingredient: String(facts.ingredient ?? ''), total: total === null || total === undefined ? '; the combined daily amount could not be worked out' : `; together about ${number(total)} mg a day${max ? `, against an adult label maximum of ${number(max)} mg` : ''}` }); values.ingredient = values.ingredient.toLowerCase(); title = title.replace('{ingredient}', String(facts.ingredient ?? '')); } else if (finding.ruleId === 'paracetamol-lower-maximum') { Object.assign(values, { total: number(Number(facts.total)), reason: facts.reason === 'weight_under_50_kg' ? 'with a recorded weight under 50 kg' : 'at age 75 or over' }); } } // The basis is source names (data): the check covers MARMAR's own words. const body = { title: fillAll(title, values), why: fillAll(why, values) }; if ([...clinicianWordingProblems(body.title, names), ...clinicianWordingProblems(body.why, names)].length) return null; return { title: body.title, why: `${body.why}${basis(entry, finding, (facts.ingredientKeys as string[] | undefined) ?? null)}` }; } /** A test's clinician title and why (FND-7), from the first rule that asked for it. */ export function clinicianTestWording(ruleId: string | undefined, medicines: string[]): { title: string; why: string } | null { const rule = MONITORING_RULES.find((candidate) => candidate.id === ruleId); const entry = ruleId ? MONITORING_WORDING[ruleId] : undefined; if (!rule || !entry) return null; const title = `${rule.test} ยท needed with ${joinNames(medicines)}`; if ([...clinicianWordingProblems(title, medicines), ...clinicianWordingProblems(entry.why, medicines)].length) return null; return { title, why: `${entry.why} Basis: ${rule.sources.map((id) => CHECK_SOURCES[id].name).join('; ')}.` }; }