Require reference ranges for published question labs
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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<QuestionLab>? 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<QuestionReference>? references, bool required)
|
||||
{
|
||||
if (references is null)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user