124 lines
5.0 KiB
Python
124 lines
5.0 KiB
Python
"""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
|