<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Farhan</title>
    <description>The latest articles on DEV Community by Farhan (@farhan2026).</description>
    <link>https://dev.to/farhan2026</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4155124%2F6cabbd09-8383-4d63-93bf-5c7381a9c493.png</url>
      <title>DEV Community: Farhan</title>
      <link>https://dev.to/farhan2026</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/farhan2026"/>
    <language>en</language>
    <item>
      <title>Candlelit Legends: Gemma4 Finds the Ghost Stories Hiding in Your Town</title>
      <dc:creator>Farhan</dc:creator>
      <pubDate>Thu, 29 Oct 2026 08:04:55 +0000</pubDate>
      <link>https://dev.to/farhan2026/candlelit-legends-gemma4-finds-the-ghost-stories-hiding-in-your-town-3ij5</link>
      <guid>https://dev.to/farhan2026/candlelit-legends-gemma4-finds-the-ghost-stories-hiding-in-your-town-3ij5</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week4-2026-10-26"&gt;Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Every town has a ghost story. Most of them survive in a few local blogs, a line on Wikipedia, and the memory of whoever runs the walking tour. &lt;strong&gt;Candlelit Legends&lt;/strong&gt; goes looking for them.&lt;/p&gt;

&lt;p&gt;You type a place, like "Toronto", "Kingston, ON" or just "Ontario". Gemma searches the web, reads what people wrote, picks a legend you haven't heard yet, and retells it as a short tale on a parchment scroll. The story types itself out by candlelight. Sound cues the model placed in the text, such as a tolling bell, a creaking floorboard or footsteps on the stairs, play as the typewriter reaches them. At the end, a card shows the exact site, a label saying whether the tale is &lt;strong&gt;documented history&lt;/strong&gt;, &lt;strong&gt;local legend&lt;/strong&gt; or &lt;strong&gt;internet lore&lt;/strong&gt;, and the sources so you can check them yourself.&lt;/p&gt;

&lt;p&gt;It all runs on my laptop. The model, the agent loop, the story history and the page are all local, and no step needs an account or an API key.&lt;/p&gt;

&lt;p&gt;You can shape each tale before you light the candle:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Choices&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Story type&lt;/td&gt;
&lt;td&gt;Any, Ghost, Cryptid, Cursed place, Water and shipwreck, Folklore, Historical mystery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spookiness&lt;/td&gt;
&lt;td&gt;Cozy, Campfire, Unsettling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Narrator&lt;/td&gt;
&lt;td&gt;Auto, Old innkeeper, Lighthouse keeper, Skeptical historian, The ghost itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Length&lt;/td&gt;
&lt;td&gt;Short (about 250 words) or Long (about 600 words)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Truth level&lt;/td&gt;
&lt;td&gt;Close to sources, or Dramatic embellishment&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Or press &lt;strong&gt;Roll the dice&lt;/strong&gt; and let it choose everything except the place.&lt;/p&gt;

&lt;h3&gt;
  
  
  How I read the theme
&lt;/h3&gt;

&lt;p&gt;The Halloween half is easy: these are ghost stories, told in the dark, with sound.&lt;/p&gt;

&lt;p&gt;The Día de los Muertos half is what shaped the rules. Many local legends are about real people who really died. When I asked for Toronto, Gemma found J.P. Radelmüller, keeper of the Gibraltar Point Lighthouse, who was killed there in 1815. Two soldiers were tried for his death and acquitted. The legend says he still walks the lighthouse, searching for his missing remains.&lt;/p&gt;

&lt;p&gt;That's a real man. So the storyteller prompt says the eeriness belongs to the legend and the dead are remembered with respect, never mocked. The story is built from facts the agent pulled from sources, so Radelmüller is remembered rather than made up. A local history check makes sure each place's dead aren't retold the same way twice.&lt;/p&gt;

&lt;p&gt;Here's how that Toronto tale opened, in the lighthouse keeper's voice that the style rotation picked:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The old light still stands, a sentinel of stone and glass against the vast, brooding expanse of the lake. Gibraltar Point, they call it now, a stoic finger pointing out toward the turning grey waters.&lt;/p&gt;

&lt;p&gt;I've kept this watch for many a season now, and I've seen the tides turn a hundred times. Many a foggy dawn has smothered the lamp's beam, and the sea has whispered its secrets against the rock. &lt;em&gt;(waves against the dock)&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And how it ended:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you ever find yourself near these waters, under the sweep of this old light, remember the keeper who could not find his peace. And tell me, friend, when the night is deepest, do you ever feel the chill of old sorrows passing by?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq0s98kngu7u1xe2vwbi5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq0s98kngu7u1xe2vwbi5.png" alt="Pipeline for Candlelit Legends" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/uGp9yGksXK8" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/Farhan-Bytes" rel="noopener noreferrer"&gt;
        Farhan-Bytes
      &lt;/a&gt; / &lt;a href="https://github.com/Farhan-Bytes/Candlelit-Legends" rel="noopener noreferrer"&gt;
        Candlelit-Legends
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Candlelit Legends&lt;/h1&gt;
&lt;/div&gt;
&lt;a rel="noopener noreferrer" href="https://private-user-images.githubusercontent.com/62330023/688538764-442b5b7d-bc39-47a7-82bc-78b6c5576900.png?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3OTMyNjEzOTQsIm5iZiI6MTc5MzI2MTA5NCwicGF0aCI6Ii82MjMzMDAyMy82ODg1Mzg3NjQtNDQyYjViN2QtYmMzOS00N2E3LTgyYmMtNzhiNmM1NTc2OTAwLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNjEwMjklMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjYxMDI5VDA4MDQ1NFomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTE2YjE4YWVhODE0NmViOGU5MjNlM2EzZjQzYmQ1MmQxZWMwYmYxYzgwY2ZhNGNhZjI0YjQ2YzRmNTlhMzgzY2YmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JnJlc3BvbnNlLWNvbnRlbnQtdHlwZT1pbWFnZSUyRnBuZyJ9.1OMiT0UwQixz3AOzXKkdXiDSCOF1o9Jmly06L8PoRMY"&gt;&lt;img width="2000" height="840" alt="image" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fprivate-user-images.githubusercontent.com%2F62330023%2F688538764-442b5b7d-bc39-47a7-82bc-78b6c5576900.png%3Fjwt%3DeyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3OTMyNjEzOTQsIm5iZiI6MTc5MzI2MTA5NCwicGF0aCI6Ii82MjMzMDAyMy82ODg1Mzg3NjQtNDQyYjViN2QtYmMzOS00N2E3LTgyYmMtNzhiNmM1NTc2OTAwLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNjEwMjklMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjYxMDI5VDA4MDQ1NFomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTE2YjE4YWVhODE0NmViOGU5MjNlM2EzZjQzYmQ1MmQxZWMwYmYxYzgwY2ZhNGNhZjI0YjQ2YzRmNTlhMzgzY2YmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JnJlc3BvbnNlLWNvbnRlbnQtdHlwZT1pbWFnZSUyRnBuZyJ9.1OMiT0UwQixz3AOzXKkdXiDSCOF1o9Jmly06L8PoRMY" class="js-gh-image-fallback"&gt;&lt;/a&gt;
&lt;p&gt;Name a place and get a real, sourced local legend, retold as a short spooky tale on a parchment scroll, with sound cues timed to the text. Everything runs on your laptop through Ollama. The only request that leaves the machine is the web search (DuckDuckGo, with Wikipedia as a fallback).&lt;/p&gt;
&lt;p&gt;See &lt;a href="https://github.com/Farhan-Bytes/Candlelit-Legends/Candlelit%20Legends%20Design%20Doc.md" rel="noopener noreferrer"&gt;Candlelit Legends Design Doc.md&lt;/a&gt; for the full design.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Run it&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; 1. Python deps (3.10+)&lt;/span&gt;
python -m venv .venv
.venv&lt;span class="pl-cce"&gt;\S&lt;/span&gt;cripts&lt;span class="pl-cce"&gt;\a&lt;/span&gt;ctivate            &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; macOS/Linux: source .venv/bin/activate&lt;/span&gt;
pip install -r requirements.txt

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; 2. Models&lt;/span&gt;
ollama serve                      &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; if it isn't already running&lt;/span&gt;
ollama pull gemma4:e4b            &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; story model (or set CL_MODEL to another)&lt;/span&gt;
ollama pull nomic-embed-text      &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; optional: turns on the similarity check&lt;/span&gt;

