marmar-context / web / context / src / flow.ts
flow.ts
Raw
import { unresolvedCoverage, type CoverageReview } from "./coverage";
import type { Assessment, Draft, Fact, Review, Source } from "./types";
import { conflicts } from "./review";

type Finding = Assessment["contextual"]["findings"][number];

export const MAX_SOURCES = 8;
export const MAX_SOURCE_CHARS = 12000;

const months = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" ");
/** Show an ISO date as "10 Oct 2026"; anything else is returned unchanged. */
export function formatDate(value: string): string {
  const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
  if (!match) return value;
  const month = months[Number(match[2]) - 1];
  return month ? `${Number(match[3])} ${month} ${match[1]}` : value;
}

export const plural = (count: number, one: string, many = `${one}s`) =>
  `${count} ${count === 1 ? one : many}`;

export function initials(name: string): string {
  return (
    name
      .trim()
      .split(/\s+/)
      .slice(0, 2)
      .map((part) => part[0] ?? "")
      .join("")
      .toUpperCase() || "?"
  );
}

/**
 * Why the medication check cannot run yet, in the order a reviewer should fix things.
 * Returns null when the check may run. Mirrors the gates the server enforces.
 */
export function checkBlocker(input: {
  draft: Draft | null;
  review: Review;
  acknowledged: boolean;
  coverageReview?: CoverageReview;
  editing: boolean;
}): string | null {
  const { draft, review, acknowledged, editing } = input;
  if (editing) return "Save or cancel your record edit first.";
  if (!draft) return "Read the records first.";
  const pending = draft.facts.filter((f) => !review[f.id]).length;
  if (pending)
    return `Include or exclude ${plural(pending, "more fact")}.`;
  const selected = draft.facts.filter((f) => review[f.id] === "include");
  if (selected.some((f) => f.problem))
    return "An included fact needs clarification. Exclude it or add a clarification.";
  const keys = conflicts(selected);
  if (keys.length) {
    const labels = [
      ...new Set(selected.filter((f) => keys.includes(f.key)).map((f) => f.label)),
    ];
    return `Records disagree about ${labels.join(", ")}. Exclude the entry you are not using.`;
  }
  if (!selected.some((f) => f.kind === "medicine"))
    return "Include at least one medication.";
  if ((draft.unparsed.length || draft.sourceReviewRequired) && !acknowledged)
    return "Confirm you checked the original notes.";
  const unresolved = unresolvedCoverage(draft.coverage ?? [], input.coverageReview ?? {}, draft.fingerprint);
  if (unresolved.length) return `Resolve medication coverage for ${unresolved.map((c) => c.text).join(", ")}.`;
  return null;
}

/** The reviewed facts that a finding rests on, so the reviewer can jump to their source lines. */
export function findingEvidence(assessment: Assessment, finding: Finding): Fact[] {
  return assessment.includedFacts.filter((fact) =>
    fact.kind === "medicine"
      ? finding.medicines.some(
          (m) =>
            m.id === fact.id || m.name.toLowerCase() === fact.label.toLowerCase()
        )
      : finding.kind === "lab"
      ? fact.kind === "lab" && fact.id === finding.lab_evidence?.result.id
      : finding.kind === "allergy"
      ? fact.kind === "allergy"
      : fact.kind === "age"
  );
}

/** The patient's first name, for "Personalized to Grace". */
export function patientName(assessment: Assessment): string {
  const name = assessment.includedFacts.find((f) => f.kind === "patient")?.label;
  return name?.trim().split(/\s+/)[0] || "this patient";
}

/** The one-sentence answer that leads the findings screen. */
export function resultSummary(assessment: Assessment) {
  const before = assessment.baseline.findings.length;
  const after = assessment.contextual.findings.length;
  const added = assessment.addedRuleIds.length;
  const name = patientName(assessment);
  const headline = added
    ? `Personalized to ${name}: ${plural(added, "finding")} the medication list alone did not show.`
    : after
    ? `${plural(after, "finding")} for clinician review. The medication list alone showed the same.`
    : "No findings from the supported rules.";
  const subline =
    after === 0
      ? "This does not mean the prescription is safe. Check what could not be assessed."
      : "Each finding links back to the record lines it came from. A clinician decides what to do.";
  return { before, after, added, headline, subline };
}

/** "01-clinic_prescription.txt" becomes "Clinic prescription". */
function recordTitle(fileName: string): string {
  const title = fileName
    .replace(/\.txt$/i, "")
    .replace(/^\d+(?=[\s._-])/, "")
    .replace(/[-_.]+/g, " ")
    .replace(/\s+/g, " ")
    .trim()
    .slice(0, 120);
  return title ? title[0].toUpperCase() + title.slice(1) : "Pasted note";
}

/** Turn uploaded or pasted text into separate source records, each traceable on its own. */
export function recordsFromText(
  items: { name: string; text: string }[],
  existing: number,
  makeId: () => string
): { records: Source[]; error: string | null } {
  const usable = items.filter((item) => item.text.trim());
  if (!usable.length)
    return { records: [], error: "Add at least one record: upload a .txt file or paste text." };
  if (existing + usable.length > MAX_SOURCES)
    return {
      records: [],
      error: `A patient can have up to ${MAX_SOURCES} records. Remove ${
        existing + usable.length - MAX_SOURCES
      } and try again.`,
    };
  for (const item of usable) {
    if (item.text.length > MAX_SOURCE_CHARS)
      return {
        records: [],
        error: `${item.name || "A record"} is longer than ${MAX_SOURCE_CHARS.toLocaleString(
          "en"
        )} characters. Split it into smaller records.`,
      };
    if (item.text.includes("\u0000"))
      return { records: [], error: `${item.name || "A record"} is not plain text.` };
  }
  return {
    error: null,
    records: usable.map((item) => ({
      id: `source-${makeId()}`,
      title: recordTitle(item.name),
      kind: "Synthetic note",
      date: "2026-10-23",
      text: item.text.trim(),
    })),
  };
}

