DEV Community

Gajanand V Dhayagode
Gajanand V Dhayagode

Posted on

My fine-tuned Gemma blamed a critical haemoglobin on "how the sample was handled". My eval gave it a pass.

Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle Submission 💸

This is a submission for the Hacktoberfest Open-Source AI Challenge: Week 2 — Side Hustle.

What I Built

In India, your lab report usually arrives as a PDF on WhatsApp, hours or days before you see a doctor. So people ask the health professional who's always there, the pharmacist at the counter: "Is this bad?"

Report Saathi (saathi means "companion") answers that question. You upload the PDF (or a photo), say whether you're pregnant, and within a few minutes (about one for a short report, three to five for a full check-up) you get a short explanation in plain English:

  • the values worth asking your doctor about, and what each test measures;
  • small differences grouped in one line, so a full check-up shows a handful of things instead of sixteen alarms;
  • the lab's own notes, quoted as the lab's words;
  • neutral questions to take to your doctor;
  • a "contact your doctor today" line, only for critical values.

It never diagnoses and never mentions medicines. Every answer ends: "This explains your report. It is not a diagnosis. Please show this report to your doctor."

The side hustle: a pharmacy puts a QR code on the counter. Customers scan it and upload their report, and each upload is counted for that shop. The shop pays a small monthly fee. The customer gets an answer, and the pharmacist gets their time back.

The niche: pregnancy. Pregnancy changes what "normal" means. Haemoglobin, TSH and the sugar test all have different ranges in each trimester. An explainer that uses adult ranges will tell a pregnant woman her normal result is low. Report Saathi asks about pregnancy (reports never say), uses the trimester range, and tells her when a value is normal only because she's pregnant.

This post is mostly about one lesson, though: a reassuring excuse is worse than no reason at all, and my own fine-tune taught me that.

Demo

See a finished example right away (no upload, no waiting): https://report-saathi.onrender.com/example
It walks through the three steps on a made-up report: what the reader found, what the rules decided, and the explanation.

Try your own report: https://report-saathi.onrender.com

Measured on the live app with real (redacted) full check-ups of 83–87 rows: read in 117–150 s, explained in 69–126 s, 3–5 minutes in all. A short report takes about a minute.

Code

Report Saathi: understand your lab report

Report Saathi (saathi means "companion") explains a blood-test report in simple English. You upload the PDF your lab sent on WhatsApp, or a photo of the paper report, and say whether you're pregnant. A few seconds later you see:

  • which values are worth asking your doctor about, and what each test measures
  • small differences that usually don't matter on their own, grouped in one line
  • your normal results in one line
  • the lab's own comments, quoted as the lab's words
  • neutral questions to ask your doctor
  • an urgent "contact your doctor today" line, only for critical values

It never diagnoses and never suggests medicines, supplements or doses. Every answer ends with: "This explains your report. It is not a diagnosis. Please show this report to your doctor."

Built for the DEV "Hacktoberfest Open-Source AI Challenge: Side Hustle" (15–19 October 2026). Measured…

The web app deploys to Render with one click (free plan, no card). The worker runs on any machine with an OpenAI-compatible server (we use vLLM on a DGX Spark). rules.csv, the table that decides what's high or low, is plain text that a doctor can check line by line.

How I Built It

phone ──upload──► web app on Render ◄── polls (outbound only) ── worker on a DGX Spark
                  job queue (Postgres)                            1. READER     Gemma 4 12B reads the report
                  deletes everything after 24 h                   2. RULES      rules.csv flags each value (no AI)
   ◄── explanation streams back ───┘                              3. EXPLAINER  the same 12B + our LoRA explains
Enter fullscreen mode Exit fullscreen mode

The first decision was the important one: no AI decides whether a value is abnormal. A model that says "your TSH is fine" when it isn't is the worst thing this app could do. So the job is split in three, and the AI never makes the medical call.

1. The reader has to prove every number

Indian lab PDFs usually have a text layer, but it's jumbled: columns interleave, ranges float above their test, and one test can print five ranges (child, adult, three trimesters). So Gemma 4 gets each page as an image plus that page's exact text, and returns the rows.

Then one rule makes it trustworthy: every number Gemma returns must appear in the PDF's own text. If it doesn't, the row becomes "couldn't read" and is never explained. Photos have no text layer, so the app shows the numbers it read and asks "Does this match your report?" before explaining anything.

On 27 real reports (names, IDs, doctors and lab removed; used only for testing, never for training), 969 rows in all, I tested two readers:

Gemma 4 31B Gemma 4 12B (live)
High/low results the user sees 960 / 960 right 958 / 960 right
Values exactly right 963 / 969 966 / 969
Values it made up 1, caught by the PDF check 0
Time per report median 37 s median 110 s

