"""Typed client for the Unit 4 GAIA scoring API. Four routes are exposed by the course backend: * ``GET /questions`` -> list of question objects * ``GET /random-question`` -> a single question object * ``GET /files/{task_id}`` -> the raw file attached to a task (bytes) * ``POST /submit`` -> score the submitted answers Every function here wraps one of those routes with timeouts, retry on transient failures, and clear exceptions. JSON routes return parsed Python objects; the file route returns raw ``bytes``. """ from __future__ import annotations import time from typing import Any, Dict, List, Mapping, Sequence import requests from . import config class ScoringAPIError(RuntimeError): """Raised when the scoring API returns an error or is unreachable.""" def __init__(self, message: str, *, status_code: int | None = None) -> None: super().__init__(message) self.status_code = status_code # Status codes worth retrying: transient server / gateway errors. _RETRYABLE_STATUS = {500, 502, 503, 504} def _request( method: str, path: str, *, json_body: Any | None = None, expect_json: bool = True, ) -> Any: """Perform an HTTP request with timeout, retry and uniform error handling. Returns parsed JSON when ``expect_json`` is True, otherwise raw bytes. Raises :class:`ScoringAPIError` on any unrecoverable failure. """ url = f"{config.SCORING_API_BASE}/{path.lstrip('/')}" last_exc: Exception | None = None for attempt in range(1, config.MAX_RETRIES + 1): try: response = requests.request( method, url, json=json_body, timeout=config.DEFAULT_TIMEOUT, ) except requests.RequestException as exc: # Network-level failure (DNS, connection reset, timeout). Retry, but # don't sleep after the final attempt — we're about to give up. last_exc = exc if attempt < config.MAX_RETRIES: _backoff(attempt) continue # Retry transient server errors; surface everything else immediately. if response.status_code in _RETRYABLE_STATUS and attempt < config.MAX_RETRIES: last_exc = ScoringAPIError( f"{method} {url} returned {response.status_code}", status_code=response.status_code, ) _backoff(attempt) continue if not response.ok: raise ScoringAPIError( f"{method} {url} failed: {response.status_code} {response.text[:300]}", status_code=response.status_code, ) if expect_json: try: return response.json() except ValueError as exc: raise ScoringAPIError( f"{method} {url} returned non-JSON body: {response.text[:200]}" ) from exc return response.content # Exhausted retries. raise ScoringAPIError( f"{method} {url} failed after {config.MAX_RETRIES} attempts: {last_exc}" ) from last_exc def _backoff(attempt: int) -> None: """Exponential backoff: 1s, 2s, 4s, ... capped at 16s.""" time.sleep(min(2 ** (attempt - 1), 16)) def get_questions() -> List[Dict[str, Any]]: """Return the full list of Unit 4 questions. Each item is a dict that includes at least ``task_id`` and ``question``; tasks with attachments also carry a non-empty ``file_name``. """ return _request("GET", "/questions") def get_random_question() -> Dict[str, Any]: """Return a single random question dict (handy for quick dry runs).""" return _request("GET", "/random-question") def get_file(task_id: str) -> bytes: """Download the raw file attached to ``task_id`` and return its bytes. The caller is responsible for writing the bytes to disk and interpreting them (csv/xlsx/image/audio/...). See ``files.py`` for that logic. """ if not task_id: raise ValueError("task_id is required to fetch a file") return _request("GET", f"/files/{task_id}", expect_json=False) def submit_answers( username: str, agent_code: str, answers: Sequence[Mapping[str, str]], ) -> Dict[str, Any]: """Submit answers for scoring and return the parsed result. Parameters ---------- username: Your Hugging Face username. agent_code: Public Space code link ending in ``/tree/main``. answers: Sequence of ``{"task_id": ..., "submitted_answer": ...}`` mappings. Use :func:`build_answer` / :func:`build_answers` to construct these. The returned dict typically contains ``score``, ``correct_count``, ``total_attempted`` and a human-readable ``message``. """ if not username: raise ValueError("username is required") if not agent_code or not agent_code.rstrip("/").endswith("/tree/main"): raise ValueError("agent_code must be a public Space link ending in /tree/main") if not answers: raise ValueError("answers must contain at least one item") payload = { "username": username.strip(), "agent_code": agent_code.strip(), "answers": list(answers), } return _request("POST", "/submit", json_body=payload) def build_answer(task_id: str, submitted_answer: Any) -> Dict[str, str]: """Build a single answer entry in the exact format the API expects. The submitted answer is coerced to ``str`` because scoring is exact-match over text; normalization of the *content* belongs in ``normalize.py``. """ return {"task_id": str(task_id), "submitted_answer": str(submitted_answer)} def build_answers(pairs: Mapping[str, Any]) -> List[Dict[str, str]]: """Build the full answers list from a ``{task_id: answer}`` mapping.""" return [build_answer(task_id, answer) for task_id, answer in pairs.items()]