Butterfly Design Logo Butterfly Design Title
← Natrag na blog Flutter • AI • Clean Architecture • Gemini

Kako smo ugradili AI asistenta u ERP sustav: od prompta do produkcije

Butterfly Design • Ivan Rendulić  ·  Svibanj 2026

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:

// ChatController.cs — tri endpointa POST /api/chat // Šalje poruku, vraća AI odgovor + podatke POST /api/chat/dml // Izvršava INSERT/UPDATE/DELETE koji je korisnik potvrdio POST /api/chat/email // Šalje e-mail koji je korisnik potvrdio (opciono s PDF prilogom)

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.

Flutter Desktop ChatBloc · chat_screen.dart
API Layer ChatController (3 endpointa)
Application Layer MediatR · IChatService
ChatService Infrastructure · Gemini + Dapper
↙    ↘
Gemini 2.5 Flash Intent detection + SQL generiranje
SQL Server Read-only viewovi (vw_*)

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.

// Isječak iz system prompta — opis jednog viewa vw_Racuni (računi): IDRacun(int PK), DokumentID(int), StrankaID(int FK), StrankaNaziv(varchar), Broj(int), Godina(int), Tip(char), Datum(datetime), DatumDospijeca(datetime), Iznos(money), IznosRabat(money), IznosPDV(money), IznosUkupno(money), Proknjizen(bit), JeVPRacun(bit), ObracunPretplateID(int), PretplataID(int)

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:

// Odgovor koji LLM mora vratiti — uvijek JSON, bez slobodnog teksta { "answer": "Tvoj odgovor korisniku na hrvatskom", "sql": "SELECT ... (ili null)", "dml_description": "Kratki opis akcije (samo za DML)", "is_email": false, "email_to": null, "email_subject": null, "email_body": null, "is_pdf_report": false, "pdf_title": null, "is_pdf_email": false }

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:

🔍 SELECT upit Automatski se izvršava. Rezultati se vraćaju kao tablica.
✏️ DML akcija INSERT/UPDATE/DELETE. Prikazuje se SQL i opis — čeka potvrdu.
📧 E-mail LLM sastavlja e-mail. UI prikazuje preview — čeka potvrdu.
📄 PDF izvještaj Generira se na klijentu (Flutter) i sprema u Downloads.
📨 PDF + e-mail PDF se generira na klijentu, šalje kao prilog na serveru.
💬 Slobodan odgovor Opće pitanje — LLM odgovara bez SQL-a.

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.

Sigurnosna pravila

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.

private static bool IsSafeSelectQuery(string sql) { var upper = sql.Trim().ToUpperInvariant(); if (!upper.StartsWith("SELECT")) return false; var forbidden = new[] { "INSERT", "UPDATE", "DELETE", "DROP", "TRUNCATE", "EXEC", "MERGE", "OPENROWSET" }; return !forbidden.Any(f => Regex.IsMatch(upper, $@"\b{f}\b")); } private static bool IsSafeDmlQuery(string sql) { var upper = sql.Trim().ToUpperInvariant(); // UPDATE i DELETE bez WHERE — odbijamo if ((upper.StartsWith("UPDATE") || upper.StartsWith("DELETE")) && !upper.Contains("WHERE")) return false; // ... provjera forbidden keyword-a }

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.

1 LLM generira SQL ili e-mail sadržaj, ovisno o intendu
2 UI prikazuje Korisnik vidi što će se točno izvršiti
3 Korisnik potvrđuje Klikom na "Izvrši" / "Pošalji"
4 Backend izvršava Drugi endpoint: /chat/dml ili /chat/email

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:

// ChatBloc._onSendMessage — history enrichment final history = state.messages.map((m) { if (m.hasData) { final header = m.columns!.join(' | '); final rowsText = m.rows!.take(50) .map((r) => r.map((c) => c?.toString() ?? '').join(' | ')) .join('\n'); return { 'role': m.role, 'content': '${m.content}\n\n[Rezultati upita:\n$header\n$rowsText]', }; } return {'role': m.role, 'content': m.content}; }).toList();

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.

// ChatBloc — PDF se generira na klijentu, bytes idu na server za email final pdfBytes = await _buildPdfBytes( title: msg.pdfTitle ?? 'Izvještaj', columns: dataMsg.columns!, rows: dataMsg.rows!, ); final base64Pdf = base64Encode(pdfBytes); final data = await AdremaApi.postMap('chat/email', { 'to': msg.emailTo ?? '', 'subject': msg.emailSubject ?? msg.pdfTitle ?? 'Izvještaj', 'body': msg.emailBody ?? 'U prilogu se nalazi traženi izvještaj.', 'attachmentBase64': base64Pdf, 'attachmentFilename': filename, });

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.

Cijeli sustav — od korisnikovog pitanja na prirodnom jeziku do podataka u tablici, PDF-a ili poslanog e-maila — radi bez da korisnik ikada vidi SQL ili razumije pozadinsku strukturu baze.

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 →