From 2ef54ccc9462c5fb1f74ca4f5997fe9564c9418f Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 6 May 2026 19:26:09 +0800 Subject: [PATCH] fix(ai): enforce ollama first for drift governance --- apps/api/src/services/ai_router.py | 5 ++ apps/api/src/services/drift_interpreter.py | 18 +++++-- apps/api/src/services/openclaw.py | 19 ++++++-- .../test_drift_interpreter_ollama_first.py | 42 ++++++++++++++++ ...test_openclaw_alert_cloud_fallback_gate.py | 34 +++++++++++++ docs/LOGBOOK.md | 48 +++++++++++++++++++ 6 files changed, 157 insertions(+), 9 deletions(-) create mode 100644 apps/api/tests/test_drift_interpreter_ollama_first.py diff --git a/apps/api/src/services/ai_router.py b/apps/api/src/services/ai_router.py index 3e27dd68..ce9594cb 100644 --- a/apps/api/src/services/ai_router.py +++ b/apps/api/src/services/ai_router.py @@ -1090,6 +1090,11 @@ class AIRouterExecutor: ) and bool(provider_order) and provider_order[0].startswith("ollama") + ) or ( + bool(context) + and bool(context.get("enforce_ollama_first")) + and bool(provider_order) + and provider_order[0].startswith("ollama") ) if ( cached_provider == "ollama" diff --git a/apps/api/src/services/drift_interpreter.py b/apps/api/src/services/drift_interpreter.py index ee88d30b..0ca8ef58 100644 --- a/apps/api/src/services/drift_interpreter.py +++ b/apps/api/src/services/drift_interpreter.py @@ -21,7 +21,7 @@ from typing import TYPE_CHECKING import structlog -from src.models.drift import DriftIntent, DriftInterpretation, DriftItem +from src.models.drift import DriftIntent, DriftInterpretation if TYPE_CHECKING: from src.models.drift import DriftReport @@ -62,7 +62,7 @@ class NemotronDriftInterpreter: ❌ 不直接呼叫 kubectl 或 git """ - async def analyze(self, report: "DriftReport") -> DriftInterpretation: + async def analyze(self, report: DriftReport) -> DriftInterpretation: """ 分析漂移意圖 @@ -85,7 +85,7 @@ class NemotronDriftInterpreter: result = await self._call_nemotron(prompt) return result - def _format_diff_for_prompt(self, report: "DriftReport") -> str: + def _format_diff_for_prompt(self, report: DriftReport) -> str: """格式化 diff 給 Nemotron 分析用""" lines = [] for item in report.items[:10]: # 最多 10 項避免 token 過多 @@ -111,7 +111,17 @@ class NemotronDriftInterpreter: try: from src.services.openclaw import get_openclaw openclaw = get_openclaw() - response_text, _provider, success = await openclaw.call(prompt) + response_text, _provider, success = await openclaw.call( + prompt, + alert_context={ + "intent_hint": "config", + "task_type": "diagnose", + "enforce_ollama_first": True, + "allow_gcp_heavy_model": True, + "target_resource": "config-drift", + "alert_type": "ConfigDriftInternalScan", + }, + ) if not success or not response_text: logger.warning("drift_interpreter_openclaw_failed", provider=_provider) diff --git a/apps/api/src/services/openclaw.py b/apps/api/src/services/openclaw.py index 946a6550..35d9eba4 100644 --- a/apps/api/src/services/openclaw.py +++ b/apps/api/src/services/openclaw.py @@ -194,14 +194,20 @@ class OpenClawService: def _cloud_fallback_allowed_for_alert(self, alert_context: dict | None) -> bool: """Cloud fallback is allowed after the ordered Ollama lane for alerts.""" + if alert_context and alert_context.get("allow_cloud_fallback") is False: + return False if not self._is_incident_alert_context(alert_context): return True return bool(getattr(settings, "ALERT_AI_ALLOW_CLOUD_FALLBACK", True)) def _alert_enforces_ollama_first(self, alert_context: dict | None) -> bool: - """Alert cards must try GCP-A/GCP-B/111 before Gemini backup.""" + """Alert and AI-governance lanes must try GCP-A/GCP-B/111 before Gemini backup.""" return ( - self._is_incident_alert_context(alert_context) + bool(alert_context) + and ( + bool(alert_context.get("enforce_ollama_first")) + or self._is_incident_alert_context(alert_context) + ) and bool(getattr(settings, "ALERT_AI_ENFORCE_OLLAMA_FIRST", True)) ) @@ -1144,9 +1150,12 @@ class OpenClawService: exec_context = dict(alert_context) if alert_context else {} if decision.intent == IntentType.DIAGNOSE: exec_context["task_type"] = "diagnose" - if self._is_incident_alert_context(alert_context): - exec_context["ollama_model"] = getattr(settings, "ALERT_OLLAMA_MODEL", "qwen3:14b") - exec_context["allow_gcp_heavy_model"] = True + if self._alert_enforces_ollama_first(alert_context): + exec_context.setdefault("task_type", "diagnose") + exec_context.setdefault("ollama_model", getattr(settings, "ALERT_OLLAMA_MODEL", "qwen3:14b")) + exec_context["allow_gcp_heavy_model"] = bool( + exec_context.get("allow_gcp_heavy_model", True) + ) exec_context["alert_requires_ollama_before_cloud"] = True result = await executor.execute( diff --git a/apps/api/tests/test_drift_interpreter_ollama_first.py b/apps/api/tests/test_drift_interpreter_ollama_first.py new file mode 100644 index 00000000..bf645701 --- /dev/null +++ b/apps/api/tests/test_drift_interpreter_ollama_first.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from src.models.drift import DriftIntent +from src.services import openclaw as openclaw_module +from src.services.drift_interpreter import NemotronDriftInterpreter + + +class _FakeOpenClaw: + def __init__(self) -> None: + self.alert_context: dict[str, Any] | None = None + + async def call( + self, + prompt: str, + alert_context: dict[str, Any] | None = None, + ) -> tuple[str, str, bool]: + self.alert_context = alert_context + return ( + '{"intent":"automated_change","explanation":"HPA 自動調整","risk":"LOW","confidence":0.8}', + "ollama_gcp_a", + True, + ) + + +@pytest.mark.asyncio +async def test_drift_interpreter_declares_ollama_first_governance_lane( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_openclaw = _FakeOpenClaw() + monkeypatch.setattr(openclaw_module, "get_openclaw", lambda: fake_openclaw) + + result = await NemotronDriftInterpreter()._call_nemotron("請分析漂移") + + assert result.intent == DriftIntent.AUTOMATED_CHANGE + assert fake_openclaw.alert_context is not None + assert fake_openclaw.alert_context["enforce_ollama_first"] is True + assert fake_openclaw.alert_context["task_type"] == "diagnose" + assert fake_openclaw.alert_context["allow_gcp_heavy_model"] is True diff --git a/apps/api/tests/test_openclaw_alert_cloud_fallback_gate.py b/apps/api/tests/test_openclaw_alert_cloud_fallback_gate.py index af3462ef..977da1f4 100644 --- a/apps/api/tests/test_openclaw_alert_cloud_fallback_gate.py +++ b/apps/api/tests/test_openclaw_alert_cloud_fallback_gate.py @@ -177,6 +177,40 @@ async def test_non_alert_context_keeps_router_cloud_order( assert fake_executor.provider_order == ["gemini", "claude", "ollama"] +@pytest.mark.asyncio +async def test_explicit_ai_governance_context_uses_ollama_first( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_executor = _FakeExecutor() + fake_failover = _FakeFailoverManager() + + monkeypatch.setattr(openclaw_module.settings, "USE_AI_ROUTER", True) + monkeypatch.setattr(openclaw_module.settings, "MOCK_MODE", False) + monkeypatch.setattr(openclaw_module.settings, "ALERT_AI_ALLOW_CLOUD_FALLBACK", True) + monkeypatch.setattr(openclaw_module.settings, "ALERT_AI_ENFORCE_OLLAMA_FIRST", True) + monkeypatch.setattr(ai_control_module, "get_ai_router_enabled", AsyncMock(return_value=None)) + monkeypatch.setattr(ai_control_module, "get_primary_provider", AsyncMock(return_value=None)) + monkeypatch.setattr(ai_control_module, "is_provider_disabled", AsyncMock(return_value=False)) + monkeypatch.setattr(ai_router_module, "get_ai_router", lambda: _FakeRouter()) + monkeypatch.setattr(ai_router_module, "get_ai_executor", lambda: fake_executor) + monkeypatch.setattr(openclaw_module, "get_ollama_failover_manager", lambda: fake_failover) + + service = object.__new__(OpenClawService) + await service._call_with_fallback( + "analyze config drift", + alert_context={ + "intent_hint": "config", + "task_type": "diagnose", + "enforce_ollama_first": True, + "allow_gcp_heavy_model": True, + "target_resource": "config-drift", + }, + ) + + assert fake_executor.provider_order == ["ollama_gcp_a", "ollama_gcp_b", "ollama_local", "gemini"] + assert fake_failover.task_types == ["diagnose"] + + @pytest.mark.asyncio async def test_alert_context_uses_gcp_a_gcp_b_then_111_order( monkeypatch: pytest.MonkeyPatch, diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index a51b7822..a96b6344 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -4014,3 +4014,51 @@ py_compile apps/api/src/services/heartbeat_report_service.py apps/api/tests/test ruff check --select F401,F821,I001 apps/api/src/services/heartbeat_report_service.py apps/api/tests/test_heartbeat_ollama_endpoints.py # All checks passed ``` + +--- + +## 2026-05-06(台北)— Drift AI 治理路徑改為 Ollama-first + +**觸發**:production 仍出現 `provider=gemini` 的成功呼叫,路徑為 `/api/v1/drift/internal/scan`,不是 Telegram 告警卡片。追查後確認 Redis AI Control 仍停用 `openclaw_nemo`;OpenClaw `/health` 雖正常,但直接呼叫 `/api/v1/analyze/incident` 回傳 `openclaw_degraded`,原因為下游 LLM 輸出非法 JSON escape(`JSONDecodeError: Invalid \\uXXXX escape`),因此不能直接重新啟用。 + +### 已修正 + +| 範圍 | 結果 | +|------|------| +| `openclaw.py` | 新增顯式 `enforce_ollama_first` 治理旗標;除了 Telegram/incident alert,AI governance 任務也能強制 GCP-A → GCP-B → 111 → Gemini 備援 | +| `ai_router.py` | cache gate 同步辨識 `enforce_ollama_first`,避免 Ollama-first 任務誤用舊 Gemini cache | +| `drift_interpreter.py` | Config drift intent 分析呼叫 OpenClaw 時帶上 `enforce_ollama_first`、`task_type=diagnose`、`allow_gcp_heavy_model=true`,避免 drift/governance 掃描直接走 Gemini | +| 測試 | 新增 drift interpreter 回歸測試,並擴充 OpenClaw provider-order 測試 | + +### Live 判斷 + +| 檢查點 | 結果 | +|--------|------| +| GCP-A / GCP-B / 111 | API Pod 目前都可探測成功;心跳報告可分別顯示三端點 | +| 188 Ollama | 已退場,不再作為 AWOOOI Ollama provider | +| `openclaw_nemo` | 仍不可重新啟用,因實際 analyze 端點回 degraded;需另修 OpenClaw 服務端 JSON 修復/結構化輸出 | +| Gemini | 保留為最後備援,不禁用;但 drift/governance 現在會先走 Ollama 三段式 | + +### 驗證 + +```text +DATABASE_URL='postgresql+asyncpg://test:test@localhost:5432/test' pytest \ + apps/api/tests/test_openclaw_alert_cloud_fallback_gate.py \ + apps/api/tests/test_drift_interpreter_ollama_first.py +# 8 passed + +ruff check \ + apps/api/src/services/openclaw.py \ + apps/api/src/services/drift_interpreter.py \ + apps/api/tests/test_openclaw_alert_cloud_fallback_gate.py \ + apps/api/tests/test_drift_interpreter_ollama_first.py +# All checks passed + +py_compile \ + apps/api/src/services/openclaw.py \ + apps/api/src/services/ai_router.py \ + apps/api/src/services/drift_interpreter.py \ + apps/api/tests/test_openclaw_alert_cloud_fallback_gate.py \ + apps/api/tests/test_drift_interpreter_ollama_first.py +# 通過 +```