# aider_event_service | 2026-04-20 @ Asia/Taipei """aider event 分類 + 轉成 awoooi signal_data 給 IncidentService。 設計原則: - 不重做 dedup — 既有 IncidentService.create_incident_from_signal 已有 3min fingerprint debounce - 不做 pattern extract — Task A8 ai_router 會直接從 aider_event_repository 聚合 - 純函式為主,副作用(建 incident)由 caller(A7 processor job)管理 - Redis stream 推送 (Task A6):轉接 Router 層 """ from __future__ import annotations import os from typing import Any from src.models.aider import AiderEventIn, AiderBatchIn from src.utils.secret_redactor import redact # ---- 分類 ---- def classify_severity(ev: AiderEventIn) -> str | None: """回傳對應 awoooi Signal 的 severity 字串(_parse_severity 會轉 P0-P3), None = 不該建 incident。 映射: error → warning (P2) silent_timeout → info (P3) session_end + nonzero exit + error_count>=3 → high (P1) session_end + nonzero exit → warning (P2) 其他 → None """ t = ev.type if t == "error": return "warning" if t == "silent_timeout": return "info" if t == "session_end": exit_code = ev.payload.get("exit_code", 0) err_count = ev.payload.get("error_count", 0) if exit_code != 0 and err_count >= 3: return "high" if exit_code != 0: return "warning" return None def should_create_incident(ev: AiderEventIn) -> bool: return classify_severity(ev) is not None # ---- signal_data 構造 ---- def build_signal_data(ev: AiderEventIn) -> dict[str, Any] | None: """把 AiderEventIn 轉成 IncidentService.create_incident_from_signal 所需 dict。 不該建 incident 的 event(session_start 等)回傳 None。 Fingerprint 設計:'aider:{session_id}:{type}' — 讓既有 3min debounce 自然發揮 (同 session 60s 內連續 5 個 error 會只建 1 個 incident)。 """ sev = classify_severity(ev) if sev is None: return None p = redact(ev.payload) cwd = p.get("cwd") or "" model = p.get("model") or "unknown" repo = _repo_basename(cwd) alert_name_map = { "error": "AiderError", "silent_timeout": "AiderSilentTimeout", "session_end": "AiderSessionFailure", } alert_name = alert_name_map.get(ev.type, "AiderEvent") return { "alert_name": alert_name, "severity": sev, "source": "manual", # aider 不屬 prometheus/signoz/alertmanager/telegram "fingerprint": f"aider:{ev.session_id}:{ev.type}", "target": repo or "unknown", "labels": { "session_id": ev.session_id, "host": ev.host, "repo": repo, "model": model, "event_type": ev.type, }, "annotations": { "summary": f"[aider/{ev.type}] {repo} ({model})", "description": _compact_desc(ev.type, p), }, } def _repo_basename(cwd: str) -> str: """/Users/ogt/awoooi → awoooi""" if not cwd: return "" return cwd.rstrip("/").rsplit("/", 1)[-1] def _compact_desc(event_type: str, payload: dict) -> str: """把 payload 壓成 200 字內描述,secret 已 redacted。""" if event_type == "error": kind = payload.get("kind", "unknown") msg = payload.get("message", "")[:150] return f"kind={kind} message={msg}" if event_type == "silent_timeout": return f"idle_sec={payload.get('idle_sec')} tail={payload.get('last_output_tail','')[:50]}" if event_type == "session_end": return (f"exit_code={payload.get('exit_code')} " f"errors={payload.get('error_count',0)} " f"duration={payload.get('duration_sec',0)}s " f"tokens={payload.get('tokens_sent',0)}+{payload.get('tokens_received',0)}") return str(payload)[:200] # ---- Redis stream 推送 (Task A6) ---- async def push_aider_batch_to_stream(batch: AiderBatchIn) -> list[str]: """把 event batch 推到 Redis stream。回傳 stream ID 列表。""" from src.core.redis_client import get_redis stream_key = os.environ.get("AIDER_EVENTS_STREAM_KEY", "signals:aider:events") r = get_redis() ids = [] for ev in batch.events: msg_id = await r.xadd(stream_key, {"payload": ev.model_dump_json()}) ids.append(_to_str(msg_id)) return ids def _to_str(x) -> str: """轉成 str(相容 bytes 和 str 回傳值)。""" return x.decode() if isinstance(x, bytes) else str(x)