refactor(fusion_accounting): move AI module code into fusion_accounting_ai sub-module
git mv preserves history. fusion_accounting/ retains only __manifest__.py, __init__.py, CLAUDE.md, and docs/ — the meta-module shell. All Python, data, views, security, services, static, tests, wizards, report move to fusion_accounting_ai/. Manifest data list updated; security.xml move to _core deferred to Task 12. Made-with: Cursor
This commit is contained in:
947
fusion_accounting_ai/services/agent.py
Normal file
947
fusion_accounting_ai/services/agent.py
Normal file
@@ -0,0 +1,947 @@
|
||||
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__)
|
||||
|
||||
# In-memory execution state for live status polling.
|
||||
# Key: session_id, Value: {thinking, tool_calls, status}
|
||||
# Cleared after each chat() call completes.
|
||||
_execution_state = {}
|
||||
|
||||
|
||||
def get_execution_state(session_id):
|
||||
"""Get the current execution state for a session (called by polling endpoint)."""
|
||||
return _execution_state.get(session_id, {'status': 'idle', 'thinking': '', 'tool_calls': []})
|
||||
|
||||
|
||||
# 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)
|
||||
|
||||
# Domains that need deeper reasoning → use Sonnet
|
||||
COMPLEX_DOMAINS = {'audit', 'month_end', 'hst_management', 'payroll_management'}
|
||||
# Keywords in user messages that suggest complex analysis → use Sonnet
|
||||
COMPLEX_KEYWORDS = {
|
||||
'audit', 'analyze', 'analyse', 'review all', 'full report', 'investigate',
|
||||
'month-end', 'month end', 'close the books', 'hst filing', 'tax return',
|
||||
'what went wrong', 'why is', 'explain the difference', 'compare',
|
||||
}
|
||||
|
||||
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 _route_model(self, session, user_message, has_image=False):
|
||||
"""Smart model routing: Haiku for routine tool calling, Sonnet for complex analysis.
|
||||
Returns (model_name, can_escalate) — can_escalate=True means Haiku is trying first
|
||||
and we should check if it needs help."""
|
||||
provider = session.ai_provider or self._get_config('ai_provider', 'claude')
|
||||
if provider != 'claude':
|
||||
return None, False
|
||||
|
||||
# Always use Sonnet for images (vision quality matters for OCR)
|
||||
if has_image:
|
||||
return 'claude-sonnet-4-6', False
|
||||
|
||||
# Use Sonnet for complex domains
|
||||
if session.context_domain in self.COMPLEX_DOMAINS:
|
||||
return 'claude-sonnet-4-6', False
|
||||
|
||||
# Use Sonnet if the message contains complex analysis keywords
|
||||
msg_lower = (user_message or '').lower()
|
||||
if any(kw in msg_lower for kw in self.COMPLEX_KEYWORDS):
|
||||
return 'claude-sonnet-4-6', False
|
||||
|
||||
# Default: Haiku with escalation enabled
|
||||
return 'claude-haiku-4-5', True
|
||||
|
||||
def _should_escalate(self, response, tool_calls_log, turn):
|
||||
"""Check if Haiku's response suggests it needs Sonnet's help."""
|
||||
text = (response.get('text') or '').lower()
|
||||
|
||||
# Haiku said it can't do something
|
||||
uncertainty_phrases = [
|
||||
"i'm not sure", "i cannot determine", "i don't have enough",
|
||||
"unable to", "i'm unable", "this is complex", "beyond my",
|
||||
"i need more context", "difficult to assess", "i apologize",
|
||||
"i'm having trouble", "let me think about this differently",
|
||||
]
|
||||
if any(phrase in text for phrase in uncertainty_phrases):
|
||||
return True
|
||||
|
||||
# Haiku made no tool calls on first turn when it probably should have
|
||||
# (user asked a question but Haiku just gave text without using tools)
|
||||
if turn == 0 and not response.get('tool_calls') and not text:
|
||||
return True
|
||||
|
||||
# Haiku had multiple tool errors
|
||||
error_count = sum(1 for tc in tool_calls_log if tc.get('status') == 'error')
|
||||
if error_count >= 2:
|
||||
return True
|
||||
|
||||
# Response is very short for a data question (Haiku might be confused)
|
||||
if turn == 0 and not response.get('tool_calls') and len(text) < 50:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
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, indent=2, default=str) if params else '{}',
|
||||
'tool_result': json.dumps(result, indent=2, default=str) 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, image=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 '[]')
|
||||
|
||||
# Build user message — may include image for vision
|
||||
if image and isinstance(image, dict) and image.get('base64'):
|
||||
user_content = []
|
||||
if user_message:
|
||||
user_content.append({'type': 'text', 'text': user_message})
|
||||
user_content.append({
|
||||
'type': 'image',
|
||||
'source': {
|
||||
'type': 'base64',
|
||||
'media_type': image.get('media_type', 'image/png'),
|
||||
'data': image['base64'],
|
||||
},
|
||||
})
|
||||
messages_json.append({'role': 'user', 'content': user_content})
|
||||
else:
|
||||
messages_json.append({'role': 'user', 'content': user_message})
|
||||
|
||||
# Smart model routing: Haiku for routine, Sonnet for complex
|
||||
has_image = bool(image and isinstance(image, dict) and image.get('base64'))
|
||||
model_override, can_escalate = self._route_model(session, user_message, has_image=has_image)
|
||||
escalated = False
|
||||
if model_override:
|
||||
_logger.info("Model routing: %s → %s (escalation=%s)", session.name, model_override, can_escalate)
|
||||
|
||||
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
|
||||
tool_calls_log = [] # Track tool calls for frontend display
|
||||
reconciliation_data = None # Raw data from suggest_bank_line_matches
|
||||
|
||||
# Initialize live execution state for polling
|
||||
_execution_state[session.id] = {
|
||||
'status': 'thinking',
|
||||
'thinking': '',
|
||||
'tool_calls': [],
|
||||
'turn': 0,
|
||||
}
|
||||
|
||||
for turn in range(max_turns):
|
||||
_execution_state[session.id]['status'] = 'calling_ai'
|
||||
_execution_state[session.id]['turn'] = turn + 1
|
||||
|
||||
response = adapter.call_with_tools(
|
||||
system_prompt=system_prompt,
|
||||
messages=messages_json,
|
||||
tools=tool_definitions,
|
||||
model_override=model_override,
|
||||
)
|
||||
total_tokens_in += response.get('tokens_in', 0)
|
||||
total_tokens_out += response.get('tokens_out', 0)
|
||||
|
||||
# Check if Haiku needs to escalate to Sonnet
|
||||
if can_escalate and not escalated and self._should_escalate(response, tool_calls_log, turn):
|
||||
_logger.info("Escalating %s from Haiku → Sonnet (turn %d)", session.name, turn)
|
||||
model_override = 'claude-sonnet-4-6'
|
||||
escalated = True
|
||||
can_escalate = False
|
||||
_execution_state[session.id]['status'] = 'escalating'
|
||||
# Re-call with Sonnet
|
||||
response = adapter.call_with_tools(
|
||||
system_prompt=system_prompt,
|
||||
messages=messages_json,
|
||||
tools=tool_definitions,
|
||||
model_override=model_override,
|
||||
)
|
||||
total_tokens_in += response.get('tokens_in', 0)
|
||||
total_tokens_out += response.get('tokens_out', 0)
|
||||
|
||||
# Capture thinking text for live display
|
||||
thinking = ''
|
||||
for block in (response.get('raw_content') or []):
|
||||
if hasattr(block, 'type') and block.type == 'thinking':
|
||||
thinking = block.thinking
|
||||
break
|
||||
if thinking:
|
||||
_execution_state[session.id]['thinking'] = thinking[:500] # Truncated for live display
|
||||
|
||||
if response.get('tool_calls'):
|
||||
tool_results = []
|
||||
_execution_state[session.id]['status'] = 'calling_tools'
|
||||
|
||||
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'
|
||||
|
||||
# Update live state: show which tool is running
|
||||
_execution_state[session.id]['tool_calls'].append({
|
||||
'name': tool_name, 'status': 'running',
|
||||
})
|
||||
|
||||
if tier == '3':
|
||||
has_pending_tier3 = True
|
||||
history_rec = self._log_match_history(
|
||||
session, tool_name, tool_params, None,
|
||||
reasoning=thinking or '',
|
||||
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}',
|
||||
}),
|
||||
})
|
||||
tool_calls_log.append({
|
||||
'name': tool_name,
|
||||
'tier': tier,
|
||||
'status': 'pending_approval',
|
||||
'summary': self._build_tool_call_summary(tool_name, tool_params, None),
|
||||
})
|
||||
_execution_state[session.id]['tool_calls'][-1]['status'] = 'pending'
|
||||
else:
|
||||
t0 = time.time()
|
||||
result = self._execute_tool(tool_name, tool_params, session.id)
|
||||
elapsed = round((time.time() - t0) * 1000)
|
||||
self._log_match_history(
|
||||
session, tool_name, tool_params, result,
|
||||
reasoning=thinking or '',
|
||||
tier=tier,
|
||||
)
|
||||
tool_results.append({
|
||||
'tool_call_id': tc.get('id', ''),
|
||||
'result': json.dumps(result) if not isinstance(result, str) else result,
|
||||
})
|
||||
tc_status = 'error' if isinstance(result, dict) and result.get('error') else 'ok'
|
||||
tc_summary = self._build_tool_call_summary(tool_name, tool_params, result)
|
||||
|
||||
# Capture reconciliation data for direct frontend rendering
|
||||
if tool_name == 'suggest_bank_line_matches' and tc_status == 'ok':
|
||||
reconciliation_data = result
|
||||
tool_calls_log.append({
|
||||
'name': tool_name,
|
||||
'tier': tier,
|
||||
'status': tc_status,
|
||||
'summary': tc_summary,
|
||||
'duration_ms': elapsed,
|
||||
})
|
||||
# Update live state
|
||||
_execution_state[session.id]['tool_calls'][-1].update({
|
||||
'status': tc_status, 'summary': tc_summary, 'duration_ms': elapsed,
|
||||
})
|
||||
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=[],
|
||||
model_override=model_override,
|
||||
)
|
||||
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=[],
|
||||
model_override=model_override,
|
||||
)
|
||||
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': model_override or adapter._get_model_name(),
|
||||
})
|
||||
|
||||
pending = self.env['fusion.accounting.match.history'].search([
|
||||
('session_id', '=', session.id),
|
||||
('decision', '=', 'pending'),
|
||||
])
|
||||
|
||||
# Clear live execution state
|
||||
_execution_state.pop(session.id, None)
|
||||
|
||||
# Add escalation marker to tool calls log if it happened
|
||||
if escalated:
|
||||
tool_calls_log.insert(0, {
|
||||
'name': 'model_escalation',
|
||||
'tier': '-',
|
||||
'status': 'ok',
|
||||
'summary': 'Escalated from Haiku to Sonnet for deeper analysis',
|
||||
'duration_ms': 0,
|
||||
})
|
||||
|
||||
result_payload = {
|
||||
'text': response.get('text', ''),
|
||||
'tool_calls_log': tool_calls_log,
|
||||
'pending_approvals': [self._format_pending_approval(p) for p in pending],
|
||||
'session_id': session.id,
|
||||
'model_used': model_override or adapter._get_model_name(),
|
||||
}
|
||||
|
||||
# Attach raw reconciliation data so frontend renders it directly
|
||||
# (instead of relying on AI to format fusion-table JSON correctly)
|
||||
if reconciliation_data:
|
||||
result_payload['reconciliation_table'] = reconciliation_data
|
||||
|
||||
return result_payload
|
||||
|
||||
def _build_tool_call_summary(self, tool_name, params, result):
|
||||
"""Build a one-line summary of what a tool call did, for the collapsed tool log."""
|
||||
try:
|
||||
# Result-based summaries (when we have output)
|
||||
if result and isinstance(result, dict) and not result.get('error'):
|
||||
count = result.get('count')
|
||||
status = result.get('status')
|
||||
if status == 'created':
|
||||
name = result.get('name', '')
|
||||
return f"Created {name}" if name else "Created successfully"
|
||||
if status == 'matched':
|
||||
return "Matched successfully"
|
||||
if count is not None:
|
||||
return f"Found {count} result{'s' if count != 1 else ''}"
|
||||
if 'balance' in result:
|
||||
return f"Balance: ${result['balance']:,.2f}"
|
||||
if 'total' in result:
|
||||
return f"Total: ${result['total']:,.2f}"
|
||||
if 'entries' in result:
|
||||
return f"Found {len(result['entries'])} entries"
|
||||
if 'accounts' in result:
|
||||
return f"Found {len(result['accounts'])} accounts"
|
||||
if status:
|
||||
return str(status)
|
||||
|
||||
if result and isinstance(result, dict) and result.get('error'):
|
||||
err = str(result['error'])
|
||||
return f"Error: {err[:80]}"
|
||||
|
||||
# Params-based summaries (for pending approvals, no result yet)
|
||||
if params:
|
||||
ref = params.get('ref', params.get('reference', params.get('name', '')))
|
||||
amount = params.get('amount')
|
||||
lines = params.get('lines', [])
|
||||
if lines:
|
||||
total = sum(l.get('debit', 0) for l in lines)
|
||||
return f"{ref} — ${total:,.2f}" if ref else f"${total:,.2f} journal entry"
|
||||
if ref and amount:
|
||||
return f"{ref} — ${abs(amount):,.2f}"
|
||||
if ref:
|
||||
return str(ref)
|
||||
|
||||
return "Completed"
|
||||
except Exception:
|
||||
return "Completed"
|
||||
|
||||
def _format_pending_approval(self, history):
|
||||
"""Build a rich approval payload so the UI can show exactly what's being approved."""
|
||||
params = {}
|
||||
try:
|
||||
params = json.loads(history.tool_params or '{}')
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Extract amount from params — look in common locations
|
||||
amount = history.amount or 0.0
|
||||
if not amount:
|
||||
# Try to compute from journal entry lines
|
||||
lines = params.get('lines', [])
|
||||
if lines:
|
||||
amount = sum(l.get('debit', 0) for l in lines)
|
||||
# Or from direct amount field
|
||||
if not amount:
|
||||
amount = abs(params.get('amount', 0))
|
||||
|
||||
# Build a human-readable summary of what this action will do
|
||||
summary = self._build_approval_summary(history.tool_name, params)
|
||||
|
||||
return {
|
||||
'id': history.id,
|
||||
'tool_name': history.tool_name,
|
||||
'params': history.tool_params,
|
||||
'reasoning': history.ai_reasoning,
|
||||
'confidence': history.ai_confidence,
|
||||
'amount': amount,
|
||||
'summary': summary,
|
||||
}
|
||||
|
||||
def _resolve_account_label(self, account_id):
|
||||
"""Resolve an account ID to 'code - name' for display."""
|
||||
if not account_id:
|
||||
return '?'
|
||||
try:
|
||||
acct = self.env['account.account'].browse(int(account_id))
|
||||
if acct.exists():
|
||||
return f"{acct.code} {acct.name}"
|
||||
except Exception:
|
||||
pass
|
||||
return str(account_id)
|
||||
|
||||
def _build_approval_summary(self, tool_name, params):
|
||||
"""Generate a short human-readable description of what a Tier 3 action will do."""
|
||||
try:
|
||||
if tool_name == 'create_payroll_journal_entry':
|
||||
ref = params.get('ref', 'Payroll Entry')
|
||||
date = params.get('date', '?')
|
||||
lines = params.get('lines', [])
|
||||
total = sum(l.get('debit', 0) for l in lines)
|
||||
acct_names = []
|
||||
for l in lines:
|
||||
aid = l.get('account_id', '')
|
||||
acct_label = self._resolve_account_label(aid)
|
||||
if l.get('debit'):
|
||||
acct_names.append(f"Dr {acct_label}: ${l['debit']:,.2f}")
|
||||
elif l.get('credit'):
|
||||
acct_names.append(f"Cr {acct_label}: ${l['credit']:,.2f}")
|
||||
detail = ' / '.join(acct_names) if acct_names else ''
|
||||
return f"{ref} on {date} — ${total:,.2f}\n{detail}"
|
||||
|
||||
elif tool_name == 'create_vendor_bill':
|
||||
partner = params.get('partner_name', params.get('partner_id', '?'))
|
||||
amount = params.get('amount', 0)
|
||||
ref = params.get('ref', params.get('reference', ''))
|
||||
date = params.get('date', '?')
|
||||
return f"Vendor bill for {partner} — ${abs(amount):,.2f} on {date}" + (f" ({ref})" if ref else "")
|
||||
|
||||
elif tool_name == 'register_bill_payment':
|
||||
bill_id = params.get('bill_id', '?')
|
||||
amount = params.get('amount', 0)
|
||||
journal = params.get('journal_id', '?')
|
||||
return f"Pay bill #{bill_id} — ${abs(amount):,.2f} from journal {journal}"
|
||||
|
||||
elif tool_name == 'create_expense_entry':
|
||||
ref = params.get('ref', params.get('memo', 'Expense'))
|
||||
amount = params.get('amount', 0)
|
||||
account = params.get('expense_account_id', '?')
|
||||
return f"{ref} — ${abs(amount):,.2f} to account {account}"
|
||||
|
||||
elif tool_name == 'register_hst_payment':
|
||||
amount = params.get('amount', 0)
|
||||
date = params.get('date', '?')
|
||||
return f"HST remittance — ${abs(amount):,.2f} on {date}"
|
||||
|
||||
elif tool_name in ('apply_payment', 'send_followup', 'create_payment_reminder'):
|
||||
partner = params.get('partner_name', params.get('partner_id', '?'))
|
||||
amount = params.get('amount', 0)
|
||||
return f"{tool_name.replace('_', ' ').title()} for {partner}" + (f" — ${abs(amount):,.2f}" if amount else "")
|
||||
|
||||
elif tool_name == 'flag_entry':
|
||||
move_id = params.get('move_id', '?')
|
||||
reason = params.get('reason', '')
|
||||
return f"Flag entry #{move_id}" + (f": {reason}" if reason else "")
|
||||
|
||||
else:
|
||||
# Generic fallback: show key params
|
||||
parts = []
|
||||
for key in ('ref', 'reference', 'name', 'partner_name', 'date', 'move_id'):
|
||||
if key in params:
|
||||
parts.append(f"{key}: {params[key]}")
|
||||
if 'amount' in params:
|
||||
parts.append(f"${abs(params['amount']):,.2f}")
|
||||
return ' | '.join(parts) if parts else json.dumps(params)[:120]
|
||||
|
||||
except Exception:
|
||||
return str(params)[:120]
|
||||
|
||||
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)
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# One-time: Match payroll cheque bank lines against open payroll liability entries
|
||||
# ----------------------------------------------------------------
|
||||
@api.model
|
||||
def _reconcile_payroll_cheques(self):
|
||||
"""Reconcile payroll cheque bank lines using writeoff to Payroll Liabilities (2201).
|
||||
|
||||
Your payroll JEs post:
|
||||
Dr Salaries / Dr ER CPP-EI / Dr CRA Taxes
|
||||
Cr 2201 Payroll Liabilities (net pay = cheque amount)
|
||||
|
||||
When the cheque clears the bank, the bank line shows:
|
||||
"Cheque 1773 : Cheque" = -$1,477.95
|
||||
|
||||
This method finds cheque bank lines that have a matching payroll liability
|
||||
entry (same amount) and applies a reconcile model that writes off to account
|
||||
433 (Payroll Liabilities). This debits 433 to clear the liability.
|
||||
|
||||
Non-payroll cheques (no matching entry on 433) are skipped.
|
||||
"""
|
||||
PAYROLL_LIABILITY_ACCT_ID = 433 # code 2201
|
||||
SCOTIA_CURRENT_JOURNAL_ID = 50
|
||||
|
||||
AML = self.env['account.move.line'].sudo()
|
||||
BSL = self.env['account.bank.statement.line'].sudo()
|
||||
RecModel = self.env['account.reconcile.model'].sudo()
|
||||
|
||||
# Find the payroll cheque reconcile model (must be pre-created via XML or manually)
|
||||
model = RecModel.search([
|
||||
('name', 'ilike', 'Payroll Cheque'),
|
||||
('company_id', '=', self.env.company.id),
|
||||
], limit=1)
|
||||
|
||||
if not model:
|
||||
_logger.warning("Payroll cheque reconcile: no 'Payroll Cheque' model found — create one manually")
|
||||
return
|
||||
|
||||
# Find all unreconciled cheque lines on Scotia Current (negative = outgoing)
|
||||
# Only process lines after lock date to avoid lock date errors
|
||||
cheque_lines = BSL.search([
|
||||
('journal_id', '=', SCOTIA_CURRENT_JOURNAL_ID),
|
||||
('is_reconciled', '=', False),
|
||||
('amount', '<', 0),
|
||||
('payment_ref', 'ilike', 'cheque'),
|
||||
('company_id', '=', self.env.company.id),
|
||||
], order='move_id asc')
|
||||
|
||||
# Filter to post-lock-date lines only
|
||||
lock_date = self.env.company.fiscalyear_lock_date
|
||||
if lock_date:
|
||||
cheque_lines = cheque_lines.filtered(lambda l: l.move_id.date > lock_date)
|
||||
|
||||
_logger.info("Payroll cheque reconcile: found %d unreconciled cheque lines (post lock date)", len(cheque_lines))
|
||||
|
||||
# Build set of all known payroll liability credit amounts
|
||||
payroll_credit_amounts = set()
|
||||
for aml in AML.search([
|
||||
('account_id', '=', PAYROLL_LIABILITY_ACCT_ID),
|
||||
('parent_state', '=', 'posted'),
|
||||
('credit', '>', 0),
|
||||
]):
|
||||
payroll_credit_amounts.add(round(aml.credit, 2))
|
||||
|
||||
# Filter: only reconcile cheques that have a matching payroll liability entry
|
||||
payroll_lines = cheque_lines.filtered(
|
||||
lambda l: round(abs(l.amount), 2) in payroll_credit_amounts
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
"Payroll cheque reconcile: %d payroll, %d non-payroll (skipped)",
|
||||
len(payroll_lines), len(cheque_lines) - len(payroll_lines),
|
||||
)
|
||||
|
||||
if not payroll_lines:
|
||||
_logger.info("Payroll cheque reconcile: nothing to reconcile")
|
||||
return
|
||||
|
||||
# Apply the reconcile model to payroll cheque lines
|
||||
try:
|
||||
model._apply_reconcile_models(payroll_lines)
|
||||
self.env.cr.commit()
|
||||
except Exception as e:
|
||||
_logger.exception("Payroll cheque reconcile batch failed: %s", e)
|
||||
self.env.cr.rollback()
|
||||
return
|
||||
|
||||
# Count results
|
||||
still_unreconciled = payroll_lines.filtered(lambda l: not l.is_reconciled)
|
||||
reconciled = len(payroll_lines) - len(still_unreconciled)
|
||||
|
||||
for line in still_unreconciled[:10]:
|
||||
_logger.info("Payroll cheque still unreconciled: %s $%.2f", line.payment_ref, abs(line.amount))
|
||||
|
||||
_logger.info(
|
||||
"Payroll cheque reconcile complete: %d reconciled, %d still unreconciled",
|
||||
reconciled, len(still_unreconciled),
|
||||
)
|
||||
Reference in New Issue
Block a user