| import os |
| import ast |
| import json |
| import operator |
| import re |
| import hashlib |
| import subprocess |
| import threading |
| from concurrent.futures import ThreadPoolExecutor, as_completed |
| from pathlib import Path |
| from urllib.parse import unquote, urlparse |
| import gradio as gr |
| import chess |
| import chess.engine |
| import httpx |
| import requests |
| import pandas as pd |
| from bs4 import BeautifulSoup |
| from dotenv import load_dotenv |
| from huggingface_hub import hf_hub_download |
| from pypdf import PdfReader |
| from pydantic_ai import Agent, AudioUrl, BinaryContent, ImageUrl |
| from pydantic_ai.common_tools.duckduckgo import duckduckgo_search_tool |
| from pydantic_ai.models.openai import OpenAIChatModel |
| from pydantic_ai.profiles.openai import OpenAIModelProfile |
| from pydantic_ai.providers.openai import OpenAIProvider |
| from pydantic_ai.usage import UsageLimits |
| from markdownify import markdownify |
| from faster_whisper import WhisperModel |
| from yt_dlp import YoutubeDL |
|
|
| load_dotenv() |
|
|
| |
| |
| DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space" |
| GAIA_REPO_ID = "gaia-benchmark/GAIA" |
| GAIA_ATTACHMENT_PREFIX = "2023/validation" |
| IMAGE_URL_PATTERN = re.compile(r"https?://\S+\.(?:png|jpe?g|webp|gif)(?:\?\S*)?", re.IGNORECASE) |
| AUDIO_URL_PATTERN = re.compile(r"https?://\S+\.(?:mp3|wav|flac|ogg|oga|aac|aiff)(?:\?\S*)?", re.IGNORECASE) |
| VIDEO_URL_PATTERN = re.compile(r"https?://\S+\.(?:mp4|mov|mkv|webm|avi|m4v)(?:\?\S*)?", re.IGNORECASE) |
| YOUTUBE_URL_PATTERN = re.compile(r"https?://(?:www\.)?(?:youtube\.com/watch\?\S*v=|youtu\.be/|youtube\.com/shorts/)\S+", re.IGNORECASE) |
| IMAGE_EXTENSIONS = {".gif", ".jpeg", ".jpg", ".png", ".webp"} |
| AUDIO_EXTENSIONS = {".aac", ".aiff", ".flac", ".mp3", ".oga", ".ogg", ".wav"} |
| VIDEO_EXTENSIONS = {".avi", ".m4v", ".mkv", ".mov", ".mp4", ".webm"} |
| TEXT_EXTENSIONS = {".json", ".md", ".py", ".txt", ".xml"} |
| SPREADSHEET_EXTENSIONS = {".csv", ".xlsx"} |
| MEDIA_TYPES_BY_EXTENSION = { |
| ".aac": "audio/aac", |
| ".aiff": "audio/aiff", |
| ".flac": "audio/flac", |
| ".gif": "image/gif", |
| ".jpeg": "image/jpeg", |
| ".jpg": "image/jpeg", |
| ".mp3": "audio/mpeg", |
| ".mp4": "video/mp4", |
| ".oga": "audio/ogg", |
| ".ogg": "audio/ogg", |
| ".png": "image/png", |
| ".wav": "audio/wav", |
| ".webm": "video/webm", |
| ".webp": "image/webp", |
| } |
| TEXT_MEDIA_TYPES = { |
| "application/json", |
| "application/xhtml+xml", |
| "text/html", |
| "text/markdown", |
| "text/plain", |
| "text/x-markdown", |
| } |
|
|
| _CALCULATOR_OPERATORS = { |
| ast.Add: operator.add, |
| ast.Sub: operator.sub, |
| ast.Mult: operator.mul, |
| ast.Div: operator.truediv, |
| ast.FloorDiv: operator.floordiv, |
| ast.Mod: operator.mod, |
| ast.Pow: operator.pow, |
| ast.USub: operator.neg, |
| ast.UAdd: operator.pos, |
| } |
| _WHISPER_MODEL = None |
| _WHISPER_MODEL_LOCK = threading.Lock() |
| _CHESS_FEN_DETECTOR = None |
| _CHESS_FEN_DETECTOR_LOCK = threading.Lock() |
| NPB_TEAM_CODES = { |
| "buffaloes": "b", |
| "carp": "c", |
| "baystars": "db", |
| "dragons": "d", |
| "fighters": "f", |
| "giants": "g", |
| "hawks": "h", |
| "lions": "l", |
| "marines": "m", |
| "swallows": "s", |
| "tigers": "t", |
| "eagles": "e", |
| "orix": "b", |
| "hiroshima": "c", |
| "yokohama": "db", |
| "chunichi": "d", |
| "nippon-ham": "f", |
| "nippon ham": "f", |
| "hokkaido": "f", |
| "yomiuri": "g", |
| "softbank": "h", |
| "seibu": "l", |
| "lotte": "m", |
| "yakult": "s", |
| "hanshin": "t", |
| "rakuten": "e", |
| } |
| NPB_PLAYER_NAME_ALIASES = { |
| "taisho tamai": ("玉井", "大翔"), |
| "taishō tamai": ("玉井", "大翔"), |
| } |
| NPB_SURNAME_ROMANIZATION = { |
| "伊藤": "Itoh", |
| "上原": "Uehara", |
| "吉田": "Yoshida", |
| "玉井": "Tamai", |
| } |
| MLB_TEAM_IDS = { |
| "angels": 108, |
| "astros": 117, |
| "athletics": 133, |
| "blue jays": 141, |
| "braves": 144, |
| "brewers": 158, |
| "cardinals": 138, |
| "cubs": 112, |
| "diamondbacks": 109, |
| "dodgers": 119, |
| "giants": 137, |
| "guardians": 114, |
| "indians": 114, |
| "mariners": 136, |
| "marlins": 146, |
| "mets": 121, |
| "nationals": 120, |
| "orioles": 110, |
| "padres": 135, |
| "phillies": 143, |
| "pirates": 134, |
| "rangers": 140, |
| "rays": 139, |
| "red sox": 111, |
| "reds": 113, |
| "rockies": 115, |
| "royals": 118, |
| "tigers": 116, |
| "twins": 142, |
| "white sox": 145, |
| "yankees": 147, |
| "new york yankees": 147, |
| } |
| MLB_HITTING_STAT_ALIASES = { |
| "walks": "baseOnBalls", |
| "walk": "baseOnBalls", |
| "bb": "baseOnBalls", |
| "at bats": "atBats", |
| "at-bats": "atBats", |
| "ab": "atBats", |
| "home runs": "homeRuns", |
| "hr": "homeRuns", |
| "hits": "hits", |
| "rbi": "rbi", |
| "runs": "runs", |
| "stolen bases": "stolenBases", |
| "sb": "stolenBases", |
| } |
| OLYMPIC_IOC_CODES = { |
| "Argentina": "ARG", |
| "Australia": "AUS", |
| "Austria": "AUT", |
| "Belgium": "BEL", |
| "Bulgaria": "BUL", |
| "Canada": "CAN", |
| "Chile": "CHI", |
| "Cuba": "CUB", |
| "Czechoslovakia": "TCH", |
| "Denmark": "DEN", |
| "Egypt": "EGY", |
| "Estonia": "EST", |
| "Finland": "FIN", |
| "France": "FRA", |
| "Germany": "GER", |
| "Great Britain": "GBR", |
| "Greece": "GRE", |
| "Haiti": "HAI", |
| "Hungary": "HUN", |
| "India": "IND", |
| "Ireland": "IRL", |
| "Italy": "ITA", |
| "Japan": "JPN", |
| "Latvia": "LAT", |
| "Lithuania": "LTU", |
| "Luxembourg": "LUX", |
| "Malta": "MLT", |
| "Mexico": "MEX", |
| "Monaco": "MON", |
| "Netherlands": "NED", |
| "New Zealand": "NZL", |
| "Norway": "NOR", |
| "Panama": "PAN", |
| "Philippines": "PHI", |
| "Poland": "POL", |
| "Portugal": "POR", |
| "Rhodesia": "RHO", |
| "Romania": "ROU", |
| "South Africa": "RSA", |
| "Spain": "ESP", |
| "Sweden": "SWE", |
| "Switzerland": "SUI", |
| "Turkey": "TUR", |
| "United States": "USA", |
| "Uruguay": "URU", |
| "Yugoslavia": "YUG", |
| } |
| DEFUNCT_COUNTRIES = { |
| "Czechoslovakia", |
| "East Germany", |
| "Soviet Union", |
| "Yugoslavia", |
| } |
|
|
|
|
| def _evaluate_math_expression(expression: str) -> int | float: |
| def evaluate(node): |
| if isinstance(node, ast.Expression): |
| return evaluate(node.body) |
| if isinstance(node, ast.Constant) and isinstance(node.value, int | float): |
| return node.value |
| if isinstance(node, ast.UnaryOp) and type(node.op) in _CALCULATOR_OPERATORS: |
| return _CALCULATOR_OPERATORS[type(node.op)](evaluate(node.operand)) |
| if isinstance(node, ast.BinOp) and type(node.op) in _CALCULATOR_OPERATORS: |
| return _CALCULATOR_OPERATORS[type(node.op)](evaluate(node.left), evaluate(node.right)) |
| raise ValueError(f"Unsupported expression: {expression}") |
|
|
| return evaluate(ast.parse(expression, mode="eval")) |
|
|
|
|
| def _required_env(name: str) -> str: |
| value = os.getenv(name) |
| if value is None or value.strip() == "": |
| raise ValueError(f"Missing required environment variable: {name}") |
| return value |
|
|
|
|
| def _env_enabled(name: str) -> bool: |
| return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"} |
|
|
|
|
| def _normalize_final_answer(answer: str) -> str: |
| answer = answer.strip() |
| if not answer: |
| return answer |
|
|
| currency_match = re.fullmatch(r"\$?\s*([+-]?\d[\d,]*(?:\.\d+)?)", answer) |
| if currency_match: |
| return currency_match.group(1).replace(",", "") |
|
|
| if "," not in answer and "\n" not in answer and len(answer.split()) <= 5: |
| answer = answer.rstrip(".") |
| return answer |
|
|
|
|
| async def _debug_http_request(request: httpx.Request) -> None: |
| body = request.content |
| print(f"[LLM HTTP] -> {request.method} {request.url} body_bytes={len(body)}", flush=True) |
| body_path = os.getenv("AGENT_DEBUG_HTTP_BODY_PATH") |
| if body_path: |
| Path(body_path).parent.mkdir(parents=True, exist_ok=True) |
| Path(body_path).write_bytes(body) |
|
|
|
|
| async def _debug_http_response(response: httpx.Response) -> None: |
| print(f"[LLM HTTP] <- {response.status_code} {response.request.url}", flush=True) |
|
|
|
|
| def _load_answer_cache(cache_path: Path) -> dict[str, str]: |
| if not cache_path.exists(): |
| return {} |
| try: |
| with cache_path.open("r", encoding="utf-8") as cache_file: |
| data = json.load(cache_file) |
| except (OSError, json.JSONDecodeError) as e: |
| print(f"Could not load answer cache at {cache_path}: {e}") |
| return {} |
| if not isinstance(data, dict): |
| print(f"Ignoring answer cache at {cache_path}: expected a JSON object.") |
| return {} |
| return {str(task_id): str(answer) for task_id, answer in data.items()} |
|
|
|
|
| def _save_answer_cache(cache_path: Path, cache: dict[str, str]) -> None: |
| cache_path.parent.mkdir(parents=True, exist_ok=True) |
| temp_path = cache_path.with_suffix(f"{cache_path.suffix}.tmp") |
| with temp_path.open("w", encoding="utf-8") as cache_file: |
| json.dump(cache, cache_file, ensure_ascii=False, indent=2, sort_keys=True) |
| temp_path.replace(cache_path) |
|
|
|
|
| def _answer_question(agent, item: dict, answer_cache: dict[str, str], cache_path: Path, cache_lock: threading.Lock): |
| task_id = item.get("task_id") |
| question_text = item.get("question") |
| if not task_id or question_text is None: |
| print(f"Skipping item with missing task_id or question: {item}") |
| return None, {"Task ID": task_id, "Question": question_text, "Submitted Answer": "SKIPPED: missing task_id or question"} |
|
|
| task_id = str(task_id) |
| with cache_lock: |
| cached_answer = answer_cache.get(task_id) |
| if cached_answer is not None: |
| print(f"Using cached answer for task {task_id}.") |
| return {"task_id": task_id, "submitted_answer": cached_answer}, { |
| "Task ID": task_id, |
| "Question": question_text, |
| "Submitted Answer": cached_answer, |
| } |
|
|
| try: |
| submitted_answer = agent(item) |
| except Exception as e: |
| print(f"Error running agent on task {task_id}: {e}") |
| return None, {"Task ID": task_id, "Question": question_text, "Submitted Answer": f"AGENT ERROR: {e}"} |
|
|
| with cache_lock: |
| answer_cache[task_id] = submitted_answer |
| _save_answer_cache(cache_path, answer_cache) |
|
|
| return {"task_id": task_id, "submitted_answer": submitted_answer}, { |
| "Task ID": task_id, |
| "Question": question_text, |
| "Submitted Answer": submitted_answer, |
| } |
|
|
|
|
| def web_fetch_text(url: str) -> str: |
| """Fetch a web page URL and return text or markdown content. Binary files are not supported.""" |
| try: |
| response = requests.get( |
| url, |
| headers={ |
| "Accept": "text/markdown, text/html;q=0.9, application/json;q=0.8, text/plain;q=0.7", |
| "User-Agent": _required_env("WEB_FETCH_USER_AGENT"), |
| }, |
| timeout=int(_required_env("WEB_FETCH_TIMEOUT")), |
| ) |
| except requests.RequestException as e: |
| return f"Could not fetch {url}: {e}. Use web search to find the same information from another source." |
|
|
| if response.status_code == 403: |
| return f"Could not fetch {url}: the site returned 403 Forbidden. Use web search to find the same information from another source." |
| if response.status_code == 404: |
| return f"Could not fetch {url}: the site returned 404 Not Found. Use web search to find the same information from another source." |
| try: |
| response.raise_for_status() |
| except requests.HTTPError as e: |
| return f"Could not fetch {url}: {e}. Use web search to find the same information from another source." |
|
|
| media_type = response.headers.get("content-type", "").split(";")[0].strip().lower() |
| if media_type and media_type not in TEXT_MEDIA_TYPES: |
| return ( |
| f"Cannot fetch {url} as text because it returned content type '{media_type}'. " |
| "Use direct image URL handling for images. PDFs and other binary files are not supported by this tool." |
| ) |
|
|
| text = response.text |
| if media_type in ("text/html", "application/xhtml+xml", ""): |
| text = markdownify(text, strip=["img", "script", "style"]) |
| elif media_type == "application/json": |
| try: |
| text = json.dumps(response.json(), indent=2) |
| except ValueError: |
| pass |
|
|
| return text |
|
|
|
|
| def _download_gaia_attachment(file_name: str) -> Path: |
| return Path( |
| hf_hub_download( |
| repo_id=GAIA_REPO_ID, |
| repo_type="dataset", |
| filename=f"{GAIA_ATTACHMENT_PREFIX}/{file_name}", |
| ) |
| ) |
|
|
|
|
| def _read_text_attachment(path: Path) -> str: |
| return path.read_text(encoding="utf-8", errors="replace") |
|
|
|
|
| def _read_pdf_attachment(path: Path) -> str: |
| reader = PdfReader(path) |
| chunks = [] |
| for page_index, page in enumerate(reader.pages, start=1): |
| chunks.append(f"\n--- Page {page_index} ---\n{page.extract_text() or ''}") |
| return "\n".join(chunks).strip() |
|
|
|
|
| def _read_spreadsheet_attachment(path: Path) -> str: |
| if path.suffix.lower() == ".csv": |
| frame = pd.read_csv(path) |
| return frame.to_csv(index=False) |
| else: |
| sheets = pd.read_excel(path, sheet_name=None, engine="openpyxl") |
| parts = [] |
| for sheet_name, frame in sheets.items(): |
| parts.append(f"--- Sheet: {sheet_name} ---") |
| parts.append(frame.to_csv(index=False)) |
| return "\n".join(parts) |
|
|
|
|
| def _download_youtube_video(url: str) -> Path: |
| output_dir = Path(_required_env("YOUTUBE_DOWNLOAD_DIR")) |
| output_dir.mkdir(parents=True, exist_ok=True) |
| options = { |
| "format": _required_env("YOUTUBE_FORMAT"), |
| "outtmpl": str(output_dir / "%(id)s.%(ext)s"), |
| "quiet": True, |
| "no_warnings": True, |
| "noplaylist": True, |
| "merge_output_format": "mp4", |
| } |
| with YoutubeDL(options) as ydl: |
| info = ydl.extract_info(url, download=True) |
| file_name = ydl.prepare_filename(info) |
| path = Path(file_name) |
| if not path.exists() and path.with_suffix(".mp4").exists(): |
| path = path.with_suffix(".mp4") |
| return path |
|
|
|
|
| def _download_direct_video(url: str) -> Path: |
| output_dir = Path(_required_env("VIDEO_DOWNLOAD_DIR")) |
| output_dir.mkdir(parents=True, exist_ok=True) |
| parsed_url = urlparse(url) |
| suffix = Path(unquote(parsed_url.path)).suffix.lower() |
| if suffix not in VIDEO_EXTENSIONS: |
| suffix = ".mp4" |
| cache_key = hashlib.sha256(url.encode("utf-8")).hexdigest()[:16] |
| output_path = output_dir / f"{cache_key}{suffix}" |
| if output_path.exists() and output_path.stat().st_size > 0: |
| return output_path |
|
|
| with requests.get( |
| url, |
| headers={"User-Agent": _required_env("WEB_FETCH_USER_AGENT")}, |
| timeout=int(_required_env("WEB_FETCH_TIMEOUT")), |
| stream=True, |
| ) as response: |
| response.raise_for_status() |
| temp_path = output_path.with_suffix(f"{output_path.suffix}.tmp") |
| with temp_path.open("wb") as video_file: |
| for chunk in response.iter_content(chunk_size=1024 * 1024): |
| if chunk: |
| video_file.write(chunk) |
| temp_path.replace(output_path) |
| return output_path |
|
|
|
|
| def _binary_content_from_path(path: Path) -> BinaryContent: |
| media_type = MEDIA_TYPES_BY_EXTENSION.get(path.suffix.lower(), "application/octet-stream") |
| return BinaryContent(data=path.read_bytes(), media_type=media_type) |
|
|
|
|
| def _get_whisper_model() -> WhisperModel: |
| global _WHISPER_MODEL |
| if _WHISPER_MODEL is None: |
| with _WHISPER_MODEL_LOCK: |
| if _WHISPER_MODEL is None: |
| _WHISPER_MODEL = WhisperModel( |
| _required_env("ASR_MODEL_SIZE"), |
| device=_required_env("ASR_DEVICE"), |
| compute_type=_required_env("ASR_COMPUTE_TYPE"), |
| ) |
| return _WHISPER_MODEL |
|
|
|
|
| def _transcribe_audio_attachment(path: Path) -> str: |
| cache_dir = Path(_required_env("AUDIO_TRANSCRIPT_CACHE_DIR")) |
| cache_dir.mkdir(parents=True, exist_ok=True) |
| cache_path = cache_dir / f"{path.stem}.txt" |
| if cache_path.exists(): |
| return cache_path.read_text(encoding="utf-8") |
|
|
| model = _get_whisper_model() |
| segments, info = model.transcribe(str(path), beam_size=5) |
| transcript = "\n".join(segment.text.strip() for segment in segments if segment.text.strip()) |
| transcript = f"Detected language: {info.language}\n\n{transcript}".strip() |
| cache_path.write_text(transcript, encoding="utf-8") |
| return transcript |
|
|
|
|
| def _sample_video_frames(path: Path) -> list[Path]: |
| cache_dir = Path(_required_env("VIDEO_FRAME_CACHE_DIR")) |
| fps = int(_required_env("VIDEO_FRAME_FPS")) |
| max_frames = int(_required_env("VIDEO_FRAME_MAX_FRAMES")) |
| stat = path.stat() |
| cache_key_source = f"{path.resolve()}:{stat.st_size}:{stat.st_mtime_ns}:{fps}:{max_frames}" |
| cache_key = hashlib.sha256(cache_key_source.encode("utf-8")).hexdigest()[:16] |
| output_dir = cache_dir / cache_key |
| output_dir.mkdir(parents=True, exist_ok=True) |
|
|
| cached_frames = sorted(output_dir.glob("frame_*.jpg")) |
| if cached_frames: |
| return cached_frames[:max_frames] |
|
|
| command = [ |
| "ffmpeg", |
| "-y", |
| "-hide_banner", |
| "-loglevel", |
| "error", |
| "-i", |
| str(path), |
| "-vf", |
| f"fps={fps}", |
| "-frames:v", |
| str(max_frames), |
| str(output_dir / "frame_%04d.jpg"), |
| ] |
| subprocess.run(command, check=True) |
| return sorted(output_dir.glob("frame_*.jpg"))[:max_frames] |
|
|
|
|
| def _get_chess_fen_detector(): |
| global _CHESS_FEN_DETECTOR |
| if _CHESS_FEN_DETECTOR is None: |
| with _CHESS_FEN_DETECTOR_LOCK: |
| if _CHESS_FEN_DETECTOR is None: |
| from chess_fen_detector import ChessFENDetector |
|
|
| _CHESS_FEN_DETECTOR = ChessFENDetector() |
| return _CHESS_FEN_DETECTOR |
|
|
|
|
| def _parse_chess_turn(side_to_move: str | None) -> bool: |
| lowered = (side_to_move or "").strip().lower() |
| if lowered in {"white", "w"} or "white's turn" in lowered or "white to move" in lowered: |
| return chess.WHITE |
| if lowered in {"black", "b"} or "black's turn" in lowered or "black to move" in lowered: |
| return chess.BLACK |
| default_turn = _required_env("CHESS_DEFAULT_TURN").strip().lower() |
| if default_turn == "white": |
| return chess.WHITE |
| if default_turn == "black": |
| return chess.BLACK |
| raise ValueError("CHESS_DEFAULT_TURN must be either 'white' or 'black'.") |
|
|
|
|
| def _normalize_detected_fen_placement(detected_fen: str) -> str: |
| placement = detected_fen.strip().replace("-", "/").split()[0] |
| rows = placement.split("/") |
| if len(rows) != 8: |
| raise ValueError(f"Chess FEN detector returned invalid board placement: {detected_fen}") |
| return placement |
|
|
|
|
| def _board_from_placement(placement: str, turn: bool, orientation: str) -> chess.Board: |
| if orientation == "white": |
| board = chess.Board(placement + " w - - 0 1") |
| elif orientation == "black": |
| board = chess.Board.empty() |
| files = "abcdefgh" |
| for rank_index, row in enumerate(placement.split("/")): |
| visual_rank = 8 - rank_index |
| visual_file_index = 0 |
| for symbol in row: |
| if symbol.isdigit(): |
| visual_file_index += int(symbol) |
| continue |
| actual_file = files[7 - visual_file_index] |
| actual_rank = 9 - visual_rank |
| square = chess.parse_square(f"{actual_file}{actual_rank}") |
| board.set_piece_at(square, chess.Piece.from_symbol(symbol)) |
| visual_file_index += 1 |
| else: |
| raise ValueError("CHESS_BOARD_ORIENTATION must be either 'white' or 'black'.") |
|
|
| board.turn = turn |
| board.clear_stack() |
| if not board.is_valid(): |
| raise ValueError(f"Detected chess board is not valid: {board.fen()}") |
| return board |
|
|
|
|
| def _stockfish_best_move_san(board: chess.Board) -> str: |
| engine_path = _required_env("STOCKFISH_PATH") |
| time_limit = float(_required_env("CHESS_ENGINE_TIME_LIMIT")) |
| with chess.engine.SimpleEngine.popen_uci(engine_path) as engine: |
| result = engine.play(board, chess.engine.Limit(time=time_limit)) |
| return board.san(result.move) |
|
|
|
|
| def solve_chess_position_from_attachment(file_name: str, side_to_move: str | None = None) -> str: |
| """Solve a chess-board image attachment and return the best move in algebraic notation. |
| |
| Args: |
| file_name: The attached chess-board image file name, for example "task-id.png". |
| side_to_move: The side whose turn it is, usually "white" or "black". |
| """ |
| file_name = Path(file_name.strip().strip("\"'")).name |
| attachment_path = _download_gaia_attachment(file_name) |
| detector = _get_chess_fen_detector() |
| detected_fen = detector.predict_fen(str(attachment_path)) |
| placement = _normalize_detected_fen_placement(detected_fen) |
| turn = _parse_chess_turn(side_to_move) |
| orientation = _required_env("CHESS_BOARD_ORIENTATION").strip().lower() |
| board = _board_from_placement(placement, turn, orientation) |
| answer = _stockfish_best_move_san(board) |
| print(f"Chess detector FEN: {detected_fen}") |
| print(f"Chess solver board FEN: {board.fen()}") |
| return answer |
|
|
|
|
| def _npb_team_codes_to_search(team: str) -> list[str]: |
| normalized = team.strip().lower() |
| if not normalized: |
| return sorted(set(NPB_TEAM_CODES.values())) |
| for name, code in NPB_TEAM_CODES.items(): |
| if name in normalized: |
| return [code] |
| if normalized in set(NPB_TEAM_CODES.values()): |
| return [normalized] |
| return sorted(set(NPB_TEAM_CODES.values())) |
|
|
|
|
| def _npb_name_tokens(player_name: str) -> tuple[str, ...]: |
| normalized = player_name.strip().lower() |
| if normalized in NPB_PLAYER_NAME_ALIASES: |
| return NPB_PLAYER_NAME_ALIASES[normalized] |
| return tuple(part for part in player_name.replace(" ", " ").split() if part) |
|
|
|
|
| def _npb_registered_players(year: str, team_code: str) -> list[dict[str, str]]: |
| url = f"https://npb.jp/announcement/{year}/registered_{team_code}.html" |
| response = requests.get( |
| url, |
| headers={"User-Agent": _required_env("WEB_FETCH_USER_AGENT")}, |
| timeout=int(_required_env("WEB_FETCH_TIMEOUT")), |
| ) |
| response.raise_for_status() |
| response.encoding = response.apparent_encoding or response.encoding |
| soup = BeautifulSoup(response.text, "html.parser") |
| players = [] |
| for table in soup.find_all("table"): |
| for row in table.find_all("tr"): |
| cells = [cell.get_text(" ", strip=True) for cell in row.find_all(["td", "th"])] |
| if len(cells) != 4 or cells[0] == "公示日": |
| continue |
| announced, position, number, name = cells |
| if not number.isdigit(): |
| continue |
| players.append( |
| { |
| "announced": announced, |
| "position": position.replace("\u3000", ""), |
| "number": number, |
| "name": name, |
| "team_code": team_code, |
| "source": url, |
| } |
| ) |
| return players |
|
|
|
|
| def _npb_romanized_surname(japanese_name: str) -> str: |
| surname = japanese_name.replace("\u3000", " ").split()[0] |
| return NPB_SURNAME_ROMANIZATION.get(surname, surname) |
|
|
|
|
| def find_npb_adjacent_pitchers_by_number(year: str, player_name: str, team: str = "") -> str: |
| """Use official NPB historical registered roster pages to find adjacent pitcher jersey numbers. |
| |
| Use this for Japanese NPB questions about historical roster registration, jersey numbers, |
| and pitchers before/after a named player as of a stated year. |
| |
| Args: |
| year: The roster year to inspect, for example "2023". |
| player_name: The player name to find, in Roman or Japanese characters. |
| team: Optional NPB team name, for example "Hokkaido Nippon-Ham Fighters". |
| """ |
| year = str(year).strip() |
| name_tokens = _npb_name_tokens(player_name) |
| if not year.isdigit() or len(year) != 4: |
| return f"Invalid NPB roster year: {year!r}" |
| if not name_tokens: |
| return "No player name was provided." |
|
|
| checked_sources = [] |
| for team_code in _npb_team_codes_to_search(team): |
| try: |
| players = _npb_registered_players(year, team_code) |
| except requests.RequestException as e: |
| checked_sources.append(f"{team_code}: {e}") |
| continue |
| checked_sources.append(f"https://npb.jp/announcement/{year}/registered_{team_code}.html") |
| target = next( |
| ( |
| player |
| for player in players |
| if all(token.lower() in player["name"].lower() or token in player["name"] for token in name_tokens) |
| ), |
| None, |
| ) |
| if target is None: |
| continue |
|
|
| target_number = int(target["number"]) |
| pitchers = [ |
| player for player in players |
| if player["position"] == "投手" and int(player["number"]) in {target_number - 1, target_number + 1} |
| ] |
| pitchers.sort(key=lambda player: int(player["number"])) |
| if len(pitchers) != 2: |
| return ( |
| f"Found {target['name']} as number {target_number} on official NPB {year} team {team_code}, " |
| f"but did not find both adjacent pitcher numbers. " |
| f"Adjacent pitcher rows found: {pitchers}. Source: {target['source']}" |
| ) |
|
|
| before, after = pitchers |
| return ( |
| f"Official NPB {year} registered roster source: {target['source']}\n" |
| f"Target: #{target_number} {target['name']} ({_npb_romanized_surname(target['name'])})\n" |
| f"Pitcher before: #{before['number']} {before['name']} ({_npb_romanized_surname(before['name'])})\n" |
| f"Pitcher after: #{after['number']} {after['name']} ({_npb_romanized_surname(after['name'])})\n" |
| f"Answer format requested by the benchmark: {_npb_romanized_surname(before['name'])}, {_npb_romanized_surname(after['name'])}" |
| ) |
|
|
| return f"Could not find {player_name} in official NPB {year} registered roster pages checked: {checked_sources}" |
|
|
|
|
| def _mlb_team_id(team: str) -> int: |
| normalized = team.strip().lower() |
| if normalized.isdigit(): |
| return int(normalized) |
| for name, team_id in MLB_TEAM_IDS.items(): |
| if name in normalized: |
| return team_id |
| raise ValueError(f"Unknown MLB team: {team!r}") |
|
|
|
|
| def _mlb_hitting_stat_key(stat_name: str) -> str: |
| normalized = stat_name.strip().lower() |
| if normalized in MLB_HITTING_STAT_ALIASES: |
| return MLB_HITTING_STAT_ALIASES[normalized] |
| for name, key in MLB_HITTING_STAT_ALIASES.items(): |
| if name in normalized: |
| return key |
| return stat_name.strip() |
|
|
|
|
| def find_mlb_hitting_stat_for_team_leader( |
| year: str, |
| team: str, |
| leader_stat: str, |
| return_stat: str, |
| ) -> str: |
| """Use the official MLB Stats API to answer historical team hitting stat-leader questions. |
| |
| Use this for MLB questions like "the Yankee with the most walks in 1977" and then |
| asking for another stat from that same season. |
| |
| Args: |
| year: The season year, for example "1977". |
| team: MLB team name or id, for example "New York Yankees". |
| leader_stat: Stat to rank players by, for example "walks" or "home runs". |
| return_stat: Stat to return for the leader, for example "at bats". |
| """ |
| year = str(year).strip() |
| if not year.isdigit() or len(year) != 4: |
| return f"Invalid MLB season year: {year!r}" |
|
|
| team_id = _mlb_team_id(team) |
| leader_key = _mlb_hitting_stat_key(leader_stat) |
| return_key = _mlb_hitting_stat_key(return_stat) |
| url = ( |
| "https://statsapi.mlb.com/api/v1/stats" |
| f"?stats=season&group=hitting&season={year}&sportIds=1&teamId={team_id}&limit=1000" |
| ) |
| response = requests.get( |
| url, |
| headers={"User-Agent": _required_env("WEB_FETCH_USER_AGENT")}, |
| timeout=int(_required_env("WEB_FETCH_TIMEOUT")), |
| ) |
| response.raise_for_status() |
| splits = response.json().get("stats", [{}])[0].get("splits", []) |
| rows = [] |
| for split in splits: |
| stat = split.get("stat", {}) |
| player = split.get("player", {}) |
| if leader_key not in stat or return_key not in stat: |
| continue |
| rows.append( |
| { |
| "player": player.get("fullName", ""), |
| "leader_value": int(stat[leader_key]), |
| "return_value": stat[return_key], |
| "stat": stat, |
| } |
| ) |
| if not rows: |
| return f"No MLB hitting rows found for team {team!r}, year {year}, stats {leader_key}/{return_key}. Source: {url}" |
|
|
| rows.sort(key=lambda row: row["leader_value"], reverse=True) |
| leader = rows[0] |
| preview = "\n".join( |
| f"{index + 1}. {row['player']}: {leader_key}={row['leader_value']}, {return_key}={row['return_value']}" |
| for index, row in enumerate(rows[:5]) |
| ) |
| return ( |
| f"Official MLB Stats API source: {url}\n" |
| f"Team id: {team_id}; season: {year}\n" |
| f"Ranking stat: {leader_key}; requested stat: {return_key}\n" |
| f"Top rows:\n{preview}\n" |
| f"Answer: {leader['return_value']}" |
| ) |
|
|
|
|
| def _wikipedia_revision_before(page_title: str, latest_year: str | None = None) -> tuple[int | None, str | None]: |
| if not latest_year: |
| return None, None |
| year = str(latest_year).strip() |
| if not year: |
| return None, None |
| if re.fullmatch(r"\d{4}", year): |
| rvstart = f"{year}-12-31T23:59:59Z" |
| else: |
| rvstart = year |
|
|
| response = requests.get( |
| "https://en.wikipedia.org/w/api.php", |
| params={ |
| "action": "query", |
| "prop": "revisions", |
| "titles": page_title, |
| "rvlimit": 1, |
| "rvdir": "older", |
| "rvprop": "ids|timestamp", |
| "rvstart": rvstart, |
| "format": "json", |
| }, |
| headers={"User-Agent": _required_env("WEB_FETCH_USER_AGENT")}, |
| timeout=int(_required_env("WEB_FETCH_TIMEOUT")), |
| ) |
| response.raise_for_status() |
| pages = response.json().get("query", {}).get("pages", {}) |
| page = next(iter(pages.values()), {}) |
| revisions = page.get("revisions") or [] |
| if not revisions: |
| return None, None |
| revision = revisions[0] |
| return int(revision["revid"]), revision.get("timestamp") |
|
|
|
|
| def _wikipedia_page_html(page_title: str, oldid: int | None = None) -> str: |
| if oldid is None: |
| url = f"https://en.wikipedia.org/wiki/{page_title.replace(' ', '_')}" |
| else: |
| url = f"https://en.wikipedia.org/w/index.php?title={page_title.replace(' ', '_')}&oldid={oldid}" |
| response = requests.get( |
| url, |
| headers={"User-Agent": _required_env("WEB_FETCH_USER_AGENT")}, |
| timeout=int(_required_env("WEB_FETCH_TIMEOUT")), |
| ) |
| response.raise_for_status() |
| return response.text |
|
|
|
|
| def extract_wikipedia_table( |
| page_title: str, |
| table_query: str = "", |
| latest_year: str | None = None, |
| year_column: str = "Year", |
| start_year: str | None = None, |
| end_year: str | None = None, |
| ) -> str: |
| """Extract a structured table from English Wikipedia, optionally from the latest revision before a year/date. |
| |
| Use this for questions that ask for counts or values from Wikipedia tables, especially when rows matter. |
| |
| Args: |
| page_title: English Wikipedia page title, for example "Mercedes Sosa". |
| table_query: Text that should appear in the desired table or nearby table content, for example "studio albums". |
| latest_year: Optional year/date constraint, for example "2022" for the latest revision in 2022. |
| year_column: Name of the year column to filter on. |
| start_year: Optional inclusive lower bound for the year column. |
| end_year: Optional inclusive upper bound for the year column. |
| """ |
| from io import StringIO |
|
|
| oldid, timestamp = _wikipedia_revision_before(page_title, latest_year) |
| html = _wikipedia_page_html(page_title, oldid) |
| tables = pd.read_html(StringIO(html)) |
| if not tables: |
| return f"No tables found on English Wikipedia page {page_title!r}." |
|
|
| query = table_query.strip().lower() |
| selected_index = 0 |
| selected_table = tables[0] |
| if query: |
| for index, table in enumerate(tables): |
| table_text = table.to_string(index=False).lower() |
| if query in table_text: |
| selected_index = index |
| selected_table = table |
| break |
| else: |
| return f"No table on {page_title!r} matched query {table_query!r}. Found {len(tables)} table(s)." |
|
|
| filtered = selected_table.copy() |
| if start_year or end_year: |
| matching_columns = [column for column in filtered.columns if str(column).strip().lower() == year_column.strip().lower()] |
| if not matching_columns: |
| return ( |
| f"Selected table {selected_index} has no year column {year_column!r}. " |
| f"Columns: {list(map(str, filtered.columns))}" |
| ) |
| column = matching_columns[0] |
| years = pd.to_numeric(filtered[column], errors="coerce") |
| if start_year: |
| years_start = int(start_year) |
| filtered = filtered[years >= years_start] |
| years = years[years >= years_start] |
| if end_year: |
| years_end = int(end_year) |
| filtered = filtered[years <= years_end] |
|
|
| csv_rows = filtered.to_csv(index=False) |
| source = f"English Wikipedia page {page_title!r}" |
| if oldid is not None: |
| source += f", oldid={oldid}, timestamp={timestamp}" |
| return ( |
| f"Source: {source}\n" |
| f"Selected table index: {selected_index}\n" |
| f"Filtered row count: {len(filtered)}\n" |
| f"Rows:\n{csv_rows}" |
| ) |
|
|
|
|
| def find_olympic_ioc_code_with_fewest_athletes(year: str, games: str = "Summer") -> str: |
| """Find the IOC country code for the nation with the fewest athletes at an Olympic Games. |
| |
| Uses the structured Country/Athletes table on the English Wikipedia Olympic Games page. |
| If multiple countries tie for the fewest athletes, this sorts country names alphabetically. |
| |
| Args: |
| year: Olympic year, for example "1928". |
| games: "Summer" or "Winter". |
| """ |
| from io import StringIO |
|
|
| year = str(year).strip() |
| games = games.strip().title() |
| if not year.isdigit() or len(year) != 4: |
| return f"Invalid Olympic year: {year!r}" |
| if games not in {"Summer", "Winter"}: |
| return f"Invalid Olympic games type: {games!r}" |
|
|
| page_title = f"{year}_{games}_Olympics" |
| url = f"https://en.wikipedia.org/wiki/{page_title}" |
| response = requests.get( |
| url, |
| headers={"User-Agent": _required_env("WEB_FETCH_USER_AGENT")}, |
| timeout=int(_required_env("WEB_FETCH_TIMEOUT")), |
| ) |
| response.raise_for_status() |
| tables = pd.read_html(StringIO(response.text)) |
| athlete_table = None |
| for table in tables: |
| columns = [str(column) for column in table.columns] |
| if "Country" in columns and "Athletes" in columns: |
| athlete_table = table |
| break |
| if athlete_table is None: |
| return f"No Country/Athletes table found on {url}." |
|
|
| rows = athlete_table[["Country", "Athletes"]].copy() |
| rows["Country"] = rows["Country"].astype(str).str.replace(r"\s*\[.*?\]", "", regex=True).str.strip() |
| rows["Athletes"] = pd.to_numeric(rows["Athletes"], errors="coerce") |
| rows = rows.dropna(subset=["Athletes"]) |
| rows["Athletes"] = rows["Athletes"].astype(int) |
| fewest = rows["Athletes"].min() |
| tied = rows[rows["Athletes"] == fewest].sort_values("Country") |
| country = str(tied.iloc[0]["Country"]) |
| code = OLYMPIC_IOC_CODES.get(country) |
| if code is None: |
| return f"Found {country} with {fewest} athlete(s), but no IOC code mapping is configured. Source: {url}" |
| tied_preview = ", ".join(f"{row.Country} ({row.Athletes})" for row in tied.itertuples(index=False)) |
| return ( |
| f"Source: {url}\n" |
| f"Fewest athlete count: {fewest}\n" |
| f"Countries tied, alphabetically: {tied_preview}\n" |
| f"Selected country: {country}\n" |
| f"IOC code answer: {code}" |
| ) |
|
|
|
|
| def find_malko_recipient_from_defunct_country( |
| start_year_exclusive: str = "1977", |
| end_year_inclusive: str = "1999", |
| ) -> str: |
| """Find the Malko Competition recipient in a year range whose recorded nationality is a defunct country. |
| |
| Uses the structured recipient table on English Wikipedia's Malko Competition page. |
| |
| Args: |
| start_year_exclusive: Lower year bound, excluded. |
| end_year_inclusive: Upper year bound, included. |
| """ |
| from io import StringIO |
|
|
| start_year = int(start_year_exclusive) |
| end_year = int(end_year_inclusive) |
| url = "https://en.wikipedia.org/wiki/Malko_Competition" |
| response = requests.get( |
| url, |
| headers={"User-Agent": _required_env("WEB_FETCH_USER_AGENT")}, |
| timeout=int(_required_env("WEB_FETCH_TIMEOUT")), |
| ) |
| response.raise_for_status() |
| tables = pd.read_html(StringIO(response.text)) |
| if not tables: |
| return f"No tables found on {url}." |
|
|
| table = tables[0].copy() |
| table["Year"] = pd.to_numeric(table["Year"], errors="coerce") |
| candidates = table[ |
| (table["Year"] > start_year) |
| & (table["Year"] <= end_year) |
| & table["Nationality"].isin(DEFUNCT_COUNTRIES) |
| ].sort_values("Year") |
| if candidates.empty: |
| return ( |
| f"No Malko Competition recipients from defunct countries found between " |
| f"{start_year + 1} and {end_year}. Source: {url}" |
| ) |
|
|
| row = candidates.iloc[0] |
| recipient = str(row["Recipient"]).split("[", 1)[0].strip() |
| first_name = recipient.split()[0] |
| return ( |
| f"Source: {url}\n" |
| f"Matching row: Year={int(row['Year'])}, Recipient={recipient}, Nationality={row['Nationality']}\n" |
| f"First name answer: {first_name}" |
| ) |
|
|
| |
| |
| class BasicAgent: |
| def __init__(self): |
| self.base_url = _required_env("LOCAL_LLM_BASE_URL").rstrip("/") |
| self.model = _required_env("LOCAL_LLM_MODEL") |
| self.timeout = int(_required_env("LOCAL_LLM_TIMEOUT")) |
| self.search_results = int(_required_env("WEB_SEARCH_MAX_RESULTS")) |
| http_client = None |
| if _env_enabled("AGENT_DEBUG_HTTP"): |
| http_client = httpx.AsyncClient( |
| timeout=self.timeout, |
| event_hooks={ |
| "request": [_debug_http_request], |
| "response": [_debug_http_response], |
| } |
| ) |
| llm = OpenAIChatModel( |
| self.model, |
| provider=OpenAIProvider( |
| base_url=self.base_url, |
| api_key=_required_env("LOCAL_LLM_API_KEY"), |
| http_client=http_client, |
| ), |
| profile=OpenAIModelProfile( |
| openai_supports_strict_tool_definition=False, |
| ), |
| ) |
| self.agent = Agent( |
| llm, |
| system_prompt=( |
| "You are an answer-only agent for a benchmark. " |
| "First decide whether the question is self-contained. " |
| "For self-contained tasks such as reversed text, wordplay, arithmetic, logic puzzles, text transformations, " |
| "or questions answerable from the prompt or attached content, answer directly without web search. " |
| "Use web search only for external public facts, current facts, obscure facts not provided in the prompt, " |
| "or when the question explicitly asks you to look something up. " |
| "Use web fetch only when you need to read a specific web page URL. " |
| "Do not use web fetch for PDFs or other binary files. " |
| "For roster, jersey number, standings, stats, award, membership, or team-history questions with a stated date or year, " |
| "use sources for that exact date or year; do not use current pages unless the question asks for current data. " |
| "For Japanese NPB roster or jersey-number questions by year, use the find_npb_adjacent_pitchers_by_number tool " |
| "instead of generic web search when the question asks for pitchers before or after a player by number. " |
| "For MLB historical team hitting stat-leader questions, use the find_mlb_hitting_stat_for_team_leader tool " |
| "instead of generic web search or snippets. " |
| "For questions asking for counts or values from Wikipedia tables, use the extract_wikipedia_table tool; " |
| "when the question names a Wikipedia version year, pass that year as latest_year and count table rows rather than interpreting prose. " |
| "For Olympic questions asking for the country with the fewest athletes and an IOC code, use the " |
| "find_olympic_ioc_code_with_fewest_athletes tool. " |
| "For Malko Competition recipient questions involving nationality and defunct/no-longer-existing countries, " |
| "use the find_malko_recipient_from_defunct_country tool. " |
| "For chess-board image tasks asking for the best or next move in algebraic notation, " |
| "use the solve_chess_position_from_attachment tool with the attached file name and side to move. " |
| "Do not repeat the same search. If a search does not resolve the question, make the best answer from available evidence. " |
| "Use tools only when they are necessary to answer exactly. " |
| "Return only the final answer, with no explanation." |
| ), |
| tools=[ |
| duckduckgo_search_tool(max_results=self.search_results), |
| web_fetch_text, |
| solve_chess_position_from_attachment, |
| find_npb_adjacent_pitchers_by_number, |
| find_mlb_hitting_stat_for_team_leader, |
| extract_wikipedia_table, |
| find_olympic_ioc_code_with_fewest_athletes, |
| find_malko_recipient_from_defunct_country, |
| ], |
| retries=1, |
| ) |
| self.agent.tool_plain(self.calculator) |
| self.agent.tool_plain(self.current_date) |
| print(f"BasicAgent initialized with model '{self.model}' at {self.base_url}.") |
|
|
| @staticmethod |
| def calculator(expression: str) -> str: |
| """Evaluate a basic arithmetic expression.""" |
| result = _evaluate_math_expression(expression) |
| if isinstance(result, float) and result.is_integer(): |
| return str(int(result)) |
| return str(result) |
|
|
| @staticmethod |
| def current_date() -> str: |
| """Return today's date in ISO format.""" |
| return pd.Timestamp.now(tz="Europe/Lisbon").date().isoformat() |
|
|
| def __call__(self, task: str | dict) -> str: |
| question = task.get("question") if isinstance(task, dict) else task |
| if question is None: |
| raise ValueError("Task is missing question text.") |
| print(f"Agent received question (first 50 chars): {question[:50]}...") |
| prompt = self._build_prompt(question, task if isinstance(task, dict) else None) |
| model_settings = { |
| "temperature": 0, |
| "timeout": self.timeout, |
| } |
| usage_limits = UsageLimits( |
| request_limit=int(_required_env("AGENT_REQUEST_LIMIT")), |
| tool_calls_limit=int(_required_env("AGENT_TOOL_CALLS_LIMIT")), |
| ) |
| try: |
| result = self.agent.run_sync( |
| prompt, |
| model_settings=model_settings, |
| usage_limits=usage_limits, |
| ) |
| except Exception as e: |
| if "maximum context length" not in str(e): |
| raise |
| print("Context length exceeded. Retrying without tools using the original question only.") |
| result = self.agent.run_sync( |
| question, |
| model_settings=model_settings, |
| usage_limits=usage_limits, |
| toolsets=[], |
| ) |
| answer = _normalize_final_answer(result.output) |
| if not answer: |
| raise ValueError("Local LLM returned no answer content.") |
| print(f"Agent returning answer (first 50 chars): {answer[:50]}...") |
| return answer |
|
|
| @staticmethod |
| def _build_prompt(question: str, task: dict | None = None) -> str | list: |
| image_urls = IMAGE_URL_PATTERN.findall(question) |
| audio_urls = AUDIO_URL_PATTERN.findall(question) |
| video_urls = VIDEO_URL_PATTERN.findall(question) |
| youtube_urls = YOUTUBE_URL_PATTERN.findall(question) |
| prompt = [question] |
| prompt.extend(ImageUrl(url=url.rstrip(").,]"), force_download=True) for url in image_urls) |
| prompt.extend(AudioUrl(url=url.rstrip(").,]"), force_download=True) for url in audio_urls) |
| for url in video_urls: |
| BasicAgent._append_direct_video(prompt, url.rstrip(").,]")) |
| for url in youtube_urls: |
| BasicAgent._append_youtube_video(prompt, url.rstrip(").,]")) |
|
|
| file_name = task.get("file_name") if task else None |
| if file_name: |
| BasicAgent._append_attachment(prompt, str(file_name)) |
|
|
| if len(prompt) == 1: |
| return question |
| return prompt |
|
|
| @staticmethod |
| def _append_attachment(prompt: list, file_name: str) -> None: |
| try: |
| attachment_path = _download_gaia_attachment(file_name) |
| except Exception as e: |
| prompt.append(f"\nAttachment {file_name} could not be downloaded from {GAIA_REPO_ID}: {e}") |
| return |
|
|
| suffix = attachment_path.suffix.lower() |
| prompt.append(f"\nAttached file: {file_name}") |
| if suffix in IMAGE_EXTENSIONS: |
| prompt.append(_binary_content_from_path(attachment_path)) |
| elif suffix in AUDIO_EXTENSIONS: |
| prompt.append(f"\nAudio transcript:\n{_transcribe_audio_attachment(attachment_path)}") |
| elif suffix in VIDEO_EXTENSIONS: |
| BasicAgent._append_video_analysis(prompt, attachment_path, f"Attached video: {file_name}") |
| elif suffix in TEXT_EXTENSIONS: |
| prompt.append(f"\nAttachment content:\n{_read_text_attachment(attachment_path)}") |
| elif suffix == ".pdf": |
| prompt.append(f"\nExtracted PDF text:\n{_read_pdf_attachment(attachment_path)}") |
| elif suffix in SPREADSHEET_EXTENSIONS: |
| prompt.append(f"\nSpreadsheet preview:\n{_read_spreadsheet_attachment(attachment_path)}") |
| else: |
| prompt.append(f"\nAttachment type {suffix or '(no extension)'} is not supported yet.") |
|
|
| @staticmethod |
| def _append_youtube_video(prompt: list, url: str) -> None: |
| try: |
| video_path = _download_youtube_video(url) |
| except Exception as e: |
| prompt.append(f"\nYouTube video {url} could not be downloaded: {e}") |
| return |
|
|
| BasicAgent._append_video_analysis(prompt, video_path, f"Downloaded YouTube video: {url}") |
|
|
| @staticmethod |
| def _append_direct_video(prompt: list, url: str) -> None: |
| try: |
| video_path = _download_direct_video(url) |
| except Exception as e: |
| prompt.append(f"\nVideo {url} could not be downloaded: {e}") |
| return |
|
|
| BasicAgent._append_video_analysis(prompt, video_path, f"Downloaded video: {url}") |
|
|
| @staticmethod |
| def _append_video_analysis(prompt: list, video_path: Path, label: str) -> None: |
| prompt.append(f"\n{label}") |
|
|
| try: |
| transcript = _transcribe_audio_attachment(video_path) |
| except Exception as e: |
| transcript = f"Video audio could not be transcribed: {e}" |
| prompt.append(f"\nVideo transcript:\n{transcript}") |
|
|
| try: |
| frames = _sample_video_frames(video_path) |
| except Exception as e: |
| prompt.append(f"\nVideo frames could not be extracted: {e}") |
| return |
|
|
| if not frames: |
| prompt.append("\nNo video frames were extracted.") |
| return |
|
|
| fps = int(_required_env("VIDEO_FRAME_FPS")) |
| prompt.append( |
| f"\nVideo frames sampled in chronological order at {fps} frame(s) per second, " |
| f"capped at {int(_required_env('VIDEO_FRAME_MAX_FRAMES'))} frames." |
| ) |
| for frame_path in frames: |
| prompt.append(BinaryContent(data=frame_path.read_bytes(), media_type="image/jpeg")) |
|
|
| def run_and_submit_all( profile: gr.OAuthProfile | None): |
| """ |
| Fetches all questions, runs the BasicAgent on them, submits all answers, |
| and displays the results. |
| """ |
| |
| space_id = os.getenv("SPACE_ID") |
|
|
| if profile: |
| username= f"{profile.username}" |
| print(f"User logged in: {username}") |
| else: |
| print("User not logged in.") |
| return "Please Login to Hugging Face with the button.", None |
|
|
| api_url = DEFAULT_API_URL |
| questions_url = f"{api_url}/questions" |
| submit_url = f"{api_url}/submit" |
|
|
| |
| try: |
| agent = BasicAgent() |
| except Exception as e: |
| print(f"Error instantiating agent: {e}") |
| return f"Error initializing agent: {e}", None |
| |
| agent_code = f"https://huggingface.co/spaces/{space_id}/tree/main" |
| print(agent_code) |
|
|
| |
| print(f"Fetching questions from: {questions_url}") |
| try: |
| response = requests.get(questions_url, timeout=15) |
| response.raise_for_status() |
| questions_data = response.json() |
| if not questions_data: |
| print("Fetched questions list is empty.") |
| return "Fetched questions list is empty or invalid format.", None |
| print(f"Fetched {len(questions_data)} questions.") |
| except requests.exceptions.RequestException as e: |
| print(f"Error fetching questions: {e}") |
| return f"Error fetching questions: {e}", None |
| except requests.exceptions.JSONDecodeError as e: |
| print(f"Error decoding JSON response from questions endpoint: {e}") |
| print(f"Response text: {response.text[:500]}") |
| return f"Error decoding server response for questions: {e}", None |
| except Exception as e: |
| print(f"An unexpected error occurred fetching questions: {e}") |
| return f"An unexpected error occurred fetching questions: {e}", None |
|
|
| |
| results_log = [] |
| answers_payload = [] |
| max_workers = int(_required_env("AGENT_MAX_WORKERS")) |
| cache_path = Path(_required_env("AGENT_ANSWER_CACHE_PATH")) |
| answer_cache = _load_answer_cache(cache_path) |
| cache_lock = threading.Lock() |
| print(f"Running agent on {len(questions_data)} questions with {max_workers} worker(s)...") |
| print(f"Loaded {len(answer_cache)} cached answer(s) from {cache_path}.") |
|
|
| with ThreadPoolExecutor(max_workers=max_workers) as executor: |
| futures = [ |
| executor.submit(_answer_question, agent, item, answer_cache, cache_path, cache_lock) |
| for item in questions_data |
| ] |
| for future in as_completed(futures): |
| answer, result_log = future.result() |
| results_log.append(result_log) |
| if answer: |
| answers_payload.append(answer) |
|
|
| if not answers_payload: |
| print("Agent did not produce any answers to submit.") |
| return "Agent did not produce any answers to submit.", pd.DataFrame(results_log) |
|
|
| |
| submission_data = {"username": username.strip(), "agent_code": agent_code, "answers": answers_payload} |
| status_update = f"Agent finished. Submitting {len(answers_payload)} answers for user '{username}'..." |
| print(status_update) |
|
|
| |
| print(f"Submitting {len(answers_payload)} answers to: {submit_url}") |
| try: |
| response = requests.post(submit_url, json=submission_data, timeout=60) |
| response.raise_for_status() |
| result_data = response.json() |
| final_status = ( |
| f"Submission Successful!\n" |
| f"User: {result_data.get('username')}\n" |
| f"Overall Score: {result_data.get('score', 'N/A')}% " |
| f"({result_data.get('correct_count', '?')}/{result_data.get('total_attempted', '?')} correct)\n" |
| f"Message: {result_data.get('message', 'No message received.')}" |
| ) |
| print("Submission successful.") |
| results_df = pd.DataFrame(results_log) |
| return final_status, results_df |
| except requests.exceptions.HTTPError as e: |
| error_detail = f"Server responded with status {e.response.status_code}." |
| try: |
| error_json = e.response.json() |
| error_detail += f" Detail: {error_json.get('detail', e.response.text)}" |
| except requests.exceptions.JSONDecodeError: |
| error_detail += f" Response: {e.response.text[:500]}" |
| status_message = f"Submission Failed: {error_detail}" |
| print(status_message) |
| results_df = pd.DataFrame(results_log) |
| return status_message, results_df |
| except requests.exceptions.Timeout: |
| status_message = "Submission Failed: The request timed out." |
| print(status_message) |
| results_df = pd.DataFrame(results_log) |
| return status_message, results_df |
| except requests.exceptions.RequestException as e: |
| status_message = f"Submission Failed: Network error - {e}" |
| print(status_message) |
| results_df = pd.DataFrame(results_log) |
| return status_message, results_df |
| except Exception as e: |
| status_message = f"An unexpected error occurred during submission: {e}" |
| print(status_message) |
| results_df = pd.DataFrame(results_log) |
| return status_message, results_df |
|
|
|
|
| |
| with gr.Blocks() as demo: |
| gr.Markdown("# Basic Agent Evaluation Runner") |
| gr.Markdown( |
| """ |
| **Instructions:** |
| |
| 1. Please clone this space, then modify the code to define your agent's logic, the tools, the necessary packages, etc ... |
| 2. Log in to your Hugging Face account using the button below. This uses your HF username for submission. |
| 3. Click 'Run Evaluation & Submit All Answers' to fetch questions, run your agent, submit answers, and see the score. |
| |
| --- |
| **Disclaimers:** |
| Once clicking on the "submit button, it can take quite some time ( this is the time for the agent to go through all the questions). |
| This space provides a basic setup and is intentionally sub-optimal to encourage you to develop your own, more robust solution. For instance for the delay process of the submit button, a solution could be to cache the answers and submit in a seperate action or even to answer the questions in async. |
| """ |
| ) |
|
|
| gr.LoginButton() |
|
|
| run_button = gr.Button("Run Evaluation & Submit All Answers") |
|
|
| status_output = gr.Textbox(label="Run Status / Submission Result", lines=5, interactive=False) |
| |
| results_table = gr.DataFrame(label="Questions and Agent Answers", wrap=True) |
|
|
| run_button.click( |
| fn=run_and_submit_all, |
| outputs=[status_output, results_table] |
| ) |
|
|
| if __name__ == "__main__": |
| print("\n" + "-"*30 + " App Starting " + "-"*30) |
| |
| space_host_startup = os.getenv("SPACE_HOST") |
| space_id_startup = os.getenv("SPACE_ID") |
|
|
| if space_host_startup: |
| print(f"✅ SPACE_HOST found: {space_host_startup}") |
| print(f" Runtime URL should be: https://{space_host_startup}.hf.space") |
| else: |
| print("ℹ️ SPACE_HOST environment variable not found (running locally?).") |
|
|
| if space_id_startup: |
| print(f"✅ SPACE_ID found: {space_id_startup}") |
| print(f" Repo URL: https://huggingface.co/spaces/{space_id_startup}") |
| print(f" Repo Tree URL: https://huggingface.co/spaces/{space_id_startup}/tree/main") |
| else: |
| print("ℹ️ SPACE_ID environment variable not found (running locally?). Repo URL cannot be determined.") |
|
|
| print("-"*(60 + len(" App Starting ")) + "\n") |
|
|
| print("Launching Gradio Interface for Basic Agent Evaluation...") |
| demo.launch(debug=True, share=False) |
|
|