import { findLabTest } from '../labs/catalog.js'; import type { CoverageOutput } from './clinician-types.js'; import type { EngineTrace } from './clinician-check.js'; import type { CalculatedEgfr } from './egfr.js'; import type { CheckInput, CheckResult } from './engine.js'; import { MONITORING_RULES } from './rules.js'; /** * CHK-3, CHK-9 (coverage): what the check did not check, for the end of every Context result and export. Products * with no rules, facts recorded that no rule read, facts missing that a rule here would read, rules waiting for * review, and check-in answers, which no rule reads. Says what happened; never what to do. */ const NOT_CHECKED = 'Recorded, not checked: no rules for this product yet.'; const PARTLY_CHECKED = 'Recorded, partly checked: some of what is in it has no rules yet.'; const NOT_READ = 'recorded, not read by any current rule.'; const CHECK_INS = 'Answers from check-ins are recorded as notes; no rule reads them.'; const PRODUCT_REASONS = new Set(['not_recognised', 'contents_unknown', 'no_rules', 'partly_recognised']); const labName = (code: string) => findLabTest(code)?.name ?? code; const names = (medicines: Array<{ name: string }>) => { const list = medicines.map((medicine) => medicine.name); return list.length <= 1 ? list.join('') : `${list.slice(0, -1).join(', ')} and ${list[list.length - 1]}`; }; export function buildCoverage(context: { input: CheckInput; result: CheckResult; trace: EngineTrace; egfr: CalculatedEgfr | null; waiting: CoverageOutput['rules_waiting_for_review'] }): CoverageOutput { const { input, result, trace, egfr, waiting } = context; const products: CoverageOutput['products_not_checked'] = []; for (const item of result.what_we_couldnt_check) { if (!PRODUCT_REASONS.has(item.reason)) continue; if (products.some((known) => known.item === item.item && known.medicine_id === item.medicine_id)) continue; // A medicine partly recognised is also listed for its unruled ingredients: one line, the partly-checked one. const partly = result.what_we_couldnt_check.some((other) => other.item === item.item && other.medicine_id === item.medicine_id && other.reason === 'partly_recognised') || (item.reason === 'no_rules' && result.what_we_checked.medicines.includes(item.item)); products.push({ item: item.item, medicine_id: item.medicine_id, reason: item.reason, plain: partly ? PARTLY_CHECKED : NOT_CHECKED }); } const notRead: CoverageOutput['facts_not_read'] = []; const calculated = egfr?.status === 'calculated'; if (input.subject.sex && !calculated) notRead.push({ fact: 'sex', item: 'Sex', plain: `Sex: ${NOT_READ}` }); if (input.reproductive) { const item = input.reproductive === 'pregnant' ? 'Pregnancy' : input.reproductive === 'breastfeeding' ? 'Breastfeeding' : 'Planning a pregnancy'; notRead.push({ fact: 'pregnancy', item, plain: `${item}: ${NOT_READ}` }); } for (const condition of input.conditions) notRead.push({ fact: 'condition', item: condition, plain: `${condition}: ${NOT_READ}` }); const weight = input.subject.weightKg; if (weight !== null && weight !== undefined && !trace.weightReadBy.length) { notRead.push({ fact: 'weight', item: 'Weight', plain: 'Weight: recorded, not read by any rule in this check (only the paracetamol maximum reads it).' }); } const datesRead = new Set([...trace.monitoring.values()].flatMap((entry) => entry.ruleIds) .map((id) => MONITORING_RULES.find((rule) => rule.id === id)?.labCode).filter((code): code is string => Boolean(code))); for (const code of [...new Set(input.labs.map((lab) => lab.code))]) { if (trace.labsRead.has(code) || (code === 'creatinine' && calculated)) continue; const item = labName(code); notRead.push({ fact: 'lab', item, plain: datesRead.has(code) ? `${item}: its date is used for monitoring; its value is not read by any current rule.` : `${item}: ${NOT_READ}` }); } const missing: CoverageOutput['facts_missing'] = []; const medicines = input.medicines.map((medicine) => ({ id: medicine.id, name: medicine.name })); if (input.medicines.length && input.subject.age === null && !input.subject.ageRange) { missing.push({ fact: 'age', plain: 'Age: needed for dose limits and for the older-adult and children\'s rules.', medicines }); } if (trace.weightReadBy.length && (weight === null || weight === undefined)) { missing.push({ fact: 'weight', plain: 'Weight: needed for the paracetamol maximum under 50 kg.', medicines: trace.weightReadBy }); } for (const gap of trace.missingLabs) { missing.push({ fact: gap.labCode, plain: `${labName(gap.labCode)}: needed with ${names(gap.medicines)}.`, medicines: gap.medicines }); } if (egfr?.status === 'not_calculated') missing.push({ fact: 'egfr_calculation', plain: egfr.plain, medicines: [] }); if (input.allergiesStatus === 'not_sure') missing.push({ fact: 'allergies', plain: 'Allergies: not confirmed.', medicines: [] }); const lines = [ ...products.map((product) => `${product.item}: ${product.plain.charAt(0).toLowerCase()}${product.plain.slice(1)}`), ...notRead.map((fact) => fact.plain), ...missing.map((fact) => fact.plain), ...(waiting.length ? [`Rules waiting for review: ${[...new Set(waiting.map((rule) => rule.rule_id))].join(', ')}.`] : []), CHECK_INS, ]; return { products_not_checked: products, facts_not_read: notRead, facts_missing: missing, rules_waiting_for_review: waiting, check_ins: { read: false, plain: CHECK_INS }, lines }; }