507 lines
22 KiB
Python
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)
|