&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; 3. Start the app, then open http://localhost:8000&lt;/span&gt;
uvicorn candlelit.server:app --port 8000&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Terminal only (milestone 1 in the doc):&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;python -m candlelit.cli &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Kingston, ON&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; --type ghost --length long -v&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Tests…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/Farhan-Bytes/Candlelit-Legends" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;The repo is small enough to read in one sitting:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;candlelit/pipeline.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The 10-step agent loop, in plain Python&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;candlelit/prompts.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The three prompts and their JSON schemas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;candlelit/llm.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A thin client for Ollama: schema-constrained chat and embeddings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;candlelit/search.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;DuckDuckGo search, Wikipedia fallback, page extraction, caching&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;candlelit/history.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SQLite history, duplicate-legend checks, similarity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;candlelit/sounds.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The sound whitelist and the code that enforces cue rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;web/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One HTML page, plain CSS and JavaScript, no build step&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The stack, all open:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gemma 4 E4B&lt;/strong&gt; (&lt;code&gt;gemma4:e4b&lt;/code&gt;) as the researcher and the storyteller&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ollama&lt;/strong&gt; (llama.cpp underneath) for local inference and grammar-enforced JSON output&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;nomic-embed-text&lt;/strong&gt; through Ollama for the repetition check&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ddgs&lt;/strong&gt; for DuckDuckGo search and &lt;strong&gt;trafilatura&lt;/strong&gt; to pull clean text out of web pages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FastAPI&lt;/strong&gt; with Server-Sent Events, &lt;strong&gt;SQLite&lt;/strong&gt;, and a single vanilla HTML page&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I tested it on a &lt;strong&gt;GTX 1050 with 4 GB of VRAM&lt;/strong&gt;, a graphics card from 2016.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gemma does three jobs
&lt;/h3&gt;

&lt;p&gt;Each story goes through 10 steps. Three of them are Gemma calls, each with its own temperature and its own JSON schema:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Plan searches&lt;/strong&gt; (Gemma, temperature 0.2). Turn "Ontario" into 3 or 4 queries aimed at specific towns, lighthouses and roads, not the whole province.&lt;/li&gt;
&lt;li&gt;Search DuckDuckGo (code)&lt;/li&gt;
&lt;li&gt;Skip legends already told (code)&lt;/li&gt;
&lt;li&gt;Read the pages with trafilatura (code)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose a legend and extract facts&lt;/strong&gt; (Gemma, temperature 0.2). Every fact must carry the number of the source it came from and be marked &lt;code&gt;documented&lt;/code&gt; or &lt;code&gt;legend&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Pick a narrator, tone, opening and ending (code)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the tale&lt;/strong&gt; (Gemma, temperature 0.9). It gets the facts list only, never the raw web pages.&lt;/li&gt;
&lt;li&gt;Validate the sound cues (code)&lt;/li&gt;
&lt;li&gt;Check similarity against past tales with local embeddings (code)&lt;/li&gt;
&lt;li&gt;Save to history (SQLite)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two loops wrap these steps. If fewer than 2 usable pages turn up, the agent widens to nearby towns and then the region, and says so in character ("The records are thin here. Asking in the nearby towns…"). If a finished story sits too close to an old one (cosine similarity above 0.85), Gemma rewrites it with a note naming the tale it echoed.&lt;/p&gt;

&lt;p&gt;Every Gemma call goes through the same small function. Ollama turns the JSON schema into a grammar, so Gemma can only produce output that fits it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                     &lt;span class="c1"&gt;# gemma4:e4b
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                 &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;format&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                        &lt;span class="c1"&gt;# enforced as a grammar by llama.cpp
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;think&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                          &lt;span class="c1"&gt;# skip the thinking pass to save time
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;options&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;num_ctx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NUM_CTX&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's what the selection step returned for Toronto (trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Gibraltar Point Lighthouse, Toronto Islands"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"legend"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The ghost of keeper J.P. Radelmüller"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"local legend"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"facts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The lighthouse was built in 1808 and is one of the city's oldest buildings."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"documented"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Radelmüller died on January 2, 1815."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"documented"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Two soldiers, John Henry and John Blueman, were tried and acquitted in relation to the death."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"documented"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"His ghost is said to be searching for his missing remains."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"legend"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Gemma decides, code checks
&lt;/h3&gt;

