diff --git a/fusion_accounting_ai/services/prompts/__init__.py b/fusion_accounting_ai/services/prompts/__init__.py index ff7682de..d2a2b3e1 100644 --- a/fusion_accounting_ai/services/prompts/__init__.py +++ b/fusion_accounting_ai/services/prompts/__init__.py @@ -1,2 +1,3 @@ from . import system_prompt from . import domain_prompts +from . import bank_rec_prompt diff --git a/fusion_accounting_ai/services/prompts/bank_rec_prompt.py b/fusion_accounting_ai/services/prompts/bank_rec_prompt.py new file mode 100644 index 00000000..7f0f82b3 --- /dev/null +++ b/fusion_accounting_ai/services/prompts/bank_rec_prompt.py @@ -0,0 +1,107 @@ +"""Bank reconciliation AI re-rank prompt. + +Used by fusion_accounting_bank_rec/services/confidence_scoring.py to ask +an LLM to refine the statistical ranking of candidate matches. + +Output contract: the LLM MUST respond with valid JSON of shape: + {"ranked": [{"candidate_id": int, "confidence": float, "reason": str}, ...]} + +System prompt is provider-agnostic - works with OpenAI Chat Completions, +Claude Messages, and local OpenAI-compatible servers (LM Studio, Ollama). +""" + +from datetime import date + + +SYSTEM_PROMPT = """You are an expert accountant assisting with bank reconciliation. + +Your job: given a bank statement line and a list of candidate journal items +that statistically scored well as potential matches, re-rank them based on +domain expertise. Consider: + +1. **Amount-exact matches** are almost always correct unless the partner is wrong. +2. **Memo / reference clues** - bank memos often contain invoice numbers, partner + names, or transaction references that disambiguate matches. +3. **Date proximity** - invoices are typically reconciled within 30 days of issue. +4. **Pattern conformance** - if the partner has a learned pattern (e.g. "always + pays exact amount, weekly cadence"), favor candidates that fit that pattern. +5. **Precedent similarity** - if a near-identical reconcile happened before, + it's likely the right one. + +Return ONLY valid JSON of this exact shape: +{ + "ranked": [ + {"candidate_id": , "confidence": , "reason": ""}, + ... + ] +} + +Do NOT include any prose before or after the JSON. Do NOT use markdown code fences. +The "ranked" array MUST contain every candidate_id from the input, in your +preferred order (highest confidence first). +""" + + +def build_prompt(statement_line, scored_candidates, pattern=None, precedents=None): + """Build (system_prompt, user_prompt) for AI re-rank. + + Args: + statement_line: account.bank.statement.line recordset (singleton) + scored_candidates: list of ScoredCandidate dataclasses (from confidence_scoring) + pattern: fusion.reconcile.pattern recordset for the partner, or None + precedents: list of PrecedentMatch dataclasses, or None + + Returns: + (system_prompt: str, user_prompt: str) tuple + """ + user_parts = [] + + user_parts.append("BANK LINE:") + user_parts.append(f" Date: {statement_line.date}") + user_parts.append( + f" Amount: {statement_line.amount} {statement_line.currency_id.name or ''}" + ) + user_parts.append( + f" Memo / payment ref: {statement_line.payment_ref or '(none)'}" + ) + if statement_line.partner_id: + user_parts.append(f" Partner: {statement_line.partner_id.name}") + + if pattern: + user_parts.append("") + user_parts.append("PARTNER PATTERN (learned from past reconciles):") + user_parts.append(f" Reconcile count: {pattern.reconcile_count}") + user_parts.append(f" Preferred strategy: {pattern.pref_strategy}") + user_parts.append( + f" Typical cadence: ~{pattern.typical_cadence_days} days between reconciles" + ) + if pattern.typical_amount_range: + user_parts.append(f" Typical amount range: {pattern.typical_amount_range}") + if pattern.common_memo_tokens: + user_parts.append(f" Common memo tokens: {pattern.common_memo_tokens}") + + if precedents: + user_parts.append("") + user_parts.append("RECENT PRECEDENTS (most-similar past reconciles for this partner):") + # Cap at 3 precedents to keep prompt small and reduce token cost. + for p in precedents[:3]: + user_parts.append( + f" - amount={p.amount}, similarity={p.similarity_score:.2f}, " + f"matched {p.matched_move_line_count} line(s), tokens={p.memo_tokens}" + ) + + user_parts.append("") + user_parts.append("CANDIDATES (scored by statistical pipeline):") + for s in scored_candidates: + user_parts.append( + f" - candidate_id={s.candidate_id}, statistical_confidence={s.confidence}, " + f"amount_match={s.score_amount_match}, pattern_fit={s.score_partner_pattern}, " + f"precedent_sim={s.score_precedent_similarity}, " + f"reason=\"{s.reasoning}\"" + ) + + user_parts.append("") + user_parts.append("Re-rank these candidates and return JSON per the system prompt.") + + user_prompt = "\n".join(user_parts) + return (SYSTEM_PROMPT, user_prompt) diff --git a/fusion_accounting_bank_rec/tests/__init__.py b/fusion_accounting_bank_rec/tests/__init__.py index 066ea966..26b2e51d 100644 --- a/fusion_accounting_bank_rec/tests/__init__.py +++ b/fusion_accounting_bank_rec/tests/__init__.py @@ -9,3 +9,4 @@ from . import test_reconcile_engine_unit from . import test_reconcile_engine_property from . import test_factories from . import test_reconcile_engine_integration +from . import test_bank_rec_prompt diff --git a/fusion_accounting_bank_rec/tests/test_bank_rec_prompt.py b/fusion_accounting_bank_rec/tests/test_bank_rec_prompt.py new file mode 100644 index 00000000..06b94d50 --- /dev/null +++ b/fusion_accounting_bank_rec/tests/test_bank_rec_prompt.py @@ -0,0 +1,92 @@ +"""Smoke tests for bank_rec_prompt module.""" + +from odoo.tests.common import TransactionCase, tagged +from odoo.addons.fusion_accounting_ai.services.prompts.bank_rec_prompt import ( + SYSTEM_PROMPT, + build_prompt, +) +from odoo.addons.fusion_accounting_bank_rec.services.confidence_scoring import ( + ScoredCandidate, +) +from . import _factories as f + + +@tagged('post_install', '-at_install') +class TestBankRecPrompt(TransactionCase): + + def setUp(self): + super().setUp() + self.partner = self.env['res.partner'].create({'name': 'Prompt Test Partner'}) + self.bank_line = f.make_bank_line( + self.env, + amount=1847.50, + partner=self.partner, + memo='RBC ETF DEP REF 4831', + ) + self.scored = [ + ScoredCandidate( + candidate_id=101, + confidence=0.92, + reasoning='Exact amount match', + score_amount_match=1.0, + score_partner_pattern=0.5, + score_precedent_similarity=0.85, + ), + ScoredCandidate( + candidate_id=102, + confidence=0.71, + reasoning='Close amount', + score_amount_match=0.95, + score_partner_pattern=0.5, + score_precedent_similarity=0.6, + ), + ] + + def test_system_prompt_requires_json_output(self): + self.assertIn('JSON', SYSTEM_PROMPT) + self.assertIn('"ranked"', SYSTEM_PROMPT) + + def test_build_prompt_returns_tuple(self): + result = build_prompt(self.bank_line, self.scored) + self.assertEqual(len(result), 2) + system, user = result + self.assertIsInstance(system, str) + self.assertIsInstance(user, str) + + def test_user_prompt_includes_bank_line_details(self): + _, user = build_prompt(self.bank_line, self.scored) + self.assertIn('1847.5', user) + self.assertIn('RBC ETF DEP REF 4831', user) + self.assertIn('Prompt Test Partner', user) + + def test_user_prompt_includes_all_candidates(self): + _, user = build_prompt(self.bank_line, self.scored) + self.assertIn('candidate_id=101', user) + self.assertIn('candidate_id=102', user) + + def test_user_prompt_omits_pattern_section_when_none(self): + _, user = build_prompt(self.bank_line, self.scored, pattern=None) + self.assertNotIn('PARTNER PATTERN', user) + + def test_user_prompt_includes_pattern_section_when_provided(self): + pattern = f.make_pattern(self.env, partner=self.partner, reconcile_count=15) + _, user = build_prompt(self.bank_line, self.scored, pattern=pattern) + self.assertIn('PARTNER PATTERN', user) + self.assertIn('15', user) + + def test_user_prompt_includes_precedents_when_provided(self): + from odoo.addons.fusion_accounting_bank_rec.services.precedent_lookup import ( + PrecedentMatch, + ) + precedents = [ + PrecedentMatch( + precedent_id=1, + amount=1847.50, + memo_tokens='RBC,ETF', + matched_move_line_count=1, + similarity_score=0.95, + ), + ] + _, user = build_prompt(self.bank_line, self.scored, precedents=precedents) + self.assertIn('RECENT PRECEDENTS', user) + self.assertIn('0.95', user)