marmar-context / src / domain / checks / clinician-check.ts
clinician-check.ts
Raw
import { allergyTier, classifyReaction, detailFor, reactionOutput } from './allergy-detail.js';
import { clinicianTestWording, clinicianWording } from './clinician-wording.js';
import type { SkippedRule } from './clinician-types.js';
import { buildCoverage } from './coverage.js';
import { calculateEgfr } from './egfr.js';
import type { CheckInput, CheckResult } from './engine.js';
import { kidneyDoseHook } from './kidney-dose.js';
import { CLINICIAN_OPTIONS_VERSION, optionsInUse, type CheckOptions, type EngineHooks } from './options.js';
import { acceptedSignOffs, ruleReview } from './signed-off-mode.js';
import { normalizeText } from './normalize.js';
import { engineCodedRuleFor } from './signoff.js';
import { SPECIFIC_REFERENCES } from './sources.js';

/**
 * runCheck with options on (MARMAR Context only). The analysis gets hooks and records a trace; the result then gains
 * the options' additive fields. Nothing here runs for the app, which calls runCheck without options.
 */

type Medicine = { id: string | null; name: string };

/** What the analysis records when it has hooks (engine.ts analyse). */
export interface EngineTrace {
  skipped: Array<{ ruleId: string; medicines: Medicine[] }>;
  /** By finding id: what each finding was worked out from. */
  facts: Map<string, Record<string, unknown>>;
  /** By test name: the medicines that need it and the rules that asked for it. */
  monitoring: Map<string, { medicines: Medicine[]; ruleIds: string[] }>;
  /** Lab rule groups with a matching medicine but no result. */
  missingLabs: Array<{ labCode: string; medicines: Medicine[] }>;
  /** The medicines that made a rule read the weight (the paracetamol maximum reads it when paracetamol is taken). */
  weightReadBy: Medicine[];
  /** Lab codes whose latest value a lab rule read. */
  labsRead: Set<string>;
}

type Analyse = (input: CheckInput, hooks?: EngineHooks, trace?: EngineTrace) => { result: CheckResult; held: string[] };

const SIGNED_OFF_PLAIN = 'Only rules whose current content a sign-off approved produced findings. Rules waiting for review are listed, not run.';
const CLINICIAN_PLAIN = 'Engine options for MARMAR Context, all drafts waiting for the clinical safety lead\'s sign-off.';

