refactor(api): Phase 17 metrics.py Router 層違規修復
移除 Router 層直接 DB 存取,遵循 leWOOOgo 積木化原則: - 新增 IMetricsRepository Protocol (interfaces.py) - 新增 MetricsDBRepository 封裝 DB 查詢 - 新增 MetricsService 封裝業務邏輯 - Router 層只做 HTTP 轉發 架構: Router → Service → Repository → PostgreSQL Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,18 +24,26 @@ from src.repositories.incident_repository import (
|
||||
from src.repositories.interfaces import (
|
||||
IApprovalRepository,
|
||||
IIncidentRepository,
|
||||
IMetricsRepository,
|
||||
ITimelineRepository,
|
||||
)
|
||||
from src.repositories.metrics_repository import (
|
||||
MetricsDBRepository,
|
||||
get_metrics_repository,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Interfaces
|
||||
"IApprovalRepository",
|
||||
"IIncidentRepository",
|
||||
"IMetricsRepository",
|
||||
"ITimelineRepository",
|
||||
# Implementations
|
||||
"ApprovalDBRepository",
|
||||
"IncidentDBRepository",
|
||||
"MetricsDBRepository",
|
||||
# Getters
|
||||
"get_approval_repository",
|
||||
"get_incident_repository",
|
||||
"get_metrics_repository",
|
||||
]
|
||||
|
||||
@@ -125,3 +125,49 @@ class ITimelineRepository(Protocol):
|
||||
) -> list[dict]:
|
||||
"""取得最近的 Timeline 事件"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IMetricsRepository(Protocol):
|
||||
"""
|
||||
Metrics Repository Protocol
|
||||
|
||||
職責: Metrics 相關 DB 查詢 (AI Success Rate)
|
||||
實作: MetricsDBRepository (PostgreSQL)
|
||||
|
||||
版本: v1.0
|
||||
建立: 2026-03-26 (台北時區)
|
||||
建立者: Claude Code (Phase 17 技術債修復)
|
||||
"""
|
||||
|
||||
async def get_ai_success_rate(
|
||||
self,
|
||||
hours: int = 24,
|
||||
) -> tuple[float, int, int]:
|
||||
"""
|
||||
計算 AI 提案成功執行率
|
||||
|
||||
Args:
|
||||
hours: 統計時間範圍 (小時)
|
||||
|
||||
Returns:
|
||||
(success_rate_percent, executed_count, total_count)
|
||||
"""
|
||||
...
|
||||
|
||||
async def get_ai_success_trend(
|
||||
self,
|
||||
hours: int = 24,
|
||||
points: int = 10,
|
||||
) -> list[float]:
|
||||
"""
|
||||
取得 AI 成功率趨勢 (Sparkline 用)
|
||||
|
||||
Args:
|
||||
hours: 統計時間範圍 (小時)
|
||||
points: 趨勢點數量
|
||||
|
||||
Returns:
|
||||
list[float]: 每小時成功率列表 (由舊到新)
|
||||
"""
|
||||
...
|
||||
|
||||
186
apps/api/src/repositories/metrics_repository.py
Normal file
186
apps/api/src/repositories/metrics_repository.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
Metrics Repository - PostgreSQL 實作
|
||||
=====================================
|
||||
Phase 17 技術債修復: Router 層違規抽取
|
||||
|
||||
職責: Metrics 相關 DB 查詢 (AI Success Rate 統計)
|
||||
設計: 實作 IMetricsRepository Protocol
|
||||
|
||||
版本: v1.0
|
||||
建立: 2026-03-26 (台北時區)
|
||||
建立者: Claude Code (Phase 17 技術債修復)
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import text
|
||||
|
||||
from src.db.base import get_db_context
|
||||
from src.repositories.interfaces import IMetricsRepository
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MetricsDBRepository
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class MetricsDBRepository(IMetricsRepository):
|
||||
"""
|
||||
Metrics Repository - PostgreSQL 實作
|
||||
|
||||
職責:
|
||||
- AI Success Rate 統計查詢
|
||||
- 趨勢數據查詢 (Sparkline)
|
||||
|
||||
純 CRUD 操作,不含業務邏輯
|
||||
業務邏輯請放在 Service 層
|
||||
"""
|
||||
|
||||
async def get_ai_success_rate(
|
||||
self,
|
||||
hours: int = 24,
|
||||
) -> tuple[float, int, int]:
|
||||
"""
|
||||
計算 AI 提案成功執行率
|
||||
|
||||
統帥鐵律: 若無數據,回傳真實的 0,嚴禁造假
|
||||
|
||||
Args:
|
||||
hours: 統計時間範圍 (小時)
|
||||
|
||||
Returns:
|
||||
(success_rate_percent, executed_count, total_count)
|
||||
"""
|
||||
try:
|
||||
async with get_db_context() as session:
|
||||
cutoff = datetime.now(UTC) - timedelta(hours=hours)
|
||||
cutoff_str = cutoff.isoformat()
|
||||
|
||||
# Query: 統計 executed vs total (approved + executed + execution_failed)
|
||||
query = text("""
|
||||
SELECT
|
||||
COUNT(CASE WHEN status = 'executed' THEN 1 END) as executed_count,
|
||||
COUNT(*) as total_count
|
||||
FROM approval_records
|
||||
WHERE created_at >= :cutoff
|
||||
AND status IN ('approved', 'executed', 'execution_failed')
|
||||
""")
|
||||
|
||||
result = await session.execute(query, {"cutoff": cutoff_str})
|
||||
row = result.fetchone()
|
||||
|
||||
if row and row.total_count > 0:
|
||||
executed = row.executed_count or 0
|
||||
total = row.total_count
|
||||
success_rate = (executed / total) * 100
|
||||
else:
|
||||
executed = 0
|
||||
total = 0
|
||||
success_rate = 0.0
|
||||
|
||||
logger.debug(
|
||||
"ai_success_rate_queried",
|
||||
hours=hours,
|
||||
executed=executed,
|
||||
total=total,
|
||||
rate=success_rate,
|
||||
)
|
||||
|
||||
return success_rate, executed, total
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"ai_success_rate_query_error",
|
||||
hours=hours,
|
||||
error=str(e),
|
||||
)
|
||||
# 統帥鐵律: 發生錯誤時回傳真實的 0,非假數據
|
||||
return 0.0, 0, 0
|
||||
|
||||
async def get_ai_success_trend(
|
||||
self,
|
||||
hours: int = 24,
|
||||
points: int = 10,
|
||||
) -> list[float]:
|
||||
"""
|
||||
取得 AI 成功率趨勢 (Sparkline 用)
|
||||
|
||||
統帥鐵律: 若無數據,回傳真實的 [0.0] * points,嚴禁造假
|
||||
|
||||
Args:
|
||||
hours: 統計時間範圍 (小時)
|
||||
points: 趨勢點數量
|
||||
|
||||
Returns:
|
||||
list[float]: 每小時成功率列表 (由舊到新)
|
||||
"""
|
||||
try:
|
||||
async with get_db_context() as session:
|
||||
cutoff = datetime.now(UTC) - timedelta(hours=hours)
|
||||
cutoff_str = cutoff.isoformat()
|
||||
|
||||
# Trend: 過去 N 個時間點的成功率 (每小時一點)
|
||||
trend_query = text("""
|
||||
SELECT
|
||||
strftime('%Y-%m-%d %H:00:00', created_at) as hour_bucket,
|
||||
COUNT(CASE WHEN status = 'executed' THEN 1 END) * 100.0 /
|
||||
NULLIF(COUNT(*), 0) as hourly_rate
|
||||
FROM approval_records
|
||||
WHERE created_at >= :cutoff
|
||||
AND status IN ('approved', 'executed', 'execution_failed')
|
||||
GROUP BY hour_bucket
|
||||
ORDER BY hour_bucket DESC
|
||||
LIMIT :limit
|
||||
""")
|
||||
|
||||
result = await session.execute(
|
||||
trend_query,
|
||||
{"cutoff": cutoff_str, "limit": points},
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
if rows:
|
||||
# 由舊到新排列
|
||||
trend_values = [float(r.hourly_rate or 0) for r in reversed(rows)]
|
||||
# 如果點數不足,前面補 0
|
||||
if len(trend_values) < points:
|
||||
trend_values = [0.0] * (points - len(trend_values)) + trend_values
|
||||
else:
|
||||
trend_values = [0.0] * points
|
||||
|
||||
logger.debug(
|
||||
"ai_success_trend_queried",
|
||||
hours=hours,
|
||||
points=points,
|
||||
actual_points=len(trend_values),
|
||||
)
|
||||
|
||||
return trend_values
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"ai_success_trend_query_error",
|
||||
hours=hours,
|
||||
points=points,
|
||||
error=str(e),
|
||||
)
|
||||
# 統帥鐵律: 發生錯誤時回傳真實的 0,非假數據
|
||||
return [0.0] * points
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Singleton
|
||||
# =============================================================================
|
||||
|
||||
_metrics_repository: MetricsDBRepository | None = None
|
||||
|
||||
|
||||
def get_metrics_repository() -> MetricsDBRepository:
|
||||
"""取得 MetricsDBRepository 實例 (Singleton)"""
|
||||
global _metrics_repository
|
||||
if _metrics_repository is None:
|
||||
_metrics_repository = MetricsDBRepository()
|
||||
return _metrics_repository
|
||||
Reference in New Issue
Block a user