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<CouldntCheckReason> = 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<CouldntCheckReason> = new Set(['strength_unreadable', 'strength_not_per_ml', 'dose_unit_unknown']);
const wholeWord = (phrase: string) => new RegExp(`(?<![a-z0-9])${normalizeText(phrase).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![a-z0-9])`);
/** The allergy families an ingredient belongs to, from its classes (N3): "Nurofen" is ibuprofen, an NSAID. */
const familiesOf = (key: string): AllergyClassRule[] => {
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<string> = 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 = /(?<![a-z])(peanuts?|groundnuts?|nuts?|eggs?|milk|dairy|lactose|gluten|wheat|fish|shellfish|seafood|crayfish|prawns?|shrimps?|crabs?|soy|soya|sesame|latex|dust|pollen|cats?|dogs?|animals?|grass|mou?ld|bees?|wasps?|insects?|stings?|perfumes?|fragrances?|nickel|lanolin|wool|coumarin|inulin|foods?)(?![a-z])/;
/**
* What an allergy entry refers to: medicines by name, and allergy families
* (H-C). A family comes from the words ("penicillin", "sulpha drugs") or from
* the classes of the medicine named ("Zinnat" is cefuroxime, a
* cephalosporin; N3). A misspelling ("Penicilin", "Pencillin") is corrected
* towards the families' names only, and `readAs` says how it was read, so the
* finding can say so (N9). Null: we can't tell, so it isn't checked.
*/
function readAllergy(allergy: string): { ingredients: string[]; classRules: AllergyClassRule[]; readAs: string | null } | null {
const attempt = (text: string) => {
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<string, { low: number | null; high: number | null }>;
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<ClinicianFindingFields> {
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<ClinicianResultFields> {
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<ClinicianTestFields>>;
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<Tier, number> = { 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<CheckFinding, 'teach_back' | 'numbers' | 'id'> & {
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<string, unknown>;
};
/**
* "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<Evidence, string> = {
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<string, Dose>;
/** 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<typeof voice>, 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<typeof voice>, 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<typeof voice>, 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<string, Strength>(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(`(?<![a-z0-9])(?:${pattern})(?![a-z0-9])`)),
}));
export const isEnglish = (language: string | null | undefined) => /^en(-|$)/i.test(language ?? 'en');
/** Every word of every medicine name we know: they count as readable in symptom text. */
const MEDICINE_WORDS: ReadonlySet<string> = 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<CheckLab, 'value' | 'comparator'>, 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<CheckSourceId>();
const noKeepTaking = new Set<string>();
/** Medicines a finding says to stop, or to call about before the next dose. */
const held = new Set<string>();
/** Medicines a finding says to stop. */
const stopped = new Set<string>();
/** Findings to act on within a day or two. */
const soonFindings = new Set<string>();
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<string, { rank: number; order: number; finding: NewFinding }>();
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<string>();
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<Resolved, Map<string, { sum: number; max: number; others: string[]; combination: boolean }>>();
const byIngredient = new Map<string, Resolved[]>();
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<string, CheckLab>();
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<string>();
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';
}