refactor(api): playbook_rag.py 模組化改造 (P1 違規修復)
修復 P1 違規: - Line 29: Service 直接 import Redis → Repository Pattern - Line 156: 自建 httpx.AsyncClient → DI 注入 變更: - 新增 IEmbeddingCacheRepository Protocol (interfaces.py) - 新增 EmbeddingCacheRepository 實作 (embedding_repository.py) - PlaybookRAGService 改用 DI 注入 http_client + embedding_cache - get_playbook_rag_service() 改為 async factory - PlaybookService 改用 lazy initialization Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,22 +12,26 @@ ADR-030: 智能自動修復系統
|
||||
- Embedding 快取,避免重複計算
|
||||
- 混合搜尋 (向量 + Jaccard)
|
||||
- Fallback: Embedding 失敗時用 Jaccard
|
||||
- 2026-03-27 ogt: 模組化改造 (P1 違規修復)
|
||||
- Repository Pattern for Redis
|
||||
- DI httpx client from Lifespan
|
||||
|
||||
版本: v1.0
|
||||
版本: v1.1
|
||||
建立: 2026-03-26 (台北時區)
|
||||
修改: 2026-03-27 (模組化改造)
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
|
||||
from src.core.config import settings
|
||||
from src.core.redis_client import get_redis
|
||||
from src.models.playbook import Playbook, SymptomPattern
|
||||
from src.repositories.interfaces import IEmbeddingCacheRepository
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -40,13 +44,6 @@ logger = structlog.get_logger(__name__)
|
||||
EMBEDDING_MODEL = "nomic-embed-text"
|
||||
EMBEDDING_DIM = 768 # nomic-embed-text 向量維度
|
||||
|
||||
# Redis Keys
|
||||
PLAYBOOK_EMBEDDING_PREFIX = "playbook:embedding:"
|
||||
PLAYBOOK_EMBEDDING_INDEX = "playbook:embedding:index"
|
||||
|
||||
# Cache TTL: 30 天 (Embedding 不常變化)
|
||||
EMBEDDING_TTL_SECONDS = 30 * 24 * 60 * 60
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Data Models
|
||||
@@ -130,9 +127,25 @@ class PlaybookRAGService:
|
||||
1. 將 Playbook 向量化並存入 Redis
|
||||
2. 語意搜尋相似的 Playbook
|
||||
3. 混合搜尋 (向量 + Jaccard)
|
||||
|
||||
2026-03-27 ogt: 模組化改造
|
||||
- 使用 DI 注入 http_client 和 embedding_cache
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(
|
||||
self,
|
||||
http_client: httpx.AsyncClient,
|
||||
embedding_cache: IEmbeddingCacheRepository,
|
||||
):
|
||||
"""
|
||||
初始化 RAG Service
|
||||
|
||||
Args:
|
||||
http_client: httpx AsyncClient (DI 注入,來自 Lifespan)
|
||||
embedding_cache: Embedding Cache Repository (DI 注入)
|
||||
"""
|
||||
self._http_client = http_client
|
||||
self._embedding_cache = embedding_cache
|
||||
self.ollama_url = settings.OLLAMA_URL
|
||||
self.embedding_model = EMBEDDING_MODEL
|
||||
|
||||
@@ -144,6 +157,8 @@ class PlaybookRAGService:
|
||||
"""
|
||||
使用 Ollama 生成文字 embedding
|
||||
|
||||
2026-03-27 ogt: 改用 DI 注入的 http_client (P1 違規修復)
|
||||
|
||||
Args:
|
||||
text: 要向量化的文字
|
||||
|
||||
@@ -151,33 +166,32 @@ class PlaybookRAGService:
|
||||
向量 (768 維) 或 None (失敗時)
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
# 使用 DI 注入的 http_client,設置單次請求 timeout
|
||||
response = await self._http_client.post(
|
||||
f"{self.ollama_url}/api/embeddings",
|
||||
json={
|
||||
"model": self.embedding_model,
|
||||
"prompt": text,
|
||||
},
|
||||
timeout=30.0, # 單次請求 timeout
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
f"{self.ollama_url}/api/embeddings",
|
||||
json={
|
||||
"model": self.embedding_model,
|
||||
"prompt": text,
|
||||
},
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"ollama_embedding_failed",
|
||||
status_code=response.status_code,
|
||||
text_preview=text[:50],
|
||||
)
|
||||
return None
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"ollama_embedding_failed",
|
||||
status_code=response.status_code,
|
||||
text_preview=text[:50],
|
||||
)
|
||||
return None
|
||||
result = response.json()
|
||||
embedding = result.get("embedding", [])
|
||||
|
||||
result = response.json()
|
||||
embedding = result.get("embedding", [])
|
||||
if not embedding:
|
||||
logger.warning("ollama_embedding_empty", text_preview=text[:50])
|
||||
return None
|
||||
|
||||
if not embedding:
|
||||
logger.warning("ollama_embedding_empty", text_preview=text[:50])
|
||||
return None
|
||||
|
||||
return normalize_vector(embedding)
|
||||
return normalize_vector(embedding)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
@@ -249,7 +263,8 @@ class PlaybookRAGService:
|
||||
return await self.embed_text(text)
|
||||
|
||||
# =========================================================================
|
||||
# Storage Operations
|
||||
# Storage Operations (委派給 Repository)
|
||||
# 2026-03-27 ogt: 改用 DI 注入的 embedding_cache (P1 違規修復)
|
||||
# =========================================================================
|
||||
|
||||
async def store_playbook_embedding(
|
||||
@@ -258,97 +273,19 @@ class PlaybookRAGService:
|
||||
embedding: list[float],
|
||||
metadata: dict | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
儲存 Playbook 向量到 Redis
|
||||
|
||||
存儲格式:
|
||||
- playbook:embedding:{id} -> {vector: [...], metadata: {...}}
|
||||
- playbook:embedding:index -> Set of playbook_ids
|
||||
"""
|
||||
try:
|
||||
redis = get_redis()
|
||||
key = f"{PLAYBOOK_EMBEDDING_PREFIX}{playbook_id}"
|
||||
|
||||
data = {
|
||||
"vector": embedding,
|
||||
"metadata": metadata or {},
|
||||
"updated_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
await redis.set(
|
||||
key,
|
||||
json.dumps(data),
|
||||
ex=EMBEDDING_TTL_SECONDS,
|
||||
)
|
||||
|
||||
# 更新索引
|
||||
await redis.sadd(PLAYBOOK_EMBEDDING_INDEX, playbook_id)
|
||||
|
||||
logger.debug(
|
||||
"playbook_embedding_stored",
|
||||
playbook_id=playbook_id,
|
||||
vector_dim=len(embedding),
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"playbook_embedding_store_failed",
|
||||
playbook_id=playbook_id,
|
||||
error=str(e),
|
||||
)
|
||||
return False
|
||||
"""儲存 Playbook 向量到 Redis (委派給 Repository)"""
|
||||
return await self._embedding_cache.store(playbook_id, embedding, metadata)
|
||||
|
||||
async def get_playbook_embedding(
|
||||
self,
|
||||
playbook_id: str,
|
||||
) -> list[float] | None:
|
||||
"""取得 Playbook 向量"""
|
||||
try:
|
||||
redis = get_redis()
|
||||
key = f"{PLAYBOOK_EMBEDDING_PREFIX}{playbook_id}"
|
||||
|
||||
data = await redis.get(key)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
parsed = json.loads(data)
|
||||
return parsed.get("vector")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"playbook_embedding_get_failed",
|
||||
playbook_id=playbook_id,
|
||||
error=str(e),
|
||||
)
|
||||
return None
|
||||
"""取得 Playbook 向量 (委派給 Repository)"""
|
||||
return await self._embedding_cache.get(playbook_id)
|
||||
|
||||
async def get_all_playbook_embeddings(self) -> dict[str, list[float]]:
|
||||
"""取得所有 Playbook 向量"""
|
||||
try:
|
||||
redis = get_redis()
|
||||
|
||||
# 取得所有 playbook_id
|
||||
playbook_ids = await redis.smembers(PLAYBOOK_EMBEDDING_INDEX)
|
||||
if not playbook_ids:
|
||||
return {}
|
||||
|
||||
# 批次取得向量
|
||||
result = {}
|
||||
for pid in playbook_ids:
|
||||
pid_str = pid.decode() if isinstance(pid, bytes) else pid
|
||||
vec = await self.get_playbook_embedding(pid_str)
|
||||
if vec:
|
||||
result[pid_str] = vec
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"playbook_embeddings_get_all_failed",
|
||||
error=str(e),
|
||||
)
|
||||
return {}
|
||||
"""取得所有 Playbook 向量 (委派給 Repository)"""
|
||||
return await self._embedding_cache.get_all()
|
||||
|
||||
# =========================================================================
|
||||
# Search Operations
|
||||
@@ -561,24 +498,8 @@ class PlaybookRAGService:
|
||||
)
|
||||
|
||||
async def remove_playbook_index(self, playbook_id: str) -> bool:
|
||||
"""移除 Playbook 向量索引"""
|
||||
try:
|
||||
redis = get_redis()
|
||||
key = f"{PLAYBOOK_EMBEDDING_PREFIX}{playbook_id}"
|
||||
|
||||
await redis.delete(key)
|
||||
await redis.srem(PLAYBOOK_EMBEDDING_INDEX, playbook_id)
|
||||
|
||||
logger.info("playbook_index_removed", playbook_id=playbook_id)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"playbook_index_remove_failed",
|
||||
playbook_id=playbook_id,
|
||||
error=str(e),
|
||||
)
|
||||
return False
|
||||
"""移除 Playbook 向量索引 (委派給 Repository)"""
|
||||
return await self._embedding_cache.remove(playbook_id)
|
||||
|
||||
async def reindex_all_playbooks(
|
||||
self,
|
||||
@@ -610,15 +531,54 @@ class PlaybookRAGService:
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Singleton
|
||||
# Factory (DI-aware)
|
||||
# 2026-03-27 ogt: 模組化改造 - 支援 DI 注入
|
||||
# =============================================================================
|
||||
|
||||
_rag_service: PlaybookRAGService | None = None
|
||||
|
||||
|
||||
def get_playbook_rag_service() -> PlaybookRAGService:
|
||||
"""取得 Playbook RAG 服務 singleton"""
|
||||
async def get_playbook_rag_service() -> PlaybookRAGService:
|
||||
"""
|
||||
取得 Playbook RAG 服務 singleton (lazy initialization)
|
||||
|
||||
2026-03-27 ogt: 改用 DI 注入,從 Lifespan 取得 http_client 和 Redis
|
||||
"""
|
||||
global _rag_service
|
||||
if _rag_service is None:
|
||||
_rag_service = PlaybookRAGService()
|
||||
# 延遲導入避免循環依賴
|
||||
from src.core.http_client import get_general_client
|
||||
from src.core.redis_client import get_redis
|
||||
from src.repositories.embedding_repository import EmbeddingCacheRepository
|
||||
|
||||
http_client = await get_general_client()
|
||||
redis = get_redis()
|
||||
embedding_cache = EmbeddingCacheRepository(redis)
|
||||
|
||||
_rag_service = PlaybookRAGService(
|
||||
http_client=http_client,
|
||||
embedding_cache=embedding_cache,
|
||||
)
|
||||
return _rag_service
|
||||
|
||||
|
||||
def create_playbook_rag_service(
|
||||
http_client: httpx.AsyncClient,
|
||||
embedding_cache: IEmbeddingCacheRepository,
|
||||
) -> PlaybookRAGService:
|
||||
"""
|
||||
建立 PlaybookRAGService 實例 (工廠函數)
|
||||
|
||||
用於測試或需要自訂依賴的場景
|
||||
|
||||
Args:
|
||||
http_client: httpx AsyncClient
|
||||
embedding_cache: Embedding Cache Repository
|
||||
|
||||
Returns:
|
||||
PlaybookRAGService 實例
|
||||
"""
|
||||
return PlaybookRAGService(
|
||||
http_client=http_client,
|
||||
embedding_cache=embedding_cache,
|
||||
)
|
||||
|
||||
@@ -87,7 +87,14 @@ class PlaybookService:
|
||||
|
||||
def __init__(self, repository: IPlaybookRepository | None = None):
|
||||
self._repository = repository or get_playbook_repository()
|
||||
self._rag_service = get_playbook_rag_service()
|
||||
# 2026-03-27 ogt: RAG Service 改為 lazy initialization (async factory)
|
||||
self._rag_service = None
|
||||
|
||||
async def _get_rag_service(self):
|
||||
"""Lazy initialization for RAG service (2026-03-27 async factory)"""
|
||||
if self._rag_service is None:
|
||||
self._rag_service = await get_playbook_rag_service()
|
||||
return self._rag_service
|
||||
|
||||
# === Core Operations ===
|
||||
|
||||
@@ -182,7 +189,8 @@ class PlaybookService:
|
||||
async def _index_playbook_async(self, playbook: Playbook) -> None:
|
||||
"""非同步建立 Playbook 向量索引 (ADR-030 Phase 3)"""
|
||||
try:
|
||||
success = await self._rag_service.index_playbook(playbook)
|
||||
rag_service = await self._get_rag_service()
|
||||
success = await rag_service.index_playbook(playbook)
|
||||
if success:
|
||||
logger.debug(
|
||||
"playbook_indexed",
|
||||
@@ -223,7 +231,8 @@ class PlaybookService:
|
||||
# Step 2: RAG 混合搜尋 (如果啟用)
|
||||
if use_rag and symptoms.alert_names:
|
||||
try:
|
||||
hybrid_matches = await self._rag_service.hybrid_search(
|
||||
rag_service = await self._get_rag_service()
|
||||
hybrid_matches = await rag_service.hybrid_search(
|
||||
symptoms=symptoms,
|
||||
jaccard_results=jaccard_results,
|
||||
top_k=top_k * 2,
|
||||
|
||||
Reference in New Issue
Block a user