In Mexico, electronic health records mostly don’t exist outside a handful of private hospital chains. Every visit produces a paper prescription or a printed lab report, and nothing connects one to the next. If you’re the family member managing care for someone else (an aging parent, a relative with a chronic condition), that gap becomes your job: the medication list lives in your head, the lab history in a drawer somewhere, and “who prescribed what, and how do I reach them” in whatever photo you happened to take at the time. I do this for my family, coordinating specialists, prescriptions, and lab work across appointments, and it isn’t just me: we also have paid caregivers on morning and night shifts who actually give the medication, off a whiteboard that someone in the family has to keep updated by hand. Every new doctor starts from zero, and every handoff to a caregiver depends on whoever last updated that whiteboard remembering to do it. A lot of the actual care happens between visits, informally, too: a dose gets adjusted over a phone call, and the only record of it is whether someone remembers to walk over and change the board.

I’m building Recetario to fix that: photograph every document as it’s handed to us, let an LLM read it into structured data, review and confirm it by hand, and generate a doctor-ready summary before the next visit.

Why not just use an existing EHR

I actually ran OpenEMR, a mature open-source EHR, on my own infrastructure, for exactly this. It just wasn’t solving my problem. OpenEMR is built for a practice: staff entering data as care happens, in a data model shaped around scheduling and billing, where the data-entry cost is amortized across many patients a day. I’m not running a practice. I’m the after-the-fact archivist for care that already happened somewhere else, and the only artifact I have is a photo. OpenEMR gave me a place to store structured data, but none of the work of getting from a photo to structured data: every field was still mine to type by hand. The cost a real practice spreads across many patients was, for me, pure overhead, every single time.

That’s the piece an LLM actually replaces: not the record-keeping, the transcription. That set up the two real problems behind Recetario’s design.

The data isn’t one shape

A photographed prescription starts as an unstructured blob (an image, then LLM-extracted JSON) that a human has to confirm before it means anything. Once confirmed, it’s relentlessly relational: a person saw a doctor, who prescribed a medication, for a condition, at a visit. Lab results are a time series per analyte, glucose and creatinine trending over years of panels. And the whole point of keeping any of this is answering “why is this medication needed, and who told them to take it,” which means free-text semantic search over everything. Four data shapes for one small app: document, graph, time-series, vector.

Keeping the LLM in its lane

An LLM is doing the reading, on documents in Spanish, of varying photo quality, in a domain where a wrong guess isn’t a cosmetic bug. Every extraction has to come back as a reviewable draft, never committed automatically, and every AI-generated artifact needs guardrails against anything that looks like a diagnosis or a dosing opinion. The AI layer also needed to be swappable across providers while I find out which models actually read these documents well.

The data layer: care is a graph, not tables with a graph bolted on

A person’s care is a network, not a list: the same medication under two prescribers who don’t talk to each other, a condition connected to every encounter and drug meant to manage it. Those are graph questions from the start, and Recetario’s roadmap ends with medication reconciliation and interaction-graph queries, deliberately last, because they only work once the relationships already exist as relationships. Retrofitting a graph out of foreign-key columns later is possible, but it’s a migration I didn’t want to owe myself.

The default way to get there is recursive CTEs, or Neo4j bolted onto a relational core. That’s a completely reasonable choice, and for a product other people depended on, I’d probably still make it, because Postgres has more mature tooling and more operational knowledge behind it than I have with ArcadeDB today. I picked ArcadeDB instead: one embedded engine that treats document, graph, time-series, and vector as first-class in a single JVM process, no separate database server. For a project one person operates for several family members, that’s zero extra systems to run instead of four.

Two decisions did the real work. The “never auto-commit” rule became a storage decision, not just an application check: an Extraction is a document type, not a vertex, specifically because it isn’t part of the graph until a human confirms it. Only review turns it into real Person, Encounter, and Medication vertices connected by edges. And relationships are edges, never foreign keys. A Medication vertex has no personId property, which meant GraphQL field resolvers fell out almost for free, because a GraphQL schema is already a graph of typed relationships:

@SchemaMapping(typeName = "Encounter", field = "prescriber")
fun prescriber(encounter: Encounter): Prescriber? {
    val cypher = "MATCH (e:Encounter)-[:SEEN_BY]->(pr:Prescriber) WHERE e.id = \$id RETURN pr.id AS id"
    database.query("cypher", cypher, mapOf("id" to encounter.id)).use { rs -> /* ... */ }
}

“Multi-model” didn’t save me from query-shape decisions, though. A fast per-analyte lab trend still meant denormalizing reportId and collectedOn onto every LabResult. And for vectors, I skipped ArcadeDB’s native vector index. Semantic search is brute-force cosine similarity in memory, because a family’s record set is dozens to low hundreds of documents, not millions. An approximate nearest-neighbor (ANN) index solves a scale problem I don’t have.

The AI layer: Koog, called directly

Recetario’s LLM calls run on Koog, JetBrains’ Kotlin agent framework, wired into Spring by hand. Koog does ship Spring AI bridge starters, but they were too new to pin against Spring Boot 4 with confidence, so I skipped that layer. Every generative task (classification, extraction, briefing) is a provider:model config string, resolved into a Koog executor at startup, which makes swapping models or providers a config change, not a code change. Extraction asks Koog for a typed result directly:

aiModels.extraction.executor.executeStructured<PrescriptionExtractionResult>(
    prompt = requestPrompt,
    model = aiModels.extraction.model,
).getOrThrow().data

Every one of those calls sends a photo or the text of a family member’s medical record to whichever provider the config names, so picking a provider is a privacy decision too. A hosted model is only acceptable if its API terms exclude training on submitted data. The better answer is not sending the data at all: any provider name the backend doesn’t recognize is treated as OpenAI-compatible, so pointing extraction at a local open-weight model through Ollama is a config change, and nothing leaves the machine. Whether something like MedGemma can read a handwritten Spanish prescription well enough is the next thing I want to find out.

The guardrails live in plain system-prompt text, in Spanish, since that’s the app’s primary language: “no diagnostiques, no des indicaciones de dosis, no inventes datos que no estén en los registros.” Nothing here is exotic agent behavior: it’s one LLM call per task, a strict prompt, and a typed result. That’s the level of “agent” this problem actually calls for.

The whiteboard was right, but it needs to stay in sync

The paid caregivers who give my family their medication still work off a physical whiteboard, updated by hand whenever a dose changes. Writing this post made me realize that instinct was correct, not a stopgap: NHS wards run the same idea digitally, with wall-mounted boards for meds and shift handover, and UK care homes run eMAR (electronic medication administration record) systems built around the same always-visible-current-state pattern. So the next thing on Recetario’s roadmap isn’t a whiteboard app, it’s a live, no-login view of a person’s current medications that reads straight from the same confirmed record the doctor summary already draws from, so there’s nothing left to keep in sync by hand. What I’m deliberately not building yet is anything that turns it into an audit trail: caregivers marking doses as given, timestamps, accountability. That’s a real feature with its own scope, and it can wait until the read-only version proves itself.