export type ProfileStatus = "used" | "not_assessed" | "missing";
export type ProfileItem = {
  id: string;
  label: string;
  value: string | null;
  status: ProfileStatus;
  note: string;
  fact?: Fact;
};

const allergyUnknown = /^(unknown|not known|not sure|not recorded)$/i;

/**
 * The personal factors this check could and could not use, in the order a
 * prescriber reads them. "Used" means a current rule reads it; recorded facts
 * no rule reads are labelled as such rather than implied to be checked.
 */
export function patientProfile(assessment: Assessment): ProfileItem[] {
  const facts = assessment.includedFacts;
  const first = (kind: Fact["kind"]) => facts.find((f) => f.kind === kind);
  const lab = (code: string) =>
    facts.find(
      (f) => f.kind === "lab" && f.label.toLowerCase().startsWith(code + " "),
    );
  const labValue = (f: Fact) =>
    `${f.label.replace(/^\S+\s/, "")} ${f.detail.split(" · ")[0].replace(/\/1\.73m(2|²)$/i, "")}`;
  const labDate = (f: Fact) => formatDate(f.detail.split(" · ")[1] ?? "");
  const items: ProfileItem[] = [];
  const age = first("age");
  items.push(
    age
      ? { id: "age", label: "Age", value: age.label, status: "used", note: "Older-adult, child and dose rules", fact: age }
      : { id: "age", label: "Age", value: null, status: "missing", note: "Needed for older-adult, child and dose rules" },
  );
  const weight = first("weight");
  items.push(
    weight
      ? { id: "weight", label: "Weight", value: weight.label, status: "used", note: "Dose limits under 50 kg", fact: weight }
      : { id: "weight", label: "Weight", value: null, status: "missing", note: "Needed for dose limits under 50 kg" },
  );
  const sex = first("sex");
  if (sex)
    items.push({ id: "sex", label: "Sex", value: sex.label, status: "not_assessed", note: "Recorded; no current rule depends on it", fact: sex });
  const egfr = lab("egfr");
  items.push(
    egfr
      ? { id: "egfr", label: "Kidney function (eGFR)", value: labValue(egfr), status: "used", note: `${labDate(egfr)} · Kidney dose rules`, fact: egfr }
      : { id: "egfr", label: "Kidney function (eGFR)", value: null, status: "missing", note: "Needed for kidney dose rules" },
  );
  for (const [code, label, note] of [
    ["potassium", "Potassium", "Potassium rules"],
    ["inr", "INR", "Warfarin rules"],
  ] as const) {
    const f = lab(code);
    if (f) items.push({ id: code, label, value: labValue(f), status: "used", note: `${labDate(f)} · ${note}`, fact: f });
  }
  const allergies = facts.filter((f) => f.kind === "allergy");
  const known = allergies.filter((f) => !allergyUnknown.test(f.label));
  items.push(
    known.length
      ? { id: "allergies", label: "Allergies", value: known.map((f) => f.label).join(", "), status: "used", note: "Allergy and cross-reaction rules", fact: known[0] }
      : { id: "allergies", label: "Allergies", value: allergies.length ? "Unknown" : null, status: "missing", note: "Unknown is not the same as none" },
  );
  const pregnancy = first("pregnancy");
  if (pregnancy)
    items.push({
      id: "pregnancy",
      label: "Pregnancy",
      value: pregnancy.label,
      status: "not_assessed",
      note: pregnancy.label === "not pregnant" ? "Recorded" : "Recorded; not yet checked by current rules",
      fact: pregnancy,
    });
  const conditions = facts.filter((f) => f.kind === "condition" && !allergyUnknown.test(f.label));
  if (conditions.length)
    items.push({
      id: "conditions",
      label: "Conditions",
      value: conditions.map((f) => f.label).join(", "),
      status: "not_assessed",
      note: "Recorded; not yet checked by current rules",
      fact: conditions[0],
    });
  return items;
}

type DoseStatus = Assessment["contextual"]["dose_check"][number]["status"];
export const doseLabels: Record<DoseStatus, string> = {
  within_usual_range: "Within the usual adult maximum",
  above_limit: "Above the label maximum",
  check_with_pharmacist: "Usual dose may not apply",
  not_enough_info: "Not enough information",
};

/** Each included medicine with its personalized dose status and the findings that name it. */
export function medicinePlan(assessment: Assessment) {
  const result = assessment.contextual;
  return assessment.includedFacts
    .filter((f) => f.kind === "medicine")
    .map((fact) => {
      const dose =
        result.dose_check.find((d) => d.medicine_id === fact.id) ??
        result.dose_check.find(
          (d) => d.medicine.toLowerCase() === fact.label.toLowerCase(),
        );
      const findings = result.findings.filter((f) =>
        f.medicines.some(
          (m) => m.id === fact.id || m.name.toLowerCase() === fact.label.toLowerCase(),
        ),
      );
      return {
        fact,
        status: (dose?.status ?? "not_enough_info") as DoseStatus,
        plain: dose?.plain ?? "This medicine was not dose-checked.",
        findings,
      };
    });
}