""" Intent Classifier Tests - Phase 13.4 Ollama 整合 ================================================ 2026-03-31 Claude Code (Phase 22 P1 修復): 移除 Mock,使用真實 Ollama 測試範圍: - _llm_classify: LLM 分類邏輯 (需要真實 Ollama) - _parse_intent_type: 意圖解析 - _llm_fallback_result: 失敗回退 - classify: 完整分類流程 遵循規範: - feedback_no_mock_testing.md: 禁止 MagicMock/AsyncMock/patch """ import pytest from src.services.intent_classifier import ( IntentClassifier, IntentType, IntentResult, RiskLevel, get_intent_classifier, ) # ============================================================================= # Test Markers # ============================================================================= requires_ollama = pytest.mark.skipif( "not config.getoption('--run-ollama', default=False)", reason="Need --run-ollama option to run Ollama integration tests", ) # ============================================================================= # Fixtures # ============================================================================= @pytest.fixture def classifier(): """測試用 IntentClassifier""" return IntentClassifier() # ============================================================================= # Test Cases - Pure Unit Tests (No External Dependencies) # ============================================================================= class TestParseIntentType: """_parse_intent_type 測試 - 純函數,不需要外部依賴""" def test_parse_restart(self, classifier): """測試解析 restart""" assert classifier._parse_intent_type("restart") == IntentType.RESTART assert classifier._parse_intent_type("RESTART") == IntentType.RESTART def test_parse_scale(self, classifier): """測試解析 scale""" assert classifier._parse_intent_type("scale") == IntentType.SCALE def test_parse_config(self, classifier): """測試解析 config""" assert classifier._parse_intent_type("config") == IntentType.CONFIG def test_parse_diagnose(self, classifier): """測試解析 diagnose""" assert classifier._parse_intent_type("diagnose") == IntentType.DIAGNOSE def test_parse_delete(self, classifier): """測試解析 delete""" assert classifier._parse_intent_type("delete") == IntentType.DELETE def test_parse_rollback(self, classifier): """測試解析 rollback""" assert classifier._parse_intent_type("rollback") == IntentType.ROLLBACK def test_parse_unknown(self, classifier): """測試解析 unknown""" assert classifier._parse_intent_type("unknown") == IntentType.UNKNOWN assert classifier._parse_intent_type("invalid") == IntentType.UNKNOWN assert classifier._parse_intent_type("") == IntentType.UNKNOWN class TestLlmFallbackResult: """_llm_fallback_result 測試 - 純函數""" def test_fallback_result(self, classifier): """測試 fallback 結果""" result = classifier._llm_fallback_result("test error") assert result.intent == IntentType.UNKNOWN assert result.confidence == 0.0 assert result.method == "llm" assert result.reasoning == "test error" assert result.matched_keywords == [] assert result.detected_resources == [] class TestKeywordClassify: """關鍵字分類測試 - 純函數,不需要 LLM""" def test_restart_keywords(self, classifier): """測試重啟關鍵字""" result = classifier.classify_sync("重啟 api-server pod") assert result.intent == IntentType.RESTART assert result.method == "keyword" # 關鍵字匹配信心度為 0 (非 AI 分析) assert result.confidence == 0.0 assert "重啟" in result.matched_keywords def test_scale_keywords(self, classifier): """測試擴縮容關鍵字""" result = classifier.classify_sync("擴展 deployment 副本數到 5") assert result.intent == IntentType.SCALE assert result.method == "keyword" def test_diagnose_keywords(self, classifier): """測試診斷關鍵字""" result = classifier.classify_sync("查看 pod 日誌") assert result.intent == IntentType.DIAGNOSE assert result.method == "keyword" def test_delete_keywords(self, classifier): """測試刪除關鍵字""" result = classifier.classify_sync("刪除這個 pod") assert result.intent == IntentType.DELETE assert result.method == "keyword" def test_unknown_text(self, classifier): """測試無法識別的文字""" result = classifier.classify_sync("今天天氣真好") assert result.intent == IntentType.UNKNOWN class TestGetIntentClassifier: """Singleton 測試""" def test_singleton(self): """測試 singleton 模式""" c1 = get_intent_classifier() c2 = get_intent_classifier() assert c1 is c2 class TestIntentResult: """IntentResult 測試 - 純 dataclass""" def test_dataclass_fields(self): """測試 dataclass 欄位""" result = IntentResult( intent=IntentType.RESTART, confidence=0.9, method="llm", matched_keywords=["重啟", "pod"], detected_resources=["api-server"], reasoning="匹配重啟關鍵字", ) assert result.intent == IntentType.RESTART assert result.confidence == 0.9 assert result.method == "llm" assert result.risk_level == RiskLevel.MEDIUM # auto-set by __post_init__ assert "重啟" in result.matched_keywords assert "api-server" in result.detected_resources def test_risk_level_auto_set(self): """測試風險等級自動設定""" # DELETE 應該是 CRITICAL delete_result = IntentResult( intent=IntentType.DELETE, confidence=0.8, method="llm", ) assert delete_result.risk_level == RiskLevel.CRITICAL # DIAGNOSE 應該是 LOW diagnose_result = IntentResult( intent=IntentType.DIAGNOSE, confidence=0.8, method="llm", ) assert diagnose_result.risk_level == RiskLevel.LOW # ============================================================================= # Integration Tests - Require Real Ollama # ============================================================================= class TestLlmClassifyIntegration: """ _llm_classify 整合測試 - 需要真實 Ollama Phase 22 P1 修復: 移除 Mock,使用真實 Ollama 2026-03-31 Claude Code (首席架構師) """ @pytest.mark.asyncio @requires_ollama async def test_llm_success(self, classifier): """ 測試 LLM 成功分類 使用真實 Ollama 服務測試: - 能正確解析意圖 - 返回合理的信心度 - method 為 "llm" """ result = await classifier._llm_classify("重啟 api 服務的 pod") # LLM 應該能識別為 RESTART 意圖 assert result.intent == IntentType.RESTART assert result.method == "llm" # 真實 LLM 應該有大於 0 的信心度 assert result.confidence > 0.0 @pytest.mark.asyncio @requires_ollama async def test_llm_scale_intent(self, classifier): """測試 LLM 擴縮容意圖識別""" result = await classifier._llm_classify("把 deployment 擴展到 5 個副本") assert result.intent == IntentType.SCALE assert result.method == "llm" @pytest.mark.asyncio @requires_ollama async def test_llm_diagnose_intent(self, classifier): """測試 LLM 診斷意圖識別""" result = await classifier._llm_classify("幫我分析一下為什麼 pod 一直重啟") assert result.intent == IntentType.DIAGNOSE assert result.method == "llm" @pytest.mark.asyncio @requires_ollama async def test_llm_delete_intent(self, classifier): """測試 LLM 刪除意圖識別 (高風險)""" result = await classifier._llm_classify("刪除這個有問題的 pod") assert result.intent == IntentType.DELETE assert result.method == "llm" class TestClassifyIntegration: """ 完整分類流程整合測試 - 需要真實 Ollama Phase 22 P1 修復: 移除 Mock,使用真實 Ollama 2026-03-31 Claude Code (首席架構師) """ @pytest.mark.asyncio @requires_ollama async def test_classify_with_real_llm(self, classifier): """ 測試完整分類流程 (關鍵字 + LLM) 流程: 1. 先嘗試關鍵字匹配 2. 如果關鍵字匹配成功但信心度為 0,嘗試 LLM 3. 選擇信心度較高的結果 """ result = await classifier.classify("重啟 api pod") # 意圖應該是 RESTART assert result.intent == IntentType.RESTART # method 可能是 keyword 或 llm,取決於哪個信心度更高 assert result.method in ["keyword", "llm"] @pytest.mark.asyncio @requires_ollama async def test_classify_complex_query(self, classifier): """測試複雜查詢 (需要 LLM 理解上下文)""" result = await classifier.classify("API 回應很慢,幫我看一下是不是需要增加副本") # 這種情況可能是 DIAGNOSE 或 SCALE assert result.intent in [IntentType.DIAGNOSE, IntentType.SCALE] # 複雜查詢更可能使用 LLM assert result.method == "llm" @pytest.mark.asyncio @requires_ollama async def test_classify_ambiguous_query(self, classifier): """測試模糊查詢""" result = await classifier.classify("幫我處理一下這個服務") # 模糊查詢可能返回 UNKNOWN 或 DIAGNOSE assert result.intent in [IntentType.UNKNOWN, IntentType.DIAGNOSE]