Skip to content

Document matcher

Source: agent/src/accounting_agent/domain/matching.py (the pure scorer) + agent/src/accounting_agent/reconcile/document_matcher.py (the doc-side caller, ingest pipeline).

The bank-side caller is the EB poller.

API

score = score_match(movement, candidate)
# MatchScore(candidate_id, kind, score, signals)

signals carries which signals fired (reference_match, amount_match, date_proximity_days, counterparty_match, card_debit_boost) so HITL prompts can show the operator why the agent thinks they're a pair.

Scoring details are in Matching.

Two callers, same scorer

Bank-side (EB poller)

Every poll tick. For each new or unmatched recon row, walk the candidate documents in the date+currency window. Promote proposed when the top score clears HITL.

Doc-side (ingest pipeline)

Every time a new document is indexed. Walk the unmatched recon rows in the date+currency window. Promote proposed on the same condition.

Both paths land at the same end state in Turso. The state machine prevents collisions (the set_reconciliation_proposed write is gated on status='unmatched', so whichever caller wins, the other silently no-ops).

The card-debit boost

Card debits never carry the invoice number in their bank description. Without help, they score ≤ 0.45 and stay below HITL forever. The boost adds +0.15 when:

  • reference_hit is False (substitutes for the missing reference signal)
  • amount_exact (zero-cent diff)
  • counterparty_hit (matches by token overlap)
  • days_off ≤ 2

Tuned for Wise / Revolut / similar card-issuing fintechs. The boost is gated on not reference_hit so cases where the reference IS present don't inflate past 1.0.

See commit ad5ac8f for the rationale.

Indexed narrowing

For pools with 50+ candidates the matcher includes a MatchIndex that buckets candidates by bank_ref_number and amount bucket. For 30 transactions × 50 candidates this drops score_match calls from ~1500 to ~150. The narrower's tolerance is intentionally a hair wider than the scorer's so it never drops a candidate the scorer would have admitted.

Pattern ported from iseppo/e-arveldaja-mcp's buildInvoiceIndex + getIndexedCandidates. See ADR-037.

Calibration

The thresholds (HITL=0.55, AUTO_ACCEPT=0.85) are calibrated for Estonian OÜ scale — a handful of receipts per month, EUR- heavy, mostly EU vendors. If you change the signal weights, also change the thresholds: they're a system, not independent knobs. Pinned by tests/domain/test_matching.py.