From 561bcb638bdbe1037383fa34e75f63a60604113f Mon Sep 17 00:00:00 2001 From: OG T Date: Tue, 7 Apr 2026 12:53:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(api):=20Sprint=204=20=E9=A6=96=E5=B8=AD?= =?UTF-8?q?=E6=9E=B6=E6=A7=8B=E5=B8=AB=20Review=20P0=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=20=E2=80=94=20hash=20=E7=B5=B1=E4=B8=80=20+=20=E7=A9=8D?= =?UTF-8?q?=E6=9C=A8=E5=8C=96=E5=90=88=E8=A6=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/api/v1/auto_repair.py | 7 +- apps/api/src/api/v1/stats.py | 79 ++++++-------------- apps/api/src/services/anomaly_counter.py | 70 +++++++++++++++++ apps/api/src/services/approval_execution.py | 3 +- apps/api/src/services/auto_repair_service.py | 29 ++++++- apps/api/src/services/incident_service.py | 3 +- 6 files changed, 122 insertions(+), 69 deletions(-) diff --git a/apps/api/src/api/v1/auto_repair.py b/apps/api/src/api/v1/auto_repair.py index 005cd27a2..926dcfb05 100644 --- a/apps/api/src/api/v1/auto_repair.py +++ b/apps/api/src/api/v1/auto_repair.py @@ -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 diff --git a/apps/api/src/api/v1/stats.py b/apps/api/src/api/v1/stats.py index 330e9bddb..e77f73e40 100644 --- a/apps/api/src/api/v1/stats.py +++ b/apps/api/src/api/v1/stats.py @@ -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: diff --git a/apps/api/src/services/anomaly_counter.py b/apps/api/src/services/anomaly_counter.py index df6380740..259298d73 100644 --- a/apps/api/src/services/anomaly_counter.py +++ b/apps/api/src/services/anomaly_counter.py @@ -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) diff --git a/apps/api/src/services/approval_execution.py b/apps/api/src/services/approval_execution.py index b1780e67a..c47195faa 100644 --- a/apps/api/src/services/approval_execution.py +++ b/apps/api/src/services/approval_execution.py @@ -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) diff --git a/apps/api/src/services/auto_repair_service.py b/apps/api/src/services/auto_repair_service.py index 6d59db0a0..61338b846 100644 --- a/apps/api/src/services/auto_repair_service.py +++ b/apps/api/src/services/auto_repair_service.py @@ -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 = [] diff --git a/apps/api/src/services/incident_service.py b/apps/api/src/services/incident_service.py index 9c357524a..106bc4e2e 100644 --- a/apps/api/src/services/incident_service.py +++ b/apps/api/src/services/incident_service.py @@ -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)