<?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: Matheus Ferreira</title>
    <description>The latest articles on DEV Community by Matheus Ferreira (@mathfe).</description>
    <link>https://dev.to/mathfe</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%2F4158690%2Faf2e39b2-2132-4226-84e2-737988a8ecc7.jpg</url>
      <title>DEV Community: Matheus Ferreira</title>
      <link>https://dev.to/mathfe</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mathfe"/>
    <language>en</language>
    <item>
      <title>SlothScout: a local agent that reads my job alerts at 3 a.m. so I don't have to</title>
      <dc:creator>Matheus Ferreira</dc:creator>
      <pubDate>Sat, 24 Oct 2026 22:19:56 +0000</pubDate>
      <link>https://dev.to/mathfe/slothscout-a-local-agent-that-reads-my-job-alerts-at-3-am-so-i-dont-have-to-5apo</link>
      <guid>https://dev.to/mathfe/slothscout-a-local-agent-that-reads-my-job-alerts-at-3-am-so-i-dont-have-to-5apo</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hf26"&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 junior dev in São José dos Campos, Brazil, looking for my next job. Like everyone, I signed up for job alerts on LinkedIn, Indeed and Gupy. And like everyone, my inbox turned into a wall of "47 new jobs for you" emails.&lt;/p&gt;

&lt;p&gt;I was spending &lt;strong&gt;about 4 hours a day&lt;/strong&gt; on them: reading the alerts, opening each site, reading the requirements, and finding out (again) that "Junior" meant 5 years of experience.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;SlothScout&lt;/strong&gt; 🦥. Every night at 3 a.m., while I'm asleep, it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;reads only my &lt;code&gt;Vagas&lt;/code&gt; Gmail label over IMAP (read-only),&lt;/li&gt;
&lt;li&gt;pulls every job posting out of the alert emails,&lt;/li&gt;
&lt;li&gt;dedupes them (the same job shows up in three alerts),&lt;/li&gt;
&lt;li&gt;scores each one against my résumé and my criteria with &lt;strong&gt;Gemma 3 4B running locally on Ollama&lt;/strong&gt;,&lt;/li&gt;
&lt;li&gt;and leaves me a morning page with two lines: &lt;em&gt;apply to these, ignore the rest.&lt;/em&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;My first real run:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Alert emails read&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;15&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Job postings scored&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;49&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Above my cutoff (70)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total time&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;152 seconds&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One job worth my attention: a remote &lt;strong&gt;Backend Developer Jr&lt;/strong&gt; role. The other 48 got a score and a reason, and I never had to open them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lazy payoff: it saves me about 3 hours a day.&lt;/strong&gt; I still spend around an hour on the shortlist and the borderline ones, actually reading them and applying. The other three hours of clicking through postings that were never a fit are gone. That's very sloth-approved.&lt;/p&gt;

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

