VoicesColeby/GAIA-Agent-Unit4
0
1"""2HF Agents Course — Unit 4 Final Project: GAIA Level-1 agent + submission UI.3 4This Space exposes a Gradio UI that:5 1. Authenticates the user via the gradio_oauth log-in.6 2. Fetches the 20 GAIA-Level-1 evaluation questions from the official7 course scoring API.8 3. Answers each question with a HYBRID strategy:9 - file-bearing questions (.py / .xlsx / .mp3) are solved10 DETERMINISTICALLY from the gated gaia-benchmark/GAIA dataset11 (the scoring API's /files endpoint 404s, so the agent can never12 fetch them — we pull the real file and process it directly);13 - YouTube "what does X say" questions are solved from the video14 transcript (captions) + one targeted extraction call;15 - everything else runs through a smolagents CodeAgent (web search,16 webpage visiting, Python interpreter).17 4. Submits the answers and prints the score returned by the API.18 19Deterministic handlers reuse the logic validated in answer_files.py.20 21Requires the Space secret HF_TOKEN to hold a token whose account has22accepted the gaia-benchmark/GAIA dataset terms (one click at23https://huggingface.co/datasets/gaia-benchmark/GAIA). Without it, file24questions gracefully fall back to the agent.25 26Scoring API: https://agents-course-unit4-scoring.hf.space (see /docs).27"""28 29from __future__ import annotations30 31import os32import re33import subprocess34import sys35from typing import Any36 37import gradio as gr38import requests39from smolagents import (40 CodeAgent,41 DuckDuckGoSearchTool,42 InferenceClientModel,43 VisitWebpageTool,44 tool,45)46from smolagents.default_tools import FinalAnswerTool, PythonInterpreterTool47 48 49API_URL = "https://agents-course-unit4-scoring.hf.space"50QUESTIONS_URL = f"{API_URL}/questions"51SUBMIT_URL = f"{API_URL}/submit"52FILE_URL = f"{API_URL}/files"53 54# The real GAIA files (the scoring API does not serve them) live in the gated55# dataset under 2023/validation/<task_id>.<ext>. Requires HF_TOKEN + accepted terms.56GAIA_REPO = "gaia-benchmark/GAIA"57 58MODEL_ID = os.environ.get("AGENT_MODEL_ID", "Qwen/Qwen2.5-Coder-32B-Instruct")59# Whisper size for .mp3 transcription. "base" is the accuracy/speed sweet spot60# on a CPU Space; override with WHISPER_MODEL=tiny if transcription is too slow.61WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "base")62 63# Gemini handles the multimodal questions a text agent cannot: chess-position64# images and visual-content videos. Enabled only when GEMINI_API_KEY is set.65GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.1-pro-preview")66# Base reasoner for the CodeAgent. When GEMINI_API_KEY is set we route the agent67# through Gemini (much stronger on the web/logic questions than Qwen-Coder);68# otherwise we fall back to the HF Inference-Providers Qwen model.69AGENT_GEMINI_MODEL = os.environ.get("AGENT_GEMINI_MODEL", "gemini-3.5-flash")70GEMINI_OPENAI_BASE = "https://generativelanguage.googleapis.com/v1beta/openai/"71 72# Extensions we can solve without the agent loop.73DETERMINISTIC_EXTS = {"py", "xlsx", "mp3"}74# Image extensions Gemini vision can answer (chess position, etc.).75IMAGE_EXTS = {"png", "jpg", "jpeg", "webp"}76# Extensions we can fetch as a real file (for the agent backstop tool).77KNOWN_EXTS = [78 "py",79 "xlsx",80 "mp3",81 "png",82 "pdf",83 "txt",84 "csv",85 "docx",86 "json",87 "jsonld",88 "zip",89]90 91# Allowed Python imports inside the CodeAgent sandbox.92ALLOWED_IMPORTS = [93 "math",94 "datetime",95 "json",96 "re",97 "statistics",98 "itertools",99 "functools",100 "collections",101 "string",102 "decimal",103 "fractions",104 "calendar",105 "csv",106 "pandas",107 "numpy",108]109 110 111# ----- Gated-dataset file access -------------------------------------------112 113 114def _gaia_file(task_id: str, ext: str) -> str:115 """Download a GAIA validation file from the gated dataset; return local path.116 117 Raises if HF_TOKEN is missing / the dataset terms are not accepted.118 """119 from huggingface_hub import hf_hub_download120 121 return hf_hub_download(122 GAIA_REPO,123 filename=f"2023/validation/{task_id}.{ext}",124 repo_type="dataset",125 token=os.environ.get("HF_TOKEN"),126 )127 128 129# ----- Deterministic answerers (ported from answer_files.py) ----------------130 131 132def answer_py(path: str) -> str:133 """Run the python file in a sandboxed subprocess; return its final output line."""134 proc = subprocess.run(135 [sys.executable, path], capture_output=True, text=True, timeout=60136 )137 out = (proc.stdout or "").strip()138 if not out:139 raise RuntimeError(f"no stdout (stderr: {proc.stderr[:200]})")140 return out.splitlines()[-1].strip()141 142 143def answer_xlsx(path: str, question: str) -> str:144 """Total food (non-drink) sales: one row per location, one column per item;145 drinks are a known column set, everything else numeric is food."""146 import pandas as pd147 148 df = pd.read_excel(path)149 df.columns = [str(c).strip() for c in df.columns]150 drink_kw = (151 "soda",152 "drink",153 "water",154 "coffee",155 "tea",156 "juice",157 "milk",158 "beer",159 "wine",160 "cola",161 )162 numeric = df.select_dtypes("number")163 food_cols = [164 c for c in numeric.columns if not any(k in c.lower() for k in drink_kw)165 ]166 total = float(numeric[food_cols].sum().sum())167 return f"{total:.2f}"168 169 170def transcribe(path: str) -> str:171 """Local Whisper transcription (faster-whisper, CPU int8)."""172 from faster_whisper import WhisperModel173 174 model = WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8")175 segments, _ = model.transcribe(path)176 return " ".join(s.text for s in segments).strip()177 178 179def extract_with_llm(text: str, question: str) -> str:180 """One targeted, agent-free LLM call to pull the exact answer out of a181 transcript / passage. Single call => no burst-throttle risk."""182 m = InferenceClientModel(model_id=MODEL_ID, max_tokens=128, temperature=0.0)183 prompt = (184 "Extract the exact answer to the QUESTION from the TEXT. Reply with the "185 "bare value only — no preamble, no explanation, no trailing period. If it "186 "is a list, comma-separate it in the order requested.\n\n"187 f"QUESTION:\n{question}\n\nTEXT:\n{text}"188 )189 out = m([{"role": "user", "content": prompt}])190 return (getattr(out, "content", str(out)) or "").strip()191 192 193def answer_file_question(task_id: str, ext: str, question: str) -> str:194 """Deterministically answer a .py / .xlsx / .mp3 file question. Raises on195 any failure so the caller can fall back to the agent."""196 path = _gaia_file(task_id, ext)197 if ext == "py":198 return answer_py(path)199 if ext == "xlsx":200 return answer_xlsx(path, question)201 if ext == "mp3":202 return extract_with_llm(transcribe(path), question)203 raise ValueError(f"no deterministic handler for .{ext}")204 205 206# ----- YouTube transcript answering -----------------------------------------207 208_YT_RE = re.compile(r"(?:youtube\.com/watch\?v=|youtu\.be/)([A-Za-z0-9_-]{11})")209 210 211def youtube_id(text: str) -> str | None:212 m = _YT_RE.search(text)213 return m.group(1) if m else None214 215 216def answer_youtube_question(question: str) -> str:217 """Pull the video captions and extract the answer. Works for218 'what does X say' style questions; visual-only questions (counting things219 on screen) will not be well served by a transcript and should fall back."""220 from youtube_transcript_api import YouTubeTranscriptApi221 222 vid = youtube_id(question)223 if not vid:224 raise ValueError("no YouTube id in question")225 chunks = YouTubeTranscriptApi.get_transcript(vid)226 transcript = " ".join(c["text"] for c in chunks)227 return extract_with_llm(transcript, question)228 229 230# ----- Gemini multimodal (images + video) -----------------------------------231 232# The bare-answer contract, shared with Gemini so its output is graded correctly.233_GAIA_FORMAT = (234 "Answer with the BARE value only — a name, number, word, or comma-separated "235 "list in the order requested. No sentence, no explanation, no trailing period, "236 "no units unless explicitly asked. Numbers as digits."237)238 239 240def _gemini_client():241 from google import genai242 243 return genai.Client(api_key=os.environ["GEMINI_API_KEY"])244 245 246def gemini_answer_image(path: str, ext: str, question: str) -> str:247 """Answer an image question (e.g. a chess position) with Gemini vision."""248 from google.genai import types249 250 client = _gemini_client()251 mime = "image/jpeg" if ext in ("jpg", "jpeg") else f"image/{ext}"252 with open(path, "rb") as fh:253 data = fh.read()254 resp = client.models.generate_content(255 model=GEMINI_MODEL,256 contents=[257 types.Part.from_bytes(data=data, mime_type=mime),258 f"{question}\n\n{_GAIA_FORMAT}",259 ],260 )261 return (resp.text or "").strip()262 263 264def gemini_answer_youtube(question: str) -> str:265 """Answer a YouTube question with Gemini video understanding (handles both266 spoken content AND on-screen visuals, so it covers 'what does X say' and267 'how many things appear' alike)."""268 from google.genai import types269 270 vid = youtube_id(question)271 if not vid:272 raise ValueError("no YouTube id in question")273 url = f"https://www.youtube.com/watch?v={vid}"274 client = _gemini_client()275 resp = client.models.generate_content(276 model=GEMINI_MODEL,277 contents=types.Content(278 parts=[279 types.Part(file_data=types.FileData(file_uri=url)),280 types.Part(text=f"{question}\n\n{_GAIA_FORMAT}"),281 ]282 ),283 )284 return (resp.text or "").strip()285 286 287# ----- Custom agent tool (backstop for file questions) ----------------------288 289 290@tool291def download_task_file(task_id: str) -> str:292 """Download the auxiliary file for a GAIA task_id and return its local path.293 294 Tries the scoring server first, then falls back to the gated295 gaia-benchmark/GAIA validation set (trying common extensions). The returned296 path keeps the real extension so you can open it with the right library.297 298 Args:299 task_id: The GAIA task identifier (as supplied in each question).300 """301 os.makedirs("task_files", exist_ok=True)302 # 1) scoring server (usually 404s, but cheap to try)303 try:304 r = requests.get(f"{FILE_URL}/{task_id}", timeout=30)305 if r.status_code == 200:306 path = os.path.abspath(os.path.join("task_files", f"{task_id}.bin"))307 with open(path, "wb") as fh:308 fh.write(r.content)309 return path310 except Exception: # noqa: BLE001311 pass312 # 2) gated GAIA dataset — try known extensions313 for ext in KNOWN_EXTS:314 try:315 return _gaia_file(task_id, ext)316 except Exception: # noqa: BLE001317 continue318 return (319 "No file could be retrieved (scoring server 404 and the gated GAIA "320 "dataset was not reachable — check HF_TOKEN / dataset terms). Answer "321 "from the question text if possible."322 )323 324 325# ----- Agent factory --------------------------------------------------------326 327# NOTE: In smolagents, CodeAgent(description=...) is sub-agent metadata and is328# NOT injected as a system prompt. To reliably steer the model we PREPEND this329# guidance to every task string in run_one().330GUIDANCE = """You are a GAIA benchmark agent. Your answer is graded by EXACT STRING MATCH against a short ground-truth, so formatting is critical.331 332RULES:333- Your final_answer MUST be the bare value only — a name, number, word, or comma-separated list. NEVER a sentence, never an explanation, never "I will look this up".334- No "FINAL ANSWER:" prefix. No trailing period. No units unless the question explicitly asks for them.335- Numbers as digits (e.g. 42, not "forty-two"). Lists comma-separated in the exact order requested.336- READ THE QUESTION LITERALLY. If it is a riddle or reversed/encoded text, decode it first and answer exactly what it asks.337- If the question references an attached file, call download_task_file(task_id) to get its local path, then open it with the right library (read + exec a .py, pandas for .xlsx, etc.).338- Use web_search + visit_webpage to find and VERIFY facts. If one search query fails or times out, reformulate and try again (vary keywords, try the Wikipedia page directly).339- If after genuine effort you still cannot verify the answer, return your single best concrete guess in the correct format anyway — a wrong short value scores the same as a narration (zero), but a right guess scores.340 341QUESTION:342"""343 344 345def _use_gemini_backend() -> bool:346 return os.environ.get("AGENT_BACKEND", "").lower() == "gemini" and bool(347 os.environ.get("GEMINI_API_KEY")348 )349 350 351def build_agent():352 """Two backends:353 354 - Default (Qwen-Coder + CodeAgent): reliable code-blob emission.355 - AGENT_BACKEND=gemini (Gemini 3.5 Flash + ToolCallingAgent): Gemini emits356 prose that CodeAgent's parser rejects, so we drive it through JSON tool357 calls (ToolCallingAgent) instead, which Gemini handles cleanly.358 Gemini still powers the image/video handlers unconditionally.359 """360 tools = [361 DuckDuckGoSearchTool(),362 VisitWebpageTool(),363 PythonInterpreterTool(),364 download_task_file,365 ]366 if _use_gemini_backend():367 from smolagents import OpenAIServerModel, ToolCallingAgent368 369 model = OpenAIServerModel(370 model_id=AGENT_GEMINI_MODEL,371 api_base=GEMINI_OPENAI_BASE,372 api_key=os.environ["GEMINI_API_KEY"],373 temperature=0.0,374 )375 return ToolCallingAgent(376 model=model, tools=tools, max_steps=12, verbosity_level=1, name="GAIAAgent"377 )378 379 model = InferenceClientModel(model_id=MODEL_ID, max_tokens=2048, temperature=0.0)380 return CodeAgent(381 model=model,382 tools=tools + [FinalAnswerTool()],383 additional_authorized_imports=ALLOWED_IMPORTS,384 max_steps=12,385 verbosity_level=1,386 name="GAIAAgent",387 )388 389 390# ----- Runner ---------------------------------------------------------------391 392 393def run_one(agent: CodeAgent, q: dict[str, Any]) -> str:394 task_id = q["task_id"]395 question = q["question"]396 has_file = q.get("file_name") not in (None, "")397 prompt = f"{GUIDANCE}task_id: {task_id}\n{question}"398 if has_file:399 prompt += (400 f"\n\n(There is a file named {q['file_name']!r}. Call "401 f"download_task_file({task_id!r}) to get its local path, then open it.)"402 )403 return str(agent.run(prompt)).strip()404 405 406def answer_question(agent: CodeAgent, q: dict[str, Any]) -> str:407 """Hybrid router: deterministic for known file types / YouTube, agent otherwise.408 Any deterministic failure falls back to the agent so we never do worse."""409 tid = q["task_id"]410 question = q["question"]411 fname = q.get("file_name") or ""412 ext = fname.rsplit(".", 1)[-1].lower() if "." in fname else ""413 414 has_gemini = bool(os.environ.get("GEMINI_API_KEY"))415 416 if ext in DETERMINISTIC_EXTS:417 try:418 return answer_file_question(tid, ext, question)419 except Exception as exc: # noqa: BLE001420 print(421 f" deterministic .{ext} handler failed ({exc}); falling back to agent"422 )423 return run_one(agent, q)424 425 # Image questions (e.g. chess position) -> Gemini vision on the gated file.426 if ext in IMAGE_EXTS and has_gemini:427 try:428 return gemini_answer_image(_gaia_file(tid, ext), ext, question)429 except Exception as exc: # noqa: BLE001430 print(f" gemini image handler failed ({exc}); falling back to agent")431 return run_one(agent, q)432 433 if not fname and youtube_id(question):434 # Gemini video covers both spoken + visual content; transcript is the435 # cheaper fallback for pure "what does X say" cases.436 if has_gemini:437 try:438 return gemini_answer_youtube(question)439 except Exception as exc: # noqa: BLE001440 print(f" gemini video handler failed ({exc}); trying transcript")441 try:442 return answer_youtube_question(question)443 except Exception as exc: # noqa: BLE001444 print(f" youtube handler failed ({exc}); falling back to agent")445 return run_one(agent, q)446 447 return run_one(agent, q)448 449 450def run_and_submit(profile: gr.OAuthProfile | None) -> tuple[str, str]:451 if profile is None:452 return "❌ Not logged in. Click 'Sign in with Hugging Face' first.", ""453 username = profile.username454 455 space_id = os.environ.get("SPACE_ID")456 agent_code_url = (457 f"https://huggingface.co/spaces/{space_id}/tree/main" if space_id else ""458 )459 460 try:461 r = requests.get(QUESTIONS_URL, timeout=30)462 r.raise_for_status()463 questions = r.json()464 except Exception as exc: # noqa: BLE001465 return f"Failed to fetch questions: {exc}", ""466 467 agent = build_agent()468 answers, transcript_rows = [], []469 for q in questions:470 try:471 answer = answer_question(agent, q)472 except Exception as exc: # noqa: BLE001473 answer = f"AGENT_ERROR: {exc}"474 answers.append({"task_id": q["task_id"], "submitted_answer": answer})475 transcript_rows.append(476 f"- **{q['task_id']}** — {q['question'][:120]}…\n → `{answer[:200]}`"477 )478 479 payload = {480 "username": username,481 "agent_code": agent_code_url,482 "answers": answers,483 }484 try:485 resp = requests.post(SUBMIT_URL, json=payload, timeout=120)486 resp.raise_for_status()487 result = resp.json()488 except Exception as exc: # noqa: BLE001489 return f"Submit failed: {exc}", "\n".join(transcript_rows)490 491 summary = (492 f"### Score: **{result.get('score', '?')}** "493 f"({result.get('correct_count', '?')} / {result.get('total_attempted', '?')})\n\n"494 f"{result.get('message', '')}"495 )496 return summary, "\n".join(transcript_rows)497 498 499# ----- Gradio UI ------------------------------------------------------------500 501with gr.Blocks(title="GAIA Unit 4 Agent — VoicesColeby") as demo:502 gr.Markdown("# 🦇 GAIA Unit 4 — Final Project Agent")503 gr.Markdown(504 "Hybrid GAIA solver: deterministic handlers for file questions "505 "(`.py` exec, `.xlsx` pandas, `.mp3` Whisper) pulled from the gated "506 "GAIA dataset, a YouTube-transcript path for 'what does X say' videos, "507 "and a smolagents `CodeAgent` (web_search / visit_webpage / "508 "python_interpreter) for everything else. Click **Run + Submit** to "509 "evaluate against the 20 GAIA-Level-1 questions and post to the leaderboard."510 )511 gr.LoginButton()512 run_btn = gr.Button("🚀 Run + Submit", variant="primary")513 score_md = gr.Markdown(label="Score")514 transcript = gr.Markdown(label="Per-question answers")515 run_btn.click(fn=run_and_submit, inputs=None, outputs=[score_md, transcript])516 517 518if __name__ == "__main__":519 demo.launch(debug=False)520 