Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 

Repository files navigation

What Survives the Trip to FHIR: Mapping a Spanish-Language Clinical LLM Pipeline to R4

Both runs described here used entirely invented data: a JSON fixture I wrote by hand, and a clinical case I fabricated from scratch for this experiment. Every patient detail, value, and document below is fiction. No real patient data was involved at any point, and none was ever sent to the validation server.

The fabricated case is published in case/caso-ficticio-UCI-001.md — a reusable synthetic Spanish ICU case (four documents: attending evolution, resident note, nursing record, lab report) for testing clinical NLP pipelines.

Context

I'm an emergency and critical care physician in Colombia, and I've spent my nights building OMAXI: an LLM pipeline that reads Spanish clinical documents — nursing records, resident notes, attending evolutions, lab reports — and turns them into structured data plus a note a physician can sign. The rule is narrow: structure what the documents say, never infer what they don't. Architecturally it's deterministic orchestration around probabilistic components — fixed phases and validation gates containing the model calls, with no open-ended agency — because in critical care the failure that matters isn't a missed nuance, it's a confident fabrication.

That JSON was designed for the clinical workflow, not for interoperability — which raises the question this experiment exists to answer: if a hospital wanted to send this data elsewhere, how much would survive? Here, ICD-10 (CIE-10) coding feeds RIPS, Colombia's mandatory individual health-service reporting used for claims and billing, so a record with no codes fails at the first step — and data only your current vendor can read isn't really yours. FHIR is the most credible answer available; I wanted to see what happens when Spanish free-text meets its constraints, against a real server that can say no.

The experiment

Scope: labs → Observation, administered medications → MedicationAdministration, diagnoses → Condition. Validation against Medplum's sandbox — not a local schema check but actual POSTs, with every resource read back from the server to confirm what persisted.

Two runs, both fictional. The first used a JSON fixture I hand-wrote to imitate the pipeline's output shape, rough edges on purpose: labs with no timestamp, empty units, free-text doses, route written "EV" instead of "IV". The second — the one that matters — used genuine pipeline output over UCI-FICT-001, a fabricated day-2 ICU case (septic shock of urinary origin with acute kidney injury) across four documents rendered as text-selectable PDFs. Everything created carries meta.tag = HTEST, the standard "test health data" tag, so nothing in that sandbox can be mistaken for a real record.

Development was AI-assisted and clinician-directed: I made and defended every mapping decision, the assistant wrote the mapper. The code is the instrument; the deliverable is the friction log — blockers, degradations and assumptions, counted. First run: 38 resources, 36 on the first pass, 2 after a fix. Second run: 36, all first pass.

What Medplum's validation caught

The first run's most useful moment was a rejection. R4 carries an invariant on MedicationAdministration:

Constraint mad-1 not met: SHALL have at least one of dosage.dose or dosage.rate[x]

If you emit dosage, it must carry a dose or a rate; text alone isn't enough. Two of eight medications failed — one of them norepinephrine, extracted exactly as documented: 0.35 mcg/kg/min. That can't become a UCUM Quantity without the patient's weight, which the pipeline doesn't extract. So the drug that defines the severity of septic shock was the one FHIR refused.

I probed two fixes; the server accepted both. (A) emit dosage.dose present but empty, carrying the standard data-absent-reason extension with value as-text — "the content is represented as text". (B) drop dosage and move everything into a note. I took A: it satisfies the invariant without asserting any quantity, and preserves dosage.text and the SNOMED-coded route that B would have thrown away.

What I didn't do was the easy thing. Writing dose: 0.35 mcg would have passed instantly, asserting a vasopressor dose the source never stated. The goal isn't to produce valid resources; it's to avoid producing valid resources that lie. When the source can't quantify, the correct FHIR answer is to declare the absence explicitly — the standard has a mechanism for exactly that, and it's underused.

In the genuine run the fix acted preventively on three medications — 36 of 36 landed first try. Norepinephrine arrived again at 0.28 mcg/kg/min, still unquantifiable, and this time the case showed why that's worse than it looks: the weight, "68 kg", appears in two of the four documents and nowhere in the output, and nursing had documented that same infusion volumetrically — 8 mg in 250 mL at 36 mL/h, expressible as rateQuantity with no weight needed. The extractor had two representations of one fact and chose the only one FHIR can't model. Fentanyl went the other way, picking up 100 mcg/h and producing a clean rate: the difference isn't capability, it's which form got chosen.

Where the data gets lost

Sorting the losses by layer was the most useful thing I did: each layer needs a different fix.

Extraction — better than expected. When the lab report carries a "date and time of collection" column, the pipeline reads it: 20 of 20 labs with full timestamps, in 7 distinct instants. The serial lactate survived intact — 4.8 → 2.9 → 1.6 with its three real times, which is the whole point of measuring it serially. I had assumed the extractor couldn't read lab dates; it can.

