AndreasG10's picture
Upload 4 files
5b88a66 verified
Raw History Blame Contribute Delete
12.9 kB
"""
GAIA agent for the Hugging Face Agents Course (Unit 4 final assignment).
Default: local Ollama (same as your original setup). Configure with env vars:
LLM_BACKEND "ollama" (default) | "hf" (HF Inference, needs HF_TOKEN) | "litellm"
LLM_MODEL_ID default for ollama: ollama_chat/qwen2.5:7b
LLM_API_BASE default for ollama: http://localhost:11434
LLM_API_KEY only for "litellm"
NUM_CTX Ollama context window (default 8192)
MAX_PAGE_CHARS max characters a web tool returns (default 5000, keeps the 8k context safe)
"""
import os
import re
import tempfile
from pathlib import Path
from urllib.parse import urlparse, parse_qs
import pandas as pd
import requests
from bs4 import BeautifulSoup
from smolagents import (
CodeAgent,
DuckDuckGoSearchTool,
InferenceClientModel,
LiteLLMModel,
tool,
)
MAX_PAGE_CHARS = int(os.getenv("MAX_PAGE_CHARS", "5000"))
HEADERS = {"User-Agent": "Mozilla/5.0 (GAIA course agent; educational use)"}
# --------------------------------------------------------------------------- #
# Model
# --------------------------------------------------------------------------- #
def build_model():
backend = os.getenv("LLM_BACKEND", "ollama").lower()
if backend == "ollama":
return LiteLLMModel(
model_id=os.getenv("LLM_MODEL_ID", "ollama_chat/qwen2.5:7b"),
api_base=os.getenv("LLM_API_BASE", "http://localhost:11434"),
api_key="ollama",
num_ctx=int(os.getenv("NUM_CTX", "8192")),
)
if backend == "litellm":
kwargs = {"model_id": os.environ["LLM_MODEL_ID"]}
if os.getenv("LLM_API_KEY"):
kwargs["api_key"] = os.getenv("LLM_API_KEY")
if os.getenv("LLM_API_BASE"):
kwargs["api_base"] = os.getenv("LLM_API_BASE")
return LiteLLMModel(**kwargs)
token = os.getenv("HF_TOKEN")
if not token:
raise RuntimeError("HF_TOKEN is not set (needed for LLM_BACKEND=hf).")
return InferenceClientModel(
model_id=os.getenv("LLM_MODEL_ID", "Qwen/Qwen2.5-72B-Instruct"), token=token
)
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _page_text(html: str) -> str:
soup = BeautifulSoup(html, "html.parser")
for tag in soup(["script", "style", "nav", "footer", "header", "aside"]):
tag.decompose()
return soup.get_text(separator="\n", strip=True)
def download_task_file(api_url: str, task_id: str, file_name: str | None = None) -> str | None:
"""Download the file attached to a task (if any). Returns a local path or None."""
try:
r = requests.get(f"{api_url}/files/{task_id}", timeout=30)
if r.status_code == 404:
return None
r.raise_for_status()
except requests.RequestException as e:
print(f"Could not download file for task {task_id}: {e}")
return None
if not file_name:
cd = r.headers.get("content-disposition", "")
m = re.search(r'filename="?([^";]+)"?', cd)
file_name = m.group(1) if m else task_id
out_dir = Path(tempfile.gettempdir()) / "gaia_files" / task_id
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / Path(file_name).name
path.write_bytes(r.content)
return str(path)
def clean_answer(text: str) -> str:
"""The scorer is EXACT MATCH, so strip anything that is not the answer."""
t = str(text).strip()
# Never submit the literal "FINAL ANSWER" marker (course requirement).
t = re.sub(r"(?is)^.*?final answer\s*[:\-]\s*", "", t).strip()
# Remove trailing sentence periods, then wrapping quotes.
t = t.rstrip(".").strip()
if len(t) >= 2 and t[0] == t[-1] and t[0] in "\"'":
t = t[1:-1].strip()
return t
# --------------------------------------------------------------------------- #
# Tools
# --------------------------------------------------------------------------- #
@tool
def visit_webpage(url: str) -> str:
"""
Read the textual content of a public webpage.
Args:
url: The URL of the webpage to retrieve.
"""
try:
r = requests.get(url, timeout=20, headers=HEADERS)
r.raise_for_status()
except requests.RequestException as e:
return f"Error fetching {url}: {e}"
return _page_text(r.text)[:MAX_PAGE_CHARS]
@tool
def extract_webpage_section(url: str, keyword: str) -> str:
"""
Retrieve a webpage and return the text around a keyword (useful for long pages).
Args:
url: URL of the webpage.
keyword: Section title or phrase to search for.
"""
try:
r = requests.get(url, timeout=20, headers=HEADERS)
r.raise_for_status()
except requests.RequestException as e:
return f"Error fetching {url}: {e}"
text = _page_text(r.text)
pos = text.lower().find(keyword.lower())
if pos == -1:
return "Keyword not found."
start = max(0, pos - 300)
return text[start : start + MAX_PAGE_CHARS]
@tool
def wikipedia_search(query: str) -> str:
"""
Search English Wikipedia and return the titles and snippets of the best matches.
Args:
query: What to search for.
"""
try:
r = requests.get(
"https://en.wikipedia.org/w/api.php",
params={
"action": "query",
"list": "search",
"srsearch": query,
"srlimit": 5,
"format": "json",
},
headers=HEADERS,
timeout=20,
)
r.raise_for_status()
hits = r.json()["query"]["search"]
except Exception as e:
return f"Wikipedia search failed: {e}"
if not hits:
return "No results."
return "\n".join(
f"- {h['title']}: {BeautifulSoup(h['snippet'], 'html.parser').get_text()}"
for h in hits
)
@tool
def wikipedia_page(title: str, section_keyword: str = "") -> str:
"""
Get the plain text of an English Wikipedia article (tables included as text).
Args:
title: Exact article title, e.g. "Mercedes Sosa".
section_keyword: Optional word/phrase (e.g. "Discography") - if given, return the text around it.
"""
try:
r = requests.get(
f"https://en.wikipedia.org/wiki/{title.replace(' ', '_')}",
headers=HEADERS,
timeout=20,
)
r.raise_for_status()
except requests.RequestException as e:
return f"Error fetching article '{title}': {e}"
soup = BeautifulSoup(r.text, "html.parser")
content = soup.find(id="mw-content-text") or soup
for tag in content(["script", "style", "sup"]) + content.select(".navbox, .reflist"):
tag.decompose()
text = content.get_text(separator="\n", strip=True)
if section_keyword:
pos = text.lower().find(section_keyword.lower())
if pos != -1:
return text[max(0, pos - 200) : pos + MAX_PAGE_CHARS]
return text[:MAX_PAGE_CHARS]
@tool
def read_file(path: str) -> str:
"""
Read a file attached to the task. Supports text/code/json/csv/xlsx files.
For audio use transcribe_audio, for images use describe_image.
Args:
path: Local path of the file.
"""
p = Path(path)
if not p.exists():
return f"File not found: {path}"
ext = p.suffix.lower()
try:
if ext in (".xlsx", ".xls"):
sheets = pd.read_excel(p, sheet_name=None)
return "\n\n".join(
f"## Sheet: {name}\n{df.to_string()}" for name, df in sheets.items()
)[:20000]
if ext == ".csv":
return pd.read_csv(p).to_string()[:20000]
return p.read_text(errors="replace")[:20000]
except Exception as e:
return f"Could not read {path}: {e}"
@tool
def transcribe_audio(path: str) -> str:
"""
Transcribe a local audio file (mp3, wav, ...) to text.
Args:
path: Local path of the audio file.
"""
try:
from huggingface_hub import InferenceClient
client = InferenceClient(token=os.getenv("HF_TOKEN"))
out = client.automatic_speech_recognition(
path, model=os.getenv("ASR_MODEL_ID", "openai/whisper-large-v3")
)
return out.text
except Exception as e:
return f"Transcription failed: {e}"
@tool
def describe_image(path: str, question: str) -> str:
"""
Ask a vision model a question about a local image file.
Args:
path: Local path of the image.
question: What you want to know about the image.
"""
try:
import base64
import mimetypes
from huggingface_hub import InferenceClient
mime = mimetypes.guess_type(path)[0] or "image/png"
b64 = base64.b64encode(Path(path).read_bytes()).decode()
client = InferenceClient(token=os.getenv("HF_TOKEN"))
resp = client.chat_completion(
model=os.getenv("VISION_MODEL_ID", "Qwen/Qwen2.5-VL-72B-Instruct"),
messages=[
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}},
{"type": "text", "text": question},
],
}
],
max_tokens=500,
)
return resp.choices[0].message.content
except Exception as e:
return f"Image analysis failed: {e}"
@tool
def youtube_transcript(url: str) -> str:
"""
Get the transcript (captions) of a YouTube video.
Args:
url: The YouTube video URL.
"""
try:
from youtube_transcript_api import YouTubeTranscriptApi
parsed = urlparse(url)
video_id = parse_qs(parsed.query).get("v", [None])[0] or parsed.path.strip("/").split("/")[-1]
segments = YouTubeTranscriptApi().fetch(video_id)
return " ".join(s.text for s in segments)[:MAX_PAGE_CHARS * 2]
except Exception as e:
return f"Could not get transcript: {e}"
# --------------------------------------------------------------------------- #
# Agent
# --------------------------------------------------------------------------- #
INSTRUCTIONS = """
You are solving questions from the GAIA benchmark. Answers are graded by EXACT MATCH.
Method:
- Work out exactly what is asked and which source/date/category it specifies; use that source.
- Use the tools: web search, Wikipedia tools, visit_webpage, and for attached files
read_file / transcribe_audio / describe_image. Use python for any counting or arithmetic
instead of doing it in your head.
- For counting or list questions, enumerate every candidate first, check each against ALL
constraints (e.g. studio albums only, date ranges inclusive/exclusive), then count.
- Never invent facts. If one source fails, try another.
Final answer format (very important):
- Call final_answer(...) with ONLY the answer - no explanation, no sentence, no "FINAL ANSWER".
- Numbers: plain digits, no thousands separators, no units or % unless the question asks.
- Strings: no articles (a/an/the) and no abbreviations unless asked; digits written as digits.
- Lists: comma-separated, in the order requested, following the same rules per element.
""".strip()
class BasicAgent:
def __init__(self):
self.model = build_model()
self.agent = CodeAgent(
tools=[
DuckDuckGoSearchTool(max_results=5),
wikipedia_search,
wikipedia_page,
visit_webpage,
extract_webpage_section,
read_file,
transcribe_audio,
describe_image,
youtube_transcript,
],
model=self.model,
instructions=INSTRUCTIONS,
additional_authorized_imports=[
"re", "math", "statistics", "itertools", "collections",
"datetime", "json", "csv", "pandas", "numpy", "unicodedata",
],
max_steps=10,
verbosity_level=1,
)
def __call__(self, question: str, file_path: str | None = None) -> str:
task = question
if file_path:
task += (
f"\n\nAn attached file for this question is available locally at: {file_path}"
"\nOpen it with the appropriate tool (read_file, transcribe_audio, describe_image)."
)
last_error = None
for _ in range(2): # one retry for transient API / search failures
try:
return clean_answer(self.agent.run(task))
except Exception as e: # noqa: BLE001
last_error = e
print(f"Agent attempt failed: {e}")
raise last_error