fix(ci): 修復 docker socket 重複掛載 (1774793847)
Some checks failed
CD Pipeline / build-and-deploy (push) Failing after 3m22s
E2E Health Check / e2e-health (push) Failing after 11s

This commit is contained in:
OG T
2026-03-29 22:17:18 +08:00
parent 1b292e8ed4
commit 3eb3051a73
3 changed files with 445 additions and 11 deletions

View File

@@ -21,18 +21,40 @@ K8s 操作意圖分類器,用於智能路由模型選擇
from __future__ import annotations
import json
import re
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Protocol, runtime_checkable
import httpx
import structlog
from src.core.config import settings
from src.services.model_registry import get_model_registry
logger = structlog.get_logger(__name__)
# LLM 分類 Prompt 模板 (Phase 13.4)
_LLM_CLASSIFY_PROMPT = """你是 K8s 操作意圖分類專家。根據以下輸入,判斷用戶的操作意圖。
可選意圖類型:
- restart: 重啟 Pod/Deployment/StatefulSet
- scale: 擴縮容、HPA 調整
- config: ConfigMap/Secret/ENV 變更
- diagnose: 日誌查詢、健康檢查、RCA
- delete: 刪除資源(高風險)
- rollback: 回滾版本
- unknown: 無法判斷
輸入: {text}
請以 JSON 格式回答,只輸出 JSON:
{{"intent": "<類型>", "confidence": <0.0-1.0>, "reasoning": "<判斷依據>"}}"""
# =============================================================================
# 意圖類型定義 (Phase 13.3 #85)
# =============================================================================
@@ -503,9 +525,9 @@ class IntentClassifier:
async def _llm_classify(self, text: str) -> IntentResult:
"""
LLM 分類 (方案 B)
LLM 分類 (方案 B) - Phase 13.4
目標延遲: < 100ms (使用 qwen2.5:1b)
目標延遲: < 100ms (使用 qwen2.5:1b 或配置的 intent 模型)
Args:
text: 已轉小寫的輸入文字
@@ -513,20 +535,114 @@ class IntentClassifier:
Returns:
IntentResult: 分類結果
Note:
目前返回 UNKNOWN待 Ollama qwen2.5:1b 部署後啟用
2026-03-30 Claude Code: 實作 Ollama 整合
"""
# TODO: 整合 Ollama qwen2.5:1b (Phase 13.4)
# 預計使用 text 呼叫 Ollama API 進行分類
# 目前先返回 UNKNOWN規則引擎已能處理大部分情況
del text # 預留給 LLM 分類使用,避免 unused-parameter 警告
start_time = time.time()
try:
# 建構 Prompt
prompt = _LLM_CLASSIFY_PROMPT.format(text=text)
# 取得模型配置
model_name = self.llm_model # qwen2.5:1b 或配置值
# 呼叫 Ollama
async with httpx.AsyncClient() as client:
response = await client.post(
f"{settings.OLLAMA_URL}/api/generate",
json={
"model": model_name,
"prompt": prompt,
"stream": False,
"format": "json",
"options": {
"num_predict": 128, # 意圖分類只需短回應
"temperature": 0.0, # 確定性輸出
"top_p": 0.9,
},
},
timeout=httpx.Timeout(5.0, connect=2.0), # 嚴格超時
)
response.raise_for_status()
data = response.json()
result_text = data.get("response", "")
# 解析 JSON 回應
elapsed_ms = (time.time() - start_time) * 1000
try:
parsed = json.loads(result_text)
intent_str = parsed.get("intent", "unknown").lower()
confidence = float(parsed.get("confidence", 0.5))
reasoning = parsed.get("reasoning", "")
# 映射到 IntentType
intent = self._parse_intent_type(intent_str)
logger.info(
"intent_llm_classified",
intent=intent.value,
confidence=confidence,
elapsed_ms=round(elapsed_ms, 1),
model=model_name,
)
return IntentResult(
intent=intent,
confidence=confidence,
method="llm",
matched_keywords=[],
detected_resources=[],
reasoning=reasoning,
)
except (json.JSONDecodeError, KeyError, ValueError) as e:
logger.warning(
"intent_llm_parse_failed",
error=str(e),
response_preview=result_text[:100],
)
return self._llm_fallback_result("JSON 解析失敗")
except httpx.TimeoutException:
elapsed_ms = (time.time() - start_time) * 1000
logger.warning(
"intent_llm_timeout",
elapsed_ms=round(elapsed_ms, 1),
)
return self._llm_fallback_result("LLM 超時")
except Exception as e:
logger.warning(
"intent_llm_error",
error=str(e),
error_type=type(e).__name__,
)
return self._llm_fallback_result(f"LLM 錯誤: {type(e).__name__}")
def _parse_intent_type(self, intent_str: str) -> IntentType:
"""解析意圖字串為 IntentType"""
intent_map = {
"restart": IntentType.RESTART,
"scale": IntentType.SCALE,
"config": IntentType.CONFIG,
"diagnose": IntentType.DIAGNOSE,
"delete": IntentType.DELETE,
"rollback": IntentType.ROLLBACK,
"unknown": IntentType.UNKNOWN,
}
return intent_map.get(intent_str.lower(), IntentType.UNKNOWN)
def _llm_fallback_result(self, reason: str) -> IntentResult:
"""LLM 失敗時的 fallback 結果"""
return IntentResult(
intent=IntentType.UNKNOWN,
confidence=0.0, # 🔴 LLM 未啟用,非 AI 分析
confidence=0.0,
method="llm",
matched_keywords=[],
detected_resources=[],
reasoning="LLM 分類尚未啟用",
reasoning=reason,
)
def get_supported_intents(self) -> list[dict]:

