<?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: MinchinWeb</title>
    <description>The latest articles on DEV Community by MinchinWeb (@minchinweb).</description>
    <link>https://dev.to/minchinweb</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%2F495959%2F8adc0074-3bfa-4aaa-a84b-0ec57e08e308.jpeg</url>
      <title>DEV Community: MinchinWeb</title>
      <link>https://dev.to/minchinweb</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/minchinweb"/>
    <language>en</language>
    <item>
      <title>Only two lines on an Alberta power bill matter. A local model finds them.</title>
      <dc:creator>MinchinWeb</dc:creator>
      <pubDate>Thu, 15 Oct 2026 22:32:08 +0000</pubDate>
      <link>https://dev.to/minchinweb/only-two-lines-on-an-alberta-power-bill-matter-a-local-model-finds-them-47ap</link>
      <guid>https://dev.to/minchinweb/only-two-lines-on-an-alberta-power-bill-matter-a-local-model-finds-them-47ap</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;I run a small utility retailer in Alberta. Many new customer starts the same way: someone looks at their current bill, works out whether switching would save them money, and then types the site IDs, name and address into an enrolment form. A referral partner can bring in hundreds of homes at a time, and many of those homes have two sites, one for power and one for gas. That is a lot of squinting at photographs of bills.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;bill2quote&lt;/strong&gt; turns a photo or PDF of a utility bill into two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A one-page savings comparison&lt;/strong&gt; the customer can read: what this bill cost on their current plan, what the same usage would cost on our offer, and the difference. If their current plan is cheaper, the page says that, plainly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An enrolment-ready row&lt;/strong&gt; with site ID(s), service address, name and current retailer, plus the partner's referral code.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A vision model running on my own machine reads the bill. Plain Python checks what it read, does every calculation, and writes the outputs. A person confirms the numbers on a review screen before any customer-facing quote exists.&lt;/p&gt;

&lt;p&gt;The insight that makes this tractable: of the dozens of lines on an Alberta bill, only two belong to the retailer, the &lt;strong&gt;energy charge&lt;/strong&gt; and the &lt;strong&gt;admin fee&lt;/strong&gt;. Delivery, riders and the local access fee are set by regulators and municipalities and are the same whichever retailer you pick (&lt;a href="https://ucahelps.alberta.ca/understanding-your-bill.aspx" rel="noopener noreferrer"&gt;Utilities Consumer Advocate, "How to read your bill"&lt;/a&gt;). So the comparison is two lines, plus GST on the difference. The arithmetic is easy. Reading the bill is the hard part.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy1tdfiq6qlrwfe001lxy.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy1tdfiq6qlrwfe001lxy.gif" alt="bill2quote on a phone: photo of a bill in, red checks, fix the numbers, quote out" width="360" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The clip is the real flow on my Pixel 7, with the slow parts sped up. I photograph a printed bill, a local model reads it, and the review screen shows what doesn't add up. Two red checks block the quote until I fix the numbers, then the comparison appears, and "Edit numbers and re-run" fixes a last warning. The final screen is the enrolment list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;N.B.&lt;/strong&gt;: Every bill in this post and in the repo is synthetic. The retailer, people, addresses, account numbers and site IDs are all invented.&lt;/p&gt;

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


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/minchinweb" rel="noopener noreferrer"&gt;
        minchinweb
      &lt;/a&gt; / &lt;a href="https://github.com/minchinweb/hacktoberfest-2026-2" rel="noopener noreferrer"&gt;
        hacktoberfest-2026-2
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Hacktoberfest 2026 Open-Source AI Challenge, Week 2: Side Hustle
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;bill2quote&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;em&gt;Hacktoberfest 2026 Open-Source AI Challenge, Week 2: Side Hustle.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;A photo or PDF of an Alberta utility bill in; an honest one-page savings comparison and an
enrolment-ready record out. A local open-weight vision model (via Ollama) &lt;strong&gt;reads&lt;/strong&gt; the bill
Plain Python &lt;strong&gt;checks&lt;/strong&gt; the arithmetic, does the money math and writes the outputs. A person
confirms the numbers on a review screen before any customer-facing quote exists.&lt;/p&gt;
&lt;p&gt;Only two lines on the bill belong to the retailer: the energy charge and the admin fee. Delivery
riders and the local access fee are the same whichever retailer the customer picks, so savings
come only from those two lines (plus GST on the difference). If the customer's current plan is
cheaper, the page says so.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Data note&lt;/h2&gt;

&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Every bill in this repo is synthetic.&lt;/strong&gt; The retailer, people, addresses, account numbers and
site IDs (&lt;code&gt;0010099…&lt;/code&gt; electricity, &lt;code&gt;0000099…&lt;/code&gt; gas) are fictional, and the…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/minchinweb/hacktoberfest-2026-2" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Python 3.11+, FastAPI, Jinja2, pypdfium2, Pillow, Ollama. No build step, no database, no hosted API. &lt;code&gt;bill2quote batch DIR&lt;/code&gt; for the office, &lt;code&gt;bill2quote serve&lt;/code&gt; for the phone.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  The model reads. The code checks.
&lt;/h3&gt;

&lt;p&gt;The rule I wrote at the top of the spec and kept to: the model transcribes what is printed and never calculates, converts or corrects anything. A suspect number is flagged and shown to a person, never quietly fixed.&lt;/p&gt;

&lt;p&gt;The model gets one request per bill with every page attached, a JSON schema, temperature 0 and thinking off (to encourage deterministic processing). Every field in the schema is required but nullable, so the output is either complete or rejected, and "I couldn't read it" is an answer the model is allowed to give. That is a lesson from &lt;a href="https://dev.to/minchinweb/roadbean-say-it-in-the-car-get-it-in-the-ledger-pf5"&gt;Week 1&lt;/a&gt;, where every field was required too, and where the most useful failure was a garbled odometer reading that the pipeline happily booked as a plausible 642 km trip.&lt;/p&gt;

