實作 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>
230 lines
8.3 KiB
Python
230 lines
8.3 KiB
Python
"""
|
||
Incident Memory Provider - 事件記憶體提供者
|
||
============================================
|
||
Phase 6.4e: DualIncidentMemory 整合
|
||
Phase 16 R1.2: 絞殺者模式 (Strangler Fig Pattern) 2026-03-26
|
||
Phase R-R2 (2026-04-01 ogt): 移除內嵌 DualIncidentMemory 重複邏輯,
|
||
全面切換至 lewooogo-brain。回滾方式: git revert + redeploy。
|
||
|
||
設計:
|
||
- IncidentDbAdapter: SQLAlchemy Bridge,注入 lewooogo-brain DualIncidentMemory
|
||
- 雙層記憶體: Working (Redis) + Episodic (PostgreSQL)
|
||
- 反向索引: namespace:target -> incident_id
|
||
|
||
統帥鐵律:
|
||
- Working Memory (Redis): 7 天 TTL
|
||
- Episodic Memory (PostgreSQL): 永久
|
||
- 反向索引: 30 分鐘 TTL (聚合窗口)
|
||
"""
|
||
|
||
from typing import Any
|
||
|
||
import structlog
|
||
|
||
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__)
|
||
|
||
|
||
# =============================================================================
|
||
# Phase 16: IncidentDbAdapter (DI 注入實現)
|
||
# =============================================================================
|
||
|
||
class IncidentDbAdapter:
|
||
"""
|
||
Incident DB Adapter - 實現 lewooogo-brain 的 IIncidentDbAdapter
|
||
|
||
Phase 16: 將 apps/api 的 SQLAlchemy Model 操作封裝為 adapter
|
||
注入到 lewooogo-brain 的 DualIncidentMemory
|
||
"""
|
||
|
||
async def load(self, incident_id: str) -> Incident | None:
|
||
"""從 PostgreSQL 載入 Incident"""
|
||
try:
|
||
async with get_db_context() as db:
|
||
from sqlalchemy import select
|
||
stmt = select(IncidentRecord).where(
|
||
IncidentRecord.incident_id == incident_id
|
||
)
|
||
result = await db.execute(stmt)
|
||
record = result.scalar_one_or_none()
|
||
|
||
if record:
|
||
return self._record_to_incident(record)
|
||
return None
|
||
|
||
except Exception as e:
|
||
logger.error("db_adapter_load_failed", incident_id=incident_id, error=str(e))
|
||
return None
|
||
|
||
async def save(self, incident: Incident) -> bool:
|
||
"""儲存 Incident 到 PostgreSQL (upsert)"""
|
||
try:
|
||
async with get_db_context() as db:
|
||
from sqlalchemy import select
|
||
|
||
stmt = select(IncidentRecord).where(
|
||
IncidentRecord.incident_id == incident.incident_id
|
||
)
|
||
result = await db.execute(stmt)
|
||
existing = result.scalar_one_or_none()
|
||
|
||
if existing:
|
||
existing.status = incident.status.value
|
||
existing.severity = incident.severity.value
|
||
existing.signals = [
|
||
s.model_dump(mode="json") for s in incident.signals
|
||
]
|
||
existing.affected_services = incident.affected_services
|
||
existing.updated_at = incident.updated_at
|
||
if incident.resolved_at:
|
||
existing.resolved_at = incident.resolved_at
|
||
if incident.closed_at:
|
||
existing.closed_at = incident.closed_at
|
||
else:
|
||
record = IncidentRecord(
|
||
incident_id=incident.incident_id,
|
||
status=incident.status.value,
|
||
severity=incident.severity.value,
|
||
signals=[
|
||
s.model_dump(mode="json") for s in incident.signals
|
||
],
|
||
affected_services=incident.affected_services,
|
||
decision_chain=(
|
||
incident.decision_chain.model_dump(mode="json")
|
||
if hasattr(incident, 'decision_chain') and incident.decision_chain
|
||
else None
|
||
),
|
||
proposal_ids=[str(pid) for pid in incident.proposal_ids],
|
||
outcome=(
|
||
incident.outcome.model_dump(mode="json")
|
||
if hasattr(incident, 'outcome') and incident.outcome
|
||
else None
|
||
),
|
||
created_at=incident.created_at,
|
||
updated_at=incident.updated_at,
|
||
resolved_at=incident.resolved_at,
|
||
closed_at=incident.closed_at,
|
||
ttl_days=getattr(incident, 'ttl_days', 30),
|
||
vectorized=getattr(incident, 'vectorized', False),
|
||
)
|
||
db.add(record)
|
||
|
||
logger.debug("db_adapter_save_success", incident_id=incident.incident_id)
|
||
return True
|
||
|
||
except Exception as e:
|
||
logger.error("db_adapter_save_failed", incident_id=incident.incident_id, error=str(e))
|
||
return False
|
||
|
||
def _record_to_incident(self, record: IncidentRecord) -> Any:
|
||
"""
|
||
將 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,
|
||
)
|
||
from lewooogo_brain.interfaces.incident_processor import (
|
||
IncidentStatus as BrainIncidentStatus,
|
||
)
|
||
from lewooogo_brain.interfaces.incident_processor import (
|
||
Severity as BrainSeverity,
|
||
)
|
||
from lewooogo_brain.interfaces.incident_processor import (
|
||
Signal as BrainSignal,
|
||
)
|
||
|
||
signals = []
|
||
for s in record.signals or []:
|
||
signals.append(BrainSignal.model_validate(s))
|
||
|
||
return BrainIncident(
|
||
incident_id=record.incident_id,
|
||
status=BrainIncidentStatus(record.status),
|
||
severity=BrainSeverity(record.severity),
|
||
signals=signals,
|
||
affected_services=record.affected_services or [],
|
||
proposal_ids=record.proposal_ids or [],
|
||
created_at=record.created_at,
|
||
updated_at=record.updated_at,
|
||
resolved_at=record.resolved_at,
|
||
closed_at=record.closed_at,
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# Singleton (Phase R-R2: 僅保留 lewooogo-brain 版本)
|
||
# =============================================================================
|
||
|
||
_new_engine_memory: Any | None = None
|
||
_db_adapter: IncidentDbAdapter | None = None
|
||
|
||
|
||
def get_incident_memory() -> Any:
|
||
"""
|
||
取得 DualIncidentMemory 實例 (Singleton)
|
||
|
||
Phase R-R2: 統一使用 lewooogo-brain 套件版本。
|
||
回滾方式: git revert Phase R-R2 commit + redeploy。
|
||
"""
|
||
return _get_new_engine_memory()
|
||
|
||
|
||
def _get_new_engine_memory() -> Any:
|
||
"""
|
||
取得 lewooogo-brain 套件版本
|
||
|
||
注意事項:
|
||
- 需要 lewooogo-brain 已安裝 (Dockerfile 已配置)
|
||
- PostgreSQL 透過 IncidentDbAdapter 注入 (Phase 16 DI 模式)
|
||
"""
|
||
global _new_engine_memory, _db_adapter
|
||
|
||
if _new_engine_memory is None:
|
||
try:
|
||
from lewooogo_brain.adapters.incident_memory import (
|
||
DualIncidentMemory as NewDualIncidentMemory,
|
||
)
|
||
|
||
redis_client = get_redis()
|
||
|
||
if _db_adapter is None:
|
||
_db_adapter = IncidentDbAdapter()
|
||
|
||
_new_engine_memory = NewDualIncidentMemory(
|
||
redis_client=redis_client,
|
||
db_adapter=_db_adapter,
|
||
key_prefix="awoooi:incidents",
|
||
)
|
||
|
||
logger.info(
|
||
"incident_memory_initialized",
|
||
engine="lewooogo_brain_package",
|
||
db_adapter="IncidentDbAdapter",
|
||
redis_connected=True,
|
||
)
|
||
|
||
except ImportError as e:
|
||
logger.error(
|
||
"lewooogo_brain_not_available",
|
||
error=str(e),
|
||
)
|
||
raise
|
||
|
||
except Exception as e:
|
||
logger.error(
|
||
"new_engine_init_failed",
|
||
error=str(e),
|
||
)
|
||
raise
|
||
|
||
return _new_engine_memory
|