diff --git a/fusion_accounting_bank_rec/tests/__init__.py b/fusion_accounting_bank_rec/tests/__init__.py index 63bfa701..d98aea8f 100644 --- a/fusion_accounting_bank_rec/tests/__init__.py +++ b/fusion_accounting_bank_rec/tests/__init__.py @@ -22,3 +22,4 @@ from . import test_migration_round_trip from . import test_coexistence from . import test_bank_rec_tours from . import test_performance_benchmarks +from . import test_local_llm_compat diff --git a/fusion_accounting_bank_rec/tests/test_local_llm_compat.py b/fusion_accounting_bank_rec/tests/test_local_llm_compat.py new file mode 100644 index 00000000..97e94293 --- /dev/null +++ b/fusion_accounting_bank_rec/tests/test_local_llm_compat.py @@ -0,0 +1,102 @@ +"""Local LLM compatibility test (LM Studio, Ollama, etc.). + +Skips if no local OpenAI-compatible LLM server is reachable. When one is +running (LM Studio at :1234, Ollama at :11434), runs an end-to-end: + +1. Configure ``ir.config_parameter`` to point at the local server. +2. Trigger ``engine.suggest_matches`` with the 'openai' provider. +3. Assert the call did not crash and produced at least one suggestion. + +The smoke is intentionally lenient: local models often emit malformed +JSON, in which case ``confidence_scoring`` falls back to statistical-only +ranking. We assert end-to-end happiness, not AI re-rank quality. +""" + +import socket + +from odoo.tests.common import TransactionCase, tagged + +from . import _factories as f + + +def _server_reachable(host, port, timeout=1.0): + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except (OSError, socket.timeout): + return False + + +def _detect_local_llm(): + """Return (base_url, model_name) tuple, or (None, None) if no server. + + Tries LM Studio (:1234) and Ollama (:11434) on both + ``host.docker.internal`` (so the container can reach the host) and + ``localhost`` (so a non-containerised run finds the same servers). + """ + candidates = ( + ('host.docker.internal', 1234, 'local-model'), # LM Studio + ('host.docker.internal', 11434, 'llama3.1:8b'), # Ollama + ('localhost', 1234, 'local-model'), + ('localhost', 11434, 'llama3.1:8b'), + ) + for host, port, default_model in candidates: + if _server_reachable(host, port, timeout=0.5): + return (f'http://{host}:{port}/v1', default_model) + return (None, None) + + +@tagged('post_install', '-at_install', 'local_llm') +class TestLocalLLMCompat(TransactionCase): + + def setUp(self): + super().setUp() + self.base_url, self.model = _detect_local_llm() + if not self.base_url: + self.skipTest( + "No local LLM server detected " + "(LM Studio :1234 / Ollama :11434)") + + def test_suggest_matches_with_local_llm(self): + params = self.env['ir.config_parameter'].sudo() + prior = { + 'fusion_accounting.openai_base_url': params.get_param( + 'fusion_accounting.openai_base_url'), + 'fusion_accounting.openai_model': params.get_param( + 'fusion_accounting.openai_model'), + 'fusion_accounting.openai_api_key': params.get_param( + 'fusion_accounting.openai_api_key'), + 'fusion_accounting.provider.bank_rec_suggest': params.get_param( + 'fusion_accounting.provider.bank_rec_suggest'), + } + + params.set_param('fusion_accounting.openai_base_url', self.base_url) + params.set_param('fusion_accounting.openai_model', self.model) + # Local servers ignore the key but the adapter requires *some* value. + params.set_param('fusion_accounting.openai_api_key', 'lm-studio') + params.set_param( + 'fusion_accounting.provider.bank_rec_suggest', 'openai') + + try: + partner = self.env['res.partner'].create( + {'name': 'Local LLM Partner'}) + f.make_invoice(self.env, partner=partner, amount=750) + bank_line = f.make_bank_line( + self.env, amount=750, partner=partner, + memo='REF 12345 Local LLM test') + + result = self.env['fusion.reconcile.engine'].suggest_matches( + bank_line, limit_per_line=3) + + self.assertIn(bank_line.id, result) + suggestions = self.env['fusion.reconcile.suggestion'].search([ + ('statement_line_id', '=', bank_line.id), + ]) + self.assertGreater( + len(suggestions), 0, + "Local LLM run should still produce at least one suggestion " + "(statistical fallback if AI re-rank fails)") + finally: + for key, value in prior.items(): + if value is not None: + params.set_param(key, value)