import { INGREDIENTS, INGREDIENT_BY_KEY, PRODUCTS, type DrugClass, type Ingredient } from './catalog.js'; import type { Matcher } from './rules.js'; /** * Turns what people type ("Panadol Extra 500mg", "agbo", "Coartem") into the * ingredients the rules talk about. Whole-word matching only: "Ginkgo" must * never become ginger and "Barkley" never bark. Longest names win, so * "Panadol Extra" is paracetamol + caffeine, not just paracetamol. */ export function normalizeText(value: string): string { return value .normalize('NFD').replace(/[̀-ͯ]/g, '') .toLowerCase() .replace(/['’]/g, '') // Hyphens become spaces so co-codamol, co codamol and cocodamol all match. .replace(/[^a-z0-9+/. ]+/g, ' ') .replace(/\s+/g, ' ') .trim(); } interface NameEntry { name: string; ingredients: string[]; via: 'product' | 'generic' } const NAME_ENTRIES: NameEntry[] = [ ...PRODUCTS.flatMap((product) => product.names.map((name) => ({ name: normalizeText(name), ingredients: product.ingredients, via: 'product' as const }))), ...INGREDIENTS.flatMap((ingredient) => [ingredient.name, ...(ingredient.aliases ?? []), ingredient.key.replace(/-/g, ' ')] .map((name) => ({ name: normalizeText(name), ingredients: [ingredient.key], via: 'generic' as const }))), ].sort((a, b) => b.name.length - a.name.length); const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const PATTERNS = NAME_ENTRIES.map((entry) => ({ entry, pattern: new RegExp(`(? Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0))); for (let i = 1; i <= a.length; i++) { for (let j = 1; j <= b.length; j++) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; rows[i][j] = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost); if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) rows[i][j] = Math.min(rows[i][j], rows[i - 2][j - 2] + 1); } } return rows[a.length][b.length]; } /** The drug-name ending of a word ("dipine" in felodipine), or null. */ export const drugNameEnding = (word: string): string | null => DRUG_NAME_ENDING.exec(word)?.[1] ?? null; /** "penicillin" and "penicillins" are one word for correcting spelling (N10). */ const sameWord = (a: string, b: string) => a === b || `${a}s` === b || `${b}s` === a; /** * Corrects likely misspellings ("Penicilin", "Pencillin", "Septrine") against * the given known words: a word of 6 or more letters is replaced by the one * known word within 1 edit (2 edits for 9 or more letters). A singular and its * plural count as one word ("Penicilline" is penicillin). Two different * equally close words leave it unchanged, and so does a word that reads as * another drug by its ending ("Felodipine" is never amlodipine), unless the * known word has the same ending (N9). Returns null when nothing was corrected. */ export function correctSpelling(text: string, known: ReadonlySet): string | null { let changed = false; const words = normalizeText(text).split(' ').map((word) => { if (word.length < 6 || known.has(word) || !/^[a-z]+$/.test(word)) return word; const allowed = word.length >= 9 ? 2 : 1; const ending = drugNameEnding(word); let best: string | null = null; let bestDistance = Infinity; let tie = false; for (const candidate of known) { if (Math.abs(candidate.length - word.length) > allowed) continue; if (ending && drugNameEnding(candidate) !== ending) continue; const distance = editDistance(word, candidate); if (distance > allowed) continue; if (distance < bestDistance) { best = candidate; bestDistance = distance; tie = false; } else if (distance === bestDistance && best && !sameWord(candidate, best)) tie = true; else if (distance === bestDistance && best && candidate.length < best.length) best = candidate; } if (!best || tie) return word; changed = true; return best; }); return changed ? words.join(' ') : null; } /** * Minerals that are also the salt in a drug's full name: "atorvastatin * calcium", "esomeprazole magnesium", "insulin zinc". Straight after another * drug's name they are part of that drug, not a supplement (L1). */ const SALT_MINERALS = new Set(['calcium', 'magnesium', 'zinc']); const isMineralOrHerbal = (keys: string[]) => keys.every((key) => SALT_MINERALS.has(key) || key === 'iron' || Boolean(INGREDIENT_BY_KEY.get(key)?.herbal)); /** The word lists that decide whether a name is recognised, so answers built on the matcher can say which lists they used (medicine facts). */ export const MATCHER_WORDS = { notAnIngredient: [...NOT_AN_INGREDIENT].sort(), saltMinerals: [...SALT_MINERALS].sort(), drugNameEnding: DRUG_NAME_ENDING.source, }; interface NameHit { entry: NameEntry; start: number; end: number } /** Every catalogue name in a normalized text, longest first, each read once, with where it is. */ function namesIn(original: string): NameHit[] { let text = original; const hits: NameHit[] = []; for (const { entry, pattern } of PATTERNS) { const match = pattern.exec(text); if (!match) continue; hits.push({ entry, start: match.index, end: match.index + match[0].length }); // Blank out the matched words so "panadol" isn't matched again inside "panadol extra". text = `${text.slice(0, match.index)}${' '.repeat(match[0].length)}${text.slice(match.index + match[0].length)}`; } return hits; } /** The minerals among the names that are another drug's salt (L1): "atorvastatin calcium" is atorvastatin. */ function saltsAmong(hits: NameHit[], original: string): Set { const byPosition = [...hits].sort((x, y) => x.start - y.start); return new Set(byPosition.filter((hit, index) => { if (hit.entry.via !== 'generic' || hit.entry.ingredients.length !== 1 || !SALT_MINERALS.has(hit.entry.ingredients[0])) return false; // "Atorvastatin calcium", "rosuvastatin (as calcium)": the mineral directly follows another drug's name. const before = byPosition[index - 1]; if (before && !isMineralOrHerbal(before.entry.ingredients) && /^ (?:as )?$/.test(original.slice(before.end, hit.start))) return true; // "Pitavastatin calcium": or a word that reads as a drug name the catalogue doesn't hold (M-2). const previous = /([a-z]+) (?:as )?$/.exec(original.slice(0, hit.start)); return Boolean(previous && DRUG_NAME_ENDING.test(previous[1])); })); } export function matchIngredients(name: string): MatchedMedicine { const original = normalizeText(name); const hits = namesIn(original); const salts = saltsAmong(hits, original); // What the matched names (and the salts read as part of them) don't cover. let uncovered = original; for (const hit of hits) uncovered = `${uncovered.slice(0, hit.start)}${' '.repeat(hit.end - hit.start)}${uncovered.slice(hit.end)}`; // Two-letter words count ("Tylenol PM", "Panadol CF"), and so does "D" ("Claritin-D"): each can be another ingredient (N11). // "x2" (how many) is never an ingredient (L3). const unmatchedWords = uncovered.split(/[^a-z0-9]+/).filter((word) => (word.length >= 2 || word === 'd') && !/^[0-9]/.test(word) && !/^x[0-9]+$/.test(word) && !NOT_AN_INGREDIENT.has(word)); const found: string[] = []; const names: string[] = []; let via: MatchedMedicine['via'] = 'none'; for (const hit of hits) { if (salts.has(hit)) continue; for (const key of hit.entry.ingredients) if (!found.includes(key)) found.push(key); names.push(hit.entry.name); if (via !== 'product') via = hit.entry.via; } return { ingredients: found, via, matchedNames: names, unmatchedWords }; } /** A medicine name found in free text: what it contains, and whether it is a brand or a generic name. */ export interface MedicineMention { name: string; ingredients: string[]; via: 'product' | 'generic' } /** Every medicine name the catalogue knows in a piece of free text ("Can she take Ponstan with her warfarin?"; N2). */ export function medicineMentions(text: string): MedicineMention[] { let normalized = normalizeText(text); const found: Array = []; for (const { entry, pattern } of PATTERNS) { const match = pattern.exec(normalized); if (!match) continue; found.push({ name: match[0], ingredients: entry.ingredients, via: entry.via, at: match.index }); normalized = `${normalized.slice(0, match.index)}${' '.repeat(match[0].length)}${normalized.slice(match.index + match[0].length)}`; } // In the order they were written. return found.sort((a, b) => a.at - b.at).map(({ at, ...mention }) => { void at; return mention; }); } /** A strength in milligrams: per tablet, capsule or other single unit, or per ml of a liquid. */ export type Strength = { per: 'unit'; mg: number } | { per: 'ml'; mg: number }; /** Weights as people write them (M2): "mgs", "mgm" and "milligramme" are mg, "gm" and "gms" are g, "mcgs" is mcg. */ const WEIGHT = 'mg|mgs|mgm|milligrams?|milligrammes?|g|gm|gms|grams?|grammes?|mcg|mcgs|micrograms?|microgrammes?|ug'; const MASS = `(\\d+(?:\\.\\d+)?)\\s*(${WEIGHT})(?![a-z])`; const ML = '(?:ml|mls|millilitres?|milliliters?|cc)(?![a-z])'; /** Words a strength can be "per" and still mean one tablet, capsule or dose ("100 mcg/dose"). */ const SINGLE_UNIT_WORDS = new Set(['tab', 'tabs', 'tablet', 'tablets', 'cap', 'caps', 'capsule', 'capsules', 'caplet', 'caplets', 'dose', 'doses', 'puff', 'puffs', 'actuation', 'actuations', 'spray', 'sprays', 'sachet', 'sachets', 'suppository', 'suppositories', 'lozenge', 'lozenges', 'pastille', 'pastilles', 'pill', 'pills', 'vial', 'vials', 'ampoule', 'ampoules', 'ampule', 'ampules']); export function toMg(value: string, unit: string): number { const amount = Number.parseFloat(value); if (unit.startsWith('mcg') || unit.startsWith('microgram') || unit === 'ug') return amount / 1000; if (unit === 'g' || unit === 'gm' || unit === 'gms' || unit.startsWith('gram')) return amount * 1000; return amount; } /** "µg" and "μg" are micrograms; normalizeText would otherwise drop the µ and leave grams. */ const microAsU = (text: string) => text.replace(/[µμ]/g, 'u'); const SPACE = '[ \\u00a0\\u202f]'; /** * Numbers as people type them (H-B). normalizeText() turns commas into spaces, * so "1,000 mg" would read as "000 mg": thousands separators are removed first. * "1,000" and, before a weight, "1 000" are a thousand. A decimal comma ("2,5") * could be 2.5 or 25, and a dot before exactly three digits ("1.000") could be * 1 or a thousand (as domain/labs/values.ts reads it; N12), so the text is not * read at all (null). A leading zero ("0.125") can only be a decimal. */ function plainNumbers(raw: string, spacedThousands: boolean): string | null { const text = microAsU(raw); if (/\d,\d{1,2}(?!\d)|\d,\d{4,}/.test(text)) return null; if (/(? number.replace(/,/g, '')); return spacedThousands ? commas.replace(new RegExp(`(? number.replace(new RegExp(SPACE, 'g'), '')) : commas; } /** * The strength as entered: "500 mg" (per unit), "120 mg/5 ml" or "24 mg per ml" * (per ml of a liquid), "100 mcg/dose". Null for combinations ("500/30", * "500 mg + 30 mg", "caffeine 65 mg, paracetamol 500 mg": two amounts are * never read as one, whichever comes first), percentages, strengths per gram * of a cream and anything else that is not a dose we can count. A * combination's amounts are read by readStrength (amounts.ts). */ export function parseStrength(strength: string | null | undefined): Strength | null { if (!strength) return null; const numbers = plainNumbers(strength, true); if (numbers === null) return null; const text = normalizeText(numbers); if (/\d\s*[/+]\s*\.?\d/.test(text) || (text.match(new RegExp(MASS, 'g')) ?? []).length > 1) return null; const perMl = new RegExp(`${MASS}\\s*(?:/|per|in|each)\\s*(\\d+(?:\\.\\d+)?)?\\s*${ML}`).exec(text); if (perMl) { const ml = perMl[3] ? Number.parseFloat(perMl[3]) : 1; const mg = toMg(perMl[1], perMl[2]); return ml > 0 && Number.isFinite(mg) ? { per: 'ml', mg: mg / ml } : null; } const single = new RegExp(MASS).exec(text); if (!single) return null; // "10 mg/g", "5 mg/24 hours" or "40 mg/100 tablets" are not per tablet. const per = /^\s*(?:\/|per\s)\s*([a-z]+|\d+)/.exec(text.slice(single.index + single[0].length)); if (per && !SINGLE_UNIT_WORDS.has(per[1])) return null; return { per: 'unit', mg: toMg(single[1], single[2]) }; } /** Milligrams per tablet or other single unit, from "500 mg", "1 g", "75 mcg". Null for liquids, combinations and units we can't convert. */ export function parseStrengthMg(strength: string | null | undefined): number | null { const parsed = parseStrength(strength); return parsed?.per === 'unit' ? parsed.mg : null; } /** One piece of a strength as written: an amount (its unit when one follows it), one medicine's name, or what joins them ("/", "+", "and"). */ export type StrengthPiece = | { kind: 'amount'; value: string; unit: string | null } | { kind: 'name'; ingredient: string } | { kind: 'join' }; /** The weights parseStrength reads (WEIGHT). */ const WEIGHT_WORDS = new Set(WEIGHT.split('|').flatMap((word) => (word.endsWith('s?') ? [word.slice(0, -2), `${word.slice(0, -2)}s`] : [word]))); /** * Words that change what the numbers beside them mean ("per day", "x 2", * "100 units", "every 4 hours", "15 or 30 mg"), and volumes parseStrength * doesn't read either ("5 ml" anywhere but at the end): a strength with any * of them isn't read as a combination's amounts. */ const CHANGES_THE_NUMBERS = new Set(['per', 'x', 'times', 'or', 'to', 'unit', 'units', 'iu', 'mmol', 'meq', 'kg', 'ml', 'mls', 'cc', 'l', 'litre', 'litres', 'liter', 'liters', 'millilitre', 'millilitres', 'milliliter', 'milliliters', 'h', 'hr', 'hrs', 'hour', 'hours', 'day', 'days', 'daily', 'week', 'weeks', 'weekly', 'min', 'mins', 'minute', 'minutes']); const JOINING_WORDS = new Set(['and', 'with', 'plus']); /** "per tablet", "/tab", "each capsule", "a sachet": one unit, which a strength is for anyway. */ const PER_UNIT = new RegExp(`(?:/|\\b(?:per|each|a|every)\\b) ?(?:${[...SINGLE_UNIT_WORDS].join('|')})(?![a-z])`, 'g'); /** "32 tablets", "2 caps": how many, never part of a strength (but "30/500 tablets" is the pack's 30/500). */ const UNIT_COUNT = new RegExp(`(? 0)) return null; text = text.slice(0, volume.index); } text = text.replace(PER_UNIT, ' ').replace(UNIT_COUNT, ' '); const pieces: Array = []; // One medicine's name ("codeine", "acetaminophen", "Panadol") can say which amount is its own. A product's name with more // than one medicine in it ("co-codamol") can't, and a mineral after a drug's name is its salt ("atorvastatin calcium"). const hits = namesIn(text); const salts = saltsAmong(hits, text); let rest = text; for (const hit of hits) { if (hit.entry.ingredients.length === 1 && !salts.has(hit)) pieces.push({ kind: 'name', ingredient: hit.entry.ingredients[0], at: hit.start }); rest = `${rest.slice(0, hit.start)}${' '.repeat(hit.end - hit.start)}${rest.slice(hit.end)}`; } const tokens = [...rest.matchAll(/(\d+(?:\.\d+)?|\.\d+)|([a-z]+)|[/+]/g)]; for (let index = 0; index < tokens.length; index++) { const [whole, number, word] = tokens[index]; const at = tokens[index].index!; if (number !== undefined) { // "500mg", "500 mg": a weight straight after the number is its unit. const next = tokens[index + 1]; const unit = next?.[2] !== undefined && WEIGHT_WORDS.has(next[2]) && !rest.slice(at + whole.length, next.index).trim() ? next[2] : null; if (unit) index++; pieces.push({ kind: 'amount', value: number.startsWith('.') ? `0${number}` : number, unit, at }); } else if (word !== undefined) { // A weight with no number before it ("10 mg/g"), or a word that changes what the numbers mean. if (WEIGHT_WORDS.has(word) || CHANGES_THE_NUMBERS.has(word)) return null; if (JOINING_WORDS.has(word)) pieces.push({ kind: 'join', at }); } else { pieces.push({ kind: 'join', at }); } } return { pieces: pieces.sort((a, b) => a.at - b.at).map(({ at, ...piece }) => { void at; return piece; }), perMl }; } /** How much is taken each time: tablets and the like, ml, mg, or a measure we can't convert (spoons, drops). */ export interface DoseAmount { amount: number; unit: 'unit' | 'ml' | 'mg' | 'unknown'; /** Spoons or drops: they can't be converted, but they say the medicine is a liquid, whatever its saved form (R1). */ liquid?: true; } const NUMBER_WORDS: Record = { half: 0.5, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10 }; const UNIT_WORDS = new Set([...SINGLE_UNIT_WORDS, 'patch', 'patches', 'injection', 'injections', 'shot', 'shots']); /** Measures only a liquid is given in: spoons, droppers and drops. */ const LIQUID_MEASURES = new Set(['spoon', 'spoons', 'spoonful', 'spoonfuls', 'teaspoon', 'teaspoons', 'teaspoonful', 'teaspoonfuls', 'tablespoon', 'tablespoons', 'tablespoonful', 'tablespoonfuls', 'tsp', 'tsps', 'tbsp', 'tbsps', 'drop', 'drops', 'dropper', 'droppers', 'dropperful', 'dropperfuls', 'capful', 'capfuls']); /** * A plan amount such as "2 tablets", "10 ml", "2 x 5 ml", "500 mg" or "1". * Nothing entered counts as one tablet (or one of whatever the medicine * comes in). Spoons, drops, international units and anything we can't read * are `unknown`, so the dose is reported as not checked rather than guessed. */ export function parseDoseAmount(raw: string | null | undefined): DoseAmount { const unknown: DoseAmount = { amount: 1, unit: 'unknown' }; // "1,000 mg" is a thousand; "2 500 mg" could be two 500 mg tablets, so spaced digits are not joined here. const numbers = plainNumbers(raw ?? '', false); if (numbers === null) return unknown; const text = normalizeText(numbers).replace(/\b1\/2\b/g, 'half'); if (!text) return { amount: 1, unit: 'unit' }; const times = new RegExp(`^(\\d+(?:\\.\\d+)?)\\s*(?:x|times)\\s*(\\d+(?:\\.\\d+)?)\\s*${ML}`).exec(text); if (times) return { amount: Number.parseFloat(times[1]) * Number.parseFloat(times[2]), unit: 'ml' }; const lead = /^(\d+(?:\.\d+)?|half|one|two|three|four|five|six|seven|eight|nine|ten)(?:\s+(?:a|an|of a|of an))?\s*(.*)$/.exec(text); if (!lead) return unknown; let amount = NUMBER_WORDS[lead[1]] ?? Number.parseFloat(lead[1]); let rest = lead[2].trim(); if (!Number.isFinite(amount) || amount <= 0) return unknown; // "2 5 ml spoons": two 5 ml spoons. const spoons = new RegExp(`^(\\d+(?:\\.\\d+)?)\\s*${ML}\\s*spoon`).exec(rest); if (spoons) return { amount: amount * Number.parseFloat(spoons[1]), unit: 'ml' }; // A range ("1-2 tablets", "5 to 10 ml") counts as the larger amount. "2 500 mg" could be // a range or two 500 mg tablets, so a second number before a weight is not read. const range = /^(?:to |or )?(\d+(?:\.\d+)?)\s*(.*)$/.exec(rest); if (range) { if (/^(mg|milligrams?|g|grams?|mcg|micrograms?|ug)(?![a-z])/.test(range[2])) return unknown; amount = Math.max(amount, Number.parseFloat(range[1])); rest = range[2].trim(); } if (new RegExp(`^${ML}`).test(rest)) return amount <= 100 ? { amount, unit: 'ml' } : unknown; const mass = /^(mg|milligrams?|g|grams?|mcg|micrograms?|ug)(?![a-z])/.exec(rest); if (mass) return { amount: toMg(String(amount), mass[1]), unit: 'mg' }; const word = rest.split(' ')[0]; if (!rest || UNIT_WORDS.has(word)) return amount <= 20 ? { amount, unit: 'unit' } : unknown; if (LIQUID_MEASURES.has(word)) return { amount, unit: 'unknown', liquid: true }; return unknown; } /** Units per dose from a plan amount like "2 tablets" or "1". Defaults to 1. */ export function unitsPerDose(amount: string | null | undefined): number { const parsed = parseDoseAmount(amount); return parsed.unit === 'unit' ? parsed.amount : 1; } /** What "the most a day" counts: doses, tablets and the like, ml, or a weight (mg, g, mcg). null when not said; `unknown` when we can't tell. */ export function prnUnitKind(unit: string | null | undefined): 'dose' | 'unit' | 'ml' | 'mg' | 'g' | 'mcg' | 'unknown' | null { const text = normalizeText(microAsU(unit ?? '')); if (!text) return null; const word = text.split(' ')[0]; if (['dose', 'doses', 'time', 'times'].includes(word)) return 'dose'; if (UNIT_WORDS.has(word)) return 'unit'; if (new RegExp(`^${ML}`).test(word)) return 'ml'; if (/^(mg|milligrams?)$/.test(word)) return 'mg'; if (/^(g|grams?|gm|gms)$/.test(word)) return 'g'; if (/^(mcg|micrograms?|ug)$/.test(word)) return 'mcg'; return 'unknown'; } /** How many times a day a medicine is taken, from the saved frequency or reminder times. */ export function timesPerDay(frequency: string | null | undefined, reminderTimes: number | null | undefined): number | null { switch (frequency) { case 'once_daily': case 'every_morning': case 'every_evening': case 'every_night': return 1; case 'twice_daily': return 2; case 'three_times_daily': return 3; case 'four_times_daily': return 4; default: return reminderTimes && reminderTimes > 0 ? reminderTimes : null; } } /** Forms used on the skin. Their NSAIDs are not counted with NSAIDs taken by mouth. */ const SKIN_FORMS = new Set(['cream', 'gel', 'ointment']); const SKIN_NAME = /(? name !== 'nsaid' && name !== 'aspirin_dose_unknown'), 'topical_nsaid']; } return classes; } export function matches(matcher: Matcher, ingredientKey: string, classes: DrugClass[]): boolean { if (matcher.ingredient) return matcher.ingredient === ingredientKey; if (matcher.class) return classes.includes(matcher.class) && !(matcher.except && classes.includes(matcher.except)); return false; } export function ingredientName(key: string): string { return INGREDIENT_BY_KEY.get(key)?.name ?? key; } export function ingredient(key: string): Ingredient | undefined { return INGREDIENT_BY_KEY.get(key); }