feat(fusion_accounting_reports): commentary_generator service with templated fallback
Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
'name': 'Fusion Accounting Reports',
|
||||
'version': '19.0.1.0.9',
|
||||
'version': '19.0.1.0.10',
|
||||
'category': 'Accounting/Accounting',
|
||||
'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).',
|
||||
'description': """
|
||||
|
||||
@@ -5,3 +5,4 @@ from . import currency_conversion
|
||||
from . import line_resolver
|
||||
from . import drill_down_resolver
|
||||
from . import anomaly_detection
|
||||
from . import commentary_generator
|
||||
|
||||
103
fusion_accounting_reports/services/commentary_generator.py
Normal file
103
fusion_accounting_reports/services/commentary_generator.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""AI-generated narrative commentary for financial reports.
|
||||
|
||||
Takes a report_result dict + optional anomalies list, builds an LLM
|
||||
prompt, parses the structured output. Output contract:
|
||||
{
|
||||
'summary': str, # 2-3 sentence executive summary
|
||||
'highlights': [str, ...], # 3-5 bullet observations
|
||||
'concerns': [str, ...], # things that warrant investigation
|
||||
'next_actions': [str, ...] # suggested follow-ups
|
||||
}
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_commentary(env, *, report_result: dict, anomalies: list = None,
|
||||
provider=None) -> dict:
|
||||
"""Generate narrative commentary via LLM. Returns dict per the contract.
|
||||
|
||||
If no provider configured, returns a templated fallback (no LLM)."""
|
||||
if provider is None:
|
||||
provider = _get_provider(env)
|
||||
if provider is None:
|
||||
return _templated_fallback(report_result, anomalies)
|
||||
|
||||
try:
|
||||
from odoo.addons.fusion_accounting_reports.services.commentary_prompt import build_prompt
|
||||
except ImportError:
|
||||
_logger.debug("commentary_prompt module not yet available; using fallback")
|
||||
return _templated_fallback(report_result, anomalies)
|
||||
|
||||
system, user = build_prompt(report_result, anomalies or [])
|
||||
try:
|
||||
response = provider.complete(
|
||||
system=system,
|
||||
messages=[{'role': 'user', 'content': user}],
|
||||
max_tokens=1200,
|
||||
temperature=0.2,
|
||||
)
|
||||
content = response.get('content') if isinstance(response, dict) else response
|
||||
parsed = json.loads(content)
|
||||
# Validate shape
|
||||
for key in ('summary', 'highlights', 'concerns', 'next_actions'):
|
||||
parsed.setdefault(key, [] if key != 'summary' else '')
|
||||
return parsed
|
||||
except Exception as e:
|
||||
_logger.warning("AI commentary generation failed: %s", e)
|
||||
return _templated_fallback(report_result, anomalies)
|
||||
|
||||
|
||||
def _templated_fallback(report_result: dict, anomalies: list = None) -> dict:
|
||||
"""No-LLM fallback that produces a basic narrative from the report data."""
|
||||
anomalies = anomalies or []
|
||||
rows = report_result.get('rows', [])
|
||||
period = report_result.get('period', {})
|
||||
period_label = period.get('label', 'this period')
|
||||
|
||||
# Find subtotal rows for the summary
|
||||
subtotals = [r for r in rows if r.get('is_subtotal')]
|
||||
summary_parts = [f"{report_result.get('report_name', 'Report')} for {period_label}."]
|
||||
if subtotals:
|
||||
last = subtotals[-1]
|
||||
summary_parts.append(f"{last['label']}: ${last['amount']:,.2f}.")
|
||||
|
||||
highlights = []
|
||||
for row in subtotals[:3]:
|
||||
highlights.append(f"{row['label']}: ${row['amount']:,.2f}")
|
||||
|
||||
concerns = []
|
||||
for a in anomalies[:3]:
|
||||
concerns.append(
|
||||
f"{a['label']} {a['direction']}d {a['variance_pct']:.1f}% "
|
||||
f"(${a['variance_amount']:+,.2f})")
|
||||
|
||||
return {
|
||||
'summary': ' '.join(summary_parts),
|
||||
'highlights': highlights,
|
||||
'concerns': concerns,
|
||||
'next_actions': ['Review the flagged anomalies above.'] if concerns else [],
|
||||
}
|
||||
|
||||
|
||||
def _get_provider(env):
|
||||
"""Look up provider for 'reports_commentary' feature; return None if not configured."""
|
||||
param = env['ir.config_parameter'].sudo()
|
||||
provider_name = param.get_param('fusion_accounting.provider.reports_commentary')
|
||||
if not provider_name:
|
||||
provider_name = param.get_param('fusion_accounting.provider.default')
|
||||
if not provider_name:
|
||||
return None
|
||||
try:
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.claude import ClaudeAdapter
|
||||
except ImportError:
|
||||
return None
|
||||
if provider_name.startswith('openai'):
|
||||
return OpenAIAdapter(env)
|
||||
elif provider_name.startswith('claude'):
|
||||
return ClaudeAdapter(env)
|
||||
return None
|
||||
@@ -6,3 +6,4 @@ from . import test_drill_down_resolver
|
||||
from . import test_fusion_report_engine
|
||||
from . import test_seeded_reports
|
||||
from . import test_anomaly_detection
|
||||
from . import test_commentary_generator
|
||||
|
||||
54
fusion_accounting_reports/tests/test_commentary_generator.py
Normal file
54
fusion_accounting_reports/tests/test_commentary_generator.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Tests for commentary_generator service."""
|
||||
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_reports.services.commentary_generator import (
|
||||
generate_commentary, _templated_fallback,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestCommentaryGenerator(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Ensure no provider is configured so we exercise the fallback path
|
||||
self.env['ir.config_parameter'].sudo().search([
|
||||
('key', 'in', ['fusion_accounting.provider.reports_commentary',
|
||||
'fusion_accounting.provider.default'])
|
||||
]).unlink()
|
||||
|
||||
def test_fallback_when_no_provider(self):
|
||||
report = {
|
||||
'report_name': 'P&L',
|
||||
'period': {'label': 'Apr 2026'},
|
||||
'rows': [
|
||||
{'id': 'r1', 'label': 'Revenue', 'amount': 100000, 'is_subtotal': False},
|
||||
{'id': 'r2', 'label': 'Net Income', 'amount': 25000, 'is_subtotal': True},
|
||||
],
|
||||
}
|
||||
result = generate_commentary(self.env, report_result=report)
|
||||
self.assertIn('summary', result)
|
||||
self.assertIn('Net Income', result['summary'])
|
||||
self.assertIn('25,000', result['summary'])
|
||||
|
||||
def test_fallback_includes_anomalies_in_concerns(self):
|
||||
report = {
|
||||
'report_name': 'P&L',
|
||||
'period': {'label': 'Apr 2026'},
|
||||
'rows': [],
|
||||
}
|
||||
anomalies = [
|
||||
{'label': 'Revenue', 'direction': 'increase', 'variance_pct': 30.0,
|
||||
'variance_amount': 5000, 'severity': 'medium'},
|
||||
]
|
||||
result = generate_commentary(self.env, report_result=report, anomalies=anomalies)
|
||||
self.assertEqual(len(result['concerns']), 1)
|
||||
self.assertIn('Revenue', result['concerns'][0])
|
||||
self.assertIn('30.0%', result['concerns'][0])
|
||||
self.assertGreater(len(result['next_actions']), 0)
|
||||
|
||||
def test_returns_dict_with_required_keys(self):
|
||||
report = {'report_name': 'Test', 'period': {'label': 'X'}, 'rows': []}
|
||||
result = generate_commentary(self.env, report_result=report)
|
||||
for key in ('summary', 'highlights', 'concerns', 'next_actions'):
|
||||
self.assertIn(key, result)
|
||||
Reference in New Issue
Block a user