<?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: Amrit Dash</title>
    <description>The latest articles on DEV Community by Amrit Dash (@amrit-dash).</description>
    <link>https://dev.to/amrit-dash</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%2F528594%2F06a84c6f-5c03-413c-a154-5c09105a7778.PNG</url>
      <title>DEV Community: Amrit Dash</title>
      <link>https://dev.to/amrit-dash</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/amrit-dash"/>
    <language>en</language>
    <item>
      <title>Lockfile Ofrenda: candles for the packages that stopped speaking</title>
      <dc:creator>Amrit Dash</dc:creator>
      <pubDate>Thu, 29 Oct 2026 12:14:57 +0000</pubDate>
      <link>https://dev.to/amrit-dash/lockfile-ofrenda-candles-for-the-packages-that-stopped-speaking-12pi</link>
      <guid>https://dev.to/amrit-dash/lockfile-ofrenda-candles-for-the-packages-that-stopped-speaking-12pi</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;Lockfile Ofrenda is a Día de los Muertos altar for the packages that are still in &lt;em&gt;your&lt;/em&gt; &lt;code&gt;package-lock.json&lt;/code&gt; after the registry has gone quiet.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;npm outdated&lt;/code&gt; tells you that you froze a version. It does not tell you that the latest tag on the registry has not moved since 2015, that the maintainer wrote a deprecation note you never read, or that the name 404s now. Those packages are not ghouls in a haunted terminal. They are offerings that never left the table: jade, gulp-util, request, colors. Nested &lt;code&gt;node_modules&lt;/code&gt; count. Direct dependencies count. The lockfile is the ofrenda because that is what you actually ship.&lt;/p&gt;

&lt;p&gt;The app parses npm lockfile v2/v3 (and the older nested &lt;code&gt;dependencies&lt;/code&gt; tree), asks &lt;code&gt;registry.npmjs.org&lt;/code&gt; for last publish, deprecation text, and maintainers, then lights a marigold candle for anything unpublished, deprecated, silent for 730 days, or listed with no maintainers. Click a candle and a local &lt;strong&gt;Gemma&lt;/strong&gt; model (via Ollama) may write a remembrance. Code then checks the text against the record. Invented years, invented CVEs, invented replacements are dropped. If Gemma is offline or fails that check, you get a clerk's copy of the registry fields instead, labeled as such.&lt;/p&gt;

&lt;p&gt;I built this instead of a possessed shell because the ghosts in my machine were already there, sitting in lockfiles I had not opened in years. The theme asked for something that honors the past. An ofrenda is not a jump scare. It is a list of names and a light.&lt;/p&gt;

&lt;p&gt;This is a new project for this window. MIT. No login.&lt;/p&gt;

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

&lt;p&gt;There is no login and nothing to host a session cookie in. Run it locally, drop a lockfile or use the demo, click a candle.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/amrit-contour/lockfile-ofrenda
&lt;span class="nb"&gt;cd &lt;/span&gt;lockfile-ofrenda
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;span class="c"&gt;# optional, for spoken remembrances instead of the clerk copy:&lt;/span&gt;
ollama serve &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ollama pull gemma3:4b
npm start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Open &lt;a href="http://localhost:8788" rel="noopener noreferrer"&gt;http://localhost:8788&lt;/a&gt;. &lt;strong&gt;Use the demo lockfile&lt;/strong&gt; is the fastest path for judges. The sample is seven packages: six quiet, &lt;code&gt;debug&lt;/code&gt; still alive. Filter Quiet / All / Direct. Click &lt;strong&gt;jade&lt;/strong&gt;. Snuff a candle if you do not want it spoken of again this session.&lt;/p&gt;

&lt;p&gt;If Ollama is not running, the slip still fills. That is not a failed demo. The clerk copy is the product working without a hosted model.&lt;/p&gt;

&lt;p&gt;Empty altar:&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%2F3l76dfaie8s9kvdk0lty.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%2F3l76dfaie8s9kvdk0lty.png" alt="Empty Lockfile Ofrenda, before a lockfile is dropped" width="800" height="941"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Quiet packages from the demo lockfile:&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%2Fgb4zh08ic34xwq288t1a.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%2Fgb4zh08ic34xwq288t1a.png" alt="Altar with quiet packages: jade, istanbul, gulp-util, colors, request" width="800" height="941"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;jade's slip, copied from the registry when Gemma was silent:&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%2Fnk9dvjkuoyn7to560lxa.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%2Fnk9dvjkuoyn7to560lxa.png" alt="Remembrance slip for jade 1.11.0, last publish 2015, deprecated in favor of pug" width="800" height="704"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A public Render URL is not part of this entry. &lt;code&gt;render.yaml&lt;/code&gt; is in the repo if you want a host. This post does not claim Best Use of Render.&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/amrit-contour" rel="noopener noreferrer"&gt;
        amrit-contour
      &lt;/a&gt; / &lt;a href="https://github.com/amrit-contour/lockfile-ofrenda" rel="noopener noreferrer"&gt;
        lockfile-ofrenda
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Día de los Muertos altar for quiet packages in your npm lockfile
    &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;Lockfile Ofrenda&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;A Día de los Muertos altar for the quiet packages in &lt;strong&gt;your&lt;/strong&gt; npm lockfile.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/amrit-contour/lockfile-ofrenda" rel="noopener noreferrer"&gt;github.com/amrit-contour/lockfile-ofrenda&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;npm outdated&lt;/code&gt; tells you that you froze a version. It does not tell you that the registry has not heard from that package in years, or that the maintainer marked it deprecated and you still pull it in transitively. This app reads &lt;code&gt;package-lock.json&lt;/code&gt;, asks the public npm registry for last publish / deprecated / maintainers, and lights a candle for anything unpublished, deprecated, silent for two years, or listed with no maintainers.&lt;/p&gt;

&lt;p&gt;A local &lt;strong&gt;Gemma&lt;/strong&gt; model (via Ollama) may write a remembrance. Code then checks the text against the record. Invented years and wanderings are dropped. If Gemma is offline or fails the check, you get a clerk’s copy of the registry fields instead.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why open weights&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;The lockfile is a map of what you ship. Sending it to a hosted…&lt;/p&gt;&lt;/div&gt;


&lt;/div&gt;
&lt;br&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/amrit-contour/lockfile-ofrenda" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;br&gt;
&lt;/div&gt;
&lt;br&gt;


