Dev.to AI πŸ€– Ai πŸ‘ 0 πŸ“– 4 min read

Never let the model retype a number: how we read lab reports with two AI passes

People photograph a blood test report and ask "is this bad?". When we built the lab report explainer in Saluvo, the explanation turned out to be the easy part. The risky part is reading the numbers: a misplaced decimal t

People photograph a blood test report and ask "is this bad?". When we built the lab report explainer in Saluvo, the explanation turned out to be the easy part. The risky part is reading the numbers: a misplaced decimal turns a normal result into an alarming one, or the reverse.

These are the design rules we ended up with. None of them need a special model, and they apply to any LLM pipeline that pulls numbers out of documents: invoices, prescriptions, bank statements.

1. Extraction is copying, not understanding

The extraction prompt is deliberately dull:

Copy every test row exactly as printed. Never guess a value; skip a row you cannot read.

Values come back as strings exactly as printed ("7.8", "1,250", "<0.5"), at temperature 0, in a strict JSON shape. Reference ranges come back twice: as printed text ("4.0 - 5.6") and as numbers where they can be parsed (refLow, refHigh). A row the model can't read is skipped, not reconstructed from what's "usually" there.

The prompt also handles the boring failures: if the photo isn't a lab report at all, the model returns {"markers": [], "notReport": true} and the user is asked for a clearer photo of the results page.

2. Read it twice, independently

Two extraction calls run in parallel on the same image or PDF text:

const [first, second] = await Promise.allSettled([
  extract(MODELS[mode].precise, input, meter),
  extract(MODELS.normal.precise, input, meter),
]);

Promise.allSettled, not Promise.all: if one reader fails or times out, the other's result is still used. If both fail, the user gets an error, never a half-guessed table.

Then the two readings are matched row by row. Names are normalised ("HbA1c", "Hb A1c" and "HBA1C" become the same key), and values are compared exactly as numbers:

function crossCheck(primary: Marker[], second: Marker[] | null): Marker[] {
  if (!second?.length) return primary;
  const other = new Map(second.map((m) => [keyOf(m.name), m]));
  return primary.map((m) => {
    const o = other.get(keyOf(m.name));
    if (!o) return m;
    const a = numberOf(m.value), b = numberOf(o.value);
    const same = Number.isFinite(a) && Number.isFinite(b) ? Math.abs(a - b) < 1e-9 : m.value === o.value;
    return same ? { ...m, verified: true } : { ...m, verified: false, altValue: o.value };
  });
}

No tolerance band. If one reader saw 5.6 and the other 5.8, that's a disagreement, because on a lab report it might be the whole story.

3. When they disagree, ask the person holding the paper

A disagreement isn't resolved by a third model or a vote. The row is shown with both readings and the user picks the one that matches their paper. Confirming a value also recomputes its flag:

export function confirmMarker(m: Marker, value: string): Marker {
  return { ...normaliseMarker({ ...m, value })!, verified: true };
}

The person has the original in their hand. Asking them is cheaper, faster and more honest than any amount of model cleverness, and it makes the tool say clearly what it couldn't read.

4. Don't trust the model's "high" or "low"

The extraction returns a flag for each row, but the app doesn't use it when it can do the maths itself:

if (Number.isFinite(num) && (refLow !== null || refHigh !== null)) {
  flag = refLow !== null && num < refLow ? "low"
       : refHigh !== null && num > refHigh ? "high"
       : "normal";
}

The flag comes from the printed number and the printed range. The model's label is only a fallback for rows where the range isn't numeric.

5. The explaining model never sees the values

This is the rule I'd most recommend stealing. The second stage writes the plain-words explanation, the questions to ask the doctor and which kind of doctor to see. It receives each marker's name, unit, printed range and flag, but not the value:

prompt: JSON.stringify(markers.map(({ name, unit, refText, flag }) => ({ name, unit, refText, flag }))),

And its instructions say: don't write the patient's result values anywhere; the app shows the exact numbers next to your text.

So no model ever retypes a number the user sees. The numbers on screen always come from the extracted, cross-checked, user-confirmed table. A model can't make an arithmetic slip, round 6.45 to 6.5, or quietly "correct" a value it finds implausible, because it never handles the value in the first place.

6. Make sure it's the right person's report

Reports get uploaded into the wrong family profile. The extraction also reads the patient printed on the report (name, age, sex; never the referring doctor or the lab's signatory), and the app checks it against the profile before explaining. If it doesn't match, it asks instead of explaining someone else's results as yours.

7. Optional extras must never block the core

For out-of-range values, the app can look them up on trusted medical sites and summarise them with citations. That step is wrapped so that a failure there never fails the report: no sources is a missing extra, not an error.

What this doesn't do

It doesn't diagnose. A flagged value is a flag, not a condition, and the explanation says which kind of doctor to see and how soon, more specific than "see a doctor". It's marked urgent only for values that need same-day care, and then the screen says to show the report to a doctor today. The explaining model's instructions start with "You are not a doctor and you do not diagnose", and it's told never to suggest medicines or doses.

The general lesson

If your LLM pipeline handles numbers that matter:

  • Extract as strings, exactly as printed. Parse them yourself.
  • Read twice, compare exactly, and let a human settle disagreements.
  • Compute derived facts (high/low, totals) in code, not in the model.
  • Keep the numbers away from the model that writes prose. Let it refer to them by name; render the values yourself.

We wrote a plain-language companion for patients too, if you want the other side of this: how to read a blood test report.

πŸ“° Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.