diff --git a/apps/api/src/services/decision_manager.py b/apps/api/src/services/decision_manager.py index 584aa822..8a2e997f 100644 --- a/apps/api/src/services/decision_manager.py +++ b/apps/api/src/services/decision_manager.py @@ -240,6 +240,17 @@ async def _push_decision_to_telegram( # 非同步執行,不阻塞主流程 asyncio.create_task(_send_log_summary(incident)) + # MCP Phase 4a: NemoClaw second opinion (2026-04-11 Claude Sonnet 4.6) + # 若 proposal_data 有 advisory_note,用 NemoClaw bot 身分追加一條訊息 + _advisory_note = proposal_data.get("advisory_note", "") + if _advisory_note: + asyncio.create_task( + gateway.send_as_nemotron( + f"🤔 NemoClaw 第二意見 (信心={confidence:.2f})\n" + f"{_advisory_note}" + ) + ) + # 🔴 發送成功後設置去重 key (TTL 10 分鐘) await redis.setex(dedup_key, 600, "1") @@ -259,6 +270,62 @@ async def _push_decision_to_telegram( ) +async def _nemoclaw_second_opinion(incident: "Incident", primary_result: dict) -> str | None: + """ + MCP Phase 4a: NemoClaw second opinion — 信心 < 0.7 時觸發 + ============================================================ + 用 deepseek-r1:14b (Ollama 188) 對同一份資料做獨立推理, + 輸出純文字 advisory_note,不執行任何操作。 + + 2026-04-11 Claude Sonnet 4.6 Asia/Taipei + """ + try: + from src.core.config import settings + import httpx as _httpx + + ollama_url = getattr(settings, "OLLAMA_URL", "http://192.168.0.188:11434") + model = "deepseek-r1:14b" + + signals_summary = "" + if incident.signals: + lbl = incident.signals[0].labels + signals_summary = ( + f"alertname={lbl.get('alertname','?')} " + f"severity={lbl.get('severity','?')} " + f"instance={lbl.get('instance','?')}" + ) + + prompt = ( + f"你是資深 SRE,請對以下告警做獨立分析並提出建議。\n" + f"告警摘要: {signals_summary}\n" + f"受影響服務: {', '.join(incident.affected_services or [])}\n" + f"主 AI 已提出: {primary_result.get('action','?')} " + f"(信心={primary_result.get('confidence',0):.2f})\n" + f"主 AI 根因: {primary_result.get('reasoning','')[:200]}\n\n" + f"請用繁體中文,100 字以內,給出你的獨立判斷與建議(若同意主 AI 則說明原因)。" + ) + + async with _httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + f"{ollama_url}/api/generate", + json={"model": model, "prompt": prompt, "stream": False}, + ) + resp.raise_for_status() + data = resp.json() + + advisory = data.get("response", "").strip() + # 截取 ... 後的正文(deepseek-r1 CoT 格式) + if "" in advisory: + advisory = advisory.split("", 1)[-1].strip() + + return advisory[:300] if advisory else None + + except Exception as e: + import structlog as _sl + _sl.get_logger(__name__).debug("nemoclaw_second_opinion_error", error=str(e)) + return None + + async def _fetch_metrics_snapshot(incident: Incident) -> dict: """ ADR-071-I: 從 Prometheus 抓取與此 incident 相關的指標快照 @@ -993,10 +1060,25 @@ class DecisionManager: provider=provider, kb_rag=bool(kb_context), ) - return { - **llm_result, - "source": f"llm_{provider}", - } + result = {**llm_result, "source": f"llm_{provider}"} + + # MCP Phase 4a: 信心 < 0.7 → NemoClaw second opinion (2026-04-11 Claude Sonnet 4.6) + _conf = float(result.get("confidence", 1.0)) + if _conf < 0.7: + try: + _advisory = await _nemoclaw_second_opinion(incident, result) + if _advisory: + result["advisory_note"] = _advisory + logger.info( + "nemoclaw_second_opinion_added", + incident_id=incident.incident_id, + confidence=_conf, + ) + except Exception as _soe: + logger.warning("nemoclaw_second_opinion_failed", + incident_id=incident.incident_id, error=str(_soe)) + + return result except Exception as e: logger.warning(