167 lines
5.5 KiB
Python
167 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""Hermes 分析師必須透過 OllamaService 三主機級聯。"""
|
|
|
|
import time
|
|
import inspect
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
import services.ai_call_logger as logger_mod
|
|
import services.hermes_analyst_service as hermes_mod
|
|
from services.ai_call_logger import _reset_kill_switch
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_ai_logger(monkeypatch):
|
|
_reset_kill_switch()
|
|
captured = []
|
|
|
|
def fake_write(state):
|
|
captured.append({
|
|
'caller': state.caller,
|
|
'provider': state.provider,
|
|
'model': state.model,
|
|
'status': state.status,
|
|
'fallback_to': state.fallback_to,
|
|
'error': state.error,
|
|
'meta': dict(state.meta),
|
|
})
|
|
|
|
monkeypatch.setattr(logger_mod, '_write_to_db', fake_write)
|
|
monkeypatch.setenv('AI_CALL_LOGGING_ENABLED', 'true')
|
|
yield captured
|
|
|
|
|
|
def _wait_for(captured, n=1, timeout=2.0):
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
if len(captured) >= n:
|
|
return True
|
|
time.sleep(0.01)
|
|
return False
|
|
|
|
|
|
def _stub_ollama(monkeypatch, *, content: str, host: str):
|
|
fake_resp = SimpleNamespace(
|
|
success=True,
|
|
content=content,
|
|
model=hermes_mod.HERMES_MODEL,
|
|
error=None,
|
|
total_duration=1.2,
|
|
host=host,
|
|
input_tokens=33,
|
|
output_tokens=22,
|
|
)
|
|
|
|
class FakeOllamaService:
|
|
instances = []
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self.init_args = args
|
|
self.init_kwargs = kwargs
|
|
self.generate_calls = []
|
|
FakeOllamaService.instances.append(self)
|
|
|
|
def generate(self, **kwargs):
|
|
self.generate_calls.append(kwargs)
|
|
return fake_resp
|
|
|
|
monkeypatch.setattr(hermes_mod, 'OllamaService', FakeOllamaService)
|
|
return FakeOllamaService
|
|
|
|
|
|
def test_hermes_intent_uses_ollama_service_and_logs_actual_host(monkeypatch, reset_ai_logger):
|
|
fake_service = _stub_ollama(
|
|
monkeypatch,
|
|
content='{"intent":"query_sales","confidence":0.9,"complexity_score":0.8,'
|
|
'"requires_data_fetch":true,"preliminary_answer":""}',
|
|
host='http://34.21.145.224:11434',
|
|
)
|
|
|
|
svc = hermes_mod.HermesAnalystService()
|
|
result = svc._call_hermes_intent("本週業績如何?")
|
|
|
|
assert result['intent'] == 'query_sales'
|
|
assert result['metadata']['source'] == 'hermes_llm'
|
|
call_kwargs = fake_service.instances[0].generate_calls[0]
|
|
assert call_kwargs['model'] == hermes_mod.HERMES_MODEL
|
|
assert call_kwargs['keep_alive'] == hermes_mod.HERMES_KEEP_ALIVE
|
|
assert call_kwargs['allow_111_fallback'] is False
|
|
|
|
assert _wait_for(reset_ai_logger, 1)
|
|
rec = reset_ai_logger[0]
|
|
assert rec['caller'] == 'hermes_intent'
|
|
assert rec['provider'] == 'ollama_secondary'
|
|
assert rec['meta']['host_label'] == 'GCP-SSD-2'
|
|
|
|
|
|
def test_hermes_batch_analyze_uses_ollama_service_and_logs_secondary(monkeypatch, reset_ai_logger):
|
|
fake_service = _stub_ollama(
|
|
monkeypatch,
|
|
content='[{"sku":"A1","name":"測試商品","category":"家電","momo_price":120,'
|
|
'"pchome_price":100,"gap_pct":20,"sales_7d_delta_pct":-30,'
|
|
'"risk":"HIGH","recommended_action":"建議人工評估","confidence":0.8}]',
|
|
host='http://34.21.145.224:11434',
|
|
)
|
|
monkeypatch.setattr(hermes_mod, 'build_mcp_context', lambda *args, **kwargs: 'MCP context')
|
|
|
|
candidates = [{
|
|
'sku': 'A1',
|
|
'name': '測試商品',
|
|
'category': '家電',
|
|
'momo_price': 120,
|
|
'pchome_price': 100,
|
|
'sales_7d_prev': 1000,
|
|
'sales_7d_curr': 700,
|
|
'competitor_tags': [
|
|
'identity_v2',
|
|
'match_type_same_product_different_pack',
|
|
'price_basis_unit_price',
|
|
'alert_tier_unit_price_review',
|
|
],
|
|
'competitor_match_score': 0.74,
|
|
'competitor_product_id': 'PCH-UNIT',
|
|
'competitor_product_name': '測試商品 2入組',
|
|
}]
|
|
|
|
svc = hermes_mod.HermesAnalystService()
|
|
raw_threats, items = svc._batch_analyze(candidates)
|
|
|
|
assert raw_threats[0]['sku'] == 'A1'
|
|
assert items[0]['gap_pct'] == 20.0
|
|
assert items[0]['match_type'] == 'same_product_different_pack'
|
|
assert items[0]['price_basis'] == 'unit_price'
|
|
assert items[0]['alert_tier'] == 'unit_price_review'
|
|
call_kwargs = fake_service.instances[0].generate_calls[0]
|
|
assert call_kwargs['system_prompt'] == svc.SYSTEM_PROMPT
|
|
assert call_kwargs['keep_alive'] == hermes_mod.HERMES_KEEP_ALIVE
|
|
assert call_kwargs['allow_111_fallback'] is False
|
|
|
|
assert _wait_for(reset_ai_logger, 1)
|
|
rec = reset_ai_logger[0]
|
|
assert rec['caller'] == 'hermes_analyst'
|
|
assert rec['provider'] == 'ollama_secondary'
|
|
assert rec['meta']['host_label'] == 'GCP-SSD-2'
|
|
|
|
|
|
def test_hermes_candidate_sql_only_joins_direct_price_alert_matches():
|
|
sql_text = inspect.getsource(hermes_mod.HermesAnalystService.fetch_candidates)
|
|
compact_sql = "".join(sql_text.split())
|
|
|
|
assert '"match_type":"exact"' in compact_sql
|
|
assert '"price_basis":"total_price"' in compact_sql
|
|
assert '"alert_tier":"price_alert_exact"' in compact_sql
|
|
assert "match_type_exact" in sql_text
|
|
assert "price_basis_total_price" in sql_text
|
|
assert "alert_tier_price_alert_exact" in sql_text
|
|
|
|
|
|
def test_hermes_keep_alive_defaults_to_short_runner_residency():
|
|
assert hermes_mod.HERMES_KEEP_ALIVE == "5m"
|
|
|
|
|
|
def test_hermes_disables_111_fallback_by_default():
|
|
assert hermes_mod.HERMES_ALLOW_111_FALLBACK is False
|