""" Incident Memory Provider - 事件記憶體提供者 ============================================ Phase 6.4e: DualIncidentMemory 整合 Phase 16 R1.2: 絞殺者模式 (Strangler Fig Pattern) 2026-03-26 Phase R-R2 (2026-04-01 ogt): 移除內嵌 DualIncidentMemory 重複邏輯, 全面切換至 lewooogo-brain。回滾方式: git revert + redeploy。 設計: - IncidentDbAdapter: SQLAlchemy Bridge,注入 lewooogo-brain DualIncidentMemory - 雙層記憶體: Working (Redis) + Episodic (PostgreSQL) - 反向索引: namespace:target -> incident_id 統帥鐵律: - Working Memory (Redis): 7 天 TTL - Episodic Memory (PostgreSQL): 永久 - 反向索引: 30 分鐘 TTL (聚合窗口) """ from typing import Any import structlog from src.core.redis_client import get_redis from src.db.base import get_db_context from src.db.models import IncidentRecord from src.models.incident import Incident logger = structlog.get_logger(__name__) def _signal_to_dict(signal: Any) -> dict[str, Any]: """Normalize brain/local Signal objects and raw dicts into one shape.""" if isinstance(signal, dict): return signal if hasattr(signal, "model_dump"): return signal.model_dump(mode="json") return { "alert_name": getattr(signal, "alert_name", None), "severity": getattr(signal, "severity", None), "source": getattr(signal, "source", None), "labels": getattr(signal, "labels", None) or {}, "annotations": getattr(signal, "annotations", None) or {}, "fingerprint": getattr(signal, "fingerprint", None), } def _derive_incident_alert_metadata(incident: Incident) -> dict[str, Any]: """Derive alert metadata for incidents saved through the lewooogo bridge.""" first_signal = incident.signals[0] if incident.signals else None signal = _signal_to_dict(first_signal) if first_signal else {} labels = signal.get("labels") or {} annotations = signal.get("annotations") or {} alertname = ( labels.get("alertname") or signal.get("alert_name") or signal.get("alertname") or "" ) severity = ( signal.get("severity") or getattr(incident.severity, "value", incident.severity) or labels.get("severity") or "warning" ) severity = getattr(severity, "value", severity) alert_category = None notification_type = None if alertname: from src.services.incident_service import classify_alert_early alert_category, notification_type = classify_alert_early( str(alertname), str(severity), labels, ) description = ( annotations.get("message") or annotations.get("description") or annotations.get("summary") or "" ) return { "alertname": str(alertname) if alertname else None, "severity": str(severity) if severity else None, "alert_category": alert_category, "notification_type": notification_type, "description": str(description) if description else None, "actor": signal.get("source") or labels.get("source") or "signal_worker", } async def _add_signal_timeline_event( incident: Incident, metadata: dict[str, Any], ) -> None: """Best-effort timeline seed for incidents created outside Alertmanager.""" alertname = metadata.get("alertname") if not alertname: return try: from src.services.approval_db import get_timeline_service await get_timeline_service().add_event( event_type="webhook", status="success", title=f"Signal received: {alertname}", description=metadata.get("description"), actor=metadata.get("actor"), actor_role="signal_worker", risk_level=getattr(incident.severity, "value", incident.severity), incident_id=incident.incident_id, ) except Exception as exc: logger.warning( "incident_signal_timeline_seed_failed", incident_id=incident.incident_id, alertname=alertname, error=str(exc), ) # ============================================================================= # Phase 16: IncidentDbAdapter (DI 注入實現) # ============================================================================= class IncidentDbAdapter: """ Incident DB Adapter - 實現 lewooogo-brain 的 IIncidentDbAdapter Phase 16: 將 apps/api 的 SQLAlchemy Model 操作封裝為 adapter 注入到 lewooogo-brain 的 DualIncidentMemory """ async def load(self, incident_id: str) -> Incident | None: """從 PostgreSQL 載入 Incident""" try: async with get_db_context() as db: from sqlalchemy import select stmt = select(IncidentRecord).where( IncidentRecord.incident_id == incident_id ) result = await db.execute(stmt) record = result.scalar_one_or_none() if record: return self._record_to_incident(record) return None except Exception as e: logger.error("db_adapter_load_failed", incident_id=incident_id, error=str(e)) return None async def save(self, incident: Incident) -> bool: """儲存 Incident 到 PostgreSQL (upsert)""" metadata = _derive_incident_alert_metadata(incident) created = False try: async with get_db_context() as db: from sqlalchemy import select stmt = select(IncidentRecord).where( IncidentRecord.incident_id == incident.incident_id ) result = await db.execute(stmt) existing = result.scalar_one_or_none() if existing: existing.status = incident.status.value existing.severity = incident.severity.value existing.signals = [ s.model_dump(mode="json") for s in incident.signals ] existing.affected_services = incident.affected_services existing.updated_at = incident.updated_at if incident.resolved_at: existing.resolved_at = incident.resolved_at if incident.closed_at: existing.closed_at = incident.closed_at if metadata.get("alertname") and not existing.alertname: existing.alertname = metadata["alertname"] if metadata.get("notification_type") and not existing.notification_type: existing.notification_type = metadata["notification_type"] if metadata.get("alert_category") and not existing.alert_category: existing.alert_category = metadata["alert_category"] else: record = IncidentRecord( incident_id=incident.incident_id, status=incident.status.value, severity=incident.severity.value, signals=[ s.model_dump(mode="json") for s in incident.signals ], affected_services=incident.affected_services, decision_chain=( incident.decision_chain.model_dump(mode="json") if hasattr(incident, 'decision_chain') and incident.decision_chain else None ), proposal_ids=[str(pid) for pid in incident.proposal_ids], outcome=( incident.outcome.model_dump(mode="json") if hasattr(incident, 'outcome') and incident.outcome else None ), created_at=incident.created_at, updated_at=incident.updated_at, resolved_at=incident.resolved_at, closed_at=incident.closed_at, ttl_days=getattr(incident, 'ttl_days', 30), vectorized=getattr(incident, 'vectorized', False), alertname=metadata.get("alertname"), notification_type=metadata.get("notification_type"), alert_category=metadata.get("alert_category"), ) db.add(record) created = True if created: await _add_signal_timeline_event(incident, metadata) logger.debug("db_adapter_save_success", incident_id=incident.incident_id) return True except Exception as e: logger.error("db_adapter_save_failed", incident_id=incident.incident_id, error=str(e)) return False def _record_to_incident(self, record: IncidentRecord) -> Any: """ 將 DB Record 轉換為 BrainIncident (lewooogo-brain 版本) 注意: 返回 BrainIncident 供 lewooogo-brain DualIncidentMemory 內部使用。 本地服務消費時透過 IncidentConverter.brain_to_local() 轉換。 (ADR-046 - 2026-04-01 ogt) """ from lewooogo_brain.interfaces.incident_processor import ( Incident as BrainIncident, ) from lewooogo_brain.interfaces.incident_processor import ( IncidentStatus as BrainIncidentStatus, ) from lewooogo_brain.interfaces.incident_processor import ( Severity as BrainSeverity, ) from lewooogo_brain.interfaces.incident_processor import ( Signal as BrainSignal, ) signals = [] for s in record.signals or []: signals.append(BrainSignal.model_validate(s)) return BrainIncident( incident_id=record.incident_id, status=BrainIncidentStatus(record.status), severity=BrainSeverity(record.severity), signals=signals, affected_services=record.affected_services or [], proposal_ids=record.proposal_ids or [], created_at=record.created_at, updated_at=record.updated_at, resolved_at=record.resolved_at, closed_at=record.closed_at, ) # ============================================================================= # Singleton (Phase R-R2: 僅保留 lewooogo-brain 版本) # ============================================================================= _new_engine_memory: Any | None = None _db_adapter: IncidentDbAdapter | None = None def get_incident_memory() -> Any: """ 取得 DualIncidentMemory 實例 (Singleton) Phase R-R2: 統一使用 lewooogo-brain 套件版本。 回滾方式: git revert Phase R-R2 commit + redeploy。 """ return _get_new_engine_memory() def _get_new_engine_memory() -> Any: """ 取得 lewooogo-brain 套件版本 注意事項: - 需要 lewooogo-brain 已安裝 (Dockerfile 已配置) - PostgreSQL 透過 IncidentDbAdapter 注入 (Phase 16 DI 模式) """ global _new_engine_memory, _db_adapter if _new_engine_memory is None: try: from lewooogo_brain.adapters.incident_memory import ( DualIncidentMemory as NewDualIncidentMemory, ) redis_client = get_redis() if _db_adapter is None: _db_adapter = IncidentDbAdapter() _new_engine_memory = NewDualIncidentMemory( redis_client=redis_client, db_adapter=_db_adapter, key_prefix="awoooi:incidents", ) logger.info( "incident_memory_initialized", engine="lewooogo_brain_package", db_adapter="IncidentDbAdapter", redis_connected=True, ) except ImportError as e: logger.error( "lewooogo_brain_not_available", error=str(e), ) raise except Exception as e: logger.error( "new_engine_init_failed", error=str(e), ) raise return _new_engine_memory