&lt;p&gt;This is a real excerpt of the report SlothScout wrote this morning (&lt;code&gt;reports/2026-10-24.md&lt;/code&gt;, titles trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# SlothScout · 2026-10-24&lt;/span&gt;

49 vagas avaliadas · corte 70 · modelo &lt;span class="sb"&gt;`gemma3:4b`&lt;/span&gt;

&lt;span class="gu"&gt;## Aplica nessas&lt;/span&gt;

&lt;span class="gu"&gt;### 75 · Desenvolvedor Backend Jr · ABC Fitness · Brasil (Remoto)&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; ✓ Desenvolvedor Backend
&lt;span class="p"&gt;-&lt;/span&gt; ✓ Remoto
&lt;span class="p"&gt;-&lt;/span&gt; ✓ Crescimento rápido

&lt;span class="gu"&gt;## Ignora o resto (48)&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; 65 · Developer Fullstack Jr (Java + Angular) · CI&amp;amp;T
&lt;span class="p"&gt;-&lt;/span&gt; 65 · Desenvolvedor Java Jr · Firedev IT
&lt;span class="p"&gt;-&lt;/span&gt; 25 · Desenvolvedor(a) Fullstack Sênior – Integrações de Sistemas · Lyncas
&lt;span class="p"&gt;-&lt;/span&gt; 10 · Tech Lead – AI Engineering · VENHA SER #SANGUELARANJA
&lt;span class="p"&gt;-&lt;/span&gt; 5  · Cplug I Engenheiro (a) de Dados Sênior I Remoto · LWSA
&lt;span class="p"&gt;-&lt;/span&gt; ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;("Aplica nessas" / "Ignora o resto" = "apply to these" / "ignore the rest".) The same content is served as a Thymeleaf page at &lt;code&gt;localhost:8080&lt;/code&gt;, with a &lt;strong&gt;Rodar agora&lt;/strong&gt; ("run now") button so I don't have to wait for 3 a.m. Every ✓ means the quoted reason really appears in the posting (more on that below).&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/MathFe" rel="noopener noreferrer"&gt;
        MathFe
      &lt;/a&gt; / &lt;a href="https://github.com/MathFe/SlothScout" rel="noopener noreferrer"&gt;
        SlothScout
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Local agent that triages job-alert emails overnight with Gemma via Ollama
    &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;🦥 SlothScout&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;A local agent that reads your job-alert emails at 3 a.m., scores every posting against your résumé with a local open-weight model, and greets you in the morning with two lines: &lt;em&gt;apply to these, ignore the rest.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Nothing leaves your machine except the IMAP fetch. Your résumé and inbox never touch anyone's API.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How it works&lt;/h2&gt;
&lt;/div&gt;


&lt;ol&gt;

&lt;li&gt;

&lt;strong&gt;Schedule&lt;/strong&gt;: a Spring &lt;code&gt;@Scheduled&lt;/code&gt; job runs at 03:00 &lt;code&gt;America/Sao_Paulo&lt;/code&gt;.&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;Collect&lt;/strong&gt;: reads only the alerts folder over IMAP, read-only. Processed emails are tracked by &lt;code&gt;Message-ID&lt;/code&gt; in a local H2 DB, so your Gmail "read" state is never touched.&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;Extract&lt;/strong&gt;: one Jsoup parser per sender (LinkedIn, Indeed, Gupy). Each one anchors on the job link and its ID, which stay stable while the email layouts keep changing.&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;Dedupe&lt;/strong&gt;: postings are keyed by &lt;code&gt;source:jobId&lt;/code&gt;, so the same job from three alerts is scored once.&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;Score&lt;/strong&gt;: one call per…&lt;/li&gt;

&lt;/ol&gt;&lt;/div&gt;
&lt;br&gt;
  &lt;/div&gt;
&lt;br&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/MathFe/SlothScout" 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;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;Java 25 + Spring Boot, Spring AI with the Ollama starter, Jsoup, H2, Thymeleaf.&lt;/p&gt;

&lt;h3&gt;
  
  
  One parser per sender, anchored on the job link
&lt;/h3&gt;

&lt;p&gt;Alert email layouts change all the time, but the job link and its ID don't. Every parser (LinkedIn, Indeed, Gupy) finds the job links first and then reads title/company/location around them. Postings are keyed by &lt;code&gt;source:jobId&lt;/code&gt;, so a job that arrives in three alerts is scored once.&lt;/p&gt;

&lt;h3&gt;
  
  
  Criteria live in config, not in the prompt
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;slothscout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;criteria&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;levels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Júnior&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Estágio&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;on-site-cities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;São José dos Campos&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Taubaté&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Caçapava&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Jacareí&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;remote&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;remoto no Brasil ou internacional&lt;/span&gt;
    &lt;span class="na"&gt;stacks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Java&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Python&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;JavaScript&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;.NET&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;max-years-experience&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
    &lt;span class="na"&gt;exclusions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;senioridade alta (pleno, sênior, especialista, lead)&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;viagem obrigatória para São Paulo&lt;/span&gt;
    &lt;span class="na"&gt;cutoff&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;70&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tune what I'm looking for without touching code or prompts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Structured output, with the "reading" fields before the score
&lt;/h3&gt;

&lt;p&gt;Spring AI generates a JSON Schema from a Java record, Ollama enforces it, and I get a typed object back. The field order is deliberate: the model has to write down what level and location it &lt;em&gt;read&lt;/em&gt; in the posting before it's allowed to give a score. With a 4B model, that noticeably cut down on guessing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;JobScore&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;levelInText&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// what the posting actually says&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;locationInText&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;techRole&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;              &lt;span class="c1"&gt;// only after the above&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;reasons&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// each must quote the posting&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Reasons must quote the posting, and I check them
&lt;/h3&gt;

&lt;p&gt;Alerts only carry a snippet of each job, so small models love to fill the gaps. Every reason has to quote a word or phrase from the posting, and a tiny &lt;code&gt;Grounding&lt;/code&gt; class checks (accent- and case-insensitive) whether that quote really exists in the text. The page marks each reason ✓ or not. It doesn't correct the model; it just makes it obvious when it's making things up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Built to be interrupted
&lt;/h3&gt;

&lt;p&gt;Each job is saved as soon as it's scored, and processed emails are tracked by &lt;code&gt;Message-ID&lt;/code&gt; in H2 (so my Gmail "read" state is never touched). If a run dies halfway, the next one continues where it stopped.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two-line summary with a safety net
&lt;/h3&gt;

&lt;p&gt;A second Gemma call writes the two-line summary from the shortlist. If it leaves out a shortlisted company, a deterministic fallback summary is used instead. I'd rather have a boring summary than one that hides the only job worth applying to.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Privacy, concretely.&lt;/strong&gt; My résumé and the contents of my inbox never go to anyone's API. The only network traffic is the IMAP fetch; all inference runs on my own GPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero recurring cost.&lt;/strong&gt; This runs every night, forever. With a paid API, every scored job would cost something, every day, for as long as I'm job hunting. Locally, it's electricity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swapping models is one line.&lt;/strong&gt; &lt;code&gt;spring.ai.ollama.chat.model: gemma3:4b&lt;/code&gt; → &lt;code&gt;qwen2.5:3b&lt;/code&gt;. Same pipeline, same schema.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where It Fell Short (Honestly)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scores cluster.&lt;/strong&gt; 16 of the 49 jobs got exactly 65, just under my cutoff. A remote "Desenvolvedor Java Jr" landed at 65 while a remote "Backend Developer Jr" got 75. With only a snippet of each job, Gemma 4B seems to fall back to a few "default" scores when it's unsure. I'd rather it be cautious than pad my shortlist, but the boundary is fuzzy, and I check the 65s by hand for now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Messy titles are my fault, not the model's.&lt;/strong&gt; LinkedIn alerts glue extra text into the title area ("Recrutando agora", "Crescimento rápido", "3 ex-alunos…"), and my parser still picks some of it up. That's next on the list.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Clean up the LinkedIn title extraction.&lt;/li&gt;
&lt;li&gt;Run the same night's alerts through &lt;code&gt;qwen2.5:3b&lt;/code&gt; (replay mode already makes this a one-flag test) and compare.&lt;/li&gt;
&lt;li&gt;Tune the scoring guide so "unsure" doesn't always mean 65.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Best Use of Gemma&lt;/strong&gt;: Gemma 3 4B via Ollama does all the scoring and the morning summary, locally, with schema-enforced structured output.&lt;/p&gt;




&lt;p&gt;Related reading on DEV:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/jamilxt/stop-begging-your-llm-for-valid-json-self-correcting-structured-output-in-spring-ai-20-4hl7"&gt;Stop Begging Your LLM for Valid JSON: Self-Correcting Structured Output in Spring AI 2.0&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/tridaai/structured-output-is-not-a-guarantee-48f"&gt;Structured Output Is Not a Guarantee&lt;/a&gt;: a valid schema doesn't make the values true, which is why the ✓ check exists.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>java</category>
      <category>opensource</category>
    </item>
    <item>
      <title>A local AI that types up a Brazilian accountant's receipts (and knows when it's wrong)</title>
      <dc:creator>Matheus Ferreira</dc:creator>
      <pubDate>Fri, 16 Oct 2026 00:39:28 +0000</pubDate>
      <link>https://dev.to/mathfe/a-local-ai-that-types-up-a-brazilian-accountants-receipts-and-knows-when-its-wrong-5gbi</link>
      <guid>https://dev.to/mathfe/a-local-ai-that-types-up-a-brazilian-accountants-receipts-and-knows-when-its-wrong-5gbi</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hf26"&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;A self-employed accountant in Brazil gets &lt;strong&gt;~100 documents a week&lt;/strong&gt; from her small-business clients: NF-e invoices (PDF), NFC-e receipts photographed on a phone, Pix transfer screenshots, and the occasional handwritten &lt;em&gt;recibo&lt;/em&gt;. Today she types every one of them into a spreadsheet by hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Leitor de notas&lt;/strong&gt; ("receipt reader") turns that pile into a reviewed Excel file:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Drop the files into a Streamlit page and pick the accounting month (&lt;em&gt;competência&lt;/em&gt;).&lt;/li&gt;
