diff --git a/fusion_accounting_reports/__manifest__.py b/fusion_accounting_reports/__manifest__.py index ce141c8b..50a6cab9 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.10', + 'version': '19.0.1.0.11', '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 59c6c214..30e66b9e 100644 --- a/fusion_accounting_reports/services/__init__.py +++ b/fusion_accounting_reports/services/__init__.py @@ -5,4 +5,5 @@ from . import currency_conversion from . import line_resolver from . import drill_down_resolver from . import anomaly_detection +from . import commentary_prompt from . import commentary_generator diff --git a/fusion_accounting_reports/services/commentary_prompt.py b/fusion_accounting_reports/services/commentary_prompt.py new file mode 100644 index 00000000..3d2462e3 --- /dev/null +++ b/fusion_accounting_reports/services/commentary_prompt.py @@ -0,0 +1,67 @@ +"""LLM prompt for AI report commentary. + +Provider-agnostic system + user prompt builder. Output contract: +JSON with keys summary, highlights, concerns, next_actions.""" + + +SYSTEM_PROMPT = """You are an experienced CFO providing executive-level commentary +on a financial report. Your output MUST be valid JSON of this exact shape: + +{ + "summary": "<2-3 sentence executive summary of the report period>", + "highlights": ["", "", ...], + "concerns": ["", ...], + "next_actions": ["", ...] +} + +Rules: +- Use the data provided. Do not invent numbers. +- Tone: professional, concise, factual. +- Currency formatting: always include the $ symbol and 2 decimal places. +- For anomalies: explicitly mention the variance percentage AND the dollar amount. +- Do NOT include markdown code fences. Do NOT include any prose outside the JSON. +""" + + +def build_prompt(report_result: dict, anomalies: list) -> tuple[str, str]: + """Build (system_prompt, user_prompt) tuple.""" + parts = [] + + # Report context + parts.append(f"REPORT: {report_result.get('report_name', 'Untitled')}") + period = report_result.get('period', {}) + parts.append(f"PERIOD: {period.get('label', '')} " + f"({period.get('date_from', '')} to {period.get('date_to', '')})") + comp_period = report_result.get('comparison_period') + if comp_period: + parts.append(f"COMPARED TO: {comp_period.get('label', '')} " + f"({comp_period.get('date_from', '')} to {comp_period.get('date_to', '')})") + parts.append("") + + # Rows (the actual numbers) + parts.append("REPORT LINES:") + for row in report_result.get('rows', []): + line = f" - {row.get('label', '?')}: ${row.get('amount', 0):,.2f}" + if row.get('amount_comparison') is not None: + line += f" (comparison: ${row['amount_comparison']:,.2f}" + if row.get('variance_pct') is not None: + line += f", {row['variance_pct']:+.1f}%" + line += ")" + if row.get('is_subtotal'): + line += " [SUBTOTAL]" + parts.append(line) + parts.append("") + + # Anomalies + if anomalies: + parts.append("ANOMALIES (variances exceeding threshold):") + for a in anomalies[:10]: + parts.append( + f" - {a['label']}: {a['direction']}d {a['variance_pct']:.1f}% " + f"(${a['variance_amount']:+,.2f}, severity: {a['severity']})" + ) + parts.append("") + + parts.append("Generate the JSON commentary per the system prompt.") + + return (SYSTEM_PROMPT, "\n".join(parts)) diff --git a/fusion_accounting_reports/tests/__init__.py b/fusion_accounting_reports/tests/__init__.py index fad8dbf1..1d9a597b 100644 --- a/fusion_accounting_reports/tests/__init__.py +++ b/fusion_accounting_reports/tests/__init__.py @@ -6,4 +6,5 @@ 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_prompt from . import test_commentary_generator diff --git a/fusion_accounting_reports/tests/test_commentary_prompt.py b/fusion_accounting_reports/tests/test_commentary_prompt.py new file mode 100644 index 00000000..198f6002 --- /dev/null +++ b/fusion_accounting_reports/tests/test_commentary_prompt.py @@ -0,0 +1,50 @@ +"""Tests for commentary_prompt module.""" + +from odoo.tests.common import TransactionCase, tagged +from odoo.addons.fusion_accounting_reports.services.commentary_prompt import ( + SYSTEM_PROMPT, build_prompt, +) + + +@tagged('post_install', '-at_install') +class TestCommentaryPrompt(TransactionCase): + + def test_system_prompt_requires_json(self): + self.assertIn('JSON', SYSTEM_PROMPT) + self.assertIn('"summary"', SYSTEM_PROMPT) + self.assertIn('"highlights"', SYSTEM_PROMPT) + + def test_build_prompt_returns_tuple(self): + report = {'report_name': 'P&L', 'period': {'label': 'Apr 2026', + 'date_from': '2026-04-01', + 'date_to': '2026-04-30'}, + 'rows': []} + result = build_prompt(report, []) + self.assertEqual(len(result), 2) + self.assertIn('REPORT', result[1]) + self.assertIn('Apr 2026', result[1]) + + def test_user_prompt_includes_rows(self): + report = { + 'report_name': 'P&L', + 'period': {'label': 'X', 'date_from': 'a', 'date_to': 'b'}, + 'rows': [ + {'id': 'r1', 'label': 'Revenue', 'amount': 100000.50}, + {'id': 'r2', 'label': 'Net Income', 'amount': 25000, 'is_subtotal': True}, + ], + } + _, user = build_prompt(report, []) + self.assertIn('Revenue', user) + self.assertIn('100,000.50', user) + self.assertIn('SUBTOTAL', user) + + def test_user_prompt_includes_anomalies(self): + report = {'report_name': 'X', 'period': {'label': 'X', 'date_from': '', 'date_to': ''}, 'rows': []} + anomalies = [ + {'label': 'Revenue', 'direction': 'increase', 'variance_pct': 25.0, + 'variance_amount': 5000, 'severity': 'medium'}, + ] + _, user = build_prompt(report, anomalies) + self.assertIn('ANOMALIES', user) + self.assertIn('Revenue', user) + self.assertIn('25.0%', user)