feat(fusion_accounting_bank_rec): 4-pass confidence scoring pipeline

Task 11 of Phase 1 Bank Reconciliation. Adds the brain that ranks
candidate journal-item matches for a bank statement line.

Pass 1 — SQL filter (done by caller's _fetch_candidates).
Pass 2 — Statistical scoring: weighted blend of amount-delta,
         partner pattern fit, and precedent similarity.
Pass 3 — Optional AI re-rank when an LLM provider is configured;
         gracefully no-ops when provider missing, prompt module not
         yet present (Task 20), or the JSON response is malformed.
Pass 4 — Persistence (handled by engine.suggest_matches).

Returns top-K ScoredCandidate dataclasses with per-feature scores
exposed for transparency and future learning.

7 new tests added; full module suite green (51 tests, 0 failures).

Made-with: Cursor
This commit is contained in:
gsinghpal
2026-04-19 10:37:37 -04:00
parent 06e382b27b
commit 920a624cd1
4 changed files with 282 additions and 0 deletions

View File

@@ -4,3 +4,4 @@ from . import test_matching_strategies
from . import test_ai_suggestion_lifecycle
from . import test_precedent_lookup
from . import test_pattern_extraction
from . import test_confidence_scoring

View File

@@ -0,0 +1,102 @@
from datetime import date, timedelta, datetime
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_bank_rec.services.confidence_scoring import (
score_candidates, ScoredCandidate,
)
from odoo.addons.fusion_accounting_bank_rec.services.matching_strategies import Candidate
@tagged('post_install', '-at_install')
class TestConfidenceScoring(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'Scoring Test Partner'})
self.company = self.env.company
self.currency = self.env.ref('base.CAD')
self.journal = self.env['account.journal'].create({
'name': 'Test Bank Scoring',
'type': 'bank',
'code': 'TBSC',
})
statement = self.env['account.bank.statement'].create({
'name': 'Test Statement',
'journal_id': self.journal.id,
})
self.line = self.env['account.bank.statement.line'].create({
'statement_id': statement.id,
'journal_id': self.journal.id,
'date': date.today(),
'payment_ref': 'RBC ETF DEP REF 4831',
'amount': 1847.50,
'partner_id': self.partner.id,
})
def _candidate(self, id_, amount, age_days=10):
return Candidate(id=id_, amount=amount, partner_id=self.partner.id, age_days=age_days)
def test_returns_empty_when_no_candidates(self):
result = score_candidates(self.env, statement_line=self.line, candidates=[], k=5)
self.assertEqual(result, [])
def test_returns_empty_when_no_statement_line(self):
result = score_candidates(self.env, statement_line=None,
candidates=[self._candidate(1, 100)], k=5)
self.assertEqual(result, [])
def test_amount_exact_dominates(self):
candidates = [
self._candidate(1, 1847.50),
self._candidate(2, 1800.00),
]
result = score_candidates(self.env, statement_line=self.line, candidates=candidates, k=5,
use_ai=False)
self.assertEqual(len(result), 2)
self.assertEqual(result[0].candidate_id, 1)
self.assertGreater(result[0].confidence, result[1].confidence)
self.assertGreater(result[0].score_amount_match, 0.99)
def test_returns_top_k(self):
candidates = [self._candidate(i, 1847.50 - i) for i in range(10)]
result = score_candidates(self.env, statement_line=self.line, candidates=candidates, k=3,
use_ai=False)
self.assertEqual(len(result), 3)
def test_no_ai_provider_returns_statistical_only(self):
"""When no AI provider config, score_ai_rerank stays at 0.0."""
self.env['ir.config_parameter'].sudo().search([
('key', 'in', ['fusion_accounting.provider.bank_rec_suggest',
'fusion_accounting.provider.default'])
]).unlink()
candidates = [self._candidate(1, 1847.50)]
result = score_candidates(self.env, statement_line=self.line, candidates=candidates, k=5,
use_ai=True)
self.assertEqual(result[0].score_ai_rerank, 0.0)
def test_use_ai_false_skips_ai_rerank(self):
candidates = [self._candidate(1, 1847.50)]
result = score_candidates(self.env, statement_line=self.line, candidates=candidates, k=5,
use_ai=False)
self.assertEqual(result[0].score_ai_rerank, 0.0)
def test_pattern_match_boosts_confidence(self):
"""When the partner has a matching pattern, confidence is higher than no-pattern case."""
self.env['fusion.reconcile.pattern'].create({
'company_id': self.company.id,
'partner_id': self.partner.id,
'reconcile_count': 10,
'pref_strategy': 'exact_amount',
})
candidates = [self._candidate(1, 1847.50)]
with_pattern = score_candidates(self.env, statement_line=self.line,
candidates=candidates, k=5, use_ai=False)
other_partner = self.env['res.partner'].create({'name': 'No Pattern Partner'})
self.line.write({'partner_id': other_partner.id})
other_candidates = [Candidate(id=1, amount=1847.50, partner_id=other_partner.id, age_days=10)]
without_pattern = score_candidates(self.env, statement_line=self.line,
candidates=other_candidates, k=5, use_ai=False)
self.assertGreater(with_pattern[0].score_partner_pattern,
without_pattern[0].score_partner_pattern - 0.001)