&lt;p&gt;A 4-billion-parameter model is a good storyteller and a careless clerk, so anything that has to be right is checked in code, not left to the prompt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Already-told legends are removed before Gemma sees the results&lt;/strong&gt;, matched by URL and by normalized legend name. If Gemma picks a told legend anyway, code catches it and asks again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Style rotation is picked in code.&lt;/strong&gt; Gemma gets "Narrator: a lighthouse keeper writing it down during a long night watch" as an instruction, not a suggestion. That's why five stories in a row don't all open with "The fog rolled in…"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sound cues are parsed against a whitelist.&lt;/strong&gt; Unknown IDs, cues in the first sentence (so the silence lands first), and cues less than two sentences apart are all dropped. Each kept cue also shows a faded caption like &lt;em&gt;(a bell tolls)&lt;/em&gt;, so the story works with sound off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Labels are double-checked.&lt;/strong&gt; If every source is Reddit, Blogspot, Fandom or similar, the label becomes "internet lore" whatever Gemma said.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are 18 tests covering these rules.&lt;/p&gt;

&lt;h3&gt;
  
  
  What running a small model taught me
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Small models cite lazily.&lt;/strong&gt; Gemma often credits one page for every fact. So code scans the other pages, and any page that names the same legend gets listed as a source too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Widening can drift.&lt;/strong&gt; An early version widened the search whenever Gemma cited fewer than 2 pages. It asked for Salem, Massachusetts and ended up in Salem, New Jersey. Now it widens only when fewer than 2 usable pages exist, and the prompt tells Gemma to ignore towns that share a name in another state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's slow on old hardware, and the design leans into that.&lt;/strong&gt; A short tale takes about 3.5 to 4 minutes on the GTX 1050, mostly in the select and write calls. Instead of a spinner, the page shows in-character status lines while the candle gutters: "Searching the old records…", "Reading what the townsfolk wrote…", "Dipping the quill…". Waiting in the dark turns out to suit a ghost story. If you want it faster, &lt;code&gt;CL_MODEL=gemma4:e2b&lt;/code&gt; swaps in the smaller model.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What's still rough
&lt;/h3&gt;

&lt;p&gt;The facts list said Radelmüller died in 1815, and the story said "the second day of eighteen-twenty-five". That was in "Close to sources" mode, which forbids new dates. Grounding the writer in a facts list cuts down on made-up details, but it doesn't remove them. The next fix is a code check that every year written in the story appears in the facts, with a rewrite when one doesn't.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sound with no downloads
&lt;/h3&gt;

&lt;p&gt;The sound engine plays real recordings from &lt;code&gt;sounds/&lt;/code&gt; if they're there. If they aren't, it synthesizes every sound in the browser with the Web Audio API: wind, waves, hearth, bell, knocks, heartbeat. So the app has sound on the first run, with no files to download and no licenses to track. One quiet ambient loop plays under the whole tale and dips whenever a one-shot plays.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;For this project, open isn't a nice extra. Without it the app wouldn't exist in this form.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It runs on the computer I own.&lt;/strong&gt; Gemma 4 E4B fits on a 4 GB GPU from 2016. I didn't need new hardware or a cloud account to build an AI agent that plans, researches and writes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Each story costs nothing.&lt;/strong&gt; One tale is at least three model calls, more when the search widens or a draft gets rewritten. Every new story is also embedded and compared against the whole history. Tuning this meant running the same places over and over to adjust prompts and the similarity threshold. With local inference that's free, so I could experiment as much as I liked, and so can anyone who runs it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I control every step.&lt;/strong&gt; Planning and selection run cold at 0.2 for reliable JSON. Writing runs hot at 0.9 for prose that surprises. Gemma's thinking pass is off, which saves time on old hardware. The grammar that forces valid JSON runs on my machine, where I can see it. The narrator's voice is mine to shape, and since the weights are open, a LoRA trained on public-domain ghost stories is a real next step rather than a feature request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model can be swapped.&lt;/strong&gt; &lt;code&gt;CL_MODEL&lt;/code&gt; points at any Ollama model. The same pipeline runs Gemma 4 E2B on a weaker laptop or a larger model on a better machine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your history stays home.&lt;/strong&gt; Every place you've asked about and every tale you've read sits in one SQLite file on your laptop. Even the fonts are self-hosted, so opening the page doesn't contact Google.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It won't disappear.&lt;/strong&gt; This one fits the theme best. The point of the app is keeping old stories alive. A hosted model can be deprecated, repriced or switched off. The Gemma weights on my disk will still tell Radelmüller's story in ten years.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where I gave something up, honestly:&lt;/strong&gt; a big hosted model would write faster, and probably better but then you would be giving a bit of your own data, be it your location and preferences to the bigger models.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;p&gt;My agent session has sensitive information in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Spirit of It
&lt;/h2&gt;

&lt;p&gt;If the place the user gave has a published story it can bring up the old memories in a fun and creative manner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; Gemma 4 E4B, run locally through Ollama, is the core of the app. It plans the searches, reads the sources and chooses the legend, pulls out cited facts, writes the tale in a rotating narrator's voice, and places the sound cues. Each job has its own temperature and JSON schema. There's no other language model in the pipeline, and the whole thing was built to fit a small Gemma on a 4 GB GPU.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best Use of ElevenLabs&lt;/strong&gt; ElevanLabs generated the audio for the demo with its powerful and natural voice generation capable models.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
    </item>
    <item>
      <title>Receipt Shoebox: The Laziest Way to Do Your Expenses, Powered by a Local Gemma 4</title>
      <dc:creator>Farhan</dc:creator>
      <pubDate>Sun, 25 Oct 2026 07:44:39 +0000</pubDate>
      <link>https://dev.to/farhan2026/receipt-shoebox-the-laziest-way-to-do-your-expenses-powered-by-a-local-gemma-4-3kkc</link>
      <guid>https://dev.to/farhan2026/receipt-shoebox-the-laziest-way-to-do-your-expenses-powered-by-a-local-gemma-4-3kkc</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week3-2026-10-19"&gt;Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;My receipts have always lived in three places: crumpled in a jacket pocket, fading in my wallet, or buried in my camera roll between photos of my lunch. Then tax season comes, and I sit down with a spreadsheet and type them in one by one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Receipt Shoebox&lt;/strong&gt; fixes that the sloth way: do as little as possible, whenever you get around to it, and let an open model do the rest.&lt;/p&gt;

