<?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: Mauricio Andrade</title>
    <description>The latest articles on DEV Community by Mauricio Andrade (@mauricioandrade).</description>
    <link>https://dev.to/mauricioandrade</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%2F4164493%2F31f3a6d2-2291-4e11-8d7f-e70c282dec5a.jpg</url>
      <title>DEV Community: Mauricio Andrade</title>
      <link>https://dev.to/mauricioandrade</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mauricioandrade"/>
    <language>en</language>
    <item>
      <title>Kuyō: a funeral for the code you deleted, written by a local Gemma</title>
      <dc:creator>Mauricio Andrade</dc:creator>
      <pubDate>Wed, 28 Oct 2026 17:54:29 +0000</pubDate>
      <link>https://dev.to/mauricioandrade/kuyo-a-funeral-for-the-code-you-deleted-written-by-a-local-gemma-c4i</link>
      <guid>https://dev.to/mauricioandrade/kuyo-a-funeral-for-the-code-you-deleted-written-by-a-local-gemma-c4i</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;DEV Hacktoberfest 2026 Challenge&lt;/a&gt;, week 4: Ghost in the Machine.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;EmailService.java, who managed the dispatch of status notifications for eleven days, met a swift end. It was decommissioned by the decree: "chore: remover EmailService (código morto, email de status não será usado)". May the unnecessary transmissions it once managed now find eternal silence.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I deleted that class myself, in a commit whose message calls it dead code. I never thought about it again until a Gemma model running on my own machine wrote it an epitaph.&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%2F3yk46o0suoq5va1wyqty.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%2F3yk46o0suoq5va1wyqty.gif" alt="The cemetery of a private repository: graves with spirit flames under a full moon, and a washi strip writing an epitaph" width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://mauricioandrade.github.io/kuyo/cemetery.html" rel="noopener noreferrer"&gt;Walk through the cemetery →&lt;/a&gt;&lt;/strong&gt; (hover or tap a grave)&lt;/p&gt;

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

&lt;p&gt;In Japan, &lt;em&gt;kuyō&lt;/em&gt; (供養) is a memorial service for objects that served well. Needles, dolls, brushes and even eyeglasses are thanked and laid to rest instead of just being thrown away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kuyō does the same for source code.&lt;/strong&gt; You point it at a git repository and it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;exhumes&lt;/strong&gt; every file that was ever deleted, following renames so that a moved file isn't counted as dead;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;examines&lt;/strong&gt; each death: how long the file lived, the hour it died, how large the commit that killed it was, whether any test witnessed it, and what its last words were (the commit message);&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;scores its restlessness&lt;/strong&gt; from 0 to 1 and decides the cause of death: violent, premature, forgotten or natural;&lt;/li&gt;
&lt;li&gt;asks &lt;strong&gt;Gemma, running locally on Ollama&lt;/strong&gt;, to write an epitaph from those facts;&lt;/li&gt;
&lt;li&gt;renders a &lt;strong&gt;cemetery&lt;/strong&gt;: one self-contained HTML file where restless souls haunt their graves.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It can also ask a code reviewer for an autopsy of each dead Java file, haunt your shell so that deleted files whisper back, and run as a GitHub Action that reburies your repository on every push.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kuyo scan path/to/repo     &lt;span class="c"&gt;# the dead and their cause of death, no LLM&lt;/span&gt;
kuyo bury path/to/repo     &lt;span class="c"&gt;# epitaphs + cemetery.html&lt;/span&gt;
kuyo seance path/to/repo EmailService.java   &lt;span class="c"&gt;# talk to a dead file&lt;/span&gt;
kuyo bury path/to/repo &lt;span class="nt"&gt;--autopsy&lt;/span&gt;            &lt;span class="c"&gt;# plus the Shishō's autopsy&lt;/span&gt;
kuyo haunt &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.bashrc                      &lt;span class="c"&gt;# deleted files whisper back&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

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

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


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://mauricioandrade.github.io/kuyo/cemetery.html" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;mauricioandrade.github.io&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;



&lt;p&gt;The cemetery above comes from &lt;strong&gt;stocklentes-hub, a private repository of mine&lt;/strong&gt;: 137 deleted files in five months. The page shows only names, dates, commit messages and Gemma's epitaphs, and the code never left my machine.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It is a &lt;strong&gt;Japanese cemetery&lt;/strong&gt;: every file gets a granite family grave with its name carved in gold, &lt;em&gt;sotoba&lt;/em&gt; slats behind it and incense in front.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The facts are the scene.&lt;/strong&gt; Pillar height follows the file's lifespan, and the &lt;em&gt;hitodama&lt;/em&gt; (spirit flames) above each grave show how restless it is, from one calm blue flame to three trembling violet ones. Violent graves lean and a &lt;em&gt;yūrei&lt;/em&gt; rises behind them, forgotten ones grow moss, and premature ones get a small Jizō with a red bib, as children's graves do in Japan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Approach a grave&lt;/strong&gt; and its epitaph is brushed onto a washi strip, stamped with the cause of death as a vermilion seal (横死 violent, 夭折 premature, 無縁 forgotten, 天寿 natural).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn the sound on&lt;/strong&gt; for wind, crickets and an &lt;em&gt;orin&lt;/em&gt; bell whose tone follows the cause of death, all synthesized in the browser.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each terrace is a day or a month of deaths. When one commit took many souls at once, the terrace says so. In that private repo, 33 files fell together to a commit called &lt;code&gt;fix: backend&lt;/code&gt;. Gemma noticed:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Frontend/src/assets/react.svg, who represented the spirit for eighteen short hours, vanished without fanfare. Its final word was the pragmatic "fix: backend", a most unpoetic exit.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2uzmoeel7duv8f5mt12a.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%2F2uzmoeel7duv8f5mt12a.gif" alt="Turning blue with second-hand embarrassment" width="360" height="201"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Talking to the dead
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;kuyo seance&lt;/code&gt; summons one file's ghost. It answers in first person, remembering only its last contents and the facts of its life, so I went back to that EmailService:&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%2F7wwm4leskoapfz8djk3u.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%2F7wwm4leskoapfz8djk3u.gif" alt="Are you..." width="360" height="202"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt; What did you do when you were alive? Be specific.
EmailService.java: My primary function was to send status change notifications via email.
I held a method, `enviarMudancaStatus`, which asynchronously dispatched messages detailing
updates to a specific order's status. I used a `JavaMailSender` to accomplish this task.

&amp;gt; Who was the president of Brazil when you died?
EmailService.java: The dead do not know such things.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The second answer is the one I'm proudest of. A ghost that makes things up would be a much worse ghost.&lt;/p&gt;
&lt;h3&gt;
  
  
  A haunted shell
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;kuyo haunt&lt;/code&gt; possesses your terminal. It prints a few lines of shell for your &lt;code&gt;.bashrc&lt;/code&gt; or &lt;code&gt;.zshrc&lt;/code&gt;, and from then on, deleting a tracked file has consequences:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ rm src/main/java/dev/kuyo/haunt/Lines.java
$
  霊 Lines, a mere four hours of existence, still trembles in the shadow of that `rm` command. Don't delete me yet; I have so much to say.
     there is still time: git restore -SW -- ':/src/main/java/dev/kuyo/haunt/Lines.java'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That's Kuyō's own &lt;code&gt;Lines.java&lt;/code&gt;, the file that holds what ghosts say when Gemma is away, begging for its life four hours after it was born.&lt;/p&gt;

&lt;p&gt;The whisper arrives a few seconds after the prompt comes back, because Kuyō runs in the background while Gemma writes it. The file is examined as if it died right now, with your shell command as its last words. &lt;code&gt;git reset --hard&lt;/code&gt;, &lt;code&gt;git push --force&lt;/code&gt; and their friends get a line too, and every other command gets silence.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Shishō's autopsy
&lt;/h3&gt;

&lt;p&gt;Epitaphs are kind, but autopsies are not. Earlier this month I built &lt;a href="https://github.com/mauricioandrade/shisho" rel="noopener noreferrer"&gt;Shishō&lt;/a&gt; (師匠, "master"), a reviewer that reads Java code the way a sensei reads a student's work. With &lt;code&gt;--autopsy&lt;/code&gt;, every dead &lt;code&gt;.java&lt;/code&gt; file goes on its table.&lt;/p&gt;

&lt;p&gt;Kuyō rebuilds the file next to the &lt;code&gt;pom.xml&lt;/code&gt; or &lt;code&gt;build.gradle&lt;/code&gt; it lived with, as of the commit before it died, and carves the Shishō's lessons on the grave under a vermilion 師:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;師 &lt;strong&gt;The Shishō's autopsy:&lt;/strong&gt; medium · No centralized error handling (line 21)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That one is from &lt;code&gt;SpikeResource.java&lt;/code&gt;, a spike endpoint in another project of mine that lived nine hours. When the Shishō finds nothing, the washi says the body &lt;em&gt;died clean&lt;/em&gt;, and that was the verdict for EmailService. As with restlessness, no model is involved: the autopsy is deterministic.&lt;/p&gt;
&lt;h3&gt;
  
  
  Burying on every push
&lt;/h3&gt;

&lt;p&gt;Kuyō is also a GitHub Action. Any repository can hold its funerals in CI:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mauricioandrade/kuyo@main&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;_site/index.html&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Gemma runs on the runner itself, so the code still never goes to a third-party API, and cached epitaphs mean each push only pays for its new deaths.&lt;/p&gt;

&lt;p&gt;Kuyō buries itself on every push, and &lt;a href="https://mauricioandrade.github.io/kuyo/self/" rel="noopener noreferrer"&gt;its own cemetery&lt;/a&gt; already has a mass grave. Midway through the challenge I translated the codebase from Portuguese to English, and 24 files fell together in one commit: &lt;code&gt;Legista.java&lt;/code&gt; (the coroner), &lt;code&gt;Exumador.java&lt;/code&gt; (the exhumer), &lt;code&gt;Falecido.java&lt;/code&gt; (the deceased) and the rest of their family, all killed by &lt;em&gt;"refactor: write the code in English"&lt;/em&gt;. The tool now holds funerals for its own past.&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/mauricioandrade" rel="noopener noreferrer"&gt;
        mauricioandrade
      &lt;/a&gt; / &lt;a href="https://github.com/mauricioandrade/kuyo" rel="noopener noreferrer"&gt;
        kuyo
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      供養 A funeral for the code that's gone. Reads your git history, diagnoses how each deleted file died, and has a local Gemma model write its epitaph in a haunted HTML cemetery. Runs offline.
    &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/mauricioandrade/kuyo/docs/banner.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%2Fmauricioandrade%2Fkuyo%2FHEAD%2Fdocs%2Fbanner.svg" alt="Kuyō 供養: a funeral for the code that's gone" width="100%"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;p&gt;
  &lt;b&gt;English&lt;/b&gt; · &lt;a href="https://github.com/mauricioandrade/kuyo/README.pt-BR.md" rel="noopener noreferrer"&gt;Português&lt;/a&gt;