&lt;p&gt;Then 20 deterministic checks run, each returning a flag with a severity. A few of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;C01:&lt;/strong&gt; does the energy charge equal usage × rate? A small gap is a warning, since a tiered rate can explain it. More than three times apart blocks the quote, since nothing explains that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;C04:&lt;/strong&gt; do the meter readings add up to the usage?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;C06:&lt;/strong&gt; is the rate plausible for its unit? (This one catches ¢ vs $ mix-ups and blocks the quote.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;C07 / C17:&lt;/strong&gt; is the site ID 13 digits starting with 00 (electricity, never 000) or 000 (gas), and is it &lt;em&gt;not&lt;/em&gt; the account number? Some account numbers are also 13 digits, so a model can easily put the wrong one in the wrong box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;C12:&lt;/strong&gt; is anything quote-critical missing?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;C20:&lt;/strong&gt; do the lines add up? Energy charge + admin fee + delivery should equal the printed subtotal. A value read from the wrong row breaks that straight away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;C19:&lt;/strong&gt; does the comparison itself make sense? If the two retailer lines on the offer come out three times higher or lower than on the current plan, a number was misread, and the quote is not shown.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Blocking flags stop a customer quote. Warnings must be acknowledged on the review screen with a tick box. Every edit a reviewer makes is logged with the model's value, the new value and the time.&lt;/p&gt;

&lt;h3&gt;
  
  
  How it did
&lt;/h3&gt;

&lt;p&gt;I generated eight synthetic bills across three layouts (a one-page table, a summary plus detail page, and a two-column dual-fuel statement) with varied wording, plus a Pillow "phone photo" of one of them: rotated, in perspective, shadowed, JPEG at quality 60. Ground truth comes from the same data that rendered each bill. Two models, same prompt, same bills:&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;Bills with every quote-critical field right&lt;/th&gt;
&lt;th&gt;Valid output&lt;/th&gt;
&lt;th&gt;Processing time per bill&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 e4b&lt;/td&gt;
&lt;td&gt;5 of 8&lt;/td&gt;
&lt;td&gt;8 of 8&lt;/td&gt;
&lt;td&gt;about 9s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-VL 8b&lt;/td&gt;
&lt;td&gt;7 of 8&lt;/td&gt;
&lt;td&gt;7 of 8&lt;/td&gt;
&lt;td&gt;about 73s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Quote-critical" means usage, days, energy charge or rate, and admin fee. Field-level accuracy was 95% for Gemma and 97% for Qwen, which sounds great and is exactly the wrong number to look at: one wrong digit in the one field that matters is a wrong quote.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One failure, told straight.&lt;/strong&gt; On an earlier version of my synthetic gas bill, Gemma put the usage (37.49 GJ) into the energy charge field. (That version had a bug of mine: the generator printed "Electricity Supply" on the energy line of a &lt;em&gt;gas&lt;/em&gt; bill. With the label fixed, Gemma reads that PDF correctly, so a wrong label alone was enough to derail it. The misread still shows up on the photographed print, which predates the fix.) The correct charge was $183.33. A quote built from that would have told a customer they'd lose $117.70 by switching, when they'd have saved money. Nothing about it looks wrong at a glance. What caught it was check C01: &lt;em&gt;charge 37.49 ≠ usage 37.49 × rate 4.89 = 183.33&lt;/em&gt;. (At that point C01 was only a warning; it now blocks the quote, see below.) Two more, from the current run: on the phone photo it read usage as 30.937 kWh, which looks like the previous meter reading (30,937) with the comma taken for a decimal point, and C04 flagged it; on another bill it labelled a 12.05 ¢/kWh rate as dollars, and C06 blocked the quote. Another bill came back with both site IDs one digit short: Gemma read &lt;code&gt;0010099000016&lt;/code&gt; as &lt;code&gt;001009900016&lt;/code&gt;, and the gas ID on the same bill lost a zero the same way, apparently dropping one from a run of zeros. C07 blocked enrolment on both, with &lt;em&gt;site ID is not 13 digits starting with 00&lt;/em&gt;. A truncated ID would be quietly accepted by any tool that only checks the field is filled in. Three of eight bills needed a human fix. That is why the review screen is the product, not an afterthought.&lt;/p&gt;

&lt;h3&gt;
  
  
  Real phone photos
&lt;/h3&gt;

&lt;p&gt;I then printed the bills and photographed them with a Pixel 7 on a table: some tilted, one with my finger over part of the page, bill 03 as two photos. Seven bills, scored separately from the table above, which still includes the simulated photo. Same prompt, same models:&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;Bills with every quote-critical field right&lt;/th&gt;
&lt;th&gt;Valid output&lt;/th&gt;
&lt;th&gt;Processing time per bill&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 e4b&lt;/td&gt;
&lt;td&gt;3 of 7&lt;/td&gt;
&lt;td&gt;7 of 7&lt;/td&gt;
&lt;td&gt;about 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-VL 8b&lt;/td&gt;
&lt;td&gt;5 of 7&lt;/td&gt;
&lt;td&gt;5 of 7&lt;/td&gt;
&lt;td&gt;about 72&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is the hardest test so far, and Gemma lost ground: 3 of 7, against 5 of 8 on the clean PDFs. On the bills with a different label wording it often put values against the wrong row. My guess is that the tilt of the page shifts the right-hand values against the left-hand labels, but I haven't tested that. Its four failures were bills 02, 05, 06 and 08. On a second read of those four, a check fired on every one: C06 blocked the gas bill, where a usage of 37.49 had landed in the rate field, C12 blocked the one where usage came back empty, and the others raised C01, C03 or C04 warnings (for example &lt;em&gt;meter reads give 0.700 kWh but usage says 700.0&lt;/em&gt;). Qwen got both of its misses by returning nothing usable: on bills 05 and 08 the JSON was cut off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A warning was not enough.&lt;/strong&gt; While recording the demo on bill 02, I fixed the fields the checks had flagged and left two that weren't, usage (the m³ figure had landed in the GJ field) and the energy charge, then ticked the box and confirmed. The page cheerfully said my current plan was cheaper by $4,072.21, or $47,947 over a year. C01 had only been a warning, and I had acknowledged it. So I changed two things. A C01 gap of more than 3× now blocks the quote, and a new check (C19) refuses any comparison whose two sides are more than 3× apart. The same data now gets a red field on the review screen and no quote. And the quote page has an &lt;strong&gt;Edit numbers and re-run&lt;/strong&gt; link that reopens the form with the saved values and the offer choices, so fixing a number and re-running the comparison takes seconds. The new blocks can be overridden, since a customer on a very low promotional rate really could be more than 3× away, but only with a written reason that is saved with the bill, and the override stops applying the moment a number or the offer changes.&lt;/p&gt;

&lt;p&gt;Qwen is ahead on both sets, but it always thinks (the &lt;code&gt;think: false&lt;/code&gt; flag has no effect on it), it is about eight times slower, and one of its eight PDF outputs failed to validate (two did in the previous run, on different bills). I suspect the thinking ate the whole 8,192-token budget, but I haven't proved it. I kept Gemma as the default, but less comfortably than before: Qwen is two bills ahead on both sets (7 of 8 against 5 of 8 on the PDFs and the simulated photo, 5 of 7 against 3 of 7 on the real photos), though that is still a small sample and it is 8 times slower. Gemma is fast enough to use at a kitchen table, and when it is wrong the checks have caught it every time I looked. That is the trade I'm making, and more bills could change it. The reader is one config value (&lt;code&gt;models.extract&lt;/code&gt;) and &lt;code&gt;bill2quote eval --model X&lt;/code&gt; scores any Ollama vision model on the same bills, so swapping in a better model when one is released, or a locally fine-tuned one, means changing a setting and re-running the eval, not touching the code.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every bill here is synthetic&lt;/strong&gt;, printed and photographed. None is a real customer's bill. Real retailer layouts are the next test -- all different layouts, not nearly as clear (colour and contrast), extra text (up ot 7 pages each) -- and I expect them to be harder. Seven photos is a small sample. The photos are in &lt;code&gt;samples/photos/&lt;/code&gt;, and the results are in &lt;code&gt;docs/eval/photos/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The printed bills I photographed were made before I fixed the generator's "Electricity Supply" label on gas bills, so photos 02 and 08 carry the old label. The PDF eval uses the fixed bills.&lt;/li&gt;
&lt;li&gt;A frontier hosted model would probably read a blurry phone photo better. That is a real cost of staying local.&lt;/li&gt;
&lt;li&gt;The 12-month estimate for gas is a rough days-scaling, labelled as such on the page, until I add heating-degree-day data. Per-bill numbers get top billing for that reason.&lt;/li&gt;
&lt;li&gt;The offers in the demo are made-up round numbers. This is not a statement about any real price.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The data is sensitive in a specific way.&lt;/strong&gt; A bill carries a name, a service address, an account number, and a site ID. A site ID is the "address", the identity in the market and most of what's needed to enrol a site. Sending a stack of those to a hosted API is the wrong default for a business that handles thousands. Here the bills never leave the machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No meter running.&lt;/strong&gt; Reading a thousand multi-page bills costs nothing per page, and re-reading them after a prompt change is free. The eval above would have been a line item on an API invoice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model is a config value, and I can measure it.&lt;/strong&gt; Swapping Gemma for Qwen was one flag, and the table above is the result. The choice of model rests on numbers, not on a vendor's say-so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Room to specialise.&lt;/strong&gt; Alberta bills are a narrow layout family. That is the kind of place a small model could later be fine-tuned. Not this week, but it's possible, which is the point.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;I built this with Claude Code from a written spec, in two sessions. The first took the spec to a working pipeline: scaffold, checks, ingest, extraction, quote math, the web review flow and tests.&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;bill2quote, part 1: from a written spec to a working pipeline in one session&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;Build the &lt;a class="mentioned-user" href="https://dev.to/spec"&gt;@spec&lt;/a&gt;.md&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;I'll start with M0 (scaffold), then M1, following the kickoff prompt. First I'll check the repo and toolchain.&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-powershell"&gt;PowerShell&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;ls -Force; python --version; pip 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"&gt;&lt;code&gt;ls -Force; python --version; pip 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;Python 3.15.0; repo has .git, README.md, SPEC.md&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;Writing the first-commit files: &lt;code&gt;.gitignore&lt;/code&gt; (per rule 3), licence, and &lt;code&gt;pyproject.toml&lt;/code&gt;.&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;mkdir -p src/bill2quote/web/templates prompts samples tests docs; cat &amp;gt; .giti...&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;$ mkdir -p src/bill2quote/web/templates prompts samples tests docs; cat &amp;gt; .gitignore&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;(no output)&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;Dependencies installed. Now the core: schema and config.&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;cat &amp;gt; src/bill2quote/__init__.py, schema.py, config.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 agent-session-terminal"&gt;&lt;code&gt;$ cat &amp;gt; src/bill2quote/__init__.py, schema.py, config.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;(no output)&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;Now the deterministic checks (§9).&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;cat &amp;gt; checks.py &amp;lt;&amp;lt;'EOF'&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;$ cat &amp;gt; checks.py &amp;lt;&amp;lt;'EOF'&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;Exit code 2: unexpected EOF while looking for matching quote&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 shell choked on that heredoc, so I'll use the Write tool for the files instead.&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;src/bill2quote/checks.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;src&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;bill2quote&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;checks&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;File&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="n"&gt;successfully&lt;/span&gt;&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;Now tests for the checks (pass, fail, and missing-value cases).&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;tests/test_checks.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;tests&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;test_checks&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;File&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="n"&gt;successfully&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-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;pytest -q&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;$ pytest -q&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;33 passed in 0.29s&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;33 checks tests pass. Now ingest, extract, the prompt, and the CLI with &lt;code&gt;doctor&lt;/code&gt;.&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;ingest.py, prompts/extract_v1.md, extract.py, doctor.py, cli.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;ingest.py, prompts/extract_v1.md, extract.py, doctor.py, cli.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;Files created&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;Now the synthetic sample generator: 8 bills across 3 layouts with varied wording, truth files built from the same data, plus a Pillow "phone photo" for #4.&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;samples/make_samples.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_samples&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;File&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="n"&gt;successfully&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-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;python samples/make_samples.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 agent-session-terminal"&gt;&lt;code&gt;$ python samples/make_samples.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;wrote 01_elec_clean.pdf, 02_gas_clean.pdf, 03_dual …&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 generator works (it needs numpy, so I'll add that to the dev deps). Next I'll check that every truth file passes the checks as expected, then look at the output.&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;pytest -q&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;$ pytest -q&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;42 passed&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;All eight truths behave as designed. I'll look at what the bills actually look like.&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;samples/04_phone_photo_dual_fuel.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"&gt;&lt;code&gt;samples/04_phone_photo_dual_fuel.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;[image omitted]&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 page content is legible, but the shadow is wrong: a hard diagonal band from the rotated gradient, not a soft shadow. I'll replace it with a smooth numpy gradient.&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_samples.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_samples&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;updated&lt;/span&gt;&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 photo now reads like a phone shot. Next I'll clean up the lint, run the tests, and try &lt;code&gt;doctor&lt;/code&gt; against Ollama.&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;ruff check --fix; pytest -q&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;$ ruff check --fix; pytest -q&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;Found 1 error (1 fixed)&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;Ollama isn't running here, so I can't test model reading yet. I'll commit what's done.&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 add -A &amp;amp;&amp;amp; git commit -m "M0 scaffold + M1 (partial)"&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 add -A &amp;amp;&amp;amp; git commit -m "M0 scaffold + M1 (partial)"&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;committed 2da9267&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;p&gt;I've built M0 and most of M1, and committed it as &lt;code&gt;2da9267&lt;/code&gt;, but I haven't tested reading a bill with a model yet. Ollama isn't running on this machine, and &lt;code&gt;bill2quote doctor&lt;/code&gt; fails with connection refused.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What works&lt;/strong&gt;&lt;br&gt;
- &lt;strong&gt;Scaffold:&lt;/strong&gt; &lt;code&gt;.gitignore&lt;/code&gt;, MIT licence, &lt;code&gt;pyproject.toml&lt;/code&gt;, and a &lt;code&gt;.venv&lt;/code&gt;. The CLI has &lt;code&gt;init&lt;/code&gt;, &lt;code&gt;doctor&lt;/code&gt;, &lt;code&gt;read&lt;/code&gt; and &lt;code&gt;check&lt;/code&gt;.&lt;br&gt;
- &lt;strong&gt;Reading code:&lt;/strong&gt; the schema, config loader, image/PDF ingest, and the Ollama call (schema-constrained, temperature 0, thinking off). Written but unrun against a model.&lt;br&gt;
- &lt;strong&gt;Checks:&lt;/strong&gt; C01–C18, each with tests for the pass, fail and missing-value cases.&lt;br&gt;
- &lt;strong&gt;Samples:&lt;/strong&gt; 8 bills with truth files, in three layouts with varied wording. Bill #4 is a Pillow-made phone photo of #3. Every truth file raises exactly the flags it should.&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &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;Ollama should now be up. Carry on&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-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;bill2quote doctor; 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;$ bill2quote doctor; 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;Ollama reachable; only gemma4:e4b is pulled&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;&lt;code&gt;qwen3-vl:8b&lt;/code&gt; isn't pulled, although the spec says it is. Eval needs a second model, so I'll pull it in the background and start reading bills with Gemma meanwhile.&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;ollama pull qwen3-vl:8b (background); bill2quote read samples/01_elec_clean.pdf&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;$ ollama pull qwen3-vl:8b (background); bill2quote read samples/01_elec_clean.pdf&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;real 0m8.396s&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;Gemma read bill #1 in 8 seconds. Now &lt;code&gt;eval&lt;/code&gt;, which scores each model field by field against the truth files, and also gives the M1 check for all 8 bills.&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;src/bill2quote/evaluate.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;src&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;bill2quote&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;evaluate&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;File&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt;&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;All 8 bills come back as valid BillFacts, which meets the M1 gate. Quote-critical accuracy is 5/8, so I'll look at what went wrong on the three misses.&lt;/p&gt;

                &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Qwen failed all 8, with 0 valid reads. I'll read the actual error before treating that as a result.&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;print the error for each Qwen result&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;$ print the error for each Qwen result&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;model output did not match the schema: 1 validation error&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;Qwen returns empty content. I'll look at the raw response to see where its output is going.&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;raw request to Ollama /api/chat with Qwen&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;$ raw request to Ollama /api/chat with Qwen&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;content: '' | thinking: 1815 chars | done_reason: length at 637 tokens&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;Qwen always emits thinking (the &lt;code&gt;think:false&lt;/code&gt; flag has no effect on it) and is cut off with &lt;code&gt;done_reason: length&lt;/code&gt; at about 637 tokens. I'll try a bigger context and output budget.&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;same request with num_ctx and num_predict raised&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;$ same request with num_ctx and num_predict raised&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;51 s, done_reason: stop, content: valid JSON&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;That works with the bigger budget, at 51 s per bill because of the thinking. I'll make &lt;code&gt;num_ctx&lt;/code&gt; and &lt;code&gt;num_predict&lt;/code&gt; config values, applied to every model.&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;annualize.py, quote.py, tests/test_quote.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;annualize.py, quote.py, tests/test_quote.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;Files created&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;pytest -q&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;$ pytest -q&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;49 passed&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 49 tests pass, and the hand calculation (79.32 − 64.0188 = 15.30) matches. Bill #8 is negative with the demo offers. Now the quote page renderer, enrolment CSV and &lt;code&gt;batch&lt;/code&gt;.&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;quote.html, render.py, enrol.py, store.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;quote.html, render.py, enrol.py, store.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;Files created&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 background eval is still running. In the meantime I'll write the web review flow (M3): upload, polling, review with flags, edit log, quote and enrol.&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;src/bill2quote/web/app.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;src&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;bill2quote&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;web&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;app&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;File&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt;&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;&lt;code&gt;flags_for&lt;/code&gt; is more convoluted than it needs to be. I'll make duplicate detection simply exclude the bill's own folder.&lt;/p&gt;

                &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Now the templates: base styles, upload, polling wait page, review, quote bar, enrolments and login.&lt;/p&gt;

                &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The shell choked on a heredoc again, so I'll write these with the Write tool. Writing each template directly.&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;base.html, login.html, index.html, wait.html, review.html, quote_bar.html, en...&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;base.html, login.html, index.html, wait.html, review.html, quote_bar.html, enrolments.html&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;Files created&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;Next, a test for the whole web flow without a model: seed a bill from truth #7 (the planted error), then go through review, the edit log, quote and enrol.&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;tests/test_web.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;tests&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;test_web&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="mi"&gt;6&lt;/span&gt; &lt;span class="n"&gt;passed&lt;/span&gt;&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;All six pass, but two of my assertions are loose (they have &lt;code&gt;or&lt;/code&gt; clauses that can't fail). I'll tighten them so they test what they claim.&lt;/p&gt;

                &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Eval finished. Gemma is 5/8 quote-critical in 6.7 s per bill. Qwen is 6/8 but takes 78 s per bill, and 2 of its 8 outputs weren't valid. I'll see why those two failed before drawing conclusions.&lt;/p&gt;

                &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The checks caught each real misread: a meter reading taken as usage, a ¢/$ unit mix-up, and a missing usage value. I'll commit, then smoke-test the live server with a real upload.&lt;/p&gt;

                &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The review page renders, but the page image returns 404. I'll check on disk.&lt;/p&gt;

                &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;That's a real bug. This bill had already been read by &lt;code&gt;batch&lt;/code&gt;, so upload skipped the background job, and page images are only written by that job. I'll make upload always ensure the pages exist.&lt;/p&gt;

                &lt;/div&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;I'll add a regression test for that, then restart the server and run the whole flow (review → quote → enrol) through real HTTP.&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;pytest -q; run the flow against the live server&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;$ pytest -q; run the flow against the live server&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;56 passed; review 200, quote 200, enrolment row written&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;p&gt;M0 to M3 are built and committed (latest &lt;code&gt;76f2c31&lt;/code&gt;). All 56 tests pass. I also ran the whole flow against the real server and Gemma. The phone run on your Pixel 7 is still to do, so M3 isn't fully done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What now works&lt;/strong&gt;&lt;br&gt;
