o2o-triple-pick/backend/app/routers/leaderboard.py

166 lines
5.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""리더보드 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, UserPrediction
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),
league: str = Query("", description="wc | kbo | mlb — 빈값이면 전체 합산"),
db: AsyncSession = Depends(get_db),
) -> LeaderboardOut:
if league:
# 리그별 랭킹 — 채점된 픽(points)을 리그 경기로 한정해 이메일별 재집계.
picks = (
await db.execute(
select(UserPrediction, Match)
.join(Match, Match.match_id == UserPrediction.match_id)
.where(
Match.league == league,
Match.result_outcome.is_not(None),
UserPrediction.points.is_not(None),
UserPrediction.email.is_not(None),
)
)
).all()
agg: dict[str, list] = {} # email → [pts, exact, played, first_ts]
for pk, m in picks:
row = agg.setdefault(pk.email, [0, 0, 0, None])
row[0] += pk.points or 0
row[1] += 1 if (
pk.score_a == m.result_score_a and pk.score_b == m.result_score_b
) else 0
row[2] += 1
ranked_rows = sorted(
((e, v) for e, v in agg.items() if not is_excluded(e)),
key=lambda kv: (-kv[1][0], -kv[1][1], -kv[1][2]),
)
scored_matches = (
await db.execute(
select(func.count()).select_from(Match).where(
Match.league == league, Match.result_outcome.is_not(None)
)
)
).scalar() or 0
standings = [
StandingOut(
rank=i + 1, emailMasked=_mask(e),
totalPoints=v[0], exactCount=v[1], matchesPlayed=v[2],
)
for i, (e, v) in enumerate(ranked_rows[:limit])
]
return LeaderboardOut(standings=standings, scoredMatches=scored_matches)
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(
league: str = Query("", description="wc | kbo | mlb — 빈값이면 전체"),
db: AsyncSession = Depends(get_db),
) -> AILeaderboardOut:
"""AI 모델 누적 랭킹 — 종료된 경기의 AI 예측을 유저와 동일한 배점으로 채점·합산.
별도 누적 테이블 없이 매 조회 시 종료 경기 전수 재계산 → 결과/배점 변동에
항상 일관. (모델 3개 × 경기 수라 비용 무시 가능.)
"""
q = (
select(Match)
.where(Match.result_outcome.is_not(None))
.options(selectinload(Match.predictions))
)
if league:
q = q.where(Match.league == league)
matches = (await db.execute(q)).scalars().all()
# model → [points, exact_count, matches_played]
agg: dict[str, list[int]] = {}
for m in matches:
bb = m.league in ("kbo", "mlb")
for p in m.predictions:
pts = score_prediction(
p.score_a, p.score_b, m.result_score_a, m.result_score_b, baseball=bb
)
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))