marmar-context / web / context / src / coverage.ts
coverage.ts
Raw
import type { Fact, Source } from "./types";
import { policyLines } from "../server/source-policy";

export const COVERAGE_VERSION = "medication-spans-v1";
export type CoverageKind = "medicine" | "allergy" | "kidney";
export type CoverageCandidate = {
  id: string;
  /** The backend also finds allergies and kidney results (REV-4); the preview finds medicines only. */
  kind?: CoverageKind;
  sourceId: string;
  line: number;
  start: number;
  end: number;
  text: string;
  quote: string;
  matchedFactIds: string[];
  contextExclusion: string | null;
};
export type CoverageChoice = "not_current" | "not_relevant";
export type CoverageReview = Record<string, {
  fingerprint: string;
  /** The backend's choice; the preview's older resolutions have none and are read as not relevant. */
  choice?: CoverageChoice;
  reason: string;
  confirmedBy: string;
}>;
export const RESOLUTION_REASON_MIN = 10;
const normal = (text: string) => text.toLowerCase().replace(/\s+/g, " ").trim();
// How often or when, never a medicine: "And agbo, sometimes." (a text matcher, not a rule).
const HOW_OFTEN = "sometimes|usually|often|occasionally|rarely|seldom|always|never|regularly|mostly|frequently|nightly|weekly|monthly";
const nonMedicine = new RegExp(`^(?:patient|age|weight|dose|strength|frequency|take|takes|taking|prescribed|tablet|tablets|capsule|capsules|one|two|three|four|half|daily|once|twice|unknown|none|no|was|is|were|are|it|her|his|the|also|too|again|now|still|only|${HOW_OFTEN})$`, "i");
const leadingHowOften = new RegExp(`^\\s*(?:(?:${HOW_OFTEN}|also|only|still|now|again)\\s+)+`, "i");
const nameEnds = new RegExp(`\\s+\\d|\\b(?:once|twice|daily|every|at|for|as|with|without|was|is|has|had|after|before|when|whenever|if|${HOW_OFTEN})\\b|[.!?()]`, "i");

/** Bounded English candidate detection, independent of model output and drug rules.
 * Offsets are UTF-16 indices into the original source, end-exclusive. This is a
 * review aid, not a claim of exhaustive terminology or semantic coverage.
 */
