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.', )