An Indonesian prescription screening aid that reads a photo of a paper prescription and runs it through 34 pharmacy checks. The language model transcribes; every verdict is deterministic code, and the checks that cannot run say so instead of reporting green.
Role Sole engineer. Period May to August 2026. Scope Product design, screening engine, data pipeline, deployment.
ResepCerdas takes a photograph of an Indonesian paper prescription and turns it into a screening report for the pharmacist at the counter, plus a dosing ticket the patient can actually read. Prescriptions here are handwritten in Latin shorthand, the pharmacist is the last check before the medicine changes hands, and a wrong paediatric dose can injure a child.
The design question that shaped everything is where the language model is allowed to sit. It reads the photo, and that is all it does. Every one of the 34 screening verdicts is computed by ordinary TypeScript against curated reference data. That boundary is what makes the output something a pharmacist can audit rather than something they have to trust.
Version one turned typed prescription shorthand into a friendly sentence for the patient, which helped nobody at the counter. Version two moved the input to a photo, the output to a screening aid, and the platform from Vercel to Cloudflare Workers.
Exactly three places in the codebase call a language model: the vision step that reads the photo, an advisory chat assistant with no write access, and the legacy text flow. None is a screening stage, and this is enforced rather than merely intended. The type a check may attach to a finding has no value meaning “the model said so”, so the compiler rejects any check that tries, and no file in the three screening layers imports the model client at all.
Dose arithmetic is the sharpest version of the rule. Scraped reference text is shown to the pharmacist word for word and never parsed into numbers; every milligram per kilogram figure the engine computes with comes from a curated table where a named pharmacist signed the row.
The app is Next.js 16 on Cloudflare Workers, built through OpenNext, and each stage is a numbered directory rather than a service. Stage one is upload policy: JPEG, PNG or WEBP, a 10 MB ceiling, five uploads per minute per IP through a Workers rate limit binding. The photo lands in an R2 bucket and the row lands in D1.
Stage two is the only place a model reads the image. One call to gemini-3.1-flash-lite returns a single JSON object, routed through Cloudflare AI Gateway when an account id and gateway token are both present and straight to Google’s endpoint when they are not. The reply is parsed by a Zod schema before anything downstream sees it, then cached in Workers KV under the image sha256 with a 30 day expiry, so re-screening the same photo costs nothing. Drug names are resolved against a local name table, falling back to live BPOM and RxNorm lookups whose results are written back into D1.
Stages three to five are the screening layers and they all run inside one POST endpoint. The route builds the database handle and a knowledge base object once and passes both in as arguments; the handler then calls the three layers in order. Every check is a single file exporting a function of the prescription and the knowledge base, and none of them imports the database client, so each one is testable with a plain object and no database at all. The knowledge base object memoises its queries for the life of the request, which is why 34 checks over four drug lines do not become 34 round trips.
The whole grid is then persisted, every verdict rather than only the problems. A seventh stage may call the model again, but only to fill slots the deterministic pass left grey: it cannot overwrite a verdict, and the merged row is forced to record the model as its source. Re-screening deletes the previous grid and the pharmacist decisions attached to it, because a sign-off made against a weight of 12 kg is not a sign-off against 18 kg.
Read the figure along the top row first, then down the layers. The red boxes are the only two places a model is allowed to act.
Compliance tools usually settle on pass, review, and fail. Several checks here reach none of those, because no weight was recorded or nobody has curated a dose row for this drug yet, and folding that into either verdict is a safety defect. So there are five outcomes. Green, amber, and red behave as expected. Grey means the machine could not check, and it must name the reason and the person who can clear it. The fifth is recorded but hidden: the check considered this drug and had nothing to say.
The interaction check can never return green, deliberately: that dataset is an extract, not a registry, so a missing row means there is no row, never that two drugs are safe together. Every verdict is persisted, roughly 92 rows for a four drug prescription, so the record answers what the machine examined rather than only what it complained about.
Five vision models were benchmarked on a clean and a deliberately degraded prescription image, scored against 19 ground truth tokens. Gemini 3.1 Flash Lite was the only one to score 19 out of 19 on both while staying under three seconds, at roughly USD 0.0008 per image. A cheaper alternative scored well but silently discarded the printed letterhead, losing the doctor name and licence number that the administrative layer needs.
The finding that changed the interface was different. The chosen model returned maximum confidence on every field of the blurred, skewed, shadowed image. Self reported confidence is uncalibrated, so the plan to flag low confidence fields was abandoned in favour of objective signals: disagreement between two models, token level probabilities, and failure to match a drug name against a registry.
The benchmark used synthetic prescriptions in a handwriting font, so it tests layout and medical shorthand rather than real doctors’ handwriting.
The knowledge base is 2,008 drugs, 484,719 interaction pairs, 552 formulary entries and 7,862 name variants, seeded as 1,471 SQL shards totalling 86 MB. Uploads failed until batches came down to roughly 0.9 MB, and even then 84 retries were needed across 105 batches.
Eight of the 34 checks are declared but not implemented, covering allergy history, pregnancy risk, indication matching, paediatric age bands and four others. Each reports grey with a named reason rather than being silently absent. The curated dose table is empty in a fresh deployment by design, so dose checks stay grey until a pharmacist fills it in.
More seriously, no prescription route has an ownership check: anyone holding a prescription identifier can read the patient name, age, weight, address and full drug list. The bot protection on upload is still Cloudflare’s always-pass test key. Both are blocking for real patient data, and both are written down as such in the repository.
Deployed on 2 August 2026 to a Cloudflare Workers subdomain with the knowledge base seeded, and used by nobody. There is no pilot, no pharmacy partner, and no launch date.
The engineering is verifiable: 267 tests pass across 35 files inside the actual Workers runtime, and 120 hand labelled fixtures run through the pharmaceutical and clinical stages with zero mismatches, 14 of them excluded because their drug names were illustrative. A corpus of 20 real and 21 synthetic prescription photos with ground truth labels sits in the repository, so far unused for an end to end evaluation.
What the project lacks is contact with a pharmacist, and until the access control gap is closed that cannot responsibly happen. Authentication is the next piece of work, ahead of any missing check.