- 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>
349 lines
16 KiB
Python
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.',
|
|
)
|