feat(api+telegram): Sprint 4 Phase C+D — API 端點 + Telegram 處置統計

Phase C: API 端點
- C1: GET /api/v1/stats/disposition — 完整處置分佈統計
  - DispositionSummary: auto/human/manual/cold_start + auto_rate
  - DispositionByAnomaly: 按異常類型明細 (最多 20 筆)
  - Redis SCAN + HGETALL 聚合
- C2: GET /api/v1/auto-repair/stats 擴充 disposition_summary

Phase D: Telegram 告警格式
- D1: 告警卡片加處置統計行
  - 🤖 自動: N | 👤 審核: N | 🔧 手動: N
  - 自動化率百分比
- D2: 歷史按鈕強化處置分佈明細
  - 完整 5 項計數 + 自動化率

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-07 12:17:20 +08:00
parent 9253281d46
commit a85e9ced08
3 changed files with 183 additions and 3 deletions

View File

@@ -190,12 +190,32 @@ async def get_auto_repair_stats() -> dict:
total_executions = sum(p.total_executions for p in playbooks)
total_success = sum(p.success_count for p in playbooks)
# 2026-04-07 Claude Code: Sprint 4 C2 — 加入處置分佈摘要
disposition_summary = {"auto_repair": 0, "human_approved": 0, "manual_resolved": 0, "cold_start_trust": 0, "total": 0}
try:
from src.services.anomaly_counter import get_anomaly_counter
counter = get_anomaly_counter()
pattern = f"{counter.PREFIX_DISPOSITION}*"
async for key in counter.redis.scan_iter(match=pattern, count=100):
raw = await counter.redis.hgetall(key)
for field in disposition_summary:
disposition_summary[field] += int(raw.get(field.encode(), raw.get(field, 0)))
except Exception:
pass
total_disp = disposition_summary["total"]
auto_cnt = disposition_summary["auto_repair"] + disposition_summary["cold_start_trust"]
return {
"approved_playbooks": len(playbooks),
"high_quality_playbooks": high_quality_count,
"total_executions": total_executions,
"overall_success_rate": total_success / total_executions if total_executions > 0 else 0.0,
"auto_repair_eligible": high_quality_count > 0,
"disposition_summary": {
**disposition_summary,
"auto_rate": auto_cnt / total_disp if total_disp > 0 else 0.0,
},
}

View File

@@ -401,3 +401,128 @@ async def trigger_weekly_report(
"success": success,
"message": "週報已發送" if success else "週報發送失敗",
}
# =============================================================================
# 2026-04-07 Claude Code: Sprint 4 C1 — 告警處置統計
# =============================================================================
class DispositionSummary(BaseModel):
"""處置類型分佈"""
total: int = Field(default=0, description="總處置次數")
auto_repair: int = Field(default=0, description="自動修復次數")
human_approved: int = Field(default=0, description="人工審核批准次數")
manual_resolved: int = Field(default=0, description="手動處理次數")
cold_start_trust: int = Field(default=0, description="冷啟動信任次數")
auto_rate: float = Field(default=0.0, description="自動化率 (auto_repair + cold_start) / total")
human_rate: float = Field(default=0.0, description="人工介入率")
class DispositionByAnomaly(BaseModel):
"""按異常類型的處置分佈"""
anomaly_key: str
alert_name: str = ""
disposition: DispositionSummary
class DispositionResponse(BaseModel):
"""處置統計 API 回應"""
summary: DispositionSummary
by_anomaly: list[DispositionByAnomaly] = Field(default_factory=list)
@router.get(
"/disposition",
response_model=DispositionResponse,
summary="告警處置統計",
)
async def get_disposition_stats(
stats: StatsServiceDep,
days: int = Query(default=7, ge=1, le=90, description="統計天數"),
) -> DispositionResponse:
"""
取得告警處置類型分佈統計。
2026-04-07 Claude Code: Sprint 4 C1
包含:
- 總覽: 自動修復/人工審核/手動處理/冷啟動信任 各幾次
- 自動化率
- 按異常類型明細
"""
try:
from src.services.anomaly_counter import get_anomaly_counter
counter = get_anomaly_counter()
# 掃描所有活躍的 anomaly disposition keys
pattern = f"{counter.PREFIX_DISPOSITION}*"
keys = []
async for key in counter.redis.scan_iter(match=pattern, count=100):
keys.append(key)
total_summary = {
"auto_repair": 0, "human_approved": 0,
"manual_resolved": 0, "cold_start_trust": 0, "total": 0,
}
by_anomaly: list[DispositionByAnomaly] = []
for key in keys:
raw = await counter.redis.hgetall(key)
# Extract anomaly_key from Redis key
key_str = key.decode() if isinstance(key, bytes) else key
anomaly_key = key_str.replace(counter.PREFIX_DISPOSITION, "")
d = {
"auto_repair": int(raw.get(b"auto_repair", raw.get("auto_repair", 0))),
"human_approved": int(raw.get(b"human_approved", raw.get("human_approved", 0))),
"manual_resolved": int(raw.get(b"manual_resolved", raw.get("manual_resolved", 0))),
"cold_start_trust": int(raw.get(b"cold_start_trust", raw.get("cold_start_trust", 0))),
"total": int(raw.get(b"total", raw.get("total", 0))),
}
for k in total_summary:
total_summary[k] += d[k]
# 嘗試取得 alert_name
alert_name = ""
try:
meta_key = f"{counter.PREFIX_METADATA}{anomaly_key}"
meta_raw = await counter.redis.hget(meta_key, "signature")
if meta_raw:
import json
sig = json.loads(meta_raw)
alert_name = sig.get("alert_name", "")
except Exception:
pass
if d["total"] > 0:
auto_cnt = d["auto_repair"] + d["cold_start_trust"]
by_anomaly.append(DispositionByAnomaly(
anomaly_key=anomaly_key,
alert_name=alert_name,
disposition=DispositionSummary(
**d,
auto_rate=auto_cnt / d["total"] if d["total"] > 0 else 0,
human_rate=d["human_approved"] / d["total"] if d["total"] > 0 else 0,
),
))
# Sort by total descending
by_anomaly.sort(key=lambda x: x.disposition.total, reverse=True)
total = total_summary["total"]
auto_cnt = total_summary["auto_repair"] + total_summary["cold_start_trust"]
return DispositionResponse(
summary=DispositionSummary(
**total_summary,
auto_rate=auto_cnt / total if total > 0 else 0,
human_rate=total_summary["human_approved"] / total if total > 0 else 0,
),
by_anomaly=by_anomaly[:20], # 最多回傳 20 筆
)
except Exception as e:
import structlog
structlog.get_logger(__name__).warning("disposition_stats_error", error=str(e))
return DispositionResponse(summary=DispositionSummary())

