跳到正文
DEV Community· Tanmay·· 5 小時前AI 評分57

LedgerLocal 以 Gemma 4 本機讀取及校驗印度自由工作者發票

LedgerLocal: a private invoice reader for Indian freelancers, running on Gemma 4

AI 導讀

LedgerLocal 是面向印度自由工作者的開源發票讀取工具,使用 Gemma 4 在本機擷取發票資料並匯出 CSV,資料不會上傳。Python 會核對欄位是否出現在票據文字中,並檢查 GSTIN 格式及税額計算;未通過的資料會標記覆核。在 49 份公開文件測試中,29 份完全正確、20 份標記覆核,沒有錯誤未被標記。

正文

This is a submission for the Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle

What I Built

In India, freelancers and small businesses who want to claim GST input tax credit have to type every bill into a spreadsheet: vendor name, GSTIN, tax and total, one bill at a time. Those bills arrive as WhatsApp photos, PDFs in email, and pages torn from handwritten bill books. I wanted a tool that does the typing for me, without uploading private bills to someone else's server.

LedgerLocal is a small web app that runs on your own laptop. You drop in photos, screenshots or PDFs of invoices, Google's open-weight Gemma 4 model reads them, and you get a clean table you can export as CSV. Nothing is uploaded anywhere.

The part I care most about is that it does not blindly trust the model. After Gemma returns the fields, plain Python checks them:

  • every value must actually appear in the text read from the bill; anything the model made up is removed
  • a vendor name that only appears under "Bill to" or "Ship to" is flagged as possibly the customer
  • the GSTIN must match the official format and its check digit
  • subtotal plus CGST, SGST and IGST must equal the total, within a rupee of rounding
  • CGST must equal SGST, and IGST should not appear alongside them
  • missing vendors, dates or totals, and dates in the future, are flagged

Rows that fail a check are marked Needs a look with the reason, so you review three rows instead of thirty. You can fix any cell inline before exporting.

Who would pay for it: freelancers and small agencies who file GST, and the accountants who process their receipts every month. The code is free and open source under MIT, and anyone technical can run it today. The side hustle is for everyone else: a one-click Mac app that bundles the model and needs no Terminal, sold once for about Rs. 999. Because there is no per-invoice API cost, a one-time price works instead of yet another subscription.

Demo

The repo includes two sample invoices. The cowork invoice has two deliberate mistakes: the total is Rs. 100 too high and the GSTIN check digit is wrong. Here is what happens when I drop both onto the page:

LedgerLocal after reading two invoices: one marked Looks right, one marked Needs a look

The hosting invoice passes every check. The cowork invoice is flagged, and the reasons are spelled out:

Close-up of the review flags: GSTIN check digit does not match, and subtotal plus tax is 8732.00 but the total says 8832.00

Each read took between 5 and 6 seconds on my Mac, with the model running locally.

Code

GitHub logo tanmayhutt / ledgerlocal

Private invoice reader for Indian freelancers. Gemma 4 runs locally, Python checks GSTIN and tax maths.

LedgerLocal

A private invoice reader for Indian freelancers and small businesses. Drop in receipt photos, screenshots or PDF invoices. Your Mac reads the text, Google's open-weight Gemma 4 model running locally in llama.cpp pulls out the fields, and LedgerLocal checks the results before you trust them:

  • GSTIN format and check digit
  • Subtotal + CGST + SGST + IGST = total (within one rupee of rounding)
  • CGST equals SGST, and IGST is not mixed with them
  • Tax lines are classified from their printed label in code, not by the model
  • Every value must appear in the text read from the bill; guesses are removed and listed
  • A vendor that only appears under "Bill to", "Ship to" or similar is flagged as possibly the customer
  • Missing vendor, date or total, and dates in the future

LedgerLocal after reading two invoices

You review the flagged rows, fix any cell inline, and export a CSV for your accountant or…

No pip install. The server uses only the Python standard library, the page is plain HTML, CSS and JavaScript, and the model runs in llama.cpp:

brew install llama.cpp poppler
LEDGERLOCAL_GGUF=/path/to/gemma-4-E2B.gguf python3 -m ledgerlocal

How I Built It

The pipeline has four small steps, all on the laptop:

  1. Read the text. Text PDFs are read exactly with pdftotext. Photos and scanned PDFs go through Apple's on-device Vision OCR, using a 20-line Swift helper that the app compiles once.
  2. Understand it with Gemma 4. The text goes to Gemma 4 E2B (4-bit GGUF, about 3.5 GB) running in llama.cpp's llama-server, which the app starts and stops by itself. Each request passes a JSON schema, at temperature 0, so the model must answer with exactly the fields I need.
  3. Check it in code. GSTIN checksum (mod 36), Indian number formats like Rs. 1,23,456.50, a dozen date formats, and the tax arithmetic are all plain Python with unit tests.
  4. Review and export. A plain HTML page shows the table, highlights flagged rows, lets you edit cells and exports a UTF-8 CSV that opens cleanly in Excel.

