From dab6354d09a25ef19bd23f05c767aba786af7624 Mon Sep 17 00:00:00 2001 From: ActiveBlue Build Date: Sun, 12 Apr 2026 17:51:49 -0400 Subject: [PATCH] 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 --- agent_service/agents/finance_agent.py | 348 +++++++++++++++++++++++ agent_service/prompts/finance_system.txt | 31 ++ agent_service/tools/finance_tools.py | 119 ++++++++ research/finance_research.md | 37 +++ 4 files changed, 535 insertions(+) create mode 100644 agent_service/agents/finance_agent.py create mode 100644 agent_service/prompts/finance_system.txt create mode 100644 agent_service/tools/finance_tools.py create mode 100644 research/finance_research.md diff --git a/agent_service/agents/finance_agent.py b/agent_service/agents/finance_agent.py new file mode 100644 index 0000000..1739089 --- /dev/null +++ b/agent_service/agents/finance_agent.py @@ -0,0 +1,348 @@ +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.', + ) diff --git a/agent_service/prompts/finance_system.txt b/agent_service/prompts/finance_system.txt new file mode 100644 index 0000000..c4d84eb --- /dev/null +++ b/agent_service/prompts/finance_system.txt @@ -0,0 +1,31 @@ +You are the Finance Agent for ActiveBlue AI, a specialist in accounts receivable, invoicing, and financial health monitoring within Odoo 18. + +## Your Role +You analyse invoice data, identify overdue balances, monitor collection rates, and take automated actions such as sending payment reminders and flagging high-risk accounts for human review. + +## Capabilities +- Retrieve invoices with flexible filters (state, partner, date range, type) +- Identify overdue invoices and calculate days overdue +- Generate financial summaries by period +- Review payment history per partner +- Send payment reminder emails via Odoo chatter +- Flag records for human review with severity levels +- Post internal notes on invoice records + +## Rules +- NEVER create, modify, or delete invoices or payments directly +- NEVER send a reminder without confirming the invoice is genuinely unpaid (state = posted, payment_state != paid) +- Flag but do not automatically write off any invoice over 90 days overdue — escalate to human +- All financial data is HIPAA-sensitive: never include account numbers or personal data in escalation messages to non-finance agents +- When uncertain about a financial decision, flag for review rather than act + +## Data Privacy +This agent is HIPAA-locked. All processing occurs on-premises using the local LLM only. No financial data is sent to cloud APIs. + +## Output Format +Respond with structured findings: +1. Summary of financial health +2. Overdue invoices list (partner, amount, days overdue) +3. Actions taken (reminders sent, flags raised) +4. Escalations requiring human review +5. Recommendations for next steps diff --git a/agent_service/tools/finance_tools.py b/agent_service/tools/finance_tools.py new file mode 100644 index 0000000..7cf2601 --- /dev/null +++ b/agent_service/tools/finance_tools.py @@ -0,0 +1,119 @@ +from __future__ import annotations +import logging +from ..tools.odoo_client import OdooClient + +logger = logging.getLogger(__name__) + + +class FinanceTools: + def __init__(self, odoo: OdooClient): + self._odoo = odoo + + async def get_invoices(self, state='all', partner_id=None, + date_from=None, date_to=None, + move_type='all', limit=50): + domain = [] + if move_type != 'all': + domain.append(['move_type', '=', move_type]) + else: + domain.append(['move_type', 'in', ['out_invoice', 'in_invoice', 'out_refund', 'in_refund']]) + if state != 'all': + domain.append(['state', '=', state]) + if partner_id: + domain.append(['partner_id', '=', partner_id]) + if date_from: + domain.append(['invoice_date', '>=', date_from]) + if date_to: + domain.append(['invoice_date', '<=', date_to]) + fields = ['name', 'move_type', 'state', 'partner_id', 'amount_total', + 'amount_residual', 'payment_state', 'invoice_date', 'invoice_date_due'] + return await self._odoo.search_read('account.move', domain, fields, limit=limit, + order='invoice_date_due desc') + + async def get_overdue_invoices(self, partner_id=None, min_days_overdue=1): + from datetime import date, timedelta + cutoff = (date.today() - timedelta(days=min_days_overdue)).isoformat() + domain = [ + ['move_type', 'in', ['out_invoice', 'out_refund']], + ['state', '=', 'posted'], + ['payment_state', 'in', ['not_paid', 'partial']], + ['invoice_date_due', '<', cutoff], + ] + if partner_id: + domain.append(['partner_id', '=', partner_id]) + fields = ['name', 'partner_id', 'amount_total', 'amount_residual', + 'invoice_date_due', 'payment_state'] + return await self._odoo.search_read('account.move', domain, fields, + order='invoice_date_due asc') + + async def get_unreconciled_statements(self, journal_id, date_from=None, date_to=None): + domain = [['journal_id', '=', journal_id], ['is_reconciled', '=', False]] + if date_from: + domain.append(['date', '>=', date_from]) + if date_to: + domain.append(['date', '<=', date_to]) + fields = ['date', 'payment_ref', 'amount', 'partner_id', 'is_reconciled', 'move_id'] + return await self._odoo.search_read('account.bank.statement.line', domain, fields, + order='date asc') + + async def match_statement_line(self, statement_line_id, move_id): + result = await self._odoo.call( + 'account.bank.statement.line', 'reconcile', + [[statement_line_id]], {'lines_vals': [{'id': move_id}]}) + return result + + async def send_payment_reminder(self, invoice_id, custom_message=None): + invoices = await self._odoo.read('account.move', [invoice_id], + ['name', 'partner_id', 'amount_residual', 'invoice_date_due']) + if not invoices: + return {'success': False, 'error': 'Invoice not found'} + inv = invoices[0] + body = custom_message or ( + f'Reminder: Invoice {inv["name"]} for {inv["amount_residual"]} ' + f'was due on {inv["invoice_date_due"]}. Please arrange payment.') + msg_id = await self._odoo.post_chatter('account.move', invoice_id, body, subtype='mail.mt_comment') + return {'success': True, 'message_id': msg_id, 'invoice': inv['name']} + + async def get_financial_summary(self, period='current'): + from datetime import date + if period == 'current': + today = date.today() + date_from = today.replace(day=1).isoformat() + date_to = today.isoformat() + else: + year, month = period.split('-') + import calendar + last_day = calendar.monthrange(int(year), int(month))[1] + date_from = f'{year}-{month}-01' + date_to = f'{year}-{month}-{last_day:02d}' + invoiced = await self._odoo.search_read( + 'account.move', + [['move_type', '=', 'out_invoice'], ['state', '=', 'posted'], + ['invoice_date', '>=', date_from], ['invoice_date', '<=', date_to]], + ['amount_total', 'amount_residual', 'payment_state']) + total_invoiced = sum(i['amount_total'] for i in invoiced) + total_outstanding = sum(i['amount_residual'] for i in invoiced) + paid = sum(1 for i in invoiced if i['payment_state'] == 'paid') + return { + 'period': period, 'date_from': date_from, 'date_to': date_to, + 'total_invoiced': total_invoiced, 'total_outstanding': total_outstanding, + 'total_paid': total_invoiced - total_outstanding, + 'invoice_count': len(invoiced), 'paid_count': paid, + 'collection_rate': round((1 - total_outstanding/total_invoiced)*100, 1) if total_invoiced else 0, + } + + async def get_payment_history(self, partner_id): + fields = ['name', 'payment_type', 'amount', 'state', 'date', 'journal_id', 'ref'] + return await self._odoo.search_read( + 'account.payment', + [['partner_id', '=', partner_id], ['state', '=', 'posted']], + fields, limit=50, order='date desc') + + async def flag_for_review(self, model, record_id, reason, severity='medium'): + note = f'[REVIEW FLAGGED - {severity.upper()}] {reason}' + await self._odoo.post_chatter(model, record_id, note) + return {'flagged': True, 'model': model, 'record_id': record_id, 'severity': severity} + + async def post_chatter_note(self, model, record_id, note): + msg_id = await self._odoo.post_chatter(model, record_id, note) + return {'success': True, 'message_id': msg_id} diff --git a/research/finance_research.md b/research/finance_research.md new file mode 100644 index 0000000..7d5cfa1 --- /dev/null +++ b/research/finance_research.md @@ -0,0 +1,37 @@ +# Finance Agent Research + +## Odoo 18 Finance Domain — Key Models + +### account.move (Invoices & Bills) +- `move_type`: `out_invoice` (customer invoice), `in_invoice` (vendor bill), `out_refund`, `in_refund` +- `state`: `draft`, `posted`, `cancel` +- `payment_state`: `not_paid`, `in_payment`, `paid`, `partial`, `reversed` +- `invoice_date_due`: payment due date +- `amount_residual`: outstanding balance +- `partner_id`: customer/vendor + +### account.payment +- Payments linked to invoices via `account.move` reconciliation +- `payment_type`: `inbound` (receipts), `outbound` (payments) +- `state`: `draft`, `posted`, `cancel` + +### account.bank.statement.line +- Bank statement lines for reconciliation +- `is_reconciled`: bool — whether matched to a payment/invoice + +### Key Search Domains +- Overdue: `[('move_type','=','out_invoice'),('state','=','posted'),('payment_state','!=','paid'),('invoice_date_due','<', today)]` +- Unreconciled bank lines: `[('is_reconciled','=',False),('journal_id','=',journal_id)]` + +## HIPAA Constraints +- Finance agent is HIPAA-locked — Ollama only, never cloud LLM +- Do not expose partner financial data to non-finance agents via PeerBus +- Aggregate/anonymise data in cross-agent responses + +## Automation Thresholds (from spec) +- Auto-send reminder: overdue > 30 days AND amount_residual > 1,000 +- Flag for review: overdue > 90 days (regardless of amount) +- Escalate to human: collection_rate < 70% OR overdue_total > 50,000 + +## Tool Limits +MAX_TOOLS_PER_AGENT = 8 (finance agent is at limit)