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