From a4728d7ae79b3e6dabef5fb50e338e4922fbc68f Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 15:29:44 -0400 Subject: [PATCH] feat(fusion_accounting_reports): commentary_generator service with templated fallback Made-with: Cursor --- fusion_accounting_reports/__manifest__.py | 2 +- .../services/__init__.py | 1 + .../services/commentary_generator.py | 103 ++++++++++++++++++ fusion_accounting_reports/tests/__init__.py | 1 + .../tests/test_commentary_generator.py | 54 +++++++++ 5 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_reports/services/commentary_generator.py create mode 100644 fusion_accounting_reports/tests/test_commentary_generator.py diff --git a/fusion_accounting_reports/__manifest__.py b/fusion_accounting_reports/__manifest__.py index 6ddc94bb..ce141c8b 100644 --- a/fusion_accounting_reports/__manifest__.py +++ b/fusion_accounting_reports/__manifest__.py @@ -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': """ diff --git a/fusion_accounting_reports/services/__init__.py b/fusion_accounting_reports/services/__init__.py index d3e585df..59c6c214 100644 --- a/fusion_accounting_reports/services/__init__.py +++ b/fusion_accounting_reports/services/__init__.py @@ -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 diff --git a/fusion_accounting_reports/services/commentary_generator.py b/fusion_accounting_reports/services/commentary_generator.py new file mode 100644 index 00000000..26eeee71 --- /dev/null +++ b/fusion_accounting_reports/services/commentary_generator.py @@ -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 diff --git a/fusion_accounting_reports/tests/__init__.py b/fusion_accounting_reports/tests/__init__.py index eba0be58..fad8dbf1 100644 --- a/fusion_accounting_reports/tests/__init__.py +++ b/fusion_accounting_reports/tests/__init__.py @@ -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 diff --git a/fusion_accounting_reports/tests/test_commentary_generator.py b/fusion_accounting_reports/tests/test_commentary_generator.py new file mode 100644 index 00000000..04ccadbc --- /dev/null +++ b/fusion_accounting_reports/tests/test_commentary_generator.py @@ -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)