How an AI expense agent is actually structured
Building agentic AI? I co-run a 6-week cohort where you ship a production-ready agent, not another API wrapper.
Most AI agent tutorials show you the LLM call. They skip the part where you have to do something useful with the response.
An LLM call is just a function call that returns structured data. Once you see it that way, the rest of the architecture follows using patterns you already know.
This is the architecture we use for the expense AI agent program. Four layers, all standard Python.
Layer 1: Abstract the provider with a Protocol
The first decision is how to talk to the LLM. You could hardcode openai.chat.completions.create(...) everywhere, but then you are coupled to one vendor. Swap to Groq, Anthropic, or a local model later, you have to update it in many places.
Instead, define a Protocol that describes what any LLM provider must do:
from decimal import Decimal
from typing import Protocol, Sequence
class Assistant(Protocol):
def completion(self, messages: list[dict[str, str]]) -> ExpenseCategorizationResponse: ...
def calculate_cost(self, prompt_tokens: int, completion_tokens: int) -> Decimal: ...
def get_available_models(self) -> Sequence[str]: ...
Now OpenAIAssistant and GroqAssistant satisfy this protocol without inheriting from it. Your service layer accepts Assistant, the concrete provider is injected at startup. Swap it in tests without touching anything else.
This is called structural typing and the type checker enforces the contract for you; duck typing with safety.
Layer 2: Return a Pydantic model, not a string
The LLM returns text. You want typed data. Pydantic bridges this gap.
OpenAI's structured outputs API accepts a Pydantic model directly and returns an instance of it:
from decimal import Decimal
from pydantic import BaseModel, Field
class ExpenseCategorizationResponse(BaseModel):
category: str
total_amount: Decimal
currency: Currency
confidence: float
cost: Decimal
comments: str | None = Noneresponse = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=messages,
response_format=ExpenseCategorizationResponse,
)
result: ExpenseCategorizationResponse = response.choices[0].message.parsed
# result.total_amount is Decimal("5.50"), not a string
# result.currency is Currency.USD, not "USD"
No regex. No JSON parsing. No float(response.split("$")[1]) hacks. Pydantic validates types on the way in, coerces where it can, and raises a clear error when the model returns something unexpected.
If you've worked with FastAPI, you've already seen what Pydantic does for request/response models. The same applies here.
The LLM also returns a confidence score for each classification. You'll use it in layer 4.
Layer 3: The service layer coordinates everything
Without a service layer, classification logic ends up copy-pasted into the CLI, the Telegram bot, and the REST API. The service layer centralizes it:
from dataclasses import dataclass
@dataclass
class ClassificationService:
assistant: Assistant
expense_repo: ExpenseRepository | None = None
def classify(
self,
expense_description: str,
persist: bool = False,
) -> ClassificationResult:
messages = self._build_messages(expense_description)
response = self.assistant.completion(messages)
if persist and self.expense_repo:
self._persist_expense(expense_description, response)
return ClassificationResult(response=response, persisted=persist)
The assistant is injected, not created inside. The expense_repo is optional so you can classify without writing to storage (useful for preview UI, dry runs, and testing).
Testing this service is straightforward: pass in a mock Assistant that returns a known ExpenseCategorizationResponse. No real API call. No .env file.
from unittest.mock import create_autospec
def test_classify_calls_assistant():
mock_assistant = create_autospec(Assistant)
mock_assistant.completion.return_value = ExpenseCategorizationResponse(
category="Food",
total_amount=Decimal("5.50"),
currency=Currency.USD,
confidence=0.95,
cost=Decimal("0.001"),
)
service = ClassificationService(assistant=mock_assistant)
result = service.classify("Coffee at Starbucks $5.50")
assert result.response.category == "Food"
assert result.persisted is False
Note that, using create_autospec instead of MagicMock directly, means the test will fail if you refactor the Assistant protocol but forget to update the mock. See Jan Giacomelli's tip here.
Layer 4: Human-in-the-loop catches what the model gets wrong
"Uber to airport" → Transport. Correct. "Uber Eats dinner" → Transport (85% confidence). Wrong.
The LLM is often right. Not always. Human-in-the-loop (HITL) is how you catch what the model gets wrong. It proposes, you decide.
When confidence drops below a threshold, the Telegram bot presents an inline keyboard with the AI's suggestion highlighted:
def build_category_confirmation_keyboard(
suggested_category: str,
all_categories: list[str],
) -> InlineKeyboardMarkup:
buttons = []
for category in all_categories:
text = f">> {category} <<" if category == suggested_category else category
buttons.append(InlineKeyboardButton(text=text, callback_data=f"cat:{category}"))
...
The user sees [>> Transport <<] [Food] [Shopping]. One tap to confirm or correct. When they select, the service persists with confidence=1.0:
def persist_with_category(self, expense_description: str, category_name: str, response: ExpenseCategorizationResponse) -> Expense:
expense = Expense(
amount=response.total_amount,
currency=response.currency,
description=expense_description,
category=ExpenseCategory(category_name),
confidence=Decimal("1.0"), # human confirmed
)
self._expense_repo.add(expense)
return expense
A human-confirmed label is by definition correct. The stored confidence should reflect that.
Python AI agent architecture: four layers
The four layers map to Python patterns you already know:
| Layer | Pattern | What it gives you |
|---|---|---|
| Provider | Protocol | Swap LLM backends without touching business logic |
| Response | Pydantic BaseModel | Typed, validated data from unstructured text |
| Coordinator | Service + dependency injection | Testable logic, reused across CLI/bot/API |
| Verification | HITL | Human oversight where model confidence is low |
None of this is new. Service layers and dependency injection are standard patterns. Protocols give you duck typing with type safety: structural contracts that don't require inheritance. Pydantic is the de facto standard for structured data in modern Python APIs. And HITL is a form flow where AI proposes and humans confirm - simple in concept, but the feedback loop is what makes it powerful.
What makes it feel like "AI development" is that the LLM sits in the middle. The code around it is just Python. The patterns are the same; the capabilities are new.
This is the project structure from our Expense AI Agent cohort that Juan José Expósito and I are running.
Most AI tutorials end at "call the API." This cohort ends with a deployed agent: function calling, structured outputs, three interfaces, Docker, 95%+ test coverage. Six weeks of real engineering, not notebooks. Join the next Agentic AI cohort →