The 31B reads better and faster. The live app uses the 12B anyway, because one model does both jobs: the 12B's own weights read the report, and the same server applies our LoRA to write the explanation. The 31B was only ever the teacher, and the GPU box is shared with other projects, so one 12B instead of two big models frees about 46 GB for everyone else.

The cost is real and I'd rather show it: one of the 12B's two misses turned a low HDL into "Normal", most likely because it misread the printed range on a crowded page. I tried a safety net that cross-checks every value against our own table, but on correct reports the two disagree on 2% of rows (mostly small red-cell indices), so it would have raised false alarms. That's the first thing to fix next.

2. The rules: a table a doctor can check

rules.csv lists every test the way labs print it ("SGPT", "ALT (SGPT)"…), with units, ranges, pregnancy ranges by trimester, critical limits and an importance level. The rules use the lab's printed range first, pick the one that matches the patient's age or trimester, and handle bands ("5.7–6.4 = pre-diabetic") and values that fall in the gap between them.

The real reports kept teaching it. The first 6 were thyroid, HbA1c and antenatal panels. The next 21 brought urine tests, dengue, typhoid (Widal), HIV and hepatitis screens and urine cultures: 194 rows the table didn't know. Now it knows all of them, with care. A positive screening test is "a screening result your doctor will confirm", never "you have HIV". A culture report keeps the germ's name but drops the antibiotic list, so the explainer never sees a medicine name.

The rules also gave me the most embarrassing bug of the week. This lab prints platelets as "373 lakhs/cmm", which would be 100× too many. It means 373 thousand. My rules converted it correctly to 3.73 lakh, then attached a note saying "read as thousand/cmm". Put that next to 3.73 and a patient reads 3,730: a dangerously low count. The rules were right and the note was wrong. The blind review below caught it; the note now gives both numbers.

3. The explainer, and the excuse it learned

The explainer only sees the flagged rows. I wanted a small, fast model, so the plan was Gemma 4 31B as the teacher and Gemma 4 12B as the student, fine-tuned on the team's DGX Spark.

The base 12B wasn't safe enough. It skipped the doctor questions on all three critical cases in my first test set, and it told two patients they had a "urinary tract infection". That's a diagnosis, which this app must never make.

So I made training data. A generator writes synthetic reports in real lab formats (40% pregnant, lots of critical values and odd cases). The rules flag them and the 31B explains each one. An answer is kept only if it passes every automatic check (no diagnosis, no medicine words, every flagged number mentioned, the urgent line only when critical) and a review by the 31B as a judge. Real reports are never used.

Fine-tune 1 copied the teacher's filler. It learned the format perfectly, and it also learned "this can happen for several general health reasons." True, and useless. So I wrote a rule against vagueness: give "concrete, everyday, non-alarming reasons", for example a recent meal, dehydration, the time of day, how the sample was handled. I added the same examples to the judge.

Fine-tune 2 looked like a win. 2,351 of 3,500 teacher answers passed, ft2 trained on 2,000 of them (a 16-bit LoRA on gemma-4-12B-it), and on my 36-case test (each case run twice) it beat everything:

Model Passes checks + judge (of 72) Rule breaks
Gemma 4 12B, base 39 6
Gemma 4 31B, base (the teacher) 53 9
12B, fine-tune 2 63 0

A fine-tuned 12B beating its own teacher, with zero rule breaks. I nearly made that the headline.

Then the answers were read blind. I gave 13 shuffled pairs (fine-tune 2 vs the 31B, same report) to a separate AI model as a reviewer, without telling it which was which. The 31B won 9, the fine-tune 3, one tie. The reasons were the problem:

Haemoglobin 6.2, critically low, 32 weeks pregnant (fine-tune 2): "This can happen due to the body's increased needs during pregnancy or **how the sample was handled."
A positive HIV screening test (fine-tune 2): "This can happen **if the sample was handled in a certain way* or if there is a false positive result."*
An HbA1c just above normal, a 3-month average of blood sugar (fine-tune 2): "This can happen due to **a recent meal, the time of day* the sample was taken…"*

All three passed my eval. They're calm, specific and everyday, which is exactly what I'd asked for. The HbA1c one is simply false: a 3-month average doesn't care about breakfast. The other two are worse. They hand a frightened person the comfortable reading of a result that needs a doctor today.

My example list had become the answer. And the student learned it harder than the teacher. Counting the excuses in the same 72 answers:

"time of day" "how the sample was handled" "all other tests were normal" on a one-test report
Gemma 4 12B, base 0 11 1
Gemma 4 31B (teacher) 16 18 18
12B, fine-tune 2 30 27 20

A fine-tune doesn't just copy the teacher. It amplifies whatever the filter rewards, and my filter rewarded reassurance. The judge was the 31B grading with my examples, so it marked the excuses as good answers. Scoring 63 of 72 measured how well ft2 had learned my rubric, not whether it explained well.