&lt;p&gt;Repo: &lt;a href="https://github.com/amrit-contour/lockfile-ofrenda" rel="noopener noreferrer"&gt;github.com/amrit-contour/lockfile-ofrenda&lt;/a&gt; (public, MIT). My DEV GitHub handle is &lt;code&gt;the-AoG-guy&lt;/code&gt;; this repo lives on the &lt;code&gt;amrit-contour&lt;/code&gt; account I ship from.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
  lock[package-lock.json] --&amp;gt; parse[parseLockfile]
  parse --&amp;gt; registry[registry.npmjs.org]
  registry --&amp;gt; score[score: unpublished / deprecated / silent 730d / no maintainers]
  score --&amp;gt; altar[altar UI]
  altar --&amp;gt; gemma[Gemma via Ollama]
  gemma --&amp;gt; ground[groundEulogy]
  ground --&amp;gt;|facts only| slip[remembrance slip]
  ground --&amp;gt;|wandered or offline| clerk[clerk copy of the record]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Quiet is scored from the &lt;strong&gt;latest tag's publish time&lt;/strong&gt;, not &lt;code&gt;time.modified&lt;/code&gt;. &lt;code&gt;ms&lt;/code&gt; in the demo is quiet because the latest publish is 2020-12-08 even though the record has been touched later. That is the point: a bump of metadata is not a release.&lt;/p&gt;

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

&lt;p&gt;Open-weight Gemma is the remembrance, not the scanner. The scanner is TypeScript: a lockfile walk, a registry fetch, a 730-day threshold, a grounding pass. Gemma is allowed to write 90–140 words from a JSON fact bag. Temperature is 0.2. The system prompt is a clerk of a death register, not a poet with a search engine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;You may only use the JSON facts. Do not invent successors, CVEs, people, or dates.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;groundEulogy&lt;/code&gt; tokenizes the reply against the record. Years that are not in the facts are dropped. If too much of the text is invention, the whole remembrance is refused and the clerk copy is shown. Source is labeled &lt;code&gt;gemma&lt;/code&gt; or &lt;code&gt;record&lt;/code&gt; so the altar never pretends a model spoke when it did not.&lt;/p&gt;

&lt;p&gt;That is the same instinct as &lt;a href="https://dev.to/farhans123456/penmunshi-an-offline-pentest-report-copilot-where-gemma-writes-and-code-checks-every-fact-2jpa"&gt;PenMunshi&lt;/a&gt;: Gemma writes, code checks every fact. A lockfile is a map of what you ship. Sending it to a hosted chat API is a worse deal than reading &lt;code&gt;registry.npmjs.org&lt;/code&gt; yourself.&lt;/p&gt;

&lt;p&gt;I did not scan &lt;code&gt;package.json&lt;/code&gt; alone. Week 4 already has &lt;a href="https://dev.to/ibrahimiqbal/exorcist-a-ghost-hunter-for-your-packagejson-4oij"&gt;Exorcist&lt;/a&gt; hunting declared deps. The ghosts I care about are nested: jade arrives because something else still asks for it. The lockfile is the honest list.&lt;/p&gt;

&lt;p&gt;Stack: Node 20, Hono, tsx, vanilla HTML/CSS/JS. Tests cover lockfile parsing. &lt;code&gt;npm test&lt;/code&gt; and &lt;code&gt;npm run typecheck&lt;/code&gt; are in the README.&lt;/p&gt;

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

&lt;p&gt;A hosted model would have been easier. It would also mean uploading the names and versions of everything I ship, plus asking a vendor to invent a eulogy about software they do not maintain. The ofrenda only needs two networks: the public npm registry, which already published those facts, and optionally localhost Ollama.&lt;/p&gt;

&lt;p&gt;If Gemma is down, the altar still works. That is the open-weight contract I wanted: the model is a clerk who may be out of the office. The record is not. A closed API disappearing is the same story this app is telling about packages. I did not want the remembrance layer to be another ghost.&lt;/p&gt;

&lt;p&gt;Running Gemma locally also means the tone can be strict. I do not have to hope a remote safety layer will refuse to invent a CVE. I refuse it in &lt;code&gt;ground.ts&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;The first name that came up on the demo lockfile was &lt;strong&gt;jade 1.11.0&lt;/strong&gt;. Last publish 2015-06-12. The deprecation field still says: Jade has been renamed to pug. Maintainers: forbeslindesay. License: MIT. Description: a clean, whitespace-sensitive template language for writing HTML.&lt;/p&gt;

&lt;p&gt;That package taught a lot of people to indent HTML. It is still in lockfiles. The slip does not ask you to feel guilty. It lights a candle and copies the record. Snuff it if you already know.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.to/challenges/hf26#best-use-of-gemma"&gt;Best Use of Gemma&lt;/a&gt;&lt;/strong&gt; — local &lt;code&gt;gemma3:4b&lt;/code&gt; through Ollama writes the remembrance; grounding code decides whether that text may leave the altar. No other partner category is claimed.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>hf26challenge</category>
      <category>devchallenge</category>
      <category>gemma</category>
      <category>opensource</category>
    </item>
    <item>
      <title>My WhatsApp Was My Open-Mic Booking System, So I Gave It to Gemma</title>
      <dc:creator>Amrit Dash</dc:creator>
      <pubDate>Mon, 26 Oct 2026 03:57:47 +0000</pubDate>
      <link>https://dev.to/amrit-dash/my-whatsapp-was-my-open-mic-booking-system-so-i-gave-it-to-gemma-5274</link>
      <guid>https://dev.to/amrit-dash/my-whatsapp-was-my-open-mic-booking-system-so-i-gave-it-to-gemma-5274</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;I'm a stand-up comic in Bangalore, and I curate the Monday and Tuesday open-mic lineups at a club.&lt;/p&gt;

&lt;p&gt;WhatsApp is my daily driver. I use it to ask other rooms for spots, gossip about shows and audience with comics, reply to my family group, talk to my girlfriend and somewhere in all of that, comics message me asking for a spot on the lineups I curate. Every new message pushes the older ones down. Some requests just slip out of view, and that isn't fair to the comic who asked on Saturday morning.&lt;/p&gt;

&lt;p&gt;So every week, to keep it fair, I scroll back through every chat, work out who actually asked, check who performed last week (they go to the back of the queue), decide if last week's cancelled mic gets repeated, message everyone, and add and remove people from each show's WhatsApp group by hand.&lt;/p&gt;

&lt;p&gt;It takes me about 3 hours every week. I'd rather spend those 3 hours writing jokes. Or sleeping. Mostly sleeping.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;Spot Solutions&lt;/strong&gt;. A Gemma model running on my old laptop reads my DMs, picks out the real spot requests, and drops them on a lineup board. I drag names into shows, press send, and the confirmations, polite regrets and group updates take care of themselves.&lt;/p&gt;

