This commit is contained in:
gsinghpal
2026-04-03 15:45:18 -04:00
parent 4cd7357aa0
commit c66bdf5089
71 changed files with 6721 additions and 118 deletions

View File

@@ -1,12 +1,21 @@
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'
@@ -41,9 +50,14 @@ class FusionAccountingAgent(models.AbstractModel):
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': tool.description,
'description': desc,
}
if tool.parameters_schema:
try:
@@ -117,6 +131,21 @@ class FusionAccountingAgent(models.AbstractModel):
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()
@@ -132,6 +161,7 @@ class FusionAccountingAgent(models.AbstractModel):
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(
@@ -151,6 +181,7 @@ class FusionAccountingAgent(models.AbstractModel):
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', ''),
@@ -184,7 +215,29 @@ class FusionAccountingAgent(models.AbstractModel):
messages_json = adapter.append_tool_results(
messages_json, response, tool_results,
)
session.tool_call_count += len(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})
@@ -210,7 +263,7 @@ class FusionAccountingAgent(models.AbstractModel):
'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_provider': provider,
'ai_model': adapter._get_model_name(),
})
@@ -249,6 +302,15 @@ class FusionAccountingAgent(models.AbstractModel):
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):
@@ -312,4 +374,133 @@ class FusionAccountingAgent(models.AbstractModel):
if history.rule_id:
history.rule_id.sudo()._record_decision(approved=False)
return {'status': 'rejected', 'reason': reason}
# 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)