feat(ai_router): feedback_from_aider_events read-only hook (Phase 24 A8)

This commit is contained in:
Your Name
2026-04-20 08:47:02 +08:00
parent df72da69e2
commit 40771cda6d
2 changed files with 137 additions and 0 deletions

View File

@@ -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

View 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 == {} # 降級為空 dictcaller 不該崩
@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 == {}