View File

@@ -240,12 +240,32 @@ class TelegramMessage:
"PERMANENT_FIX": "🚨",
}.get(freq.get("escalation_level"), "")
# 2026-04-07 Claude Code: Sprint 4 D1 — 處置統計行
auto_r = freq.get("auto_repair_count", 0)
human_a = freq.get("human_approved_count", 0)
manual_r = freq.get("manual_resolved_count", 0)
cold_s = freq.get("cold_start_trust_count", 0)
total_res = freq.get("total_resolution_count", 0)
# 處置分佈行 (只在有處置紀錄時顯示)
disposition_line = ""
if total_res > 0:
auto_total = auto_r + cold_s
auto_rate = int(auto_total / total_res * 100) if total_res > 0 else 0
disposition_line = (
f"├ 🤖 自動: <code>{auto_total}</code>"
f" | 👤 審核: <code>{human_a}</code>"
f" | 🔧 手動: <code>{manual_r}</code>\n"
f"├ 自動化率: <b>{auto_rate}%</b>\n"
)
frequency_block = (
f"━━━━━━━━━━━━━━━━━━━\n"
f"📊 <b>頻率統計</b> {escalation_emoji}\n"
f"├ 1h: <code>{freq.get('count_1h', 0)}</code> 次\n"
f" 24h: <code>{freq.get('count_24h', 0)}</code> 次\n"
f"└ 修復: <code>{freq.get('auto_repair_count', 0)}</code> 次\n"
f"├ 1h: <code>{freq.get('count_1h', 0)}</code> 次"
f" | 24h: <code>{freq.get('count_24h', 0)}</code> 次\n"
f"{disposition_line}"
f"└ 累計修復: <code>{auto_r}</code> 次\n"
)
if freq.get("escalation_level"):
frequency_block += f"🔺 升級: <b>{freq['escalation_level']}</b>\n"
@@ -2550,6 +2570,21 @@ class TelegramGateway:
if fs.last_repair_action:
lines.append(f" 最後動作: {html.escape(fs.last_repair_action[:80])}")
# 2026-04-07 Claude Code: Sprint 4 D2 — 處置分佈明細
total_res = fs.total_resolution_count
if total_res > 0:
auto_total = fs.auto_repair_count + fs.cold_start_trust_count
auto_rate = int(auto_total / total_res * 100) if total_res > 0 else 0
lines += [
f"",
f"📋 <b>處置分佈</b> (共 {total_res} 次)",
f" 🤖 自動修復: {fs.auto_repair_count}",
f" ❄️ 冷啟動信任: {fs.cold_start_trust_count}",
f" 👤 人工審核: {fs.human_approved_count}",
f" 🔧 手動處理: {fs.manual_resolved_count}",
f" 📈 自動化率: <b>{auto_rate}%</b>",
]
await self.send_notification("\n".join(lines))
except Exception as e: