marmar-context / src / domain / checks / clinician-wording.ts
clinician-wording.ts
Raw
import { detailFor } from './allergy-detail.js';
import { clinicianWordingProblems } from './clinician-guard.js';
import { ALLERGY_CLASS_WORDS, ALLERGY_WORDING, CHILD_WORDING, ENGINE_WORDING, INTERACTION_WORDING, LAB_WORDING, MONITORING_WORDING, OLDER_WORDING,
  type ClinicianWordingEntry } from './clinician-wording-rules.js';
import type { CheckFinding, CheckInput } from './engine.js';
import { normalizeText } from './normalize.js';
import { MONITORING_RULES } from './rules.js';
import { CHECK_SOURCES, SPECIFIC_REFERENCES, type CheckSourceId } from './sources.js';

/**
 * CHK-5 (clinicianWording): renders the draft clinician wording (clinician-wording-rules.ts) for a finding from the
 * facts the engine recorded. Each text (before its "Basis:" list of source names) is checked by clinician-guard.ts with
 * the medicine names masked; a text with a problem is not shown (null), so no instruction ever reaches a clinician.
 */

const joinNames = (names: string[]) => (names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);
const number = (value: number) => Math.round(value).toLocaleString('en-GB');
export const clinicianDate = (iso: string) => new Date(`${iso.slice(0, 10)}T00:00:00Z`)
  .toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });

const fillAll = (text: string, values: Record<string, string>) => text.replace(/\{(\w+)\}/g, (whole, key: string) => values[key] ?? whole);

/** The sources an explanation rests on: the entry's own basis or the rule's, then its specific references for these ingredients. */
function basis(entry: { basis?: CheckSourceId[] } | null, finding: CheckFinding, ingredientKeys: string[] | null): string {
  const ids = [...(entry?.basis ?? finding.source_ids)];
  for (const reference of SPECIFIC_REFERENCES[finding.ruleId] ?? []) {
    if (reference.appliesTo && ingredientKeys && !reference.appliesTo.some((key) => ingredientKeys.includes(key))) continue;
    if (!ids.includes(reference.sourceId)) ids.push(reference.sourceId);
  }
  return ids.length ? ` Basis: ${ids.map((id) => CHECK_SOURCES[id].name).join('; ')}.` : '';
}

function ageText(facts: Record<string, unknown>): string {
  const range = facts.ageRange as { min: number; max: number } | null | undefined;
  if (range && range.min !== range.max) return `age ${range.min} to ${range.max}`;
  const age = (facts.age as number | null | undefined) ?? range?.min;
  return age === null || age === undefined ? 'an age not recorded' : `age ${age}`;
}

function resultText(facts: Record<string, unknown>): string {
  const code = String(facts.code ?? '');
  const unit = facts.unit && !['egfr', 'inr'].includes(code) ? ` ${String(facts.unit)}` : '';
  const how = facts.calculated ? ', calculated from creatinine with CKD-EPI 2021' : '';
  return `${String(facts.test)} ${String(facts.printed)}${unit}${how} (${clinicianDate(String(facts.observedAt))})`;
}

function reactionText(allergy: string, input: CheckInput): string {
  const detail = detailFor(allergy, input.allergyDetails);
  if (!detail || (!detail.reaction && !detail.reactionKind)) return '; no reaction recorded';
  const when = [detail.onset && detail.onset !== 'unknown' ? `${detail.onset} onset` : '', detail.year ? String(detail.year) : ''].filter(Boolean).join(', ');
  return `; recorded reaction: ${detail.reaction ?? detail.reactionKind}${when ? ` (${when})` : ''}`;
}

function entryFor(finding: CheckFinding): { title: string; why: string; entry: ClinicianWordingEntry | null } | null {
  const id = finding.ruleId;
  if (INTERACTION_WORDING[id]) return { title: '{a} with {b}', why: INTERACTION_WORDING[id].why, entry: INTERACTION_WORDING[id] };
  if (OLDER_WORDING[id]) return { title: '{a} at {age}', why: OLDER_WORDING[id].why, entry: OLDER_WORDING[id] };
  if (CHILD_WORDING[id]) return { title: '{a} at {age}', why: CHILD_WORDING[id].why, entry: CHILD_WORDING[id] };
  if (LAB_WORDING[id]) return { title: '', why: LAB_WORDING[id].why, entry: LAB_WORDING[id] };
  if (id.startsWith('dose-max-')) return { ...ENGINE_WORDING['dose-max'], entry: null };
  if (id.startsWith('duplicate-')) return { ...ENGINE_WORDING.duplicate, entry: null };
  if (id === 'paracetamol-lower-maximum') return { ...ENGINE_WORDING['paracetamol-lower-maximum'], entry: null };
  return null;
}

