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:
@@ -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
|
||||
|
||||
102
fusion_accounting_bank_rec/tests/test_confidence_scoring.py
Normal file
102
fusion_accounting_bank_rec/tests/test_confidence_scoring.py
Normal 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)
|
||||
Reference in New Issue
Block a user