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. Sarajevo
  • l10n_ba_bank_pdf_sparkasse — Sparkasse Bank d.d. BiH
  • l10n_ba_bank_pdf_procredit — ProCredit Bank d.d. Sarajevo
  • l10n_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 izvoda198
BankeProCredit (45), Raiffeisen (76), Sparkasse (77)
Failover engagement (Pass 2 aktivirano)54 (27.3%)
Suspicious flagovi5
Balance walk failures1 (Raiffeisen #14, model-level mis-bind)
Nerazriješene greške nakon Pass 20
AUTO-RECOVERED placeholder linije (nakon fix-eva)0
Ukupna potrošnja na OpenRouter-u$0.701.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:

  1. Pass 1 — primarni model: Qwen3-VL-32B (preko OpenRouter-a). Brz, jeftin (≈ $2.40 / 1000 izvoda).
  2. 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 tekstu
  • row_phantom — AI je emitovao red čiji dokument ne postoji u PDF tekstu
  • suspicious — AI je emitovao 16-cifreni broj računa kojeg nema u PDF tekstu
  • amount_snap — AI je pogrešno pročitao iznos
  • snap / 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:

BankaAnchorKolone koje pratimo
ProCreditRbr. (broj reda) + DatumIsplate, Uplate, Provizija (za naknade)
Sparkasse9-cifreni BROJ (lijeva kolona)Duguje, Potražuje
Raiffeisenbroj naloga + datumduguje, potražuje

Cross-check radi u tri faze:

  1. Phase 1 — pari AI redove sa ground-truth po dokument-u (gdje banka ima eksplicitni dokument broj).
  2. 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).
  3. 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 KM1.0×0.18×
Dvostepena (27% failover)~$6.10~10.98 KM2.5×0.45×
Samo Gemini~$13.50~24.30 KM5.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)

BankaIzvodiFailover engagedFailover stopaSuspiciousBalance fail
ProCredit45511.1%00
Raiffeisen762938.2%01¹
Sparkasse772026.0%50
Ukupno1985427.3%51

¹ 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:

Napomena

Generisano od strane Claude 🤖


Ernad Husremović, hernad@bring.out.ba