From 4ad12a30c55ee7ff4ed655dcf287860d17f72940 Mon Sep 17 00:00:00 2001
From: MaXiMo000
Date: Sat, 10 Oct 2026 12:12:25 +0530
Subject: [PATCH 1/7] Read real Quest and LabCorp reports correctly
Checked every row of five real sample reports (88 rows) by hand against
the PDFs. Four values were wrong, including a positive SARS-CoV-2 result
read as "NOT DETECTED" from the reference column, and creatinine, five
differential percentages, HbA1c and body weight mapped to the wrong
LOINC codes.
- extract: split value+flag and value+unit glued by one space, strip
"See Note" lines and site codes, read paired Component/Result columns,
normalise "> OR =" ranges
- mapping: a curated stage for the names real reports print most, with
the printed unit deciding between a fraction and a count; blood and
serum only, so urine rows continue down the cascade
- expected.json pins all 88 rows; the extractor must match exactly
Offline: 81 rows map correctly, none wrong, 7 left for review.
Co-Authored-By: Claude Opus 5.5
---
backend/app/data/common_tests.py | 102 +++
backend/app/models/observation.py | 1 +
backend/app/pipeline/extract.py | 98 ++-
backend/app/pipeline/mapping.py | 38 +-
backend/results.xml | 174 -----
.../tests/fixtures/real_world/PROVENANCE.md | 39 +-
.../tests/fixtures/real_world/expected.json | 629 ++++++++++++++++++
backend/tests/test_mapping.py | 40 ++
backend/tests/test_panel_trends.py | 13 +-
backend/tests/test_real_world.py | 18 +
frontend/src/screens/Provenance.jsx | 4 +
11 files changed, 953 insertions(+), 203 deletions(-)
create mode 100644 backend/app/data/common_tests.py
delete mode 100644 backend/results.xml
create mode 100644 backend/tests/fixtures/real_world/expected.json
diff --git a/backend/app/data/common_tests.py b/backend/app/data/common_tests.py
new file mode 100644
index 0000000..06d3b52
--- /dev/null
+++ b/backend/app/data/common_tests.py
@@ -0,0 +1,102 @@
+"""Curated LOINC codes for the tests real reports print most.
+
+Run against real Quest and LabCorp sample reports, the fuzzy cascade alone
+left most of a basic metabolic panel, a CBC and a lipid panel unmapped --
+and got some wrong in ways worse than unmapped: creatinine to the *urine*
+code, "Neutrophils 63.4 %" to the absolute-count code, "WEIGHT" to the
+weight of a kidney stone. Real lab systems solve this with a curated
+compendium; this is a small one.
+
+Every code here was looked up in the seeded LOINC table, not recalled.
+Where one printed name means two different tests, the unit decides: a
+differential printed in % is a fraction of leukocytes, in cells/uL a count.
+With no unit to decide by, the row falls through to the cascade rather
+than being guessed.
+"""
+from __future__ import annotations
+
+import re
+
+# name -> code, or name -> {"percent": code, "count": code}
+TABLE: dict[str, str | dict[str, str]] = {
+ # basic / comprehensive metabolic panel
+ "GLUCOSE": "2345-7",
+ "UREA NITROGEN": "3094-0", "UREA NITROGEN BUN": "3094-0", "BUN": "3094-0",
+ "CREATININE": "2160-0",
+ "EGFR": "98979-8",
+ "BUN/Creatinine Ratio": "3097-3",
+ "SODIUM": "2951-2", "POTASSIUM": "2823-3", "CHLORIDE": "2075-0",
+ "CARBON DIOXIDE": "2028-9", "CO2": "2028-9",
+ "CALCIUM": "17861-6",
+ "PROTEIN TOTAL": "2885-2", "TOTAL PROTEIN": "2885-2",
+ "ALBUMIN": "1751-7", "GLOBULIN": "10834-0",
+ "Albumin/Globulin Ratio": "1759-0", "A/G Ratio": "1759-0",
+ "BILIRUBIN TOTAL": "1975-2", "TOTAL BILIRUBIN": "1975-2",
+ "ALKALINE PHOSPHATASE": "6768-6", "AST": "1920-8", "ALT": "1742-6",
+ # lipids
+ "TOTAL CHOLESTEROL": "2093-3", "CHOLESTEROL TOTAL": "2093-3", "CHOLESTEROL": "2093-3",
+ "HDL CHOLESTEROL": "2085-9", "HDL": "2085-9",
+ "LDL CHOLESTEROL CALCULATED": "13457-7", "LDL CHOLESTEROL": "13457-7",
+ "Non-HDL Cholesterol": "43396-1",
+ "Cholesterol/HDL Ratio": "9830-1", "Cholesterol/HDL Ratio (Calculated)": "9830-1",
+ "TRIGLYCERIDES": "2571-8",
+ # glycemic
+ "HEMOGLOBIN A1C": {"percent": "4548-4"},
+ # hormones
+ "TESTOSTERONE TOTAL MS": "2986-8", "TESTOSTERONE TOTAL": "2986-8",
+ "ESTRADIOL": "2243-4", "FSH": "15067-2", "LH": "10501-5",
+ # CBC
+ "WHITE BLOOD CELL COUNT": "6690-2", "WBC": "6690-2",
+ "RED BLOOD CELL COUNT": "789-8", "RBC": "789-8",
+ "HEMOGLOBIN": "718-7", "HEMATOCRIT": "4544-3",
+ "MCV": "787-2", "MCH": "785-6", "MCHC": "786-4", "RDW": "788-0",
+ "PLATELET COUNT": "777-3", "PLATELETS": "777-3", "MPV": "32623-1",
+ # differential: % of leukocytes, or an absolute count
+ "NEUTROPHILS": {"percent": "770-8", "count": "751-8"},
+ "LYMPHOCYTES": {"percent": "736-9", "count": "731-0"},
+ "MONOCYTES": {"percent": "5905-5", "count": "742-7"},
+ "EOSINOPHILS": {"percent": "713-8", "count": "711-2"},
+ "BASOPHILS": {"percent": "706-2", "count": "704-7"},
+ "BAND NEUTROPHILS": {"percent": "764-1", "count": "26507-4"},
+ "METAMYELOCYTES": {"percent": "740-1", "count": "30433-7"},
+ "MYELOCYTES": {"percent": "749-2", "count": "30446-9"},
+ "PROMYELOCYTES": {"percent": "26524-9", "count": "26523-1"},
+ "BLASTS": {"percent": "709-6", "count": "30376-8"},
+ "ABSOLUTE NEUTROPHILS": {"count": "751-8"},
+ "ABSOLUTE LYMPHOCYTES": {"count": "731-0"},
+ "ABSOLUTE MONOCYTES": {"count": "742-7"},
+ "ABSOLUTE EOSINOPHILS": {"count": "711-2"},
+ "ABSOLUTE BASOPHILS": {"count": "704-7"},
+ "ABSOLUTE BAND NEUTROPHILS": {"count": "26507-4"},
+ "ABSOLUTE METAMYELOCYTES": {"count": "30433-7"},
+ "ABSOLUTE MYELOCYTES": {"count": "30446-9"},
+ "ABSOLUTE PROMYELOCYTES": {"count": "26523-1"},
+ "ABSOLUTE BLASTS": {"count": "30376-8"},
+ "ABSOLUTE NUCLEATED RBC": {"count": "30392-5"},
+ "NUCLEATED RBC": "58413-6",
+ # infectious disease
+ "SARS-CoV-2, NAA": "94309-2", "SARS-CoV-2 RNA": "94309-2",
+ # allergy (LabCorp prints the ImmunoCAP code first: "E001-IgE Cat Dander")
+ "E001-IgE Cat Dander": "6833-8", "CAT DANDER IGE": "6833-8",
+ "E005-IgE Dog Dander": "6098-8", "DOG DANDER IGE": "6098-8",
+ "E003-IgE Horse Dander": "6143-2", "HORSE DANDER IGE": "6143-2",
+ "E094-IgE Fel d 1": "64975-6", "E220-IgE Fel d 2": "19732-7", "E228-IgE Fel d 4": "64976-4",
+ "E101-IgE Can f 1": "58773-3", "E102-IgE Can f 2": "58772-5", "E221-IgE Can f 3": "19738-4",
+ "E227-IgE Equ c 1": "64974-9",
+ # Vital signs: not in the lab LOINC table this app seeds, so there is no
+ # right code to give -- and the fuzzy stage gave "WEIGHT" the weight of a
+ # kidney stone. "" means: never auto-map, send to review.
+ "WEIGHT": "", "HEIGHT FEET": "", "HEIGHT INCHES": "", "HEIGHT": "",
+ "SYSTOLIC BLOOD PRESSURE": "", "DIASTOLIC BLOOD PRESSURE": "",
+ "CALCULATED BMI": "", "BMI": "",
+}
+
+_COUNT_UNIT = re.compile(r"/\s*u?L\b|/\s*mm3|cells|thousand|million|10\*|x\s*10|#", re.IGNORECASE)
+
+
+def unit_kind(unit: str | None) -> str | None:
+ if not unit:
+ return None
+ if "%" in unit:
+ return "percent"
+ return "count" if _COUNT_UNIT.search(unit) else None
diff --git a/backend/app/models/observation.py b/backend/app/models/observation.py
index ec160e3..b7adb44 100644
--- a/backend/app/models/observation.py
+++ b/backend/app/models/observation.py
@@ -9,6 +9,7 @@
MappingStage = Literal[
"alias", # user-confirmed lookup, conf 1.00
+ "curated", # app/data/common_tests.py, codes checked in the table, conf 0.98
"exact", # exact hit on a primary LOINC name, conf 0.95
"related_corroborated", # related-name hit + specimen + unit agree; always reviewed
"narrowed_fuzzy",
diff --git a/backend/app/pipeline/extract.py b/backend/app/pipeline/extract.py
index 1b026bd..afc8bef 100644
--- a/backend/app/pipeline/extract.py
+++ b/backend/app/pipeline/extract.py
@@ -68,7 +68,7 @@ class ExtractResult:
# measured on a real Quest report), so it is only a reference range once a
# value already exists on the row; see the ref-vs-value split below.
RE_RANGE_DASH = re.compile(r"^(?:[\d.]+\s*[-–—]\s*[\d.]+|[\d.]+\s*[-–—]\s*)$")
-RE_RANGE_CMP = re.compile(r"^[<>≤≥]\s*[\d.]+$")
+RE_RANGE_CMP = re.compile(r"^[<>≤≥]=?\s*[\d.]+$")
RE_RANGE = re.compile(f"(?:{RE_RANGE_DASH.pattern}|{RE_RANGE_CMP.pattern})")
RE_NUMBER = re.compile(r"^[<>≤≥]?\s*-?[\d,]+\.?\d*$")
RE_FLAG = re.compile(r"^(H|L|HH|LL|A|AB|N|HIGH|LOW|ABNORMAL|CRITICAL)$", re.IGNORECASE)
@@ -156,11 +156,41 @@ def detect_specimen(line: str) -> str | None:
return None
+# Real report conventions, all measured on Quest's lipid sample report:
+# a table cell carries a "See Note 1" line under the range; "> OR = 46"
+# is how Quest prints >=46; "(calc)" marks a calculated row.
+_SEE_NOTE = re.compile(r"\bsee note\s*\d+\b", re.IGNORECASE)
+_CALC = re.compile(r"\(\s*calc(?:ulated)?\s*\)", re.IGNORECASE)
+_OR_EQUAL = [(re.compile(r">\s*OR\s*=\s*", re.IGNORECASE), "≥"),
+ (re.compile(r"<\s*OR\s*=\s*", re.IGNORECASE), "≤")]
+
+
+def clean_token(tok: str) -> str:
+ t = " ".join(line for line in tok.splitlines() if not _SEE_NOTE.fullmatch(line.strip()))
+ t = _CALC.sub(" ", t)
+ for pattern, symbol in _OR_EQUAL:
+ t = pattern.sub(symbol, t)
+ return re.sub(r"\s+", " ", t).strip()
+
+
+def _split_range_unit(t: str) -> tuple[str, str] | None:
+ """"65-99 mg/dL" or "<5.7 % of total Hgb": a range followed by a unit,
+ which may be several words. The range is the shortest leading run of
+ words that is a range; the rest must look like a unit."""
+ words = t.split(" ")
+ for k in range(1, min(len(words), 4)):
+ left, right = " ".join(words[:k]), " ".join(words[k:])
+ if (RE_RANGE.match(left.replace(" ", "")) and len(right) <= 24
+ and UNIT_HINT.search(right) and not RE_NUMBER.match(right)):
+ return left.replace(" ", ""), right
+ return None
+
+
def classify_tokens(tokens: list[str]) -> tuple[str | None, str | None, str | None, str | None]:
"""-> (value, unit, ref_range, flag). Order-independent."""
value = unit = ref = flag = None
for tok in tokens:
- t = tok.strip()
+ t = clean_token(tok)
if not t:
continue
# A tight real-world column sometimes prints "65-99 mg/dL" as one
@@ -168,10 +198,9 @@ def classify_tokens(tokens: list[str]) -> tuple[str | None, str | None, str | No
# tokenizer splits columns on) -- measured on a real Quest report.
# Split range from unit before the whole-token RE_RANGE check,
# which would otherwise never match the trailing unit text.
- if ref is None and " " in t:
- left, _, right = t.rpartition(" ")
- if (RE_RANGE.match(left) and not (RE_RANGE_CMP.match(left) and value is None)
- and RE_UNIT.match(right) and UNIT_HINT.search(right)):
+ if ref is None and " " in t and (split := _split_range_unit(t)):
+ left, right = split
+ if not (RE_RANGE_CMP.match(left) and value is None):
ref = left
if unit is None:
unit = right
@@ -206,6 +235,31 @@ def classify_tokens(tokens: list[str]) -> tuple[str | None, str | None, str | No
if flag is None:
flag = right.upper()
continue
+ # A qualitative result glued to its flag: "Detected Abnormal" --
+ # LabCorp's SARS-CoV-2 sample. Unrecognised, the value stayed empty
+ # and the next column -- the *reference* value, "Not Detected" --
+ # was read as the result: a positive test reported as negative.
+ if value is None and " " in t:
+ words = t.upper().split(" ")
+ for k in range(len(words) - 1, 0, -1):
+ head, tail = " ".join(words[:k]), " ".join(words[k:])
+ # "NORMAL" too: Quest's "NOT APPLICABLE NORMAL" left the ratio's
+ # value empty and its lab-site code ("01") was read as it.
+ if head in QUALITATIVE and (RE_FLAG.match(tail) or tail == "NORMAL"):
+ value, flag = head, flag or tail
+ break
+ if value is not None:
+ continue
+ # And value glued to its unit: "106 mmHg", "150 lbs" -- Quest's
+ # "Personal factors" table prints them that way.
+ if value is None and " " in t:
+ left, _, right = t.partition(" ")
+ if (RE_NUMBER.match(left) and RE_UNIT.match(right)
+ and (UNIT_HINT.search(right) or right in {"ft", "in", "lbs", "kg", "cm"})):
+ value = left.replace(",", "")
+ if unit is None:
+ unit = right
+ continue
if value is None and RE_NUMBER.match(t.replace(" ", "")):
value = t.replace(",", "").replace(" ", "")
continue
@@ -213,10 +267,15 @@ def classify_tokens(tokens: list[str]) -> tuple[str | None, str | None, str | No
value = t.upper()
continue
# Require a unit-ish shape so stray words don't become units.
- if (unit is None and RE_UNIT.match(t) and not RE_NUMBER.match(t)
+ if (unit is None and (RE_UNIT.match(t) or (len(t) <= 24 and t[:1] in "%µμ"))
+ and not RE_NUMBER.match(t)
and (UNIT_HINT.search(t) or t in {"pH", "ratio", "index"})):
unit = t
continue
+ # Quest prints a performing-site code ("01") after the unit; glued on, it
+ # made "g/dL 01" and "Thousand/uL 01", which no unit table knows.
+ if unit:
+ unit = re.sub(r"\s+\d{1,3}$", "", unit) or None
return value, unit, ref, flag
@@ -262,14 +321,23 @@ def _rows_from_tables(pdf) -> list[RawRow]:
continue
if RE_NOISE.match(joined):
continue
- name, rest = cells[0], cells[1:]
- name = re.sub(r"\s+", " ", name).strip(" .:-")
- if not (2 <= len(name) <= 60) or not re.search(r"[A-Za-z]{2}", name):
- continue
- value, unit, ref, flag = classify_tokens(rest)
- if value is None:
- continue
- rows.append(RawRow(name, value, unit, ref, specimen, flag, pno, line_no))
+ # "Component | Result | Component | Result": two results per
+ # row -- Quest's "Personal factors" table; the second pair
+ # (blood pressure) used to be dropped.
+ header = [(c or "").strip().lower() for c in table[0]]
+ half = len(header) // 2
+ pairs = ([cells[:half], cells[half:]]
+ if len(header) % 2 == 0 and half >= 2 and header[:half] == header[half:]
+ else [cells])
+ for pair in pairs:
+ name, rest = pair[0], pair[1:]
+ name = re.sub(r"\s+", " ", name).strip(" .:-")
+ if not (2 <= len(name) <= 60) or not re.search(r"[A-Za-z]{2}", name):
+ continue
+ value, unit, ref, flag = classify_tokens(rest)
+ if value is None:
+ continue
+ rows.append(RawRow(name, value, unit, ref, specimen, flag, pno, line_no))
return rows
diff --git a/backend/app/pipeline/mapping.py b/backend/app/pipeline/mapping.py
index 94b457b..023d15c 100644
--- a/backend/app/pipeline/mapping.py
+++ b/backend/app/pipeline/mapping.py
@@ -25,6 +25,7 @@
from beanie import PydanticObjectId
from rapidfuzz import fuzz, process
+from app.data import common_tests
from app.models.alias import Alias
from app.models.loinc import LoincEntry
@@ -142,6 +143,24 @@ def __post_init__(self):
# stage 0 - learned aliases
# --------------------------------------------------------------------------
+_CURATED: dict | None = None
+
+
+def _curated(norm: str, unit: str | None) -> str | None:
+ global _CURATED
+ if _CURATED is None:
+ _CURATED = {normalize(name): code for name, code in common_tests.TABLE.items()}
+ entry = _CURATED.get(norm)
+ if entry is None or isinstance(entry, str):
+ return entry
+ if len(entry) == 1:
+ # The name alone is unambiguous ("ABSOLUTE BLASTS" is a count),
+ # including on a "DNR" row that prints no unit.
+ return next(iter(entry.values()))
+ kind = common_tests.unit_kind(unit)
+ return entry.get(kind) if kind else None
+
+
async def _stage_alias(norm: str, specimen: str | None,
user_id: PydanticObjectId | None) -> Alias | None:
"""Look up a confirmed alias.
@@ -322,7 +341,7 @@ def should_force_review(stage: str, component: str | None) -> bool:
LLM -- is not auto-accepted for an analyte that drives dosing, no matter
how confident it claims to be.
"""
- if stage in ("alias", "exact"):
+ if stage in ("alias", "curated", "exact"):
return False
if stage == "related_corroborated":
return True # associative evidence: always propose, never auto-accept
@@ -356,6 +375,23 @@ async def resolve(
note=f"confirmed {alias.confirmed_count}x",
)
+ # stage 0.5: the curated compendium -- after a user's own confirmed
+ # aliases, before anything fuzzy. See app/data/common_tests.py.
+ # Every curated code is a blood/serum/plasma test: "GLUCOSE" printed in a
+ # urine panel is a different test, and must not take the serum code.
+ blood = (specimen or "").upper() in ("", "SERUM", "PLASMA", "SERUM/PLASMA", "BLOOD", "WHOLE BLOOD")
+ code = _curated(norm, raw_unit) if blood else None
+ if code == "":
+ return MappingResult(stage="unmapped", candidates=[], candidates_considered=0,
+ note="not a lab test in the LOINC table: left for review")
+ if code:
+ entry = await LoincEntry.find_one(LoincEntry.loinc_num == code)
+ if entry:
+ return MappingResult(
+ stage="curated", loinc_code=code, loinc_display=entry.long_common_name,
+ confidence=0.98, candidates_considered=1, component=entry.component,
+ )
+
# stage 1
primary, related = await _stage_exact(norm, systems, raw_unit)
if not primary and not related and systems:
diff --git a/backend/results.xml b/backend/results.xml
deleted file mode 100644
index a08f01f..0000000
--- a/backend/results.xml
+++ /dev/null
@@ -1,174 +0,0 @@
-monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x10d71e300>
-
- async def test_llm_index_is_mapped_to_the_offered_code(monkeypatch):
- await _stub(monkeypatch, "0")
-> code, _ = await llm.adjudicate("HDL", "mg/dL", "SERUM", CANDIDATES)
-
-tests/test_mapping.py:134:
-_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
-
-name = 'HDL', unit = 'mg/dL', specimen = 'SERUM'
-candidates = [Candidate(loinc_code='2085-9', display='Cholesterol in HDL', score=90.0, common_rank=63, system='Ser/Plas', property=...2093-3', display='Cholesterol', score=88.0, common_rank=12, system='Ser/Plas', property='MCnc', raw_score=0.0, why='')]
-model = None, timeout = 30.0
-
- async def adjudicate(
- name: str,
- unit: str | None,
- specimen: str | None,
- candidates: list, # list[Candidate] from mapping.py
- model: str | None = None,
- timeout: float = 30.0, # noqa: ASYNC109 - passed to httpx, not an asyncio timeout
- ) -> tuple[str | None, str]:
- """-> (chosen loinc_code or None, model_used)."""
- if not settings.gemini_api_key:
-> raise LLMUnavailableError("GEMINI_API_KEY not set")
-E app.pipeline.llm.LLMUnavailableError: GEMINI_API_KEY not set
-
-app/pipeline/llm.py:62: LLMUnavailableErrormonkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x10d3961e0>
-
- async def test_llm_cannot_return_a_code_it_was_not_offered(monkeypatch):
- """The core safety property. Even if the model emits a real LOINC code, it
- is not an index into the offered list, so it is discarded."""
- await _stub(monkeypatch, "2951-2") # a genuine code, never offered
-> code, _ = await llm.adjudicate("HDL", "mg/dL", "SERUM", CANDIDATES)
-
-tests/test_mapping.py:142:
-_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
-
-name = 'HDL', unit = 'mg/dL', specimen = 'SERUM'
-candidates = [Candidate(loinc_code='2085-9', display='Cholesterol in HDL', score=90.0, common_rank=63, system='Ser/Plas', property=...2093-3', display='Cholesterol', score=88.0, common_rank=12, system='Ser/Plas', property='MCnc', raw_score=0.0, why='')]
-model = None, timeout = 30.0
-
- async def adjudicate(
- name: str,
- unit: str | None,
- specimen: str | None,
- candidates: list, # list[Candidate] from mapping.py
- model: str | None = None,
- timeout: float = 30.0, # noqa: ASYNC109 - passed to httpx, not an asyncio timeout
- ) -> tuple[str | None, str]:
- """-> (chosen loinc_code or None, model_used)."""
- if not settings.gemini_api_key:
-> raise LLMUnavailableError("GEMINI_API_KEY not set")
-E app.pipeline.llm.LLMUnavailableError: GEMINI_API_KEY not set
-
-app/pipeline/llm.py:62: LLMUnavailableErrormonkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x10d397650>
-
- async def test_llm_out_of_range_index_is_rejected(monkeypatch):
- await _stub(monkeypatch, "7")
-> code, _ = await llm.adjudicate("HDL", "mg/dL", "SERUM", CANDIDATES)
-
-tests/test_mapping.py:148:
-_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
-
-name = 'HDL', unit = 'mg/dL', specimen = 'SERUM'
-candidates = [Candidate(loinc_code='2085-9', display='Cholesterol in HDL', score=90.0, common_rank=63, system='Ser/Plas', property=...2093-3', display='Cholesterol', score=88.0, common_rank=12, system='Ser/Plas', property='MCnc', raw_score=0.0, why='')]
-model = None, timeout = 30.0
-
- async def adjudicate(
- name: str,
- unit: str | None,
- specimen: str | None,
- candidates: list, # list[Candidate] from mapping.py
- model: str | None = None,
- timeout: float = 30.0, # noqa: ASYNC109 - passed to httpx, not an asyncio timeout
- ) -> tuple[str | None, str]:
- """-> (chosen loinc_code or None, model_used)."""
- if not settings.gemini_api_key:
-> raise LLMUnavailableError("GEMINI_API_KEY not set")
-E app.pipeline.llm.LLMUnavailableError: GEMINI_API_KEY not set
-
-app/pipeline/llm.py:62: LLMUnavailableErrormonkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x10d396e70>
-
- async def test_llm_none_is_honoured(monkeypatch):
- await _stub(monkeypatch, "NONE")
-> code, _ = await llm.adjudicate("HDL", "mg/dL", "SERUM", CANDIDATES)
-
-tests/test_mapping.py:154:
-_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
-
-name = 'HDL', unit = 'mg/dL', specimen = 'SERUM'
-candidates = [Candidate(loinc_code='2085-9', display='Cholesterol in HDL', score=90.0, common_rank=63, system='Ser/Plas', property=...2093-3', display='Cholesterol', score=88.0, common_rank=12, system='Ser/Plas', property='MCnc', raw_score=0.0, why='')]
-model = None, timeout = 30.0
-
- async def adjudicate(
- name: str,
- unit: str | None,
- specimen: str | None,
- candidates: list, # list[Candidate] from mapping.py
- model: str | None = None,
- timeout: float = 30.0, # noqa: ASYNC109 - passed to httpx, not an asyncio timeout
- ) -> tuple[str | None, str]:
- """-> (chosen loinc_code or None, model_used)."""
- if not settings.gemini_api_key:
-> raise LLMUnavailableError("GEMINI_API_KEY not set")
-E app.pipeline.llm.LLMUnavailableError: GEMINI_API_KEY not set
-
-app/pipeline/llm.py:62: LLMUnavailableErrormonkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x10d3a0290>
-
- async def test_llm_prose_reply_does_not_smuggle_a_code(monkeypatch):
- await _stub(monkeypatch, "The answer is LOINC 2951-2 for sodium")
-> code, _ = await llm.adjudicate("HDL", "mg/dL", "SERUM", CANDIDATES)
-
-tests/test_mapping.py:160:
-_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
-
-name = 'HDL', unit = 'mg/dL', specimen = 'SERUM'
-candidates = [Candidate(loinc_code='2085-9', display='Cholesterol in HDL', score=90.0, common_rank=63, system='Ser/Plas', property=...2093-3', display='Cholesterol', score=88.0, common_rank=12, system='Ser/Plas', property='MCnc', raw_score=0.0, why='')]
-model = None, timeout = 30.0
-
- async def adjudicate(
- name: str,
- unit: str | None,
- specimen: str | None,
- candidates: list, # list[Candidate] from mapping.py
- model: str | None = None,
- timeout: float = 30.0, # noqa: ASYNC109 - passed to httpx, not an asyncio timeout
- ) -> tuple[str | None, str]:
- """-> (chosen loinc_code or None, model_used)."""
- if not settings.gemini_api_key:
-> raise LLMUnavailableError("GEMINI_API_KEY not set")
-E app.pipeline.llm.LLMUnavailableError: GEMINI_API_KEY not set
-
-app/pipeline/llm.py:62: LLMUnavailableErrormonkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x10d3a3170>
-
- async def test_llm_prompt_never_contains_the_measured_value(monkeypatch):
- """De-identification: name, unit and specimen go to Google. Never the
- patient, the date, or the result value."""
- sent = {}
-
- class R:
- status_code = 200
- def json(self): return {"candidates": [{"content": {"parts": [{"text": "0"}]}}]}
-
- class C:
- async def __aenter__(self): return self
- async def __aexit__(self, *a): return False
- async def post(self, *a, **k):
- sent["body"] = k["json"]["contents"][0]["parts"][0]["text"]
- return R()
-
- monkeypatch.setattr(llm.httpx, "AsyncClient", lambda **k: C())
-> await llm.adjudicate("FERRITIN", "ng/mL", "SERUM", CANDIDATES)
-
-tests/test_mapping.py:182:
-_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
-
-name = 'FERRITIN', unit = 'ng/mL', specimen = 'SERUM'
-candidates = [Candidate(loinc_code='2085-9', display='Cholesterol in HDL', score=90.0, common_rank=63, system='Ser/Plas', property=...2093-3', display='Cholesterol', score=88.0, common_rank=12, system='Ser/Plas', property='MCnc', raw_score=0.0, why='')]
-model = None, timeout = 30.0
-
- async def adjudicate(
- name: str,
- unit: str | None,
- specimen: str | None,
- candidates: list, # list[Candidate] from mapping.py
- model: str | None = None,
- timeout: float = 30.0, # noqa: ASYNC109 - passed to httpx, not an asyncio timeout
- ) -> tuple[str | None, str]:
- """-> (chosen loinc_code or None, model_used)."""
- if not settings.gemini_api_key:
-> raise LLMUnavailableError("GEMINI_API_KEY not set")
-E app.pipeline.llm.LLMUnavailableError: GEMINI_API_KEY not set
-
-app/pipeline/llm.py:62: LLMUnavailableError
\ No newline at end of file
diff --git a/backend/tests/fixtures/real_world/PROVENANCE.md b/backend/tests/fixtures/real_world/PROVENANCE.md
index bbd00c9..3a6021a 100644
--- a/backend/tests/fixtures/real_world/PROVENANCE.md
+++ b/backend/tests/fixtures/real_world/PROVENANCE.md
@@ -24,12 +24,33 @@ instead of a column gap, and a "Class N" severity column with no numeric
range at all. Real formatting is messier than the two synthetic fixtures in
`tests/test_extract.py` -- that's the point of having both.
-## Measured coverage (this corpus, current extractor)
-
-Run `pytest tests/test_real_world.py -v` to reproduce. As of the last
-extraction-accuracy pass: 85 rows extracted across the 6 files, 100% with a
-value, 74% with a flag, 68% with a unit, 40% with a reference range. The
-reference-range gap is mostly `labcorp_607016` (a "Class 0"/"Class III"
-severity label, not a number, so there is nothing to extract) and a few
-computed/ratio rows Quest prints without republishing a range. See
-`ARCHITECTURE.md` for what's still a known gap rather than a bug.
+## Measured accuracy (this corpus, current extractor)
+
+`expected.json` holds every row of the five readable reports -- 88 rows --
+checked by hand against the PDF text: name, value, unit, reference range,
+flag. `test_every_value_unit_and_range_matches_the_printed_report` requires
+an exact match, and `tests/test_mapping.py::REAL_ROWS` pins the LOINC code
+for the rows most likely to be mapped wrong.
+
+| | before the hand check | now |
+|---|---|---|
+| rows extracted | 85 | 88 (+ the "Component / Result / Component / Result" vitals pairs) |
+| values that are wrong | **4** | 0 |
+| rows with a unit | 58 (68%) | 75 (85%) |
+| rows with a reference range | 34 (40%) | 48 (55%) |
+| rows mapped to a LOINC code, offline | 30, **at least 8 wrong** | 81, **0 wrong** (7 left for review) |
+
+The four wrong values were the reason to stop counting rows: a positive
+SARS-CoV-2 result ("Detected Abnormal") and an indeterminate one read as
+**NOT DETECTED** -- the reference column taken for the value -- and a
+BUN/creatinine ratio ("NOT APPLICABLE NORMAL") read as its lab-site code
+"01". The wrong mappings: creatinine to the *urine* code, five differential
+percentages to absolute-count codes, HbA1c in % to a mass concentration,
+nucleated RBCs to spinal fluid, and body weight to the weight of a kidney
+stone. `app/data/common_tests.py` -- every code looked up in the seeded
+LOINC table -- fixes those; vital signs, which that table doesn't cover,
+go to review instead of being mapped to anything.
+
+The remaining range gap is mostly `labcorp_607016` (a "Class 0"/"Class III"
+severity grade, not a number) and Quest differential rows that print no
+range.
diff --git a/backend/tests/fixtures/real_world/expected.json b/backend/tests/fixtures/real_world/expected.json
new file mode 100644
index 0000000..bd81d3e
--- /dev/null
+++ b/backend/tests/fixtures/real_world/expected.json
@@ -0,0 +1,629 @@
+{
+ "_note": "Every row checked by hand against the PDF text: [name, value, unit, reference range, flag].",
+ "labcorp_001453_hba1c_sample.pdf": [
+ [
+ "Hemoglobin A1c",
+ "4.9",
+ "%",
+ "4.8 - 5.6",
+ null
+ ],
+ [
+ "Hemoglobin A1c",
+ "5.7",
+ "%",
+ "4.8 - 5.6",
+ "HIGH"
+ ]
+ ],
+ "labcorp_139900_covid_naa_sample.pdf": [
+ [
+ "SARS-CoV-2, NAA",
+ "NOT DETECTED",
+ null,
+ null,
+ null
+ ],
+ [
+ "SARS-CoV-2, NAA",
+ "DETECTED",
+ null,
+ null,
+ "ABNORMAL"
+ ],
+ [
+ "SARS-CoV-2, NAA",
+ "INDETERMINATE",
+ null,
+ null,
+ "ABNORMAL"
+ ]
+ ],
+ "labcorp_607016_allergy_ige_sample.pdf": [
+ [
+ "E001-IgE Cat Dander",
+ "<0.10",
+ "kU/L",
+ null,
+ null
+ ],
+ [
+ "E005-IgE Dog Dander",
+ "<0.10",
+ "kU/L",
+ null,
+ null
+ ],
+ [
+ "E003-IgE Horse Dander",
+ "<0.10",
+ "kU/L",
+ null,
+ null
+ ],
+ [
+ "E001-IgE Cat Dander",
+ "2.50",
+ "kU/L",
+ null,
+ "ABNORMAL"
+ ],
+ [
+ "E094-IgE Fel d 1",
+ "<0.10",
+ "kU/L",
+ null,
+ null
+ ],
+ [
+ "E220-IgE Fel d 2",
+ "<0.10",
+ "kU/L",
+ null,
+ null
+ ],
+ [
+ "E228-IgE Fel d 4",
+ "1.50",
+ "kU/L",
+ null,
+ "ABNORMAL"
+ ],
+ [
+ "E005-IgE Dog Dander",
+ "3.50",
+ "kU/L",
+ null,
+ "ABNORMAL"
+ ],
+ [
+ "E101-IgE Can f 1",
+ "2.00",
+ "kU/L",
+ null,
+ "ABNORMAL"
+ ],
+ [
+ "E102-IgE Can f 2",
+ "<0.10",
+ "kU/L",
+ null,
+ null
+ ],
+ [
+ "E221-IgE Can f 3",
+ "<0.10",
+ "kU/L",
+ null,
+ null
+ ],
+ [
+ "E226-IgE Can f 5",
+ "<0.10",
+ "kU/L",
+ null,
+ null
+ ],
+ [
+ "E003-IgE Horse Dander",
+ "6.00",
+ "kU/L",
+ null,
+ "ABNORMAL"
+ ],
+ [
+ "E227-IgE Equ c 1",
+ "4.00",
+ "kU/L",
+ null,
+ "ABNORMAL"
+ ]
+ ],
+ "quest_cmp_cbc_panel_sample.pdf": [
+ [
+ "GLUCOSE",
+ "85",
+ "mg/dL",
+ "65-99",
+ "NORMAL"
+ ],
+ [
+ "UREA NITROGEN (BUN)",
+ "14",
+ "mg/dL",
+ "7-25",
+ "NORMAL"
+ ],
+ [
+ "CREATININE",
+ "0.89",
+ "mg/dL",
+ "0.70-1.30",
+ "NORMAL"
+ ],
+ [
+ "EGFR",
+ "103",
+ "mL/min/1.73m2",
+ "≥60",
+ "NORMAL"
+ ],
+ [
+ "BUN/CREATININE RATIO",
+ "NOT APPLICABLE",
+ null,
+ "6-22",
+ "NORMAL"
+ ],
+ [
+ "SODIUM",
+ "135",
+ "mmol/L",
+ "135-146",
+ "NORMAL"
+ ],
+ [
+ "POTASSIUM",
+ "4.8",
+ "mmol/L",
+ "3.5-5.3",
+ "NORMAL"
+ ],
+ [
+ "CHLORIDE",
+ "104",
+ "mmol/L",
+ "98-110",
+ "NORMAL"
+ ],
+ [
+ "CARBON DIOXIDE",
+ "25",
+ "mmol/L",
+ "20-32",
+ "NORMAL"
+ ],
+ [
+ "CALCIUM",
+ "9.3",
+ "mg/dL",
+ "8.6-10.3",
+ "NORMAL"
+ ],
+ [
+ "PROTEIN, TOTAL",
+ "7.2",
+ "g/dL",
+ "6.1-8.1",
+ "NORMAL"
+ ],
+ [
+ "ALBUMIN",
+ "4.4",
+ "g/dL",
+ "3.6-5.1",
+ "NORMAL"
+ ],
+ [
+ "GLOBULIN",
+ "2.8",
+ "g/dL",
+ "1.9-3.7",
+ "NORMAL"
+ ],
+ [
+ "ALBUMIN/GLOBULIN RATIO",
+ "1.6",
+ null,
+ "1.0-2.5",
+ "NORMAL"
+ ],
+ [
+ "BILIRUBIN, TOTAL",
+ "0.6",
+ "mg/dL",
+ "0.2-1.2",
+ "NORMAL"
+ ],
+ [
+ "ALKALINE PHOSPHATASE",
+ "77",
+ "U/L",
+ "35-144",
+ "NORMAL"
+ ],
+ [
+ "AST",
+ "21",
+ "U/L",
+ "10-35",
+ "NORMAL"
+ ],
+ [
+ "ALT",
+ "20",
+ "U/L",
+ "9-46",
+ "NORMAL"
+ ],
+ [
+ "TESTOSTERONE, TOTAL, MS",
+ "1122",
+ "ng/dL",
+ "250-1100",
+ "HIGH"
+ ],
+ [
+ "ESTRADIOL",
+ "21",
+ "pg/mL",
+ "≤39",
+ "NORMAL"
+ ],
+ [
+ "FSH",
+ "<0.7",
+ "mIU/mL",
+ "1.6-8.0",
+ "LOW"
+ ],
+ [
+ "LH",
+ "<0.2",
+ "mIU/mL",
+ "1.5-9.3",
+ "LOW"
+ ],
+ [
+ "WHITE BLOOD CELL COUNT",
+ "5.1",
+ "Thousand/uL",
+ "3.8-10.8",
+ "NORMAL"
+ ],
+ [
+ "RED BLOOD CELL COUNT",
+ "5.44",
+ "Million/uL",
+ "4.20-5.80",
+ "NORMAL"
+ ],
+ [
+ "HEMOGLOBIN",
+ "16.3",
+ "g/dL",
+ "13.2-17.1",
+ "NORMAL"
+ ],
+ [
+ "HEMATOCRIT",
+ "48.5",
+ "%",
+ "38.5-50.0",
+ "NORMAL"
+ ],
+ [
+ "MCV",
+ "89.2",
+ "fL",
+ "80.0-100.0",
+ "NORMAL"
+ ],
+ [
+ "MCH",
+ "30.0",
+ "pg",
+ "27.0-33.0",
+ "NORMAL"
+ ],
+ [
+ "MCHC",
+ "33.6",
+ "g/dL",
+ "32.0-36.0",
+ "NORMAL"
+ ],
+ [
+ "RDW",
+ "13.4",
+ "%",
+ "11.0-15.0",
+ "NORMAL"
+ ],
+ [
+ "PLATELET COUNT",
+ "293",
+ "Thousand/uL",
+ "140-400",
+ "NORMAL"
+ ],
+ [
+ "MPV",
+ "10.4",
+ "fL",
+ "7.5-12.5",
+ "NORMAL"
+ ],
+ [
+ "ABSOLUTE NEUTROPHILS",
+ "3233",
+ "cells/uL",
+ "1500-7800",
+ "NORMAL"
+ ],
+ [
+ "ABSOLUTE BAND NEUTROPHILS",
+ "DNR",
+ "cells/uL",
+ "0-750",
+ "NORMAL"
+ ],
+ [
+ "ABSOLUTE METAMYELOCYTES",
+ "DNR",
+ null,
+ null,
+ "NORMAL"
+ ],
+ [
+ "ABSOLUTE MYELOCYTES",
+ "DNR",
+ null,
+ null,
+ "NORMAL"
+ ],
+ [
+ "ABSOLUTE PROMYELOCYTES",
+ "DNR",
+ null,
+ null,
+ "NORMAL"
+ ],
+ [
+ "ABSOLUTE LYMPHOCYTES",
+ "1372",
+ "cells/uL",
+ "850-3900",
+ "NORMAL"
+ ],
+ [
+ "ABSOLUTE MONOCYTES",
+ "423",
+ "cells/uL",
+ "200-950",
+ "NORMAL"
+ ],
+ [
+ "ABSOLUTE EOSINOPHILS",
+ "51",
+ "cells/uL",
+ "15-500",
+ "NORMAL"
+ ],
+ [
+ "ABSOLUTE BASOPHILS",
+ "20",
+ "cells/uL",
+ "0-200",
+ "NORMAL"
+ ],
+ [
+ "ABSOLUTE BLASTS",
+ "DNR",
+ null,
+ null,
+ "NORMAL"
+ ],
+ [
+ "ABSOLUTE NUCLEATED RBC",
+ "DNR",
+ null,
+ null,
+ "NORMAL"
+ ],
+ [
+ "NEUTROPHILS",
+ "63.4",
+ "%",
+ null,
+ "NORMAL"
+ ],
+ [
+ "BAND NEUTROPHILS",
+ "DNR",
+ "%",
+ null,
+ "NORMAL"
+ ],
+ [
+ "METAMYELOCYTES",
+ "DNR",
+ "%",
+ null,
+ "NORMAL"
+ ],
+ [
+ "MYELOCYTES",
+ "DNR",
+ "%",
+ null,
+ "NORMAL"
+ ],
+ [
+ "PROMYELOCYTES",
+ "DNR",
+ "%",
+ null,
+ "NORMAL"
+ ],
+ [
+ "LYMPHOCYTES",
+ "26.9",
+ "%",
+ null,
+ "NORMAL"
+ ],
+ [
+ "REACTIVE LYMPHOCYTES",
+ "DNR",
+ "%",
+ "0-10",
+ "NORMAL"
+ ],
+ [
+ "MONOCYTES",
+ "8.3",
+ "%",
+ null,
+ "NORMAL"
+ ],
+ [
+ "EOSINOPHILS",
+ "1.0",
+ "%",
+ null,
+ "NORMAL"
+ ],
+ [
+ "BASOPHILS",
+ "0.4",
+ "%",
+ null,
+ "NORMAL"
+ ],
+ [
+ "BLASTS",
+ "DNR",
+ "%",
+ null,
+ "NORMAL"
+ ],
+ [
+ "NUCLEATED RBC",
+ "DNR",
+ null,
+ null,
+ "NORMAL"
+ ]
+ ],
+ "quest_lipid_panel_sample.pdf": [
+ [
+ "Glucose",
+ "115",
+ "mg/dL",
+ "65-99",
+ "H"
+ ],
+ [
+ "Hemoglobin A1c",
+ "6.1",
+ "% of total Hgb",
+ "<5.7",
+ "H"
+ ],
+ [
+ "Total Cholesterol",
+ "164",
+ "mg/dL",
+ "125-200",
+ null
+ ],
+ [
+ "HDL Cholesterol",
+ "81",
+ "mg/dL",
+ "≥46",
+ null
+ ],
+ [
+ "Triglycerides",
+ "45",
+ "mg/dL",
+ "<150",
+ null
+ ],
+ [
+ "LDL Cholesterol (Calculated)",
+ "74",
+ "mg/dL",
+ "<130",
+ null
+ ],
+ [
+ "Non-HDL Cholesterol",
+ "83",
+ "mg/dL",
+ null,
+ null
+ ],
+ [
+ "Cholesterol/HDL Ratio (Calculated)",
+ "2.0",
+ null,
+ "≤5.0",
+ null
+ ],
+ [
+ "HEIGHT FEET",
+ "5",
+ "ft",
+ null,
+ null
+ ],
+ [
+ "SYSTOLIC BLOOD PRESSURE",
+ "106",
+ "mmHg",
+ null,
+ null
+ ],
+ [
+ "HEIGHT INCHES",
+ "4",
+ "in",
+ null,
+ null
+ ],
+ [
+ "DIASTOLIC BLOOD PRESSURE",
+ "65",
+ "mmHg",
+ null,
+ null
+ ],
+ [
+ "WEIGHT",
+ "150",
+ "lbs",
+ null,
+ null
+ ],
+ [
+ "CALCULATED BMI",
+ "25.7",
+ null,
+ null,
+ null
+ ]
+ ]
+}
diff --git a/backend/tests/test_mapping.py b/backend/tests/test_mapping.py
index 998ff47..1e367d1 100644
--- a/backend/tests/test_mapping.py
+++ b/backend/tests/test_mapping.py
@@ -190,3 +190,43 @@ async def test_llm_unavailable_when_no_key(monkeypatch):
monkeypatch.setattr(llm.settings, "gemini_api_key", None)
with pytest.raises(llm.LLMUnavailableError):
await llm.adjudicate("X", None, None, CANDIDATES)
+
+
+# --- real reports ----------------------------------------------------------
+
+# Every row of five real Quest/LabCorp sample reports (tests/fixtures/
+# real_world/expected.json), mapped by hand-checked code. Before the curated
+# stage these went wrong, not just unmapped: creatinine to the urine code,
+# "NEUTROPHILS 63.4 %" to the absolute-count code, HbA1c in % to a mass
+# concentration, "WEIGHT" to the weight of a kidney stone.
+REAL_ROWS = [
+ ("CREATININE", "mg/dL", "2160-0"),
+ ("NEUTROPHILS", "%", "770-8"),
+ ("ABSOLUTE NEUTROPHILS", "cells/uL", "751-8"),
+ ("LYMPHOCYTES", "%", "736-9"),
+ ("Hemoglobin A1c", "% of total Hgb", "4548-4"),
+ ("ABSOLUTE BLASTS", None, "30376-8"), # a "DNR" row prints no unit
+ ("UREA NITROGEN (BUN)", "mg/dL", "3094-0"),
+ ("HDL Cholesterol", "mg/dL", "2085-9"),
+ ("Cholesterol/HDL Ratio (Calculated)", None, "9830-1"),
+ ("SARS-CoV-2, NAA", None, "94309-2"),
+ ("E001-IgE Cat Dander", "kU/L", "6833-8"),
+]
+
+
+@pytest.mark.parametrize("name,unit,code", REAL_ROWS)
+async def test_real_report_rows_map_to_the_checked_code(loinc_db, name, unit, code):
+ result = await resolve(name, unit)
+ assert (result.stage, result.loinc_code) == ("curated", code)
+
+
+async def test_a_differential_without_a_unit_is_not_guessed(loinc_db):
+ # "NEUTROPHILS" is a % or a count; with no unit, the curated stage
+ # declines and leaves it to the cascade.
+ result = await resolve("NEUTROPHILS", None)
+ assert result.stage != "curated"
+
+
+async def test_vital_signs_are_left_for_review_not_mapped_absurdly(loinc_db):
+ result = await resolve("WEIGHT", "lbs")
+ assert (result.stage, result.loinc_code) == ("unmapped", None)
diff --git a/backend/tests/test_panel_trends.py b/backend/tests/test_panel_trends.py
index f15b8e5..dafd77e 100644
--- a/backend/tests/test_panel_trends.py
+++ b/backend/tests/test_panel_trends.py
@@ -50,11 +50,16 @@ async def test_an_analyte_with_nothing_chartable_is_still_returned(client, accou
h, pid = await loaded(client, account)
# Everything awaiting review is excluded from a chart by the same rule the
- # single-analyte series uses, so a fresh document has such rows.
- d = (await client.get(f"/api/observations/{pid}/panel-trends?panel=cbc",
- headers=h)).json()
+ # single-analyte series uses. Put one analyte there explicitly: which rows
+ # a fresh document leaves pending changes as the cascade improves.
+ url = f"/api/observations/{pid}/panel-trends?panel=cbc"
+ code = (await client.get(url, headers=h)).json()["tracks"][0]["loinc_code"]
+ await Observation.find(Observation.loinc_code == code).update(
+ {"$set": {"review_status": "pending"}})
+
+ d = (await client.get(url, headers=h)).json()
empty = [t for t in d["tracks"] if not t["points"]]
- assert empty, "the fixture leaves some CBC rows pending review"
+ assert [t["loinc_code"] for t in empty] == [code]
for t in empty:
assert t["excluded"] > 0, f"{t['loinc_code']} vanished without saying why"
diff --git a/backend/tests/test_real_world.py b/backend/tests/test_real_world.py
index 8ca03d0..9c998d0 100644
--- a/backend/tests/test_real_world.py
+++ b/backend/tests/test_real_world.py
@@ -49,3 +49,21 @@ def test_every_row_across_the_corpus_has_a_value():
total += 1
assert row.raw_value is not None, f"{pdf.name}: {row}"
assert total >= 80
+
+
+def test_every_value_unit_and_range_matches_the_printed_report():
+ """Ground truth: each row of five real sample reports, checked by hand
+ against the PDF. Exact, not counts -- a count-based test passed while a
+ positive SARS-CoV-2 result read as "NOT DETECTED" (the reference column
+ taken for the value) and a ratio's value read as its lab-site code "01"."""
+ import json
+ expected = json.loads((FIXTURES / "expected.json").read_text(encoding="utf-8"))
+ expected.pop("_note")
+ wrong = []
+ for name, rows in expected.items():
+ got = [[r.raw_name, r.raw_value, r.raw_unit, r.raw_ref_range, r.raw_flag]
+ for r in extract((FIXTURES / name).read_bytes()).rows]
+ for want in rows:
+ if want not in got:
+ wrong.append(f"{name}: expected {want}")
+ assert not wrong, "\n".join(wrong)
diff --git a/frontend/src/screens/Provenance.jsx b/frontend/src/screens/Provenance.jsx
index 51fdeab..e5f08e1 100644
--- a/frontend/src/screens/Provenance.jsx
+++ b/frontend/src/screens/Provenance.jsx
@@ -22,6 +22,10 @@ const STAGE_STORY = {
title: "Matched a name you confirmed",
body: "You have confirmed this printed name before, so it resolved by lookup — no matching, no model.",
},
+ curated: {
+ title: "Matched a common lab test",
+ body: "This printed name is one of the tests reports print most, with its code checked in the LOINC table ahead of time. Where a name means two tests — a differential as a percentage or a count — the printed unit decided.",
+ },
exact: {
title: "Matched a LOINC name exactly",
body: "The printed name is one of this code's own names, character for character after normalising case and punctuation.",
From 55762802d589abd9ff5555f499a73868135d2df1 Mon Sep 17 00:00:00 2001
From: MaXiMo000
Date: Sat, 10 Oct 2026 12:12:25 +0530
Subject: [PATCH 2/7] Export a record as a FHIR R4 bundle
GET /api/observations/{patient}/fhir returns every row, and the Record
screen downloads it. A value travels in the unit of the range it was
judged against; a row awaiting review goes out as preliminary with the
printed name and no code. The download is audited.
Co-Authored-By: Claude Opus 5.5
---
backend/app/pipeline/fhir.py | 112 ++++++++++++++++++++++++++++
backend/app/routers/observations.py | 25 ++++++-
backend/tests/test_fhir.py | 85 +++++++++++++++++++++
frontend/src/screens/Record.jsx | 37 ++++++++-
4 files changed, 257 insertions(+), 2 deletions(-)
create mode 100644 backend/app/pipeline/fhir.py
create mode 100644 backend/tests/test_fhir.py
diff --git a/backend/app/pipeline/fhir.py b/backend/app/pipeline/fhir.py
new file mode 100644
index 0000000..3ee68f7
--- /dev/null
+++ b/backend/app/pipeline/fhir.py
@@ -0,0 +1,112 @@
+"""Export observations as a FHIR R4 Bundle.
+
+So a record can leave in a form another system reads.
+
+Three rules shape what goes out:
+
+* **A value travels with the range it was judged against.** A printed range is
+ in the laboratory's units and a built-in one is in the canonical unit, so
+ the exported quantity follows whichever the range came from. Exporting the
+ canonical value beside a printed range would pair a number with an interval
+ in a different unit.
+* **An unconfirmed code is not asserted.** A row still awaiting review goes
+ out as `preliminary` with the printed name only. A LOINC code in an export
+ reads as a fact to whatever imports it.
+* **Units are the printed or canonical strings, without a UCUM `system`.**
+ Laboratories print things like `x10E3/uL` that are not valid UCUM, and
+ labelling them as UCUM would be a false claim about every one of them.
+"""
+
+import uuid
+from datetime import date
+
+from app.models.observation import Observation
+
+LOINC = "http://loinc.org"
+_CATEGORY = "http://terminology.hl7.org/CodeSystem/observation-category"
+_INTERPRETATION = "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation"
+_FLAG = {"low": ("L", "Low"), "high": ("H", "High"),
+ "abnormal": ("A", "Abnormal"), "normal": ("N", "Normal")}
+_GENDER = {"M": "male", "F": "female", "X": "other"}
+
+
+def _urn(object_id) -> str:
+ """Return a stable urn:uuid for a database id.
+
+ Bundle entries need an absolute fullUrl, and there is no FHIR server here
+ to give them a real one.
+ """
+ return f"urn:uuid:{uuid.uuid5(uuid.NAMESPACE_OID, str(object_id))}"
+
+
+def _quantity(value: float, unit: str | None) -> dict:
+ return {"value": value, **({"unit": unit} if unit else {})}
+
+
+def observation(o: Observation) -> dict:
+ """Render one row as a FHIR Observation resource."""
+ asserted = bool(o.loinc_code) and o.review_status != "pending"
+ code: dict = {"text": o.raw_name}
+ if asserted:
+ coding = {"system": LOINC, "code": o.loinc_code}
+ if o.loinc_display:
+ coding["display"] = o.loinc_display
+ code["coding"] = [coding]
+
+ out: dict = {
+ "resourceType": "Observation",
+ "id": str(o.id),
+ "status": "preliminary" if o.review_status == "pending" else "final",
+ "category": [{"coding": [{"system": _CATEGORY, "code": "laboratory"}]}],
+ "code": code,
+ "subject": {"reference": _urn(o.patient_id)},
+ }
+ if o.collected_at:
+ out["effectiveDateTime"] = o.collected_at.isoformat()
+
+ builtin = o.ref_source == "builtin" and o.canonical_value is not None
+ value = o.canonical_value if builtin else o.value_num
+ unit = o.canonical_unit if builtin else o.raw_unit
+ if value is not None:
+ out["valueQuantity"] = _quantity(value, unit)
+ if o.value_operator:
+ out["valueQuantity"]["comparator"] = o.value_operator
+ elif o.value_text or o.raw_value:
+ out["valueString"] = o.value_text or o.raw_value
+
+ if o.ref_low is not None or o.ref_high is not None or o.raw_ref_range:
+ rng: dict = {}
+ if o.ref_low is not None:
+ rng["low"] = _quantity(o.ref_low, unit)
+ if o.ref_high is not None:
+ rng["high"] = _quantity(o.ref_high, unit)
+ if o.ref_source == "pdf" and o.raw_ref_range:
+ rng["text"] = o.raw_ref_range
+ elif o.ref_source == "builtin":
+ rng["text"] = "LabLedger built-in interval, not printed on the report"
+ if rng:
+ out["referenceRange"] = [rng]
+
+ if o.flag in _FLAG:
+ flag_code, display = _FLAG[o.flag]
+ out["interpretation"] = [{"coding": [
+ {"system": _INTERPRETATION, "code": flag_code, "display": display}]}]
+ if o.raw_specimen:
+ out["specimen"] = {"display": o.raw_specimen}
+ return out
+
+
+def bundle(patient_id: str, name: str, dob: date | None, sex: str | None,
+ rows: list[Observation]) -> dict:
+ """Build a `collection` Bundle: the patient, then every observation."""
+ patient: dict = {"resourceType": "Patient", "id": patient_id, "name": [{"text": name}]}
+ if dob:
+ patient["birthDate"] = dob.isoformat()
+ if sex in _GENDER:
+ patient["gender"] = _GENDER[sex]
+ resources = [patient] + [observation(o) for o in rows]
+ return {
+ "resourceType": "Bundle",
+ "type": "collection",
+ "entry": [{"fullUrl": _urn(r["id"]), "resource": r} for r in resources],
+ }
diff --git a/backend/app/routers/observations.py b/backend/app/routers/observations.py
index f46170b..9f1e1d6 100644
--- a/backend/app/routers/observations.py
+++ b/backend/app/routers/observations.py
@@ -3,6 +3,7 @@
from datetime import date, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
+from fastapi.responses import JSONResponse
from pydantic import BaseModel
from app import repo
@@ -14,7 +15,7 @@
from app.models.loinc import LoincEntry
from app.models.observation import Observation
from app.models.user import User
-from app.pipeline import flags
+from app.pipeline import fhir, flags
from app.pipeline.derived import derive
from app.pipeline.insights import analyse
from app.security import decrypt_str
@@ -640,6 +641,28 @@ async def panels(
return out
+@router.get("/{patient_id}/fhir")
+async def fhir_export(patient_id: str, user: User = Depends(current_user)):
+ """Return the whole record as a FHIR R4 Bundle, to take to another system.
+
+ Every row goes out, including the ones no chart shows: an export that
+ quietly held back the unmapped and the unreviewed would be a different
+ record from the one stored. See `pipeline/fhir.py` for what is and is not
+ asserted about each.
+ """
+ patient, q = await repo.patient_observations(user, patient_id)
+ rows = await q.sort(*_CHART_ORDER).to_list()
+ dob_raw = decrypt_str(patient.dob_enc)
+ body = fhir.bundle(
+ str(patient.id), patient.display_name,
+ date.fromisoformat(dob_raw) if dob_raw else None,
+ decrypt_str(patient.sex_at_birth_enc), rows,
+ )
+ await record("download", "observation", patient_id=patient.id)
+ return JSONResponse(body, media_type="application/fhir+json", headers={
+ "Content-Disposition": 'attachment; filename="labledger-fhir.json"'})
+
+
# Declared last on purpose: this path parameter would otherwise capture the
# literal sibling routes above it, and "/panels" would arrive here as an id.
@router.get("/item/{observation_id}", response_model=Provenance)
diff --git a/backend/tests/test_fhir.py b/backend/tests/test_fhir.py
new file mode 100644
index 0000000..c5ae552
--- /dev/null
+++ b/backend/tests/test_fhir.py
@@ -0,0 +1,85 @@
+"""The FHIR export: the record leaves whole, and says only what is known."""
+
+from pathlib import Path
+
+import pytest
+
+from app.models.observation import Observation
+from app.worker import process_document
+
+pytestmark = pytest.mark.asyncio
+
+PDF = (Path(__file__).parent / "fixtures" / "quest_style.pdf").read_bytes()
+
+
+async def exported(client, account):
+ h, pid = account
+ doc_id = (await client.post(f"/api/documents/{pid}", headers=h,
+ files={"file": ("q.pdf", PDF, "application/pdf")})).json()["id"]
+ await process_document({}, doc_id)
+ r = await client.get(f"/api/observations/{pid}/fhir", headers=h)
+ assert r.status_code == 200
+ assert r.headers["content-type"].startswith("application/fhir+json")
+ return r.json(), pid
+
+
+async def test_needs_a_session_and_hides_other_records(client, account):
+ _, pid = account
+ assert (await client.get(f"/api/observations/{pid}/fhir")).status_code == 401
+ r = await client.post("/api/auth/register", json={
+ "email": "stranger@example.com", "name": "Stranger",
+ "password": "correct-horse-battery"})
+ other = {"Authorization": f"Bearer {r.json()['access_token']}"}
+ assert (await client.get(f"/api/observations/{pid}/fhir", headers=other)).status_code == 404
+
+
+async def test_every_row_leaves_and_points_at_the_patient(client, account):
+ bundle, _ = await exported(client, account)
+ assert (bundle["resourceType"], bundle["type"]) == ("Bundle", "collection")
+ patient, *obs = bundle["entry"]
+ assert patient["resource"]["resourceType"] == "Patient"
+ assert patient["resource"]["birthDate"] == "1996-04-12"
+ assert len(obs) == await Observation.find_all().count() > 0
+ urls = [e["fullUrl"] for e in bundle["entry"]]
+ assert len(set(urls)) == len(urls)
+ for e in obs:
+ r = e["resource"]
+ assert r["subject"]["reference"] == patient["fullUrl"]
+ assert r["code"]["text"]
+ assert ("valueQuantity" in r) != ("valueString" in r)
+
+
+async def test_an_unconfirmed_code_is_not_asserted(client, account):
+ h, pid = account
+ await exported(client, account)
+ o = await Observation.find_one(Observation.loinc_code != None) # noqa: E711
+ o.review_status = "pending"
+ await o.save()
+ bundle = (await client.get(f"/api/observations/{pid}/fhir", headers=h)).json()
+ by_id = {e["resource"]["id"]: e["resource"] for e in bundle["entry"]}
+ pending = by_id[str(o.id)]
+ assert pending["status"] == "preliminary"
+ assert "coding" not in pending["code"]
+ final = [r for r in by_id.values()
+ if r["resourceType"] == "Observation" and r["status"] == "final"]
+ assert any(r["code"].get("coding", [{}])[0].get("system") == "http://loinc.org" for r in final)
+
+
+async def test_a_value_shares_a_unit_with_its_range(client, account):
+ bundle, _ = await exported(client, account)
+ checked = 0
+ for e in bundle["entry"][1:]:
+ r = e["resource"]
+ for rng in r.get("referenceRange", []):
+ for bound in ("low", "high"):
+ if bound in rng and "valueQuantity" in r:
+ assert rng[bound].get("unit") == r["valueQuantity"].get("unit")
+ checked += 1
+ assert checked > 0
+
+
+async def test_the_download_is_in_the_audit_trail(client, account):
+ h, pid = account
+ await exported(client, account)
+ rows = (await client.get(f"/api/audit/patient/{pid}", headers=h)).json()
+ assert any(r["action"] == "download" and r["resource"] == "observation" for r in rows)
diff --git a/frontend/src/screens/Record.jsx b/frontend/src/screens/Record.jsx
index 1c7a944..1257830 100644
--- a/frontend/src/screens/Record.jsx
+++ b/frontend/src/screens/Record.jsx
@@ -539,7 +539,9 @@ function Activity({ patientId }) {
{when(r.at)}{r.actor_email}
- {ACTION_WORDS[r.action] ?? r.action} {r.resource}
+ {r.action === "download" && r.resource === "observation"
+ ? "exported the results (FHIR)"
+ : `${ACTION_WORDS[r.action] ?? r.action} ${r.resource}`}
{r.ip ?? ""}
@@ -557,6 +559,38 @@ function Activity({ patientId }) {
);
}
+function Export({ patientId }) {
+ const [error, setError] = useState(null);
+ const save = useMutation({
+ mutationFn: () => api.get(`/observations/${patientId}/fhir`, { responseType: "blob" }),
+ onSuccess: ({ data }) => {
+ const url = URL.createObjectURL(data);
+ const a = Object.assign(document.createElement("a"), { href: url, download: "labledger-fhir.json" });
+ a.click();
+ URL.revokeObjectURL(url);
+ },
+ onError: (e) => setError(messageFor(e)),
+ });
+
+ return (
+
+
+
Take this record with you
+
+
+
+ Every result as a FHIR R4 bundle, the format hospital systems and health
+ apps exchange. Results still awaiting review go out marked preliminary,
+ with the name as printed and no code. The download is recorded below.
+
+ {error &&
{error}
}
+
+ );
+}
+
export default function Record() {
const { active } = usePatient();
@@ -575,6 +609,7 @@ export default function Record() {
+
{isOwner ? (
<>
From ddb73db1c2fd686b6c3c6de4d486607811d07394 Mon Sep 17 00:00:00 2001
From: MaXiMo000
Date: Sat, 10 Oct 2026 12:12:53 +0530
Subject: [PATCH 3/7] Add a privacy page stating what the software does with a
report
What is stored, what is encrypted, what can leave the server and who can
see a record, each line describing existing backend behaviour. Linked
from the landing page and sign-in.
Co-Authored-By: Claude Opus 5.5
---
frontend/src/App.jsx | 2 +
frontend/src/screens/Privacy.css | 28 +++++++++++
frontend/src/screens/Privacy.jsx | 81 ++++++++++++++++++++++++++++++++
frontend/src/screens/SignIn.jsx | 3 ++
frontend/src/sections/Hero.jsx | 1 +
5 files changed, 115 insertions(+)
create mode 100644 frontend/src/screens/Privacy.css
create mode 100644 frontend/src/screens/Privacy.jsx
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 2de5f03..ed03384 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -25,6 +25,7 @@ const Security = lazy(() => import("./screens/Security.jsx"));
const Invite = lazy(() => import("./screens/Invite.jsx"));
const Aliases = lazy(() => import("./screens/Aliases.jsx"));
const ResetPassword = lazy(() => import("./screens/ResetPassword.jsx"));
+const Privacy = lazy(() => import("./screens/Privacy.jsx"));
const queryClient = new QueryClient({
defaultOptions: {
@@ -94,6 +95,7 @@ export default function App() {
{/* Public: the whole point is that the invitee has no access yet. */}
} />
} />
+ } />
+ LabLedger
+
Privacy
+
What happens to your reports
+
+ LabLedger is software somebody runs. Whoever operates this server holds
+ the data and is responsible for it; this page describes what the
+ software itself does.
+
+
+ {SECTIONS.map((s) => (
+
+
{s.title}
+
+ {s.items.map((text) =>
{text}
)}
+
+
+ ))}
+
+
+ LabLedger is not a medical device and gives no advice. It shows what
+ was printed, what it resolved that to, and why.
+
{/* The right column is the product, not an illustration of it: three
diff --git a/frontend/src/sections/Hero.jsx b/frontend/src/sections/Hero.jsx
index ac32e32..9e8ae74 100644
--- a/frontend/src/sections/Hero.jsx
+++ b/frontend/src/sections/Hero.jsx
@@ -47,6 +47,7 @@ export default function Hero() {
See how it resolves
↓
+ What happens to your data
From f3104d2c5b002cdfd408242ed21fbb21d6461323 Mon Sep 17 00:00:00 2001
From: MaXiMo000
Date: Sat, 10 Oct 2026 12:19:05 +0530
Subject: [PATCH 4/7] Stop dropping rows named "K" and "A1C"
A name needed two letters in a row, so a potassium printed as "K" and an
HbA1c printed as "A1C" vanished from the report without a trace. Also
adds the HGB, HCT, PLT, A1C and hyphenated LDL spellings to the curated
table.
Co-Authored-By: Claude Opus 5.5
---
backend/app/data/common_tests.py | 8 +++++---
backend/app/pipeline/extract.py | 13 +++++++++++--
backend/tests/test_extract.py | 9 +++++++++
3 files changed, 25 insertions(+), 5 deletions(-)
diff --git a/backend/app/data/common_tests.py b/backend/app/data/common_tests.py
index 06d3b52..6268c3d 100644
--- a/backend/app/data/common_tests.py
+++ b/backend/app/data/common_tests.py
@@ -37,20 +37,22 @@
"TOTAL CHOLESTEROL": "2093-3", "CHOLESTEROL TOTAL": "2093-3", "CHOLESTEROL": "2093-3",
"HDL CHOLESTEROL": "2085-9", "HDL": "2085-9",
"LDL CHOLESTEROL CALCULATED": "13457-7", "LDL CHOLESTEROL": "13457-7",
+ "LDL-CHOLESTEROL": "13457-7",
"Non-HDL Cholesterol": "43396-1",
"Cholesterol/HDL Ratio": "9830-1", "Cholesterol/HDL Ratio (Calculated)": "9830-1",
"TRIGLYCERIDES": "2571-8",
# glycemic
- "HEMOGLOBIN A1C": {"percent": "4548-4"},
+ "HEMOGLOBIN A1C": {"percent": "4548-4"}, "A1C": {"percent": "4548-4"},
+ "HBA1C": {"percent": "4548-4"},
# hormones
"TESTOSTERONE TOTAL MS": "2986-8", "TESTOSTERONE TOTAL": "2986-8",
"ESTRADIOL": "2243-4", "FSH": "15067-2", "LH": "10501-5",
# CBC
"WHITE BLOOD CELL COUNT": "6690-2", "WBC": "6690-2",
"RED BLOOD CELL COUNT": "789-8", "RBC": "789-8",
- "HEMOGLOBIN": "718-7", "HEMATOCRIT": "4544-3",
+ "HEMOGLOBIN": "718-7", "HGB": "718-7", "HEMATOCRIT": "4544-3", "HCT": "4544-3",
"MCV": "787-2", "MCH": "785-6", "MCHC": "786-4", "RDW": "788-0",
- "PLATELET COUNT": "777-3", "PLATELETS": "777-3", "MPV": "32623-1",
+ "PLATELET COUNT": "777-3", "PLATELETS": "777-3", "PLT": "777-3", "MPV": "32623-1",
# differential: % of leukocytes, or an absolute count
"NEUTROPHILS": {"percent": "770-8", "count": "751-8"},
"LYMPHOCYTES": {"percent": "736-9", "count": "731-0"},
diff --git a/backend/app/pipeline/extract.py b/backend/app/pipeline/extract.py
index afc8bef..42116c0 100644
--- a/backend/app/pipeline/extract.py
+++ b/backend/app/pipeline/extract.py
@@ -165,6 +165,15 @@ def detect_specimen(line: str) -> str | None:
(re.compile(r"<\s*OR\s*=\s*", re.IGNORECASE), "≤")]
+def _plausible_name(name: str) -> bool:
+ """Tell a test name from a stray token in the name column.
+
+ Two letters anywhere, not two in a row: "A1C" is a name. One letter is a
+ flag ("H", "L") except for potassium, which some labs print as "K".
+ """
+ return len(name) <= 60 and (name.upper() == "K" or len(re.findall(r"[A-Za-z]", name)) >= 2)
+
+
def clean_token(tok: str) -> str:
t = " ".join(line for line in tok.splitlines() if not _SEE_NOTE.fullmatch(line.strip()))
t = _CALC.sub(" ", t)
@@ -288,7 +297,7 @@ def parse_line(line: str, page: int, line_no: int, specimen: str | None) -> RawR
if not m:
return None
name = re.sub(r"\s+", " ", m.group("name")).strip(" .:-")
- if not (2 <= len(name) <= 60) or not re.search(r"[A-Za-z]{2}", name):
+ if not _plausible_name(name):
return None
rest = m.group("rest")
@@ -332,7 +341,7 @@ def _rows_from_tables(pdf) -> list[RawRow]:
for pair in pairs:
name, rest = pair[0], pair[1:]
name = re.sub(r"\s+", " ", name).strip(" .:-")
- if not (2 <= len(name) <= 60) or not re.search(r"[A-Za-z]{2}", name):
+ if not _plausible_name(name):
continue
value, unit, ref, flag = classify_tokens(rest)
if value is None:
diff --git a/backend/tests/test_extract.py b/backend/tests/test_extract.py
index 8835a34..71ec17d 100644
--- a/backend/tests/test_extract.py
+++ b/backend/tests/test_extract.py
@@ -132,3 +132,12 @@ def test_extract_raises_typed_error_on_unreadable_bytes():
"""Callers need to distinguish "bad PDF" from a bug in the extractor."""
with pytest.raises(UnreadablePDFError):
extract(b"this is not a pdf")
+
+
+def test_short_analyte_names_are_rows_and_stray_flags_are_not():
+ """ "K" and "A1C" are how some labs print potassium and HbA1c. Both were
+ dropped without a trace: one for being a single letter, the other for
+ having no two letters in a row."""
+ names = [parse_line(f"{n} 4.1 mmol/L 3.5-5.2", 1, 1, None)
+ for n in ("K", "A1C", "H", "7")]
+ assert [r.raw_name if r else None for r in names] == ["K", "A1C", None, None]
From 6113a3f10629a12898f0dcd73f41be311bbb8afd Mon Sep 17 00:00:00 2001
From: MaXiMo000
Date: Sat, 10 Oct 2026 12:19:05 +0530
Subject: [PATCH 5/7] Add a one-command demo with synthetic data
docker compose up --build starts Mongo, Redis, the API, the worker and
the built frontend behind nginx, generates its own keys into a volume,
and reads six generated reports through the real pipeline. CI runs it
and pulls the FHIR export through the proxy.
The sample reports now name a fictional patient.
Co-Authored-By: Claude Opus 5.5
---
.github/workflows/ci.yml | 44 ++++++++++++-
README.md | 63 +++++++++++++++---
backend/.dockerignore | 8 +++
backend/.gitignore | 1 +
backend/Dockerfile | 9 +++
backend/scripts/make_sample_reports.py | 8 ++-
backend/scripts/seed_demo.py | 88 ++++++++++++++++++++++++++
docker-compose.yml | 84 ++++++++++++++++++++++++
frontend/.dockerignore | 2 +
frontend/Dockerfile | 10 +++
frontend/nginx.conf | 18 ++++++
11 files changed, 323 insertions(+), 12 deletions(-)
create mode 100644 backend/.dockerignore
create mode 100644 backend/Dockerfile
create mode 100644 backend/scripts/seed_demo.py
create mode 100644 docker-compose.yml
create mode 100644 frontend/.dockerignore
create mode 100644 frontend/Dockerfile
create mode 100644 frontend/nginx.conf
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0ab7f0d..c59da2f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -70,7 +70,7 @@ jobs:
run: |
python - <<'PY'
import sys, xml.etree.ElementTree as ET
- FLOOR = 360
+ FLOOR = 400
try:
s = ET.parse("results.xml").getroot()
except Exception as e:
@@ -103,6 +103,48 @@ jobs:
- run: npm run build
working-directory: frontend
+ demo:
+ # `docker compose up` is the first thing the README asks anyone to run, so
+ # it is run here: build, seed, sign in through the proxy and pull the
+ # export. Six reports of twenty rows each, plus the patient.
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - run: docker compose up -d --build
+ - name: Sign in and export
+ run: |
+ for _ in $(seq 1 60); do
+ curl -fsS http://localhost:8080/api/health >/dev/null && break
+ sleep 2
+ done
+ python3 - <<'PY'
+ import json, urllib.request
+
+ BASE = "http://localhost:8080"
+
+ def call(path, body=None, token=None):
+ headers = {"Content-Type": "application/json"}
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+ req = urllib.request.Request(
+ BASE + path, headers=headers,
+ data=json.dumps(body).encode() if body else None)
+ return urllib.request.urlopen(req, timeout=30).read()
+
+ token = json.loads(call("/api/auth/login", {
+ "email": "demo@example.com", "password": "labledger-demo-only"}))["access_token"]
+ patient = json.loads(call("/api/patients", token=token))[0]["id"]
+ bundle = json.loads(call(f"/api/observations/{patient}/fhir", token=token))
+ entries = len(bundle["entry"])
+ coded = sum("coding" in e["resource"].get("code", {}) for e in bundle["entry"])
+ print(f"{entries} entries, {coded} with a LOINC code")
+ assert entries == 121, entries
+ assert coded >= 90, coded
+ assert b'id="root"' in call("/privacy")
+ PY
+ - if: failure()
+ run: docker compose logs --no-color --tail 80
+
carabiner:
# The scanner its author wrote, gating the repo that handles lab reports.
runs-on: ubuntu-latest
diff --git a/README.md b/README.md
index e6e4316..5bd9e7a 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,23 @@ The hard part is not the chart. It is that `FERRTN SER`, `Ferritin, Serum` and
µg/L and another in ng/mL, and that being confidently wrong about either is
worse than admitting you do not know.
+## Try it
+
+```bash
+docker compose up --build
+```
+
+Then open and sign in as `demo@example.com` /
+`labledger-demo-only`. The record holds six generated reports from three
+"labs" across three years, read by the same pipeline an upload goes through:
+ferritin falls from 96 to 18 ng/mL, and the third lab prints it as
+`FERRTN SER` in µg/L, so the names and units reconcile into one line.
+
+Nothing in it is a real person or result. It listens on localhost only,
+generates its own keys on first start, and has no LLM key, so whatever the
+deterministic stages cannot settle is waiting in Review. `docker compose down
+-v` removes it.
+
---
## What it does
@@ -19,7 +36,7 @@ worse than admitting you do not know.
| | |
|---|---|
| **Extract** | `pdfplumber` over the page, table-aware, falling back to line parsing |
-| **Map** | five-stage cascade from exact match to an LLM that may only *pick from a list* |
+| **Map** | six-stage cascade from a confirmed alias to an LLM that may only *pick from a list* |
| **Convert** | hand-audited unit table per LOINC — deterministic or absent, never guessed |
| **Range** | the report's own interval when printed; an age- and sex-aware table when not |
| **Flag** | low / normal / high, plus critical limits and delta checks (below) |
@@ -32,13 +49,27 @@ than narrowing to empty:
1. **Alias** — this user already confirmed what this printed name means. Free,
deterministic, no network.
-2. **Exact** — the printed name matches a LOINC component outright.
-3. **Related, corroborated** — LOINC's associative `RELATEDNAMES2`, but only
+2. **Curated** — the ~100 printed names real reports use most (metabolic
+ panel, CBC and differential, lipids, HbA1c, common allergy and infectious
+ tests), each code looked up in the seeded LOINC table. Where one name is
+ two tests the printed unit decides: "NEUTROPHILS 63.4 %" is a fraction of
+ leukocytes, "3233 cells/uL" a count; with no unit it declines. Blood and
+ serum only — "GLUCOSE" in a urine panel goes on down the cascade.
+3. **Exact** — the printed name matches a LOINC component outright.
+4. **Related, corroborated** — LOINC's associative `RELATEDNAMES2`, but only
when a second signal agrees. A related name is *never* identity on its own.
-4. **Narrowed fuzzy** — `rapidfuzz` over a pool narrowed by specimen and unit.
-5. **LLM** — `gemini-3.5-flash`, offered a numbered list and required to return
+5. **Narrowed fuzzy** — `rapidfuzz` over a pool narrowed by specimen and unit.
+6. **LLM** — `gemini-3.5-flash`, offered a numbered list and required to return
an index. It cannot invent a code, and it is expected to abstain.
+Checked against every row of five real Quest and LabCorp sample reports (88
+rows, each verified by hand against the PDF — `backend/tests/fixtures/real_world/`):
+every value, unit and range extracted matches, and offline (no LLM) 81 rows
+map to the right LOINC code with **none wrong** and 7 left for review. Before
+that check, a positive SARS-CoV-2 result read as "NOT DETECTED", and
+creatinine, five differential percentages, HbA1c and body weight mapped to
+the wrong codes. See `PROVENANCE.md` there for the numbers.
+
Nothing auto-accepts below the confidence floor, and **critical analytes always
reach a human** when a probabilistic stage decided. Confidence is statistical;
those failures are categorical.
@@ -89,6 +120,19 @@ the stage that mapped it, the confidence, the conversion factor, and where the
reference interval came from. A resolved value the user cannot trace is not
finished work.
+### Leaving with your data
+
+`/api/observations/{patient}/fhir` returns the whole record as a **FHIR R4**
+bundle, and the Record screen downloads it. Every row goes out. A value
+travels in the same unit as the range it was judged against; a row still
+awaiting review is marked `preliminary` and carries the printed name only,
+because a LOINC code in an export reads as a fact to whatever imports it.
+The download is written to the access log.
+
+`/privacy` in the app states what is stored, what is encrypted, and the one
+thing that can leave the server (a test's printed name, unit and specimen,
+never its value) in plain language.
+
---
## Who can see what
@@ -192,17 +236,18 @@ backend/app/
worker.py arq job: extract -> map -> finalise
pipeline/
extract.py PDF -> rows
- mapping.py the five-stage cascade
+ mapping.py the mapping cascade
units.py hand-audited conversion table
ranges.py age- and sex-aware reference intervals
flags.py critical limits and delta checks
insights.py statements about a series
llm.py Gemini, index-only answers
+ fhir.py FHIR R4 export
routers/ auth, patients, documents, observations, review, audit
frontend/src/
api/client.js token in a closure, single-flight refresh
auth/ session state, idle lock
- screens/ Trends, Reports, Upload, Review, Record, Security, Invite
+ screens/ Trends, Reports, Upload, Review, Record, Security, Invite, Privacy
components/ Select, Modal, IntervalRail
styles/tokens.css the design system
```
@@ -215,7 +260,7 @@ frontend/src/
cd backend && .venv/bin/python -m pytest -q
```
-394 tests. They run against a real MongoDB (`labledger_test`) rather than
+420 tests. They run against a real MongoDB (`labledger_test`) rather than
mocks, because the things worth testing here are queries and indexes —
`test_extract.py`, `test_real_world.py` and `test_totp_vectors.py` are the
exceptions, calling pure functions directly with no database needed.
@@ -259,7 +304,7 @@ category of work:
| | What it needs |
|---|---|
-| **EHR integration** | HL7 v2 / FHIR against an interface engine. Nobody uploads PDFs on a ward. |
+| **EHR integration** | HL7 v2 / FHIR against an interface engine. The FHIR export is a file a person downloads, not an interface. Nobody uploads PDFs on a ward. |
| **Clinical validation** | Measured sensitivity and specificity per analyte against a clinician-adjudicated gold set. |
| **Critical limits** | The shipped table is *illustrative adult values*. A deployment replaces it with limits its own laboratory director has signed. |
| **Regulatory** | The reviewable-basis boundary must be re-checked whenever interface copy changes. |
diff --git a/backend/.dockerignore b/backend/.dockerignore
new file mode 100644
index 0000000..12287d0
--- /dev/null
+++ b/backend/.dockerignore
@@ -0,0 +1,8 @@
+.venv
+.env
+.env.*
+__pycache__
+.pytest_cache
+.ruff_cache
+results.xml
+tests
diff --git a/backend/.gitignore b/backend/.gitignore
index b2d69f4..978e0d1 100644
--- a/backend/.gitignore
+++ b/backend/.gitignore
@@ -5,3 +5,4 @@ __pycache__/
.env.local
data/loinc*.csv
.pytest_cache/
+results.xml
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..c7c0b98
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,9 @@
+FROM python:3.12-slim
+WORKDIR /app
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+COPY . .
+RUN useradd --create-home app && mkdir /secrets && chown app /secrets
+USER app
+EXPOSE 8000
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/backend/scripts/make_sample_reports.py b/backend/scripts/make_sample_reports.py
index 15289bd..ba6ea05 100644
--- a/backend/scripts/make_sample_reports.py
+++ b/backend/scripts/make_sample_reports.py
@@ -84,6 +84,10 @@
},
]
+# A person who does not exist. seed_demo.py gives the demo record the same date
+# of birth, so the age-aware reference ranges agree with the page.
+DOB = date(1984, 6, 2)
+
VISITS = [
(date(2023, 2, 14), 0), (date(2023, 9, 6), 1),
(date(2024, 3, 14), 2), (date(2024, 10, 22), 3),
@@ -115,10 +119,10 @@ def build(path: Path, lab: dict, when: date, idx: int) -> None:
y -= 18
c.setFont("Helvetica", 8)
for line in [
- "Patient: SAINI, RITISH DOB: 04/12/1996 Sex: M",
+ f"Patient: PATIENT, SAMPLE DOB: {DOB:%m/%d/%Y} Sex: M",
f"MRN: SAMPLE-00417 Accession: {lab['name'][:2].upper()}-{when:%Y%m%d}",
f"Collected: {when:%m/%d/%Y} Reported: {when:%m/%d/%Y}",
- "Ordering Physician: PATEL, ANJALI MD",
+ "Ordering Physician: PHYSICIAN, SAMPLE MD",
]:
c.drawString(60, y, line)
y -= 11
diff --git a/backend/scripts/seed_demo.py b/backend/scripts/seed_demo.py
new file mode 100644
index 0000000..bbc5e24
--- /dev/null
+++ b/backend/scripts/seed_demo.py
@@ -0,0 +1,88 @@
+"""Fill an empty database with a demo account and six synthetic reports.
+
+ python scripts/seed_demo.py
+ python scripts/seed_demo.py --make-keys # print fresh JWT and field keys
+
+Used by `docker compose up`. The reports come from make_sample_reports.py and
+go through the same `process_document` an upload does, so what the demo shows
+is what the pipeline produced -- nothing is inserted pre-mapped.
+
+Safe to run again: it does nothing once the demo account exists. The password
+is a default for a database that only ever holds generated data; set
+DEMO_PASSWORD to use another.
+"""
+
+import asyncio
+import base64
+import hashlib
+import os
+import secrets
+import sys
+import tempfile
+from pathlib import Path
+
+if "--make-keys" in sys.argv:
+ # Before any `app` import: Settings refuses to load without these.
+ print(f"JWT_SECRET={secrets.token_urlsafe(48)}")
+ print(f"FIELD_ENCRYPTION_KEY={base64.b64encode(os.urandom(32)).decode()}")
+ sys.exit(0)
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+import make_sample_reports
+import seed_loinc
+
+from app import access
+from app.db import close_db, init_db
+from app.models.document import LabDocument
+from app.models.loinc import LoincEntry
+from app.models.patient import Patient
+from app.models.user import User
+from app.security import encrypt_field, hash_password
+from app.worker import process_document
+
+EMAIL = "demo@example.com"
+PASSWORD = os.environ.get("DEMO_PASSWORD", "labledger-demo-only")
+
+
+async def main() -> None:
+ await init_db()
+ if not await LoincEntry.count():
+ # seed_loinc opens and closes its own connection.
+ await close_db()
+ await seed_loinc.main(drop=False)
+ await init_db()
+ try:
+ if await User.find_one(User.email == EMAIL):
+ print("demo account already present")
+ else:
+ user = User(email=EMAIL, name="Demo", password_hash=hash_password(PASSWORD))
+ await user.insert()
+ patient = Patient(
+ display_name="Sample Patient",
+ dob_enc=encrypt_field(make_sample_reports.DOB.isoformat()),
+ sex_at_birth_enc=encrypt_field("M"),
+ created_by=user.id,
+ )
+ await patient.insert()
+ await access.grant(user.id, patient.id, "owner", granted_by=user.id)
+
+ with tempfile.TemporaryDirectory() as tmp:
+ make_sample_reports.main(Path(tmp))
+ for pdf in sorted(Path(tmp).glob("*.pdf")):
+ data = pdf.read_bytes()
+ doc = LabDocument(
+ patient_id=patient.id, uploaded_by=user.id, filename=pdf.name,
+ sha256=hashlib.sha256(data).hexdigest(), size_bytes=len(data),
+ blob_enc=encrypt_field(data),
+ )
+ await doc.insert()
+ print(f" {pdf.name}: {await process_document({}, str(doc.id))}")
+ finally:
+ await close_db()
+ print(f"\nSign in at http://localhost:8080 as {EMAIL} / {PASSWORD}")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..9e526f8
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,84 @@
+# The whole system on one machine, with sample data:
+#
+# docker compose up --build
+#
+# then open http://localhost:8080 and sign in with the account the `init`
+# service prints. Everything in it is synthetic -- six generated reports for a
+# person who does not exist.
+#
+# This is a demonstration, not a deployment. It listens on localhost only, the
+# signing and encryption keys are generated on first start and kept in a
+# volume, and there is no LLM key, so the rows the deterministic stages cannot
+# settle wait in the review queue -- which is the behaviour worth seeing.
+# `docker compose down -v` removes it, data and keys included.
+
+x-backend: &backend
+ build: ./backend
+ environment:
+ ENV: development
+ MONGO_URI: mongodb://mongo:27017
+ MONGO_DB_NAME: labledger
+ REDIS_URL: redis://redis:6379
+ FRONTEND_URL: http://localhost:8080
+ BACKEND_URL: http://localhost:8080
+ volumes:
+ - secrets:/secrets
+ # The keys live in the volume, not in this file or the image.
+ entrypoint: ["sh", "-c", "set -a; . /secrets/demo.env; set +a; exec \"$$@\"", "--"]
+
+services:
+ mongo:
+ image: mongo:7
+ volumes:
+ - mongo:/data/db
+ healthcheck:
+ test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand({ping:1}).ok"]
+ interval: 5s
+ retries: 20
+
+ redis:
+ image: redis:7-alpine
+
+ # Runs once: makes the keys, loads the LOINC table, creates the demo account
+ # and reads the six sample reports through the real pipeline.
+ init:
+ <<: *backend
+ depends_on:
+ mongo:
+ condition: service_healthy
+ entrypoint: ["sh", "-c"]
+ command:
+ - |
+ [ -f /secrets/demo.env ] || python scripts/seed_demo.py --make-keys > /secrets/demo.env
+ set -a; . /secrets/demo.env; set +a
+ python scripts/seed_demo.py
+
+ api:
+ <<: *backend
+ # Stated here because overriding the entrypoint discards the image's CMD.
+ command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+ depends_on:
+ init:
+ condition: service_completed_successfully
+ redis:
+ condition: service_started
+
+ worker:
+ <<: *backend
+ command: ["arq", "app.worker.WorkerSettings"]
+ depends_on:
+ init:
+ condition: service_completed_successfully
+ redis:
+ condition: service_started
+
+ web:
+ build: ./frontend
+ ports:
+ - "127.0.0.1:8080:80"
+ depends_on:
+ - api
+
+volumes:
+ mongo:
+ secrets:
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
new file mode 100644
index 0000000..f06235c
--- /dev/null
+++ b/frontend/.dockerignore
@@ -0,0 +1,2 @@
+node_modules
+dist
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..d8df271
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,10 @@
+FROM node:22-alpine AS build
+WORKDIR /app
+COPY package.json package-lock.json ./
+RUN npm ci
+COPY . .
+RUN npm run build
+
+FROM nginx:1.27-alpine
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+COPY --from=build /app/dist /usr/share/nginx/html
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
new file mode 100644
index 0000000..fba08b7
--- /dev/null
+++ b/frontend/nginx.conf
@@ -0,0 +1,18 @@
+# The browser stays on one origin and /api is proxied, the same shape as the
+# Vite dev proxy and the Render rewrite: the refresh cookie is SameSite and
+# scoped to /api/auth, so a cross-origin API would break session restore.
+server {
+ listen 80;
+ root /usr/share/nginx/html;
+ client_max_body_size 26m;
+
+ location /api/ {
+ proxy_pass http://api:8000;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ }
+
+ location / {
+ try_files $uri /index.html;
+ }
+}
From 2ba2fd6809f3b1411b0a8794a731670b96c38710 Mon Sep 17 00:00:00 2001
From: MaXiMo000
Date: Sat, 10 Oct 2026 12:43:00 +0530
Subject: [PATCH 6/7] Run the demo's web container as a non-root user
Co-Authored-By: Claude Opus 5.5
---
docker-compose.yml | 2 +-
frontend/Dockerfile | 3 ++-
frontend/nginx.conf | 2 +-
3 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/docker-compose.yml b/docker-compose.yml
index 9e526f8..01ac920 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -75,7 +75,7 @@ services:
web:
build: ./frontend
ports:
- - "127.0.0.1:8080:80"
+ - "127.0.0.1:8080:8080"
depends_on:
- api
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index d8df271..9e1798f 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -5,6 +5,7 @@ RUN npm ci
COPY . .
RUN npm run build
-FROM nginx:1.27-alpine
+FROM nginxinc/nginx-unprivileged:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
+USER 101
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index fba08b7..e2c8da3 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -2,7 +2,7 @@
# Vite dev proxy and the Render rewrite: the refresh cookie is SameSite and
# scoped to /api/auth, so a cross-origin API would break session restore.
server {
- listen 80;
+ listen 8080;
root /usr/share/nginx/html;
client_max_body_size 26m;
From 01b4cde43bd65535dbf9ca30e7e08622941cef45 Mon Sep 17 00:00:00 2001
From: MaXiMo000
Date: Sat, 10 Oct 2026 12:47:27 +0530
Subject: [PATCH 7/7] Stop the audit leak test tripping on random ids
It looked for the value "18" anywhere in the log, and a hex database id
contains it about once in several runs.
Co-Authored-By: Claude Opus 5.5
---
backend/tests/test_audit.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py
index 4ddd1b3..863a518 100644
--- a/backend/tests/test_audit.py
+++ b/backend/tests/test_audit.py
@@ -5,6 +5,7 @@
test walks the whole clinical API surface and asserts every call left a trace.
"""
+import re
from pathlib import Path
import pytest
@@ -126,6 +127,9 @@ async def test_the_log_holds_no_clinical_content(client):
f"{e.action} {e.resource} {e.resource_id or ''}"
for e in await AuditEntry.find().to_list()
)
+ # Database ids are random hex and are allowed here; one containing "18"
+ # is not the ferritin value.
+ blob = re.sub(r"[0-9a-f]{24}", "", blob)
for leak in ("FERRITIN", "ng/mL", "Quest", "q.pdf", "SERUM", "18"):
assert leak not in blob, f"audit log leaked {leak!r}"