Case study
I built this because the incoming documents were a mess, and the compliance requirements around them weren't optional. The backend is Django. It runs OCR, extracts a structured record with an LLM, writes FHIR R4, and keeps an audit trail of what it did.
21k+
Lines across 7 Django apps
6k+
Lines of FHIR merge logic
280+
Unit tests on the merge system
55
Audited HIPAA event types
01 — The problem
Extraction is only half the job
Most of the conversation around a project like this is about getting diagnoses out of a PDF. I spent more time on the other half: making sure a failure couldn't look like a success in the UI.
Unstructured input
A sending facility will use whatever they have — an ED note one day, a faxed lab the next. There isn't a schema on the other end of that.
A merge you can undo
If two documents belong to the same patient, they have to land on one record. I also had to be able to take one of them back out without tearing up the rest.
HIPAA is the constraint
PHI stays encrypted. Codes that aren't PHI stay searchable. The audit log only writes fields I've explicitly allowed.
02 — The pipeline
What happens to a document
There are eight stages from upload to patient record. Click one to see which task owns it and what stops it from running away. The HTTP request is already gone by the time Textract starts; Celery has the rest.
01 · Upload
Upload creates a Document row after MIME and size checks. Celery picks up processing from there, so the HTTP request is already finished.
- Owner
- documents views
- Guardrail
- MIME type, size, and extension checks before anything hits disk
03 — The bottleneck
Approve after the merge, not before
The first version held every extraction until someone clicked approve. I thought that was the careful design. It just meant good documents sat in a queue with the bad ones.
Data merges immediately even if flagged. Review happens after merge.
Before
Every document waits
After
Merge first, review second
Review state machine
A five-state field replaced a boolean. The merge doesn't wait on the flag.
pending
Just extracted
auto_approved
All five gates passed
flagged
Any gate failed
reviewed
Human confirmed
rejected
Rolled back
Auto-approval requires every gate
- Confidence score at or above 0.80
- Primary Claude model — not the GPT fallback
- At least one FHIR resource extracted
- Confidence at or above 0.95 when fewer than three resources
- No patient date-of-birth or name conflict
Missing any gate flags the extraction, but the merge has already happened. A reviewer either confirms it or rolls that document back. I replaced is_approved with a five-state review_status so the flag and the merge could move independently.
pending → auto_approved | flagged
→ reviewed | rejected
# Merge is independent of the flag.
# Rollback is selective by document.04 — Privacy by construction
Encrypt the narrative. Index the codes.
A SNOMED code isn't PHI, and a patient name is. I store them differently so you can search the record without decrypting the person attached to it.
Encrypted at rest
Fernet field encryption · django-cryptography-5
- encrypted_fhir_bundle
- first_name · last_name
- date_of_birth
- ssn · address · phone · email
The complete FHIR bundle — names, dates, narrative — never leaves ciphertext.
Searchable without PHI
Plaintext JSONB · GIN indexes
- searchable_medical_codes
- encounter_dates
- provider_references
- MRN (plaintext by design)
SNOMED, ICD, RxNorm, and LOINC codes aren't PHI. That's what buys sub-50ms search over encrypted records.
Date of birth is an encrypted string because that's what the field-encryption library can wrap. MRN stays plaintext so you can look a patient up; the model comment spells that out. I also added management commands that verify encryption, print an audit report, or scrub a database before it leaves the building.
05 — Audit trail
An allowlist, not a dump
There are 55 event types, from PHI access through the OCR job lifecycle. I spent as much time deciding what the log is forbidden to store as I did adding events.
Safe to log
- Document IDs and medical record numbers
- Confidence scores and resource counts
- Model name and provider
- Whether a reviewer left notes (boolean only)
Never logged
- Patient names and dates of birth
- Diagnoses, medications, clinical codes
- FHIR bundle contents
- The text of a reviewer’s notes
Three helpers share the same allowlist — audit_extraction_decision, audit_merge_operation, audit_manual_review. Those calls are wrapped. If the audit write throws, the record still goes through.
06 — The silent failure
Document 88 processed. The data was gone.
Document 88 processed and the UI marked it complete. The extracted data never merged. It was sitting in a ParsedData row I didn't know to look for.
The merge had hit a Postgres B-tree limit of 2,704 bytes per index entry. Document 88's searchable_medical_codes JSONB was 4,448 bytes, so the write failed while the document status stayed complete.
A green status over a missing record is a bad failure mode in any system. In healthcare it's worse, because someone will trust the screen.
Migration 0009 converted those indexes to GIN. The task also flips the document to failed now, and the recovery message includes the orphaned ParsedData ID.
Index payload vs B-tree cap
+1,744 bytes over
PostgreSQL B-tree limit
2,704 bytes
Document 88 searchable_medical_codes
4,448 bytes
Left of the marker would have indexed. Right of it is why the merge failed while the UI still said complete.
07 — Proof
Measured, not hoped
I put a pytest-benchmark suite on the FHIR merge path after I got tired of estimating. Quality and conflict checks are under 100ms each, and the merge itself is well inside the SLA.
Measured vs SLA
Log scale in milliseconds — otherwise the measured bars disappear.
| Operation | Measured | Target | Margin |
|---|---|---|---|
| Merge 10 resources | 0.475 ms | 50 ms | 100× under |
| Merge 50 resources | 0.742 ms | 200 ms | 270× under |
| Merge 100 resources | 1.06 ms | 500 ms | 470× under |
| Rollback 100 resources | 0.54 ms | 500 ms | 925× under |
08 — Guardrails
The pipeline assumes things will go wrong
A lot of this code exists because documents are large, model calls are expensive, and extraction isn't deterministic. I trust the boring parts more than I trust a happy path.
Cost circuit breaker
Each chunk call checks a $5.00 per-document ceiling and a $100.00 daily one. If either trips, the job stops and the error names the setting you'd raise.
Prompt caching
Anthropic caches the MediExtract prefix, which is enough to cut input cost on a multi-chunk document by about half.
Partial completion
A document can finish as partial once 85% of its chunks succeed. The rest is recoverable, so one noisy page doesn't fail the file.
Checkpoint and resume
If Celery hits a soft time limit, the job writes a checkpoint and continues from there instead of restarting. After a configured number of resumes it fails with a real error.
Provenance chain
Merges, conflict resolutions, and dedups each write a FHIR Provenance resource, so you can walk a diagnosis back through the documents that produced it.
Fail-open audit
The audit helpers are wrapped so a thrown AuditLog write can't stop the clinical workflow. The record still goes through.
Stack
Django
5.2.3
PostgreSQL
JSONB + GIN
Celery / Redis
5.3.1 / 5.0.0
Claude Sonnet
primary extractor
GPT-4o-mini
fallback model
AWS Textract
sync + async OCR
fhir.resources
R4 · 7.1.0
django-cryptography
Fernet fields