104 lines
3.8 KiB
Python
104 lines
3.8 KiB
Python
"""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
|