Bosanska lokalizacija "Odoo" open-source platforme, demonstracija AI/OCR procesiranja pdf izvoda l10n_ba_bank_pdf (sparkasse, raiffeisen, procredit)
Uvod
Modul l10n_ba_bank_pdf je bosanska lokalizacija Odoo platforme (verzija 16.0) za automatsko procesiranje PDF izvoda poslovnih banaka u BiH. Statement stiže kao e-mail prilog, fetchmail server ga pokupi, OCR (LLM nad PDF-om) izvuče stavke, parser ih provjeri protiv tekstualnog sloja PDF-a i kreira account.bank.statement sa svim transakcijama.
Trenutno podržane banke (svaka sa svojim addon submodulom):
l10n_ba_bank_pdf_raiffeisen— Raiffeisen Bank d.d. Sarajevol10n_ba_bank_pdf_sparkasse— Sparkasse Bank d.d. BiHl10n_ba_bank_pdf_procredit— ProCredit Bank d.d. Sarajevol10n_ba_bank_pdf_pbs— Privredna banka Sarajevo
Source: github.com/bringout/odoo-bringout-l10n_ba_bank_pdf
Video demonstracija
Rekapitulacija (anonimizovani test, april 2026)
Tabela sumira ono što je dokumentovano u nastavku posta — performanse pipeline-a kroz sve banke, zajedno sa stvarnom potrošnjom na OpenRouter-u mjereno iz Credits dashboard-a:
| Metrika | USD | BAM (KM) |
|---|---|---|
| Ukupno obrađenih izvoda | 198 | |
| Banke | ProCredit (45), Raiffeisen (76), Sparkasse (77) | |
| Failover engagement (Pass 2 aktivirano) | 54 (27.3%) | |
| Suspicious flagovi | 5 | |
| Balance walk failures | 1 (Raiffeisen #14, model-level mis-bind) | |
| Nerazriješene greške nakon Pass 2 | 0 | |
| AUTO-RECOVERED placeholder linije (nakon fix-eva) | 0 | |
| Ukupna potrošnja na OpenRouter-u | $0.70 | 1.26 KM |
| Cijena po izvodu (stvarna) | ~$0.0035 | ~0.0064 KM |
| Cijena na 1000 izvoda (stvarna) | ~$3.50 | ~6.30 KM |
Konverzija: 1 USD ≈ 1.80 BAM (KM). BAM je vezana za euro kursom 1 EUR = 1.95583 KM.
Slika iznad: snapshot OpenRouter Credits dashboard-a — start $11.60, kraj $10.91, ukupna potrošnja $0.70 (1.26 KM) za 198 izvoda kroz oba modela (Qwen3-VL-32B primarni + Gemini 3 Flash failover na 27% poziva).
Dvostepena OCR validacija
Svaki PDF prolazi kroz dva nezavisna prolaza:
- Pass 1 — primarni model:
Qwen3-VL-32B(preko OpenRouter-a). Brz, jeftin (≈ $2.40 / 1000 izvoda). - Pass 2 — failover model:
Gemini 3 Flash. Skuplji (≈ $13.50 / 1000 izvoda) ali bolji na gustijim layoutima i složenim tabelama.
Kada se aktivira failover? Parser nakon Pass 1 pokreće determinističku kontrolu (po banci) koja upoređuje AI-ev JSON izlaz sa “ground truth” redovima izvučenim direktno iz tekstualnog sloja PDF-a (fitz / PyMuPDF). Ako kontrola flaguje:
row_missing— AI je propustio red koji je vidljiv u PDF teksturow_phantom— AI je emitovao red čijidokumentne postoji u PDF tekstususpicious— AI je emitovao 16-cifreni broj računa kojeg nema u PDF tekstuamount_snap— AI je pogrešno pročitao iznossnap/snap_long— AI je propustio cifru u broju računa
→ Pass 2 se aktivira automatski, fallback model dobije isti PDF. Rezultat fallback-a zamjenjuje primarni rezultat ako je čišći.
Deterministička kontrola po banci
Svaka banka ima svoj _extract_ground_truth_rows metod koji koristi prostorne koordinate riječi iz PDF-a (a ne flat regex nad tekstom — kolona-po-kolona reading order ne radi za regex). Anchori po banci:
| Banka | Anchor | Kolone koje pratimo |
|---|---|---|
| ProCredit | Rbr. (broj reda) + Datum | Isplate, Uplate, Provizija (za naknade) |
| Sparkasse | 9-cifreni BROJ (lijeva kolona) | Duguje, Potražuje |
| Raiffeisen | broj naloga + datum | duguje, potražuje |
Cross-check radi u tri faze:
- Phase 1 — pari AI redove sa ground-truth po
dokument-u (gdje banka ima eksplicitni dokument broj). - Phase 2 — za AI redove bez dokumenta, pari po iznosu. Ako AI emituje N redova sa istim iznosom i PDF ima N takvih redova, sve se konzumiraju (čak i ako je vezivanje dvosmisleno — broj se slaže).
- Phase 3 — preostali ground-truth redovi (ako ih ima) tretiraju se kao
row_missing→ AUTO-RECOVERED placeholder se ubacuje u izvod sa korektnim datumom i iznosom; partner i opis se popunjavaju ručno.
Audit trail u chatter-u
Operater koji otvori uvezeni izvod u Odoo-u vidi cijeli tok obrade u chatter-u, bez potrebe da gleda u logove servera:
Pass 1 — Primary model Qwen3-VL-32B: confidence 95%, 7 transactions, repair tags: confirmed, row_missing.
Primary model Qwen3-VL-32B failed: primary triggered cross-check repairs: confirmed, row_missing. Re-running extraction with fallback model Gemini 3 Flash (preview).
Pass 2 — Fallback model Gemini 3 Flash: confidence 98%, 7 transactions, repair tags: row_missing.
OCR fallback succeeded with parser auto-corrections: row_missing. Replacing primary result.
AUTO-RECOVERED stavke (1) — sintetisane iz teksta PDF-a jer ih AI nije emitovao u JSON odgovoru. Saldo je očuvan, ali partner i opis fale.
- dokument=
128202938, datum 2026-02-10, iznos 150.01 — AI parser je ispustio ovu stavku; popunjena je iz tekstualnog sloja PDF-a (partner i opis su nepoznati, potrebno je ručno dopuniti).
Cijena na OpenRouter-u
Cijene po milion tokena (snapshot 2026-04-29):
| Qwen3-VL-32B (primary) | Gemini 3 Flash (failover) | |
|---|---|---|
| Input / 1M tok | $0.104 (0.187 KM) | $0.50 (0.90 KM) |
| Output / 1M tok | $0.416 (0.749 KM) | $3.00 (5.40 KM) |
| Po pozivu (≈ 15K in + 2K out) | ~$0.0024 (0.0043 KM) | ~$0.0135 (0.0243 KM) |
| Po 1000 izvoda | ~$2.40 (4.32 KM) | ~$13.50 (24.30 KM) |
Gemini je ~5.6× skuplji po pozivu, ali se aktivira selektivno samo kada deterministička kontrola flaguje problem. Stvarna blended cijena pri ~27% failover stope:
| Strategija | Cijena / 1000 izvoda (USD) | Cijena / 1000 izvoda (BAM / KM) | vs Qwen | vs Gemini |
|---|---|---|---|---|
| Samo Qwen | ~$2.40 | ~4.32 KM | 1.0× | 0.18× |
| Dvostepena (27% failover) | ~$6.10 | ~10.98 KM | 2.5× | 0.45× |
| Samo Gemini | ~$13.50 | ~24.30 KM | 5.6× | 1.0× |
→ 2-pass dizajn plaća 2.5× premium nad samo-Qwen za korak-naviše u kvalitetu, ali ostaje 2.2× jeftiniji od samo-Gemini.
Rezultati testa (anonimizovani dataset, 198 izvoda)
| Banka | Izvodi | Failover engaged | Failover stopa | Suspicious | Balance fail |
|---|---|---|---|---|---|
| ProCredit | 45 | 5 | 11.1% | 0 | 0 |
| Raiffeisen | 76 | 29 | 38.2% | 0 | 1¹ |
| Sparkasse | 77 | 20 | 26.0% | 5 | 0 |
| Ukupno | 198 | 54 | 27.3% | 5 | 1 |
¹ Raiffeisen #14 ima dva susjedna reda istog datuma (26.01.2026) sa iznosima +4445.01 i −5850.68 koje oba modela vežu za pogrešne redove. Saldo je očuvan (zbir +5−4 = +5 tačan), ali bind-vezivanje opisa za iznos je krivo. Cross-check ne hvata ovu klasu jer su oba iznosa prisutna u PDF tekstu — samo na pogrešnim redovima. Operater mora ručno zamijeniti dva iznosa.
Nedavni fix-ovi (april 2026)
Phase-2 duplikat-iznosa matcher
Stari matcher je odbijao da pari kad više ground-truth redova dijeli isti iznos (if len(hits) == 1). Sparkasse #24 ima dva reda 150.01 istog datuma — matcher je propuštao oba u Phase-3, pa su se kreirale dvije lažne [AUTO-RECOVERED] linije iako je AI ispravno emitovao oba reda. Novi matcher računa po amount-bucket-u: ako AI ima N redova sa iznosom X i PDF ima N takvih redova, svi se konzumiraju.
ProCredit Naknada / Provizija kolona
ProCredit “Naknada za održavanje racuna” redovi nemaju iznos u Isplate ili Uplate kolonama — naknada je samo u Provizija koloni. Stari extractor je tu kolonu eksplicitno odbacivao (# probably the Provizija column or further; not part of the signed amount), pa je takav red imao amount=0 u ground truth-u → Phase-3 je sintetisao lažni AUTO-RECOVERED red. Novi extractor čuva i Provizija kolonu i koristi je kao fallback iznos kad su Isplate i Uplate prazni.
Watchdog na LLM pozivu
Ako OpenRouter pošalje TCP keepalive pakete tokom dugačkog HTTP read-a, Python requests ne diže timeout — poziv može visiti satima. Dodali smo wall-clock watchdog preko concurrent.futures.ThreadPoolExecutor koji garantuje da nijedan poziv ne traje duže od read_timeout + 60s. Nakon timeout-a, placeholder se preimenuje u FAILED-* i operater može jednim klikom pokrenuti reprocess.
XMLRPC socket timeout
Test pipeline koristi XMLRPC nad HTTPS-om za polling. Bez explicit socket timeout-a, jedan zaglavljeni _sslobj.read zaključa cijeli poll-loop neovisno od wait_timeout parametra. Dodali smo _TimeoutSafeTransport (60s default) tako da loop može preskočiti zaglavljen poziv i nastaviti.
Audit chatter
Sve poruke o tome šta se desilo (Pass 1 ishod, Pass 2 ishod, koje su stavke AUTO-RECOVERED, koja je pokrenula failover) sada se ispisuju u chatter izvoda. Operater ne mora više da zaviruje u logove servera — sav tok je u Odoo UI-u.
Github
Sve pomenute popravke su pushane:
bringout/odoo-bringout-l10n_ba_bank_pdf55c6464— Phase-2 dup-amount + Pass-1/Pass-2 chatter + AUTO-RECOVERED auditbringout/odoo-bringout-l10n_ba_bank_pdf_procreditd0c601b— Provizija kolona za Naknada redove
Napomena
Generisano od strane Claude 🤖
Ernad Husremović, hernad@bring.out.ba