From c14ac1d65b913cef3c7efb2737b855723e2d61c4 Mon Sep 17 00:00:00 2001 From: hz Date: Wed, 29 Jul 2026 01:28:25 -0500 Subject: [PATCH] Require reference ranges for published question labs --- api/QuestionsApi.Tests/ChoicePoolTests.cs | 39 +++ .../Services/QuestionValidator.cs | 20 ++ tools/backfill_lab_references.py | 230 ++++++++++++++++++ 3 files changed, 289 insertions(+) create mode 100644 tools/backfill_lab_references.py diff --git a/api/QuestionsApi.Tests/ChoicePoolTests.cs b/api/QuestionsApi.Tests/ChoicePoolTests.cs index 8f80e14..15b447e 100644 --- a/api/QuestionsApi.Tests/ChoicePoolTests.cs +++ b/api/QuestionsApi.Tests/ChoicePoolTests.cs @@ -148,6 +148,45 @@ public sealed class ChoicePoolTests Assert.Null(QuestionValidator.Validate(request)); } + [Fact] + public void PublishedQuestionRequiresCompleteLabReferenceRanges() + { + var request = BuildRequest(5); + request.Labs = + [ + new QuestionLab { Name = "Sodium", Value = "128", Unit = "mEq/L", Reference = "" } + ]; + + Assert.Contains("requires a reference range", QuestionValidator.Validate(request)); + + request.Labs[0].Reference = "136–145 mEq/L"; + Assert.Null(QuestionValidator.Validate(request)); + } + + [Fact] + public void DraftQuestionMaySaveLabWithoutReferenceRange() + { + var request = BuildRequest(5); + request.IsPublished = false; + request.Labs = + [ + new QuestionLab { Name = "Sodium", Value = "128", Unit = "mEq/L", Reference = "" } + ]; + + Assert.Null(QuestionValidator.Validate(request)); + } + + [Fact] + public void LabEntriesAlwaysRequireNamesAndValues() + { + var request = BuildRequest(5); + request.Labs = [new QuestionLab { Name = "", Value = "128", Reference = "136–145 mEq/L" }]; + Assert.Contains("lab name and value", QuestionValidator.Validate(request)); + + request.Labs = [new QuestionLab { Name = "Sodium", Value = "", Reference = "136–145 mEq/L" }]; + Assert.Contains("lab name and value", QuestionValidator.Validate(request)); + } + [Theory] [InlineData("http://example.org/guideline", "HTTPS")] [InlineData("https://example.org/guideline", null)] diff --git a/api/QuestionsApi/Services/QuestionValidator.cs b/api/QuestionsApi/Services/QuestionValidator.cs index bf3e117..78feef6 100644 --- a/api/QuestionsApi/Services/QuestionValidator.cs +++ b/api/QuestionsApi/Services/QuestionValidator.cs @@ -68,11 +68,31 @@ public static class QuestionValidator if (string.IsNullOrWhiteSpace(req.System)) return "system required"; if (string.IsNullOrWhiteSpace(req.Discipline)) return "discipline required"; if (req.Difficulty is < 1 or > 5) return "difficulty must be between 1 and 5"; + var labValidation = ValidateLabs(req.Labs, req.IsPublished); + if (labValidation is not null) return labValidation; var referenceValidation = ValidateReferences(req.References, req.IsPublished); if (referenceValidation is not null) return referenceValidation; return null; } + public static string? ValidateLabs(IReadOnlyCollection? labs, bool requireComplete) + { + if (labs is null) + return "labs must be an array"; + if (labs.Count > 50) + return "questions support at most fifty lab results"; + + foreach (var lab in labs) + { + if (string.IsNullOrWhiteSpace(lab.Name) || string.IsNullOrWhiteSpace(lab.Value)) + return "lab name and value are required"; + if (requireComplete && string.IsNullOrWhiteSpace(lab.Reference)) + return $"published question lab '{lab.Name.Trim()}' requires a reference range"; + } + + return null; + } + public static string? ValidateReferences(IReadOnlyCollection? references, bool required) { if (references is null) diff --git a/tools/backfill_lab_references.py b/tools/backfill_lab_references.py new file mode 100644 index 0000000..d531d9d --- /dev/null +++ b/tools/backfill_lab_references.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Audit or backfill missing question lab reference ranges. + +Ranges are based primarily on the NBME Laboratory Reference Values published +April 2026. The command is dry-run by default and writes a recoverable MongoDB +backup plus an audit record before applying changes. +""" + +import argparse +import copy +import uuid +from datetime import datetime, timezone + +from pymongo import MongoClient + + +NBME = { + "sodium": "136–146 mEq/L", "serum sodium": "136–146 mEq/L", + "potassium": "3.5–5.0 mEq/L", "serum potassium": "3.5–5.0 mEq/L", + "chloride": "95–105 mEq/L", "serum chloride": "95–105 mEq/L", + "bicarbonate": "22–28 mEq/L", "serum bicarbonate": "22–28 mEq/L", "hco3-": "22–28 mEq/L", + "blood urea nitrogen": "7–18 mg/dL", "serum urea nitrogen": "7–18 mg/dL", + "urea nitrogen": "7–18 mg/dL", "bun": "7–18 mg/dL", + "creatinine": "0.6–1.2 mg/dL", "serum creatinine": "0.6–1.2 mg/dL", + "baseline creatinine": "0.6–1.2 mg/dL", "postprocedure day 1 creatinine": "0.6–1.2 mg/dL", + "postprocedure day 2 creatinine": "0.6–1.2 mg/dL", + "glucose": "Fasting 70–100; random <140 mg/dL", "serum glucose": "Fasting 70–100; random <140 mg/dL", + "fasting glucose": "70–100 mg/dL", "fingerstick glucose": "70–100 mg/dL", + "point-of-care glucose": "70–100 mg/dL", "plasma glucose during episode": "70–100 mg/dL fasting", + "calcium": "8.4–10.2 mg/dL", "magnesium": "1.5–2.0 mg/dL", + "phosphorus": "3.0–4.5 mg/dL", "albumin": "3.5–5.5 g/dL", + "ast": "12–38 U/L", "aspartate aminotransferase": "12–38 U/L", + "alt": "10–40 U/L", "alanine aminotransferase": "10–40 U/L", + "alkaline phosphatase": "25–100 U/L", "amylase": "25–125 U/L", "lipase": "13–60 U/L", + "total bilirubin": "0.1–1.0 mg/dL", "serum bilirubin": "0.1–1.0 mg/dL", + "direct bilirubin": "0.0–0.3 mg/dL", "lactate dehydrogenase": "45–200 U/L", + "serum osmolality": "275–295 mOsm/kg H2O", "serum uric acid": "3.0–8.2 mg/dL", + "creatine kinase": "Male 25–90; female 10–70 U/L", + "total cholesterol": "Normal <200 mg/dL", "ldl cholesterol": "<160 mg/dL", + "ldl-cholesterol": "<160 mg/dL", "hdl cholesterol": "40–60 mg/dL", + "triglycerides": "Normal <150 mg/dL", "serum ferritin": "Male 20–250; female 10–120 ng/mL", + "tsh": "0.4–4.0 mIU/L", "thyroid-stimulating hormone": "0.4–4.0 mIU/L", + "free t4": "0.9–1.7 ng/dL", "total t3": "100–200 ng/dL", + "serum ige": "0–380 IU/mL", "ph": "7.35–7.45", "pco2": "33–45 mm Hg", + "paco2": "33–45 mm Hg", "arterial pco2": "33–45 mm Hg", + "pao2": "75–105 mm Hg", "po2": "75–105 mm Hg", "arterial po2": "75–105 mm Hg", + "arterial ph": "7.35–7.45", + "hemoglobin": "Male 13.5–17.5; female 12.0–16.0 g/dL", + "hematocrit": "Male 41%–53%; female 36%–46%", + "mean corpuscular volume": "80–100 fL", "mcv": "80–100 fL", + "leukocyte count": "4,500–11,000/mm3", "white blood cell count": "4,500–11,000/mm3", + "wbc count": "4,500–11,000/mm3", "wbc": "4,500–11,000/mm3", + "platelet count": "150,000–400,000/mm3", "platelets": "150,000–400,000/mm3", + "segmented neutrophils": "54%–62%", "band neutrophils": "3%–5%", + "lymphocytes": "25%–33%", "blood eosinophils": "1%–3%", + "pt": "11–15 seconds", "prothrombin time": "11–15 seconds", + "ptt": "25–40 seconds", "activated partial thromboplastin time": "25–40 seconds", + "inr": "0.8–1.2", "d-dimer": "≤250 ng/mL", "fibrinogen": "200–400 mg/dL", + "reticulocyte count": "0.5%–1.5%", "cd4 count": "≥500/mm3", + "hemoglobin a1c": "≤6%", "erythrocyte sedimentation rate": "Male 0–15; female 0–20 mm/h", + "urine osmolality": "50–1200 mOsm/kg H2O", "urine specific gravity": "1.005–1.030", + "urine albumin/creatinine ratio": "<30 mg/g", "urine albumin-to-creatinine ratio": "<30 mg/g", + "urine albumin-creatinine ratio": "<30 mg/g", "urine protein": "Negative; total <150 mg/24 h", + "urine wbc": "0–5/hpf", "urine wbcs": "0–5/hpf", "urine white blood cells": "0–5/hpf", + "urine leukocytes": "0–5/hpf", "urine rbc": "0–2/hpf", "urine rbcs": "0–2/hpf", + "urine erythrocytes": "0–2/hpf", "rbcs": "0–2/hpf", "wbcs": "0–5/hpf", + "bmi": "Adult 19–25 kg/m2", "cd4+ t-lymphocyte count": "≥500/mm3", + "c-reactive protein": "<1.0 mg/dL", "lactate": "0.5–2.2 mmol/L", + "vitamin b12": "200–900 pg/mL", "haptoglobin": "30–200 mg/dL", + "25-hydroxyvitamin d": "20–50 ng/mL", "aldolase": "1.0–7.5 U/L", + "bnp": "<100 pg/mL", "high-sensitivity troponin i": "Assay-specific; typically <99th percentile", + "oxygen saturation": "95%–100%", "room-air oxygen saturation": "95%–100%", + "temperature": "36.5–37.5°C", "pulse": "60–100/min", "respirations": "12–20/min", + "blood pressure": "Adult <120/80 mm Hg", "left ventricular ejection fraction": "55%–70%", + "qrs duration": "≤120 milliseconds", "pr interval": "120–200 milliseconds", + "estimated glomerular filtration rate": "≥60 mL/min/1.73 m2", "estimated gfr": "≥60 mL/min/1.73 m2", + "salicylate": "Therapeutic 10–30 mg/dL", "c3": "90–180 mg/dL", "c4": "10–40 mg/dL", + "c1q": "12–22 mg/dL", "synovial fluid leukocyte count": "<200/mm3", +} + +QUALITATIVE = { + "urinalysis", "peripheral blood smear", "serial cardiac troponins", "resting ecg between episodes", + "exercise treadmill test", "serum electrolytes", "urinalysis - leukocyte esterase", + "urinalysis - nitrites", "urinalysis - white blood cells", "compression ultrasonography", + "stool occult blood", "gastric biopsy", "urine toxicology screen", "repeat liver studies", + "synovial fluid gram stain", "synovial fluid bacterial culture", "synovial fluid crystal analysis", + "pulmonary function testing", "chest ct", "adrenal ct", "urine beta-hcg", "lithium level", + "serum beta-hydroxybutyrate", "antistreptolysin o titer", "fev1", "cbc", + "complete blood count", "comprehensive metabolic panel", "ecg", "echocardiogram", + "maternal hepatitis b surface antigen", "leukocyte esterase", "nitrite", "bacteria", + "urine blood", "urine leukocyte esterase", "urine nitrite", "urine casts", + "urine dipstick blood", "pregnancy test", "urine pregnancy test", + "potassium hydroxide preparation from scale scraping", "fetal karyotype", + "glucose and electrolytes", "cbc, ferritin, zinc, and venous lead", +} + +CUSTOM = { + "urine sodium": "40–220 mEq/24 h (diet dependent)", + "hiv rna": "Not detected", + "urine erythrocytes": "0–2/hpf", "urine leukocytes": "0–5/hpf", + "urine calcium": "100–300 mg/24 h", "urine blood": "Negative", + "urine nitrite": "Negative", "urine leukocyte esterase": "Negative", + "urine dipstick blood": "Negative", "pregnancy test": "Negative", + "urine pregnancy test": "Negative", "urine beta-hcg": "Negative", + "urine casts": "None to rare hyaline casts", + "urine albumin-creatinine ratio": "<30 mg/g", + "urine albumin-to-creatinine ratio": "<30 mg/g", + "urine albumin/creatinine ratio": "<30 mg/g", + "urine protein": "Negative; total <150 mg/24 h", + "ascitic fluid albumin": "Interpret with serum albumin (SAAG)", + "ascitic fluid total protein": "Clinical-context dependent", + "home glucose values": "Individualized target; commonly 80–130 mg/dL premeal", + "oxygen saturation": "95%–100%", "estimated pulmonary artery systolic pressure": "15–30 mm Hg", + "pulmonary-to-systemic flow ratio (qp:qs)": "Approximately 1:1", + "glucose infusion rate": "Age- and clinical-context dependent", + "tuberculin skin test": "Positive at ≥5, ≥10, or ≥15 mm depending on risk", + "urine output": "Adult target ≥0.5 mL/kg/h", +} + + +def embedded_test(value): + lowered = value.lower().strip() + aliases = [ + ("thyroid-stimulating hormone", "thyroid-stimulating hormone"), ("complete metabolic panel", None), + ("complete blood count", None), ("liver function tests", None), ("erythrocyte sedimentation rate", "erythrocyte sedimentation rate"), + ("measured serum osmolality", "serum osmolality"), ("activated partial thromboplastin time", "activated partial thromboplastin time"), + ("platelet count", "platelet count"), ("white blood cell count", "white blood cell count"), + ("blood urea nitrogen", "blood urea nitrogen"), ("oxygen saturation", "oxygen saturation"), + ("prothrombin time", "prothrombin time"), + ("segmented neutrophils", "segmented neutrophils"), ("creatine kinase", "creatine kinase"), + ("hematocrit", "hematocrit"), ("hemoglobin", "hemoglobin"), ("leukocyte count", "leukocyte count"), + ("arterial ph", "ph"), ("serum lipase", "lipase"), ("serum albumin", "albumin"), + ("total cholesterol", "total cholesterol"), ("urine output", "urine output"), + ("tuberculin skin test induration", "tuberculin skin test"), ("hiv rna", "hiv rna"), + ("cd4 count", "cd4 count"), ("pco2", "pco2"), ("po2", "po2"), ("sodium", "sodium"), + ("potassium", "potassium"), ("chloride", "chloride"), ("bicarbonate", "bicarbonate"), + ("hco3", "bicarbonate"), ("glucose", "glucose"), ("creatinine", "creatinine"), + ("calcium", "calcium"), ("magnesium", "magnesium"), ("phosphorus", "phosphorus"), + ("albumin", "albumin"), ("lipase", "lipase"), ("bands", "band neutrophils"), + ("lymphocytes", "lymphocytes"), ("inr", "inr"), ("na ", "sodium"), ("cl ", "chloride"), + ("k ", "potassium"), ("bun ", "bun"), + ] + for prefix, key in aliases: + if lowered.startswith(prefix) or lowered.startswith("admission " + prefix) or lowered.startswith("repeat " + prefix): + return key + return None + + +def reference_for(name, value): + key = name.strip().lower() + if key == "laboratory result": + key = embedded_test(value) + if key is None: + if " normal" in value.lower() or "within reference range" in value.lower(): + return "Normal/within the laboratory reference interval" + return None + if key in CUSTOM: + return CUSTOM[key] + if key in NBME: + return NBME[key] + if key in QUALITATIVE: + return "Expected: normal/negative unless otherwise specified" + return None + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--uri", required=True) + parser.add_argument("--database", required=True) + parser.add_argument("--apply", action="store_true") + args = parser.parse_args() + + client = MongoClient(args.uri, serverSelectionTimeoutMS=5000) + db = client[args.database] + questions = db["Questions"] + changes = [] + unresolved = [] + for question in questions.find({"Labs.0": {"$exists": True}}): + labs = question.get("Labs") or [] + original_labs = copy.deepcopy(labs) + updated = False + for lab in labs: + if (lab.get("Reference") or "").strip(): + continue + reference = reference_for(str(lab.get("Name") or ""), str(lab.get("Value") or "")) + if reference is None: + unresolved.append((question.get("ExternalId"), lab.get("Name"), lab.get("Value"))) + else: + lab["Reference"] = reference + updated = True + if updated: + changes.append((question, original_labs, labs)) + + print(f"database={args.database} questions_to_update={len(changes)} unresolved_labs={len(unresolved)}") + for item in unresolved: + print(f"UNRESOLVED externalId={item[0]!r} name={item[1]!r} value={item[2]!r}") + if unresolved: + raise SystemExit("Refusing to apply while lab entries remain unresolved") + if not args.apply: + return + + batch_id = f"lab-reference-{datetime.now(timezone.utc):%Y%m%dT%H%M%SZ}-{uuid.uuid4().hex[:8]}" + backups = db["QuestionLabReferenceBackups"] + audit = db["ContentMaintenance"] + now = datetime.now(timezone.utc) + for question, original_labs, labs in changes: + backups.insert_one({ + "BatchId": batch_id, "QuestionId": question["_id"], "ExternalId": question.get("ExternalId"), + "Labs": original_labs, "BackedUpUtc": now, + }) + questions.update_one({"_id": question["_id"]}, {"$set": {"Labs": labs, "UpdatedUtc": now}}) + remaining = questions.count_documents({ + "IsPublished": True, + "Labs": {"$elemMatch": {"$or": [ + {"Reference": {"$exists": False}}, {"Reference": ""}, {"Reference": None} + ]}} + }) + audit.insert_one({ + "BatchId": batch_id, "Operation": "BackfillQuestionLabReferences", + "Database": args.database, "QuestionsUpdated": len(changes), + "RemainingPublishedQuestionsWithMissingReferences": remaining, "CompletedUtc": now, + "Source": "NBME Laboratory Reference Values (April 2026)", + }) + print(f"applied batch={batch_id} questions_updated={len(changes)} remaining_published={remaining}") + if remaining: + raise SystemExit("Post-update verification failed") + + +if __name__ == "__main__": + main()