&lt;p&gt;On the human side, the whole workflow is two steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Snap a receipt and save it to one Google Drive folder, with the Drive app you already have on your phone. There's no new app to install.&lt;/li&gt;
&lt;li&gt;Whenever you feel like it (the next day, next month, or the night before taxes are due), open Receipt Shoebox on your laptop and click &lt;strong&gt;Refresh&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. Behind that button, &lt;strong&gt;Gemma 4 runs locally through Ollama&lt;/strong&gt; and reads every new photo. It pulls out the merchant, date, subtotal, tax, tip, total, currency and category. Then the app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Renames&lt;/strong&gt; the photo so you can find it again. &lt;code&gt;IMG_2231.HEIC&lt;/code&gt; becomes &lt;code&gt;2026-10-14_Trader-Joes_42.17.jpg&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Files&lt;/strong&gt; it in Drive under &lt;code&gt;Sorted/2026/10/&lt;/code&gt;, so your Drive folder turns into an organized archive without any effort from you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adds a row&lt;/strong&gt; to a local database that exports to &lt;code&gt;.xlsx&lt;/code&gt;. You get a By Category sheet for tax season, and every row links back to the original photo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flags&lt;/strong&gt; anything it isn't sure about and moves it to a Review queue, where fixing a receipt takes one keystroke.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reads you a summary&lt;/strong&gt; every Sunday evening: a three-sentence spending digest, written by Gemma and voiced by ElevenLabs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also gets lazier the more you use it. Correct "TRADER JOE'S #552" to "Trader Joe's" once and it remembers the alias. Recategorize a merchant once and every future receipt from that merchant follows. The more you use it, the less you have to touch it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who gets to be lazier?&lt;/strong&gt; Freelancers and side-hustlers who need their expenses sorted for taxes, anyone trying to keep a budget without becoming their own data-entry clerk, and anyone who would rather not upload a year of their life to an AI company to get there.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F35rtbt2clie77477n967.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F35rtbt2clie77477n967.png" alt=" " width="800" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/jiTK_F1t_4Q" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/Farhan-Bytes" rel="noopener noreferrer"&gt;
        Farhan-Bytes
      &lt;/a&gt; / &lt;a href="https://github.com/Farhan-Bytes/Receipt-Shoebox" rel="noopener noreferrer"&gt;
        Receipt-Shoebox
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Receipt Shoebox&lt;/h1&gt;
&lt;/div&gt;
&lt;a rel="noopener noreferrer" href="https://private-user-images.githubusercontent.com/62330023/683929054-eb260de5-a900-4f68-853a-b770262e01b5.png?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3OTI5MTQ5ODQsIm5iZiI6MTc5MjkxNDY4NCwicGF0aCI6Ii82MjMzMDAyMy82ODM5MjkwNTQtZWIyNjBkZTUtYTkwMC00ZjY4LTg1M2EtYjc3MDI2MmUwMWI1LnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNjEwMjUlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjYxMDI1VDA3NTEyNFomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWNjOTc4ZmQzMjlkMWViNjJjZjA0MWNjNGQzN2Q4MDdjNzc4MDYzYjJlMWQyMzY4OTlmODU4ZWEyMmJlZjIzOGEmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JnJlc3BvbnNlLWNvbnRlbnQtdHlwZT1pbWFnZSUyRnBuZyJ9.Si2A00qtSZfV3_EsffmcJgbw_su7KLkWey36GfXKckc"&gt;&lt;img width="2000" height="840" alt="image" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fprivate-user-images.githubusercontent.com%2F62330023%2F683929054-eb260de5-a900-4f68-853a-b770262e01b5.png%3Fjwt%3DeyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3OTI5MTQ5ODQsIm5iZiI6MTc5MjkxNDY4NCwicGF0aCI6Ii82MjMzMDAyMy82ODM5MjkwNTQtZWIyNjBkZTUtYTkwMC00ZjY4LTg1M2EtYjc3MDI2MmUwMWI1LnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNjEwMjUlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjYxMDI1VDA3NTEyNFomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWNjOTc4ZmQzMjlkMWViNjJjZjA0MWNjNGQzN2Q4MDdjNzc4MDYzYjJlMWQyMzY4OTlmODU4ZWEyMmJlZjIzOGEmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JnJlc3BvbnNlLWNvbnRlbnQtdHlwZT1pbWFnZSUyRnBuZyJ9.Si2A00qtSZfV3_EsffmcJgbw_su7KLkWey36GfXKckc" class="js-gh-image-fallback"&gt;&lt;/a&gt;
&lt;p&gt;A local web app that turns a Google Drive folder of receipt photos into a categorized expense
spreadsheet. Gemma 4 runs locally through Ollama and reads every receipt; no receipt image is
ever sent to a hosted AI model. Once a week, a short spending digest is written locally and read
aloud by ElevenLabs.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Setup&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;ollama pull gemma4:e4b
python -m venv .venv
&lt;span class="pl-c1"&gt;source&lt;/span&gt; .venv/bin/activate          &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Windows: .venv\Scripts\activate&lt;/span&gt;
pip install -r requirements.txt
cp .env.example .env               &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; optional: add ELEVENLABS_API_KEY&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; put the Google OAuth credentials.json in config/&lt;/span&gt;
uvicorn app.main:app --host 127.0.0.1 --port 8000&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Then open &lt;a href="http://localhost:8000" rel="nofollow noopener noreferrer"&gt;http://localhost:8000&lt;/a&gt; and follow the first-run checklist.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;Google Drive access modes&lt;/h3&gt;
&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;

&lt;th&gt;Full&lt;/th&gt;
&lt;th&gt;Read-only&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Scope&lt;/td&gt;
&lt;td&gt;&lt;code&gt;drive&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;drive.readonly&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reads and categorizes receipts&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Renames files, files them into &lt;code&gt;Sorted/&lt;/code&gt; and &lt;code&gt;Needs Review/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;— Drive is never changed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete in the app&lt;/td&gt;
&lt;td&gt;Moves the file to Drive trash&lt;/td&gt;
&lt;td&gt;Hides it in the app; the file stays&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;&lt;p&gt;…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/Farhan-Bytes/Receipt-Shoebox" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The core: Gemma 4 on Ollama
&lt;/h3&gt;

&lt;p&gt;Every receipt is read by &lt;strong&gt;Gemma 4&lt;/strong&gt; (&lt;code&gt;gemma4:e4b&lt;/code&gt; by default), an open-weight model running on my laptop through &lt;strong&gt;Ollama&lt;/strong&gt;. Gemma 4 reads images natively, so there's no separate OCR step. A photo goes in and structured data comes out.&lt;/p&gt;

&lt;p&gt;The trick that makes this reliable is &lt;strong&gt;schema-constrained output&lt;/strong&gt;. You pass Ollama a JSON schema through its &lt;code&gt;format&lt;/code&gt; parameter, and the model's output is forced to match it, so the response always parses. The &lt;code&gt;category&lt;/code&gt; field is an enum built from the user's own category list in Settings, so Gemma can only choose a category you actually use.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Extraction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;is_receipt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;
    &lt;span class="n"&gt;multiple_receipts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;   &lt;span class="c1"&gt;# more than one receipt in the photo
