feat(flywheel): surface ai automation and code review
This commit is contained in:
@@ -10,10 +10,12 @@ show the full path.
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy import or_, select, text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from src.db.base import get_db_context
|
||||
from src.db.models import (
|
||||
@@ -76,6 +78,40 @@ _EVENT_STAGE_MAP = {
|
||||
"close": "close",
|
||||
"resolved": "close",
|
||||
}
|
||||
_AUTOMATION_STAGE_MAP = {
|
||||
"monitor_configured": "investigator",
|
||||
"monitor_removed": "safe",
|
||||
"alert_fired": "webhook",
|
||||
"alert_suppressed": "safe",
|
||||
"alert_routed": "safe",
|
||||
"rule_created": "km",
|
||||
"rule_updated": "km",
|
||||
"rule_matched": "ai_router",
|
||||
"rule_rejected": "safe",
|
||||
"rule_deprecated": "km",
|
||||
"playbook_generated": "km",
|
||||
"playbook_updated": "km",
|
||||
"playbook_executed": "executor",
|
||||
"remediation_executed": "executor",
|
||||
"remediation_verified": "verifier",
|
||||
"remediation_rolled_back": "executor",
|
||||
"self_correction_attempted": "verifier",
|
||||
"km_created": "km",
|
||||
"km_updated": "km",
|
||||
"km_linked": "km",
|
||||
"asset_discovered": "investigator",
|
||||
"coverage_recalculated": "verifier",
|
||||
"capacity_recommendation": "investigator",
|
||||
"quota_enforced": "safe",
|
||||
"notification_formatted": "safe",
|
||||
}
|
||||
_AUTOMATION_STATUS_MAP = {
|
||||
"pending": "pending",
|
||||
"success": "success",
|
||||
"failed": "error",
|
||||
"dry_run": "info",
|
||||
"rolled_back": "warning",
|
||||
}
|
||||
|
||||
|
||||
def _value(value: Any) -> Any:
|
||||
@@ -159,6 +195,81 @@ def _stage_from_event_type(event_type: str | None) -> str:
|
||||
return _EVENT_STAGE_MAP.get((event_type or "").lower(), "safe")
|
||||
|
||||
|
||||
def _stage_from_automation_op(operation_type: Any) -> str:
|
||||
return _AUTOMATION_STAGE_MAP.get(str(operation_type or "").lower(), "safe")
|
||||
|
||||
|
||||
def _automation_status(status: Any) -> str:
|
||||
return _AUTOMATION_STATUS_MAP.get(str(status or "").lower(), "info")
|
||||
|
||||
|
||||
def _as_dict(value: Any) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _automation_summary(row: Any) -> str | None:
|
||||
output = _as_dict(row.output)
|
||||
input_data = _as_dict(row.input)
|
||||
for key in ("summary", "message", "action", "rule_id", "playbook_id"):
|
||||
value = output.get(key) or input_data.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
return row.error
|
||||
|
||||
|
||||
async def _fetch_automation_ops(
|
||||
db: Any,
|
||||
incident_id: str,
|
||||
approval_ids: list[str],
|
||||
) -> list[Any]:
|
||||
"""Best-effort ADR-090 automation_operation_log lookup for one incident."""
|
||||
params: dict[str, Any] = {"incident_id": incident_id}
|
||||
approval_clause = ""
|
||||
if approval_ids:
|
||||
placeholders = []
|
||||
for idx, approval_id in enumerate(approval_ids):
|
||||
key = f"approval_id_{idx}"
|
||||
params[key] = approval_id
|
||||
placeholders.append(f":{key}")
|
||||
in_list = ", ".join(placeholders)
|
||||
approval_clause = (
|
||||
f" OR input ->> 'approval_id' IN ({in_list})"
|
||||
f" OR output ->> 'approval_id' IN ({in_list})"
|
||||
)
|
||||
|
||||
try:
|
||||
rows = await db.execute(
|
||||
text(f"""
|
||||
SELECT
|
||||
op_id::text AS op_id,
|
||||
operation_type,
|
||||
actor,
|
||||
status,
|
||||
input,
|
||||
output,
|
||||
error,
|
||||
duration_ms,
|
||||
tags,
|
||||
created_at
|
||||
FROM automation_operation_log
|
||||
WHERE input ->> 'incident_id' = :incident_id
|
||||
OR output ->> 'incident_id' = :incident_id
|
||||
{approval_clause}
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 100
|
||||
"""),
|
||||
params,
|
||||
)
|
||||
return [SimpleNamespace(**dict(row)) for row in rows.mappings().all()]
|
||||
except SQLAlchemyError as exc:
|
||||
logger.debug(
|
||||
"incident_timeline_automation_log_skipped",
|
||||
incident_id=incident_id,
|
||||
error=str(exc),
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def format_ascii_timeline(stages: list[dict[str, Any]]) -> str:
|
||||
"""Compact ASCII line for Telegram and logs."""
|
||||
marks = {
|
||||
@@ -246,6 +357,7 @@ async def fetch_incident_timeline(incident_id: str) -> dict[str, Any] | None:
|
||||
.limit(100)
|
||||
)
|
||||
).scalars().all()
|
||||
automation_ops = await _fetch_automation_ops(db, incident_id, approval_ids)
|
||||
|
||||
events: list[dict[str, Any]] = []
|
||||
|
||||
@@ -486,6 +598,24 @@ async def fetch_incident_timeline(incident_id: str) -> dict[str, Any] | None:
|
||||
},
|
||||
))
|
||||
|
||||
for op in automation_ops:
|
||||
events.append(_event(
|
||||
stage=_stage_from_automation_op(op.operation_type),
|
||||
status=_automation_status(op.status),
|
||||
title=f"Automation: {op.operation_type}",
|
||||
timestamp=op.created_at,
|
||||
description=_automation_summary(op),
|
||||
actor=op.actor,
|
||||
source_table="automation_operation_log",
|
||||
data={
|
||||
"op_id": op.op_id,
|
||||
"operation_type": op.operation_type,
|
||||
"status": op.status,
|
||||
"duration_ms": op.duration_ms,
|
||||
"tags": op.tags or [],
|
||||
},
|
||||
))
|
||||
|
||||
events.sort(key=lambda e: e["timestamp"] or "")
|
||||
for event in events:
|
||||
_apply_event(stages, event)
|
||||
|
||||
@@ -217,6 +217,58 @@ class TelegramMessage:
|
||||
nemotron_validation: str = "" # "✅ 驗證通過" / "❌ 驗證失敗" / "⏳ 驗證中"
|
||||
nemotron_latency_ms: float = 0.0 # Nemotron 呼叫延遲 (ms)
|
||||
|
||||
def _provider_display(self) -> tuple[str, str]:
|
||||
"""Return display provider and optional model suffix."""
|
||||
provider_names = {
|
||||
"ollama": "Ollama",
|
||||
"gemini": "Gemini",
|
||||
"claude": "Claude",
|
||||
"nvidia": "Nemotron",
|
||||
"openclaw_nemo": "OpenClaw Nemo",
|
||||
"openclaw_nvidia_nim": "OpenClaw Nemo",
|
||||
"openclaw_qwen": "OpenClaw Nemo",
|
||||
}
|
||||
provider = (self.ai_provider or "").strip().lower()
|
||||
if provider:
|
||||
provider_display = provider_names.get(provider, self.ai_provider.upper())
|
||||
elif self.confidence > 0:
|
||||
provider_display = "AI Router"
|
||||
else:
|
||||
provider_display = "rule_fallback"
|
||||
model_suffix = f" ({html.escape(self.ai_model)})" if self.ai_model else ""
|
||||
return provider_display, model_suffix
|
||||
|
||||
def _automation_mode(self) -> str:
|
||||
text = f"{self.root_cause} {self.suggested_action}".lower()
|
||||
if "超時" in text or "timeout" in text:
|
||||
return "llm_timeout_manual_gate"
|
||||
if self.confidence > 0 and self.suggested_action and self.suggested_action != "待分析":
|
||||
return "ai_proposal_ready"
|
||||
if self.suggested_action in {"待分析", "", "NO_ACTION"}:
|
||||
return "analysis_degraded"
|
||||
return "safe_gate_pending"
|
||||
|
||||
def _format_automation_block(self) -> str:
|
||||
"""Visible AI automation chain for every ACTION REQUIRED card."""
|
||||
provider_display, model_suffix = self._provider_display()
|
||||
mode = self._automation_mode()
|
||||
openclaw_state = provider_display if provider_display != "rule_fallback" else "degraded"
|
||||
nemotron_state = "tool_ready" if self.nemotron_enabled else "standby"
|
||||
hermes_state = self.playbook_name or "rule_catalog"
|
||||
elephant_state = "timeline_km_pending"
|
||||
flow = "webhook>investigator>router>llm/rule>safe>approval"
|
||||
|
||||
return (
|
||||
f"🤖 <b>AI 自動化鏈路</b>\n"
|
||||
f"├ Router:<code>{html.escape(provider_display)}{model_suffix}</code>\n"
|
||||
f"├ Mode:<code>{html.escape(mode)}</code>\n"
|
||||
f"├ OpenClaw:<code>{html.escape(openclaw_state)}</code> | "
|
||||
f"NemoTron:<code>{html.escape(nemotron_state)}</code>\n"
|
||||
f"├ Hermes:<code>{html.escape(hermes_state)}</code> | "
|
||||
f"ElephantAlpha:<code>{html.escape(elephant_state)}</code>\n"
|
||||
f"└ Flow:<code>{flow}</code>\n"
|
||||
)
|
||||
|
||||
def format(self) -> str:
|
||||
"""
|
||||
格式化為 SOUL.md 規範的訊息 (含 AI 仲裁 + SignOz)
|
||||
@@ -320,22 +372,12 @@ class TelegramMessage:
|
||||
# ADR-075 TYPE-3 格式 (2026-04-12 ogt)
|
||||
# AI 來源標籤:confidence=0 不顯示 0%,顯示 📋 規則分析
|
||||
if self.confidence > 0 and self.ai_provider:
|
||||
provider_names = {
|
||||
"ollama": "Ollama",
|
||||
"gemini": "Gemini",
|
||||
"claude": "Claude",
|
||||
"nvidia": "Nemotron",
|
||||
"openclaw_nemo": "Nemotron",
|
||||
"openclaw_nvidia_nim": "Nemotron",
|
||||
"openclaw_qwen": "Nemotron",
|
||||
}
|
||||
provider_display = provider_names.get(self.ai_provider.lower(), self.ai_provider.upper())
|
||||
model_suffix = f" ({html.escape(self.ai_model)})" if self.ai_model else ""
|
||||
provider_display, model_suffix = self._provider_display()
|
||||
ai_source = f"🤖 <b>{provider_display}{model_suffix}</b> {conf_emoji} {confidence_pct}%"
|
||||
elif self.confidence > 0:
|
||||
ai_source = f"🤖 <b>AI 仲裁</b> {conf_emoji} {confidence_pct}%"
|
||||
else:
|
||||
ai_source = "📋 規則分析"
|
||||
ai_source = "⚙️ <b>規則/降級分析</b>"
|
||||
|
||||
# 風險等級中文
|
||||
risk_zh = {
|
||||
@@ -368,16 +410,18 @@ class TelegramMessage:
|
||||
playbook_line = ""
|
||||
if self.playbook_name:
|
||||
playbook_line = f"📖 Playbook:<code>{html.escape(self.playbook_name)}</code>\n"
|
||||
automation_block = self._format_automation_block()
|
||||
|
||||
# ADR-075 TYPE-3 格式組裝
|
||||
message = (
|
||||
f"{self.status_emoji} ACTION REQUIRED | <b>{html.escape(risk_zh)}</b>\n"
|
||||
f"──────────────────────\n"
|
||||
f"📋 <code>{html.escape(incident_id)}</code>\n"
|
||||
f"流程:<code>webhook>investigator>ai>safe>executor>verifier>km</code>\n"
|
||||
f"🎯 資源:<code>{safe_resource}</code>\n"
|
||||
f"{category_line}"
|
||||
f"\n"
|
||||
f"{automation_block}"
|
||||
f"\n"
|
||||
f"🧠 <b>AI 深度診斷</b>\n"
|
||||
f"├─ 分析:{safe_root_cause}\n"
|
||||
f"├─ 責任:{resp_display}\n"
|
||||
@@ -461,17 +505,7 @@ class TelegramMessage:
|
||||
# 2026-04-04 ogt: 加入 ai_model 顯示底層模型名稱
|
||||
# 2026-04-12 ogt: 規則匹配不顯示 🔴 0%,改用 ✅
|
||||
if self.confidence > 0 and self.ai_provider:
|
||||
provider_names = {
|
||||
"ollama": "Ollama",
|
||||
"gemini": "Gemini",
|
||||
"claude": "Claude",
|
||||
"nvidia": "Nemotron",
|
||||
"openclaw_nemo": "OpenClaw Nemo",
|
||||
"openclaw_nvidia_nim": "OpenClaw Nemo",
|
||||
"openclaw_qwen": "OpenClaw Nemo",
|
||||
}
|
||||
provider_display = provider_names.get(self.ai_provider.lower(), self.ai_provider.upper())
|
||||
model_suffix = f" ({html.escape(self.ai_model)})" if self.ai_model else ""
|
||||
provider_display, model_suffix = self._provider_display()
|
||||
conf_line = f"🤖 <b>{provider_display} 仲裁</b>{model_suffix} {conf_emoji} {confidence_pct}%"
|
||||
elif self.confidence > 0:
|
||||
conf_line = f"🤖 <b>OpenClaw 仲裁</b> {conf_emoji} {confidence_pct}%"
|
||||
@@ -538,6 +572,7 @@ class TelegramMessage:
|
||||
f"<b>{safe_resource}</b>\n"
|
||||
f"{category_line}"
|
||||
f"\n"
|
||||
f"{self._format_automation_block()}\n"
|
||||
f"{conf_line}\n"
|
||||
f"👥 {resp_display}\n"
|
||||
f"💡 {safe_root_cause}\n"
|
||||
|
||||
Reference in New Issue
Block a user