95 lines
3.6 KiB
Python
95 lines
3.6 KiB
Python
"""AI-suggested useful life from invoice context.
|
|
|
|
Wraps useful_life_prompt + an LLMProvider. Returns a dict per the prompt's
|
|
output contract. Templated fallback when no provider configured.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Templated fallback rules: (regex, years, method, rationale)
|
|
FALLBACK_RULES = [
|
|
(r'\b(computer|laptop|monitor|server|workstation)\b', 4, 'straight_line', 'Computer hardware'),
|
|
(r'\b(furniture|desk|chair|cabinet)\b', 7, 'straight_line', 'Furniture'),
|
|
(r'\b(vehicle|truck|car|van)\b', 5, 'declining_balance', 'Vehicle (CRA Class 10)'),
|
|
(r'\b(building|warehouse)\b', 30, 'straight_line', 'Building'),
|
|
(r'\b(software|license)\b', 4, 'straight_line', 'Software license'),
|
|
(r'\b(equipment|machinery|machine)\b', 10, 'straight_line', 'Manufacturing equipment'),
|
|
(r'\b(leasehold improvement)\b', 5, 'straight_line', 'Leasehold improvements'),
|
|
]
|
|
FALLBACK_DEFAULT = (5, 'straight_line', 'Generic fixed asset (default)')
|
|
|
|
|
|
def predict_useful_life(env, *, description: str, amount: float = None,
|
|
partner_name: str = None, provider=None) -> dict:
|
|
"""Suggest useful life + method via LLM, with templated fallback."""
|
|
if provider is None:
|
|
provider = _get_provider(env)
|
|
if provider is None:
|
|
return _templated_fallback(description)
|
|
|
|
try:
|
|
from .useful_life_prompt import build_prompt
|
|
system, user = build_prompt(
|
|
description=description, amount=amount, partner_name=partner_name,
|
|
)
|
|
response = provider.complete(
|
|
system=system,
|
|
messages=[{'role': 'user', 'content': user}],
|
|
max_tokens=400, temperature=0.1,
|
|
)
|
|
content = response.get('content') if isinstance(response, dict) else response
|
|
parsed = json.loads(content)
|
|
for key in ('useful_life_years', 'depreciation_method', 'rationale'):
|
|
if key not in parsed:
|
|
raise ValueError(f"Missing key: {key}")
|
|
parsed.setdefault('confidence', 0.7)
|
|
return parsed
|
|
except Exception as e:
|
|
_logger.warning("Useful life LLM prediction failed (%s); falling back", e)
|
|
return _templated_fallback(description)
|
|
|
|
|
|
def _templated_fallback(description: str) -> dict:
|
|
"""Pattern-match keyword rules. Always returns a usable dict."""
|
|
desc_lower = description.lower() if description else ''
|
|
for pattern, years, method, rationale in FALLBACK_RULES:
|
|
if re.search(pattern, desc_lower):
|
|
return {
|
|
'useful_life_years': years,
|
|
'depreciation_method': method,
|
|
'rationale': rationale,
|
|
'confidence': 0.5,
|
|
}
|
|
years, method, rationale = FALLBACK_DEFAULT
|
|
return {
|
|
'useful_life_years': years,
|
|
'depreciation_method': method,
|
|
'rationale': rationale,
|
|
'confidence': 0.3,
|
|
}
|
|
|
|
|
|
def _get_provider(env):
|
|
"""Look up provider for 'asset_useful_life' feature."""
|
|
param = env['ir.config_parameter'].sudo()
|
|
name = param.get_param('fusion_accounting.provider.asset_useful_life')
|
|
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
|