Add annual ranking snapshot API

This commit is contained in:
devRaGonSa
2026-06-08 14:27:01 +02:00
parent 4c4de2b053
commit c8ba141a4f
4 changed files with 247 additions and 27 deletions

View File

@@ -1,7 +1,7 @@
--- ---
id: TASK-169-add-annual-ranking-snapshot-api id: TASK-169-add-annual-ranking-snapshot-api
title: Add annual ranking snapshot API title: Add annual ranking snapshot API
status: pending status: done
type: backend type: backend
team: Backend Senior team: Backend Senior
supporting_teams: supporting_teams:
@@ -15,20 +15,20 @@ priority: medium
## Goal ## Goal
Exponer una API de lectura pública para el ranking anual top 20 precomputado desde snapshots almacenadas. Exponer una API de lectura publica para el ranking anual top 20 precomputado desde snapshots almacenadas.
## Context ## Context
La API debe consumir `rcon_annual_ranking_snapshots` y `rcon_annual_ranking_snapshot_items` generados por el módulo de generación (TASK-168) y las tablas añadidas por TASK-167. La API debe consumir `rcon_annual_ranking_snapshots` y `rcon_annual_ranking_snapshot_items` generados por el modulo de generacion (TASK-168) y las tablas añadidas por TASK-167.
Mantiene compatibilidad entre SQLite y Postgres usando el patrón de conexión compartido. Mantiene compatibilidad entre SQLite y Postgres usando el patron de conexion compartido.
## Steps ## Steps
1. Leer los archivos indicados en "Files to Read First". 1. Leer los archivos indicados en "Files to Read First".
2. Implementar el endpoint GET `/api/stats/rankings/annual` en backend sin recalcular ranking. 2. Implementar el endpoint GET `/api/stats/rankings/annual` en backend sin recalcular ranking.
3. Añadir payload y registro de ruta siguiendo patrones existentes. 3. Anadir payload y registro de ruta siguiendo patrones existentes.
4. Validar comportamientos esperados (snapshot existente, faltante, métrica no soportada). 4. Validar comportamientos esperados (snapshot existente, faltante, metrica no soportada).
## Files to Read First ## Files to Read First
@@ -47,7 +47,6 @@ Mantiene compatibilidad entre SQLite y Postgres usando el patr
## Expected Files to Modify ## Expected Files to Modify
- `ai/tasks/pending/TASK-169-add-annual-ranking-snapshot-api.md` (al crear la tarea)
- `backend/app/rcon_annual_rankings.py` - `backend/app/rcon_annual_rankings.py`
- `backend/app/payloads.py` - `backend/app/payloads.py`
- `backend/app/routes.py` - `backend/app/routes.py`
@@ -65,17 +64,47 @@ Mantiene compatibilidad entre SQLite y Postgres usando el patr
- No tocar `frontend/assets/js/partida-actual.js` ni `frontend/assets/img/clans/bxb.png`. - No tocar `frontend/assets/js/partida-actual.js` ni `frontend/assets/img/clans/bxb.png`.
- No reactivar Elo/MMR. - No reactivar Elo/MMR.
- No incluir Comunidad Hispana #03. - No incluir Comunidad Hispana #03.
- No modificar workers históricos salvo justificación imprescindible. - No modificar workers historicos salvo justificacion imprescindible.
## Validation ## Validation
- `python -m compileall backend/app` - `python -m compileall backend/app`
- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` - `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`
- Validación manual del contrato con backend local o método equivalente. - Validacion manual del contrato con backend local o metodo equivalente.
## Outcome ## Outcome
- registrar archivos modificados ### Files modified
- validar comportamiento con y sin snapshot
- indicar limitaciones conocidas - `backend/app/rcon_annual_rankings.py`
- proponer siguiente tarea - `backend/app/payloads.py`
- `backend/app/routes.py`
### Endpoint created
- `GET /api/stats/rankings/annual?year=<year>&server_id=<server-or-all>&metric=kills&limit=<n>`
- Respuestas:
- `snapshot_status=ready` cuando existe snapshot.
- `snapshot_status=missing` cuando no existe snapshot, y devuelve `items=[]`.
- Soporta `metric=kills` y devuelve 400 para metricas no soportadas.
### Validations performed
- `python -m compileall backend/app`
- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`
- Validacion manual usando `app.routes.resolve_get_payload`:
- `/api/stats/rankings/annual` (HTTP 200, respuesta OK)
- `/api/stats/rankings/annual?year=1990` (HTTP 200)
- `/api/stats/rankings/annual?year=1990&metric=kills&limit=3` (HTTP 200)
- `/api/stats/rankings/annual?metric=deaths` (HTTP 400, metrica invalida)
- `/api/stats/rankings/annual?year=1900` (HTTP 200, snapshot_status `missing`, `items=[]`)
### Limitations
- Soporta solo `metric=kills` en esta fase.
- Endpoint no genera snapshots (no recalculo); requiere que TASK-168 haya generado datos.
- No se toco frontend en este alcance.
### Next task recommendation
Conectar ranking anual top 20 desde frontend Stats.

