File size: 40,117 Bytes
332415a 0d23d00 332415a 817e2b1 332415a 0d23d00 817e2b1 0d23d00 332415a 0d23d00 332415a 817e2b1 332415a 0d23d00 817e2b1 0d23d00 332415a 6c11eb2 332415a 0d23d00 817e2b1 332415a 0d23d00 332415a 0d23d00 817e2b1 332415a 817e2b1 332415a 817e2b1 332415a 817e2b1 332415a 817e2b1 332415a 817e2b1 332415a 817e2b1 332415a 817e2b1 0d23d00 332415a 0d23d00 332415a 817e2b1 332415a 817e2b1 332415a 817e2b1 332415a 817e2b1 332415a 817e2b1 332415a 817e2b1 332415a 817e2b1 332415a 817e2b1 332415a 817e2b1 332415a 817e2b1 332415a 0d23d00 332415a 817e2b1 332415a 817e2b1 332415a 817e2b1 332415a 817e2b1 332415a 817e2b1 332415a 817e2b1 332415a 817e2b1 332415a 817e2b1 332415a 0d23d00 332415a 817e2b1 332415a 817e2b1 332415a 0d23d00 332415a 817e2b1 332415a 817e2b1 332415a 0d23d00 817e2b1 332415a 817e2b1 332415a 0d23d00 332415a 0d23d00 332415a 0d23d00 332415a 0d23d00 332415a 0d23d00 332415a 0d23d00 332415a 0d23d00 817e2b1 0d23d00 332415a 0d23d00 332415a 817e2b1 332415a 0d23d00 332415a 0d23d00 332415a 817e2b1 332415a 0d23d00 332415a 31243f4 332415a 40801b3 d6674a2 332415a e80aab9 332415a 817e2b1 332415a e80aab9 817e2b1 332415a 40801b3 332415a 40801b3 332415a 817e2b1 332415a e80aab9 332415a 0ee0419 e514fd7 332415a d6674a2 332415a 40801b3 e80aab9 7e4a06b 332415a e80aab9 40801b3 e80aab9 332415a 817e2b1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 | """
GAIA Unit 4 final-assignment agent (GPT-4o, tool-calling loop).
Keeps the official course workflow: Gradio + Hugging Face OAuth login,
GET /questions, POST /submit with {username, agent_code, answers}.
Secrets (Space Settings -> Variables and secrets):
OPENAI_API_KEY required
HF_TOKEN optional but recommended: lets the agent fetch task
attachments straight from the gated GAIA dataset if the
course /files/{task_id} endpoint fails (accept the dataset
conditions at huggingface.co/datasets/gaia-benchmark/GAIA).
"""
from __future__ import annotations
import base64
import io
import json
import mimetypes
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import Any
import gradio as gr
import pandas as pd
import requests
from openai import OpenAI
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
SCORING_API = os.getenv("SCORING_API", "https://agents-course-unit4-scoring.hf.space")
GAIA_DATASET_ID = "gaia-benchmark/GAIA"
GAIA_FILE_DIRS = ("2023/validation", "2023/test")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o")
OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
OPENAI_AUDIO_MODEL = os.getenv("OPENAI_AUDIO_MODEL", "whisper-1")
HF_TOKEN = os.getenv("HF_TOKEN", "")
MAX_STEPS = int(os.getenv("GAIA_MAX_STEPS", "9")) # tool-loop iterations per question
MAX_COMPLETION_TOKENS = 900
HTTP_TIMEOUT = 30
FETCH_CHARS = 6000 # chars returned per fetch_url call
SEARCH_RESULTS = 6
PY_TIMEOUT = 40
PY_OUTPUT_CHARS = 4000
WHISPER_MAX_BYTES = 24 * 1024 * 1024
BROWSER_UA = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0 Safari/537.36"
)
TEXT_EXT = {".txt", ".md", ".json", ".jsonl", ".csv", ".tsv", ".xml", ".html",
".htm", ".log", ".yaml", ".yml", ".jsonld"}
IMAGE_EXT = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"}
AUDIO_EXT = {".mp3", ".wav", ".m4a", ".mpga", ".mpeg", ".ogg", ".flac", ".webm", ".mp4"}
# Optional ZeroGPU startup compatibility. Inference is remote (OpenAI), so this
# function is never called. It only prevents "No @spaces.GPU function detected"
# if the Space hardware is still set to ZeroGPU. Harmless on CPU Basic.
try:
import spaces # type: ignore
@spaces.GPU(duration=1)
def _zerogpu_startup_probe():
return None
except Exception:
pass
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def truncate(text: str, limit: int) -> str:
text = text or ""
if len(text) <= limit:
return text
return text[:limit] + f"\n...[truncated, {len(text) - limit} more chars]"
def clean_answer(value: Any) -> str:
"""Extract and normalise the final answer for EXACT-MATCH scoring."""
text = str(value or "").strip()
matches = list(re.finditer(r"final answer\s*[:\-]\s*", text, flags=re.IGNORECASE))
if matches:
text = text[matches[-1].end():]
text = text.strip().splitlines()[0].strip() if text.strip() else ""
if len(text) >= 2 and text[0] == text[-1] and text[0] in {"'", '"'}:
text = text[1:-1].strip()
text = text.replace("**", "").strip()
# "1,234" -> "1234" (GAIA numbers carry no thousands separators); lists use ", "
if re.fullmatch(r"-?\d{1,3}(,\d{3})+(\.\d+)?", text):
text = text.replace(",", "")
# drop a trailing full stop on short answers
if text.endswith(".") and len(text.split()) <= 6 and not re.search(r"\b[A-Z]\.$", text):
text = text[:-1].strip()
return text
def image_to_data_url(path: str, max_side: int = 1568) -> str:
"""Downscale large images (cost control) and return a data URL."""
try:
from PIL import Image
with Image.open(path) as img:
img.load()
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
img.thumbnail((max_side, max_side))
buf = io.BytesIO()
img.save(buf, format="PNG" if img.mode == "L" else "JPEG", quality=90) \
if img.mode != "L" else img.save(buf, format="PNG")
mime = "image/png" if img.mode == "L" else "image/jpeg"
return f"data:{mime};base64,{base64.b64encode(buf.getvalue()).decode()}"
except Exception:
mime = mimetypes.guess_type(path)[0] or "image/png"
return f"data:{mime};base64,{base64.b64encode(Path(path).read_bytes()).decode()}"
def html_to_text(html: str) -> str:
"""HTML -> readable text; tables become 'a | b | c' rows."""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "lxml") if _has_lxml() else BeautifulSoup(html, "html.parser")
for tag in soup(["script", "style", "noscript", "svg", "nav", "footer", "form", "aside"]):
tag.decompose()
for sup in soup.select("sup.reference, .mw-editsection, .navbox, .reflist"):
sup.decompose()
for table in soup.find_all("table"):
rows = []
for tr in table.find_all("tr"):
cells = [c.get_text(" ", strip=True) for c in tr.find_all(["th", "td"])]
if any(cells):
rows.append(" | ".join(cells))
table.replace_with(soup.new_string("\n[TABLE]\n" + "\n".join(rows) + "\n[/TABLE]\n"))
text = soup.get_text("\n")
text = re.sub(r"[ \t\xa0]+", " ", text)
text = re.sub(r"\n\s*\n+", "\n", text)
return text.strip()
def _has_lxml() -> bool:
try:
import lxml # noqa: F401
return True
except Exception:
return False
def youtube_id(url: str) -> str | None:
m = re.search(r"(?:v=|youtu\.be/|/shorts/|/embed/)([A-Za-z0-9_-]{11})", url or "")
return m.group(1) if m else None
def parse_vtt(raw: str) -> str:
lines, seen = [], set()
for line in raw.splitlines():
line = line.strip()
if (not line or line == "WEBVTT" or "-->" in line or line.isdigit()
or line.startswith(("NOTE", "Kind:", "Language:"))):
continue
line = re.sub(r"<[^>]+>", "", line)
line = line.replace("&", "&").replace(""", '"').replace("'", "'")
if line and line not in seen:
seen.add(line)
lines.append(line)
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Attachment retrieval
# ---------------------------------------------------------------------------
def download_attachment(task_id: str, file_name: str, dest_dir: str) -> str | None:
"""Course API first, then the gated GAIA dataset (needs HF_TOKEN)."""
if not file_name:
return None
dest = Path(dest_dir) / Path(file_name).name
# 1) course scoring API
try:
r = requests.get(f"{SCORING_API}/files/{task_id}", timeout=HTTP_TIMEOUT)
ctype = r.headers.get("content-type", "")
looks_like_error = "json" in ctype and r.content[:1] in (b"{", b"[") and len(r.content) < 600
if r.ok and r.content and not looks_like_error:
dest.write_bytes(r.content)
print(f"[files] {task_id}: course API ok ({len(r.content)} bytes)")
return str(dest)
print(f"[files] {task_id}: course API returned {r.status_code}; trying GAIA dataset")
except requests.RequestException as exc:
print(f"[files] {task_id}: course API error: {exc}")
# 2) GAIA dataset: the file lives at <split dir>/<file_name>
try:
from huggingface_hub import hf_hub_download
for folder in GAIA_FILE_DIRS:
try:
local = hf_hub_download(
repo_id=GAIA_DATASET_ID,
filename=f"{folder}/{Path(file_name).name}",
repo_type="dataset",
token=HF_TOKEN or None,
)
shutil.copy(local, dest)
print(f"[files] {task_id}: fetched from GAIA dataset ({folder})")
return str(dest)
except Exception as exc: # try next split
last = exc
print(f"[files] {task_id}: GAIA dataset download failed: {last}")
except Exception as exc:
print(f"[files] {task_id}: huggingface_hub unavailable: {exc}")
return None
# ---------------------------------------------------------------------------
# Agent
# ---------------------------------------------------------------------------
SYSTEM_PROMPT = """You are an expert research agent solving GAIA benchmark questions. \
Answers are graded by EXACT MATCH, so correctness and format both matter.
How to work:
- Plan briefly, then use tools. Never answer facts from memory when they can be verified: use web_search, then fetch_url to read the actual page (snippets are not enough). Prefer Wikipedia, official sites, and primary sources. Use fetch_url's `find` argument to jump to relevant passages on long pages.
- Do ALL arithmetic, counting, sorting, filtering, date maths and table/spreadsheet analysis with run_python, never by eye. Attachments are saved locally; their path is given in the task. For Excel use pandas/openpyxl (openpyxl for cell colours/formatting).
- Python attachments: run them with run_python (e.g. subprocess or exec of the file) to get the real output rather than guessing.
- Questions with a YouTube link: call youtube_transcript first; for questions about what is visible on screen call youtube_frames. Audio attachments are already transcribed for you.
- If the question text looks reversed or encoded, decode it first (run_python can do this).
- Read constraints carefully (dates, 'as of', 'between', 'at least', which edition/version, rounding, units requested).
- If a source fails, try another query or source. Do not give up early, but do not loop endlessly; stop researching once you have verified evidence.
- Never claim to have viewed a file/page/video you could not access. If something is truly unobtainable, give your best-supported answer.
Final answer format (very important):
- End with a line: FINAL ANSWER: <answer>
- Number -> digits only, no commas, no units or % or $ unless the question demands them.
- String -> as few words as possible, no articles (a/an/the), no abbreviations unless asked, digits in plain form.
- List -> comma-separated, applying the above per element, in the order requested.
- No explanations, no markdown, nothing after the final answer line."""
TOOLS = [
{"type": "function", "function": {
"name": "web_search",
"description": "Search the web (DuckDuckGo, Wikipedia fallback). Returns titles, URLs and snippets. Follow up with fetch_url on promising results.",
"parameters": {"type": "object", "properties": {
"query": {"type": "string"}},
"required": ["query"]}}},
{"type": "function", "function": {
"name": "fetch_url",
"description": "Download a web page / PDF / text file and return readable text (tables as 'a | b' rows). Works for Wikipedia and web.archive.org URLs. Use `find` (keywords, case-insensitive) to get only matching passages from long pages, or `offset` to page through.",
"parameters": {"type": "object", "properties": {
"url": {"type": "string"},
"find": {"type": "string", "description": "Optional keyword/regex; returns passages around matches."},
"offset": {"type": "integer", "description": "Character offset to start from (default 0)."}},
"required": ["url"]}}},
{"type": "function", "function": {
"name": "run_python",
"description": "Execute Python 3 (pandas, openpyxl, numpy, requests available) in the task's working directory and return stdout/stderr. Always print() results. Use it for all calculation and for reading/processing attachments.",
"parameters": {"type": "object", "properties": {
"code": {"type": "string"}},
"required": ["code"]}}},
{"type": "function", "function": {
"name": "youtube_transcript",
"description": "Get the caption transcript (or, if none, a Whisper audio transcript) of a YouTube video.",
"parameters": {"type": "object", "properties": {
"url": {"type": "string"}},
"required": ["url"]}}},
{"type": "function", "function": {
"name": "youtube_frames",
"description": "Sample frames from a YouTube video and show them to you as images. Use for questions about what is visible. Keep num_frames small.",
"parameters": {"type": "object", "properties": {
"url": {"type": "string"},
"num_frames": {"type": "integer", "description": "1-12, default 8"},
"start_seconds": {"type": "number"},
"end_seconds": {"type": "number"}},
"required": ["url"]}}},
{"type": "function", "function": {
"name": "view_image",
"description": "Show a local image file or an image URL to you for visual inspection.",
"parameters": {"type": "object", "properties": {
"path_or_url": {"type": "string"}},
"required": ["path_or_url"]}}},
{"type": "function", "function": {
"name": "transcribe_audio",
"description": "Transcribe a local audio file (mp3/wav/m4a/...) with Whisper.",
"parameters": {"type": "object", "properties": {
"path": {"type": "string"}},
"required": ["path"]}}},
]
class GaiaAgent:
def __init__(self):
if not OPENAI_API_KEY:
raise ValueError(
"Missing OPENAI_API_KEY. Add it under Space Settings -> Variables and secrets."
)
self.client = OpenAI(api_key=OPENAI_API_KEY, base_url=OPENAI_BASE_URL,
timeout=120, max_retries=4)
self.http = requests.Session()
self.http.headers.update({"User-Agent": BROWSER_UA, "Accept-Language": "en-US,en;q=0.9"})
self.workdir = ""
self.pending_images: list[dict] = []
self.usage = {"prompt": 0, "completion": 0}
print(f"Initialized agent with model {OPENAI_MODEL}")
# ------------------------------ tools ---------------------------------
def web_search(self, query: str) -> str:
results = []
try:
from ddgs import DDGS
for attempt in range(2):
try:
results = list(DDGS().text(query, max_results=SEARCH_RESULTS) or [])
if results:
break
except Exception as exc:
print(f"[search] ddgs attempt {attempt + 1} failed: {exc}")
time.sleep(1.5)
except Exception as exc:
print(f"[search] ddgs unavailable: {exc}")
lines = []
for r in results:
lines.append(f"- {r.get('title', '')}\n URL: {r.get('href') or r.get('url', '')}\n"
f" {truncate(r.get('body', ''), 300)}")
# Wikipedia fallback / supplement (always cheap and reliable)
if len(results) < 3:
try:
resp = self.http.get(
"https://en.wikipedia.org/w/api.php",
params={"action": "query", "list": "search", "srsearch": query,
"format": "json", "srlimit": 5},
timeout=HTTP_TIMEOUT)
for item in resp.json().get("query", {}).get("search", []):
title = item["title"]
snippet = re.sub(r"<[^>]+>", "", item.get("snippet", ""))
url = "https://en.wikipedia.org/wiki/" + title.replace(" ", "_")
lines.append(f"- [Wikipedia] {title}\n URL: {url}\n {snippet}")
except Exception as exc:
print(f"[search] wikipedia fallback failed: {exc}")
return "\n".join(lines) if lines else "No results. Try a different query."
def fetch_url(self, url: str, find: str | None = None, offset: int = 0) -> str:
url = (url or "").strip()
if not url.startswith("http"):
return "Invalid URL."
try:
resp = self.http.get(url, timeout=HTTP_TIMEOUT)
resp.raise_for_status()
except Exception as exc:
return f"Fetch failed: {exc}"
ctype = resp.headers.get("content-type", "").lower()
try:
if "pdf" in ctype or url.lower().endswith(".pdf"):
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(resp.content))
text = "\n".join((p.extract_text() or "") for p in reader.pages[:60])
elif "html" in ctype or "<html" in resp.text[:500].lower():
text = html_to_text(resp.text)
elif any(t in ctype for t in ("text", "json", "xml", "csv")):
text = resp.text
else:
saved = Path(self.workdir or tempfile.gettempdir()) / (Path(url.split("?")[0]).name or "download.bin")
saved.write_bytes(resp.content)
return f"Binary content ({ctype}) saved to {saved}. Process it with run_python."
except Exception as exc:
return f"Could not parse page: {exc}"
if find:
try:
pattern = re.compile(find, re.IGNORECASE)
except re.error:
pattern = re.compile(re.escape(find), re.IGNORECASE)
spans, last_end = [], -1
for m in pattern.finditer(text):
if m.start() < last_end:
continue
a, b = max(0, m.start() - 350), min(len(text), m.end() + 650)
spans.append(text[a:b])
last_end = b
if len(spans) >= 8:
break
if spans:
return truncate("\n---\n".join(spans), FETCH_CHARS) + \
f"\n[page length {len(text)} chars]"
return f"No match for '{find}'. Page length {len(text)} chars; first part:\n" + \
truncate(text[:2000], 2000)
chunk = text[offset: offset + FETCH_CHARS]
more = len(text) - (offset + FETCH_CHARS)
suffix = f"\n[page length {len(text)} chars; next offset {offset + FETCH_CHARS}]" if more > 0 else ""
return chunk + suffix
def run_python(self, code: str) -> str:
env = {k: v for k, v in os.environ.items()
if k not in {"OPENAI_API_KEY", "HF_TOKEN", "HUGGING_FACE_HUB_TOKEN"}}
env["MPLBACKEND"] = "Agg"
script = Path(self.workdir) / "_snippet.py"
script.write_text(code, encoding="utf-8")
try:
proc = subprocess.run(
[sys.executable, str(script)], cwd=self.workdir, env=env,
capture_output=True, text=True, timeout=PY_TIMEOUT)
out = proc.stdout
if proc.stderr:
out += ("\n[stderr]\n" + proc.stderr[-1500:])
return truncate(out.strip() or "(no output - remember to print())", PY_OUTPUT_CHARS)
except subprocess.TimeoutExpired:
return f"Execution timed out after {PY_TIMEOUT}s."
except Exception as exc:
return f"Execution error: {exc}"
def transcribe_audio(self, path: str) -> str:
p = Path(path)
if not p.exists():
return f"File not found: {path}"
if p.stat().st_size > WHISPER_MAX_BYTES:
return "Audio file larger than 24MB; cannot transcribe."
try:
with open(p, "rb") as fh:
resp = self.client.audio.transcriptions.create(model=OPENAI_AUDIO_MODEL, file=fh)
return resp.text or "(empty transcript)"
except Exception as exc:
return f"Transcription failed: {exc}"
def view_image(self, path_or_url: str) -> str:
try:
if path_or_url.startswith("http"):
resp = self.http.get(path_or_url, timeout=HTTP_TIMEOUT)
resp.raise_for_status()
tmp = Path(self.workdir) / "_remote_image"
tmp.write_bytes(resp.content)
path = str(tmp)
else:
path = path_or_url
self.pending_images.append(
{"type": "image_url", "image_url": {"url": image_to_data_url(path), "detail": "high"}})
return "Image loaded; it is shown in the next message."
except Exception as exc:
return f"Could not load image: {exc}"
# ---- YouTube ----
def youtube_transcript(self, url: str) -> str:
vid = youtube_id(url)
if not vid:
return "Not a valid YouTube URL."
errors = []
# 1) youtube-transcript-api (handles old and new API shapes)
try:
from youtube_transcript_api import YouTubeTranscriptApi
segments = None
try:
api = YouTubeTranscriptApi()
fetched = api.fetch(vid, languages=["en", "en-US", "en-GB"])
segments = [getattr(s, "text", None) or s["text"] for s in fetched]
except AttributeError:
data = YouTubeTranscriptApi.get_transcript(vid, languages=["en", "en-US", "en-GB"])
segments = [s["text"] for s in data]
if segments:
return truncate("TRANSCRIPT:\n" + " ".join(segments), 12000)
except Exception as exc:
errors.append(f"transcript-api: {str(exc)[:200]}")
# 2) yt-dlp subtitles
try:
import yt_dlp
with tempfile.TemporaryDirectory() as tmp:
opts = {"skip_download": True, "writesubtitles": True, "writeautomaticsub": True,
"subtitleslangs": ["en", "en-US", "en-GB", "en.*"], "subtitlesformat": "vtt",
"outtmpl": str(Path(tmp) / "yt.%(ext)s"), "quiet": True,
"no_warnings": True, "noplaylist": True, "socket_timeout": 20}
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(url, download=True)
files = list(Path(tmp).glob("*.vtt"))
if files:
text = parse_vtt(files[0].read_text(encoding="utf-8", errors="replace"))
if text:
return truncate(f"TITLE: {(info or {}).get('title', '')}\nTRANSCRIPT:\n{text}", 12000)
except Exception as exc:
errors.append(f"yt-dlp subtitles: {str(exc)[:200]}")
# 3) audio -> Whisper (short videos only)
try:
import yt_dlp
with tempfile.TemporaryDirectory() as tmp:
opts = {"format": "bestaudio[filesize<24M]/worstaudio/worst",
"outtmpl": str(Path(tmp) / "audio.%(ext)s"), "quiet": True,
"no_warnings": True, "noplaylist": True, "socket_timeout": 20,
"max_filesize": WHISPER_MAX_BYTES}
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(url, download=True)
if (info or {}).get("duration", 0) and info["duration"] > 1500:
errors.append("video longer than 25 minutes; audio transcription skipped")
else:
files = [p for p in Path(tmp).glob("audio.*")]
if files:
text = self.transcribe_audio(str(files[0]))
return truncate("WHISPER TRANSCRIPT (no captions available):\n" + text, 12000)
except Exception as exc:
errors.append(f"audio download: {str(exc)[:200]}")
return ("Could not retrieve transcript. Reasons: " + " | ".join(errors) +
". Try web_search for a transcript/summary of this video instead.")
def youtube_frames(self, url: str, num_frames: int = 8,
start_seconds: float | None = None, end_seconds: float | None = None) -> str:
num_frames = max(1, min(int(num_frames or 8), 12))
try:
import cv2
import yt_dlp
from PIL import Image
except Exception as exc:
return f"Frame extraction dependencies missing: {exc}"
try:
with tempfile.TemporaryDirectory() as tmp:
opts = {"format": "worst[ext=mp4]/worst[height>=240]/worst",
"outtmpl": str(Path(tmp) / "video.%(ext)s"), "quiet": True,
"no_warnings": True, "noplaylist": True, "socket_timeout": 25,
"max_filesize": 120 * 1024 * 1024}
with yt_dlp.YoutubeDL(opts) as ydl:
ydl.download([url])
vids = [p for p in Path(tmp).glob("video.*") if p.suffix.lower() in {".mp4", ".mkv", ".webm"}]
if not vids:
return "Video download failed (no file produced)."
cap = cv2.VideoCapture(str(vids[0]))
fps = cap.get(cv2.CAP_PROP_FPS) or 25
total = cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0
duration = total / fps if fps else 0
if duration <= 0:
return "Could not read video duration."
t0 = max(0.0, start_seconds or 0.0)
t1 = min(duration, end_seconds if end_seconds else duration)
added = 0
for i in range(num_frames):
t = t0 + (t1 - t0) * (i + 0.5) / num_frames
cap.set(cv2.CAP_PROP_POS_MSEC, t * 1000)
ok, frame = cap.read()
if not ok:
continue
img = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
img.thumbnail((768, 768))
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=80)
self.pending_images.append({"type": "image_url", "image_url": {
"url": "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode(),
"detail": "auto"}})
added += 1
cap.release()
if not added:
return "No frames could be decoded."
return (f"{added} frames sampled between {t0:.0f}s and {t1:.0f}s "
f"(video length {duration:.0f}s); shown in the next message.")
except Exception as exc:
return f"Frame extraction failed: {str(exc)[:300]}"
def dispatch(self, name: str, args: dict) -> str:
try:
if name == "web_search":
return self.web_search(args.get("query", ""))
if name == "fetch_url":
return self.fetch_url(args.get("url", ""), args.get("find"), int(args.get("offset") or 0))
if name == "run_python":
return self.run_python(args.get("code", ""))
if name == "youtube_transcript":
return self.youtube_transcript(args.get("url", ""))
if name == "youtube_frames":
return self.youtube_frames(args.get("url", ""), args.get("num_frames", 8),
args.get("start_seconds"), args.get("end_seconds"))
if name == "view_image":
return self.view_image(args.get("path_or_url", ""))
if name == "transcribe_audio":
return self.transcribe_audio(args.get("path", ""))
return f"Unknown tool: {name}"
except Exception as exc:
return f"Tool {name} crashed: {exc}"
# ------------------------- attachment preview --------------------------
def prepare_attachment(self, task_id: str, file_name: str) -> tuple[str, list[dict]]:
"""Return (description for the prompt, images to attach)."""
if not file_name:
return "", []
path = download_attachment(task_id, file_name, self.workdir)
if not path:
return (f"ATTACHMENT: '{file_name}' exists for this task but could NOT be downloaded "
"(course API and GAIA dataset both failed). Say so honestly if the answer "
"depends on it; otherwise answer from other evidence."), []
ext = Path(path).suffix.lower()
header = f"ATTACHMENT saved at: {path} (type {ext or 'unknown'}, {Path(path).stat().st_size} bytes)\n"
images: list[dict] = []
try:
if ext in IMAGE_EXT:
images.append({"type": "image_url",
"image_url": {"url": image_to_data_url(path), "detail": "high"}})
return header + "The image is attached to this message.", images
if ext in {".mp3", ".wav", ".m4a", ".mpga", ".mpeg", ".ogg", ".flac"}:
return header + "AUDIO TRANSCRIPT (Whisper):\n" + self.transcribe_audio(path), images
if ext in {".xlsx", ".xlsm", ".xls"}:
sheets = pd.read_excel(path, sheet_name=None, header=None)
parts = []
for name, frame in sheets.items():
parts.append(f"SHEET '{name}' shape={frame.shape}\n{truncate(frame.head(25).to_csv(index=False, header=False), 2500)}")
return header + "Spreadsheet preview (first rows, no header inference). " \
"Use run_python with pandas/openpyxl for the full data:\n" + "\n".join(parts), images
if ext == ".py":
code = Path(path).read_text(encoding="utf-8", errors="replace")
return header + "PYTHON SOURCE (execute it with run_python to get real output):\n" + truncate(code, 5000), images
if ext in {".csv", ".tsv"}:
return header + "File head:\n" + truncate(Path(path).read_text(encoding="utf-8", errors="replace"), 2500), images
if ext in TEXT_EXT:
return header + "CONTENT:\n" + truncate(Path(path).read_text(encoding="utf-8", errors="replace"), 6000), images
if ext == ".pdf":
from pypdf import PdfReader
text = "\n".join((p.extract_text() or "") for p in PdfReader(path).pages[:30])
return header + "PDF TEXT:\n" + truncate(text, 6000), images
if ext == ".docx":
from docx import Document
doc = Document(path)
lines = [p.text for p in doc.paragraphs if p.text.strip()]
for t in doc.tables:
for row in t.rows:
lines.append(" | ".join(c.text for c in row.cells))
return header + "DOCX TEXT:\n" + truncate("\n".join(lines), 6000), images
if ext == ".pptx":
from pptx import Presentation
out = []
for i, slide in enumerate(Presentation(path).slides, 1):
texts = [s.text for s in slide.shapes if getattr(s, "has_text_frame", False)]
out.append(f"SLIDE {i}: " + " / ".join(t for t in texts if t))
return header + truncate("\n".join(out), 6000), images
if ext == ".zip":
import zipfile
with zipfile.ZipFile(path) as z:
z.extractall(self.workdir)
return header + "ZIP extracted into the working directory. Files: " + ", ".join(z.namelist()[:40]), images
if ext in {".mp4", ".mov", ".mkv"}:
return header + "Video file; use run_python (cv2) to inspect frames if needed.", images
except Exception as exc:
return header + f"(Preview failed: {exc}. Inspect it with run_python.)", images
return header + "Unknown format; inspect it with run_python.", images
# ------------------------------ main loop ------------------------------
def _chat(self, messages: list[dict], allow_tools: bool = True):
kwargs: dict[str, Any] = dict(model=OPENAI_MODEL, temperature=0,
max_tokens=MAX_COMPLETION_TOKENS, messages=messages)
if allow_tools:
kwargs.update(tools=TOOLS, tool_choice="auto")
resp = self.client.chat.completions.create(**kwargs)
if resp.usage:
self.usage["prompt"] += resp.usage.prompt_tokens
self.usage["completion"] += resp.usage.completion_tokens
return resp.choices[0].message
def __call__(self, task: dict) -> str:
task_id = str(task.get("task_id", ""))
question = str(task.get("question", ""))
file_name = str(task.get("file_name") or "")
print(f"\n=== Task {task_id} | attachment={file_name or 'none'}")
self.workdir = tempfile.mkdtemp(prefix=f"gaia_{task_id[:8]}_")
self.pending_images = []
try:
attachment_text, attachment_images = self.prepare_attachment(task_id, file_name)
text = f"QUESTION:\n{question}"
if attachment_text:
text += f"\n\n{attachment_text}"
user_content: Any = [{"type": "text", "text": text}] + attachment_images \
if attachment_images else text
messages: list[dict] = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_content},
]
final_text = ""
for step in range(MAX_STEPS):
msg = self._chat(messages)
if not msg.tool_calls:
final_text = msg.content or ""
break
messages.append({
"role": "assistant", "content": msg.content or "",
"tool_calls": [{"id": tc.id, "type": "function",
"function": {"name": tc.function.name,
"arguments": tc.function.arguments}}
for tc in msg.tool_calls]})
for tc in msg.tool_calls:
try:
args = json.loads(tc.function.arguments or "{}")
except json.JSONDecodeError:
args = {}
print(f"[step {step + 1}] {tc.function.name}({truncate(json.dumps(args), 160)})")
result = self.dispatch(tc.function.name, args)
messages.append({"role": "tool", "tool_call_id": tc.id,
"content": truncate(result, 12500)})
if self.pending_images:
messages.append({"role": "user", "content": [
{"type": "text", "text": "Images requested by your tool call:"},
*self.pending_images[:12]]})
self.pending_images = []
else:
# Out of steps: force a final answer from what has been gathered.
messages.append({"role": "user", "content":
"Step limit reached. Give your best-supported answer now as "
"'FINAL ANSWER: ...' with no further tool use."})
final_text = self._chat(messages, allow_tools=False).content or ""
answer = clean_answer(final_text)
print(f"Answer [{task_id}]: {answer} | tokens so far: {self.usage}")
return answer
finally:
shutil.rmtree(self.workdir, ignore_errors=True)
# ---------------------------------------------------------------------------
# Evaluation and submission (official course workflow)
# ---------------------------------------------------------------------------
def run_and_submit_all(profile: gr.OAuthProfile | None, progress=gr.Progress()):
if not profile:
return "Please log in to Hugging Face with the button.", None
username = str(profile.username).strip()
print(f"User logged in: {username}")
space_id = os.getenv("SPACE_ID", "")
agent_code = f"https://huggingface.co/spaces/{space_id}/tree/main" if space_id else ""
try:
agent = GaiaAgent()
except Exception as exc:
return f"Error initializing agent: {exc}", None
try:
resp = requests.get(f"{SCORING_API}/questions", timeout=HTTP_TIMEOUT)
resp.raise_for_status()
questions = resp.json()
if not isinstance(questions, list) or not questions:
return "Fetched questions list is empty or invalid.", None
print(f"Fetched {len(questions)} questions.")
except Exception as exc:
return f"Error fetching questions: {exc}", None
results_log, answers_payload = [], []
total = len(questions)
for index, item in enumerate(questions, start=1):
task_id = item.get("task_id")
question_text = item.get("question")
if not task_id or question_text is None:
continue
progress((index - 1) / total, desc=f"Solving question {index}/{total}")
try:
answer = agent(item) or "unknown"
except Exception as exc:
print(f"Task {task_id} failed: {exc}")
answer = "unknown"
answers_payload.append({"task_id": task_id, "submitted_answer": answer})
results_log.append({"Task ID": task_id, "Question": question_text,
"Attachment": item.get("file_name", ""),
"Submitted Answer": answer})
results_df = pd.DataFrame(results_log)
if not answers_payload:
return "Agent did not produce any answers to submit.", results_df
print(f"Total tokens used: {agent.usage}")
submission = {"username": username, "agent_code": agent_code, "answers": answers_payload}
progress(0.97, desc="Submitting answers")
try:
resp = requests.post(f"{SCORING_API}/submit", json=submission, timeout=120)
resp.raise_for_status()
data = resp.json()
status = (
"Submission Successful!\n"
f"User: {data.get('username')}\n"
f"Overall Score: {data.get('score', 'N/A')}% "
f"({data.get('correct_count', '?')}/{data.get('total_attempted', '?')} correct)\n"
f"Message: {data.get('message', 'No message received.')}\n"
f"OpenAI tokens this run: {agent.usage['prompt']} in / {agent.usage['completion']} out"
)
return status, results_df
except requests.exceptions.HTTPError as exc:
detail = exc.response.text[:500] if exc.response is not None else ""
return f"Submission Failed: HTTP {getattr(exc.response, 'status_code', '?')}: {detail}", results_df
except Exception as exc:
return f"Submission Failed: {exc}", results_df
# ---------------------------------------------------------------------------
# Gradio UI
# ---------------------------------------------------------------------------
with gr.Blocks() as demo:
gr.Markdown("# GAIA Final Assignment - GPT-4o Tool-Calling Agent")
gr.Markdown(
"""
**Instructions**
1. Add `OPENAI_API_KEY` (required) and `HF_TOKEN` (recommended, for GAIA attachment fallback) as Space secrets.
2. Log in with your Hugging Face account below.
3. Click **Run Evaluation & Submit All Answers**. The run takes several minutes.
The agent searches the web, reads pages, runs Python, and handles audio, images, YouTube and spreadsheets.
Keep this Space public so `agent_code` can be verified.
"""
)
gr.LoginButton()
run_button = gr.Button("Run Evaluation & Submit All Answers", variant="primary")
status_output = gr.Textbox(label="Run Status / Submission Result", lines=7, 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(f"Starting GAIA agent. Model: {OPENAI_MODEL}. Space: {os.getenv('SPACE_ID', 'local')}")
demo.launch(debug=True, share=False) |