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 matching_strategies
|
||||||
from . import precedent_lookup
|
from . import precedent_lookup
|
||||||
from . import pattern_extractor
|
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_ai_suggestion_lifecycle
|
||||||
from . import test_precedent_lookup
|
from . import test_precedent_lookup
|
||||||
from . import test_pattern_extraction
|
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