Rules first, LLM second: categorizing Colombian bank transactions
Nora is a personal-finance app I'm building for Colombia. Colombian banks don't offer open APIs to regular developers, so the first step is simple: you upload your bank statement and Nora organizes your transactions. The interesting part is putting each transaction in the right category, and deciding when an LLM should be involved.
Step 1: parse without AI
Each bank has its own parser: Bancolombia (PDF, Excel and CSV), Davivienda, Nequi, Nu Colombia and Daviplata. Statement formats are stable enough that deterministic parsers are cheaper, faster and easier to test than asking a model to read a PDF. A unique constraint skips duplicates when someone uploads overlapping statements.
Step 2: categorize without AI, when possible
Each transaction goes through a fixed order, and stops at the first match:
- The user's learned rules. If you (or the AI, see below) already categorized this merchant, that wins. Confidence grows with every match, up to a cap.
- Static Colombian merchant patterns. Common merchants and payees that every user benefits from.
- Category keywords, including ones the user added.
Descriptions are normalized first (lowercase, accents removed), because "Éxito" and "EXITO" should be the same store.
Step 3: the LLM gets only what's left
After the statement is saved, an event triggers the AI step for the transactions that are still uncategorized:
- They're sent in batches of 50 to a small, cheap model (
gpt-4o-mini) with a low temperature. - The model receives the user's own category list and answers through a function-calling schema, picking a category for each description or "sin categoría".
- Any answer that isn't one of those categories is ignored, not trusted.
- If there's no API key configured, the step is skipped and the app still works.
Step 4: every AI answer becomes a rule
This is the part I like most. When the model categorizes a transaction, Nora also saves a rule for that user and that description. The next statement with the same merchant is handled in step 2, without calling the model. The AI cost per user goes down over time instead of growing with every upload, and the user's corrections feed the same rule system.
Why this order
- Cost: most transactions never reach the model.
- Predictability: rules behave the same every time; the model is used where rules have nothing to say.
- Trust: each transaction stores a confidence score, so the app can tell a learned rule from a model guess.
- Graceful failure: if the model is down or wrong, you get an uncategorized transaction, not a wrong one.
The general lesson: treat the LLM as the most expensive and least predictable step in the pipeline, and design so that each call makes the next one less necessary.