Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
63 changes: 54 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <http://localhost:8080> 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
Expand All @@ -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) |
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
```
Expand All @@ -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.
Expand Down Expand Up @@ -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. |
Expand Down
8 changes: 8 additions & 0 deletions backend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.venv
.env
.env.*
__pycache__
.pytest_cache
.ruff_cache
results.xml
tests
1 change: 1 addition & 0 deletions backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ __pycache__/
.env.local
data/loinc*.csv
.pytest_cache/
results.xml
9 changes: 9 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
104 changes: 104 additions & 0 deletions backend/app/data/common_tests.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions backend/app/models/observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading