fix(api): Sprint 4 首席架構師 Review P0 修正 — hash 統一 + 積木化合規

P0-1: anomaly_key hash 推導統一
- B1: 新增 _derive_anomaly_key() 使用 AnomalyCounter.hash_signature()
  取代 symptoms.compute_hash()
- B3/B4: namespace 改用 signal.labels.get("namespace", "")
  修正 getattr(signal, "namespace", "") 永遠回傳空字串

P0-2: Router 層積木化合規
- C1/C2: 封裝 get_all_disposition_stats() 到 AnomalyCounter
- Router 不再直接存取 counter.redis
- stats.py 移除未使用的 days/stats 參數

P1: get_frequency() 填充 disposition 欄位
- 與 _record_anomaly_impl() 一致,回傳完整處置統計

首席架構師評分: 82/100 → P0 全數修正

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-07 12:53:12 +08:00
parent a85e9ced08
commit 561bcb638b
6 changed files with 122 additions and 69 deletions

View File

@@ -191,15 +191,12 @@ async def get_auto_repair_stats() -> dict:
total_success = sum(p.success_count for p in playbooks)
# 2026-04-07 Claude Code: Sprint 4 C2 — 加入處置分佈摘要
# P0-2 Fix: 呼叫 Service 層封裝方法
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)))
disposition_summary, _ = await counter.get_all_disposition_stats()
except Exception:
pass

View File

@@ -437,13 +437,11 @@ class DispositionResponse(BaseModel):
response_model=DispositionResponse,
summary="告警處置統計",
)
async def get_disposition_stats(
stats: StatsServiceDep,
days: int = Query(default=7, ge=1, le=90, description="統計天數"),
) -> DispositionResponse:
async def get_disposition_stats() -> DispositionResponse:
"""
取得告警處置類型分佈統計。
2026-04-07 Claude Code: Sprint 4 C1
2026-04-07 Claude Code: P0-2 Fix — 封裝到 Service 層Router 不直接存取 Redis
包含:
- 總覽: 自動修復/人工審核/手動處理/冷啟動信任 各幾次
@@ -454,61 +452,26 @@ async def get_disposition_stats(
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)
# P0-2 Fix: 呼叫 Service 層封裝方法,不直接存取 Redis
total_summary, by_anomaly_raw = await counter.get_all_disposition_stats()
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)
for item in by_anomaly_raw:
d_total = item["total"]
auto_cnt = item["auto_repair"] + item["cold_start_trust"]
by_anomaly.append(DispositionByAnomaly(
anomaly_key=item["anomaly_key"],
alert_name=item.get("alert_name", ""),
disposition=DispositionSummary(
total=d_total,
auto_repair=item["auto_repair"],
human_approved=item["human_approved"],
manual_resolved=item["manual_resolved"],
cold_start_trust=item["cold_start_trust"],
auto_rate=auto_cnt / d_total if d_total > 0 else 0,
human_rate=item["human_approved"] / d_total if d_total > 0 else 0,
),
))
total = total_summary["total"]
auto_cnt = total_summary["auto_repair"] + total_summary["cold_start_trust"]
@@ -519,7 +482,7 @@ async def get_disposition_stats(
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 筆
by_anomaly=by_anomaly,
)
except Exception as e:

View File

@@ -429,6 +429,69 @@ class AnomalyCounter:
"total": 0,
}
async def get_all_disposition_stats(self) -> tuple[dict, list[dict]]:
"""
聚合所有 anomaly 的處置分佈統計。
2026-04-07 Claude Code: P0-2 Fix — 封裝 Redis SCAN 到 Service 層
Returns:
(summary_dict, by_anomaly_list)
summary_dict: {"auto_repair": N, "human_approved": N, ...}
by_anomaly_list: [{"anomaly_key": str, "alert_name": str, ...}, ...]
"""
total_summary = {
"auto_repair": 0, "human_approved": 0,
"manual_resolved": 0, "cold_start_trust": 0, "total": 0,
}
by_anomaly: list[dict] = []
try:
pattern = f"{self.PREFIX_DISPOSITION}*"
keys: list = []
async for key in self.redis.scan_iter(match=pattern, count=100):
keys.append(key)
for key in keys:
raw = await self.redis.hgetall(key)
key_str = key.decode() if isinstance(key, bytes) else key
anomaly_key = key_str.replace(self.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"{self.PREFIX_METADATA}{anomaly_key}"
meta_raw = await self.redis.hget(meta_key, "signature")
if meta_raw:
sig = json.loads(meta_raw)
alert_name = sig.get("alert_name", "")
except Exception:
pass
if d["total"] > 0:
by_anomaly.append({
"anomaly_key": anomaly_key,
"alert_name": alert_name,
**d,
})
by_anomaly.sort(key=lambda x: x["total"], reverse=True)
except Exception as e:
logger.warning("get_all_disposition_stats_error", error=str(e))
return total_summary, by_anomaly[:20]
async def mark_permanent_fix_applied(
self,
anomaly_key: str,
@@ -625,6 +688,9 @@ class AnomalyCounter:
escalation_level = self._get_escalation_level(count_24h)
# P1 Fix: 填充 disposition 欄位 (與 _record_anomaly_impl 一致)
disposition = await self.get_disposition_stats(anomaly_key)
return AnomalyFrequency(
anomaly_key=anomaly_key,
count_1h=count_1h,
@@ -636,6 +702,10 @@ class AnomalyCounter:
auto_repair_count=auto_repair_count,
permanent_fix_applied=permanent_fix,
escalation_level=escalation_level,
human_approved_count=disposition["human_approved"],
manual_resolved_count=disposition["manual_resolved"],
cold_start_trust_count=disposition["cold_start_trust"],
total_resolution_count=disposition["total"],
)
except Exception as e:
logger.warning("get_frequency_redis_error", error=str(e), anomaly_key=anomaly_key)

View File

@@ -234,11 +234,12 @@ class ApprovalExecutionService:
if not incident or not incident.signals:
return None
# 從第一個 signal 建立 anomaly signature
# P0-1 Fix: namespace 從 signal.labels 取,非 getattr
signal = incident.signals[0]
signature = {
"alert_name": signal.alert_name,
"service": incident.affected_services[0] if incident.affected_services else "",
"namespace": getattr(signal, "namespace", ""),
"namespace": signal.labels.get("namespace", "") if signal.labels else "",
}
from src.services.anomaly_counter import AnomalyCounter
return AnomalyCounter.hash_signature(signature)

View File

@@ -407,13 +407,14 @@ class AutoRepairService:
)
# 2026-04-07 Claude Code: Sprint 4 B1/B2 — 記錄處置類型
# P0-1 Fix: 統一使用 AnomalyCounter.hash_signature()
try:
from src.services.anomaly_counter import get_anomaly_counter
counter = get_anomaly_counter()
symptoms = self._extract_symptoms(incident)
anomaly_key = symptoms.compute_hash()
disposition_type = "cold_start_trust" if is_cold_start else "auto_repair"
await counter.record_disposition(anomaly_key, disposition_type)
anomaly_key = self._derive_anomaly_key(incident)
if anomaly_key:
disposition_type = "cold_start_trust" if is_cold_start else "auto_repair"
await counter.record_disposition(anomaly_key, disposition_type)
except Exception as _disp_e:
logger.warning("disposition_record_failed", error=str(_disp_e))
@@ -481,6 +482,26 @@ class AutoRepairService:
# === Private Helpers ===
@staticmethod
def _derive_anomaly_key(incident: Incident) -> str | None:
"""
從 Incident 推導 anomaly_key統一使用 AnomalyCounter.hash_signature()。
2026-04-07 Claude Code: P0-1 Fix — 統一 hash 演算法
Returns:
anomaly_key or None if not derivable
"""
if not incident.signals:
return None
signal = incident.signals[0]
signature = {
"alert_name": signal.alert_name,
"service": incident.affected_services[0] if incident.affected_services else "",
"namespace": signal.labels.get("namespace", "") if signal.labels else "",
}
from src.services.anomaly_counter import AnomalyCounter
return AnomalyCounter.hash_signature(signature)
def _extract_symptoms(self, incident: Incident) -> SymptomPattern:
"""從 Incident 提取症狀模式"""
alert_names = []

View File

@@ -698,11 +698,12 @@ class IncidentService:
from src.services.anomaly_counter import AnomalyCounter, get_anomaly_counter
counter = get_anomaly_counter()
if incident.signals:
# P0-1 Fix: namespace 從 signal.labels 取
signal = incident.signals[0]
signature = {
"alert_name": signal.alert_name,
"service": incident.affected_services[0] if incident.affected_services else "",
"namespace": getattr(signal, "namespace", ""),
"namespace": signal.labels.get("namespace", "") if signal.labels else "",
}
anomaly_key = AnomalyCounter.hash_signature(signature)
disposition = await counter.get_disposition_stats(anomaly_key)