Kako smo ugradili AI asistenta u ERP sustav: od prompta do produkcije
U prethodnom blogu pisali smo o tome kako je čist API preduvjet za svaki agentic sustav. Ovaj put nećemo pisati o teoriji — pokazat ćemo što smo konkretno izgradili. U ERP sustav Adrema, koji smo razvili od nule na Flutter + Clean Architecture + ASP.NET Core, ugradili smo AI asistenta koji korisnicima omogućuje postavljanje pitanja prirodnim jezikom, dobivanje podataka direktno iz baze, i izvršavanje akcija poput slanja e-maila ili generiranja PDF-a — sve iz jednog sučelja u chat stilu.
Ovaj tekst je tehnički walkthrough. Prikazujemo stvarni kod, stvarne odluke i razloge iza njih.
Arhitektura: tri endpointa, jedan tok
Cijela AI funkcionalnost u backendu izložena je kroz tri endpointa unutar ChatController-a, koji slijedi Clean Architecture pattern koji smo koristili za ostatak sustava:
Svaka radnja prolazi kroz MediatR i dolazi do ChatService-a u Infrastructure sloju. Sâm tok je asimetričan: dohvat podataka je automatski, dok svaka destruktivna akcija čeka eksplicitnu potvrdu korisnika. O tome više u sekciji o sigurnosti.
System prompt: baza znanja za LLM
Srce cijelog sustava nije AI model — to je system prompt. U njemu smo opisali kompletnu shemu baze podataka kroz 20+ SQL Server viewova, s tipovima kolona i stranim ključevima, te skup pravila ponašanja. LLM nema pristup baznim tablicama — samo viewovima koje smo za to namjenski izradili.
Svaki view dolazi s opisom na hrvatskom i popisom svih kolona s tipovima — dovoljno informacija da LLM može generirati ispravan SQL bez pogađanja. Uz opis sheme, prompt sadrži i pravila: uvijek odgovaraj na hrvatskom, koristi TOP 100 ako korisnik ne navede ograničenje, za JOIN-ove koristi FK kolone iz sheme, za UPDATE i DELETE uvijek koristi WHERE s primarnim ključem.
Na kraju prompta stoji najvažniji dio — format odgovora. LLM ne smije vratiti slobodni tekst. Mora vratiti strogo definirani JSON:
Ovaj pristup — structured output iz LLM-a — eliminira problem parsiranja prirodnog teksta i čini backend deterministički predvidljivim. Polje is_pdf_email: true jednoznačno znači jednu stvar, bez interpretacije.
Intent detection: sedam načina da odgovorimo
Na temelju tog JSON-a, ChatService prepoznaje sedam različitih intenta i za svaki ima drugačiji tok:
Sigurnosni sloj: zašto viewovi, a ne tablice
Ovo je bila jedna od prvih arhitekturalnih odluka. LLM-u nikad nismo dali pristup baznim tablicama — samo namjenskim SQL Server viewovima (vw_*). Razlog je jednostavan: viewovi su read-only kontrakti koje kontroliramo mi, ne LLM.
Za SELECT upite: dopuštamo samo ako upit počinje s SELECT i ne sadrži niti jedan od zabranjenih keyword-a (INSERT, UPDATE, DELETE, DROP, TRUNCATE, EXEC, MERGE, OPENROWSET…). Za DML: UPDATE i DELETE moraju imati WHERE uvjet — bez njega se odbijaju. Svi DML upiti prikazuju se korisniku na pregled prije izvršavanja.
Uz to, backend automatski ubacuje TOP 1000 u svaki SELECT koji ne specificira ograničenje — LLM ponekad generira upite bez limita, a mi ne želimo da korisnik nehotice dohvati milijun redaka.
Dvofazni flow: predloži, pa izvrši
Razlika između SELECT-a i svega ostalog je u tome što SELECT nikad ne mijenja stanje sustava. Zato ga izvršavamo odmah. Sve ostalo — DML, e-mail, PDF — prolazi kroz dvofazni flow: backend vraća prijedlog, frontend ga prikaže korisniku, korisnik potvrđuje, tek onda se izvršava.
U Flutter implementaciji, svaka poruka u chatu nosi flag koji određuje koji widget se prikazuje uz mjehurić: _DmlConfirmWidget, _EmailConfirmWidget, _PdfReportWidget ili _PdfEmailWidget. ChatBloc upravlja stanjem svake poruke zasebno, po indeksu — tako je moguće da korisnik ima više nepotvrđenih akcija u chatu i potvrdi ih bilo kojim redoslijedom.
Flutter ChatBloc: kontekst koji pamti tablice
Jedna od zanimljivijih tehničkih odluka bila je kako prenijeti kontekst razgovora. Standardni pristup — slanje historije poruka — ne funkcionira dobro kada je AI prethodno prikazao tablicu s podacima i korisnik želi referirati te podatke u sljedećem upitu (npr. "pošalji e-mail na adresu iz prošlih rezultata").
Riješili smo to tako da ChatBloc obogaćuje historiju asistentovih poruka koje sadrže tabličare podatke — uključujemo prvih 50 redaka tablice kao tekst, zajedno s imenima kolona:
Zahvaljujući tome, korisnik može reći "zapakiraj mi ovo u PDF i pošalji na ivan.rendulic@gmail.com" — a LLM zna i koja e-mail adresa je bila u prethodnoj tablici, i koji naslov treba staviti na PDF.
PDF generiranje: na klijentu, ne na serveru
PDF izvještaji generiraju se na Flutter klijentu koristeći pdf paket. Ovo nije bila podrazumijevana odluka, ali ima smisla: podaci su već na klijentu (u ChatBloc stanju), server ne mora znati kako formatirati PDF, a klijent ima direktan pristup Windows font datotekama za ispravno renderiranje dijakritičkih znakova.
Server prima base64-enkodiran PDF i prosljeđuje ga kao prilog e-maila putem MailKit + SMTP. Cijeli tok — od korisnikovog zahtjeva do poslanog e-maila s PDF prilogom — odvija se u jednom razgovoru, bez da korisnik napusti chat sučelje.
Interaktivna tablica: sortiranje, grafovi, Excel
Svaki SELECT rezultat u chatu ne prikazuje se kao sirovi tekst — korisnik dobiva interaktivnu tablicu unutar mjehurića. Tablični prikaz (_ChatDataTable) podržava sortiranje klikom na zaglavlje stupca, promjenu širine stupaca povlačenjem, i horizontalni/vertikalni scroll za veće skupove podataka.
Za numeričke skupove podataka dostupan je i grafički prikaz: stupčasti, linijski ili tortni graf s konfiguracijom X i Y osi. Korisnik može prebacivati između tablice i grafa jednim klikom. Uz to, svaki rezultat može se jednim klikom eksportirati u Excel datoteku (excel paket) koja se sprema direktno u Downloads.
Bonus: SUDREG integracija
Jedna od manjih, ali korisnih značajki — direktna integracija s Sudskim registrom RH. Korisnik može utipkati /sudreg 12345678901 i dobiti podatke o tvrtki (naziv, MBS, OIB, adresa, e-mail) direktno iz javnog SUDREG API-ja, u istom chat sučelju. Rezultati se prikazuju kao standardna tablica, a podaci mogu odmah biti iskorišteni za daljnje upite ili akcije.
Zašto je Clean Architecture ovdje bila ključna
Kad smo dodavali AI asistenta, nismo dirali domenski sloj ni Application layer — samo smo dodali novi IChatService interfejs u Application i implementirali ga u Infrastrukturi. Controller je ostao tanak. MediatR query handleri su delegirali. Svaki dio sustava je ostao na svom mjestu.
U sustavu koji je bio nastao "organskim rastom" bez jasne arhitekture, dodavanje AI asistenta bi vjerojatno zahtijevalo prerađivanje velikog dijela koda. Ovdje smo ga dodali kao novu feature, ne kao refaktor. Arhitektura koja je bila dobra za integracije, bila je dobra i za AI.
Što bismo drugačije
LLM povremeno generira SQL koji prolazi sigurnosne provjere, ali vraća neočekivane rezultate zbog previše širokog upita. Sljedeći korak je uvođenje query plan provjere prije izvršavanja — ako procijenjeni trošak upita prelazi prag, korisnika pitamo za potvrdu.
Drugi plan je streaming odgovora: trenutno korisnik čeka cijeli odgovor prije nego što vidi išta. Gemini podržava streaming, a Flutter i flutter_bloc mogu elegantno renderirati parcijalne poruke — to je logičan sljedeći korak za bolji UX.
Razmišljate o ugradnji AI asistenta u vlastiti poslovni sustav? Ili tek kreće modernizacija legacy-ja i zanima vas kako izgraditi temelje koji AI mogu koristiti?
Razgovarajmo →