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:
OG T
2026-03-29 16:23:30 +08:00
parent fc3d4a6b3a
commit 8724ed7dcf
4 changed files with 238 additions and 23 deletions

View File

@@ -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 (含認證)"""

View File

@@ -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")

View File

@@ -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:

View 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