muhgalal commited on
Commit
5c09212
·
1 Parent(s): 9874d8c

Initial deploy: backend + models + photos

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +34 -0
  2. .gitattributes +4 -0
  3. .gitignore +36 -0
  4. Dockerfile +54 -0
  5. backend/.dockerignore +12 -0
  6. backend/Dockerfile +43 -0
  7. backend/app/__init__.py +0 -0
  8. backend/app/config.py +29 -0
  9. backend/app/deps.py +13 -0
  10. backend/app/main.py +82 -0
  11. backend/app/routes/__init__.py +0 -0
  12. backend/app/routes/catalog.py +47 -0
  13. backend/app/routes/health.py +30 -0
  14. backend/app/routes/players.py +111 -0
  15. backend/app/routes/search.py +92 -0
  16. backend/app/routes/validations.py +251 -0
  17. backend/app/schemas/__init__.py +0 -0
  18. backend/app/schemas/common.py +18 -0
  19. backend/app/schemas/player.py +36 -0
  20. backend/app/schemas/search.py +47 -0
  21. backend/app/services/__init__.py +0 -0
  22. backend/app/services/engine.py +84 -0
  23. backend/app/services/heatmap.py +141 -0
  24. backend/app/services/images.py +60 -0
  25. backend/app/services/jobs.py +153 -0
  26. backend/app/services/mane_preset.py +45 -0
  27. backend/app/services/player_index.py +97 -0
  28. backend/app/static/players/.gitkeep +0 -0
  29. backend/app/static/players/10007.jpg +3 -0
  30. backend/app/static/players/10030.jpg +3 -0
  31. backend/app/static/players/10031.jpg +3 -0
  32. backend/app/static/players/10032.jpg +3 -0
  33. backend/app/static/players/10036.jpg +3 -0
  34. backend/app/static/players/10037.jpg +3 -0
  35. backend/app/static/players/10281.jpg +3 -0
  36. backend/app/static/players/10287.jpg +3 -0
  37. backend/app/static/players/10448.jpg +3 -0
  38. backend/app/static/players/10457.jpg +3 -0
  39. backend/app/static/players/10476.jpg +3 -0
  40. backend/app/static/players/10483.jpg +3 -0
  41. backend/app/static/players/10499.jpg +3 -0
  42. backend/app/static/players/10503.jpg +3 -0
  43. backend/app/static/players/10522.jpg +3 -0
  44. backend/app/static/players/10530.jpg +3 -0
  45. backend/app/static/players/10594.jpg +3 -0
  46. backend/app/static/players/10609.jpg +3 -0
  47. backend/app/static/players/10612.jpg +3 -0
  48. backend/app/static/players/106683.jpg +3 -0
  49. backend/app/static/players/10736.jpg +3 -0
  50. backend/app/static/players/10746.jpg +3 -0