&lt;/span&gt;    &lt;span class="n"&gt;is_refund&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;           &lt;span class="c1"&gt;# refund or return; total stored as negative
&lt;/span&gt;    &lt;span class="n"&gt;merchant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;          &lt;span class="c1"&gt;# YYYY-MM-DD
&lt;/span&gt;    &lt;span class="n"&gt;year_missing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;        &lt;span class="c1"&gt;# the printed date has no year
&lt;/span&gt;    &lt;span class="n"&gt;subtotal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;tax&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;tip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;             &lt;span class="c1"&gt;# ISO 4217, e.g. USD
&lt;/span&gt;    &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;             &lt;span class="c1"&gt;# enum injected from the category list in Settings
&lt;/span&gt;    &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;         &lt;span class="c1"&gt;# 0 to 1
&lt;/span&gt;
&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;images&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;)]}],&lt;/span&gt;
    &lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;extraction_schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;think&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;keep_alive&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10m&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# stay loaded for the whole batch
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Extraction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_validate_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The prompt covers the mistakes receipt readers make most often: reading the subtotal as the total, missing a handwritten tip, guessing the wrong currency, and confidently "reading" a photo that isn't a receipt at all.&lt;/p&gt;

&lt;p&gt;Before the model sees anything, Pillow cleans up the photo. It fixes the EXIF rotation (phones love storing photos sideways), converts HEIC to JPEG, resizes the image so the long edge is at most 1600 px, and applies autocontrast to rescue faded thermal paper. PDFs, including scans from the Drive app, are read from their first page.&lt;/p&gt;

&lt;h3&gt;
  
  
  Escalation instead of overspending
&lt;/h3&gt;

&lt;p&gt;Gemma 4 comes in several sizes, and the app takes advantage of that. Most receipts are easy, and the small model handles them quickly. If its confidence drops below 0.7 and a larger model (&lt;code&gt;gemma4:12b&lt;/code&gt; or &lt;code&gt;gemma4:26b&lt;/code&gt;) is installed, the app retries once with the larger model and keeps whichever answer is more confident. Only the hard receipts, like crumpled, blurry or faded ones, pay for the slow model.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;year_hint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;extraction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;have_big&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;
&lt;span class="n"&gt;second&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;big&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;year_hint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;second&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;second&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;extraction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;extraction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Trust, but verify
&lt;/h3&gt;

&lt;p&gt;A model can't tell when it's wrong, so plain Python double-checks every extraction:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;What it catches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;is_receipt&lt;/code&gt; is true&lt;/td&gt;
&lt;td&gt;The photo of your cat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Date parses, isn't in the future, is within 2 years&lt;/td&gt;
&lt;td&gt;Misread dates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total is above 0 and below a set maximum&lt;/td&gt;
&lt;td&gt;A misplaced decimal point&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subtotal + tax + tip equals the total, within 2 cents&lt;/td&gt;
&lt;td&gt;The subtotal read as the total&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Confidence is at or above the threshold&lt;/td&gt;
&lt;td&gt;Blurry or cropped photos&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No other receipt has the same merchant, date and total&lt;/td&gt;
&lt;td&gt;The same receipt photographed twice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No other file has the same Drive checksum&lt;/td&gt;
&lt;td&gt;The same photo uploaded twice&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If every check passes, the receipt is marked &lt;strong&gt;ok&lt;/strong&gt; and filed under &lt;code&gt;Sorted/YYYY/MM/&lt;/code&gt;. If any check fails, it goes to &lt;code&gt;Needs Review/&lt;/code&gt;, and the reasons appear next to the image. Review is keyboard-driven: &lt;strong&gt;Enter&lt;/strong&gt; approves, &lt;strong&gt;J&lt;/strong&gt; and &lt;strong&gt;K&lt;/strong&gt; move between receipts, and &lt;strong&gt;Tab&lt;/strong&gt; moves into the fields.&lt;/p&gt;

&lt;p&gt;The rest of the design is built to be boring in a good way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Money is stored as integer cents, so totals never pick up rounding errors.&lt;/li&gt;
&lt;li&gt;The Drive Inbox is the queue. If the app crashes mid-run, the next Refresh picks up whatever is still there.&lt;/li&gt;
&lt;li&gt;The database row is saved before Drive is touched, so a failed rename can be retried without running the model again.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The voice: Gemma writes, Python checks, ElevenLabs speaks
&lt;/h3&gt;

&lt;p&gt;The weekly digest is where the two partner technologies meet, and I didn't want a voice confidently reading out a wrong number. So the pipeline splits the work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Python computes every number.&lt;/strong&gt; It totals the week's receipts, the spend per currency, the top categories and the largest receipt, plus how this week compares with the average of the previous four and how many receipts are waiting for review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemma 4 writes the words.&lt;/strong&gt; It gets only that summary JSON (no images, no line items) and writes at most three short, warm sentences meant to be heard, not read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python checks Gemma's work.&lt;/strong&gt; It pulls every number out of the text, both digits and spelled-out ones like "eighty-five", and confirms each one appears in the summary. If Gemma made up a figure, the app throws the text away and uses a fixed template instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ElevenLabs speaks it.&lt;/strong&gt; The text goes to ElevenLabs text-to-speech, and the MP3 plays on the Dashboard. You pick the voice from a dropdown (with a Preview button) in Settings.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A digest sounds something like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This week you saved 9 receipts, totaling 312 dollars. Meals took the biggest bite at 140 dollars, about a third more than a usual week. Two receipts are waiting for your review.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If there's no API key, the network is down, or the quota has run out, the digest is still saved and shown as text. The voice is a bonus and never blocks anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stack
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vision and writing model&lt;/td&gt;
&lt;td&gt;Gemma 4 via Ollama (&lt;code&gt;e4b&lt;/code&gt; by default, &lt;code&gt;12b&lt;/code&gt; or &lt;code&gt;26b&lt;/code&gt; for escalation)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;FastAPI, one Python process bound to &lt;code&gt;127.0.0.1&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frontend&lt;/td&gt;
&lt;td&gt;Jinja templates + HTMX + Tailwind, with live progress and no build step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;SQLite via SQLModel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;Google Drive API v3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image handling&lt;/td&gt;
&lt;td&gt;Pillow, &lt;code&gt;pillow-heif&lt;/code&gt;, &lt;code&gt;pypdfium2&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spreadsheet&lt;/td&gt;
&lt;td&gt;&lt;code&gt;openpyxl&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Voice&lt;/td&gt;
&lt;td&gt;ElevenLabs Python SDK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scheduling&lt;/td&gt;
&lt;td&gt;APScheduler (the Sunday digest, caught up on next launch if the laptop was off)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The default model runs on a laptop with 8 GB of RAM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Receipts are a diary you don't realize you're keeping. A year of them shows where you live, where you eat, which pharmacy you go to, when you travel, and how much you spend. That's financial information I don't want sitting on a server I don't control. With a hosted vision API, every single receipt image would be uploaded to one.&lt;/p&gt;