&lt;/p&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/18845fb9d096ba35a9db80f28eb9511598b27bbcc37039bc5d7d59225eabc62b/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4a6176612d32352d6537366630303f7374796c653d666c61742d737175617265266c6f676f3d6f70656e6a646b266c6f676f436f6c6f723d7768697465"&gt;&lt;img alt="Java 25" src="https://camo.githubusercontent.com/18845fb9d096ba35a9db80f28eb9511598b27bbcc37039bc5d7d59225eabc62b/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4a6176612d32352d6537366630303f7374796c653d666c61742d737175617265266c6f676f3d6f70656e6a646b266c6f676f436f6c6f723d7768697465"&gt;&lt;/a&gt;
  &lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/cf558c92554637009471f24009fc53605e2a15d30db2e45eb63a1b27bff1ee22/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f517561726b75732d332e34302d3436393565623f7374796c653d666c61742d737175617265266c6f676f3d717561726b7573266c6f676f436f6c6f723d7768697465"&gt;&lt;img alt="Quarkus" src="https://camo.githubusercontent.com/cf558c92554637009471f24009fc53605e2a15d30db2e45eb63a1b27bff1ee22/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f517561726b75732d332e34302d3436393565623f7374796c653d666c61742d737175617265266c6f676f3d717561726b7573266c6f676f436f6c6f723d7768697465"&gt;&lt;/a&gt;
  &lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/0776ccc51e911ce912953e3d81dcf9eb74afdb7bce6331cd5b33ebc6a9df2207/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f47656d6d612d6f6e2532304f6c6c616d612d3665353663663f7374796c653d666c61742d737175617265266c6f676f3d6f6c6c616d61266c6f676f436f6c6f723d7768697465"&gt;&lt;img alt="Gemma on Ollama" src="https://camo.githubusercontent.com/0776ccc51e911ce912953e3d81dcf9eb74afdb7bce6331cd5b33ebc6a9df2207/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f47656d6d612d6f6e2532304f6c6c616d612d3665353663663f7374796c653d666c61742d737175617265266c6f676f3d6f6c6c616d61266c6f676f436f6c6f723d7768697465"&gt;&lt;/a&gt;
  &lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/931e2dd24b3f027b5516ad2b6e6984f67f3c52f3fed73c2b8a02e62982099b84/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f72756e732d6f66666c696e652d3265386235373f7374796c653d666c61742d737175617265"&gt;&lt;img alt="Runs offline" src="https://camo.githubusercontent.com/931e2dd24b3f027b5516ad2b6e6984f67f3c52f3fed73c2b8a02e62982099b84/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f72756e732d6f66666c696e652d3265386235373f7374796c653d666c61742d737175617265"&gt;&lt;/a&gt;
  &lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/07b0fb5dd651d3725f675f06974dc605378f9373f53962bfbeb910c7116c1bca/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d3535353f7374796c653d666c61742d737175617265"&gt;&lt;img alt="MIT license" src="https://camo.githubusercontent.com/07b0fb5dd651d3725f675f06974dc605378f9373f53962bfbeb910c7116c1bca/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d3535353f7374796c653d666c61742d737175617265"&gt;&lt;/a&gt;
  &lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/18240396f5849b0a78208d8cd58727dc0510920bf89aa1b3f46b60ccc18b7c80/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4861636b746f626572666573742d323032362d6666376130303f7374796c653d666c61742d737175617265"&gt;&lt;img alt="Hacktoberfest 2026" src="https://camo.githubusercontent.com/18240396f5849b0a78208d8cd58727dc0510920bf89aa1b3f46b60ccc18b7c80/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4861636b746f626572666573742d323032362d6666376130303f7374796c653d666c61742d737175617265"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;In Japan, &lt;em&gt;kuyō&lt;/em&gt; (供養) is a memorial service for objects that served well: needles, dolls, brushes, even eyeglasses. They are thanked and laid to rest instead of just thrown away.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Kuyō does the same for source code.&lt;/strong&gt; It reads a repository's git history, finds every file that was deleted, and holds a funeral for each one: how it lived, how it died, and how restless its soul is. A local open-weight model writes the epitaphs, and the result is a cemetery in a single HTML page, where restless souls haunt their graves.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;🔒 &lt;strong&gt;Your code never leaves your machine.&lt;/strong&gt; Everything runs offline, with a model you control.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Built for the DEV Hacktoberfest 2026 challenge, week 4: &lt;em&gt;Ghost in the Machine&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/mauricioandrade/kuyo/docs/demo.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%2Fmauricioandrade%2Fkuyo%2FHEAD%2Fdocs%2Fdemo.gif" alt="Walking the cemetery: approaching a violent grave raises a yūrei while its epitaph is brushed onto a washi strip" width="100%"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;p&gt;&lt;a href="https://mauricioandrade.github.io/kuyo/cemetery.html" rel="nofollow noopener noreferrer"&gt;&lt;b&gt;Walk the cemetery yourself →&lt;/b&gt;&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The demo cemetery comes from &lt;strong&gt;stocklentes-hub, a private repository of mine&lt;/strong&gt;: 137 deleted files over five months. Its code stays…&lt;/p&gt;
&lt;/blockquote&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/mauricioandrade/kuyo" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;



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

&lt;p&gt;&lt;strong&gt;Java 25 + Quarkus (picocli)&lt;/strong&gt; for the CLI, &lt;strong&gt;JGit&lt;/strong&gt; to read history without shelling out to &lt;code&gt;git&lt;/code&gt;, &lt;strong&gt;LangChain4j&lt;/strong&gt; to talk to &lt;strong&gt;Ollama&lt;/strong&gt;, and &lt;strong&gt;Qute&lt;/strong&gt; for the template.&lt;/p&gt;

&lt;h3&gt;
  
  
  The facts are Java's, the words are Gemma's
&lt;/h3&gt;

&lt;p&gt;I wanted a hard rule: the model never decides anything. Restlessness is computed deterministically from five factors:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Factor&lt;/th&gt;
&lt;th&gt;Weight&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Size of the death commit (log scale, maxes out at 50 files or 2,000 lines)&lt;/td&gt;
&lt;td&gt;0.30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hour of death, in the author's own timezone (midnight to 5 a.m.)&lt;/td&gt;
&lt;td&gt;0.15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Last words: empty, or &lt;code&gt;remove&lt;/code&gt;, &lt;code&gt;wip&lt;/code&gt;, &lt;code&gt;hotfix&lt;/code&gt;, &lt;code&gt;cleanup&lt;/code&gt;, &lt;code&gt;oops&lt;/code&gt;…&lt;/td&gt;
&lt;td&gt;0.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No test touched in the commit (code files only)&lt;/td&gt;
&lt;td&gt;0.15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Short life (log scale in hours)&lt;/td&gt;
&lt;td&gt;0.20&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My first draft used linear scales with a seven-day cutoff for a "premature" death. When I ran it against 175 real deaths from nine of my repos, almost every file came out premature and tormented. In practice files die young and in mass graves: half of mine lived less than a day, and most died in commits touching 20 to 120 files. Log scales fixed the spread. The rename threshold also had to match git's 50%, because JGit defaults to 60% and was turning my refactors into deaths.&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%2Fi5ln6kqqokxxjqdno4h1.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%2Fi5ln6kqqokxxjqdno4h1.gif" alt="Pikachu, deeply unwell" width="360" height="251"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Gemma then receives only the result (file, lifespan, authors, cause, last words, hour, how many died alongside it) with one instruction: dry humor, and never invent a fact.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three things that bit me
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Gemma 4 thinks by default.&lt;/strong&gt; My first request took 51 seconds and returned an empty epitaph: the model had spent its whole token budget reasoning. With &lt;code&gt;think: false&lt;/code&gt; it takes about a second per epitaph.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Quarkus Ollama extension fixes the model id at build time.&lt;/strong&gt; That contradicted the whole "swap the model" promise, so the chat model is built at runtime through a &lt;code&gt;Supplier&lt;/code&gt; instead. &lt;code&gt;KUYO_MODEL=whatever&lt;/code&gt; works without a rebuild.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chrome groups &lt;code&gt;@font-face&lt;/code&gt; by weight before it applies &lt;code&gt;unicode-range&lt;/code&gt;.&lt;/strong&gt; My embedded kanji subset rendered as empty boxes until I declared it once per weight.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Everything stays in one file
&lt;/h3&gt;

&lt;p&gt;The cemetery makes no network requests: every grave, lantern and flame is hand-drawn CSS and SVG, the typeface is embedded as small base64 subsets, and &lt;code&gt;prefers-reduced-motion&lt;/code&gt; turns every animation off.&lt;/p&gt;

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

&lt;p&gt;Because the most interesting history is usually the one you can't share.&lt;/p&gt;

&lt;p&gt;Proprietary code can't be pasted into a closed API, and that is exactly the code whose ghosts I wanted to meet. With an open-weight model on my own machine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nothing leaves the machine.&lt;/strong&gt; The demo above comes from a private repo, and that was never a problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It costs nothing per run&lt;/strong&gt;, so burying 137 files is a non-event.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It works offline.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model is just a setting.&lt;/strong&gt; Gemma today, whatever open model you prefer tomorrow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Open tools made the rest possible: JGit, Quarkus, LangChain4j, Ollama, and an OFL typeface from the Shippori Mincho Project.&lt;/p&gt;

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

&lt;p&gt;In Brazil, November 2nd is &lt;em&gt;Finados&lt;/em&gt;, the day we visit our dead. It falls right after this challenge ends.&lt;/p&gt;

&lt;p&gt;Every file in these cemeteries did a job once. Some lived eighteen hours to hold up a screen that changed the next morning, some were placeholders whose only purpose was to be replaced. The projects I work on today stand on all of them. Kuyō is a small way to say thank you before moving on.&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 (&lt;code&gt;gemma4:e4b&lt;/code&gt;) runs locally on Ollama and writes every epitaph from facts computed in Java, so private code stays private.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Credits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/fontdasu/ShipporiMincho" rel="noopener noreferrer"&gt;Shippori Mincho B1&lt;/a&gt; by the Shippori Mincho Project Authors, SIL Open Font License 1.1.&lt;/li&gt;
&lt;li&gt;Built with Quarkus, JGit, LangChain4j and Ollama.&lt;/li&gt;
&lt;li&gt;Reaction GIFs belong to their respective anime and shows.&lt;/li&gt;
&lt;li&gt;Pair-programmed with Claude Code. Every epitaph and every ghost's answer is Gemma's.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>bestuseofgemma</category>
      <category>java</category>
    </item>
    <item>
      <title>Kikanai: I stopped listening to voice notes, and a local Gemma reads them for me</title>
      <dc:creator>Mauricio Andrade</dc:creator>
      <pubDate>Tue, 20 Oct 2026 01:51:46 +0000</pubDate>
      <link>https://dev.to/mauricioandrade/kikanai-i-stopped-listening-to-voice-notes-and-a-local-gemma-reads-them-for-me-35kh</link>
      <guid>https://dev.to/mauricioandrade/kikanai-i-stopped-listening-to-voice-notes-and-a-local-gemma-reads-them-for-me-35kh</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;p&gt;In Brazil, nobody types. WhatsApp voice notes are how families, friends and half of the people you do business with talk, and every family has a Tia Cida: the aunt who forwards you seven minutes of audio to say there is a barbecue on Sunday and you should bring ice.&lt;/p&gt;

&lt;p&gt;You can listen to it at 2× and still lose three and a half minutes. Or you can do what everyone secretly does:&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%2F1ct2flzt3eej9eecuh4g.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%2F1ct2flzt3eej9eecuh4g.gif" alt="Dean Winchester shaking his head: " width="400" height="327"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So I built the lazy option properly. &lt;em&gt;Kikanai&lt;/em&gt; (聞かない) is Japanese for "I won't listen".&lt;/p&gt;

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

&lt;p&gt;You drop a voice note on a page running on your own computer, and you read something like this instead of playing it (this one is the example on the banner):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tia Cida&lt;/strong&gt;&lt;br&gt;
Churrasco no domingo, 13h.&lt;br&gt;
Leva gelo. Não precisa responder.&lt;/p&gt;

&lt;p&gt;☐ Levar gelo · domingo 13h&lt;br&gt;
&lt;code&gt;−7m 01s&lt;/code&gt; · &lt;code&gt;enrolação 84%&lt;/code&gt; · &lt;em&gt;no need to reply&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two lines, at most. The things you have to do, with when and where. Whether you actually need to answer, which is the real question behind every voice note. And two numbers: the time you did not spend listening, and the &lt;em&gt;enrolação&lt;/em&gt; (rambling) index, the share of the audio's words that didn't make it into the summary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who gets to be lazier:&lt;/strong&gt; anyone who receives long voice notes, which in Brazil is everyone. You can drop the file on the page, or just save it into a folder: Kikanai picks it up, summarizes it into your history, and moves it to &lt;code&gt;done/&lt;/code&gt;. You never open the audio.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And nothing leaves your machine.&lt;/strong&gt; &lt;a href="https://ffmpeg.org" rel="noopener noreferrer"&gt;ffmpeg&lt;/a&gt; converts the audio, &lt;a href="https://github.com/ggml-org/whisper.cpp" rel="noopener noreferrer"&gt;whisper.cpp&lt;/a&gt; transcribes it, and Gemma 4 on &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt; summarizes it, all locally. Your aunt's health updates, your friend's breakup, your landlord's excuses: none of it goes to anyone's server.&lt;/p&gt;

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

&lt;p&gt;Kikanai runs on your computer, never on a public server, so there is no live link. This is a real run on an 18-second test recording, shown at 2× speed:&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%2F5o6fkmy0ue83i1yis22d.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%2F5o6fkmy0ue83i1yis22d.gif" alt="The Kikanai page: a voice note is dropped with the sender " width="800" height="550"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The left side is where audio goes: the drop target is drawn like a forwarded voice note, and the whole window accepts the file. The stages light up as the server reports them, with a clock that doesn't lie. The right side is paper: the summary slides out from under the blue line, and every summary stays in the stack below.&lt;/p&gt;

&lt;p&gt;The first time you open it, the page shows an example slip (clearly labeled &lt;em&gt;exemplo&lt;/em&gt;) so you know what is coming. If something is missing, like the Whisper model or Ollama, it says so above the drop target, with the command that fixes it.&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/mauricioandrade" rel="noopener noreferrer"&gt;
        mauricioandrade
      &lt;/a&gt; / &lt;a href="https://github.com/mauricioandrade/kikanai" rel="noopener noreferrer"&gt;
        kikanai
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
       Voice notes you never have to play. Drop a long WhatsApp voice note, read two lines: whisper.cpp transcribes, Gemma 4 on Ollama summarizes, Java counts the time you saved. Java 25 + Quarkus, 100% offline.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Kikanai (聞かない)&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Voice notes you never have to play.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/mauricioandrade/kikanai/docs/banner.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%2Fmauricioandrade%2Fkikanai%2FHEAD%2Fdocs%2Fbanner.png" alt="Kikanai: a forwarded 7-minute voice note from Tia Cida becomes a two-line summary, &amp;quot;Churrasco no domingo, 13h. Leva gelo. Não precisa responder.&amp;quot;, with -7m 01s saved and 84% rambling"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/e2961406d68d821a926efc004a8c0405b01b4ac7f2aeeca1406074d7af13cca0/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4a6176612d32352d6f72616e67653f6c6f676f3d6f70656e6a646b"&gt;&lt;img src="https://camo.githubusercontent.com/e2961406d68d821a926efc004a8c0405b01b4ac7f2aeeca1406074d7af13cca0/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4a6176612d32352d6f72616e67653f6c6f676f3d6f70656e6a646b" alt="Java 25"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/e4de40489b942dced56628167d4a701605f6f1f4827026fd54c22aa0854579a7/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f517561726b75732d332e34302532304c54532d3436393545423f6c6f676f3d717561726b7573266c6f676f436f6c6f723d7768697465"&gt;&lt;img src="https://camo.githubusercontent.com/e4de40489b942dced56628167d4a701605f6f1f4827026fd54c22aa0854579a7/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f517561726b75732d332e34302532304c54532d3436393545423f6c6f676f3d717561726b7573266c6f676f436f6c6f723d7768697465" alt="Quarkus 3.40"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/21f4c68649bac6a197061d59acaa2ffb92279d72c7f132c503465d47d847d827/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f776869737065722e6370702d76312e392e352d353535"&gt;&lt;img src="https://camo.githubusercontent.com/21f4c68649bac6a197061d59acaa2ffb92279d72c7f132c503465d47d847d827/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f776869737065722e6370702d76312e392e352d353535" alt="whisper.cpp v1.9.5"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/bfa95e1a7a9c16631ba8e8410c49bbceab45c5a80d44b66d3ceb88976f3c76a3/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f47656d6d61253230342532304534422d6f6e2532304f6c6c616d612d3845373542323f6c6f676f3d6f6c6c616d61266c6f676f436f6c6f723d7768697465"&gt;&lt;img src="https://camo.githubusercontent.com/bfa95e1a7a9c16631ba8e8410c49bbceab45c5a80d44b66d3ceb88976f3c76a3/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f47656d6d61253230342532304534422d6f6e2532304f6c6c616d612d3845373542323f6c6f676f3d6f6c6c616d61266c6f676f436f6c6f723d7768697465" alt="Gemma 4 E4B on Ollama"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/38383e494f88f32ce7ef1ea839391720af0d8e22c4ea4979a0487902a80b5efd/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f72756e732d3130302532352532306f66666c696e652d396664313861"&gt;&lt;img src="https://camo.githubusercontent.com/38383e494f88f32ce7ef1ea839391720af0d8e22c4ea4979a0487902a80b5efd/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f72756e732d3130302532352532306f66666c696e652d396664313861" alt="100% offline"&gt;&lt;/a&gt;
&lt;a href="https://github.com/mauricioandrade/shisho" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/c80c2186f8afa8d3aefac8e98dc89953be4c3a9e293c814d141f588a6fbd3597/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f53686973682543352538442d31737425323064616e2d443937373537" alt="Reviewed by Shishō: 1st dan"&gt;&lt;/a&gt;
&lt;a href="https://github.com/mauricioandrade/kikanai/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/f8df3091bbe1149f398a5369b2c39e896766f9f6efba3477c63e9b4aa940ef14/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d677265656e" alt="MIT license"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/7ed762eda8289c54974153e601bd73d34b4d67f604e3ef6ed93984b13a893ea7/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4861636b746f626572666573742d323032362d663062343565"&gt;&lt;img src="https://camo.githubusercontent.com/7ed762eda8289c54974153e601bd73d34b4d67f604e3ef6ed93984b13a893ea7/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4861636b746f626572666573742d323032362d663062343565" alt="Hacktoberfest 2026"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;English&lt;/strong&gt; · &lt;a href="https://github.com/mauricioandrade/kikanai/README.pt-BR.md" rel="noopener noreferrer"&gt;Português&lt;/a&gt;&lt;/p&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Contents:&lt;/strong&gt; &lt;a href="https://github.com/mauricioandrade/kikanai#what-it-is" rel="noopener noreferrer"&gt;What it is&lt;/a&gt; · &lt;a href="https://github.com/mauricioandrade/kikanai#demo" rel="noopener noreferrer"&gt;Demo&lt;/a&gt; · &lt;a href="https://github.com/mauricioandrade/kikanai#how-it-works" rel="noopener noreferrer"&gt;How it works&lt;/a&gt; · &lt;a href="https://github.com/mauricioandrade/kikanai#install" rel="noopener noreferrer"&gt;Install&lt;/a&gt; · &lt;a href="https://github.com/mauricioandrade/kikanai#use" rel="noopener noreferrer"&gt;Use&lt;/a&gt; · &lt;a href="https://github.com/mauricioandrade/kikanai#privacy" rel="noopener noreferrer"&gt;Privacy&lt;/a&gt; · &lt;a href="https://github.com/mauricioandrade/kikanai#honest-numbers" rel="noopener noreferrer"&gt;Honest numbers&lt;/a&gt; · &lt;a href="https://github.com/mauricioandrade/kikanai#what-gemma-got-wrong" rel="noopener noreferrer"&gt;What Gemma got wrong&lt;/a&gt; · &lt;a href="https://github.com/mauricioandrade/kikanai#architecture" rel="noopener noreferrer"&gt;Architecture&lt;/a&gt; · &lt;a href="https://github.com/mauricioandrade/kikanai#reviewed-by-shish%C5%8D" rel="noopener noreferrer"&gt;Reviewed by Shishō&lt;/a&gt; · &lt;a href="https://github.com/mauricioandrade/kikanai#tests" rel="noopener noreferrer"&gt;Tests&lt;/a&gt; · &lt;a href="https://github.com/mauricioandrade/kikanai#lazy-payoff" rel="noopener noreferrer"&gt;Lazy payoff&lt;/a&gt; · &lt;a href="https://github.com/mauricioandrade/kikanai#license" rel="noopener noreferrer"&gt;License&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What it is&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;&lt;em&gt;Kikanai&lt;/em&gt; (聞かない) is Japanese for "I won't listen". Someone forwards you a 7-minute voice note; you drop it on a local page, and a minute later you read two lines, the things you have to do, and whether you need to reply at all.&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/mauricioandrade/kikanai/docs/gifs/nope.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%2Fmauricioandrade%2Fkikanai%2FHEAD%2Fdocs%2Fgifs%2Fnope.gif" width="300" alt="Dean Winchester, from Supernatural, slowly shaking his head and saying 'Nope.'"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Me, looking at a 7-minute voice note.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Everything runs on your machine: &lt;a href="https://ffmpeg.org" rel="nofollow noopener noreferrer"&gt;ffmpeg&lt;/a&gt; converts the audio, &lt;a href="https://github.com/ggml-org/whisper.cpp" rel="noopener noreferrer"&gt;whisper.cpp&lt;/a&gt; transcribes it, and Gemma 4 on &lt;a href="https://ollama.com" rel="nofollow noopener noreferrer"&gt;Ollama&lt;/a&gt; summarizes it. No audio, transcript or summary ever leaves the computer.&lt;/p&gt;
&lt;p&gt;Built for the &lt;a href="https://dev.to" rel="nofollow"&gt;Hacktoberfest 2026 DEV Challenge&lt;/a&gt;, week 3: &lt;em&gt;Slow and Steady&lt;/em&gt;, AI that rewards laziness.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Demo&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/mauricioandrade/kikanai" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;MIT licensed. The README has the full setup (whisper.cpp build, model checksum, Ollama on Windows with Kikanai in WSL2) in English and Portuguese.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&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%2Fvijure6to5nh7q8z4o0e.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%2Fvijure6to5nh7q8z4o0e.png" alt="Kikanai, on one machine: voice note, then ffmpeg, whisper.cpp, Gemma 4 E4B on Ollama, Java 25, and the paper slip on the page" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Speech to text&lt;/td&gt;
&lt;td&gt;whisper.cpp v1.9.5, &lt;code&gt;ggml-small&lt;/code&gt;, Portuguese&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Summary&lt;/td&gt;
&lt;td&gt;Gemma 4 E4B (&lt;code&gt;gemma4:e4b&lt;/code&gt;) on Ollama, temperature 0.2, thinking off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio&lt;/td&gt;
&lt;td&gt;ffmpeg / ffprobe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;Java 25 + Quarkus 3.40 LTS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page&lt;/td&gt;
&lt;td&gt;one HTML file, plain CSS and JS, self-hosted fonts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;one JSON file per summary; no database&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Two open models, each doing what it is good at
&lt;/h3&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%2F1sjhc8dh7shtmj1actn9.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%2F1sjhc8dh7shtmj1actn9.gif" alt="Naruto and Minato bumping fists in Kurama mode" width="400" height="224"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Gemma 4's small models can take audio directly, so my first idea was to skip Whisper. But they take about 30 seconds of it, and the voice notes worth summarizing are the long ones. So each model does its job: Whisper is a speech recognition specialist that handles long audio, and Gemma does the part that needs understanding, deciding what matters, what has to be done, and whether a question was asked.&lt;/p&gt;

&lt;p&gt;Composing two specialized open models like this is the point of open weights. Nobody sells me this pipeline; I just plug the pieces together.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gemma answers in a schema, not in prose
&lt;/h3&gt;

&lt;p&gt;Asking a model to "answer in JSON" and hoping is how you end up parsing with regex. Ollama has &lt;a href="https://ollama.com/blog/structured-outputs" rel="noopener noreferrer"&gt;structured outputs&lt;/a&gt;: you pass a JSON Schema in the &lt;code&gt;format&lt;/code&gt; field, and generation is constrained to it. The schema is the contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"remetente"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"resumo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"array"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"minItems"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxItems"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"acoes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"array"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"descricao"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"quando"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"descricao"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"quando"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"precisaResponder"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"boolean"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"remetente"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"resumo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"acoes"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"precisaResponder"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The keys are in Portuguese on purpose: the prompt is in Portuguese, and the model fills them more reliably that way.&lt;/p&gt;

&lt;h3&gt;
  
  
  The laziness numbers are arithmetic, not AI
&lt;/h3&gt;

&lt;p&gt;If Gemma made up "84% rambling", the number would change on every run and mean nothing. So the model never sees the metrics. They are plain Java in the domain, reproducible and unit-tested:&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="c1"&gt;// Words ÷ 200 wpm, rounded up to whole seconds, never below one second.&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt; &lt;span class="nf"&gt;readingTimeOf&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;words&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ceilDiv&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60L&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;READING_WORDS_PER_MINUTE&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;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofSeconds&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 1 − summary/transcript: the share of words the listener didn't need. 0.84 means 84% filler.&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="nf"&gt;ramblingIndexOf&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;summaryWords&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;transcriptWords&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transcriptWords&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&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="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;summaryWords&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;transcriptWords&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;Math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;clamp&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&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 AI does what only it can do. The counting stays in code.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Gemma got wrong, and how I fixed it
&lt;/h3&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%2F8td139m853y1n1vsa2tt.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%2F8td139m853y1n1vsa2tt.gif" alt="Stark from Frieren, sweating nervously" width="220" height="242"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My test recording is me saying that it is a transcription test and that I can't remember the app's name. Gemma's first summary:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Este áudio é um teste de transcrição do aplicativo Ki-Kanae.&lt;br&gt;
O remetente está verificando o nome do aplicativo.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"This audio is a test… &lt;em&gt;The sender is checking the app's name.&lt;/em&gt;" It described the voice note instead of summarizing it. A summary about the message is a summary of nothing, and in a two-line budget it is half the output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix one, the prompt.&lt;/strong&gt; Saying "don't describe the audio" helped a bit. What worked better was stating what I wanted instead of what I didn't: write &lt;em&gt;the note the speaker would have sent instead of the audio&lt;/em&gt;. That is exactly the voice of "Leva gelo." I also added one worked example, deliberately unlike my test transcript, so a test could never pass just because the model copied it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix two, a guard.&lt;/strong&gt; At temperature 0.2 it still slipped once in a while. Retrying the same question at low temperature tends to get the same answer, so the parser rejects lines about "o áudio", "a mensagem", "o remetente" or "a pessoa", and the retry sends Gemma its own answer plus what was wrong with it:&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="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;InvalidSummaryException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// At low temperature the same question gets the same answer; the retry says what was wrong.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&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="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&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;ChatRequest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Message&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"assistant"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&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;ChatRequest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Message&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"user"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;correction&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;In the real run that followed, Gemma wrote "O remetente…" on the first attempt, got rejected, and came back with "Teste de transcrição do aplicativo Ki-Kanae." Clean.&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%2Fri4l0ntiaxiohv6p3uld.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%2Fri4l0ntiaxiohv6p3uld.gif" alt="Batman slapping Robin" width="400" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The parser, every time Gemma writes "O remetente está…".&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Being lazy takes some engineering
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The page shows real progress.&lt;/strong&gt; The pipeline takes seconds to minutes, and a spinner on a long wait feels broken. The same &lt;code&gt;POST /api/voice-notes&lt;/code&gt; streams NDJSON when the page asks for it: one line as each stage starts, then the result. &lt;code&gt;curl&lt;/code&gt; still gets plain JSON.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The folder waits for downloads.&lt;/strong&gt; Browsers download into &lt;code&gt;.crdownload&lt;/code&gt; and rename to &lt;code&gt;.ogg&lt;/code&gt; when done, so files that aren't audio are ignored, and a file only counts once its size stops changing. You can save a voice note from WhatsApp Web straight into &lt;code&gt;~/kikanai/inbox&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An assembly line.&lt;/strong&gt; Each voice note runs on its own virtual thread, and each stage has a lane one note wide: whisper (CPU) transcribes the next note while Gemma (GPU) summarizes the previous one, and no two notes fight over the same stage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything checks itself.&lt;/strong&gt; &lt;code&gt;GET /api/health/deps&lt;/code&gt; reports ffmpeg, whisper-cli, the model, Ollama and the Gemma model, with the fix for whatever is missing. The page shows that list on first run.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Java 25 doing real work
&lt;/h3&gt;

&lt;p&gt;The pipeline returns a sealed result, and the web layer deconstructs it in a &lt;code&gt;switch&lt;/code&gt; the compiler checks for completeness:&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;switch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;summarizeVoiceNote&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;summarize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;voiceNote&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="o"&gt;)))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nf"&gt;Done&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SummaryResponse&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="o"&gt;)).&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nf"&gt;Failed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ProblemResponse&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProblemResponse&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UNPROCESSABLE_CONTENT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="no"&gt;CANNOT_SUMMARIZE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stage&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;A &lt;code&gt;ScopedValue&lt;/code&gt; carries the processing id into every log line of every stage, adapters included, without threading it through method signatures, and it can't leak into the next file the way a &lt;code&gt;ThreadLocal&lt;/code&gt; on a reused thread can:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[94f6fd01] Converting WhatsApp Ptt 2026-10-19 at 19.21.37.ogg
[94f6fd01] Transcribing 18s of audio
[94f6fd01] Summarizing 37 words
[94f6fd01] Done in 13s, saved 12s of listening
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The upload endpoint sits on a virtual thread (&lt;code&gt;@RunOnVirtualThread&lt;/code&gt;) for the whole pipeline, which costs no platform thread while it waits for whisper and Gemma.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reviewed by my other project
&lt;/h3&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%2Fh6iy087b9jjem16r9cy4.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%2Fh6iy087b9jjem16r9cy4.gif" alt="Cyclops walking stiffly, Wolverine behind him" width="400" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/mauricioandrade/shisho" rel="noopener noreferrer"&gt;Shishō&lt;/a&gt; (師匠, "master") is my opinionated Java linter: it explains &lt;em&gt;why&lt;/em&gt; something is wrong and whether the architecture fits the problem. It reviewed Kikanai after every phase.&lt;/p&gt;

&lt;p&gt;Clean Architecture for a weekend tool sounds like overengineering, and without context Shishō would say exactly that. So the repo declares its context in &lt;code&gt;shisho.yaml&lt;/code&gt;: one person, short life, and infrastructure that will change (whisper.cpp could become another ASR, Ollama another runtime, the JSON files a database). Its verdict: &lt;em&gt;Clean Architecture is adequate; the declared context pays its cost.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It also caught a real gap: errors were handled endpoint by endpoint, so they now all go through one set of &lt;code&gt;@ServerExceptionMapper&lt;/code&gt; methods as RFC 9457 problem+json. And it got one wrong: it raised that lesson on the Ollama REST &lt;em&gt;client&lt;/em&gt; interface, before any server endpoint existed. A false positive I now get to fix in Shishō itself. Final grade: 1st dan.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Because these are the most private recordings people have.&lt;/strong&gt; Voice notes are family health news, money, fights, gossip, people's voices. Sending them to a cloud API to save three minutes is a bad trade, and most people wouldn't make it if they thought about it. With open weights, the audio, the transcript and the summary never leave the computer, and the page doesn't even load a font from outside.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Because I could compose the right models.&lt;/strong&gt; A closed audio API would have sold me one model to do everything, priced per minute of audio, which is exactly the wrong unit for a tool whose whole point is long audio. Open models let me pick a speech specialist and a reasoning model and wire them together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Because I could see why it failed, and fix it.&lt;/strong&gt; Running Gemma myself, I could read every raw answer, constrain its output with a schema at the grammar level, and send it my own correction on retry. The "O remetente está…" fix came from reading what the model actually wrote, not from guessing at a black box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Because it costs nothing per voice note.&lt;/strong&gt; The busiest family group in the world costs the same electricity as a quiet one.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpv96x0p3803tkpj9peac.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%2Fpv96x0p3803tkpj9peac.gif" alt="Frieren, eyes closed and smiling, calmly raising her hands" width="400" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first version lost to just listening.&lt;/strong&gt; I ran four personal voice notes from one afternoon through it (they stay private, so only totals here), and processing took 97 s for 1m 49s of audio. For short notes, that is a bad deal, and the honest clock on the page showed it.&lt;/p&gt;

&lt;p&gt;So I asked where the time went, and the answer was not in Java. It was whisper on the CPU and Gemma on the GPU; Java took milliseconds. Virtual threads make &lt;em&gt;waiting&lt;/em&gt; free, not computing faster, so the fix had to be in how the tools were used:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;whisper with 8 threads instead of 4 (12 was slower again)&lt;/td&gt;
&lt;td&gt;8.2 s → 5.2 s on my 18-second test&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;keep_alive&lt;/code&gt; on every request, so Ollama stops unloading Gemma after 5 idle minutes&lt;/td&gt;
&lt;td&gt;no more 20 to 90 s reload on the next note&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;warming Gemma up at startup, with the &lt;em&gt;same&lt;/em&gt; context length as real requests&lt;/td&gt;
&lt;td&gt;the first note no longer pays for loading it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;one lane per stage instead of one note at a time&lt;/td&gt;
&lt;td&gt;a batch overlaps whisper (CPU) with Gemma (GPU)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The third row hides my favorite bug of the week: the first warm-up loaded Gemma with Ollama's default context, so the first real request, asking for 8192, made Ollama reload the entire model anyway. The fix was one argument.&lt;/p&gt;

&lt;p&gt;After tuning, on my machine (WSL2 with 12 threads and 7 GB of RAM, Ollama on the Windows side with an RTX 2060), my 18-second test takes &lt;strong&gt;7.0 s&lt;/strong&gt;: 0.2 s of ffmpeg, 5.3 s of whisper and 1.5 s of Gemma. And the same four voice notes:&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;Voice notes&lt;/td&gt;
&lt;td&gt;4, from 18 s to 41 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio I never played&lt;/td&gt;
&lt;td&gt;1m 49s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read instead&lt;/td&gt;
&lt;td&gt;25 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time not listened&lt;/td&gt;
&lt;td&gt;1m 24s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Average rambling&lt;/td&gt;
&lt;td&gt;68% (from 30% to 86%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Processing&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;46 s&lt;/strong&gt; in total (97 s before tuning), each note ready before it would have finished playing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Dropped all at once they take 43 s: whisper is the bottleneck now, so the assembly line only hides Gemma's part. And on average, 68% of the words in those voice notes never made it into the summary. The longer the note, the better the deal: transcription grows with the audio, the reading doesn't.&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%2Fbxw9fnvmor5gnbbcbpoh.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%2Fbxw9fnvmor5gnbbcbpoh.gif" alt="Ranpo happily eating a snack" width="400" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Me, reading two lines instead.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; Gemma 4 E4B runs locally through Ollama and does the part that needs understanding: from a raw Portuguese transcript, it decides what matters, writes it as the two-line note the speaker would have sent, extracts the actions with when and where, and decides whether a reply is needed. Its output is constrained by a JSON Schema through Ollama's structured outputs, validated, and corrected with its own previous answer when it slips, while every number on the page stays deterministic Java.&lt;/p&gt;




&lt;p&gt;Kikanai is week 3 of my Hacktoberfest run, after &lt;a href="https://github.com/mauricioandrade/janela" rel="noopener noreferrer"&gt;Janela&lt;/a&gt; and &lt;a href="https://github.com/mauricioandrade/kakeizu" rel="noopener noreferrer"&gt;Kakeizu&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/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffujsk3e4w9xtlm56eiv9.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%2Ffujsk3e4w9xtlm56eiv9.gif" alt="Dean Winchester: " width="200" height="200"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>hf26challenge</category>
      <category>devchallenge</category>
      <category>java</category>
      <category>bestuseofgemma</category>
    </item>
    <item>
      <title>Kakeizu: a local Gemma reads the gas station's receipts, and check digits catch its mistakes</title>
      <dc:creator>Mauricio Andrade</dc:creator>
      <pubDate>Wed, 14 Oct 2026 04:29:22 +0000</pubDate>
      <link>https://dev.to/mauricioandrade/kakeizu-a-local-gemma-reads-the-gas-stations-receipts-and-check-digits-catch-its-mistakes-3gg</link>
      <guid>https://dev.to/mauricioandrade/kakeizu-a-local-gemma-reads-the-gas-stations-receipts-and-check-digits-catch-its-mistakes-3gg</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A friend of mine works at a gas station in the interior of São Paulo. The station has a system, but closing the day still means checking a pile of paper against it by hand: card machine slips, fiscal coupons, Pix receipts, the odd cheque. Amount, date, payment method, one receipt at a time.&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%2Fs0eq8g2jk7dhsbocsmf3.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%2Fs0eq8g2jk7dhsbocsmf3.gif" alt="Itadori looking sick" width="240" height="209"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Still checking, one receipt at a time.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So I built Kakeizu: you photograph the receipt on your phone, a Gemma model running on the shop's own computer reads it, the domain checks every digit it can, and a person confirms. Nothing is confirmed automatically, and nothing leaves the shop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The AI suggests, the domain validates, the human decides.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Kakeizu&lt;/strong&gt; reads a photo of a Brazilian payment receipt with Gemma running on the shop's own computer, checks every digit it can, and waits for a person to confirm. The name is &lt;em&gt;Kakeibo&lt;/em&gt; (家計簿, the Japanese household ledger) plus &lt;em&gt;Kakuzu&lt;/em&gt;, the Akatsuki member who only thinks about money.&lt;/p&gt;

&lt;p&gt;It reads the receipts a Brazilian gas station or corner shop sees every day:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Receipt&lt;/th&gt;
&lt;th&gt;What the domain checks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;NFC-e / NF-e (fiscal coupon or invoice)&lt;/td&gt;
&lt;td&gt;44-digit access key with a mod-11 check digit; its month and CNPJ must match the other fields&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Card machine slip (Stone, Cielo, Getnet)&lt;/td&gt;
&lt;td&gt;the store's CNPJ check digits, the authorization code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pix receipt&lt;/td&gt;
&lt;td&gt;the 32-character end-to-end ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cheque&lt;/td&gt;
&lt;td&gt;the three mod-10 check digits of the magnetic line (CMC-7); a date over 6 months old gets a warning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any of them&lt;/td&gt;
&lt;td&gt;amount, date never in the future, the same photo or the same key never twice&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Confirmed receipts feed a daily summary and a sales report by hour, weekday, payment method, installments and card brand. An NFC-e and the card slip of the same sale are paired, so the sale counts once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who pays for it:&lt;/strong&gt; the owner of a small shop who closes the cash register by hand. Cloud receipt readers exist, but they charge per document and want the shop's sales, suppliers and customers on someone else's server.&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%2Fwpsoviox1ropx3kwugpv.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%2Fwpsoviox1ropx3kwugpv.gif" alt="J. Jonah Jameson laughing" width="220" height="220"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The cloud vendor, sending this month's per-document invoice.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Kakeizu costs nothing per document and nothing leaves the shop. The owner pays for the setup and the hour it saves at the end of every day, not for each photo.&lt;/p&gt;

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

&lt;p&gt;Kakeizu runs on the shop's computer and never on a public server, so there is no live link: the demo is the flow below, recorded with the anonymized sample receipts from the repository.&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%2Fv8xv2yai1g6lal9ra912.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%2Fv8xv2yai1g6lal9ra912.gif" alt="Demo: a photo goes up, the line reads in the background and prints itself in, then the receipt is reviewed and confirmed" width="351" height="741"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Today, on the phone.&lt;/strong&gt; The photo goes up and the list comes back at once. The model reads in the background (dashed violet line), and the line prints itself in when it is done. Orange means "needs you", double green means confirmed.&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%2Fbralsc4tt2ywbt1uzh37.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%2Fbralsc4tt2ywbt1uzh37.png" alt="Today's receipts on a phone: one reading, two to review, two confirmed" width="780" height="1688"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Review.&lt;/strong&gt; The photo sits next to everything the model read, and every field that failed a check says why. This template NFC-e has no CNPJ and a zeroed access key, so it cannot be confirmed as read.&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%2Fknxds1sk1mwsjo2j0mko.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%2Fknxds1sk1mwsjo2j0mko.png" alt="Review screen: the receipt photo beside the editable fields, with errors highlighted" width="800" height="1192"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Look at the amount, too. The photo says &lt;strong&gt;146,46&lt;/strong&gt;; the model read &lt;strong&gt;4,00&lt;/strong&gt;. That number is well formed and no check digit covers it, so only the person comparing it with the photo catches it.&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%2Fynyabh35gver8nozhde6.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%2Fynyabh35gver8nozhde6.gif" alt="Joey from Friends, disappointed" width="220" height="220"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sales report.&lt;/strong&gt; Confirmed sales by hour, weekday, payment method, installments and card brand, with a slip paired to its NFC-e so each sale counts once.&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%2Fkgul9dxs4l4ci6p0uvc7.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%2Fkgul9dxs4l4ci6p0uvc7.png" alt="Sales report on a phone" width="780" height="2484"&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/mauricioandrade" rel="noopener noreferrer"&gt;
        mauricioandrade
      &lt;/a&gt; / &lt;a href="https://github.com/mauricioandrade/kakeizu" rel="noopener noreferrer"&gt;
        kakeizu
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Local-first receipt reader for small shops: Gemma (via Ollama) reads NFC-e, card slips, Pix and cheques; check digits validate; a person confirms. Java 25 + Quarkus. Nothing leaves the machine.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;🧾 Kakeizu&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;A receipt reader for small shops that runs 100% locally.&lt;/strong&gt;
&lt;br&gt;A photo goes in, structured data comes out, and nothing leaves the owner's machine.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Kakeibo&lt;/em&gt; (家計簿, the Japanese household ledger) + &lt;em&gt;Kakuzu&lt;/em&gt; (the Akatsuki member who only thinks about money)&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/4e456d8609ec40e25278393e43e5a2315b711512d86e496061178d9419e08086/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4a6176612d32352d4544384230303f6c6f676f3d6f70656e6a646b266c6f676f436f6c6f723d7768697465"&gt;&lt;img src="https://camo.githubusercontent.com/4e456d8609ec40e25278393e43e5a2315b711512d86e496061178d9419e08086/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4a6176612d32352d4544384230303f6c6f676f3d6f70656e6a646b266c6f676f436f6c6f723d7768697465" alt="Java 25"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/992080f9aa3e3927c24fc5e40725bd992ea622744f39738793497dc655351b42/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f517561726b75732d332e34302d3436393545423f6c6f676f3d717561726b7573266c6f676f436f6c6f723d7768697465"&gt;&lt;img src="https://camo.githubusercontent.com/992080f9aa3e3927c24fc5e40725bd992ea622744f39738793497dc655351b42/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f517561726b75732d332e34302d3436393545423f6c6f676f3d717561726b7573266c6f676f436f6c6f723d7768697465" alt="Quarkus 3.40"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/4575f5d379a78b6d482a3f3a08c63b04b4db13938e72e8cf5deff518f5088bd0/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4f6c6c616d612d6c6f63616c2d3030303030303f6c6f676f3d6f6c6c616d61266c6f676f436f6c6f723d7768697465"&gt;&lt;img src="https://camo.githubusercontent.com/4575f5d379a78b6d482a3f3a08c63b04b4db13938e72e8cf5deff518f5088bd0/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4f6c6c616d612d6c6f63616c2d3030303030303f6c6f676f3d6f6c6c616d61266c6f676f436f6c6f723d7768697465" alt="Ollama"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/cf4aa13efa1475921fff043c51dcfe236df6bb86e49b9f528f81790dffa20462/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f47656d6d612d766973696f6e2d3845373542323f6c6f676f3d676f6f676c6567656d696e69266c6f676f436f6c6f723d7768697465"&gt;&lt;img src="https://camo.githubusercontent.com/cf4aa13efa1475921fff043c51dcfe236df6bb86e49b9f528f81790dffa20462/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f47656d6d612d766973696f6e2d3845373542323f6c6f676f3d676f6f676c6567656d696e69266c6f676f436f6c6f723d7768697465" alt="Gemma"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/fd0b646dbd6dcccc138eb1db86d3bec1a67e1be682e361d8059908ae72ec0812/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f506f737467726553514c2d31362d3431363945313f6c6f676f3d706f737467726573716c266c6f676f436f6c6f723d7768697465"&gt;&lt;img src="https://camo.githubusercontent.com/fd0b646dbd6dcccc138eb1db86d3bec1a67e1be682e361d8059908ae72ec0812/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f506f737467726553514c2d31362d3431363945313f6c6f676f3d706f737467726573716c266c6f676f436f6c6f723d7768697465" alt="PostgreSQL"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/d16b1253b269884b394157ce0de15de7e2b42960c7efb35a0da7aa782c829f49/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f68746d782d322e302d3333363643433f6c6f676f3d68746d78266c6f676f436f6c6f723d7768697465"&gt;&lt;img src="https://camo.githubusercontent.com/d16b1253b269884b394157ce0de15de7e2b42960c7efb35a0da7aa782c829f49/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f68746d782d322e302d3333363643433f6c6f676f3d68746d78266c6f676f436f6c6f723d7768697465" alt="htmx"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/08206fac92ddcff40b866fc24413168b718686c9071aca8bcefbdffadac19b7c/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d417061636865253230322e302d4432323132383f6c6f676f3d617061636865266c6f676f436f6c6f723d7768697465"&gt;&lt;img src="https://camo.githubusercontent.com/08206fac92ddcff40b866fc24413168b718686c9071aca8bcefbdffadac19b7c/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d417061636865253230322e302d4432323132383f6c6f676f3d617061636865266c6f676f436f6c6f723d7768697465" alt="License: Apache 2.0"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;🤖 The AI suggests · 🧮 the domain validates · 🧑 the human decides&lt;/h3&gt;
&lt;/div&gt;
&lt;/div&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;📑 Contents&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/mauricioandrade/kakeizu#-the-problem" rel="noopener noreferrer"&gt;😩 The problem&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/mauricioandrade/kakeizu#-how-it-works" rel="noopener noreferrer"&gt;🔄 How it works&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/mauricioandrade/kakeizu#-screenshots" rel="noopener noreferrer"&gt;📸 Screenshots&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/mauricioandrade/kakeizu#-running" rel="noopener noreferrer"&gt;🚀 Running&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/mauricioandrade/kakeizu#-sales-report" rel="noopener noreferrer"&gt;📊 Sales report&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/mauricioandrade/kakeizu#-accuracy-on-real-receipts" rel="noopener noreferrer"&gt;🎯 Accuracy on real receipts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/mauricioandrade/kakeizu#-why-open-matters" rel="noopener noreferrer"&gt;🔓 Why open matters&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/mauricioandrade/kakeizu#-license" rel="noopener noreferrer"&gt;📄 License&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;😩 The problem&lt;/h2&gt;

&lt;/div&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/mauricioandrade/kakeizu/docs/gifs/closing-the-day.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%2Fmauricioandrade%2Fkakeizu%2FHEAD%2Fdocs%2Fgifs%2Fclosing-the-day.gif" alt="Anime character looking sick and horrified" width="200"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The owner or manager of a gas station, a corner shop or a small restaurant gets &lt;strong&gt;dozens of receipts a
day&lt;/strong&gt;: NFC-e, card machine slips, Pix receipts, the odd cheque. Closing the day means typing amount, date
and who paid or got paid into a spreadsheet, one receipt at a time.&lt;/p&gt;
&lt;p&gt;Cloud tools do this, but they want the business's financial data on…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/mauricioandrade/kakeizu" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Apache 2.0. The README has the setup for both Ollama layouts on Windows + WSL2, the accuracy tables and the backup script.&lt;/p&gt;

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

&lt;p&gt;The whole design is one sentence: &lt;strong&gt;the AI suggests, the domain validates, the human decides.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Model&lt;/td&gt;
&lt;td&gt;Gemma 4 (&lt;code&gt;gemma4:e4b&lt;/code&gt;), vision, thinking on, temperature 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inference&lt;/td&gt;
&lt;td&gt;Ollama on the Windows side, reached from WSL2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Glue&lt;/td&gt;
&lt;td&gt;Quarkus 3.40 with &lt;code&gt;quarkus-langchain4j-ollama&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Language&lt;/td&gt;
&lt;td&gt;Java 25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;PostgreSQL + Flyway&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web&lt;/td&gt;
&lt;td&gt;Qute templates + htmx, no frontend build&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Gemma reads, and nothing it returns is trusted
&lt;/h3&gt;

&lt;p&gt;Gemma gets the photo and a field guide in the system prompt, and returns plain strings: type, amount, date, counterparty, tax ID, unique ID, time, payment method, installments, card brand. Those strings never touch the database as values. They go through the same domain objects a person's correction goes through, and each object checks what it can.&lt;/p&gt;

&lt;p&gt;The check digits are the trick. They exist to catch a cashier's typo, and they catch a model's misread just as well. A CNPJ, for instance, carries two mod-11 digits:&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="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;Cnpj&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;value&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Cnpj&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;replaceAll&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"[.\\-/\\s]"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;toUpperCase&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;matches&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"[A-Z0-9]{12}\\d{2}"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidValueException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"CNPJ must have 14 characters"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;checkDigit&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;FIRST_WEIGHTS&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;valueAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;checkDigit&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;SECOND_WEIGHTS&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;valueAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidValueException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"CNPJ check digits do not match: "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;value&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="c1"&gt;// Each character is worth ASCII - 48, so the new alphanumeric CNPJ&lt;/span&gt;
    &lt;span class="c1"&gt;// (issued since July 2026) and the old all-digit one share one rule&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;valueAt&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;cnpj&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;index&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="n"&gt;cnpj&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;charAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="sc"&gt;'0'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a real Cielo slip, Gemma read the CNPJ as &lt;code&gt;13.438.977/0001-00&lt;/code&gt; three times out of three. The real one is &lt;code&gt;13.458.397/0001-01&lt;/code&gt;. The check digits rejected it every time, and the review screen put the photo next to the red field. It also read the authorization code &lt;code&gt;375579&lt;/code&gt; as &lt;code&gt;375759&lt;/code&gt;. No check digit covers that code, so nothing caught it. That is exactly why a person confirms every receipt.&lt;/p&gt;

&lt;h3&gt;
  
  
  How often it is right
&lt;/h3&gt;

&lt;p&gt;Seven receipts, three readings each, run through the app's own validation:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Of 210 fields&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Right on the first reading&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200 (95%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wrong, and the domain flags that field&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wrong, in a receipt the domain blocks for another field&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wrong, and nothing flags it&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Then a real gas station's day: a Getnet debit slip (3 of 3 ready to confirm as read), an NFC-e paid in cash (2 of 3, the 44-digit access key right every time), a Pix paid on the card machine, and a handwritten cheque. The small sample is the honest caveat: treat these as a sanity check, not a benchmark.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I learned the hard way
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Every field must be required in the schema.&lt;/strong&gt; Ollama uses the JSON schema LangChain4j sends as a grammar, and by default no field is required. Gemma's reasoning said "installments = 5" and the answer simply left the key out. Marking every field &lt;code&gt;@JsonProperty(required = true)&lt;/code&gt; took the share of right fields from 88% to 95%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model cannot say null.&lt;/strong&gt; With every field required and typed as a string, it writes the word &lt;code&gt;"null"&lt;/code&gt;, or just &lt;code&gt;", "&lt;/code&gt;, when a field is not on the receipt. The adapter turns those into a missing field.&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%2Fe3axlmdaragi9u5drcrc.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%2Fe3axlmdaragi9u5drcrc.gif" alt="Gintoki spilling his drink" width="240" height="172"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Thinking matters more than speed.&lt;/strong&gt; With thinking off, a reading takes about 2 seconds instead of 15. It also took an item price as the NFC-e total and the card brand as the store name: wrong values in a valid format, which no check digit can catch. Thinking stayed on.&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%2F5t441859anu3u50l219g.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%2F5t441859anu3u50l219g.gif" alt="Vegeta powering up" width="240" height="135"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Temperature 0 is not deterministic.&lt;/strong&gt; The same photo gave different answers between runs, so every receipt in the accuracy tables was read three times. The NFC-e template's total, 146,46, came back right, then as 46,46, then as 4,00, in different runs.&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%2Fbw23y3pzcuzet2uiati8.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%2Fbw23y3pzcuzet2uiati8.gif" alt="Naruto in shock" width="240" height="180"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tell the model where things are printed.&lt;/strong&gt; It returned the NFC-e note number as the access key until the prompt said the key is all 44 digits, in groups of four, under "Consulte pela Chave de Acesso". A Pix paid on the card machine kept coming back as a card slip until the prompt described it: an "ID/TRANSAÇÃO" and no card brand, no card number, no authorization code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Some mistakes need the calendar, not a check digit.&lt;/strong&gt; On a handwritten cheque, Gemma read the year "26" as 2022, three times out of three. A cheque that old can no longer be cashed in Brazil, so a cheque dated more than six months ago now gets a warning on review.&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%2Fs3ztl6u94tmt0qqsydk7.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%2Fs3ztl6u94tmt0qqsydk7.gif" alt="Joey trying hard to think" width="240" height="243"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Nobody waits for the model
&lt;/h3&gt;

&lt;p&gt;A warm reading takes about 15 seconds on a consumer GPU. I measured where it goes: the image takes under a second, and the rest is Gemma writing about 750 tokens of reasoning at 75 tokens a second. Turning the reasoning off cost accuracy, and asking for shorter reasoning changed nothing, so the app stopped waiting for it instead. The upload saves the photo and returns at once, a background worker reads it, and the line on today's list updates itself. If Ollama is down, the receipt still goes to review, empty, to be typed from the photo.&lt;/p&gt;

&lt;h3&gt;
  
  
  Java 25 doing real work
&lt;/h3&gt;

&lt;p&gt;The receipt is a sealed interface with one record per type, so the mapper and the reports are exhaustive switches with record patterns and no &lt;code&gt;default&lt;/code&gt;. Up to five photos can go in one upload: each is saved and queued on its own virtual thread inside a &lt;code&gt;StructuredTaskScope&lt;/code&gt; (a preview in Java 25), so one bad photo never stops the others. A correlation id rides a &lt;code&gt;ScopedValue&lt;/code&gt; into every log line of an upload, across the forked threads and into the background reader.&lt;/p&gt;

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

&lt;p&gt;Because with a closed API, this product could not be sold to the person it is for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The data cannot leave the shop.&lt;/strong&gt; A day of receipts is the business itself: what it sold, to whom, through which bank, from which supplier, and sometimes a customer's name and partial CPF. An open-weight Gemma on the shop's own GPU means the photos, the database and the model all stay on one computer. There is no privacy policy to read because there is no third party.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cost does not grow with the work.&lt;/strong&gt; A gas station photographs dozens of receipts a day. A per-call API turns every one of them into a line on a bill, so the busiest day costs the most. Local inference costs the electricity of about 15 seconds of GPU per receipt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model is a setting.&lt;/strong&gt; &lt;code&gt;KAKEIZU_MODEL&lt;/code&gt; swaps Gemma for any Ollama vision model, and the same check digits judge the new one. When a better small open model ships, the shop gets it for free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open models let me see why they fail.&lt;/strong&gt; Because I ran Gemma myself, I could turn thinking on and off, read its reasoning, and see it decide "installments = 5" and then drop the key. That is how the required-schema fix was found. A closed endpoint would have shown me only a wrong answer.&lt;/p&gt;

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

&lt;p&gt;My first user is that friend at the gas station. Midway through the week they sent me photos of a real day's receipts: a Getnet debit slip, a Pix paid on the card machine, an NFC-e paid in cash, and a handwritten cheque. The station stays anonymous here, and none of those receipts are in the repository.&lt;/p&gt;

&lt;p&gt;That one batch changed the product more than any test I wrote:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A Pix paid on the card machine has no end-to-end ID.&lt;/strong&gt; The slip prints the machine's own transaction ID, so the Pix ID became optional and the prompt learned to tell this slip from a card slip.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gas stations take cheques.&lt;/strong&gt; Kakeizu now reads them, checks the magnetic line's three check digits when it is typed, and treats the cheque the other way round: the CNPJ on it is the payer's, so a cheque from another company is a sale. Shops that do not take cheques switch this off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The NFC-e access key is printed far from its label.&lt;/strong&gt; The prompt now says exactly where to find all 44 digits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After testing it, they want to use it every day.&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%2Flhavdw00vqxat7ehxjqw.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%2Flhavdw00vqxat7ehxjqw.gif" alt="Gintoki saying " width="240" height="172"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; Gemma 4 (&lt;code&gt;gemma4:e4b&lt;/code&gt;) runs locally through Ollama and does all the reading: receipt type, amount, date, counterparty, tax ID, access keys and payment details, from phone photos of thermal slips, fiscal coupons and handwritten cheques. Kakeizu is built around both its strengths and its mistakes: thinking stays on because it buys accuracy, and every field it returns goes through deterministic checks before a person confirms it.&lt;/p&gt;

</description>
      <category>hf26challenge</category>
      <category>java</category>
      <category>quarkus</category>
      <category>bestuseofgemma</category>
    </item>
    <item>
      <title>Janela — a local Gemma tells you when to go outside (and why)</title>
      <dc:creator>Mauricio Andrade</dc:creator>
      <pubDate>Wed, 07 Oct 2026 22:28:05 +0000</pubDate>
      <link>https://dev.to/mauricioandrade/janela-a-local-gemma-tells-you-when-to-go-outside-and-why-2j37</link>
      <guid>https://dev.to/mauricioandrade/janela-a-local-gemma-tells-you-when-to-go-outside-and-why-2j37</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 2026 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;In most of Brazil, "I'll go for a run later" isn't about frost or snow. It's about dodging a &lt;strong&gt;UV index of 11 at noon&lt;/strong&gt;, a feels-like of 38 °C at 2 PM, and the &lt;strong&gt;thunderstorm that shows up at 5 PM&lt;/strong&gt; almost every summer afternoon. The weather app has all of that data. It just doesn't tell you &lt;em&gt;when to go&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Hina from &lt;em&gt;Weathering With You&lt;/em&gt; could pray for a sunny window. The rest of us have to read the forecast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Janela&lt;/strong&gt; (Portuguese for &lt;em&gt;window&lt;/em&gt;) does that for you. You give it a city, an activity (run, walk, bike, picnic, gardening, or an outdoor workout at the square's gym), how long you want to be out, and how many days ahead to look. It answers with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;the best time window&lt;/strong&gt;, plus two alternatives, each scored 0–100 with its feels-like temperature, UV, rain chance and wind;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;each day's comfort drawn as a tide&lt;/strong&gt;, so you see at a glance when the day opens up and when it closes;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;a short explanation written by Gemma running on my own laptop&lt;/strong&gt;, saying &lt;em&gt;why&lt;/em&gt; that window wins, plus a tiny "touch grass" challenge for when you're out there.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It works in Portuguese and English (°F and mph in English), and searches are shareable links.&lt;/p&gt;

&lt;h3&gt;
  
  
  Every activity, in anime terms
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;🏃 Run&lt;/th&gt;
&lt;th&gt;🚶 Walk&lt;/th&gt;
&lt;th&gt;🚴 Bike&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&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%2Flcsakh902ighbsg0mu0s.gif" alt="Chubby Naruto doing the Naruto run, captioned " width="480" height="359"&gt;&lt;/td&gt;
&lt;td&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%2Fx5tlcsqep8w2zs4lc688.gif" alt="Zoro from One Piece pausing in an alley, captioned " width="480" height="258"&gt;&lt;/td&gt;
&lt;td&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%2Fpihtdi8bhbn6lx264hnh.gif" alt="The Sohoku cycling team from Yowamushi Pedal racing around a curve" width="480" height="270"&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;10–22 °C feels-like. Form not included.&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;Janela finds the window. Finding the park is on you.&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;Pick the cool hour, then draft behind your friends.&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;🧺 Picnic&lt;/th&gt;
&lt;th&gt;🌱 Gardening&lt;/th&gt;
&lt;th&gt;💪 Square gym&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&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%2Fx79oju9scnd8c4asryiv.gif" alt="Young Goku from Dragon Ball happily eating from a huge pile of food" width="480" height="360"&gt;&lt;/td&gt;
&lt;td&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%2F3tehubjwa8pe9vgfn5s7.gif" alt="Two hands joining in a hand sign from Jujutsu Kaisen" width="480" height="270"&gt;&lt;/td&gt;
&lt;td&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%2Fj31mzdbn6gfncadd3gbm.gif" alt="Rock Lee and Might Guy striking the same pose against the sky" width="480" height="356"&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;18–28 °C feels-like. Bring enough for Goku.&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;Domain Expansion: Vegetable Garden.&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;The springtime of youth starts at 06:00.&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Lain lived in the Wired. Janela is for the rest of us, who occasionally need to log off.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fekmk6pla568yiu5m4zjc.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%2Fekmk6pla568yiu5m4zjc.gif" alt="Typing Recife, picking it from the suggestions, choosing Run and 3 days, then the answer: Thursday 06:00–07:00 with Gemma's explanation, the comfort tide for Wednesday to Friday and the table of three windows" width="800" height="500"&gt;&lt;/a&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;city + activity ──▶ Spring Boot API ──▶ Open-Meteo (geocoding + hourly forecast)
                         │
                         ▼
                  WindowScorer (plain Java, unit-tested)
                         │  top 3 windows, already ranked
                         ▼
               Ollama up? ── yes ──▶ Gemma writes the explanation
                         └── no ───▶ template writes it instead
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Java computes, Gemma explains
&lt;/h3&gt;

&lt;p&gt;This was the one rule I set before writing any code: &lt;strong&gt;the model never picks a time and never does math.&lt;/strong&gt; Java is Senku here: it does the science. Gemma just presents the results.&lt;/p&gt;

&lt;p&gt;Small models (I'm using &lt;code&gt;gemma3:4b&lt;/code&gt;) are great at turning data into friendly sentences and bad at arithmetic. If I asked Gemma "when should I run in Recife tomorrow?" with a raw forecast, it would answer confidently, and sometimes wrongly.&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%2Fqm22qr9uikr8cma6lrmy.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%2Fqm22qr9uikr8cma6lrmy.gif" alt="Aqua from Konosuba leaning in with a cheerful, overconfident grin" width="546" height="640"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A 4B model doing arithmetic: full confidence, questionable results.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So all the deciding happens in a deterministic domain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every daylight hour starts at 100 and loses points for rain chance, feels-like temperature outside the activity's range, and UV or wind above its limits (a run wants 10–22 °C feels-like, a picnic 18–28 °C);&lt;/li&gt;
&lt;li&gt;a window is the best run of consecutive hours covering your duration, and any hour below 40 rules the whole window out;&lt;/li&gt;
&lt;li&gt;the best window of each day is picked before repeating a day, and once the sun has set, "3 days" starts tomorrow instead of wasting today.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of that is covered by JUnit tests (118 on the backend), with an injected &lt;code&gt;Clock&lt;/code&gt; so "drop the hours that already passed" is testable too.&lt;/p&gt;
&lt;h3&gt;
  
  
  Pre-digesting the payload for a 4B model
&lt;/h3&gt;

&lt;p&gt;The interesting part was learning what a small model needs to &lt;em&gt;not&lt;/em&gt; make things up. My first prompts sent ISO timestamps and raw numbers, and Gemma would echo &lt;code&gt;2026-10-07T17:00&lt;/code&gt;, call UV 0.6 "high", and praise window #2.&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%2Fjr0ut8e8eiky9vyypeyx.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%2Fjr0ut8e8eiky9vyypeyx.jpg" alt="The " width="676" height="454"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;gemma3:4b looking at UV 0.6, before I pre-digested the payload.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What fixed it was doing the interpreting in Java and letting the model only reword:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"best"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"rating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"great"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"day"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Thursday, Oct 8"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"06:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"end"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"07:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"temperature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;83&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"comfort"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"uvIndex"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"uvLevel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"low"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"rainChancePercent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"rainLevel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"low"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"wind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"reasons"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"before the day's heat peaks at 13:00"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"alternative"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"rating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fair"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"day"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Friday, Oct 9"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"17:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"end"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"18:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"…"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ul&gt;
&lt;li&gt;day names and times are &lt;strong&gt;already formatted&lt;/strong&gt; in the reader's language;&lt;/li&gt;
&lt;li&gt;numbers are &lt;strong&gt;rounded and converted&lt;/strong&gt; (°F/mph in English) the way the UI shows them;&lt;/li&gt;
&lt;li&gt;the window's quality, temperature, UV and rain come &lt;strong&gt;with words&lt;/strong&gt; (&lt;code&gt;rating&lt;/code&gt; is the same word the screen shows next to the score, plus &lt;code&gt;comfort&lt;/code&gt;, the WHO UV category and &lt;code&gt;rainLevel&lt;/code&gt;), so Gemma never decides what "hot", "low chance" or "ideal" means;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;reasons&lt;/code&gt; are the window's advantages &lt;strong&gt;already worked out in Java&lt;/strong&gt; from the day's outlook; the prompt tells Gemma to reword those and draw no conclusions of its own;&lt;/li&gt;
&lt;li&gt;the score is left out on purpose, because the model kept quoting it;&lt;/li&gt;
&lt;li&gt;there are no ranks: the best window and one alternative go &lt;strong&gt;by name&lt;/strong&gt;, because with a ranked list of three, Gemma mixed up the order, praised the wrong one and wrote "Rank 2" into the text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The prompt then forbids the specific failures I saw in testing: inventing a beach or a park, saying "today" about a window two days out, writing JSON field names into the text, or describing a 33 °C afternoon as "pleasant".&lt;/p&gt;

&lt;p&gt;A prompt is a request, not a guarantee, so &lt;strong&gt;Java also reads the reply&lt;/strong&gt; before showing it. Any time it wasn't given, a 12-hour clock, a field name, an invented beach, "pleasant" for a hot hour, "ideal" for a fair window or a missing 🌿 line gets the reply rejected, and Gemma gets one more try with the mistake named. It usually fixes it, and on my laptop the retry costs 2–4 seconds.&lt;/p&gt;

&lt;p&gt;If the second try still breaks a rule, or Ollama is off or slow (30 s timeout), a template writes the text and the UI labels it as such instead of pretending.&lt;/p&gt;
&lt;h3&gt;
  
  
  Stack
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backend:&lt;/strong&gt; Java 21, Spring Boot 4, Spring AI 2.0 with the Ollama starter, Clean Architecture (the use case is framework-free), API versioning with Spring Framework 7, Caffeine caches, OpenAPI docs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model:&lt;/strong&gt; &lt;code&gt;gemma3:4b&lt;/code&gt; via &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt;, swappable with one env var&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weather:&lt;/strong&gt; &lt;a href="https://open-meteo.com" rel="noopener noreferrer"&gt;Open-Meteo&lt;/a&gt; forecast and geocoding (open data, no key)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontend:&lt;/strong&gt; React 19, TypeScript, Vite, TanStack Query, Tailwind 4, shadcn/ui on Base UI, a hand-drawn SVG tide chart&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Why open innovation matters here
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It runs on a laptop.&lt;/strong&gt; No API key, no account, no cost per question. Anyone can clone it and have it working in three commands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your routine stays with you.&lt;/strong&gt; Where you live and when you go out are exactly the kind of data I don't want to send to an AI cloud. With Gemma local, they don't leave the machine. To be honest about it: the forecast comes from Open-Meteo's open API, so &lt;em&gt;that&lt;/em&gt; request does go out (just coordinates, no profile).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swap the model with one variable.&lt;/strong&gt; &lt;code&gt;OLLAMA_MODEL=gemma3:12b&lt;/code&gt; and you're done. Open weights mean the app isn't tied to one vendor's pricing or deprecation schedule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It works without AI.&lt;/strong&gt; Because the AI only explains and never decides, turning it off loses the nice sentences, not the answer.&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) runs locally through Ollama and writes every recommendation, in Portuguese or English. The project is built around what a small open model is good at (language) and keeps it away from what it isn't (math and decisions).&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/mauricioandrade" rel="noopener noreferrer"&gt;
        mauricioandrade
      &lt;/a&gt; / &lt;a href="https://github.com/mauricioandrade/janela" rel="noopener noreferrer"&gt;
        janela
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      🌿 Find your window to touch grass. Picks the best outdoor time for running, walking or cycling based on heat, UV and rain — explained by a local open-weight model (Gemma via Ollama). Java 21 + Spring AI. Built for Hacktoberfest 2026.
    &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;🌿 Janela&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Find your window to touch grass.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Janela (Portuguese for &lt;em&gt;window&lt;/em&gt;) finds the best time windows in the next few days for an outdoor activity —
running, walking, cycling, a picnic, some gardening, a workout at the square's outdoor gym — based on what actually matters in a tropical climate
heat, UV index, afternoon storms and wind.&lt;/p&gt;
&lt;p&gt;Then a &lt;strong&gt;local open-weight model (Gemma, via Ollama)&lt;/strong&gt; turns those numbers into a short, human recommendation
and a tiny "touch grass" challenge. No API keys, no per-query costs, and it still works if the model is offline.&lt;/p&gt;
&lt;p&gt;Built for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05" rel="nofollow"&gt;Hacktoberfest 2026 Open-Source AI Challenge: Week 1 — "Touch Grass"&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/mauricioandrade/janela/docs/demo.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%2Fmauricioandrade%2Fjanela%2FHEAD%2Fdocs%2Fdemo.gif" alt="Demo: typing Recife, picking Run and 3 days, then the answer — Thursday 06:00–07:00 — with Gemma's explanation, each day's comfort tide and the table of windows" width="94%"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/mauricioandrade/janela/docs/screenshot-light.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%2Fmauricioandrade%2Fjanela%2FHEAD%2Fdocs%2Fscreenshot-light.png" alt="Janela in English on desktop: a run in Recife on Thursday 06:00–07:00, score 72 Great, 83°F feels-like, with Gemma's explanation below" width="62%"&gt;&lt;/a&gt;
  &amp;nbsp;
  &lt;a rel="noopener noreferrer" href="https://github.com/mauricioandrade/janela/docs/screenshot-dark-mobile.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%2Fmauricioandrade%2Fjanela%2FHEAD%2Fdocs%2Fscreenshot-dark-mobile.png" alt="Janela in Portuguese on mobile, dark theme: picnic windows in Rio de Janeiro" width="30%"&gt;&lt;/a&gt;
&lt;/p&gt;

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

&lt;p&gt;In much of Brazil, "go for a run" isn't about frost. It's about dodging a UV index of 11 at noon
and the thunderstorm that shows up at 5 PM. Weather apps give you the data; Janela tells you…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/mauricioandrade/janela" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  Credits
&lt;/h2&gt;

&lt;p&gt;Weather data by &lt;a href="https://open-meteo.com" rel="noopener noreferrer"&gt;Open-Meteo&lt;/a&gt; (CC BY 4.0) · &lt;a href="https://ai.google.dev/gemma" rel="noopener noreferrer"&gt;Gemma&lt;/a&gt; by Google · &lt;a href="https://spring.io/projects/spring-ai" rel="noopener noreferrer"&gt;Spring AI&lt;/a&gt; and &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt; · Archivo typeface by Omnibus-Type · country flags from &lt;a href="https://gitlab.com/catamphetamine/country-flag-icons" rel="noopener noreferrer"&gt;country-flag-icons&lt;/a&gt;, Brazilian state flags from Wikimedia Commons. Anime GIFs belong to their respective creators and studios.&lt;/p&gt;

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