Case Study

Medical Document Parser

Django backend with OCR pipelines, LLM extraction, FHIR integration, and HIPAA-compliant PHI storage

January 2026
DjangoPythonPostgreSQLOCRLLMCelery

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.

CndMedObsEncFHIR

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.

halfhalf
FAX

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.

Design note, optimistic concurrency

Before

Every document waits

Upload
Extract
Hold
Manual approve
Merge

After

Merge first, review second

Upload
Extract
Auto-merge
Patient record

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.

Quality check, not a merge gate
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.

I10E11.9R73.9

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.

88DATA?

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.

1msSLA

Measured vs SLA

Log scale in milliseconds — otherwise the measured bars disappear.

OperationMeasuredTargetMargin
Merge 10 resources0.475 ms50 ms100× under
Merge 50 resources0.742 ms200 ms270× under
Merge 100 resources1.06 ms500 ms470× under
Rollback 100 resources0.54 ms500 ms925× 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.

85%

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