Files
awoooi/apps/api/tests/test_ai_router_failover_integration.py
Your Name fb0c72db42
Some checks failed
CD Pipeline / build-and-deploy (push) Failing after 2m26s
feat(ai-router): 推翻 A2 鐵律 — DIAGNOSE primary 改 Ollama 本地優先
統帥鐵律 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>
2026-04-29 11:39:36 +08:00

244 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 mockALERT_TRIAGE→DIAGNOSEnormalize_intent 映射),改用 UNKNOWN無 overridescore=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
測試分類unitmock 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():
"""初步路由選 NEMOTRONtool_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 → CLAUDEfailover_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"},
)
# 推翻 A2DIAGNOSE 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-opendecision 仍然成功(使用原始 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