marmar-context / src / domain / checks / allergy-detail.ts
allergy-detail.ts
Raw
import { normalizeText } from './normalize.js';
import type { AllergyDetail, ReactionClass } from './options.js';
import type { Tier } from './rules.js';

/**
 * CHK-6a (allergyTiers): how a recorded reaction is read. Swelling, breathing trouble and blistering skin (NICE CG183
 * recommendation 1.1.1) are urgent; so is anything not readable as a milder reaction. Draft, for the clinical lead.
 */

const word = (source: string) => new RegExp(`(?<![a-z0-9])(?:${source})(?![a-z0-9])`);

/** Over-matches on purpose ("no swelling" still matches): a false urgent costs a call, a missed one can cost a life. */
const SWELLING = word('swell|swells|swelled|swelling|swollen|angioedema|angio oedema|angio edema|oedema|edema|puffy face|face swell|body swell|lips? swell|tongue swell|throat swell');
const BREATHING = word('breath|breathe|breathing|breathless|breathlessness|short of breath|shortness of breath|wheeze|wheezing|wheezy|bronchospasm|stridor|'
  + 'anaphylaxis|anaphylactic|airway|throat (?:closing|tight|tightness)|cant breathe|could not breathe|couldnt breathe|no fit breathe|collapse|collapsed');
const BLISTERING = word('blister|blisters|blistered|blistering|peel|peeling|peeled|skin (?:came off|coming off|detach[a-z]*)|stevens johnson|sjs|'
  + 'toxic epidermal necrolysis|epidermal necrolysis|mucosal|erosions?');
/** Early signs of a severe skin reaction (CG183 table 3): never read as mild. */
const NOT_MILD = word('fever|painful rash|mouth (?:sores|ulcers)|sore mouth|eye (?:sores|ulcers)|dress|organ|liver|kidney|purple');
/** Reactions that are read as not severe, when nothing above is said. */
const MILD = word('rash|rashes|hives|urticaria|itch|itchy|itching|itches|nettle rash|red skin|redness|nausea|nauseous|vomit|vomiting|vomited|'
  + 'diarrhoea|diarrhea|stomach upset|upset stomach|stomach pain|headache|thrush');

const sameEntry = (a: string, b: string) => normalizeText(a) === normalizeText(b);

/** The detail recorded for an allergy entry, if any. */
export function detailFor(allergy: string, details: readonly AllergyDetail[] | undefined): AllergyDetail | null {
  return details?.find((detail) => sameEntry(detail.allergy, allergy)) ?? null;
}

/** How the engine reads a recorded reaction. With nothing recorded, unknown. */
export function classifyReaction(detail: AllergyDetail | null): ReactionClass {
  if (!detail) return 'unknown';
  const kind = detail.reactionKind ?? null;
  if (kind === 'swelling' || kind === 'breathing' || kind === 'blistering') return kind;
  if (kind === 'anaphylaxis') return 'breathing';
  const text = normalizeText(detail.reaction ?? '');
  if (text) {
    if (SWELLING.test(text)) return 'swelling';
    if (BREATHING.test(text)) return 'breathing';
    if (BLISTERING.test(text)) return 'blistering';
  }
  if (detail.severity === 'severe') return 'severity_severe';
  if (text) return !NOT_MILD.test(text) && MILD.test(text) ? 'not_severe' : 'unknown';
  // A chosen mild kind, with no text to contradict it.
  return kind && kind !== 'other' && kind !== 'unknown' ? 'not_severe' : 'unknown';
}

const RANK: Record<Tier, number> = { get_help_now: 4, today: 3, this_week: 2, good_to_know: 1 };

/**
 * The tier of an allergy finding with allergyTiers on. A medicine with the allergy's ingredient or in its class is at
 * least `today`, and `get_help_now` (Context: Urgent review) when the reaction was severe or is unknown. A possible
 * cross-reaction (a cephalosporin with a penicillin allergy) keeps the rule's own tier: the PRD covers same-class matches.
 */
export function allergyTier(matchKind: 'ingredient' | 'class' | 'cross', tier: Tier, reaction: ReactionClass): Tier {
  if (matchKind === 'cross') return tier;
  if (reaction !== 'not_severe') return 'get_help_now';
  return RANK[tier] < RANK.today ? 'today' : tier;
}

/** The recorded detail as it goes on the finding, with how it was read. */
export function reactionOutput(allergy: string, detail: AllergyDetail | null) {
  return {
    allergy,
    reaction: detail?.reaction ?? null,
    reaction_kind: detail?.reactionKind ?? null,
    onset: detail?.onset ?? null,
    severity: detail?.severity ?? null,
    year: detail?.year ?? null,
    reaction_class: classifyReaction(detail),
  };
}
export type AllergyReactionOutput = ReturnType<typeof reactionOutput>;