feat(fusion_accounting_followup): AI follow-up text generator + prompt

Made-with: Cursor
This commit is contained in:
gsinghpal
2026-04-19 20:40:26 -04:00
parent 63f3e0ec14
commit 1829f0584f
6 changed files with 263 additions and 1 deletions

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting Follow-up',
'version': '19.0.1.0.4',
'version': '19.0.1.0.5',
'category': 'Accounting/Accounting',
'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.',
'description': """

View File

@@ -2,3 +2,5 @@ from . import overdue_aging
from . import level_resolver
from . import risk_scorer
from . import tone_selector
from . import followup_text_prompt
from . import followup_text_generator

View File

@@ -0,0 +1,123 @@
"""AI-generated follow-up text with templated fallback."""
import json
import logging
_logger = logging.getLogger(__name__)
TEMPLATES = {
'gentle': {
'subject': 'Friendly reminder: invoice payment',
'body': 'Dear {partner_name},\n\nThis is a friendly reminder that you have '
'{currency_code} {total_overdue:,.2f} outstanding on invoices that '
'are now {longest_overdue_days} days past due. We understand things '
'happen — please let us know if there is anything we can do to help '
'resolve this.\n\nBest regards.',
},
'firm': {
'subject': 'Outstanding invoices — action required',
'body': 'Dear {partner_name},\n\nOur records show {currency_code} '
'{total_overdue:,.2f} outstanding on {invoice_count} invoice(s), '
'with the longest now {longest_overdue_days} days overdue. We '
'request immediate payment to avoid further action.\n\nRegards.',
},
'legal': {
'subject': 'FINAL NOTICE — outstanding balance',
'body': 'Dear {partner_name},\n\nDespite previous reminders, '
'{currency_code} {total_overdue:,.2f} remains outstanding on your '
'account, with the longest invoice {longest_overdue_days} days '
'overdue. If full payment is not received within 7 days, we will '
'be forced to refer this matter for legal collection.\n\n'
'Regards.',
},
}
def generate_followup_text(env, *, partner_name: str, total_overdue: float,
currency_code: str, longest_overdue_days: int,
tone: str, invoice_count: int = 0,
last_payment_date: str = None,
risk_drivers: list[str] = None,
provider=None) -> dict:
"""Generate follow-up text via LLM, with templated fallback.
Returns: {subject, body, tone_used, key_points}"""
if provider is None:
provider = _get_provider(env)
if provider is None:
return _templated_fallback(
partner_name=partner_name, total_overdue=total_overdue,
currency_code=currency_code,
longest_overdue_days=longest_overdue_days,
tone=tone, invoice_count=invoice_count,
)
try:
from .followup_text_prompt import build_prompt
system, user = build_prompt(
partner_name=partner_name, total_overdue=total_overdue,
currency_code=currency_code,
longest_overdue_days=longest_overdue_days, tone=tone,
invoice_count=invoice_count, last_payment_date=last_payment_date,
risk_drivers=risk_drivers,
)
response = provider.complete(
system=system,
messages=[{'role': 'user', 'content': user}],
max_tokens=800, temperature=0.3,
)
content = response.get('content') if isinstance(response, dict) else response
parsed = json.loads(content)
for key in ('subject', 'body', 'tone_used'):
if key not in parsed:
raise ValueError(f"Missing key: {key}")
parsed.setdefault('key_points', [])
return parsed
except Exception as e:
_logger.warning("Follow-up text LLM generation failed (%s); falling back", e)
return _templated_fallback(
partner_name=partner_name, total_overdue=total_overdue,
currency_code=currency_code,
longest_overdue_days=longest_overdue_days,
tone=tone, invoice_count=invoice_count,
)
def _templated_fallback(*, partner_name, total_overdue, currency_code,
longest_overdue_days, tone, invoice_count) -> dict:
template = TEMPLATES.get(tone, TEMPLATES['gentle'])
return {
'subject': template['subject'],
'body': template['body'].format(
partner_name=partner_name, total_overdue=total_overdue,
currency_code=currency_code,
longest_overdue_days=longest_overdue_days,
invoice_count=invoice_count or 0,
),
'tone_used': tone,
'key_points': [
f"${total_overdue:,.2f} outstanding",
f"{longest_overdue_days} days overdue",
],
}
def _get_provider(env):
"""Look up provider for 'followup_text' feature."""
param = env['ir.config_parameter'].sudo()
name = param.get_param('fusion_accounting.provider.followup_text')
if not name:
name = param.get_param('fusion_accounting.provider.default')
if not 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 name.startswith('openai'):
return OpenAIAdapter(env)
elif name.startswith('claude'):
return ClaudeAdapter(env)
return None

View File

@@ -0,0 +1,56 @@
"""LLM prompt for AI-generated follow-up text.
Output contract: {
"subject": str,
"body": str,
"tone_used": str,
"key_points": [str, ...]
}"""
SYSTEM_PROMPT = """You are an experienced credit collections specialist writing a
follow-up email for an unpaid invoice. Output MUST be valid JSON of this
exact shape:
{
"subject": "<email subject line>",
"body": "<plain-text or simple HTML body, no <html> wrapper>",
"tone_used": "gentle" | "firm" | "legal",
"key_points": ["<point 1>", "<point 2>", ...]
}
Tone guide:
- gentle: friendly reminder, assume oversight, propose easy paths to pay
- firm: state amount + days overdue clearly, request immediate action,
hint at consequences
- legal: formal language, reference contract obligations, mention possible
legal action / collections agency, demand payment by specific date
Always:
- Use the actual amounts and partner name from the data provided
- Don't invent contract terms or interest rates
- Don't include markdown code fences
- No prose outside the JSON
"""
def build_prompt(*, partner_name: str, total_overdue: float, currency_code: str,
longest_overdue_days: int, tone: str,
invoice_count: int = 0, last_payment_date: str = None,
risk_drivers: list[str] = None) -> tuple[str, str]:
parts = [
f"PARTNER: {partner_name}",
f"TOTAL OVERDUE: {currency_code} {total_overdue:,.2f}",
f"LONGEST OVERDUE: {longest_overdue_days} days",
f"OPEN INVOICE COUNT: {invoice_count}",
f"REQUESTED TONE: {tone}",
]
if last_payment_date:
parts.append(f"LAST PAYMENT: {last_payment_date}")
if risk_drivers:
parts.append("RISK FACTORS:")
for d in risk_drivers[:5]:
parts.append(f" - {d}")
parts.append("")
parts.append("Write the follow-up email per the system prompt.")
return (SYSTEM_PROMPT, "\n".join(parts))

View File

@@ -2,3 +2,4 @@ from . import test_overdue_aging
from . import test_level_resolver
from . import test_risk_scorer
from . import test_tone_selector
from . import test_followup_text_generator

View File

@@ -0,0 +1,80 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.addons.fusion_accounting_followup.services.followup_text_generator import (
generate_followup_text,
)
from odoo.addons.fusion_accounting_followup.services.followup_text_prompt import (
SYSTEM_PROMPT, build_prompt,
)
@tagged('post_install', '-at_install')
class TestFollowupTextGenerator(TransactionCase):
def setUp(self):
super().setUp()
self.env['ir.config_parameter'].sudo().search([
('key', 'in', ['fusion_accounting.provider.followup_text',
'fusion_accounting.provider.default'])
]).unlink()
def test_fallback_gentle(self):
result = generate_followup_text(
self.env, partner_name='Acme Corp', total_overdue=1500,
currency_code='USD', longest_overdue_days=15, tone='gentle',
invoice_count=2,
)
self.assertEqual(result['tone_used'], 'gentle')
self.assertIn('Acme Corp', result['body'])
self.assertIn('1,500.00', result['body'])
def test_fallback_firm(self):
result = generate_followup_text(
self.env, partner_name='Acme', total_overdue=5000,
currency_code='USD', longest_overdue_days=45, tone='firm',
invoice_count=3,
)
self.assertEqual(result['tone_used'], 'firm')
def test_fallback_legal(self):
result = generate_followup_text(
self.env, partner_name='Acme', total_overdue=10000,
currency_code='USD', longest_overdue_days=90, tone='legal',
invoice_count=5,
)
self.assertEqual(result['tone_used'], 'legal')
self.assertIn('FINAL NOTICE', result['subject'])
def test_returns_required_keys(self):
result = generate_followup_text(
self.env, partner_name='X', total_overdue=100,
currency_code='USD', longest_overdue_days=10, tone='gentle',
)
for key in ('subject', 'body', 'tone_used', 'key_points'):
self.assertIn(key, result)
@tagged('post_install', '-at_install')
class TestFollowupTextPrompt(TransactionCase):
def test_system_prompt_requires_json(self):
self.assertIn('JSON', SYSTEM_PROMPT)
self.assertIn('"subject"', SYSTEM_PROMPT)
self.assertIn('"body"', SYSTEM_PROMPT)
def test_build_prompt_returns_tuple(self):
result = build_prompt(
partner_name='X', total_overdue=100, currency_code='USD',
longest_overdue_days=10, tone='gentle',
)
self.assertEqual(len(result), 2)
self.assertIn('100.00', result[1])
def test_build_prompt_includes_risk_drivers(self):
_, user = build_prompt(
partner_name='X', total_overdue=100, currency_code='USD',
longest_overdue_days=10, tone='firm',
risk_drivers=['Chronic late payer', '5/10 paid late'],
)
self.assertIn('RISK FACTORS', user)
self.assertIn('Chronic late payer', user)