feat(ai_router): feedback_from_aider_events read-only hook (Phase 24 A8)
This commit is contained in:
@@ -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
|
||||
|
||||
93
apps/api/tests/test_ai_router_feedback.py
Normal file
93
apps/api/tests/test_ai_router_feedback.py
Normal file
@@ -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 == {}
|
||||
Reference in New Issue
Block a user