&lt;li&gt;Each document is read by an &lt;strong&gt;open-weight model running locally&lt;/strong&gt; in Ollama.&lt;/li&gt;
&lt;li&gt;Python validates everything the model returned and flags the rows a human must check.&lt;/li&gt;
&lt;li&gt;She fixes the flagged rows in an editable table and exports to Excel (one sheet of entries, one summary by category).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Who pays for it?&lt;/strong&gt; Accountants bill small businesses a monthly fee, and data entry is the part of that work that doesn't scale. Saving a few hours a week per client is worth money. The catch is that these are clients' financial records, covered by Brazil's data protection law (LGPD). "Upload your clients' bank receipts to our cloud" is a hard sell to her, and to her clients.&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%2F0289gxgt1qua11dxvrx5.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%2F0289gxgt1qua11dxvrx5.png" alt=" " width="800" height="325"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;This project only works &lt;em&gt;because&lt;/em&gt; the model is open-weight and runs on the machine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Privacy is the product.&lt;/strong&gt; Nothing leaves the PC. The only network call is to Ollama on &lt;code&gt;localhost:11434&lt;/code&gt;. I even turned off Streamlit's usage stats (&lt;code&gt;gatherUsageStats = false&lt;/code&gt;), which are on by default, and bound the server to localhost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cost per document is zero.&lt;/strong&gt; At ~100 docs/week across many clients, a per-page API bill eats directly into an accountant's margin. A one-time GPU purchase doesn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I can swap models freely.&lt;/strong&gt; Model names live in one constant. When a better small vision model comes out, it's a one-line change and a re-run of the evaluation script, with no new contract and no new pricing.&lt;/li&gt;
&lt;/ul&gt;

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

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

&lt;p&gt;Evaluation on 10 &lt;strong&gt;fictitious&lt;/strong&gt; documents (text PDFs, Pix receipts, tilted phone photos, a handwritten receipt, and one deliberately unreadable blurry receipt):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;field&lt;/th&gt;
&lt;th&gt;date&lt;/th&gt;
&lt;th&gt;amount&lt;/th&gt;
&lt;th&gt;CNPJ&lt;/th&gt;
&lt;th&gt;category&lt;/th&gt;
&lt;th&gt;path&lt;/th&gt;
&lt;th&gt;needs review&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;correct&lt;/td&gt;
&lt;td&gt;9/10&lt;/td&gt;
&lt;td&gt;9/10&lt;/td&gt;
&lt;td&gt;9/10&lt;/td&gt;
&lt;td&gt;10/10&lt;/td&gt;
&lt;td&gt;10/10&lt;/td&gt;
&lt;td&gt;10/10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;95% of fields correct.&lt;/strong&gt; All three misses come from the blurry receipt. The model hallucinated a date and a CNPJ there, and validation caught both, so the document landed in the review queue, which is exactly what should happen. Average time on an AMD RX 9060 XT (Ollama, Vulkan): &lt;strong&gt;2.2 s per text PDF, 4.0 s per image&lt;/strong&gt;. That's about 5 minutes for a week of documents.&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/MathFe" rel="noopener noreferrer"&gt;
        MathFe
      &lt;/a&gt; / &lt;a href="https://github.com/MathFe/leitor-notas-local" rel="noopener noreferrer"&gt;
        leitor-notas-local
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Leitor local de notas fiscais e comprovantes de Pix com IA open-source (Ollama) — DEV Hacktoberfest 2026 · Side Hustle
    &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;Leitor local de notas fiscais e comprovantes de Pix&lt;/h1&gt;

&lt;/div&gt;
&lt;p&gt;Projeto para o &lt;strong&gt;DEV Hacktoberfest 2026 — desafio "Side Hustle"&lt;/strong&gt;: IA open-source rodando 100% local
(Ollama + Qwen 2.5 / Qwen 2.5-VL) para ler notas fiscais, cupons NFC-e, comprovantes de Pix e recibos
de uma contadora autônoma e transformar tudo numa planilha Excel, sem nenhum dado sair da máquina (LGPD).&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/MathFe/leitor-notas-local/leitor_notas/" rel="noopener noreferrer"&gt;&lt;code&gt;leitor_notas/&lt;/code&gt;&lt;/a&gt; — o aplicativo (pipeline, validação, avaliação e interface Streamlit). Veja o README dele.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/MathFe/leitor-notas-local/amostras-mariana/" rel="noopener noreferrer"&gt;&lt;code&gt;amostras-mariana/&lt;/code&gt;&lt;/a&gt; — 10 documentos &lt;strong&gt;fictícios&lt;/strong&gt; + gabarito para avaliar a acurácia.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Resultado nas amostras: &lt;strong&gt;95% dos campos corretos&lt;/strong&gt;, com os 2 documentos problemáticos enviados para revisão.&lt;/p&gt;
&lt;p&gt;Licença: &lt;a href="https://github.com/MathFe/leitor-notas-local/LICENSE" rel="noopener noreferrer"&gt;MIT&lt;/a&gt;.&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/MathFe/leitor-notas-local" 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;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Stack:&lt;/strong&gt; Python, Ollama, &lt;code&gt;qwen2.5:3b&lt;/code&gt; (text), &lt;code&gt;qwen2.5vl:7b&lt;/code&gt; (vision), pdfplumber, Pillow, pydantic, pandas/openpyxl, Streamlit.&lt;/p&gt;