export function medicationCoverage(sources: Source[], facts: Fact[], fingerprint: string): CoverageCandidate[] {
  const result: CoverageCandidate[] = [];
  for (const source of sources) {
    let offset = 0;
    const policies = policyLines(source.text);
    for (const [index, raw] of source.text.split("\n").entries()) {
      const line = index + 1;
      const base = offset;
      offset += raw.length + 1;
      if (policies[index].blocked || /^\s*(?:allerg(?:y|ies)|lab|condition|patient|weight|age)\s*:/i.test(raw)) continue;
      const spans: {start: number; end: number}[] = [];
      const add = (start: number, value: string) => {
        const trimmed = value.trim();
        if (!trimmed || /^(?:one|two|three|four|half|\d+)\s+(?:tablet|capsule)s?$/i.test(trimmed) || nonMedicine.test(trimmed) || !/^[\p{L}][\p{L}\p{N}' -]*$/u.test(trimmed)) return;
        start += value.indexOf(trimmed);
        spans.push({start, end: start + trimmed.length});
      };
      // Names beside an explicit strength, including previously unseen brands.
      for (const match of raw.matchAll(/\b([\p{L}][\p{L}\p{N}'-]*)\s+\d+(?:[.,]\d+)?\s*(?:mg|mcg|g|ml|units?)\b/giu)) add(match.index!, match[1]);
      // Labelled medication identity, and bare lists introduced by medication verbs.
      const labelled = /^\s*Medication:\s*([^;]+)/i.exec(raw);
      if (labelled) {
        const valueStart = raw.indexOf(labelled[1]);
        for (const part of labelled[1].matchAll(/[^,]+?(?=\s+and\s+|,|$)/gi)) {
          const value = part[0].replace(/^\s*and\s+/i, "").split(/\s+\d/)[0];
          add(valueStart + part.index! + part[0].indexOf(value), value);
        }
      }
      let inList = false;
      for (const part of raw.matchAll(/[^;,]+?(?=\s+and\s+|\s+but\s+|;|,|$)/gi)) {
        const clause = part[0];
        const cue = /\b(?:takes?|taking|prescribed|medicines?|medications?)\s*:?[ \t]+/i.exec(clause);
        if (cue) inList = true;
        if (!inList || labelled) continue;
        const begin = cue ? cue.index + cue[0].length : (/^\s*(?:and|but)\s+/i.exec(clause)?.[0].length ?? 0);
        const tail = clause.slice(begin);
        const name = tail.replace(leadingHowOften, "").split(nameEnds)[0].trim();
        if (name.split(/\s+/).length <= 3) add(part.index! + begin + tail.indexOf(name), name);
      }
      // Prefer the longest overlapping candidate, preserving repeated occurrences.
      const unique = spans.filter((span, i) => !spans.some((other, j) =>
        j !== i && other.start <= span.start && other.end >= span.end &&
        (other.end - other.start > span.end - span.start || j < i),
      )).sort((a, b) => a.start - b.start);
      for (const span of unique) {
        const text = raw.slice(span.start, span.end);
        const prefix = raw.slice(0, span.start).split(/[;,]|\band\b|\bbut\b/i).at(-1)!;
        // Only explicit local clauses are automatically classified. Ambiguous
        // pronouns and cross-line subjects remain for human disposition.
        const family = /\b(?:mother|father|sister|brother|daughter|son|wife|husband)\b/i.test(prefix);
        const patient = /\bpatient\b/i.test(prefix);
        const notCurrent = /\b(?:stopped|discontinued|withheld|denies taking|does not take|not taking|no longer takes|consider taking)\b/i.test(prefix);
        const contextExclusion = family && !patient ? "Explicit relative in this clause" : notCurrent ? "Explicit non-current or conditional use in this clause" : null;
        const matchedFactIds = facts.filter((fact) => {
          if (fact.kind !== "medicine" || normal(fact.label) !== normal(text)) return false;
          if (fact.sourceId === source.id && fact.line === line) return true;
          const clarification = sources.find((s) => s.id === fact.sourceId)?.clarification;
          return clarification?.sourceId === source.id && clarification.line === line && clarification.quote === raw.trim();
        }).map((fact) => fact.id);
        result.push({id: `${fingerprint}:${source.id}:${base + span.start}:${base + span.end}`, sourceId: source.id, line,
          start: base + span.start, end: base + span.end, text, quote: raw.trim(), matchedFactIds, contextExclusion});
      }
    }
  }
  return result;
}

/** The backend's rule (src/clinician/context/coverage.ts): a reason is needed with no choice, and always for an allergy. */
export function validCoverageResolution(value: CoverageReview[string] | undefined, fingerprint: string, kind: CoverageKind = "medicine") {
  if (!value || value.fingerprint !== fingerprint || !value.confirmedBy?.trim() || value.confirmedBy.length > 120) return false;
  if ((value.reason ?? "").length > 500) return false;
  if (value.choice !== undefined && value.choice !== "not_current" && value.choice !== "not_relevant") return false;
  const needsReason = value.choice === undefined || kind === "allergy";
  return !needsReason || (value.reason ?? "").trim().length >= RESOLUTION_REASON_MIN;
}
export function unresolvedCoverage(candidates: CoverageCandidate[], review: CoverageReview, fingerprint: string) {
  return candidates.filter((c) => !c.contextExclusion && !c.matchedFactIds.length && !validCoverageResolution(review[c.id], fingerprint, c.kind ?? "medicine"));
}