feat(types): ADR-046 IncidentConverter + IncidentEngineAdapter

實作 ADR-046 Option B: IncidentConverter 轉換層,解決
BrainIncident (lewooogo-brain) 與 LocalIncident (apps/api) 型別邊界問題。

變更:
- 新增 src/utils/incident_converter.py
  - brain_to_local(): BrainIncident → LocalIncident
  - local_to_brain(): LocalIncident → BrainIncident
  - ESCALATED → MITIGATING 映射 (brain 無 ESCALATED)
- incident_engine.py: 新增 IncidentEngineAdapter 包裝層
  - process_signal() / get_incident() 輸出轉換為 LocalIncident
  - get_incident_engine() 返回 IncidentEngineAdapter
- incident_memory.py: 加入 brain_to_local import,更新 _record_to_incident 說明
- ADR-046: 標記三個轉換點全部完成

解鎖: #123 proposal_service.py 清理 (下一步)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-31 22:47:54 +08:00
parent 95de7e0e15
commit a94bb57d8b
4 changed files with 281 additions and 21 deletions

View File

@@ -0,0 +1,205 @@
"""
Incident Type Converter - 跨套件 Incident 型別轉換
====================================================
ADR-046: BrainIncident (lewooogo-brain) ↔ LocalIncident (apps/api) 轉換層
設計原則:
- BrainIncident: lewooogo-brain 套件的精簡型別 (無 AWOOOI 擴展欄位)
- LocalIncident: apps/api 的完整型別 (含 decision_chain, outcome 等稽核欄位)
- 兩者保持套件邊界,互不依賴,透過 Converter 顯式轉換
轉換方向:
brain→local: 引擎輸出 → 本地服務消費 (IncidentMemoryAdapter, signal_worker)
local→brain: 本地資料 → 引擎輸入 (儲存到 DualIncidentMemory)
版本: v1.0
建立: 2026-04-01 (台北時區)
建立者: Claude Code (ADR-046 Phase R-R3 Sprint)
"""
from typing import Any
from uuid import uuid4
import structlog
from src.models.incident import (
Incident,
IncidentStatus,
Severity,
Signal,
)
logger = structlog.get_logger(__name__)
def brain_to_local(brain_incident: Any) -> Incident:
"""
BrainIncident → LocalIncident 轉換
ADR-046: 跨套件邊界時呼叫,將 lewooogo-brain 引擎輸出轉為本地型別。
欄位對應:
- incident_id, status, severity, signals, affected_services,
proposal_ids, created_at, updated_at, resolved_at, closed_at → 直接對應
- decision_chain, outcome, frequency_stats → None (BrainIncident 無此欄位)
- ttl_days, persisted_to_pg, vectorized → 使用預設值
BrainIncidentStatus.ESCALATED 不存在 → 本地 ESCALATED 需 local_to_brain 時處理
"""
try:
from lewooogo_brain.interfaces.incident_processor import (
IncidentStatus as BrainIncidentStatus,
Severity as BrainSeverity,
)
# --- 狀態轉換 ---
try:
local_status = IncidentStatus(brain_incident.status.value)
except ValueError:
# BrainIncident 無 ESCALATED理論上不應發生記錄並降級
logger.warning(
"brain_to_local_unknown_status",
status=brain_incident.status,
incident_id=brain_incident.incident_id,
)
local_status = IncidentStatus.INVESTIGATING
# --- 嚴重度轉換 ---
try:
local_severity = Severity(brain_incident.severity.value)
except ValueError:
logger.warning(
"brain_to_local_unknown_severity",
severity=brain_incident.severity,
incident_id=brain_incident.incident_id,
)
local_severity = Severity.P2
# --- Signal 轉換 ---
local_signals = []
for brain_signal in brain_incident.signals:
try:
sig_severity = Severity(brain_signal.severity.value)
except ValueError:
sig_severity = Severity.P2
# BrainSignal.source 是 strLocalSignal.source 是 Literal
# 使用 getattr 確保不因 BrainSignal 欄位增減而崩潰
source = getattr(brain_signal, "source", "alertmanager")
valid_sources = {"prometheus", "signoz", "alertmanager", "manual", "telegram"}
if source not in valid_sources:
source = "alertmanager"
local_signals.append(
Signal(
signal_id=str(uuid4())[:8], # BrainSignal 無 signal_id自動生成
alert_name=brain_signal.alert_name,
severity=sig_severity,
source=source,
fired_at=brain_signal.fired_at,
resolved_at=getattr(brain_signal, "resolved_at", None),
labels=getattr(brain_signal, "labels", {}),
annotations=getattr(brain_signal, "annotations", {}),
fingerprint=getattr(brain_signal, "fingerprint", None),
)
)
return Incident(
incident_id=brain_incident.incident_id,
status=local_status,
severity=local_severity,
signals=local_signals,
affected_services=brain_incident.affected_services or [],
decision_chain=None, # BrainIncident 無此欄位
proposal_ids=list(brain_incident.proposal_ids or []),
outcome=None, # BrainIncident 無此欄位
frequency_stats=None, # BrainIncident 無此欄位
created_at=brain_incident.created_at,
updated_at=brain_incident.updated_at,
resolved_at=brain_incident.resolved_at,
closed_at=brain_incident.closed_at,
ttl_days=7,
persisted_to_pg=False,
vectorized=False,
)
except Exception as e:
logger.error(
"brain_to_local_conversion_failed",
incident_id=getattr(brain_incident, "incident_id", "unknown"),
error=str(e),
)
raise
def local_to_brain(local_incident: Incident) -> Any:
"""
LocalIncident → BrainIncident 轉換
ADR-046: 將本地完整 Incident 轉換為 lewooogo-brain 精簡格式,
供 DualIncidentMemory 儲存或 IncidentEngine 消費。
LocalIncidentStatus.ESCALATED 無對應 BrainStatus → 映射為 MITIGATING
(升級中的事件在 brain 視角仍屬「處置中」)
local-only 欄位 (decision_chain, outcome, frequency_stats 等) 在轉換後遺失,
請勿用 local_to_brain → brain_to_local 做 round-trip。
"""
try:
from lewooogo_brain.interfaces.incident_processor import (
Incident as BrainIncident,
IncidentStatus as BrainIncidentStatus,
Severity as BrainSeverity,
Signal as BrainSignal,
)
# --- 狀態轉換 ---
_status_map = {
"investigating": "investigating",
"mitigating": "mitigating",
"resolved": "resolved",
"closed": "closed",
"escalated": "mitigating", # ADR-046: ESCALATED → MITIGATING (brain 無 ESCALATED)
}
brain_status_value = _status_map.get(local_incident.status.value, "investigating")
brain_status = BrainIncidentStatus(brain_status_value)
# --- 嚴重度轉換 (1:1 對應) ---
brain_severity = BrainSeverity(local_incident.severity.value)
# --- Signal 轉換 ---
brain_signals = []
for local_signal in local_incident.signals:
brain_signals.append(
BrainSignal(
alert_name=local_signal.alert_name,
severity=BrainSeverity(local_signal.severity.value),
source=local_signal.source, # str 相容
fired_at=local_signal.fired_at,
labels=local_signal.labels,
annotations=local_signal.annotations,
fingerprint=local_signal.fingerprint,
# signal_id 和 resolved_at 是 LocalSignal 擴展欄位BrainSignal 不存在
)
)
return BrainIncident(
incident_id=local_incident.incident_id,
status=brain_status,
severity=brain_severity,
signals=brain_signals,
affected_services=local_incident.affected_services,
proposal_ids=local_incident.proposal_ids,
created_at=local_incident.created_at,
updated_at=local_incident.updated_at,
resolved_at=local_incident.resolved_at,
closed_at=local_incident.closed_at,
)
except Exception as e:
logger.error(
"local_to_brain_conversion_failed",
incident_id=local_incident.incident_id,
error=str(e),
)
raise