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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user