diff --git a/apps/api/src/services/incident_engine.py b/apps/api/src/services/incident_engine.py index 31287bd5..1a1fcef7 100644 --- a/apps/api/src/services/incident_engine.py +++ b/apps/api/src/services/incident_engine.py @@ -593,7 +593,8 @@ class IncidentEngine: # 修復可能被轉成空物件的陣列欄位 array_fields = ["signals", "affected_services", "proposal_ids"] for field in array_fields: - if field in data and isinstance(data[field], dict) and len(data[field]) == 0: + is_empty_dict = isinstance(data[field], dict) and len(data[field]) == 0 + if field in data and is_empty_dict: data[field] = [] return Incident.model_validate(data) @@ -643,15 +644,175 @@ class IncidentEngine: # ============================================================================= -# Singleton +# Phase 16: 絞殺者模式 - Adapter 實作 +# ============================================================================= + +class IncidentMemoryAdapter: + """ + Incident Memory Adapter - 實作 lewooogo-brain 的 IIncidentMemory Protocol + + Phase 16 R1.3: 橋接現有 Lua Scripts + DualIncidentMemory 到新 IncidentEngine + + 版本: v1.0 + 建立: 2026-03-26 (台北時區) + 建立者: Claude Code + """ + + def __init__(self, memory: DualIncidentMemory) -> None: + self._memory = memory + self._lua_create_sha: str | None = None + self._lua_aggregate_sha: str | None = None + + async def _ensure_lua_scripts(self) -> None: + """確保 Lua Scripts 已載入""" + if self._lua_create_sha: + return + redis_client = get_redis() + self._lua_create_sha = await redis_client.script_load(LUA_CREATE_OR_AGGREGATE) + self._lua_aggregate_sha = await redis_client.script_load(LUA_AGGREGATE_SIGNAL) + + async def load_incident(self, incident_id: str) -> Incident | None: + """從 Working Memory 載入 Incident""" + return await self._memory.load_incident(incident_id) + + async def save_incident( + self, incident: Incident, ttl_seconds: int = 604800 + ) -> bool: + """儲存 Incident 到 Working Memory""" + try: + redis_client = get_redis() + key = f"{INCIDENT_KEY_PREFIX}{incident.incident_id}" + await redis_client.set(key, incident.model_dump_json(), ex=ttl_seconds) + return True + except Exception as e: + logger.exception("save_incident_error", error=str(e)) + return False + + async def persist_incident(self, incident: Incident) -> bool: + """持久化到 Episodic Memory (PostgreSQL)""" + return await self._memory.persist_incident(incident) + + async def find_related_incident( + self, + namespace: str, + target: str, + window_minutes: int = 30, # noqa: ARG002 + ) -> Incident | None: + """尋找相關的活躍 Incident (用於聚合)""" + redis_client = get_redis() + + # 嘗試 namespace 索引 + ns_key = f"{INCIDENT_INDEX_NS}{namespace}" + incident_id = await redis_client.get(ns_key) + + if not incident_id: + # 嘗試 target 索引 + target_key = f"{INCIDENT_INDEX_TARGET}{target}" + incident_id = await redis_client.get(target_key) + + if incident_id: + if isinstance(incident_id, bytes): + incident_id = incident_id.decode() + return await self.load_incident(incident_id) + + return None + + async def update_index( + self, + incident_id: str, + namespace: str, + target: str, + ) -> bool: + """更新反向索引""" + try: + redis_client = get_redis() + ttl = AGGREGATION_WINDOW_SECONDS + + ns_key = f"{INCIDENT_INDEX_NS}{namespace}" + target_key = f"{INCIDENT_INDEX_TARGET}{target}" + + await redis_client.set(ns_key, incident_id, ex=ttl, nx=True) + await redis_client.set(target_key, incident_id, ex=ttl, nx=True) + return True + except Exception as e: + logger.exception("update_index_error", error=str(e)) + return False + + +class BlastRadiusAdapter: + """ + Blast Radius Adapter - 實作 lewooogo-brain 的 IBlastRadiusAnalyzer Protocol + + Phase 16 R1.3: 包裝現有 topology_graph + + 版本: v1.0 + 建立: 2026-03-26 (台北時區) + 建立者: Claude Code + """ + + def __init__(self, graph=None) -> None: + self._graph = graph or topology_graph + + def analyze(self, target: str) -> list[str]: + """分析受影響的服務列表""" + try: + result: BlastRadiusResult = self._graph.get_blast_radius(target) + return result.affected_services + except Exception as e: + logger.warning("blast_radius_analysis_failed", target=target, error=str(e)) + return [target] if target != "unknown" else [] + + +# ============================================================================= +# Singleton + 絞殺者模式切換 # ============================================================================= _incident_engine: IncidentEngine | None = None +_new_incident_engine = None # Type: lewooogo_brain IncidentEngine -def get_incident_engine() -> IncidentEngine: - """取得 Incident Engine 實例 (Singleton)""" +def _get_new_engine(): + """取得 lewooogo-brain 的 IncidentEngine (Phase 16 新版)""" + global _new_incident_engine + if _new_incident_engine is None: + from lewooogo_brain.engines import IncidentEngine as NewIncidentEngine + + # 建立 Adapters + memory_adapter = IncidentMemoryAdapter(get_incident_memory()) + blast_adapter = BlastRadiusAdapter() + + _new_incident_engine = NewIncidentEngine( + memory=memory_adapter, + blast_analyzer=blast_adapter, + logger=logger, + ) + logger.info("new_incident_engine_initialized", version="lewooogo-brain") + return _new_incident_engine + + +def _get_legacy_engine() -> IncidentEngine: + """取得舊版 IncidentEngine""" global _incident_engine if _incident_engine is None: _incident_engine = IncidentEngine() return _incident_engine + + +def get_incident_engine(): + """ + 取得 Incident Engine 實例 (Singleton + 絞殺者模式) + + Phase 16: 根據 USE_NEW_ENGINE 設定切換引擎 + - False (預設): 使用內嵌版 IncidentEngine + - True: 使用 lewooogo-brain 的 IncidentEngine + + 回滾方式: + kubectl set env deployment/awoooi-api USE_NEW_ENGINE=false + """ + from src.core.config import settings + + if settings.USE_NEW_ENGINE: + logger.debug("using_new_incident_engine", version="lewooogo-brain") + return _get_new_engine() + else: + return _get_legacy_engine()