Files
awoooi/apps/api/src/services/drift_narrator_service.py
OG T eda0cfd034
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 14m13s
fix(adr075): drift 通知改用 send_drift_card,補齊所有呼叫點
- 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>
2026-04-12 20:20:47 +08:00

266 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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