diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index cd00c486..c2336df3 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -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': """ diff --git a/fusion_accounting_followup/services/__init__.py b/fusion_accounting_followup/services/__init__.py index 1338970d..8a72cd93 100644 --- a/fusion_accounting_followup/services/__init__.py +++ b/fusion_accounting_followup/services/__init__.py @@ -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 diff --git a/fusion_accounting_followup/services/followup_text_generator.py b/fusion_accounting_followup/services/followup_text_generator.py new file mode 100644 index 00000000..66478f15 --- /dev/null +++ b/fusion_accounting_followup/services/followup_text_generator.py @@ -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 diff --git a/fusion_accounting_followup/services/followup_text_prompt.py b/fusion_accounting_followup/services/followup_text_prompt.py new file mode 100644 index 00000000..f3635c01 --- /dev/null +++ b/fusion_accounting_followup/services/followup_text_prompt.py @@ -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": "", + "body": " wrapper>", + "tone_used": "gentle" | "firm" | "legal", + "key_points": ["", "", ...] +} + +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)) diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index d50dde45..68af990b 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -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 diff --git a/fusion_accounting_followup/tests/test_followup_text_generator.py b/fusion_accounting_followup/tests/test_followup_text_generator.py new file mode 100644 index 00000000..a8e62819 --- /dev/null +++ b/fusion_accounting_followup/tests/test_followup_text_generator.py @@ -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)