115 lines
3.7 KiB
Python
115 lines
3.7 KiB
Python
"""리더보드 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))
|