DEV Community

JohnX4321
JohnX4321

Posted on AI-assisted

SafePlate: a menu checker for my friend with a peanut allergy, built on Gemma

Hacktoberfest Weekend Challenge: Build for a Friend Submission 🤝

This is a submission for the Hacktoberfest Weekend Challenge: Build for a Friend

For my Friend Akshitha with allergies

What I Built

Akshitha has an anaphylactic peanut allergy and is also allergic to tree nuts and shellfish. Every time we eat out, the same thing happens. She reads the menu twice, then asks the server three questions, then orders the plain rice anyway. Peanuts hide in places you wouldn't expect: satay, pesto, "crunchy topping", the wok that cooked the last order.

SafePlate is a phone-friendly web page. She takes a photo of the menu and gets back every dish sorted into 🟥 Avoid, 🟧 Ask first and 🟩 Looks safe, each with a reason. Each risky dish also comes with the exact question to ask the server, like "Is the Kung Pao cooked in the same wok as other dishes?"

At a loud table she can tap 🔊 Read it to me and hear the verdicts instead of squinting at her phone.

Demo

Live: https://safeplate-2kzw.onrender.com (Username: friend, password: AqAnIuHQImW9xF1BbcetAbLF8WrtQ7OKJYMFd/UAcho=)

Here's real output from a test menu photo, with the laptop offline:

AVOID  Kung Pao Chicken        Ask: "Can you confirm the wok is cleaned between dishes?"
AVOID  Garlic Butter Scallops  (caught by the keyword safety net)
AVOID  Basil Pesto Pasta       Ask: "Do you offer a pesto without pine nuts?"
AVOID  Vegetable Spring Rolls  Ask: "What's in the sweet chili dip?"
SAFE   Steamed Jasmine Rice
Enter fullscreen mode Exit fullscreen mode

That's about 13 seconds per photo on my laptop. The spoken version sounds like this: "Avoid: Kung Pao Chicken. Ask about Green Papaya Salad. Can you confirm if the fish sauce is prepared separately from any nuts? Looks safe: Steamed rice."

Code

SafePlate 🍽️

Snap a restaurant menu and SafePlate tells your friend which dishes to avoid, which to ask about, and which look safe for their allergies. It also hands them the exact question to ask the server.

Gemma 3 does the work. It reads the menu photo itself (vision) and runs locally through Ollama. Nothing leaves the laptop: no API key, no cloud, no cost.

Run it (2 commands)

ollama pull gemma3:4b
Enter fullscreen mode Exit fullscreen mode
python3 app.py
Enter fullscreen mode Exit fullscreen mode

Then open http://localhost:8000. You need Python 3.9+ and nothing else, because the app uses only the standard library.

To use it from a phone at the table, connect the phone to the same Wi‑Fi and open http://<laptop-ip>:8000. The camera button opens the phone's camera.

Deploy to Render (free)

