Files
awoooi/apps/api/tests/test_intent_classifier.py
OG T 3eb3051a73
Some checks failed
CD Pipeline / build-and-deploy (push) Failing after 3m22s
E2E Health Check / e2e-health (push) Failing after 11s
fix(ci): 修復 docker socket 重複掛載 (1774793847)
2026-03-29 22:17:27 +08:00

289 lines
9.9 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.
"""
Intent Classifier Tests - Phase 13.4 Ollama 整合
================================================
2026-03-30 Claude Code: Intent Classifier LLM 整合測試
測試範圍:
- _llm_classify: LLM 分類邏輯
- _parse_intent_type: 意圖解析
- _llm_fallback_result: 失敗回退
- classify: 完整分類流程
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
import json
from src.services.intent_classifier import (
IntentClassifier,
IntentType,
IntentResult,
RiskLevel,
get_intent_classifier,
)
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def classifier():
"""測試用 IntentClassifier"""
return IntentClassifier()
@pytest.fixture
def mock_ollama_response():
"""模擬 Ollama 回應"""
return {
"response": json.dumps({
"intent": "restart",
"confidence": 0.85,
"reasoning": "用戶要求重啟 Pod"
})
}
# =============================================================================
# Test Cases
# =============================================================================
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:
"""關鍵字分類測試"""
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 TestLlmClassify:
"""_llm_classify 測試"""
@pytest.mark.asyncio
async def test_llm_success(self, classifier, mock_ollama_response):
"""測試 LLM 成功分類"""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = mock_ollama_response
mock_response.raise_for_status = MagicMock()
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_instance.post.return_value = mock_response
mock_instance.__aenter__.return_value = mock_instance
mock_instance.__aexit__.return_value = None
mock_client.return_value = mock_instance
result = await classifier._llm_classify("重啟 pod")
assert result.intent == IntentType.RESTART
assert result.confidence == 0.85
assert result.method == "llm"
@pytest.mark.asyncio
async def test_llm_timeout(self, classifier):
"""測試 LLM 超時"""
import httpx
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_instance.post.side_effect = httpx.TimeoutException("timeout")
mock_instance.__aenter__.return_value = mock_instance
mock_instance.__aexit__.return_value = None
mock_client.return_value = mock_instance
result = await classifier._llm_classify("重啟 pod")
assert result.intent == IntentType.UNKNOWN
assert result.confidence == 0.0
assert "超時" in result.reasoning
@pytest.mark.asyncio
async def test_llm_invalid_json(self, classifier):
"""測試 LLM 返回無效 JSON"""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"response": "not valid json"}
mock_response.raise_for_status = MagicMock()
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_instance.post.return_value = mock_response
mock_instance.__aenter__.return_value = mock_instance
mock_instance.__aexit__.return_value = None
mock_client.return_value = mock_instance
result = await classifier._llm_classify("重啟 pod")
assert result.intent == IntentType.UNKNOWN
assert "解析失敗" in result.reasoning
class TestClassify:
"""完整分類流程測試"""
@pytest.mark.asyncio
async def test_keyword_with_llm_fallback(self, classifier):
"""測試關鍵字匹配 + LLM fallback"""
# 由於關鍵字信心度為 0會嘗試 LLM
# LLM 可能超時或失敗,最終返回關鍵字結果
result = await classifier.classify("重啟 api pod")
# 意圖應該是 RESTART (來自關鍵字或 LLM)
assert result.intent == IntentType.RESTART
# method 可能是 keyword (LLM 超時) 或 llm (LLM 成功)
assert result.method in ["keyword", "llm"]
@pytest.mark.asyncio
async def test_llm_used_when_available(self, classifier, mock_ollama_response):
"""測試 LLM 可用時使用 LLM 結果"""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = mock_ollama_response
mock_response.raise_for_status = MagicMock()
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_instance.post.return_value = mock_response
mock_instance.__aenter__.return_value = mock_instance
mock_instance.__aexit__.return_value = None
mock_client.return_value = mock_instance
result = await classifier.classify("重啟 pod")
# LLM 成功時,應該使用 LLM 結果 (信心度 0.85 > 0)
assert result.intent == IntentType.RESTART
assert result.method == "llm"
assert result.confidence == 0.85
class TestGetIntentClassifier:
"""Singleton 測試"""
def test_singleton(self):
"""測試 singleton 模式"""
c1 = get_intent_classifier()
c2 = get_intent_classifier()
assert c1 is c2
class TestIntentResult:
"""IntentResult 測試"""
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