Health Docs
Photograph a medical document and it comes back named, filed, attached to the right episode of care, with the personal summary already rewritten. A local model does the reading, on a machine at home.

Eight model calls turn one uploaded scan into a filed documentClassification, naming and filing by a local model, nothing leaves the houseOne document at a time: the queue is deliberately not parallelScanned pages are the one path that still reaches a hosted APIOn hold until larger models fit on hardware I can own
The problem
Medical history arrives as a decade of PDFs and phone photos from a dozen sources, none of which talk to each other. The question you actually want answered, when was that last checked and what did it say, is buried across all of them under filenames like IMG_20231015.
It is also the single most sensitive corpus a person owns, which rules out every service that would happily solve the problem in exchange for a copy.
What it does
You photograph a document in the waiting room and put the phone away. A few minutes later it has a name that describes it, a category, a folder, and a place on a timeline of medical episodes, none of which you typed.
The personal summary is rewritten each time a document lands, so it is only ever as stale as the last upload. A chat answers questions about the documents you point it at, or about the most recent ones if you point at nothing.
- Runs on
- One machine at home, plus a phone on the same network
- Model
- llama3.1:8b through Ollama, on 10 GB of VRAM
- Storage
- MinIO for the files, SQLite in WAL mode for everything else
- Client
- Expo app, for the phone in the waiting room
- State
- On hold. Nothing is running right now
Architecture
- PhoneExpo app, camera
Express API
- Express APIUpload, then a queue of one
MinIO, Ollama, Hosted vision API, SQLite
- Ollamallama3.1:8b, on my GPU
- MinIOThe file itself
- Hosted vision APIScanned pages only
- SQLiteText, categories, folders, timeline
Ollama
Express API and processing queue
Accepts the upload, answers immediately, and hands the document to an in-process queue that runs one job at a time. Everything expensive happens after the phone has stopped waiting.
MinIO storage
Object storage on my own hardware, holding the file itself. The documents never reach a third-party bucket, and the rename that follows classification happens in place.
One AI interface, two providers
Every model call goes through a single service that resolves to a local model or to a hosted API. Local is what runs. The seam exists so the choice stays a line of configuration rather than a rewrite.
Filing decided by the model
The model is shown the folder tree and the existing medical episodes as they stand, and decides whether this document joins one or starts a new one. There is no fixed taxonomy to keep up to date.
Built with
Client
- Expo Router
- Six tabs on a phone: home, documents, add, chat, reports, profile
- expo-image-picker
- The camera path, which is how most documents actually arrive
- AsyncStorage
- Holds the access and refresh tokens between launches
- Polling on a three second timer
- Shows the queue draining, because processing outlives the request
Backend
- Express and multer
- Upload endpoint, size and mime filtering before anything is stored
- In-process queue
- Forty lines, concurrency of one, so a batch upload cannot thrash a single GPU
- jsonwebtoken and bcrypt
- Short access tokens with rotating refresh tokens, so every row is scoped to a user
- pdf-parse
- First attempt at the text, and the test that decides a page is a scan
Data
- better-sqlite3
- Synchronous, WAL mode, one file to back up. There is no database server to lose
- MinIO through the S3 client
- Self-hosted bucket, spoken to with the same API a cloud bucket would use
Inference
- Ollama
- Every classification, summary, filename and filing decision, on my own GPU
- Anthropic SDK
- The alternate provider, and the only path a scanned page can take today
Every stage runs on hardware I own
Under the hood
01One interface, two providers, and only one of them is the point
Every model call goes through a service that resolves to Ollama or to a hosted API from one environment variable. Writing that seam cost an afternoon at a moment when the hosted model was the only one good enough to build against. It is what let the project be tested seriously and then moved home without touching a controller. The version that runs is the local one.
02The filing plan is a prompt, not a schema
Nothing in the code knows what a medical folder tree looks like. On each document the model receives the tree as it stands, with document counts, and answers use_existing or create_new with a path. A fixed taxonomy would have been simpler and would have been wrong by the third unusual document, which in a medical history is roughly every document. The bill for that choice is in Would change, below.
03An 8B model needs a JSON parser that expects to be lied to
The hosted model returns the JSON you asked for. A local 8B model returns the JSON plus a sentence explaining how helpful it has been, sometimes in a fenced block, sometimes with a trailing comma or a stray comment. Rather than retry until it complies, which burns seconds on a single GPU, the local path takes the first brace to the last and repairs the usual damage before parsing. It is not elegant and it is why the local provider became usable at all.
const firstBrace = cleanedResponse.indexOf('{');
const lastBrace = cleanedResponse.lastIndexOf('}');
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
cleanedResponse = cleanedResponse.substring(firstBrace, lastBrace + 1);
}
// Trailing commas, // comments, single quoted keys
jsonString = jsonString.replace(/,(\s*[}\]])/g, '$1');The alternative was a retry loop, at a few seconds of local inference per attempt.
04The summary is rewritten by the model, never patched by hand
A hand-maintained medical summary is wrong within a month. Here the model reads the whole current report and the new document, and returns the whole report again, so the documents stay the thing that has to be right. Asking for a diff would be cheaper and would have meant merging model output into a document by hand, which is exactly the class of bug you do not want in a medical record.
Gallery




Try it
Nothing to try. It is on hold, and it was never public.
Medical records do not get a public demo: not a redacted one, not a blurred one, not a read-only one. The instance is also switched off for now, waiting on hardware that can run a bigger model without giving the documents away. What is on this page instead is the pipeline, in the order it runs, and the two places it does not hold.
The screenshots on this page are a synthetic record, seeded on a separate database and a separate bucket for the purpose. No real medical document appears anywhere here.
What broke
The model was allowed to name its own categories
Categories are created from whatever string the classifier returns. Thirty five documents produced fourteen categories, including the same label in singular and in plural counted as two, and one whose name carries its own definition in brackets because the model was being helpful. Free text from a model is fine as a display label and unusable as a key. The classifier should have been handed the existing list and made to justify adding to it.
Every upload regenerates the entire medical summary
Each document triggers an incremental rewrite of the personal report, and then a full regeneration of the medical summary across every processed document. So the cost of the tenth document is higher than the first, and it grows with the corpus, on a queue that runs one job at a time. Importing a folder of old records is the exact workload this design punishes, and it is the workload the project exists for.
Nothing leaves the house, except the pages that cannot be read at home
When a PDF yields almost no text it is a scan, and scans and photos are sent to a hosted vision API to be read. That fallback ignores the local provider setting, because the local model has no vision path. It is the one hole in the promise the whole project is built on, and it opens on precisely the documents most likely to be old and hand annotated. A local vision model is the fix, and it needs the same hardware everything else here is waiting on.
Capped by the graphics card, so it is parked
Keeping the documents at home means the model fits in 10 GB of VRAM. An 8B model handles classification and metadata extraction. Anything larger spills into system memory and eight calls per document stop being a few minutes. The alternative was to rewrite it around a hosted API and hand over the medical records, which defeats the only property that made it worth building, so I stopped adding to it instead. When a machine that runs larger models locally becomes reasonable to own, it picks up where it left off.
Links
Nothing to open from here.
Source
Not public
Not public. It holds my own medical history.