diff --git a/apps/api/src/services/ai_router.py b/apps/api/src/services/ai_router.py index e2bbb053..b960b9b8 100644 --- a/apps/api/src/services/ai_router.py +++ b/apps/api/src/services/ai_router.py @@ -655,6 +655,50 @@ class AIRouter: }, ] + async def feedback_from_aider_events( + self, + repo: str | None = None, + days: int = 7, + ) -> dict[str, float]: + """從 aider_events 聚合近 N 日 success rate per model。 + + Phase 24 ADR-052 延伸:AI 自主化 feedback loop。 + 目前為 read-only(未接入 route() 決策),等 USE_AIDER_FEEDBACK flag + + 7 天灰度驗證後才會調整 provider 權重。 + + Args: + repo: 若給定,只聚合該 repo 的 session;否則所有 repo。 + days: 時間窗口(預設 7 天)。 + + Returns: + {model_name: success_rate_float} e.g. {"elephant-alpha": 0.85, "gemini-pro": 0.92} + 空 dict 代表無資料或查詢失敗(caller 應降級為忽略)。 + """ + try: + from src.db.base import get_session_factory + from src.repositories.aider_event_repository import AiderEventRepository + except ImportError: + return {} + + try: + sf = get_session_factory() + async with sf() as sess: + repo_obj = AiderEventRepository(sess) + stats = await repo_obj.model_stats_since(days=days) + except Exception: + logger.debug("ai_router_feedback_aggregation_failed") + return {} + + out: dict[str, float] = {} + for row in stats: + if repo and row.get("repo") != repo: + continue + model = row.get("model") + if not model: + continue + out[model] = float(row.get("success_rate") or 0) + return out + # ============================================================================= # Phase 24 ADR-052: AI Provider Registry + Execution Layer diff --git a/apps/api/tests/test_ai_router_feedback.py b/apps/api/tests/test_ai_router_feedback.py new file mode 100644 index 00000000..3586c322 --- /dev/null +++ b/apps/api/tests/test_ai_router_feedback.py @@ -0,0 +1,93 @@ +# apps/api/tests/test_ai_router_feedback.py | 2026-04-20 @ Asia/Taipei +"""Task A8: AIRouter.feedback_from_aider_events read-only aggregation test.""" +import pytest +from unittest.mock import AsyncMock, MagicMock +from src.services.ai_router import AIRouter + + +@pytest.mark.asyncio +async def test_feedback_aggregates_by_model(monkeypatch): + stats = [ + {"repo": "awoooi", "model": "elephant-alpha", "total": 10, + "errors": 2, "success_rate": 0.8}, + {"repo": "awoooi", "model": "gemini-pro", "total": 5, + "errors": 0, "success_rate": 1.0}, + ] + + class FakeRepo: + def __init__(self, sess): pass + async def model_stats_since(self, days): return stats + + class FakeSession: + async def __aenter__(self): return self + async def __aexit__(self, *a): return False + + monkeypatch.setattr("src.services.ai_router.get_session_factory", + lambda: (lambda: FakeSession()), raising=False) + monkeypatch.setattr("src.repositories.aider_event_repository.AiderEventRepository", + FakeRepo) + + r = AIRouter() + out = await r.feedback_from_aider_events(days=7) + assert out["elephant-alpha"] == 0.8 + assert out["gemini-pro"] == 1.0 + + +@pytest.mark.asyncio +async def test_feedback_filters_by_repo(monkeypatch): + stats = [ + {"repo": "awoooi", "model": "elephant-alpha", "total": 5, + "errors": 1, "success_rate": 0.8}, + {"repo": "other-repo", "model": "elephant-alpha", "total": 3, + "errors": 3, "success_rate": 0.0}, + ] + + class FakeRepo: + def __init__(self, sess): pass + async def model_stats_since(self, days): return stats + + class FakeSession: + async def __aenter__(self): return self + async def __aexit__(self, *a): return False + + monkeypatch.setattr("src.services.ai_router.get_session_factory", + lambda: (lambda: FakeSession()), raising=False) + monkeypatch.setattr("src.repositories.aider_event_repository.AiderEventRepository", + FakeRepo) + + r = AIRouter() + out = await r.feedback_from_aider_events(repo="awoooi", days=7) + assert out == {"elephant-alpha": 0.8} # other-repo 過濾掉 + + +@pytest.mark.asyncio +async def test_feedback_returns_empty_on_db_failure(monkeypatch): + def fail_sf(): + raise RuntimeError("DB unavailable") + + monkeypatch.setattr("src.services.ai_router.get_session_factory", + fail_sf, raising=False) + + r = AIRouter() + out = await r.feedback_from_aider_events(days=7) + assert out == {} # 降級為空 dict,caller 不該崩 + + +@pytest.mark.asyncio +async def test_feedback_handles_empty_stats(monkeypatch): + class FakeRepo: + def __init__(self, sess): pass + async def model_stats_since(self, days): return [] + + class FakeSession: + async def __aenter__(self): return self + async def __aexit__(self, *a): return False + + monkeypatch.setattr("src.services.ai_router.get_session_factory", + lambda: (lambda: FakeSession()), raising=False) + monkeypatch.setattr("src.repositories.aider_event_repository.AiderEventRepository", + FakeRepo) + + r = AIRouter() + out = await r.feedback_from_aider_events() + assert out == {}