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>
This commit is contained in:
ActiveBlue Build
2026-04-12 17:51:49 -04:00
parent 4ca62ee54b
commit dab6354d09
4 changed files with 535 additions and 0 deletions

View File

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

View File

@@ -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

View File

@@ -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}

View File

@@ -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)