&lt;p&gt;Nothing goes out without my OK. I wanted to be lazy, not crazy.&lt;/p&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%2F27px8itz5k3x6rp1jlkb.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%2F27px8itz5k3x6rp1jlkb.gif" alt="Gemma sorting a weekend of DMs into Monday and Tuesday lineups" width="720" height="543"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Live app:&lt;/strong&gt; &lt;a href="https://spot-solutions.web.app" rel="noopener noreferrer"&gt;spot-solutions.web.app&lt;/a&gt;. Hit &lt;strong&gt;Try the live demo&lt;/strong&gt; and you get your own sandbox of a curator's week. No sign-up. Drag comics around, edit the messages, reset a lineup, break things. It's yours.&lt;/p&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%2F8ty77ovunubtzojie53u.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%2F8ty77ovunubtzojie53u.gif" alt="Walkthrough: starting the demo, the triaged requests, dragging a comic into a show" width="720" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;(&lt;a href="https://spot-solutions.web.app/press/demo.mp4" rel="noopener noreferrer"&gt;Watch it as a video&lt;/a&gt;, 25 seconds.)&lt;/p&gt;

&lt;p&gt;Here's the weekly loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Comics DM me like they always have. Nothing changes for them.&lt;/li&gt;
&lt;li&gt;The server on my laptop waits until a chat goes quiet, then hands the whole burst to Gemma. So "hey" ... "monday?" ... "got a new 5" counts as one request, not three.&lt;/li&gt;
&lt;li&gt;Gemma decides what each conversation is: a spot request (with preferred days), a cancellation, "am I up this week?", a question about the venue, or noise.&lt;/li&gt;
&lt;li&gt;Requests land in &lt;strong&gt;Waiting&lt;/strong&gt; on the board. Last week's performers sink to the bottom with a tag.&lt;/li&gt;
&lt;li&gt;I drag comics into Monday and Tuesday, hit &lt;strong&gt;Confirm &amp;amp; send&lt;/strong&gt;, and review who gets added to and removed from each show's group before anything changes.&lt;/li&gt;
&lt;/ol&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%2Fxbvud7z2jn51aec0s94l.jpg" 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%2Fxbvud7z2jn51aec0s94l.jpg" alt="The requests inbox: a cancellation Gemma caught, with a reply drafted and waiting for my OK" width="800" height="500"&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%2F3tqreekk1bw18yrw4sjg.jpg" 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%2F3tqreekk1bw18yrw4sjg.jpg" alt="The lineup board: Waiting on the left, Monday and Tuesday filling up" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's built for the phone, because that's where I actually curate:&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%2Fmg9ailijnzxezxhrsud6.jpg" 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%2Fmg9ailijnzxezxhrsud6.jpg" alt="The lineup on a phone" width="780" height="1688"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;What Spot Solutions takes off my plate every week:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;finding the actual requests in a weekend of DMs&lt;/li&gt;
&lt;li&gt;checking who performed last week, who asked for which day, and whether a cancelled mic repeats&lt;/li&gt;
&lt;li&gt;messaging every comic, the ones who got a spot and the ones who didn't&lt;/li&gt;
&lt;li&gt;adding and removing people from each show's group&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Before: about 3 hours a week. Now: about 15 minutes&lt;/strong&gt;, most of it spent dragging names around.&lt;/p&gt;

&lt;p&gt;That's about 12 hours a month back. It also runs for next to nothing, on a laptop that was gathering dust.&lt;/p&gt;

&lt;p&gt;This isn't my first attempt either. Version 1 ran for 7 months across 2024 and 2025 on Google Apps Script, Google Sheets and Twilio: 2,200+ spot requests from 150+ comics. It worked, but it needed rigid keywords, cost money per message, and every rule lived in spreadsheet formulas. Version 2 keeps all the rules V1 learned (request windows, skip-last-week, ban and ignore lists, "did the mic happen?" checks, last-minute spot offers) and lets Gemma handle the messy human part.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WhatsApp:&lt;/strong&gt; &lt;a href="https://wwebjs.dev" rel="noopener noreferrer"&gt;whatsapp-web.js&lt;/a&gt; in a Node worker. One headless WhatsApp Web session per curator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI:&lt;/strong&gt; Gemma 3 (4B) through &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt;, on the same laptop. Gemini is an optional fallback, off unless you add a key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App:&lt;/strong&gt; React + Vite with Material 3, hosted on Firebase. Works on a phone, and installs as a PWA.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The glue:&lt;/strong&gt; Firestore. The web app writes commands, the worker listens and writes results back. The laptop only makes outbound connections. No open ports, no tunnel, no static IP. It works on home Wi-Fi while I'm on mobile data across town.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The part where the small model broke
&lt;/h3&gt;

&lt;p&gt;Comics ask for spots in every way imaginable:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"bhai monday ka spot milega? naya 5 hai"&lt;br&gt;
"put me down pls"&lt;br&gt;
"any day works, got new bits"&lt;br&gt;
"guru tuesday sigutta?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And my WhatsApp isn't only comics. It's bank alerts, UPI receipts, OTPs, delivery updates, forwarded memes and audience members asking about tickets. A 4B model asked "is this a spot request?" will say yes with confidence to things that clearly aren't, and a confidence score alone didn't protect against that.&lt;/p&gt;

&lt;p&gt;What worked was making the model &lt;strong&gt;quote the ask&lt;/strong&gt;. Every classification has to include an &lt;code&gt;evidence&lt;/code&gt; field copied from the messages, and the worker checks that quote is really there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** The model must quote the ask. If its "evidence" isn't actually in the messages, it made it up. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;evidenceIsReal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;words&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;words&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;words&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.75&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 the quote isn't in the chat, the classification gets thrown out as noise. The model can't invent a request out of a bank alert when it has to point at the words. The 75% word match is loose on purpose: Hinglish has no fixed spelling, so a quote can drift a little from the original and still be honest.&lt;/p&gt;

&lt;p&gt;Auto-acknowledging ("got your request, will confirm soon") is off by default. When it's on, it needs a quoted ask at 85%+ confidence, in a message under 10 minutes old, from a real person and never a business account.&lt;/p&gt;
&lt;h3&gt;
  
  
  Making an old laptop a real server
&lt;/h3&gt;