export function checkWithOptions(input: CheckInput, options: CheckOptions, analyse: Analyse): CheckResult {
  const defaultRun = (each: CheckInput) => analyse(each).result;
  const egfr = options.egfrFromCreatinine ? calculateEgfr(input) : null;
  const used: CheckInput = egfr?.lab ? { ...input, labs: [...input.labs, egfr.lab] } : input;
  const mode = options.signedOffOnly ? options.signedOffOnly : null;
  const accepted = acceptedSignOffs(mode ?? true);
  const hooks: EngineHooks = {
    calculatedLab: egfr?.lab ?? null,
    ...(mode ? { allow: (ruleId: string) => ruleReview(ruleId, accepted, defaultRun) === 'signed_off' } : {}),
    ...(options.allergyTiers ? { allergyTier: ({ allergy, matchKind, tier }) =>
      allergyTier(matchKind, tier, classifyReaction(detailFor(allergy, input.allergyDetails))) } satisfies EngineHooks : {}),
  };
  const trace: EngineTrace = { skipped: [], facts: new Map(), monitoring: new Map(), missingLabs: [], weightReadBy: [], labsRead: new Set() };
  if (options.kidneyDoseLimits) {
    const kidney = kidneyDoseHook(used.labs, used.today, egfr?.lab ?? null);
    // signedOffOnly: the kidney-adjusted line is an engine-coded rule too; until it is signed off the usual line shows.
    const allowed = !mode || ruleReview('kidney-dose-metformin', accepted, defaultRun) === 'signed_off';
    hooks.kidneyDose = allowed ? kidney : (context) => {
      if (kidney(context)) trace.skipped.push({ ruleId: 'kidney-dose-metformin', medicines: used.medicines.filter((medicine) => medicine.name === context.medicine)
        .map((medicine) => ({ id: medicine.id, name: medicine.name })) });
      return null;
    };
  }
  const { result } = analyse(used, hooks, trace);

  for (const finding of result.findings) {
    const facts = trace.facts.get(finding.id) ?? {};
    if (options.clinicianWording) {
      const wording = clinicianWording(finding, facts, used);
      if (wording) Object.assign(finding, { clinician_title: wording.title, clinician_why: wording.why, clinician_wording_review: 'draft' as const });
    }
    if (options.allergyTiers && finding.kind === 'allergy' && typeof facts.allergy === 'string') {
      finding.allergy_reaction = reactionOutput(facts.allergy, detailFor(facts.allergy, input.allergyDetails));
      finding.tier_without_reaction = facts.ruleTier as CheckResult['findings'][number]['tier'];
    }
    if (options.specificReferences) {
      const keys = (facts.ingredientKeys as string[] | undefined) ?? null;
      finding.specific_references = (SPECIFIC_REFERENCES[finding.ruleId] ?? [])
        .filter((reference) => !reference.appliesTo || !keys || reference.appliesTo.some((key) => keys.includes(key))).map((reference) => ({ ...reference }));
    }
    if (options.coverage || mode) finding.rule_review = ruleReview(finding.ruleId, accepted, defaultRun);
  }

  for (const test of result.tests_to_ask_about) {
    const asked = trace.monitoring.get(test.test);
    if (options.monitoringMedicines && asked) Object.assign(test, { medicines: asked.medicines, rule_ids: asked.ruleIds });
    if (options.clinicianWording && asked) {
      const wording = clinicianTestWording(asked.ruleIds[0], asked.medicines.map((medicine) => medicine.name));
      if (wording) Object.assign(test, { clinician_title: wording.title, clinician_why: wording.why });
    }
  }

  const skipped = skippedRules(trace, accepted, defaultRun);
  if (egfr) result.calculated_egfr = egfr.output;
  if (mode) result.signed_off_only = { accepted_sign_offs: accepted, skipped, plain: SIGNED_OFF_PLAIN };
  if (options.coverage) {
    const ran = result.findings.filter((finding) => finding.rule_review && finding.rule_review !== 'signed_off')
      .map((finding) => ({ rule_id: finding.ruleId, kind: engineCodedRuleFor(finding.ruleId) ? 'engine' as const : 'table' as const,
        review: finding.rule_review!, medicines: finding.medicines, ran: true }));
    result.coverage = buildCoverage({ input: used, result, trace, egfr: egfr?.output ?? null, waiting: [...ran, ...skipped.map((rule) => ({ ...rule, ran: false }))] });
  }
  result.clinician = { options_version: CLINICIAN_OPTIONS_VERSION, options: optionsInUse(options), wording_review: 'draft', plain: CLINICIAN_PLAIN };
  return result;
}

/** Each skipped rule once per set of medicines, with why it was skipped. */
function skippedRules(trace: EngineTrace, accepted: readonly string[], run: (input: CheckInput) => CheckResult): SkippedRule[] {
  const seen = new Map<string, SkippedRule>();
  for (const { ruleId, medicines } of trace.skipped) {
    const key = `${ruleId}|${medicines.map((medicine) => `${medicine.id}:${medicine.name}`).sort().join('+')}`;
    if (seen.has(key)) continue;
    seen.set(key, { rule_id: ruleId, kind: engineCodedRuleFor(ruleId) ? 'engine' : 'table', review: ruleReview(ruleId, accepted, run), medicines });
  }
  return [...seen.values()];
}

/** What the options add to checkFingerprint: the options, and the input fields only they read. */
export function fingerprintOptions(input: CheckInput, options: CheckOptions): unknown {
  return {
    v: CLINICIAN_OPTIONS_VERSION,
    o: optionsInUse(options),
    a: (input.allergyDetails ?? []).map((detail) => [normalizeText(detail.allergy), detail.reaction ?? null, detail.reactionKind ?? null, detail.onset ?? null,
      detail.severity ?? null, detail.year ?? null]).sort(),
    u: input.labs.map((lab) => [lab.code, lab.observedAt, lab.unit ?? lab.evidence?.result.unit ?? null]).sort(),
  };
}