fix(api): 方案 C - Incident 解析相容舊格式 Enum

問題: Redis 存有舊 Enum 值 (status='open', severity='critical')
      導致 Pydantic 驗證失敗

解法:
- normalize_status(): 'open' → 'investigating'
- normalize_severity(): 'critical' → 'P0' 等
- 應用於 get_from_working_memory, get_active_incidents, _record_to_incident

優點:
- 零資料風險 (不動 Redis)
- 回滾 = git revert (秒級)
- 新舊格式都能讀

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-25 14:14:58 +08:00
parent 805d353892
commit c0ad8f8686

View File

@@ -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,