View File

@@ -0,0 +1,288 @@
"""
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

View File

@@ -5,10 +5,11 @@
---
## 📍 當前狀態 (2026-03-30 00:30 台北)
## 📍 當前狀態 (2026-03-30 01:00 台北)
| 項目 | 狀態 |
|------|------|
| **Intent Classifier** | ✅ **Ollama 整合完成** (21 測試通過) |
| **Learning Service** | ✅ **Playbook 信心度調整完成** (13 測試通過) |
| **🔴 ADR-039 Gitea 遷移** | 🔄 **執行中** (方案 B - GitHub → Gitea CI/CD) |
| **Gitea CI/CD** | ✅ **已設置** (cd.yaml + e2e-health.yaml) |
@@ -45,6 +46,35 @@
| **Wave 2 Worker HPA** | ✅ **已部署** (min:1 max:3, CPU 70%) |
| **Wave C-D 監控** | ✅ **全部完成** (generate + discover + coverage_report) |
## ✅ Intent Classifier Ollama 整合 (2026-03-30 01:00 台北)
### 實作內容 (Phase 13.4)
| 功能 | 說明 |
|------|------|
| `_llm_classify` | 呼叫 Ollama 進行意圖分類 |
| `_parse_intent_type` | 解析 LLM 返回的意圖字串 |
| `_llm_fallback_result` | LLM 失敗時的 fallback |
| `_LLM_CLASSIFY_PROMPT` | 結構化 Prompt 模板 |
### 設計特點
| 特性 | 說明 |
|------|------|
| **超時控制** | 5 秒超時 (目標 < 100ms) |
| **JSON Mode** | 強制 Ollama 返回 JSON |
| **Fallback** | LLM 失敗時使用關鍵字結果 |
| **確定性** | temperature: 0.0 |
### 測試覆蓋
- **21 測試案例全部通過**
- LLM 成功/超時/解析失敗測試
- 關鍵字分類測試
- 完整流程測試
---
## ✅ Learning Service 信心度調整 (2026-03-30 00:30 台北)
### 實作內容