.dockerignore ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # .dockerignore for the Hugging Face Space repo.
2
+ #
3
+ # Copy this file to the ROOT of your HF Space repo alongside Dockerfile.
4
+ #
5
+ # CRITICAL: models/ is NOT excluded here, because the HF Space image MUST
6
+ # contain the runtime model artifacts. (Contrast with the project's root
7
+ # .dockerignore, which DOES exclude models/ since local docker-compose
8
+ # mounts them as a read-only volume.)
9
+
10
+ .git/
11
+ .gitattributes
12
+ __pycache__/
13
+ *.pyc
14
+ .pytest_cache/
15
+ .mypy_cache/
16
+ .ruff_cache/
17
+
18
+ # Editor / OS
19
+ .DS_Store
20
+ Thumbs.db
21
+ .idea/
22
+ .vscode/
23
+
24
+ # Frontend / Node — the Space serves the backend only.
25
+ node_modules/
26
+ .next/
27
+
28
+ # Static photos and team logos ARE included in the deployed image.
29
+ # They live at backend/app/static/players/*.jpg and teams/*.png and need
30
+ # to be served by FastAPI's static mount. The deploying user should
31
+ # commit them to the Space repo via Git LFS so they:
32
+ # 1) push reliably without bloating non-LFS git history, and
33
+ # 2) get COPY'd into the Docker image during build.
34
+ # (No exclusion rules here — letting Docker include them on purpose.)
.gitattributes CHANGED
@@ -33,3 +33,7 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ models/** filter=lfs diff=lfs merge=lfs -text
37
+ *.jsonl filter=lfs diff=lfs merge=lfs -text
38
+ backend/app/static/players/*.jpg filter=lfs diff=lfs merge=lfs -text
39
+ backend/app/static/teams/*.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .mypy_cache/
8
+
9
+ # Virtual environments
10
+ .venv/
11
+ venv/
12
+ env/
13
+ backend/.venv/
14
+
15
+ # Local environment files
16
+ .env
17
+ .env.*
18
+ !.env.example
19
+ !.env.local.example
20
+
21
+ # Frontend build/dependencies
22
+ node_modules/
23
+ frontend/node_modules/
24
+ frontend/.next/
25
+ frontend/out/
26
+ frontend/dist/
27
+
28
+ # Non-serving research/training artifacts kept locally only
29
+ models/gp1/
30
+ models/gp2/action_sentences.jsonl
31
+ models/gp2/action2vec.model
32
+
33
+ # OS/editor noise
34
+ .DS_Store
35
+ Thumbs.db
36
+ *.log
Dockerfile ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Space Dockerfile.
2
+ #
3
+ # IMPORTANT: this file is committed in this repo at hf-space/Dockerfile,
4
+ # but is meant to be COPIED to the ROOT of your HF Space repo (not this
5
+ # repo). The HF Space repo layout should be:
6
+ #
7
+ # <space-repo>/
8
+ # ├── Dockerfile ← copy this file here
9
+ # ├── .dockerignore ← copy hf-space/.dockerignore here
10
+ # ├── README.md ← HF requires this at root
11
+ # ├── backend/ ← copy from this repo
12
+ # ├── src/ ← copy from this repo
13
+ # └── models/gp2/ ← copy from this repo (tracked via Git LFS)
14
+ #
15
+ # Unlike backend/Dockerfile (which omits models because the local Docker
16
+ # stack mounts them as a volume), this Dockerfile COPIES models/ into the
17
+ # image so the Space is self-contained.
18
+
19
+ FROM python:3.11-slim
20
+
21
+ RUN apt-get update \
22
+ && apt-get install -y --no-install-recommends gcc g++ curl \
23
+ && rm -rf /var/lib/apt/lists/*
24
+
25
+ # HF Spaces require the running user to have UID 1000 and HOME=/home/user.
26
+ RUN useradd -m -u 1000 user
27
+ ENV HOME=/home/user \
28
+ PATH=/home/user/.local/bin:$PATH
29
+
30
+ WORKDIR /app
31
+
32
+ COPY --chown=user backend/requirements.txt /app/backend/requirements.txt
33
+ USER user
34
+ RUN pip install --no-cache-dir --user -r /app/backend/requirements.txt
35
+ USER root
36
+
37
+ COPY --chown=user backend/ /app/backend/
38
+ COPY --chown=user src/ /app/src/
39
+ COPY --chown=user models/ /app/models/
40
+
41
+ RUN chown -R user:user /app
42
+ USER user
43
+ WORKDIR /app/backend
44
+
45
+ ENV PORT=7860 \
46
+ PYTHONUNBUFFERED=1 \
47
+ PYTHONDONTWRITEBYTECODE=1
48
+
49
+ EXPOSE 7860
50
+
51
+ HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=120s \
52
+ CMD curl -fsS http://localhost:${PORT}/api/health || exit 1
53
+
54
+ CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT}"]
backend/.dockerignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Note: the effective ignore for backend builds is ROOT .dockerignore, since
2
+ # the build context is the project root. This file is kept for clarity and
3
+ # in case someone builds with the backend/ directory as the context.
4
+ __pycache__/
5
+ *.pyc
6
+ .venv/
7
+ .pytest_cache/
8
+ tests/
9
+ app/static/players/*
10
+ app/static/teams/*
11
+ !app/static/players/.gitkeep
12
+ !app/static/teams/.gitkeep
backend/Dockerfile ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Backend Dockerfile for LOCAL docker-compose use.
2
+ # Build context is the PROJECT ROOT (see docker-compose.yml), so all COPY
3
+ # paths here are relative to the project root, not to backend/.
4
+ #
5
+ # models/ is NOT copied into this image. Local development mounts it via a
6
+ # docker-compose volume (./models:/app/models:ro). For Hugging Face Spaces,
7
+ # use hf-space/Dockerfile, which copies models/ in.
8
+
9
+ FROM python:3.11-slim
10
+
11
+ # Build tools required for some scientific-Python wheels on slim images,
12
+ # plus curl for the HEALTHCHECK.
13
+ RUN apt-get update \
14
+ && apt-get install -y --no-install-recommends gcc g++ curl \
15
+ && rm -rf /var/lib/apt/lists/*
16
+
17
+ RUN useradd -m -u 1000 appuser
18
+
19
+ WORKDIR /app
20
+
21
+ # Install Python deps first (cacheable layer).
22
+ COPY backend/requirements.txt /app/backend/requirements.txt
23
+ RUN pip install --no-cache-dir -r /app/backend/requirements.txt
24
+
25
+ # Application code.
26
+ COPY backend/ /app/backend/
27
+ COPY src/ /app/src/
28
+
29
+ RUN chown -R appuser:appuser /app
30
+
31
+ USER appuser
32
+ WORKDIR /app/backend
33
+
34
+ ENV PORT=7860 \
35
+ PYTHONUNBUFFERED=1 \
36
+ PYTHONDONTWRITEBYTECODE=1
37
+
38
+ EXPOSE 7860
39
+
40
+ HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
41
+ CMD curl -fsS http://localhost:${PORT}/api/health || exit 1
42
+
43
+ CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT}"]
backend/app/__init__.py ADDED
File without changes
backend/app/config.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Backend configuration — env vars and derived paths."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from pathlib import Path
6
+
7
+ # Path layout: this file lives at /app/backend/app/config.py in the container,
8
+ # so parents[2] is /app — the project root that contains src/ and models/.
9
+ # Same math applies locally.
10
+ PROJECT_ROOT = Path(__file__).resolve().parents[2]
11
+ BACKEND_ROOT = Path(__file__).resolve().parents[1]
12
+ APP_DIR = Path(__file__).resolve().parent # /app/backend/app
13
+ STATIC_DIR = APP_DIR / "static" # /app/backend/app/static
14
+ PLAYERS_STATIC_DIR = STATIC_DIR / "players"
15
+ TEAMS_STATIC_DIR = STATIC_DIR / "teams"
16
+
17
+ PORT = int(os.getenv("PORT", "7860"))
18
+
19
+ _BASE_CORS_ORIGINS = ["http://localhost:3000"]
20
+ # Permissive by design for v1: any vercel.app subdomain may call the backend.
21
+ # Tradeoff accepted because this is a portfolio/demo backend with no auth and
22
+ # no per-tenant data; locking it down requires the Vercel deployment URL to
23
+ # be known at backend deploy time, which would couple the two deploys.
24
+ CORS_ORIGIN_REGEX = r"https://.*\.vercel\.app"
25
+
26
+ def cors_origins() -> list[str]:
27
+ extra = os.getenv("CORS_EXTRA_ORIGINS", "")
28
+ extras = [o.strip() for o in extra.split(",") if o.strip()]
29
+ return _BASE_CORS_ORIGINS + extras
backend/app/deps.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared FastAPI dependencies."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import HTTPException, status
5
+
6
+ from app.services import engine
7
+
8
+
9
+ def require_engine() -> None:
10
+ """Raise 503 if the engine is not in the 'ready' state."""
11
+ if engine.engine_state != "ready":
12
+ detail = f"Engine unavailable: {engine.engine_message or 'not loaded'}"
13
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=detail)
backend/app/main.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI application entry point."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import time
6
+ import traceback
7
+ from contextlib import asynccontextmanager
8
+
9
+ from fastapi import FastAPI
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from fastapi.staticfiles import StaticFiles
12
+
13
+ from app.config import CORS_ORIGIN_REGEX, STATIC_DIR, cors_origins
14
+ from app.routes import catalog, health, players, search, validations
15
+ from app.services import engine
16
+ from app.services.jobs import store
17
+
18
+ logging.basicConfig(
19
+ level=logging.INFO,
20
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
21
+ )
22
+ logger = logging.getLogger("app")
23
+
24
+
25
+ @asynccontextmanager
26
+ async def lifespan(app: FastAPI):
27
+ start = time.monotonic()
28
+ health._set_start_time(start)
29
+ logger.info("Starting up. Project root: %s", engine.PROJECT_ROOT if hasattr(engine, "PROJECT_ROOT") else "(unknown)")
30
+
31
+ # If the engine module itself failed to import, leave state as "unavailable"
32
+ # and skip warm-up. App still serves /api/health and /api/upgrades, /api/filters.
33
+ if engine.scouting_engine is None:
34
+ logger.warning("Engine module did not import; serving in degraded mode.")
35
+ else:
36
+ try:
37
+ await engine.run_engine(engine.scouting_engine.list_available_players, "", 1)
38
+ engine.set_state("ready")
39
+ elapsed = time.monotonic() - start
40
+ logger.info("Engine ready (warm-up %.2fs)", elapsed)
41
+ except Exception as e:
42
+ logger.error("Engine warm-up failed:\n%s", traceback.format_exc())
43
+ engine.set_state("unavailable", str(e))
44
+
45
+ store.start()
46
+
47
+ try:
48
+ yield
49
+ finally:
50
+ logger.info("Shutting down.")
51
+ await store.stop()
52
+ engine.shutdown_executor()
53
+
54
+
55
+ app = FastAPI(
56
+ title="Replacement Scout API",
57
+ version="0.1.0",
58
+ lifespan=lifespan,
59
+ )
60
+
61
+ app.add_middleware(
62
+ CORSMiddleware,
63
+ allow_origins=cors_origins(),
64
+ allow_origin_regex=CORS_ORIGIN_REGEX,
65
+ allow_credentials=False,
66
+ allow_methods=["*"],
67
+ allow_headers=["*"],
68
+ )
69
+
70
+ # Static images (player photos, team logos). Directory is auto-created on
71
+ # startup; missing individual files are handled by the URL helpers
72
+ # (services/images.py) which return None when a file is absent.
73
+ STATIC_DIR.mkdir(parents=True, exist_ok=True)
74
+ (STATIC_DIR / "players").mkdir(exist_ok=True)
75
+ (STATIC_DIR / "teams").mkdir(exist_ok=True)
76
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
77
+
78
+ app.include_router(health.router, prefix="/api")
79
+ app.include_router(catalog.router, prefix="/api")
80
+ app.include_router(players.router, prefix="/api")
81
+ app.include_router(search.router, prefix="/api")
82
+ app.include_router(validations.router, prefix="/api")
backend/app/routes/__init__.py ADDED
File without changes
backend/app/routes/catalog.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ /api/upgrades and /api/filters.
3
+
4
+ Both are model-INDEPENDENT — the underlying engine functions are pure
5
+ constant returns and do NOT call _ensure_loaded(). Verified against
6
+ src/gp2/evaluation/scouting_engine.py:145 (list_available_filters) and :291
7
+ (list_available_upgrades). Safe to call even when engine_state is
8
+ 'unavailable'.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from fastapi import APIRouter, HTTPException
13
+
14
+ from app.services import engine
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ @router.get("/upgrades")
20
+ def get_upgrades() -> dict:
21
+ if engine.scouting_engine is None:
22
+ raise HTTPException(503, detail="Engine module failed to import")
23
+ flat = engine.scouting_engine.list_available_upgrades()
24
+ grouped: dict[str, list[dict]] = {"onball": [], "offball": []}
25
+ for u in flat:
26
+ bucket = u.get("applies_to")
27
+ if bucket in grouped:
28
+ grouped[bucket].append(u)
29
+ return grouped
30
+
31
+
32
+ _COMING_SOON_FILTERS = [
33
+ "min_age",
34
+ "max_age",
35
+ "leagues",
36
+ "min_market_value",
37
+ "max_market_value",
38
+ ]
39
+
40
+
41
+ @router.get("/filters")
42
+ def get_filters() -> dict:
43
+ if engine.scouting_engine is None:
44
+ raise HTTPException(503, detail="Engine module failed to import")
45
+ result = dict(engine.scouting_engine.list_available_filters())
46
+ result["coming_soon"] = _COMING_SOON_FILTERS
47
+ return result
backend/app/routes/health.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Health endpoint. Model-independent — always 200 as long as the app imports."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+
6
+ from fastapi import APIRouter
7
+
8
+ from app.schemas.common import HealthResponse
9
+ from app.services import engine
10
+
11
+ router = APIRouter()
12
+
13
+ # Module-level start time. Set in main.py via _set_start_time on lifespan startup.
14
+ _start_time: float = time.monotonic()
15
+
16
+
17
+ def _set_start_time(t: float) -> None:
18
+ global _start_time
19
+ _start_time = t
20
+
21
+
22
+ @router.get("/health", response_model=HealthResponse)
23
+ def health() -> HealthResponse:
24
+ return HealthResponse(
25
+ status="ok",
26
+ engine_loaded=engine.engine_loaded(),
27
+ engine_state=engine.engine_state, # type: ignore[arg-type]
28
+ engine_message=engine.engine_message,
29
+ uptime_seconds=round(time.monotonic() - _start_time, 3),
30
+ )
backend/app/routes/players.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Player lookup and similar-players endpoints."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
8
+
9
+ from app.deps import require_engine
10
+ from app.services import engine, player_index
11
+ from app.services.heatmap import NUM_ZONES, player_heatmap
12
+ from app.services.images import logo_url_for, photo_url_for
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ def _attach_images(p: dict[str, Any], request: Request) -> dict[str, Any]:
20
+ p = dict(p) # don't mutate engine's cache
21
+ p["photo_url"] = photo_url_for(p.get("player_id", ""), request)
22
+ p["team_logo_url"] = logo_url_for(p.get("team"), request)
23
+ return p
24
+
25
+
26
+ @router.get("/players")
27
+ async def list_players(
28
+ request: Request,
29
+ q: str = Query("", description="Accent- and case-insensitive substring match on player name."),
30
+ limit: int = Query(20, ge=1, le=50),
31
+ _: None = Depends(require_engine),
32
+ ) -> list[dict[str, Any]]:
33
+ # Use the local accent-insensitive index instead of calling the engine
34
+ # directly: the engine's matcher does plain Unicode substring, so
35
+ # `q=mane` would miss "Sadio Mané". The index pulls the full player
36
+ # list once via the engine (cheap, no inference), pre-strips accents,
37
+ # then matches in pure Python.
38
+ try:
39
+ await player_index.ensure_built()
40
+ results = player_index.search(q, limit)
41
+ except Exception as e:
42
+ logger.exception("/api/players failed")
43
+ raise HTTPException(500, detail=str(e))
44
+ return [_attach_images(p, request) for p in results]
45
+
46
+
47
+ @router.get("/players/{player_id}")
48
+ async def get_player(
49
+ player_id: str,
50
+ request: Request,
51
+ _: None = Depends(require_engine),
52
+ ) -> dict[str, Any]:
53
+ try:
54
+ summary = await engine.run_engine(engine.scouting_engine.get_player_summary, player_id)
55
+ except Exception as e:
56
+ logger.exception("get_player_summary failed")
57
+ raise HTTPException(500, detail=str(e))
58
+ if summary is None:
59
+ raise HTTPException(404, detail=f"Player not found: {player_id}")
60
+ return _attach_images(summary, request)
61
+
62
+
63
+ @router.get("/players/{player_id}/heatmap")
64
+ async def player_heatmap_endpoint(
65
+ player_id: str,
66
+ _: None = Depends(require_engine),
67
+ ) -> dict[str, Any]:
68
+ """
69
+ Return a 6×3 pitch-zone activity heatmap for a player.
70
+
71
+ Aggregates the start-zone of every structured token (on-ball + off-ball)
72
+ across all of the player's matches in the dataset. Pure modifier tokens
73
+ (e.g. `progressive_pass`, `cut_inside_right`) have no zone and are skipped.
74
+
75
+ Response: {counts: int[18], total: int, num_x: 6, num_y: 3} — counts are
76
+ indexed row-major from the top-left of the StatsBomb pitch (120×80,
77
+ attacking left-to-right).
78
+ """
79
+ try:
80
+ counts = await player_heatmap(player_id)
81
+ except FileNotFoundError as e:
82
+ raise HTTPException(503, detail=f"Heatmap data unavailable: {e}")
83
+ except Exception as e:
84
+ logger.exception("heatmap failed")
85
+ raise HTTPException(500, detail=str(e))
86
+ if counts is None:
87
+ raise HTTPException(404, detail=f"Player not found: {player_id}")
88
+ return {"counts": counts, "total": sum(counts), "num_x": 6, "num_y": 3, "num_zones": NUM_ZONES}
89
+
90
+
91
+ @router.get("/players/{player_id}/similar")
92
+ async def similar_players(
93
+ player_id: str,
94
+ request: Request,
95
+ top_k: int = Query(10, ge=1, le=100),
96
+ _: None = Depends(require_engine),
97
+ ) -> list[dict[str, Any]]:
98
+ try:
99
+ result = await engine.run_engine(
100
+ engine.scouting_engine.search_replacements,
101
+ sources=[player_id],
102
+ upgrades=[],
103
+ top_k=top_k,
104
+ )
105
+ except ValueError as e:
106
+ # Engine raises ValueError for unknown sources / no sources resolved.
107
+ raise HTTPException(404, detail=str(e))
108
+ except Exception as e:
109
+ logger.exception("similar search failed")
110
+ raise HTTPException(500, detail=str(e))
111
+ return [_attach_images(c, request) for c in result.get("candidates", [])]
backend/app/routes/search.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ POST /api/search — synchronous if no upgrades, otherwise enqueued as an async job.
3
+ GET /api/search/{job_id} — model-independent job-state lookup.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+
9
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
10
+
11
+ from app.deps import require_engine
12
+ from app.schemas.search import (
13
+ JobAcceptedResponse,
14
+ JobRecord,
15
+ SearchRequest,
16
+ SyncSearchResponse,
17
+ )
18
+ from app.services import engine
19
+ from app.services.images import enrich_search_result
20
+ from app.services.jobs import RETRY_AFTER_SECONDS, store
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ router = APIRouter()
25
+
26
+
27
+ def _has_upgrades(req: SearchRequest) -> bool:
28
+ if req.upgrades is None:
29
+ return False
30
+ if isinstance(req.upgrades, (list, dict)) and len(req.upgrades) == 0:
31
+ return False
32
+ return True
33
+
34
+
35
+ @router.post("/search")
36
+ async def post_search(
37
+ req: SearchRequest,
38
+ response: Response,
39
+ request: Request,
40
+ _: None = Depends(require_engine),
41
+ ):
42
+ kwargs = dict(
43
+ sources=req.sources,
44
+ upgrades=req.upgrades if req.upgrades is not None else [],
45
+ upgrade_intensity=req.upgrade_intensity,
46
+ top_k=req.top_k,
47
+ filters=req.filters,
48
+ exclude_sources=req.exclude_sources,
49
+ seed=req.seed,
50
+ )
51
+
52
+ if not _has_upgrades(req):
53
+ # Fast path: similarity-only search runs in well under a second.
54
+ try:
55
+ result = await engine.run_engine(
56
+ engine.scouting_engine.search_replacements, **kwargs
57
+ )
58
+ except ValueError as e:
59
+ raise HTTPException(400, detail=str(e))
60
+ except Exception as e:
61
+ logger.exception("sync search failed")
62
+ raise HTTPException(500, detail=str(e))
63
+ # Attach photo_url + team_logo_url to sources and candidates.
64
+ return SyncSearchResponse(**enrich_search_result(result, request))
65
+
66
+ # Upgrade path: enqueue as a background job.
67
+ job_id = store.enqueue(kwargs)
68
+ if job_id is None:
69
+ response.headers["Retry-After"] = str(RETRY_AFTER_SECONDS)
70
+ raise HTTPException(
71
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
72
+ detail="Search queue full, retry shortly",
73
+ headers={
74
+ "Retry-After": str(RETRY_AFTER_SECONDS),
75
+ },
76
+ )
77
+ return JobAcceptedResponse(job_id=job_id, status="pending")
78
+
79
+
80
+ @router.get("/search/{job_id}", response_model=JobRecord)
81
+ def get_job(job_id: str, request: Request) -> JobRecord:
82
+ # Model-independent: only reads the in-memory job store.
83
+ record = store.get(job_id)
84
+ if record is None:
85
+ raise HTTPException(404, detail=f"Job not found: {job_id}")
86
+ # If the job is done, enrich its result with per-request image URLs.
87
+ if record.get("status") == "done" and record.get("result"):
88
+ record = {
89
+ **record,
90
+ "result": enrich_search_result(record["result"], request),
91
+ }
92
+ return JobRecord(**record)
backend/app/routes/validations.py ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ /api/validations/mane — the Mané regression check, surfaced as an API endpoint.
3
+
4
+ Reproduces the config from src/gp2/evaluation/mane_case_validation.py without
5
+ importing its `validate()` function. Post-filters defenders, re-ranks
6
+ attackers, finds Mané's attacker_rank, returns a verdict.
7
+
8
+ Cache: deterministic (single variant, seed-fixed), so we cache the response
9
+ in module state after the first successful run. Double-checked-lock pattern
10
+ prevents two simultaneous cold-cache callers from each running the ~30–60 s
11
+ inference.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import logging
17
+ from typing import Any
18
+
19
+ from fastapi import APIRouter, Depends, HTTPException, Request
20
+
21
+ from app.deps import require_engine
22
+ from app.services import engine
23
+ from app.services.heatmap import NUM_ZONES, aggregate_heatmap, player_heatmap
24
+ from app.services.images import logo_url_for, photo_url_for
25
+ from app.services.mane_preset import (
26
+ DEFENDER_POSITIONS,
27
+ KLOPP_UPGRADES_VALIDATED,
28
+ LIVERPOOL_2015_16_ATTACKERS,
29
+ MANE_FINAL_TOP_N,
30
+ MANE_SEED,
31
+ MANE_TOP_K,
32
+ )
33
+
34
+ logger = logging.getLogger(__name__)
35
+ router = APIRouter()
36
+
37
+ _mane_cache: dict[str, Any] | None = None
38
+ _mane_lock = asyncio.Lock()
39
+
40
+
41
+ def _verdict_for(rank: int | None) -> tuple[str, str]:
42
+ if rank is None:
43
+ return (
44
+ "FAIL",
45
+ "Mané did not appear in the post-filtered attacker candidate pool.",
46
+ )
47
+ if rank <= 5:
48
+ return ("EXCELLENT", f"Mané ranked #{rank} among attackers — methodology strongly recovers Liverpool's actual 2016 signing.")
49
+ if rank <= 10:
50
+ return ("STRONG", f"Mané ranked #{rank} among attackers — methodology cleanly recovers Liverpool's actual 2016 signing.")
51
+ if rank <= 20:
52
+ return ("ACCEPTABLE", f"Mané ranked #{rank} among attackers — methodology recovers Liverpool's actual 2016 signing, with noise.")
53
+ if rank <= MANE_FINAL_TOP_N:
54
+ return ("MARGINAL", f"Mané ranked #{rank} among attackers — within the top {MANE_FINAL_TOP_N} but on the edge.")
55
+ return (
56
+ "FAIL",
57
+ f"Mané ranked #{rank} — outside the top {MANE_FINAL_TOP_N} attackers.",
58
+ )
59
+
60
+
61
+ def _build_response(engine_result: dict[str, Any]) -> dict[str, Any]:
62
+ candidates: list[dict[str, Any]] = engine_result.get("candidates", []) or []
63
+ warnings: list[str] = engine_result.get("warnings", []) or []
64
+
65
+ attackers = [c for c in candidates if c.get("primary_position") not in DEFENDER_POSITIONS]
66
+ filtered_defender_count = len(candidates) - len(attackers)
67
+
68
+ mane_rank: int | None = None
69
+ for i, c in enumerate(attackers, start=1):
70
+ c["attacker_rank"] = i
71
+ if mane_rank is None and "Mané" in (c.get("name") or ""):
72
+ mane_rank = i
73
+
74
+ top_attackers = attackers[:MANE_FINAL_TOP_N]
75
+ verdict, description = _verdict_for(mane_rank)
76
+
77
+ return {
78
+ "query": engine_result.get("query", {}),
79
+ "candidates": top_attackers,
80
+ "mane_rank": mane_rank,
81
+ "verdict": verdict,
82
+ "verdict_description": description,
83
+ "filtered_defender_count": filtered_defender_count,
84
+ "warnings": warnings,
85
+ }
86
+
87
+
88
+ async def _compute() -> dict[str, Any]:
89
+ result = await engine.run_engine(
90
+ engine.scouting_engine.search_replacements,
91
+ sources=LIVERPOOL_2015_16_ATTACKERS,
92
+ upgrades=KLOPP_UPGRADES_VALIDATED,
93
+ top_k=MANE_TOP_K,
94
+ seed=MANE_SEED,
95
+ )
96
+ return _build_response(result)
97
+
98
+
99
+ @router.get("/validations/mane")
100
+ async def mane_validation(_: None = Depends(require_engine)) -> dict[str, Any]:
101
+ global _mane_cache
102
+ if _mane_cache is not None:
103
+ return _mane_cache
104
+ async with _mane_lock:
105
+ # Double-checked: another coroutine may have populated the cache while we were
106
+ # waiting on the lock.
107
+ if _mane_cache is not None:
108
+ return _mane_cache
109
+ try:
110
+ response = await _compute()
111
+ except ValueError as e:
112
+ raise HTTPException(400, detail=str(e))
113
+ except Exception as e:
114
+ logger.exception("Mané validation failed")
115
+ raise HTTPException(500, detail=str(e))
116
+ _mane_cache = response
117
+ return _mane_cache
118
+
119
+
120
+ _mane_heatmap_cache: dict[str, Any] | None = None
121
+ _mane_heatmap_lock = asyncio.Lock()
122
+
123
+
124
+ @router.get("/validations/mane/heatmap")
125
+ async def mane_heatmap(
126
+ request: Request,
127
+ _: None = Depends(require_engine),
128
+ ) -> dict[str, Any]:
129
+ """
130
+ Heatmaps for the Mané validation explainer:
131
+ - `pool`: aggregated zone counts across the 6 Liverpool 2015-16 attackers
132
+ - `mane`: Mané's own zone counts
133
+ - `top_candidates`: heatmaps for the top 5 attacking candidates the
134
+ methodology surfaced (defenders filtered out). Lets the user see
135
+ that all 5 share a similar spatial profile — visual proof of the
136
+ ranking.
137
+
138
+ Depends on the main /api/validations/mane having been computed (or
139
+ computes it on demand) so we know who the top-5 attackers are.
140
+ """
141
+ global _mane_heatmap_cache
142
+ if _mane_heatmap_cache is not None:
143
+ return _enrich_heatmap_response(_mane_heatmap_cache, request)
144
+ async with _mane_heatmap_lock:
145
+ if _mane_heatmap_cache is not None:
146
+ return _enrich_heatmap_response(_mane_heatmap_cache, request)
147
+
148
+ # Resolve the 6 source player names → player_ids via the engine's
149
+ # name lookup (this is cheap — no model inference required, just a
150
+ # dict scan).
151
+ try:
152
+ source_ids: list[str] = []
153
+ source_names: list[str] = []
154
+ for name in LIVERPOOL_2015_16_ATTACKERS:
155
+ pid = await engine.run_engine(engine.scouting_engine.find_player_id, name)
156
+ if pid is None:
157
+ continue
158
+ summary = await engine.run_engine(
159
+ engine.scouting_engine.get_player_summary, pid
160
+ )
161
+ source_ids.append(pid)
162
+ if summary and summary.get("name"):
163
+ source_names.append(summary["name"])
164
+
165
+ # Find Mané by name. The engine's matcher is plain Unicode substring;
166
+ # the accented form is required.
167
+ mane_id = await engine.run_engine(engine.scouting_engine.find_player_id, "Mané")
168
+
169
+ pool_counts = await aggregate_heatmap(source_ids)
170
+ mane_counts = await player_heatmap(mane_id) if mane_id else None
171
+
172
+ # Top-5 attacking candidates: reuse the main validation result.
173
+ # If it hasn't been computed yet, compute it now (single inference,
174
+ # then cached for both endpoints).
175
+ global _mane_cache
176
+ validation = _mane_cache
177
+ if validation is None:
178
+ validation = await _compute()
179
+ _mane_cache = validation
180
+ top_candidates = []
181
+ for c in (validation.get("candidates") or [])[:5]:
182
+ cid = c.get("player_id")
183
+ if not cid:
184
+ continue
185
+ counts = await player_heatmap(str(cid))
186
+ if counts is None:
187
+ continue
188
+ # Photo URLs are NOT cached here — they're attached at response
189
+ # time below because the base URL depends on the current
190
+ # request (different deploy host, different scheme, etc.).
191
+ top_candidates.append({
192
+ "player_id": cid,
193
+ "name": c.get("name"),
194
+ "primary_position": c.get("primary_position"),
195
+ "team": c.get("team"),
196
+ "similarity": c.get("similarity"),
197
+ "attacker_rank": c.get("attacker_rank"),
198
+ "counts": counts,
199
+ "total": sum(counts),
200
+ "is_mane": "Mané" in (c.get("name") or ""),
201
+ })
202
+
203
+ except FileNotFoundError as e:
204
+ raise HTTPException(503, detail=f"Heatmap data unavailable: {e}")
205
+ except Exception as e:
206
+ logger.exception("Mané heatmap failed")
207
+ raise HTTPException(500, detail=str(e))
208
+
209
+ _mane_heatmap_cache = {
210
+ "pool": {
211
+ "counts": pool_counts,
212
+ "total": sum(pool_counts),
213
+ "source_names": source_names,
214
+ "label": "Liverpool 2015-16 attacking pool",
215
+ },
216
+ "mane": {
217
+ "counts": mane_counts,
218
+ "total": sum(mane_counts) if mane_counts else 0,
219
+ "label": "Sadio Mané",
220
+ "player_id": mane_id,
221
+ },
222
+ "top_candidates": top_candidates,
223
+ "num_x": 6,
224
+ "num_y": 3,
225
+ "num_zones": NUM_ZONES,
226
+ }
227
+
228
+ return _enrich_heatmap_response(_mane_heatmap_cache, request)
229
+
230
+
231
+ def _enrich_heatmap_response(cached: dict[str, Any], request: Request) -> dict[str, Any]:
232
+ """Attach per-request image URLs to the cached heatmap response.
233
+
234
+ Image URLs include the request's base URL, so they MUST be computed per
235
+ request (different deploy hosts, schemes, ports). We never mutate the
236
+ cache itself.
237
+ """
238
+ enriched_candidates = []
239
+ for c in cached.get("top_candidates", []):
240
+ c2 = dict(c)
241
+ c2["photo_url"] = photo_url_for(str(c.get("player_id") or ""), request)
242
+ c2["team_logo_url"] = logo_url_for(c.get("team"), request)
243
+ enriched_candidates.append(c2)
244
+ return {
245
+ **cached,
246
+ "top_candidates": enriched_candidates,
247
+ "mane": {
248
+ **cached.get("mane", {}),
249
+ "photo_url": photo_url_for(str(cached.get("mane", {}).get("player_id") or ""), request),
250
+ },
251
+ }
backend/app/schemas/__init__.py ADDED
File without changes
backend/app/schemas/common.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Schemas shared across routes."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Literal
5
+
6
+ from pydantic import BaseModel
7
+
8
+
9
+ class HealthResponse(BaseModel):
10
+ status: Literal["ok"] = "ok"
11
+ engine_loaded: bool
12
+ engine_state: Literal["warming", "ready", "unavailable"]
13
+ engine_message: str | None = None
14
+ uptime_seconds: float
15
+
16
+
17
+ class ErrorResponse(BaseModel):
18
+ detail: str
backend/app/schemas/player.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Player-related schemas. Loose typing on engine-returned fields because
2
+ the engine's dicts include some Optional values we surface as-is."""
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel
8
+
9
+
10
+ class PlayerSummary(BaseModel):
11
+ player_id: str
12
+ name: str
13
+ primary_position: str
14
+ team: str
15
+ versatility_score: float
16
+ num_matches: int
17
+ photo_url: str | None = None
18
+ team_logo_url: str | None = None
19
+
20
+
21
+ class PlayerDetail(PlayerSummary):
22
+ num_distinct_positions: int
23
+ position_distribution: dict[str, Any]
24
+
25
+
26
+ class Candidate(BaseModel):
27
+ rank: int
28
+ player_id: str
29
+ name: str
30
+ primary_position: str
31
+ team: str
32
+ similarity: float
33
+ versatility_score: float
34
+ num_matches: int
35
+ photo_url: str | None = None
36
+ team_logo_url: str | None = None
backend/app/schemas/search.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Search request/response schemas."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Literal
5
+
6
+ from pydantic import BaseModel, Field, field_validator
7
+
8
+
9
+ class SearchRequest(BaseModel):
10
+ sources: list[str] = Field(..., min_length=1)
11
+ upgrades: list[str] | dict[str, float] | None = None
12
+ upgrade_intensity: float = Field(0.5, ge=0.0, le=1.0)
13
+ top_k: int = Field(30, ge=1, le=100)
14
+ filters: dict[str, Any] | None = None
15
+ exclude_sources: bool = True
16
+ seed: int = 42
17
+
18
+ @field_validator("upgrades")
19
+ @classmethod
20
+ def _validate_upgrade_probs(cls, v):
21
+ if isinstance(v, dict):
22
+ for key, prob in v.items():
23
+ if not isinstance(prob, (int, float)):
24
+ raise ValueError(f"upgrade probability for '{key}' must be numeric")
25
+ if not (0.0 <= float(prob) <= 1.0):
26
+ raise ValueError(f"upgrade probability for '{key}' must be in [0.0, 1.0]")
27
+ return v
28
+
29
+
30
+ class SyncSearchResponse(BaseModel):
31
+ query: dict[str, Any]
32
+ candidates: list[dict[str, Any]]
33
+ warnings: list[str]
34
+
35
+
36
+ class JobAcceptedResponse(BaseModel):
37
+ job_id: str
38
+ status: Literal["pending"]
39
+
40
+
41
+ class JobRecord(BaseModel):
42
+ job_id: str
43
+ status: Literal["pending", "running", "done", "error"]
44
+ created_at: str
45
+ updated_at: str
46
+ result: dict[str, Any] | None = None
47
+ error: str | None = None
backend/app/services/__init__.py ADDED
File without changes
backend/app/services/engine.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Engine wrapper.
3
+
4
+ Imports the locked GP2 scouting engine via sys.path and exposes a single
5
+ async entry point `run_engine(fn, *args, **kwargs)` that routes every
6
+ CPU-bound engine call through a shared single-worker ThreadPoolExecutor.
7
+
8
+ The single-worker constraint is structural concurrency control: it means we
9
+ can never run two model inferences in parallel, regardless of which endpoint
10
+ triggered them. The /api/search job queue's "1 running + 3 pending" policy
11
+ is layered on top for upgrade jobs specifically.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import logging
17
+ import sys
18
+ import traceback
19
+ from concurrent.futures import ThreadPoolExecutor
20
+ from typing import Any, Callable
21
+
22
+ from app.config import PROJECT_ROOT
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # Make `from src.gp2.evaluation import scouting_engine` resolvable.
27
+ _project_root_str = str(PROJECT_ROOT)
28
+ if _project_root_str not in sys.path:
29
+ sys.path.insert(0, _project_root_str)
30
+
31
+ # State exposed to the rest of the app.
32
+ engine_state: str = "warming" # "warming" | "ready" | "unavailable"
33
+ engine_message: str | None = None
34
+
35
+ # Import the engine lazily-friendly: we still import at module load so the
36
+ # rest of the app can reference `scouting_engine.*` directly, but if the
37
+ # import itself fails we mark the engine unavailable and re-raise so the
38
+ # caller sees a clean error.
39
+ try:
40
+ from src.gp2.evaluation import scouting_engine # type: ignore
41
+ except Exception as e: # pragma: no cover — only fires if src/ is missing
42
+ scouting_engine = None # type: ignore[assignment]
43
+ engine_state = "unavailable"
44
+ engine_message = f"Engine import failed: {e}"
45
+ logger.error("Engine import failed:\n%s", traceback.format_exc())
46
+
47
+
48
+ # Shared single-worker executor. Lazily (re)created so that a shutdown
49
+ # during one app lifespan doesn't permanently kill the executor for a
50
+ # subsequent lifespan in the same process — important for pytest's
51
+ # TestClient pattern, where each test may open a fresh TestClient.
52
+ _executor: ThreadPoolExecutor | None = None
53
+
54
+
55
+ def _ensure_executor() -> ThreadPoolExecutor:
56
+ global _executor
57
+ if _executor is None:
58
+ _executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="engine")
59
+ return _executor
60
+
61
+
62
+ def engine_loaded() -> bool:
63
+ return engine_state == "ready"
64
+
65
+
66
+ def set_state(state: str, message: str | None = None) -> None:
67
+ global engine_state, engine_message
68
+ engine_state = state
69
+ engine_message = message
70
+
71
+
72
+ async def run_engine(fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
73
+ """Run a CPU-bound engine function on the shared single-worker executor."""
74
+ loop = asyncio.get_running_loop()
75
+ return await loop.run_in_executor(_ensure_executor(), lambda: fn(*args, **kwargs))
76
+
77
+
78
+ def shutdown_executor() -> None:
79
+ """Stop the executor. Safe to call multiple times; a later run_engine
80
+ call will lazily create a fresh executor."""
81
+ global _executor
82
+ if _executor is not None:
83
+ _executor.shutdown(wait=False, cancel_futures=False)
84
+ _executor = None
backend/app/services/heatmap.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Heatmap aggregation service.
3
+
4
+ Streams `models/gp2/player_match_docs_split.jsonl` and counts pitch-zone
5
+ occurrences per player. The engine's tokens encode locations as zone IDs
6
+ on a 6×3 grid (NUM_X_BINS=6, NUM_Y_BINS=3, row-major from top-left of a
7
+ 120×80 StatsBomb pitch — see src/gp2/preprocess/zones.py).
8
+
9
+ Token shapes we extract zones from:
10
+ pass|z17_z11|f|r|l|med -> 17 (start zone)
11
+ carry|z16_z17|fwd -> 16
12
+ shot|z11|on|r|open|xg_l|mid -> 11
13
+ pressure|z16|reg -> 16
14
+ duel|z14|ground|lost -> 14
15
+ ... etc.
16
+
17
+ Tokens that are pure modifiers (e.g. `progressive_pass`, `cut_inside_right`,
18
+ `under_pressure`) have no zone field and are skipped.
19
+
20
+ We use *start zone only*, which represents "where this player was active."
21
+ This is the right primitive for "show me their pitch footprint." We also
22
+ keep the totals so the heatmap can be normalized to percentages downstream.
23
+
24
+ The data file is ~132 MB and contains ~49k player-match documents — a full
25
+ scan takes a few seconds. The result for each player is cached in
26
+ process memory; the index `_zone_index` (built lazily on first call) maps
27
+ player_id -> 18-element count array.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import json
33
+ import logging
34
+ import re
35
+ from pathlib import Path
36
+ from typing import Iterable, Optional
37
+
38
+ from app.config import PROJECT_ROOT
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ # Matches `z<digits>` at the start of the second pipe-field, possibly followed
43
+ # by `_z<digits>` (start_end pair) or other field separators. We only care
44
+ # about the first (start) zone.
45
+ _ZONE_RE = re.compile(r"^z(\d+)")
46
+
47
+ NUM_ZONES = 18 # 6×3 grid
48
+ DOCS_PATH = PROJECT_ROOT / "models" / "gp2" / "player_match_docs_split.jsonl"
49
+
50
+ # player_id -> [count_z0, count_z1, ..., count_z17]
51
+ _zone_index: Optional[dict[str, list[int]]] = None
52
+ _index_lock = asyncio.Lock()
53
+
54
+
55
+ def _extract_zone(token: str) -> Optional[int]:
56
+ """Return the start-zone id (0-17) for a structured token, or None for modifier-only tokens."""
57
+ # Tokens look like "<verb>|<zone-or-pair>|<...>" — split once, look at field[1].
58
+ parts = token.split("|", 2)
59
+ if len(parts) < 2:
60
+ return None
61
+ m = _ZONE_RE.match(parts[1])
62
+ if not m:
63
+ return None
64
+ try:
65
+ zid = int(m.group(1))
66
+ except ValueError:
67
+ return None
68
+ if 0 <= zid < NUM_ZONES:
69
+ return zid
70
+ return None
71
+
72
+
73
+ def _build_index_sync() -> dict[str, list[int]]:
74
+ """Stream the docs jsonl once and tally zones per player. Blocking I/O — caller must offload."""
75
+ if not DOCS_PATH.exists():
76
+ raise FileNotFoundError(f"{DOCS_PATH} not found")
77
+
78
+ index: dict[str, list[int]] = {}
79
+ n_docs = 0
80
+ with open(DOCS_PATH, "r", encoding="utf-8") as f:
81
+ for line in f:
82
+ line = line.strip()
83
+ if not line:
84
+ continue
85
+ try:
86
+ doc = json.loads(line)
87
+ except json.JSONDecodeError:
88
+ continue
89
+ pid = doc.get("player_id")
90
+ if pid is None:
91
+ continue
92
+ pid = str(pid)
93
+ counts = index.get(pid)
94
+ if counts is None:
95
+ counts = [0] * NUM_ZONES
96
+ index[pid] = counts
97
+ for tok in doc.get("onball_tokens", []) or []:
98
+ z = _extract_zone(tok)
99
+ if z is not None:
100
+ counts[z] += 1
101
+ for tok in doc.get("offball_tokens", []) or []:
102
+ z = _extract_zone(tok)
103
+ if z is not None:
104
+ counts[z] += 1
105
+ n_docs += 1
106
+ logger.info("Heatmap index built: %d players from %d documents", len(index), n_docs)
107
+ return index
108
+
109
+
110
+ async def _ensure_index() -> dict[str, list[int]]:
111
+ global _zone_index
112
+ if _zone_index is not None:
113
+ return _zone_index
114
+ async with _index_lock:
115
+ if _zone_index is not None:
116
+ return _zone_index
117
+ loop = asyncio.get_running_loop()
118
+ _zone_index = await loop.run_in_executor(None, _build_index_sync)
119
+ return _zone_index
120
+
121
+
122
+ async def player_heatmap(player_id: str) -> Optional[list[int]]:
123
+ """Return the 18-element zone-count array for a player, or None if unknown."""
124
+ idx = await _ensure_index()
125
+ counts = idx.get(str(player_id))
126
+ if counts is None:
127
+ return None
128
+ return list(counts)
129
+
130
+
131
+ async def aggregate_heatmap(player_ids: Iterable[str]) -> list[int]:
132
+ """Sum zone counts across a set of player_ids. Missing IDs contribute zeros."""
133
+ idx = await _ensure_index()
134
+ out = [0] * NUM_ZONES
135
+ for pid in player_ids:
136
+ counts = idx.get(str(pid))
137
+ if counts is None:
138
+ continue
139
+ for i, c in enumerate(counts):
140
+ out[i] += c
141
+ return out
backend/app/services/images.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Static image URL resolution. Missing files → None (frontend uses initials fallback)."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from typing import Any
6
+
7
+ from fastapi import Request
8
+
9
+ from app.config import PLAYERS_STATIC_DIR, TEAMS_STATIC_DIR
10
+
11
+ _SLUG_NON_ALNUM = re.compile(r"[^a-z0-9]+")
12
+
13
+
14
+ def team_slug(team_name: str) -> str:
15
+ s = _SLUG_NON_ALNUM.sub("-", team_name.lower()).strip("-")
16
+ return s or "unknown"
17
+
18
+
19
+ def photo_url_for(player_id: str, request: Request) -> str | None:
20
+ if not player_id:
21
+ return None
22
+ if not (PLAYERS_STATIC_DIR / f"{player_id}.jpg").is_file():
23
+ return None
24
+ return f"{str(request.base_url).rstrip('/')}/static/players/{player_id}.jpg"
25
+
26
+
27
+ def logo_url_for(team_name: str | None, request: Request) -> str | None:
28
+ if not team_name:
29
+ return None
30
+ slug = team_slug(team_name)
31
+ if not (TEAMS_STATIC_DIR / f"{slug}.png").is_file():
32
+ return None
33
+ return f"{str(request.base_url).rstrip('/')}/static/teams/{slug}.png"
34
+
35
+
36
+ def attach_images(p: dict[str, Any], request: Request) -> dict[str, Any]:
37
+ """Return a copy of a player/candidate dict with photo_url + team_logo_url
38
+ populated using the current request's base URL. Safe to call on engine
39
+ output (does not mutate the engine's cache)."""
40
+ out = dict(p)
41
+ out["photo_url"] = photo_url_for(str(p.get("player_id") or ""), request)
42
+ out["team_logo_url"] = logo_url_for(p.get("team"), request)
43
+ return out
44
+
45
+
46
+ def enrich_search_result(result: dict[str, Any], request: Request) -> dict[str, Any]:
47
+ """Add photo_url + team_logo_url to every player in a search-result dict.
48
+
49
+ Applies to both `query.sources` (the resolved source players) and the
50
+ `candidates` array. Returns a new dict — does not mutate the input.
51
+ """
52
+ query = dict(result.get("query") or {})
53
+ sources = [attach_images(s, request) for s in (query.get("sources") or [])]
54
+ query["sources"] = sources
55
+ candidates = [attach_images(c, request) for c in (result.get("candidates") or [])]
56
+ return {
57
+ **result,
58
+ "query": query,
59
+ "candidates": candidates,
60
+ }
backend/app/services/jobs.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ In-memory async job store for upgrade searches.
3
+
4
+ Design:
5
+ - One asyncio.Queue (maxsize=3) of pending job_ids.
6
+ - One long-running worker task drains it; each job runs through
7
+ services.engine.run_engine(...), which uses the shared single-worker
8
+ executor — so heavy work is automatically serialized with any other
9
+ engine call site (validation endpoint, similar-players, etc.).
10
+ - Records live in a dict keyed by job_id. Records older than 1 hour are
11
+ swept on every enqueue and every status lookup.
12
+
13
+ Lost on restart by design. Acceptable for v1; a Redis/SQLite store is the
14
+ follow-up if production reliability is needed.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import logging
20
+ import traceback
21
+ import uuid
22
+ from dataclasses import dataclass, field
23
+ from datetime import datetime, timedelta, timezone
24
+ from typing import Any, Optional
25
+
26
+ from app.services import engine
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ JOB_TTL = timedelta(hours=1)
31
+ QUEUE_MAXSIZE = 3
32
+ RETRY_AFTER_SECONDS = 30
33
+
34
+
35
+ def _now_iso() -> str:
36
+ return datetime.now(timezone.utc).isoformat()
37
+
38
+
39
+ @dataclass
40
+ class Job:
41
+ job_id: str
42
+ status: str # "pending" | "running" | "done" | "error"
43
+ created_at: str
44
+ updated_at: str
45
+ kwargs: dict[str, Any]
46
+ result: Optional[dict[str, Any]] = None
47
+ error: Optional[str] = None
48
+
49
+ def to_dict(self) -> dict[str, Any]:
50
+ # kwargs is internal — never expose it
51
+ return {
52
+ "job_id": self.job_id,
53
+ "status": self.status,
54
+ "created_at": self.created_at,
55
+ "updated_at": self.updated_at,
56
+ "result": self.result,
57
+ "error": self.error,
58
+ }
59
+
60
+
61
+ class JobStore:
62
+ def __init__(self) -> None:
63
+ self._jobs: dict[str, Job] = {}
64
+ self._queue: asyncio.Queue[str] = asyncio.Queue(maxsize=QUEUE_MAXSIZE)
65
+ self._worker_task: asyncio.Task | None = None
66
+ self._running_job_id: str | None = None
67
+
68
+ def _sweep(self) -> None:
69
+ cutoff = datetime.now(timezone.utc) - JOB_TTL
70
+ stale = [
71
+ jid for jid, j in self._jobs.items()
72
+ if datetime.fromisoformat(j.created_at) < cutoff
73
+ ]
74
+ for jid in stale:
75
+ del self._jobs[jid]
76
+
77
+ def is_full(self) -> bool:
78
+ # "Full" = there is currently a running job AND the pending queue is at capacity.
79
+ return self._running_job_id is not None and self._queue.full()
80
+
81
+ def enqueue(self, kwargs: dict[str, Any]) -> str | None:
82
+ """Enqueue a job. Returns job_id, or None if capacity is exceeded."""
83
+ self._sweep()
84
+ if self.is_full():
85
+ return None
86
+ job_id = uuid.uuid4().hex
87
+ now = _now_iso()
88
+ self._jobs[job_id] = Job(
89
+ job_id=job_id,
90
+ status="pending",
91
+ created_at=now,
92
+ updated_at=now,
93
+ kwargs=kwargs,
94
+ )
95
+ try:
96
+ self._queue.put_nowait(job_id)
97
+ except asyncio.QueueFull:
98
+ # Shouldn't happen given the is_full() guard, but be defensive.
99
+ del self._jobs[job_id]
100
+ return None
101
+ return job_id
102
+
103
+ def get(self, job_id: str) -> dict[str, Any] | None:
104
+ self._sweep()
105
+ job = self._jobs.get(job_id)
106
+ return job.to_dict() if job else None
107
+
108
+ async def _worker(self) -> None:
109
+ from app.services.engine import scouting_engine # late import; may be None
110
+
111
+ while True:
112
+ try:
113
+ job_id = await self._queue.get()
114
+ except asyncio.CancelledError:
115
+ return
116
+ job = self._jobs.get(job_id)
117
+ if job is None:
118
+ continue
119
+ self._running_job_id = job_id
120
+ job.status = "running"
121
+ job.updated_at = _now_iso()
122
+ try:
123
+ if scouting_engine is None:
124
+ raise RuntimeError("Engine unavailable")
125
+ result = await engine.run_engine(
126
+ scouting_engine.search_replacements, **job.kwargs
127
+ )
128
+ job.result = result
129
+ job.status = "done"
130
+ except Exception as e:
131
+ logger.error("Job %s failed:\n%s", job_id, traceback.format_exc())
132
+ job.error = f"{type(e).__name__}: {e}"
133
+ job.status = "error"
134
+ finally:
135
+ job.updated_at = _now_iso()
136
+ self._running_job_id = None
137
+
138
+ def start(self, loop: asyncio.AbstractEventLoop | None = None) -> None:
139
+ if self._worker_task is None or self._worker_task.done():
140
+ self._worker_task = asyncio.create_task(self._worker(), name="job-worker")
141
+
142
+ async def stop(self) -> None:
143
+ if self._worker_task is not None:
144
+ self._worker_task.cancel()
145
+ try:
146
+ await self._worker_task
147
+ except (asyncio.CancelledError, Exception):
148
+ pass
149
+ self._worker_task = None
150
+
151
+
152
+ # Module-level singleton; the FastAPI app touches this via `from app.services.jobs import store`.
153
+ store = JobStore()
backend/app/services/mane_preset.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Mané-validation preset, reproduced from `src/gp2/evaluation/mane_case_validation.py`.
3
+
4
+ Reproduced verbatim (not imported) because we don't want to call the
5
+ script's `validate()` function — it has print-heavy reporting side effects
6
+ and a different return contract. The constants here are the regression
7
+ checkpoint; if they drift from the engine's expectations, that's intentional
8
+ breakage to be reviewed.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ # Six sources — Coutinho was removed in the engine's regression file
13
+ # (mane_case_validation.py:37, comment "##coutinho removed").
14
+ LIVERPOOL_2015_16_ATTACKERS: list[str] = [
15
+ "Lallana",
16
+ "Firmino",
17
+ "Sturridge",
18
+ "Origi",
19
+ "Ibe",
20
+ "Benteke",
21
+ ]
22
+
23
+ KLOPP_UPGRADES_VALIDATED: dict[str, float] = {
24
+ "cut_inside": 0.7,
25
+ "finishing": 0.5,
26
+ "progression": 0.4,
27
+ "chance_creation": 0.4,
28
+ "dribbling": 0.4,
29
+ "pressing": 0.7,
30
+ }
31
+
32
+ DEFENDER_POSITIONS: set[str] = {
33
+ "Goalkeeper",
34
+ "Center Back",
35
+ "Left Center Back",
36
+ "Right Center Back",
37
+ "Left Back",
38
+ "Right Back",
39
+ "Left Wing Back",
40
+ "Right Wing Back",
41
+ }
42
+
43
+ MANE_TOP_K: int = 60
44
+ MANE_SEED: int = 42
45
+ MANE_FINAL_TOP_N: int = 30
backend/app/services/player_index.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ In-memory player index with accent-insensitive substring matching.
3
+
4
+ The locked engine's `list_available_players(query, limit)` does plain
5
+ lower()+substring matching, so `q=mane` does NOT match "Sadio Mané" because
6
+ 'e' != 'é'. We can't fix that in the engine (src/gp2/ is locked), so we
7
+ wrap it here:
8
+
9
+ 1. On first use, fetch the full player list once via
10
+ `engine.list_available_players("", BIG)` (cheap — just dict
11
+ construction over cached metadata; no model inference).
12
+ 2. Pre-compute an accent-stripped lowercased name for each player.
13
+ 3. Per request, strip accents from the user's query and substring-match
14
+ against the pre-computed names. Return canonical (accented) entries.
15
+
16
+ Cache invalidation: none — the player set is fixed for a given model
17
+ build. If models are reloaded, the engine warmup would discard our cache
18
+ indirectly (we'd need to restart the process to rebuild it). Acceptable.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import threading
24
+ import unicodedata
25
+ from typing import Any
26
+
27
+ from app.services import engine
28
+
29
+ # A bigger limit than any plausible player count; the engine returns at
30
+ # most metadata.size items regardless.
31
+ _FULL_LIST_LIMIT = 100_000
32
+
33
+ _lock = threading.Lock()
34
+ _full_list: list[dict[str, Any]] | None = None
35
+ _normalized: list[str] | None = None
36
+
37
+
38
+ def _strip_accents(s: str) -> str:
39
+ """NFD decomposition + drop combining marks. 'Mané'→'Mane', 'Bruyne'→'Bruyne'."""
40
+ return "".join(
41
+ c for c in unicodedata.normalize("NFD", s)
42
+ if unicodedata.category(c) != "Mn"
43
+ ).lower()
44
+
45
+
46
+ def _build_sync() -> None:
47
+ """Synchronous (engine call) — must run on the engine executor."""
48
+ global _full_list, _normalized
49
+ if engine.scouting_engine is None:
50
+ raise RuntimeError("Engine not available")
51
+ rows = engine.scouting_engine.list_available_players("", _FULL_LIST_LIMIT)
52
+ _full_list = rows
53
+ _normalized = [_strip_accents(r.get("name", "")) for r in rows]
54
+
55
+
56
+ async def ensure_built() -> None:
57
+ """Build the index if needed. Safe to call from any request handler."""
58
+ if _full_list is not None:
59
+ return
60
+ # Engine call goes through the shared single-worker executor so we don't
61
+ # block the event loop and we serialize with other engine work.
62
+ await engine.run_engine(_acquire_and_build)
63
+
64
+
65
+ def _acquire_and_build() -> None:
66
+ # Runs on the engine thread; the lock guards against the (unlikely)
67
+ # case where two requests both call ensure_built() and both make it
68
+ # past the early-return check before either has populated the cache.
69
+ with _lock:
70
+ if _full_list is None:
71
+ _build_sync()
72
+
73
+
74
+ def search(query: str, limit: int) -> list[dict[str, Any]]:
75
+ """Substring-match `query` against accent-stripped names. Caller is
76
+ expected to have awaited `ensure_built()` first."""
77
+ if _full_list is None or _normalized is None:
78
+ return []
79
+ q = _strip_accents(query).strip()
80
+ if not q:
81
+ return list(_full_list[:limit])
82
+ out: list[dict[str, Any]] = []
83
+ for row, norm in zip(_full_list, _normalized):
84
+ if q in norm:
85
+ out.append(row)
86
+ if len(out) >= limit:
87
+ break
88
+ return out
89
+
90
+
91
+ # Test hook — lets tests reset the cache between runs without restarting
92
+ # the process.
93
+ def _reset() -> None:
94
+ global _full_list, _normalized
95
+ with _lock:
96
+ _full_list = None
97
+ _normalized = None
backend/app/static/players/.gitkeep ADDED
File without changes
backend/app/static/players/10007.jpg ADDED

Git LFS Details

  • SHA256: c00e984e4c150cb9b3b21f442bdbf75cefe6c373afebcd1fa6c0404febfc8922
  • Pointer size: 129 Bytes
  • Size of remote file: 7.47 kB
backend/app/static/players/10030.jpg ADDED

Git LFS Details

  • SHA256: e659b682ef7efbec3e2b32ec3d875484998ef0fe248411313a7937b7e2f33585
  • Pointer size: 129 Bytes
  • Size of remote file: 9.84 kB
backend/app/static/players/10031.jpg ADDED

Git LFS Details

  • SHA256: c2de27a201b359d9ed34a4d388a921570577ddfff84063851de61ed0806dc2f1
  • Pointer size: 130 Bytes
  • Size of remote file: 14.4 kB
backend/app/static/players/10032.jpg ADDED

Git LFS Details

  • SHA256: 5252131a0658c0a790befe9397d57a341569b01f0fb1fc65cbbeea108db1c1d1
  • Pointer size: 129 Bytes
  • Size of remote file: 8.38 kB
backend/app/static/players/10036.jpg ADDED

Git LFS Details

  • SHA256: 760c3582095662be063f710adae72aa7761f5fb6aea3ad208ff34568873a6b0c
  • Pointer size: 130 Bytes
  • Size of remote file: 27.3 kB
backend/app/static/players/10037.jpg ADDED

Git LFS Details

  • SHA256: 07a002760e3719f5ffc1a7358b582da831bde6c8e06db7ea028f5c515db4acb7
  • Pointer size: 130 Bytes
  • Size of remote file: 23.6 kB
backend/app/static/players/10281.jpg ADDED

Git LFS Details

  • SHA256: 542e4778addfa35603784fc958942d32f1112d93e162032d50842c067684d32c
  • Pointer size: 129 Bytes
  • Size of remote file: 8.28 kB
backend/app/static/players/10287.jpg ADDED

Git LFS Details

  • SHA256: d0759fa410ae41aec365ec772e731fc683989f871433669c50ee0b54d137f5f8
  • Pointer size: 129 Bytes
  • Size of remote file: 7.28 kB
backend/app/static/players/10448.jpg ADDED

Git LFS Details

  • SHA256: 9d9255dceedc96979d610518ed08438b1fb2e1eb2b4c41bd5a1650488bda327a
  • Pointer size: 130 Bytes
  • Size of remote file: 11.5 kB
backend/app/static/players/10457.jpg ADDED

Git LFS Details

  • SHA256: 932995dac20827f989017a9d797abded8d53763ae29ee9afdefc7f30542969bb
  • Pointer size: 129 Bytes
  • Size of remote file: 9.85 kB
backend/app/static/players/10476.jpg ADDED

Git LFS Details

  • SHA256: ae0c24358dc8a0b221745052727bbcb4c4f9866fd6bc4cbe64407271adbe533f
  • Pointer size: 129 Bytes
  • Size of remote file: 8.93 kB
backend/app/static/players/10483.jpg ADDED

Git LFS Details

  • SHA256: 059066cbc95b1dc9f74050ba56f885e3b76e10da9d5c46dbdcfa756486ad2c55
  • Pointer size: 130 Bytes
  • Size of remote file: 34.1 kB
backend/app/static/players/10499.jpg ADDED

Git LFS Details

  • SHA256: 154a7fcfe00b4966516b1e4c6d2adb1f3bf6b3e459bd182f66d24a620b8b599a
  • Pointer size: 130 Bytes
  • Size of remote file: 30.1 kB
backend/app/static/players/10503.jpg ADDED

Git LFS Details

  • SHA256: 3544875d894e38761ac4bdbe1778d40d879c6cc429df4d8cfeb9f1878438e977
  • Pointer size: 129 Bytes
  • Size of remote file: 8.56 kB
backend/app/static/players/10522.jpg ADDED

Git LFS Details

  • SHA256: 8b55961713bfee1446892ce532f06ec9011cc3b41cc64033ea67a6479983250a
  • Pointer size: 130 Bytes
  • Size of remote file: 24 kB
backend/app/static/players/10530.jpg ADDED

Git LFS Details

  • SHA256: 07d5d0af57ca4440e40a8ab849b14208d1fb4db7e83aa94983c66271063fb67c
  • Pointer size: 130 Bytes
  • Size of remote file: 20.2 kB
backend/app/static/players/10594.jpg ADDED

Git LFS Details

  • SHA256: f2efc12f7f49ba13247c1198f4905f7b9b9ec664f71f108735389622b3922719
  • Pointer size: 130 Bytes
  • Size of remote file: 11.1 kB
backend/app/static/players/10609.jpg ADDED

Git LFS Details

  • SHA256: 1af17b827e0eae67c4e3b71908e1e569df3c415669141f7099999d881aac5b8f
  • Pointer size: 129 Bytes
  • Size of remote file: 7.72 kB
backend/app/static/players/10612.jpg ADDED

Git LFS Details

  • SHA256: d7c3f3aa665f5899d6e651ef8f5afb8c2ef26fd8948e7f4ecc0b702997653eb4
  • Pointer size: 130 Bytes
  • Size of remote file: 28 kB
backend/app/static/players/106683.jpg ADDED

Git LFS Details

  • SHA256: 7fa037500c7b8744356d90d03346f16989558e8d025b262a031560d9150b8ff4
  • Pointer size: 130 Bytes
  • Size of remote file: 26 kB
backend/app/static/players/10736.jpg ADDED

Git LFS Details

  • SHA256: 0a325fe1ae6f7393d497c6a0311fe2c15296f1b023d790035a697e9c7501a11c
  • Pointer size: 130 Bytes
  • Size of remote file: 30.2 kB
backend/app/static/players/10746.jpg ADDED

Git LFS Details

  • SHA256: 44d05da72d02c58cbf9f244042ac0652a779778934c1ac5ec8e113528dbe6d44
  • Pointer size: 129 Bytes
  • Size of remote file: 8.66 kB