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:
@@ -3,3 +3,4 @@ from . import exchange_diff
|
||||
from . import matching_strategies
|
||||
from . import precedent_lookup
|
||||
from . import pattern_extractor
|
||||
from . import confidence_scoring
|
||||
|
||||
178
fusion_accounting_bank_rec/services/confidence_scoring.py
Normal file
178
fusion_accounting_bank_rec/services/confidence_scoring.py
Normal 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
|
||||
@@ -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