- &lt;strong&gt;&lt;code&gt;read&lt;/code&gt;, &lt;code&gt;check&lt;/code&gt;, &lt;code&gt;batch&lt;/code&gt;, &lt;code&gt;eval&lt;/code&gt; and &lt;code&gt;serve&lt;/code&gt;&lt;/strong&gt;, along with &lt;code&gt;doctor&lt;/code&gt;.&lt;br&gt;
- &lt;strong&gt;Quote math:&lt;/strong&gt; checked by hand. For bill #1 the retailer lines come to $79.32 now against $64.0188 on the offer, a difference of $15.30, which is $16.07 with GST.&lt;br&gt;
- &lt;strong&gt;Customer view:&lt;/strong&gt; masks site IDs and hides flags.&lt;br&gt;
- &lt;strong&gt;Negative savings:&lt;/strong&gt; bill #8 shows "your current plan is cheaper" with the demo offers.&lt;br&gt;
- &lt;strong&gt;Web flow over real HTTP:&lt;/strong&gt; upload, review, confirm, the customer quote, and an enrolment row with no account number.&lt;br&gt;
- &lt;strong&gt;Bug found and fixed:&lt;/strong&gt; a bill that had already been read skipped creating its page images. There's a regression test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model eval (8 synthetic bills)&lt;/strong&gt;&lt;br&gt;
- Gemma 4 e4b: 5 of 8 quote-critical correct, all 8 valid, about 7 s a bill.&lt;br&gt;
- Qwen3-VL 8b: 6 of 8, 2 outputs invalid, about 78 s a bill.&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &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;Re "Batch duplicate flag: batch doesn't run the duplicate-bill check (C16). The web app does."  Fix this.   Also draft the Dev post&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;Fixing C16 in &lt;code&gt;batch&lt;/code&gt; first. I'll also show flag IDs rather than counts in the flags column, so the duplicate flag is visible.&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;edit cli.py; pytest -q&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;$ edit cli.py; pytest -q&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;58 passed&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-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;POST.md&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 markdown"&gt;&lt;code&gt;POST.md&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 markdown"&gt;&lt;code&gt;File created&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 commit -m "POST: correct claims about the phone photo and hardware"&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 -m "POST: correct claims about the phone photo and hardware"&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;committed 1ccf427&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;p&gt;&lt;code&gt;batch&lt;/code&gt; now runs the duplicate check, and the post is drafted in &lt;code&gt;POST.md&lt;/code&gt;. 58 tests pass and both changes are committed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;C16 in &lt;code&gt;batch&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
- It flags a bill whose site and period match a different bill already processed.&lt;br&gt;
- On the samples it now flags #3 and #4, which are the same bill as a PDF and as a phone photo.&lt;br&gt;
- A bill never flags against its own folder.&lt;br&gt;
- The flags column now lists flag IDs (like &lt;code&gt;C01 C16&lt;/code&gt;) instead of counts. There are two new tests, with no model needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The post&lt;/strong&gt;&lt;br&gt;
- It follows the DEV template headings in order, with the intro line and the four tags.&lt;br&gt;
- The hook, the "two lines" insight, how the checks work, the eval table, one failure story told straight, limits, why open, and the Author's Note are all written from what's in the repo.&lt;br&gt;
- The failure story is the Gemma gas bill. It put the usage into the charge field, which would have told the customer they'd lose $117.70 when they'd actually have saved money. Check C01 caught it.&lt;br&gt;
- Marked &lt;code&gt;[TODO]&lt;/code&gt; in the post: the demo, the first-user timings, and the real phone photos.&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &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;
        6 of 6 messages
    &lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;The second is the last two days: real phone photos, the plausibility checks, the override, and the demo.&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;bill2quote, part 2: real phone photos, plausibility checks and the demo&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;Fix the site ID coding to match the SPEC. An electricity Site ID will not start 000. Regenerate the synthetic bills and related testing.&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;Electricity site IDs must now be 13 digits starting 00 (never 000), gas IDs 13 digits starting 000. I changed the regexes in config.py and config.example.toml, made the C07 message name the right prefix per commodity, regenerated the synthetic bills, and added a test for both prefixes. 60 tests pass.&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-editbash"&gt;Edit/Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;config.py, checks.py, make_samples.py, tests&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;config.py, checks.py, make_samples.py, tests&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;60 passed&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;Re-run the eval on the regenerated bills and re-read the worked examples in the post.&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;Eval on the regenerated PDFs: Gemma 4 e4b 5 of 8 quote-critical, 8 of 8 valid, about 9 s a bill. Qwen3-VL 8b ahead, about 73 s a bill. I re-read all three worked examples against fresh runs and they still reproduce. Added a C07 example (Gemma dropped a digit from the site IDs and C07 blocked enrolment).&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;I printed the bills and photographed them with my phone. Please remove the location from EXIF if we commit them. Crop the slippers out of two of them; the hand is fine.&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;All eight photos had GPS coordinates. I re-saved each from pixels only, so no EXIF or GPS survives, cropped the slippers out of photos 02 and 08, and extended the eval to accept multi-photo bills (bill 03 is two photos). My first patch to evaluate.py applied only halfway because the file uses Windows line endings, so the first eval run crashed; I fixed the loop and re-ran.&lt;/p&gt;