&lt;p&gt;Most of the second half of the week went into making the server boring to run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One installer each for Ubuntu (even from a USB stick), Windows and macOS. The Mac gets a small menu-bar app.&lt;/li&gt;
&lt;li&gt;It survives restarts, power cuts and closed lids. The WhatsApp login gets backed up, and when the server comes back it catches up on DMs that arrived while it was down, without auto-replying to any of them.&lt;/li&gt;
&lt;li&gt;Restarting the worker, restarting Ollama, reading logs and approving new curators all happen from the app's Server page, so after setup I never need to touch the laptop.&lt;/li&gt;
&lt;li&gt;It's invite-only. Other curators sign in, I approve them, and they link their own WhatsApp. An 8 GB laptop should comfortably host about 6 curators.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Why Open Matters Here
&lt;/h2&gt;

&lt;p&gt;My comics' DMs are private. People send me their phone numbers, their sets, their bad days. Sending every one of those messages to a cloud API so a model can decide whether "bro monday?" is a spot request felt wrong.&lt;/p&gt;

&lt;p&gt;With Gemma running locally:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No cloud model reads the chats.&lt;/strong&gt; Gemma reads them on my own laptop, and messages that aren't spot requests aren't even stored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The AI costs nothing.&lt;/strong&gt; No per-message bill, which matters when a busy weekend means hundreds of messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I can swap the model.&lt;/strong&gt; Gemma 3 1B on a weak machine, 12B on a better one, or Gemini as a fallback when I choose. Each curator gets a setting for this: "Local only" or "Local first".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Old hardware gets a second life.&lt;/strong&gt; The laptop running this was headed for a drawer.&lt;/li&gt;
&lt;/ul&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%2Fhrxt1enf6xlqdwkynjmt.jpg" 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%2Fhrxt1enf6xlqdwkynjmt.jpg" alt="Settings: choose where your chats may go" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And it's all MIT licensed. Another open-mic scene can run its own server for its own comics, and nothing about it depends on me.&lt;/p&gt;
&lt;h2&gt;
  
  
  Several Laptops, One Lineup
&lt;/h2&gt;

&lt;p&gt;One laptop is a single point of failure, and old laptops fail. So Spot Solutions runs on as many servers as you want, all pointed at the same project: a Mac, a Windows laptop, an Ubuntu box in a cupboard.&lt;/p&gt;

&lt;p&gt;Each WhatsApp account runs on exactly one of them at a time. That server holds a lease and renews it every 30 seconds. If it goes quiet (power cut, closed lid, a cat on the keyboard), it stops its own WhatsApp first, and only after that can another server take the account over.&lt;/p&gt;

&lt;p&gt;The new server doesn't ask for a QR scan. It pulls an encrypted copy of the WhatsApp login from Cloud Storage, unlocks it, and carries on. Every copy gets a version number, and the newest copy always wins, so a laptop that comes back after a week can't overwrite a fresher login with its old one.&lt;/p&gt;

&lt;p&gt;Accounts go to the least busy server, curators can pin themselves to one, and a curator who picks "Local only" only ever lands on a server running Gemma. Each machine gets its own key, so if a laptop gets stolen I revoke that one key and the rest keep running.&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/amrit-contour" rel="noopener noreferrer"&gt;
        amrit-contour
      &lt;/a&gt; / &lt;a href="https://github.com/amrit-contour/spot-solutions" rel="noopener noreferrer"&gt;
        spot-solutions
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Open-mic lineups from your WhatsApp DMs, sorted by Gemma running on your own laptop. Drag, review, send.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/amrit-contour/spot-solutions/apps/web/public/press/cover.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2Famrit-contour%2Fspot-solutions%2FHEAD%2Fapps%2Fweb%2Fpublic%2Fpress%2Fcover.gif" alt="Spot Solutions: WhatsApp spot requests, sorted by Gemma on an old laptop" width="100%"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;p&gt;
  &lt;a href="https://spot-solutions.web.app" rel="nofollow noopener noreferrer"&gt;&lt;img alt="Live demo" src="https://camo.githubusercontent.com/33b7e9b655e09af904b5f0a1e45f172b13ebdd9e76201c03e2df336b460c37dd/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c69766525323064656d6f2d73706f742d2d736f6c7574696f6e732e7765622e6170702d4538413333443f7374796c653d666f722d7468652d6261646765266c6162656c436f6c6f723d316631383132"&gt;&lt;/a&gt;
  &lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/77c40bea585a7ccd35d473058d24312eeeda53025708f5c60e0b337046f1dbda/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f47656d6d61253230332d6f6e2532304f6c6c616d612d4538413333443f7374796c653d666f722d7468652d6261646765266c6162656c436f6c6f723d316631383132"&gt;&lt;img alt="Gemma 3 on Ollama" src="https://camo.githubusercontent.com/77c40bea585a7ccd35d473058d24312eeeda53025708f5c60e0b337046f1dbda/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f47656d6d61253230332d6f6e2532304f6c6c616d612d4538413333443f7374796c653d666f722d7468652d6261646765266c6162656c436f6c6f723d316631383132"&gt;&lt;/a&gt;
  &lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/535021b93ba0cf7107943b731d019afd7a6ee560473b04f30191cb35a9f128d8/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e63652d4d49542d4538413333443f7374796c653d666f722d7468652d6261646765266c6162656c436f6c6f723d316631383132"&gt;&lt;img alt="MIT licence" src="https://camo.githubusercontent.com/535021b93ba0cf7107943b731d019afd7a6ee560473b04f30191cb35a9f128d8/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e63652d4d49542d4538413333443f7374796c653d666f722d7468652d6261646765266c6162656c436f6c6f723d316631383132"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;p&gt;
  &lt;b&gt;Your open-mic lineup, sorted while you sleep.&lt;/b&gt;&lt;br&gt;
  Comics ask for spots in your WhatsApp DMs, however they like. A Gemma model running on your own laptop picks out the real requests.&lt;br&gt;
  You drag names into shows and press send. Confirmations, polite regrets and show groups take care of themselves
&lt;/p&gt;
&lt;p&gt;
  &lt;a href="https://spot-solutions.web.app" rel="nofollow noopener noreferrer"&gt;&lt;b&gt;Try the live demo&lt;/b&gt;&lt;/a&gt; ·
  &lt;a href="https://dev.to/amrit-dash/my-whatsapp-was-my-open-mic-booking-system-so-i-gave-it-to-gemma-5274" rel="nofollow"&gt;The write-up on DEV&lt;/a&gt; ·
  &lt;a href="https://github.com/amrit-contour/spot-solutions#run-a-server" rel="noopener noreferrer"&gt;Run a server&lt;/a&gt; ·
  &lt;a href="https://github.com/amrit-contour/spot-solutions/docs/README.md" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;