&lt;h3&gt;
  
  
  A hybrid pipeline: the cheapest model that can do the job
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PDF with extractable text (&amp;gt; 50 chars) ──► pdfplumber ──► qwen2.5:3b
Scanned PDF ──► render page to image ───┐
JPG / PNG ──────────────────────────────┴──► qwen2.5vl:7b
                         │
                         ▼
   JSON forced by schema ─► Python validation ─► needs_review = True/False
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most NF-e invoices arrive as real PDFs with a text layer, so they never need a vision model. Output is constrained with Ollama's &lt;code&gt;format&lt;/code&gt; parameter, which takes the JSON Schema of a pydantic model, so the response always parses.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lesson 1: don't let a small model reason about roles
&lt;/h3&gt;

&lt;p&gt;My first prompt asked the model for "the counterparty: the issuer if it was a purchase, the payer if it was income". The 3B model kept returning the &lt;strong&gt;recipient&lt;/strong&gt; of the invoice, which is the client herself.&lt;/p&gt;

&lt;p&gt;The fix was to ask only for what is &lt;em&gt;literally on the page&lt;/em&gt;, "who received the money" and "who paid", and let Python decide the counterparty. Better still, a Brazilian invoice's 44-digit &lt;strong&gt;access key embeds the issuer's CNPJ&lt;/strong&gt; (company tax ID), so Python can check the model's answer and undo a swap deterministically:&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;if&lt;/span&gt; &lt;span class="n"&gt;chave&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;validar_chave_acesso&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chave&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;emitente&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cnpj_da_chave&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chave&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# digits 7–20 of the access key
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;normalizar_cnpj&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pagou&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="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;emitente&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nf"&gt;normalizar_cnpj&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recebeu&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="n"&gt;recebeu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pagou&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pagou&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recebeu&lt;/span&gt;      &lt;span class="c1"&gt;# model swapped issuer and buyer
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same idea applies to the access key itself. A 3B model copying 44 digits will drop one, so for text PDFs a regex finds the key and its mod-11 check digit confirms it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lesson 2: one small prompt per decision
&lt;/h3&gt;

&lt;p&gt;Category accuracy was &lt;strong&gt;70%&lt;/strong&gt; when the extraction prompt also picked the category. "Limpeza e recarga gás split" (A/C cleaning) came out as &lt;em&gt;cleaning supplies&lt;/em&gt;, and a received Pix came out as &lt;em&gt;food&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Two changes took it to &lt;strong&gt;100%&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Whether money came in or went out is decided by &lt;strong&gt;rules&lt;/strong&gt;: the document type, plus whether the client's own CNPJ is the receiver. Income is always "Receita". The model never gets to guess it.&lt;/li&gt;
&lt;li&gt;Expenses get a &lt;strong&gt;second, tiny call&lt;/strong&gt; to the 3B model with only the supplier name, the document type and a short description, and an enum of expense categories with one-line hints. It costs ~0.3 s.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Small models are much better at one narrow question than at five questions in one prompt.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lesson 3: validation is the feature
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://dev.to/taranpreet_kaur_4b538d878/why-an-llm-alone-cannot-do-invoice-extraction-yet-1oa5"&gt;Why an LLM Alone Cannot Do Invoice Extraction Yet&lt;/a&gt; by Taranpreet Kaur makes the case that model output needs an independent check, and that matched what I saw. Every record goes through Python before anyone trusts it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CNPJ check digits, including the &lt;strong&gt;new alphanumeric CNPJ&lt;/strong&gt; in force since July 2026&lt;/li&gt;
&lt;li&gt;NF-e access key: 44 digits plus mod-11 check digit, and the CNPJ must match the issuer inside the key&lt;/li&gt;
&lt;li&gt;amount &amp;gt; 0, and date inside the chosen &lt;em&gt;competência&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;an expense with no supplier CNPJ (e.g. a recibo from an individual) is flagged so she can check withholding taxes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Any failure sets &lt;code&gt;revisar = True&lt;/code&gt; with a human-readable reason, such as &lt;em&gt;"data 29/09/2026 fora da competência 10/2026"&lt;/em&gt; ("date outside the accounting month"). A document that crashes the pipeline becomes a flagged row instead of killing the batch. The accountant doesn't need the AI to be perfect. She needs to know &lt;strong&gt;which 2 of the 10 to look at&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is the same layering described in &lt;a href="https://dev.to/devshakib/structured-output-from-llms-a-retry-repair-loop-your-parser-never-sees-through-3b0b"&gt;Structured Output From LLMs: A Retry-Repair Loop Your Parser Never Sees Through&lt;/a&gt; by devshakib: constrained decoding, then validation you own. I chose to route failures to a human rather than retry, because a retry on a blurry photo just produces a different hallucination.&lt;/p&gt;

&lt;h3&gt;
  
  
  Measure, don't vibe
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;avaliar.py&lt;/code&gt; runs the whole set against a &lt;code&gt;gabarito.json&lt;/code&gt; (answer key) and prints accuracy per field and per document, plus the mandatory check that documents 09 and 10 end up in review. Every change above was kept or reverted based on that table. The test documents are generated by a script (fake companies, valid fake CNPJs and access keys, simulated phone photos with rotation, blur and noise), so anyone can reproduce the numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Her reaction
&lt;/h2&gt;