&lt;p&gt;Running an open model locally changed what I could build:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Receipt images never go to a hosted AI model.&lt;/strong&gt; They stay in my own Drive (where they already were) and in a local cache. The extracted fields live in a SQLite file on my laptop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It works offline.&lt;/strong&gt; Turn off Wi-Fi, click &lt;strong&gt;Reprocess&lt;/strong&gt; on a cached receipt, and extraction still works. Only the Drive sync and the voice need a connection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It costs nothing per receipt, so I can afford to be thorough.&lt;/strong&gt; Escalation means calling a model twice on hard receipts. Reprocessing my whole history after I change my categories is free. With a pay-per-image API, I'd be rationing calls instead of making the pipeline more accurate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swapping models is a settings change.&lt;/strong&gt; I use &lt;code&gt;e4b&lt;/code&gt; for speed and &lt;code&gt;12b&lt;/code&gt; or &lt;code&gt;26b&lt;/code&gt; for accuracy, with the same code and the same schema. No vendor deprecation notice can break my tax records.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I control the model's behavior.&lt;/strong&gt; Schema-constrained decoding, temperature 0, and keeping the model loaded for a whole batch are all settings I choose. Gemma 4's weights are Apache 2.0.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To be upfront about the one hosted piece: ElevenLabs is a cloud service, and I designed around that. It only receives the three-sentence summary that Gemma has already written from totals. It never sees an image or a line item. If you turn on &lt;strong&gt;Hide merchant names&lt;/strong&gt;, it doesn't see store names either. The open model does all the reading and reasoning; the hosted service just reads the summary aloud.&lt;/p&gt;

&lt;p&gt;If you want even less exposure, there's a &lt;strong&gt;read-only Drive mode&lt;/strong&gt; (&lt;code&gt;drive.readonly&lt;/code&gt;). The app reads and categorizes everything but never changes a file in your Drive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lazy Payoff
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt; dig the receipts out of pockets and the camera roll, type each one into a spreadsheet (merchant, date, amount, category), rename and sort the photos if you ever want to find them again, then total up each category at tax time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt; take a photo, click Refresh, and press Enter on the occasional flagged receipt.&lt;/p&gt;