&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this exists&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;I'm a stand-up comic in Bangalore and I curate the Monday and Tuesday open-mic lineups at a club. Spot requests arrive in WhatsApp between family groups, comic gossip, bank alerts and memes. Every new message buries the older ones, so being fair to whoever asked first meant scrolling back through every chat, checking who performed last week, messaging everyone and fixing each show's group by hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That was about 3 hours a week. Now it's about 15 minutes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Version 1 ran for 7…&lt;/p&gt;&lt;/div&gt;


&lt;/div&gt;
&lt;br&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/amrit-contour/spot-solutions" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;br&gt;
&lt;/div&gt;
&lt;br&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 3 runs every AI decision in the app, locally through Ollama: reading bursts of Hinglish, English and Kannada DMs, sorting them into spot requests, cancellations, questions and noise, pulling out preferred days, and quoting the ask so nothing gets invented. Curators who pick "Local only" never touch a cloud model at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Built with Gemma 3, Ollama, whatsapp-web.js, React and Firebase, mostly by a sloth with a laptop and a AI subscription.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>TestMadeEasy: Gemma 4 turns a photo of a textbook page into a branded test paper</title>
      <dc:creator>Amrit Dash</dc:creator>
      <pubDate>Mon, 19 Oct 2026 05:51:44 +0000</pubDate>
      <link>https://dev.to/amrit-dash/testmadeeasy-gemma-4-turns-a-photo-of-a-textbook-page-into-a-branded-test-paper-32m5</link>
      <guid>https://dev.to/amrit-dash/testmadeeasy-gemma-4-turns-a-photo-of-a-textbook-page-into-a-branded-test-paper-32m5</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&lt;/a&gt; (Side Hustle).&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;My friend Akash Agrawal (&lt;a class="mentioned-user" href="https://dev.to/baniyaman"&gt;@baniyaman&lt;/a&gt;) teaches at a coaching centre. Every time he needs a test paper for a class, a subject or a unit, he does the same thing: picks questions from books and old papers, screenshots them, marks them up, and sends the pile to a &lt;strong&gt;DTP operator&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The DTP operator's whole job is turning those screenshots into a paper. They retype every question in proper maths notation, redraw the figures, drop it all into the coaching centre's branded template with its logo, and send back a PDF to review and print. Akash's centre pays &lt;strong&gt;₹5 for a plain question and ₹10 and up for anything with a diagram&lt;/strong&gt;. And it takes about a week: 2 days of the teacher picking questions, then 5 days waiting on the operator.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TestMadeEasy&lt;/strong&gt; removes that middle layer. The teacher photographs a page (or marks a section of one), and the app gives back every question as clean, consistent text:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the question in &lt;strong&gt;LaTeX&lt;/strong&gt;, so maths and chemistry notation looks like a real exam paper&lt;/li&gt;
&lt;li&gt;options, kept separate from the question&lt;/li&gt;
&lt;li&gt;a generated answer and solution&lt;/li&gt;
&lt;li&gt;subject, chapter, topic, difficulty and marks&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;diagram&lt;/strong&gt;, cropped straight out of the photo&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then the teacher picks questions, applies their institute's branding (logo, colours, header, watermark) and prints the paper and its answer key. Same day, no operator.&lt;/p&gt;

&lt;p&gt;There's one more piece that makes it smarter over time: a shared &lt;strong&gt;master question bank&lt;/strong&gt;. Every extracted question is checked against it, so a question someone has already scanned gets matched and synced instead of extracted again. The bigger the bank gets, the less anyone needs to scan. You start changing values in existing questions instead of typing new ones.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Who pays for it
&lt;/h2&gt;

&lt;p&gt;Small coaching institutes, the kind with a few teachers running weekly JEE, NEET and board-exam tests. They already pay for this work today, per question, to a DTP operator, or as a salary (some operators get ₹15,000 to 20,000 per exam cycle).&lt;/p&gt;

&lt;p&gt;Our plan is a subscription in tiers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free:&lt;/strong&gt; 5 question papers, kept on the account for good.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paid:&lt;/strong&gt; teacher accounts and up to 100 papers a month, priced per paper generated. With 10 to 15 questions per paper, we're aiming for &lt;strong&gt;₹1 to 2 per question&lt;/strong&gt;, against the ₹5 to ₹10+ they pay now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Premium:&lt;/strong&gt; access to the shared master bank, so you can build papers from questions other teachers have already extracted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bring your own key:&lt;/strong&gt; bigger centres can plug in their own API key and a stronger model if they want.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That ₹1 to 2 number is only possible because of the model this week's rebuild runs on. More on that below.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://testmadeeasy.tech" rel="noopener noreferrer"&gt;testmadeeasy.tech&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Judge login&lt;/strong&gt; (a teacher account with sample data already loaded):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Email: &lt;code&gt;testinguseramrit@gmail.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Password: &lt;code&gt;hacktoberfest&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It has 18 questions extracted from 4 practice sheets, a "Shree Science Academy" brand, and 3 test papers with answer keys. To try it yourself, go to &lt;strong&gt;Add Questions&lt;/strong&gt; and upload any page (or one of the &lt;a href="https://github.com/amrit-dash/testmadeeasy/tree/main/docs/images" rel="noopener noreferrer"&gt;sample sheets in the repo&lt;/a&gt;). A page usually takes under a minute.&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%2Fya18umsbobpvuki9uiyi.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%2Fya18umsbobpvuki9uiyi.png" alt="A practice sheet photo on the left, the branded paper TestMadeEasy made from it on the right" width="800" height="642"&gt;&lt;/a&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/amrit-dash" rel="noopener noreferrer"&gt;
        amrit-dash
      &lt;/a&gt; / &lt;a href="https://github.com/amrit-dash/testmadeeasy" rel="noopener noreferrer"&gt;
        testmadeeasy
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Turn a photo of a textbook page into a branded, print-ready test paper. Gemma 4 extracts questions as LaTeX.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/amrit-dash/testmadeeasy/logo.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Famrit-dash%2Ftestmadeeasy%2FHEAD%2Flogo.svg" alt="TestMadeEasy logo" width="72" height="72"&gt;&lt;/a&gt;
  &lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;TestMadeEasy&lt;/h1&gt;