&lt;p&gt;When I showed it to her, she loved it. Typing up these documents is a problem she has had for years, and she didn't know it was something that could be solved. She told me that if she had known, she would have done this a long time ago. She was really grateful, and she said she would pay R$ 2,000 for it.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Run it on her own machine with her real documents and collect what she asks for next.&lt;/li&gt;
&lt;li&gt;Multi-page NF-e with many items, and reading the NFC-e QR code directly instead of relying on the vision model to read the key.&lt;/li&gt;
&lt;li&gt;An export in the layout her accounting software imports.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Built with an AI coding agent. The repo is MIT-licensed.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>python</category>
      <category>ai</category>
    </item>
    <item>
      <title>Duelo de Fotos: a photo duel for two, judged by a local Gemma that is sometimes confidently wrong</title>
      <dc:creator>Matheus Ferreira</dc:creator>
      <pubDate>Wed, 07 Oct 2026 02:54:21 +0000</pubDate>
      <link>https://dev.to/mathfe/duelo-de-fotos-a-photo-duel-for-two-judged-by-a-local-gemma-that-is-sometimes-confidently-wrong-1bmg</link>
      <guid>https://dev.to/mathfe/duelo-de-fotos-a-photo-duel-for-two-judged-by-a-local-gemma-that-is-sometimes-confidently-wrong-1bmg</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&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;My girlfriend and I like going out, by day or by night. We also both own a phone, which means a walk can quietly turn into two people looking at two screens in a nicer location.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;Duelo de Fotos&lt;/strong&gt; ("photo duel"), a game for the two of us that cannot be played from the couch.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Before leaving&lt;/strong&gt;, we type our two names and say whether the walk is by day or by night. A Gemma model running on my computer picks five secret themes for each of us: "something rusty", "something that blinks", "something that looks like it has a face".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Each of us opens only our own card&lt;/strong&gt;, takes a picture of it with the phone, and closes it. From here on the phone is a camera and nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outside&lt;/strong&gt;, we each hunt for one photo per theme. Interpretation is allowed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Back home&lt;/strong&gt;, the photos go in and Gemma becomes the judge. It looks at each photo on its own, gives it a score from 0 to 10 and one sentence explaining why. The page adds it up and says who won.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then we argue with the judge&lt;/strong&gt;, because it is a 4B model and it is sometimes confidently wrong. I built the game around that instead of hiding it, and I will come back to why.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The challenge says the best builds "should make the screen the shortest part of the experience". That sentence shaped everything. The app has two short moments, one before the walk and one after. In between there is no app to open, nothing to fill in, and no reason to look at the phone except to take a picture of a rusty gate.&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%2Ff7zyezi0a3yzs8ntksoa.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%2Ff7zyezi0a3yzs8ntksoa.png" alt="The scoreboard after a test duel" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The scoreboard from a test duel. These are drawings I generated to test the judge, not real photos.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One thing I want to say up front: the game is finished and working, but we have not played it on a real walk yet. Everything below about how the judge behaves comes from test runs at my desk. The first real duel is the next thing on the list.&lt;/p&gt;

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

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

&lt;p&gt;The app runs locally, so there is no hosted link. The video shows it running on my own computer.&lt;/p&gt;

&lt;p&gt;The interface is in Brazilian Portuguese, because the game is for the two of us. What you are seeing, in order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Sortear os temas"&lt;/strong&gt; (draw the themes): the form with the two names and the day or night choice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Carta secreta"&lt;/strong&gt; (secret card): one player's five themes. Each theme starts with "Algo", which means "something".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Chamar o juiz"&lt;/strong&gt; (call the judge): the upload page, one photo per theme.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The scoreboard&lt;/strong&gt;: the two totals, and under each photo the theme in bold and the judge's reason in grey.&lt;/li&gt;
&lt;/ul&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/MathFe" rel="noopener noreferrer"&gt;
        MathFe
      &lt;/a&gt; / &lt;a href="https://github.com/MathFe/Touch-Grass" rel="noopener noreferrer"&gt;
        Touch-Grass
      &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;Duelo de Fotos&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;A photo duel for two people, judged by a Gemma model running on your own computer.&lt;/p&gt;
&lt;p&gt;Before you go out, Gemma hands each player five secret themes ("something rusty", "something that blinks"). Outside, your phone is only a camera. Back home, you drop the photos in, Gemma scores each one from 0 to 10 with a one-line reason, and the scoreboard tells you who won.&lt;/p&gt;
&lt;p&gt;The screen is the short part: about a minute before the walk and a minute after. Everything in between happens outside.&lt;/p&gt;
&lt;p&gt;Built for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05" rel="nofollow"&gt;DEV Hacktoberfest 2026 challenge, week 1: "Touch Grass"&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/MathFe/Touch-Grass/docs/placar.png"&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%2FMathFe%2FTouch-Grass%2FHEAD%2Fdocs%2Fplacar.png" alt="The scoreboard after a test duel"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The photos in the screenshots are test drawings, not real pictures. The app's interface is in Brazilian Portuguese.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How a duel goes&lt;/h2&gt;
&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Draw the themes.&lt;/strong&gt; Type the two names, say whether the walk is by day or by night, and optionally what kind of place it is. Gemma picks…&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/MathFe/Touch-Grass" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;MIT licensed. To run it you need Java 25 and &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull gemma3:4b
./mvnw spring-boot:run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open &lt;code&gt;http://127.0.0.1:8080&lt;/code&gt;.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser  -&amp;gt;  Spring Boot  -&amp;gt;  Spring AI ChatClient  -&amp;gt;  Ollama  -&amp;gt;  Gemma 3 (4B)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Java 25, Spring Boot 4.1, Spring AI 2.0, Thymeleaf pages, and &lt;code&gt;gemma3:4b&lt;/code&gt; through Ollama. Duels are stored as a folder on disk with the photos and one JSON file. There is no database.&lt;/p&gt;

&lt;h3&gt;
  
  
  The first idea was wrong
&lt;/h3&gt;

&lt;p&gt;I did not start with a game. I started with a walk diary: you come home, drop in your photos, and Gemma writes a caption for each one and lays them out like polaroids on a corkboard. I even had a star map of the sky above the place and time of the last photo.&lt;/p&gt;

&lt;p&gt;It worked, and it was dry. It organized a walk that had already happened. Nothing in it gave anyone a reason to leave the house, and the theme of the week is exactly that. I threw it away the same evening, star map included.&lt;/p&gt;

&lt;p&gt;The duel fixes this at the root: the app has nothing to judge until you go out and come back.&lt;/p&gt;

&lt;h3&gt;
  
  
  Letting a small model invent the themes did not work
&lt;/h3&gt;

