marmar-context / web / context / server / guards.ts
guards.ts
Raw
import { parseLine } from "./context";
import { instructionLike } from "./source-policy";

const clean = (value: string) =>
  value
    .toLowerCase()
    .replace(/²/g, "2")
    .replace(/(?<!\d)\.|\.(?!\d)/g, " ")
    .replace(/[^a-z0-9<>/.]+/g, " ")
    .replace(/\s+/g, " ")
    .trim();
const contains = (quote: string, value: string) =>
  (" " + clean(quote) + " ").includes(" " + clean(value) + " ");
const escape = (value: string) => value.replace(/[.*+?^$()|[\]\\{}]/g, "\\$&");
const fields = (line: string) =>
  Object.fromEntries(
    line.split(";").map((part) => {
      const at = part.indexOf(":");
      return [
        part.slice(0, at).trim().toLowerCase(),
        part.slice(at + 1).trim(),
      ];
    }),
  );
const family =
  /\b(mother|father|sister|brother|daughter|son|wife|husband)\b|family history/i;
const uncertain =
  /\b(no|not|denies|denied|never|without|possible|suspected|maybe)\b|negative for|ruled out/i;
const unknown = /^(unknown|not known|not sure|not recorded)$/i;
const none = /^(none|no known (drug )?allergies|nkda)$/i;