&lt;/div&gt;
  &lt;p&gt;&lt;strong&gt;Photo of a textbook page in. Print-ready, branded test paper out.&lt;/strong&gt;&lt;/p&gt;
  &lt;p&gt;
    &lt;a href="https://testmadeeasy.tech" rel="nofollow noopener noreferrer"&gt;testmadeeasy.tech&lt;/a&gt; ·
    &lt;a href="https://github.com/amrit-dash/testmadeeasy/docs/FEATURES.md" rel="noopener noreferrer"&gt;Features&lt;/a&gt; ·
    &lt;a href="https://github.com/amrit-dash/testmadeeasy/docs/USER_GUIDE.md" rel="noopener noreferrer"&gt;User guide&lt;/a&gt;
  &lt;/p&gt;
&lt;/div&gt;

&lt;p&gt;TestMadeEasy is a question-paper builder for coaching teachers. You photograph a page from a reference book, and the open-weight &lt;strong&gt;Gemma 4&lt;/strong&gt; model pulls every question off it as clean LaTeX: options, subject, chapter, topic, difficulty, marks, and where any diagram sits. The questions land in your own question bank. You pick the ones you want, add your institute's logo, colour and watermark, and print a test paper plus its answer key.&lt;/p&gt;
&lt;p&gt;It's built for the small coaching institutes in India that run weekly JEE, NEET and board-exam tests and currently pay a DTP operator to retype every question by hand.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Demo video:&lt;/strong&gt; &lt;a href="https://youtu.be/lA2mvoYwOtQ" rel="nofollow noopener noreferrer"&gt;youtu.be/lA2mvoYwOtQ&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://youtu.be/lA2mvoYwOtQ" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Famrit-dash%2Ftestmadeeasy%2FHEAD%2Fdocs%2Fimages%2Fcover.png" alt="Textbook photo in, branded test paper out"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Want to try it without your own pages? The 4 practice sheets in &lt;a href="https://github.com/amrit-dash/testmadeeasy/docs/images" rel="noopener noreferrer"&gt;&lt;code&gt;docs/images&lt;/code&gt;&lt;/a&gt; (physics, chemistry, maths, biology, all original questions) are what the demo uses.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What it does&lt;/h2&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/amrit-dash/testmadeeasy" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  Where this week fits
&lt;/h2&gt;

&lt;p&gt;I want to be upfront about the timeline. TestMadeEasy started as a private project earlier in 2026, built on Gemini. Akash built the first UI in Replit, and I built the backend, the AI pipeline and the infrastructure around it.&lt;/p&gt;

&lt;p&gt;We pitched it to an IIT-prep coaching centre at &lt;strong&gt;₹5,000&lt;/strong&gt;. They didn't say no. They paid &lt;strong&gt;₹1,000 for a trial&lt;/strong&gt;, we set them up with accounts, fed in their previous papers, and scraped previous-year IIT papers to pre-fill the master bank. The extraction was good enough that the papers came out right.&lt;/p&gt;

&lt;p&gt;They still didn't really use it. They kept sending us question papers &lt;strong&gt;on WhatsApp&lt;/strong&gt;, so we'd run them through the platform ourselves and send the paper back. What they actually asked for was a WhatsApp bot: send photos, get a paper back. It was habit, plus a platform they never quite got comfortable with, and we didn't move past the trial. Akash, though, uses it every day for his own classes, and he's passed it on to other teachers who use it too.&lt;/p&gt;

&lt;p&gt;The lesson I took from that pitch was price. ₹5,000 for a centre that's used to paying an operator per question is a hard sell. &lt;strong&gt;This week was the turning point:&lt;/strong&gt; we rebuilt the AI core on open-weight Gemma 4 to get the cost low enough to pitch ₹1 to 2 per question next time, and we opened the code.&lt;/p&gt;

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

&lt;p&gt;React and Vite on the front, Express on the back, Postgres, auth and image storage on Supabase. It all runs as one container on Google Cloud Run behind Firebase Hosting, on a &lt;code&gt;.tech&lt;/code&gt; domain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Swapping in Gemma 4
&lt;/h3&gt;

&lt;p&gt;Every AI call used to go to Gemini. Gemma 4 is served through the same Gemini API, with the same key, and it accepts the same system prompts and JSON response schemas. So the first step was mostly configuration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;gemma-4-31b-it&lt;/code&gt; does extraction, answers and diagram redraws.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gemma-4-26b-a4b-it&lt;/code&gt; (the mixture-of-experts one, 4B active) scores teacher edits to shared questions.&lt;/li&gt;
&lt;li&gt;An admin can switch any task back to a Gemini model from a dropdown, and nothing else in the code knows which one is running.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then we tested it on real pages, and that's where the work was.&lt;/p&gt;

&lt;h3&gt;
  
  
  Diagrams without image generation
&lt;/h3&gt;

&lt;p&gt;Gemma doesn't generate images, and we don't want it to. Redrawing a diagram loses detail and costs tokens. Instead, the model tells us &lt;strong&gt;where&lt;/strong&gt; each diagram is, and we crop it out of the original photo. Nothing is lost, and it's cheap.&lt;/p&gt;

&lt;p&gt;The old prompt asked for boxes as &lt;code&gt;{x, y, width, height}&lt;/code&gt; in percent. Gemma kept returning half a box (&lt;code&gt;{x: 61, y: 158}&lt;/code&gt;, no width or height) or raw pixels, so the app had nothing to crop with and showed the whole page. We switched to &lt;code&gt;[ymin, xmin, ymax, xmax]&lt;/code&gt; on a 0 to 1000 grid. That's the format Google's vision models are trained on for detection; Google AI's post on &lt;a href="https://dev.to/googleai/detecting-and-editing-visual-objects-with-gemini-116p"&gt;Detecting and Editing Visual Objects with Gemini&lt;/a&gt; and &lt;a href="https://dev.to/amirhosseinghanipour/native-bounding-boxes-changes-everything-for-visions-devs-19no"&gt;Native Bounding Boxes Changes Everything for Visions Devs&lt;/a&gt; by amirhosseinghanipour both explain it well. The server converts the box back to percent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// [ymin, xmin, ymax, xmax] on a 0-1000 grid -&amp;gt; {x, y, width, height} in percent&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;boxToBounds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;padPct&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;ymin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;xmin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ymax&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;xmax&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Math&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="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&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="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&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="nx"&gt;xmin&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;padPct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&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="nx"&gt;ymin&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;padPct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&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="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;xmax&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;padPct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&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="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ymax&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;padPct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;y&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;The boxes went from garbage to tight on the first try.&lt;/p&gt;

&lt;p&gt;The model also tends to stop a question's box one line early, so the last row of options got cut off. Rather than add another paragraph of "PLEASE INCLUDE THE OPTIONS" to the prompt, the server grows each box with plain geometry: it takes in the question's own diagram and reaches down to just above the next question in the same column.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making Gemma reliable
&lt;/h3&gt;

