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