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:
OG T
2026-03-26 10:01:57 +08:00
parent 58b4004a18
commit e7f361db50
5 changed files with 664 additions and 190 deletions

View File

@@ -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",
]

View File

@@ -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]: 每小時成功率列表 (由舊到新)
"""
...

View 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