Files
Odoo-Modules/fusion_accounting/services/agent.py
gsinghpal c66bdf5089 changes
2026-04-03 15:45:18 -04:00

507 lines
22 KiB
Python

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)