跳到正文
DEV Community· Vinicius Pereira·· 3 小時前AI 評分55

ghostsubs 用本機 Gemma 4(12B)識別銀行交易商戶,程式逐一核對名稱

Slow once, instant after: Gemma 4 names each bank descriptor one time on your laptop, and code checks every name

AI 導讀

ghostsubs 以本機 Gemma 4(12B)辨認銀行及信用卡 CSV 交易商戶,再由程式核對名稱及判斷重複收費。四份虛構帳單的首輪測試共向模型提問 100 次,耗時 101.78 秒;快取後再跑需 0.57 秒,無模型模式需 0.53 秒。作者指出,測試數據是自行生成,提示詞和規則亦參照模型輸出調整,並非留出基準。

正文

This is a submission for the Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady

What I Built

Once a month, a folder of bank exports turns into one line:

$ ghostsubs line examples/bank-a.csv
7 subscriptions, R$ 267,35/mo (other recurring: R$ 2.659,39/mo). Netflix went up 13% (R$ 39,90 -> R$ 44,90). New: HBO Max (trial R$ 1,99, now R$ 34,90 monthly). No charge from Globoplay since 2026-08-08 (cancelled?).

That one comes from a made-up Brazilian checking account. Every part of it is money that is easy to miss while scrolling.

The HBO Max trial cost R$ 1,99, and the plan it turned into costs R$ 34,90 a month. The question mark after Globoplay is on purpose, because a statement can't tell a cancellation from a failed card.

ghostsubs reads the CSV exports of a bank or card account and finds everything the account pays on repeat. There are no categories to set up and no budget to keep. You drop each export in a folder and read the line.

The hard part sits in one column. Banks print each charge as a descriptor, and the descriptor rarely says "Spotify" cleanly. It says PAYPAL *SPOTIFY 4025550134, EBN*SPOTIFY or SPOTIFY AB STOCKHOLM SE, sometimes all three for the same plan.

Gemma 4 reads each descriptor and says which business it is, and it does nothing else. Everything about time and money is plain code.

Demo

ghostsubs: Gemma 4 names each bank descriptor on your laptop, and code checks every name before the monthly line is written

The four statements in the repo are made up, so their output can be published. Brand names are real, because telling them apart is the job, and they are all national or international brands. Every card number, phone number, landlord, lender, local shop, utility and person is invented.

Part of the report for the US card example, cut where it says ...:

$ ghostsubs report examples/card-b.csv --currency '$'
ghostsubs report: examples/card-b.csv
1483 charges and 27 credits, 2024-08-01 to 2026-10-14, counted from 2026-10-14
read as: dates mdy, decimal '.', charges type column
names: 108 descriptors (44 once codes are masked), 0 asked to gemma4:12b now, 44 from cache; 108 named and checked, 0 UNVERIFIED (cleaned up by code)

13 subscriptions, $191.96/mo (other recurring: $217.40/mo). Spotify went up 8% ($11.99 -> $12.99). Adobe Creative Cloud renews in 9 days: $239.88. New: Audible (trial $0.99, now $14.95 monthly). No charge from Max since 2026-08-15 (cancelled?).

SUBSCRIPTIONS (13, $191.96/mo)
  ...
  YouTube Premium          monthly             $13.99        $13.99/mo  next 2026-10-27 (in 13 days)
      subscription
      named from:
      GOOGLE *YouTubePremium g.co/helppay#  <- "YouTubePremium"
      GOOGLE *YouTube Premium  <- "YouTube Premium"
      YOUTUBEPREMIUM GOOGLE.COM CA  <- "YOUTUBEPREMIUM"
      26 charges: 2024-08-27 $13.99 · 2024-09-27 $13.99 · 2024-10-27 $13.99 · 2024-11-27 $13.99 ...
  ...

YouTube Premium was named from three descriptors, each shown with the exact quote Gemma pointed to. Code merged them into one plan because all three resolved to the same checked name.

Also look at 0 asked to gemma4:12b now, 44 from cache, which is where the slow and steady part shows.

Code

GitHub logo vinimabreu / ghostsubs

Finds recurring charges in bank and card CSV exports. Gemma 4 runs locally to name each descriptor, and code checks every name.

ghostsubs

test Python 3.11+ License: MIT

Drop the CSV exports of a bank or card account into a folder. Once a month you get one line about everything the account pays on repeat:

$ ghostsubs line examples/bank-a.csv
7 subscriptions, R$ 267,35/mo (other recurring: R$ 2.659,39/mo). Netflix went up 13% (R$ 39,90 -> R$ 44,90). New: HBO Max (trial R$ 1,99, now R$ 34,90 monthly). No charge from Globoplay since 2026-08-08 (cancelled?)

The line reports a price that went up, a trial that turned into a paid plan, a yearly renewal that is close, and a service that stopped charging. You don't track anything yourself, so the money you stop leaking costs you no effort.

A bank statement is private, so the model runs on your own laptop: Gemma 4 (12B) through Ollama, offline and free. Nothing leaves the machine unless you add --ntfy (the one line, to the ntfy server you choose) or --allow-remote-model…

