Some checks failed
CD Pipeline / build-and-deploy (push) Failing after 2m26s
統帥鐵律 2026-04-29:「主要優先用 111 主機的 Ollama」
+ feedback_ai_autonomous_direction.md:以本地免費 LLM 為主
+ feedback_ollama_111_only.md:Ollama 唯一主機 = 111
## 推翻 A2 (2026-04-27 INC-20260425) 的事實基礎
**舊事實**:Ollama = CPU-only deepseek-r1:14b @ 238s(不可用)
**新事實**:prod Ollama 111 = M1 Pro Apple Silicon GPU + qwen2.5:7b-instruct
VRAM 8.2GB 全載入,ctx 32k,實測 hi prompt 0.54s
**雲端全死**(2026-04-29 prod log 證據):
- OpenClaw 188:8088 → 500 Internal Server Error
- Gemini → 429 Too Many Requests(配額爆)
- Claude → 404 Not Found(model claude-3-haiku-20240307 過期)
**不推翻 A2 → 100% incident llm_failed → AI 自動修復永遠不啟動**
## 修改範圍(最小、安全、可驗證)
### ai_router.py
- `_diagnose_fallback_chain`: OLLAMA 第一順位(取代「永久排除」舊註解)
順序:[OLLAMA, OPENCLAW_NEMO, GEMINI, CLAUDE]
- `_intent_provider_overrides[DIAGNOSE]`: OPENCLAW_NEMO → OLLAMA
- 不動 _full_fallback_chain(避免影響 RESTART/SCALE/CONFIG/DELETE)
- 不動 _tool_calling_fallback_chain
- 不動 complexity_map(critic M2 留待後續)
### openclaw.py
- 注入 task_type="diagnose" 到 alert_context(critic C2 真根因)
- 修復 ai_providers/ollama.py:77 timeout 對齊問題:
- 有 task_type → OLLAMA_DIAGNOSE_TIMEOUT_SECONDS=200s
- 沒有 → OPENCLAW_TIMEOUT=30s(不夠 qwen2.5:7b 推理)
- prod log 看到 latency_ms=120014 的根因
- 用 dict(alert_context) 複製,不污染原 context
## Regression Test 同步更新(5 個)
A2 鐵律守門 test 全部反映新鐵律:
- test_p0_diagnose_routing.py::test_diagnose_override_is_ollama
(原 test_diagnose_override_is_openclaw_nemo)
- test_ai_router_diagnose_fallback.py::test_diagnose_fallback_chain_ollama_primary
(原 test_diagnose_fallback_chain_no_ollama)
- test_ai_router_diagnose_fallback.py::test_diagnose_route_primary_is_ollama
(原 test_diagnose_route_fallback_chain_excludes_ollama)
- test_ai_router_diagnose_fallback.py::test_diagnose_route_sync_primary_is_ollama
(原 test_diagnose_route_sync_fallback_chain_excludes_ollama)
- test_ai_router_diagnose_fallback.py::test_build_fallback_chain_for_intent_diagnose_with_ollama_primary
(原 test_build_fallback_chain_for_intent_diagnose_no_ollama)
- test_ai_router_failover_integration.py::test_router_uses_failover_for_diagnose_ollama_primary
(原 test_router_does_not_use_failover_for_openclaw_nemo)
每個 test docstring 都記載歷史脈絡 + 推翻原因。
## 驗證
- 1608 unit tests 全綠
- LLM 路徑 16 個 test 全綠(含 6 個 A2 守門 test 更新版)
- complexity_scorer / failover_manager / intent_classifier 不受影響
## 期望 prod 行為(部署後驗證)
incident 進入 → DIAGNOSE intent → primary OLLAMA (qwen2.5:7b on M1 Pro GPU)
失敗才 fallback → OpenClaw 188 → Gemini → Claude
Ollama 用 200s timeout(之前 30s 不夠)
→ AI 自動修復終於可以啟動,不再 100% llm_failed
## 已知債(後續處理)
- models.json:21 ollama.default 仍是 deepseek-r1:14b(critic C1,但 prod 已自動 route 到實載 model)
- complexity 4/5 仍寫死 gemini/claude(critic M2)
- Gemini API key 在 prod log 明文(需輪換 + sanitize)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
244 lines
10 KiB
Python
244 lines
10 KiB
Python
# apps/api/tests/test_ai_router_failover_integration.py | 2026-04-25 @ Asia/Taipei
|
||
# 2026-04-25 P1.2 by Claude Engineer-A2 — failover 整合到 ai_router + lifespan
|
||
# 2026-04-26 Wave4 P1.2-tests-fix by Claude Engineer-A3 — 修正 intent mock:ALERT_TRIAGE→DIAGNOSE(normalize_intent 映射),改用 UNKNOWN(無 override,score=1 → OLLAMA → failover 觸發)
|
||
# 2026-04-26 Wave4 P1.2-tests-fix-v2 by Claude Opus 4.7 — UNKNOWN intent 在 router 內仍被 reclassify 成 DIAGNOSE → openclaw_nemo
|
||
# 改用 patch.object(router, "_select_provider_and_model") 直接強制初始路由為 OLLAMA,繞過 normalize / alert detection 邏輯
|
||
"""
|
||
AIRouter × OllamaFailoverManager 整合測試
|
||
==========================================
|
||
測試覆蓋:
|
||
1. 初步路由選 OLLAMA → failover_manager 重評 → decision 使用 failover 結果
|
||
2. failover 回傳 GEMINI primary → decision.selected_provider == GEMINI
|
||
3. failover 的 fallback_chain 正確轉換到 decision.fallback_chain
|
||
4. 初步路由選 NEMOTRON → failover_manager 不被呼叫
|
||
5. 初步路由選 OPENCLAW_NEMO → failover_manager 不被呼叫
|
||
6. failover_manager 發生例外 → fail-open,保留原始 provider
|
||
|
||
測試分類:unit(mock OllamaFailoverManager,無 Redis / DB 依賴)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
from src.services.ai_router import AIProviderEnum, AIRouter, reset_ai_router
|
||
from src.services.ollama_failover_manager import OllamaEndpoint, OllamaRoutingResult
|
||
from src.services.ollama_health_monitor import HealthReport, HealthStatus
|
||
|
||
|
||
# =============================================================================
|
||
# Fixtures / Helpers
|
||
# =============================================================================
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def reset_router_singleton():
|
||
"""每個測試前後重置 AIRouter singleton,避免 failover_manager mock 殘留"""
|
||
yield
|
||
reset_ai_router()
|
||
|
||
|
||
def _make_health(status: HealthStatus) -> HealthReport:
|
||
return HealthReport(status=status, host="http://192.168.0.111:11434", latency_ms=500.0)
|
||
|
||
|
||
def _make_failover_result(
|
||
primary_provider: str,
|
||
primary_model: str,
|
||
fallback: list[tuple[str, str]] | None = None,
|
||
) -> OllamaRoutingResult:
|
||
"""建立 OllamaRoutingResult 測試物件"""
|
||
fb_endpoints = [
|
||
OllamaEndpoint(url="", provider_name=p, model=m)
|
||
for p, m in (fallback or [])
|
||
]
|
||
return OllamaRoutingResult(
|
||
primary=OllamaEndpoint(url="", provider_name=primary_provider, model=primary_model),
|
||
fallback_chain=fb_endpoints,
|
||
routing_reason=f"test: {primary_provider}",
|
||
health_111=_make_health(HealthStatus.OFFLINE),
|
||
)
|
||
|
||
|
||
def _make_router_with_mock_failover(mock_failover_manager) -> AIRouter:
|
||
"""建立 AIRouter,並替換其 _failover_manager"""
|
||
router = AIRouter()
|
||
router._failover_manager = mock_failover_manager
|
||
return router
|
||
|
||
|
||
# =============================================================================
|
||
# Test 1: OLLAMA 路由 → failover_manager 重評 → 使用 GEMINI
|
||
# =============================================================================
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_router_uses_failover_when_ollama_initial_provider():
|
||
"""初步路由選 OLLAMA → 應走 failover_manager 重評,decision.selected_provider == GEMINI"""
|
||
mock_fm = MagicMock()
|
||
mock_fm.select_provider = AsyncMock(
|
||
return_value=_make_failover_result(
|
||
primary_provider="gemini",
|
||
primary_model="gemini-1.5-flash",
|
||
fallback=[("ollama_188", "qwen2.5:7b-instruct"), ("nemotron", "nvidia/nemotron-mini-4b-instruct")],
|
||
)
|
||
)
|
||
|
||
router = _make_router_with_mock_failover(mock_fm)
|
||
|
||
# 2026-04-26 Wave4 v2 by Claude Opus 4.7 — 直接 mock _select_provider_and_model 強制初始 OLLAMA
|
||
with patch.object(
|
||
router,
|
||
"_select_provider_and_model",
|
||
return_value=(AIProviderEnum.OLLAMA, "qwen3:8b", "test forced ollama"),
|
||
):
|
||
decision = await router.route("test alert message")
|
||
|
||
assert decision.selected_provider == AIProviderEnum.GEMINI
|
||
assert decision.selected_model == "gemini-1.5-flash"
|
||
mock_fm.select_provider.assert_awaited_once()
|
||
|
||
|
||
# =============================================================================
|
||
# Test 2: fallback_chain 正確轉換
|
||
# =============================================================================
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_router_failover_fallback_chain_converted():
|
||
"""failover_manager 回傳 fallback_chain → decision.fallback_chain 包含 OLLAMA_188"""
|
||
mock_fm = MagicMock()
|
||
mock_fm.select_provider = AsyncMock(
|
||
return_value=_make_failover_result(
|
||
primary_provider="gemini",
|
||
primary_model="gemini-1.5-flash",
|
||
fallback=[
|
||
("ollama_188", "qwen2.5:7b-instruct"),
|
||
("nemotron", "nvidia/nemotron-mini-4b-instruct"),
|
||
("claude", "claude-3-5-haiku-20241022"),
|
||
],
|
||
)
|
||
)
|
||
|
||
router = _make_router_with_mock_failover(mock_fm)
|
||
|
||
# 2026-04-26 Wave4 v2 by Claude Opus 4.7 — 直接 mock _select_provider_and_model 強制初始 OLLAMA
|
||
with patch.object(
|
||
router,
|
||
"_select_provider_and_model",
|
||
return_value=(AIProviderEnum.OLLAMA, "qwen3:8b", "test forced ollama"),
|
||
):
|
||
decision = await router.route("test alert message")
|
||
|
||
fb_providers = [p for p, _ in decision.fallback_chain]
|
||
assert AIProviderEnum.OLLAMA_188 in fb_providers, (
|
||
f"OLLAMA_188 not in fallback_chain: {fb_providers}"
|
||
)
|
||
assert AIProviderEnum.NEMOTRON in fb_providers
|
||
assert AIProviderEnum.CLAUDE in fb_providers
|
||
|
||
|
||
# =============================================================================
|
||
# Test 3: NEMOTRON 路由 → failover_manager 不被呼叫
|
||
# =============================================================================
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_router_does_not_use_failover_for_nemotron():
|
||
"""初步路由選 NEMOTRON(tool_calling)→ failover_manager.select_provider 不應被呼叫"""
|
||
mock_fm = MagicMock()
|
||
mock_fm.select_provider = AsyncMock()
|
||
|
||
router = _make_router_with_mock_failover(mock_fm)
|
||
|
||
# 強制 intent = DIAGNOSE(→ OPENCLAW_NEMO),再用 context_hint 跳過 LLM
|
||
# 但 NEMOTRON 只由 route_tool_calling() 觸發,route() 最多到 OPENCLAW_NEMO
|
||
# 改用 QUERY → OLLAMA 的 override,然後驗 failover 被觸發(這不是 NEMOTRON 測試)
|
||
# 正確測試:強制 CRITICAL → CLAUDE,驗 failover 不被呼叫
|
||
with patch.object(router._intent_classifier, "classify") as mock_classify:
|
||
from src.services.intent_classifier import IntentResult, IntentType, RiskLevel
|
||
from src.services.complexity_scorer import ComplexityScore
|
||
|
||
mock_classify.return_value = IntentResult(
|
||
intent=IntentType.DELETE,
|
||
confidence=1.0,
|
||
method="keyword",
|
||
matched_keywords=["delete"],
|
||
detected_resources=[],
|
||
reasoning="test",
|
||
risk_level=RiskLevel.CRITICAL,
|
||
)
|
||
with patch.object(router._complexity_scorer, "score") as mock_score:
|
||
mock_score.return_value = ComplexityScore(score=5, features={})
|
||
|
||
decision = await router.route("delete this service")
|
||
|
||
# CRITICAL risk → CLAUDE,failover_manager 不應被呼叫
|
||
assert decision.selected_provider == AIProviderEnum.CLAUDE
|
||
mock_fm.select_provider.assert_not_awaited()
|
||
|
||
|
||
# =============================================================================
|
||
# Test 4: OPENCLAW_NEMO 路由 → failover_manager 不被呼叫
|
||
# =============================================================================
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_router_uses_failover_for_diagnose_ollama_primary():
|
||
"""2026-04-29: DIAGNOSE intent → OLLAMA → failover_manager 應被呼叫
|
||
|
||
推翻 A2 後 DIAGNOSE primary 為 OLLAMA(本地優先鐵律)
|
||
failover_manager 對 OLLAMA primary 會檢查健康度(111 vs 188 CPU 備援切換)
|
||
"""
|
||
mock_fm = MagicMock()
|
||
mock_fm.select_provider = AsyncMock()
|
||
# mock 回傳保持原 provider(無切換)
|
||
mock_decision = MagicMock()
|
||
mock_decision.primary.provider_name = "ollama"
|
||
mock_decision.primary.model = "qwen2.5:7b-instruct"
|
||
mock_decision.fallback_chain = []
|
||
mock_fm.select_provider.return_value = mock_decision
|
||
|
||
router = _make_router_with_mock_failover(mock_fm)
|
||
|
||
decision = await router.route(
|
||
"diagnose service crash",
|
||
context={"intent_hint": "diagnose"},
|
||
)
|
||
|
||
# 推翻 A2:DIAGNOSE primary 是 OLLAMA
|
||
assert decision.selected_provider == AIProviderEnum.OLLAMA
|
||
# OLLAMA primary 觸發 failover_manager 健康檢查(111 vs 188)
|
||
mock_fm.select_provider.assert_awaited()
|
||
|
||
|
||
# =============================================================================
|
||
# Test 5: failover_manager 發生例外 → fail-open,保留原始 OLLAMA
|
||
# =============================================================================
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_router_failopen_when_failover_manager_raises():
|
||
"""failover_manager.select_provider 拋出例外 → fail-open,decision 仍然成功(使用原始 OLLAMA)"""
|
||
mock_fm = MagicMock()
|
||
mock_fm.select_provider = AsyncMock(side_effect=RuntimeError("redis timeout"))
|
||
|
||
router = _make_router_with_mock_failover(mock_fm)
|
||
|
||
# 2026-04-26 Wave4 v2 by Claude Opus 4.7 — 直接 mock _select_provider_and_model 強制初始 OLLAMA
|
||
# → failover 觸發 → raises RuntimeError → fail-open → 保留 OLLAMA
|
||
with patch.object(
|
||
router,
|
||
"_select_provider_and_model",
|
||
return_value=(AIProviderEnum.OLLAMA, "qwen3:8b", "test forced ollama"),
|
||
):
|
||
# 不應 raise,應 fail-open
|
||
decision = await router.route("test alert message")
|
||
|
||
# fail-open → 保留 OLLAMA(原始 initial decision)
|
||
assert decision.selected_provider == AIProviderEnum.OLLAMA
|
||
# fallback_chain 仍然存在(來自 _build_fallback_chain)
|
||
assert len(decision.fallback_chain) > 0
|