FinFlow
Every break has a reason. FinFlow finds it automatically.
Five-pass reconciliation engine over Kafka-streamed transactions. Deterministic passes handle 90%+ of breaks at high confidence. Ollama resolves the rest in plain language. pgvector remembers every anomaly so patterns don't slip through twice.
// 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.
Reconciliation breaks are inevitable. Timing drift, reference typos, rounding — they happen every day across every payment system. The cost isn't the break. It's the analyst spending 20 minutes per break ruling out the obvious cases before they can see the real problem.
I built FinFlow to push the deterministic work into code. Exact matches go instantly. Timing gaps get a 3-day tolerance window. Fuzzy references run Levenshtein distance. Amount deltas get a 1% threshold pass. Only the truly ambiguous cases ever reach the LLM — and the LLM is running locally via Ollama, zero API cost.
The second layer is memory. pgvector stores a 384-dimension embedding of every anomaly explanation. When a new anomaly fires, cosine similarity finds past anomalies that looked the same. Patterns that recur get caught faster each time.
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
// live output
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.
Deterministic before probabilistic
Passes 1–4 use exact logic — no LLM involved. This means 90%+ of matches carry confidence ≥ 0.80 with zero token cost. Ollama is reserved for true residual ambiguity, keeping costs at zero and latency predictable.
Kafka checkpoint replay
The consumer stores (topic, partition, offset) in PostgreSQL. On restart, it replays from the last committed offset. Combined with ON CONFLICT DO NOTHING inserts, the pipeline is fully idempotent — safe to kill and restart at any point.
pgvector as anomaly memory
Every anomaly explanation is embedded to 384 dimensions via sentence-transformers and indexed with an IVFFlat cosine index. This lets the agent ask 'have we seen something like this before?' across all history in a single SQL query.
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 }
]
}}