Render's free tier has no GPU, so the hosted version calls the same Gemma family through Google's Gemini API (gemma-4-26b-a4b-it, which…




Two files do the work: app.py, which uses only the Python standard library, and index.html. There's nothing to pip install. To run it locally:

ollama pull gemma3:4b
python3 app.py
Enter fullscreen mode Exit fullscreen mode

How I Built It

Gemma reads the menu. Locally it's Gemma 3 4B through Ollama. Gemma is multimodal, so it reads the menu photo directly and I don't need a separate OCR step. The model is 3.3 GB and runs fine on an ordinary laptop. The browser shrinks photos to 1280px first, which keeps inference fast.

Structured output, not free text. Ollama's format parameter holds Gemma to a JSON schema. Every dish comes back with a verdict of exactly safe, ask or avoid, plus a reason and an ask_server question. Temperature is 0, and the prompt says "when unsure, choose 'ask'."

A safety net that doesn't trust the model. This is the part I care about most. The 4B model is good, but "good" isn't enough for an allergy. In testing it rated Garlic Butter Scallops and Tom Yum Goong (with prawns) below "avoid" for someone with a shellfish allergy.

So after Gemma answers, a deliberately dumb keyword pass runs over each dish's menu text:

def safety_net(dishes, profile):
    """Never trust the model alone: any dish whose text names an allergen keyword is forced to 'avoid'."""
    for d in dishes:
        text = f"{d.get('name', '')} {d.get('menu_text', '')}".lower()
        hits = [a for a, words in profile["allergens"].items() if any(w.lower() in text for w in words)]
        if hits and d.get("verdict") != "avoid":
            d["verdict"] = "avoid"
            d["reason"] = f"Menu text mentions {', '.join(hits)}. " + d.get("reason", "")
            d["flagged_by"] = "keyword safety net"
        if d.get("verdict") not in RANK:
            d["verdict"] = "ask"  # unknown verdict = don't claim safe
    return sorted(dishes, key=lambda d: -RANK[d["verdict"]])
Enter fullscreen mode Exit fullscreen mode

The model handles the nuance: sauces, cuisines that often use peanuts, cross-contact. The keywords guarantee the obvious cases. The profile maps each allergen to the words it hides behind (peanuts: satay, groundnut, arachis…, tree nuts: pesto, praline, marzipan…). The UI labels a dish the net overrode, so she can see why. The code can only make a verdict stricter, never more relaxed.

One app, two places to run it. If GEMINI_API_KEY is set, the same code calls hosted Gemma 4 (gemma-4-26b-a4b-it) through Google's Gemini API. That's how it runs on Render's free tier, which has no GPU. A render.yaml blueprint with no build step deploys it, and a password protects the URL. Without the key, it talks to local Ollama. It's the same prompt and the same safety net either way.

Hosted Gemma has no JSON mode, so the prompt includes the schema and the code takes the first {...} block out of the reply. Gemma 4 can include its "thoughts" in the reply too, and those get filtered out before parsing.

A voice from ElevenLabs. The verdicts become one short spoken summary: what to avoid, then each "ask" dish with its question, then what's safe. That goes to ElevenLabs' eleven_flash_v2_5 model, chosen for low latency. With no key, or offline, the button falls back to the browser's built-in speechSynthesis, so it always works.

Things that bit me deploying to Render:

  • My health check pointed at a password-protected path, so it got a 401 every time and the deploy never went live. The fix was an unauthenticated /healthz.
  • Render's startup probe sends HEAD /, which Python's http.server rejects with 501 unless you add do_HEAD.
  • Google removed Gemma 3 from the Gemini API partway through, which caused a 404. The fix was switching to Gemma 4 and showing the provider's real error message instead of a bare "HTTP Error 404".

Why Does Open Innovation Matter?

  • Her health data can stay with her. A list of someone's life-threatening allergies is medical information. With an open-weight model running locally, it never leaves the laptop. There's no vendor, no retention policy to read, and no account to make.
  • Private when it matters, convenient when it doesn't. The Render version is the convenient one. Because Gemma is open-weight, the same model family also runs on hardware she controls. With a closed model, that choice wouldn't exist.
  • It works where restaurants are. Basements, patios and bad signal don't matter when inference runs on the device she's carrying.
  • It's free to run. Locally there's no API key and no per-photo bill, so she can check every menu without thinking about it.
  • I can swap the model with one setting. MODEL=gemma3:12b gives more accuracy on a bigger machine, and the hosted version moved to Gemma 4 with a one-line change.
  • I control the whole pipeline. Because I own the inference call, I could enforce a JSON schema, set temperature to 0 and put my own deterministic check after the model. A closed chatbot gives you an answer and asks you to trust it, and for an allergy I won't.

My Agent Session

I built this with Claude Code. The session shows the whole path: picking the idea for cost and simplicity, the first test where Gemma missed the shellfish dishes (which is why the safety net exists), and the three Render deploy failures being debugged from real logs.

Building SafePlate: an allergy menu checker on Gemma, ElevenLabs and Render
You

You are a professional software engineer. With year of Hackathon and SOftware experience. You re required to create project implementations based on request.

Challenge Prompt
Build for a Friend
Build something with open-source AI at its core.
That can mean running an open-weight model, building on an open-source agent harness or framework, running inference locally, or all three. Whatever you pick, the open pieces should be what makes your project work.
In your post, tell us why open innovation matters for what you built. Does it run on a laptop with no internet? Keep someone's data off a server they don't control? Let you fine-tune, swap models, or change how your agent behaves? Cost nothing to run? Tell us where your open-based approach worked better than a closed one.
This Weekend's Theme: Build for a Friend
Ship something that solves a real problem for a friend or someone you love.
Pick one real person and build something for them. It doesn't have to be big. It has to matter to them. A few ideas to get you going:

  • A meal planner that knows your roommate's allergies
  • A patient practice partner for a friend learning a new language
  • A tool that turns your grandpa's voice memos into a family recipe book

Bonus points if you actually hand it over and tell us what they said.
[challenge template, judging criteria and prize categories trimmed]

Create the project based on ease of creation, operation and cheaper to run/deploy.

Agent

Empty dir, Ollama installed. Plan: SafePlate — a friend with food allergies snaps a restaurant menu; Gemma 3 (vision, running locally via Ollama) flags which dishes are safe, risky, or unsafe for their allergy profile. Health data never leaves the laptop, it works offline, and it costs $0 to run. That targets the Gemma category. Pulling the model in the background while I write the code.

Self-check passes. Now I'll write the README while the model downloads.

Model's ready. Starting the server and running a real end-to-end check against Gemma, first with text, then with a generated menu image.

Text path works: 12.6s on the laptop, and the keyword safety net caught the prawn soup that Gemma only rated "ask". Now the vision path:

Vision works: Gemma read the photo in 13s, and the safety net caught the scallops it missed. The UI loads and picks up the profile. Next, a draft of the DEV post, since writing quality is the top judging criterion.

I built SafePlate, a web app for a friend with food allergies. You photograph a restaurant menu and Gemma 3, running on your laptop through Ollama, sorts every dish into Avoid, Ask first or Looks safe. Risky dishes come with a question to ask the server.

I tested it against the real model. A pasted menu took about 13 seconds, and so did a menu photo.

How it scores on what you asked for:
- Easy to build: it's one Python file using only the standard library, plus one HTML page. There's nothing to pip install.
- Easy to run: two commands, ollama pull gemma3:4b and python3 app.py, then open localhost:8000. A phone on the same Wi‑Fi can use its camera at http://<laptop-ip>:8000.
- Cost: $0, since nothing runs in the cloud and there's no API key.
- Prize category: it fits Best Use of Gemma, one of the $200 featured categories.

Safety net: Gemma alone missed things. It didn't mark the shellfish dishes (scallops, prawn soup) as "avoid". So after the model answers, a keyword check against the friend's profile forces any dish mentioning an allergen to "avoid". An answer the app doesn't recognise falls back to "ask", never "safe". test_app.py covers this and passes.

The server is still running at localhost:8000. I left out hosting it on Render or DigitalOcean, because that means paying for a GPU or sending the menu to a hosted model, which undercuts the privacy story. If you want to go after another prize category too, I could add ElevenLabs to read the verdicts aloud.

You

1) Add elevenLabs
2) I still want to deploy online. If pushing to Render should be fine.

