changes
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user