Python 3.11 or newer, with no Python dependencies. The names need Ollama and the model:

ollama pull gemma4:12b
pipx install git+https://github.com/vinimabreu/ghostsubs

Without Ollama, --no-model runs on code cleanup alone. The tests replay the answers gemma4:12b gave for the example statements, so they run with no model and no network.

How I Built It

One job for the model

Each new descriptor goes to Gemma 4 12B through Ollama on 127.0.0.1, at temperature 0, with a JSON schema. The answer has three fields: merchant, evidence (the piece of the descriptor that names it) and kind.

The kind is subscription, bill, loan_or_rent or other. It decides which of two totals a charge adds to, and never touches an amount.

Code in recur.py decides which charges repeat and when the next one is due, so Gemma never writes a number that reaches you.

Every name is checked against its descriptor

naming.py keeps a name only when the quote is in the descriptor, character for character. Every word of the name must also be spelled out in that quote. A payment processor in front of the real merchant is refused, so for PAYPAL *SPOTIFY an answer of PayPal fails.

A word can be spelled out glued to others (YouTubePremium) or cut short (DISNEY PLU). Either way it has to keep three quarters of its length and start at the beginning of a word. So APPLE.COM does not give Applebees.

A word can also come from two closed tables in the code. One holds bank abbreviations like MGMT and IMOB, and the other holds five brand abbreviations like AMZN and MSFT.

The rows below come from the measurement file, and they show where the model earns its place:

descriptor code cleanup alone Gemma 4 + check
PADDLE.NET* N8N CLOUD1 Paddle n8n
HLU*HULU 8005550163-U Hlu Hulu U Hulu
MSFT *E0300ABCDE Msft Microsoft
AMZN Mktp US*2K4L19ZX0 Amzn Mktp Amazon Marketplace
SPOTIFY AB STOCKHOLM SE Spotify Ab Spotify

The last row matters because code cleanup gives Spotify for the other two Spotify descriptors and Spotify Ab for this one. So one plan comes out as two, one that "stopped" in May and one that "started" in June.

The check also catches wrong answers, like PAGTO FATURA CARTAO in the example statements, a credit card bill paid from a checking account. PAGTO is Portuguese bank shorthand for "payment".

Gemma read PAG as the PagSeguro processor, and PagSeguro is not spelled out in the quote, so the check refused it. The report shows the cleaned descriptor instead, marked UNVERIFIED.

Slow the first time, instant after

On the four example statements, the first run asked Gemma 100 questions and took 101.78 seconds. The second run took 0.57 seconds, against 0.53 seconds with no model at all.

statement first run questions to the model from the cache no model
bank-a.csv 29.78 s 28 0.11 s 0.08 s
card-b.csv 43.75 s 44 0.30 s 0.30 s
export-c.csv 17.10 s 17 0.08 s 0.08 s
bank-d.csv 11.15 s 11 0.08 s 0.07 s

The first-run times leave out loading the model into memory, because a warm-up call ran before them.

Every answer is cached in ~/.cache/ghostsubs/, one file per model and prompt version, readable only by you. The cache key ignores case, spacing, digits and reference codes. So AMZN Mktp US*2K4L19ZX0 and next month's AMZN Mktp US*RT5Y21QX8 are one question.

That is why card-b has 108 descriptors and only 44 questions. A new month's export only sends the model what the cache hasn't seen, like a merchant you never paid before.

Finding what recurs

Code groups the charges by checked merchant and picks the cadence its median interval falls in.

My first version of these rules reported nothing extra on the four example statements. Then it ran on 40 statements from seeds 1 to 10 of the same generator, which plant the same plans with other dates, amounts, posting delays and decoys.

It reported 17 series nobody had planted, in 9 of the 10 seeds. Fourteen were two similar charges a year apart at a merchant paid often, like rides or groceries, read as a yearly plan.

Two more were runs of equal cash withdrawals read as weekly, and one was an electricity bill. Fifteen of the 17 were still active, so they sat inside the total.

After that I made the rules stricter. A series now has to land within a few days of one fixed calendar, and own at least half the charges under its descriptor. A yearly pair has to agree within 1%, and a weekly plan needs 6 charges.

A second review found real plans those rules had dropped. A plan billed every 4 weeks drifts off any monthly calendar, so it now counts once 5 charges land within 4 days of one fixed spacing. A charge billed twice now sits aside as "charged twice?" instead of breaking the plan around it.

The same 420 planted series, before and after:

10 other seeds previous version this version
series reported 437 420
right schedule, amount and name 370 370
right schedule and amount, other name 50 50
missed 0 0
reported but not planted 17 0

The stricter rules miss a plan billed under the same descriptor as frequent purchases at other amounts, because that series doesn't own its descriptor. They also count a weekly plan only once it has 6 charges.

Measured

Everything below comes from runs of scripts/measure.py and scripts/measure_seeds.py on 19 October 2026. They ran gemma4:12b (digest 6114515d63c1) on an Apple Silicon laptop, and the raw JSON with every answer is in the repo.

Names, on 159 made-up descriptors, three runs with no cache:

Gemma 4 + check code cleanup only
name shown is right 148 of 159 99 of 159
passed the check, right but cut short 2
passed the check and wrong 6
refused by the check 3
kind label right 141

The three runs gave character-identical answers for all 159 descriptors.

End to end, on the 42 recurring series planted in the four example statements:

with Gemma 4 code cleanup only
series reported 42 45
right schedule, amount and name 37 24
right schedule and amount, other name 5 18
missed 0 0
reported but not planted 0 3

Without the model, the schedules are found just as well, since that part is code, but the names split plans in two. The 3 extra series are halves of Netflix, Spotify and YouTube Premium.

Worse, three plans still being paid were reported as stopped. A false "cancelled?" is the mistake that makes someone stop trusting the line. Merging those descriptors is what the model is for.

Where it is weak

These numbers come from my own made-up data, and part of that data shaped the code. I wrote the prompt and the abbreviation table while looking at the model's answers for the example statements.

Those statements hold 71 of the 159 descriptors, and 65 of those names were right, against 83 of the other 88. The seed runs reuse the same descriptors, and I wrote the stricter rules after seeing seed runs like them, so nothing here is a held-out benchmark.

I widened the accepted names twice after seeing model output, only with correct full names I had left out. After a review I also removed accepted names that kept a bank's label, so Aluguel Imob Horizonte ("aluguel" means rent) now counts as wrong.

Six wrong names passed the check, because their words really are in the descriptor. WHOLEFDS MKT came back unexpanded twice, and 99Ride stood in for the 99 app. The check proves a name is grounded in the descriptor, and these six show where that proof stops.

The check costs something too. It refused 3 right answers, like Kindle Unlimited for Kindle Unltd*AB12CD, since Unltd doesn't spell out Unlimited, and the cleaned descriptor shown in their place was wrong each time.

The kind was wrong on 18 of 159, and code can't check it. Five were subscriptions labeled other, including Max from its HELP.MAX.COM descriptor, and ten were bills labeled other.

A wrong kind moves a charge to the other total. In the end-to-end run, 3 of the 37 sat in the wrong one.

Two plans at the same price from the same merchant on the same cadence are not found at all, such as two accounts of one service. Each one's charges look like the same price showing up off the other's schedule.

Why Does Open Innovation Matter?

A statement shows where you shop and when you get paid. It is about the most private file a person has. I don't want it on someone else's server just to find a forgotten trial.

With open weights, the model runs on the laptop that already holds the file. The model client refuses any Ollama host that is not loopback, unless you pass --allow-remote-model. It ignores proxy settings and follows no redirects, so neither can carry a descriptor elsewhere.

A local Ollama can also serve cloud models, which it forwards off the machine. ghostsubs refuses those too, by a name ending in -cloud or :cloud and by the remote_host field Ollama reports for them.

With those defaults, the only thing that can leave the machine is the line, and only when you add --ntfy. It pushes the line to your phone through ntfy, the open-source push service. The server is ntfy.sh, or your own with --ntfy-server.

The line holds merchant names and amounts, and a name can be a person you pay by transfer. So read one line before you turn the push on. Topic names on ntfy.sh are public, which is why the topic should be random.

And --model swaps Gemma for any other local model Ollama serves, while scripts/measure.py --model scores it on the same ground truth.

The Lazy Payoff

The human side of the routine is one export a month, dropped in a folder. watch reads every CSV there, and a row repeated across overlapping exports counts once.

A crontab line runs it on the first day of each month. pipx puts the command in ~/.local/bin, and openssl rand -hex 16 makes a topic name:

0 9 1 * * $HOME/.local/bin/ghostsubs watch $HOME/Statements --ntfy <your-random-topic>

It prints the line only when something new shows up, so a quiet month sends nothing. With too little history, the line says a monthly plan needs 3 charges and asks for more months.

A file in the folder that isn't a statement is skipped and named. When nothing can be read, or Ollama is down, the phone gets "ghostsubs could not run" with the reason, once, so a broken setup doesn't stay silent.

A paid plan can't count as recurring until it has charged three times, so the line warns earlier about trials. It speaks up once a small first charge is followed by a full one. A new shop starts the same way, so this only happens at a merchant Gemma labels a subscription. Here is the same example counted from an earlier day:

$ ghostsubs line examples/bank-a.csv --today 2026-08-20
7 subscriptions, R$ 250,35/mo (other recurring: R$ 2.659,39/mo). Possible trial turned paid: HBO Max (R$ 1,99, then R$ 34,90 on 2026-07-17).

The work it removes is reading statements line by line. The US card example holds 1483 charges and 27 credits over 26 months, and 13 subscriptions are buried in them.

The machine gets to be lazy too, since Gemma is asked about each descriptor once. A month with no new ones costs about what plain code costs. For card-b, the first run took 43.75 seconds and the second took 0.30.

Every run in this post is on made-up statements from the repo, so the payoff I can show is the measured one.

Prize Categories

Best Use of Gemma: Gemma 4 12B, running locally through Ollama, names every descriptor, and code checks each name before it is used. Gemma never writes a number that reaches the user. The repo measures it against code cleanup alone on 159 descriptors and 42 planted series, plus 420 series from ten other seeds.

來源:DEV Community · dev.to