fix(ai): enforce ollama first for drift governance
This commit is contained in:
@@ -1090,6 +1090,11 @@ class AIRouterExecutor:
|
|||||||
)
|
)
|
||||||
and bool(provider_order)
|
and bool(provider_order)
|
||||||
and provider_order[0].startswith("ollama")
|
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 (
|
if (
|
||||||
cached_provider == "ollama"
|
cached_provider == "ollama"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from src.models.drift import DriftIntent, DriftInterpretation, DriftItem
|
from src.models.drift import DriftIntent, DriftInterpretation
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.models.drift import DriftReport
|
from src.models.drift import DriftReport
|
||||||
@@ -62,7 +62,7 @@ class NemotronDriftInterpreter:
|
|||||||
❌ 不直接呼叫 kubectl 或 git
|
❌ 不直接呼叫 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)
|
result = await self._call_nemotron(prompt)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _format_diff_for_prompt(self, report: "DriftReport") -> str:
|
def _format_diff_for_prompt(self, report: DriftReport) -> str:
|
||||||
"""格式化 diff 給 Nemotron 分析用"""
|
"""格式化 diff 給 Nemotron 分析用"""
|
||||||
lines = []
|
lines = []
|
||||||
for item in report.items[:10]: # 最多 10 項避免 token 過多
|
for item in report.items[:10]: # 最多 10 項避免 token 過多
|
||||||
@@ -111,7 +111,17 @@ class NemotronDriftInterpreter:
|
|||||||
try:
|
try:
|
||||||
from src.services.openclaw import get_openclaw
|
from src.services.openclaw import get_openclaw
|
||||||
openclaw = 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:
|
if not success or not response_text:
|
||||||
logger.warning("drift_interpreter_openclaw_failed", provider=_provider)
|
logger.warning("drift_interpreter_openclaw_failed", provider=_provider)
|
||||||
|
|||||||
@@ -194,14 +194,20 @@ class OpenClawService:
|
|||||||
|
|
||||||
def _cloud_fallback_allowed_for_alert(self, alert_context: dict | None) -> bool:
|
def _cloud_fallback_allowed_for_alert(self, alert_context: dict | None) -> bool:
|
||||||
"""Cloud fallback is allowed after the ordered Ollama lane for alerts."""
|
"""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):
|
if not self._is_incident_alert_context(alert_context):
|
||||||
return True
|
return True
|
||||||
return bool(getattr(settings, "ALERT_AI_ALLOW_CLOUD_FALLBACK", True))
|
return bool(getattr(settings, "ALERT_AI_ALLOW_CLOUD_FALLBACK", True))
|
||||||
|
|
||||||
def _alert_enforces_ollama_first(self, alert_context: dict | None) -> bool:
|
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 (
|
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))
|
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 {}
|
exec_context = dict(alert_context) if alert_context else {}
|
||||||
if decision.intent == IntentType.DIAGNOSE:
|
if decision.intent == IntentType.DIAGNOSE:
|
||||||
exec_context["task_type"] = "diagnose"
|
exec_context["task_type"] = "diagnose"
|
||||||
if self._is_incident_alert_context(alert_context):
|
if self._alert_enforces_ollama_first(alert_context):
|
||||||
exec_context["ollama_model"] = getattr(settings, "ALERT_OLLAMA_MODEL", "qwen3:14b")
|
exec_context.setdefault("task_type", "diagnose")
|
||||||
exec_context["allow_gcp_heavy_model"] = True
|
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
|
exec_context["alert_requires_ollama_before_cloud"] = True
|
||||||
|
|
||||||
result = await executor.execute(
|
result = await executor.execute(
|
||||||
|
|||||||
42
apps/api/tests/test_drift_interpreter_ollama_first.py
Normal file
42
apps/api/tests/test_drift_interpreter_ollama_first.py
Normal file
@@ -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
|
||||||
@@ -177,6 +177,40 @@ async def test_non_alert_context_keeps_router_cloud_order(
|
|||||||
assert fake_executor.provider_order == ["gemini", "claude", "ollama"]
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_alert_context_uses_gcp_a_gcp_b_then_111_order(
|
async def test_alert_context_uses_gcp_a_gcp_b_then_111_order(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|||||||
@@ -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
|
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
|
# 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
|
||||||
|
# 通過
|
||||||
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user