import json import logging import time from odoo import models, fields, api, _ from odoo.exceptions import UserError _logger = logging.getLogger(__name__) 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: defn = { 'name': tool.name, 'description': tool.description, } 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() 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} 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': 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.tool_call_count += len(tool_results) 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': self._get_config('ai_provider', 'claude'), '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) 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) return {'status': 'rejected', 'reason': reason}