fix(api): tolerate legacy incident decision chains
All checks were successful
Code Review / ai-code-review (push) Successful in 10s
CD Pipeline / tests (push) Successful in 1m23s
CD Pipeline / build-and-deploy (push) Successful in 3m37s
CD Pipeline / post-deploy-checks (push) Successful in 1m34s

This commit is contained in:
Your Name
2026-05-19 11:55:42 +08:00
parent ab2862a214
commit 6da0c3969b
2 changed files with 70 additions and 6 deletions

View File

@@ -502,6 +502,32 @@ def normalize_severity(value: str | Severity) -> str:
return legacy_map.get(normalized, raw)
def parse_decision_chain(value: Any, incident_id: str | None = None):
"""Best-effort restore of legacy decision_chain payloads from PostgreSQL."""
if not value:
return None
from src.models.incident import AIDecisionChain
if not isinstance(value, dict):
logger.warning(
"legacy_decision_chain_skipped",
incident_id=incident_id,
value_type=type(value).__name__,
)
return None
try:
return AIDecisionChain(**value)
except Exception as exc:
logger.warning(
"decision_chain_parse_failed",
incident_id=incident_id,
error=str(exc),
)
return None
# =============================================================================
# Constants
# =============================================================================
@@ -790,7 +816,7 @@ class IncidentService:
方案 C: 解析時正規化舊格式 Enum 值
"""
from src.models.incident import AIDecisionChain, IncidentOutcome
from src.models.incident import IncidentOutcome
# 方案 C: 正規化 signals 內的舊格式 severity
signals = []
@@ -800,10 +826,9 @@ class IncidentService:
signal_data["severity"] = normalize_severity(signal_data["severity"])
signals.append(Signal(**signal_data))
decision_chain = (
AIDecisionChain(**record.decision_chain)
if record.decision_chain
else None
decision_chain = parse_decision_chain(
record.decision_chain,
incident_id=record.incident_id,
)
outcome = (
IncidentOutcome(**record.outcome)

View File

@@ -12,13 +12,18 @@ test_incident_service_resolve_idempotency
重新放大「resolve_incident 重複觸發 postmortem 洗版」的舊風險。
"""
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from src.models.incident import IncidentStatus
from src.services.incident_service import IncidentService, normalize_status
from src.services.incident_service import (
IncidentService,
normalize_status,
parse_decision_chain,
)
@pytest.mark.asyncio
@@ -95,3 +100,37 @@ def test_normalize_status_accepts_db_enum_name() -> None:
"""PostgreSQL SQLEnum 會存 Enum name讀回時必須正規化成 Pydantic value。"""
assert normalize_status("INVESTIGATING") == "investigating"
assert normalize_status(IncidentStatus.CLOSED) == "closed"
def test_parse_decision_chain_skips_legacy_list_payload() -> None:
"""舊資料 decision_chain 可能是 list不應阻斷 incident hydrate / resolve。"""
assert parse_decision_chain([{"stage": "router"}], "INC-LEGACY-CHAIN") is None
def test_record_to_incident_tolerates_legacy_decision_chain_list() -> None:
"""DB fallback 必須能讀回舊 incident即使 decision_chain 不是新 schema。"""
now = datetime.now(UTC)
record = SimpleNamespace(
incident_id="INC-LEGACY-CHAIN",
status="INVESTIGATING",
severity="P2",
signals=[],
affected_services=[],
decision_chain=[{"stage": "router"}],
proposal_ids=[],
outcome=None,
created_at=now,
updated_at=now,
resolved_at=None,
closed_at=None,
ttl_days=7,
vectorized=False,
notification_type="TYPE-3",
alert_category="host_resource",
)
incident = IncidentService()._record_to_incident(record)
assert incident.incident_id == "INC-LEGACY-CHAIN"
assert incident.status == IncidentStatus.INVESTIGATING
assert incident.decision_chain is None