import { parseLine } from "./context"; import { instructionLike } from "./source-policy"; const clean = (value: string) => value .toLowerCase() .replace(/²/g, "2") .replace(/(?/.]+/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 { 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( "(? 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( "(?