The prompt also tells the model that the invoice text is a document to read, not instructions to follow, so a malicious "ignore previous instructions" line on a receipt does nothing.

What surprised me: on my first run, Gemma 4 E2B read a hosting invoice perfectly except for one thing. The invoice charged 18% IGST, and the model put that amount in the CGST field, because CGST came first in my schema. My own tax check caught it immediately ("CGST and SGST should be equal"), which was exactly the point of having checks. Instead of fighting the prompt, I changed the design: the model now copies each tax line exactly as printed ("IGST @ 18%", 540.00), and Python decides whether it is CGST, SGST or IGST from the label. A 2B model is very good at copying and less good at categorising, so I let each side do what it is good at.

The bigger lesson came from a real bill. I tried a handwritten bill book from a clothing shop: crossed-out lines, scribbles at the top, no printed shop name, no GSTIN, no date. The OCR could only read parts of it. Gemma did not say "I don't know". It filled every field anyway, with a made-up GSTIN, a 2023 date and tax amounts that are nowhere on the bill. My maths checks could not catch that, because invented numbers can still add up.

So I added the check I now think matters most: the model only ever sees the bill's text, so every value it returns must be findable in that text. Amounts are matched as numbers (so 3,540.00 matches 3540), codes are matched ignoring spaces and punctuation, and dates are matched in several formats. Anything not found is removed and listed. On that handwritten bill, LedgerLocal now says "Could not read this bill reliably. Enter it by hand" instead of producing a confident, wrong row. On the printed invoices, nothing changed. A tool for tax records should rather say "I can't read this" than guess.

I also tried a WhatsApp photo of a shop bill. The first version broke on it: the model kept repeating the same tax line until it ran out of room, and the answer came back unreadable. I capped the number of tax lines and made the code check each one: it must have a tax word like GST or VAT, and both the word and the amount must be on the bill. Now that bill loads, but the handwritten numbers are hard enough that the text reader saw the total 9,222 as 92,221. LedgerLocal marks the row "Needs a look" instead of trusting it, which is the behaviour I want.

Then I measured it properly. I collected 49 public documents that come with answer keys: real invoices from the invoice2data project (including Flipkart and OYO GST invoices), 12 photographed shop receipts from the ICDAR SROIE dataset, and 22 hard cases from an invoice benchmark: phone photos, coffee stains, fax-quality scans, handwritten bill books, tax-inclusive totals.

The first run was humbling: 14 of 49 fully correct. But every wrong answer was flagged, so nothing slipped through silently. The failures had boring, fixable causes:

  • My own prompt said for example "CGST @ 9%", so on European VAT invoices the model wrote "CGST @ 9%". The "must be on the bill" check removed it every time. My example was the bug.
  • R 12 843,69 and 56,02 (space thousands, decimal comma) were read as the wrong numbers.
  • Dates like 28/03/2018 10:36:13, 2026/03/14, 7. Mai 2014 and US-style 03/20/2023 were not parsed.

After fixing those: 29 of 49 fully correct, 20 flagged for review, 0 wrong-and-unflagged. Along the way the second round produced 2 silent errors, where the model picked the customer as the vendor (a Dutch "Factuuradres" block and a German window-envelope layout). I added one more check: if the vendor name only appears under "Bill to", "Ship to", "M/s", "Factuuradres" and similar labels, the row is flagged. Back to zero.

First run Final
Fully correct 14 / 49 29 / 49
Wrong and not flagged 0 0
Dates right 32 / 49 42 / 49
Totals right 34 / 47 41 / 47
Tax right 6 / 22 18 / 22

The evaluation script and the list of documents are in the repo, so anyone can rerun it.

Why Does Open Innovation Matter?

Invoices carry client names, tax IDs, bank details and amounts. With a closed API, I would be sending all of that to someone else's servers and paying for every page. An open-weight model changes the product:

  • Privacy I can actually promise. The data never leaves the laptop, which is easy to explain to a client or an accountant.
  • Zero cost per invoice. That is what makes a cheap one-time price possible for a solo freelancer.
  • Works offline. On a train, in a cafe with bad Wi-Fi, anywhere.
  • No lock-in. I can swap the GGUF for a bigger Gemma 4, or fine-tune one on Indian invoice layouts later, without waiting on a vendor or a price change.

It also runs fast enough to be practical: about 5 to 10 seconds per invoice on my Mac.

Prize Categories

  • Best Use of Gemma: LedgerLocal is built around Gemma 4 running locally in llama.cpp.

來源:DEV Community · dev.to