gaia-agent-final / src /scoring_api.py
Wiktor
Fix: bump gradio to 5.29.0 for Python 3.13 / huggingface_hub 1.x compatibility
476893a
Raw History Blame Contribute Delete
5.97 kB
"""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()]