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/app/data/common_tests.py b/backend/app/data/common_tests.py new file mode 100644 index 0000000..6268c3d --- /dev/null +++ b/backend/app/data/common_tests.py @@ -0,0 +1,104 @@ +"""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", + "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"}, "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", "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", "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"}, + "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..42116c0 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,50 @@ 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 _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) + 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 +207,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 +244,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 +276,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 @@ -229,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") @@ -262,14 +330,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 _plausible_name(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/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/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/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/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/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/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_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}" 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] 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/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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..01ac920 --- /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:8080" + 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..9e1798f --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build + +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 new file mode 100644 index 0000000..e2c8da3 --- /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 8080; + 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; + } +} 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. +

+ + ); +} 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.", 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 ? ( <> diff --git a/frontend/src/screens/SignIn.jsx b/frontend/src/screens/SignIn.jsx index 91ccbae..4eba252 100644 --- a/frontend/src/screens/SignIn.jsx +++ b/frontend/src/screens/SignIn.jsx @@ -208,6 +208,9 @@ export default function SignIn() { {creating ? "Sign in" : "Create one"}

+

+ What happens to your reports +

{/* 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