Schema — where the irreversible losses live. Three facts were read from the documents and destroyed because the type schema had nowhere to put them. Administration times: nursing documented exact hours — meropenem 20:00, enoxaparin and omeprazole 22:00, dipyrone 23:30, meropenem again at 04:00 — but the treatment record type has no temporal field at all, so all eight administrations carry the nursing document's shift-close time, an hour at which nothing was given. Five timed administrations produced four resources: meropenem, given twice, collapsed into one carrying frequency: "c/8h" — FHIR wants discrete events, the pipeline describes ongoing state. The weight, as above. And a negation: the model did try to mark the ruled-out pulmonary embolism, but wrote negated into the clinical-role field instead of the certainty field, and the sanitizer flattened it to contextual, logged a warning and moved on. It fails in the dangerous direction — anyone filtering on "explicit certainty" reads "pulmonary embolism ruled out" as an asserted diagnosis. None of the three is fixable with a better prompt.

What happens after correct extraction. A value read correctly can still arrive wrong. Two blood draws produced four Observation resources: two correct, from the lab report (1.9 mg/dL at 05:40, 2.4 at 05:15 the next morning), and two that are the same 05:15 draw quoted in two physicians' notes and stamped with the hour of the note quoting it — 2.3 at 06:15 (unit lost, the resident didn't write one) and 2.4 at 07:30. The conflicting_data flag that exists for precisely this stayed false. KDIGO and AKIN stage kidney injury by the delta over 48 hours, so an algorithm reading these four sees 1.9 → 2.4 → 2.3 → 2.4, a decline at 06:15 that never happened; the real series is 1.9 → 2.4. The same duplication produced a phantom fourth lactate which, called "lactato" rather than "Lactato arterial", landed under a different LOINC code: one analyte, measured once, two observations, two codes.

Diagnoses failed in both directions at once. Of six entries in the diagnostic impression only two were correct diagnoses — one was a duplicate, two weren't diagnoses at all, and "acute respiratory failure" appears in none of the four documents: it follows clinically from mechanical ventilation, which is exactly the problem, because an inference became a confirmed Condition nobody signed. Meanwhile AKI KDIGO 2, the case's second principal diagnosis, was extracted with explicit certainty, never promoted into the impression list, and fell into the gap between the two sources my mapper joins — so it never reached FHIR at all. One run invented a diagnosis and dropped a real one, and FHIR faithfully published the invention while never hearing about the omission.

My own layer was no cleaner. I built the LOINC catalog with clinical judgment; checked against the NLM's official table, 3 of 20 codes were wrong — a 15% error rate, one a panel code used as a value-bearing observation. And the genuine case caught a bug the fixture structurally couldn't: CRP arrived in mg/dL, my catalog assumed mg/L and overrode the source unit, labeling 24.5 mg/dL as 24.5 mg/La factor-of-ten error introduced by the mapper. The fixture couldn't reveal it because I had written its units to match my own catalog. Fixed: the source unit now wins, the catalog is only a fallback.

Lessons for Spanish-language FHIR deployments

Keep code.text in Spanish and put universal codings beside it. Every lab kept its literal Spanish name — the only trace of what the document said, and what a Colombian physician recognizes on sight — with LOINC alongside. Normalizing that text away to make coding easier destroys the audit trail back to the source document.

Terminology validation is a required layer, not a polish step. Beyond the 15%: the model uses different aliases for one analyte across documents ("Creatinina sérica" in the lab report, "creatinina" in the note), so a mapper needs a synonym dictionary or the same test ends up coded in one document and uncoded in another. The vocabularies need local thought too — RxNorm is the FHIR default and a US formulary, while what applies here is ATC or INVIMA's national CUM registry. All seven Conditions came out with code.text and no coding, and I chose not to invent any: assigning ICD-10 to "septic shock of urinary origin" means choosing between A41.9, R57.2 and an extra code for the urinary focus — a billing decision no unsupervised mapper should make. A resource with Spanish text and no codes is syntactically FHIR and semantically a string: it doesn't aggregate, doesn't analyze, doesn't bill.

provisional and refuted are expressive capacity worth using. This was the one finding that ran the other way. A signable note shouldn't assert what isn't confirmed, so the pipeline drops uncertain and negated findings from the final impression — but FHIR can represent them without asserting them. "Probable aspiration pneumonia" reached FHIR as verificationStatus: provisional, semantics intact, and a ruled-out diagnosis is valuable: it stops someone repeating the doppler. One wrinkle: Spanish carries negation lexically ("TVP descartada" — DVT ruled out), FHIR carries it structurally, and emitting both yields a double negative reading "it is refuted that the patient has ruled-out DVT". Resolving that means rewriting what the physician documented, which this mapper won't do.

What's next

Schema changes come first, since the genuine run showed they're the binding constraint: temporal fields on administrations, patient weight, negation routed into the certainty field, and a preference for the volumetric form of an infusion when the document offers both. Then a terminology layer validated against official catalogs, with the Colombian vocabularies wired in; then reconciliation before mapping, so a value restated in three notes doesn't become three observations; then Encounter, whose absence means nothing anchors these resources to a specific ICU stay, so two admissions of the same patient would mix.

If you work on FHIR in Latin America, on Spanish-language clinical NLP, or you've solved terminology assignment for Colombian billing codes, I'd like to compare notes — particularly on ICD-10 with a human in the loop, which I don't think is safely automatable yet. I'm also curious whether the friction log travels: counting blockers, degradations and assumptions proved far more informative than any pass/fail validation report.


Jesus Pantoja — August 2026 · github.com/JesusPantojaP

About

Mapping a Spanish-language clinical LLM pipeline to FHIR R4: what survives, what breaks, and why. Includes a reusable fictitious Spanish ICU test case.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors