DEV Community

Avery Lin
Avery Lin

Posted on

Parse, Validate, Then Persist

Last Saturday I shipped a receipt scanner for a side project. It takes a photo, asks a model for JSON, and writes one row into SQLite. Two hours later my ledger showed a coffee costing $1,240.00.

The model returned "amount": "12,40". My parser stripped the comma and moved on. Nothing crashed, so nobody complained.

That is the standard failure mode of cheap model calls. The call succeeds and the data is wrong. A free tier makes it worse, because you run more calls and read fewer outputs.

I fixed it with a gate. The gate is about forty lines of Python. It has saved me more time than any prompt rewrite.

The model reads. The code decides.

Two conditions must hold before a draft touches a database. It must parse as JSON. It must match a schema you wrote by hand.

Nothing else belongs in the gate. Do not ask the model to grade itself. Do not ask a second model to approve the first. Keep the decision deterministic, fast, and boring.

Here is the schema for one receipt. The bounds are the point.

# gate.py
import json, sqlite3, jsonschema

SCHEMA = {
    "type": "object",
    "additionalProperties": False,
    "required": ["merchant", "amount_cents", "currency", "date"],
    "properties": {
        "merchant":     {"type": "string", "minLength": 1, "maxLength": 120},
        "amount_cents": {"type": "integer", "minimum": 1, "maximum": 5_000_000},
        "currency":     {"type": "string", "enum": ["USD", "EUR", "GBP"]},
        "date":         {"type": "string", "pattern": r"^\d{4}-\d{2}-\d{2}$"},
    },
}
Enter fullscreen mode Exit fullscreen mode

amount_cents is an integer with a ceiling. That one line kills the $1,240.00 coffee. A comma decimal cannot pass as an integer, so the gate returns a reject instead of a bad row.

Why not just write a better prompt.

Prompts decay. They are tied to a model version, and free tiers rotate versions quietly. The schema does not move. It is the only part of this pipeline you actually control.

Retry once. Then reject loudly.

Free access changes shape without notice. One retry with stricter instructions is cheap insurance. A third retry means the prompt is wrong, not the model.

def parse_receipt(text, call_model):
    last_error = None
    for attempt in (1, 2):
        raw = call_model(text, strict=(attempt == 2))
        try:
            doc = json.loads(raw)
            jsonschema.validate(doc, SCHEMA)
            return doc
        except (json.JSONDecodeError, jsonschema.ValidationError) as exc:
            last_error = str(exc)[:300]
    record_reject(text, last_error)
    raise ValueError("reject: see rejects table")
Enter fullscreen mode Exit fullscreen mode

record_reject writes the raw text and the error string into a table. You never lose the evidence. You also never write it into the ledger.

CREATE TABLE IF NOT EXISTS rejects (
  id INTEGER PRIMARY KEY,
  created_at TEXT DEFAULT (datetime('now')),
  raw TEXT NOT NULL,
  error TEXT
);
Enter fullscreen mode Exit fullscreen mode

Five fixtures that must never pass.

An untested gate is decoration. I keep five bad payloads in a parametrized test. Each one is a real output I have seen from a cheap call.

import json, jsonschema, pytest
from gate import SCHEMA

BAD = [
    '{"merchant":"Cafe","amount_cents":1240.0,"currency":"USD","date":"2026-10-18"}',
    '{"merchant":"Cafe","amount_cents":1240,"currency":"usd","date":"2026-10-18"}',
    '{"merchant":"Cafe","amount_cents":1240,"currency":"USD"}',
    '{"merchant":"Cafe","amount_cents":1240,"currency":"USD","date":"2026-10-18","tip":2}',
    '{"merchant":"Cafe","amount_cents":0,"currency":"USD","date":"2026-10-18"}',
]

@pytest.mark.parametrize("raw", BAD)
def test_gate_rejects(raw):
    with pytest.raises(jsonschema.ValidationError):
        jsonschema.validate(json.loads(raw), SCHEMA)
Enter fullscreen mode Exit fullscreen mode

Run it before every deploy. The command is one line.

pytest -q tests/test_gate.py
Enter fullscreen mode Exit fullscreen mode

One request proves the wiring.

You want a single endpoint that returns a row or an error id. Never both, and never a half row.

curl -s -X POST localhost:8000/parse \
  -H 'content-type: application/json' \
  -d '{"text":"CAFE LUNA 12,40 EUR 2026-10-18"}'
Enter fullscreen mode Exit fullscreen mode

A bad decimal now returns {"error":"reject","id":41}. That id points straight at the raw text in the rejects table.

The reject table is your roadmap.

One query tells you what to fix next. Compare rejects against successes for the same day. The ratio is the only metric that matters here.

SELECT date(created_at) AS day, COUNT(*) AS rejects
FROM rejects GROUP BY day ORDER BY day DESC LIMIT 7;
Enter fullscreen mode Exit fullscreen mode

My rule is simple. Above two percent, stop adding features and rewrite the failing part of the prompt. Below one percent, the parser is good enough to keep shipping.

Where the free tier earns its place.

I run this loop against MonkeyCode's free model access. The gate calls the model twice on bad output, so usage grows while you tune. A free tier absorbs that iteration without a billing surprise at the end of the month.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The free server option hosts the same FastAPI app and the same SQLite file. The validator sits next to the reject log, which is where it belongs. Read the project page for the current limits and re-read them each release. Treat them as limits, not commitments.

What the gate cannot do.

A valid row can still be a wrong row. The gate checks shape, bounds, and enums. It cannot check whether the merchant name is spelled correctly, or whether the receipt was already scanned last week.

Keep a one-tap confirm screen for the user. Store the raw text beside the parsed row. When someone disputes a number, you need both.

The gate also costs latency on failure. A retry is a full model round trip, and on a small free box that trip is slower than the happy path.

Who should skip this.

Skip it if a human reads every output anyway. Skip it if the payload is free text with no numbers or dates downstream. Skip it if you need signed audit records, because SQLite on an ephemeral box will not carry that weight.

Ship the gate this afternoon.

Pick one narrow parser. Write the schema before you write the prompt. Add five fixtures and a rejects table. Then let the free tier absorb the retries while you watch the ratio. That is the whole workflow, and it fits in a single sitting.

Top comments (0)