"""리더보드 API — 누적 포인트 1위 (docs/SCORING.md §3 랭킹 규칙). user_points 테이블(채점 시 누적 갱신) 기준 랭킹. 1차 정렬: 누적 포인트. 동점: 정확스코어 횟수 → 적중률 → 참여수 → 최초도달. 주최측/임직원 배제(P1). 이메일은 마스킹하여 노출. """ from __future__ import annotations from fastapi import APIRouter, Depends, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from ..database import get_db from ..models import Match, UserPoints from ..scoring import is_excluded, score_prediction from ..schemas import ( AILeaderboardOut, AIStandingOut, LeaderboardOut, StandingOut, ) router = APIRouter(prefix="/api/leaderboard", tags=["leaderboard"]) def _mask(email: str) -> str: name, _, domain = email.partition("@") head = name[:2] if len(name) >= 2 else name return f"{head}{'*' * max(1, len(name) - 2)}@{domain}" @router.get("", response_model=LeaderboardOut) async def leaderboard( limit: int = Query(50, ge=1, le=200), db: AsyncSession = Depends(get_db) ) -> LeaderboardOut: rows = ( await db.execute(select(UserPoints)) ).scalars().all() ranked = sorted( (r for r in rows if not is_excluded(r.email)), key=lambda r: ( -r.total_points, -r.exact_count, -(r.total_points / r.matches_played if r.matches_played else 0), -r.matches_played, r.first_scored_at.timestamp() if r.first_scored_at else 0, ), ) scored_matches = ( await db.execute( select(func.count()) .select_from(Match) .where(Match.result_outcome.is_not(None)) ) ).scalar() or 0 standings = [ StandingOut( rank=i + 1, emailMasked=_mask(r.email), totalPoints=r.total_points, exactCount=r.exact_count, matchesPlayed=r.matches_played, ) for i, r in enumerate(ranked[:limit]) ] return LeaderboardOut(standings=standings, scoredMatches=scored_matches) @router.get("/ai", response_model=AILeaderboardOut) async def ai_leaderboard(db: AsyncSession = Depends(get_db)) -> AILeaderboardOut: """AI 모델 누적 랭킹 — 종료된 경기의 AI 예측을 유저와 동일한 배점으로 채점·합산. 별도 누적 테이블 없이 매 조회 시 종료 경기 전수 재계산 → 결과/배점 변동에 항상 일관. (모델 3개 × 경기 수라 비용 무시 가능.) """ matches = ( await db.execute( select(Match) .where(Match.result_outcome.is_not(None)) .options(selectinload(Match.predictions)) ) ).scalars().all() # model → [points, exact_count, matches_played] agg: dict[str, list[int]] = {} for m in matches: for p in m.predictions: pts = score_prediction(p.score_a, p.score_b, m.result_score_a, m.result_score_b) row = agg.setdefault(p.model, [0, 0, 0]) row[0] += pts row[1] += 1 if (p.score_a == m.result_score_a and p.score_b == m.result_score_b) else 0 row[2] += 1 order = {"GPT": 0, "Claude": 1, "Gemini": 2} ranked = sorted( agg.items(), key=lambda kv: (-kv[1][0], -kv[1][1], -kv[1][2], order.get(kv[0], 9)), ) standings = [ AIStandingOut( rank=i + 1, model=model, # type: ignore[arg-type] totalPoints=pts, exactCount=exact, matchesPlayed=played, ) for i, (model, (pts, exact, played)) in enumerate(ranked) ] return AILeaderboardOut(standings=standings, scoredMatches=len(matches))