All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 14m13s
- drift.py: 移除死碼 send_text(),改由 narrate_and_notify() 統一發卡片 - drift_narrator_service: _send_telegram() 改呼 send_drift_card() 帶四顆按鈕 - webhooks.py /alerts 路徑: 補傳 alert_category 啟用動態按鈕 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
266 lines
9.1 KiB
Python
266 lines
9.1 KiB
Python
"""
|
||
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
|