diff --git a/apps/api/src/services/drift_narrator_service.py b/apps/api/src/services/drift_narrator_service.py index 5db010d4..c0bcef03 100644 --- a/apps/api/src/services/drift_narrator_service.py +++ b/apps/api/src/services/drift_narrator_service.py @@ -53,22 +53,37 @@ TRIGGER_MEDIUM_MIN = 3 # ============================================================ # Prompt # ============================================================ -_NARRATIVE_PROMPT = """你是 AWOOOI SRE 維運助理,請將以下 K8s 配置漂移報告轉為繁體中文人話。 +# 2026-04-18 ogt + Claude Opus 4.7: B 方案 — LLM 驅動智能摘要(取代 Python str()[:30] 截斷) +# 架構鐵律: 捨棄 Python 寫死字串解析,結構化 diff 直接餵 LLM,由 LLM 產出繁中 Top 5 摘要 +_NARRATIVE_PROMPT = """你是 AWOOOI SRE 維運助理。以下是 K8s Config Drift 報告的原始結構化資料。 -## 漂移摘要 -{drift_summary} +## 漂移項目原始資料(JSON) +{drift_items_json} ## 意圖分析 {intent_summary} -## 要求 -- 繁體中文,5 行以內 -- 第 1 行:說明漂移了哪些資源(resource name) -- 第 2 行:說明嚴重程度和數量 -- 第 3 行:最可能的原因(引用意圖分析) -- 第 4 行:建議的運維動作(rollback 或 adopt) -- 避免技術術語,用平實口語 -- 只輸出摘要文字,不要標題或 markdown +## 輸出規格(必須是合法 JSON,不得有任何前後文字) +{{ + "narrative": "4-5 行繁體中文敘述,說明漂移了哪些資源/嚴重程度/可能原因/建議動作", + "items": [ + {{ + "level": "high 或 medium", + "field": "簡化後的欄位路徑 (40 字內)", + "summary": "30 字內繁體中文口語摘要,說明從什麼變成什麼" + }} + ] +}} + +## 規則 +- 繁體中文 +- items 最多挑 5 筆最重要的(HIGH 優先) +- summary 要讓非技術人員看懂「改了什麼」,例如: + - "新增 repair-ssh-key secret 掛載"(而非 repr 一長串) + - "(未設) → awoooi-executor" + - "新增 pod anti-affinity 規則" +- 禁止 markdown、反引號、emoji +- 只輸出純 JSON,不要包在 code block 裡 """ @@ -110,8 +125,9 @@ class DriftNarratorService: 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) + # 2026-04-18 B 方案: LLM 同時產 narrative + 結構化 items(取代 str()[:30]) + narrative, items = await self._generate_narrative_and_items(report, interpretation) + await self._send_telegram(report, narrative, items) # 寫入 DB narrative_text (Phase 30 ADR-067) try: @@ -142,68 +158,115 @@ class DriftNarratorService: 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( + async def _generate_narrative_and_items( self, report: "DriftReport", interpretation: "DriftInterpretation | None", - ) -> str: - """呼叫 Ollama qwen2.5:7b-instruct 生成摘要""" - drift_summary = self._format_drift_summary(report) + ) -> tuple[str, list[dict]]: + """ + 2026-04-18 ogt + Claude Opus 4.7: B 方案 — LLM 產生 narrative + 結構化 items + + 回傳 (narrative, items): + narrative: 繁中 4-5 行敘述 + items: [{level, field, summary}, ...] 最多 5 筆 + + LLM 失敗則 fallback 到 Python 智能截斷(不是 str()[:30] 暴力砍) + """ + import json as _json + + drift_items_json = self._format_drift_for_llm(report) intent_summary = self._format_intent_summary(interpretation) prompt = _NARRATIVE_PROMPT.format( - drift_summary=drift_summary, + drift_items_json=drift_items_json, intent_summary=intent_summary, ) - # 2026-04-17 ogt + Claude Sonnet 4.6: 改用 OpenClaw AI Router 取代直接 Ollama httpx - # 根因:直接呼叫 192.168.0.111:11434 繞過 AI Router,無 fallback → "All connection attempts failed" - # 修復:統一走 openclaw.call(),自動享有 Provider 降級與 fallback 機制 - # 同 drift_interpreter.py 的修法(d952435) try: openclaw = get_openclaw() text, _provider, success = await openclaw.call(prompt) if success and text and text.strip(): - # 2026-04-17 ogt + Claude Sonnet 4.6: 修復 JSON 裸奔問題 - # 根因:openclaw.call() 經 NEMOTRON 路由後強制回傳 JSON(NEMOTRON_SYSTEM_PROMPT 要求) - # 但此處需要純文字敘述 → JSON 被直接吐到 Telegram
區塊
- # 修復:嘗試解析 JSON,優先取 description;否則視為純文字使用
- import json as _json
_raw = text.strip()
+ # 嘗試剝 code fence
+ if _raw.startswith("```"):
+ _raw = _raw.strip("`").lstrip("json").strip()
try:
_parsed = _json.loads(_raw)
if isinstance(_parsed, dict):
- narrative = (
- _parsed.get("description")
- or _parsed.get("action_title")
- or _parsed.get("reasoning")
- or _raw
- )
- return str(narrative).strip()
- except (_json.JSONDecodeError, ValueError):
- pass
- return _raw
+ narrative = str(_parsed.get("narrative", "")).strip()
+ items = _parsed.get("items", [])
+ if isinstance(items, list) and narrative:
+ # 驗證 item 結構
+ clean_items = []
+ for it in items[:5]:
+ if isinstance(it, dict) and it.get("field") and it.get("summary"):
+ clean_items.append({
+ "level": it.get("level", "medium"),
+ "field": str(it["field"])[:60],
+ "summary": str(it["summary"])[:80],
+ })
+ if clean_items:
+ return narrative, clean_items
+ except (_json.JSONDecodeError, ValueError) as e:
+ logger.warning("drift_narrator_json_parse_fail", err=str(e), raw_prefix=_raw[:80])
logger.warning("drift_narrator_openclaw_failed", provider=_provider)
except Exception as e:
logger.warning("drift_narrator_llm_error", error=str(e))
- # Fallback:結構化文字摘要
- return self._fallback_narrative(report, interpretation)
+ # Fallback:Python 智能截斷(不是 str()[:30])
+ return self._fallback_narrative(report, interpretation), self._fallback_items(report)
- def _format_drift_summary(self, report: "DriftReport") -> str:
- lines = []
- for item in report.items[:8]:
+ def _format_drift_for_llm(self, report: "DriftReport") -> str:
+ """
+ 2026-04-18 ogt + Claude Opus 4.7: B 方案 — 餵 LLM 用的 JSON 序列化
+ 保留更多原始 context 給 LLM 推理,不做 30 字元暴力截斷
+ """
+ import json as _json
+ items_for_llm = []
+ for item in report.items[:12]:
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 "(均為白名單欄位)"
+ items_for_llm.append({
+ "level": item.drift_level.value,
+ "resource": f"{item.resource_kind}/{item.resource_name}",
+ "field": item.field_path,
+ "git_value": str(item.git_value)[:200] if item.git_value is not None else None,
+ "actual_value": str(item.actual_value)[:200] if item.actual_value is not None else None,
+ })
+ return _json.dumps(items_for_llm, ensure_ascii=False, indent=2)
+
+ def _smart_shorten(self, val) -> str:
+ """型別安全摘要 — dict/list 顯示大小,字串保留頭尾,None 轉「未設」"""
+ if val is None:
+ return "(未設)"
+ s = str(val)
+ # 嘗試判斷是不是 JSON 字串
+ if s.startswith("[") and s.endswith("]"):
+ return f"[清單 {s.count(',')+1 if s != '[]' else 0} 項]"
+ if s.startswith("{") and s.endswith("}"):
+ # 粗估欄位數
+ return f"{{物件 {s.count(':')} 欄位}}"
+ if len(s) > 40:
+ return s[:37] + "..."
+ return s
+
+ def _fallback_items(self, report: "DriftReport") -> list[dict]:
+ """LLM 失敗時的 Python 智能摘要(取代舊 str()[:30])"""
+ items = []
+ for item in report.items[:5]:
+ if item.is_allowlisted or item.field_path in _HPA_ALLOWLIST_PATHS:
+ continue
+ from_val = self._smart_shorten(item.git_value)
+ to_val = self._smart_shorten(item.actual_value)
+ items.append({
+ "level": item.drift_level.value,
+ "field": item.field_path[:60],
+ "summary": f"{from_val} → {to_val}",
+ })
+ return items
def _format_intent_summary(self, interpretation: "DriftInterpretation | None") -> str:
if not interpretation:
@@ -234,21 +297,21 @@ class DriftNarratorService:
f"建議:確認是否需要 rollback 回 Git 狀態。"
)
- async def _send_telegram(self, report: "DriftReport", narrative: str) -> None:
+ async def _send_telegram(
+ self,
+ report: "DriftReport",
+ narrative: str,
+ items: list[dict],
+ ) -> None:
"""
- 推送 TYPE-4D Config Drift 卡片(ADR-075)
+ 推送 TYPE-4D Config Drift 卡片(ADR-075)+ B 方案智能摘要
- 使用 send_drift_card() 取代舊 send_notification(),呈現結構化格式與操作按鈕。
- diff_summary = AI 研判(narrative) + 漂移詳情(前 8 筆)
- approval_id / incident_id 均使用 report_id(無需建立 ApprovalRequest)
+ 2026-04-18 ogt + Claude Opus 4.7: 改用 LLM 產的結構化 items,
+ 取代 str()[:30] 暴力截斷產生的亂碼
"""
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)}"
- )
+ diff_summary = self._render_telegram_body(report, narrative, items)
try:
tg = get_telegram_gateway()
@@ -262,6 +325,38 @@ class DriftNarratorService:
except Exception as e:
logger.warning("drift_narrator_telegram_error", error=str(e))
+ def _render_telegram_body(
+ self,
+ report: "DriftReport",
+ narrative: str,
+ items: list[dict],
+ ) -> str:
+ """
+ 組裝 Telegram 卡片 body(B 方案格式)
+
+ 範例輸出:
+ 🤖 AI 研判
+ volumes 與 affinity 被手動修改...
+
+ 📊 漂移明細 (HIGH: 1 | MEDIUM: 29)
+ 🔴 spec.template.spec.volumes: 新增 2 項 repair-ssh-key 掛載
+ 🟡 spec.template.spec.serviceAccount: (未設) → awoooi-executor
+ 🟡 spec.template.spec.affinity.podAntiAffinity: 新增 preferred 規則
+ ... 還有 27 項
+ """
+ lines = [f"🤖 AI 研判\n{narrative}\n"]
+ lines.append(f"📊 漂移明細 (HIGH: {report.high_count} | MEDIUM: {report.medium_count})")
+ for it in items:
+ emoji = "🔴" if it.get("level") == "high" else "🟡"
+ lines.append(f"{emoji} {it['field']}: {it['summary']}")
+
+ total = report.high_count + report.medium_count
+ shown = len(items)
+ if total > shown:
+ lines.append(f"... 還有 {total - shown} 項 (按 🔍 查看 Diff)")
+
+ return "\n".join(lines)
+
# ============================================================
# Singleton