import { INGREDIENTS, INGREDIENT_BY_KEY, PRODUCTS } from './catalog.js'; import { normalizeText } from './normalize.js'; import { SAME_CLAUSE } from './rules.js'; /** * J1: paracetamol is never a regimen above the most anyone should take in 24 * hours, whatever the schedule. Over several days that is a staggered * overdose, which needs same-day hospital assessment even when the person * feels well: the delay is what makes it dangerous. The limits are 8 tablets * a day (8 x 500 mg is 4,000 mg) and 4 doses a day of a children's product * (and never more than 8 of anything). Pharmacist to confirm the threshold of * 8: US regular-strength tablets are 325 mg. */ export const PARACETAMOL_TABLETS_A_DAY = 8; export const CHILD_PARACETAMOL_DOSES_A_DAY = 4; const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const paracetamol = INGREDIENT_BY_KEY.get('paracetamol'); /** * Paracetamol by any name: its generic names, every brand or combination the * catalogue knows with it in (Panadol, Calpol, co-codamol, Night Nurse...), * and how people write them ("Emzor" for Emzor paracetamol, "paras"). */ const NAMES = [...new Set([paracetamol?.name ?? 'Paracetamol', ...(paracetamol?.aliases ?? []), ...PRODUCTS.filter((product) => product.ingredients.includes('paracetamol')).flatMap((product) => product.names), 'paracetemol', 'paracetamole', 'paracetamal', 'parcetamol', 'paracetmol', 'paras', 'emzor', 'panadol baby', 'calpol six plus', 'calpol infant', ].map((name) => normalizeText(name)))].sort((a, b) => b.length - a.length); const NAME = `(?:${NAMES.map(escapeRegex).join('|')})s?`; const SMALL: Record = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16, seventeen: 17, eighteen: 18, nineteen: 19 }; const TENS: Record = { twenty: 20, thirty: 30, forty: 40, fifty: 50, sixty: 60, seventy: 70, eighty: 80, ninety: 90 }; const DIGIT_WORDS = 'one|two|three|four|five|six|seven|eight|nine'; const NUM = `(?:[0-9]+|(?:${Object.keys(TENS).join('|')})(?: (?:${DIGIT_WORDS}))?|${Object.keys(SMALL).join('|')}|a dozen|dozen|a hundred|hundred|a couple of|a few|several)`; /** "12", "twelve", "twenty four", "a dozen"; "a few" and "several" are read as 3. */ function valueOf(words: string): number { if (/^[0-9]+$/.test(words)) return Number(words); if (words === 'a dozen' || words === 'dozen') return 12; if (words === 'a hundred' || words === 'hundred') return 100; if (words === 'a couple of') return 2; if (words === 'a few' || words === 'several') return 3; const [first, second] = words.split(' '); return (TENS[first] ?? SMALL[first] ?? 0) + (second ? SMALL[second] ?? 0 : 0); } const TABLET_UNIT = '(?:tablets?|tabs?|caplets?|capsules?|caps|pills?)'; const DOSE_UNIT = '(?:doses?|sachets?|spoons?|spoonfuls?|teaspoons?|teaspoonfuls?|syringes?|syringefuls?|measures?)'; const UNIT = `(?:${TABLET_UNIT}|${DOSE_UNIT})`; /** L4: a form after the name or the strength: "paracetamol syrup 10 ml", "Panadol 500mg tablets". */ const FORM = `(?:${UNIT}|syrup|suspension|elixir|liquid|solution|soluble|oral|effervescent|dispersible|drops)`; const QUALIFIER = '(?:about|around|over|like|almost|nearly|up to|maybe|at least|more than|another|only|just)'; const OWNER = '(?:my|her|his|their|the|your|our|dis|di|these|those)'; /** * H1: a strength or a volume is never a count: "500 mg", "500mg", "1 g", "5 ml", * "120mg/5ml". "Panadol 500 mg twice a day" is 2 doses, not 500 tablets. */ const STRENGTH_UNIT = '(?:mg|mgs|milligrams?|g|gm|gms|grams?|mcg|ug|micrograms?|ml|mls|millilitres?|milliliters?|cc)'; const STRENGTH = `(?:[0-9]+(?:\\.[0-9]+)?|${NUM}) ?${STRENGTH_UNIT}(?: ?/ ?(?:[0-9]+(?:\\.[0-9]+)? ?)?${STRENGTH_UNIT})?(?![a-z0-9])`; const NOT_A_STRENGTH = `(?!(?:\\.[0-9]+)? ?${STRENGTH_UNIT}(?![a-z0-9]))`; /** * M-3: a strength with no unit, right after a paracetamol name: the usual * strengths ("Panadol 500 twice a day", "Tylenol 325") and pairs * ("co-codamol 30/500", "8/500", "500/65"). Skipped like "500 mg", and never * a count: 120 tablets or more is never a dose (the table's "fifty or more" * still reads them as too much). */ const BARE_STRENGTH = '(?:120|125|160|250|325|500|650|665|1000|[0-9]+(?:\\.[0-9]+)?/[0-9]+(?:\\.[0-9]+)?)(?![a-z0-9./])'; /** "10 paracetamol", "12 tablets of Panadol", "10 of her Panadol", "10 Calpol sachets", "6 doses of Calpol", "two 500mg paracetamol". */ const COUNT_FIRST = new RegExp(`(?${NUM})${NOT_A_STRENGTH}(?: ?x)?(?: ${STRENGTH})?(?: (?${UNIT}))?(?: of)?(?: ${OWNER})?(?: ${STRENGTH})? (?${NAME})` // LOW: a strength after the name ("I take 3 co-codamol 30/500 four times a day"). + `(?: (?:${BARE_STRENGTH}|${STRENGTH}))?(?: (?${UNIT}))?(?![a-z0-9])`, 'g'); /** "paracetamol 10 a day", "Panadol, 3 tablets 4 times a day", "paracetamol 500 mg, 3 tablets 4 times a day" (the strength skipped). */ const NAME_FIRST = new RegExp(`(?${NAME})(?: (?${FORM}))?(?: ${BARE_STRENGTH})?(?: ${STRENGTH}){0,2}(?: (?
${FORM}))?(?: ${QUALIFIER})? (?x ?)?(?!${BARE_STRENGTH})(?${NUM})${NOT_A_STRENGTH}` + `(?: (?${UNIT}))?(?! times)(?![a-z0-9])`, 'g'); /** "Calpol 6 times a day", "Calpol 5 ml four times a day", R9-L4: "paracetamol 1g every 3 hours": doses, with no count. */ const NAME_TIMES = new RegExp(`(?${NAME})(?: (?${FORM}))?(?: ${BARE_STRENGTH})?(?: ${STRENGTH}){0,2}(?: (?${FORM}))?(?: ${QUALIFIER})?` + `(?= (?:twice|thrice|${NUM} times?|x ?${NUM}|${NUM} ?x|every ${NUM} (?:hours|hrs))(?![a-z0-9]))`, 'g'); /** * L4: a total in mg or g: "I took 8000mg of paracetamol today", "she swallowed 15 g of Panadol". With "of", or over 1000 mg, it * is a total; "500mg paracetamol" is a strength, and the count after it is read instead. */ const MASS_FIRST = new RegExp(`(?[0-9]+(?:\\.[0-9]+)?) ?(?mg|mgs|milligrams?|g|gm|gms|grams?)(? of)?(?: ${OWNER})? (?${NAME})(?![a-z0-9])`, 'g'); /** A clock time, or a time of day: "at 8am", "at 10 30pm", "at 2", "at noon", "at bedtime". */ const CLOCK = '(?:[0-9]{1,2}(?: ?[0-9]{2})?(?: ?(?:am|pm|oclock))?|noon|midday|midnight|night|bedtime|lunch|lunchtime|breakfast|dinner|teatime)'; /** * LOW, M-E: amounts at clock times, one day's worth: "paracetamol 2 g at 8am, 2 g at 2pm and 1 g at 8pm" is 5 g, "I took 2 g * of paracetamol at 8am and 2 g at 2pm and 1 g at 8pm" too, and "4 paracetamol at 10pm last night and 6 at 6am" is 10 tablets. * R9-L4: a child's doses by volume ("Calpol 5ml at 8am, 5ml at 12, ...") are doses. A count is never a date or a room ("on the 12 * at 3pm"). */ const AT_A_TIME = new RegExp(`(?[0-9]+(?:\\.[0-9]+)?) ?(?mg|mgs|milligrams?|g|gm|gms|grams?)` + `|(?[0-9]+(?:\\.[0-9]+)?) ?(?:ml|mls|millilitres?|milliliters?)` + `|(?${NUM})${NOT_A_STRENGTH}(?: more)?(?: ${TABLET_UNIT})?)` + `(?:(?: of)?(?: ${OWNER})? (?${NAME})(?: ${TABLET_UNIT})?)? at ${CLOCK}(?![a-z0-9])`, 'g'); /** * M-E: every other medicine the catalogue knows, by any name, so an amount at a clock time is read with the medicine named * nearest before it: "metformin 1g at 8am and 1g at 8pm" is metformin, never paracetamol. */ const OTHER_NAMES = [...new Set([...INGREDIENTS.flatMap((ingredient) => [ingredient.name, ...(ingredient.aliases ?? [])]), ...PRODUCTS.flatMap((product) => product.names)] .map((name) => normalizeText(name)).filter((name) => name && !NAMES.includes(name)))].sort((a, b) => b.length - a.length); /** * R9-M6: what may sit between an amount at a clock time and the paracetamol name it is read with: more amounts and times, and * joining words ("paracetamol 1g at 8am, then 1g at 2pm and another 1g at 8pm"). Anything else, such as a medicine the catalogue * doesn't know ("gabapentin 300mg at 8am"), ends the run. */ const RUN = /^(?:\s|[0-9.]|mg|mgs|ml|mls|g|gm|grams?|tablets?|tabs?|at|am|pm|oclock|noon|midday|midnight|night|bedtime|lunch|lunchtime|breakfast|dinner|teatime|and|then|plus|another|more|also|of|my|her|his|last|this|morning|evening|today|tonight)+$/; /** R9-M6: "I take", "Mum takes" before a list of amounts that opens the text. */ const LIST_OPENING = /^(?:(?:i|she|he|we|they|mum|mom|dad|my [a-z]+) (?:take|takes|took|had|have|has|gave|give|gives) )/; const MEDICINE_NAMED = new RegExp(`(?${NAME})|(?:${OTHER_NAMES.map(escapeRegex).join('|')})s?)(?![a-z0-9])`, 'g'); /** LOW: one dose in mg or g, written with the name ("paracetamol 1g x 5 a day"); never a pair ("30/500") or a volume ("120mg/5ml"). */ const DOSE_MASS = /(?${ONE_DAY})`, // R9-L4: "at once" or "in one go" is one day's too ("I took 9 paracetamol at once at 2pm"). '(?in the (?:morning|evening|afternoon)|at (?:night|bedtime|lunch|lunchtime|noon|midday|teatime|tea time|dinner|supper|breakfast)|(?:every|each) (?:morning|evening|night|afternoon)|nightly|once (?:a|per|each|every) day|once daily|(?:all )?at once|in one go|at one time|at a go)', `(?twice|thrice) ${ONE_DAY}`, `(?${NUM}) times ${ONE_DAY}`, // L4: "x 3", "x3 a day", "3x daily". `x ?(?${NUM})(?: (?:times? )?${ONE_DAY})?|(?${NUM}) ?x ${ONE_DAY}`, '(?morning and (?:evening|night))', `(?:over|in|across|within)(?: the (?:last|past|next))? (?${NUM}) days`, `(?:over|in|across|within)(?: the (?:last|past|next))? (?a|one|${NUM}) weeks?`, `(?:over|in|within)(?: the (?:last|past|next))? (?${NUM}) (?:hours|hrs)`, `(?for(?: the (?:last|past|next))? (?:${NUM}) (?:days|weeks)|for a week)`, '(?a week|per week|every week|weekly)', `every (?${NUM}) (?:hours|hrs)`, ].join('|')})(?![a-z0-9])`; /** How often, soon after the count and in the same clause. */ const FREQUENCY = new RegExp(`^${SAME_CLAUSE}{0,4}? ${FREQUENCY_WORDS}`); /** How often, straight after a count that adds to one ("... and 1 at night"). */ const FREQUENCY_NEXT = new RegExp(`^ ${FREQUENCY_WORDS}`); /** * LOW: more of the same paracetamol in the same sentence: "2 four times a day and 1 at night", "2 at lunch, 3 at night", and * (L3) a child's extra dose by volume: "Calpol 5ml 4 times a day and another 5ml at night". */ const MORE = new RegExp(`^(?: (?:and|plus|then|also|with|or|but))?(?: (?:another|an extra|extra))? (?:(?${NUM}|once)${NOT_A_STRENGTH}(?: (?${UNIT}))?|(?${STRENGTH}))` + '(?: more)?(?: of (?:them|those|it|these))?(?= )'); /** * L1, H-1.5: the day a schedule names, so "6 yesterday and 6 today" is two days of 6, not 12; null for any day ("a day", "at * night"). "Last night", "tonight", "this morning", "today" and "since" are one rolling 24 hours ("5 last night and 5 this * morning" is 10); only "yesterday" is a day of its own. */ type Day = 'yesterday' | 'today' | null; const dayOf = (schedule: string): Day => (/(?${NUM}) (?:year|yr)s? old(?![a-z0-9])`); const isChild = (clause: string) => CHILD.test(clause) || (YOUNG.exec(clause)?.groups ? valueOf(YOUNG.exec(clause)!.groups!.age) < 16 : false); /** * Asked about doing it, not done: "Can I take 10 paracetamol a day?", "Is it * ok to take...", "If I take...", "Is 10 a day too much?". Not when it is * being taken: "I take 12 a day", "Can she keep taking 10 a day?", "If she * has been taking...". */ // L2: a child's age may sit on either side of the verb: "Can my 5 year old have...", "Can I give my 3 year old Calpol...". const ASKED_VERB = /(?:^| )(?:can|could|should|shall|may|might|would|will|to|if|fit|make|do)((?: [a-z0-9]+){0,4}) (?:take|have|give|use|swallow|drink|eat|taking|having|giving|using)(?: [a-z0-9]+){0,4} $/; const ALREADY = /(?:^| )(?:is|are|am|was|were|been|be|has|had|have|did|dey|don|keep|keeps|continue|continues|still|carry|stop)(?= |$)/; const ASKED_NO_VERB = /^(?:is|isnt|are|arent|would|will|wouldnt|can|could|how about|what about)(?: [a-z]+){0,3} $/; const TAKEN_WORD = /(?:^| )(?:take|takes|took|taken|taking|give|gives|gave|given|giving|swallow|swallowed|had|has|been|used|using)(?= |$)/; function asked(before: string): boolean { const verb = ASKED_VERB.exec(before); if (verb) return !ALREADY.test(verb[1]); return ASKED_NO_VERB.test(before) && !TAKEN_WORD.test(before); } const DOSE_UNIT_ONLY = new RegExp(`^${DOSE_UNIT}$`); /** * J1: paracetamol over the most in 24 hours, in symptom text or a question: * more than 8 tablets a day, more than 4 doses a day of a children's product, * or a total over several days that averages more than 8 a day ("took 30 over * 3 days"). Everything a sentence says about one day is added up (LOW): "2 * four times a day and 1 at night" is 9, but "6 yesterday and 6 today" is 6 * a day (L1). "taken" when it was or is being taken ("took", "has * been taking", "takes", "gave her"), "asked" when the question is about doing * it, null when neither. Taken wins when a text says both. */ export function paracetamolOverLimit(text: string | null | undefined): 'taken' | 'asked' | null { let found: 'taken' | 'asked' | null = null; // LOW, M-E: amounts at clock times, wherever they are in the text, are one day's paracetamol. R9-M6: each one only with the // paracetamol name in it, or as the nearest name before it with nothing but amounts, times and joining words in between; or, // for a list that opens the text ("1g at 8am, 1g at 2pm ... of paracetamol"), the first name after it, the same way. const whole = normalizeText(text ?? ''); const named = [...whole.matchAll(MEDICINE_NAMED)]; const ofParacetamol = (at: RegExpMatchArray) => { if (at.groups!.name) return true; const before = named.filter((name) => name.index! + name[0].length <= at.index!).pop(); if (before) return Boolean(before.groups?.paracetamol) && RUN.test(whole.slice(before.index! + before[0].length, at.index!) || ' '); const after = named.find((name) => name.index! >= at.index!); const opening = whole.slice(0, at.index!).replace(LIST_OPENING, ''); return (!opening.trim() || RUN.test(opening)) && Boolean(after?.groups?.paracetamol) && RUN.test(whole.slice(at.index!, after!.index!) || ' '); }; const atTimes = [...whole.matchAll(AT_A_TIME)].filter(ofParacetamol); const atTimesMg = atTimes.reduce((sum, at) => sum + (at.groups!.mass ? Number(at.groups!.mass) * (/^(?:g|gm|gms|grams?)$/.test(at.groups!.massUnit) ? 1000 : 1) : 0), 0); const atTimesTablets = atTimes.reduce((sum, at) => sum + (at.groups!.count ? valueOf(at.groups!.count) : 0), 0); const atTimesChildDoses = isChild(whole) ? atTimes.filter((at) => at.groups!.volume).length : 0; if (atTimesMg > PARACETAMOL_MG_A_DAY || atTimesTablets > PARACETAMOL_TABLETS_A_DAY || atTimesChildDoses > CHILD_PARACETAMOL_DOSES_A_DAY) { if (!asked(whole.slice(0, atTimes[0].index!))) return 'taken'; found = 'asked'; } // Sentences end at "." only before a space, so "2.5 ml" stays whole. for (const raw of (text ?? '').split(/(?:[!?;\n]|\.(?=\s|$))+/)) { const clause = normalizeText(raw); if (!clause) continue; const child = isChild(clause); // In the order written, each once: "Calpol 5 ml four times a day" is one dose statement, not two. const matches = [...clause.matchAll(COUNT_FIRST), ...clause.matchAll(NAME_FIRST), ...clause.matchAll(NAME_TIMES), ...clause.matchAll(MASS_FIRST)] .sort((a, b) => a.index! - b.index!); // L1: what each day comes to; "daily" is any day ("a day", "at night"), and counts towards today and yesterday too. const units = { daily: 0, today: 0, yesterday: 0 }; const childDoses = { daily: 0, today: 0, yesterday: 0 }; const mg = { daily: 0, today: 0, yesterday: 0 }; let end = 0; let taken = false; let counted = false; for (const match of matches) { if (match.index! < end) continue; const groups = match.groups!; if (groups.mass) { // L4: a total in mg ("8000mg of paracetamol today"); with no schedule, taken at once. const total = Number(groups.mass) * (/^(?:g|gm|gms|grams?)$/.test(groups.massUnit) ? 1000 : 1); const at = match.index! + match[0].length; if (!groups.of && total <= 1000) { // LOW: a dose so many times a day is the dose times the doses: "1g paracetamol 6 times a day" is 6 g. const doses = perDay(null, clause.slice(at), FREQUENCY_NEXT); if (!doses?.doses) continue; counted = true; taken ||= !asked(clause.slice(0, match.index!)); mg[doses.day ?? 'daily'] += total * doses.doses; end = at + doses.length; continue; } const day = perDay(total, clause.slice(at)) ?? { units: total, doses: null, length: 0, day: null }; counted = true; taken ||= !asked(clause.slice(0, match.index!)); mg[day.day ?? 'daily'] += day.units ?? 0; end = at + day.length; continue; } const childProduct = child || /calpol|baby|infant/.test(groups.name); // An adult's doses ("6 doses a day") aren't counted: only tablets, or a bare count ("12 Panadol a day"). if (!childProduct && DOSE_UNIT_ONLY.test(groups.unit ?? groups.after ?? groups.form ?? '')) continue; let at = match.index! + match[0].length; const day = perDay(groups.count ? valueOf(groups.count) : null, clause.slice(at)); if (!day) continue; counted = true; taken ||= !asked(clause.slice(0, match.index!)); const statementDay = day.day ?? 'daily'; units[statementDay] += day.units ?? 0; if (childProduct) childDoses[statementDay] += day.doses ?? 0; // LOW: a dose in mg, with no tablets, so many times a day: "paracetamol 1g 6 times a day" and "paracetamol 1g x 5 a day" are // 6 g and 5 g. Never with tablets ("paracetamol 1g, 2 tablets 4 times a day" can be 2 x 500 mg). const dose = groups.unit || (groups.count && !groups.x) ? null : doseMg(match[0]); if (dose !== null) mg[statementDay] += dose * ((groups.count ? day.units : day.doses) ?? 0); at += day.length; // More of the same, straight after: "... and 1 at night" (the same day), "... and 6 today" (its own day). for (let more = MORE.exec(clause.slice(at)); more; more = MORE.exec(clause.slice(at))) { // A volume ("another 5ml") is one dose, and never tablets. const volume = Boolean(more.groups!.volume); const next = perDay(volume || more.groups!.count === 'once' ? 1 : valueOf(more.groups!.count), clause.slice(at + more[0].length), FREQUENCY_NEXT); if (!next) break; const nextDay = next.day ?? statementDay; if (!volume) units[nextDay] += next.units ?? 0; if (childProduct) childDoses[nextDay] += next.doses ?? 0; at += more[0].length + next.length; } end = at; } const most = (totals: { daily: number; today: number; yesterday: number }) => totals.daily + Math.max(totals.today, totals.yesterday); if (!counted || (most(units) <= PARACETAMOL_TABLETS_A_DAY && most(childDoses) <= CHILD_PARACETAMOL_DOSES_A_DAY && most(mg) <= PARACETAMOL_MG_A_DAY)) continue; if (taken) return 'taken'; found = 'asked'; } return found; }