diff --git a/apps/api/src/api/v1/stats.py b/apps/api/src/api/v1/stats.py index b78e14cb..4668dfef 100644 --- a/apps/api/src/api/v1/stats.py +++ b/apps/api/src/api/v1/stats.py @@ -122,6 +122,11 @@ class AIPerformance(BaseModel): effectiveness_distribution: dict[int, int] = Field( description="有效性評分分佈 {1: count, 2: count, ...}" ) + outcome_proposal_count: int = Field(default=0, description="Incident outcome 舊提案數") + outcome_executed_count: int = Field(default=0, description="Incident outcome 舊執行數") + auto_repair_total: int = Field(default=0, description="自動修復執行紀錄數") + auto_repair_success: int = Field(default=0, description="自動修復成功紀錄數") + source: str = Field(default="incident_outcome", description="AI 效能資料來源") class ServiceImpact(BaseModel): diff --git a/apps/api/src/services/stats_service.py b/apps/api/src/services/stats_service.py index 4eba53fc..63d5d5ca 100644 --- a/apps/api/src/services/stats_service.py +++ b/apps/api/src/services/stats_service.py @@ -27,7 +27,7 @@ from sqlalchemy import func, select from src.core.redis_client import get_redis from src.db.base import get_db_context -from src.db.models import IncidentRecord +from src.db.models import AutoRepairExecution, IncidentRecord from src.models.incident import IncidentStatus logger = structlog.get_logger(__name__) @@ -343,12 +343,26 @@ class StatsService: ) outcomes = [row[0] for row in result.all() if row[0]] - total = len(outcomes) - executed = sum(1 for o in outcomes if o.get("proposal_executed")) - success = sum( + outcome_total = len(outcomes) + outcome_executed = sum(1 for o in outcomes if o.get("proposal_executed")) + outcome_success = sum( 1 for o in outcomes if o.get("proposal_executed") and o.get("execution_success") ) + auto_result = await db.execute( + select( + AutoRepairExecution.success, + AutoRepairExecution.execution_time_ms, + ).where(AutoRepairExecution.created_at >= since) + ) + auto_rows = auto_result.all() + auto_total = len(auto_rows) + auto_success = sum(1 for row in auto_rows if row[0] is True) + + total = max(outcome_total, auto_total) + executed = max(outcome_executed, auto_total) + success = auto_success if auto_total > 0 else outcome_success + effectiveness_dist: dict[int, int] = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0} scores = [] for o in outcomes: @@ -367,6 +381,13 @@ class StatsService: "success_rate": round((success / executed * 100) if executed > 0 else 0, 2), "avg_effectiveness": round(avg_effectiveness, 2) if avg_effectiveness else None, "effectiveness_distribution": effectiveness_dist, + "outcome_proposal_count": outcome_total, + "outcome_executed_count": outcome_executed, + "auto_repair_total": auto_total, + "auto_repair_success": auto_success, + "source": "auto_repair_executions+incident_outcome" + if auto_total > 0 + else "incident_outcome", } return await self.get_cached_or_compute(cache_key, compute) diff --git a/apps/api/tests/test_stats_service_tenant_scope.py b/apps/api/tests/test_stats_service_tenant_scope.py index d2e7ed93..f4f1b531 100644 --- a/apps/api/tests/test_stats_service_tenant_scope.py +++ b/apps/api/tests/test_stats_service_tenant_scope.py @@ -22,11 +22,27 @@ class _EmptyResult: return [] +class _RowsResult: + def __init__(self, rows: list[tuple]) -> None: + self._rows = rows + + def all(self) -> list[tuple]: + return self._rows + + class _FakeDb: async def execute(self, statement) -> _EmptyResult: return _EmptyResult() +class _QueuedDb: + def __init__(self, results: list[_RowsResult]) -> None: + self._results = results + + async def execute(self, statement) -> _RowsResult: + return self._results.pop(0) + + @pytest.mark.asyncio async def test_stats_service_uses_default_project_for_db_context(monkeypatch) -> None: seen_project_ids: list[str | None] = [] @@ -68,3 +84,33 @@ async def test_stats_service_allows_explicit_project_for_db_context(monkeypatch) assert result["total_proposals"] == 0 assert seen_project_ids == ["vibework"] assert seen_cache_keys == ["stats:vibework:ai_performance:7"] + + +@pytest.mark.asyncio +async def test_ai_performance_uses_auto_repair_execution_truth(monkeypatch) -> None: + seen_project_ids: list[str | None] = [] + db = _QueuedDb( + [ + _RowsResult([]), + _RowsResult([(True, 1200), (False, 800), (True, 500)]), + ] + ) + + @asynccontextmanager + async def fake_db_context(project_id: str | None = None) -> AsyncGenerator[_QueuedDb, None]: + seen_project_ids.append(project_id) + yield db + + monkeypatch.setattr(stats_service_module, "get_db_context", fake_db_context) + monkeypatch.setattr(stats_service_module, "get_redis", lambda: _NoopRedis()) + + result = await StatsService().get_ai_performance(days=7) + + assert seen_project_ids == ["awoooi"] + assert result["total_proposals"] == 3 + assert result["executed_count"] == 3 + assert result["success_count"] == 2 + assert result["success_rate"] == 66.67 + assert result["auto_repair_total"] == 3 + assert result["auto_repair_success"] == 2 + assert result["source"] == "auto_repair_executions+incident_outcome"