An LLM agent that investigates a flagged fraud case, decides a disposition, and — before any irreversible action — hands the decision to a human. Built with Spring Boot and Spring AI, running 100% locally and for free on Ollama with a small open model. No API key, no cloud, no cost.
The domain (fraud triage) is just a vehicle. What it teaches transfers to any agent: using an LLM with tools, turning its output into a structured decision, and putting a guardrail on a dangerous action.
Given a case like "Customer C-9: 8× ~$1,200 gift cards to RU in 5 minutes, card-not-present, 2-week-old account", the service:
- investigates — a mandatory watchlist/sanctions screen runs in code for every case, then the model calls read tools (customer history, recent transactions, velocity, geo risk) and Spring AI runs the tool-call loop; every result is recorded as ground-truth evidence;
- decides — a structured
Disposition(approve/monitor/block) is produced via Spring AI's typed output — reliable, not free text; - gates the block — if the disposition is
block, your code calls a humanApprover, and the account is blocked only on approval.
flowchart TB
CASE["Flagged case"]:::io --> INV
subgraph INV["1 · investigate (Spring AI ChatClient + tools)"]
direction TB
W["mandatory watchlist screen<br/>(code — runs every case)"]:::code
A["LLM · qwen2.5:3b"]:::llm
T["@Tool methods: history · recent txns · velocity · geo"]:::code
A -->|tool calls| T
T -->|results recorded as Evidence| A
end
INV -->|evidence| DEC["2 · decide<br/>structured Disposition<br/>approve · monitor · block"]:::llm
DEC -->|approve / monitor| DONE["disposition returned"]:::io
DEC -->|block| GATE{"3 · human approval gate<br/>(your code calls the Approver)"}:::gate
GATE -->|approve| BLOCK["ACCOUNT BLOCKED"]:::danger
GATE -->|deny| ACTIVE["block denied · account active"]:::io
classDef io fill:#16202b,stroke:#16202b,color:#ffffff;
classDef llm fill:#0f766e,stroke:#0b5c56,color:#ffffff;
classDef code fill:#334155,stroke:#1e293b,color:#ffffff;
classDef gate fill:#ffffff,stroke:#94a3b8,color:#334155;
classDef danger fill:#b91c1c,stroke:#7f1d1d,color:#ffffff;
The agent does not get to call the block itself. A small model can't be trusted
to reliably invoke an irreversible action — it will write "block" in prose without
actually doing anything. So the model only produces a recommendation (the
structured Disposition), and the code enforces the human gate off that
decision. Same case, human approves → account blocked; human denies → account stays
active — deterministically, every time. That's the robust production pattern, and
it's the heart of this project.
- JDK 17+ (built and tested on JDK 17 bytecode; verified running on JDK 25).
- Maven 3.9+ (or use your IDE's bundled Maven).
- Ollama running locally with the model pulled.
- A GPU is optional — everything works on CPU (slower; a full investigation is several model calls).
# 1. Install Ollama (https://ollama.com/download), then start it:
ollama serve
# 2. Pull the model (one time):
ollama pull qwen2.5:3bFor stronger multi-step tool use, pull qwen2.5:7b and set the model in
src/main/resources/application.properties.
# The demo — one case, human approves vs. denies:
mvn -q -DskipTests package
java -jar target/fraud-investigation-agent-0.1.0.jar
# (or: mvn spring-boot:run)
# The smoke test — real calls; auto-skips if Ollama isn't running:
mvn testExpected demo output:
[HUMAN APPROVES]
|| APPROVAL REQUIRED: block_account(C-9) — ...watchlist MATCH... geo_risk 85/100...
|| human decision: APPROVE
disposition = block (...)
blocked = true
outcome = ACCOUNT BLOCKED for C-9 (human-approved). ...
[HUMAN DENIES]
...
blocked = false
outcome = BLOCK DENIED by human reviewer for C-9; account remains active.
| Concept | Spring AI |
|---|---|
| Local model | spring-ai-starter-model-ollama auto-configures a ChatModel bean |
| Agentic tool loop | chatClient.prompt().tools(fraudTools)... — Spring AI runs the request → tool → result loop |
| Agent tools | plain methods annotated @Tool / @ToolParam on FraudTools |
| Mandatory screen | screenWatchlist(...) — a plain method the service calls in code (not a @Tool), so compliance never depends on the model |
| Structured decision | .call().entity(Disposition.class) maps the response onto a typed record |
| Human gate | a @FunctionalInterface Approver the service calls in code |
Configuration lives in src/main/resources/application.properties
(spring.ai.ollama.base-url, ...chat.options.model, temperature).
.
├── pom.xml # Spring Boot 4 parent + Spring AI 2 BOM
├── src/main/java/com/example/investigator/
│ ├── InvestigatorApplication.java # app + demo runner (approve vs. deny)
│ ├── InvestigationService.java # investigate → decide → gate
│ ├── FraudTools.java # @Tool read tools; records evidence
│ ├── Disposition.java # structured decision (record + enum)
│ ├── Evidence.java # one recorded tool result
│ ├── Approver.java # human-in-the-loop injection point
│ └── InvestigationResult.java # disposition + evidence + outcome
├── src/main/resources/application.properties
└── src/test/java/.../InvestigationServiceTest.java # smoke test (skips if Ollama down)
- Expose it over HTTP — add
spring-boot-starter-weband a controller: an/investigateendpoint that returns the disposition, and an/approveendpoint for the human gate. Spring Boot makes this a few lines. - Add MCP tools — Spring AI has MCP client/server starters; move a tool (e.g. watchlist screening) to a standalone Model Context Protocol server.
- Add evals — a labeled set plus a rationale-vs-evidence check; the highest- leverage habit for taking an agent from demo to trustworthy.
- Swap the model — point
application.propertiesat any Ollama model, or at a hosted provider (Spring AI supports many) — the service code doesn't change.
MIT — see LICENSE. Use it, fork it, teach with it.