&lt;p&gt;My first version of the duel asked Gemma to write ten themes from scratch. I tried four different prompts, and each one failed in its own way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Too poetic.&lt;/strong&gt; "Shared laughter in silence", "Time in every instant". Nobody can photograph those. One theme, "People in search of moments", asked for photos of strangers, which the prompt had explicitly forbidden.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Too specific.&lt;/strong&gt; After I asked for concrete things: "Blue street-cleaning truck", "Large polished metal chandeliers". Good luck finding those on a Tuesday night.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stilted.&lt;/strong&gt; After I asked for "something" plus a visible quality and listed the kinds of quality (colour, shape, size, material), the model copied my category words straight into the output: "Something size grandiose imposing", "Something position horizontal calm".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Poetic again.&lt;/strong&gt; After I removed the list of categories: "Something that resonates mysteriously".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Last week, building &lt;a href="https://dev.to/mathfe/papel-claro-a-local-gemma-that-explains-my-grandmothers-bank-letters-in-plain-portuguese-1l63"&gt;Papel Claro&lt;/a&gt;, I learned that a list of examples is a list of suggestions to a 4B model. This week I learned the same is true of a list of categories.&lt;/p&gt;

&lt;p&gt;So I stopped asking the model to write. The themes now come from &lt;a href="https://github.com/MathFe/Touch-Grass/blob/main/src/main/resources/temas.txt" rel="noopener noreferrer"&gt;a hand-written list of 75&lt;/a&gt;, each tagged as day, night or any time. For each duel the code draws 24 candidates and asks Gemma to choose the ten that fit the walk. Any line it returns that is not in the list is ignored, and if it picks fewer than ten the code fills the rest. In four test runs it returned ten valid themes every time, and the themes read like something a person would say: "something with a double shadow", "something that grew where it should not have".&lt;/p&gt;

&lt;p&gt;The honest cost: Gemma now chooses instead of creating, and the "kind of place" you type barely changes the result. Day versus night makes a real difference, and that comes from the list.&lt;/p&gt;

&lt;h3&gt;
  
  
  One photo per call, and a strict answer format
&lt;/h3&gt;

&lt;p&gt;Each photo goes to the model alone, with its theme. Last week the same model read a close-up well and a full page badly, so I never ask it to compare two images.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;resposta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pedido&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;media&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tipo&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ByteArrayResource&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;foto&lt;/span&gt;&lt;span class="o"&gt;)))&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Veredito&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;veredito&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vereditoDe&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resposta&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model must answer with a JSON object holding a score and a reason. A small model sometimes returns JSON without the fields you asked for, so an answer missing either one is a failure, not an empty verdict:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;veredito&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;veredito&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;nota&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;veredito&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;nota&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;veredito&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;nota&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
        &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;veredito&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;motivo&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;veredito&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;motivo&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;isBlank&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;empty&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app tries once more and then shows "the judge could not judge this photo", worth zero points.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the judge gets wrong
&lt;/h3&gt;

&lt;p&gt;I tested the judge with simple drawings. One of them was a red ball on a grey floor.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For the theme "something vivid blue", the judge gave it &lt;strong&gt;3&lt;/strong&gt;. Correct. Its reason: "The photo shows the flag of Japan." It is not the flag of Japan.&lt;/li&gt;
&lt;li&gt;For the theme "something made of smooth wood", the judge gave the same red ball &lt;strong&gt;8&lt;/strong&gt;, because it saw "a block of smooth wood".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is also generous: seven of the nine scores in that test landed on 7 or 8.&lt;/p&gt;

&lt;p&gt;In most projects this would be the section where I explain how I worked around the model. Here I decided not to. A referee who is occasionally, confidently wrong is a familiar thing in every sport, and I expect arguing about the call to be half the fun. The reason is printed under every photo so there is always something specific to argue with. What I did instead was make the failure visible and cheap: a low-stakes game is the right place for a 4B judge. A tool that decided anything important with these verdicts would not be.&lt;/p&gt;

&lt;h3&gt;
  
  
  Things I only found by running it
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The scoreboard crashed the first time a full duel finished.&lt;/strong&gt; Thymeleaf could not call a non-public method on my record, and the unit tests had never rendered that page. There is now a test that renders every page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spring AI retries were already off.&lt;/strong&gt; I carried that over from last week: with the default, a closed Ollama freezes the page for minutes. In Spring AI 2.0 the setting counts retries, so &lt;code&gt;1&lt;/code&gt; still makes two calls and &lt;code&gt;0&lt;/code&gt; makes one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logs record only the exception class.&lt;/strong&gt; The message of a failed model call can quote what was sent to the model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On my machine (an AMD RX 9060 XT and 16 GB of RAM) choosing the themes takes about 2 seconds, and judging nine small test drawings took 18 seconds. Full-size phone photos will take longer, and I have not measured by how much.&lt;/p&gt;

&lt;h3&gt;
  
  
  Honest limits
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It has not been played on a real walk yet.&lt;/strong&gt; The judge was tested with drawings, not with photos taken outside, and night photos are the case I trust least.&lt;/li&gt;
&lt;li&gt;The judge is generous and sometimes wrong, as described above.&lt;/li&gt;
&lt;li&gt;HEIC photos are not accepted, so an iPhone has to save JPEG.&lt;/li&gt;
&lt;li&gt;There is no list of past duels. You return to a duel through its address.&lt;/li&gt;
&lt;li&gt;The interface is in Portuguese only.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;The photos are of us.&lt;/strong&gt; A game like this collects exactly the pictures I would not upload anywhere: the two of us, the streets around where we live, at the times we are usually out. With an open-weight model on my own computer, the photos go from the browser to a folder on the same machine. The server listens on &lt;code&gt;127.0.0.1&lt;/code&gt; only. Nothing in the project can send a photo anywhere, because nothing in it talks to anything but the local Ollama.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A silly game should cost nothing to play.&lt;/strong&gt; One duel is one call to choose themes and ten vision calls to judge. With a paid API I would start counting, and a game you ration is not a game. With a local model the tenth duel of the month costs the same as the first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You can read what the judge was told.&lt;/strong&gt; The judge's instructions and the list of themes are plain text in the repository. When a verdict is absurd, I can see exactly what the model was asked. And the list is the part I expect people to change: add your own themes, your own inside jokes, one line each.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The judge can be replaced.&lt;/strong&gt; The model is one line in &lt;code&gt;application.properties&lt;/code&gt;. If you have the hardware for a larger Gemma and want a stricter referee, change the name and play again.&lt;/p&gt;

