fix(mcp): P1 修復 - DI 一致性 + 測試補充 + 配置優化
首席架構師審查 P1 修復清單: P1-1 RAG Provider DI 模式一致性: - 支援 rag_service 參數注入 - 新增 close() 方法 - TYPE_CHECKING 延遲導入 P1-3 RAG 測試補充: - test_rag_provider.py (9 tests) - DI 注入/Lazy Load/Tool Schema/驗證/Close P1-4 Grafana Config 快取優化: - URL/Key 首次查詢後快取 - 減少重複 settings 存取 P1-5 Embedding 維度配置化: - MODEL_DIMENSIONS 字典 (qwen/llama/nomic) - default_dimension 參數 - 支援更多模型 測試: 9/9 PASSED Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,8 +17,9 @@ Grafana MCP Tool Provider - Phase 13.2 #83
|
||||
|
||||
@see docs/adr/ADR-015-mcp-modular-architecture.md
|
||||
@author Claude Code
|
||||
@version 1.0
|
||||
@version 1.1
|
||||
@created 2026-03-26 (台北時區)
|
||||
@updated 2026-03-29 (台北時區) - P1 修復: URL/Key 快取優化
|
||||
@issue #83
|
||||
"""
|
||||
|
||||
@@ -80,9 +81,13 @@ class GrafanaProvider(MCPToolProvider):
|
||||
Args:
|
||||
grafana_url: Grafana URL (default: from settings)
|
||||
api_key: Grafana API Key (default: from settings)
|
||||
|
||||
P1 修復 (2026-03-29): 快取 URL/Key 避免重複 settings 查詢
|
||||
"""
|
||||
self._grafana_url = grafana_url
|
||||
self._api_key = api_key
|
||||
self._grafana_url_input = grafana_url
|
||||
self._api_key_input = api_key
|
||||
self._grafana_url: str | None = None # 快取解析後的 URL
|
||||
self._api_key: str | None = None # 快取解析後的 Key
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
logger.info(
|
||||
@@ -91,28 +96,48 @@ class GrafanaProvider(MCPToolProvider):
|
||||
)
|
||||
|
||||
def _get_grafana_url(self) -> str:
|
||||
"""取得 Grafana URL (lazy load from settings)"""
|
||||
if self._grafana_url:
|
||||
"""
|
||||
取得 Grafana URL (快取 + lazy load from settings)
|
||||
|
||||
P1 修復: 首次查詢後快取結果
|
||||
"""
|
||||
if self._grafana_url is not None:
|
||||
return self._grafana_url
|
||||
|
||||
if self._grafana_url_input:
|
||||
self._grafana_url = self._grafana_url_input
|
||||
return self._grafana_url
|
||||
|
||||
try:
|
||||
from src.core.config import get_settings
|
||||
settings = get_settings()
|
||||
return getattr(settings, "GRAFANA_URL", DEFAULT_GRAFANA_URL)
|
||||
self._grafana_url = getattr(settings, "GRAFANA_URL", DEFAULT_GRAFANA_URL)
|
||||
except Exception:
|
||||
return DEFAULT_GRAFANA_URL
|
||||
self._grafana_url = DEFAULT_GRAFANA_URL
|
||||
|
||||
return self._grafana_url
|
||||
|
||||
def _get_api_key(self) -> str | None:
|
||||
"""取得 API Key (lazy load from settings)"""
|
||||
if self._api_key:
|
||||
"""
|
||||
取得 API Key (快取 + lazy load from settings)
|
||||
|
||||
P1 修復: 首次查詢後快取結果
|
||||
"""
|
||||
if self._api_key is not None:
|
||||
return self._api_key
|
||||
|
||||
if self._api_key_input:
|
||||
self._api_key = self._api_key_input
|
||||
return self._api_key
|
||||
|
||||
try:
|
||||
from src.core.config import get_settings
|
||||
settings = get_settings()
|
||||
return getattr(settings, "GRAFANA_API_KEY", None)
|
||||
self._api_key = getattr(settings, "GRAFANA_API_KEY", None)
|
||||
except Exception:
|
||||
return None
|
||||
self._api_key = None
|
||||
|
||||
return self._api_key
|
||||
|
||||
def _get_headers(self) -> dict[str, str]:
|
||||
"""取得 HTTP Headers (含認證)"""
|
||||
|
||||
@@ -9,20 +9,25 @@ RAG MCP Tool Provider - ADR-015 模組化架構
|
||||
|
||||
Phase 13.2 #84 - Runbook RAG Tool
|
||||
|
||||
版本: v1.0
|
||||
版本: v1.1
|
||||
建立日期: 2026-03-27 00:00 (台北時區)
|
||||
更新日期: 2026-03-29 20:40 (台北時區)
|
||||
建立者: Claude Code
|
||||
更新者: Claude Code (P1 修復: DI 模式一致性)
|
||||
"""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import structlog
|
||||
|
||||
from src.plugins.mcp.interfaces import MCPTool, MCPToolProvider, MCPToolResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.services.rag_service import IRAGService
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@@ -32,17 +37,35 @@ class RAGProvider(MCPToolProvider):
|
||||
|
||||
提供維運手冊的語義搜尋功能。
|
||||
使用 Redis Vector Search + Ollama Embedding。
|
||||
|
||||
支援 DI 注入或 Lazy Load (向後相容):
|
||||
# DI 注入 (推薦)
|
||||
provider = RAGProvider(rag_service=my_rag_service)
|
||||
|
||||
# Lazy Load (向後相容)
|
||||
provider = RAGProvider()
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._rag_service = None
|
||||
def __init__(self, rag_service: "IRAGService | None" = None) -> None:
|
||||
"""
|
||||
初始化 Provider
|
||||
|
||||
Args:
|
||||
rag_service: RAG Service 實例 (可選,不提供則 lazy load)
|
||||
"""
|
||||
self._rag_service = rag_service
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "runbooks"
|
||||
|
||||
def _get_rag_service(self):
|
||||
"""Lazy load RAG service"""
|
||||
def _get_rag_service(self) -> "IRAGService":
|
||||
"""
|
||||
取得 RAG Service (支援 DI 或 Lazy Load)
|
||||
|
||||
Returns:
|
||||
IRAGService: RAG Service 實例
|
||||
"""
|
||||
if self._rag_service is None:
|
||||
from src.services.rag_service import get_rag_service
|
||||
self._rag_service = get_rag_service()
|
||||
@@ -232,4 +255,9 @@ class RAGProvider(MCPToolProvider):
|
||||
except Exception as e:
|
||||
logger.warning("rag_health_check_failed", error=str(e))
|
||||
return False
|
||||
# Trigger CD build
|
||||
|
||||
async def close(self) -> None:
|
||||
"""關閉 Provider (清理資源)"""
|
||||
# RAG Service 使用共享 Redis client,不在此關閉
|
||||
self._rag_service = None
|
||||
logger.debug("rag_provider_closed")
|
||||
|
||||
@@ -7,9 +7,11 @@ Embedding Service - Ollama BGE-M3 替代方案
|
||||
|
||||
Phase 13.2 #84 - RAG Tool 基礎設施
|
||||
|
||||
版本: v1.0
|
||||
版本: v1.1
|
||||
建立日期: 2026-03-26 20:30 (台北時區)
|
||||
更新日期: 2026-03-29 20:50 (台北時區)
|
||||
建立者: Claude Code
|
||||
更新者: Claude Code (P1 修復: 維度配置化)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -62,11 +64,21 @@ class OllamaEmbeddingService:
|
||||
vector = await service.embed_text("維運手冊")
|
||||
"""
|
||||
|
||||
# 已知模型維度 (P1 修復: 避免硬編碼)
|
||||
MODEL_DIMENSIONS: dict[str, int] = {
|
||||
"qwen2.5:7b-instruct": 3584,
|
||||
"qwen2.5:3b-instruct": 2048,
|
||||
"llama3.2:3b": 3072,
|
||||
"nomic-embed-text": 768,
|
||||
}
|
||||
DEFAULT_DIMENSION = 3584 # 未知模型的預設值
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str = "qwen2.5:7b-instruct",
|
||||
ollama_url: str | None = None,
|
||||
timeout: float = 30.0,
|
||||
default_dimension: int | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化 Embedding Service
|
||||
@@ -75,10 +87,16 @@ class OllamaEmbeddingService:
|
||||
model: Ollama 模型名稱 (必須支援 embedding)
|
||||
ollama_url: Ollama API URL (預設從 config 讀取)
|
||||
timeout: 請求超時 (秒)
|
||||
default_dimension: 預設向量維度 (可選,未提供則從 MODEL_DIMENSIONS 查詢)
|
||||
|
||||
P1 修復 (2026-03-29): 維度配置化,支援更多模型
|
||||
"""
|
||||
self._model = model
|
||||
self._ollama_url = ollama_url or settings.OLLAMA_URL
|
||||
self._timeout = timeout
|
||||
self._default_dimension = default_dimension or self.MODEL_DIMENSIONS.get(
|
||||
model, self.DEFAULT_DIMENSION
|
||||
)
|
||||
self._dimension: int | None = None
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
@@ -87,12 +105,11 @@ class OllamaEmbeddingService:
|
||||
"""
|
||||
向量維度
|
||||
|
||||
首次呼叫會自動偵測,之後快取。
|
||||
qwen2.5:7b-instruct = 3584 維
|
||||
首次 embed 呼叫會自動偵測實際維度並快取。
|
||||
偵測前返回 MODEL_DIMENSIONS 中的預設值。
|
||||
"""
|
||||
if self._dimension is None:
|
||||
# 預設值,實際會在第一次 embed 時更新
|
||||
return 3584
|
||||
return self._default_dimension
|
||||
return self._dimension
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
|
||||
145
apps/api/tests/test_rag_provider.py
Normal file
145
apps/api/tests/test_rag_provider.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
RAG Provider 測試
|
||||
================
|
||||
Phase 13.2 #84 - Runbook RAG Tool
|
||||
|
||||
測試項目:
|
||||
- Provider 初始化 (DI 注入 vs Lazy Load)
|
||||
- Tool 列表
|
||||
- Input 驗證
|
||||
- 健康檢查
|
||||
|
||||
P1 修復: 2026-03-29 (首席架構師審查後補充)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugins.mcp.providers.rag_provider import RAGProvider
|
||||
|
||||
|
||||
class TestRAGProviderInit:
|
||||
"""Provider 初始化測試"""
|
||||
|
||||
def test_init_without_service(self):
|
||||
"""測試無參數初始化 (Lazy Load 模式)"""
|
||||
provider = RAGProvider()
|
||||
assert provider.name == "runbooks"
|
||||
assert provider._rag_service is None
|
||||
|
||||
def test_init_with_service_injection(self):
|
||||
"""測試 DI 注入模式"""
|
||||
|
||||
class MockRAGService:
|
||||
"""Minimal mock for DI testing"""
|
||||
|
||||
async def search(self, query: str, top_k: int):
|
||||
return []
|
||||
|
||||
async def get_index_stats(self):
|
||||
return {"status": "ok"}
|
||||
|
||||
mock_service = MockRAGService()
|
||||
provider = RAGProvider(rag_service=mock_service)
|
||||
assert provider._rag_service is mock_service
|
||||
|
||||
|
||||
class TestRAGProviderTools:
|
||||
"""Tool 列表測試"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_returns_three_tools(self):
|
||||
"""確認提供 3 個工具"""
|
||||
provider = RAGProvider()
|
||||
tools = await provider.list_tools()
|
||||
|
||||
assert len(tools) == 3
|
||||
tool_names = {t.name for t in tools}
|
||||
assert tool_names == {"search_runbook", "index_documents", "get_index_stats"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_runbook_tool_schema(self):
|
||||
"""確認 search_runbook 的 schema 正確"""
|
||||
provider = RAGProvider()
|
||||
tools = await provider.list_tools()
|
||||
|
||||
search_tool = next(t for t in tools if t.name == "search_runbook")
|
||||
schema = search_tool.input_schema
|
||||
|
||||
assert "properties" in schema
|
||||
assert "query" in schema["properties"]
|
||||
assert schema["required"] == ["query"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_documents_tool_schema(self):
|
||||
"""確認 index_documents 的 schema 正確"""
|
||||
provider = RAGProvider()
|
||||
tools = await provider.list_tools()
|
||||
|
||||
index_tool = next(t for t in tools if t.name == "index_documents")
|
||||
schema = index_tool.input_schema
|
||||
|
||||
assert "properties" in schema
|
||||
assert "force" in schema["properties"]
|
||||
|
||||
|
||||
class TestRAGProviderValidation:
|
||||
"""Input 驗證測試"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_without_query_returns_error(self):
|
||||
"""空 query 應該返回錯誤"""
|
||||
|
||||
class MockRAGService:
|
||||
async def search(self, query: str, top_k: int):
|
||||
return []
|
||||
|
||||
provider = RAGProvider(rag_service=MockRAGService())
|
||||
result = await provider.execute("search_runbook", {"query": ""})
|
||||
|
||||
assert result.success is True # 執行成功但結果為空
|
||||
assert result.output["error"] == "Query is required"
|
||||
assert result.output["results"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_top_k_limited_to_10(self):
|
||||
"""top_k 應該被限制在 10 以內"""
|
||||
|
||||
class MockRAGService:
|
||||
def __init__(self):
|
||||
self.last_top_k = None
|
||||
|
||||
async def search(self, query: str, top_k: int):
|
||||
self.last_top_k = top_k
|
||||
return [{"content": "test", "score": 0.9}]
|
||||
|
||||
mock_service = MockRAGService()
|
||||
provider = RAGProvider(rag_service=mock_service)
|
||||
|
||||
await provider.execute("search_runbook", {"query": "test", "top_k": 100})
|
||||
assert mock_service.last_top_k == 10 # 應該被限制為 10
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_tool_returns_error(self):
|
||||
"""未知工具應該返回錯誤"""
|
||||
provider = RAGProvider()
|
||||
result = await provider.execute("unknown_tool", {})
|
||||
|
||||
assert result.success is False
|
||||
assert "Unknown tool" in result.error
|
||||
|
||||
|
||||
class TestRAGProviderClose:
|
||||
"""Close 方法測試"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_clears_service(self):
|
||||
"""close 應該清理 service 引用"""
|
||||
|
||||
class MockRAGService:
|
||||
pass
|
||||
|
||||
provider = RAGProvider(rag_service=MockRAGService())
|
||||
assert provider._rag_service is not None
|
||||
|
||||
await provider.close()
|
||||
assert provider._rag_service is None
|
||||
Reference in New Issue
Block a user