All checks were successful
E2E Health Check / e2e-health (push) Successful in 17s
根本原因: 前端未傳送 CSRF Token,API 拒絕所有簽核請求 修復內容: 1. live-approval-panel.tsx: 整合 useCSRF hook - 簽核時帶上 csrfToken 參數 - 拒絕時帶上 csrfToken 參數 - 新增 CSRF 載入/錯誤狀態顯示 2. test_intent_classifier.py: 移除 Mock 違規 (P1) - 改用 @requires_ollama marker - 真實 Ollama 整合測試 3. test_terminal_service.py: 移除 Mock 違規 (P1) - 改用 @requires_database/@requires_k8s markers - 保留純函數單元測試 遵循規範: - feedback_no_mock_testing.md: 禁止 MagicMock/AsyncMock - Phase 20 CSRF Protection: Double Submit Cookie Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
294 lines
9.8 KiB
Python
294 lines
9.8 KiB
Python
"""
|
||
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]
|