import json import logging import time from datetime import timedelta from odoo import models, fields, api, _ from odoo.exceptions import UserError _logger = logging.getLogger(__name__) # Inter-account transfer pairs: (source_journal, cc_journal, cc_account_pattern) # Source sends "MB-CREDIT CARD" (outgoing), CC receives "PAYMENT FROM" (incoming) TRANSFER_PAIRS = [ # (source_journal_id, cc_journal_id, outstanding_account_id) (50, 51, 493), # Scotia Current → Passport Visa, Outstanding Receipts - All Banks (53, 28, 493), # RBC Chequing → RBC Visa, Outstanding Receipts - All Banks ] class FusionAccountingAgent(models.AbstractModel): _name = 'fusion.accounting.agent' _description = 'Fusion Accounting AI Agent Orchestrator' def _get_config(self, key, default=None): ICP = self.env['ir.config_parameter'].sudo() return ICP.get_param(f'fusion_accounting.{key}', default) def _get_adapter(self): provider = self._get_config('ai_provider', 'claude') if provider == 'claude': return self.env['fusion.accounting.adapter.claude'] return self.env['fusion.accounting.adapter.openai'] def _get_tool_registry(self): return self.env['fusion.accounting.tool'].search([('active', '=', True)]) def _get_tools_for_user(self, user=None): user = user or self.env.user tools = self._get_tool_registry() filtered = self.env['fusion.accounting.tool'] for tool in tools: if not tool.required_groups: filtered |= tool continue group_xmlids = [g.strip() for g in tool.required_groups.split(',') if g.strip()] if all(user.has_group(g) for g in group_xmlids): filtered |= tool return filtered def _build_tool_definitions(self, tools): definitions = [] for tool in tools: # A2: Include tier info in description so AI knows which tools need approval tier_label = {'1': 'Read-only', '2': 'Auto-approved', '3': 'Requires user approval'}.get(tool.tier, '') desc = tool.description or '' if tier_label: desc = f"[Tier {tool.tier}: {tier_label}] {desc}" defn = { 'name': tool.name, 'description': desc, } if tool.parameters_schema: try: defn['parameters'] = json.loads(tool.parameters_schema) except json.JSONDecodeError: defn['parameters'] = {'type': 'object', 'properties': {}} else: defn['parameters'] = {'type': 'object', 'properties': {}} definitions.append(defn) return definitions def _load_rules(self, domain=None): rule_domain = [('active', '=', True), ('company_id', '=', self.env.company.id)] if domain: rule_domain.append(('rule_type', '=', domain)) rules = self.env['fusion.accounting.rule'].search(rule_domain, order='sequence') admin_rules = rules.filtered(lambda r: r.created_by == 'admin') ai_auto = rules.filtered(lambda r: r.created_by == 'ai' and r.approval_tier == 'auto') ai_pending = rules.filtered(lambda r: r.created_by == 'ai' and r.approval_tier == 'needs_approval') return admin_rules | ai_auto | ai_pending def _load_match_history(self, domain=None, limit=None): limit = limit or int(self._get_config('history_in_prompt', '50')) history_domain = [('company_id', '=', self.env.company.id)] if domain: history_domain.append(('tool_name', 'ilike', domain)) return self.env['fusion.accounting.match.history'].search( history_domain, limit=limit, order='proposed_at desc', ) def _build_system_prompt(self, rules, history, context=None, domain=None): from .prompts.system_prompt import build_system_prompt from .prompts.domain_prompts import get_domain_prompt base = build_system_prompt(rules, history, context) if domain: domain_prompt = get_domain_prompt(domain) if domain_prompt: base = f"{base}\n\n{domain_prompt}" return base def _execute_tool(self, tool_name, params, session_id=None): from .tools import TOOL_DISPATCH if tool_name not in TOOL_DISPATCH: return {'error': f'Unknown tool: {tool_name}'} try: result = TOOL_DISPATCH[tool_name](self.env, params) return result except Exception as e: _logger.error("Tool execution error (%s): %s", tool_name, e) return {'error': str(e)} def _log_match_history(self, session, tool_name, params, result, reasoning='', confidence=0.0, rule=None, tier='1'): vals = { 'session_id': session.id if session else False, 'tool_name': tool_name, 'tool_params': json.dumps(params) if params else '{}', 'tool_result': json.dumps(result) if result else '{}', 'ai_reasoning': reasoning, 'ai_confidence': confidence, 'rule_id': rule.id if rule else False, 'proposed_at': fields.Datetime.now(), 'decision': 'auto' if tier in ('1', '2') else 'pending', 'company_id': self.env.company.id, } return self.env['fusion.accounting.match.history'].sudo().create(vals) def chat(self, session_id, user_message, context=None): session = self.env['fusion.accounting.session'].browse(session_id) if not session.exists(): raise UserError(_("Session not found.")) adapter = self._get_adapter() provider = self._get_config('ai_provider', 'claude') # Pin provider to session to prevent cross-adapter message contamination (C5) if session.ai_provider and session.ai_provider != provider: _logger.warning( "Session %s was started with %s but current provider is %s. " "Keeping original provider to avoid message format conflicts.", session.name, session.ai_provider, provider, ) provider = session.ai_provider if provider == 'claude': adapter = self.env['fusion.accounting.adapter.claude'] else: adapter = self.env['fusion.accounting.adapter.openai'] tools = self._get_tools_for_user() tool_definitions = self._build_tool_definitions(tools) rules = self._load_rules() history = self._load_match_history() system_prompt = self._build_system_prompt( rules, history, context, domain=session.context_domain, ) messages_json = json.loads(session.message_ids_json or '[]') messages_json.append({'role': 'user', 'content': user_message}) max_turns = max(int(self._get_config('max_tool_calls', '20')), 1) total_tokens_in = 0 total_tokens_out = 0 response = {'text': '', 'tool_calls': None} has_pending_tier3 = False for turn in range(max_turns): response = adapter.call_with_tools( system_prompt=system_prompt, messages=messages_json, tools=tool_definitions, ) total_tokens_in += response.get('tokens_in', 0) total_tokens_out += response.get('tokens_out', 0) if response.get('tool_calls'): tool_results = [] for tc in response['tool_calls']: tool_name = tc['name'] tool_params = tc.get('arguments', {}) tool_rec = tools.filtered(lambda t: t.name == tool_name) tier = tool_rec.tier if tool_rec else '1' if tier == '3': has_pending_tier3 = True history_rec = self._log_match_history( session, tool_name, tool_params, None, reasoning=tc.get('reasoning', ''), confidence=tc.get('confidence', 0.0), tier='3', ) tool_results.append({ 'tool_call_id': tc.get('id', ''), 'result': json.dumps({ 'status': 'pending_approval', 'match_history_id': history_rec.id, 'message': f'Action requires user approval. Match history ID: {history_rec.id}', }), }) else: result = self._execute_tool(tool_name, tool_params, session.id) self._log_match_history( session, tool_name, tool_params, result, reasoning=tc.get('reasoning', ''), tier=tier, ) tool_results.append({ 'tool_call_id': tc.get('id', ''), 'result': json.dumps(result) if not isinstance(result, str) else result, }) try: self._check_rule_proposal(tool_name, tool_params, session) except Exception: _logger.exception("Error in _check_rule_proposal for tool %s", tool_name) messages_json = adapter.append_tool_results( messages_json, response, tool_results, ) session.write({'tool_call_count': session.tool_call_count + len(tool_results)}) # C2: Short-circuit loop when Tier 3 actions are pending — # force a final text response so the AI can present approval cards if has_pending_tier3: try: response = adapter.call_with_tools( system_prompt=system_prompt, messages=messages_json, tools=[], ) total_tokens_in += response.get('tokens_in', 0) total_tokens_out += response.get('tokens_out', 0) messages_json.append({ 'role': 'assistant', 'content': response.get('text', 'I have proposed actions that require your approval.'), }) except Exception: messages_json.append({ 'role': 'assistant', 'content': 'I have proposed actions that require your approval. Please review the pending items above.', }) break else: assistant_text = response.get('text', '') messages_json.append({'role': 'assistant', 'content': assistant_text}) break else: # Loop exhausted — force a final text response without tools try: response = adapter.call_with_tools( system_prompt=system_prompt, messages=messages_json, tools=[], ) total_tokens_in += response.get('tokens_in', 0) total_tokens_out += response.get('tokens_out', 0) messages_json.append({ 'role': 'assistant', 'content': response.get('text', 'I reached the maximum number of steps. Please continue the conversation.'), }) except Exception: _logger.warning("Failed to get final summary after max tool calls") session.write({ 'message_ids_json': json.dumps(messages_json), 'token_count_in': session.token_count_in + total_tokens_in, 'token_count_out': session.token_count_out + total_tokens_out, 'ai_provider': provider, 'ai_model': adapter._get_model_name(), }) pending = self.env['fusion.accounting.match.history'].search([ ('session_id', '=', session.id), ('decision', '=', 'pending'), ]) return { 'text': response.get('text', ''), 'pending_approvals': [{ 'id': p.id, 'tool_name': p.tool_name, 'params': p.tool_params, 'reasoning': p.ai_reasoning, 'confidence': p.ai_confidence, 'amount': p.amount, } for p in pending], 'session_id': session.id, } def approve_action(self, match_history_id): history = self.env['fusion.accounting.match.history'].browse(match_history_id) if not history.exists() or history.decision != 'pending': raise UserError(_("Action not found or already decided.")) params = json.loads(history.tool_params or '{}') result = self._execute_tool(history.tool_name, params, history.session_id.id) history.write({ 'decision': 'approved', 'decided_at': fields.Datetime.now(), 'decided_by': self.env.user.id, 'tool_result': json.dumps(result) if not isinstance(result, str) else result, }) if history.rule_id: history.rule_id.sudo()._record_decision(approved=True) # C1: Update session messages_json so next chat turn has coherent history self._update_session_after_decision(history, result) # M8: Trigger promotion check after approval try: self.env['fusion.accounting.scoring'].check_promotions() except Exception: _logger.exception("Error checking promotions after approval") return result def _check_rule_proposal(self, tool_name, params, session): """Detect repeated patterns and propose new rules when 3+ identical matches.""" recent = self.env['fusion.accounting.match.history'].search([ ('tool_name', '=', tool_name), ('decision', 'in', ('approved', 'auto')), ('company_id', '=', self.env.company.id), ], limit=20, order='proposed_at desc') if len(recent) < 3: return from collections import Counter param_keys = [] for h in recent: try: p = json.loads(h.tool_params or '{}') key_parts = [] for k in sorted(p.keys()): if k not in ('limit', 'date_from', 'date_to'): key_parts.append(f'{k}={json.dumps(p[k], sort_keys=True)}') if key_parts: param_keys.append('|'.join(key_parts)) except json.JSONDecodeError: continue counts = Counter(param_keys) for pattern, count in counts.most_common(3): if count < 3: break existing = self.env['fusion.accounting.rule'].search([ ('match_logic', 'ilike', pattern[:50]), ('company_id', '=', self.env.company.id), ], limit=1) if existing: continue self.env['fusion.accounting.rule'].create({ 'name': f'AI Pattern: {tool_name} ({pattern[:40]})', 'rule_type': 'match', 'description': f'Automatically detected pattern from {count} approved uses of {tool_name}.', 'match_logic': f'When using {tool_name} with parameters matching: {pattern}', 'created_by': 'ai', 'approval_tier': 'needs_approval', 'company_id': self.env.company.id, }) _logger.info("AI proposed rule for pattern: %s (%d matches)", tool_name, count) def reject_action(self, match_history_id, reason=''): history = self.env['fusion.accounting.match.history'].browse(match_history_id) if not history.exists() or history.decision != 'pending': raise UserError(_("Action not found or already decided.")) history.write({ 'decision': 'rejected', 'decided_at': fields.Datetime.now(), 'decided_by': self.env.user.id, 'rejection_reason': reason, }) if history.rule_id: history.rule_id.sudo()._record_decision(approved=False) # C1: Update session messages_json so next chat turn has coherent history reject_result = {'status': 'rejected', 'reason': reason} self._update_session_after_decision(history, reject_result) return reject_result def _update_session_after_decision(self, history, result): """Update session messages_json to replace pending_approval placeholder with actual tool result, preventing dangling tool_use blocks.""" session = history.session_id if not session or not session.message_ids_json: return try: messages = json.loads(session.message_ids_json) result_str = json.dumps(result) if not isinstance(result, str) else result updated = False for msg in messages: if msg.get('role') != 'user': continue content = msg.get('content') if isinstance(content, list): for block in content: if (isinstance(block, dict) and block.get('type') == 'tool_result' and 'pending_approval' in str(block.get('content', ''))): # Check if this is the matching tool_result block if str(history.id) in str(block.get('content', '')): block['content'] = result_str updated = True break if updated: break if updated: session.write({'message_ids_json': json.dumps(messages)}) except Exception: _logger.warning("Failed to update session messages after decision for history %s", history.id) # ---------------------------------------------------------------- # Cron: Auto-Reconcile Inter-Account Transfers # ---------------------------------------------------------------- @api.model def _cron_reconcile_transfers(self): """Automatically reconcile inter-account credit card payments. When a payment is made from a bank account (e.g. Scotia Current) to a credit card (e.g. Scotia Passport Visa), two bank statement lines appear: - Source side: "MB-CREDIT CARD" (negative) — reconciled by model 38/35 - CC side: "PAYMENT FROM *7814" (positive) — needs matching The source-side reconciliation creates outstanding entries on account 493. This cron matches the CC-side lines against those outstanding entries by exact amount and closest date (within 3 days). """ AML = self.env['account.move.line'].sudo() BSL = self.env['account.bank.statement.line'].sudo() company_partner_id = self.env.company.partner_id.id total_reconciled = 0 for source_jid, cc_jid, outstanding_acct_id in TRANSFER_PAIRS: # Find all unreconciled INCOMING lines on the credit card journal cc_lines = BSL.search([ ('journal_id', '=', cc_jid), ('is_reconciled', '=', False), ('amount', '>', 0), # Incoming payments only ('company_id', '=', self.env.company.id), ]) if not cc_lines: continue journal_name = cc_lines[0].journal_id.name _logger.info( "Transfer reconcile: %s — %d incoming unreconciled lines", journal_name, len(cc_lines), ) reconciled = 0 skipped = 0 for line in cc_lines: line_date = line.move_id.date amount = line.amount # Find outstanding entries with exact matching amount candidates = AML.search([ ('account_id', '=', outstanding_acct_id), ('partner_id', '=', company_partner_id), ('reconciled', '=', False), ('amount_residual', '=', amount), ]) if not candidates: skipped += 1 continue # Pick the candidate closest in date (within 3 days) best = None best_gap = 999 for c in candidates: gap = abs((c.date - line_date).days) if gap < best_gap: best_gap = gap best = c if best_gap > 7: skipped += 1 continue # Set partner and reconcile try: line.partner_id = company_partner_id line.set_line_bank_statement_line(best.ids) reconciled += 1 except Exception as e: _logger.warning( "Transfer reconcile failed: line %s (%s, $%.2f): %s", line.id, line.payment_ref, amount, e, ) # Commit every 50 lines to avoid long transactions if reconciled % 50 == 0 and reconciled > 0: self.env.cr.commit() self.env.cr.commit() total_reconciled += reconciled _logger.info( "Transfer reconcile: %s — reconciled %d, skipped %d", journal_name, reconciled, skipped, ) _logger.info("Transfer reconcile complete: %d total reconciled", total_reconciled)