&lt;p&gt;None of the heavy pieces are mine. Gemma, Ollama, Spring AI and Spring Boot are all open. My part was finding a use for them that ends with two people outside.&lt;/p&gt;

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

&lt;p&gt;I built this with Claude Code, in one long session, and I would rather describe it plainly.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The code was written by Claude.&lt;/strong&gt; I chose the stack and the constraints, and I ran and looked at every step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The direction was mine, mostly by saying no.&lt;/strong&gt; The walk diary was my idea and I was the one who called it dry. The star map was also my idea, and I dropped it a few minutes after seeing it work. I asked for other ideas, got five, and picked the duel. When the first design looked tacky, I asked for a cleaner one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This article was drafted with Claude too.&lt;/strong&gt; The idea, the decisions and the person it is for are mine.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; A single &lt;code&gt;gemma3:4b&lt;/code&gt;, running locally through Ollama, does both jobs in the game. As text model it chooses the themes that fit the walk. As vision model it looks at every photo and writes the verdict. Its size is what makes the project possible, since it runs on an ordinary home computer and the photos never leave it. Its size is also what makes the project fun: the game is built around a judge that can be wrong, instead of pretending it never is.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>java</category>
    </item>
    <item>
      <title>Papel Claro: a local Gemma that explains my grandmother's bank letters in plain Portuguese</title>
      <dc:creator>Matheus Ferreira</dc:creator>
      <pubDate>Mon, 05 Oct 2026 00:02:41 +0000</pubDate>
      <link>https://dev.to/mathfe/papel-claro-a-local-gemma-that-explains-my-grandmothers-bank-letters-in-plain-portuguese-1l63</link>
      <guid>https://dev.to/mathfe/papel-claro-a-local-gemma-that-explains-my-grandmothers-bank-letters-in-plain-portuguese-1l63</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/devteam/join-the-hacktoberfest-weekend-challenge-build-for-a-friend-2450-in-prizes-across-17-winners-1aj5"&gt;Hacktoberfest Weekend Challenge: Build for a Friend&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;My grandmother gets letters from her bank that she cannot make sense of. It is not one letter, it keeps happening. Each time she does one of three things: she ignores it, she pays without understanding what she is paying for, or she asks someone to read it to her.&lt;/p&gt;

&lt;p&gt;A regular chatbot was never going to help. She does not know how to use one, she does not trust them, and she is afraid of exposing her data on the internet.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;Papel Claro&lt;/strong&gt; ("clear paper") for her. You paste the text of a difficult document, or take a photo of it, and it answers the questions a person actually has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What is this?&lt;/strong&gt; The type of document and a summary in two to four short sentences.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What do I have to do?&lt;/strong&gt; A numbered list of actions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;By when, and how much?&lt;/strong&gt; Dates and amounts, copied as they are written.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What should I watch out for?&lt;/strong&gt; Fines, interest, a name sent to a credit bureau.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What do these words mean?&lt;/strong&gt; A small glossary of the hard words in the document.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After that you can ask follow-up questions ("can I pay this in instalments?"), and the app answers only from the document. If the document does not say, it tells you so. There is also a button that reads the explanation aloud.&lt;/p&gt;

&lt;p&gt;Brazilian contracts and bank letters are written in &lt;em&gt;juridiquês&lt;/em&gt;, a dialect of Portuguese that seems designed to be read by lawyers only. A sentence like "o não pagamento poderá ensejar o vencimento antecipado das parcelas vincendas" means "if you do not pay, the bank can demand all the remaining instalments at once". Nobody should need a lawyer to learn that.&lt;/p&gt;

&lt;p&gt;Her fear about her data is reasonable. These letters carry a full name, an address, a CPF (the Brazilian tax ID) and the exact size of a debt. That is the last text I want my grandmother to paste into a website. So the model runs on a computer at home, and she opens the page on her phone over the home Wi-Fi. The document never leaves the house.&lt;/p&gt;

&lt;p&gt;She has already used it that way, and she thought it was wonderful.&lt;/p&gt;

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

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

&lt;p&gt;The app runs locally, so there is no hosted link. The video shows it running on my own computer.&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/MathFe" rel="noopener noreferrer"&gt;
        MathFe
      &lt;/a&gt; / &lt;a href="https://github.com/MathFe/papel-claro" rel="noopener noreferrer"&gt;
        papel-claro
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Explains difficult documents in plain Brazilian Portuguese, running Gemma locally through Ollama.
    &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;Papel Claro&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Paste a confusing document, or take a photo of it, and get it explained in plain Brazilian Portuguese.&lt;/strong&gt;
