"""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