""" AI Advisory Helpers — Leader Lock + Snooze + Inline Keyboard (ADR-092 升級) ============================================================================= 統帥 2026-04-19 反饋: - 多 Pod 重複推 Telegram (leader 機制缺) - 純文字無按鈕 (feedback 無閉環) - AI 只「建議」不「執行」(view_detail 缺) 本 helper 提供 4 LLM scanner 共用: 1. daily_job_leader_lock: Redis SETNX,只 leader Pod 跑 daily job (避免 replica 重複) 2. is_snoozed: 檢查是否被 snooze 24h (避免同 host/rule 重複告警) 3. build_ai_advisory_keyboard: 統一 4 按鈕 (已處理/忽略 24h/查看詳情/產指令) callback_data 格式 (與 telegram_gateway 對接): 'ai_advisory_{action}:{advisory_type}:{advisory_id}' action: handled / snooze / view / produce_cmd advisory_type: capacity_forecast / compliance_posture / rule_quality / coverage_gap advisory_id: aol.op_id (UUID) or 組合 key (host@date) 2026-04-19 ogt + Claude Opus 4.7 (1M context) Asia/Taipei ADR-092 § AI Decision LLM 擴展層 P0 修復 """ from __future__ import annotations from datetime import date as _date from typing import Any import structlog logger = structlog.get_logger(__name__) # ============================================================================ # Leader Lock — 多 Pod 場景只 leader 跑 daily job # ============================================================================ async def try_acquire_daily_lock(job_name: str) -> bool: """ 用 Redis SETNX 搶當日 lock. 只搶到的 Pod 跑 daily job. key: aiops:daily_lock:{job_name}:{YYYY-MM-DD-Taipei} TTL: 25h (覆蓋 daily tick + 時差保險) Returns: True — 搶到 lock (此 Pod 跑) False — 其他 Pod 已搶 (此 Pod skip) 失敗安全: Redis 掛 → 視為搶到 (fail-open,不阻塞主流程,可能造成一次多 Pod 推送,但好過不推) """ try: from src.core.redis_client import get_redis from src.utils.timezone import now_taipei today = now_taipei().date().isoformat() key = f"aiops:daily_lock:{job_name}:{today}" redis = await get_redis() # NX: 只有不存在才設. EX 25h. 回 True/None acquired = await redis.set(key, "1", nx=True, ex=25 * 3600) if acquired: logger.info("daily_lock_acquired", job=job_name, date=today, key=key) return True logger.info("daily_lock_skipped_other_pod", job=job_name, date=today) return False except Exception as e: logger.warning("daily_lock_redis_failed_fail_open", job=job_name, error=str(e)) return True # fail-open async def try_acquire_hourly_lock(job_name: str) -> bool: """ 類似 daily 但 hourly granularity. 適用於 coverage_evaluator 等每 1h 跑的 scanner. key: aiops:hourly_lock:{job_name}:{YYYY-MM-DD-HH-Taipei} TTL: 75 分鐘 (覆蓋 hourly tick + 緩衝) """ try: from src.core.redis_client import get_redis from src.utils.timezone import now_taipei now = now_taipei() slot = f"{now.date().isoformat()}-{now.hour:02d}" key = f"aiops:hourly_lock:{job_name}:{slot}" redis = await get_redis() acquired = await redis.set(key, "1", nx=True, ex=75 * 60) if acquired: logger.info("hourly_lock_acquired", job=job_name, slot=slot) return True logger.info("hourly_lock_skipped_other_pod", job=job_name, slot=slot) return False except Exception as e: logger.warning("hourly_lock_redis_failed_fail_open", job=job_name, error=str(e)) return True # ============================================================================ # Snooze — 按「忽略 24h」後避免重複告警 # ============================================================================ async def is_snoozed(advisory_type: str, target: str) -> bool: """ 檢查 advisory 是否被 snooze (人工按「忽略 24h」後). key: aiops:snooze:{advisory_type}:{target} target: host IP / rule_name / worst_dimension 等 Returns: True — 被 snooze,應跳過 Telegram 推送 False — 未 snooze 或 Redis 失敗 """ try: from src.core.redis_client import get_redis redis = await get_redis() key = f"aiops:snooze:{advisory_type}:{target}" val = await redis.get(key) return val is not None except Exception as e: logger.warning("snooze_check_failed", type=advisory_type, target=target, error=str(e)) return False async def set_snooze(advisory_type: str, target: str, hours: int = 24) -> None: """設 snooze TTL — 供 callback handler 呼叫.""" try: from src.core.redis_client import get_redis redis = await get_redis() key = f"aiops:snooze:{advisory_type}:{target}" await redis.set(key, "1", ex=hours * 3600) logger.info("snooze_set", type=advisory_type, target=target, hours=hours) except Exception as e: logger.warning("snooze_set_failed", type=advisory_type, target=target, error=str(e)) # ============================================================================ # Inline Keyboard — 統一 AI advisory 按鈕格式 # ============================================================================ def build_ai_advisory_keyboard( advisory_type: str, advisory_id: str, include_view: bool = True, include_produce_cmd: bool = False, ) -> dict[str, Any]: """ 建 AI advisory 訊息的 inline_keyboard. callback_data 格式: 'ai_advisory_{action}:{advisory_type}:{advisory_id}' Args: advisory_type: capacity_forecast / compliance_posture / rule_quality / coverage_gap advisory_id: aol.op_id 或組合 key include_view: 是否含「🔍 查看詳情」按鈕 (P1,目前多數 disabled) include_produce_cmd: 是否含「📋 產指令」按鈕 (P1,需 LLM 產 kubectl command) Returns: dict: {"inline_keyboard": [[...]]} 可直接傳給 Telegram sendMessage reply_markup """ cb = lambda action: f"ai_advisory_{action}:{advisory_type}:{advisory_id}" # noqa: E731 row1 = [ {"text": "✅ 已處理", "callback_data": cb("handled")}, {"text": "😴 忽略 24h", "callback_data": cb("snooze")}, ] row2 = [] if include_view: row2.append({"text": "🔍 查看詳情", "callback_data": cb("view")}) if include_produce_cmd: row2.append({"text": "📋 產 kubectl 指令", "callback_data": cb("produce_cmd")}) keyboard = {"inline_keyboard": [row1]} if row2: keyboard["inline_keyboard"].append(row2) return keyboard # ============================================================================ # Callback Handler — 處理「已處理」/「忽略 24h」按鈕 # ============================================================================ async def handle_ai_advisory_callback( action: str, advisory_type: str, advisory_id: str, username: str, ) -> dict[str, Any]: """ 處理 ai_advisory_* callback. Args: action: handled / snooze / view / produce_cmd advisory_type: 4 種 advisory 類型 advisory_id: 對應 aol.op_id 或組合 key Returns: {success: bool, feedback_text: str (給 Telegram answer_callback 顯示)} 邏輯: - handled: UPDATE aol.output jsonb += {human_feedback, by, at} - snooze: set_snooze(24h) + UPDATE aol - view: (P1 TODO — 未來 LLM 產詳情) - produce_cmd: (P1 TODO — 未來 LLM 產 kubectl command) """ from datetime import datetime from src.utils.timezone import now_taipei feedback_text = "" success = False if action == "handled": success = await _write_human_feedback(advisory_id, "handled", username) feedback_text = f"✅ 已記錄「已處理」by {username}" elif action == "snooze": # advisory_id 本身就是 target (host / rule_name 等) await set_snooze(advisory_type, advisory_id, hours=24) success = await _write_human_feedback(advisory_id, "snoozed_24h", username) feedback_text = "😴 已忽略 24 小時" elif action == "view": feedback_text = "🔍 詳情功能下階段實作" # P1 elif action == "produce_cmd": feedback_text = "📋 產指令功能下階段實作" # P1 else: feedback_text = f"❓ 未知 action: {action}" return {"success": success, "feedback_text": feedback_text} async def _write_human_feedback(advisory_id: str, feedback: str, username: str) -> bool: """UPDATE aol.output.human_feedback 供 AI 學習.""" try: from sqlalchemy import text as _sql from src.db.base import get_db_context from src.utils.timezone import now_taipei async with get_db_context() as db: await db.execute( _sql(""" UPDATE automation_operation_log SET output = output || jsonb_build_object( 'human_feedback', :fb, 'human_feedback_by', :who, 'human_feedback_at', :at ) WHERE op_id::text = :aid OR (input::jsonb->>'advisory_id' = :aid) OR (output::jsonb->>'host' = :aid) OR (output::jsonb->>'rule_name' = :aid) """), { "fb": feedback, "who": username[:50], "at": now_taipei().isoformat(), "aid": advisory_id, }, ) return True except Exception as e: logger.warning("write_human_feedback_failed", advisory_id=advisory_id, error=str(e)) return False