&lt;p&gt;Result on 7 real photos: Gemma 3 of 7 (all valid), Qwen 5 of 7 (only 5 valid). A check fired on every Gemma miss.&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;Stick with Gemma for now, but note the project is designed so models can be swapped as better ones are released or fine tuned locally.&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;Added a sentence to the post: the reader is one config value (models.extract), and bill2quote eval --model X scores any Ollama vision model on the same bills.&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;Next is the GIF demo. Review my screen recording, trim it, add subtitles, make an 8 FPS GIF.&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;I dumped frames at a constant rate (the recording is variable frame rate, which made seeking unreliable), cut segments with speed-ups for the slow waits, covered the browser address bar, burned in captions, dropped partial and dimmed frames, and encoded at 8 fps. A first pass leaked the LAN address in a dimmed file-chooser frame and an automated scan caught it, so those frames are skipped.&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;In the recording I confirmed bill 02 and it said my current plan was cheaper by $4,072. Fix that, and allow returning to the field entry form to fix numbers and re-run the comparison.&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;Root cause: I had fixed the flagged fields but left a wrong usage (the m3 figure in the GJ field) and a wrong energy charge, and C01 was only a warning I had acknowledged. Changes: a C01 gap over 3x now blocks the quote; a new check C19 refuses any comparison whose two retailer-line sides are more than 3x apart; the quote page gets an 'Edit numbers and re-run' link that reopens the form with the saved values and offer choices. On the real session data the $4,072 quote is now refused and the energy charge shows a red C01.&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;Allow the review override option. Add the check on addition.&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;Added assess.py, shared by the web app and the CLI. Only C01-block and C19 can be overridden, a written reason is required and logged, and the override stops applying as soon as a number or the offer changes. Added C20: energy charge + admin fee + delivery must equal the printed subtotal. A stray newline from my patch script broke a string literal in app.py and I fixed it directly. 72 tests pass.&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;On Bill 2 the line reads 'Electricity Supply' but this is a gas bill.&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;That was a bug in my sample generator: the alternate wording reused the electricity label for the gas energy line. I gave gas its own label ('Natural Gas Supply'), regenerated, and re-ran the eval. The post now says the printed photos predate the fix. Then: fresh-clone test (install, 72 tests, doctor, serve), and found git's autocrlf had altered the PDFs, so I added .gitattributes marking them binary and verified a clean GitHub clone byte-exact.&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;Use the video times for the tool time. My manual time was 17:44 for 7 bills. I caught a $25 promotional discount and confirmed no bill carried a balance forward. Value to me is as part of a bigger CRM package.&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 up the first-user session in docs/first-user.md and the post: manual about 2:32 a bill; tool about 1:02 on a clean read and 3:09 on a bad one, measured to the first correct quote so enrolment is excluded on both sides. The post says plainly that the tool never reads the previous balance, and that the CRM link is not built.&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;Add a Markdown link to our Week 1 post, add the link to the GIF demo, add the agent sessions, remove the top comment.&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;Done in POST.md. I also corrected one sentence to match what the Week 1 post actually says (every field required; the most useful failure was a garbled odometer booked as a plausible 642 km trip).&lt;/p&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;
        20 of 20 messages
    &lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Two moments worth pointing at. In the first session, Qwen came back with empty output; reading the raw response showed its thinking was eating the whole token budget, so the fix was a bigger budget, not a prompt change. In the second, I confirmed a bill that still had a wrong usage and energy charge and got a quote saying my current plan was cheaper by $4,072. That is where C19 and the override came from.&lt;/p&gt;

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

&lt;p&gt;My first user is me, as the retailer: internal use by the buyer, which is a weaker test than an outside customer, but the retailer is the one who pays. The full notes are in &lt;code&gt;docs/first-user.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I reviewed the seven printed bills by hand and quoted the comparison, then ran the tool on two of them. Enrolment is extra time in both cases and isn't counted in either case, since I have no way to automate it yet. All seven bills are in one basic layout with no extra text, so reading them by eye is easy and fast. That flatters the manual side.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Manual:&lt;/strong&gt; 17 minutes 44 seconds for seven bills, about 2 min 32 s each.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;With bill2quote:&lt;/strong&gt; about 1 min 02 s for a bill that read cleanly, and about 3 min 09 s for one the model misread badly and I had to correct field by field. The mean of those two is about 2 min 06 s. Those were demo recordings, not a stopwatch session, so I'm not making a speed claim beyond this: a little faster on average, much faster when the read is clean, slower when it isn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What I caught that the tool didn't:&lt;/strong&gt; a $25 promotional discount on one bill, which I need to discuss with the client, and that none of the bills carried a balance forward. A balance carried forward is a credit-risk signal, and we might not want that customer. The tool reads credits and lists them as an informational note, but it never reads the previous balance at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What the tool flags that I missed:&lt;/strong&gt; I did not see any of the issues it would have flagged. On these bills that is the planted $20 error in the energy charge on bill 07 (C01), the estimated meter read on bill 05 and the credit on bill 06. I did not run bill 07 through the tool live, so that catch comes from the tests and the eval, not from the session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verdict as the buyer:&lt;/strong&gt; as a stand-alone tool its value is hard to pin down. For me it would be part of a bigger package: launching a CRM. A CRM that does this wins over one that doesn't, and no CRM does it out of the box, so the value is as a differentiator inside a package deal, not a price per bill. What I expect from it: it can deal with different bill formats once properly configured, whereas this test was one format I could read by eye; a properly formatted quote for the client instead of my scribbles on their bill; and a record of each enrolment (partner code, bill, offer) that can go into a CRM for follow-up and to confirm the enrolment went through. The quote and the record exist today. The CRM connection and the success tracking do not, and neither does reading the previous balance or treating a promotional credit as a conversation with the client.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; Gemma 4 e4b is the default reader. It reads every bill in the eval to a valid result and takes about nine seconds a bill on my Windows laptop. I report where it fails and how the checks catch it, and I report that Qwen3-VL scored two bills higher on both the PDFs and the real photos. I'm not claiming any other category.&lt;/p&gt;




&lt;h3&gt;
  
  
  Author's Notes
&lt;/h3&gt;

