feat(flywheel): surface ai automation and code review
Some checks failed
Code Review / ai-code-review (push) Successful in 31s
CD Pipeline / build-and-deploy (push) Failing after 5m23s

This commit is contained in:
Your Name
2026-04-30 00:09:25 +08:00
parent d197e2785d
commit 639bb64788
8 changed files with 734 additions and 26 deletions

View File

@@ -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)

View File

@@ -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&gt;investigator&gt;ai&gt;safe&gt;executor&gt;verifier&gt;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"