Dev.to · 6 min read

I parse blood test PDFs on the phone, and the parser has no AI in it

I parse blood test PDFs on the phone, and the parser has no AI in it

I have 113 lab reports. Five labs, three countries: Ukraine, Poland, Turkey. Same ferritin, three spellings, two units, one of them in Turkish. For years my "system" was a spreadsheet and a folder called analizy_final_2. I wanted one chart per marker. I also didn't want to upload my blood to anyone to get that chart. So I built Soma, an iPhone app that reads the reports on the device. Full disclosure before anyone asks in the comments: I'm a product designer, and I wrote most of the code with AI coding agents. Yes, I see the irony of an article about keeping AI out of the parser. The agents write the code. They don't read your blood test. Rule one: no model in the parsing The obvious move in 2026 is to hand the PDF to an LLM and ask for JSON. I tried. It reads a clean report nicely, and then, when a line is cut off, it quietly invents a reference range. Once it "fixed" 0.91 into 9.1. On a chart of your own health a confident wrong number is the worst bug there is, because it looks exactly like a right one. So the parser is boring on purpose: text in, rows out, same output every run. If a number moves between two runs, the code moved. That turned out to matter more than any accuracy figure, because every regression shows up as a diff instead of a vibe. Python first, Dart second, and they're not allowed to drift I prototyped in Python, because poking regexes at a folder of reports is fast there. The app is Flutter, so it had to become Dart, and I didn't want two parsers slowly growing apart like divorced parents. Tables are generated, not copied. A script writes the Dart lookup tables from the Python ones: marker aliases, and a character-folding table, because Dart has no built-in Unicode normalisation. Nobody edits those files by hand. Nobody is allowed to. Parity fixtures. The Python parser's output on every report becomes a fixture, and the Dart tests have to reproduce it row for row. A fuzzer for the ugly stuff. Real reports only sometimes contain the things that break a parser, so a seeded generator makes thousands of fake ones: non-breaking and thin spaces, decimal commas, footnote marks glued to the range ("норма¹"), age and sex bands, the range printed on the line below the value. Two cases deserve their own paragraph. Turkish has two letters i, with a dot and without, in both cases, and 'İ'.toLowerCase() is not what you think it is. The fuzzer makes sure I never forget that again. And Cyrillic. A "С" in "СРБ" (that's CRP) can be Latin or Cyrillic depending on which lab software exported it. The two strings look identical in every editor I own and are not equal. That's not a bug report, that's the plot of a horror film. Measuring it with a file I typed by hand You can't improve what you can't count, and you can't count without an answer key. So I typed one: every row of 12 reports, by hand, from the PDFs. Not from my trusty spreadsheet. Good thing, because the spreadsheet had a wrong reference range in it. Years of trusting it. I only found out because the hand-typed key disagreed. The eval scores five things separately: did we find the row, is the value right, the range, the unit, and how many rows did we make up. Scoring them apart matters. A parser can get every value right and still be useless, if it says your vitamin D is 30 mmol/L. My reports are personal data, so they never leave the laptop; the key and the texts live in a git-ignored folder. For the parts I can share I built synthetic report sets in French, Italian, Spanish, Dutch, Swedish, Danish, Norwegian, Portuguese, Korean and Japanese. The Japanese set taught me that "(−)" and "(±)" are results, not decoration. OCR is where it actually fails Text-layer PDFs are mostly solved. Photos of paper are not. On iPhone the OCR is Apple's Vision, which is good, and which still occasionally gives me "Asparale aminotransterase". That's an ALT test, not a Harry Potter spell. Some PDFs only pretend to be text: one Polish lab's exports have a text layer that decodes into confident garbage, so the extractor has to notice that and OCR the rendered page instead. And my Turkish lab prints the reference range in bold with a table of thresholds printed right on top of it. Vision merges the two layers into soup. My insulin result from Istanbul has no reference range in the app to this day, because the parser refuses to guess one, and I'd rather it refused. The last round of fixes moved the public sample set from 0.976 to 0.985. The PDFs in that set were already read in full; every point came from photos. That's where the remaining errors live, and I don't think they'll go to zero. So the app shows every row it read and lets you fix it before anything lands on a chart. A product decision forced by an engineering limit. I've made peace with it. Then I added an LLM anyway, on a short leash People don't want a chart. They want to ask "is my iron getting better?" So there's an assistant, running on the phone: Gemma, a 2.6 GB download, once. The leash: the model never sees the data. It only turns the question into one call from a fixed list of eleven (value, trend, compare, explain, retest and so on), with the marker in the person's own words. Plain Dart code finds the marker, runs the query and writes the numbers into the answer. The model can phrase things. It can't make up a ferritin value, because it never had one. On my test set this routing got 109 of 111 calls right, against 97 when the model did everything itself. I was very pleased with myself for about a day. Then I wrote fresh questions the code had never seen, and the number fell off a cliff: my phrase lists had been fitted to the old questions. After fixing that, the fresh set is at 125 of 145, 86%. "When was my last blood test?" still goes to the wrong place. I'm working on it. Probably. I also benchmarked Apple's own on-device model, because it's free and already on the phone. On 75 deliberately awkward questions in English and German it got 27 right; Gemma got 64. Apple's model invented values and ranges, and in German it told a test user, very confidently, that they had an underactive thyroid. So Gemma is the default, and Apple's model is an option with a note: faster, less accurate. If you're starting something like this Type the answer key before you write the parser. Keep the model out of anything that produces a number. Generate shared tables, never copy them. And buy a cheap printer, because at some point you'll catch yourself photographing your own blood test at a bad angle, in bad light, on purpose, for science. Soma is in App Store review right now: somalab.app. If you parse documents for a living and something here looks dumb, tell me. Nicely, ideally.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More AI & Machine Learning News