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:
OG T
2026-03-27 10:07:30 +08:00
parent 30bed33401
commit f1b037bb0c
4 changed files with 317 additions and 143 deletions

View File

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

View File

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