feat(fusion_accounting_assets): AI useful life predictor + prompt
Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
'name': 'Fusion Accounting Assets',
|
'name': 'Fusion Accounting Assets',
|
||||||
'version': '19.0.1.0.4',
|
'version': '19.0.1.0.5',
|
||||||
'category': 'Accounting/Accounting',
|
'category': 'Accounting/Accounting',
|
||||||
'summary': 'AI-augmented asset management with depreciation schedules.',
|
'summary': 'AI-augmented asset management with depreciation schedules.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -2,3 +2,5 @@ from . import depreciation_methods
|
|||||||
from . import prorate
|
from . import prorate
|
||||||
from . import salvage_value
|
from . import salvage_value
|
||||||
from . import anomaly_detection
|
from . import anomaly_detection
|
||||||
|
from . import useful_life_prompt
|
||||||
|
from . import useful_life_predictor
|
||||||
|
|||||||
94
fusion_accounting_assets/services/useful_life_predictor.py
Normal file
94
fusion_accounting_assets/services/useful_life_predictor.py
Normal 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
|
||||||
48
fusion_accounting_assets/services/useful_life_prompt.py
Normal file
48
fusion_accounting_assets/services/useful_life_prompt.py
Normal 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))
|
||||||
@@ -2,3 +2,4 @@ from . import test_depreciation_methods
|
|||||||
from . import test_prorate
|
from . import test_prorate
|
||||||
from . import test_salvage_value
|
from . import test_salvage_value
|
||||||
from . import test_asset_anomaly_detection
|
from . import test_asset_anomaly_detection
|
||||||
|
from . import test_useful_life_predictor
|
||||||
|
|||||||
61
fusion_accounting_assets/tests/test_useful_life_predictor.py
Normal file
61
fusion_accounting_assets/tests/test_useful_life_predictor.py
Normal 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)
|
||||||
Reference in New Issue
Block a user