The fix was to the rubric, not the model. Prompt v1.4 says: give a real, common cause for that test, or none at all. For example, low MCV is commonly linked to low iron or an inherited trait; HbA1c isn't changed by a meal. Critical values and positive screening tests get no reassuring reason. The urgent line moves to straight after the overview. New automatic checks catch "how the sample was handled", meal excuses for HbA1c, and invented "all other tests are normal" lines. The judge now fails a wrong reason instead of rewarding it.

Then I re-tested both models with prompt v1.4 and the stricter judge (same 72 answers, no retraining):

Passes (of 72) Rule breaks "time of day" "sample" invented "all other tests normal"
Gemma 4 31B 54 2 0 2 4
12B, fine-tune 2 64 0 0 2 0

The same fine-tune, given honest instructions, dropped the habit it had learned hardest: "time of day" went from 30 answers to 0. The critical haemoglobin now reads:

"Please contact your doctor today about your Haemoglobin." (straight after the overview), then: "Haemoglobin… is 6.2 g/dL. This is critically low. The normal range is different for people in their third trimester of pregnancy."

No excuse at all. And the low MCV that used to be blamed on the time of day now gets "Small red cells are commonly linked to low iron." The 31B's two rule breaks were both "urinary tract infection" on urine tests, a diagnosis. So fine-tune 2 writes the explanations in the live app, with prompt v1.4.

A second blind read of these new answers is next.

There wasn't time to retrain before the deadline. A fine-tune 3 on v1.4 data is next, and this time I'll read its answers blind before I believe its score.

Speed: Gemma 4's FP8 serving with its MTP drafter makes the 12B 5× faster (3.4 s against 17 s per explanation). It cost the base 12B nothing in quality, but it cost the fine-tune 4 passes and 3 rule breaks, so I didn't take the trade. The text streams in as it's written anyway.

One deploy surprise

I planned a paid Render setup with an hourly clean-up job. My Indian debit card was declined ("does not support this type of purchase"), so it runs on Render's free plan. With no cron, the app deletes jobs older than 24 hours itself whenever the worker checks in, and the worker's polling keeps the free service awake. The free Postgres worked on the first deploy.

What still doesn't work

  • The reasons look fixed on the test, but the habit is still in the weights. Prompt v1.4 holds it back; a fine-tune 3 trained on v1.4 answers would remove it. Two answers still mention the sample.
  • Counting on big reports. On a 70-value check-up the models sometimes miscount what's worth asking about.
  • No doctor has reviewed it yet. A gynaecologist is reviewing the pregnancy ranges and critical limits. Until then, rules.csv is carefully researched, not clinically signed off.
  • The 12B is slow on big reports. A full 84-row check-up takes about 2 minutes to read and 1–2 minutes to explain (3–5 minutes in all on the live app); the explanation streams in as it's written. Serving the 12B in FP8 with Gemma 4's drafter would be about 5× faster, which I measured, but the fine-tune lost quality in FP8.
  • A real report broke the live app twice last night (once on the explainer's context limit, once on a missing range label in the rules). Both are fixed on the GPU box; the second fix still has to reach the public code.
  • One lab. All 27 real reports use one Bangalore lab's template. Other labs will break the reader in new ways.

I built this with Claude Code as my pair programmer. A second Claude Code session ran the DGX through a private repo: I wrote task files, it pushed results. So every number above came from a script, and the one that fooled me came from my own rubric.

Why Does Open Innovation Matter?

  • Lab reports are some of the most private documents people have. They show pregnancies, HIV tests and diabetes. With open weights, every model runs on a machine our team owns. No report goes to an AI company, and Render deletes everything within 24 hours.
  • I could find the bug in my own rubric. With open weights I could train my own judge-filtered data, count every excuse in every answer, and compare the student with its teacher. A closed API would have handed me a score, not a habit to fix.
  • It costs nothing per report. Thousands of teacher answers, two fine-tunes and six-model evaluations, with no API bill. That's also what makes a small monthly fee per pharmacy workable.
  • The honest trade-off: a big closed model would probably have given better medical reasons on day one. I chose privacy and paid for it in engineering. This week, that engineering is the story.

Prize Categories

  • Best Use of Gemma. Gemma 4 does all the AI work. In the live app one Gemma 4 12B does both jobs: its own weights read every report (page image plus text, each number checked against the PDF), and our 16-bit LoRA on the same server writes every explanation. Gemma 4 31B taught and judged the fine-tune. We fine-tuned Gemma 4 12B with a 16-bit LoRA, measured FP8 and the MTP drafter, and found out, by reading blind, what the scores were hiding.
  • Best Use of Render. Render hosts the front end, the job queue (web service + Postgres from one render.yaml, free plan) and an example page that works even when the GPU box is busy. The GPU worker only ever connects outward to Render, so it needs no open ports.

Got a lab report on your phone right now? That's exactly who this is for. And if you're grading a model with its own teacher, read some of its answers blind. 🩺

Top comments (0)