View File

@@ -49,6 +49,7 @@ from .historical_storage import (
list_weekly_top_kills, list_weekly_top_kills,
) )
from .rcon_historical_read_model import get_rcon_historical_match_detail from .rcon_historical_read_model import get_rcon_historical_match_detail
from .rcon_annual_rankings import get_annual_ranking_snapshot
from .rcon_historical_player_stats import search_rcon_materialized_players from .rcon_historical_player_stats import search_rcon_materialized_players
from .rcon_historical_player_stats import get_rcon_materialized_player_stats from .rcon_historical_player_stats import get_rcon_materialized_player_stats
from .normalizers import normalize_map_name from .normalizers import normalize_map_name
@@ -661,6 +662,53 @@ def build_stats_player_profile_payload(
} }
def build_annual_ranking_snapshot_payload(
*,
year: int,
server_id: str | None = None,
metric: str = "kills",
limit: int = 20,
) -> dict[str, object]:
"""Return an annual ranking payload from precomputed snapshots."""
result = get_annual_ranking_snapshot(
year=year,
server_key=server_id,
metric=metric,
limit=limit,
)
items = result.get("items") or []
return {
"status": "ok",
"data": {
"year": result.get("year"),
"server_id": result.get("server_id"),
"metric": result.get("metric"),
"limit": result.get("limit"),
"source": result.get("source"),
"snapshot_status": result.get("snapshot_status"),
"generated_at": result.get("generated_at"),
"window_start": result.get("window_start"),
"window_end": result.get("window_end"),
"source_matches_count": int(result.get("source_matches_count") or 0),
"items": [
{
"ranking_position": int(item.get("ranking_position") or 0),
"player_id": item.get("player_id"),
"player_name": item.get("player_name"),
"metric_value": int(item.get("metric_value") or 0),
"matches_considered": int(item.get("matches_considered") or 0),
"kills": int(item.get("kills") or 0),
"deaths": int(item.get("deaths") or 0),
"teamkills": int(item.get("teamkills") or 0),
"kd_ratio": float(item.get("kd_ratio") or 0.0),
}
for item in items
if isinstance(item, dict)
],
},
}
def build_weekly_leaderboard_payload( def build_weekly_leaderboard_payload(
*, *,
limit: int = 10, limit: int = 10,

View File

@@ -1,16 +1,17 @@
"""Annual ranking snapshot generator over materialized RCON match stats.""" """Annual ranking snapshot generator and reader over materialized RCON match stats."""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json import json
from contextlib import closing
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from .config import get_storage_path, use_postgres_rcon_storage from .config import get_storage_path, use_postgres_rcon_storage
from .historical_storage import ALL_SERVERS_SLUG from .historical_storage import ALL_SERVERS_SLUG
from .rcon_admin_log_materialization import MATCH_RESULT_SOURCE, initialize_rcon_materialized_storage from .rcon_admin_log_materialization import MATCH_RESULT_SOURCE, initialize_rcon_materialized_storage
from .sqlite_utils import connect_sqlite_writer from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer
def generate_annual_ranking_snapshot( def generate_annual_ranking_snapshot(
@@ -73,7 +74,7 @@ def generate_annual_ranking_snapshot(
scope_params=scope_params, scope_params=scope_params,
) )
snapshot_id = _delete_existing_snapshot( _delete_existing_snapshot(
connection=connection, connection=connection,
year=year, year=year,
server_key=normalized_server_key, server_key=normalized_server_key,
@@ -109,6 +110,75 @@ def generate_annual_ranking_snapshot(
} }
def get_annual_ranking_snapshot(
*,
year: int,
server_key: str | None = None,
metric: str = "kills",
limit: int = 20,
db_path: Path | None = None,
) -> dict[str, object]:
"""Load one annual ranking snapshot without recalculating the ranking."""
normalized_year = int(year)
if normalized_year < 1:
raise ValueError("year must be greater than zero")
normalized_server_key = _normalize_server_key(server_key)
normalized_metric = _normalize_metric(metric)
normalized_limit = max(1, int(limit))
resolved_path = initialize_rcon_materialized_storage(db_path=db_path)
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import connect_postgres_compat
connection_scope = connect_postgres_compat()
else:
connection_scope = closing(connect_sqlite_readonly(resolved_path))
with connection_scope as connection:
snapshot = _find_snapshot(
connection=connection,
year=normalized_year,
server_key=normalized_server_key,
metric=normalized_metric,
)
if snapshot is None:
return {
"snapshot_status": "missing",
"year": normalized_year,
"server_id": normalized_server_key,
"metric": normalized_metric,
"limit": normalized_limit,
"source": "rcon-annual-ranking-snapshot",
"generated_at": None,
"window_start": None,
"window_end": None,
"source_matches_count": 0,
"items": [],
}
effective_limit = min(normalized_limit, int(snapshot.get("limit_size") or normalized_limit))
items = _list_items(
connection=connection,
snapshot_id=int(snapshot["id"]),
limit=effective_limit,
)
return {
"snapshot_status": "ready",
"year": normalized_year,
"server_id": normalized_server_key,
"metric": normalized_metric,
"limit": effective_limit,
"source": "rcon-annual-ranking-snapshot",
"generated_at": snapshot.get("generated_at"),
"window_start": snapshot.get("window_start"),
"window_end": snapshot.get("window_end"),
"source_matches_count": int(snapshot.get("source_matches_count") or 0),
"items": items,
}
def _normalize_server_key(server_key: str | None) -> str: def _normalize_server_key(server_key: str | None) -> str:
normalized = str(server_key or "").strip() normalized = str(server_key or "").strip()
normalized_lower = normalized.lower() normalized_lower = normalized.lower()
@@ -120,7 +190,7 @@ def _normalize_server_key(server_key: str | None) -> str:
def _normalize_metric(metric: str) -> str: def _normalize_metric(metric: str) -> str:
normalized = str(metric or "kills").strip().lower() normalized = str(metric or "kills").strip().lower()
if normalized != "kills": if normalized != "kills":
raise ValueError("Only metric 'kills' is supported for annual ranking generation.") raise ValueError("Only metric 'kills' is supported for annual ranking endpoints.")
return normalized return normalized
@@ -141,6 +211,9 @@ def _fetch_annual_ranking_rows(
scope_params: list[object], scope_params: list[object],
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
# For now metric support is intentionally narrowed to kills only. # For now metric support is intentionally narrowed to kills only.
if metric != "kills":
return []
rows = connection.execute( rows = connection.execute(
f""" f"""
SELECT SELECT
@@ -167,8 +240,6 @@ def _fetch_annual_ranking_rows(
""", """,
[MATCH_RESULT_SOURCE, start, end, *scope_params, limit], [MATCH_RESULT_SOURCE, start, end, *scope_params, limit],
).fetchall() ).fetchall()
if metric != "kills":
return []
return [dict(row) for row in rows] return [dict(row) for row in rows]
@@ -198,6 +269,36 @@ def _count_matches_in_window(
return int(row["source_matches_count"] or 0) if row else 0 return int(row["source_matches_count"] or 0) if row else 0
def _find_snapshot(
*,
connection: object,
year: int,
server_key: str,
metric: str,
) -> dict[str, object] | None:
row = connection.execute(
"""
SELECT
id,
year,
server_key,
metric,
limit_size,
source_basis,
window_start,
window_end,
status,
source_matches_count,
generated_at
FROM rcon_annual_ranking_snapshots
WHERE year = ? AND server_key = ? AND metric = ?
LIMIT 1
""",
[year, server_key, metric],
).fetchone()
return dict(row) if row else None
def _find_existing_snapshot( def _find_existing_snapshot(
*, *,
connection: object, connection: object,
@@ -361,9 +462,9 @@ def _get_snapshot(*, connection: object, snapshot_id: int) -> dict[str, object]
return dict(row) return dict(row)
def _list_items(*, connection: object, snapshot_id: int) -> list[dict[str, object]]: def _list_items(*, connection: object, snapshot_id: int, limit: int | None = None) -> list[dict[str, object]]:
rows = connection.execute( params: list[object] = [snapshot_id]
""" query = """
SELECT SELECT
ranking_position, ranking_position,
player_id, player_id,
@@ -377,16 +478,21 @@ def _list_items(*, connection: object, snapshot_id: int) -> list[dict[str, objec
FROM rcon_annual_ranking_snapshot_items FROM rcon_annual_ranking_snapshot_items
WHERE snapshot_id = ? WHERE snapshot_id = ?
ORDER BY ranking_position ASC ORDER BY ranking_position ASC
""", """
[snapshot_id], if limit is not None:
).fetchall() query = f"{query}\n LIMIT ?"
params.append(limit)
rows = connection.execute(query, params).fetchall()
return [dict(row) for row in rows] return [dict(row) for row in rows]
def _build_scope_sql(server_key: str, *, table_alias: str = "matches") -> tuple[str, list[object]]: def _build_scope_sql(server_key: str, *, table_alias: str = "matches") -> tuple[str, list[object]]:
if server_key == ALL_SERVERS_SLUG: if server_key == ALL_SERVERS_SLUG:
return "", [] return "", []
return f"AND ({table_alias}.target_key = ? OR {table_alias}.external_server_id = ?)", [server_key, server_key] return (
f"AND ({table_alias}.target_key = ? OR {table_alias}.external_server_id = ?)",
[server_key, server_key],
)
def _main(argv: list[str] | None = None) -> int: def _main(argv: list[str] | None = None) -> int:

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from http import HTTPStatus from http import HTTPStatus
from datetime import datetime, timezone
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from .config import get_historical_data_source_kind from .config import get_historical_data_source_kind
@@ -17,6 +18,7 @@ from .payloads import (
build_elo_mmr_player_payload, build_elo_mmr_player_payload,
build_error_payload, build_error_payload,
build_health_payload, build_health_payload,
build_annual_ranking_snapshot_payload,
build_historical_leaderboard_payload, build_historical_leaderboard_payload,
build_historical_match_detail_payload, build_historical_match_detail_payload,
build_monthly_mvp_payload, build_monthly_mvp_payload,
@@ -85,6 +87,27 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
limit=limit, limit=limit,
) )
if parsed.path == "/api/stats/rankings/annual":
params = parse_qs(parsed.query)
metric = params.get("metric", ["kills"])[0]
if metric != "kills":
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter")
year = _parse_year(parsed.query)
if year is None:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid year parameter")
limit = _parse_limit(parsed.query)
if limit is None:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
server_id = params.get("server_id", [None])[0]
if server_id is None:
server_id = params.get("server", [None])[0]
return HTTPStatus.OK, build_annual_ranking_snapshot_payload(
year=year,
server_id=server_id,
metric=metric,
limit=limit,
)
if parsed.path == "/api/current-match": if parsed.path == "/api/current-match":
server_slug = parse_qs(parsed.query).get("server", [None])[0] server_slug = parse_qs(parsed.query).get("server", [None])[0]
if not server_slug: if not server_slug:
@@ -431,6 +454,20 @@ def _parse_limit(query: str) -> int | None:
return limit return limit
def _parse_year(query: str) -> int | None:
params = parse_qs(query)
raw_year = params.get("year", [None])[0]
if raw_year is None:
return datetime.now(timezone.utc).year
try:
year = int(raw_year)
except ValueError:
return None
if year <= 0:
return None
return year
def _parse_limit_with_default(query: str, default: int = 20) -> int | None: def _parse_limit_with_default(query: str, default: int = 20) -> int | None:
params = parse_qs(query) params = parse_qs(query)
if "limit" not in params: if "limit" not in params: