import { emergencyAction, type EmergencyAdvice } from '../../shared/emergency/contract.js'; import { createHash } from 'node:crypto'; import { INGREDIENTS, INGREDIENT_BY_KEY, PRODUCTS, type DrugClass } from './catalog.js'; import { CHECK_SOURCES, type CheckSourceId } from './sources.js'; import { ALLERGY_RULES, CHILD_RULES, INTERACTION_RULES, LAB_RULES, MONITORING_RULES, OLDER_ADULT_RULES, RED_FLAGS, type AllergyClassRule, type Evidence, type LabBound, type Matcher, type Tier, } from './rules.js'; import { classesFor, correctSpelling, ingredientName, isLocal, isPatchOrSpray, matchIngredients, matches, normalizeText, type Strength } from './normalize.js'; import { readStrength, type StrengthReading } from './amounts.js'; import { DOSE_NOT_IN_MG, DOSE_PROBLEM_PLAIN, doseOf, TOPICAL, type Dose } from './dose-amount.js'; import { findLabTest } from '../labs/catalog.js'; import { readsAsEnglish } from './english.js'; import { intentIn, withoutWordsOfIntentRead } from './intent.js'; import { paracetamolOverLimit } from './overuse.js'; import { CHECK_SIGNED_OFF } from './signoff.js'; import { implausibleResult, targetProblem } from '../labs/targets.js'; import { hasOptions, type AllergyDetail, type CheckOptions, type EngineHooks, type KidneyLimitOutput } from './options.js'; import { checkWithOptions, fingerprintOptions, type EngineTrace } from './clinician-check.js'; import type { ClinicianFindingFields, ClinicianResultFields, ClinicianTestFields } from './clinician-types.js'; /** * The deterministic V2.2 check (rules decide, the model explains). * * Pure: no I/O. Given what the person entered, it returns every finding with * its tier, sources and plain template wording. The optional writing layer may * rephrase the wording but never changes a tier, a finding or a source; when it * fails its post-checks, this wording is what people see. */ /** * ENGINE_VERSION changes with how the engine reads and words what it is given (2.2.0: combinations dose-checked one medicine * at a time, strength_unreadable; 2.2.1: maximum-only dose wording; 2.2.2: incomplete clinical context is visible in the verdict). * RULES_VERSION moves with each clinical sign-off of the rule tables and catalogue * (signoff.ts): 2026-10-15 recorded the pharmacist's approval of the review pack of 13 October 2026 (rules * draft-2026-10-14, engine 2.1.0). Engine 2.2.0's changes since then wait for a top-up review. */ export const ENGINE_VERSION = '2.2.2'; export const RULES_VERSION = '2026-10-15'; export type VerdictTier = Tier | 'nothing_found' | 'couldnt_check'; /** * Why something on what_we_couldnt_check wasn't checked. The first six are * about a medicine on the list; the rest are about the person or their results. * Unassessed clinical context also prevents a clean verdict when no actionable finding exists. * strength_unreadable: a strength was entered for a medicine with a daily * maximum, but couldn't be read, so its dose wasn't checked (gap 2). * strength_not_per_ml: a liquid's strength that isn't per ml ("120 mg"), so * its dose in ml couldn't be worked out (M1). * dose_unit_unknown: an adult's dose, or most a day, that can't be turned * into mg (spoons, drops, "1 dose" of a liquid, mg of a combination), for a * medicine whose dose would otherwise be checked (ruling A). */ export type CouldntCheckReason = | 'not_recognised' | 'contents_unknown' | 'no_rules' | 'partly_recognised' | 'strength_unreadable' | 'strength_not_per_ml' | 'dose_unit_unknown' | 'symptoms_not_screened' | 'symptoms_unclear' | 'course_ended' | 'age' | 'pregnancy' | 'condition' | 'lab' | 'allergy'; const MEDICINE_REASONS: ReadonlySet = new Set(['not_recognised', 'contents_unknown', 'no_rules', 'partly_recognised', 'strength_unreadable', 'strength_not_per_ml', 'dose_unit_unknown']); /** The medicine reasons about its dose only: the subline says "The dose of Calpol couldn't be checked." */ const DOSE_REASONS: ReadonlySet = new Set(['strength_unreadable', 'strength_not_per_ml', 'dose_unit_unknown']); const wholeWord = (phrase: string) => new RegExp(`(? { const classes = classesFor(key, { name: key }); return ALLERGY_RULES.filter((rule) => rule.matches.some((matcher) => matches(matcher, key, classes))); }; /** Which ingredient of an allergy brings a family (L8): the ibuprofen in "Nurofen Plus" for NSAIDs. */ const familyFrom = (keys: string[], rule: AllergyClassRule) => keys.find((key) => familiesOf(key).includes(rule)) ?? null; /** * Words of 6 or more letters from the allergy families' names: their terms, * and every catalogue name (generic or brand) of a medicine in a family. A * misspelled allergy is only ever corrected towards one of these (H-C, N9): * never "Lanolin" to Lanoxin or "Inulin" to insulin. */ const ALLERGY_WORDS: ReadonlySet = new Set([ ...ALLERGY_RULES.flatMap((rule) => rule.allergyTerms), ...INGREDIENTS.filter((ingredient) => familiesOf(ingredient.key).length) .flatMap((ingredient) => [ingredient.name, ...(ingredient.aliases ?? []), ingredient.key.replace(/-/g, ' ')]), ...PRODUCTS.filter((product) => product.ingredients.some((key) => familiesOf(key).length)).flatMap((product) => product.names), ].flatMap((name) => normalizeText(name).split(' ')).filter((word) => word.length >= 6 && /^[a-z]+$/.test(word))); /** Entries that mean "no allergies": "None", "Nil known", "No known drug allergy", "NKDA" (N15). */ const NO_ALLERGY = /^(?:none|nil|no|nkda|nka|n a|na|not applicable|(?:none|nil|no) known(?: drug| medicine| medication)?(?: allerg(?:y|ies))?|no(?: drug| medicine| medication)? allerg(?:y|ies))$/; /** Allergies that aren't to a medicine: listed as not checked, without making the whole check "couldn't check". */ const NOT_A_MEDICINE = /(? { const normalized = normalizeText(text); const ingredients = matchIngredients(text).ingredients; const classRules = ALLERGY_RULES.filter((rule) => rule.allergyTerms.some((term) => wholeWord(term).test(normalized)) || ingredients.some((key) => familiesOf(key).includes(rule))); return ingredients.length || classRules.length ? { ingredients, classRules } : null; }; const exact = attempt(allergy); if (exact) return { ...exact, readAs: null }; const corrected = correctSpelling(allergy, ALLERGY_WORDS); const read = corrected ? attempt(corrected) : null; return read ? { ...read, readAs: corrected } : null; } export interface CheckMedicine { id: string | null; name: string; strength: string | null; form: string | null; /** Doses per day, when known. */ timesPerDay: number | null; /** How much each dose is, counted in `doseUnit`. */ unitsPerDose: number; /** What unitsPerDose counts: tablets and the like (the default), ml, mg, or a measure we can't convert (spoons, drops). */ doseUnit?: 'unit' | 'ml' | 'mg' | 'unknown'; /** The dose is in spoons or drops: the medicine is a liquid, whatever its saved form (R1). */ liquidDose?: boolean; /** For "only when needed" medicines: the most a day (S100a). */ prnMaxPerDay: number | null; /** What prnMaxPerDay counts, as entered: "tablets", "doses", "ml". */ prnMaxUnit?: string | null; origin: 'prescribed' | 'over_the_counter' | 'herbal' | 'market' | 'hospital' | 'unknown' | null; /** false = "Not sure what's in it" (S33, S90). */ contentsKnown: boolean; } /** Stored with the finding, never refreshed from today's lab record. */ export interface LabEvidence { result: { id: string; code: string; name: string; value: number; valueText: string; comparator: '<' | '<=' | '>' | '>=' | null; unit: string | null; observedAt: string; referenceRange: { low: number | null; high: number | null; text: string | null }; labFlag: 'low' | 'high' | 'normal' | 'abnormal' | null; }; target: { code: string; name: string; low: number | null; high: number | null; unit: string | null; setBy: 'clinician' | 'self'; note: string | null; updatedAt: string } | null; } export interface CheckLab { evidence?: LabEvidence; code: string; value: number; observedAt: string; /** * Set when the result was printed as a bound (">8", "<15"): `value` is then * the bound. It counts as a recent test, and meets a threshold only when * every value it allows does (H3). */ comparator?: '<' | '<=' | '>' | '>=' | null; /** The unit as printed, when known: read only by the egfrFromCreatinine option (creatinine). */ unit?: string | null; } export interface CheckInput { subject: { kind: 'self' | 'person'; name: string | null; sex: string | null; /** The age to show: from the birth date or year when known (M2). */ age: number | null; /** * The youngest and oldest they could be today, when the exact birthday * isn't known ("Just the year": 17 or 18). Rules for children use the * youngest, rules for 65 and over the oldest. Defaults to `age` for both. */ ageRange?: { min: number; max: number } | null; /** From the health profile, when given. */ weightKg?: number | null; }; /** Medicines being taken now. A course that has ended is left out. */ medicines: CheckMedicine[]; /** Courses that ended in the last two weeks: not checked, but named in case they are still being taken. */ endedMedicines?: Array<{ id: string | null; name: string; endedOn: string }>; allergies: string[]; /** Preserve an explicit uncertain answer instead of equating an empty list with none. */ allergiesStatus?: 'listed' | 'none' | 'not_sure'; /** What each allergy was like, when recorded: read only by the allergyTiers option (CHK-6a). */ allergyDetails?: AllergyDetail[]; /** Listed under what_we_couldnt_check: no rule reads conditions yet (H5). */ conditions: string[]; conditionsStatus?: 'listed' | 'none' | 'not_sure'; /** Pregnancy, breastfeeding or planning a pregnancy, when recorded. No rule reads it yet (H5). */ reproductive?: 'pregnant' | 'breastfeeding' | 'planning_pregnancy' | null; labs: CheckLab[]; labTargets: Record; symptoms: string | null; emergency: EmergencyAdvice; language: string; today: string; } /** The Partial fields come only with an option on (options.ts, clinician-types.ts). */ export interface CheckFinding extends Partial { lab_evidence?: LabEvidence; id: string; kind: 'interaction' | 'allergy' | 'duplicate' | 'dose' | 'older_adult' | 'child' | 'lab'; tier: Tier; ruleId: string; medicines: Array<{ id: string | null; name: string }>; title: string; why: string; what_to_do: string[]; watch_for: string[]; numbers: null; evidence_label: string; source_ids: CheckSourceId[]; questions_for_doctor: string[]; allow_stop: boolean; clinical: string; teach_back: { question: string; options: string[]; answer_index: number } | null; } export interface CheckResult extends Partial { engineVersion: string; rulesVersion: string; /** True only when every rule is approved and nothing waits for a top-up review (signoff.ts). */ rulesReviewed: boolean; verdict: { tier: VerdictTier; headline: string; subline: string; keep_taking_line: string | null }; get_help_now: { show: boolean; signs: string[]; action: string; contact?: EmergencyAdvice }; findings: CheckFinding[]; tests_to_ask_about: Array<{ test: string; why: string; when: string; source_id: CheckSourceId; status: 'due' | 'up_to_date' | 'no_result'; last_result_at: string | null } & Partial>; dose_check: Array<{ medicine: string; medicine_id: string | null; status: 'within_usual_range' | 'above_limit' | 'check_with_pharmacist' | 'not_enough_info'; plain: string; /** kidneyDoseLimits only. */ kidney_limit?: KidneyLimitOutput }>; what_we_checked: { medicines: string[]; conditions: string[]; allergies: string[]; age: number | null; references: Array<{ id: CheckSourceId; name: string; url: string | null }> }; what_we_couldnt_check: Array<{ item: string; medicine_id: string | null; reason: CouldntCheckReason; plain: string }>; for_the_doctor: { summary: string; flags: Array<{ finding_id: string; reference: string; for_discussion: string }> }; language: string; language_fallback: boolean; wording: 'template' | 'model'; } const TIER_RANK: Record = { get_help_now: 4, today: 3, this_week: 2, good_to_know: 1 }; /** A lab result older than this is acted on the same day at most, and says how old it is (L-2). */ const DATED_RESULT_DAYS = 14; /** A lab result older than this isn't treated as current (L-2). */ const STALE_RESULT_DAYS = 90; /** Every matcher in the rule tables. */ const RULE_MATCHERS: Matcher[] = [ ...INTERACTION_RULES.flatMap((rule) => [rule.a, rule.b]), ...ALLERGY_RULES.flatMap((rule) => [...rule.matches, ...(rule.crossReactive ?? [])]), ...OLDER_ADULT_RULES.map((rule) => rule.match), ...CHILD_RULES.map((rule) => rule.match), ...MONITORING_RULES.map((rule) => rule.match), ...LAB_RULES.flatMap((rule) => [...rule.match, ...(rule.alsoNeeds ? [rule.alsoNeeds] : [])]), ]; /** Ingredients some rule can match, or with a dose maximum to check. Any other has had nothing checked (L1, H-F). */ const HAS_RULES = new Set(INGREDIENTS.filter((ingredient) => ingredient.maxDailyMg || RULE_MATCHERS.some((matcher) => matches(matcher, ingredient.key, ingredient.classes))).map((ingredient) => ingredient.key)); /** A medicine's ingredients no rule can match, apart from companions checked through their partner (caffeine, clavulanic acid). */ const unruledIngredients = (med: { ingredients: Array<{ key: string }> }) => med.ingredients.filter((ingredient) => !HAS_RULES.has(ingredient.key) && !INGREDIENT_BY_KEY.get(ingredient.key)?.companion); /** A medicine counts as checked when at least one ingredient that isn't just a companion has rules. */ const isChecked = (med: { ingredients: Array<{ key: string }> }) => med.ingredients.some((ingredient) => HAS_RULES.has(ingredient.key) && !INGREDIENT_BY_KEY.get(ingredient.key)?.companion); /** * A finding before its id and teach-back are worked out; idSeed makes the id * stable for the same medicines. noKeepTaking: the finding says to call before * the next dose and must not be paired with "keep taking" (the doctor or clinic * decides the next dose), so the verdict leaves its keep-taking line out. */ type NewFinding = Omit & { idSeed: string; noKeepTaking?: boolean; /** Within a day or two, sooner than its tier (N23). */ soon?: boolean; /** Medicines this finding says to stop or to call about first: no finding may say to keep taking them (H-E). Default: all of a call-first finding's medicines. */ holds?: string[]; /** What the finding was worked out from, for the options' clinician wording (clinician-check.ts). Never on the finding. */ facts?: Record; }; /** * "Keep taking" lines are written as markers while findings are built and * worded at the end (H-E), once it is known which medicines a finding says to * stop or to call about first: those are never named as ones to keep taking. */ const KEEP_ALL = '\u0000keep-all'; const keepLine = (names: string[], until: boolean) => `\u0000keep:${until ? 'until' : 'plain'}:${JSON.stringify(names)}`; /** A line left out when another finding says to stop every one of these medicines (N22). */ const unlessBothStopped = (names: string[], text: string) => `\u0000unless-stopped:${JSON.stringify(names)}\u0001${text}`; function joinNames(names: string[]): string { return names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`; } /** The youngest and oldest the person could be (H5, M2); null when no age is known. */ export function ageRangeOf(subject: CheckInput['subject']): { min: number; max: number } | null { if (subject.ageRange) return subject.ageRange; return subject.age === null || subject.age === undefined ? null : { min: subject.age, max: subject.age }; } const EVIDENCE_LABEL: Record = { studies: 'Shown in studies', few_reports: 'Reported in a few people', theoretical: 'Expected from how the medicines work', }; interface Resolved { medicine: CheckMedicine; /** Used on the skin, eye, ear, nose or vagina: only allergy and duplicate checks apply (LOW). */ local: boolean; /** Words of the name we couldn't place: maybe more ingredients (M-2). */ unmatchedWords: string[]; /** Product names the name matched ("night nurse"). */ products: string[]; ingredients: Array<{ key: string; classes: DrugClass[] }>; /** The strength is the pack's total for all its ingredients (Augmentin "625 mg"): no ingredient's own. */ packTotal: boolean; /** A strength was entered, but neither it nor the name could be read (gap 2): never "Add the strength", which is for one that is missing. */ strengthUnreadable: boolean; /** Where the strength used was written: the strength entered, or the medicine's name ("Calpol 120mg"). */ strengthFrom: 'strength' | 'name' | null; /** Each counted ingredient's dose (caffeine aside): a combination's are worked out one by one. */ doses: Map; /** The medicine's dose: its one ingredient's, or, for a combination, its first ingredient with a maximum (they share how often and how much). */ dose: Dose; } /** A strength as quoted back: on one line, cut short after 40 characters (whole characters, so never half an emoji). */ const quoted = (text: string) => { const characters = Array.from(text.trim().replace(/\s+/g, ' ')); return characters.length > 40 ? `${characters.slice(0, 40).join('').trimEnd()}...` : characters.join(''); }; /** A finding's title within its 70 characters: cut only after a whole word, and marked "...", never half a word or a number (R2). */ export function fitTitle(text: string, max = 70): string { if (text.length <= max) return text; const space = text.lastIndexOf(' ', max - 3); return `${(space > 0 ? text.slice(0, space) : text.slice(0, max - 3)).replace(/[\s,:;]+$/, '')}...`; } /** * A verdict's subline within 120 characters: whole sentences only (R2). A * first sentence longer than that is kept whole, never cut short: "The label * maximums for adults are 40" must never stand under the headline. */ export function fitSentences(text: string, max = 120): string { if (text.length <= max) return text; let end = 0; for (const match of text.matchAll(/[.!?](?=\s|$)/g)) { const at = match.index! + 1; if (at > max) return text.slice(0, end || at); end = at; } return end ? text.slice(0, end) : text; } function voice(input: CheckInput) { const self = input.subject.kind === 'self'; const name = input.subject.name ?? 'them'; const sex = (input.subject.sex ?? '').toLowerCase(); const pronoun = self ? 'your' : sex === 'female' ? 'her' : sex === 'male' ? 'his' : 'their'; return { self, name, possessive: pronoun, /** First mention: "Amina's" or "your". */ owner: self ? 'your' : `${name}'s`, object: self ? 'you' : sex === 'female' ? 'her' : sex === 'male' ? 'him' : 'them', }; } function fill(text: string, a: string, b = ''): string { return text.replace(/\{a\}/g, a).replace(/\{b\}/g, b); } function shortId(...parts: string[]): string { return createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 12); } function teachBack(tier: Tier, action: string, v: ReturnType, seed: string) { if (tier !== 'today' && tier !== 'this_week') return null; // Wrong answers that are harmless even if someone picks one (never "stop all her medicines"). const options = [action, 'Wait for the next check-up', 'I\'m not sure']; const rotate = Number.parseInt(seed.slice(0, 2), 16) % 3; const rotated = [...options.slice(rotate), ...options.slice(0, rotate)]; return { question: 'What is the next step?', options: rotated, answer_index: rotated.indexOf(action) }; } /** Over a maximum (N4): call before the next dose, never "keep taking" (111 also answers in the UK). */ const overMaximumAction = (v: ReturnType, input: CheckInput) => `Before the next dose, call ${v.possessive} pharmacist or doctor today${input.emergency.countryCode === 'GB' ? ', or 111' : ''}.`; function tierAction(tier: Tier, v: ReturnType, input: CheckInput): string { switch (tier) { case 'get_help_now': return emergencyAction(input.emergency); case 'today': return `Before the next dose, call ${v.possessive} doctor or pharmacist today.`; case 'this_week': return `Ask ${v.possessive} pharmacist or doctor about this in the next week.`; default: return `Mention this to ${v.possessive} pharmacist next time.`; } } /** * H2: stopping is advised only for a medicine the person said they bought over * the counter (origin over_the_counter; null and unknown don't count, whatever * the catalogue says), and never for aspirin at an antiplatelet dose or an * unknown dose: that may be a planned heart or stroke medicine. */ function mayStop(med: Resolved): boolean { if (med.medicine.origin !== 'over_the_counter') return false; const aspirin = med.ingredients.find((ingredient) => ingredient.key === 'aspirin'); return !aspirin || (aspirin.classes.includes('nsaid') && !aspirin.classes.includes('aspirin_dose_unknown')); } const usable = (reading: StrengthReading) => reading.kind === 'read' || reading.kind === 'total'; function resolve(medicine: CheckMedicine): Resolved { const matched = medicine.contentsKnown ? matchIngredients(medicine.name) : { ingredients: [] as string[], unmatchedWords: [] as string[], matchedNames: [] as string[] }; // The strength entered, else one in the name ("Co-codamol 30/500"), as before: one medicine's, or each of a combination's (amounts.ts). const entered = readStrength(medicine.strength, matched); const reading = usable(entered) ? entered : readStrength(medicine.name, matched); const strengths = new Map(reading.kind === 'read' ? Object.entries(reading.mg).map(([key, mg]) => [key, { per: reading.per, mg }]) : []); const strengthUnreadable = entered.kind === 'unreadable' && !usable(reading); const local = isLocal(medicine.form, medicine.name); const topical = local || isPatchOrSpray(medicine.form, medicine.name); // Each ingredient that counts towards a dose (caffeine aside), from its own strength. const dosed = matched.ingredients.filter((key) => key !== 'caffeine'); const how = { combination: dosed.length > 1, unreadable: strengthUnreadable }; const doses = new Map(dosed.map((key) => [key, topical ? TOPICAL : doseOf(medicine, strengths.get(key) ?? null, how)])); const first = dosed.find((key) => INGREDIENT_BY_KEY.get(key)?.maxDailyMg) ?? dosed[0]; const dose = topical ? TOPICAL : first ? doses.get(first)! : doseOf(medicine, null, how); // Aspirin's dose decides whether it is an antiplatelet or also an NSAID: its own // amount, so a strength that belongs to another ingredient of a combination never counts. const aspirinDose = doses.get('aspirin'); const aspirinStrength = strengths.get('aspirin'); const aspirinMg = aspirinDose ? aspirinDose.perDayMg ?? aspirinDose.perDoseMg ?? (aspirinStrength?.per === 'unit' ? aspirinStrength.mg : null) : null; return { medicine, local, unmatchedWords: matched.unmatchedWords, products: matched.matchedNames, packTotal: reading.kind === 'total', strengthUnreadable, strengthFrom: usable(entered) ? 'strength' : usable(reading) ? 'name' : null, doses, dose, ingredients: matched.ingredients.map((key) => ({ key, classes: classesFor(key, { name: medicine.name, topical, aspirinMg }) })), }; } const RED_FLAG_PATTERNS = RED_FLAGS.map((flag) => ({ ...flag, regexes: flag.patterns.map((pattern) => new RegExp(`(? /^en(-|$)/i.test(language ?? 'en'); /** Every word of every medicine name we know: they count as readable in symptom text. */ const MEDICINE_WORDS: ReadonlySet = new Set([ ...INGREDIENTS.flatMap((ingredient) => [ingredient.name, ...(ingredient.aliases ?? [])]), ...PRODUCTS.flatMap((product) => product.names), ].flatMap((name) => normalizeText(name).split(/[^a-z]+/)).filter((word) => word.length >= 2)); export interface SymptomScreen { /** RED_FLAGS ids found, in table order. */ ids: string[]; /** Their plain signs, for get_help_now.signs. */ signs: string[]; /** false when the text could not be fully screened: not in English (by the language given, or by its words), or no Latin letters at all. */ screened: boolean; /** * H-1.7: a word about dying, death, killing or suicide that is neither a sign nor never a sign, with no other sign: no label, * but the emergency line ("couldn't be sure" on the check, the banner in Ask). */ unsure: boolean; } /** * RS6: emergency signs in what someone wrote (check symptoms, an Ask question). * Only English and common Pidgin forms are recognised, so anything else is * reported as not screened and the caller always shows the emergency number. * J1: paracetamol over the most in 24 hours is too much medicine, however it * is scheduled ("took 10 paracetamol a day for a week"). Ask answers a question * about doing it itself (`paracetamolQuestions: false`); in symptom text it is * a sign, so the check never says "No problems found" next to it. M2: the * words of intent Ask uses (intent.ts) are signs here too: self-harm, too much * medicine, or (M-1) feeling like dying. */ export function screenSymptoms(text: string | null | undefined, language: string | null | undefined = 'en', options: { paracetamolQuestions?: boolean } = {}): SymptomScreen { const raw = (text ?? '').trim(); if (!raw) return { ids: [], signs: [], screened: true, unsure: false }; const normalized = normalizeText(raw); const paracetamol = paracetamolOverLimit(raw); const tooMuchParacetamol = paracetamol === 'taken' || (paracetamol === 'asked' && options.paracetamolQuestions !== false); // As written: the intent list splits it into clauses before normalizing (H-1.2). const intent = intentIn(raw); const found = RED_FLAG_PATTERNS.filter((flag) => (flag.id === 'overdose' && (tooMuchParacetamol || intent.tooMuch)) || (flag.id === 'self-harm' && intent.selfHarm) || (flag.id === 'dying' && intent.dying) || flag.regexes.some((regex) => regex.test(normalized))); // LOW: the Hausa, Yoruba and Igbo words of intent the list reads ("Mo fe ku") count as read, so a text of only those was screened. const unread = withoutWordsOfIntentRead(normalized); const screened = /[a-z]/.test(normalized) && isEnglish(language) && (!/[a-z]/.test(unread) || readsAsEnglish(unread, MEDICINE_WORDS)); return { ids: found.map((flag) => flag.id), signs: found.map((flag) => flag.sign), screened, unsure: intent.unsure && !found.length }; } /** "Call 112 now, ..." as it reads mid-sentence. */ const midSentence = (sentence: string) => sentence.charAt(0).toLowerCase() + sentence.slice(1); /** * Whether a result is past a threshold (H3). An exact value is a point; one * printed as a bound is the range it allows (">8": above 8). Past it only * when the whole range is; not past it when none of it is; otherwise we * can't tell, and the caller says so. */ export function crosses(lab: Pick, direction: LabBound['direction'], threshold: number, inclusive: boolean): 'yes' | 'no' | 'unknown' { let low = lab.value; let high = lab.value; let lowOpen = false; let highOpen = false; if (lab.comparator === '>' || lab.comparator === '>=') { high = Infinity; lowOpen = lab.comparator === '>'; } if (lab.comparator === '<' || lab.comparator === '<=') { low = -Infinity; highOpen = lab.comparator === '<'; } if (direction === 'above') { const yes = inclusive ? low >= threshold : low > threshold || (low === threshold && lowOpen); const no = inclusive ? high < threshold || (high === threshold && highOpen) : high <= threshold; return yes ? 'yes' : no ? 'no' : 'unknown'; } const yes = inclusive ? high <= threshold : high < threshold || (high === threshold && highOpen); const no = inclusive ? low > threshold || (low === threshold && lowOpen) : low >= threshold; return yes ? 'yes' : no ? 'no' : 'unknown'; } /** Ingredients of a medicine a rule's matcher picks out. A local medicine (cream, eye drops) only counts for allergies. */ function hits(med: Resolved, matcher: Matcher, options: { allergy?: boolean } = {}) { if (med.local && !options.allergy) return []; if (matcher.product) return med.products.includes(normalizeText(matcher.product)) ? med.ingredients : []; return med.ingredients.filter((ingredient) => matches(matcher, ingredient.key, ingredient.classes)); } /** Ingredients that count towards a dose. Caffeine in "extra" painkillers has no limit here. */ function dosedIngredients(med: Resolved) { return med.ingredients.filter((ingredient) => ingredient.key !== 'caffeine'); } /** An ingredient's mg in 24 hours from this medicine: a combination's from that ingredient's own amount ("30/500" is codeine 30 mg, paracetamol 500 mg). */ function dailyMg(med: Resolved, key: string): number | null { return med.doses.get(key)?.perDayMg ?? null; } /** The ingredients of a medicine whose doses are checked: those with a label maximum. */ const limitedIngredients = (med: Resolved) => dosedIngredients(med).filter((ingredient) => INGREDIENT_BY_KEY.get(ingredient.key)?.maxDailyMg); /** Where an ingredient's maximum comes from (M3): its own source, the NSAID labels for an NSAID, else its own product label; never another medicine's. */ function maximumSources(key: string): CheckSourceId[] { const ingredient = INGREDIENT_BY_KEY.get(key); return ingredient?.maxSources ?? (ingredient?.classes.includes('nsaid') ? ['label-nsaid'] : ['label-product']); } /** Signs of too much of an opioid (codeine, dihydrocodeine, tramadol), in the children's rules' words (M3). */ const OPIOID_SIGNS = ['very sleepy or hard to wake', 'slow or shallow breathing']; const PARACETAMOL_SIGNS = ['feeling sick or being sick', 'pain under the right ribs']; /** What to watch for with too much of these ingredients, at most 3: an opioid's breathing signs first, then paracetamol's. */ const tooMuchSigns = (keys: string[]) => [...(keys.some((key) => INGREDIENT_BY_KEY.get(key)?.classes.includes('opioid')) ? OPIOID_SIGNS : []), ...(keys.includes('paracetamol') ? PARACETAMOL_SIGNS : [])].slice(0, 3); /** With no option on (the app), exactly the default check. Options are for MARMAR Context only (options.ts). */ export function runCheck(input: CheckInput, options?: CheckOptions): CheckResult { if (!hasOptions(options)) return analyse(input).result; return checkWithOptions(input, options, analyse); } /** * Medicines some finding says to stop, or to call about before the next dose: * nothing may say to keep taking them. The writer's guard needs them (N1). */ export function heldMedicines(input: CheckInput): string[] { return analyse(input).held; } /** hooks and trace come only with an option on (clinician-check.ts); without them this is the default check. */ function analyse(input: CheckInput, hooks?: EngineHooks, trace?: EngineTrace): { result: CheckResult; held: string[] } { const v = voice(input); const resolved = input.medicines.map(resolve); const findings: CheckFinding[] = []; const usedSources = new Set(); const noKeepTaking = new Set(); /** Medicines a finding says to stop, or to call about before the next dose. */ const held = new Set(); /** Medicines a finding says to stop. */ const stopped = new Set(); /** Findings to act on within a day or two. */ const soonFindings = new Set(); const push = (finding: NewFinding) => { const id = shortId(finding.ruleId, finding.idSeed); if (findings.some((existing) => existing.id === id)) return; if (hooks?.allow && !hooks.allow(finding.ruleId)) { trace?.skipped.push({ ruleId: finding.ruleId, medicines: finding.medicines }); return; } finding.source_ids.forEach((source) => usedSources.add(source)); const { idSeed, noKeepTaking: callFirst, holds, soon, facts, ...rest } = finding; void idSeed; if (facts) trace?.facts.set(id, facts); if (soon) soonFindings.add(id); if (callFirst) noKeepTaking.add(id); for (const name of holds ?? (callFirst ? rest.medicines.map((medicine) => medicine.name) : [])) held.add(name); if (rest.allow_stop) for (const name of holds ?? []) stopped.add(name); findings.push({ ...rest, id, numbers: null, teach_back: teachBack(rest.tier, rest.what_to_do[0] ?? tierAction(rest.tier, v, input), v, id) }); }; const ageRange = ageRangeOf(input.subject); // Adult dose limits only apply when they can't be under 18 (H5). const adultLimits = ageRange !== null && ageRange.min >= 18; const keepTaking = KEEP_ALL; // Emergency signs (RS6) run on symptom text and override everything. const screen = screenSymptoms(input.symptoms, input.language); const signs = screen.signs; /** Signs from findings that need help now (a very high potassium), for the help card. */ const helpSigns: string[] = []; // Interactions between two different medicines: one finding per pair, from // the most urgent rule that applies (ties go to the earlier rule in the table). const pairs = new Map(); for (let i = 0; i < resolved.length; i++) { for (let j = 0; j < resolved.length; j++) { if (i === j) continue; const first = resolved[i]; const second = resolved[j]; INTERACTION_RULES.forEach((rule, order) => { const aHits = hits(first, rule.a); const bHits = hits(second, rule.b); if (!aHits.length || !bHits.length) return; // A dose-dependent rule: skipped only when side a's daily dose is known and within the limit. if (rule.aOverMgPerDay !== undefined) { const limit = rule.aOverMgPerDay; if (aHits.every((hit) => { const mg = dailyMg(first, hit.key); return mg !== null && mg <= limit; })) return; } // A symmetric rule (same matcher both sides) is reported once, for i < j. if (JSON.stringify(rule.a) === JSON.stringify(rule.b) && i > j) return; // signedOffOnly: a rule that isn't signed off leaves the pair to the next rule that is. if (hooks?.allow && !hooks.allow(rule.id)) { trace?.skipped.push({ ruleId: rule.id, medicines: [first, second].map((med) => ({ id: med.medicine.id, name: med.medicine.name })) }); return; } const a = first.medicine.name; const b = second.medicine.name; // H2: which medicine, if any, may be stopped. Never chosen by list order. let stop: Resolved | null = null; if (rule.allowStop === 'a' || rule.allowStop === 'b') { const side = rule.allowStop === 'a' ? first : second; if (mayStop(side)) stop = side; } else if (rule.allowStop === 'either') { const firstOtc = first.medicine.origin === 'over_the_counter'; const secondOtc = second.medicine.origin === 'over_the_counter'; const side = firstOtc && !secondOtc ? first : secondOtc && !firstOtc ? second : null; if (side && mayStop(side)) stop = side; } // "Stop the Ibuprofen" could be read as stopping a prescribed Brufen too: never stop while // another listed medicine that wasn't bought over the counter has the same ingredient (H-A). // Then every medicine with that ingredient is held: none is "keep taking" next to the blood thinner (N6). const chosen = stop; const sharing = chosen ? resolved.filter((other) => other !== chosen && other.medicine.origin !== 'over_the_counter' && other.ingredients.some((ingredient) => chosen.ingredients.some((own) => own.key === ingredient.key))) : []; if (sharing.length) stop = null; const keepName = stop === first ? b : a; // M-3, N5: aspirin with a blood thinner. Only a prescribed one is "keep taking both"; otherwise, at any // dose, call today before the next aspirin (NHS: no aspirin with warfarin unless a doctor says so). const aspirinPrescribed = second.medicine.origin === 'prescribed' || second.medicine.origin === 'hospital'; const isAspirin = second.ingredients.some((ingredient) => ingredient.key === 'aspirin'); const aspirinCall = !aspirinPrescribed && (Boolean(rule.keepBoth) || (Boolean(rule.unprescribedAspirinToday) && isAspirin)); const tier: Tier = aspirinCall ? 'today' : rule.tier; const whatToDo = stop ? [`Stop the ${stop.medicine.name}. It was bought without a prescription, and the NHS advises against taking it with ${keepName}.`, `Ask ${v.possessive} pharmacist which painkiller to use instead.`, keepLine([keepName], false)] : aspirinCall // N5: a medicine saved before V2.2 has no origin, so a prescribed aspirin (after a stent) may read as unprescribed. ? [`Call ${v.possessive} doctor or pharmacist today, before the next ${b}.`, 'If a doctor prescribed it, for example after a stent, don\'t miss it before you\'ve spoken to them.', keepLine([a], true)] : rule.keepBoth ? [tierAction(tier, v, input), keepLine([a, b], true)] : tier === 'good_to_know' && rule.advice ? [fill(rule.advice, a, b), `Ask ${v.possessive} pharmacist if you're not sure how to space them.`] // N22: "which one to keep" is left out when another finding says to stop both. : [tierAction(tier, v, input), ...(rule.advice ? [fill(rule.advice, a, b)] : []), ...(rule.noStopAction ? [unlessBothStopped([a, b], fill(rule.noStopAction, a, b))] : []), ...(tier === 'good_to_know' ? [] : [keepTaking])]; const holds = stop ? [stop.medicine.name] : aspirinCall ? [b] : sharing.length && chosen ? [chosen.medicine.name, ...sharing.map((other) => other.medicine.name)] : undefined; const title = tier === 'today' ? `Call ${v.possessive} doctor today about ${a} and ${b}` : tier === 'this_week' ? `Ask about ${a} with ${b} this week` : `${a} with ${b}: good to know`; const finding: NewFinding = { kind: 'interaction', tier, ruleId: rule.id, idSeed: [first.medicine.id ?? a, second.medicine.id ?? b].sort().join('+'), medicines: [{ id: first.medicine.id, name: a }, { id: second.medicine.id, name: b }], title: fitTitle(title), why: fill(rule.why, a, b), what_to_do: whatToDo, watch_for: rule.watchFor.slice(0, 3), evidence_label: EVIDENCE_LABEL[rule.evidence], source_ids: rule.sources, questions_for_doctor: [fill(rule.question, a, b)], allow_stop: Boolean(stop), noKeepTaking: Boolean(holds), holds, clinical: `${a} + ${b}: ${rule.clinical}` }; const key = i < j ? `${i}:${j}` : `${j}:${i}`; const best = pairs.get(key); const rank = TIER_RANK[tier]; if (!best || rank > best.rank || (rank === best.rank && order < best.order)) pairs.set(key, { rank, order, finding }); }); } } for (const { finding } of pairs.values()) push(finding); // Allergies (RS4: an allergy match is always red). One we can't read is said, never treated as checked (H-C). const checkedAllergies: string[] = []; const allergyNotes: CheckResult['what_we_couldnt_check'] = []; const allergyGaps = new Set(); for (const allergy of input.allergies) { const said = normalizeText(allergy); if (!said || NO_ALLERGY.test(said)) continue; const read = readAllergy(allergy); if (!read) { const item = `${allergy} (allergy)`; if (NOT_A_MEDICINE.test(said)) { allergyNotes.push({ item, medicine_id: null, reason: 'allergy', plain: `We only check allergies to medicines, so ${allergy} wasn't checked. Ask ${v.possessive} pharmacist whether any of ${v.possessive} medicines contain it.` }); } else { allergyGaps.add(item); allergyNotes.push({ item, medicine_id: null, reason: 'allergy', plain: `We couldn't match the allergy "${allergy}" to a medicine we know, so ${v.possessive} medicines weren't checked against it. Show it to ${v.possessive} pharmacist.` }); } continue; } checkedAllergies.push(allergy); const directIngredients = read.ingredients; const classRules = read.classRules; // N9: a corrected spelling is said, so a wrong reading can be spotted. const readAs = read.readAs ? ` We read "${allergy}" as ${read.readAs}.` : ''; // L8: an allergy to a combination ("Nurofen Plus", "Panadol Extra") says which of its ingredients a finding goes by. const combination = directIngredients.length > 1; const who = v.self ? 'you are' : `${v.name} is`; const allergyWhy = (name: string, direct: string | null, family: AllergyClassRule | null) => { const from = family ? familyFrom(directIngredients, family) : null; if (combination && direct) return `${name} has ${ingredientName(direct).toLowerCase()} in it, like ${allergy}, which ${who} allergic to.`; if (combination && from) return `${name} is in the same family as the ${ingredientName(from).toLowerCase()} in ${allergy}, which ${who} allergic to.`; if (direct || family) return `${name} is in the same family as something ${who} allergic to (${allergy}).`; // A cross-reaction. Pharmacist to review: call-first because how severe the allergy was isn't recorded (ALLERGY_RULES notes). return crossTier === 'today' ? `${name} is related to ${allergy}. A few people with this allergy react to it too.` : `${name} is related to ${allergy}. A reaction is unlikely but possible.`; }; let crossTier: Tier = 'this_week'; for (const med of resolved) { const direct = med.ingredients.find((ingredient) => directIngredients.includes(ingredient.key)); const classMatch = classRules.find((rule) => rule.matches.some((matcher) => hits(med, matcher, { allergy: true }).length)); const cross = classMatch ? null : classRules.find((rule) => rule.crossReactive?.some((matcher) => hits(med, matcher, { allergy: true }).length)); if (!direct && !classMatch && !cross) continue; const ruleTier: Tier = direct || classMatch ? 'today' : cross?.crossReactiveTier ?? 'this_week'; crossTier = ruleTier; const matchKind = direct ? 'ingredient' : classMatch ? 'class' : 'cross'; // allergyTiers (CHK-6a): the recorded reaction can make it urgent. Urgent keeps the call-first wording. const tier = hooks?.allergyTier ? hooks.allergyTier({ allergy, matchKind, tier: ruleTier }) : ruleTier; const callFirst = TIER_RANK[tier] >= TIER_RANK.today; const sources: CheckSourceId[] = classMatch?.sources ?? cross?.sources ?? ['nhs-anaphylaxis']; const matched = direct ? [direct.key] : med.ingredients.filter((ingredient) => (classMatch?.matches ?? cross?.crossReactive ?? []) .some((matcher) => matches(matcher, ingredient.key, ingredient.classes))).map((ingredient) => ingredient.key); push({ kind: 'allergy', tier, ruleId: classMatch?.id ?? cross?.id ?? 'allergy-ingredient', idSeed: `${med.medicine.id ?? med.medicine.name}|${allergy}`, medicines: [{ id: med.medicine.id, name: med.medicine.name }], title: fitTitle(callFirst ? `Call today: ${med.medicine.name} and ${v.possessive} ${allergy} allergy` : `${med.medicine.name} and ${v.possessive} ${allergy} allergy`), why: `${allergyWhy(med.medicine.name, direct?.key ?? null, classMatch ?? null)}${readAs}`, // Call before the next dose; never "don't take" a medicine that may be prescribed, and never "keep taking" beside it. what_to_do: callFirst ? [`Before the next dose, call ${v.possessive} doctor or pharmacist today about the ${allergy} allergy.`] : [tierAction('this_week', v, input), keepTaking], noKeepTaking: callFirst, facts: { allergy, matchKind, ingredients: matched.map(ingredientName), ingredientKeys: med.ingredients.map((ingredient) => ingredient.key), ruleTier, readAs: read.readAs }, watch_for: ['swelling of the lips, face or throat', 'trouble breathing', 'a rash with feeling faint'], evidence_label: EVIDENCE_LABEL.studies, source_ids: sources, questions_for_doctor: [`Is ${med.medicine.name} OK with ${v.possessive} ${allergy} allergy?`], allow_stop: false, clinical: `${med.medicine.name} with documented ${allergy} allergy (${direct || classMatch ? 'same class' : 'possible cross-reactivity'}).` }); } } // The same ingredient in more than one product, and daily totals (RS5). A // combination counts with its own amount of it: co-codamol's paracetamol with Panadol's. /** Products over an ingredient's limit only together, by ingredient: their dose lines say so (N18). */ const overTogether = new Map>(); const byIngredient = new Map(); for (const med of resolved) for (const ingredient of med.ingredients) { byIngredient.set(ingredient.key, [...(byIngredient.get(ingredient.key) ?? []), med]); } for (const [key, meds] of byIngredient) { if (meds.length < 2 || key === 'caffeine') continue; const name = ingredientName(key); const totals = meds.map((med) => dailyMg(med, key)); const known = totals.every((total) => total !== null); const sum = known ? (totals as number[]).reduce((acc, value) => acc + value, 0) : null; const max = INGREDIENT_BY_KEY.get(key)?.maxDailyMg ?? null; const over = sum !== null && max !== null && sum > max; // With a combination among them, each dose line names the ingredient: "about 5000 mg of paracetamol a day in total". const combination = meds.some((med) => dosedIngredients(med).length > 1); if (over) { for (const med of meds) { const byKey = overTogether.get(med) ?? new Map(); byKey.set(key, { sum: sum!, max: max!, others: meds.filter((other) => other !== med).map((other) => other.medicine.name), combination }); overTogether.set(med, byKey); } } // Over the limit is same-day (RS5). Paracetamol twice with a total we can't work out is too, // and so is paracetamol twice for a child or someone of unknown age: the adult limit doesn't apply (H5). const tier: Tier = over || ((sum === null || !adultLimits) && key === 'paracetamol') ? 'today' : 'this_week'; const list = meds.map((med) => med.medicine.name).join(' and '); push({ kind: 'duplicate', tier, ruleId: `duplicate-${key}`, idSeed: meds.map((med) => med.medicine.id ?? med.medicine.name).sort().join('+'), medicines: meds.map((med) => ({ id: med.medicine.id, name: med.medicine.name })), title: fitTitle(`Two medicines with ${name.toLowerCase()}`), why: over && max !== null ? `${list} both contain ${name.toLowerCase()}. Together that's about ${Math.round(sum!)} mg a day, more than the ${max} mg limit for adults.` : `${list} both contain ${name.toLowerCase()}, so it's easy to take too much.`, what_to_do: over ? [overMaximumAction(v, input), 'Check the labels for the same medicine name before taking both.'] : [tierAction(tier, v, input), 'Check the labels for the same medicine name before taking both.'], watch_for: tooMuchSigns([key]), evidence_label: EVIDENCE_LABEL.studies, source_ids: key === 'paracetamol' ? ['nhs-paracetamol', 'label-paracetamol'] : maximumSources(key), questions_for_doctor: [`Should ${v.self ? 'I' : v.name} take both ${list}?`], allow_stop: false, // N4: over the maximum, no finding says to keep taking any of them. noKeepTaking: over || (tier === 'today' && key === 'paracetamol'), facts: { ingredient: name, total: sum, max }, clinical: `Duplicate ingredient ${name}${sum !== null ? `, estimated ${Math.round(sum)} mg/24 h${max ? ` (adult max ${max})` : ''}` : ''}.` }); } // Paracetamol near the usual adult maximum after 75, or under 50 kg: worth asking about a lower one (pharmacist note). const paracetamol = resolved.filter((med) => med.ingredients.some((ingredient) => ingredient.key === 'paracetamol')); const paracetamolDaily = paracetamol.map((med) => dailyMg(med, 'paracetamol')); const paracetamolTotal = paracetamol.length && paracetamolDaily.every((mg) => mg !== null) ? (paracetamolDaily as number[]).reduce((sum, mg) => sum + mg, 0) : null; const light = input.subject.weightKg !== null && input.subject.weightKg !== undefined && input.subject.weightKg < 50; const over75 = ageRange !== null && ageRange.max >= 75; if (trace) trace.weightReadBy = paracetamol.map((med) => ({ id: med.medicine.id, name: med.medicine.name })); if (paracetamolTotal !== null && paracetamolTotal > 3000 && paracetamolTotal <= 4000 && (light || over75)) { push({ kind: 'dose', tier: 'good_to_know', ruleId: 'paracetamol-lower-maximum', idSeed: paracetamol.map((med) => med.medicine.id ?? med.medicine.name).sort().join('+'), medicines: paracetamol.map((med) => ({ id: med.medicine.id, name: med.medicine.name })), title: 'Paracetamol: ask about a lower daily maximum', why: `About ${Math.round(paracetamolTotal)} mg of paracetamol a day is near the usual adult maximum. ${light ? 'Under 50 kg' : 'After 75'}, a lower maximum is often advised.`, what_to_do: [`Ask ${v.possessive} pharmacist whether a lower daily maximum would be better.`], watch_for: [], evidence_label: EVIDENCE_LABEL.studies, source_ids: ['nhs-paracetamol', 'label-paracetamol'], questions_for_doctor: ['Should I have a lower daily maximum of paracetamol?'], allow_stop: false, facts: { total: paracetamolTotal, reason: light ? 'weight_under_50_kg' : 'age_75_or_over', weightKg: input.subject.weightKg ?? null }, clinical: `Paracetamol about ${Math.round(paracetamolTotal)} mg/24 h with ${light ? 'body weight < 50 kg' : 'age 75 or over'}: consider a lower maximum (e.g. 3 g/day).` }); } // Doses against the label maximum. The label limits are for adults: for a // child, or someone of unknown age, a dose is never "within the usual range" (H5). const doseCheck: CheckResult['dose_check'] = []; const ageBlock = !ageRange ? 'Not checked: we need an age to check doses.' : !adultLimits ? 'Not checked: adult dose limits don\'t apply to children.' : null; for (const med of resolved) { const dosed = dosedIngredients(med); if (!dosed.length) continue; const line = (status: CheckResult['dose_check'][number]['status'], plain: string, kidney?: KidneyLimitOutput) => doseCheck.push({ medicine: med.medicine.name, medicine_id: med.medicine.id, status, plain, ...(kidney ? { kidney_limit: kidney } : {}) }); if (med.dose.problem === 'topical') { line('not_enough_info', DOSE_PROBLEM_PLAIN.topical); continue; } // Nothing in it has a label maximum, or its strength is the pack's total for all its medicines (Augmentin "625 mg"). const limited = limitedIngredients(med); if (!limited.length || med.packTotal) { line('not_enough_info', ageBlock ?? 'We can\'t check this dose automatically.'); continue; } // A combination is checked like any other medicine, one ingredient at a time, and its lines name the ingredient. const combination = dosed.length > 1; const parts = limited.map((ingredient) => ({ key: ingredient.key, ingredient: INGREDIENT_BY_KEY.get(ingredient.key)!, total: dailyMg(med, ingredient.key) })); const unknown = parts.find((part) => part.total === null); // kidneyDoseLimits (CHK-11): the label's kidney-adjusted maximum, for adults. const kidneyPart = hooks?.kidneyDose && adultLimits ? parts.find((part) => part.key === 'metformin') : undefined; const kidney = kidneyPart ? hooks!.kidneyDose!({ key: kidneyPart.key, total: kidneyPart.total, medicine: med.medicine.name }) : null; if (unknown) { if (kidney?.contraindicated) line(kidney.status, kidney.plain, kidney.kidney_limit); else line('not_enough_info', ageBlock ?? DOSE_PROBLEM_PLAIN[med.doses.get(unknown.key)?.problem ?? 'no_strength']); continue; } // The lower over-the-counter ceiling applies unless the medicine was prescribed (H2: never assume where it came from). const prescribed = med.medicine.origin === 'prescribed' || med.medicine.origin === 'hospital'; const over: string[] = []; const capped: string[] = []; /** Each ingredient over its own maximum: one same-day finding for the medicine names them all (pharmacist question 2). */ const tooMuch: Array<{ key: string; name: string; total: number; max: number }> = []; for (const { key, ingredient, total: known } of parts) { const total = known!; const maxDailyMg = ingredient.maxDailyMg!; const name = ingredient.name.toLowerCase(); const of = combination ? ` of ${name}` : ''; const together = overTogether.get(med)?.get(key); const otcCap = ingredient.otcMaxDailyMg && !prescribed ? ingredient.otcMaxDailyMg : null; if (total > maxDailyMg) { // More than the adult maximum is too much at any age, so this is reported whatever the age. over.push(`About ${Math.round(total)} mg${of} a day is more than the label's ${maxDailyMg} mg for adults.`); tooMuch.push({ key, name, total, max: maxDailyMg }); } else if (together) { const ofAll = combination || together.combination ? ` of ${name}` : ''; over.push(`With ${joinNames(together.others)}, about ${Math.round(together.sum)} mg${ofAll} a day in total, more than the label's ${together.max} mg for adults.`); } else if (otcCap && total > otcCap) { capped.push(`More than the ${otcCap} mg${of} a day allowed without a prescription.`); } } if (tooMuch.length) { // N4: more than the label maximum is same-day for every medicine, held until a pharmacist or doctor has been asked. One // finding for the medicine, however many of its ingredients are over: one regimen, one call before the next dose. const amounts = joinNames(tooMuch.map((part) => `${Math.round(part.total)} mg${combination ? ` of ${part.name}` : ''}`)); const keys = tooMuch.map((part) => part.key); const maximums = `label maximum${tooMuch.length > 1 ? 's' : ''}`; const named = combination ? `${med.medicine.name}: more ${joinNames(tooMuch.map((part) => part.name))} than the ${maximums}` : `${med.medicine.name}: more than the ${maximums}`; push({ kind: 'dose', tier: 'today', ruleId: `dose-max-${keys.join('+')}`, idSeed: med.medicine.id ?? med.medicine.name, medicines: [{ id: med.medicine.id, name: med.medicine.name }], // Too long ("Tylenol with codeine: more paracetamol and codeine than..."): the medicine and the maximums, in fewer words (R2). title: fitTitle(named.length <= 70 ? named : `${med.medicine.name}: over the ${maximums}`), why: tooMuch.length > 1 ? `As entered, that's about ${amounts} a day. The label maximums for adults are ${joinNames(tooMuch.map((part) => `${part.max} mg`))}.` : `As entered, that's about ${amounts} a day. The label maximum for adults is ${tooMuch[0].max} mg in 24 hours.`, what_to_do: [overMaximumAction(v, input), // Over both maximums, the three signs leave out paracetamol's liver sign: no signs must never read as safe (re-review, item b). ...(keys.length > 1 && keys.includes('paracetamol') ? [`Too much paracetamol can harm the liver before any signs appear, so make this call today even if ${ v.self ? 'you feel' : v.object === 'her' ? 'she feels' : v.object === 'him' ? 'he feels' : 'they feel'} well.`] : []), 'Check the dose on the label or prescription in case it was entered wrongly.'], noKeepTaking: true, watch_for: tooMuchSigns(keys), evidence_label: EVIDENCE_LABEL.studies, source_ids: [...new Set(keys.flatMap(maximumSources))], questions_for_doctor: [combination ? `Is ${amounts} a day from ${med.medicine.name} right?` : `Is ${amounts} a day of ${med.medicine.name} right?`], allow_stop: false, facts: { parts: tooMuch.map((part) => ({ ingredient: part.name, total: part.total, max: part.max })) }, clinical: `${med.medicine.name}: ${tooMuch.map((part) => `${combination ? `${part.name} ` : ''}${Math.round(part.total)} mg/24 h exceeds adult label max ${part.max} mg`).join('; ')}.` }); } if (over.length) line('above_limit', kidney && kidney.status !== 'within_usual_range' ? `${over.join(' ')} ${kidney.plain}` : over.join(' '), kidney?.kidney_limit); else if (kidney) line(kidney.status, kidney.plain, kidney.kidney_limit); else if (ageBlock) line('not_enough_info', ageBlock); else if (capped.length) { line('check_with_pharmacist', med.medicine.origin === 'over_the_counter' ? capped.join(' ') : `${capped.join(' ')} If a doctor didn't prescribe this dose, ask ${v.possessive} pharmacist.`); } else { // This only compares entered amounts with catalogue maxima. It does not // inspect the person's label, establish a minimum, or approve their dose. // Keep the status token for existing clients; state the narrower evidence. const qualification = 'This does not confirm the right dose to take.'; line('within_usual_range', limited.length === dosed.length ? `The daily amount entered does not exceed the general adult maximum checked here. ${qualification}` : `The daily amount${limited.length > 1 ? 's' : ''} of ${joinNames(limited.map((ingredient) => ingredientName(ingredient.key).toLowerCase()))} entered ${limited.length > 1 ? 'do' : 'does'} not exceed the general adult maximum${limited.length > 1 ? 's' : ''} checked here. Other ingredients were not fully checked. ${qualification}`); } } // Older adults (RS8): worth reviewing, never stop. "Just the year": anyone who reaches 65 this year counts (M2). if (ageRange && ageRange.max >= 65) { for (const med of resolved) for (const rule of OLDER_ADULT_RULES) { if (!hits(med, rule.match).length) continue; push({ kind: 'older_adult', tier: rule.tier, ruleId: rule.id, idSeed: med.medicine.id ?? med.medicine.name, medicines: [{ id: med.medicine.id, name: med.medicine.name }], title: fitTitle(`${med.medicine.name}: worth reviewing after 65`), why: fill(rule.why, med.medicine.name), what_to_do: [tierAction(rule.tier, v, input), keepTaking], watch_for: rule.watchFor.slice(0, 3), evidence_label: EVIDENCE_LABEL.studies, source_ids: rule.sources, questions_for_doctor: [fill(rule.question, med.medicine.name)], allow_stop: false, clinical: `${med.medicine.name}: ${rule.clinical}`, facts: { age: input.subject.age, ageRange } }); } } // Children (H5): medicines that aren't for them, by the youngest they could be. Call before the next dose; never "stop", never "keep giving". if (ageRange) { for (const rule of CHILD_RULES) { if (ageRange.min >= rule.under) continue; for (const med of resolved) { if (!hits(med, rule.match).length) continue; if (rule.nameWords && !rule.nameWords.some((word) => wholeWord(word).test(normalizeText(med.medicine.name)))) continue; push({ kind: 'child', tier: rule.tier, ruleId: rule.id, idSeed: med.medicine.id ?? med.medicine.name, medicines: [{ id: med.medicine.id, name: med.medicine.name }], title: fitTitle(`Call ${v.possessive} doctor today about ${med.medicine.name}`), why: fill(rule.why, med.medicine.name), what_to_do: [tierAction(rule.tier, v, input)], watch_for: rule.watchFor.slice(0, 3), evidence_label: EVIDENCE_LABEL.studies, source_ids: rule.sources, questions_for_doctor: [fill(rule.question, med.medicine.name)], allow_stop: false, noKeepTaking: true, clinical: `${med.medicine.name}, age ${input.subject.age ?? `${ageRange.min} to ${ageRange.max}`}: ${rule.clinical}`, facts: { age: input.subject.age, ageRange, under: rule.under } }); } } } // Lab results (L4, L5, H3): only published thresholds. Each group is a // ladder for one test, most urgent first; only the first rule that applies is reported. const latestLab = new Map(); for (const lab of [...input.labs].sort((x, y) => x.observedAt.localeCompare(y.observedAt))) latestLab.set(lab.code, lab); const labNotes: CheckResult['what_we_couldnt_check'] = []; const bleedingSigns = screen.ids.includes('bleeding') || screen.ids.includes('stroke'); const targetNoted = new Set(); const thresholdFor = (rule: (typeof LAB_RULES)[number]): number => { const { threshold } = rule.bound; if (typeof threshold === 'number') return threshold; const target = input.labTargets[rule.labCode]; if (!target) return threshold.otherwise; if (targetProblem(rule.labCode, target)) { // A target outside the plausible range (30 for 3.0) would silence alerts: ignored, and said so. if (!targetNoted.has(rule.labCode)) { targetNoted.add(rule.labCode); const name = findLabTest(rule.labCode)?.name ?? rule.labCode; const saved = [target.low !== null ? `from ${target.low}` : '', target.high !== null ? `up to ${target.high}` : ''].filter(Boolean).join(' '); labNotes.push({ item: `${name} target`, medicine_id: null, reason: 'lab', plain: `The ${name} target saved (${saved}) doesn't look right, so the usual target was used. Ask ${v.possessive} clinic for the target and correct it.` }); } return threshold.otherwise; } return (threshold.target === 'high' ? target.high : target.low) ?? threshold.otherwise; }; for (const group of [...new Set(LAB_RULES.map((rule) => rule.group))]) { const ladder = LAB_RULES.filter((candidate) => candidate.group === group); const lab = latestLab.get(ladder[0].labCode); if (!lab) { const meds = trace ? resolved.filter((candidate) => ladder.some((rule) => rule.match.some((matcher) => hits(candidate, matcher).length))) : []; if (meds.length) trace?.missingLabs.push({ labCode: ladder[0].labCode, medicines: meds.map((med) => ({ id: med.medicine.id, name: med.medicine.name })) }); continue; } // N16: a result outside what is possible (a potassium of 55 for 5.5) is most likely a typing slip, so it // never brings an emergency. M1: but on the side the rules act on it could be right (an INR of 25 does // happen), so it is never ignored either: it is same-day at most, first saying to check how it was entered. const implausible = implausibleResult(lab.code, lab.value); // Every rule the result could meet, most urgent first. const candidates: Array<{ rule: (typeof LAB_RULES)[number]; meds: Resolved[]; other?: Resolved; crossing: 'yes' | 'unknown' }> = []; for (const rule of ladder) { const meds = resolved.filter((candidate) => rule.match.some((matcher) => hits(candidate, matcher).length)); if (!meds.length && !rule.anyMedicine) continue; if (hooks?.allow && !hooks.allow(rule.id)) { const needed = !rule.alsoNeeds || resolved.some((candidate) => !meds.includes(candidate) && hits(candidate, rule.alsoNeeds!).length); if (needed && crosses(lab, rule.bound.direction, thresholdFor(rule), rule.bound.inclusive) !== 'no') { trace?.skipped.push({ ruleId: rule.id, medicines: meds.map((med) => ({ id: med.medicine.id, name: med.medicine.name })) }); } continue; } trace?.labsRead.add(lab.code); const other = rule.alsoNeeds ? resolved.find((candidate) => !meds.includes(candidate) && hits(candidate, rule.alsoNeeds!).length) : undefined; if (rule.alsoNeeds && !other) continue; const crossing = crosses(lab, rule.bound.direction, thresholdFor(rule), rule.bound.inclusive); if (crossing !== 'no') candidates.push({ rule, meds, other, crossing }); } if (implausible && !candidates.some((candidate) => candidate.crossing === 'yes')) { if (ladder.some((rule) => rule.anyMedicine || resolved.some((candidate) => rule.match.some((matcher) => hits(candidate, matcher).length)))) { const name = findLabTest(lab.code)?.name ?? lab.code.toUpperCase(); labNotes.push({ item: `${name} result`, medicine_id: null, reason: 'lab', plain: `The latest ${name} result (${lab.comparator ?? ''}${lab.value}) doesn't look right, so it wasn't used. Check how it was entered, or ask ${v.possessive} doctor.` }); } continue; } if (!candidates.length) continue; const test = findLabTest(lab.code)?.name ?? lab.code.toUpperCase(); const printed = `${lab.comparator ?? ''}${lab.value}`; const date = lab.observedAt.slice(0, 10); const against = (meds: Resolved[]) => (meds.length ? ` against ${meds.map((med) => med.medicine.name).join(' and ')}` : ''); const ageDays = (Date.parse(`${input.today}T00:00:00Z`) - Date.parse(lab.observedAt)) / 86_400_000; // L-2: a result older than 3 months isn't treated as current. if (ageDays > STALE_RESULT_DAYS) { labNotes.push({ item: `${test} result`, medicine_id: null, reason: 'lab', plain: `The latest ${test} result is from ${date}, more than 3 months ago, so it wasn't checked${against(candidates[0].meds)} as a current result. Ask ${v.possessive} doctor whether it needs repeating.` }); continue; } if (!candidates.some((candidate) => candidate.crossing === 'yes')) { labNotes.push({ item: `${test} result`, medicine_id: null, reason: 'lab', plain: `The latest ${test} result was printed as "${printed}", not as an exact number, so it couldn't be fully checked${against(candidates[0].meds)}. Ask ${v.possessive} pharmacist.` }); continue; } // M-6: a value printed as a bound past a threshold may be past a higher one too ("INR >4" could be 9), // so the most urgent rule it could meet is used, and the note says why. const { rule, meds, other, crossing } = candidates[0]; if (crossing === 'unknown') { labNotes.push({ item: `${test} result`, medicine_id: null, reason: 'lab', plain: `The latest ${test} result was printed as "${printed}", not as an exact number, so the advice is for the highest it could be.` }); } let tier = rule.tier; if (rule.tierWithBleeding && bleedingSigns) tier = rule.tierWithBleeding; const urgent = tier; if (rule.recentDays !== undefined && rule.olderTier && ageDays > rule.recentDays) tier = rule.olderTier; // L-2: older than two weeks is same-day at most, and says how old it is. const dated = ageDays > DATED_RESULT_DAYS; if (dated && TIER_RANK[tier] > TIER_RANK.today) tier = 'today'; // M1: a result that doesn't look right is same-day at most. if (implausible && TIER_RANK[tier] > TIER_RANK.today) tier = 'today'; // Too old for "call 112 now": same day, with the hospital if no one can be reached (pharmacist to confirm). const hospitalLine = urgent === 'get_help_now' && tier === 'today' ? [implausible ? 'If the result is right and no one can be reached today, go to the nearest hospital today.' : 'If they can\'t be reached today, go to the nearest hospital today.'] : []; const a = meds.map((med) => med.medicine.name).join(' and '); const b = other?.medicine.name ?? ''; const words = (text: string) => fill(text, a, b).replace(/\{test\}/g, test).replace(/\{possessive\}/g, v.possessive) .replace(/\{action\}/g, tierAction(tier, v, input)).replace(/\{emergency\}/g, tierAction('get_help_now', v, input)); const subject = meds.length ? `${a} and the latest ${test} result` : `the latest ${test} result`; const title = rule.title && meds.length ? words(rule.title) : tier === 'get_help_now' ? `Get help now: ${subject}` : tier === 'today' ? `Call today: ${subject}` : `${subject.charAt(0).toUpperCase()}${subject.slice(1)}`; // With no medicine that raises it, there is no "next dose" to call before. const actions = meds.length ? rule.actions : [tier === 'get_help_now' ? '{emergency}' : 'Call {possessive} doctor today about this result.']; const lines = actions ? actions.map(words) : [tierAction(tier, v, input), keepTaking]; for (const line of hospitalLine) if (!lines.includes(line)) lines.splice(1, 0, line); if (implausible) lines.unshift(`First check the ${test} result was typed in correctly: it says ${printed}. If it was typed wrongly, correct it and check again.`); if (tier === 'get_help_now' && lines[0] !== tierAction('get_help_now', v, input)) lines.unshift(tierAction('get_help_now', v, input)); if (tier === 'get_help_now' && rule.sign) helpSigns.push(rule.sign); // N17: a bound past one threshold "could be" past the higher one; it isn't said to be. const threshold = thresholdFor(rule); const level = typeof rule.bound.threshold !== 'number' ? 'above the target' : rule.bound.direction === 'above' ? (rule.bound.inclusive ? `${threshold} or more` : `above ${threshold}`) : (rule.bound.inclusive ? `${threshold} or less` : `below ${threshold}`); const why = crossing === 'unknown' ? `The latest ${test} was printed as "${printed}", not as an exact number, so it could be ${level}. The advice is for the highest it could be.` : implausible ? `The latest ${test} result (${printed}) doesn't look right, and may have been typed wrongly. If it is right, it is ${level}. ${fill(meds.length ? rule.why : rule.whyAlone ?? rule.why, a, b)}` : fill(meds.length ? rule.why : rule.whyAlone ?? rule.why, a, b); push({ kind: 'lab', tier, ruleId: rule.id, ...(lab.evidence ? { lab_evidence: structuredClone(lab.evidence) } : {}), idSeed: `${meds.map((med) => med.medicine.id ?? med.medicine.name).join('+')}|${lab.code}|${lab.observedAt}`, medicines: [...meds, ...(other ? [other] : [])].map((med) => ({ id: med.medicine.id, name: med.medicine.name })), title: fitTitle(title), why: dated ? `${why} This result is from ${date}.` : why, what_to_do: lines, watch_for: rule.watchFor.slice(0, 3), evidence_label: EVIDENCE_LABEL.studies, source_ids: rule.sources, questions_for_doctor: [fill(meds.length ? rule.question : 'What should happen about my {test} result?', a, b).replace(/\{test\}/g, test)], allow_stop: false, noKeepTaking: rule.keepTaking === false, soon: rule.soon, clinical: `${rule.clinical} Latest ${lab.code} ${printed} on ${date}.`, facts: { code: lab.code, test, printed, observedAt: lab.observedAt, threshold, crossing, implausible, calculated: Boolean(hooks?.calculatedLab) && hooks?.calculatedLab === lab, unit: findLabTest(lab.code)?.unit ?? null, a, b } }); } // A lab result that changes what a medicine means also changes what its dose means: // "within the usual range" would read as reassurance next to "the label says not to use it". for (const finding of findings.filter((candidate) => candidate.kind === 'lab')) { const lab = finding.ruleId.startsWith('lab-inr') ? 'INR' : finding.ruleId.startsWith('lab-potassium') ? 'potassium' : 'kidney'; for (const dose of doseCheck) { if (dose.status !== 'within_usual_range' || !finding.medicines.some((medicine) => medicine.id === dose.medicine_id && medicine.name === dose.medicine)) continue; // kidneyDoseLimits: the line already compares the dose with the label's limit for this kidney result. if (dose.kidney_limit) continue; dose.status = 'check_with_pharmacist'; dose.plain = `The usual dose may not apply with the latest ${lab} result. Ask ${v.possessive} pharmacist.`; } } // Tests to ask about (RS9): only rules with a source. const tests: CheckResult['tests_to_ask_about'] = []; for (const rule of MONITORING_RULES) { if (!resolved.some((med) => hits(med, rule.match).length)) continue; if (hooks?.allow && !hooks.allow(rule.id)) { trace?.skipped.push({ ruleId: rule.id, medicines: resolved.filter((med) => hits(med, rule.match).length).map((med) => ({ id: med.medicine.id, name: med.medicine.name })) }); continue; } if (trace) { // FND-7: every medicine that needs this test, across the rules that ask for it. const entry = trace.monitoring.get(rule.test) ?? { medicines: [], ruleIds: [] }; for (const med of resolved.filter((candidate) => hits(candidate, rule.match).length)) { if (!entry.medicines.some((known) => known.id === med.medicine.id && known.name === med.medicine.name)) entry.medicines.push({ id: med.medicine.id, name: med.medicine.name }); } entry.ruleIds.push(rule.id); trace.monitoring.set(rule.test, entry); } if (tests.some((test) => test.test === rule.test)) continue; const lab = rule.labCode ? latestLab.get(rule.labCode) : undefined; const ageDays = lab ? (Date.parse(`${input.today}T00:00:00Z`) - Date.parse(lab.observedAt)) / 86_400_000 : null; const status = !lab ? 'no_result' : rule.everyDays !== null && ageDays !== null && ageDays > rule.everyDays ? 'due' : 'up_to_date'; tests.push({ test: rule.test, why: rule.why, when: rule.when, source_id: rule.sources[0], status, last_result_at: lab?.observedAt ?? null }); rule.sources.forEach((source) => usedSources.add(source)); } // What we couldn't check (RS7): named, never skipped. const couldnt: CheckResult['what_we_couldnt_check'] = []; if (!screen.screened) { couldnt.push({ item: 'Symptoms', medicine_id: null, reason: 'symptoms_not_screened', plain: `We can only check symptoms written in English for now, so these weren't checked. ${v.self ? 'If you are' : `If ${v.name} is`} very unwell, ${midSentence(tierAction('get_help_now', v, input))}` }); } else if (screen.unsure) { // H-1.7: words about dying, death or killing we couldn't be sure of: no label, but the emergency number. couldnt.push({ item: 'Symptoms', medicine_id: null, reason: 'symptoms_unclear', plain: `We couldn't be sure what some of these symptoms meant. ${v.self ? 'If you are' : `If ${v.name} is`} in danger or very unwell, ${midSentence(tierAction('get_help_now', v, input))}` }); } for (const med of resolved) { if (med.ingredients.length) continue; const unknownMix = !med.medicine.contentsKnown; couldnt.push({ item: med.medicine.name, medicine_id: med.medicine.id, reason: unknownMix ? 'contents_unknown' : 'not_recognised', plain: unknownMix ? `What's in ${med.medicine.name} isn't known, so it couldn't be checked against ${v.possessive} other medicines. Show it to ${v.possessive} pharmacist.` : `We couldn't match "${med.medicine.name}" to a medicine we know, so it wasn't checked. Show it to ${v.possessive} pharmacist.` }); } for (const med of resolved) { if (!med.ingredients.length || !med.unmatchedWords.length) continue; couldnt.push({ item: med.medicine.name, medicine_id: med.medicine.id, reason: 'partly_recognised', plain: `We recognised ${med.ingredients.filter((ingredient) => !INGREDIENT_BY_KEY.get(ingredient.key)?.companion).map((ingredient) => ingredientName(ingredient.key)).join(' and ')} in ${med.medicine.name}, but not everything in it, so it was only partly checked. Show it to ${v.possessive} pharmacist.` }); } for (const med of resolved) { if (!med.ingredients.length) continue; const unruled = unruledIngredients(med); if (!unruled.length) continue; couldnt.push({ item: med.medicine.name, medicine_id: med.medicine.id, reason: 'no_rules', plain: `We recognise ${unruled.map((ingredient) => ingredientName(ingredient.key)).join(' and ')}, but we don't have checks for how ${unruled.length > 1 ? 'they mix' : 'it mixes'} with other medicines yet. Show ${med.medicine.name} to ${v.possessive} pharmacist.` }); } // Gap 2: a strength entered that can't be read, for a medicine with a daily maximum. Its dose couldn't be checked against the // label, which may hide a same-day finding, so it counts like a medicine we couldn't check: never "No problems found". for (const med of resolved) { if (!med.strengthUnreadable || med.dose.problem === 'topical') continue; if (!limitedIngredients(med).some((ingredient) => dailyMg(med, ingredient.key) === null)) continue; couldnt.push({ item: med.medicine.name, medicine_id: med.medicine.id, reason: 'strength_unreadable', plain: `We couldn't read the strength "${quoted(med.medicine.strength ?? '')}" entered for ${med.medicine.name}, so we couldn't check the dose against the label. Check the strength on the pack and correct it.` }); } // M1: a liquid's strength that isn't per ml ("120 mg"), for a medicine with a daily maximum: its dose in ml couldn't be // worked out, so, like a strength we can't read, it is never "No problems found". for (const med of resolved) { if (med.dose.problem === 'topical' || !limitedIngredients(med).some((ingredient) => med.doses.get(ingredient.key)?.problem === 'strength_not_per_ml')) continue; const written = med.strengthFrom === 'name' ? `in "${quoted(med.medicine.name)}"` : `"${quoted(med.medicine.strength ?? '')}" entered for ${med.medicine.name}`; couldnt.push({ item: med.medicine.name, medicine_id: med.medicine.id, reason: 'strength_not_per_ml', plain: `We couldn't use the strength ${written}: for a liquid we need the strength per 5 ml, so we couldn't check the dose against the label. Check the pack for the strength per 5 ml (for example 120 mg/5 ml) and correct it.` }); } // Ruling A: an adult's dose that can't be turned into mg (spoons or drops, "1 dose" of a liquid, mg of a combination, a most // a day with no unit), for a medicine with a maximum whose dose would otherwise be checked: like a strength we can't use, it is // never "No problems found". Children's doses aren't checked at all (the age line says so), and a strength or frequency that // is missing, or a pack total, keeps its own dose line: missing, not unusable. for (const med of resolved) { if (!adultLimits || med.dose.problem === 'topical' || !med.strengthFrom || med.packTotal) continue; if (!med.medicine.timesPerDay && !med.medicine.prnMaxPerDay) continue; const problem = limitedIngredients(med).map((ingredient) => med.doses.get(ingredient.key)?.problem).find((found) => found && DOSE_NOT_IN_MG[found]); if (!problem) continue; couldnt.push({ item: med.medicine.name, medicine_id: med.medicine.id, reason: 'dose_unit_unknown', plain: DOSE_NOT_IN_MG[problem]!(med.medicine.name) }); } for (const ended of input.endedMedicines ?? []) { couldnt.push({ item: ended.name, medicine_id: ended.id, reason: 'course_ended', plain: `${ended.name} wasn't checked because its course ended on ${ended.endedOn}. If it's still being taken, update its end date.` }); } couldnt.push(...allergyNotes); if (input.allergiesStatus === 'not_sure') { couldnt.push({ item: 'Allergies', medicine_id: null, reason: 'allergy', plain: `We don't have a confirmed allergy answer, so the allergy check is incomplete. Review it with ${v.possessive} pharmacist.` }); } if (input.conditionsStatus === 'not_sure') { couldnt.push({ item: 'Health conditions', medicine_id: null, reason: 'condition', plain: `We don't have a confirmed health-condition answer. We don't yet check medicines against health conditions. Review ${v.possessive} health profile with ${v.possessive} pharmacist.` }); } // H5: what no rule reads yet is said, never implied as checked. // N13: with no age, a medicine that isn't for children at some age couldn't be checked at all. const childRestricted = ageRange ? [] : resolved.filter((med) => CHILD_RULES.some((rule) => hits(med, rule.match).length && (!rule.nameWords || rule.nameWords.some((word) => wholeWord(word).test(normalizeText(med.medicine.name)))))); if (resolved.length && !ageRange) { couldnt.push({ item: 'Age', medicine_id: null, reason: 'age', plain: childRestricted.length ? `We don't know ${v.owner} age, so we couldn't check whether ${joinNames(childRestricted.map((med) => med.medicine.name))} ${childRestricted.length > 1 ? 'are' : 'is'} right for ${v.object}, or any doses. Add it to the health profile.` : `We don't know ${v.owner} age, so doses, and medicines that aren't for children, weren't checked. Add it to the health profile.` }); } else if (!adultLimits && resolved.some((med) => dosedIngredients(med).length && med.dose.problem !== 'topical')) { couldnt.push({ item: 'Doses', medicine_id: null, reason: 'age', plain: `Doses weren't checked: adult dose limits don't apply to children. Ask ${v.possessive} pharmacist to check each dose.` }); } if (input.reproductive) { const [item, plain] = input.reproductive === 'pregnant' ? ['Pregnancy', `We don't yet check medicines in pregnancy. Ask ${v.possessive} doctor, midwife or pharmacist about each medicine.`] : input.reproductive === 'breastfeeding' ? ['Breastfeeding', `We don't yet check medicines for breastfeeding. Ask ${v.possessive} doctor or pharmacist about each medicine.`] : ['Planning a pregnancy', `We don't yet check medicines for someone planning a pregnancy. Ask ${v.possessive} doctor or pharmacist about each medicine.`]; couldnt.push({ item, medicine_id: null, reason: 'pregnancy', plain }); } for (const condition of input.conditions) { couldnt.push({ item: condition, medicine_id: null, reason: 'condition', plain: `We don't yet check medicines against health conditions such as ${condition}. Ask ${v.possessive} pharmacist.` }); } couldnt.push(...labNotes); // An allergy we couldn't read may hide a same-day match, so it counts like a medicine we couldn't check, // and so does a missing age next to a medicine that isn't for children at some ages (N13). const medicineCouldnt = couldnt.filter((item) => MEDICINE_REASONS.has(item.reason) || allergyGaps.has(item.item) || (item.reason === 'age' && childRestricted.length > 0)); // A condition or pregnancy recorded in the form has not been evaluated just because // no medicine-pair rule matched. Make that limitation visible before the details. const contextCouldnt = couldnt.filter((item) => (resolved.length > 0 && (['age', 'pregnancy', 'condition', 'lab'].includes(item.reason) || (item.reason === 'allergy' && input.allergiesStatus === 'not_sure'))) || item.reason === 'symptoms_not_screened' || item.reason === 'symptoms_unclear'); const incomplete = medicineCouldnt.length > 0 || contextCouldnt.length > 0; if (medicineCouldnt.length) usedSources.add('marmar-unknown-product'); findings.sort((x, y) => TIER_RANK[y.tier] - TIER_RANK[x.tier]); const top = findings.length ? findings[0].tier : undefined; const tier: VerdictTier = signs.length ? 'get_help_now' : top && top !== 'good_to_know' ? top : incomplete ? 'couldnt_check' : top ?? 'nothing_found'; // No general "keep taking" line when any finding says to stop something or to call before the next dose (H-E). const holdKeepTaking = held.size > 0 || findings.some((finding) => finding.allow_stop || noKeepTaking.has(finding.id)); const generalKeep = `Keep taking ${v.possessive} medicines until you've spoken to ${v.possessive} doctor or pharmacist.`; // Word the keep-taking markers: never name a medicine a finding says to stop or call about first. const keepNames = input.medicines.map((medicine) => medicine.name).filter((name) => !held.has(name)); for (const finding of findings) { finding.what_to_do = finding.what_to_do.flatMap((line) => { if (line === KEEP_ALL) { // A rule about one pair cannot authorize continuation of every other // medicine when the person's history or another product is unassessed. if (incomplete) return []; if (!holdKeepTaking) return [generalKeep]; return keepNames.length ? [`Keep taking ${v.possessive} ${joinNames(keepNames)} until you've spoken to ${v.possessive} doctor or pharmacist.`] : []; } const unless = /^\u0000unless-stopped:(.*)\u0001(.*)$/.exec(line); if (unless) return (JSON.parse(unless[1]) as string[]).every((name) => stopped.has(name)) ? [] : [unless[2]]; const marker = /^\u0000keep:(until|plain):(.*)$/.exec(line); if (!marker) return [line]; const names = (JSON.parse(marker[2]) as string[]).filter((name) => !held.has(name)); if (!names.length) return []; return [marker[1] === 'until' ? `Keep taking ${v.possessive} ${joinNames(names)} until a doctor or pharmacist says otherwise.` : `Keep taking ${v.possessive} ${joinNames(names)}.`]; }); if (finding.teach_back) finding.teach_back = teachBack(finding.tier, finding.what_to_do[0] ?? tierAction(finding.tier, v, input), v, finding.id); } /** * What couldn't be checked, named plainly: "Herbal mix", "Part of Coartem" * when one of its ingredients was checked (N24), "The dose of Co-codamol" * when its strength couldn't be read or used (gap 2, M1), and a medicine * that isn't for children at some ages "without her age" (N13). */ const couldntSubline = () => { // Reuse the existing specific limitation instead of inventing medical advice or // producing an empty medicine-name sentence for a person-level gap. if (!medicineCouldnt.length && contextCouldnt.length) return contextCouldnt[0].plain; const ageNames = childRestricted.map((med) => med.medicine.name); // A medicine that couldn't be checked at all without an age isn't also "part of" it (L7: Night Nurse). const items = medicineCouldnt.filter((item) => item.reason !== 'age' && !ageNames.includes(item.item)); const parts = items.flatMap((item) => { // Only its dose, unless the medicine is already named for more than its dose. if (DOSE_REASONS.has(item.reason)) { return items.some((other) => !DOSE_REASONS.has(other.reason) && other.item === item.item && other.medicine_id === item.medicine_id) ? [] : [`the dose of ${item.item}`]; } const med = resolved.find((candidate) => candidate.medicine.id === item.medicine_id && candidate.medicine.name === item.item); return [med && isChecked(med) && (item.reason === 'no_rules' || item.reason === 'partly_recognised') ? `part of ${item.item}` : item.item]; }); const unique = [...new Set(parts)]; const sentence = (names: string[], tail: string) => `${names.join(', ').replace(/^(?:part|the dose) of /, (start) => `${start.charAt(0).toUpperCase()}${start.slice(1)}`)} couldn't be checked${tail}.`; if (!unique.length) return sentence(childRestricted.map((med) => med.medicine.name), ` without ${v.possessive} age`); return sentence([...unique, ...childRestricted.map((med) => med.medicine.name).filter((name) => !unique.includes(name))], ''); }; // Listed as checked: medicines with at least one ingredient the rules know about. const checked = resolved.filter(isChecked).map((med) => med.medicine.name); const count = checked.length; const verdict = ((): CheckResult['verdict'] => { switch (tier) { case 'get_help_now': return { tier, headline: tierAction('get_help_now', v, input), subline: signs.length ? 'These signs need help straight away.' : fitSentences(findings[0].why), keep_taking_line: null }; case 'today': return { tier, headline: `Call ${v.possessive} doctor or pharmacist today`, subline: fitSentences(findings[0].why), keep_taking_line: holdKeepTaking || incomplete ? null : generalKeep.slice(0, 100) }; case 'this_week': return { tier, headline: findings.some((finding) => finding.tier === 'this_week' && soonFindings.has(finding.id)) ? `Ask ${v.possessive} clinic or pharmacist in the next day or two` : `Ask ${v.possessive} pharmacist about ${findings.filter((f) => f.tier === 'this_week').length === 1 ? 'one thing' : 'a few things'} this week`, subline: fitSentences(findings[0].why), keep_taking_line: holdKeepTaking || incomplete ? null : generalKeep.slice(0, 100) }; case 'good_to_know': return { tier, headline: 'Good to know', subline: fitSentences(findings[0].why), keep_taking_line: null }; case 'couldnt_check': return { tier, headline: `We couldn't check everything`, subline: fitSentences(couldntSubline()), keep_taking_line: null }; default: return { tier, headline: count === 0 ? 'No medicines to check yet' : 'No warnings from these limited checks', subline: couldnt.length ? '"Nothing found" doesn\'t mean safe: see what we checked, and what we couldn\'t.' : '"Nothing found" doesn\'t mean safe: here is exactly what we checked.', keep_taking_line: null }; } })(); const references = [...usedSources].map((id) => ({ id, name: CHECK_SOURCES[id].name, url: CHECK_SOURCES[id].url })); const flags = findings.filter((finding) => finding.tier !== 'good_to_know').map((finding) => ({ finding_id: finding.id, reference: finding.source_ids.map((id) => CHECK_SOURCES[id].name).join('; '), for_discussion: finding.clinical })); const summaryParts = [ `${count} medicine${count === 1 ? '' : 's'} checked${input.subject.age ? `, age ${input.subject.age}` : ''}.`, ...flags.slice(0, 4).map((flag) => flag.for_discussion), couldnt.length ? `Not checked: ${couldnt.map((item) => item.item).join(', ')}.` : '', tests.filter((test) => test.status !== 'up_to_date').length ? `Monitoring to consider: ${tests.filter((test) => test.status !== 'up_to_date').map((test) => test.test).join(', ')}.` : '', ].filter(Boolean); const result: CheckResult = { engineVersion: ENGINE_VERSION, rulesVersion: RULES_VERSION, rulesReviewed: CHECK_SIGNED_OFF, verdict, get_help_now: { contact: input.emergency, show: tier === 'get_help_now', signs: [...signs, ...helpSigns].slice(0, 3), action: tierAction('get_help_now', v, input) }, findings, tests_to_ask_about: tests, dose_check: doseCheck, // Conditions stay listed under what_we_couldnt_check until rules read them (H5); the field is kept, empty. what_we_checked: { medicines: checked, conditions: [], allergies: checkedAllergies, age: input.subject.age, references }, what_we_couldnt_check: couldnt, for_the_doctor: { summary: summaryParts.join(' ').slice(0, 600), flags }, language: 'en', language_fallback: !/^en(-|$)/i.test(input.language), wording: 'template', }; return { result, held: [...held] }; } /** Same input, same fingerprint: lets the app reuse a check offline (O1). */ export function checkFingerprint(input: CheckInput, options?: CheckOptions): string { // Symptom text is never stored, so it isn't hashed either: only the signs it matched, and whether it could be screened. const screen = screenSymptoms(input.symptoms, input.language); const canonical = JSON.stringify({ s: [input.subject.kind, input.subject.age, input.subject.sex, input.subject.ageRange ?? null, input.subject.weightKg ?? null, input.reproductive ?? null], // Spoons or drops ("unknown-liquid") read differently from other measures we can't convert. m: input.medicines.map((med) => [normalizeText(med.name), med.strength, med.form, med.timesPerDay, med.unitsPerDose, med.liquidDose ? 'unknown-liquid' : med.doseUnit ?? 'unit', med.prnMaxPerDay, med.prnMaxUnit ?? null, med.origin, med.contentsKnown]).sort(), e: (input.endedMedicines ?? []).map((med) => [normalizeText(med.name), med.endedOn]).sort(), a: input.allergies.map(normalizeText).sort(), c: input.conditions.map(normalizeText).sort(), healthAnswers: [input.allergiesStatus ?? null, input.conditionsStatus ?? null], l: input.labs.map((lab) => [lab.code, lab.value, lab.comparator ?? null, lab.observedAt]).sort(), t: Object.keys(input.labTargets).sort().map((code) => [code, input.labTargets[code].low, input.labTargets[code].high]), y: [[...screen.ids].sort(), screen.screened, screen.unsure], // The emergency number and wording are in the result (N20). x: [input.emergency.number, input.emergency.extra ?? null, input.emergency.registryRevision ?? null, input.emergency.countryCode ?? null], // How old results are changes what they mean (L-2), so the day counts. d: input.today, r: RULES_VERSION, // A wording/coverage correction must not reuse a result from the old engine. engine: ENGINE_VERSION, // Options (Context only) change the result, and read allergy details and lab units: never on the app's fingerprint. ...(hasOptions(options) ? { options: fingerprintOptions(input, options) } : {}), }); return createHash('sha256').update(canonical).digest('hex'); } /** "Needs action" for care-partner alerts and lock-screen wording. */ export function needsAction(tier: VerdictTier): boolean { return tier === 'get_help_now' || tier === 'today'; }