feat(fusion_accounting_reports): commentary_prompt for LLM-generated narratives
Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
'name': 'Fusion Accounting Reports',
|
'name': 'Fusion Accounting Reports',
|
||||||
'version': '19.0.1.0.10',
|
'version': '19.0.1.0.11',
|
||||||
'category': 'Accounting/Accounting',
|
'category': 'Accounting/Accounting',
|
||||||
'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).',
|
'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ from . import currency_conversion
|
|||||||
from . import line_resolver
|
from . import line_resolver
|
||||||
from . import drill_down_resolver
|
from . import drill_down_resolver
|
||||||
from . import anomaly_detection
|
from . import anomaly_detection
|
||||||
|
from . import commentary_prompt
|
||||||
from . import commentary_generator
|
from . import commentary_generator
|
||||||
|
|||||||
67
fusion_accounting_reports/services/commentary_prompt.py
Normal file
67
fusion_accounting_reports/services/commentary_prompt.py
Normal file
@@ -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": ["<observation 1>", "<observation 2>", ...],
|
||||||
|
"concerns": ["<thing to investigate 1>", ...],
|
||||||
|
"next_actions": ["<suggested action 1>", ...]
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
@@ -6,4 +6,5 @@ from . import test_drill_down_resolver
|
|||||||
from . import test_fusion_report_engine
|
from . import test_fusion_report_engine
|
||||||
from . import test_seeded_reports
|
from . import test_seeded_reports
|
||||||
from . import test_anomaly_detection
|
from . import test_anomaly_detection
|
||||||
|
from . import test_commentary_prompt
|
||||||
from . import test_commentary_generator
|
from . import test_commentary_generator
|
||||||
|
|||||||
50
fusion_accounting_reports/tests/test_commentary_prompt.py
Normal file
50
fusion_accounting_reports/tests/test_commentary_prompt.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user