// Select a bounded assertion without changing the original quote stored in the draft.
// A family mention elsewhere never licenses an ambiguous pronoun as this patient.
function assertionScope(normalized: string, quote: string, patients: string[]) {
  const f = fields(normalized);
  const parts = quote
    .split(/(?<=[.!?])\s+(?=[A-Z])|;|\b(?:but|whereas|however)\b/i)
    .map((s) => s.trim())
    .filter(Boolean);
  const anchor = (part: string) => {
    if (f.lab)
      return contains(part, f.lab) && (!f.value || contains(part, f.value));
    if (f.medication) return contains(part, f.medication);
    const history = f.allergies || f.allergy || f.condition;
    if (history)
      return none.test(history)
        ? /no known (drug )?allergies|\bnkda\b|allergies?:? none/i.test(part)
        : unknown.test(history)
          ? /unknown|not known|not sure|not recorded/i.test(part)
          : contains(part, history);
    if (f.weight) return /\d/.test(part) && /\b(kg|kilograms?)\b/i.test(part);
    if (f.sex) return /\b(female|male|woman|man)\b/i.test(part);
    if (f.pregnancy) return /pregnan|breast-?feed/i.test(part);
    return f.patient
      ? contains(part, f.patient)
      : f.age
        ? contains(part, f.age)
        : false;
  };
  const explicitPatient = (part: string) =>
    /\b(?:the patient|patient(?:'s)?)\b/i.test(part) ||
    patients.some((name) => {
      const first = name.split(/\s+/)[0];
      return (
        contains(part, name) || (first.length >= 3 && contains(part, first))
      );
    });
  const candidates = parts.filter(anchor);
  const own = candidates.filter(
    (part) => !family.test(part) && explicitPatient(part),
  );
  const evidence =
    own.length === 1 ? own[0] : candidates.length === 1 ? candidates[0] : quote;
  return {
    evidence,
    ambiguous:
      (candidates.length > 1 && own.length !== 1) ||
      (family.test(quote) &&
        !family.test(evidence) &&
        !explicitPatient(evidence)),
  };
}

/** Conservative lexical checks; original notes still require human review. */
export function groundedFact(
  normalized: string,
  quote: string,
  patients: string[] = [],
  priorFamilyContext = false,
): ReturnType<typeof parseLine> {
  if (instructionLike(quote))
    throw new Error("Instruction-like text cannot become clinical evidence.");
  const fact = parseLine(normalized);
  if (!fact) throw new Error("The model returned an unsupported fact type.");
  const direct = parseLine(quote);
  if (direct) {
    if (normalized !== quote)
      throw new Error("A structured source field was changed.");
    return fact;
  }
  if (
    fact.kind === "patient" &&
    patients.some(
      (name) =>
        clean(name) !== clean(fact.label) &&
        clean(name).startsWith(clean(fact.label) + " "),
    )
  )
    throw new Error(
      "A partial name cannot replace the patient identity already recorded in this source.",
    );
  const f = fields(normalized);
  const scoped = assertionScope(normalized, quote, patients);
  quote = scoped.evidence;
  const namesPatient =
    /\b(?:the patient|patient(?:'s)?)\b/i.test(quote) ||
    patients.some(
      (name) =>
        contains(quote, name) ||
        (name.split(/\s+/)[0].length >= 3 &&
          contains(quote, name.split(/\s+/)[0])),
    );
  if (scoped.ambiguous || (priorFamilyContext && !namesPatient))
    fact.problem =
      "The subject of this statement is unclear. Confirm whether it describes the patient or a family member.";
  if ((fact.kind === "patient" || fact.kind === "age") && family.test(quote))
    fact.problem =
      "This line mentions a family member; confirm whose identity or age it describes.";
  if (fact.kind === "patient" && !contains(quote, fact.label))
    throw new Error("Patient name is not present in its cited source.");
  if (
    fact.kind === "age" &&
    !new RegExp(
      "\\b" +
        escape(f.age) +
        "[- ]?(?:year|years|yo|y/o)\\b|\\baged?\\s+" +
        escape(f.age) +
        "\\b",
      "i",
    ).test(quote)
  )
    throw new Error("Age is not explicitly grounded.");
  if (fact.kind === "weight") {
    const kg = String(parseFloat(fact.label));
    if (
      !new RegExp(
        "(?<![\\d.])" + escape(kg) + "(?:\\.0)?\\s*(?:kg|kilograms?)\\b",
        "i",
      ).test(quote)
    )
      throw new Error("Weight is not explicitly grounded.");
    if (family.test(quote) || uncertain.test(quote))
      fact.problem =
        "This weight is qualified or may belong to someone else. Confirm it before inclusion.";
  }
  if (fact.kind === "sex") {
    if (
      !(fact.label === "female"
        ? /\b(female|woman)\b/i
        : /\b(male|man)\b/i
      ).test(quote)
    )
      throw new Error("Sex is not explicitly recorded in the quote.");
    if (family.test(quote))
      fact.problem =
        "This line mentions a family member; confirm whose sex it records.";
  }
  if (fact.kind === "pregnancy") {
    if (!/pregnan|breast-?feed/i.test(quote))
      throw new Error("Pregnancy status is not explicitly recorded.");
    if (
      family.test(quote) ||
      (fact.label !== "not pregnant" && uncertain.test(quote)) ||
      /\b(possible|possibly|suspected|maybe|might)\b/i.test(quote)
    )
      fact.problem =
        "This pregnancy status is negated, uncertain or about someone else. Confirm it before inclusion.";
  }
  if (fact.kind === "medicine") {
    if (!contains(quote, f.medication))
      throw new Error("Medicine name is not present in the quote.");
    // Conservative clause scope prevents borrowing a neighbouring medicine's strength.
    const clauses = quote
      .split(/;|\band\b|\bbut\b/i)
      .filter((part) => contains(part, f.medication));
    const evidence = clauses.length === 1 ? clauses[0] : quote;
    if (f.strength) {
      const strength = /^(\d+(?:\.\d+)?)\s*(mg|mcg|g)$/i.exec(f.strength);
      if (
        !strength ||
        !new RegExp(
          "(?<![\\d.])" + escape(strength[1]) + "\\s*" + strength[2] + "\\b",
          "i",
        ).test(evidence)
      )
        throw new Error(
          "Medicine strength is missing or belongs to another medication.",
        );
    }
    if (
      f.form &&
      !(
        f.form === "tablet"
          ? /\b(?:tablet|tab)s?\b/i
          : f.form === "capsule"
            ? /\b(?:capsule|cap)s?\b/i
            : /$a/
      ).test(evidence)
    )
      throw new Error("Medicine form was inferred.");
    if (f.dose) {
      const words = evidence
        .toLowerCase()
        .replace(/\bone\b/g, "1")
        .replace(/\btwo\b/g, "2")
        .replace(/\bhalf(?: a)?\b/g, "0.5")
        .replace(/½/g, "0.5")
        .replace(/\btabs?\b/g, "tablet")
        .replace(/\bcaps?\b/g, "capsule")
        .replace(/tablets/g, "tablet")
        .replace(/capsules/g, "capsule");
      if (!contains(words, f.dose))
        throw new Error("Dose quantity was inferred.");
    }
    if (f.frequency) {
      const frequency = Number(f.frequency.replace("/day", ""));
      const literal = new RegExp(
        "\\b" + frequency + "\\s*(?:/day|times (?:a |per )?day)\\b",
        "i",
      ).test(evidence);
      const named =
        frequency === 1
          ? /\bonce (?:a day|daily)\b/i
          : frequency === 2
            ? /\btwice (?:a day|daily)\b/i
            : frequency === 3
              ? /\bthree times (?:a day|daily)\b/i
              : /$a/;
      const abbr =
        frequency === 1
          ? /\b(?:QD|QHS)\b/i
          : frequency === 2
            ? /\bBID\b/i
            : frequency === 3
              ? /\bTID\b/i
              : frequency === 4
                ? /\bQID\b/i
                : /$a/;
      if (!literal && !named.test(evidence) && !abbr.test(evidence))
        throw new Error("Frequency was inferred.");
    }
    if (
      f.origin &&
      f.origin !== "unknown" &&
      !(f.origin === "prescribed"
        ? /\bprescribed|prescription\b/i.test(quote)
        : f.origin === "over_the_counter"
          ? /over.the.counter|\bOTC\b/i.test(quote)
          : /\bhospital\b/i.test(quote))
    )
      throw new Error("Medication origin was inferred.");
    if (
      /\b(if|may|might|would|consider|planned|proposed|stopped|discontinued|withheld|hold|previously|no|not|denies|denied|never|possible|suspected|PRN|OD|alternate|taper|weekly)\b|not taking|do not take|as needed|up to|every other|used to|was taking/i.test(
        evidence,
      ) ||
      family.test(evidence)
    )
      fact.problem =
        "Medication status or schedule is historical, conditional, ambiguous or outside supported daily dosing. Exclude it or obtain a clarified record.";
  }
  if (fact.kind === "allergy" || fact.kind === "condition") {
    const isUnknown = unknown.test(fact.label),
      isNone = none.test(fact.label);
    if (
      isUnknown
        ? !/unknown|not known|not sure|not recorded/i.test(quote)
        : isNone
          ? !/no known (drug )?allergies|\bnkda\b|allergies?:? none/i.test(
              quote,
            )
          : !contains(quote, fact.label)
    )
      throw new Error("History is not grounded in the quote.");
    if (family.test(quote) || (!isNone && !isUnknown && uncertain.test(quote)))
      fact.problem =
        "This history is negated, qualified or belongs to a family member. Exclude it or obtain a clarified source.";
    if (
      fact.kind === "allergy" &&
      !/allerg/i.test(quote) &&
      !/\bnkda\b/i.test(quote)
    )
      throw new Error("The quote does not explicitly describe an allergy.");
  }
  if (fact.kind === "lab") {
    for (const key of ["lab", "value", "unit"]) {
      if (f[key] && !contains(quote, f[key]))
        throw new Error("The lab " + key + " is missing or was inferred.");
    }
    if (f.date && !contains(quote, f.date)) {
      const match =
        /\b(\d{1,2})\s+(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{4})\b/i.exec(
          quote,
        );
      const months = [
        "january",
        "february",
        "march",
        "april",
        "may",
        "june",
        "july",
        "august",
        "september",
        "october",
        "november",
        "december",
      ];
      const explicit = match
        ? match[3] +
          "-" +
          String(months.indexOf(match[2].toLowerCase()) + 1).padStart(2, "0") +
          "-" +
          match[1].padStart(2, "0")
        : null;
      if (explicit !== f.date)
        throw new Error("The lab date is missing or was inferred.");
    }
    if (
      family.test(quote) ||
      /\b(?:hypothetical|example|if|target|pending|not|incorrect|erroneous|retracted)\b|entered in error|wrong result/i.test(
        quote,
      )
    )
      fact.problem =
        "This result is uncertain, negated, superseded or belongs to someone else. Exclude it or clarify the source.";
  }
  return fact;
}