From d7bb0992230079c1eb2c8550976a6d837895de8b Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Tue, 24 Mar 2026 09:59:12 +0100 Subject: [PATCH] Add monthly MVP snapshots and API --- .../TASK-068-monthly-mvp-snapshots-and-api.md | 69 +++++++++++++++ backend/README.md | 8 ++ backend/app/historical_snapshot_storage.py | 4 +- backend/app/historical_snapshots.py | 52 +++++++++++ backend/app/payloads.py | 88 +++++++++++++++++++ backend/app/routes.py | 22 +++++ 6 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 ai/tasks/done/TASK-068-monthly-mvp-snapshots-and-api.md diff --git a/ai/tasks/done/TASK-068-monthly-mvp-snapshots-and-api.md b/ai/tasks/done/TASK-068-monthly-mvp-snapshots-and-api.md new file mode 100644 index 0000000..7c85653 --- /dev/null +++ b/ai/tasks/done/TASK-068-monthly-mvp-snapshots-and-api.md @@ -0,0 +1,69 @@ +# TASK-068-monthly-mvp-snapshots-and-api + +## Goal +Exponer el ranking mensual MVP V1 mediante snapshots JSON y API propia del backend, siguiendo la misma filosofía rápida y estable ya usada por la capa histórica. + +## Context +Una vez implementado el cálculo backend del monthly MVP, hace falta integrarlo en la capa de snapshots del proyecto para que: +- no dependa de cálculos pesados on-demand +- pueda servirse rápido a la UI +- sea consistente con el resto de la arquitectura histórica + +## Steps +1. Revisar la implementación del cálculo mensual MVP y la arquitectura actual de snapshots JSON. +2. Diseñar la forma de snapshot para el ranking mensual MVP, tanto por servidor como para all-servers. +3. Generar snapshots JSON apropiados, por ejemplo: + - server monthly mvp + - all-servers monthly mvp +4. Incluir metadatos claros, como mínimo: + - generated_at + - source_range_start + - source_range_end + - freshness / is_stale si aplica + - month_key o periodo real usado +5. Exponer endpoints claros y consistentes para leer esos snapshots. +6. Mantener compatibilidad y coherencia con la API histórica existente. +7. Integrar el monthly MVP en el runner/generación periódica de snapshots si corresponde. +8. No crear todavía la UI final en esta task. +9. Al completar la implementación: + - dejar el repositorio consistente + - hacer commit + - hacer push al remoto si el entorno lo permite + +## Files to Read First +- AGENTS.md +- backend/README.md +- backend/app/routes.py +- backend/app/payloads.py +- backend/app/historical_snapshots.py +- backend/app/historical_snapshot_storage.py +- backend/app/historical_runner.py +- backend/app/config.py +- docs/monthly-mvp-ranking-scoring-design.md + +## Expected Files to Modify +- backend/app/routes.py +- backend/app/payloads.py +- backend/app/historical_snapshots.py +- backend/app/historical_snapshot_storage.py +- backend/app/historical_runner.py +- backend/README.md +- opcionalmente nuevos módulos auxiliares si mejoran claridad + +## Constraints +- No recalcular el ranking pesado en cada request de usuario. +- No romper snapshots históricos existentes. +- No crear todavía UI nueva en esta task. +- No hacer cambios destructivos. +- Mantener el trabajo centrado en snapshots y API del monthly MVP. + +## Validation +- Existen snapshots JSON del monthly MVP para servidor y/o all-servers según diseño. +- Existen endpoints para leer esos snapshots. +- El monthly MVP se integra con la operativa de snapshots del proyecto. +- La respuesta es rápida y coherente con el resto de la API histórica. +- Los cambios quedan committeados y se hace push si el entorno lo permite. + +## Change Budget +- Preferir menos de 6 archivos modificados o creados. +- Preferir menos de 240 líneas cambiadas. diff --git a/backend/README.md b/backend/README.md index 38d4c00..af9f912 100644 --- a/backend/README.md +++ b/backend/README.md @@ -182,11 +182,13 @@ normaliza espacios y barras finales para mantener la comparacion con el header - `GET /api/historical/weekly-top-kills?limit=10&server=comunidad-hispana-01` - `GET /api/historical/weekly-leaderboard?metric=kills&limit=10&server=comunidad-hispana-01` - `GET /api/historical/leaderboard?timeframe=monthly&metric=kills&limit=10&server=comunidad-hispana-01` +- `GET /api/historical/monthly-mvp?limit=10&server=comunidad-hispana-01` - `GET /api/historical/recent-matches?limit=20&server=comunidad-hispana-01` - `GET /api/historical/server-summary?server=comunidad-hispana-01` - `GET /api/historical/snapshots/server-summary?server=comunidad-hispana-01` - `GET /api/historical/snapshots/weekly-leaderboard?metric=kills&limit=10&server=comunidad-hispana-01` - `GET /api/historical/snapshots/leaderboard?timeframe=monthly&metric=kills&limit=10&server=comunidad-hispana-01` +- `GET /api/historical/snapshots/monthly-mvp?limit=10&server=comunidad-hispana-01` - `GET /api/historical/snapshots/recent-matches?limit=6&server=comunidad-hispana-01` - `GET /api/historical/player-profile?player=steam%3A76561198000000000` @@ -289,6 +291,7 @@ bruto. Esta capa esta preparada para guardar: - `server-summary` - `weekly-leaderboard` con metricas `kills`, `deaths`, `support` y `matches_over_100_kills` - `monthly-leaderboard` con las mismas metricas semanticas +- `monthly-mvp` - `recent-matches` Por defecto se escriben bajo: @@ -309,6 +312,7 @@ Ejemplos: - `backend/data/snapshots/comunidad-hispana-01/weekly-kills.json` - `backend/data/snapshots/comunidad-hispana-03/recent-matches.json` - `backend/data/snapshots/all-servers/weekly-support.json` +- `backend/data/snapshots/all-servers/monthly-mvp.json` Cada archivo conserva metadatos operativos minimos: @@ -650,6 +654,10 @@ La misma capa de snapshots guarda tambien `monthly-leaderboard` por servidor y por agregado `all-servers`, con archivos como `monthly-kills.json` y `monthly-support.json`. +Tambien persiste `monthly-mvp.json` por servidor y para `all-servers`, listo +para lectura rapida desde `/api/historical/monthly-mvp` y +`/api/historical/snapshots/monthly-mvp` sin recalculo pesado en request. + El backend incluye ademas el calculo interno de `monthly MVP V1` en `app/monthly_mvp.py`, separado de los leaderboards mensuales simples por metrica. Ese calculo: diff --git a/backend/app/historical_snapshot_storage.py b/backend/app/historical_snapshot_storage.py index d3c2b2d..edb8f70 100644 --- a/backend/app/historical_snapshot_storage.py +++ b/backend/app/historical_snapshot_storage.py @@ -249,7 +249,7 @@ def _is_effectively_empty_snapshot_payload( items = payload.get("items") return not isinstance(items, list) or len(items) == 0 - if snapshot_type in {"weekly-leaderboard", "monthly-leaderboard"}: + if snapshot_type in {"weekly-leaderboard", "monthly-leaderboard", "monthly-mvp"}: items = payload.get("items") return not isinstance(items, list) or len(items) == 0 @@ -287,6 +287,8 @@ def _build_snapshot_filename(*, snapshot_type: str, metric: str | None) -> str: if snapshot_type == "monthly-leaderboard": metric_suffix = "matches-over-100-kills" if metric == "matches_over_100_kills" else _slugify(metric or "unknown") return f"monthly-{metric_suffix}.json" + if snapshot_type == "monthly-mvp": + return "monthly-mvp.json" metric_suffix = _slugify(metric or "") base_name = _slugify(snapshot_type) return f"{base_name}-{metric_suffix}.json" if metric_suffix else f"{base_name}.json" diff --git a/backend/app/historical_snapshots.py b/backend/app/historical_snapshots.py index 8492e95..8784754 100644 --- a/backend/app/historical_snapshots.py +++ b/backend/app/historical_snapshots.py @@ -10,6 +10,7 @@ from .historical_storage import ( list_historical_server_summaries, list_historical_servers, list_monthly_leaderboard, + list_monthly_mvp_ranking, list_recent_historical_matches, list_weekly_leaderboard, ) @@ -17,6 +18,7 @@ from .historical_storage import ( SNAPSHOT_TYPE_SERVER_SUMMARY = "server-summary" SNAPSHOT_TYPE_WEEKLY_LEADERBOARD = "weekly-leaderboard" SNAPSHOT_TYPE_MONTHLY_LEADERBOARD = "monthly-leaderboard" +SNAPSHOT_TYPE_MONTHLY_MVP = "monthly-mvp" SNAPSHOT_TYPE_RECENT_MATCHES = "recent-matches" SUPPORTED_SNAPSHOT_TYPES = frozenset( @@ -24,6 +26,7 @@ SUPPORTED_SNAPSHOT_TYPES = frozenset( SNAPSHOT_TYPE_SERVER_SUMMARY, SNAPSHOT_TYPE_WEEKLY_LEADERBOARD, SNAPSHOT_TYPE_MONTHLY_LEADERBOARD, + SNAPSHOT_TYPE_MONTHLY_MVP, SNAPSHOT_TYPE_RECENT_MATCHES, } ) @@ -121,7 +124,16 @@ def build_historical_server_snapshots( limit=leaderboard_limit, db_path=db_path, ) + ) + + snapshots.append( + _build_monthly_mvp_snapshot( + server_key, + generated_at_value, + limit=leaderboard_limit, + db_path=db_path, ) + ) snapshots.append( _build_recent_matches_snapshot( @@ -168,6 +180,14 @@ def build_priority_historical_snapshots( db_path=db_path, ) ) + snapshots.append( + _build_monthly_mvp_snapshot( + server_key, + generated_at_value, + limit=leaderboard_limit, + db_path=db_path, + ) + ) snapshots.append( _build_recent_matches_snapshot( server_key, @@ -400,6 +420,38 @@ def _build_recent_matches_snapshot( } +def _build_monthly_mvp_snapshot( + server_key: str, + generated_at: datetime, + *, + limit: int, + db_path: Path | None = None, +) -> dict[str, object]: + ranking_result = list_monthly_mvp_ranking( + limit=limit, + server_id=server_key, + db_path=db_path, + ) + month_key = str(ranking_result.get("window_start") or "")[:7] or None + return { + "server_key": server_key, + "snapshot_type": SNAPSHOT_TYPE_MONTHLY_MVP, + "metric": None, + "window": DEFAULT_MONTHLY_SNAPSHOT_WINDOW, + "generated_at": generated_at, + "source_range_start": _parse_optional_timestamp(ranking_result.get("window_start")), + "source_range_end": _parse_optional_timestamp(ranking_result.get("window_end")), + "is_stale": False, + "payload": { + "server_key": server_key, + "limit": limit, + "month_key": month_key, + "generated_at": _to_iso(generated_at), + **ranking_result, + }, + } + + def _resolve_snapshot_target_keys( *, server_key: str | None, diff --git a/backend/app/payloads.py b/backend/app/payloads.py index 226a1c9..e076681 100644 --- a/backend/app/payloads.py +++ b/backend/app/payloads.py @@ -12,6 +12,7 @@ from .historical_snapshots import ( DEFAULT_SNAPSHOT_WINDOW, DEFAULT_WEEKLY_SNAPSHOT_WINDOW, SNAPSHOT_TYPE_MONTHLY_LEADERBOARD, + SNAPSHOT_TYPE_MONTHLY_MVP, SNAPSHOT_TYPE_RECENT_MATCHES, SNAPSHOT_TYPE_SERVER_SUMMARY, SNAPSHOT_TYPE_WEEKLY_LEADERBOARD, @@ -326,6 +327,31 @@ def build_recent_historical_matches_payload( } +def build_monthly_mvp_payload( + *, + limit: int = 10, + server_id: str | None = None, +) -> dict[str, object]: + """Return the precomputed monthly MVP payload through the stable API surface.""" + snapshot_payload = build_monthly_mvp_snapshot_payload( + limit=limit, + server_id=server_id, + ) + data = snapshot_payload["data"] + return { + "status": "ok", + "data": { + **data, + "title": _build_monthly_mvp_title( + is_all_servers=server_id == ALL_SERVERS_SLUG, + snapshot=False, + ), + "context": "historical-monthly-mvp", + "source": "historical-precomputed-snapshots", + }, + } + + def build_historical_server_summary_snapshot_payload( *, server_slug: str | None = None, @@ -486,6 +512,62 @@ def build_recent_historical_matches_snapshot_payload( } +def build_monthly_mvp_snapshot_payload( + *, + limit: int = 10, + server_id: str | None = None, +) -> dict[str, object]: + """Return one precomputed monthly MVP snapshot.""" + snapshot = _get_historical_snapshot_record( + server_key=server_id, + snapshot_type=SNAPSHOT_TYPE_MONTHLY_MVP, + window=DEFAULT_MONTHLY_SNAPSHOT_WINDOW, + ) + payload = snapshot.get("payload") if snapshot else {} + items = payload.get("items") if isinstance(payload, dict) else None + sliced_items = list(items[:limit]) if isinstance(items, list) else [] + return { + "status": "ok", + "data": { + "title": _build_monthly_mvp_title( + is_all_servers=server_id == ALL_SERVERS_SLUG, + snapshot=True, + ), + "context": "historical-monthly-mvp-snapshot", + "source": "historical-precomputed-snapshots", + "server_slug": server_id, + "timeframe": "monthly", + "metric": "mvp", + "found": snapshot is not None, + **_build_historical_snapshot_metadata(snapshot), + "month_key": payload.get("month_key") if isinstance(payload, dict) else None, + "window_days": payload.get("window_days") if isinstance(payload, dict) else None, + "window_start": payload.get("window_start") if isinstance(payload, dict) else None, + "window_end": payload.get("window_end") if isinstance(payload, dict) else None, + "window_kind": payload.get("window_kind") if isinstance(payload, dict) else None, + "window_label": payload.get("window_label") if isinstance(payload, dict) else None, + "uses_fallback": bool(payload.get("uses_fallback")) if isinstance(payload, dict) else False, + "selection_reason": payload.get("selection_reason") if isinstance(payload, dict) else None, + "current_month_start": payload.get("current_month_start") if isinstance(payload, dict) else None, + "current_month_closed_matches": ( + payload.get("current_month_closed_matches") if isinstance(payload, dict) else None + ), + "previous_month_closed_matches": ( + payload.get("previous_month_closed_matches") if isinstance(payload, dict) else None + ), + "sufficient_sample": payload.get("sufficient_sample") if isinstance(payload, dict) else None, + "eligibility": payload.get("eligibility") if isinstance(payload, dict) else None, + "ranking_version": payload.get("ranking_version") if isinstance(payload, dict) else None, + "eligible_players_count": ( + payload.get("eligible_players_count") if isinstance(payload, dict) else 0 + ), + "snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None, + "limit": limit, + "items": sliced_items, + }, + } + + def build_historical_server_summary_payload( *, server_slug: str | None = None, @@ -590,6 +672,12 @@ def _build_leaderboard_title( return title_by_metric.get(metric, fallback_label) +def _build_monthly_mvp_title(*, is_all_servers: bool, snapshot: bool = False) -> str: + prefix = "Snapshot " if snapshot else "" + scope_label = "global" if is_all_servers else "por servidor" + return f"{prefix}Top MVP mensual {scope_label}" + + def _enrich_server_items(items: list[dict[str, object]]) -> list[dict[str, object]]: target_index = { target.external_server_id: target diff --git a/backend/app/routes.py b/backend/app/routes.py index 47dd152..4665c83 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -11,8 +11,10 @@ from .payloads import ( build_error_payload, build_health_payload, build_historical_leaderboard_payload, + build_monthly_mvp_payload, build_monthly_leaderboard_payload, build_monthly_leaderboard_snapshot_payload, + build_monthly_mvp_snapshot_payload, build_historical_server_summary_snapshot_payload, build_historical_player_profile_payload, build_historical_server_summary_payload, @@ -107,6 +109,16 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object] metric=metric, ) + if parsed.path == "/api/historical/monthly-mvp": + limit = _parse_limit(parsed.query) + if limit is None: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter") + server_id = parse_qs(parsed.query).get("server", [None])[0] + return HTTPStatus.OK, build_monthly_mvp_payload( + limit=limit, + server_id=server_id, + ) + if parsed.path == "/api/historical/snapshots/leaderboard": limit = _parse_limit(parsed.query) if limit is None: @@ -141,6 +153,16 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object] metric=metric, ) + if parsed.path == "/api/historical/snapshots/monthly-mvp": + limit = _parse_limit(parsed.query) + if limit is None: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter") + server_id = parse_qs(parsed.query).get("server", [None])[0] + return HTTPStatus.OK, build_monthly_mvp_snapshot_payload( + limit=limit, + server_id=server_id, + ) + if parsed.path == "/api/historical/snapshots/weekly-leaderboard": limit = _parse_limit(parsed.query) if limit is None: