<?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: Wellington Filipe</title>
    <description>The latest articles on DEV Community by Wellington Filipe (@wellington_filipe_fccda4c).</description>
    <link>https://dev.to/wellington_filipe_fccda4c</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%2F4116172%2Fc5e0e0ce-d3f6-4a91-891b-4c2756c9f619.jpg</url>
      <title>DEV Community: Wellington Filipe</title>
      <link>https://dev.to/wellington_filipe_fccda4c</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/wellington_filipe_fccda4c"/>
    <language>en</language>
    <item>
      <title>A ghost in my shell, powered by a local Gemma</title>
      <dc:creator>Wellington Filipe</dc:creator>
      <pubDate>Mon, 26 Oct 2026 20:48:23 +0000</pubDate>
      <link>https://dev.to/wellington_filipe_fccda4c/a-ghost-in-my-shell-powered-by-a-local-gemma-17gn</link>
      <guid>https://dev.to/wellington_filipe_fccda4c/a-ghost-in-my-shell-powered-by-a-local-gemma-17gn</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week4-2026-10-26"&gt;Hacktoberfest Open-Source AI Challenge: Week 4&lt;/a&gt; — theme: Ghost in the Machine.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Haunted Terminal&lt;/strong&gt;: a ghost that lives in your shell. Every few commands (1 in 4 by default), it whispers one eerie sentence about what you just ran and whether it failed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; node_modules
👻 A clean sweep… but something vital vanished with those modules, didn&lt;span class="s1"&gt;'t it?
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is about 80 lines: a Bash hook (&lt;code&gt;haunt.sh&lt;/code&gt;) on &lt;code&gt;PROMPT_COMMAND&lt;/code&gt; and a stdlib-only Python script (&lt;code&gt;whisper.py&lt;/code&gt;) that calls Ollama's &lt;code&gt;/api/generate&lt;/code&gt;. No dependencies, no API key, no account.&lt;/p&gt;

&lt;p&gt;Code: &lt;a href="https://github.com/Tinhomagri/haunted-terminal" rel="noopener noreferrer"&gt;https://github.com/Tinhomagri/haunted-terminal&lt;/a&gt;&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your shell history is private.&lt;/strong&gt; Commands carry hostnames, paths, tokens pasted by mistake. A ghost that reads them must not be a cloud API. Here the prompt never leaves &lt;code&gt;127.0.0.1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It works with the lights off.&lt;/strong&gt; No internet, no outage, no rate limit. If Ollama is down the ghost just stays silent (&lt;code&gt;except Exception: pass&lt;/code&gt;) and the prompt is never blocked, because the call runs in a background subshell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I can swap the model with one env var&lt;/strong&gt; (&lt;code&gt;HAUNT_MODEL&lt;/code&gt;). That let me compare two sizes honestly instead of trusting a spec sheet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Costs nothing per command.&lt;/strong&gt; A hook that fires on every few keystrokes would be absurd to bill per token.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Gemma 3 4B vs 12B (measured, not guessed)
&lt;/h2&gt;

&lt;p&gt;Same prompt, 5 commands, 16 cores, no GPU, 14 GB RAM (warm = model already loaded):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;First call&lt;/th&gt;
&lt;th&gt;Warm median&lt;/th&gt;
&lt;th&gt;Warm max&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;gemma3:4b&lt;/td&gt;
&lt;td&gt;9.5 s&lt;/td&gt;
&lt;td&gt;2.2 s&lt;/td&gt;
&lt;td&gt;2.3 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemma3:12b&lt;/td&gt;
&lt;td&gt;5.5 s (pre-warmed)&lt;/td&gt;
&lt;td&gt;8.2 s&lt;/td&gt;
&lt;td&gt;9.3 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things I learned:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;12B writes slightly better lines, but 4B is the right default.&lt;/strong&gt; The ghost runs in the background, but a whisper that shows up 9 s after the command feels disconnected; 2 s feels like it is watching you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold start can silence the ghost.&lt;/strong&gt; My first 12B run timed out: loading ~8 GB of weights exceeded the 20 s request timeout, and the "never explain yourself" error handling hid it. When it loaded first, the same call took 5.5 s. If you use a bigger model, keep it resident (&lt;code&gt;OLLAMA_KEEP_ALIVE&lt;/code&gt;) or raise the timeout.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Sample 4B lines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;git push --force&lt;/code&gt; → &lt;em&gt;The past resists your forceful rewrite, forever staining its digital echoes.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sudo apt update&lt;/code&gt; → &lt;em&gt;The system whispers of updates, but something else is being quietly installed.&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull gemma3:4b
git clone https://github.com/Tinhomagri/haunted-terminal &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;haunted-terminal
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"source &lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;/haunt.sh"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Env: &lt;code&gt;HAUNT_ODDS&lt;/code&gt;, &lt;code&gt;HAUNT_MODEL&lt;/code&gt;, &lt;code&gt;HAUNT_HOST&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;p&gt;Small models sometimes repeat "echoes" and "something unseen". Bash only (zsh needs &lt;code&gt;precmd&lt;/code&gt;). Latencies above are CPU-only on one machine.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I taught a local LLM to be lazy on my behalf (sloth-alias)</title>
      <dc:creator>Wellington Filipe</dc:creator>
      <pubDate>Tue, 20 Oct 2026 02:12:59 +0000</pubDate>
      <link>https://dev.to/wellington_filipe_fccda4c/i-taught-a-local-llm-to-be-lazy-on-my-behalf-sloth-alias-270d</link>
      <guid>https://dev.to/wellington_filipe_fccda4c/i-taught-a-local-llm-to-be-lazy-on-my-behalf-sloth-alias-270d</guid>
      <description>&lt;p&gt;&lt;em&gt;This is my submission for the Hacktoberfest Open-Source AI Challenge: Week 3 (Slow and Steady).&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;sloth-alias&lt;/strong&gt; reads a shell history file, finds the commands and command &lt;em&gt;sequences&lt;/em&gt; you keep retyping, and turns them into aliases. A local open-weight model (any Ollama model, default &lt;code&gt;gemma3:4b&lt;/code&gt;) does one job: it &lt;strong&gt;names&lt;/strong&gt; each shortcut and describes it in a few words.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 sloth_alias.py sample_history.txt     # gemma3:4b via Ollama
  deploy    x3   saves   228 keystrokes  &amp;lt;- git push origin main &amp;amp;&amp;amp; docker compose up -d --build &amp;amp;&amp;amp; ...
  commit    x4   saves   196 keystrokes  &amp;lt;- git add -A &amp;amp;&amp;amp; git commit -m wip &amp;amp;&amp;amp; git push origin main
  commit2   x4   saves   152 keystrokes  &amp;lt;- git status &amp;amp;&amp;amp; git add -A &amp;amp;&amp;amp; git commit -m wip
Total lazy payoff: 576 keystrokes (~2.9 min at 40 wpm).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Single file, Python standard library only, zero dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sloth rules (design decisions)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The model names, it never writes.&lt;/strong&gt; The alias body is always assembled from &lt;em&gt;your&lt;/em&gt; commands joined with &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;. The model returns only &lt;code&gt;{"name", "why"}&lt;/code&gt;. A prompt-injected or hallucinating model can't smuggle a command into your shell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing installs itself.&lt;/strong&gt; Output goes to &lt;code&gt;sloth-aliases.sh&lt;/code&gt;. You read it, then you &lt;code&gt;source&lt;/code&gt; it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No implicit history access.&lt;/strong&gt; You pass the file explicitly. Your history never leaves your machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Names are validated&lt;/strong&gt;: regex check, no shell keywords, and no collision with anything already on your &lt;code&gt;PATH&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Degrades gracefully&lt;/strong&gt;: model down, bad JSON, or a name that breaks the length rule (my first prompt got &lt;code&gt;statusaddcommit&lt;/code&gt; back from a 4B model) falls back to deterministic initials. A one-shot example in the prompt fixed most of it. &lt;code&gt;--no-llm&lt;/code&gt; works fully offline.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;Shell history is some of the most sensitive text on a developer machine: hostnames, paths, tokens pasted by accident. This is exactly the data you should not ship to a hosted API. With an open-weight model on localhost, the history stays on disk, it costs nothing per run, and I can swap models with one flag (&lt;code&gt;--model&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  The lazy payoff
&lt;/h2&gt;

&lt;p&gt;On the bundled 26-line sample history, it finds 576 keystrokes of savings, about 3 minutes of typing, in one run. That sample is synthetic; on a real history the repeats compound.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://github.com/Tinhomagri/sloth-alias" rel="noopener noreferrer"&gt;https://github.com/Tinhomagri/sloth-alias&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do next
&lt;/h2&gt;

&lt;p&gt;Overlap handling between sequences (right now &lt;code&gt;status &amp;amp;&amp;amp; add &amp;amp;&amp;amp; commit&lt;/code&gt; and &lt;code&gt;add &amp;amp;&amp;amp; commit &amp;amp;&amp;amp; push&lt;/code&gt; can both be suggested, as the &lt;code&gt;commit2&lt;/code&gt; name above shows), and a &lt;code&gt;--explain&lt;/code&gt; mode that lets the model flag dangerous aliases such as ones containing &lt;code&gt;rm -rf&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>hf26challenge</category>
      <category>opensource</category>
      <category>ai</category>
      <category>productivity</category>
    </item>
    <item>
      <title>The invoice on the counter: a local-first cost reader for small restaurants</title>
      <dc:creator>Wellington Filipe</dc:creator>
      <pubDate>Wed, 14 Oct 2026 15:03:27 +0000</pubDate>
      <link>https://dev.to/wellington_filipe_fccda4c/the-invoice-on-the-counter-a-local-first-cost-reader-for-small-restaurants-3bo</link>
      <guid>https://dev.to/wellington_filipe_fccda4c/the-invoice-on-the-counter-a-local-first-cost-reader-for-small-restaurants-3bo</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hf26"&gt;Hacktoberfest Open-Source AI Challenge: Week 2&lt;/a&gt;, Side Hustle theme.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Notinha&lt;/strong&gt; turns a phone photo of a supplier invoice into a cost spreadsheet a restaurant can actually import. It runs entirely on the owner's laptop — Gemma 3 through Ollama, no API key, no upload, no per-invoice cost.&lt;/p&gt;

&lt;p&gt;I work on point-of-sale software for Brazilian restaurants, so I have watched this specific chore happen a lot. The supplier delivers, hands over a paper invoice, and somebody types it into a spreadsheet line by line. Every week. The owner of a 12-table burger place does this at 11pm, after close, because nobody else will.&lt;/p&gt;

&lt;p&gt;And the number they actually need is never the number on the paper.&lt;/p&gt;

&lt;p&gt;The invoice says &lt;strong&gt;R$ 89,90 per case&lt;/strong&gt;. The recipe card needs &lt;strong&gt;R$ 7,49 per litre&lt;/strong&gt;. The invoice says &lt;code&gt;FD 6X2 ROLOS&lt;/code&gt;; the stock count is in rolls. Between the paper and the decision — &lt;em&gt;is this burger still making money?&lt;/em&gt; — there is a unit conversion that a human does in their head, badly, at 11pm.&lt;/p&gt;

&lt;p&gt;So Notinha does two things, and the second one is the product:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the table off the photo.&lt;/li&gt;
&lt;li&gt;Convert closed packaging into the unit the kitchen counts in.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ notinha nota-do-fornecedor.jpg
lendo nota-do-fornecedor.jpg com gemma3:4b (local)...

Fornecedor: SERRA AZUL LTDA   Doc: PEDIDO DE VENDA No 018447   Data: 13/10/2026
10 itens · total lido R$ 2329,8

ITEM                                    QTD        UNIT     CUSTO USO
REFRIGERANTE COLA CX 12X1L                4        89,9       7,492/L
OLEO DE SOJA CX 20X900ML                  2         118       6,556/L
QUEIJO MUSSARELA FATIADO               12,5        38,9       38,9/KG
                                 ↻ unitário relido (38,9); quantidade calculada: 486,25 ÷ 38,9
BACON EM MENTA DEFUMADO                 8,2        27,5       27,5/KG
BATATA PRE-FRITA CONG PCT 2.5KG          10        31,4       31,4/UN
PAO BRIOCHE BURGER PCT C/ 12             15        14,2       14,2/UN
ALFACE AMERICANA UNIDADE                 24         4,3        4,3/UN
TOMATE SALADA CAIXA 20KG                  1          96        4,8/KG
MOLHO BARBECUE GALAO 3.2KG                3       42,75      42,75/UN
PAPEL TOALHA FD 6X2 ROLOS                 5        33,6        2,8/UN

CSV: nota-do-fornecedor.csv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That last column is the whole point. &lt;code&gt;CX 12X1L&lt;/code&gt; at R$ 89,90 became R$ 7,49 per litre. &lt;code&gt;FD 6X2 ROLOS&lt;/code&gt; at R$ 33,60 became R$ 2,80 per roll. Nobody did arithmetic at 11pm.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;↻&lt;/code&gt; on the cheese line is the interesting part. The model read that row wrong, the arithmetic caught it, a second look fixed it, and the mark tells you which number was reconciled rather than copied. More on that below — it is most of the engineering.&lt;/p&gt;

&lt;p&gt;There is also a drag-and-drop page on &lt;code&gt;localhost:8777&lt;/code&gt;, because the person who owns the restaurant is not going to open a terminal.&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%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsykeoqx8g4b1iib0c0ll.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%2Fsykeoqx8g4b1iib0c0ll.png" alt="Notinha reading a supplier invoice in the browser: ten line items with cost per unit of use, the cheese line carrying a note that its unit price was re-read and its quantity derived, and a button to download the import CSV"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;↻&lt;/code&gt; under the cheese is the whole audit trail: &lt;em&gt;unitário relido (38,9); quantidade calculada: 486,25 ÷ 38,9&lt;/em&gt;. The first pass read that row wrong, the second pass recovered it, and the line says so rather than looking like the other nine. Anything the second pass &lt;strong&gt;cannot&lt;/strong&gt; recover stays flagged in orange and reaches the CSV with its numbers blank.&lt;/p&gt;

&lt;p&gt;Repository: &lt;strong&gt;&lt;a href="https://github.com/Tinhomagri/notinha" rel="noopener noreferrer"&gt;https://github.com/Tinhomagri/notinha&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Try it on synthetic data — no real invoice needed, and no real CNPJ, supplier or price anywhere in the repo:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull gemma3:4b
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
python3 samples/make_sample.py
notinha samples/nota-fornecedor.jpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;make_sample.py&lt;/code&gt; draws an invoice and then &lt;em&gt;ruins&lt;/em&gt; it on purpose: tilts it, drops a light gradient across it, adds sensor grain. A clean render would have told me nothing.&lt;/p&gt;
&lt;h2&gt;
  
  
  How I Used Open Models
&lt;/h2&gt;

&lt;p&gt;The stack is &lt;code&gt;gemma3:4b&lt;/code&gt; served by Ollama, called over plain HTTP with a JSON Schema. Around 590 lines of Python — 455 of them the reading pipeline, the rest the drag-and-drop page — and one dependency (&lt;code&gt;requests&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured output is doing real work here.&lt;/strong&gt; Ollama lets you hand &lt;code&gt;/api/chat&lt;/code&gt; a JSON Schema in &lt;code&gt;format&lt;/code&gt;, and the model is constrained to produce conforming JSON. No "respond only with JSON" in the prompt, no regex over a markdown fence.&lt;/p&gt;

&lt;p&gt;One detail cost me a run to find. Every numeric field in my schema is typed &lt;code&gt;string&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;valor_unitario&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Brazilian invoices write one thousand two hundred thirty-four and fifty-six cents as &lt;code&gt;1.234,56&lt;/code&gt;. Let that be a JSON &lt;code&gt;number&lt;/code&gt; and the parser reads &lt;code&gt;1.234&lt;/code&gt; — a thousandfold error, silently, with no exception anywhere. So the model copies the glyphs and my code owns the parsing:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;limpo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# comma is the decimal: a dot can only be a thousands separator
&lt;/span&gt;    &lt;span class="n"&gt;limpo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;limpo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The prompt is also explicit that the model must not compute anything — just transcribe. Arithmetic is the one thing I can do perfectly and it cannot.&lt;/p&gt;
&lt;h3&gt;
  
  
  The check that makes it usable
&lt;/h3&gt;

&lt;p&gt;A 4B model reading a tilted photo under kitchen lighting &lt;em&gt;will&lt;/em&gt; misread a digit. It reads &lt;code&gt;12,500 × 38,90&lt;/code&gt; in the cheese row as quantity &lt;code&gt;2&lt;/code&gt;, unit price &lt;code&gt;12,5&lt;/code&gt; — and it does so on every run, so this is not bad luck I can retry away.&lt;/p&gt;

&lt;p&gt;The fix is not a better prompt. It is that an invoice line is over-determined: quantity × unit price = line total. Three numbers, one constraint. So:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;quantidade&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;unitario&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;esperado&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quantidade&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;unitario&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;quantize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CENTAVO&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;esperado&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CENTAVO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alertas&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;qtd x unitário = &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;esperado&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, nota diz &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That misread line came out flagged: &lt;code&gt;⚠ qtd x unitário = 25.00, nota diz 486.25&lt;/code&gt;. The error was caught by arithmetic, not by the model noticing.&lt;/p&gt;

&lt;p&gt;The same redundancy repairs crushed invoices: if a photo loses the unit-price column to a glare, any two of the three numbers reconstruct the third.&lt;/p&gt;
&lt;h3&gt;
  
  
  Spending inference only where verification failed
&lt;/h3&gt;

&lt;p&gt;Flagging was where I stopped at first. Then I looked at &lt;em&gt;why&lt;/em&gt; the model misses that row, and the answer suggested the fix.&lt;/p&gt;

&lt;p&gt;The failure is not that the model cannot read &lt;code&gt;12,500&lt;/code&gt;. It is that it is reading a ten-row table in one pass, and the description column sits far to the left of the numeric columns. Row alignment is what breaks, not glyph recognition.&lt;/p&gt;

&lt;p&gt;So the repair is a second look with a narrower question. For each flagged line — and only those — the line goes back to the model on its own, anchored by the one number I already trust:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Na nota da imagem, ache a linha do produto "QUEIJO MUSSARELA FATIADO".
Essa linha tem valor total 486,25. Olhe só para ela e devolva a quantidade,
a unidade e o valor unitário, copiados exatamente como estão escritos.
Atenção à coluna QTD: números como 12,500 são um só valor, não dois.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The focused read returns &lt;code&gt;quantidade: 2, valor_unitario: 38,90&lt;/code&gt;. Still wrong on the quantity — but it now disagrees with the first pass on the &lt;strong&gt;unit price&lt;/strong&gt;, and 486,25 ÷ 38,90 is exactly 12,5. Two of the three numbers are solid, so the third follows.&lt;/p&gt;

&lt;p&gt;That last step needs a guard, because &lt;code&gt;(12,5 × 38,90)&lt;/code&gt; and &lt;code&gt;(38,9 × 12,50)&lt;/code&gt; both equal 486,25 — the identity alone cannot tell you which column is which. The tiebreaker is that a &lt;em&gt;focused&lt;/em&gt; read does not suffer the alignment failure that a &lt;em&gt;table-wide&lt;/em&gt; read does, so I take the new unit price only when it disagrees with the first pass, and derive the quantity from it. If anything in that chain fails, the line stays flagged.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 1st choice: the re-read closes the arithmetic on its own.
&lt;/span&gt;&lt;span class="n"&gt;candidato&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalizar_item&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quantidade&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;relido&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quantidade&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)})&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;candidato&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alertas&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="c1"&gt;# 2nd choice: keep the re-read unit price, derive quantity from the total.
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;unitario_novo&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;unitario_novo&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;valor_unitario&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The derived number is not presented as if it had been read off the paper. It carries &lt;code&gt;↻ unitário relido (38,9); quantidade calculada: 486,25 ÷ 38,9&lt;/code&gt; through the terminal, the web page and the CSV, so you can see which numbers were transcribed and which were reconciled.&lt;/p&gt;

&lt;p&gt;One invoice costs ~1m40s. One flagged line costs ~45s more. Nothing is re-read that passed.&lt;/p&gt;

&lt;p&gt;This reframed the product for me. The promise is not &lt;em&gt;the model always reads correctly&lt;/em&gt;. It is &lt;strong&gt;what it gets wrong is caught, and then either repaired in the open or handed to you marked&lt;/strong&gt;. A tool that is right 95% of the time and silent about the other 5% is useless for money.&lt;/p&gt;
&lt;h3&gt;
  
  
  Vocabulary nobody else will maintain
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;FD&lt;/code&gt;, &lt;code&gt;PÇ&lt;/code&gt;, &lt;code&gt;SC&lt;/code&gt;, &lt;code&gt;C/ 12&lt;/code&gt;, &lt;code&gt;DZ&lt;/code&gt; are Brazilian wholesale distributor shorthand. No general-purpose vision API is going to carry a dictionary for it. Mine is 20 lines and I edit it when a customer's supplier invents a new abbreviation:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;UNIDADE_CANONICA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CX&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CX&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Plus a regex that pulls &lt;code&gt;12X1L&lt;/code&gt; out of a description and divides the case price by 12 litres.&lt;/p&gt;
&lt;h3&gt;
  
  
  Honest numbers
&lt;/h3&gt;

&lt;p&gt;On a 15 GB laptop with &lt;strong&gt;no GPU&lt;/strong&gt;, &lt;code&gt;gemma3:4b&lt;/code&gt; reads a one-page invoice in &lt;strong&gt;~1m40s&lt;/strong&gt;. That is slow for a chatbot and completely fine for a thing you run once per delivery while you put the boxes away.&lt;/p&gt;

&lt;p&gt;Accuracy, measured rather than asserted. Runs at &lt;code&gt;temperature: 0&lt;/code&gt; over the same photo produce &lt;strong&gt;byte-identical CSVs&lt;/strong&gt; (same md5), so the failures are reproducible rather than a lottery — which is the only reason the numbers below mean anything. Against the known ground truth of that invoice:&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;first pass only (&lt;code&gt;--sem-reparo&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;with the repair pass&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Items found&lt;/td&gt;
&lt;td&gt;10 / 10&lt;/td&gt;
&lt;td&gt;10 / 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supplier, document number, date&lt;/td&gt;
&lt;td&gt;correct&lt;/td&gt;
&lt;td&gt;correct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invoice total reconstructed&lt;/td&gt;
&lt;td&gt;R$ 2.329,80 — exact&lt;/td&gt;
&lt;td&gt;R$ 2.329,80 — exact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Numeric lines fully correct&lt;/td&gt;
&lt;td&gt;9 / 10&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10 / 10&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;QUEIJO MUSSARELA&lt;/code&gt; (&lt;code&gt;12,500 × 38,90&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;read as &lt;code&gt;2 × 12,5&lt;/code&gt; — flagged&lt;/td&gt;
&lt;td&gt;recovered, marked &lt;code&gt;↻&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Description typos&lt;/td&gt;
&lt;td&gt;1 — &lt;code&gt;MANTA&lt;/code&gt; read as &lt;code&gt;MENTA&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1 — unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time&lt;/td&gt;
&lt;td&gt;~1m45s&lt;/td&gt;
&lt;td&gt;~2m20s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The spelling error survives both passes, and that is correct behaviour rather than a gap I failed to close. It does not touch a number, so no arithmetic can see it — and it does not need to. A misspelt item name costs you a rename. A wrong unit cost gets baked into every burger you sell. The verification layer is aimed at money, not at tidiness.&lt;/p&gt;

&lt;p&gt;When the second pass cannot close the gap either, the line reaches the CSV with its &lt;strong&gt;quantity and cost columns empty&lt;/strong&gt; and the reason written into the description field:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;ITEM&lt;/span&gt; &lt;span class="k"&gt;ILEGIVEL&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt;&lt;span class="k"&gt;insumo&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt;&lt;span class="k"&gt;KG&lt;/span&gt;&lt;span class="err"&gt;;;;&lt;/span&gt;&lt;span class="k"&gt;CONFERIR&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;qtd&lt;/span&gt; &lt;span class="k"&gt;x&lt;/span&gt; &lt;span class="k"&gt;unit&lt;/span&gt;&lt;span class="err"&gt;á&lt;/span&gt;&lt;span class="k"&gt;rio&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;25.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;nota&lt;/span&gt; &lt;span class="k"&gt;diz&lt;/span&gt; &lt;span class="mf"&gt;486.25&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="err"&gt;...;;;;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The importer refuses that row, the human looks at that one line, and the rest go in untouched. Half an automated invoice beats a wrong one.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Open Innovation Matters Here
&lt;/h2&gt;

&lt;p&gt;Four reasons, in the order a restaurant owner would care.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The invoice is a tax document.&lt;/strong&gt; It carries the supplier's CNPJ, the negotiated price, and by implication the supplier's margin. "Where does this go?" is the first question I get, and "nowhere, it stays on this machine" is an answer that ends the conversation. With a hosted API, the honest answer is a paragraph about data processing agreements, and I lose the sale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-invoice cost is zero.&lt;/strong&gt; A small restaurant receives 15–40 invoices a month. On a metered API my cost scales with my customer's volume, which means either I price for the heaviest user or I watch margin erode on them. It also means the repair pass above would be a line item I have to ration — spending a second inference on one bad row is free here, and a decision I would second-guess on a meter. Running local, a busy month costs me nothing, so I can charge a flat monthly fee, which is the only pricing a restaurant owner will agree to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It works in the stockroom.&lt;/strong&gt; Receiving happens at the back door, with the phone on one bar of signal and both hands full.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The dictionary is mine.&lt;/strong&gt; &lt;code&gt;FD 6X2 ROLOS&lt;/code&gt; is not a problem a general model will ever prioritise. It is a 20-line dict I edit the same afternoon a customer complains.&lt;/p&gt;

&lt;p&gt;What I did not get for free: a 4B model on CPU is slower and shakier than a frontier API would be, and I paid for that with the verification layer. That trade was worth it — but it &lt;em&gt;was&lt;/em&gt; a trade, and the verification layer is most of the engineering.&lt;/p&gt;
&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Not shipped yet, and I am not going to pretend otherwise: &lt;strong&gt;it has not been in front of a real restaurant owner yet.&lt;/strong&gt; Every number in this post comes from synthetic invoices I generated and degraded on purpose, run on my own machine. The real test is a stack of crumpled paper from an actual delivery, and that is the next thing, not a finished thing.&lt;/p&gt;

&lt;p&gt;The repair pass also has an obvious next move I did not take: right now it re-reads the whole page image for one line, because I never know where on the paper that line is. Cropping to the row first would make it faster and probably more accurate, and it needs bounding boxes the current prompt does not ask for.&lt;/p&gt;

&lt;p&gt;After that: reading NF-e XML when the supplier sends one (exact beats inferred, always), and tracking the same item across months so the tool can say &lt;em&gt;this went up 9% since August&lt;/em&gt; — which is the question underneath the whole chore.&lt;/p&gt;
&lt;h2&gt;
  
  
  The session itself
&lt;/h2&gt;

&lt;p&gt;The challenge notes that embedding the agent session helps judges follow the process, so here is this one. It includes the stretch where my own test-image generator painted a solid grey wedge over the product names and I spent a run blaming the model for not reading data that was not there.&lt;/p&gt;


&lt;div class="ltag-agent-session"&gt;
  &lt;div class="agent-session-header"&gt;
    
    &lt;span class="agent-session-tool-icon-badge" title="Claude Code"&gt;
&lt;/span&gt;
    &lt;span class="agent-session-title"&gt;Notinha: reading supplier invoices with Gemma 3 locally, and repairing what it misreads&lt;/span&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-scroll"&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Veja o novo desafio do Hacktoberfest e conclua para mim.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Vou buscar os desafios ativos antes de assumir qualquer coisa sobre tema ou prazo.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-getchallenges"&gt;get_challenges&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;per_page=30&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;per_page=30&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Week 2 'Side Hustle' — 12 a 18/out. Construir algo com IA open-source/open-weight no núcleo que alguém pagaria para usar. Julgamento: qualidade do texto &amp;gt; tema &amp;gt; criatividade &amp;gt; execução técnica.&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Tema: Side Hustle. Prazo 18/out 23:59 PDT. Preciso de duas decisões suas: o que construir e até onde eu vou sozinho.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Conciliador de notas de fornecedor. Construa e deixe o post em rascunho.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Primeiro conferir o que existe nesta máquina — não adianta desenhar em cima de um modelo que não está aqui.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;which ollama &amp;amp;&amp;amp; ollama list &amp;amp;&amp;amp; nvidia-smi &amp;amp;&amp;amp; free -g&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ which ollama &amp;amp;&amp;amp; ollama list &amp;amp;&amp;amp; nvidia-smi &amp;amp;&amp;amp; free -g&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;ollama 0.32.5; gemma3:12b, gemma3:4b, deepseek-r1:7b; nvidia-smi: comando não encontrado; 15 GB de RAM&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Sem GPU e com 15 GB de RAM, gemma3:4b é a escolha — e vira argumento, não desculpa: roda no notebook que o dono do restaurante já tem.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Olhando o sistema de PDV que já existe aqui, achei o formato de importação de estoque. Isso muda o produto: a saída não é 'uma planilha', é o CSV que o sistema dele já engole.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;head -3 modelos-importacao/modelo-estoque.csv&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ head -3 modelos-importacao/modelo-estoque.csv&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Nome;Tipo;Unidade;Código de Barras;Código do Produto;Descrição;Estoque Inicial;Custo Unitário;Estoque Mínimo;Estoque Máximo;Fornecedor;Prazo de Entrega (dias);Foto&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Escrevendo o extrator. Decisão que vale comentar: todo campo numérico no JSON Schema é string. Nota brasileira escreve 1.234,56 — se eu deixar como number do JSON, o parser lê 1.234 em silêncio, sem exceção nenhuma. O modelo copia os glifos, meu código faz a conta.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;notinha/extract.py&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;notinha&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;com&lt;/span&gt; &lt;span class="n"&gt;valor_unitario&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;quantidade&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;valor_total&lt;/span&gt; &lt;span class="n"&gt;como&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;chamada&lt;/span&gt; &lt;span class="n"&gt;ao&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt; &lt;span class="n"&gt;do&lt;/span&gt; &lt;span class="n"&gt;Ollama&lt;/span&gt; &lt;span class="n"&gt;com&lt;/span&gt; &lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="n"&gt;temperature&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Agora a parte que é o produto de verdade: converter embalagem fechada para a unidade que a cozinha conta. CX 12X1L a R$ 89,90 tem que virar R$ 7,49 o litro.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;notinha/normalize.py&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;notinha&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;para_decimal&lt;/span&gt; &lt;span class="n"&gt;pt&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;BR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dicionário&lt;/span&gt; &lt;span class="n"&gt;de&lt;/span&gt; &lt;span class="n"&gt;siglas&lt;/span&gt; &lt;span class="n"&gt;de&lt;/span&gt; &lt;span class="nf"&gt;distribuidor &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CX&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;FD&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;PÇ&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;SC&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;DZ&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;regex&lt;/span&gt; &lt;span class="n"&gt;de&lt;/span&gt; &lt;span class="n"&gt;embalagem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;conferência&lt;/span&gt; &lt;span class="n"&gt;qtd&lt;/span&gt; &lt;span class="err"&gt;×&lt;/span&gt; &lt;span class="n"&gt;unitário&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Preciso de uma nota de teste sem expor cliente nenhum. Gerei uma sintética e a degradei de propósito: torta, com luz de lado e granulado. Nota limpa não testa nada.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;python3 samples/make_sample.py &amp;amp;&amp;amp; notinha samples/nota-fornecedor.jpg --json&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ python3 samples/make_sample.py &amp;amp;&amp;amp; notinha samples/nota-fornecedor.jpg --json&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;descrições truncadas: 'ANA UNIDADE', 'CAIXA 20KG', '5X2 ROLOS' repetido&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Esse resultado é culpa minha, não do modelo. Vou olhar a imagem antes de mexer no prompt.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Confirmado olhando o JPEG: a rotação cortou a margem esquerda e a 'sombra' que eu desenhei virou uma cunha cinza sólida por cima dos nomes dos produtos. O dado foi destruído, não degradado — o modelo não tinha o que ler.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;samples/make_sample.py&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;make_sample&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;moldura&lt;/span&gt; &lt;span class="n"&gt;antes&lt;/span&gt; &lt;span class="n"&gt;de&lt;/span&gt; &lt;span class="n"&gt;rotacionar&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;linear_gradient&lt;/span&gt; &lt;span class="n"&gt;com&lt;/span&gt; &lt;span class="nf"&gt;point&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt; &lt;span class="n"&gt;lugar&lt;/span&gt; &lt;span class="n"&gt;da&lt;/span&gt; &lt;span class="n"&gt;cunha&lt;/span&gt; &lt;span class="n"&gt;opaca&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Isso mesmo, o do dia 12.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Com a nota legível (e ainda fotografada), a leitura fecha.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;notinha samples/nota-fornecedor.jpg&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ notinha samples/nota-fornecedor.jpg&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;10 itens · total lido R$ 2329,8

REFRIGERANTE COLA CX 12X1L                4        89,9       7,492/L
QUEIJO MUSSARELA FATIADO                  2        12,5       12,5/KG
                                 ⚠ qtd x unitário = 25.00, nota diz 486.25
PAPEL TOALHA FD 6X2 ROLOS                 5        33,6        2,8/UN&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;10 de 10 itens, total exato. E a conferência aritmética pegou sozinha a única linha que o modelo errou: leu 12,500 × 38,90 como 2 × 12,5.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Três rodadas a temperature 0 produziram CSVs byte-idênticos. Isso importa: significa que o erro é reprodutível, não loteria — e é a única razão pela qual medir faz sentido.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;md5sum out/run1.csv out/run2.csv out/run3.csv&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ md5sum out/run1.csv out/run2.csv out/run3.csv&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;5f679c8d25df02cb646ed47b80b052f4  (os três)&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;A linha marcada ainda entrava no CSV com o valor errado. Isso é dinheiro: melhor o importador reclamar de campo vazio do que aceitar custo errado calado.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;notinha/export.py&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;notinha&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;export&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;linha&lt;/span&gt; &lt;span class="n"&gt;com&lt;/span&gt; &lt;span class="n"&gt;alerta&lt;/span&gt; &lt;span class="n"&gt;vai&lt;/span&gt; &lt;span class="n"&gt;sem&lt;/span&gt; &lt;span class="n"&gt;quantidade&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="n"&gt;sem&lt;/span&gt; &lt;span class="n"&gt;custo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;com&lt;/span&gt; &lt;span class="nc"&gt;CONFERIR &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;motivo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;na&lt;/span&gt; &lt;span class="n"&gt;descrição&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Melhore.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;O furo real é que o modelo erra a linha do queijo em toda rodada e eu só marco. Antes de escrever qualquer correção: por que ele erra &lt;em&gt;essa&lt;/em&gt; linha?&lt;/p&gt;

                &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;A hipótese é que não é leitura de dígito. Ele lê uma tabela de 10 linhas numa tacada só, e a coluna de descrição fica longe das numéricas — o que quebra é o alinhamento de linha.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Se a hipótese está certa, uma pergunta mais estreita não sofre o mesmo problema. Testando uma releitura de uma linha só, ancorada no total que já conferi.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;reler_linha(img, 'QUEIJO MUSSARELA FATIADO', '486,25')&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ reler_linha(img, 'QUEIJO MUSSARELA FATIADO', '486,25')&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;{'quantidade': '2', 'unidade': 'KG', 'valor_unitario': '38,90'}&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Hipótese confirmada em parte: a quantidade continua errada, mas o unitário agora vem certo — e 486,25 ÷ 38,90 dá exatamente 12,5. Dois dos três números fecham, o terceiro sai deles.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Esse último passo precisa de guarda, e quase me escapou: (12,5 × 38,90) e (38,9 × 12,50) dão o mesmo total. A identidade sozinha não diz qual coluna é qual.&lt;/p&gt;

                &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;O desempate é o próprio diagnóstico: leitura focada não sofre o desalinhamento que a leitura da tabela inteira sofre. Então só aceito o unitário novo quando ele discorda do antigo — que é exatamente onde o desalinhamento aconteceu. Se qualquer elo falha, a linha segue marcada.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;notinha/pipeline.py&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;notinha&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;relê&lt;/span&gt; &lt;span class="n"&gt;só&lt;/span&gt; &lt;span class="n"&gt;itens&lt;/span&gt; &lt;span class="n"&gt;com&lt;/span&gt; &lt;span class="n"&gt;alerta&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="n"&gt;com&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="n"&gt;legível&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;ª&lt;/span&gt; &lt;span class="n"&gt;escolha&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;releitura&lt;/span&gt; &lt;span class="n"&gt;inteira&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;ª&lt;/span&gt; &lt;span class="n"&gt;escolha&lt;/span&gt; &lt;span class="n"&gt;unitário&lt;/span&gt; &lt;span class="n"&gt;relido&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;quantidade&lt;/span&gt; &lt;span class="n"&gt;derivada&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;marcada&lt;/span&gt; &lt;span class="n"&gt;com&lt;/span&gt; &lt;span class="err"&gt;↻&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Resultado, três rodadas, CSVs byte-idênticos de novo.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;notinha samples/nota-fornecedor.jpg&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ notinha samples/nota-fornecedor.jpg&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;QUEIJO MUSSARELA FATIADO               12,5        38,9       38,9/KG
                                 ↻ unitário relido (38,9); quantidade calculada: 486,25 ÷ 38,9

(sem itens pedindo conferência)&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;10/10 linhas numéricas contra o gabarito (12,500 KG × 38,90 = 486,25). Sem reparo eram 9/10. Custo: 1m45 → 2m20, uma inferência extra só na linha ruim.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;O que o reparo não conserta, eu não finjo que conserta: 'MANTA' lido como 'MENTA' sobrevive aos dois passos. Não toca em número, então nenhuma aritmética enxerga — e não precisa. Nome errado custa um rename; custo unitário errado entra em todo hambúrguer que ele vende.&lt;/p&gt;

                &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;E o número derivado não se disfarça de lido: o ↻ carrega a procedência até o CSV.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Publique.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Publicado com a tag do desafio. O post declara o que não foi validado: nenhuma nota real de fornecedor ainda, só a sintética degradada de propósito.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-updatearticle"&gt;update_article&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;id=4850796, published=true, tags=[hf26challenge, ai, opensource, python]&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;id=4850796, published=true, tags=[hf26challenge, ai, opensource, python]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;https://dev.to/wellington_filipe_fccda4c/the-invoice-on-the-counter-a-local-first-cost-reader-for-small-restaurants-3bo&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-footer"&gt;
    &lt;span class="agent-session-meta"&gt;
        21 of 21 messages
    &lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;






&lt;p&gt;Built with Claude Code. Code is MIT.&lt;/p&gt;

</description>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>opensource</category>
      <category>python</category>
    </item>
    <item>
      <title>trilha: identifying birds by ear on the trail, with no signal</title>
      <dc:creator>Wellington Filipe</dc:creator>
      <pubDate>Tue, 06 Oct 2026 22:05:16 +0000</pubDate>
      <link>https://dev.to/wellington_filipe_fccda4c/trilha-identifying-birds-by-ear-on-the-trail-with-no-signal-30k7</link>
      <guid>https://dev.to/wellington_filipe_fccda4c/trilha-identifying-birds-by-ear-on-the-trail-with-no-signal-30k7</guid>
      <description>&lt;p&gt;&lt;em&gt;This is my submission for the &lt;a href="https://dev.to/challenges/hf26"&gt;Hacktoberfest Open-Source AI Challenge: Week 1&lt;/a&gt; — theme: Touch Grass.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;You're on a trail. Something calls from the brush, twice, and then stops. You don't know what it was.&lt;/p&gt;

&lt;p&gt;The normal move is to hold your phone at the canopy and wait for a bar of signal. The places worth birding are exactly the places that don't have one. So you get home six hours later with a recording you can no longer place, and it sits in your phone forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;trilha&lt;/strong&gt; is a command-line tool that does the whole thing on your laptop, offline. It listens to a recording, names what it heard, and writes the outing into a field journal in prose.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ trilha listen manha.wav --lat -23.55 --lon -46.63 --place "Parque do Carmo"

  manha.wav
  596 species in range · 5.2s on CPU · week 40

   0.97 ###################  Rufous-bellied Thrush
        Turdus rufiventris · first at 30s · 22 window(s)
   0.94 ###################  Rufous-collared Sparrow
        Zonotrichia capensis · first at 42s · 26 window(s)
   0.92 ##################   Southern Lapwing
        Vanellus chilensis · first at 63s · 4 window(s)
   0.59 ############         Southern Yellowthroat
        Geothlypis velata · first at 78s · 2 window(s)

  Field note
  The presence of the Rufous-bellied Thrush, Rufous-collared Sparrow, and
  Southern Lapwing strongly suggests a mixed habitat of open woodlands and
  grasslands within Parque do Carmo. Next, while on this trail, pay particular
  attention to the calls of the Southern Yellowthroat — they often favour dense
  undergrowth and shrubbery, and their distinctive song is relatively
  high-pitched.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;No account, no API key, no upload. 5.2 seconds on CPU.&lt;/p&gt;
&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;Three open pieces, each doing the one thing it's good at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ear — BirdNET 3.0, through ONNX on CPU.&lt;/strong&gt; It knows 11,560 species. It slices the audio into 3-second windows and scores each window independently, which is why the output can tell you a bird was &lt;em&gt;first heard at 78s across 2 windows&lt;/em&gt; rather than just handing you a label.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The filter — BirdNET's range model, 14,082 species.&lt;/strong&gt; This is the part I find most interesting and it's the part nobody demos. Give it latitude, longitude and the week of the year, and it narrows the candidates to what could plausibly be there right now. At &lt;code&gt;-23.55, -46.63&lt;/code&gt; in week 40 that's &lt;strong&gt;596 species instead of 11,560&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That reduction is the difference between a useful answer and a list of everything that has ever had feathers. A classifier asked to choose from 11,560 classes will cheerfully hand you a Himalayan bird in São Paulo. Asked to choose from the 596 that are actually in range in October, it won't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The voice — Gemma 3, through Ollama, on my machine.&lt;/strong&gt; It turns the detection list into a few sentences for the journal. It never touches the identification.&lt;/p&gt;

&lt;p&gt;That last point is a hard architectural boundary, not a disclaimer: if Ollama isn't running, the note is skipped and the detections still print. The language model is the part with taste in it, so it's the part that's allowed to be absent.&lt;/p&gt;
&lt;h2&gt;
  
  
  The two bugs that mattered
&lt;/h2&gt;

&lt;p&gt;I had the thing working and almost wrote it up at that point. Then I ran it once more, properly, and read the output instead of just checking that it ran.&lt;/p&gt;

&lt;p&gt;The field note came back like this:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Field note
  Aqui está a nota de campo em português:

  **Nota de Campo – Reserva do Pampa**

  A combinação de corruíras, sabiás-laranjeira e sabiá-poca sugere um habitat
  de matagal aberto...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Two separate problems in one paragraph.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The small one:&lt;/strong&gt; the model announced what it was about to do, then added a Markdown heading. In a terminal pretending to be a paper journal, &lt;code&gt;**Nota de Campo –**&lt;/code&gt; is noise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The one that actually mattered:&lt;/strong&gt; &lt;em&gt;Reserva do Pampa&lt;/em&gt;. There is no such place in this story. I passed no &lt;code&gt;--place&lt;/code&gt;, so the prompt sent bare coordinates — &lt;code&gt;-23.55, -46.63&lt;/code&gt;, which is São Paulo. The model read the numbers, decided it knew where that was, invented a reserve name, and put the wrong biome on it. The Pampa is 1,000 km south.&lt;/p&gt;

&lt;p&gt;This is the failure mode that would have killed the project. The whole premise of a field journal is that it's a record of where you actually were. One invented place name and the entire artifact becomes untrustworthy — not just that line, all of it, retroactively. A tool that is right about the birds and wrong about the ground is worse than no tool, because you'd believe it.&lt;/p&gt;

&lt;p&gt;The root cause was in the prompt contract, not the code path. My original &lt;code&gt;SYSTEM&lt;/code&gt; already said &lt;em&gt;"never invent a species that is not listed, and never invent a count"&lt;/em&gt; — I had thought about hallucination, but only about the taxonomy. I never constrained the geography. So I added the rule I'd been missing:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;Only&lt;/span&gt; &lt;span class="n"&gt;ever&lt;/span&gt; &lt;span class="n"&gt;refer&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;location&lt;/span&gt; &lt;span class="n"&gt;exactly&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;given&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;you&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="n"&gt;When&lt;/span&gt; &lt;span class="n"&gt;you&lt;/span&gt; &lt;span class="n"&gt;are&lt;/span&gt; &lt;span class="n"&gt;given&lt;/span&gt;
&lt;span class="n"&gt;coordinates&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt; &lt;span class="n"&gt;place&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;leave&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;place&lt;/span&gt; &lt;span class="n"&gt;unnamed&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="n"&gt;never&lt;/span&gt; &lt;span class="n"&gt;guess&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;park&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="n"&gt;reserve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;biome&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;city&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;them&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;

&lt;span class="n"&gt;Return&lt;/span&gt; &lt;span class="n"&gt;only&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="n"&gt;itself&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt; &lt;span class="n"&gt;preamble&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt; &lt;span class="n"&gt;sign&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;off&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt; &lt;span class="n"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt;
&lt;span class="n"&gt;Markdown&lt;/span&gt; &lt;span class="n"&gt;formatting&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt; &lt;span class="n"&gt;restating&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="n"&gt;this&lt;/span&gt; &lt;span class="n"&gt;instruction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="n"&gt;Start&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;
&lt;span class="n"&gt;sentence&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Same recording, after:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A forte presença de corruíras e sabiás indica um habitat de matagal aberto, possivelmente com árvores frutíferas na região. A identificação do sabiá-poca com menor confiança sugere a possível existência de áreas mais sombreadas ou de vegetação densa próximas. Próximo, procure por cantos altos nas árvores, pois o sabiá-laranjeira tende a se exibir em posições elevadas para anunciar sua presença.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No preamble, no invented place, and it still flags the low-confidence detection as low-confidence.&lt;/p&gt;

&lt;p&gt;Then I checked the inverse, because a fix that only works in one direction isn't a fix: with &lt;code&gt;--place "Parque do Carmo"&lt;/code&gt; it uses the real name. And one more paranoid check — the English note told me to listen for Southern Yellowthroat, a species I hadn't noticed in the list. Was that a third hallucination? No: it's there at 0.59 confidence, 2 windows. The species rule had been holding the whole time. Only the location was unguarded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson I'm taking:&lt;/strong&gt; when you write an anti-hallucination rule, you're enumerating the categories of thing the model must not invent. I enumerated species and counts and felt covered. Place was a category I hadn't thought of, and it was sitting right there in the prompt as a bare pair of floats. Worth asking, of any prompt: &lt;em&gt;what else is in this context window that the model could decide it recognizes?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That session, if you want to watch the diagnosis happen:&lt;/p&gt;


&lt;div class="ltag-agent-session"&gt;
  &lt;div class="agent-session-header"&gt;
    
    &lt;span class="agent-session-tool-icon-badge" title="Claude Code"&gt;
&lt;/span&gt;
    &lt;span class="agent-session-title"&gt;Catching two prompt bugs in an offline bird identifier&lt;/span&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-scroll"&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Check how we're doing for Hacktoberfest — we were already building a new project.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The Week 1 theme is "Touch Grass", and the first example in the prompt is literally "a bird call identifier that works on the trail with no signal". That is exactly what trilha-offline is. Deadline Oct 11, 11:59 PM PDT.&lt;/p&gt;

&lt;p&gt;The code is 677 lines across 6 modules and the CLI runs, but there is no README (and pyproject points at one, so the build breaks), no git repo, and no DEV post.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-getchallenges"&gt;get_challenges&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;DEV challenges, active&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;DEV challenges, active&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Hacktoberfest Open-Source AI Challenge: Week 1 — theme "Touch Grass" — ends 2026-10-12T06:59:00Z. Featured categories include Best Use of Gemma.&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;trilha --version &amp;amp;&amp;amp; ollama list&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ trilha --version &amp;amp;&amp;amp; ollama list&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;trilha 0.1.0
gemma3:12b  8.1 GB
gemma3:4b   3.3 GB
deepseek-r1:7b  4.7 GB&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;What's left to finish?&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Running it end to end first, because a checklist written without running the thing is a guess.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;trilha listen samples/corruira-XC436932.mp3 --lat -23.55 --lon -46.63 --lang ...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ trilha listen samples/corruira-XC436932.mp3 --lat -23.55 --lon -46.63 --lang pt --no-journal&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;  596 species in range · 5.2s on CPU · week 40

   0.91 corruira (Troglodytes musculus) · first at 21s · 9 window(s)
   0.90 sabia-laranjeira (Turdus rufiventris) · first at 45s · 13 window(s)
   0.47 sabia-poca (Turdus amaurochalinus) · first at 3s · 1 window(s)

  Field note
  Aqui esta a nota de campo em portugues:

  **Nota de Campo - Reserva do Pampa**

  A combinacao de corruiras, sabias-laranjeira e sabia-poca sugere um habitat de matagal aberto...&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;It works — the identification is solid. But the field note has two defects.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Preamble leak: the note opens with "Aqui esta a nota de campo em portugues:" and a Markdown heading, instead of starting with the note.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Hallucinated location — the worse of the two. The note names "Reserva do Pampa". I passed no --place, so the prompt sent bare coordinates (-23.55, -46.63, which is Sao Paulo). The model invented a place name and got the biome wrong too. A field journal that invents where you were destroys its own credibility.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-grep"&gt;grep&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;place|prompt|system in trilha/narrate.py&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;place|prompt|system in trilha/narrate.py&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;29: def _prompt(listening, place)
31:   f"Location: {place or f'{lat:.4f}, {lon:.4f}'}"
55:   "system": f"{SYSTEM}\n\nWrite the note in {language}."&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Fix it!&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;p&gt;Both defects are in the prompt contract, not the code path — SYSTEM never forbade a preamble, and never constrained how the location may be referred to. Two rules added to SYSTEM in trilha/narrate.py:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Only ever refer to the location exactly as it is given to you. When you are given coordinates and no place name, leave the place unnamed — never guess a park, reserve, region, biome or city from them."&lt;/li&gt;
&lt;li&gt;"Return only the note itself: no preamble, no sign-off, no heading, no title, no Markdown formatting."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus a reinforcement at the end of the user prompt: "Output the note text and nothing else."&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;trilha listen samples/corruira-XC436932.mp3 --lat -23.55 --lon -46.63 --lang ...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ trilha listen samples/corruira-XC436932.mp3 --lat -23.55 --lon -46.63 --lang pt --no-journal&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;  Field note
  A forte presenca de corruiras e sabias indica um habitat de matagal aberto, possivelmente com arvores frutiferas na regiao. A identificacao do sabia-poca com menor confianca sugere a possivel existencia de areas mais sombreadas ou de vegetacao densa proximas. Proximo, procure por cantos altos nas arvores, pois o sabia-laranjeira tende a se exibir em posicoes elevadas para anunciar sua presenca.&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Preamble gone, no invented place. Now the inverse case — does it still honour a place when one is given?&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;trilha listen samples/sabia-laranjeira-XC112745.mp3 --lat -23.55 --lon -46.63...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ trilha listen samples/sabia-laranjeira-XC112745.mp3 --lat -23.55 --lon -46.63 --place "Parque do Carmo" --no-journal&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;  The presence of the Rufous-bellied Thrush, Rufous-collared Sparrow, and Southern Lapwing strongly suggests a mixed habitat of open woodlands and grasslands within Parque do Carmo. Next, pay particular attention to the calls of the Southern Yellowthroat - they often favour dense undergrowth and shrubbery.&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Uses the real place. One more check: the note tells the reader to listen for Southern Yellowthroat — is that species actually in the detections, or is it a third hallucination?&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;trilha listen samples/sabia-laranjeira-XC112745.mp3 --lat -23.55 --lon -46.63...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ trilha listen samples/sabia-laranjeira-XC112745.mp3 --lat -23.55 --lon -46.63 --no-note&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;   0.97 Rufous-bellied Thrush
   0.94 Rufous-collared Sparrow
   0.92 Southern Lapwing
   0.59 Southern Yellowthroat (Geothlypis velata) · 2 window(s)
   0.57 Red Junglefowl
   0.48 Southern House Wren
   0.43 Rufous-browed Peppershrike&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;It is there at 0.59. No species hallucination — the "never invent a species that is not listed" rule in the original SYSTEM was already holding. Only the location was unguarded.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Yes, let's finish this challenge.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Wrote the README around what the tool actually prints (captured from real runs, not invented), added the MIT LICENSE file that pyproject declared but did not ship, and made the first commit.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;trilha doctor&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ trilha doctor&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;  trilha 0.1.0
  state      ~/.local/share/trilha
  journal    ~/.local/share/trilha/journal.jsonl (0 outing(s))
  ear model  BirdNET 3.0 (11560 species)
  range      BirdNET 3.0 (14082 species)
  field note ready - gemma3:4b ready

  Models are cached on disk. After this check you can go offline.&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;git init -b main &amp;amp;&amp;amp; git add -A &amp;amp;&amp;amp; git commit&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ git init -b main &amp;amp;&amp;amp; git add -A &amp;amp;&amp;amp; git commit&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;6478e82 trilha 0.1.0 - identify birds by ear, offline
 13 files changed, 903 insertions(+)&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;gh repo create trilha-offline --public --source=. --push&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ gh repo create trilha-offline --public --source=. --push&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;https://github.com/Tinhomagri/trilha-offline
 * [new branch]      HEAD -&amp;gt; main&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-footer"&gt;
    &lt;span class="agent-session-meta"&gt;
        8 of 8 messages
    &lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;



&lt;h2&gt;
  
  
  A blind test across six countries
&lt;/h2&gt;

&lt;p&gt;I want to be straight about something: I have not yet taken this out on a real trail. The challenge offers bonus points for doing that and I'd rather tell you what I actually did than dress up a demo as a morning in the woods.&lt;/p&gt;

&lt;p&gt;What I did instead is arguably harder on the tool. &lt;a href="https://www.inaturalist.org" rel="noopener noreferrer"&gt;iNaturalist&lt;/a&gt; has an open API full of bird observations that carry an audio recording, the exact coordinates, the exact date, and a species identification verified by the community (&lt;em&gt;research grade&lt;/em&gt;). That is a labelled test set with real geography attached — and nothing about it was chosen by me to flatter the tool. I pulled eight at random across six countries and ran each one with its own true coordinates and date.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;Truth&lt;/th&gt;
&lt;th&gt;trilha's top guess&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🇧🇷 Parque Nacional das Emas&lt;/td&gt;
&lt;td&gt;&lt;em&gt;Melanopareia torquata&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Collared Crescentchest&lt;/strong&gt; &lt;code&gt;0.98&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🇺🇦 Lviv Oblast&lt;/td&gt;
&lt;td&gt;&lt;em&gt;Corvus corax&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Northern Raven&lt;/strong&gt; &lt;code&gt;0.95&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🇮🇹 Reggio Calabria&lt;/td&gt;
&lt;td&gt;&lt;em&gt;Certhia brachydactyla&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Short-toed Treecreeper&lt;/strong&gt; &lt;code&gt;0.94&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🇺🇸 Bannock County, Idaho&lt;/td&gt;
&lt;td&gt;&lt;em&gt;Corvus brachyrhynchos&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;American Crow&lt;/strong&gt; &lt;code&gt;0.93&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🇩🇪 Bad Wurzach&lt;/td&gt;
&lt;td&gt;&lt;em&gt;Rallus aquaticus&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Water Rail&lt;/strong&gt; &lt;code&gt;0.92&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🇩🇪 Berg im Gau&lt;/td&gt;
&lt;td&gt;&lt;em&gt;Tadorna ferruginea&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Ruddy Shelduck&lt;/strong&gt; &lt;code&gt;0.91&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🇩🇪 Duisburg&lt;/td&gt;
&lt;td&gt;&lt;em&gt;Larus canus&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;Common Whitethroat &lt;code&gt;0.52&lt;/code&gt; → &lt;strong&gt;Common Gull&lt;/strong&gt; &lt;code&gt;0.32&lt;/code&gt; at #2&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🇹🇭 Chanthaburi&lt;/td&gt;
&lt;td&gt;&lt;em&gt;Arborophila cambodiana&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;nothing above &lt;code&gt;0.10&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Six of eight correct on the first guess, seven of eight in the top three, 3.2–4.9 seconds each on CPU.&lt;/strong&gt; The range filter shrank the candidate pool differently at every site — 592 species in the Brazilian cerrado in January, 232 in western Ukraine in late November — which is the whole mechanism working as intended.&lt;/p&gt;

&lt;p&gt;The two that didn't land are the interesting ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Duisburg&lt;/strong&gt; is a city gull recording, and the tool put a warbler on top of the real answer. The gull is right there at #2. This is the honest shape of the thing: on a messy urban recording with several birds and traffic, you get a ranked list, not an oracle. The confidence bars exist so you can see when it isn't sure, and &lt;code&gt;0.52&lt;/code&gt; versus &lt;code&gt;0.32&lt;/code&gt; is visibly not sure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chanthaburi is the one that taught me something.&lt;/strong&gt; Complete miss. So I asked the obvious question — does the model even know this bird? I grepped the acoustic model's label files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; arborophila ~/.local/share/birdnet/.../labels/no.txt
Arborophila cambodiana_khmerhøne
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;It knows it. All 11,560 labels include the Chestnut-headed Partridge. Then I checked what the range filter allowed at that coordinate:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;trilha here &lt;span class="nt"&gt;--lat&lt;/span&gt; 12.9257 &lt;span class="nt"&gt;--lon&lt;/span&gt; 102.1796 &lt;span class="nt"&gt;--date&lt;/span&gt; 2024-03-16 &lt;span class="nt"&gt;--limit&lt;/span&gt; 0 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; partridge
  Green-legged Partridge  &lt;span class="o"&gt;(&lt;/span&gt;Tropicoperdix chloropus&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;429 species in range, and &lt;em&gt;Arborophila cambodiana&lt;/em&gt; is not one of them. The ear could have identified this bird. &lt;strong&gt;The filter — the component I'd just finished praising as the thing that makes the tool trustworthy — had made the correct answer unreachable.&lt;/strong&gt; It's a Cardamom Mountains endemic with a tight range, exactly the kind of bird a range map is least confident about, and exactly the kind of bird someone pointing a recorder at a Thai forest most wants named.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug the error message confessed
&lt;/h2&gt;

&lt;p&gt;While tracing that, I found this in &lt;code&gt;detect.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The range model expects no species at this coordinate. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Check --lat/--lon, or pass --no-range to listen worldwide.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;There was no &lt;code&gt;--no-range&lt;/code&gt; flag. I had written the error message for a feature I never built, and nothing caught it because that code path is hard to reach — you need a coordinate where the range model expects nothing at all.&lt;/p&gt;

&lt;p&gt;The Thai partridge turned it from a cosmetic lie into a missing feature with a concrete use case, so I built it. Same recording, filter off:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;trilha listen th-partridge.wav &lt;span class="nt"&gt;--lat&lt;/span&gt; 12.9257 &lt;span class="nt"&gt;--lon&lt;/span&gt; 102.1796 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--date&lt;/span&gt; 2024-03-16 &lt;span class="nt"&gt;--no-range&lt;/span&gt; &lt;span class="nt"&gt;--min-conf&lt;/span&gt; 0.1

  11560 species worldwide · 3.6s on CPU · week 11

   0.35 &lt;span class="c"&gt;#######              Chestnut-headed Partridge&lt;/span&gt;
   0.34 &lt;span class="c"&gt;#######              Taiwan Barbet&lt;/span&gt;
   0.21 &lt;span class="c"&gt;####                 Taiwan Scimitar Babbler&lt;/span&gt;
   0.17 &lt;span class="c"&gt;###                  Malayan Partridge&lt;/span&gt;
   0.10 &lt;span class="c"&gt;##                   Blue-throated Barbet&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Correct answer, first place. And look at what came with it: Taiwan Barbet at &lt;code&gt;0.34&lt;/code&gt;, a hair behind, from 4,000 km away. Taiwan Scimitar Babbler at &lt;code&gt;0.21&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is the trade-off made legible in one screen. With the filter you get &lt;code&gt;0.98&lt;/code&gt; on a Brazilian crescentchest and a wall in front of a Cambodian partridge. Without it you can reach the partridge, but Taiwan is suddenly a live hypothesis in Thailand. Neither mode is correct in general, so the tool now does what it should have done from the start: default to the filter, and give you a documented way out when you have reason to think it's the thing standing in your way.&lt;/p&gt;

&lt;p&gt;The blind test and the Chanthaburi diagnosis, end to end:&lt;/p&gt;


&lt;div class="ltag-agent-session"&gt;
  &lt;div class="agent-session-header"&gt;
    
    &lt;span class="agent-session-tool-icon-badge" title="Claude Code"&gt;
&lt;/span&gt;
    &lt;span class="agent-session-title"&gt;A blind test across six countries, and the range filter that hid the answer&lt;/span&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-scroll"&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Get it from the internet!&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;p&gt;I'll do it, but not as a "Taking it outside" section — writing a field report from a downloaded recording would be claiming, publicly and in your name, something that did not happen. That is literally the bug we just fixed in the code.&lt;/p&gt;

&lt;p&gt;The honest version is stronger. iNaturalist's open API has bird observations carrying audio, exact coordinates, exact date, and a community-verified species ID (research grade). That is a labelled test set with real geography attached. Run trilha with each recording's true coordinates and date and check whether it gets the known species. A real blind test, not a demo.&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;curl 'https://xeno-canto.org/api/2/recordings?query=cnt:brazil'&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ curl 'https://xeno-canto.org/api/2/recordings?query=cnt:brazil'&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre 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;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"server_error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Xeno-canto API v2 is no longer available. Visit https://xeno-canto.org/explore/api for API v3 documentation."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-webfetch"&gt;web_fetch&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;https://xeno-canto.org/explore/api — does v3 need a key?&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;https://xeno-canto.org/explore/api — does v3 need a key?&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Access denied error page (BotStopper protection). No API documentation retrievable.&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;xeno-canto v3 needs an API key and the site is behind anti-bot protection. Two closed doors — switching to iNaturalist rather than retrying.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;curl 'https://api.inaturalist.org/v1/observations?taxon_id=3&amp;amp;sounds=true&amp;amp;qual...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ curl 'https://api.inaturalist.org/v1/observations?taxon_id=3&amp;amp;sounds=true&amp;amp;quality_grade=research&amp;amp;geo=true&amp;amp;per_page=12&amp;amp;order_by=random&amp;amp;captive=false'&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;total 1062890
Melanopareia torquata | 2026-01-08 | -18.1224,-52.9085 | Parque Nacional das Emas, Brasil
Certhia brachydactyla | 2023-09-19 | 38.3150,16.0922 | Reggio Calabria, Italy
Rallus aquaticus | 2025-04-16 | 47.9144,9.8967 | Bad Wurzach, Germany
Larus canus | 2022-05-01 | 51.3441,6.6847 | Duisburg, Germany
Tadorna ferruginea | 2022-05-23 | 48.6257,11.2156 | Berg im Gau, Germany
Corvus brachyrhynchos | 2025-04-13 | 42.7115,-112.4220 | Bannock County, Idaho
Arborophila cambodiana diversa | 2024-03-16 | 12.9257,102.1796 | Chanthaburi, Thailand
Corvus corax | 2023-11-24 | 49.9325,23.7292 | Lviv Oblast, Ukraine&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Eight research-grade observations across six countries. Downloading each and running trilha with its own real coordinates and date.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;blindtest.sh — trilha listen &amp;lt;each&amp;gt; --lat &amp;lt;true&amp;gt; --lon &amp;lt;true&amp;gt; --date &amp;lt;true&amp;gt; -...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ blindtest.sh — trilha listen &amp;lt;each&amp;gt; --lat &amp;lt;true&amp;gt; --lon &amp;lt;true&amp;gt; --date &amp;lt;true&amp;gt; --min-conf 0.1&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;BR Melanopareia torquata  -&amp;gt; Collared Crescentchest 0.98   (592 in range, week 2)   OK
UA Corvus corax           -&amp;gt; Northern Raven 0.95           (232 in range, week 47)  OK
IT Certhia brachydactyla  -&amp;gt; Short-toed Treecreeper 0.94    (353 in range, week 38)  OK
US Corvus brachyrhynchos  -&amp;gt; American Crow 0.93             (258 in range, week 15)  OK
DE Rallus aquaticus       -&amp;gt; Water Rail 0.92                (331 in range, week 16)  OK
DE Tadorna ferruginea     -&amp;gt; Ruddy Shelduck 0.91            (322 in range, week 21)  OK
DE Larus canus            -&amp;gt; Common Whitethroat 0.52, Common Gull 0.32 at #2        TOP-3
TH Arborophila cambodiana -&amp;gt; nothing above 0.10             (428 in range, week 11)  MISS&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;6/8 on the first guess, 7/8 in the top three, 3.2-4.9s each on CPU. The candidate pool varied by site exactly as designed: 592 species in the Brazilian cerrado in January, 232 in western Ukraine in late November.&lt;/p&gt;

&lt;p&gt;The Chanthaburi miss is worth diagnosing rather than excusing. Two hypotheses: either the acoustic model does not know this species, or the range filter excluded it. Those are completely different bugs.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;trilha here --lat 12.9257 --lon 102.1796 --date 2024-03-16 --limit 0 | grep -...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ trilha here --lat 12.9257 --lon 102.1796 --date 2024-03-16 --limit 0 | grep -i partridge&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;  Green-legged Partridge  (Tropicoperdix chloropus)
--- 429 species in range ---&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;grep -i arborophila ~/.local/share/birdnet/acoustic-models/v3.0/onnx/labels/n...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ grep -i arborophila ~/.local/share/birdnet/acoustic-models/v3.0/onnx/labels/no.txt&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Arborophila ardens_hainanhøne
Arborophila atrogularis_hvitskjegghone
Arborophila cambodiana_khmerhøne
Arborophila campbelli_malayhøne&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Diagnosed. Arborophila cambodiana IS in the acoustic model's 11,560 labels — the ear knows the bird. It is not among the 429 species the range filter allowed at that coordinate. The filter, the component that makes the tool trustworthy, had made the correct answer unreachable. A Cardamom Mountains endemic is exactly the kind of bird a range map is least sure about.&lt;/p&gt;

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

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Found a third bug while tracing it, and this one is in code, not in a prompt.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-read"&gt;read&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;trilha/detect.py:152-157&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;trilha/detect.py:152-157&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;allowed = self.plausible_species(latitude, longitude, week)
if not allowed:
    raise RuntimeError(
        "The range model expects no species at this coordinate. "
        "Check --lat/--lon, or pass --no-range to listen worldwide."
    )&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The error message tells the user to pass --no-range. There is no --no-range flag in the CLI. The message was written for a feature never built, and nothing caught it because the code path is hard to reach — you need a coordinate where the range model expects nothing at all.&lt;/p&gt;

&lt;p&gt;The Thai partridge turns it from a cosmetic lie into a missing feature with a concrete use case, so: implement it. use_range threaded through Listener.listen, custom_species_list set to None when off, and a &lt;code&gt;ranged&lt;/code&gt; field on Listening so the output says "worldwide" instead of "in range" when the filter is off.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;trilha listen th-arborophila-cambodiana.wav --lat 12.9257 --lon 102.1796 --da...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ trilha listen th-arborophila-cambodiana.wav --lat 12.9257 --lon 102.1796 --date 2024-03-16 --no-range --min-conf 0.1&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;  11560 species worldwide · 3.6s on CPU · week 11
   0.35 Chestnut-headed Partridge
   0.34 Taiwan Barbet
   0.21 Taiwan Scimitar Babbler
   0.17 Malayan Partridge
   0.10 Blue-throated Barbet&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Correct answer in first place at 0.35 — and the trade-off visible in the same screen: Taiwan Barbet a hair behind at 0.34, from 4,000 km away. Neither mode is right in general. Default stays filtered; --no-range is the documented way out.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;trilha listen samples/corruira-XC436932.mp3 --lat -23.55 --lon -46.63  # regr...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ trilha listen samples/corruira-XC436932.mp3 --lat -23.55 --lon -46.63  # regression&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;  596 species in range · 4.5s on CPU · week 40
   0.91 Southern House Wren
   0.90 Rufous-bellied Thrush&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;No regression on the ranged path. Committed and pushed.&lt;/p&gt;

                &lt;/div&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;git commit &amp;amp;&amp;amp; git push&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ git commit &amp;amp;&amp;amp; git push&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;0d31246 listen: add the --no-range flag the error message already promised&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-footer"&gt;
    &lt;span class="agent-session-meta"&gt;
        3 of 3 messages
    &lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;



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

&lt;p&gt;Not as a philosophical preference. This tool does not function without it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No signal is the normal case, not the edge case.&lt;/strong&gt; Every model here is a file on disk. You run &lt;code&gt;trilha doctor&lt;/code&gt; once at home on wifi, it confirms the weights are cached, and then it tells you: &lt;em&gt;"Models are cached on disk. After this check you can go offline."&lt;/em&gt; A hosted API is a hard dependency on a network that, by the nature of the activity, isn't there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coordinates are a movement profile.&lt;/strong&gt; Where you go birding, at what hour, how often, how it changes over a year — that's not a bird fact, it's a map of your habits and your free time. Sending that to someone else's server to get a bird's name back is a bad trade, and it's a trade you'd be making weekly for years. Here it never leaves the machine. There is no server to leak it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I could read the filter's mind.&lt;/strong&gt; This is the one I didn't expect to matter and it turned out to be the whole story of the Thai recording. I could grep the label files to confirm the species existed, query the range model directly with &lt;code&gt;trilha here&lt;/code&gt; to see what it allowed, find the discrepancy, and ship a flag to work around it — in an afternoon, without filing a ticket with anyone. Against a closed endpoint the entire investigation would have been &lt;em&gt;"it returned nothing, I guess the bird isn't supported."&lt;/em&gt; Open weights let you distinguish &lt;em&gt;the model doesn't know this bird&lt;/em&gt; from &lt;em&gt;my pipeline threw the answer away.&lt;/em&gt; Those are completely different bugs with completely different fixes, and from the outside they look identical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Swappable voice.&lt;/strong&gt; &lt;code&gt;--model&lt;/code&gt; takes anything Ollama runs. The note is the subjective part, so it should be the replaceable part — &lt;code&gt;gemma3:4b&lt;/code&gt; on a laptop in the field, &lt;code&gt;gemma3:12b&lt;/code&gt; at a desk with RAM to spare. Same tool, no code change, no vendor to ask permission from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It costs nothing per use.&lt;/strong&gt; That blind test was 9 inference runs over 8 recordings, plus every re-run while I was chasing the partridge. On per-call pricing I would have tested three and called it good — and the Chanthaburi miss, the only genuinely instructive result of the day, was the eighth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/Tinhomagri/trilha-offline
&lt;span class="nb"&gt;cd &lt;/span&gt;trilha-offline
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
.venv/bin/pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
ollama pull gemma3:4b
trilha doctor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two xeno-canto recordings are included so you can try it before going outside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;trilha listen samples/corruira-XC436932.mp3 &lt;span class="nt"&gt;--lat&lt;/span&gt; &lt;span class="nt"&gt;-23&lt;/span&gt;.55 &lt;span class="nt"&gt;--lon&lt;/span&gt; &lt;span class="nt"&gt;-46&lt;/span&gt;.63 &lt;span class="nt"&gt;--lang&lt;/span&gt; pt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's also &lt;code&gt;trilha here --lat --lon&lt;/code&gt;, which lists what's in range at a coordinate with no audio at all — useful the night before, to know what you might be about to hear. And &lt;code&gt;trilha journal&lt;/code&gt;, which prints every outing you've recorded, or exports it to Markdown.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/Tinhomagri/trilha-offline" rel="noopener noreferrer"&gt;https://github.com/Tinhomagri/trilha-offline&lt;/a&gt; — MIT.&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 3 through Ollama writes every field note, locally, and it's the component that makes the journal readable rather than a CSV of confidence scores. It's also deliberately scoped: constrained by prompt to the detections it was handed, forbidden from naming the ground, and fully optional at runtime. The identification never depends on it.&lt;/p&gt;




&lt;p&gt;Built with BirdNET (&lt;a href="https://github.com/birdnet-team/birdnet" rel="noopener noreferrer"&gt;birdnet-team/birdnet&lt;/a&gt;), &lt;a href="https://ai.google.dev/gemma" rel="noopener noreferrer"&gt;Gemma 3&lt;/a&gt; and &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt;. Test recordings from &lt;a href="https://www.inaturalist.org" rel="noopener noreferrer"&gt;iNaturalist&lt;/a&gt; observers under their respective licenses; samples from &lt;a href="https://xeno-canto.org" rel="noopener noreferrer"&gt;xeno-canto&lt;/a&gt;.&lt;/p&gt;

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