feat(api): Phase 13 智能路由 + CI/CD 整合 (#74-88)

Phase 13.1 CI/CD Integration:
- #76 workflow_run handler for CI failure diagnosis
- #77 SignOz log query (query_logs, error_logs_summary MCP)
- #78 CIAutoRepairService with risk-based execution decisions

Phase 13.3 Smart Routing:
- #85 Intent Classifier v2.0 (rule engine + LLM fallback)
- #86 Complexity Scorer (9-dimension scoring)
- #87 AI Router v3.0 (routing decision matrix)
- #88 Token Counter (OTEL + Langfuse integration)

New files:
- services/ci_auto_repair.py (risk stratification)
- services/model_registry.py (centralized model config)
- services/token_counter.py (677 lines)
- Skill 08: Model Router Expert
- Skill 09: Strangler Pattern Expert
- ADR-023: Smart Routing Architecture
- ADR-024: API Layer Architecture

Tests:
- phase11-conversational.spec.ts (E2E tests)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-26 15:32:52 +08:00
parent b79e5f1a1a
commit 579da38b8b
15 changed files with 5895 additions and 245 deletions

View File

@@ -1,15 +1,41 @@
"""
AI Router - Phase 13.3 #87
==========================
動態模型選擇器,整合意圖分類和複雜度評分
智能 AI 路由器,根據意圖和複雜度動態選擇 AI Provider
目標: 根據請求特性自動選擇最適模型
策略: Intent + Complexity → Model Selection
策略: Intent Classifier + Complexity Scorer → Routing Decision
延遲目標: < 50ms (規則引擎優先)
Phase 13.3 (2026-03-26): 初始實作
路由決策矩陣 (ADR-023):
┌─────────────────┬───────────────┬──────────────────────────────┐
│ 複雜度 + 風險 │ Provider │ 備註 │
├─────────────────┼───────────────┼──────────────────────────────┤
│ 1-2 + LOW │ Ollama │ 快速本地處理 │
│ 3 + MEDIUM │ Ollama │ fallback → Gemini │
│ 4-5 + HIGH │ Gemini │ fallback → Claude │
│ DELETE/CRITICAL │ Claude │ 強制使用最強模型 │
└─────────────────┴───────────────┴──────────────────────────────┘
版本: v3.0
建立: 2026-03-26 (台北時區)
建立者: Claude Code
最後修改: 2026-03-26 (台北時區)
修改者: Claude Code
變更紀錄:
| 版本 | 日期 | 執行者 | 變更內容 |
|------|------|--------|----------|
| v1.0 | 2026-03-26 | Claude Code | 初始實作 |
| v2.0 | 2026-03-26 | Claude Code | 支援 IntentResult + 新意圖類型 |
| v3.0 | 2026-03-26 | Claude Code | Phase 13.3 #87 完整路由決策矩陣 |
"""
from dataclasses import dataclass
from __future__ import annotations
import time
from dataclasses import dataclass, field
from enum import Enum
import structlog
@@ -18,58 +44,169 @@ from src.services.complexity_scorer import (
get_complexity_scorer,
)
from src.services.intent_classifier import (
IntentResult,
IntentType,
RiskLevel,
get_intent_classifier,
normalize_intent,
)
from src.services.model_registry import get_model_registry
logger = structlog.get_logger(__name__)
# =============================================================================
# Provider 定義
# =============================================================================
class AIProvider(Enum):
"""AI 提供者"""
OLLAMA = "ollama"
GEMINI = "gemini"
CLAUDE = "claude"
# Provider 對應延遲預算 (ms)
PROVIDER_LATENCY_BUDGET: dict[AIProvider, int] = {
AIProvider.OLLAMA: 60000, # 本地,允許較長處理時間
AIProvider.GEMINI: 30000, # 雲端,較低延遲
AIProvider.CLAUDE: 30000, # 雲端,較低延遲
}
@dataclass
class RoutingDecision:
"""路由決策結果"""
"""
路由決策結果 (Phase 13.3 #87)
model: str # 選擇的模型
intent: IntentType # 意圖分類
包含完整的路由資訊,供 OpenClaw 主流程使用
"""
# 核心決策
selected_provider: AIProvider # 選擇的 AI Provider
selected_model: str # 選擇的模型名稱
fallback_chain: list[tuple[AIProvider, str]] # 備援鏈 [(provider, model), ...]
routing_reason: str # 路由決策原因
latency_budget_ms: int # 延遲預算 (毫秒)
# 分類結果
intent: IntentType # 意圖分類 (正規化後)
intent_result: IntentResult # 完整 Intent 分類結果
complexity: ComplexityScore # 複雜度評分
reason: str # 選擇原因
fallback_models: list[str] # 備援模型列表
risk_level: RiskLevel = field(default=RiskLevel.MEDIUM) # 風險等級
# 路由 metadata
routing_latency_ms: float = 0.0 # 路由決策耗時 (ms)
# 向後相容 (deprecated)
model: str = "" # -> selected_model
reason: str = "" # -> routing_reason
fallback_models: list[str] = field(default_factory=list) # -> fallback_chain
def __post_init__(self):
"""初始化後設定衍生欄位"""
self.risk_level = self.intent_result.risk_level
# 向後相容
self.model = self.selected_model
self.reason = self.routing_reason
self.fallback_models = [model for _, model in self.fallback_chain]
def to_dict(self) -> dict:
"""轉換為字典 (API 回應用)"""
return {
"selected_provider": self.selected_provider.value,
"selected_model": self.selected_model,
"fallback_chain": [
{"provider": p.value, "model": m} for p, m in self.fallback_chain
],
"routing_reason": self.routing_reason,
"latency_budget_ms": self.latency_budget_ms,
"intent": self.intent.value,
"risk_level": self.risk_level.value,
"complexity_score": self.complexity.score,
"routing_latency_ms": round(self.routing_latency_ms, 2),
}
class AIRouter:
"""
AI 路由器
AI 路由器 (Phase 13.3 #87)
整合 IntentClassifier 和 ComplexityScorer
動態選擇最適合的模型。
動態選擇最適合的 AI Provider 和模型。
路由策略:
1. 意圖優先覆寫 (某些意圖強制使用特定模型)
2. 複雜度導向選擇
3. 成本/延遲平衡
路由決策矩陣:
┌─────────────────┬───────────────┬──────────────────────────────┐
複雜度 + 風險 │ Provider │ 備註 │
├─────────────────┼───────────────┼──────────────────────────────┤
│ 1-2 + LOW │ Ollama │ 快速本地處理 │
│ 3 + MEDIUM │ Ollama │ fallback → Gemini │
│ 4-5 + HIGH │ Gemini │ fallback → Claude │
│ DELETE/CRITICAL │ Claude │ 強制使用最強模型 │
└─────────────────┴───────────────┴──────────────────────────────┘
路由策略 (按優先級):
1. CRITICAL 風險強制使用 Claude
2. DELETE 意圖強制使用 Claude
3. HIGH 風險或複雜度 4-5 → Gemini
4. 其他情況 → Ollama (成本優先)
"""
# 意圖強制覆寫
INTENT_OVERRIDES: dict[IntentType, str | None] = {
IntentType.CODE_REVIEW: "qwen2.5:7b-instruct", # 程式碼審查需要強模型
IntentType.DEPLOYMENT: None, # 不覆寫,依複雜度
IntentType.ALERT_TRIAGE: None,
IntentType.QUERY: "llama3.2:3b", # 查詢用快速模型
IntentType.MAINTENANCE: None,
IntentType.UNKNOWN: None,
}
# Fallback 順序
FALLBACK_ORDER = [
"qwen2.5:7b-instruct", # 本地主力
"llama3.2:3b", # 本地備援
"gemini", # 雲端備援
"claude", # 最終備援
]
def __init__(self):
self._intent_classifier = get_intent_classifier()
self._complexity_scorer = get_complexity_scorer()
self._model_registry = get_model_registry()
# 從 ModelRegistry 取得模型配置
self._ollama_default = self._model_registry.get_model("ollama", "default")
self._ollama_summary = self._model_registry.get_model("ollama", "summary")
self._gemini_default = self._model_registry.get_model("gemini", "default")
self._claude_default = self._model_registry.get_model("claude", "default")
# Provider 對應模型映射
self._provider_models: dict[AIProvider, str] = {
AIProvider.OLLAMA: self._ollama_default,
AIProvider.GEMINI: self._gemini_default,
AIProvider.CLAUDE: self._claude_default,
}
# 完整 Fallback 鏈 (Provider, Model)
self._full_fallback_chain: list[tuple[AIProvider, str]] = [
(AIProvider.OLLAMA, self._ollama_default),
(AIProvider.GEMINI, self._gemini_default),
(AIProvider.CLAUDE, self._claude_default),
]
# 意圖對應 Provider 強制覆寫 (None = 依複雜度決定)
self._intent_provider_overrides: dict[IntentType, AIProvider | None] = {
# 四大核心意圖
IntentType.RESTART: None, # 依複雜度
IntentType.SCALE: None, # 依複雜度
IntentType.CONFIG: None, # 依複雜度 (但 HIGH 會升級)
IntentType.DIAGNOSE: AIProvider.OLLAMA, # 診斷優先本地 (隱私)
# 輔助意圖
IntentType.DELETE: AIProvider.CLAUDE, # CRITICAL → 強制 Claude
IntentType.ROLLBACK: None, # 依複雜度
IntentType.UNKNOWN: None,
# 舊版兼容
IntentType.CODE_REVIEW: None,
IntentType.DEPLOYMENT: None,
IntentType.ALERT_TRIAGE: AIProvider.OLLAMA,
IntentType.QUERY: AIProvider.OLLAMA,
IntentType.MAINTENANCE: None,
}
# 向後相容
self._default_model = self._ollama_default
self._summary_model = self._ollama_summary
self._fallback_order = [
self._ollama_default,
self._ollama_summary,
"gemini",
"claude",
]
async def route(
self,
@@ -77,78 +214,203 @@ class AIRouter:
context: dict | None = None,
) -> RoutingDecision:
"""
路由請求到最適模型
路由請求到最適 AI Provider 和模型
延遲目標: < 50ms (規則引擎優先LLM 分類時可能稍長)
Args:
text: 用戶輸入或告警內容
context: 額外上下文 (服務、指標等)
Returns:
RoutingDecision: 路由決策
RoutingDecision: 完整路由決策
"""
start_time = time.perf_counter()
context = context or {}
# Step 1: 意圖分類
intent = await self._intent_classifier.classify(text)
# Step 1: 意圖分類 (返回 IntentResult, 規則引擎 < 10ms)
intent_result = await self._intent_classifier.classify(text)
intent = normalize_intent(intent_result.intent)
# Step 2: 複雜度評分
# Step 2: 複雜度評分 (< 10ms)
complexity = self._complexity_scorer.score(context)
# Step 3: 模型選擇
model, reason = self._select_model(intent, complexity)
# Step 3: Provider + Model 選擇 (< 1ms)
provider, model, reason = self._select_provider_and_model(
intent, intent_result, complexity
)
# Step 4: 建立 Fallback 列表
fallbacks = self._build_fallback_list(model)
# Step 4: 建立 Fallback
fallback_chain = self._build_fallback_chain(provider)
# Step 5: 計算延遲預算
latency_budget = PROVIDER_LATENCY_BUDGET.get(provider, 30000)
# 計算路由決策耗時
routing_latency = (time.perf_counter() - start_time) * 1000
decision = RoutingDecision(
model=model,
selected_provider=provider,
selected_model=model,
fallback_chain=fallback_chain,
routing_reason=reason,
latency_budget_ms=latency_budget,
intent=intent,
intent_result=intent_result,
complexity=complexity,
reason=reason,
fallback_models=fallbacks,
routing_latency_ms=routing_latency,
)
logger.info(
"ai_routing_decision",
provider=provider.value,
model=model,
intent=intent.value,
intent_confidence=intent_result.confidence,
risk_level=intent_result.risk_level.value,
complexity_score=complexity.score,
reason=reason,
latency_budget_ms=latency_budget,
routing_latency_ms=round(routing_latency, 2),
fallback_count=len(fallback_chain),
)
return decision
def _select_provider_and_model(
self,
intent: IntentType,
intent_result: IntentResult,
complexity: ComplexityScore,
) -> tuple[AIProvider, str, str]:
"""
選擇 Provider 和模型 (Phase 13.3 #87 核心邏輯)
路由決策矩陣:
┌─────────────────┬───────────────┬──────────────────────────────┐
│ 複雜度 + 風險 │ Provider │ 備註 │
├─────────────────┼───────────────┼──────────────────────────────┤
│ 1-2 + LOW │ Ollama │ 快速本地處理 │
│ 3 + MEDIUM │ Ollama │ fallback → Gemini │
│ 4-5 + HIGH │ Gemini │ fallback → Claude │
│ DELETE/CRITICAL │ Claude │ 強制使用最強模型 │
└─────────────────┴───────────────┴──────────────────────────────┘
Args:
intent: 正規化後的意圖
intent_result: 完整分類結果
complexity: 複雜度評分
Returns:
(provider, model, reason)
"""
risk = intent_result.risk_level
score = complexity.score
# =======================================================================
# 規則 1: CRITICAL 風險強制 Claude (最高優先級)
# =======================================================================
if risk == RiskLevel.CRITICAL:
provider = AIProvider.CLAUDE
model = self._claude_default
reason = f"CRITICAL 風險 ({intent.value}) 強制使用 Claude"
return provider, model, reason
# =======================================================================
# 規則 2: DELETE 意圖強制 Claude (不可逆操作)
# =======================================================================
if intent == IntentType.DELETE:
provider = AIProvider.CLAUDE
model = self._claude_default
reason = "DELETE 意圖 (不可逆) 強制使用 Claude"
return provider, model, reason
# =======================================================================
# 規則 3: 檢查意圖強制覆寫
# =======================================================================
provider_override = self._intent_provider_overrides.get(intent)
if provider_override is not None:
provider = provider_override
model = self._provider_models[provider]
reason = f"意圖 {intent.value} 指定使用 {provider.value}"
return provider, model, reason
# =======================================================================
# 規則 4: 複雜度 4-5 或 HIGH 風險 → Gemini
# =======================================================================
if score >= 4 or risk == RiskLevel.HIGH:
provider = AIProvider.GEMINI
model = self._gemini_default
reason = f"複雜度={score}/5, 風險={risk.value} → Gemini (fallback Claude)"
return provider, model, reason
# =======================================================================
# 規則 5: 複雜度 3 + MEDIUM → Ollama (fallback Gemini)
# =======================================================================
if score == 3:
provider = AIProvider.OLLAMA
model = self._ollama_default
reason = f"複雜度={score}/5, 風險={risk.value} → Ollama (fallback Gemini)"
return provider, model, reason
# =======================================================================
# 規則 6: 複雜度 1-2 + LOW/MEDIUM → Ollama (快速本地處理)
# =======================================================================
provider = AIProvider.OLLAMA
# 低複雜度使用輕量模型 (更快回應)
model = self._ollama_summary if score <= 1 else self._ollama_default
reason = f"複雜度={score}/5, 風險={risk.value} → Ollama (成本優先)"
return provider, model, reason
def _select_model(
self,
intent: IntentType,
intent_result: IntentResult,
complexity: ComplexityScore,
) -> tuple[str, str]:
"""
選擇模型
選擇模型 (向後相容方法)
Deprecated: 請使用 _select_provider_and_model
Args:
intent: 正規化後的意圖
intent_result: 完整分類結果
complexity: 複雜度評分
Returns:
(model_name, reason)
"""
# 檢查意圖覆寫
override = self.INTENT_OVERRIDES.get(intent)
if override:
return override, f"意圖 {intent.value} 強制使用 {override}"
# 依複雜度選擇
model = complexity.recommended_model
reason = f"複雜度 {complexity.score}/5 → {model}"
# 特殊情況調整
if intent == IntentType.ALERT_TRIAGE and complexity.score >= 4:
# 高複雜度告警優先用雲端
model = "gemini"
reason = f"高複雜度告警 (score={complexity.score}) → 使用雲端模型"
_, model, reason = self._select_provider_and_model(
intent, intent_result, complexity
)
return model, reason
def _build_fallback_chain(
self, selected_provider: AIProvider
) -> list[tuple[AIProvider, str]]:
"""
建立 Fallback 鏈 (排除已選 Provider)
Fallback 順序: Ollama → Gemini → Claude
Args:
selected_provider: 已選擇的 Provider
Returns:
Fallback 鏈 [(provider, model), ...]
"""
fallback_chain: list[tuple[AIProvider, str]] = []
for provider, model in self._full_fallback_chain:
if provider != selected_provider:
fallback_chain.append((provider, model))
return fallback_chain
def _build_fallback_list(self, selected_model: str) -> list[str]:
"""建立 Fallback 列表 (排除已選模型)"""
fallbacks = [m for m in self.FALLBACK_ORDER if m != selected_model]
"""建立 Fallback 列表 (向後相容)"""
fallbacks = [m for m in self._fallback_order if m != selected_model]
return fallbacks
def route_sync(
@@ -156,22 +418,113 @@ class AIRouter:
text: str,
context: dict | None = None,
) -> RoutingDecision:
"""同步版本 (僅關鍵字匹配)"""
"""
同步版本路由 (僅關鍵字匹配,保證 < 50ms)
適用場景: 需要快速決策,不需要 LLM 分類的情況
Args:
text: 用戶輸入或告警內容
context: 額外上下文
Returns:
RoutingDecision: 路由決策
"""
start_time = time.perf_counter()
context = context or {}
intent = self._intent_classifier.classify_sync(text)
# 同步分類 (僅規則引擎, < 10ms)
intent_result = self._intent_classifier.classify_sync(text)
intent = normalize_intent(intent_result.intent)
# 複雜度評分 (< 10ms)
complexity = self._complexity_scorer.score(context)
model, reason = self._select_model(intent, complexity)
fallbacks = self._build_fallback_list(model)
# Provider + Model 選擇
provider, model, reason = self._select_provider_and_model(
intent, intent_result, complexity
)
# 建立 Fallback 鏈
fallback_chain = self._build_fallback_chain(provider)
# 延遲預算
latency_budget = PROVIDER_LATENCY_BUDGET.get(provider, 30000)
# 計算路由決策耗時
routing_latency = (time.perf_counter() - start_time) * 1000
return RoutingDecision(
model=model,
selected_provider=provider,
selected_model=model,
fallback_chain=fallback_chain,
routing_reason=reason,
latency_budget_ms=latency_budget,
intent=intent,
intent_result=intent_result,
complexity=complexity,
reason=reason,
fallback_models=fallbacks,
routing_latency_ms=routing_latency,
)
# =========================================================================
# 便捷方法
# =========================================================================
def get_provider_for_intent(self, intent: IntentType) -> AIProvider:
"""取得意圖對應的 Provider (不考慮複雜度)"""
override = self._intent_provider_overrides.get(intent)
return override if override else AIProvider.OLLAMA
def get_model_for_provider(self, provider: AIProvider) -> str:
"""取得 Provider 對應的模型"""
return self._provider_models.get(provider, self._ollama_default)
def get_routing_matrix(self) -> list[dict]:
"""
取得路由決策矩陣 (用於 API 文檔或除錯)
Returns:
路由規則清單
"""
return [
{
"rule": 1,
"condition": "CRITICAL risk",
"provider": "claude",
"reason": "不可逆/高風險操作強制最強模型",
},
{
"rule": 2,
"condition": "DELETE intent",
"provider": "claude",
"reason": "刪除操作強制最強模型",
},
{
"rule": 3,
"condition": "Intent override",
"provider": "depends",
"reason": "特定意圖有預設 Provider",
},
{
"rule": 4,
"condition": "complexity >= 4 OR HIGH risk",
"provider": "gemini",
"reason": "高複雜度需要雲端能力",
},
{
"rule": 5,
"condition": "complexity == 3",
"provider": "ollama",
"reason": "中等複雜度本地處理",
},
{
"rule": 6,
"condition": "complexity 1-2",
"provider": "ollama",
"reason": "低複雜度快速處理",
},
]
# 單例
_router: AIRouter | None = None
@@ -183,3 +536,9 @@ def get_ai_router() -> AIRouter:
if _router is None:
_router = AIRouter()
return _router
def reset_ai_router() -> None:
"""重置單例 (用於測試)"""
global _router
_router = None