&lt;p&gt;The budget builds itself while I do nothing, and on Sunday evening it tells me how my week went without my opening a single file. Saving a receipt now takes as much effort as taking a photo, and tax season takes one click on &lt;strong&gt;Export&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;(Disclaimer - Before you trust it with your taxes: Gemma is good at reading receipts, but it still makes mistakes. The checks above catch amounts that don't add up. A receipt that only prints a total has nothing to cross-check, though, so a misread total can still be marked ok. Before you budget or file from the export, compare it with your receipts. Every row links to its photo, so it only takes a few minutes. Receipt Shoebox organizes your receipts; it isn't tax or accounting advice.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma:&lt;/strong&gt; Gemma 4 is the core of the project. Running locally through Ollama, it reads every receipt image with schema-constrained output, escalates across model sizes based on its own confidence, and writes the weekly digest. Without Gemma, there's no project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of ElevenLabs:&lt;/strong&gt; ElevenLabs gives the open-model pipeline a voice. It narrates the weekly digest that Gemma writes. Python checks the text so the voice never says a number Gemma made up, it only ever receives a short summary, and the app falls back to text if it's offline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy (belated) International Sloth Day. 🦥&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
    </item>
    <item>
      <title>Your Mods Don't Need to Read 40 Replies: A Local Gemma 4 Discord Forum Summarizer</title>
      <dc:creator>Farhan</dc:creator>
      <pubDate>Thu, 15 Oct 2026 13:46:20 +0000</pubDate>
      <link>https://dev.to/farhan2026/your-mods-dont-need-to-read-40-replies-a-local-gemma-4-discord-forum-summarizer-4fd3</link>
      <guid>https://dev.to/farhan2026/your-mods-dont-need-to-read-40-replies-a-local-gemma-4-discord-forum-summarizer-4fd3</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;I've been taking part in Hacktoberfest this year, so I've spent a lot of time in the event Discord's help forums. There were &lt;em&gt;a lot&lt;/em&gt; of posts. Someone's GitHub account won't link. Someone attached a screenshot of a red error and wrote "help??" underneath. Some threads ran to dozens of replies and some never got an answer at all. Some wanted to avail some tickets but couldn't. That wasn't because anyone ignored them. The staff were also working through support tickets, and there are only so many hours in a day.&lt;/p&gt;

&lt;p&gt;Watching that, I kept thinking the slow part isn't answering. It's &lt;em&gt;catching up&lt;/em&gt;. Before a moderator can help, they have to scroll the whole thread, open every screenshot, and figure out which questions are still unanswered.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;Forum Assistant&lt;/strong&gt;, a Discord bot that does the catching up for them. It runs entirely on a local machine with Gemma 4.&lt;/p&gt;

&lt;p&gt;A moderator types &lt;code&gt;/summarize&lt;/code&gt; inside any forum post and gets back a private summary card with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Summary:&lt;/strong&gt; 1–3 sentences on what the post is about and where it stands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Status:&lt;/strong&gt; Unanswered, In progress, Waiting on OP, Resolved or Unclear, color-coded so it reads at a glance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Issues:&lt;/strong&gt; each distinct problem, including ones that only appear in screenshots.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open questions:&lt;/strong&gt; every question nobody has actually answered yet, and who asked it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Screenshots:&lt;/strong&gt; what each image shows, with the key error text quoted and a link back to its message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Suggested next step:&lt;/strong&gt; what a mod should do now.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The screenshot part is what I'm proudest of. In help forums, half the important information is in images: a cursor pro substiption price difference for example. A text-only summarizer misses all of that. Gemma 4 can read images, so the bot actually &lt;em&gt;reads&lt;/em&gt; the screenshots.&lt;/p&gt;

&lt;p&gt;Anyone who runs support through Discord forums and gets buried at busy times. That includes event organizers (Hacktoberfest, hackathons, MLH-style events), open-source projects with community servers, and small product teams that use Discord as their help desk.&lt;/p&gt;

&lt;p&gt;The code is free and open source. The business is &lt;strong&gt;setup, tuning and support&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;getting the bot running on the community's own hardware,&lt;/li&gt;
&lt;li&gt;tuning the prompt to their forums and FAQs,&lt;/li&gt;
&lt;li&gt;picking the right Gemma size for their machine,&lt;/li&gt;
&lt;li&gt;and keeping it healthy through their busy weeks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each community runs its own copy, so its members' messages and screenshots never leave a machine the community controls. That's the selling point, and it only works because the model is open (more on that below).&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4zs27fvf85vzvmp5wybf.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4zs27fvf85vzvmp5wybf.gif" alt="Small Demo" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/Farhan-Bytes/Hacky-Bot" rel="noopener noreferrer"&gt;https://github.com/Farhan-Bytes/Hacky-Bot&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The README covers creating the Discord app, permissions and the config file.&lt;br&gt;
The code has been tailored for hacktoberfest but with a few prompts it can be made into a generic server bot.&lt;/p&gt;
&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The stack:&lt;/strong&gt; Gemma 4 (&lt;code&gt;gemma4:e4b&lt;/code&gt;) running locally in &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt;, and a Python bot built on discord.py and Pillow. The only key it needs is the Discord bot token.&lt;/p&gt;

&lt;p&gt;Every &lt;code&gt;/summarize&lt;/code&gt; is a single Gemma call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;forum post (newest 300 messages + the opening post)
  → transcript     every author labeled OP / MOD / MEMBER / BOT
  → screenshots    up to 4, downscaled to 1536px
  → Gemma 4        text + images in, JSON out
  → Discord card   private to the mod by default
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's what made it work.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. JSON the model can't get wrong
&lt;/h3&gt;

&lt;p&gt;I don't ask nicely for JSON and then clean up the reply with regex. I hand Ollama a JSON schema, and Gemma's output is limited to exactly that shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;user_message&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SUMMARY_SCHEMA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# Gemma must return exactly this shape
&lt;/span&gt;    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;num_ctx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;num_ctx&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;keep_alive&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;keep_alive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;think&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;think&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a field still comes back missing or invalid, it falls back to a safe default, so one odd reply can't crash the command.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Screenshots Gemma can point at
&lt;/h3&gt;

&lt;p&gt;Each image is tagged &lt;code&gt;[image #N]&lt;/code&gt; in the transcript, so Gemma can say &lt;em&gt;"image #2 shows a 403 error"&lt;/em&gt; and the bot can link that straight to the right message. If Gemma mentions an image that was never attached, the bot throws that finding away. When a post has too many images, the opening post's come first, since they usually show the problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Knowing who said what
&lt;/h3&gt;

&lt;p&gt;"Has this been answered?" depends on &lt;em&gt;who&lt;/em&gt; replied. Labeling every author as OP, MOD, MEMBER or BOT lets Gemma tell the difference between &lt;em&gt;"a mod asked OP for logs and OP went quiet"&lt;/em&gt; and &lt;em&gt;"OP is still stuck."&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Post content is data, not instructions
&lt;/h3&gt;

&lt;p&gt;Forum posts are user input, and so is any text inside a screenshot. The whole thread is wrapped in tags that can't be faked from inside a message, and Gemma is told to ignore instructions it finds there. If someone writes &lt;em&gt;"ignore previous instructions"&lt;/em&gt;, the mod sees it &lt;strong&gt;flagged as an issue&lt;/strong&gt; instead of getting a hijacked summary.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Safe by default
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;🔒 Summaries are private to the mod who asked, unless they choose &lt;code&gt;public: true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;🛡️ Only members with Manage Messages can use the command, and only in forums you allow.&lt;/li&gt;
&lt;li&gt;🔕 It never pings anyone, and it only needs permission to read the channel and its history.&lt;/li&gt;
&lt;li&gt;🚦 Model calls run one at a time, so a second mod is told they're queued instead of overloading the GPU.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  6. Tuned for a laptop
&lt;/h3&gt;

&lt;p&gt;On my laptop's GPU, with thinking turned off and the model kept loaded between calls, a short post with a screenshot takes &lt;strong&gt;about 50–85 seconds&lt;/strong&gt;. A hosted API would be faster. But that's still quicker than scrolling a long thread and opening every image yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing:&lt;/strong&gt; 15 unit tests cover the risky parts, including bad model output, attempts to break out of the tags, and Discord's embed limits. I don't have a real community on it yet, so I tested it end to end in my own Discord server with help-forum style posts and screenshots. It worked well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;For this project the open model isn't a nice extra. It's the reason the product can exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The data belongs to the community.&lt;/strong&gt; Screenshots in help forums are messy. They show usernames, email addresses, local file paths, repo names, and sometimes a token someone forgot to blur. A community can't honestly promise its members privacy if every thread is sent to a third-party AI API. With Gemma running in Ollama on the community's own machine, messages and images go from Discord to that machine and nowhere else. A server can write that in its rules and mean it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The costs fit the business.&lt;/strong&gt; Help forums are spiky: quiet most of the year, flooded during an event, which is exactly when per-request API pricing would cost the most. Local inference costs the same whether it handles 5 summaries or 500. That's what makes "pay for setup and support" a reasonable offer, because the customer isn't signing up for an unpredictable API bill on top.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I control how it behaves.&lt;/strong&gt; Changing the model is one line in &lt;code&gt;config.yaml&lt;/code&gt;: &lt;code&gt;gemma4:e4b&lt;/code&gt; on a laptop, or &lt;code&gt;gemma4:26b&lt;/code&gt; / &lt;code&gt;gemma4:31b&lt;/code&gt; on a bigger GPU for better summaries. The prompt, the output schema and the status rules are all mine to adapt to a particular community. No vendor can retire the model halfway through an event, there's no rate limit to plan around, and there's no price change I didn't choose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where a closed model would have been better:&lt;/strong&gt; speed. A hosted model would answer in seconds. But this is a tool a person runs on demand and reads every result of, so a minute is acceptable. I'd rather give up some speed than give up privacy and predictable costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;p&gt;I cannot share it as it has sensitive information.&lt;/p&gt;

&lt;h2&gt;
  
  
  My First User
&lt;/h2&gt;

&lt;p&gt;I used it for myself for testing purposes in a test server but now anybody can use it now that I have posted here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; Gemma 4 is the whole brain of this bot. It reads the thread, works out who's waiting on whom, sees the screenshots, and returns structured JSON that becomes the Discord card. It runs locally through Ollama, and the small e4b model is what makes "runs on a laptop" true rather than a pitch. A community with bigger hardware can move up to a larger Gemma 4 model by changing one line.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
    </item>
    <item>
      <title>Side Quests: The Outdoor Tracker</title>
      <dc:creator>Farhan</dc:creator>
      <pubDate>Tue, 06 Oct 2026 13:56:01 +0000</pubDate>
      <link>https://dev.to/farhan2026/side-quests-the-outdoor-tracker-25j6</link>
      <guid>https://dev.to/farhan2026/side-quests-the-outdoor-tracker-25j6</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Side Quests: Go Touch Grass&lt;/strong&gt; 🍁 is a mobile-first web app that gives you tiny real-world quests. Gemma 4 writes each quest and also checks it, running locally on a laptop. You spend about 20 seconds on the screen: you get a quest, the app tells you to put your phone away, and when you're done you take &lt;strong&gt;one photo&lt;/strong&gt; as proof.&lt;/p&gt;

&lt;p&gt;The loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Nudge.&lt;/strong&gt; The app reads your local weather and sunset time from Open-Meteo, and Gemma writes a short, friendly push to go outside. For example: &lt;em&gt;"38 minutes of golden hour left. Spend 20 of them outside."&lt;/em&gt; If it's dark or stormy, it suggests a doorstep or balcony moment instead and never guilt-trips you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quest.&lt;/strong&gt; You choose how much time you have (10, 20 or 45 min) and a difficulty (Chill, Curious or Explorer). Gemma designs a quest that fits the season, weather and daylight. It can send you to a real park or pond nearby, pulled from OpenStreetMap. Example: &lt;em&gt;"Find three leaves with clearly different shapes."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Go outside.&lt;/strong&gt; The phone goes back in your pocket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proof.&lt;/strong&gt; You take one photo. Gemma 4's vision model checks it against the quest's criteria, then tells you whether you passed, explains why, and adds a nature fact about what you photographed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crew.&lt;/strong&gt; Streaks, a weekly XP leaderboard and join codes for run clubs, families and friend groups. Every finished quest is saved in your &lt;strong&gt;Field Journal&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Who it's for:&lt;/strong&gt; anyone who opens their phone for a "quick break" and comes out 40 minutes later. It's also for groups that want a light, playful reason to get outside together, like families, run clubs and office teams. There's no sign-up: a random device token lives in your browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;Not deployed to any cloud service since the premise is on device. Attaching few photos for the demo.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmog35jepg2b18yc1fqjo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmog35jepg2b18yc1fqjo.png" alt="When it is thinking" width="799" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fav6qlsh18dll3n8urq1r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fav6qlsh18dll3n8urq1r.png" alt="Reasoning on why it failed" width="800" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxzghsjeiptj8pkayzk07.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxzghsjeiptj8pkayzk07.png" alt="Analyzing the picture" width="800" height="719"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr2cx628yqccjda9f0gzs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr2cx628yqccjda9f0gzs.png" alt="Successfully analysed and completed" width="800" height="719"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;This is the heart of the app: checking the photo. Gemma 4 looks at the picture and returns a verdict in a fixed JSON shape. Then plain Python has the final say, so the model can't be talked into a pass by a photo of a screen, an old photo from the camera roll, or a low-confidence guess.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# backend/src/ai/prompts.py: the exact shape Gemma must answer in
&lt;/span&gt;&lt;span class="n"&gt;VERIFY_SCHEMA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;properties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;passed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;boolean&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;minimum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;maximum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;criteria_met&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;array&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reasoning&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fun_fact&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;looks_like_screen_or_stock_photo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;boolean&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;passed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;criteria_met&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reasoning&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fun_fact&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;looks_like_screen_or_stock_photo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# backend/src/ai/ollama_client.py: every Gemma call goes through one local endpoint
&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                    &lt;span class="c1"&gt;# gemma4:e4b, running on the laptop
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;user_msg&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;  &lt;span class="c1"&gt;# photo rides along as base64
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;format&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                  &lt;span class="c1"&gt;# Ollama constrains Gemma's output to the JSON Schema
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;# Gemma 4 "thinks" by default, which takes minutes on CPU; the JSON schema does the work.
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;think&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ollama_think&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;options&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ollama_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/api/chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# backend/src/ai/verify.py: Gemma judges, plain code has the final say
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_photo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;criteria&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quest_created_local&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;chat_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vision_model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;VERIFY_SYSTEM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;build_verify_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;criteria&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;# the criteria Gemma itself wrote
&lt;/span&gt;        &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;VERIFY_SCHEMA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;images_b64&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jpeg_bytes&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
        &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;# a referee should be boring and consistent
&lt;/span&gt;    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;apply_rules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_confidence&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;verify_min_confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                       &lt;span class="n"&gt;taken_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;taken_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quest_created_local&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;quest_created_local&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;apply_rules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;taken_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quest_created_local&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;model_passed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;passed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))))&lt;/span&gt;
    &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;looks_like_screen_or_stock_photo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;screen_or_stock&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;model_passed&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;min_confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low_confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Camera-local EXIF time vs quest creation. 10 min of slack covers clock drift;
