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; 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")); }