&lt;p&gt;I drafted this post with AI help, as I did the earlier entries, and edited it myself. The software was built with Claude Code from a spec I wrote with it. All numbers above come from runs saved in the repo (&lt;code&gt;docs/eval/&lt;/code&gt;).&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>python</category>
    </item>
    <item>
      <title>roadbean: say it in the car, get it in the ledger</title>
      <dc:creator>MinchinWeb</dc:creator>
      <pubDate>Sat, 10 Oct 2026 20:19:13 +0000</pubDate>
      <link>https://dev.to/minchinweb/roadbean-say-it-in-the-car-get-it-in-the-ledger-pf5</link>
      <guid>https://dev.to/minchinweb/roadbean-say-it-in-the-car-get-it-in-the-ledger-pf5</guid>
      <description>&lt;p&gt;&lt;em&gt;This is my submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge: Week 1&lt;/a&gt; (Touch Grass). It is also entered for **Best Use of Gemma&lt;/em&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;roadbean&lt;/strong&gt; turns voice memos recorded on my phone, while I'm in the car, into entries in my &lt;a href="https://beancount.github.io/" rel="noopener noreferrer"&gt;Beancount&lt;/a&gt; ledger. I say "trip start, odometer 1234, heading to the library", and later "trip end, 1254", and say "Safeway, 42 dollars 28, GST 7 cents" when I buy groceries. I get a mileage log I can hand to the CRA (the Canada Revenue Agency, i.e. the "tax man") and receipts waiting for review.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;phone: voice memo / receipt photo
  -&amp;gt; git inbox (private repo, synced to the laptop)
  -&amp;gt; local model: speech to text, then text to JSON
  -&amp;gt; plain Python: pairs trips, checks the arithmetic, writes entries
  -&amp;gt; staging.bean: pending (!) entries you review and promote
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The design rule is &lt;strong&gt;the model reads, the code checks&lt;/strong&gt;. The model is the only non-deterministic step. Everything after it (pairing a start with an end, comparing odometer difference to the stated distance, splitting GST, applying the allowance tiers) is ordinary Python with tests. Nothing is silently corrected: if something doesn't add up, the entry is written with a &lt;code&gt;; REVIEW:&lt;/code&gt; line and stays pending (&lt;code&gt;!&lt;/code&gt;) until a human looks.&lt;/p&gt;
&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;This is the laptop side. On the phone, three Termux:Widget buttons run a script that records a memo, takes a photo (e.g. a receipt) or pushes the inbox to a private git repo that syncs to the laptop. The recording below is &lt;code&gt;roadbean process&lt;/code&gt; run against real memos from my phone.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fminchinweb%2Fhacktoberfest-2026-1%2Fmain%2Fdocs%2Froadbean-demo.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fminchinweb%2Fhacktoberfest-2026-1%2Fmain%2Fdocs%2Froadbean-demo.gif" alt="roadbean processing real phone memos" width="600" height="403"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Look at what the demo does &lt;em&gt;not&lt;/em&gt; do. My first real memo was transcribed as "uh memo". The model couldn't make a trip or receipt of it, so the entry says &lt;code&gt;not a trip or receipt; file by hand&lt;/code&gt; instead of inventing something. The receipt memo came out as "purchase staple safeway for 42 28 and gst of 7 7", and the entry was written with &lt;code&gt;no rule matched payee; left uncategorized&lt;/code&gt; and a review flag. (It also got the GST wrong; more on that below.) The run also flagged a 305,023 km "trip" as exceeding &lt;code&gt;max_trip_km&lt;/code&gt;, because a stale open trip from my earlier testing got paired with the new start. It was caught by a validation rule, not by me. Real input is messy; the point is that every miss lands as a visible review line rather than a wrong number in the books.&lt;/p&gt;
&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Speech to text.&lt;/strong&gt; Gemma's native audio input through Ollama (&lt;code&gt;gemma4:e4b&lt;/code&gt;), after ffmpeg converts the phone's &lt;code&gt;.m4a&lt;/code&gt; to 16 kHz mono WAV. Whisper.cpp and Cactus's Whistle are drop-in alternatives (&lt;code&gt;[models].stt&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extraction.&lt;/strong&gt; The transcript goes to Gemma with a JSON schema (&lt;code&gt;format=&amp;lt;schema&amp;gt;&lt;/code&gt;, &lt;code&gt;think: false&lt;/code&gt;). Every field is required, so the output is either complete or rejected. Seven few-shot examples live in &lt;code&gt;prompts/extract_v1.md&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pairing and validation&lt;/strong&gt; (pure Python, no model):

&lt;ul&gt;
&lt;li&gt;start and end memos for the same vehicle become one trip; odometer difference vs. stated distance is checked;&lt;/li&gt;
&lt;li&gt;a balance assertion on the odometer is dated the day after the trip, and is dropped (with a review note) if the model's confidence was low;&lt;/li&gt;
&lt;li&gt;GST is split from the total, and the CRA allowance tiers are applied to business km;&lt;/li&gt;
&lt;li&gt;a start with no end memo is written as a commented-out &lt;code&gt;; OPEN TRIP:&lt;/code&gt; block and replaced in place when the end memo arrives. If I close it by hand, roadbean notices and doesn't recreate it.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotent.&lt;/strong&gt; Each memo's SHA is stored in its entry, so re-running &lt;code&gt;process&lt;/code&gt; never duplicates anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logbook.&lt;/strong&gt; &lt;code&gt;roadbean logbook&lt;/code&gt; reads the real ledger through the Beancount loader and writes a CRA-style CSV with totals.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every entry carries &lt;code&gt;source: "roadbean 0.1.0"&lt;/code&gt;, so I can tell later which version wrote it.&lt;/p&gt;
&lt;h3&gt;
  
  
  What a pending entry looks like
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-10-10 ! "Safeway" "Null"
  source: "roadbean 0.1.0"
  memo-sha: "cc60a49a8f7b"
  receipt: "2026-10-10T121405.jpg"
  gst: "g"
  transcript: "purchase staple safeway for 42 28 and gst of 7 7"
  ; REVIEW: no rule matched payee; left uncategorized
  Expenses:Uncategorized      40.27 CAD
  Assets:GST-Receivable        2.01 CAD
  Liabilities:Visa           -42.28 CAD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Look closely at the GST line: &lt;strong&gt;2.01 is wrong.&lt;/strong&gt; I said "7 cents". The extractor only records a GST &lt;em&gt;code&lt;/em&gt; (&lt;code&gt;g&lt;/code&gt; = taxable, &lt;code&gt;z&lt;/code&gt; = zero-rated, &lt;code&gt;e&lt;/code&gt; = exempt), not an amount, so it heard "GST applies" and the Python code split the total at a flat 5%. That is a bad assumption for groceries, where a basket usually mixes zero-rated items (milk, vegetables) with taxable ones (snacks, paper goods). It is exactly the kind of error the review step exists for, and it is still a limitation: roadbean can't yet take a spoken GST amount. I'm leaving the real output here instead of a tidy example. (The odd &lt;code&gt;"Null"&lt;/code&gt; narration is also real output, and also still a bug.)&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Gemma
&lt;/h2&gt;

&lt;p&gt;I wanted everything to run on hardware I own. The same family covers both ends:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On the laptop:&lt;/strong&gt; &lt;code&gt;gemma4:e4b&lt;/code&gt; via Ollama does speech-to-text &lt;em&gt;and&lt;/em&gt; extraction. One model, no separate STT stack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On the phone:&lt;/strong&gt; the plan was a smaller Gemma (E2B) through llama.cpp in Termux, so a memo could be processed without the laptop. I didn't get speech recognition working on the phone in time, so the phone only records, photographs and syncs; all the model work happens on the laptop.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Native audio vs. a dedicated STT model
&lt;/h3&gt;

