""" Drift Narrator Service - Phase 30 =================================== 職責:將 DriftReport 轉為繁體中文人話,推送 Telegram 設計邊界: - 只負責「敘述」,不做分析、不生成修復指令 - 觸發條件:high_count > 0 or medium_count > 2 - 模型:qwen2.5:7b-instruct (Ollama 111, 90s timeout) - Redis 快取:drift_narrative:{report_id} TTL 1h,避免重複推送 - HPA replicas 自動調整在白名單,不觸發摘要 版本: v1.0 建立: 2026-04-10 (台北時區) 建立者: Claude Code (Phase 30 ADR-067) """ from __future__ import annotations from typing import TYPE_CHECKING import httpx import structlog from src.core.redis_client import get_redis from src.services.model_registry import get_model if TYPE_CHECKING: from src.models.drift import DriftInterpretation, DriftReport logger = structlog.get_logger(__name__) # ============================================================ # 設定 # ============================================================ OLLAMA_URL = "http://192.168.0.111:11434" # D1 集中化 2026-04-11: 從 models.json providers.ollama.models.drift_summary 讀取 NARRATOR_MODEL = get_model("ollama", "drift_summary") NARRATOR_TIMEOUT = 90.0 # seconds CACHE_TTL = 3600 # 1 小時 CACHE_PREFIX = "drift_narrative:" # HPA 自動調整白名單 field_path,不納入敘述 _HPA_ALLOWLIST_PATHS = { "spec.replicas", } # 觸發條件 TRIGGER_HIGH_MIN = 1 TRIGGER_MEDIUM_MIN = 3 # ============================================================ # Prompt # ============================================================ _NARRATIVE_PROMPT = """你是 AWOOOI SRE 維運助理,請將以下 K8s 配置漂移報告轉為繁體中文人話。 ## 漂移摘要 {drift_summary} ## 意圖分析 {intent_summary} ## 要求 - 繁體中文,5 行以內 - 第 1 行:說明漂移了哪些資源(resource name) - 第 2 行:說明嚴重程度和數量 - 第 3 行:最可能的原因(引用意圖分析) - 第 4 行:建議的運維動作(rollback 或 adopt) - 避免技術術語,用平實口語 - 只輸出摘要文字,不要標題或 markdown """ class DriftNarratorService: """ Drift 報告人話摘要服務 職責邊界: ✅ 呼叫 qwen2.5:7b-instruct 生成繁中摘要 ✅ Redis 快取(避免重複推送) ✅ 推送 Telegram ❌ 不做漂移分析 ❌ 不生成修復指令 """ async def narrate_and_notify( self, report: "DriftReport", interpretation: "DriftInterpretation | None" = None, ) -> None: """ 生成人話摘要並推送 Telegram 只在 high_count > 0 or medium_count >= TRIGGER_MEDIUM_MIN 時執行 """ if not self._should_narrate(report): logger.debug( "drift_narrator_skip", report_id=report.report_id, high=report.high_count, medium=report.medium_count, ) return # Redis 快取檢查(同 report_id 不重複推送) cache_key = f"{CACHE_PREFIX}{report.report_id}" redis = await get_redis() if await redis.exists(cache_key): logger.debug("drift_narrator_cache_hit", report_id=report.report_id) return narrative = await self._generate_narrative(report, interpretation) await self._send_telegram(report, narrative) # 寫入 DB narrative_text (Phase 30 ADR-067) try: from src.repositories.drift_repository import get_drift_repository await get_drift_repository().update_narrative(report.report_id, narrative) except Exception as e: logger.warning("drift_narrator_db_write_failed", error=str(e)) # 寫入快取 await redis.set(cache_key, narrative[:500], ex=CACHE_TTL) logger.info( "drift_narrator_sent", report_id=report.report_id, high=report.high_count, medium=report.medium_count, ) def _should_narrate(self, report: "DriftReport") -> bool: """觸發條件:high >= 1 or medium >= 3""" # 過濾 HPA 白名單後重算 non_hpa_items = [ item for item in report.items if item.field_path not in _HPA_ALLOWLIST_PATHS and not item.is_allowlisted ] high = sum(1 for i in non_hpa_items if i.drift_level.value == "high") medium = sum(1 for i in non_hpa_items if i.drift_level.value == "medium") return high >= TRIGGER_HIGH_MIN or medium >= TRIGGER_MEDIUM_MIN async def _generate_narrative( self, report: "DriftReport", interpretation: "DriftInterpretation | None", ) -> str: """呼叫 Ollama qwen2.5:7b-instruct 生成摘要""" drift_summary = self._format_drift_summary(report) intent_summary = self._format_intent_summary(interpretation) prompt = _NARRATIVE_PROMPT.format( drift_summary=drift_summary, intent_summary=intent_summary, ) try: async with httpx.AsyncClient(timeout=NARRATOR_TIMEOUT) as client: resp = await client.post( f"{OLLAMA_URL}/api/generate", json={ "model": NARRATOR_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.3, "num_predict": 300}, }, ) resp.raise_for_status() data = resp.json() text = data.get("response", "").strip() if text: return text except httpx.TimeoutException: logger.warning("drift_narrator_timeout", model=NARRATOR_MODEL) except Exception as e: logger.warning("drift_narrator_llm_error", error=str(e)) # Fallback:結構化文字摘要 return self._fallback_narrative(report, interpretation) def _format_drift_summary(self, report: "DriftReport") -> str: lines = [] for item in report.items[:8]: if item.is_allowlisted or item.field_path in _HPA_ALLOWLIST_PATHS: continue lines.append( f"- [{item.drift_level.value}] {item.resource_kind}/{item.resource_name}: " f"{item.field_path} " f"(Git: {str(item.git_value)[:30]} → K8s: {str(item.actual_value)[:30]})" ) return "\n".join(lines) if lines else "(均為白名單欄位)" def _format_intent_summary(self, interpretation: "DriftInterpretation | None") -> str: if not interpretation: return "無意圖分析" return ( f"意圖: {interpretation.intent.value} | " f"說明: {interpretation.explanation} | " f"信心: {interpretation.confidence:.0%}" ) def _fallback_narrative( self, report: "DriftReport", interpretation: "DriftInterpretation | None", ) -> str: """LLM 失敗時的結構化 fallback""" resources = list({ f"{i.resource_kind}/{i.resource_name}" for i in report.items[:5] if not i.is_allowlisted }) resource_str = "、".join(resources) if resources else "未知資源" intent_str = interpretation.explanation if interpretation else "意圖分析不可用" return ( f"偵測到 {resource_str} 等資源發生配置漂移。\n" f"嚴重度:HIGH {report.high_count} 項、MEDIUM {report.medium_count} 項。\n" f"研判原因:{intent_str}\n" f"建議:確認是否需要 rollback 回 Git 狀態。" ) async def _send_telegram(self, report: "DriftReport", narrative: str) -> None: """ 推送 TYPE-4D Config Drift 卡片(ADR-075) 使用 send_drift_card() 取代舊 send_notification(),呈現結構化格式與操作按鈕。 diff_summary = AI 研判(narrative) + 漂移詳情(前 8 筆) approval_id / incident_id 均使用 report_id(無需建立 ApprovalRequest) """ from src.services.telegram_gateway import get_telegram_gateway diff_summary = ( f"🤖 AI 研判\n{narrative}\n\n" f"漂移明細(HIGH: {report.high_count} | MEDIUM: {report.medium_count})\n" f"{self._format_drift_summary(report)}" ) try: tg = get_telegram_gateway() await tg.send_drift_card( incident_id=report.report_id, approval_id=report.report_id, resource_name=report.namespace, diff_summary=diff_summary[:500], detected_at="", ) except Exception as e: logger.warning("drift_narrator_telegram_error", error=str(e)) # ============================================================ # Singleton # ============================================================ _narrator: DriftNarratorService | None = None def get_drift_narrator_service() -> DriftNarratorService: global _narrator if _narrator is None: _narrator = DriftNarratorService() return _narrator