FinFlow
Every break has a reason. FinFlow finds it automatically.
Compares two lists of payments (say, the bank's and your own books) and explains every mismatch. Simple rule-based checks run first. Only what's left goes to an AI model running on my own computer. It also remembers past odd patterns. Built and tested on generated transactions, not real bank data.
// recon_run_2024-09-01.log
The problem
[BREAK] TXN-8821 — no ledger match
amount: $4,217.50 entity: PARTNER_C date: 2024-08-29
analyst: is this a timing issue or a real break?
analyst: pulling ledger export...
analyst: checking VLOOKUP... sorting by date...
analyst: found it — ledger says 2024-09-01
46 more breaks in the queue.
Mismatches between two payment records are normal. A payment lands a day late, a reference has a typo, an amount is rounded differently. The cost isn't the mismatch. It's the analyst who has to rule out all the harmless explanations by hand before reaching the one that matters.
I built FinFlow to do that ruling-out automatically, with simple checks in order. Same amount and reference? Match. Dates a few days apart? Probably just timing. A reference with a small typo? Still a match. An amount off by under 1%? Probably rounding. Only what is still unexplained goes to an AI model, and that model runs on my own computer, so there is no per-use cost.
It also keeps a memory. Each odd pattern it flags is saved with a short explanation, so when something similar shows up later it can say “you've seen this before.”
What this solves
- ✓Manual break triage — analysts spending hours on timing and reference noise
- ✓No audit trail — match decisions were undocumented and unreplayable
- ✓Recurring anomalies going unnoticed — no cross-run pattern memory
- ✓AI-or-nothing tradeoffs — LLM only runs when the deterministic stack fails
Key metrics
Architecture
Four layers, each responsible for one concern, communicating via Kafka and PostgreSQL.
- ·Producer generates multi-source mock transactions with realistic drift
- ·Consumer reads Kafka, writes to PostgreSQL with checkpoint-based replay
- ·ON CONFLICT DO NOTHING — idempotent, safe to restart anywhere
- ·Engine polls every 60s, pulls unreconciled IDs from last 48h
- ·Runs five ordered passes, each narrowing the unmatched set
- ·Writes results and audit trail — every decision is traceable
- ·Polls completed runs every 90s for four anomaly types
- ·Ollama llama3.2 explains each anomaly in 2–3 sentences
- ·sentence-transformers embeds explanation → pgvector stores it
- ·FastAPI + Strawberry GraphQL — typed queries with playground UI
- ·REST endpoints for stats, runs, breakdowns, anomaly resolution
- ·Streamlit dashboard: match rate trend, anomaly cards, resolve button
// example screen: sample numbers on generated data, not a measured result
Streamlit dashboard
Auto-refreshes every 30s. Four panels: KPIs, run history, pass breakdown, anomaly queue.
Match Rate
94.2%
last run
Matched
1,884
transactions
Breaks
116
unmatched
Open Anomaly
3
needs review
Total Runs
48
since deploy
// match rate trend — last 10 runs
// run #48 pass breakdown
AI used
0.7%
Token cost
$0.00
// open anomalies (3)
sorted by severityUnmatched count 2.3× above 7-day rolling avg (116 vs avg 50). Possible feed interruption from SYSTEM_2 after 14:00 UTC.
ENTITY_B accounts for 38% of all breaks this run. Reference format change detected — "REF-" prefix dropped in 3 consecutive runs.
SYSTEM_3 contributing 17% of unmatched across last 4 runs. Consistent amount delta of $0.01–$0.03 suggests rounding mode change.
The five-pass engine
Each pass takes the transactions left unmatched by the previous one. Deterministic before probabilistic — the LLM only sees what nothing else could handle.
Entity + currency + amount + (transaction ID or reference). Fastest possible match, highest confidence. No tolerance applied.
Same fields, but allows up to 3-day date difference. Catches settlement timing drift between source and ledger.
Entity + currency + amount + Levenshtein distance ≤ 4 on reference strings. Catches typos, truncation, and format variants. Confidence decreases 0.02 per distance unit.
Entity + currency + reference + amount within 1%. Catches rounding errors, FX conversion deltas, and small fee variances.
Batches of 10 unmatched transactions sent to Ollama llama3.2. Structured JSON response. Used only for true residual breaks that no deterministic pass could handle.
AI anomaly detection
The agent runs every 90s after reconciliation completes. It detects four structural anomaly types, explains each one in plain language, embeds the explanation, and uses pgvector to find similar past events.
Unmatched count > 2× 7-day rolling average
Sudden surge in unmatched transactions — usually signals a feed interruption or upstream schema change.
Ollama explains in 2–3 sentences → sentence-transformers embeds → pgvector finds similar past anomalies (cosine sim > 0.85)
Break rate > 20% of run volume
The overall ratio of breaks to matched is unusually high for this run — not a volume spike, but a quality signal.
Ollama explains in 2–3 sentences → sentence-transformers embeds → pgvector finds similar past anomalies (cosine sim > 0.85)
One entity > 40% of breaks
Breaks are clustering around a single counterparty — likely a formatting change on their side or a new data contract.
Ollama explains in 2–3 sentences → sentence-transformers embeds → pgvector finds similar past anomalies (cosine sim > 0.85)
One source system > 15% of run unmatched
A specific source system is consistently failing to match — typically a field mapping regression or deployment artifact.
Ollama explains in 2–3 sentences → sentence-transformers embeds → pgvector finds similar past anomalies (cosine sim > 0.85)
// agent output
Anomaly resolution in practice
From raw unmatched transactions to Ollama explanation to pgvector similarity search to analyst resolution — the full loop.
# anomaly_agent.py polling run #48
unmatched_count = 116
rolling_7d_avg = 50.3
ratio = 2.31 # > 2.0 threshold
✔ anomaly detected
type: volume_spike
entity: ENTITY_A
severity: HIGH
POST http://ollama:11434/api/generate
prompt →
Run #48 has 116 unmatched vs avg 50. Entity ENTITY_A is concentrated. Explain in 2-3 sentences.
llama3.2 →
ENTITY_A's transaction volume spiked 2.3× above the 7-day average in run #48, concentrated after 14:00 UTC. This pattern is consistent with a feed interruption or a schema change in the upstream SYSTEM_2 connector. Recommend checking SYSTEM_2 delivery logs for the 13:45–15:00 UTC window.
similar anomalies (cosine > 0.85)
analyst resolves
resolution_note:
Confirmed SYSTEM_2 connector restart at 13:52 UTC. Replay from offset 41820 initiated.
Design decisions
The choices that shaped the architecture.
Plain rules first, AI last
The first four checks are plain rules with no AI, so they are fast, free and give the same answer every time. The AI model is kept for the leftovers that nothing else could explain.
Safe to stop and restart
The consumer saves its place in the stream in the database. After a restart it carries on from there, and inserting the same record twice is ignored, so it is safe to kill and restart at any point.
A memory for odd patterns
Each flagged pattern is saved in a form that lets the database find similar ones later, so the agent can ask 'have we seen something like this before?' across all history.
GraphQL API
Strawberry GraphQL at /graphql — playground UI included. Typed queries over reconciliation runs, breaks, and anomalies.
# open anomalies + pass breakdown for run #48
query {
anomalies(status: "open") {
id anomalyType severity
explanation similarCount resolvedAt
}
matchBreakdown(runId: 48) {
passType matchedCount confidence
}
}
{"data": {
"anomalies": [
{
"id": "ANO-0041",
"anomalyType": "volume_spike",
"severity": "HIGH",
"explanation": "ENTITY_A volume 2.3× above 7d avg...",
"similarCount": 2,
"resolvedAt": null
} ...
],
"matchBreakdown": [
{ "passType": "exact_match", "matchedCount": 1243, "confidence": 1.0 },
{ "passType": "timing_tolerance", "matchedCount": 389, "confidence": 0.95 },
{ "passType": "ai_resolution", "matchedCount": 13, "confidence": 0.71 }
]
}}