This commit is contained in:
gsinghpal
2026-05-16 13:18:52 -04:00
parent 191a9c82be
commit 9ebf89bde2
1080 changed files with 0 additions and 1197 deletions

View File

@@ -0,0 +1,6 @@
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

@@ -0,0 +1,52 @@
"""Level resolver: which follow-up level should fire for this partner?
Pure-Python: caller passes the aging report + the configured levels list,
and we pick the highest-numbered level whose threshold is met."""
from dataclasses import dataclass
@dataclass
class FollowupLevelSpec:
sequence: int
name: str
delay_days: int
tone: str
def __post_init__(self):
if self.tone not in ('gentle', 'firm', 'legal'):
raise ValueError(f"Invalid tone: {self.tone}")
def resolve_level(*, aging_report, levels: list[FollowupLevelSpec]) -> FollowupLevelSpec | None:
"""Pick the highest-sequence level whose delay_days has been crossed by
the most-overdue line in the aging report. Returns None if no overdue
lines or no levels configured."""
if not levels or not aging_report:
return None
max_days_overdue = _max_days_overdue(aging_report)
if max_days_overdue <= 0:
return None
levels_sorted = sorted(levels, key=lambda l: l.sequence, reverse=True)
for level in levels_sorted:
if level.delay_days <= max_days_overdue:
return level
return None
def _max_days_overdue(aging_report) -> int:
"""Return the actual max days-overdue tracked on the report, falling
back to the highest populated bucket's lower bound when an older
aging report (without `max_days_overdue`) is passed in."""
tracked = getattr(aging_report, 'max_days_overdue', 0) or 0
if tracked:
return tracked
max_days = 0
for b in aging_report.buckets:
if b.name == 'current' or b.amount <= 0:
continue
if b.days_max is None:
max_days = max(max_days, b.days_min)
else:
max_days = max(max_days, b.days_min)
return max_days

View File

@@ -0,0 +1,92 @@
"""Aging bucket primitives.
Pure-Python: callers pass a list of move-line dicts with `date_maturity`
and `amount_residual`; we bucket them into 0/30/60/90/120+ days overdue."""
from dataclasses import dataclass, field
from datetime import date
BUCKETS = [
('current', 0, 0),
('1_30', 1, 30),
('31_60', 31, 60),
('61_90', 61, 90),
('91_120', 91, 120),
('120_plus', 121, None),
]
@dataclass
class AgingBucket:
name: str
days_min: int
days_max: int | None
amount: float = 0.0
line_count: int = 0
@dataclass
class AgingReport:
as_of: date
buckets: list[AgingBucket] = field(default_factory=list)
total_amount: float = 0.0
total_overdue_amount: float = 0.0
line_count: int = 0
max_days_overdue: int = 0
def to_dict(self):
return {
'as_of': str(self.as_of),
'total_amount': self.total_amount,
'total_overdue_amount': self.total_overdue_amount,
'line_count': self.line_count,
'max_days_overdue': self.max_days_overdue,
'buckets': [{
'name': b.name, 'days_min': b.days_min, 'days_max': b.days_max,
'amount': b.amount, 'line_count': b.line_count,
} for b in self.buckets],
}
def compute_aging(*, move_lines: list[dict], as_of: date | None = None) -> AgingReport:
"""Bucket move-line dicts into aging brackets.
Each dict needs: date_maturity (date), amount_residual (float).
`as_of` defaults to today."""
as_of = as_of or date.today()
report = AgingReport(as_of=as_of)
for name, days_min, days_max in BUCKETS:
report.buckets.append(AgingBucket(name=name, days_min=days_min, days_max=days_max))
for ml in move_lines:
maturity = ml.get('date_maturity')
amount = ml.get('amount_residual', 0.0)
if not maturity:
continue
days_overdue = (as_of - maturity).days
bucket = _find_bucket(report.buckets, days_overdue)
if bucket:
bucket.amount += amount
bucket.line_count += 1
report.total_amount += amount
if days_overdue > 0:
report.total_overdue_amount += amount
if days_overdue > report.max_days_overdue:
report.max_days_overdue = days_overdue
report.line_count += 1
return report
def _find_bucket(buckets: list[AgingBucket], days_overdue: int) -> AgingBucket | None:
if days_overdue <= 0:
return next((b for b in buckets if b.name == 'current'), None)
for b in buckets:
if b.name == 'current':
continue
if b.days_max is None and days_overdue >= b.days_min:
return b
if b.days_max is not None and b.days_min <= days_overdue <= b.days_max:
return b
return None

View File

@@ -0,0 +1,62 @@
"""Payment-history risk scorer.
Pure-Python: takes payment history (list of payment events) + average days-late
and returns a risk score 0-100. Higher = more risky."""
from dataclasses import dataclass
@dataclass
class PartnerRiskScore:
score: int
band: str
drivers: list[str]
def score_partner(*, total_invoices: int = 0, paid_late_count: int = 0,
avg_days_late: float = 0.0,
longest_overdue_days: int = 0,
open_overdue_amount: float = 0.0,
average_invoice_amount: float = 1000.0) -> PartnerRiskScore:
"""Compute a 0-100 risk score from payment-history primitives.
Heuristic weights:
- 30% : late-payment ratio (paid_late_count / total_invoices)
- 25% : avg days late (capped at 60 days)
- 25% : longest current overdue (capped at 120 days)
- 20% : open overdue amount as multiple of average invoice
"""
drivers: list[str] = []
score = 0.0
if total_invoices > 0:
late_ratio = paid_late_count / total_invoices
score += min(late_ratio * 100, 100) * 0.30
if late_ratio > 0.5:
drivers.append(f"{paid_late_count}/{total_invoices} invoices paid late")
score += min(avg_days_late / 60, 1) * 100 * 0.25
if avg_days_late > 14:
drivers.append(f"Avg {avg_days_late:.1f} days late on payment")
score += min(longest_overdue_days / 120, 1) * 100 * 0.25
if longest_overdue_days > 30:
drivers.append(f"Longest currently overdue: {longest_overdue_days} days")
if average_invoice_amount > 0:
ratio = open_overdue_amount / average_invoice_amount
score += min(ratio / 5, 1) * 100 * 0.20
if ratio > 1.5:
drivers.append(f"Open overdue ${open_overdue_amount:,.2f} ({ratio:.1f}x avg invoice)")
final = int(round(score))
if final >= 80:
band = 'critical'
elif final >= 60:
band = 'high'
elif final >= 30:
band = 'medium'
else:
band = 'low'
return PartnerRiskScore(score=final, band=band, drivers=drivers)

View File

@@ -0,0 +1,18 @@
"""Tone selector: pick gentle/firm/legal based on follow-up level + risk score."""
TONE_BY_LEVEL = {
1: 'gentle',
2: 'firm',
3: 'legal',
4: 'legal',
}
def select_tone(*, level_sequence: int, risk_score: int = 0) -> str:
"""Default tone follows level sequence; high risk can escalate."""
base_tone = TONE_BY_LEVEL.get(level_sequence, 'gentle')
if risk_score >= 80 and base_tone == 'gentle':
return 'firm'
if risk_score >= 90 and base_tone == 'firm':
return 'legal'
return base_tone