Agent

Neither gh nor the Render CLI is installed, and no API keys are set. Render's free tier has no GPU and 512 MB of RAM, so Gemma 4B can't run there. The online version will call the same Gemma family through Google's hosted Gemini API instead. Locally it stays on Ollama, chosen by whether GEMINI_API_KEY is set.

Now the "Read it aloud" button in the UI. It uses ElevenLabs when a key is set and falls back to the browser's built-in voice otherwise, so it still works offline.

Next: the Render blueprint, the test update, and a .gitignore.

I added both, and everything is committed in a local git repo. But I couldn't push or deploy it: the GitHub and Render command-line tools aren't installed here. I also couldn't test the hosted Gemma or ElevenLabs calls, because there are no API keys on this machine.

1. ElevenLabs. Results now have a 🔊 Read it to me button that speaks a short summary. It uses ElevenLabs when ELEVENLABS_API_KEY is set and the browser's built-in voice otherwise, so it also works offline.

2. Render. The free tier has no GPU, so when GEMINI_API_KEY is set, the app calls hosted Gemma through Google's Gemini API. render.yaml sets it up as a free web service with no build step. Anyone could find a public URL and use up your API credits, so I added a login (APP_PASSWORD).

You

The build is running from 6+ minutes

Agent

Most likely cause is my config: render.yaml points Render's health check at /profile, but with APP_PASSWORD set that page asks for a login, so the check gets a 401 on every try. Render keeps the deploy "in progress" until it times out. Also, the startup message was buffered, so Render's logs showed nothing, which makes it look frozen.

You

==> Deploying...
==> Running 'python app.py'
SafePlate on http://localhost:10000 using gemma-3-27b-it via Gemini API, voice: ElevenLabs, password: on
[REDACTED] - - [04/Oct/2026 00:19:47] code 501, message Unsupported method ('HEAD')
[REDACTED] - - [04/Oct/2026 00:19:47] "HEAD / HTTP/1.1" 501 -
[REDACTED] - - [04/Oct/2026 00:19:57] "GET /profile HTTP/1.1" 401 -

Agent

Good news: the app is running. Two things are still failing, both visible in your log:
1. GET /profile 401: Render is still health-checking /profile (it kept the old setting) and getting blocked by the login.
2. HEAD / 501: Render's startup probe sends a HEAD request, which the app didn't handle.

I'll add a /healthz page that doesn't need the login, point render.yaml at it so the stale /profile setting gets overwritten, and handle HEAD requests.

You

HTTPError: HTTP Error 404: Not Found, when I click Check this menu

Agent

A 404 there almost certainly comes from Google's API, not your app: the model name gemma-3-27b-it probably isn't served anymore. I'll check which Gemma models the Gemini API offers now.

Cause: Google removed Gemma 3 from the Gemini API, so gemma-3-27b-it no longer exists there. That's the 404. The hosted version now uses gemma-4-26b-a4b-it, which still reads menu photos. Gemma 4 can include its working-out in replies, and the code now skips that when reading the answer. Errors now show Google's actual message instead of a bare "HTTP Error 404".

You

Great. Can you publish an article to the dev.to site using the devrelay MCP, with Below Template

[DEV challenge submission template: What I Built / Demo / Code / How I Built It / Why Does Open Innovation Matter? / My Agent Session / Prize Categories]

Agent

I saved the post to your DEV account as an unpublished draft. I held it back because three facts in it are still placeholders (the friend's real name, the live link, and their reaction), and publishing those would put made-up details in a public post.

Competition: at least three other entries for this challenge are also allergy or meal apps. Yours stands out on four points: it reads restaurant menu photos, it suggests what to ask the server, it has a keyword check that overrides the model, and it reads the results aloud.

Prize Categories

  • Best Use of Gemma
  • Best Use of Render
  • Best Use of ElevenLabs
  • Best Use of Entire

SafePlate is a second pair of eyes, not a replacement for asking staff or carrying an EpiPen.

Top comments (0)