diff --git a/fusion_accounting_bank_rec/services/__init__.py b/fusion_accounting_bank_rec/services/__init__.py index 5b29b59e..d91e7e2a 100644 --- a/fusion_accounting_bank_rec/services/__init__.py +++ b/fusion_accounting_bank_rec/services/__init__.py @@ -3,3 +3,4 @@ from . import exchange_diff from . import matching_strategies from . import precedent_lookup from . import pattern_extractor +from . import confidence_scoring diff --git a/fusion_accounting_bank_rec/services/confidence_scoring.py b/fusion_accounting_bank_rec/services/confidence_scoring.py new file mode 100644 index 00000000..4b428938 --- /dev/null +++ b/fusion_accounting_bank_rec/services/confidence_scoring.py @@ -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 diff --git a/fusion_accounting_bank_rec/tests/__init__.py b/fusion_accounting_bank_rec/tests/__init__.py index 44262377..a7ca665e 100644 --- a/fusion_accounting_bank_rec/tests/__init__.py +++ b/fusion_accounting_bank_rec/tests/__init__.py @@ -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 diff --git a/fusion_accounting_bank_rec/tests/test_confidence_scoring.py b/fusion_accounting_bank_rec/tests/test_confidence_scoring.py new file mode 100644 index 00000000..22cd4f18 --- /dev/null +++ b/fusion_accounting_bank_rec/tests/test_confidence_scoring.py @@ -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)