diff --git a/apps/api/src/api/v1/auto_repair.py b/apps/api/src/api/v1/auto_repair.py
index dd5bd1e38..005cd27a2 100644
--- a/apps/api/src/api/v1/auto_repair.py
+++ b/apps/api/src/api/v1/auto_repair.py
@@ -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,
+ },
}
diff --git a/apps/api/src/api/v1/stats.py b/apps/api/src/api/v1/stats.py
index 43f6fdd16..330e9bddb 100644
--- a/apps/api/src/api/v1/stats.py
+++ b/apps/api/src/api/v1/stats.py
@@ -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())
diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py
index 8887dd29e..5b73575af 100644
--- a/apps/api/src/services/telegram_gateway.py
+++ b/apps/api/src/services/telegram_gateway.py
@@ -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"├ 🤖 自動: {auto_total}"
+ f" | 👤 審核: {human_a}"
+ f" | 🔧 手動: {manual_r}\n"
+ f"├ 自動化率: {auto_rate}%\n"
+ )
+
frequency_block = (
f"━━━━━━━━━━━━━━━━━━━\n"
f"📊 頻率統計 {escalation_emoji}\n"
- f"├ 1h: {freq.get('count_1h', 0)} 次\n"
- f"├ 24h: {freq.get('count_24h', 0)} 次\n"
- f"└ 修復: {freq.get('auto_repair_count', 0)} 次\n"
+ f"├ 1h: {freq.get('count_1h', 0)} 次"
+ f" | 24h: {freq.get('count_24h', 0)} 次\n"
+ f"{disposition_line}"
+ f"└ 累計修復: {auto_r} 次\n"
)
if freq.get("escalation_level"):
frequency_block += f"🔺 升級: {freq['escalation_level']}\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"📋 處置分佈 (共 {total_res} 次)",
+ f" 🤖 自動修復: {fs.auto_repair_count}",
+ f" ❄️ 冷啟動信任: {fs.cold_start_trust_count}",
+ f" 👤 人工審核: {fs.human_approved_count}",
+ f" 🔧 手動處理: {fs.manual_resolved_count}",
+ f" 📈 自動化率: {auto_rate}%",
+ ]
+
await self.send_notification("\n".join(lines))
except Exception as e: