feat(fusion_accounting_assets): AI useful life predictor + prompt

Made-with: Cursor
This commit is contained in:
gsinghpal
2026-04-19 16:50:01 -04:00
parent 19cbed5b37
commit bc7ba27d77
6 changed files with 207 additions and 1 deletions

View File

@@ -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': """

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
"""LLM prompt builder for AI-suggested useful life from invoice description.
Output contract:
{
"useful_life_years": <int>,
"depreciation_method": "straight_line" | "declining_balance" | "units_of_production",
"rationale": "<short explanation>",
"confidence": <float 0-1>
}
"""
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": <integer>,
"depreciation_method": "straight_line" | "declining_balance" | "units_of_production",
"rationale": "<one or two sentence explanation>",
"confidence": <float between 0 and 1>
}
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))

View File

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

View File

@@ -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)