承接 Wave 6/7/8 多 engineer 在 agent 限額前完成的代碼,補 commit 解 production HEAD 隱性 import error(decision_fusion 已被 decision_manager 引用但檔案 untracked)。 新增(後端核心): - decision_fusion.py (562 行) — P2.1 方法 III(OpenClaw + Hermes + Elephant 三 LLM 融合) - aiops_timeline.py + aiops_timeline_service.py — critic B4 修復 /api/v1/aiops/timeline endpoint,DB 存取抽到 service 層遵守 leWOOOgo 積木化 - migrations/p2_decision_fusion_columns.sql + rollback — approval_records fusion 欄位 修改(後端整合): - decision_manager.py — fusion 三斷鏈修補(critic B1+B2+B3): · B1: 寫 _evidence_snapshot_ref 到 token.proposal_data · B2: fusion 前計算 complexity_score 並寫 token · B3: fusion composite 寫 token.proposal_data["decision_fusion"] - auto_approve.py — fusion + consensus 認識(critic B3+B5): · composite > 0.7 → auto_execute_eligible bypass min_confidence · source=consensus_engine + score>=0.6 → 規則可信路徑 - consensus_engine.py — db-fix _save_consensus 重用 agent_sessions - governance_agent.py — db-fix _alert PG 寫入 ai_governance_events - approval_db.py — fusion 3 欄位 + 2 partial index + CheckConstraint - db/models.py — schema 對齊 migration - core/config.py — vuln #1 修復:OLLAMA_URL/_FALLBACK_URL field_validator 拒絕公網 IP + 外部域名,僅允許私網/loopback/K8s SVC 白名單 - core/feature_flags.py — P2 fusion + consensus flags - main.py — governance_agent lifespan 啟動 - failover_alerter.py — Wave8-X2: in-memory dedup fallback(Redis 拒絕後不 fail-open) - ollama_*.py — metrics 整合 + recovery 改善 - auto_repair_service.py — verifier 接線 新增(測試 2438 行): - test_decision_fusion.py / test_governance_agent.py / test_consensus_integration.py - test_p2_db_fixes.py / test_wave8_fusion_fixes.py - test_config_url_validation.py(vuln #1 12 tests) - test_failover_alerter.py +Wave8-X2 in-memory dedup 補測 驗收: 116 tests pass (decision_fusion + wave8_fusion + config_url + consensus + governance + p2_db_fixes + failover_alerter) Conflict resolution: - 3 檔(config.py + auto_approve.py + decision_manager.py)git stash pop 衝突 保留 stashed (engineer 最終版),補回 ValueError 「公網 IP」字樣對齊 test Note: 此 commit 解 production HEAD 隱性 import error 仍未修: vuln #4 prompt injection / debugger B14 quota fail-closed / B25-B26 drain_pending_tasks / B8 governance fail alert Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-Authored-By: Multiple Engineers (Wave 6/7/8) <noreply@anthropic.com>
154 lines
5.8 KiB
Python
154 lines
5.8 KiB
Python
"""
|
||
Incident Analysis Sweeper — 自動觸發 INVESTIGATING 事件 AI 分析
|
||
================================================================
|
||
問題背景:
|
||
Signal Worker 創建 Incident 後,AI 分析 (decision_manager) 原本只在
|
||
GET /api/v1/incidents 被呼叫時才觸發 (背景 fire-and-forget)。
|
||
若前端沒人看或 Telegram Bot 未呼叫該端點,新 Incident 永遠沒有 AI 分析。
|
||
|
||
解法:
|
||
每 90 秒掃描 INVESTIGATING 狀態且無 decision token 的 Incident,
|
||
自動在背景觸發 get_or_create_decision()。
|
||
|
||
限流:
|
||
Semaphore(3) — 避免並發壓垮 OPENCLAW_NEMO/Ollama
|
||
每批最多 5 個 incident,避免啟動雪崩
|
||
|
||
Key 格式說明:
|
||
decision token 儲存為 decision:DEC-{HEX},內部 incident_id 欄位對應 INC-*。
|
||
使用 sweeper_done:{incident_id} 輕量標記避免重複掃描。
|
||
get_or_create_decision() 本身已有 COMPLETED/READY 去重,雙重保護。
|
||
|
||
2026-04-16 Claude Sonnet 4.6 Asia/Taipei — 修正 key 格式 BUG
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
|
||
import structlog
|
||
|
||
from src.models.incident import Incident, IncidentStatus, Severity
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
_SWEEP_INTERVAL_SEC = 90 # 每 90 秒掃一次
|
||
_MAX_BATCH = 5 # 每批最多 5 個
|
||
_SEMAPHORE_LIMIT = 3 # 最多 3 個並發 AI 分析
|
||
_DONE_MARKER_PREFIX = "sweeper_done:" # 輕量標記:已觸發過分析
|
||
_DONE_MARKER_TTL = 3600 # 1 小時 TTL,後續由 get_or_create 去重
|
||
# 2026-04-16 ogt: 只處理 48h 內的 incident,避免首次啟動把所有歷史舊案洗版到 Telegram
|
||
_MAX_INCIDENT_AGE_HOURS = 48
|
||
|
||
|
||
async def run_incident_analysis_sweeper() -> None:
|
||
"""
|
||
永久迴圈:每 90 秒自動為未分析的 INVESTIGATING Incident 觸發 AI 分析。
|
||
由 main.py lifespan 透過 asyncio.create_task() 啟動。
|
||
"""
|
||
logger.info("incident_analysis_sweeper_started", interval_sec=_SWEEP_INTERVAL_SEC)
|
||
sem = asyncio.Semaphore(_SEMAPHORE_LIMIT)
|
||
|
||
while True:
|
||
try:
|
||
await _sweep_once(sem)
|
||
except Exception as e:
|
||
logger.warning("incident_analysis_sweeper_error", error=str(e))
|
||
|
||
await asyncio.sleep(_SWEEP_INTERVAL_SEC)
|
||
|
||
|
||
async def _sweep_once(sem: asyncio.Semaphore) -> None:
|
||
"""
|
||
執行一次掃描:找出沒有 decision token 的 INVESTIGATING incidents,
|
||
在背景觸發 AI 分析。
|
||
|
||
Decision token key 格式: decision:DEC-{HEX12} (非 decision:INC-*)
|
||
使用 sweeper_done:{incident_id} 輕量標記避免重複觸發。
|
||
"""
|
||
from src.services.decision_manager import get_decision_manager
|
||
from src.services.incident_service import get_incident_service
|
||
from src.core.redis_client import get_redis
|
||
|
||
redis = get_redis()
|
||
incident_service = get_incident_service()
|
||
dm = get_decision_manager()
|
||
|
||
# 取得所有 INVESTIGATING incidents
|
||
try:
|
||
incidents: list[Incident] = await incident_service.get_active_incidents()
|
||
except Exception as e:
|
||
logger.warning("sweeper_get_incidents_failed", error=str(e))
|
||
return
|
||
|
||
if not incidents:
|
||
return
|
||
|
||
# 過濾:只處理 48h 內的 incident(避免首次啟動把全部歷史舊案洗版 Telegram)
|
||
from datetime import datetime, timezone, timedelta
|
||
now_utc = datetime.now(timezone.utc)
|
||
cutoff = now_utc - timedelta(hours=_MAX_INCIDENT_AGE_HOURS)
|
||
|
||
recent_incidents = []
|
||
for incident in incidents:
|
||
created = getattr(incident, "created_at", None)
|
||
if created:
|
||
# 確保 created_at 有時區資訊
|
||
if created.tzinfo is None:
|
||
created = created.replace(tzinfo=timezone.utc)
|
||
if created >= cutoff:
|
||
recent_incidents.append(incident)
|
||
else:
|
||
# 沒有 created_at 的舊資料:跳過
|
||
pass
|
||
|
||
if not recent_incidents:
|
||
return
|
||
|
||
# 找出尚未觸發過分析的 (用輕量標記,不掃描 decision:DEC-* 全集)
|
||
unanalyzed = []
|
||
for incident in recent_incidents:
|
||
done_key = f"{_DONE_MARKER_PREFIX}{incident.incident_id}"
|
||
if not await redis.exists(done_key):
|
||
unanalyzed.append(incident)
|
||
|
||
if not unanalyzed:
|
||
return
|
||
|
||
# 限制每批
|
||
batch = unanalyzed[:_MAX_BATCH]
|
||
logger.info(
|
||
"sweeper_triggering_analysis",
|
||
total_unanalyzed=len(unanalyzed),
|
||
batch_size=len(batch),
|
||
)
|
||
|
||
async def _analyze(incident: Incident) -> None:
|
||
async with sem:
|
||
try:
|
||
timeout = 120.0 if incident.severity in (Severity.P0, Severity.P1) else 180.0
|
||
# 2026-04-26 P2.4 by Claude — 12-Agent Consensus 整合
|
||
# ENABLE_12AGENT_CONSENSUS=True + P0/P1 → 走 consensus 路徑(由 dm 內部 flag 守門)
|
||
from src.core.config import settings as _settings
|
||
if (
|
||
_settings.ENABLE_12AGENT_CONSENSUS
|
||
and incident.severity in (Severity.P0, Severity.P1)
|
||
):
|
||
await dm.get_or_create_decision_with_consensus(
|
||
incident=incident, timeout_sec=timeout
|
||
)
|
||
else:
|
||
await dm.get_or_create_decision(incident=incident, timeout_sec=timeout)
|
||
# 設 done 標記,避免下次掃描重複觸發
|
||
done_key = f"{_DONE_MARKER_PREFIX}{incident.incident_id}"
|
||
await redis.set(done_key, "1", ex=_DONE_MARKER_TTL)
|
||
logger.info("sweeper_analysis_done", incident_id=incident.incident_id)
|
||
except Exception as e:
|
||
logger.warning(
|
||
"sweeper_analysis_failed",
|
||
incident_id=incident.incident_id,
|
||
error=str(e),
|
||
)
|
||
|
||
tasks = [asyncio.create_task(_analyze(inc)) for inc in batch]
|
||
await asyncio.gather(*tasks, return_exceptions=True)
|