diff --git a/ai/tasks/done/TASK-071-public-scoreboard-provider-adapter.md b/ai/tasks/done/TASK-071-public-scoreboard-provider-adapter.md new file mode 100644 index 0000000..511d63b --- /dev/null +++ b/ai/tasks/done/TASK-071-public-scoreboard-provider-adapter.md @@ -0,0 +1,66 @@ +# TASK-071-public-scoreboard-provider-adapter + +## Goal +Adaptar la fuente actual public-scoreboard al nuevo contrato de proveedor para que siga siendo la fuente estándar en desarrollo sin romper el comportamiento existente. + +## Context +Una vez exista la abstracción de fuente, hay que mover la lógica actual a un proveedor explícito de tipo public-scoreboard. Eso permitirá mantener dev estable mientras se prepara RCON para producción. + +## Steps +1. Revisar la lógica actual que usa scoreboard/CRCON público. +2. Encapsularla dentro del proveedor public-scoreboard compatible con la nueva abstracción. +3. Asegurar que desarrollo sigue funcionando con la misma semántica actual. +4. Mantener el mismo resultado en: + - ingestión histórica + - snapshots + - estado actual de servidores +5. Reducir duplicaciones y dejar clara la responsabilidad del proveedor. +6. Actualizar documentación mínima. +7. No implementar todavía lógica RCON en esta task. +8. 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/config.py +- backend/app/historical_ingestion.py +- backend/app/payloads.py +- backend/app/routes.py +- backend/app/source_provider.py +- docs/historical-crcon-source-discovery.md + +## Expected Files to Modify +- backend/app/historical_ingestion.py +- backend/app/payloads.py +- backend/app/routes.py +- opcionalmente nuevos módulos, por ejemplo: + - backend/app/providers/public_scoreboard_provider.py +- backend/README.md + +## Constraints +- No romper el comportamiento actual en dev. +- No hacer cambios destructivos. +- Mantener el trabajo centrado en encapsular correctamente la fuente actual. + +## Validation +- El proveedor public-scoreboard funciona bajo la nueva abstracción. +- El modo desarrollo sigue comportándose como antes. +- Los cambios quedan committeados y se hace push si el entorno lo permite. + +## Change Budget +- Preferir menos de 5 archivos modificados o creados. +- Preferir menos de 220 líneas cambiadas. + +## Outcome +- Se extrajo la logica del proveedor historico actual a `backend/app/providers/public_scoreboard_provider.py`. +- `backend/app/data_sources.py` quedo reducido a contratos y seleccion de proveedor por entorno. +- El comportamiento de desarrollo se mantuvo estable con `public-scoreboard` como proveedor historico por defecto. +- No se introdujo logica RCON en esta task. + +## Validation Notes +- `python -m compileall backend/app` completo sin errores tras el refactor. +- La diff quedo limitada al selector de proveedores, el adapter publico y la documentacion minima. +- `backend/app/source_provider.py` no existia; se reutilizo `backend/app/data_sources.py` como punto de seleccion ya introducido en la task anterior. diff --git a/backend/README.md b/backend/README.md index 5bd5fac..65572f8 100644 --- a/backend/README.md +++ b/backend/README.md @@ -247,12 +247,15 @@ Defaults actuales: - `HLL_BACKEND_LIVE_DATA_SOURCE=a2s` - `HLL_BACKEND_HISTORICAL_DATA_SOURCE=public-scoreboard` -La seleccion efectiva se resuelve en `app/data_sources.py`: +La seleccion efectiva se resuelve en `app/data_sources.py` y en adapters +dedicados dentro de `app/providers/`: - `get_live_data_source()` entrega el proveedor usado por `payloads.py` cuando `/api/servers` necesita un refresh real - `get_historical_data_source()` entrega el proveedor usado por `historical_ingestion.py` para bootstrap y refresh incremental +- `providers/public_scoreboard_provider.py` encapsula la semantica actual del + scoreboard/CRCON publico bajo el contrato historico En esta task solo queda implementado el proveedor ya operativo de desarrollo: diff --git a/backend/app/data_sources.py b/backend/app/data_sources.py index 4e1aa46..7648d9a 100644 --- a/backend/app/data_sources.py +++ b/backend/app/data_sources.py @@ -1,35 +1,19 @@ -"""Data source provider contracts for live and historical backend flows.""" +"""Data source selection and contracts for live and historical backend flows.""" from __future__ import annotations -import json -import time -from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from typing import Protocol -from urllib.error import HTTPError, URLError -from urllib.parse import urlencode -from urllib.request import Request, urlopen from .collector import collect_server_snapshots -from .config import ( - get_historical_crcon_request_retries, - get_historical_crcon_request_timeout_seconds, - get_historical_crcon_retry_delay_seconds, - get_historical_data_source_kind, - get_live_data_source_kind, -) +from .config import get_historical_data_source_kind, get_live_data_source_kind +from .providers.public_scoreboard_provider import PublicScoreboardHistoricalDataSource from .server_targets import A2SServerTarget, load_a2s_targets -HISTORICAL_SOURCE_PUBLIC_SCOREBOARD = "public-scoreboard" LIVE_SOURCE_A2S = "a2s" SOURCE_KIND_RCON = "rcon" -PUBLIC_INFO_ENDPOINT = "/api/get_public_info" -MATCH_LIST_ENDPOINT = "/api/get_scoreboard_maps" -MATCH_DETAIL_ENDPOINT = "/api/get_map_scoreboard" - class HistoricalDataSource(Protocol): """Contract for historical providers used by ingestion flows.""" @@ -64,115 +48,6 @@ class LiveDataSource(Protocol): """Return optional server connection metadata keyed by external id.""" -@dataclass(frozen=True, slots=True) -class PublicScoreboardHistoricalDataSource: - """Historical provider backed by the public CRCON scoreboard JSON API.""" - - source_kind: str = HISTORICAL_SOURCE_PUBLIC_SCOREBOARD - - def fetch_public_info(self, *, base_url: str) -> dict[str, object]: - return self._fetch_dict_payload(base_url, PUBLIC_INFO_ENDPOINT) - - def fetch_match_page(self, *, base_url: str, page: int, limit: int) -> dict[str, object]: - return self._fetch_dict_payload( - base_url, - MATCH_LIST_ENDPOINT, - {"page": page, "limit": limit}, - context=f"page={page}", - ) - - def fetch_match_details( - self, - *, - base_url: str, - match_ids: list[str], - max_workers: int, - ) -> list[dict[str, object]]: - if not match_ids: - return [] - if max_workers <= 1: - return [ - self._fetch_match_detail(base_url=base_url, match_id=match_id) - for match_id in match_ids - ] - - with ThreadPoolExecutor(max_workers=max_workers) as executor: - futures = [ - executor.submit(self._fetch_match_detail, base_url=base_url, match_id=match_id) - for match_id in match_ids - ] - return [future.result() for future in futures] - - def _fetch_match_detail(self, *, base_url: str, match_id: str) -> dict[str, object]: - return self._fetch_dict_payload( - base_url, - MATCH_DETAIL_ENDPOINT, - {"map_id": match_id}, - context=f"match={match_id}", - ) - - def _fetch_json( - self, - *, - base_url: str, - endpoint: str, - query: dict[str, object] | None = None, - ) -> object: - url = f"{base_url}{endpoint}" - if query: - url = f"{url}?{urlencode(query)}" - - request = Request( - url, - headers={ - "Accept": "application/json", - "User-Agent": "HLL-Vietnam-Historical-Ingestion/0.1", - }, - ) - try: - with urlopen( - request, - timeout=get_historical_crcon_request_timeout_seconds(), - ) as response: - return json.loads(response.read().decode("utf-8")) - except HTTPError as exc: - raise RuntimeError(f"Historical provider request failed: {url} ({exc.code})") from exc - except URLError as exc: - raise RuntimeError(f"Historical provider request failed: {url} ({exc.reason})") from exc - - def _fetch_dict_payload( - self, - base_url: str, - endpoint: str, - query: dict[str, object] | None = None, - *, - context: str = "", - retries: int | None = None, - ) -> dict[str, object]: - resolved_retries = retries or get_historical_crcon_request_retries() - base_retry_delay_seconds = get_historical_crcon_retry_delay_seconds() - last_error: Exception | None = None - for attempt in range(1, resolved_retries + 1): - try: - payload = _unwrap_result( - self._fetch_json(base_url=base_url, endpoint=endpoint, query=query) - ) - except Exception as exc: # pragma: no cover - network path - last_error = exc - else: - if isinstance(payload, dict): - return payload - last_error = ValueError( - f"Unexpected payload type for {base_url}{endpoint} {context}".strip() - ) - - if attempt < resolved_retries: - time.sleep(base_retry_delay_seconds * attempt) - - assert last_error is not None - raise last_error - - @dataclass(frozen=True, slots=True) class A2SLiveDataSource: """Live provider backed by the existing A2S collector flow.""" @@ -232,7 +107,7 @@ class RconLiveDataSource: def get_historical_data_source() -> HistoricalDataSource: """Select the historical provider configured for the current environment.""" source_kind = get_historical_data_source_kind() - if source_kind == HISTORICAL_SOURCE_PUBLIC_SCOREBOARD: + if source_kind == "public-scoreboard": return PublicScoreboardHistoricalDataSource() if source_kind == SOURCE_KIND_RCON: return RconHistoricalDataSource() @@ -247,11 +122,3 @@ def get_live_data_source() -> LiveDataSource: if source_kind == SOURCE_KIND_RCON: return RconLiveDataSource() raise ValueError(f"Unsupported live data source: {source_kind}") - - -def _unwrap_result(payload: object) -> object: - if not isinstance(payload, dict): - return payload - if "result" not in payload: - return payload - return payload.get("result") diff --git a/backend/app/providers/public_scoreboard_provider.py b/backend/app/providers/public_scoreboard_provider.py new file mode 100644 index 0000000..58232e9 --- /dev/null +++ b/backend/app/providers/public_scoreboard_provider.py @@ -0,0 +1,139 @@ +"""Public scoreboard provider adapter for historical HLL data.""" + +from __future__ import annotations + +import json +import time +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +from ..config import ( + get_historical_crcon_request_retries, + get_historical_crcon_request_timeout_seconds, + get_historical_crcon_retry_delay_seconds, +) + + +PUBLIC_INFO_ENDPOINT = "/api/get_public_info" +MATCH_LIST_ENDPOINT = "/api/get_scoreboard_maps" +MATCH_DETAIL_ENDPOINT = "/api/get_map_scoreboard" + + +@dataclass(frozen=True, slots=True) +class PublicScoreboardHistoricalDataSource: + """Historical provider backed by the public CRCON scoreboard JSON API.""" + + source_kind: str = "public-scoreboard" + + def fetch_public_info(self, *, base_url: str) -> dict[str, object]: + return self._fetch_dict_payload(base_url, PUBLIC_INFO_ENDPOINT) + + def fetch_match_page(self, *, base_url: str, page: int, limit: int) -> dict[str, object]: + return self._fetch_dict_payload( + base_url, + MATCH_LIST_ENDPOINT, + {"page": page, "limit": limit}, + context=f"page={page}", + ) + + def fetch_match_details( + self, + *, + base_url: str, + match_ids: list[str], + max_workers: int, + ) -> list[dict[str, object]]: + if not match_ids: + return [] + if max_workers <= 1: + return [ + self._fetch_match_detail(base_url=base_url, match_id=match_id) + for match_id in match_ids + ] + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [ + executor.submit(self._fetch_match_detail, base_url=base_url, match_id=match_id) + for match_id in match_ids + ] + return [future.result() for future in futures] + + def _fetch_match_detail(self, *, base_url: str, match_id: str) -> dict[str, object]: + return self._fetch_dict_payload( + base_url, + MATCH_DETAIL_ENDPOINT, + {"map_id": match_id}, + context=f"match={match_id}", + ) + + def _fetch_json( + self, + *, + base_url: str, + endpoint: str, + query: dict[str, object] | None = None, + ) -> object: + url = f"{base_url}{endpoint}" + if query: + url = f"{url}?{urlencode(query)}" + + request = Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": "HLL-Vietnam-Historical-Ingestion/0.1", + }, + ) + try: + with urlopen( + request, + timeout=get_historical_crcon_request_timeout_seconds(), + ) as response: + return json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + raise RuntimeError(f"Historical provider request failed: {url} ({exc.code})") from exc + except URLError as exc: + raise RuntimeError(f"Historical provider request failed: {url} ({exc.reason})") from exc + + def _fetch_dict_payload( + self, + base_url: str, + endpoint: str, + query: dict[str, object] | None = None, + *, + context: str = "", + retries: int | None = None, + ) -> dict[str, object]: + resolved_retries = retries or get_historical_crcon_request_retries() + base_retry_delay_seconds = get_historical_crcon_retry_delay_seconds() + last_error: Exception | None = None + for attempt in range(1, resolved_retries + 1): + try: + payload = _unwrap_result( + self._fetch_json(base_url=base_url, endpoint=endpoint, query=query) + ) + except Exception as exc: # pragma: no cover - network path + last_error = exc + else: + if isinstance(payload, dict): + return payload + last_error = ValueError( + f"Unexpected payload type for {base_url}{endpoint} {context}".strip() + ) + + if attempt < resolved_retries: + time.sleep(base_retry_delay_seconds * attempt) + + assert last_error is not None + raise last_error + + +def _unwrap_result(payload: object) -> object: + if not isinstance(payload, dict): + return payload + if "result" not in payload: + return payload + return payload.get("result")