fix(critic-hotfix): 4 修補 critic BLOCKER + HIGH(CD 阻塞 + 飛輪空轉)
Some checks are pending
CD Pipeline / build-and-deploy (push) Has started running

Critic 全面審查 6 個 commit 後抓出:

CD 阻塞修復:
- test_ai_router_failover_integration.py: 3 個 test 改用 patch.object 直接
  mock _select_provider_and_model 強制初始 OLLAMA。原 IntentType.UNKNOWN mock
  在 router 內仍被 reclassify 成 DIAGNOSE → openclaw_nemo,failover 不觸發。
  → 5/5 PASSED

BLOCKER B1 — Gitea Telegram 通知永遠發不出去:
- apps/api/src/api/v1/gitea_webhook.py:399
  redis = await get_redis()  →  redis = get_redis()
  原 await 會 raise TypeError 被外層 except 吞 → Task C PR merged + workflow_run
  failure 通知全部失效(CI 綠燈是假象,test 只驗 HTTP 202 不驗實際送達)

BLOCKER B2 — P1.3+P1.4 學習鏈閉環空轉(兩處同 bug):
- apps/api/src/api/v1/webhooks.py:261
- apps/api/src/services/approval_execution.py:771(pre-existing)
  EvidenceSnapshot.get_latest_snapshot(...) 是 module-level async function
  不是 classmethod → AttributeError 被 except 吞成 warning
  → 飛輪閉環假性接通實際空跑(feature flag default off 暫時免爆)

HIGH H3 — main.py lifespan 順序競爭:
- apps/api/src/main.py: configure_alerter() 移到 _recovery_svc.start() 之前
  原順序:start() 觸發 immediate-check → 可能呼叫 alert_recovery,但 alerter
  尚未注入 Redis → dedup fail-open,重複告警風險。

HIGH H1 — Gemini quota dedup 跨日吞告警:
- apps/api/src/services/failover_alerter.py:89
  dedup key 加 :{YYYY-MM-DD} 後綴,每日獨立 dedup window
  原昨 22:00 觸發,今 21:30 再觸發時 dedup 還沒過期會被吞掉

Tests: 14 passed (failover_alerter + ai_router_failover_integration + lifespan_wiring)

延後 follow-up:
- H2: proactive_inspector memory metric 改名 + baseline 清理
- H4: probe_success NaN fallback
- M1-M4 / S1-S2: 見 critic 報告

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-04-26 20:39:53 +08:00
parent dcf2750b2b
commit 32affaffeb
6 changed files with 52 additions and 64 deletions

View File

@@ -1,5 +1,8 @@
# 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 整合測試
==========================================
@@ -86,23 +89,13 @@ async def test_router_uses_failover_when_ollama_initial_provider():
router = _make_router_with_mock_failover(mock_fm)
# 讓 intent classifier + complexity scorer 走 sync 快路徑ALERT_TRIAGE → OLLAMA
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.ALERT_TRIAGE,
confidence=0.9,
method="keyword",
matched_keywords=["alert"],
detected_resources=[],
reasoning="test",
)
with patch.object(router._complexity_scorer, "score") as mock_score:
mock_score.return_value = ComplexityScore(score=1, features={})
decision = await router.route("test alert message")
# 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"
@@ -132,22 +125,13 @@ async def test_router_failover_fallback_chain_converted():
router = _make_router_with_mock_failover(mock_fm)
with patch.object(router._intent_classifier, "classify") as mock_classify:
from src.services.intent_classifier import IntentResult, IntentType
from src.services.complexity_scorer import ComplexityScore
mock_classify.return_value = IntentResult(
intent=IntentType.ALERT_TRIAGE,
confidence=0.9,
method="keyword",
matched_keywords=["alert"],
detected_resources=[],
reasoning="test",
)
with patch.object(router._complexity_scorer, "score") as mock_score:
mock_score.return_value = ComplexityScore(score=1, features={})
decision = await router.route("test alert message")
# 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, (
@@ -233,23 +217,15 @@ async def test_router_failopen_when_failover_manager_raises():
router = _make_router_with_mock_failover(mock_fm)
with patch.object(router._intent_classifier, "classify") as mock_classify:
from src.services.intent_classifier import IntentResult, IntentType
from src.services.complexity_scorer import ComplexityScore
mock_classify.return_value = IntentResult(
intent=IntentType.ALERT_TRIAGE,
confidence=0.9,
method="keyword",
matched_keywords=["alert"],
detected_resources=[],
reasoning="test",
)
with patch.object(router._complexity_scorer, "score") as mock_score:
mock_score.return_value = ComplexityScore(score=1, features={})
# 不應 raise應 fail-open
decision = await router.route("test alert message")
# 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