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:
@@ -7,139 +7,415 @@ Complexity Scorer - Phase 13.3 #86
|
||||
策略: 基於特徵提取的加權評分
|
||||
|
||||
Phase 13.3 (2026-03-26): 初始實作
|
||||
Phase 13.3 (2026-03-26): 增強版 - 9 維度完整評分系統 (ADR-023)
|
||||
|
||||
版本: v2.0
|
||||
建立: 2026-03-26 (台北時區)
|
||||
建立者: Claude Code
|
||||
最後修改: 2026-03-26 (台北時區)
|
||||
修改者: Claude Code
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Protocol
|
||||
|
||||
import structlog
|
||||
|
||||
from src.services.model_registry import get_model_registry
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Enums
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class DataImpact(Enum):
|
||||
"""資料影響等級 (ADR-023)"""
|
||||
|
||||
NONE = "none" # 無資料影響
|
||||
READ_ONLY = "read_only" # 只讀操作
|
||||
WRITE = "write" # 寫入操作
|
||||
DESTRUCTIVE = "destructive" # 破壞性操作 (刪除、DROP)
|
||||
|
||||
|
||||
class BusinessCriticality(Enum):
|
||||
"""業務關鍵度等級"""
|
||||
|
||||
NON_CRITICAL = "non_critical" # 非關鍵服務
|
||||
SUPPORTING = "supporting" # 支援服務
|
||||
IMPORTANT = "important" # 重要服務
|
||||
CRITICAL = "critical" # 核心服務
|
||||
MISSION_CRITICAL = "mission_critical" # 業務命脈
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Interface (支援 DI 測試)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class IComplexityScorer(Protocol):
|
||||
"""Complexity Scorer Interface for DI"""
|
||||
|
||||
def score(self, context: dict) -> "ComplexityScore":
|
||||
"""計算複雜度分數"""
|
||||
...
|
||||
|
||||
def get_dimension_weights(self) -> dict[str, float]:
|
||||
"""取得維度權重配置"""
|
||||
...
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Data Classes
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _get_default_model() -> str:
|
||||
"""取得預設模型 (從 ModelRegistry)"""
|
||||
return get_model_registry().get_model("ollama", "default")
|
||||
|
||||
|
||||
@dataclass
|
||||
class DimensionScore:
|
||||
"""單一維度評分"""
|
||||
|
||||
name: str # 維度名稱
|
||||
raw_value: int | float | str | bool # 原始值
|
||||
normalized_score: int # 正規化分數 (1-5)
|
||||
weight: float # 權重
|
||||
weighted_score: float # 加權後分數
|
||||
reason: str # 評分原因
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComplexityScore:
|
||||
"""複雜度評分結果"""
|
||||
|
||||
score: int # 1-5 (1=簡單, 5=極複雜)
|
||||
features: dict[str, int] = field(default_factory=dict)
|
||||
recommended_model: str = "qwen2.5:7b-instruct"
|
||||
features: dict[str, int] = field(default_factory=dict) # 向後相容
|
||||
recommended_model: str = "" # 由 ComplexityScorer 填入
|
||||
reasoning: str = ""
|
||||
|
||||
# v2.0 新增欄位
|
||||
dimensions: list[DimensionScore] = field(default_factory=list)
|
||||
raw_weighted_sum: float = 0.0 # 加權總分 (正規化前)
|
||||
total_weight: float = 0.0 # 總權重
|
||||
|
||||
# 模型映射 (依複雜度)
|
||||
MODEL_BY_COMPLEXITY = {
|
||||
1: "llama3.2:3b", # 簡單任務,快速回應
|
||||
2: "qwen2.5:7b-instruct", # 中等任務
|
||||
3: "qwen2.5:7b-instruct", # 複雜任務
|
||||
4: "gemini", # 需要雲端能力
|
||||
5: "claude", # 極複雜,需要最強模型
|
||||
}
|
||||
def __post_init__(self):
|
||||
"""初始化後設定預設模型"""
|
||||
if not self.recommended_model:
|
||||
self.recommended_model = _get_default_model()
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""轉換為字典 (API 回應用)"""
|
||||
return {
|
||||
"score": self.score,
|
||||
"recommended_model": self.recommended_model,
|
||||
"reasoning": self.reasoning,
|
||||
"dimensions": [
|
||||
{
|
||||
"name": d.name,
|
||||
"raw_value": d.raw_value if not isinstance(d.raw_value, Enum) else d.raw_value.value,
|
||||
"normalized_score": d.normalized_score,
|
||||
"weight": d.weight,
|
||||
"weighted_score": round(d.weighted_score, 3),
|
||||
"reason": d.reason,
|
||||
}
|
||||
for d in self.dimensions
|
||||
],
|
||||
"raw_weighted_sum": round(self.raw_weighted_sum, 3),
|
||||
"total_weight": round(self.total_weight, 3),
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Complexity Scorer Implementation
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ComplexityScorer:
|
||||
"""
|
||||
複雜度評分器
|
||||
複雜度評分器 (v2.0)
|
||||
|
||||
基於規則的複雜度評估,無 LLM 依賴,確保 < 10ms
|
||||
|
||||
評分維度:
|
||||
1. 服務數量 (affected_services)
|
||||
2. 指標數量 (metrics)
|
||||
3. 是否需要程式碼分析 (requires_code_analysis)
|
||||
4. 是否跨系統 (cross_system)
|
||||
5. 是否有歷史關聯 (has_history)
|
||||
6. 嚴重程度 (severity)
|
||||
評分維度 (9 個,ADR-023):
|
||||
1. 資源數量 (resource_count)
|
||||
2. 跨命名空間 (cross_namespace)
|
||||
3. 有狀態資源 (stateful_resource)
|
||||
4. 資料影響 (data_impact)
|
||||
5. 服務依賴 (service_dependencies)
|
||||
6. 回滾難度 (rollback_difficulty)
|
||||
7. 停機時間 (downtime_estimate)
|
||||
8. 安全敏感度 (security_sensitivity)
|
||||
9. 業務關鍵度 (business_criticality)
|
||||
|
||||
權重配置說明:
|
||||
- 權重越高,對最終分數影響越大
|
||||
- 總權重 = 所有啟用維度權重之和
|
||||
- 最終分數 = 加權平均 (1-5)
|
||||
"""
|
||||
|
||||
# 權重配置
|
||||
WEIGHTS = {
|
||||
"service_count": 0.5, # 每增加一個服務 +0.5
|
||||
"metric_count": 0.3, # 每增加一個指標 +0.3
|
||||
"code_analysis": 1.5, # 需要代碼分析 +1.5
|
||||
"cross_system": 1.0, # 跨系統 +1.0
|
||||
"has_history": -0.5, # 有歷史案例 -0.5 (降低複雜度)
|
||||
"critical_severity": 1.0, # CRITICAL 告警 +1.0
|
||||
# ==========================================================================
|
||||
# 權重配置 (可透過 models.json 覆寫)
|
||||
# ==========================================================================
|
||||
|
||||
DEFAULT_WEIGHTS = {
|
||||
# 維度名稱: 權重
|
||||
"resource_count": 1.0, # 資源數量
|
||||
"cross_namespace": 1.5, # 跨命名空間 (風險較高)
|
||||
"stateful_resource": 2.0, # 有狀態資源 (最高風險)
|
||||
"data_impact": 2.0, # 資料影響 (最高風險)
|
||||
"service_dependencies": 1.0, # 服務依賴
|
||||
"rollback_difficulty": 1.5, # 回滾難度
|
||||
"downtime_estimate": 1.0, # 停機時間
|
||||
"security_sensitivity": 1.5, # 安全敏感度
|
||||
"business_criticality": 1.5, # 業務關鍵度
|
||||
# 降低複雜度的維度 (負權重)
|
||||
"has_playbook": -0.5, # 有歷史 Playbook
|
||||
"has_history": -0.5, # 有歷史案例
|
||||
}
|
||||
|
||||
# ==========================================================================
|
||||
# 評分閾值
|
||||
# ==========================================================================
|
||||
|
||||
# 資源數量閾值
|
||||
RESOURCE_COUNT_THRESHOLDS = {
|
||||
1: 1, # 1 個資源 = 分數 1
|
||||
2: 2, # 2 個資源 = 分數 2
|
||||
3: 3, # 3-4 個資源 = 分數 3
|
||||
5: 4, # 5-9 個資源 = 分數 4
|
||||
10: 5, # 10+ 個資源 = 分數 5
|
||||
}
|
||||
|
||||
# 服務依賴閾值
|
||||
SERVICE_DEPENDENCY_THRESHOLDS = {
|
||||
0: 1, # 獨立服務 = 分數 1
|
||||
1: 2, # 1 個依賴 = 分數 2
|
||||
2: 3, # 2 個依賴 = 分數 3
|
||||
4: 4, # 4 個依賴 = 分數 4
|
||||
6: 5, # 6+ 個依賴 = 分數 5
|
||||
}
|
||||
|
||||
# 停機時間閾值 (分鐘)
|
||||
DOWNTIME_THRESHOLDS = {
|
||||
0: 1, # 0 分鐘 = 分數 1
|
||||
1: 2, # 1-4 分鐘 = 分數 2
|
||||
5: 3, # 5-14 分鐘 = 分數 3
|
||||
15: 4, # 15-29 分鐘 = 分數 4
|
||||
30: 5, # 30+ 分鐘 = 分數 5
|
||||
}
|
||||
|
||||
# 資料影響對應分數
|
||||
DATA_IMPACT_SCORES = {
|
||||
DataImpact.NONE: 1,
|
||||
DataImpact.READ_ONLY: 2,
|
||||
DataImpact.WRITE: 4,
|
||||
DataImpact.DESTRUCTIVE: 5,
|
||||
}
|
||||
|
||||
# 業務關鍵度對應分數
|
||||
BUSINESS_CRITICALITY_SCORES = {
|
||||
BusinessCriticality.NON_CRITICAL: 1,
|
||||
BusinessCriticality.SUPPORTING: 2,
|
||||
BusinessCriticality.IMPORTANT: 3,
|
||||
BusinessCriticality.CRITICAL: 4,
|
||||
BusinessCriticality.MISSION_CRITICAL: 5,
|
||||
}
|
||||
|
||||
def __init__(self, weights: dict[str, float] | None = None):
|
||||
"""
|
||||
初始化 ComplexityScorer
|
||||
|
||||
Args:
|
||||
weights: 自訂權重配置,None 使用預設
|
||||
"""
|
||||
self._weights = weights or self.DEFAULT_WEIGHTS.copy()
|
||||
|
||||
def get_dimension_weights(self) -> dict[str, float]:
|
||||
"""取得維度權重配置"""
|
||||
return self._weights.copy()
|
||||
|
||||
def score(self, context: dict) -> ComplexityScore:
|
||||
"""
|
||||
計算複雜度分數
|
||||
|
||||
Args:
|
||||
context: 上下文資訊,包含:
|
||||
- affected_services: list[str]
|
||||
- metrics: list[str]
|
||||
context: 上下文資訊,包含 (全部可選):
|
||||
# 基本維度
|
||||
- resource_count: int (受影響資源數量)
|
||||
- affected_services: list[str] (受影響服務清單,向後相容)
|
||||
- metrics: list[str] (相關指標,向後相容)
|
||||
|
||||
# 命名空間與資源類型
|
||||
- namespaces: list[str] (涉及的命名空間)
|
||||
- cross_namespace: bool (是否跨命名空間)
|
||||
- stateful_resources: list[str] (有狀態資源清單)
|
||||
- has_statefulset: bool (是否有 StatefulSet)
|
||||
- has_pvc: bool (是否有 PVC)
|
||||
|
||||
# 資料影響
|
||||
- data_impact: str | DataImpact (資料影響等級)
|
||||
|
||||
# 服務依賴
|
||||
- service_dependencies: list[str] (服務依賴清單)
|
||||
- dependency_count: int (依賴數量)
|
||||
|
||||
# 回滾
|
||||
- rollback_difficulty: int (1-5)
|
||||
- can_rollback_immediately: bool (是否可立即回滾)
|
||||
- irreversible: bool (是否不可逆)
|
||||
|
||||
# 停機時間
|
||||
- downtime_minutes: int (預估停機時間)
|
||||
- zero_downtime: bool (是否零停機)
|
||||
|
||||
# 安全
|
||||
- involves_secrets: bool (是否涉及 Secret)
|
||||
- involves_rbac: bool (是否涉及 RBAC)
|
||||
- security_sensitive: bool (是否安全敏感)
|
||||
|
||||
# 業務
|
||||
- business_criticality: str | BusinessCriticality (業務關鍵度)
|
||||
- is_core_service: bool (是否核心服務)
|
||||
|
||||
# 歷史
|
||||
- has_playbook: bool (是否有 Playbook)
|
||||
- has_history: bool (是否有歷史案例)
|
||||
|
||||
# 其他 (向後相容)
|
||||
- requires_code_analysis: bool
|
||||
- cross_system: bool
|
||||
- has_history: bool
|
||||
- severity: str
|
||||
|
||||
Returns:
|
||||
ComplexityScore: 評分結果
|
||||
"""
|
||||
raw_score = 1.0 # 基準分
|
||||
features: dict[str, int] = {}
|
||||
reasons: list[str] = []
|
||||
dimensions: list[DimensionScore] = []
|
||||
features: dict[str, int] = {} # 向後相容
|
||||
|
||||
# 特徵 1: 服務數量
|
||||
services = context.get("affected_services", [])
|
||||
service_count = len(services)
|
||||
if service_count > 1:
|
||||
delta = (service_count - 1) * self.WEIGHTS["service_count"]
|
||||
raw_score += delta
|
||||
features["service_count"] = service_count
|
||||
reasons.append(f"涉及 {service_count} 個服務")
|
||||
# =======================================================================
|
||||
# 評估各維度
|
||||
# =======================================================================
|
||||
|
||||
# 特徵 2: 指標數量
|
||||
metrics = context.get("metrics", [])
|
||||
metric_count = len(metrics)
|
||||
if metric_count > 2:
|
||||
delta = (metric_count - 2) * self.WEIGHTS["metric_count"]
|
||||
raw_score += delta
|
||||
features["metric_count"] = metric_count
|
||||
reasons.append(f"涉及 {metric_count} 個指標")
|
||||
# 維度 1: 資源數量
|
||||
dim1 = self._score_resource_count(context)
|
||||
if dim1:
|
||||
dimensions.append(dim1)
|
||||
features["resource_count"] = dim1.normalized_score
|
||||
|
||||
# 特徵 3: 是否需要程式碼分析
|
||||
if context.get("requires_code_analysis", False):
|
||||
raw_score += self.WEIGHTS["code_analysis"]
|
||||
features["code_analysis"] = 1
|
||||
reasons.append("需要程式碼分析")
|
||||
# 維度 2: 跨命名空間
|
||||
dim2 = self._score_cross_namespace(context)
|
||||
if dim2:
|
||||
dimensions.append(dim2)
|
||||
features["cross_namespace"] = dim2.normalized_score
|
||||
|
||||
# 特徵 4: 是否跨系統
|
||||
if context.get("cross_system", False):
|
||||
raw_score += self.WEIGHTS["cross_system"]
|
||||
features["cross_system"] = 1
|
||||
reasons.append("跨系統問題")
|
||||
# 維度 3: 有狀態資源
|
||||
dim3 = self._score_stateful_resource(context)
|
||||
if dim3:
|
||||
dimensions.append(dim3)
|
||||
features["stateful_resource"] = dim3.normalized_score
|
||||
|
||||
# 特徵 5: 是否有歷史關聯
|
||||
if context.get("has_history", False):
|
||||
raw_score += self.WEIGHTS["has_history"] # 負數,降低複雜度
|
||||
# 維度 4: 資料影響
|
||||
dim4 = self._score_data_impact(context)
|
||||
if dim4:
|
||||
dimensions.append(dim4)
|
||||
features["data_impact"] = dim4.normalized_score
|
||||
|
||||
# 維度 5: 服務依賴
|
||||
dim5 = self._score_service_dependencies(context)
|
||||
if dim5:
|
||||
dimensions.append(dim5)
|
||||
features["service_dependencies"] = dim5.normalized_score
|
||||
|
||||
# 維度 6: 回滾難度
|
||||
dim6 = self._score_rollback_difficulty(context)
|
||||
if dim6:
|
||||
dimensions.append(dim6)
|
||||
features["rollback_difficulty"] = dim6.normalized_score
|
||||
|
||||
# 維度 7: 停機時間
|
||||
dim7 = self._score_downtime(context)
|
||||
if dim7:
|
||||
dimensions.append(dim7)
|
||||
features["downtime_estimate"] = dim7.normalized_score
|
||||
|
||||
# 維度 8: 安全敏感度
|
||||
dim8 = self._score_security_sensitivity(context)
|
||||
if dim8:
|
||||
dimensions.append(dim8)
|
||||
features["security_sensitivity"] = dim8.normalized_score
|
||||
|
||||
# 維度 9: 業務關鍵度
|
||||
dim9 = self._score_business_criticality(context)
|
||||
if dim9:
|
||||
dimensions.append(dim9)
|
||||
features["business_criticality"] = dim9.normalized_score
|
||||
|
||||
# 降低複雜度的維度
|
||||
dim_playbook = self._score_has_playbook(context)
|
||||
if dim_playbook:
|
||||
dimensions.append(dim_playbook)
|
||||
features["has_playbook"] = 1
|
||||
|
||||
dim_history = self._score_has_history(context)
|
||||
if dim_history:
|
||||
dimensions.append(dim_history)
|
||||
features["has_history"] = 1
|
||||
reasons.append("有歷史案例參考")
|
||||
|
||||
# 特徵 6: 嚴重程度
|
||||
severity = context.get("severity", "").upper()
|
||||
if severity == "CRITICAL":
|
||||
raw_score += self.WEIGHTS["critical_severity"]
|
||||
features["severity"] = 4
|
||||
reasons.append("CRITICAL 嚴重程度")
|
||||
elif severity == "HIGH":
|
||||
raw_score += 0.5
|
||||
features["severity"] = 3
|
||||
# =======================================================================
|
||||
# 計算加權平均
|
||||
# =======================================================================
|
||||
|
||||
# 正規化到 1-5
|
||||
final_score = max(1, min(5, round(raw_score)))
|
||||
if not dimensions:
|
||||
# 無維度資料,返回基本分數
|
||||
final_score = 1
|
||||
raw_weighted_sum = 1.0
|
||||
total_weight = 1.0
|
||||
reasoning = "基本複雜度 (無足夠資訊)"
|
||||
else:
|
||||
# 計算加權總分
|
||||
weighted_sum = sum(d.weighted_score for d in dimensions)
|
||||
total_weight = sum(abs(d.weight) for d in dimensions)
|
||||
|
||||
# 選擇推薦模型
|
||||
recommended_model = MODEL_BY_COMPLEXITY.get(
|
||||
final_score, "qwen2.5:7b-instruct"
|
||||
)
|
||||
# 加權平均
|
||||
if total_weight > 0:
|
||||
avg_score = weighted_sum / total_weight
|
||||
else:
|
||||
avg_score = 1.0
|
||||
|
||||
# 正規化到 1-5
|
||||
final_score = max(1, min(5, round(avg_score)))
|
||||
raw_weighted_sum = weighted_sum
|
||||
|
||||
# 生成 reasoning
|
||||
high_impact_dims = [d for d in dimensions if d.normalized_score >= 4]
|
||||
if high_impact_dims:
|
||||
reasons = [d.reason for d in high_impact_dims[:3]] # 最多 3 個
|
||||
reasoning = "; ".join(reasons)
|
||||
else:
|
||||
reasons = [d.reason for d in dimensions if d.normalized_score >= 2][:3]
|
||||
reasoning = "; ".join(reasons) if reasons else "基本複雜度"
|
||||
|
||||
# =======================================================================
|
||||
# 從 ModelRegistry 取得推薦模型
|
||||
# =======================================================================
|
||||
|
||||
registry = get_model_registry()
|
||||
recommended_model = registry.get_model_by_complexity(final_score)
|
||||
|
||||
result = ComplexityScore(
|
||||
score=final_score,
|
||||
features=features,
|
||||
recommended_model=recommended_model,
|
||||
reasoning="; ".join(reasons) if reasons else "基本複雜度",
|
||||
reasoning=reasoning,
|
||||
dimensions=dimensions,
|
||||
raw_weighted_sum=raw_weighted_sum,
|
||||
total_weight=total_weight,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
@@ -147,12 +423,361 @@ class ComplexityScorer:
|
||||
score=final_score,
|
||||
features=features,
|
||||
model=recommended_model,
|
||||
dimension_count=len(dimensions),
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
# ==========================================================================
|
||||
# 維度評分方法
|
||||
# ==========================================================================
|
||||
|
||||
def _score_resource_count(self, context: dict) -> DimensionScore | None:
|
||||
"""維度 1: 資源數量"""
|
||||
# 優先使用 resource_count,否則計算 affected_services
|
||||
count = context.get("resource_count")
|
||||
if count is None:
|
||||
services = context.get("affected_services", [])
|
||||
if not services:
|
||||
return None
|
||||
count = len(services)
|
||||
|
||||
if count < 1:
|
||||
return None
|
||||
|
||||
# 計算分數
|
||||
score = 1
|
||||
for threshold, s in sorted(self.RESOURCE_COUNT_THRESHOLDS.items()):
|
||||
if count >= threshold:
|
||||
score = s
|
||||
|
||||
weight = self._weights.get("resource_count", 1.0)
|
||||
|
||||
return DimensionScore(
|
||||
name="resource_count",
|
||||
raw_value=count,
|
||||
normalized_score=score,
|
||||
weight=weight,
|
||||
weighted_score=score * weight,
|
||||
reason=f"{count} 個資源" if count <= 5 else f"{count} 個資源 (大規模)",
|
||||
)
|
||||
|
||||
def _score_cross_namespace(self, context: dict) -> DimensionScore | None:
|
||||
"""維度 2: 跨命名空間"""
|
||||
# 直接標記
|
||||
cross_ns = context.get("cross_namespace", False)
|
||||
|
||||
# 或從 namespaces 推斷
|
||||
if not cross_ns:
|
||||
namespaces = context.get("namespaces", [])
|
||||
cross_ns = len(namespaces) > 1
|
||||
|
||||
# 或從 cross_system 推斷 (向後相容)
|
||||
if not cross_ns:
|
||||
cross_ns = context.get("cross_system", False)
|
||||
|
||||
if not cross_ns:
|
||||
return None
|
||||
|
||||
namespaces = context.get("namespaces", [])
|
||||
ns_count = len(namespaces) if namespaces else 2
|
||||
|
||||
# 跨命名空間基本分數 = 3,多個 = 4-5
|
||||
score = 3 if ns_count <= 2 else (4 if ns_count <= 4 else 5)
|
||||
weight = self._weights.get("cross_namespace", 1.5)
|
||||
|
||||
return DimensionScore(
|
||||
name="cross_namespace",
|
||||
raw_value=True,
|
||||
normalized_score=score,
|
||||
weight=weight,
|
||||
weighted_score=score * weight,
|
||||
reason=f"跨 {ns_count} 個命名空間" if ns_count > 1 else "跨命名空間操作",
|
||||
)
|
||||
|
||||
def _score_stateful_resource(self, context: dict) -> DimensionScore | None:
|
||||
"""維度 3: 有狀態資源 (StatefulSet, PVC)"""
|
||||
stateful_resources = context.get("stateful_resources", [])
|
||||
has_sts = context.get("has_statefulset", False)
|
||||
has_pvc = context.get("has_pvc", False)
|
||||
|
||||
if not stateful_resources and not has_sts and not has_pvc:
|
||||
return None
|
||||
|
||||
# 計算分數
|
||||
if has_pvc or "pvc" in str(stateful_resources).lower():
|
||||
score = 5 # PVC 最高風險
|
||||
reason = "涉及 PVC (資料持久化)"
|
||||
elif has_sts or "statefulset" in str(stateful_resources).lower():
|
||||
score = 4 # StatefulSet 高風險
|
||||
reason = "涉及 StatefulSet (有序部署)"
|
||||
else:
|
||||
score = 3
|
||||
reason = f"涉及 {len(stateful_resources)} 個有狀態資源"
|
||||
|
||||
weight = self._weights.get("stateful_resource", 2.0)
|
||||
|
||||
return DimensionScore(
|
||||
name="stateful_resource",
|
||||
raw_value=stateful_resources or [has_sts, has_pvc],
|
||||
normalized_score=score,
|
||||
weight=weight,
|
||||
weighted_score=score * weight,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
def _score_data_impact(self, context: dict) -> DimensionScore | None:
|
||||
"""維度 4: 資料影響"""
|
||||
impact = context.get("data_impact")
|
||||
|
||||
if impact is None:
|
||||
return None
|
||||
|
||||
# 轉換為 Enum
|
||||
if isinstance(impact, str):
|
||||
try:
|
||||
impact = DataImpact(impact.lower())
|
||||
except ValueError:
|
||||
return None
|
||||
elif not isinstance(impact, DataImpact):
|
||||
return None
|
||||
|
||||
if impact == DataImpact.NONE:
|
||||
return None # 無影響不計分
|
||||
|
||||
score = self.DATA_IMPACT_SCORES.get(impact, 1)
|
||||
weight = self._weights.get("data_impact", 2.0)
|
||||
|
||||
reason_map = {
|
||||
DataImpact.READ_ONLY: "只讀操作",
|
||||
DataImpact.WRITE: "寫入操作 (資料變更)",
|
||||
DataImpact.DESTRUCTIVE: "破壞性操作 (不可恢復)",
|
||||
}
|
||||
|
||||
return DimensionScore(
|
||||
name="data_impact",
|
||||
raw_value=impact,
|
||||
normalized_score=score,
|
||||
weight=weight,
|
||||
weighted_score=score * weight,
|
||||
reason=reason_map.get(impact, "資料影響"),
|
||||
)
|
||||
|
||||
def _score_service_dependencies(self, context: dict) -> DimensionScore | None:
|
||||
"""維度 5: 服務依賴"""
|
||||
deps = context.get("service_dependencies", [])
|
||||
dep_count = context.get("dependency_count")
|
||||
|
||||
if dep_count is None:
|
||||
dep_count = len(deps) if deps else 0
|
||||
|
||||
if dep_count == 0:
|
||||
return None
|
||||
|
||||
# 計算分數
|
||||
score = 1
|
||||
for threshold, s in sorted(self.SERVICE_DEPENDENCY_THRESHOLDS.items()):
|
||||
if dep_count >= threshold:
|
||||
score = s
|
||||
|
||||
weight = self._weights.get("service_dependencies", 1.0)
|
||||
|
||||
return DimensionScore(
|
||||
name="service_dependencies",
|
||||
raw_value=dep_count,
|
||||
normalized_score=score,
|
||||
weight=weight,
|
||||
weighted_score=score * weight,
|
||||
reason=f"依賴 {dep_count} 個服務",
|
||||
)
|
||||
|
||||
def _score_rollback_difficulty(self, context: dict) -> DimensionScore | None:
|
||||
"""維度 6: 回滾難度"""
|
||||
# 直接指定難度
|
||||
difficulty = context.get("rollback_difficulty")
|
||||
|
||||
if difficulty is None:
|
||||
# 從其他欄位推斷
|
||||
if context.get("irreversible", False):
|
||||
difficulty = 5
|
||||
elif context.get("can_rollback_immediately", True):
|
||||
return None # 可立即回滾,不加分
|
||||
else:
|
||||
difficulty = 3 # 預設中等
|
||||
|
||||
if difficulty is None or difficulty < 2:
|
||||
return None
|
||||
|
||||
score = max(1, min(5, difficulty))
|
||||
weight = self._weights.get("rollback_difficulty", 1.5)
|
||||
|
||||
reason_map = {
|
||||
2: "回滾需要額外步驟",
|
||||
3: "回滾難度中等",
|
||||
4: "回滾困難 (需人工介入)",
|
||||
5: "不可逆操作",
|
||||
}
|
||||
|
||||
return DimensionScore(
|
||||
name="rollback_difficulty",
|
||||
raw_value=difficulty,
|
||||
normalized_score=score,
|
||||
weight=weight,
|
||||
weighted_score=score * weight,
|
||||
reason=reason_map.get(score, f"回滾難度 {score}"),
|
||||
)
|
||||
|
||||
def _score_downtime(self, context: dict) -> DimensionScore | None:
|
||||
"""維度 7: 停機時間"""
|
||||
if context.get("zero_downtime", False):
|
||||
return None # 零停機不加分
|
||||
|
||||
downtime = context.get("downtime_minutes")
|
||||
if downtime is None or downtime == 0:
|
||||
return None
|
||||
|
||||
# 計算分數
|
||||
score = 1
|
||||
for threshold, s in sorted(self.DOWNTIME_THRESHOLDS.items()):
|
||||
if downtime >= threshold:
|
||||
score = s
|
||||
|
||||
weight = self._weights.get("downtime_estimate", 1.0)
|
||||
|
||||
if downtime < 5:
|
||||
reason = f"預估停機 {downtime} 分鐘"
|
||||
elif downtime < 15:
|
||||
reason = f"預估停機 {downtime} 分鐘 (中等)"
|
||||
else:
|
||||
reason = f"預估停機 {downtime} 分鐘 (長時間)"
|
||||
|
||||
return DimensionScore(
|
||||
name="downtime_estimate",
|
||||
raw_value=downtime,
|
||||
normalized_score=score,
|
||||
weight=weight,
|
||||
weighted_score=score * weight,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
def _score_security_sensitivity(self, context: dict) -> DimensionScore | None:
|
||||
"""維度 8: 安全敏感度 (Secret/RBAC)"""
|
||||
involves_secrets = context.get("involves_secrets", False)
|
||||
involves_rbac = context.get("involves_rbac", False)
|
||||
security_sensitive = context.get("security_sensitive", False)
|
||||
|
||||
if not involves_secrets and not involves_rbac and not security_sensitive:
|
||||
return None
|
||||
|
||||
# 計算分數
|
||||
if involves_rbac:
|
||||
score = 5 # RBAC 最敏感
|
||||
reason = "涉及 RBAC 權限變更"
|
||||
elif involves_secrets:
|
||||
score = 4 # Secret 高敏感
|
||||
reason = "涉及 Secret 操作"
|
||||
else:
|
||||
score = 3
|
||||
reason = "安全敏感操作"
|
||||
|
||||
weight = self._weights.get("security_sensitivity", 1.5)
|
||||
|
||||
return DimensionScore(
|
||||
name="security_sensitivity",
|
||||
raw_value={"secrets": involves_secrets, "rbac": involves_rbac},
|
||||
normalized_score=score,
|
||||
weight=weight,
|
||||
weighted_score=score * weight,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
def _score_business_criticality(self, context: dict) -> DimensionScore | None:
|
||||
"""維度 9: 業務關鍵度"""
|
||||
criticality = context.get("business_criticality")
|
||||
|
||||
if criticality is None:
|
||||
# 從 is_core_service 推斷
|
||||
if context.get("is_core_service", False):
|
||||
criticality = BusinessCriticality.CRITICAL
|
||||
else:
|
||||
return None
|
||||
|
||||
# 轉換為 Enum
|
||||
if isinstance(criticality, str):
|
||||
try:
|
||||
criticality = BusinessCriticality(criticality.lower())
|
||||
except ValueError:
|
||||
# 嘗試映射常見值
|
||||
mapping = {
|
||||
"low": BusinessCriticality.NON_CRITICAL,
|
||||
"medium": BusinessCriticality.IMPORTANT,
|
||||
"high": BusinessCriticality.CRITICAL,
|
||||
}
|
||||
criticality = mapping.get(criticality.lower())
|
||||
if criticality is None:
|
||||
return None
|
||||
elif not isinstance(criticality, BusinessCriticality):
|
||||
return None
|
||||
|
||||
if criticality == BusinessCriticality.NON_CRITICAL:
|
||||
return None # 非關鍵不加分
|
||||
|
||||
score = self.BUSINESS_CRITICALITY_SCORES.get(criticality, 1)
|
||||
weight = self._weights.get("business_criticality", 1.5)
|
||||
|
||||
reason_map = {
|
||||
BusinessCriticality.SUPPORTING: "支援服務",
|
||||
BusinessCriticality.IMPORTANT: "重要服務",
|
||||
BusinessCriticality.CRITICAL: "核心服務",
|
||||
BusinessCriticality.MISSION_CRITICAL: "業務命脈 (最高優先)",
|
||||
}
|
||||
|
||||
return DimensionScore(
|
||||
name="business_criticality",
|
||||
raw_value=criticality,
|
||||
normalized_score=score,
|
||||
weight=weight,
|
||||
weighted_score=score * weight,
|
||||
reason=reason_map.get(criticality, "業務關鍵度"),
|
||||
)
|
||||
|
||||
def _score_has_playbook(self, context: dict) -> DimensionScore | None:
|
||||
"""降低複雜度: 有 Playbook"""
|
||||
if not context.get("has_playbook", False):
|
||||
return None
|
||||
|
||||
weight = self._weights.get("has_playbook", -0.5)
|
||||
|
||||
return DimensionScore(
|
||||
name="has_playbook",
|
||||
raw_value=True,
|
||||
normalized_score=1, # 正向降低
|
||||
weight=weight, # 負權重
|
||||
weighted_score=1 * weight, # 負分
|
||||
reason="有歷史 Playbook (降低複雜度)",
|
||||
)
|
||||
|
||||
def _score_has_history(self, context: dict) -> DimensionScore | None:
|
||||
"""降低複雜度: 有歷史案例"""
|
||||
if not context.get("has_history", False):
|
||||
return None
|
||||
|
||||
weight = self._weights.get("has_history", -0.5)
|
||||
|
||||
return DimensionScore(
|
||||
name="has_history",
|
||||
raw_value=True,
|
||||
normalized_score=1,
|
||||
weight=weight,
|
||||
weighted_score=1 * weight,
|
||||
reason="有歷史案例參考 (降低複雜度)",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Singleton
|
||||
# =============================================================================
|
||||
|
||||
# 單例
|
||||
_scorer: ComplexityScorer | None = None
|
||||
|
||||
|
||||
@@ -162,3 +787,19 @@ def get_complexity_scorer() -> ComplexityScorer:
|
||||
if _scorer is None:
|
||||
_scorer = ComplexityScorer()
|
||||
return _scorer
|
||||
|
||||
|
||||
def reset_complexity_scorer() -> None:
|
||||
"""重置單例 (用於測試)"""
|
||||
global _scorer
|
||||
_scorer = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Convenience Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def score_complexity(context: dict) -> ComplexityScore:
|
||||
"""便捷函數: 計算複雜度"""
|
||||
return get_complexity_scorer().score(context)
|
||||
|
||||
Reference in New Issue
Block a user