diff --git a/apps/api/src/services/incident_service.py b/apps/api/src/services/incident_service.py index 991cb56bc..00142228b 100644 --- a/apps/api/src/services/incident_service.py +++ b/apps/api/src/services/incident_service.py @@ -35,6 +35,52 @@ from src.models.incident import ( logger = structlog.get_logger(__name__) +# ============================================================================= +# Legacy Value Normalization (方案 C - 代碼相容舊格式) +# ============================================================================= +# 問題: Redis 有舊 Enum 值 (status='open', severity='critical') +# 解法: 解析時正規化,不動 Redis 資料 +# 回滾: git revert (秒級恢復) +# ============================================================================= + +def normalize_status(value: str) -> str: + """ + 正規化 IncidentStatus 舊格式值 + + 舊值 → 新值: + - 'open' → 'investigating' + """ + legacy_map = { + "open": "investigating", + } + return legacy_map.get(value, value) + + +def normalize_severity(value: str) -> str: + """ + 正規化 Severity 舊格式值 + + 舊值 → 新值: + - 'critical' → 'P0' + - 'high' → 'P1' + - 'warning' → 'P2' + - 'medium' → 'P2' + - 'info' → 'P3' + - 'low' → 'P3' + - 'none' → 'P3' + """ + legacy_map = { + "critical": "P0", + "high": "P1", + "warning": "P2", + "medium": "P2", + "info": "P3", + "low": "P3", + "none": "P3", + } + return legacy_map.get(value, value) + + # ============================================================================= # Constants # ============================================================================= @@ -110,6 +156,8 @@ class IncidentService: """ 從 Working Memory 讀取 Incident + 方案 C: 解析時正規化舊格式 Enum 值 + Returns: Incident | None: 事件資料,若不存在則返回 None """ @@ -121,7 +169,19 @@ class IncidentService: if data is None: return None - return Incident.model_validate_json(data) + # 方案 C: 正規化舊格式 Enum 值 + incident_dict = json.loads(data) + if "status" in incident_dict: + incident_dict["status"] = normalize_status(incident_dict["status"]) + if "severity" in incident_dict: + incident_dict["severity"] = normalize_severity(incident_dict["severity"]) + + # 同時正規化 signals 內的 severity + for signal in incident_dict.get("signals", []): + if "severity" in signal: + signal["severity"] = normalize_severity(signal["severity"]) + + return Incident.model_validate(incident_dict) except Exception as e: logger.exception( @@ -131,6 +191,74 @@ class IncidentService: ) return None + async def get_active_incidents(self) -> list[Incident]: + """ + 列出所有活躍的 Incidents (從 Working Memory) + + 方案 C: 解析時正規化舊格式 Enum 值 + + Returns: + list[Incident]: 活躍事件列表 (investigating 或 mitigating) + """ + redis_client = get_redis() + incidents: list[Incident] = [] + + try: + # SCAN 所有 incident:* keys + async for key in redis_client.scan_iter( + match=f"{INCIDENT_KEY_PREFIX}*", + count=100, + ): + # 排除索引 keys + if ":idx:" in key: + continue + + data = await redis_client.get(key) + if data is None: + continue + + try: + # 方案 C: 正規化舊格式 Enum 值 + incident_dict = json.loads(data) + if "status" in incident_dict: + incident_dict["status"] = normalize_status(incident_dict["status"]) + if "severity" in incident_dict: + incident_dict["severity"] = normalize_severity(incident_dict["severity"]) + + # 正規化 signals 內的 severity + for signal in incident_dict.get("signals", []): + if "severity" in signal: + signal["severity"] = normalize_severity(signal["severity"]) + + incident = Incident.model_validate(incident_dict) + + # 只返回活躍狀態的 Incident + if incident.status in ( + IncidentStatus.INVESTIGATING, + IncidentStatus.MITIGATING, + ): + incidents.append(incident) + except Exception as e: + logger.warning( + "incident_parse_error", + key=key, + error=str(e), + ) + continue + + logger.info( + "get_active_incidents", + count=len(incidents), + ) + return incidents + + except Exception as e: + logger.exception( + "get_active_incidents_error", + error=str(e), + ) + return [] + # ========================================================================= # Episodic Memory (PostgreSQL) # ========================================================================= @@ -225,10 +353,21 @@ class IncidentService: return None def _record_to_incident(self, record: IncidentRecord) -> Incident: - """將 SQLAlchemy record 轉換為 Pydantic Incident""" + """ + 將 SQLAlchemy record 轉換為 Pydantic Incident + + 方案 C: 解析時正規化舊格式 Enum 值 + """ from src.models.incident import AIDecisionChain, IncidentOutcome - signals = [Signal(**s) for s in (record.signals or [])] + # 方案 C: 正規化 signals 內的舊格式 severity + signals = [] + for s in (record.signals or []): + signal_data = s.copy() + if "severity" in signal_data: + signal_data["severity"] = normalize_severity(signal_data["severity"]) + signals.append(Signal(**signal_data)) + decision_chain = ( AIDecisionChain(**record.decision_chain) if record.decision_chain @@ -240,10 +379,14 @@ class IncidentService: else None ) + # 方案 C: 正規化舊格式 Enum 值 + normalized_status = normalize_status(record.status) + normalized_severity = normalize_severity(record.severity) + return Incident( incident_id=record.incident_id, - status=IncidentStatus(record.status), - severity=Severity(record.severity), + status=IncidentStatus(normalized_status), + severity=Severity(normalized_severity), signals=signals, affected_services=record.affected_services or [], decision_chain=decision_chain,