&lt;p&gt;A few more things broke, and each fix was small once we found it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pages that "failed" for no reason.&lt;/strong&gt; Gemma sometimes wraps its JSON in a markdown fence even in JSON mode. Our "was this cut off?" check looked for a closing &lt;code&gt;}&lt;/code&gt;, found a fence, retried twice and gave up. Stripping the fence fixed 2 of our 4 test pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pages that came back with 1 question out of 4.&lt;/strong&gt; Now the schema makes the model list every question number on the page &lt;em&gt;first&lt;/em&gt;, then extract. If it lists 4 and returns 1, the server retries once and tells it what it missed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calls that hung for 15 minutes.&lt;/strong&gt; Every AI call now has a 150-second timeout, so a stalled request gets retried instead of blocking the job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jobs that froze halfway.&lt;/strong&gt; Extraction runs in the background after the upload request returns. Cloud Run, by default, only gives a container CPU while a request is in flight, so jobs crawled whenever nobody was polling. One flag, &lt;code&gt;--no-cpu-throttling&lt;/code&gt;, fixed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free-tier limits.&lt;/strong&gt; Gemma on the Gemini API has a low tokens-per-minute cap and a page costs 3 to 5k tokens, so Gemma calls now run 2 at a time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After all that: 4 practice sheets in, 18 of 18 questions out, each with a working crop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why open innovation matters here
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cost is the whole business.&lt;/strong&gt; Our pitch failed partly on price. On Gemini 3 Flash a page cost about $0.003 in tokens, small until a teacher scans a 200-page question book. Gemma 4 on the Gemini API costs nothing right now, which is what makes ₹1 to 2 per question (and a real free tier) work. The app still records token counts on every question, so if that ever changes, we'll see it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No lock-in on what matters.&lt;/strong&gt; The question bank is the product. If the free tier goes away, the weights are open: we can run the same Gemma 4 on our own GPU and keep every prompt and schema as they are, because it's the same model, not a "similar" one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Privacy is the next step, and I'll be straight about it.&lt;/strong&gt; Right now the hosted free tier is what keeps cost at zero, and Google says free-tier prompts can be used to improve its products. For schools that don't want exam papers leaving the building, self-hosting the same open model is the honest answer, and it's on our list.&lt;/p&gt;

&lt;p&gt;What we'd build next: the WhatsApp flow our trial centre asked for (send photos, get a paper back), and upscaling for cropped diagrams from low-quality phone photos.&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 31B and Gemma 4 26B A4B run every AI task by default: reading photos into structured questions, finding question and diagram boxes for cropping, generating answers, redrawing simple diagrams as TikZ, and scoring edits to the shared bank.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of a .tech Domain:&lt;/strong&gt; the app lives at &lt;a href="https://testmadeeasy.tech" rel="noopener noreferrer"&gt;testmadeeasy.tech&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Team
&lt;/h2&gt;

&lt;p&gt;Built by &lt;a class="mentioned-user" href="https://dev.to/amrit-dash"&gt;@amrit-dash&lt;/a&gt; and &lt;a class="mentioned-user" href="https://dev.to/baniyaman"&gt;@baniyaman&lt;/a&gt; (Akash Agrawal).&lt;/p&gt;

</description>
      <category>hf26challenge</category>
      <category>devchallenge</category>
      <category>hacktoberfest</category>
      <category>ai</category>
    </item>
    <item>
      <title>I made `git push` refuse to work until I touch grass</title>
      <dc:creator>Amrit Dash</dc:creator>
      <pubDate>Sat, 10 Oct 2026 13:13:54 +0000</pubDate>
      <link>https://dev.to/amrit-dash/i-made-git-push-refuse-to-work-until-i-touch-grass-138p</link>
      <guid>https://dev.to/amrit-dash/i-made-git-push-refuse-to-work-until-i-touch-grass-138p</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;For the past couple of weeks I've been losing track of time. I'd look up, see 1 o'clock, and honestly not know if it was the afternoon or the middle of the night.&lt;/p&gt;

&lt;p&gt;I was so deep in a project that I'd stopped taking breaks. That's what made me build &lt;strong&gt;touch-grass&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It's a git pre-push hook. After 3 hours indoors (you can change that), this happens. Here's a real one from testing, with the limit set to 1 minute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git push

