diff --git a/fusion_accounting_assets/__manifest__.py b/fusion_accounting_assets/__manifest__.py index 6bb31a7e..06853962 100644 --- a/fusion_accounting_assets/__manifest__.py +++ b/fusion_accounting_assets/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Assets', - 'version': '19.0.1.0.4', + 'version': '19.0.1.0.5', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented asset management with depreciation schedules.', 'description': """ diff --git a/fusion_accounting_assets/services/__init__.py b/fusion_accounting_assets/services/__init__.py index 52bf6ea0..3a238ec5 100644 --- a/fusion_accounting_assets/services/__init__.py +++ b/fusion_accounting_assets/services/__init__.py @@ -2,3 +2,5 @@ from . import depreciation_methods from . import prorate from . import salvage_value from . import anomaly_detection +from . import useful_life_prompt +from . import useful_life_predictor diff --git a/fusion_accounting_assets/services/useful_life_predictor.py b/fusion_accounting_assets/services/useful_life_predictor.py new file mode 100644 index 00000000..561e82d4 --- /dev/null +++ b/fusion_accounting_assets/services/useful_life_predictor.py @@ -0,0 +1,94 @@ +"""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 diff --git a/fusion_accounting_assets/services/useful_life_prompt.py b/fusion_accounting_assets/services/useful_life_prompt.py new file mode 100644 index 00000000..cb2da3fa --- /dev/null +++ b/fusion_accounting_assets/services/useful_life_prompt.py @@ -0,0 +1,48 @@ +"""LLM prompt builder for AI-suggested useful life from invoice description. + +Output contract: +{ + "useful_life_years": , + "depreciation_method": "straight_line" | "declining_balance" | "units_of_production", + "rationale": "", + "confidence": +} +""" + + +SYSTEM_PROMPT = """You are an experienced accountant. Given an invoice line +description for a fixed asset, suggest the appropriate useful life in years +and depreciation method based on common accounting standards (IFRS / GAAP / CRA). + +Respond ONLY with valid JSON of this exact shape: +{ + "useful_life_years": , + "depreciation_method": "straight_line" | "declining_balance" | "units_of_production", + "rationale": "", + "confidence": +} + +Common useful-life conventions: +- Furniture: 7 years, straight-line +- Office equipment: 5 years, straight-line +- Computers: 3-4 years, straight-line or declining +- Vehicles: 5 years, declining-balance (CRA Class 10 30%) +- Buildings: 25-40 years, straight-line +- Manufacturing equipment: 10-15 years, units of production if measurable +- Software (licenses): 3-5 years, straight-line +- Leasehold improvements: lesser of lease term or useful life + +Do NOT include markdown code fences. Do NOT include any prose outside the JSON.""" + + +def build_prompt(*, description: str, amount: float = None, + partner_name: str = None) -> tuple[str, str]: + """Return (system, user) prompt tuple.""" + parts = [f"INVOICE LINE: {description}"] + if amount is not None: + parts.append(f"AMOUNT: ${amount:,.2f}") + if partner_name: + parts.append(f"VENDOR: {partner_name}") + parts.append("") + parts.append("Suggest the useful life and depreciation method per the system prompt.") + return (SYSTEM_PROMPT, "\n".join(parts)) diff --git a/fusion_accounting_assets/tests/__init__.py b/fusion_accounting_assets/tests/__init__.py index c3630e6a..a40830c2 100644 --- a/fusion_accounting_assets/tests/__init__.py +++ b/fusion_accounting_assets/tests/__init__.py @@ -2,3 +2,4 @@ from . import test_depreciation_methods from . import test_prorate from . import test_salvage_value from . import test_asset_anomaly_detection +from . import test_useful_life_predictor diff --git a/fusion_accounting_assets/tests/test_useful_life_predictor.py b/fusion_accounting_assets/tests/test_useful_life_predictor.py new file mode 100644 index 00000000..a549c391 --- /dev/null +++ b/fusion_accounting_assets/tests/test_useful_life_predictor.py @@ -0,0 +1,61 @@ +from odoo.tests.common import TransactionCase +from odoo.tests import tagged +from odoo.addons.fusion_accounting_assets.services.useful_life_predictor import ( + predict_useful_life, +) +from odoo.addons.fusion_accounting_assets.services.useful_life_prompt import ( + SYSTEM_PROMPT, build_prompt, +) + + +@tagged('post_install', '-at_install') +class TestUsefulLifePredictor(TransactionCase): + + def setUp(self): + super().setUp() + # Ensure no provider configured for these fallback tests. + self.env['ir.config_parameter'].sudo().search([ + ('key', 'in', [ + 'fusion_accounting.provider.asset_useful_life', + 'fusion_accounting.provider.default', + ]) + ]).unlink() + + def test_fallback_computer(self): + result = predict_useful_life(self.env, description="Dell laptop") + self.assertEqual(result['useful_life_years'], 4) + self.assertEqual(result['depreciation_method'], 'straight_line') + + def test_fallback_furniture(self): + result = predict_useful_life(self.env, description="office desk") + self.assertEqual(result['useful_life_years'], 7) + + def test_fallback_vehicle_uses_declining(self): + result = predict_useful_life(self.env, description="Ford F-150 truck") + self.assertEqual(result['useful_life_years'], 5) + self.assertEqual(result['depreciation_method'], 'declining_balance') + + def test_fallback_default_for_unknown(self): + result = predict_useful_life(self.env, description="mystery widget") + self.assertEqual(result['useful_life_years'], 5) + self.assertEqual(result['confidence'], 0.3) + + def test_returns_dict_with_required_keys(self): + result = predict_useful_life(self.env, description="server") + for key in ('useful_life_years', 'depreciation_method', 'rationale', 'confidence'): + self.assertIn(key, result) + + +@tagged('post_install', '-at_install') +class TestUsefulLifePrompt(TransactionCase): + + def test_system_prompt_requires_json(self): + self.assertIn('JSON', SYSTEM_PROMPT) + + def test_build_prompt_returns_tuple(self): + result = build_prompt(description='test') + self.assertEqual(len(result), 2) + + def test_user_prompt_includes_amount(self): + _, user = build_prompt(description='laptop', amount=2000) + self.assertIn('2,000', user)