Files
odoo-ai/agent_service/agents/finance_agent.py
ActiveBlue Build dab6354d09 feat(finance): add FinanceAgent with full 6-step contract and FinanceTools
- FinanceAgent implements _plan/_gather/_reason/_act/_report lifecycle
- Proactive sweep flags 30+ day overdue invoices, auto-sends reminders >60d/>$1k
- PeerBus handler exposes overdue_summary, payment_history, financial_summary
- HIPAA-locked: Ollama only, no cloud LLM routing
- FinanceTools wraps OdooClient with 9 read/write methods on account.move
- finance_system.txt prompt enforces no-write-to-invoices rule
- research/finance_research.md documents Odoo 18 account model details

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 17:51:49 -04:00

349 lines
16 KiB
Python

from __future__ import annotations
import json, logging
from .base_agent import BaseAgent, AgentReport, AgentDirective, SweepReport
from ..tools.finance_tools import FinanceTools
from ..llm.tool_validator import validate_agent_tools
logger = logging.getLogger(__name__)
FINANCE_TOOLS = [
{'name': 'get_invoices', 'description': 'Retrieve invoices with filters',
'parameters': {
'state': {'type': 'string', 'enum': ['draft','posted','cancel','all'], 'optional': True},
'partner_id': {'type': 'integer', 'optional': True},
'date_from': {'type': 'string', 'optional': True},
'date_to': {'type': 'string', 'optional': True},
'move_type': {'type': 'string', 'enum': ['out_invoice','in_invoice','all'], 'optional': True},
'limit': {'type': 'integer', 'default': 50, 'optional': True},
}},
{'name': 'get_overdue_invoices', 'description': 'Get overdue unpaid invoices',
'parameters': {
'partner_id': {'type': 'integer', 'optional': True},
'min_days_overdue': {'type': 'integer', 'default': 1, 'optional': True},
}},
{'name': 'get_unreconciled_statements', 'description': 'Get unreconciled bank statement lines',
'parameters': {
'journal_id': {'type': 'integer'},
'date_from': {'type': 'string', 'optional': True},
'date_to': {'type': 'string', 'optional': True},
}},
{'name': 'send_payment_reminder', 'description': 'Send payment reminder for overdue invoice',
'parameters': {
'invoice_id': {'type': 'integer'},
'custom_message': {'type': 'string', 'optional': True},
}},
{'name': 'get_financial_summary', 'description': 'Get financial summary for a period',
'parameters': {'period': {'type': 'string', 'optional': True}}},
{'name': 'get_payment_history', 'description': 'Get payment history for a partner',
'parameters': {'partner_id': {'type': 'integer'}}},
{'name': 'flag_for_review', 'description': 'Flag a record for human review',
'parameters': {
'model': {'type': 'string'},
'record_id': {'type': 'integer'},
'reason': {'type': 'string'},
'severity': {'type': 'string', 'enum': ['low','medium','high'], 'optional': True},
}},
{'name': 'post_chatter_note', 'description': 'Post a note on a record chatter',
'parameters': {
'model': {'type': 'string'},
'record_id': {'type': 'integer'},
'note': {'type': 'string'},
}},
]
class FinanceAgent(BaseAgent):
name = 'finance_agent'
domain = 'finance'
required_odoo_module = 'account'
system_prompt_file = 'finance_system.txt'
tools = FINANCE_TOOLS
def __init__(self, odoo, llm, peer_bus=None):
super().__init__(odoo, llm, peer_bus)
self._ft = FinanceTools(odoo)
self._plan_result = None
self._gathered_data = {}
self._actions_taken = []
self._escalations_list = []
self._recommendations = []
# ------------------------------------------------------------------
# Step 1: _plan
# ------------------------------------------------------------------
async def _plan(self, directive: AgentDirective) -> dict:
intent = (directive.intent or '').lower()
plan = {
'fetch_overdue': False,
'fetch_summary': False,
'fetch_invoices': False,
'send_reminders': False,
'partner_id': directive.context.get('partner_id'),
'period': directive.context.get('period', 'this_month'),
'journal_id': directive.context.get('journal_id'),
}
if any(k in intent for k in ('overdue', 'remind', 'reminder', 'collect')):
plan['fetch_overdue'] = True
if 'remind' in intent or 'send' in intent:
plan['send_reminders'] = True
if any(k in intent for k in ('summary', 'overview', 'report', 'dashboard')):
plan['fetch_summary'] = True
if any(k in intent for k in ('invoice', 'bill', 'payment')):
plan['fetch_invoices'] = True
if not any([plan['fetch_overdue'], plan['fetch_summary'], plan['fetch_invoices']]):
plan['fetch_summary'] = True
plan['fetch_overdue'] = True
self._plan_result = plan
logger.debug('FinanceAgent plan: %s', plan)
return plan
# ------------------------------------------------------------------
# Step 2: _gather (read-only)
# ------------------------------------------------------------------
async def _gather(self, ctx: dict) -> dict:
plan = self._plan_result or {}
data: dict = {}
if plan.get('fetch_overdue'):
kwargs = {}
if plan.get('partner_id'):
kwargs['partner_id'] = plan['partner_id']
data['overdue'] = await self._ft.get_overdue_invoices(**kwargs)
if plan.get('fetch_summary'):
period = plan.get('period', 'this_month')
data['summary'] = await self._ft.get_financial_summary(period=period)
if plan.get('fetch_invoices'):
kwargs = {'state': 'posted', 'limit': 50}
if plan.get('partner_id'):
kwargs['partner_id'] = plan['partner_id']
data['invoices'] = await self._ft.get_invoices(**kwargs)
self._gathered_data = data
return data
# ------------------------------------------------------------------
# Step 3: _reason (no tool calls — pure analysis)
# ------------------------------------------------------------------
async def _reason(self, ctx: dict) -> dict:
data = self._gathered_data
analysis: dict = {'flags': [], 'recommendations': [], 'escalations': []}
overdue = data.get('overdue', [])
if overdue:
total_overdue = sum(inv.get('amount_residual', 0) for inv in overdue)
analysis['overdue_count'] = len(overdue)
analysis['overdue_total'] = total_overdue
if total_overdue > 50000:
analysis['escalations'].append(
f'High overdue balance: {total_overdue:.2f} across {len(overdue)} invoices'
)
for inv in overdue:
days = inv.get('days_overdue', 0)
if days > 90:
analysis['flags'].append({
'invoice_id': inv.get('id'),
'partner': inv.get('partner_name', 'Unknown'),
'days': days,
'amount': inv.get('amount_residual', 0),
'reason': f'Invoice overdue by {days} days',
})
if days > 30 and inv.get('amount_residual', 0) > 5000:
analysis['recommendations'].append({
'action': 'send_payment_reminder',
'invoice_id': inv.get('id'),
'partner': inv.get('partner_name', 'Unknown'),
'amount': inv.get('amount_residual', 0),
})
summary = data.get('summary', {})
if summary:
rate = summary.get('collection_rate', 100)
if rate < 70:
analysis['escalations'].append(
f'Collection rate below threshold: {rate:.1f}%'
)
self._recommendations = analysis.get('recommendations', [])
self._escalations_list = analysis.get('escalations', [])
return analysis
# ------------------------------------------------------------------
# Step 4: _act (write actions)
# ------------------------------------------------------------------
async def _act(self, ctx: dict) -> list:
plan = self._plan_result or {}
results = []
if plan.get('send_reminders'):
for rec in self._recommendations:
if rec.get('action') == 'send_payment_reminder':
inv_id = rec.get('invoice_id')
if not inv_id:
continue
try:
ok = await self._ft.send_payment_reminder(invoice_id=inv_id)
results.append({
'action': 'send_payment_reminder',
'invoice_id': inv_id,
'partner': rec.get('partner'),
'success': ok,
})
except Exception as exc:
logger.warning('send_payment_reminder failed inv=%s: %s', inv_id, exc)
results.append({
'action': 'send_payment_reminder',
'invoice_id': inv_id,
'success': False,
'error': str(exc),
})
flags = (ctx.get('analysis') or {}).get('flags', [])
for flag in flags:
inv_id = flag.get('invoice_id')
if not inv_id:
continue
try:
await self._ft.flag_for_review(
model='account.move',
record_id=inv_id,
reason=flag.get('reason', 'Flagged by finance agent'),
severity='high' if flag.get('days', 0) > 90 else 'medium',
)
results.append({'action': 'flag_for_review', 'invoice_id': inv_id, 'success': True})
except Exception as exc:
logger.warning('flag_for_review failed inv=%s: %s', inv_id, exc)
self._actions_taken = results
return results
# ------------------------------------------------------------------
# Step 5: _report
# ------------------------------------------------------------------
async def _report(self, ctx: dict) -> AgentReport:
data = self._gathered_data
analysis = ctx.get('analysis') or {}
parts = []
summary = data.get('summary', {})
if summary:
total_inv = summary.get('total_invoiced', 0)
rate = summary.get('collection_rate', 0)
parts.append(f'Period summary: {total_inv:.2f} invoiced, {rate:.1f}% collected.')
overdue_count = analysis.get('overdue_count', 0)
overdue_total = analysis.get('overdue_total', 0)
if overdue_count:
parts.append(f'{overdue_count} overdue invoices totalling {overdue_total:.2f}.')
sent = [a for a in self._actions_taken if a.get('action') == 'send_payment_reminder' and a.get('success')]
if sent:
parts.append(f'Payment reminders sent: {len(sent)}.')
flagged = [a for a in self._actions_taken if a.get('action') == 'flag_for_review' and a.get('success')]
if flagged:
parts.append(f'Records flagged for review: {len(flagged)}.')
if not parts:
parts.append('Finance check complete. No significant issues found.')
return AgentReport(
agent=self.name,
summary=chr(10).join(parts),
data={
'summary': summary,
'overdue_count': overdue_count,
'overdue_total': overdue_total,
'actions_taken': self._actions_taken,
'recommendations': self._recommendations,
},
escalations=self._escalations_list,
actions_taken=self._actions_taken,
)
# ------------------------------------------------------------------
# Tool dispatcher (called by BaseAgent._loop)
# ------------------------------------------------------------------
async def _dispatch_tool(self, name: str, args: dict):
if name == 'get_invoices':
return await self._ft.get_invoices(**args)
if name == 'get_overdue_invoices':
return await self._ft.get_overdue_invoices(**args)
if name == 'get_unreconciled_statements':
return await self._ft.get_unreconciled_statements(**args)
if name == 'send_payment_reminder':
return await self._ft.send_payment_reminder(**args)
if name == 'get_financial_summary':
return await self._ft.get_financial_summary(**args)
if name == 'get_payment_history':
return await self._ft.get_payment_history(**args)
if name == 'flag_for_review':
return await self._ft.flag_for_review(**args)
if name == 'post_chatter_note':
return await self._ft.post_chatter_note(**args)
raise ValueError(f'Unknown tool: {name}')
# ------------------------------------------------------------------
# Peer bus handler
# ------------------------------------------------------------------
async def handle_peer_request(self, request: dict) -> dict:
req_type = request.get('type', '')
try:
if req_type == 'overdue_summary':
partner_id = request.get('partner_id')
kwargs = {}
if partner_id:
kwargs['partner_id'] = partner_id
overdue = await self._ft.get_overdue_invoices(**kwargs)
total = sum(inv.get('amount_residual', 0) for inv in overdue)
return {'overdue_count': len(overdue), 'overdue_total': total, 'invoices': overdue}
if req_type == 'payment_history':
partner_id = request.get('partner_id')
if not partner_id:
return {'error': 'partner_id required'}
history = await self._ft.get_payment_history(partner_id=partner_id)
return {'history': history}
if req_type == 'financial_summary':
period = request.get('period', 'this_month')
summary = await self._ft.get_financial_summary(period=period)
return {'summary': summary}
return {'error': f'Unknown peer request type: {req_type}'}
except Exception as exc:
logger.error('handle_peer_request failed type=%s: %s', req_type, exc)
return {'error': str(exc)}
# ------------------------------------------------------------------
# Proactive sweep
# ------------------------------------------------------------------
async def sweep(self) -> SweepReport:
findings = []
actions = []
try:
overdue = await self._ft.get_overdue_invoices(min_days_overdue=30)
for inv in overdue:
days = inv.get('days_overdue', 0)
amount = inv.get('amount_residual', 0)
partner = inv.get('partner_name', 'Unknown')
findings.append({
'type': 'overdue_invoice',
'invoice_id': inv.get('id'),
'partner': partner,
'days_overdue': days,
'amount': amount,
'severity': 'high' if days > 60 else 'medium',
})
if days > 60 and amount > 1000:
try:
ok = await self._ft.send_payment_reminder(invoice_id=inv.get('id'))
if ok:
actions.append({'action': 'reminder_sent', 'invoice_id': inv.get('id')})
except Exception as exc:
logger.debug('sweep reminder failed: %s', exc)
except Exception as exc:
logger.error('FinanceAgent.sweep error: %s', exc)
return SweepReport(agent=self.name, findings=[], actions=[], error=str(exc))
return SweepReport(
agent=self.name,
findings=findings,
actions=actions,
summary=f'Sweep: {len(findings)} overdue invoices found, {len(actions)} reminders sent.',
)