/** A finding's clinician title and explanation, or null when it has none or it fails the wording check. */
export function clinicianWording(finding: CheckFinding, facts: Record<string, unknown>, input: CheckInput): { title: string; why: string } | null {
  const names = finding.medicines.map((medicine) => medicine.name);
  const values: Record<string, string> = { a: names[0] ?? '', b: names[1] ?? '', medicines: joinNames(names), age: ageText(facts) };
  let title: string;
  let why: string;
  let entry: ClinicianWordingEntry | null = null;
  if (finding.kind === 'allergy') {
    const allergy = String(facts.allergy ?? '');
    const ingredients = (facts.ingredients as string[] | undefined) ?? [];
    const sameName = ingredients.length === 1 && normalizeText(ingredients[0]) === normalizeText(values.a);
    const kind = (facts.matchKind as 'ingredient' | 'class' | 'cross' | undefined) ?? 'ingredient';
    // "Penicillin allergy" as recorded reads "a recorded Penicillin allergy", never "allergy allergy".
    Object.assign(values, { allergy: allergy.replace(/\s+allerg(?:y|ies)$/i, ''), class: ALLERGY_CLASS_WORDS[finding.ruleId] ?? '', reaction: reactionText(allergy, input),
      subject: sameName || !ingredients.length ? `${values.a} is` : `${values.a} contains ${joinNames(ingredients.map((name) => name.toLowerCase()))},` });
    title = ALLERGY_WORDING.title;
    why = `${ALLERGY_WORDING[kind]}${facts.readAs ? ` The allergy entry was read as ${String(facts.readAs)}.` : ''}`;
  } else {
    const found = entryFor(finding);
    if (!found) return null;
    entry = found.entry;
    why = found.why;
    title = found.title;
    if (finding.kind === 'lab') {
      Object.assign(values, { a: String(facts.a ?? ''), b: String(facts.b ?? ''), result: resultText(facts), threshold: String(facts.threshold ?? ''), test: String(facts.test) });
      if (!facts.a && entry?.whyAlone) why = entry.whyAlone;
      title = facts.a ? '{a} and the latest {test} result' : 'The latest {test} result';
      if (facts.crossing === 'unknown') why = `The result was printed as a bound ("${String(facts.printed)}"); this applies to the highest-risk value it allows. ${why}`;
      if (facts.implausible) why = `The result (${String(facts.printed)}) is outside the range the engine treats as plausible and may have been entered wrongly. ${why}`;
    } else if (finding.kind === 'dose' && finding.ruleId.startsWith('dose-max-')) {
      const parts = (facts.parts as Array<{ ingredient: string; total: number; max: number }> | undefined) ?? [];
      values.amounts = joinNames(parts.map((part) => `about ${number(part.total)} mg of ${part.ingredient} a day, above the adult label maximum of ${number(part.max)} mg in 24 hours`));
    } else if (finding.kind === 'duplicate') {
      const total = facts.total as number | null;
      const max = facts.max as number | null;
      Object.assign(values, { ingredient: String(facts.ingredient ?? ''), total: total === null || total === undefined
        ? '; the combined daily amount could not be worked out'
        : `; together about ${number(total)} mg a day${max ? `, against an adult label maximum of ${number(max)} mg` : ''}` });
      values.ingredient = values.ingredient.toLowerCase();
      title = title.replace('{ingredient}', String(facts.ingredient ?? ''));
    } else if (finding.ruleId === 'paracetamol-lower-maximum') {
      Object.assign(values, { total: number(Number(facts.total)), reason: facts.reason === 'weight_under_50_kg' ? 'with a recorded weight under 50 kg' : 'at age 75 or over' });
    }
  }
  // The basis is source names (data): the check covers MARMAR's own words.
  const body = { title: fillAll(title, values), why: fillAll(why, values) };
  if ([...clinicianWordingProblems(body.title, names), ...clinicianWordingProblems(body.why, names)].length) return null;
  return { title: body.title, why: `${body.why}${basis(entry, finding, (facts.ingredientKeys as string[] | undefined) ?? null)}` };
}

/** A test's clinician title and why (FND-7), from the first rule that asked for it. */
export function clinicianTestWording(ruleId: string | undefined, medicines: string[]): { title: string; why: string } | null {
  const rule = MONITORING_RULES.find((candidate) => candidate.id === ruleId);
  const entry = ruleId ? MONITORING_WORDING[ruleId] : undefined;
  if (!rule || !entry) return null;
  const title = `${rule.test} · needed with ${joinNames(medicines)}`;
  if ([...clinicianWordingProblems(title, medicines), ...clinicianWordingProblems(entry.why, medicines)].length) return null;
  return { title, why: `${entry.why} Basis: ${rule.sources.map((id) => CHECK_SOURCES[id].name).join('; ')}.` };
}