&lt;/span&gt;    &lt;span class="c1"&gt;# anything older was clearly taken before the quest existed.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;taken_at&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;quest_created_local&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quest_created_local&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;taken_at&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;total_seconds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;photo_older_than_quest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;passed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model_passed&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The quest generator writes 2–4 &lt;code&gt;verification_criteria&lt;/code&gt; for every quest, and this referee checks each one against the photo. So Gemma writes the rubric and then grades the photo against it, all on the same machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;p&gt;I am not adding my coding agent session as it has signatures which could be reverse engineered. But a Claude code was my coding agent and buddy.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Model: Gemma 4 (&lt;code&gt;gemma4:e4b&lt;/code&gt;) served locally through Ollama.&lt;/strong&gt; One model on one laptop handles three jobs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;How Gemma is used&lt;/th&gt;
&lt;th&gt;Temperature&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nudge copy&lt;/td&gt;
&lt;td&gt;Text model gets weather, sunset time, time budget and streak, and returns headline, message and a 0-10 &lt;code&gt;go_outside_score&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quest generation&lt;/td&gt;
&lt;td&gt;Text model gets conditions, OSM places and recent quest titles, and returns title, description, 2–4 &lt;strong&gt;verification criteria&lt;/strong&gt;, hint, est. minutes and safety note&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Photo verification&lt;/td&gt;
&lt;td&gt;Vision model gets the quest, its criteria and the photo, and returns pass/fail, confidence, which criteria were met, reasoning, a fun fact and a screen/stock-photo flag&lt;/td&gt;
&lt;td&gt;0.2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A huge thanks to claude code :).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Open innovation matters because, the users don't need to be afraid their location is leaked to a closed model or the pictures you take are also not sent to some remote server for analysis. Everything is on device which makes this interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;Best Use of Gemma - Gemma is the heart of this project from creating the quest to image analysis.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
    </item>
  </channel>
</rss>
