Files
awoooi/apps/api/tests/test_p0_diagnose_routing.py
OG T 5ad403b287 fix(p0): v4.3 — 實測確認 Ollama CPU-only 不可用,DIAGNOSE 統一走 NIM
實測依據 (2026-04-05):
- Ollama llama3.2:3b CPU-only: 238s 回 {"ok":true},生產不可用
- Nemotron NIM: 2.2s~27.3s,avg 10.6s,一直是主力(Phase 22 起)
- NIM 從未有隱私問題,Incident 資料一直送雲端 GPU

變更:
- ai_router.py: _local_fallback_chain 廢棄(空 list)
- ai_router.py: DIAGNOSE route/route_sync 改回 _full_fallback_chain
- config.py: 更新 timeout 說明反映實測結果
- test_p0_diagnose_routing.py: 更新 docstring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 01:49:06 +08:00

155 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
P0 DIAGNOSE Routing Tests
==========================
測試 AIRouter DIAGNOSE 路由 + require_local 隔離行為
建立時間: 2026-04-04 (台北時區)
建立者: Claude Code (P0 DIAGNOSE Privacy-First)
2026-04-05 v4.3: Ollama CPU-only 238s 不可用DIAGNOSE 統一走 NIM (_full_fallback_chain)
"""
import os
os.environ.setdefault("MOCK_MODE", "true")
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
class TestNemotronPerTaskTimeout:
"""Nemotron 支援 per-task timeout"""
@pytest.mark.asyncio
async def test_diagnose_uses_diagnose_timeout(self):
"""DIAGNOSE context 應使用 NEMOTRON_DIAGNOSE_TIMEOUT_SECONDS"""
from src.services.ai_providers.nemotron import NemotronProvider
provider = NemotronProvider()
# 建立 mock nvidia provider
mock_nvidia = MagicMock()
mock_result = MagicMock()
mock_result.tool_calls = []
mock_nvidia.tool_call = AsyncMock(return_value=mock_result)
with patch.object(provider, '_get_nvidia', return_value=mock_nvidia):
result = await provider.analyze(
prompt="測試診斷",
context={"task_type": "diagnose"},
)
assert result.success is True
mock_nvidia.tool_call.assert_called_once()
class TestLocalFallbackChain:
"""require_local=True 時 privacy 過濾生效cloud provider 不被呼叫;全部失敗 → REJECT"""
@pytest.mark.asyncio
async def test_require_local_skips_cloud_providers(self):
"""require_local=True 時cloud provider 不被呼叫"""
import os
from src.services.ai_router import AIRouterExecutor, AIProviderRegistry
from src.services.ai_providers.interfaces import AIResult
registry = AIProviderRegistry()
# Mock: Ollama 成功
mock_ollama = AsyncMock()
mock_ollama.name = "ollama"
mock_ollama.privacy_level = "local"
mock_ollama.is_enabled = True
mock_ollama.capabilities = {"rca", "chat"}
mock_ollama.analyze = AsyncMock(return_value=AIResult(
raw_response="本地診斷結果",
success=True,
provider="ollama",
))
mock_ollama.health_check = AsyncMock(return_value=True)
# Mock: Gemini不應該被呼叫
mock_gemini = AsyncMock()
mock_gemini.name = "gemini"
mock_gemini.privacy_level = "cloud"
mock_gemini.is_enabled = True
mock_gemini.analyze = AsyncMock(return_value=AIResult(
raw_response="雲端結果",
success=True,
provider="gemini",
))
registry._providers = {
"ollama": mock_ollama,
"gemini": mock_gemini,
}
executor = AIRouterExecutor(registry)
# 暫時關閉 MOCK_MODE測試真實執行路徑
with patch("src.services.ai_router._settings") as mock_settings:
mock_settings.MOCK_MODE = False
result = await executor.execute(
prompt="診斷這個問題",
provider_order=["ollama", "gemini"],
require_local=True,
)
assert result.success is True
assert result.provider == "ollama"
mock_gemini.analyze.assert_not_called()
@pytest.mark.asyncio
async def test_require_local_all_fail_returns_reject(self):
"""require_local=True 且所有 local provider 失敗 → 回傳明確錯誤"""
import os
from src.services.ai_router import AIRouterExecutor, AIProviderRegistry
from src.services.ai_providers.interfaces import AIResult
registry = AIProviderRegistry()
# Mock: Ollama 失敗
mock_ollama = AsyncMock()
mock_ollama.name = "ollama"
mock_ollama.privacy_level = "local"
mock_ollama.is_enabled = True
mock_ollama.capabilities = {"rca", "chat"}
mock_ollama.analyze = AsyncMock(return_value=AIResult(
raw_response="",
success=False,
provider="ollama",
error="timeout",
))
mock_ollama.health_check = AsyncMock(return_value=False)
registry._providers = {
"ollama": mock_ollama,
}
executor = AIRouterExecutor(registry)
# 暫時關閉 MOCK_MODE + 讓 telegram import 失敗(不影響主流程)
with patch("src.services.ai_router._settings") as mock_settings:
mock_settings.MOCK_MODE = False
result = await executor.execute(
prompt="診斷這個問題",
provider_order=["ollama"],
require_local=True,
)
assert result.success is False
assert result.error == "local_providers_unavailable"
class TestDiagnoseIntentOverride:
"""DIAGNOSE intent 應優先路由至 Nemotron非 FORCE_LOCAL 情境)"""
def test_diagnose_override_is_nemotron(self):
"""_intent_provider_overrides[DIAGNOSE] 應為 NEMOTRON"""
from src.services.ai_router import AIRouter, AIProviderEnum
from src.services.intent_classifier import IntentType
router = AIRouter()
override = router._intent_provider_overrides.get(IntentType.DIAGNOSE)
assert override == AIProviderEnum.NEMOTRON, (
f"DIAGNOSE 應路由至 NEMOTRON實際為 {override}"
)