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

@@ -3,3 +3,4 @@ from . import exchange_diff
from . import matching_strategies
from . import precedent_lookup
from . import pattern_extractor
from . import confidence_scoring

View File

@@ -0,0 +1,178 @@
"""4-pass confidence scoring pipeline.
Pass 1: SQL filter — partner match + reconcilable account (done by caller — engine._fetch_candidates)
Pass 2: Statistical scoring — amount delta + pattern match + precedent similarity
Pass 3: AI re-rank (if provider configured) — feed top 5 to LLM, parse JSON ranking
Pass 4: Persist as fusion.reconcile.suggestion rows (done by caller — engine.suggest_matches)
"""
import json
import logging
from dataclasses import dataclass
from .matching_strategies import Candidate
from .precedent_lookup import find_nearest_precedents
from .memo_tokenizer import tokenize_memo
_logger = logging.getLogger(__name__)
@dataclass
class ScoredCandidate:
candidate_id: int
confidence: float
reasoning: str
score_amount_match: float
score_partner_pattern: float
score_precedent_similarity: float
score_ai_rerank: float = 0.0
def score_candidates(env, *, statement_line, candidates, k=5, use_ai=True):
"""Score and rank candidate matches for a statement line.
Args:
env: Odoo env
statement_line: account.bank.statement.line recordset (singleton)
candidates: list of Candidate dataclasses (from matching_strategies)
k: max number of scored candidates to return
use_ai: if True AND a provider is configured, invoke AI re-rank
Returns:
list of ScoredCandidate sorted by confidence desc, max length k.
"""
if not candidates or not statement_line:
return []
partner_id = statement_line.partner_id.id if statement_line.partner_id else None
bank_amount = abs(statement_line.amount)
memo_tokens = tokenize_memo(statement_line.payment_ref)
pattern = None
if partner_id:
pattern = env['fusion.reconcile.pattern'].sudo().search(
[('partner_id', '=', partner_id)], limit=1)
if not pattern:
pattern = None
precedents = []
if partner_id:
precedents = find_nearest_precedents(
env, partner_id=partner_id, amount=bank_amount, k=5, memo_tokens=memo_tokens)
scored = []
for cand in candidates:
amount_score = 1.0 - min(abs(cand.amount - bank_amount) / max(bank_amount, 1), 1.0)
pattern_score = _pattern_score(cand, pattern, bank_amount)
precedent_score = _precedent_score(cand, precedents)
confidence = (amount_score * 0.5) + (pattern_score * 0.25) + (precedent_score * 0.25)
reasoning = _build_reasoning(amount_score, pattern_score, precedent_score, pattern)
scored.append(ScoredCandidate(
candidate_id=cand.id,
confidence=round(confidence, 3),
reasoning=reasoning,
score_amount_match=round(amount_score, 3),
score_partner_pattern=round(pattern_score, 3),
score_precedent_similarity=round(precedent_score, 3),
))
scored.sort(key=lambda s: -s.confidence)
top_k = scored[:k]
if use_ai:
provider = _get_provider(env, 'bank_rec_suggest')
if provider is not None:
try:
top_k = _ai_rerank(env, provider, statement_line, top_k, pattern, precedents)
except Exception as e:
_logger.warning("AI re-rank failed, using statistical scoring: %s", e)
return top_k
def _pattern_score(cand, pattern, bank_amount) -> float:
"""How well does this candidate fit the partner's typical pattern?"""
if not pattern:
return 0.5
score = 0.5
if pattern.pref_strategy == 'exact_amount' and abs(cand.amount - bank_amount) < 0.005:
score = 1.0
return score
def _precedent_score(cand, precedents) -> float:
"""How similar is this candidate to past precedents?"""
if not precedents:
return 0.5
best = max((p.similarity_score for p in precedents), default=0.5)
return best
def _build_reasoning(amount_score, pattern_score, precedent_score, pattern) -> str:
parts = []
if amount_score >= 0.99:
parts.append("Exact amount match")
elif amount_score >= 0.95:
parts.append("Amount close")
if pattern and pattern.reconcile_count > 5:
parts.append(f"Matches partner's {pattern.reconcile_count}-reconcile pattern")
if precedent_score >= 0.8:
parts.append("Strong precedent match")
return " · ".join(parts) if parts else "Weak signal"
def _get_provider(env, feature_name):
"""Look up provider name from per-feature config; instantiate adapter.
Returns None if no provider configured (statistical-only mode)."""
param = env['ir.config_parameter'].sudo()
provider_name = param.get_param(f'fusion_accounting.provider.{feature_name}')
if not provider_name:
provider_name = param.get_param('fusion_accounting.provider.default')
if not provider_name:
return None
try:
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
from odoo.addons.fusion_accounting_ai.services.adapters.claude import ClaudeAdapter
except ImportError:
_logger.warning("fusion_accounting_ai adapters not importable")
return None
if provider_name.startswith('openai'):
return OpenAIAdapter(env)
elif provider_name.startswith('claude'):
return ClaudeAdapter(env)
return None
def _ai_rerank(env, provider, statement_line, scored, pattern, precedents):
"""Send top-K candidates + features to LLM for re-rank. Parse JSON response.
On any failure (network, JSON parse, missing key), return scored unchanged."""
try:
from odoo.addons.fusion_accounting_ai.services.prompts.bank_rec_prompt import build_prompt
except ImportError:
_logger.debug("bank_rec_prompt not yet available; skipping AI re-rank")
return scored
system, user = build_prompt(statement_line, scored, pattern, precedents)
response = provider.complete(
system=system,
messages=[{'role': 'user', 'content': user}],
max_tokens=800,
temperature=0.0,
)
try:
parsed = json.loads(response['content'])
except (json.JSONDecodeError, KeyError, TypeError):
return scored
ai_order = {item['candidate_id']: item for item in parsed.get('ranked', [])}
for s in scored:
if s.candidate_id in ai_order:
s.score_ai_rerank = ai_order[s.candidate_id].get('confidence', s.confidence)
s.reasoning = ai_order[s.candidate_id].get('reason', s.reasoning)
s.confidence = round((s.confidence * 0.4) + (s.score_ai_rerank * 0.6), 3)
scored.sort(key=lambda x: -x.confidence)
return scored

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)