&lt;p&gt;I tried Whistle (Cactus's tiny STT model) against Gemma's native audio on five synthetic memos (clean but robotic Windows SAPI voices, so an easy case for both):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;STT&lt;/th&gt;
&lt;th&gt;Odometer read correctly (3 trips)&lt;/th&gt;
&lt;th&gt;Failure mode&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemma native audio&lt;/td&gt;
&lt;td&gt;3 / 3&lt;/td&gt;
&lt;td&gt;none on odometer readings (one free-text word misheard)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whistle&lt;/td&gt;
&lt;td&gt;0 / 3&lt;/td&gt;
&lt;td&gt;produced a plausible but wrong 642 km trip&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Whistle's wrong trip was only caught by the low-confidence flag. That result is why native audio is the default. Five synthetic recordings is a small, friendly sample, not a benchmark. Real voice samples proved harder to process, but I don't have enough of them to give meaningful numbers like the table above.&lt;/p&gt;

&lt;p&gt;I also considered EmbeddingGemma 2 for matching payees to rules. I decided against it: payees are a few dozen strings, normalized matching plus explicit rules is predictable, and an embedding match would add a non-deterministic step exactly where I want determinism.&lt;/p&gt;
&lt;h2&gt;
  
  
  Taking it outside (Touch Grass)
&lt;/h2&gt;

&lt;p&gt;The idea is to keep phone time to a minimum while still keeping the records the CRA requires.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Set up the phone side at home: install the Termux shortcuts and add them to the home screen.&lt;/li&gt;
&lt;li&gt;Get in the car at the start of the trip, tap the "memo" shortcut and say "Trip start. Odometer xxx km."&lt;/li&gt;
&lt;li&gt;Drive around town and run errands. Not a lot of grass, as it's snowing, but we still get fresh air.&lt;/li&gt;
&lt;li&gt;After the purchase, tap the "memo" shortcut again and say "Safeway purchase. 42.28 total, 7 cents GST."&lt;/li&gt;
&lt;li&gt;Tap the "photo" shortcut to photograph the receipt. There is no photo preview, so it's a best guess, but the idea is to keep phone time as low as possible.&lt;/li&gt;
&lt;li&gt;Back home, tap the "memo" shortcut and say "Trip end. Odometer xxx km."&lt;/li&gt;
&lt;li&gt;Tap the "sync" shortcut.&lt;/li&gt;
&lt;li&gt;The laptop processes the data later, at home.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The voice memos landed in the inbox as they were supposed to, and git made sure they ended up on my laptop for review; so that part worked.&lt;/p&gt;

&lt;p&gt;Gemma's transcription of the car audio was a coin flip. It's hard to know whether the cause is the low-bandwidth recording from the phone, the phone's microphone placement, or the model itself. More investigation is needed. But in a pinch, the original recordings are available for human review.&lt;/p&gt;

&lt;p&gt;For changes, I would provide more feedback when using the shortcuts; it's hard to be confident that everything is working as expected. Also, installing a bunch of scripts in Termux is cool, but not really user friendly; a full Android app would be a (much more involved) option. Finally, I would love to figure out how to do the audio processing on my phone; the model seems small enough to support this, in theory.&lt;/p&gt;
&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Tested end to end on the laptop with synthetic and a few real memos. The Termux shortcuts work on the phone, but I didn't get on-phone voice recognition working.&lt;/li&gt;
&lt;li&gt;Speech recognition errors become review lines, not wrong data, but they still need a human. Numbers read aloud ("1 2 3 4") are the weak spot.&lt;/li&gt;
&lt;li&gt;GST is a flat 5% split when the receipt is marked taxable. Mixed baskets, such as groceries, come out wrong, and a spoken GST amount is ignored.&lt;/li&gt;
&lt;li&gt;Single user, single currency (CAD default), personal vehicle with the CRA allowance enabled; the account names are the Beancount ones from my own setup.&lt;/li&gt;
&lt;li&gt;There is almost no feedback on the phone. That was a feature of the design brief, but it makes it hard to feel confident the process hasn't silently broken.&lt;/li&gt;
&lt;li&gt;It still requires a laptop to process.&lt;/li&gt;
&lt;li&gt;It syncs through git. GitHub private repos are private to other users, but GitHub still holds the data, and trip and financial data are sensitive. Self-hosting is an option, but it's a lot of setup for one project.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Why open matters here
&lt;/h2&gt;

&lt;p&gt;Trip logs and receipts say where I go, when I'm away and what I spend. I didn't want a cloud API in the middle of that.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Privacy.&lt;/strong&gt; Audio, photos and transcripts are processed on my laptop with Gemma through Ollama. Nothing is uploaded except to my own git remote, which I can control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swappable models.&lt;/strong&gt; The speech engine and the extractor are config settings (&lt;code&gt;native&lt;/code&gt;, Whistle, whisper.cpp; Ollama or llama.cpp). That is how I found that Gemma's native audio read odometers better than Whistle, with a one-line change. Hopefully better models keep coming out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No meter running.&lt;/strong&gt; A year of memos and receipts costs nothing per call. Re-runs are also "free".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where it was worse.&lt;/strong&gt; A closed cloud speech API would probably have handled my noisy car audio better. On the phone, the open pieces didn't get there in time.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;On the laptop (needs Python 3.11+, &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt; with a Gemma model, and ffmpeg):&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/minchinweb/hacktoberfest-2026-1
&lt;span class="nb"&gt;cd &lt;/span&gt;hacktoberfest-2026-1
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; .[ledger]
ollama pull gemma4:e4b
roadbean init ~/roadbean        &lt;span class="c"&gt;# creates config, rules, inbox&lt;/span&gt;
roadbean doctor ~/roadbean      &lt;span class="c"&gt;# checks Ollama, ffmpeg, the model&lt;/span&gt;
roadbean process ~/roadbean     &lt;span class="c"&gt;# inbox -&amp;gt; staging.bean&lt;/span&gt;
roadbean logbook ~/roadbean     &lt;span class="c"&gt;# CRA-style CSV&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;You can try it without a phone: drop &lt;code&gt;.m4a&lt;/code&gt; or &lt;code&gt;.wav&lt;/code&gt; memos named by time (&lt;code&gt;2026-10-10T102541.m4a&lt;/code&gt;) into &lt;code&gt;~/roadbean/inbox/&lt;/code&gt;, or use the synthetic samples in &lt;code&gt;samples/&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Setting up the phone (Android + Termux)
&lt;/h3&gt;

&lt;p&gt;The phone only records, photographs and syncs. Memos reach the laptop through a &lt;strong&gt;private&lt;/strong&gt; git repo, because they contain your voice and optionally your location.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Make a private inbox repo&lt;/strong&gt;, e.g. &lt;code&gt;gh repo create roadbean-inbox --private&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Install Termux, Termux:API and Termux:Widget from F-Droid&lt;/strong&gt; (all three from the same source), then in Termux:
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   pkg &lt;span class="nb"&gt;install &lt;/span&gt;git termux-api ffmpeg openssh
   git clone https://github.com/minchinweb/hacktoberfest-2026-1 ~/roadbean-src
   &lt;span class="nb"&gt;cd&lt;/span&gt; ~/roadbean-src &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; bash termux/install.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This puts &lt;code&gt;rb-memo&lt;/code&gt;, &lt;code&gt;rb-sync&lt;/code&gt; and &lt;code&gt;rb-photo&lt;/code&gt; in &lt;code&gt;~/.shortcuts/tasks/&lt;/code&gt;, so a widget tap runs in the background instead of opening a terminal. Run &lt;code&gt;termux-microphone-record -d&lt;/code&gt; once by hand and allow the microphone prompt.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Let the phone push.&lt;/strong&gt; Make an SSH key (&lt;code&gt;ssh-keygen -t ed25519 -N "" -f ~/.ssh/id_ed25519&lt;/code&gt;), add the public key as a deploy key with write access on the inbox repo, then run &lt;code&gt;bash termux/init-repo.sh git@github.com:YOU/roadbean-inbox.git&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the widgets.&lt;/strong&gt; Long-press the home screen, add a Termux:Widget, and pick &lt;code&gt;rb-memo&lt;/code&gt;, &lt;code&gt;rb-photo&lt;/code&gt; and &lt;code&gt;rb-sync&lt;/code&gt;. Tap &lt;code&gt;rb-memo&lt;/code&gt; to start recording and again to stop (60 second cap).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On the laptop,&lt;/strong&gt; clone the inbox repo as your roadbean folder: &lt;code&gt;git clone git@github.com:YOU/roadbean-inbox.git ~/roadbean&lt;/code&gt;, then &lt;code&gt;roadbean init ~/roadbean&lt;/code&gt;. After each trip, &lt;code&gt;git -C ~/roadbean pull &amp;amp;&amp;amp; roadbean process ~/roadbean&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The full instructions, including removing everything with &lt;code&gt;termux/uninstall.sh&lt;/code&gt;, are in the repo README.&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/minchinweb" rel="noopener noreferrer"&gt;
        minchinweb
      &lt;/a&gt; / &lt;a href="https://github.com/minchinweb/hacktoberfest-2026-1" rel="noopener noreferrer"&gt;
        hacktoberfest-2026-1
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      roadbean: voice memos to Beancount mileage + receipt entries, local open-weight models
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;roadbean&lt;/h1&gt;
&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;Say it in the car, get it in the ledger.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;roadbean turns short voice memos recorded on a phone into &lt;a href="https://beancount.github.io/" rel="nofollow noopener noreferrer"&gt;Beancount&lt;/a&gt; entries. A memo can mark the &lt;strong&gt;start or end of a business drive&lt;/strong&gt; (with the odometer reading) or describe a &lt;strong&gt;receipt&lt;/strong&gt; (fuel, parking, a coffee with a client). Local open-weight models turn each memo into structured JSON. Plain Python pairs trips, checks the numbers, and writes pending (&lt;code&gt;!&lt;/code&gt;) entries to a staging file that your main ledger includes.&lt;/p&gt;

&lt;p&gt;The phone is barely touched: one tap to record, one tap to stop, a vibration to confirm. All interpretation happens later, offline, on hardware you own. Nothing is sent to a hosted API.&lt;/p&gt;

&lt;p&gt;Built for Hacktoberfest 2026 (Open-Source AI Challenge, week 1). Design: &lt;a href="https://github.com/minchinweb/hacktoberfest-2026-1/SPEC.md" rel="noopener noreferrer"&gt;SPEC.md&lt;/a&gt;.&lt;/p&gt;

&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;
&lt;pre class="notranslate"&gt;&lt;code&gt;PHONE (Termux)                                  PHONE or LAPTOP
Termux:Widget "rb-memo" -&amp;gt; inbox/*.m4a          1. transcribe   Whistle | whisper.cpp | Gemma audio
  (+ optional location, receipt photo)          2.&lt;/code&gt;&lt;/pre&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/minchinweb/hacktoberfest-2026-1" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;



&lt;p&gt;MIT licensed, and built this week for the challenge.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it was built
&lt;/h2&gt;

&lt;p&gt;The whole thing was built in a Claude Code session, with me steering from the terminal. The curated transcript:&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;Building roadbean: voice memos to Beancount entries&lt;/span&gt;
      &lt;span class="agent-session-model-badge"&gt;claude-sonnet-5-5&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;1) Init a git repo here, and create this as a new repo on GitHub. 2) Read &lt;a class="mentioned-user" href="https://dev.to/spec"&gt;@spec&lt;/a&gt;.md , ask me any questions you need answered, and then go build it (while I'm away).&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;Authenticated. Creating the repo now.&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;Status: the repo is live on GitHub, and the config, memo discovery, STT (including Whistle), backends and emit modules are written. I'm now writing the pairing, validation and receipt core.&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;Nothing was written because the heredoc broke. I'll use the Write tool for these two files.&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;Status: 20 tests pass. I'm fixing one bug where a photo's &lt;code&gt;.extract.json&lt;/code&gt; sidecar gets picked up as a memo. After that come the Termux shortcuts, the README, and a commit and push.&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;No GGUF or Ollama is installed, so extraction can't be tested against a real model here. I'll cover the backend request and response handling with a fake local HTTP server instead.&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;roadbean is built and pushed to &lt;a href="https://github.com/minchinweb/hacktoberfest-2026-1" rel="noopener noreferrer"&gt;https://github.com/minchinweb/hacktoberfest-2026-1&lt;/a&gt; (public). All 24 tests pass, but nothing has run against a real Gemma model yet. Ollama, Gemma weights and the Pixel weren't available here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What works&lt;/strong&gt;&lt;br&gt;
- &lt;strong&gt;Core pipeline:&lt;/strong&gt; pairing, odometer checks, GST split, the allowance tier split at 5,000 km, the &lt;code&gt;Odometer opening&lt;/code&gt; entry for a vehicle's first trip, and re-runs that add nothing.&lt;br&gt;
- &lt;strong&gt;Commands:&lt;/strong&gt; &lt;code&gt;doctor&lt;/code&gt;, &lt;code&gt;process&lt;/code&gt; (&lt;code&gt;--dry-run&lt;/code&gt;, &lt;code&gt;--from-json&lt;/code&gt;), and &lt;code&gt;logbook&lt;/code&gt;.&lt;br&gt;
- &lt;strong&gt;Backends and shortcuts:&lt;/strong&gt; Ollama and llama.cpp backends, plus the Termux &lt;code&gt;rb-memo&lt;/code&gt;, &lt;code&gt;rb-sync&lt;/code&gt; and &lt;code&gt;rb-photo&lt;/code&gt; shortcuts.&lt;br&gt;
- &lt;strong&gt;Whistle:&lt;/strong&gt; added as &lt;code&gt;stt = "whistle"&lt;/code&gt;.&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &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;1) Should we use this model?  &lt;a href="https://blog.google/innovation-and-ai/technology/developers-tools/embeddinggemma-2/" rel="noopener noreferrer"&gt;https://blog.google/innovation-and-ai/technology/developers-tools/embeddinggemma-2/&lt;/a&gt;  2) draft the DEV post&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;&lt;strong&gt;1) EmbeddingGemma 2: no, not for this week.&lt;/strong&gt; roadbean has no retrieval or similarity step. Extraction is Gemma plus a schema, and categories come from your &lt;code&gt;rules.toml&lt;/code&gt; on purpose, so an embedder would add a dependency to a deterministic pipeline. It doesn't transcribe, so it can't replace Whistle. Two real uses might come later: fuzzy matching of payees/destinations, and searching old memos by meaning. Both are post-challenge ideas.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2) DEV draft is saved (unpublished).&lt;/strong&gt;&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;Ollama and the model installed here locally&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;Gemma 4 is running locally and the full pipeline now works end to end on laptop-synthesized audio. I ran the five sample memos through Ollama with &lt;code&gt;gemma4:e4b&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Speech engine&lt;/th&gt;
&lt;th&gt;Odometer readings right&lt;/th&gt;
&lt;th&gt;Whole run (5 memos)&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemma native audio&lt;/td&gt;
&lt;td&gt;3 of 3&lt;/td&gt;
&lt;td&gt;about 26 s&lt;/td&gt;
&lt;td&gt;Matches expected except one free-text word&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whistle&lt;/td&gt;
&lt;td&gt;0 of 3&lt;/td&gt;
&lt;td&gt;about 20 s&lt;/td&gt;
&lt;td&gt;A wrong 642 km trip&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What the real model run turned up, all now fixed:&lt;br&gt;
- &lt;strong&gt;Thinking mode:&lt;/strong&gt; Gemma 4 needs &lt;code&gt;think: false&lt;/code&gt;. With thinking on, the schema-constrained output came back empty or wrong and took 7-17 s per memo. With it off, a memo takes about 2.7 s.&lt;br&gt;
- &lt;strong&gt;Plausible wrong trip:&lt;/strong&gt; garbled Whistle text produced a plausible wrong trip; low-confidence extractions now get a &lt;code&gt;; REVIEW:&lt;/code&gt; line and lose their balance assertion.&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &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;Add a termux uninstall script&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;I added &lt;code&gt;termux/uninstall.sh&lt;/code&gt; and pushed it. It stops any recording left running, removes the three shortcuts, and clears the toggle file. &lt;code&gt;--purge&lt;/code&gt; deletes &lt;code&gt;$RB_HOME&lt;/code&gt; after a y/N prompt.&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;&lt;code&gt;pip install -e .[dev]&lt;/code&gt; failed on Termux  [Image #1]&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 cause is the &lt;code&gt;beancount&lt;/code&gt; dependency. It has no wheel for Android/aarch64, so pip builds it from source with Meson, and that build needs &lt;code&gt;bison&lt;/code&gt;, which Termux doesn't have. On the phone you don't need Beancount at all, so I made it an optional extra. On Termux, run &lt;code&gt;pip install -e .&lt;/code&gt; without &lt;code&gt;[dev]&lt;/code&gt;.&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;The build directory needs to be in the .gitignore&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;&lt;code&gt;build/&lt;/code&gt; was already committed (it came from my clean-venv check), so I added it to &lt;code&gt;.gitignore&lt;/code&gt; and untracked it too.&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;The termux scripts drop into the Termux screen, and don't exit from there.  Also, the (phone) install instrcutions need to include setting up the git repo to push these things to your laptop&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;Two real problems. Termux:Widget keeps a terminal open for scripts in &lt;code&gt;~/.shortcuts/&lt;/code&gt;; scripts in &lt;code&gt;~/.shortcuts/tasks/&lt;/code&gt; run in the background, so install.sh now targets that. I also added &lt;code&gt;termux/init-repo.sh &amp;lt;remote&amp;gt;&lt;/code&gt; and README steps for phone-to-laptop git sync. The &lt;code&gt;rb-memo&lt;/code&gt; toggle now uses a timestamp instead of a background &lt;code&gt;sleep&lt;/code&gt;.&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;Fix the file permissions (+x) on the init-repo.sh script&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;Git on Windows recorded every script as 100644, and &lt;code&gt;chmod&lt;/code&gt; doesn't change that here. I set the +x mode with &lt;code&gt;git update-index --chmod=+x&lt;/code&gt; on all the scripts and pushed.&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;&lt;code&gt;warning: 2026-10-10T102519: whisper-cli not found on PATH;&lt;/code&gt; (on my laptop) how do I fix this?&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 built-in default speech engine was &lt;code&gt;whisper.cpp:base.en&lt;/code&gt;, which needs a &lt;code&gt;whisper-cli&lt;/code&gt; binary. The default is now &lt;code&gt;native&lt;/code&gt; (Gemma audio through Ollama), and the whisper error message says how to fix it.&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;Rather than asking it me to provide a bunch of paths, let's just point at a single folder, that is assumed to have the rules, configuration, and inbox in 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 class="agent-session-text"&gt;
                  &lt;p&gt;Done: &lt;code&gt;roadbean init|doctor|process|logbook [folder]&lt;/code&gt;. The folder holds &lt;code&gt;roadbean.toml&lt;/code&gt;, &lt;code&gt;rules.toml&lt;/code&gt;, &lt;code&gt;inbox/&lt;/code&gt; and &lt;code&gt;staging.bean&lt;/code&gt;, and defaults to &lt;code&gt;$RB_HOME&lt;/code&gt; then the current directory.&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;My run threw this: roadbean process ..\hacktoberfest-2026-1-sync\ -- warning: 2026-10-10T102519: &lt;a href="http://localhost:11434/v1/chat/completions:" rel="noopener noreferrer"&gt;http://localhost:11434/v1/chat/completions:&lt;/a&gt; HTTP Error 400: Bad Request; skipped (will retry next run) -- 0 new entries, 0 memos already staged&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;roadbean sent your phone's raw &lt;code&gt;.m4a&lt;/code&gt; to Ollama's native-audio endpoint, which only accepts WAV. My earlier tests used &lt;code&gt;.wav&lt;/code&gt;, so these were the first real phone recordings on that path. It now converts every memo to 16 kHz mono WAV first. I added a regression test, and backend errors now include the response body, which is how I found the real cause.&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;Note somewhere on processing when we have an open trip (i.e. no finishing memo)&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;&lt;code&gt;roadbean process&lt;/code&gt; now reports any open trip on every run, with its start time, odometer and destination, and when it will be flagged as stale.&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;Use the &lt;code&gt;[GOOD]&lt;/code&gt; &lt;code&gt;[WARN]&lt;/code&gt; &lt;code&gt;[ERRO]&lt;/code&gt; logging on lines that we were using for battlestation&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 the convention in &lt;code&gt;strathcona-power-agents-1&lt;/code&gt;. Every status line roadbean prints now uses it: only the four-letter code is coloured, &lt;code&gt;[GOOD]&lt;/code&gt; green, &lt;code&gt;[WARN]&lt;/code&gt; yellow, &lt;code&gt;[ERRO]&lt;/code&gt; red, and &lt;code&gt;[INFO]&lt;/code&gt; cyan.&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;add the open trips as commented out entries in the ledger, and then update them when the trip completes&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;While a trip is open, &lt;code&gt;staging.bean&lt;/code&gt; gets a commented-out placeholder block. When the end memo arrives, or the start goes stale and gets flagged, that same block is replaced in place by the real entries. Beancount ignores the placeholder, and re-runs leave the file byte-for-byte unchanged.&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;Don't recreate the open trip if I manually edit it to close it (i.e. manually provide a closing mileage)&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;roadbean now recognises a hand-closed trip by vehicle and odometer-start/end, skips the start memo and any late end memo, and prints an &lt;code&gt;[INFO]&lt;/code&gt; line saying why.&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;All ledger entries should have a "source" metadata key (or is there a difference beancount conversion), including the script name and version&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;Beancount has no formal convention; metadata keys are free-form lowercase names and the loader reserves only &lt;code&gt;filename&lt;/code&gt;, &lt;code&gt;lineno&lt;/code&gt; and &lt;code&gt;__&lt;/code&gt;-prefixed keys. Importers conventionally use plain &lt;code&gt;source&lt;/code&gt;, so every entry now carries &lt;code&gt;source: "roadbean 0.1.0"&lt;/code&gt;.&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;Pyproject should read the version from the python source (single source of truth)&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;&lt;code&gt;pyproject.toml&lt;/code&gt; now uses &lt;code&gt;dynamic = ["version"]&lt;/code&gt; and &lt;code&gt;[tool.setuptools.dynamic] version = { attr = "roadbean.__version__" }&lt;/code&gt;. I tested it by bumping the source to 9.9.9, confirming pip reported it, and reverting.&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;GiF demo in docs folder. Draft the Dev post as a Markdown file I can edit&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;I've written the draft at &lt;code&gt;docs/devto-post.md&lt;/code&gt;, unpublished. It uses the demo GIF's real, messy results as the honest story: a garbled memo became a review line, and a 305,023 km trip was caught by &lt;code&gt;max_trip_km&lt;/code&gt; instead of being booked.&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;Re 2: I wasn't able to get the voice recognition working on my phone.  Re 3: "so so", not measured. But the raw samples are there and can be reviewed manually if needed.  Re 4: yes, add a session embed.   5) Rename the GIF&lt;/p&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;
        42 of 42 messages
    &lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  Author's Notes
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;N.B. Yes, this is very heavily written (code and documentation and the first draft of this post) using AI, and this required a fair bit of tinkering, so no "one shotting" here. As an experiment, I'm going to continue.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>python</category>
      <category>opensource</category>
    </item>
    <item>
      <title>receipt2bean: turning my partner's receipt photos into Beancount with a local vision model</title>
      <dc:creator>MinchinWeb</dc:creator>
      <pubDate>Mon, 05 Oct 2026 06:09:48 +0000</pubDate>
      <link>https://dev.to/minchinweb/receipt2bean-turning-my-partners-receipt-photos-into-beancount-with-a-local-vision-model-47p4</link>
      <guid>https://dev.to/minchinweb/receipt2bean-turning-my-partners-receipt-photos-into-beancount-with-a-local-vision-model-47p4</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-weekend-2026-10-01"&gt;Hacktoberfest Weekend Challenge: Build for a Friend&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;My business partner keeps the receipt for everything she spends for the business. She photographs them on her phone, and at the end of the year I have to sit down and type them into my books by hand. I keep the books in &lt;a href="https://beancount.github.io/" rel="noopener noreferrer"&gt;Beancount&lt;/a&gt;, a plain-text accounting system. She has been clear that she will not learn plain-text accounting.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;receipt2bean&lt;/strong&gt;. She sends me the photos. A script reads each photo with a local vision model and writes a ready-to-review Beancount transaction, with the GST split out. I check the entries; she never has to see Beancount.&lt;/p&gt;

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

&lt;p&gt;Here is a screen recording of the project in action locally:&lt;/p&gt;

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

&lt;p&gt;Sample output. GST doesn't match 5% of the total due to a tip.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-10-03 ! "CORNER CAFE" ""
  receipt: "03_corner_cafe_tip_photo.jpg"
  receipt-sha: "73f3864916bb"
  scanned-by: "qwen3-vl:8b"
  Expenses:Meals                                 20.50 CAD
  Assets:GST-Receivable                           0.93 CAD
  Liabilities:CreditCard                        -21.43 CAD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

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


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/minchinweb" rel="noopener noreferrer"&gt;
        minchinweb
      &lt;/a&gt; / &lt;a href="https://github.com/minchinweb/hacktoberfest-2026-0" rel="noopener noreferrer"&gt;
        hacktoberfest-2026-0
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Weekend Challenge: Build for a Friend
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;receipt2bean&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;Photograph a receipt, get a &lt;a href="https://beancount.github.io/" rel="nofollow noopener noreferrer"&gt;Beancount&lt;/a&gt;
transaction.&lt;/p&gt;
&lt;p&gt;Built for the Hacktoberfest 2026 Weekend Challenge ("Build for a Friend") for
my business partner, who photographs receipts but doesn't want to learn
plain-text accounting. A local open-weight vision model reads each photo. Plain
Python then checks the arithmetic and writes the entry. &lt;strong&gt;Nothing leaves your
machine.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;State&lt;/strong&gt;: Proof of concept; works with provided sample datea. Much more
customization is likely needed, and I'd like to eventually have a decent test
suite too.&lt;/p&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;receipt photo --&amp;gt; open-weight vision model (Ollama) --&amp;gt; JSON
              --&amp;gt; Python: validate totals + GST, map accounts
              --&amp;gt; staging.bean  (pending "!" entries, reviewed before they join the ledger)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Design choices&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The model reads, code checks.&lt;/strong&gt; The model returns JSON constrained to a
schema. Then Python verifies &lt;code&gt;subtotal + GST = total&lt;/code&gt;, that line items add
up, that GST is plausible (about 5%), and that the date is…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/minchinweb/hacktoberfest-2026-0" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


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

&lt;p&gt;The model is Qwen3-VL 8B, an open-weight vision-language model running locally through &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The pipeline has four steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read.&lt;/strong&gt; The photo goes to the model with a JSON schema (merchant, date, line items, subtotal, GST, total, payment method). Ollama's structured output keeps the reply in that shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check.&lt;/strong&gt; The model reads; plain Python checks. It verifies that subtotal + GST = total, that the line items add up, that the GST is plausible for a 5% tax, and that the date makes sense. If something is off, the entry gets a &lt;code&gt;; REVIEW:&lt;/code&gt; comment instead of a silent "fix".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Categorize.&lt;/strong&gt; A small &lt;code&gt;rules.toml&lt;/code&gt; maps merchant names to my accounts. I deliberately don't let the model invent accounts in my ledger.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stage.&lt;/strong&gt; Entries are written as pending (&lt;code&gt;!&lt;/code&gt;) to a staging file. Each photo's hash is stored, so running the script twice never duplicates anything. The expense posting is computed as total minus GST, so every entry balances.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I tested it on 1 real receipt and 3 synthetic receipts (hard to share real data without giving up the privacy that this whole project is about...). &lt;strong&gt;Results:&lt;/strong&gt; Two were fully correct; two failed due to OCR issues: the real one, from Staples with four individual "sub-invoices" and the "Maple Print" synthetic one that inherited the format (and I ran out of time to fix them).&lt;/p&gt;

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

&lt;p&gt;Receipts are a surprisingly complete record of someone's life: where we shop, what we buy, when we're away, what we pay. For something like that, I didn't want a cloud API in the middle if it can be helped.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Privacy.&lt;/strong&gt; Every photo is processed on my own computer. Nothing is uploaded, and the model sees receipts only while it's reading them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No per-receipt cost or account.&lt;/strong&gt; A year of receipts is hundreds of photos. Running a model locally means no meter running and no API keys for my business partner to worry about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I can see and control the pipeline.&lt;/strong&gt; Because the model is swappable (&lt;code&gt;--model&lt;/code&gt;), I can try a different open-weight one without rewriting anything; hopefully newer, better ones will keep coming out! Because the checks around the model output are ordinary Python, I can test them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failures are fixable.&lt;/strong&gt; Some of the current receipts failed. I can either fix the code once and every future receipt from that vendor should work, or still input them by hand, which is no worse than what I started with.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;A synced phone folder so receipts are scanned as she takes them&lt;/li&gt;
&lt;li&gt;Matching staged entries to my bank-import transactions&lt;/li&gt;
&lt;li&gt;Plugging into my GST tracking so the GST return worksheet builds itself&lt;/li&gt;
&lt;li&gt;Splitting multi-category receipts by line item&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thanks for reading. Next step: see what my business partner thinks of this in the morning!&lt;/p&gt;

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

&lt;p&gt;I'm not sure how to export the full transcript, but these are the three prompts I used to have it built for/with me.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Could we build a receipt scanner that would create beancount transactions?&lt;br&gt;
What open Source [sic] model could we use at the core?&lt;/p&gt;

&lt;p&gt;Let me [sic] friend be my [business partner], who otherwise I have to enter these myself at the end of the year; she doesn't want to deal with beancount.&lt;/p&gt;

&lt;p&gt;Can you create a first pass version of the code, a readme, instructions on how to put together the screen recording, and draft the post about it. Please ask any questions you need addressed to move forward&lt;/p&gt;

&lt;p&gt;Can you generate a sample receipt? You can base it on this if it helps [real sample Staples receipt attached]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Author's Notes
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;N.B. Yes, this is very heavily written (code and documentation and the first draft of this post) using AI, but I wanted to test how well I could (nearly) one-shot this. It helps that the problem is small and well understood by me. The open question is what happens when it breaks (which it already has...): Can the AI fix it? Can I fix it "manually" if the AI fails? But as an experiment, I say it passes.&lt;/em&gt;&lt;/p&gt;

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