Some checks failed
CD Pipeline / build-and-deploy (push) Failing after 1m44s
三個問題同時修復: 1. main.py: 補上 init_mcp_tool_registry() 呼叫 - ADR-081 Phase 1 建立了 MCPToolRegistry 但從未在 lifespan startup 被呼叫 - 導致 PreDecisionInvestigator sensors=0/0,evidence_summary 永遠空白 - 空白 evidence → Diagnostician 永遠 ABSTAIN 2. signal_producer.py: str(dict) → json.dumps() - labels/annotations 用 Python str() 序列化,寫入 Redis 後無法反序列化 3. brain/incident_engine.py: 新增 _parse_dict_field() helper - 從 Redis 讀回的 labels/annotations 可能是 JSON 字串 - isinstance(..., dict) 防禦不足,需先 json.loads() 2026-04-16 ogt + Claude Sonnet 4.6(亞太): 飛輪感官修復 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
122 lines
3.3 KiB
Python
122 lines
3.3 KiB
Python
"""
|
|
Signal Producer Service - Phase 17 P0 Router 層違規修復
|
|
======================================================
|
|
|
|
封裝 Redis Stream 的 Signal 生產邏輯,消除 Router 層直接存取 Redis。
|
|
|
|
功能:
|
|
- XADD 寫入 Redis Stream
|
|
- Trace Context 傳遞
|
|
|
|
符合 leWOOOgo 積木化規範:
|
|
- Router -> Service -> Redis
|
|
"""
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
import structlog
|
|
|
|
from src.core.redis_client import get_redis
|
|
from src.core.telemetry import get_trace_context
|
|
from src.utils.timezone import now_taipei
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
# Redis Stream 配置
|
|
SIGNAL_STREAM_KEY = "awoooi:signals"
|
|
SIGNAL_STREAM_MAXLEN = 10000
|
|
|
|
|
|
@dataclass
|
|
class SignalData:
|
|
"""Signal 資料結構"""
|
|
source: str
|
|
alert_name: str
|
|
severity: str
|
|
namespace: str
|
|
target: str
|
|
message: str
|
|
labels: dict[str, Any] | None = None
|
|
annotations: dict[str, Any] | None = None
|
|
|
|
|
|
class SignalProducerService:
|
|
"""
|
|
Signal 生產者服務
|
|
|
|
封裝 Redis Stream 的 XADD 操作
|
|
"""
|
|
|
|
async def produce(self, signal: SignalData) -> str:
|
|
"""
|
|
將 Signal 寫入 Redis Stream
|
|
|
|
Phase 17: 從 Router 層遷移至 Service 層
|
|
|
|
使用 Redis Streams (XADD) 實現非同步事件總線:
|
|
- MAXLEN ~10000: 限制 Stream 長度,自動裁剪舊訊息
|
|
- *: 自動生成 Message ID
|
|
|
|
Args:
|
|
signal: Signal 資料
|
|
|
|
Returns:
|
|
str: Redis Stream Message ID
|
|
"""
|
|
redis_client = get_redis()
|
|
|
|
# 組裝 Signal 字典 (所有值必須是字串)
|
|
signal_dict = {
|
|
"source": signal.source,
|
|
"alert_name": signal.alert_name,
|
|
"severity": signal.severity,
|
|
"namespace": signal.namespace,
|
|
"target": signal.target,
|
|
"message": signal.message,
|
|
"labels": json.dumps(signal.labels or {}),
|
|
"annotations": json.dumps(signal.annotations or {}),
|
|
"received_at": now_taipei().isoformat(),
|
|
}
|
|
|
|
# Phase 15.2: 注入 Trace Context
|
|
trace_ctx = get_trace_context()
|
|
if trace_ctx:
|
|
signal_dict["_trace_id"] = trace_ctx.get("trace_id", "")
|
|
signal_dict["_span_id"] = trace_ctx.get("span_id", "")
|
|
|
|
# XADD 寫入 Stream
|
|
message_id = await redis_client.xadd(
|
|
SIGNAL_STREAM_KEY,
|
|
signal_dict,
|
|
maxlen=SIGNAL_STREAM_MAXLEN,
|
|
approximate=True, # ~MAXLEN 近似裁剪,效能更好
|
|
)
|
|
|
|
logger.info(
|
|
"signal_produced",
|
|
message_id=message_id,
|
|
source=signal.source,
|
|
alert_name=signal.alert_name,
|
|
severity=signal.severity,
|
|
trace_id=trace_ctx.get("trace_id") if trace_ctx else None,
|
|
)
|
|
|
|
return message_id
|
|
|
|
|
|
# =============================================================================
|
|
# Singleton
|
|
# =============================================================================
|
|
|
|
_signal_producer: SignalProducerService | None = None
|
|
|
|
|
|
def get_signal_producer() -> SignalProducerService:
|
|
"""取得 SignalProducerService 實例 (Singleton)"""
|
|
global _signal_producer
|
|
if _signal_producer is None:
|
|
_signal_producer = SignalProducerService()
|
|
return _signal_producer
|