316 lines
13 KiB
Python
316 lines
13 KiB
Python
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}
|