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

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