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:
@@ -28,6 +28,7 @@ from src.core.redis_client import get_redis
|
||||
from src.models.incident import Incident
|
||||
from src.services.graph_rag import BlastRadiusResult, topology_graph
|
||||
from src.services.incident_memory import get_incident_memory
|
||||
from src.utils.incident_converter import brain_to_local
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -69,11 +70,11 @@ class IIncidentEngine(Protocol):
|
||||
self,
|
||||
signal_data: dict[str, Any],
|
||||
) -> Incident | None:
|
||||
"""處理 Signal: 原子建立或聚合 Incident"""
|
||||
"""處理 Signal: 原子建立或聚合 Incident (返回 LocalIncident,ADR-046)"""
|
||||
...
|
||||
|
||||
async def get_incident(self, incident_id: str) -> Incident | None:
|
||||
"""取得指定 Incident"""
|
||||
"""取得指定 Incident (返回 LocalIncident,ADR-046)"""
|
||||
...
|
||||
|
||||
async def update_status(
|
||||
@@ -106,7 +107,13 @@ class IncidentMemoryAdapter:
|
||||
self._memory = memory
|
||||
|
||||
async def load_incident(self, incident_id: str) -> Any:
|
||||
"""從 Working Memory 載入 Incident"""
|
||||
"""
|
||||
從 Working Memory 載入 Incident (返回 BrainIncident,供 brain engine 內部使用)
|
||||
|
||||
注意: 此 adapter 注入 brain engine,brain 內部呼叫時期望 BrainIncident。
|
||||
本地服務透過 IncidentEngineAdapter.get_incident() 取得 LocalIncident。
|
||||
(ADR-046 - 2026-04-01 ogt)
|
||||
"""
|
||||
return await self._memory.load_incident(incident_id)
|
||||
|
||||
async def save_incident(self, incident: Any, ttl_seconds: int = 604800) -> bool:
|
||||
@@ -188,18 +195,56 @@ class BlastRadiusAdapter:
|
||||
return [target] if target != "unknown" else []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# IncidentEngineAdapter (ADR-046: brain 輸出轉換為 LocalIncident)
|
||||
# =============================================================================
|
||||
|
||||
class IncidentEngineAdapter:
|
||||
"""
|
||||
Incident Engine Adapter - 包裝 lewooogo-brain IncidentEngine
|
||||
|
||||
ADR-046 (2026-04-01 ogt): brain 引擎輸出 BrainIncident,
|
||||
透過 brain_to_local() 轉換為 LocalIncident,供本地服務消費。
|
||||
|
||||
這是本地服務與 brain 引擎之間的邊界層。
|
||||
讓 IIncidentEngine Protocol 的返回型別宣告 Incident 得以成立。
|
||||
"""
|
||||
|
||||
def __init__(self, brain_engine: Any) -> None:
|
||||
self._engine = brain_engine
|
||||
|
||||
async def process_signal(self, signal_data: dict[str, Any]) -> Incident | None:
|
||||
"""處理 Signal,返回 LocalIncident (brain 輸出轉換)"""
|
||||
brain_incident = await self._engine.process_signal(signal_data)
|
||||
if brain_incident is None:
|
||||
return None
|
||||
return brain_to_local(brain_incident)
|
||||
|
||||
async def get_incident(self, incident_id: str) -> Incident | None:
|
||||
"""取得 Incident,返回 LocalIncident (brain 輸出轉換)"""
|
||||
brain_incident = await self._engine.get_incident(incident_id)
|
||||
if brain_incident is None:
|
||||
return None
|
||||
return brain_to_local(brain_incident)
|
||||
|
||||
async def update_status(self, incident_id: str, status: Any) -> bool:
|
||||
"""更新狀態 (直接委派,brain status 與 local status 值相容)"""
|
||||
return await self._engine.update_status(incident_id, status)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Singleton (Phase R-R2: 僅保留 lewooogo-brain 版本)
|
||||
# =============================================================================
|
||||
|
||||
_new_incident_engine: Any | None = None
|
||||
_new_incident_engine: IncidentEngineAdapter | None = None
|
||||
|
||||
|
||||
def get_incident_engine() -> Any:
|
||||
def get_incident_engine() -> IncidentEngineAdapter:
|
||||
"""
|
||||
取得 Incident Engine 實例 (Singleton)
|
||||
|
||||
Phase R-R2: 統一使用 lewooogo-brain IncidentEngine。
|
||||
ADR-046: 返回 IncidentEngineAdapter,輸出已轉換為 LocalIncident。
|
||||
回滾方式: git revert Phase R-R2 commit + redeploy。
|
||||
|
||||
Raises:
|
||||
@@ -214,12 +259,13 @@ def get_incident_engine() -> Any:
|
||||
memory_adapter = IncidentMemoryAdapter(get_incident_memory())
|
||||
blast_adapter = BlastRadiusAdapter()
|
||||
|
||||
_new_incident_engine = NewIncidentEngine(
|
||||
brain_engine = NewIncidentEngine(
|
||||
memory=memory_adapter,
|
||||
blast_analyzer=blast_adapter,
|
||||
logger=logger,
|
||||
)
|
||||
logger.info("incident_engine_initialized", version="lewooogo-brain")
|
||||
_new_incident_engine = IncidentEngineAdapter(brain_engine)
|
||||
logger.info("incident_engine_initialized", version="lewooogo-brain", adapter="IncidentEngineAdapter")
|
||||
|
||||
except ImportError as e:
|
||||
logger.error("lewooogo_brain_engines_not_available", error=str(e))
|
||||
|
||||
@@ -25,6 +25,7 @@ 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
|
||||
from src.utils.incident_converter import brain_to_local
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -121,9 +122,13 @@ class IncidentDbAdapter:
|
||||
return False
|
||||
|
||||
def _record_to_incident(self, record: IncidentRecord) -> Any:
|
||||
# 2026-04-01 ogt: 返回 BrainIncident (lewooogo-brain),非本地 Incident
|
||||
# 型別為 Any 避免靜態分析誤報,實際為 lewooogo_brain.interfaces.incident_processor.Incident
|
||||
"""將 DB Record 轉換為 Incident (lewooogo-brain 版本)"""
|
||||
"""
|
||||
將 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,
|
||||
)
|
||||
|
||||
205
apps/api/src/utils/incident_converter.py
Normal file
205
apps/api/src/utils/incident_converter.py
Normal 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 是 str,LocalSignal.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
|
||||
Reference in New Issue
Block a user