From 8724ed7dcf17fcbd6b18e0b533d97b168bb07548 Mon Sep 17 00:00:00 2001 From: OG T Date: Sun, 29 Mar 2026 16:23:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(mcp):=20P1=20=E4=BF=AE=E5=BE=A9=20-=20DI=20?= =?UTF-8?q?=E4=B8=80=E8=87=B4=E6=80=A7=20+=20=E6=B8=AC=E8=A9=A6=E8=A3=9C?= =?UTF-8?q?=E5=85=85=20+=20=E9=85=8D=E7=BD=AE=E5=84=AA=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 首席架構師審查 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 --- .../plugins/mcp/providers/grafana_provider.py | 47 ++++-- .../src/plugins/mcp/providers/rag_provider.py | 42 ++++- apps/api/src/services/embedding_service.py | 27 +++- apps/api/tests/test_rag_provider.py | 145 ++++++++++++++++++ 4 files changed, 238 insertions(+), 23 deletions(-) create mode 100644 apps/api/tests/test_rag_provider.py diff --git a/apps/api/src/plugins/mcp/providers/grafana_provider.py b/apps/api/src/plugins/mcp/providers/grafana_provider.py index 08a813dd..3d7f4915 100644 --- a/apps/api/src/plugins/mcp/providers/grafana_provider.py +++ b/apps/api/src/plugins/mcp/providers/grafana_provider.py @@ -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 (含認證)""" diff --git a/apps/api/src/plugins/mcp/providers/rag_provider.py b/apps/api/src/plugins/mcp/providers/rag_provider.py index 9db2612a..0e0b399b 100644 --- a/apps/api/src/plugins/mcp/providers/rag_provider.py +++ b/apps/api/src/plugins/mcp/providers/rag_provider.py @@ -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") diff --git a/apps/api/src/services/embedding_service.py b/apps/api/src/services/embedding_service.py index 50def1ba..fe8dda4f 100644 --- a/apps/api/src/services/embedding_service.py +++ b/apps/api/src/services/embedding_service.py @@ -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: diff --git a/apps/api/tests/test_rag_provider.py b/apps/api/tests/test_rag_provider.py new file mode 100644 index 00000000..01279658 --- /dev/null +++ b/apps/api/tests/test_rag_provider.py @@ -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