apirrone
Initial leaderboard backend
bf3e427
Raw
History Blame Contribute Delete
6.06 kB
"""Leaderboard backend for Reachy Mini Simon JS.
Single endpoint: POST /score. Validates the caller's HF OAuth token,
keeps one best score per (username, difficulty), persists the merged
table back to the public dataset `apirrone/reachy-mini-simon-scores`
as `scores.json`.
Reads happen client-side directly against the dataset CDN โ€” no need
to round-trip through here for the leaderboard render.
"""
import json
import os
import threading
from datetime import datetime, timezone
import httpx
from fastapi import FastAPI, Header, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from huggingface_hub import HfApi
from pydantic import BaseModel, Field
# โ”€โ”€โ”€ Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
DATASET_REPO = os.environ.get("DATASET_REPO", "apirrone/reachy-mini-simon-scores")
SCORES_PATH = "scores.json"
HF_TOKEN = os.environ.get("HF_TOKEN") # set as Space secret
DATASET_URL = f"https://huggingface.co/datasets/{DATASET_REPO}/resolve/main/{SCORES_PATH}"
DIFFICULTIES = {"1", "2", "3"}
MAX_ENTRIES = 100 # keep table bounded
# โ”€โ”€โ”€ App โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app = FastAPI(title="Reachy Mini Simon โ€” leaderboard")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Spaces serve under their own origin
allow_methods=["*"],
allow_headers=["*"],
)
api = HfApi(token=HF_TOKEN) if HF_TOKEN else None
_write_lock = threading.Lock() # serialise commits to the dataset
_cache: dict = {} # latest table, freshly loaded on boot
# โ”€โ”€โ”€ Models โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class ScorePayload(BaseModel):
difficulty: str
score: int
display_name: str = ""
avatar_url: str = ""
class ScoreEntry(BaseModel):
u: str = Field(..., description="HF username (slug)")
n: str = Field("", description="display name")
a: str = Field("", description="avatar URL")
s: int = Field(0, description="score (best round reached)")
d: str = Field("", description="ISO timestamp")
# โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _load_from_hub() -> dict:
try:
r = httpx.get(DATASET_URL, timeout=10.0)
if r.status_code == 200:
return r.json()
except Exception as e:
print(f"[leaderboard] could not load {DATASET_URL}: {e}")
return {}
def _commit(data: dict) -> None:
if not api:
print("[leaderboard] HF_TOKEN missing โ€” skipping commit")
return
body = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
api.upload_file(
path_or_fileobj=body,
path_in_repo=SCORES_PATH,
repo_id=DATASET_REPO,
repo_type="dataset",
commit_message="update leaderboard",
)
async def _whoami(token: str) -> dict:
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(
"https://huggingface.co/api/whoami-v2",
headers={"Authorization": f"Bearer {token}"},
)
if r.status_code != 200:
raise HTTPException(401, "Invalid HF token")
return r.json()
# โ”€โ”€โ”€ Boot โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@app.on_event("startup")
def _boot() -> None:
global _cache
_cache = _load_from_hub()
print(f"[leaderboard] loaded {sum(len(v) for v in _cache.values())} entries")
# โ”€โ”€โ”€ Routes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@app.get("/")
def root():
return {"status": "ok", "dataset": DATASET_URL}
@app.post("/score")
async def post_score(payload: ScorePayload, authorization: str = Header(default="")):
if not authorization.startswith("Bearer "):
raise HTTPException(401, "Missing Bearer token")
token = authorization[len("Bearer "):].strip()
if payload.difficulty not in DIFFICULTIES:
raise HTTPException(400, "Invalid difficulty")
if not (0 < payload.score < 1000):
raise HTTPException(400, "Invalid score")
me = await _whoami(token)
username = me.get("name") or me.get("preferred_username") or ""
if not username:
raise HTTPException(401, "Cannot resolve username")
with _write_lock:
bucket = _cache.setdefault(payload.difficulty, [])
mine = next((e for e in bucket if e.get("u") == username), None)
if mine and mine["s"] >= payload.score:
return {"status": "not_best", "entries": bucket}
entry = {
"u": username,
"n": payload.display_name or username,
"a": payload.avatar_url or "",
"s": int(payload.score),
"d": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
if mine:
bucket.remove(mine)
bucket.append(entry)
bucket.sort(key=lambda e: e["s"], reverse=True)
del bucket[MAX_ENTRIES:]
snapshot = {k: list(v) for k, v in _cache.items()}
# Commit outside the lock โ€” network can be slow.
try:
_commit(snapshot)
except Exception as e:
print(f"[leaderboard] commit failed: {e}")
# The score is still in memory; next successful write will catch up.
return {"status": "saved", "entries": _cache[payload.difficulty]}