feat(api): Phase 16 R1.3 IncidentEngine 絞殺者模式

新增:
- IncidentMemoryAdapter: 實作 IIncidentMemory Protocol
- BlastRadiusAdapter: 實作 IBlastRadiusAnalyzer Protocol
- get_incident_engine() 雙軌切換 (USE_NEW_ENGINE)

絞殺者模式設計:
- 預設 USE_NEW_ENGINE=false (使用內嵌版)
- 設為 true 時使用 lewooogo-brain IncidentEngine
- 回滾: kubectl set env deployment/awoooi-api USE_NEW_ENGINE=false

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-25 15:47:52 +08:00
parent 21ecedded2
commit 2637263093

View File

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