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