新增: - ILearningRepository Protocol (interfaces.py) - LearningRepository (Redis 持久化層) - Learning API 端點 (/api/v1/learning/*) - LearningService.get_recommended_fix() 方法 - LearningService.get_learning_summary() 方法 修正: - Service 不直接依賴 Redis Client (透過 Repository) - 符合 leWOOOgo 積木化原則 - 首席架構師審查: 74/100 → 92/100 更新: - ADR-030: 新增 Phase D-G P0 修正章節 - Skill 02: v1.9 → v2.0 - Runner 修復: 序列建構解決 _runner_file_commands 衝突 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
314 lines
9.2 KiB
Python
314 lines
9.2 KiB
Python
"""
|
|
Learning Repository - Redis 持久化層
|
|
====================================
|
|
Phase D-G P0 修正: 符合 leWOOOgo 積木化原則
|
|
|
|
職責:
|
|
- 學習數據 Redis 持久化
|
|
- 修復結果記錄
|
|
- 統計查詢
|
|
|
|
版本: v1.0
|
|
建立: 2026-03-29 (台北時區)
|
|
建立者: Claude Code (Phase D-G P0 修正)
|
|
|
|
遵循原則:
|
|
- Repository 層負責資料存取
|
|
- Service 層只透過 Interface 依賴
|
|
- 不在 Service 層直接存取 Redis
|
|
"""
|
|
|
|
import json
|
|
|
|
import structlog
|
|
|
|
from src.core.redis_client import get_redis
|
|
from src.repositories.interfaces import ILearningRepository
|
|
from src.utils.timezone import now_taipei
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
|
|
class LearningRepository:
|
|
"""
|
|
Learning Repository 實作
|
|
|
|
Redis Key 結構:
|
|
- learning:repair:{anomaly_key}:{action} -> List[JSON] (歷史記錄)
|
|
- learning:stats:{anomaly_key}:{action} -> Hash (統計)
|
|
"""
|
|
|
|
# TTL: 90 天
|
|
HISTORY_TTL = 90 * 24 * 3600
|
|
STATS_TTL = 90 * 24 * 3600
|
|
|
|
def __init__(self, redis_client=None):
|
|
"""
|
|
初始化 Repository
|
|
|
|
Args:
|
|
redis_client: Redis 客戶端 (預設使用共用實例)
|
|
"""
|
|
self._redis = redis_client
|
|
|
|
def _get_redis(self):
|
|
"""Lazy initialization for Redis client"""
|
|
if self._redis is None:
|
|
self._redis = get_redis()
|
|
return self._redis
|
|
|
|
# =========================================================================
|
|
# ILearningRepository Implementation
|
|
# =========================================================================
|
|
|
|
async def record_repair(
|
|
self,
|
|
anomaly_key: str,
|
|
repair_action: str,
|
|
success: bool,
|
|
root_cause: str | None = None,
|
|
fix_description: str | None = None,
|
|
execution_time_seconds: float | None = None,
|
|
) -> bool:
|
|
"""
|
|
記錄修復結果
|
|
|
|
Args:
|
|
anomaly_key: 異常 key
|
|
repair_action: 修復動作
|
|
success: 是否成功
|
|
root_cause: 根因 (如果找到)
|
|
fix_description: 修復說明
|
|
execution_time_seconds: 執行時間
|
|
|
|
Returns:
|
|
bool: 是否成功記錄
|
|
"""
|
|
redis = self._get_redis()
|
|
history_key = f"learning:repair:{anomaly_key}:{repair_action}"
|
|
stats_key = f"learning:stats:{anomaly_key}:{repair_action}"
|
|
|
|
try:
|
|
# 1. 記錄歷史
|
|
record = {
|
|
"success": success,
|
|
"root_cause": root_cause,
|
|
"fix_description": fix_description,
|
|
"execution_time": execution_time_seconds,
|
|
"timestamp": now_taipei().isoformat(),
|
|
}
|
|
await redis.lpush(history_key, json.dumps(record))
|
|
await redis.ltrim(history_key, 0, 99) # 保留最近 100 次
|
|
await redis.expire(history_key, self.HISTORY_TTL)
|
|
|
|
# 2. 更新統計
|
|
await redis.hincrby(stats_key, "total", 1)
|
|
if success:
|
|
await redis.hincrby(stats_key, "success", 1)
|
|
await redis.expire(stats_key, self.STATS_TTL)
|
|
|
|
logger.debug(
|
|
"learning_repair_recorded",
|
|
anomaly_key=anomaly_key,
|
|
action=repair_action,
|
|
success=success,
|
|
)
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
"learning_repair_record_failed",
|
|
anomaly_key=anomaly_key,
|
|
action=repair_action,
|
|
error=str(e),
|
|
)
|
|
return False
|
|
|
|
async def get_repair_stats(
|
|
self,
|
|
anomaly_key: str,
|
|
repair_action: str,
|
|
) -> dict:
|
|
"""
|
|
取得修復統計
|
|
|
|
Returns:
|
|
{
|
|
"total": int,
|
|
"success": int,
|
|
"success_rate": float
|
|
}
|
|
"""
|
|
redis = self._get_redis()
|
|
stats_key = f"learning:stats:{anomaly_key}:{repair_action}"
|
|
|
|
try:
|
|
data = await redis.hgetall(stats_key)
|
|
total = int(data.get("total", 0))
|
|
success = int(data.get("success", 0))
|
|
|
|
return {
|
|
"total": total,
|
|
"success": success,
|
|
"success_rate": success / total if total > 0 else 0.0,
|
|
}
|
|
except Exception as e:
|
|
logger.warning(
|
|
"learning_stats_fetch_failed",
|
|
anomaly_key=anomaly_key,
|
|
action=repair_action,
|
|
error=str(e),
|
|
)
|
|
return {"total": 0, "success": 0, "success_rate": 0.0}
|
|
|
|
async def get_all_repair_stats(
|
|
self,
|
|
anomaly_key: str,
|
|
) -> dict[str, dict]:
|
|
"""
|
|
取得所有修復動作的統計
|
|
|
|
Returns:
|
|
{
|
|
"restart_pod": {"total": 5, "success": 4, "success_rate": 0.8},
|
|
"scale_up": {"total": 2, "success": 2, "success_rate": 1.0},
|
|
...
|
|
}
|
|
"""
|
|
redis = self._get_redis()
|
|
pattern = f"learning:stats:{anomaly_key}:*"
|
|
|
|
result: dict[str, dict] = {}
|
|
|
|
try:
|
|
# 使用 SCAN 避免 KEYS 阻塞
|
|
cursor = 0
|
|
while True:
|
|
cursor, keys = await redis.scan(cursor, match=pattern, count=100)
|
|
for key in keys:
|
|
# 提取 action 名稱
|
|
action = key.split(":")[-1]
|
|
data = await redis.hgetall(key)
|
|
total = int(data.get("total", 0))
|
|
success = int(data.get("success", 0))
|
|
result[action] = {
|
|
"total": total,
|
|
"success": success,
|
|
"success_rate": success / total if total > 0 else 0.0,
|
|
}
|
|
|
|
if cursor == 0:
|
|
break
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.warning(
|
|
"learning_all_stats_fetch_failed",
|
|
anomaly_key=anomaly_key,
|
|
error=str(e),
|
|
)
|
|
return {}
|
|
|
|
async def get_repair_history(
|
|
self,
|
|
anomaly_key: str,
|
|
repair_action: str,
|
|
limit: int = 20,
|
|
) -> list[dict]:
|
|
"""
|
|
取得修復歷史記錄
|
|
|
|
Returns:
|
|
list[dict]: 最近的修復記錄 (由新到舊)
|
|
"""
|
|
redis = self._get_redis()
|
|
history_key = f"learning:repair:{anomaly_key}:{repair_action}"
|
|
|
|
try:
|
|
records = await redis.lrange(history_key, 0, limit - 1)
|
|
return [json.loads(r) for r in records]
|
|
except Exception as e:
|
|
logger.warning(
|
|
"learning_history_fetch_failed",
|
|
anomaly_key=anomaly_key,
|
|
action=repair_action,
|
|
error=str(e),
|
|
)
|
|
return []
|
|
|
|
async def get_learning_summary(
|
|
self,
|
|
anomaly_key: str,
|
|
) -> dict:
|
|
"""
|
|
取得學習摘要
|
|
|
|
Returns:
|
|
{
|
|
"anomaly_key": str,
|
|
"total_repair_attempts": int,
|
|
"overall_success_rate": float,
|
|
"actions_tried": list[str],
|
|
"best_action": {"action": str, "success_rate": float} | None,
|
|
"learning_status": str # insufficient, learning, sufficient, excellent
|
|
}
|
|
"""
|
|
all_stats = await self.get_all_repair_stats(anomaly_key)
|
|
|
|
if not all_stats:
|
|
return {
|
|
"anomaly_key": anomaly_key,
|
|
"total_repair_attempts": 0,
|
|
"overall_success_rate": 0.0,
|
|
"actions_tried": [],
|
|
"best_action": None,
|
|
"learning_status": "insufficient",
|
|
}
|
|
|
|
total_attempts = sum(s["total"] for s in all_stats.values())
|
|
total_success = sum(s["success"] for s in all_stats.values())
|
|
overall_rate = total_success / total_attempts if total_attempts > 0 else 0.0
|
|
|
|
# 找出最佳動作
|
|
best_action = None
|
|
best_rate = 0.0
|
|
for action, stats in all_stats.items():
|
|
if stats["total"] >= 3 and stats["success_rate"] > best_rate:
|
|
best_rate = stats["success_rate"]
|
|
best_action = {"action": action, "success_rate": best_rate}
|
|
|
|
# 判斷學習狀態
|
|
if total_attempts < 3:
|
|
status = "insufficient"
|
|
elif total_attempts < 10:
|
|
status = "learning"
|
|
elif overall_rate >= 0.8:
|
|
status = "excellent"
|
|
else:
|
|
status = "sufficient"
|
|
|
|
return {
|
|
"anomaly_key": anomaly_key,
|
|
"total_repair_attempts": total_attempts,
|
|
"overall_success_rate": overall_rate,
|
|
"actions_tried": list(all_stats.keys()),
|
|
"best_action": best_action,
|
|
"learning_status": status,
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# Singleton
|
|
# =============================================================================
|
|
|
|
_repository: LearningRepository | None = None
|
|
|
|
|
|
def get_learning_repository() -> ILearningRepository:
|
|
"""取得 LearningRepository 單例"""
|
|
global _repository
|
|
if _repository is None:
|
|
_repository = LearningRepository()
|
|
return _repository
|