diff --git a/apps/api/src/repositories/playbook_repository.py b/apps/api/src/repositories/playbook_repository.py index 347be26dd..468dd2d9b 100644 --- a/apps/api/src/repositories/playbook_repository.py +++ b/apps/api/src/repositories/playbook_repository.py @@ -11,6 +11,9 @@ Phase 7.2: Repository 實作 - 實作 IPlaybookRepository Protocol - Redis 為 Working Memory (7天 TTL) - PostgreSQL 為 Episodic Memory + +Phase 22 P2: 相似度計算邏輯移至 utils +2026-03-31 Claude Code (首席架構師技術債修復) """ import json @@ -24,6 +27,7 @@ from src.models.playbook import ( SymptomPattern, ) from src.repositories.interfaces import IPlaybookRepository +from src.utils.similarity import calculate_symptom_similarity from src.utils.timezone import now_taipei logger = structlog.get_logger(__name__) @@ -37,63 +41,6 @@ PLAYBOOK_INDEX_ALERT_PREFIX = "playbook:index:alert:" PLAYBOOK_INDEX_SERVICE_PREFIX = "playbook:index:service:" -def _calculate_jaccard_similarity(set_a: set, set_b: set) -> float: - """計算 Jaccard 相似度""" - if not set_a and not set_b: - return 1.0 - intersection = len(set_a & set_b) - union = len(set_a | set_b) - return intersection / union if union > 0 else 0.0 - - -def calculate_symptom_similarity( - pattern_a: SymptomPattern, - pattern_b: SymptomPattern, -) -> float: - """ - 計算症狀相似度 - - 算法: 加權 Jaccard 相似度 - - 維度權重: - - alert_names: 0.35 (最重要) - - affected_services: 0.30 - - severity: 0.15 - - keywords: 0.20 - - Returns: - float: 0.0 ~ 1.0 相似度分數 - """ - weights = { - "alert_names": 0.35, - "affected_services": 0.30, - "severity": 0.15, - "keywords": 0.20, - } - - scores = { - "alert_names": _calculate_jaccard_similarity( - set(pattern_a.alert_names), - set(pattern_b.alert_names), - ), - "affected_services": _calculate_jaccard_similarity( - set(pattern_a.affected_services), - set(pattern_b.affected_services), - ), - "severity": ( - 1.0 - if set(pattern_a.severity_range) & set(pattern_b.severity_range) - else 0.0 - ), - "keywords": _calculate_jaccard_similarity( - set(pattern_a.keywords), - set(pattern_b.keywords), - ), - } - - return sum(weights[k] * scores[k] for k in weights) - - class PlaybookRepository: """ Playbook Repository 實作 diff --git a/apps/api/src/services/learning_service.py b/apps/api/src/services/learning_service.py index 9ecb90cec..be54d6549 100644 --- a/apps/api/src/services/learning_service.py +++ b/apps/api/src/services/learning_service.py @@ -655,7 +655,12 @@ class LearningService: """ 取得學習摘要 - 2026-03-29 P0 修正: 委託 Repository 實作 + Phase 22 P2: 業務邏輯移至 Service 層 + 2026-03-31 Claude Code (首席架構師技術債修復) + + 邏輯: + - 從 Repository 取得原始統計數據 + - 在 Service 層計算 best_action 和 learning_status Returns: { @@ -667,7 +672,51 @@ class LearningService: 'learning_status': 'sufficient', } """ - return await self._repository.get_learning_summary(anomaly_key) + # 從 Repository 取得原始統計 + all_stats = await self._repository.get_all_repair_stats(anomaly_key) + + if not all_stats: + return { + "anomaly_key": anomaly_key, + "total_repair_attempts": 0, + "overall_success_rate": 0.0, + "actions_tried": [], + "best_action": None, + "learning_status": "insufficient", + } + + # === 以下為業務邏輯,應在 Service 層 === + + total_attempts = sum(s["total"] for s in all_stats.values()) + total_success = sum(s["success"] for s in all_stats.values()) + overall_rate = total_success / total_attempts if total_attempts > 0 else 0.0 + + # 找出最佳動作 (需要至少 3 次數據) + best_action = None + best_rate = 0.0 + for action, stats in all_stats.items(): + if stats["total"] >= 3 and stats["success_rate"] > best_rate: + best_rate = stats["success_rate"] + best_action = {"action": action, "success_rate": best_rate} + + # 判斷學習狀態 + if total_attempts < 3: + learning_status = "insufficient" + elif total_attempts < 10: + learning_status = "learning" + elif overall_rate >= 0.8: + learning_status = "excellent" + else: + learning_status = "sufficient" + + return { + "anomaly_key": anomaly_key, + "total_repair_attempts": total_attempts, + "overall_success_rate": overall_rate, + "actions_tried": list(all_stats.keys()), + "best_action": best_action, + "learning_status": learning_status, + } def _get_action_tier(self, action: str) -> int: """取得動作的 Tier""" diff --git a/apps/api/src/utils/similarity.py b/apps/api/src/utils/similarity.py new file mode 100644 index 000000000..7a87720d9 --- /dev/null +++ b/apps/api/src/utils/similarity.py @@ -0,0 +1,87 @@ +""" +Similarity Calculation Utils +============================= +Phase 22 P2: 將相似度計算邏輯從 Repository 移出 + +設計原則: +- 演算法邏輯應獨立於資料存取層 +- Repository 只負責 CRUD,不負責演算法 +- Service 層可以使用這些工具函數 + +版本: v1.0 +建立: 2026-03-31 (台北時區) +建立者: Claude Code (首席架構師技術債修復) +""" + +from src.models.playbook import SymptomPattern + + +def calculate_jaccard_similarity(set_a: set, set_b: set) -> float: + """ + 計算 Jaccard 相似度 + + Jaccard = |A ∩ B| / |A ∪ B| + + Args: + set_a: 集合 A + set_b: 集合 B + + Returns: + float: 0.0 ~ 1.0 + """ + if not set_a and not set_b: + return 1.0 # 兩個空集合視為完全相同 + if not set_a or not set_b: + return 0.0 + + intersection = len(set_a & set_b) + union = len(set_a | set_b) + return intersection / union if union > 0 else 0.0 + + +def calculate_symptom_similarity( + pattern_a: SymptomPattern, + pattern_b: SymptomPattern, +) -> float: + """ + 計算症狀相似度 + + 算法: 加權 Jaccard 相似度 + + 維度權重: + - alert_names: 0.35 (最重要) + - affected_services: 0.30 + - severity: 0.15 + - keywords: 0.20 + + Returns: + float: 0.0 ~ 1.0 相似度分數 + """ + weights = { + "alert_names": 0.35, + "affected_services": 0.30, + "severity": 0.15, + "keywords": 0.20, + } + + scores = { + "alert_names": calculate_jaccard_similarity( + set(pattern_a.alert_names), + set(pattern_b.alert_names), + ), + "affected_services": calculate_jaccard_similarity( + set(pattern_a.affected_services), + set(pattern_b.affected_services), + ), + "severity": ( + 1.0 + if set(pattern_a.severity_range) & set(pattern_b.severity_range) + else 0.0 + ), + "keywords": calculate_jaccard_similarity( + set(pattern_a.keywords), + set(pattern_b.keywords), + ), + } + + return sum(weights[k] * scores[k] for k in weights)