Everything runs on your own computer with &lt;a href="https://ai.google.dev/gemma" rel="nofollow noopener noreferrer"&gt;Gemma&lt;/a&gt; through &lt;a href="https://ollama.com" rel="nofollow noopener noreferrer"&gt;Ollama&lt;/a&gt;. The document never leaves the machine.&lt;/p&gt;
&lt;p&gt;Built for the DEV &lt;a href="https://dev.to/devteam/join-the-hacktoberfest-weekend-challenge-build-for-a-friend-2450-in-prizes-across-17-winners-1aj5" rel="nofollow"&gt;Hacktoberfest Weekend Challenge: Build for a Friend&lt;/a&gt;.&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;p&gt;Bank letters, rental contracts, collection notices and official notifications in Brazil are written in dense legal Portuguese ("juridiquês"). Papel Claro reads the document and answers the questions a person actually has:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What is this?&lt;/strong&gt; The type of document and a summary in two to four short sentences.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What do I have to do?&lt;/strong&gt; A numbered list of actions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;By when, and how much?&lt;/strong&gt; Dates and amounts, copied exactly as they appear.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What should I watch out for?&lt;/strong&gt; Fines, interest, automatic renewal, and common signs of a scam.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What do these words mean?&lt;/strong&gt; A small glossary of the hard…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/MathFe/papel-claro" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;MIT licensed. To run it you need Java 17 or newer and &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull gemma3:4b
./mvnw spring-boot:run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open &lt;code&gt;http://localhost:8080&lt;/code&gt;, or &lt;code&gt;http://&amp;lt;computer-ip&amp;gt;:8080&lt;/code&gt; from a phone on the same Wi-Fi.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser  -&amp;gt;  Spring Boot  -&amp;gt;  Spring AI ChatClient  -&amp;gt;  Ollama  -&amp;gt;  Gemma 3 (4B)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The stack is deliberately boring: Java, Spring Boot 4.1, Spring AI 2.0, and a page in plain HTML, CSS and JavaScript. There is no database, because there is nothing to keep.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One model does everything.&lt;/strong&gt; &lt;code&gt;gemma3:4b&lt;/code&gt; reads images as well as text, so a photo of a letter goes straight to the model. There is no separate OCR step, no second model, and no extra service to install. The same model writes the summary, extracts dates and amounts, and answers the follow-up questions. It is a 3.3 GB download, and on my machine (an AMD RX 9060 XT and 16 GB of RAM) an explanation takes 6 to 10 seconds and a follow-up answer 1 to 3 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The answer is a Java record, not a blob of text.&lt;/strong&gt; Spring AI asks the model for JSON and maps it onto records, so the screen can show actions, dates and amounts in separate blocks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;chatClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;EXPLAIN_TEMPLATE&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"document"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;documentText&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
            &lt;span class="n"&gt;attachImage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;})&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;entity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DocumentExplanation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The document goes in as a template parameter, so a contract full of &lt;code&gt;{braces}&lt;/code&gt; is never read as template syntax.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What a real run taught me.&lt;/strong&gt; The first version was written against the documentation and had never been compiled. The first time it ran against a real model, four things came up that no documentation would have told me:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A closed Ollama froze the page for minutes.&lt;/strong&gt; Spring AI retries failed calls up to ten times with a growing wait. For a developer that is resilience. For my grandmother, tapping a button on her phone while Ollama happens to be closed on the computer, it is a screen that never answers. With a single retry attempt, the page now says "open Ollama and try again" in about two seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A log line broke the privacy promise.&lt;/strong&gt; When the model returned JSON in the wrong shape, the exception message quoted the offending value, and that value was a sentence from the document. The code was logging that message. Now the log records only the kind of failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An empty answer looked like success.&lt;/strong&gt; A small model sometimes returns JSON with none of the expected fields. The records filled in defaults and the screen showed an empty explanation. That now counts as a failure and triggers a second try.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The prompt was putting ideas in the model's head.&lt;/strong&gt; The system prompt listed signs of a scam: requests for a password, PIX to a private person, strange links. The 4B model started warning about PIX in documents that never mentioned it. A list of examples is a list of suggestions to a small model. The rule now says "only mention a scam if the document itself shows one of these signs", and each field is told what to copy and from where. The invented warnings stopped.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last one is the main thing I learned about prompting a 4B model: be literal, say where each value comes from, and never offer an example you would not want to see in the output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest limits.&lt;/strong&gt; A 4B model is not a lawyer, and the app says so on every result.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A photo of a full page in small print is not reliable.&lt;/strong&gt; In testing, a photo of a whole A4 page produced an amount that was not in the document. A close-up of one paragraph was read correctly. So the advice is: photograph the part you want to understand, from close.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The glossary can be wrong.&lt;/strong&gt; It explained "pro rata die" incorrectly every time. Dates and amounts come from the document; definitions come from the model's memory, and that is the weaker part.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text is limited to 16,000 characters&lt;/strong&gt;, which fits the 8,192-token context with room for the answer.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Because of who this is for.&lt;/p&gt;

&lt;p&gt;With a hosted model, helping my grandmother would mean sending a document with her name, address, CPF and debt to a company, under terms neither of us would read. I would be solving one unreadable contract by agreeing to another. It would also be the exact thing she is afraid of. With an open-weight model on a computer at home, the document travels from her phone to that computer over the home Wi-Fi and nowhere else. The app has no database, keeps nothing once the request is answered, and does not log document content.&lt;/p&gt;

&lt;p&gt;Open weights also make this something I can give away. There is no API key to pay for, no account to create, no usage limit, and it keeps working without internet once the model is downloaded. My grandmother does not depend on me keeping a subscription alive, or on a company deciding to keep a product running.&lt;/p&gt;

&lt;p&gt;And it can be checked. The prompts are in the repository, in Portuguese, next to the code. If the explanation is wrong, anyone can see exactly what the model was asked. For a tool that tells people what a legal document means, I think that matters more than it does for most software.&lt;/p&gt;

&lt;p&gt;None of the pieces here are mine: Gemma, Ollama, Spring AI and Spring Boot are all open. My part was choosing the problem and spending a weekend getting them to work together for one person. That is the point of open innovation for me. The distance between "someone I love has a problem" and "here is a tool for it" has become short enough to cover in a weekend.&lt;/p&gt;

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

&lt;p&gt;I did not build this alone, and I would rather say so plainly.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;First version.&lt;/strong&gt; I chose the stack (Java, Spring Boot, a local Gemma) and the problem to solve. Claude wrote the first version of the code in the Claude desktop app while I followed along and it explained each decision. That session could not reach Maven Central, so the code was written against the Spring AI 2.0 documentation and had never been compiled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review.&lt;/strong&gt; Before submitting, I asked Claude Code to review the project on my machine. It ran the first real build, downloaded the model, called the app with text and photo documents, and found the four problems described above, plus the photo limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This article&lt;/strong&gt; was drafted with Claude too. Everything in it about my grandmother is mine.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; Gemma is the whole product, not a feature of it. A single &lt;code&gt;gemma3:4b&lt;/code&gt;, running locally through Ollama, does three jobs: it reads the photo of the document, it turns legal Portuguese into structured plain-language output, and it answers follow-up questions grounded in that document. Its size is what makes the privacy argument real: it is small enough to run on an ordinary home computer, so the document never has to leave the house.&lt;/p&gt;

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