🌱 touch-grass: 9m since you last saw the sky. Push blocked.
   Run `touchgrass prove`, scan the code with your phone and take a photo outside.
   (Yes, `git push --no-verify` works. You'll know.)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To get your push back, you run &lt;code&gt;touchgrass prove&lt;/code&gt;. It prints a QR code. You scan it, your phone opens the camera, and you go outside and take a photo.&lt;/p&gt;

&lt;p&gt;Then &lt;strong&gt;Gemma 4, running on your own laptop&lt;/strong&gt;, looks at it and decides if it counts.&lt;/p&gt;

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

&lt;p&gt;I didn't record a video, so here's a real &lt;code&gt;prove&lt;/code&gt; run from testing instead. I ran it over the tunnel, scanned the code and walked outside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ touchgrass prove --tunnel
📱 On your phone (anywhere), open:
   https://….trycloudflare.com/?t=…
   Model: gemma4:e4b via http://localhost:11434
   Waiting for a photo of the outside world… (Ctrl+C to give up)

✅ Grass confirmed. A hilly landscape with scattered houses under a cloudy sky.
   "It looks like you've found a much better environment to debug than your IDE."
   Push unlocked.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fdst7v1i5kwv9xrkjiuiw.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%2Fdst7v1i5kwv9xrkjiuiw.png" alt="Got Fooled By Computer Screen. LOL!" width="800" height="1731"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Code: &lt;a href="https://github.com/amrit-dash/MLH-touch-grass" rel="noopener noreferrer"&gt;https://github.com/amrit-dash/MLH-touch-grass&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Gets People Outside
&lt;/h2&gt;

&lt;p&gt;Every "go outside" app I've tried sends a notification. I swipe it away in under a second.&lt;/p&gt;

&lt;p&gt;This one sits on the command I run 20 times a day. I can't swipe it away. The only way past it (well, the honest way) is the front door.&lt;/p&gt;

&lt;p&gt;And it's tiny. You don't need a hike. You need sky or a plant in frame, taken in the last 15 minutes. That's a 2 minute walk, which is exactly the break I wasn't taking.&lt;/p&gt;

&lt;p&gt;I built it, set it up, and promptly forgot about it. Then a push got blocked, and when I looked up from the screen the sun was setting outside.&lt;/p&gt;

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

&lt;p&gt;It's about 700 lines of Python plus one HTML page, with zero required dependencies.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The hook.&lt;/strong&gt; &lt;code&gt;touchgrass install&lt;/code&gt; writes a &lt;code&gt;pre-push&lt;/code&gt; hook. It compares now against the last time a photo passed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The phone page.&lt;/strong&gt; &lt;code&gt;touchgrass prove&lt;/code&gt; starts a small server on your Wi-Fi with a one-time token in the URL. The page opens the rear camera directly (&lt;code&gt;capture="environment"&lt;/code&gt;), shrinks the photo to 1024px and sends it with its capture time. Not on the same Wi-Fi? &lt;code&gt;touchgrass prove --tunnel&lt;/code&gt; gives the page a temporary HTTPS address through a Cloudflare quick tunnel, so it works on mobile data too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The check.&lt;/strong&gt; The photo goes to Gemma through Ollama, on &lt;code&gt;localhost&lt;/code&gt;. The prompt asks for JSON only: &lt;code&gt;outdoors&lt;/code&gt;, &lt;code&gt;sky_or_plants&lt;/code&gt;, &lt;code&gt;photo_of_screen&lt;/code&gt;, a one line description and a roast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The verdict.&lt;/strong&gt; Plain Python decides, not the model. Outdoors, plus sky or plants, plus not a screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The double check.&lt;/strong&gt; Anything that passes gets one more question about screens (more on that below). Then the clock resets and you get a "grass receipt" (&lt;code&gt;touchgrass log&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I kept the decision in code on purpose. The model describes the photo, and 3 lines of &lt;code&gt;if&lt;/code&gt; decide what that means. Easier to test, and the model can't be talked into saying "pass".&lt;/p&gt;

&lt;h3&gt;
  
  
  Can you cheat it?
&lt;/h3&gt;

&lt;p&gt;Obviously that was the first thing I tried.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cheat&lt;/th&gt;
&lt;th&gt;What stops it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Old photo from the gallery&lt;/td&gt;
&lt;td&gt;Capture time has to be within 15 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Photo out the window&lt;/td&gt;
&lt;td&gt;Prompt says "not through a window" (I haven't tested this one yet)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grass wallpaper on your monitor&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;photo_of_screen&lt;/code&gt;, plus the bezel double check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A monitor so close it fills the whole frame&lt;/td&gt;
&lt;td&gt;Nothing, yet. Keep reading.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git push --no-verify&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Nothing. You'll know.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I also benchmarked it so I wasn't just trusting vibes. 18 labelled photos: 8 real outdoor scenes, 8 indoor rooms and desks, and 2 fakes where I pasted a field of cows onto a monitor in a desk photo.&lt;/p&gt;

&lt;p&gt;The first run got &lt;strong&gt;16 out of 18&lt;/strong&gt;. Every real photo was right. Both fakes sailed through as "Grass confirmed", with the roast "Hope the grass is as green as your commit history!" (rude, and also wrong).&lt;/p&gt;

&lt;p&gt;So I changed 2 things. The prompt now makes the model look for a screen &lt;em&gt;before&lt;/em&gt; it looks at the scene. And any photo that passes gets asked one more, much narrower question: what's at the edges of this photo, is it a bezel? Small models are bad at noticing a monitor frame when they're busy describing cows. They're good at it when that's the only question.&lt;/p&gt;

&lt;p&gt;Second run: &lt;strong&gt;18 out of 18&lt;/strong&gt;, about 26 seconds a photo on a 4 core CPU with no GPU. Full results are in &lt;a href="https://github.com/amrit-dash/MLH-touch-grass/blob/main/docs/benchmark.md" rel="noopener noreferrer"&gt;docs/benchmark.md&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;(18 photos is tiny, and I fixed the prompt after seeing the fakes fail, so read that as "known holes closed", not "perfect". Also it calls the cows horses every single time.)&lt;/p&gt;

&lt;p&gt;Then I tried it for real, with my own phone.&lt;/p&gt;

&lt;p&gt;The first real photo:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;✅ Grass confirmed. A multi-story residential building with laundry hanging out to dry is visible under a blue sky.&lt;br&gt;
"It looks like your deployment is stuck waiting for the laundry cycle to finish."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Fair.&lt;/p&gt;

&lt;p&gt;Then I put a photo of a grass field on my monitor and held my phone close enough that the grass filled the whole frame. No bezel, no desk.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;✅ Grass confirmed. A vibrant green field of tall grass stretches towards a line of trees under a cloudy sky.&lt;br&gt;
"Hope your code is as lush and green as this field, or you'll be debugging in the weeds."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It got me. Both checks missed it.&lt;/p&gt;

&lt;p&gt;When I tried again and a sliver of the browser bar crept into the corner, it caught me straight away:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;❌ That's a picture of a screen. Nice try.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I retook the full-frame shot, saved it and tried 4 different screen prompts on it, with and without Gemma's thinking turned on. Every one said it was a real field. Once the screen fills the frame there isn't much left to see, just slightly too-green grass.&lt;/p&gt;

&lt;p&gt;So that's the known hole. If you're willing to press your phone against your monitor to dodge a 2 minute walk, you win. You'll know, though.&lt;/p&gt;

&lt;p&gt;And the hillside in the demo above went through the new HTTPS tunnel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Open Matters Here
&lt;/h2&gt;

&lt;p&gt;This thing looks at photos of where you are. Your street, your backyard, maybe your face.&lt;/p&gt;

&lt;p&gt;I don't want that going to someone else's API every time I push code. With an open-weight model the only thing that ever looks at it is my laptop. (With &lt;code&gt;--tunnel&lt;/code&gt; the photo passes through Cloudflare on the way, encrypted, but nobody else's model sees it and nothing stores it.) It also costs nothing per check, works offline, and if you think my rules are too strict you can change the prompt or swap the model in one env var.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Missing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--no-verify&lt;/code&gt; skips it. It's a nudge with jokes, not a prison.&lt;/li&gt;
&lt;li&gt;The capture time comes from the phone's browser, so a determined cheater can fake it.&lt;/li&gt;
&lt;li&gt;A screen held close enough to fill the whole frame gets through. I couldn't prompt my way out of that one.&lt;/li&gt;
&lt;li&gt;Same Wi-Fi by default. &lt;code&gt;--tunnel&lt;/code&gt; fixes that, but then the photo goes through Cloudflare.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Best Use of Gemma&lt;/li&gt;
&lt;/ul&gt;

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