KPatelis commited on
Commit
bdc5edd
·
verified ·
1 Parent(s): 3b7ebef

Upload 29 files

Browse files
README.md CHANGED
@@ -1,131 +1,133 @@
1
- ---
2
- title: Template Final Assignment
3
- emoji: 🕵🏻‍♂️
4
- colorFrom: indigo
5
- colorTo: indigo
6
- sdk: gradio
7
- sdk_version: 6.14.0
8
- app_file: app.py
9
- pinned: false
10
- hf_oauth: true
11
- hf_oauth_expiration_minutes: 480
12
- ---
13
-
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
15
-
16
-
17
- # 🌍 GAIA Benchmark Agent
18
-
19
- **An Advanced Multi-Modal AI Agent designed to solve complex, real-world reasoning tasks.**
20
-
21
- ![Python](https://img.shields.io/badge/Python-3.13-blue.svg)
22
- ![LangGraph](https://img.shields.io/badge/LangGraph-Orchestration-orange.svg)
23
- ![HuggingFace](https://img.shields.io/badge/HuggingFace-Models-yellow.svg)
24
- ![Supabase](https://img.shields.io/badge/Supabase-Vector_Store-green.svg)
25
-
26
- > [!NOTE]
27
- > This project was developed as part of the **Hugging Face Agents Course (Unit 4: GAIA)**.
28
-
29
- ## 📖 Overview
30
-
31
- This project implements a sophisticated autonomous agent capable of solving General AI Assistants (GAIA) benchmark problems. These problems require multi-step reasoning, tool usage, and the ability to process diverse file types (documents, spreadsheets, audio, images, code).
32
-
33
- The agent leverages **LangGraph** for orchestration, allowing it to maintain state, plan its actions, and iteratively refine its answers. It integrates with **Hugging Face** for powerful LLM inference (`Qwen/Qwen3-32B-Instruct`) and **Supabase** for Retrieval-Augmented Generation (RAG) to learn from similar past examples.
34
-
35
- ## 🚀 Key Features
36
-
37
- - **🧠 Advanced Reasoning Loop**: Uses a "Plan-Execute-Observe-Refine" Chain-of-Thought approach to tackle complex questions.
38
- - **📂 Multi-Modal File Processing**: Native support for analyzing a wide range of files:
39
- - **Documents**: PDF, Word (`.docx`), PowerPoint (`.pptx`), Text
40
- - **Data**: Excel (`.xlsx`), CSV (`.csv`), JSON-LD, PDB (Protein Data Bank)
41
- - **Media**: Audio transcription (Whisper), Intelligent Image Analysis (`Qwen3-VL`)
42
- - **Code**: Python source code reading
43
- - **Archives**: ZIP extraction and inspection
44
- - **🔍 Intelligent Information Retrieval**:
45
- - **RAG**: Finds similar solved questions in a vector database to guide complex reasoning.
46
- - **Web Search**: DuckDuckGo, Tavily, Wikipedia, and ArXiv for real-time information.
47
- - **🛠️ Extensible Tool Suite**: Modular design allows easy addition of new capabilities.
48
-
49
- ## 🏗️ Architecture
50
-
51
- The agent operates on a graph-based workflow defined in `agent.py`:
52
-
53
- ```mermaid
54
- graph TD
55
- START --> Retriever["Retriever Node<br/>(Hybrid: Vector + BM25 + RRF)"]
56
- Retriever --> Reranker["Reranker Node<br/>(ModernBERT Cross-Encoder)"]
57
- Reranker --> Processor["Processor Node<br/>(Qwen 3 32B)"]
58
- Processor -->|Decide Tool| Condition{"Requires Tool?"}
59
- Condition -->|Yes| Tools["Tool Node<br/>(Execute Actions)"]
60
- Condition -->|No| END
61
- Tools --> Processor
62
- ```
63
-
64
- 1. **Retriever Node (Hybrid)**:
65
- - **Vector Search**: Finds semantically similar questions in Supabase (using [`Alibaba-NLP/gte-modernbert-base`](https://huggingface.co/Alibaba-NLP/gte-modernbert-base) embeddings).
66
- - **BM25 Search**: Finds keyword-based matches in the local `metadata.jsonl` corpus using [`bm25s`](https://github.com/xhluca/bm25s).
67
- - **RRF Fusion**: Combines results from both methods using Reciprocal Rank Fusion.
68
- 2. **Reranker Node**: Uses a ModernBERT Cross-Encoder ([`Alibaba-NLP/gte-reranker-modernbert-base`](https://huggingface.co/Alibaba-NLP/gte-reranker-modernbert-base)) to select the top 3 most relevant examples.
69
- 3. **Processor Node**: The core brain (Qwen 3 32B). It decides whether to answer directly or use a tool.
70
- 4. **Tool Node**: Executes the requested tool (e.g., `read_excel`, `duck_web_search`) and returns results.
71
-
72
- ## 🛠️ Tools & Stack
73
-
74
- | Category | Tools / Libraries | Purpose |
75
- | :--- | :--- | :--- |
76
- | **Orchestration** | `langgraph`, `langchain` | State management and graph flow control. |
77
- | **LLM Inference** | `huggingface_hub` | Inference via `Qwen/Qwen3-32B-Instruct` and `Qwen/Qwen3-VL-32B-Instruct`. |
78
- | **Vector Store** | `supabase`, `sentence-transformers` | Storing and retrieving semantic embeddings. |
79
- | **Data Processing** | `polars`, `biopython` | High-performance data manipulation. |
80
- | **Documents** | `pypdf`, `python-docx`, `python-pptx` | Extracting text from office documents. |
81
- | **Media** | `transformers` (Whisper), `pillow` | Audio transcription and image handling. |
82
- | **Web** | `duckduckgo-search`, `tavily-python` | Internet research. |
83
-
84
- ## 💻 Installation & Setup
85
-
86
- 1. **Clone the repository**:
87
-
88
- ```bash
89
- git clone <repo_url>
90
- cd <repo_name>
91
- ```
92
-
93
- 2. **Install dependencies**:
94
-
95
- ```bash
96
- pip install -r requirements.txt
97
- # OR using uv
98
- uv sync
99
- ```
100
-
101
- 3. **Configure Environment**:
102
- Create a `.env` file in the root directory with the following keys:
103
-
104
- ```ini
105
- HF_INFERENCE_KEY=hf_... # Hugging Face Inference Token
106
- SUPABASE_URL=... # Supabase Project URL
107
- SUPABASE_SERVICE_KEY=... # Supabase Service Role Key
108
- TAVILY_API_KEY=tvly-... # Tavily Search API Key (Optional)
109
- ```
110
-
111
- ## 🎮 Usage
112
-
113
- Start the Gradio interface to interact with the agent:
114
-
115
- ```bash
116
- python app.py
117
- ```
118
-
119
- This will launch a web interface where you can:
120
-
121
- 1. **Log in** with your Hugging Face account.
122
- 2. **Run Evaluation**: Automatically fetch GAIA questions, execute the agent, and submit answers for scoring.
123
- 3. **Inspect Results**: View the agent's reasoning, tool outputs, and final answers in real-time.
124
-
125
- ## 📂 Project Structure
126
-
127
- - `agent.py`: Core logic defining the LangGraph workflow and state.
128
- - `app.py`: Gradio application for the user interface and evaluation runner.
129
- - `tools.py`: Consolidated file containing all agent tools (web, math, file processing, VLM).
130
- - `prompts/`: Directory containing system prompts (`prompt.yaml`, `vlm_prompt.yaml`).
131
- - `requirements.txt`: Project dependencies.
 
 
 
1
+ # 🌍 GAIA Benchmark Agent
2
+
3
+ **An autonomous, multi-modal agent that tackles the GAIA reasoning benchmark.**
4
+
5
+ ![Python](https://img.shields.io/badge/Python-3.13-blue.svg)
6
+ ![LangGraph](https://img.shields.io/badge/LangGraph-Orchestration-orange.svg)
7
+ ![HuggingFace](https://img.shields.io/badge/HuggingFace-Inference-yellow.svg)
8
+ ![Supabase](https://img.shields.io/badge/Supabase-Vector_Store-green.svg)
9
+
10
+ > [!NOTE]
11
+ > Developed as part of the **Hugging Face Agents Course (Unit 4: GAIA)**.
12
+
13
+ ## 📖 Overview
14
+
15
+ GAIA tasks require multi-step reasoning, tool use, and the ability to process diverse file types (documents, spreadsheets, audio, images, code). This agent uses a **LangGraph** state machine to plan, retrieve few-shot context, call tools, and produce a strictly-formatted answer.
16
+
17
+ The solver model is **`Qwen/Qwen3-32B`** via Hugging Face Inference Providers; the formatter (separate node) reuses the same model bound to a single `emit_final_answer` tool to enforce the strict GAIA output contract. A Supabase + BM25 hybrid retriever surfaces similar past tasks as few-shot exemplars.
18
+
19
+ ## 🚀 Key Features
20
+
21
+ - **🧠 Plan-Execute-Observe-Refine loop** driven by LangGraph.
22
+ - **🪜 Two-stage output**: a solver node reasons freely, then a dedicated formatter node returns the GAIA-compliant answer via a Pydantic-shaped tool call — eliminating regex parsing of the LLM's free text.
23
+ - **📂 Multi-modal file processing**:
24
+ - **Documents**: PDF, Word (`.docx`), PowerPoint (`.pptx`), Text
25
+ - **Data**: Excel (`.xlsx`, multi-sheet), CSV, JSON-LD, PDB (Protein Data Bank)
26
+ - **Media**: Audio transcription (`openai/whisper-large-v3` via HF), image analysis (`Qwen/Qwen3-VL-32B-Instruct`)
27
+ - **Code**: Python source files (read & execute)
28
+ - **Archives**: ZIP extraction + inspection
29
+ - **🔍 Hybrid RAG**: Vector search (Supabase RPC over `Alibaba-NLP/gte-modernbert-base`) **+** BM25 over a local 165-question corpus, fused with Reciprocal Rank Fusion, then reranked with a ModernBERT cross-encoder.
30
+ - **🌐 Web tooling**: DuckDuckGo, Tavily, Wikipedia, ArXiv, full-page extraction (Trafilatura), YouTube transcripts.
31
+ - **🛠️ Modular tool layout**: tools are organised by domain in `gaia/tools/` (basic, web, files, media, dispatcher) so adding a new capability is a single-file change.
32
+
33
+ ## 🏗️ Architecture
34
+
35
+ ```mermaid
36
+ graph TD
37
+ START --> FileDL["File Downloader<br/>(fetches /files/{task_id})"]
38
+ FileDL --> Retriever["Retriever<br/>(Vector + BM25 + RRF)"]
39
+ Retriever --> Reranker["Reranker<br/>(ModernBERT cross-encoder)"]
40
+ Reranker --> Processor["Solver<br/>(Qwen3-32B + tools)"]
41
+ Processor -->|tool call| Tools["Tool Node"]
42
+ Tools --> Processor
43
+ Processor -->|done| Formatter["Formatter<br/>(emit_final_answer tool)"]
44
+ Formatter --> END
45
+ ```
46
+
47
+ | Node | Role |
48
+ | :--- | :--- |
49
+ | **`file_downloader_node`** | If the question has an associated file, download from `{api.base_url}/files/{task_id}` and cache on disk under `data/task_files/{task_id}/`. |
50
+ | **`retriever_node`** | Hybrid search: Supabase vector RPC + local BM25 over `data/metadata.jsonl`, fused with RRF. Returns up to 20 candidate task IDs. |
51
+ | **`reranker_node`** | `Alibaba-NLP/gte-reranker-modernbert-base` re-scores candidates and injects the top-K as few-shot examples (Question + Final Answer + Solution Steps). |
52
+ | **`processor_node`** | Qwen3-32B with all tools bound. Reasons, calls tools, loops until satisfied. |
53
+ | **`tools`** | LangGraph `ToolNode` executing the chosen tool, then returning control to the processor. |
54
+ | **`formatter_node`** | A second Qwen3-32B call bound to a single `emit_final_answer(answer: str)` tool — produces the strictly-formatted value the GAIA scorer compares against. |
55
+
56
+ ## 🛠️ Stack
57
+
58
+ | Category | Libraries | Purpose |
59
+ | :--- | :--- | :--- |
60
+ | **Orchestration** | `langgraph`, `langchain`, `langchain-huggingface` | State graph, tool binding, structured output. |
61
+ | **LLM / VLM / ASR** | `huggingface_hub` Inference API | `Qwen/Qwen3-32B`, `Qwen/Qwen3-VL-32B-Instruct`, `openai/whisper-large-v3`. |
62
+ | **Embeddings & Vector Store** | `sentence-transformers`, `supabase` | Semantic search via `Alibaba-NLP/gte-modernbert-base`. |
63
+ | **Keyword search** | `bm25s` | Local BM25 index over the 165-question GAIA corpus. |
64
+ | **Documents** | `pypdf`, `python-docx`, `python-pptx`, `openpyxl` | Office formats. |
65
+ | **Data** | `polars`, `biopython` | Tabular and PDB structural analysis. |
66
+ | **Media** | `pillow`, `librosa`, `soundfile` | Image + audio I/O. |
67
+ | **Web** | `ddgs`, `tavily-python`, `wikipedia`, `arxiv`, `trafilatura`, `youtube-transcript-api` | Search, page extraction, captions. |
68
+ | **UI** | `gradio` | Evaluation runner (HF Space entry point). |
69
+
70
+ ## 💻 Installation & Setup
71
+
72
+ ```bash
73
+ git clone <repo_url>
74
+ cd gaia
75
+ uv sync # or: pip install -r requirements.txt
76
+ cp .env.example .env # populate the keys below
77
+ ```
78
+
79
+ Required environment variables (place in `.env` or set as HF Space Secrets):
80
+
81
+ | Variable | Required? | Purpose |
82
+ | :--- | :--- | :--- |
83
+ | `HF_INFERENCE_KEY` | yes | Hugging Face token — must have **"Make calls to Inference Providers"** permission. |
84
+ | `SUPABASE_URL` | yes (if `retrievers.enable_vector_search: true`) | Supabase project URL. |
85
+ | `SUPABASE_SERVICE_KEY` | yes (same) | Supabase `service_role` key. |
86
+ | `TAVILY_API_KEY` | optional | Only needed when the agent picks `tavily_web_search`. |
87
+
88
+ ## 🎮 Usage
89
+
90
+ Start the Gradio interface:
91
+
92
+ ```bash
93
+ python app.py
94
+ ```
95
+
96
+ The UI requires a Hugging Face login. Click **"Run Evaluation & Submit All Answers"** to fetch the GAIA question set, run the agent on each, and submit to the scoring API.
97
+
98
+ To (re)populate the Supabase vector store from the local corpus:
99
+
100
+ ```bash
101
+ python scripts/create_vector_database.py
102
+ ```
103
+
104
+ ## 📂 Project Structure
105
+
106
+ ```
107
+ .
108
+ ├── app.py # HF Space entry point (Gradio)
109
+ ├── config.yaml # All tunable parameters
110
+ ├── pyproject.toml / requirements.txt # Dependencies (uv + pip parity)
111
+ ├── gaia/ # Application package
112
+ │ ├── agent.py # LangGraph nodes, graph, formatter
113
+ │ ├── states.py # AgentState TypedDict
114
+ │ ├── utils.py # config / prompt loaders, BM25, RRF, answer + youtube helpers
115
+ │ ├── prompts/
116
+ │ │ ├── prompt.yaml # Solver system prompt
117
+ │ │ └── vlm_prompt.yaml # analyze_image system prompt
118
+ │ └── tools/
119
+ │ ├── __init__.py # Aggregates tools_list
120
+ │ ├── basic.py # calculator, python_eval
121
+ │ ├── web.py # ddg / tavily / wiki / arxiv / fetch_webpage / youtube_transcript
122
+ │ ├── files.py # PDF, DOCX, PPTX, TXT, CSV, XLSX, JSON-LD, PDB, Python, ZIP
123
+ │ ├── media.py # analyze_image (VLM), transcribe_audio (ASR), shared HF client
124
+ │ └── dispatcher.py # read_file extension router
125
+ ├── scripts/
126
+ │ └── create_vector_database.py # One-shot embedder for Supabase
127
+ ├── notebooks/ # Exploratory work
128
+ ├── data/
129
+ │ └── metadata.jsonl # Local GAIA corpus (165 examples)
130
+ └── models/ # HF model cache (gitignored)
131
+ ```
132
+
133
+ All tunable knobs — model IDs, retrieval depth, thinking mode, recursion limit — live in `config.yaml`; no code change required to swap models or tweak retrieval.
app.py CHANGED
@@ -1,11 +1,11 @@
1
  import os
2
- import re
3
  import gradio as gr
4
  import requests
5
  import inspect
6
  import pandas as pd
7
  from langchain_core.messages import HumanMessage
8
- from agent import agent_graph
 
9
 
10
  # (Keep Constants as is)
11
  # --- Constants ---
@@ -26,9 +26,7 @@ class BasicAgent:
26
  })
27
  fixed_answer = (response.get("final_answer") or "").strip()
28
  if not fixed_answer:
29
- content = response['messages'][-1].content
30
- match = re.search(r'FINAL ANSWER:\s*(.*)', content, re.DOTALL | re.IGNORECASE)
31
- fixed_answer = match.group(1).strip() if match else content.strip()
32
  print(f"Agent returning fixed answer: {fixed_answer}")
33
  return fixed_answer
34
 
 
1
  import os
 
2
  import gradio as gr
3
  import requests
4
  import inspect
5
  import pandas as pd
6
  from langchain_core.messages import HumanMessage
7
+ from gaia.agent import agent_graph
8
+ from gaia.utils import extract_final_answer
9
 
10
  # (Keep Constants as is)
11
  # --- Constants ---
 
26
  })
27
  fixed_answer = (response.get("final_answer") or "").strip()
28
  if not fixed_answer:
29
+ fixed_answer = extract_final_answer(response['messages'][-1].content)
 
 
30
  print(f"Agent returning fixed answer: {fixed_answer}")
31
  return fixed_answer
32
 
config.yaml CHANGED
@@ -28,7 +28,7 @@ models:
28
  vlm:
29
  model_name: "Qwen/Qwen3-VL-32B-Instruct" # Hugging Face model ID
30
  asr:
31
- model_name: "distil-whisper/distil-large-v3" # Hugging Face model ID
32
  #device: "cuda" # cpu, cuda, or mps (for Mac)
33
  #parameters:
34
  # temperature: 0.7
@@ -36,7 +36,7 @@ models:
36
  # repetition_penalty: 1.1
37
 
38
  graph:
39
- recursion_limit: 20 # Max steps before the graph terminates
40
 
41
  api:
42
  base_url: "https://agents-course-unit4-scoring.hf.space"
 
28
  vlm:
29
  model_name: "Qwen/Qwen3-VL-32B-Instruct" # Hugging Face model ID
30
  asr:
31
+ model_name: "openai/whisper-large-v3" # Hugging Face model ID — must have a provider on HF Inference Providers
32
  #device: "cuda" # cpu, cuda, or mps (for Mac)
33
  #parameters:
34
  # temperature: 0.7
 
36
  # repetition_penalty: 1.1
37
 
38
  graph:
39
+ recursion_limit: 40 # Max graph-node visits before bailing. ~5 + 2*(tool calls), so 40 ≈ 17 tool calls.
40
 
41
  api:
42
  base_url: "https://agents-course-unit4-scoring.hf.space"
gaia/__init__.py ADDED
File without changes
gaia/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (142 Bytes). View file
 
gaia/__pycache__/agent.cpython-313.pyc ADDED
Binary file (15.3 kB). View file
 
gaia/__pycache__/states.cpython-313.pyc ADDED
Binary file (1.42 kB). View file
 
gaia/__pycache__/utils.cpython-313.pyc ADDED
Binary file (9.12 kB). View file
 
gaia/agent.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GAIA Agent with Multi-Modal File Processing and Hybrid Retrieval.
3
+
4
+ This module defines a LangGraph agent that can:
5
+ 1. Retrieve similar questions using Hybrid Search (Vector + BM25) and Reranking
6
+ 2. Process files using tools (PDF, XLSX, MP3, etc.)
7
+ 3. Answer questions using web search, calculator, and other tools
8
+ """
9
+
10
+ import os
11
+ import bm25s
12
+ from pathlib import Path
13
+ from dotenv import load_dotenv
14
+
15
+ from langgraph.graph import START, END, StateGraph
16
+ from langgraph.prebuilt import tools_condition, ToolNode
17
+
18
+ from sentence_transformers import SentenceTransformer, CrossEncoder
19
+ from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
20
+ from langchain_core.messages import HumanMessage, SystemMessage
21
+ from langchain_core.tools import tool
22
+ from supabase.client import Client, create_client
23
+
24
+ from gaia.utils import (
25
+ load_config, load_prompt, init_bm25_index, reciprocal_rank_fusion,
26
+ extract_final_answer, download_task_file,
27
+ )
28
+ from gaia.tools import tools_list
29
+ from gaia.states import AgentState
30
+
31
+ load_dotenv()
32
+ config = load_config()
33
+
34
+ # Environment details and others
35
+ hf_key = os.getenv("HF_INFERENCE_KEY")
36
+ supabase_url = os.getenv("SUPABASE_URL")
37
+ supabase_key = os.getenv("SUPABASE_SERVICE_KEY")
38
+
39
+ # ============================================
40
+ # Model & Embeddings Setup
41
+ # ============================================
42
+
43
+ enable_keyword_search = config["retrievers"]["enable_keyword_search"]
44
+ enable_vector_search = config["retrievers"]["enable_vector_search"]
45
+
46
+ # BM25 Retriever
47
+ bm25_retriever, bm25_corpus, bm25_ids = None, None, None
48
+ if enable_keyword_search:
49
+ bm25_retriever, bm25_corpus, bm25_ids = init_bm25_index(corpus_file=config["data"])
50
+
51
+ bm25_id_to_text = {}
52
+ if bm25_corpus and bm25_ids:
53
+ bm25_id_to_text = dict(zip(bm25_ids, bm25_corpus))
54
+
55
+ embeddings, supabase = None, None
56
+ if enable_vector_search:
57
+ # Embeddings for Vector Search
58
+ embeddings = SentenceTransformer(model_name_or_path=config["models"]["embeddings"]["model_name"], cache_folder=config["models"]["cache_folder"])
59
+
60
+ # Supabase Vector Store
61
+ supabase: Client = create_client(supabase_url, supabase_key)
62
+
63
+ # Reranker Model (ModernBERT Cross-Encoder)
64
+ reranker = CrossEncoder(config["models"]["reranker"]["model_name"], cache_folder=config["models"]["cache_folder"])
65
+
66
+ # LLM for Agent
67
+ llm = HuggingFaceEndpoint(
68
+ repo_id=config["models"]["llm"]["model_name"],
69
+ temperature=config["models"]["llm"]["parameters"]["temperature"],
70
+ repetition_penalty=config["models"]["llm"]["parameters"]["repetition_penalty"],
71
+ provider=config["models"]["llm"]["parameters"]["provider"],
72
+ huggingfacehub_api_token=hf_key
73
+ )
74
+
75
+ agent_llm = ChatHuggingFace(llm=llm)
76
+ agent_with_tools = agent_llm.bind_tools(tools_list)
77
+
78
+ _PROMPTS_DIR = Path(__file__).parent / "prompts"
79
+ _system_prompt = load_prompt(str(_PROMPTS_DIR / "prompt.yaml"))
80
+ _thinking_enabled = config["models"]["llm"]["parameters"].get("thinking_enabled", True)
81
+
82
+
83
+ @tool
84
+ def emit_final_answer(answer: str) -> str:
85
+ """Emit the final answer to the GAIA question in the strict scoring format.
86
+
87
+ Args:
88
+ answer: The raw answer value only.
89
+ Numbers: plain digits, no commas, no units, no symbols (write '1000000', not '1,000,000' or '$50').
90
+ Strings: no articles ('a', 'an', 'the'), no markdown, no surrounding quotes, no trailing punctuation.
91
+ Lists: comma-separated with no extra spaces, in the order requested by the question.
92
+ """
93
+ return answer
94
+
95
+
96
+ formatter_llm = agent_llm.bind_tools([emit_final_answer], tool_choice="emit_final_answer")
97
+
98
+
99
+ # ============================================
100
+ # Graph Nodes
101
+ # ============================================
102
+
103
+ def file_downloader_node(state: AgentState) -> AgentState:
104
+ """
105
+ Download the task file from the scoring API if one is associated with the question.
106
+ Saves to a local directory and stores the path in state. Detailed failure
107
+ diagnostics are logged so HF Space logs reveal the actual cause (network, HTTP, filesystem).
108
+ """
109
+ print("--- FILE DOWNLOADER NODE ---")
110
+ file_name = state.get("file_name", "")
111
+ task_id = state.get("task_id", "")
112
+
113
+ if not file_name or not task_id:
114
+ return {"file_path": ""}
115
+
116
+ local_path, err = download_task_file(
117
+ task_id=task_id,
118
+ file_name=file_name,
119
+ base_url=config["api"]["base_url"],
120
+ files_dir=config["api"]["files_dir"],
121
+ )
122
+ if local_path:
123
+ print(f"Downloaded: {file_name} → {local_path}")
124
+ return {"file_path": local_path}
125
+ print(f"File download failed for task {task_id} ({file_name}): {err}")
126
+ return {"file_path": ""}
127
+
128
+
129
+ def retriever_node(state: AgentState) -> AgentState:
130
+ """
131
+ Hybrid Search Node: Retrieve docs via Vector Search + BM25, combine with RRF.
132
+ """
133
+ print("--- RETRIEVER NODE ---")
134
+ messages = state.get("messages", [])
135
+ if not messages:
136
+ return {"retrieved_docs": []}
137
+
138
+ question_content = messages[0].content
139
+
140
+ if not enable_vector_search and not enable_keyword_search:
141
+ print("No retrieval method enabled.")
142
+ return {"retrieved_docs": []}
143
+
144
+ # 1. Vector Search
145
+ vector_docs = []
146
+ if supabase and embeddings:
147
+ try:
148
+ response = supabase.rpc(
149
+ config["retrievers"]["vector_store"]["query"],
150
+ {"query_embedding": embeddings.encode(question_content).tolist(),
151
+ "match_count": config["retrievers"]["vector_store"]["k"],
152
+ "match_threshold": config["retrievers"]["vector_store"]["threshold"]
153
+ }
154
+ ).execute()
155
+
156
+ vector_docs = response.data
157
+
158
+ except Exception as e:
159
+ print(f"Vector search error: {e}")
160
+
161
+ # 2. BM25 Search
162
+ bm25_docs = []
163
+ if bm25_retriever and bm25_corpus and bm25_ids:
164
+ try:
165
+ query_tokens = bm25s.tokenize([question_content], stopwords="en")
166
+ results, scores = bm25_retriever.retrieve(query_tokens, k=config["retrievers"]["bm25"]["k"])
167
+ indices = results[0]
168
+
169
+ for i, idx in enumerate(indices):
170
+ content = bm25_corpus[idx]
171
+ task_id = bm25_ids[idx]
172
+ score = scores[0][i]
173
+ bm25_dict = {"content":content, "metadata": {"source": "bm25_search", "task_id": task_id, "score": score}}
174
+ bm25_docs.append(bm25_dict)
175
+ except Exception as e:
176
+ print(f"BM25 search error: {e}")
177
+
178
+ # 3. RRF Fusion
179
+ final_candidates = []
180
+ if vector_docs and bm25_docs:
181
+ fused = reciprocal_rank_fusion([vector_docs, bm25_docs])
182
+ final_candidates = [id for id, doc, score in fused]
183
+ else:
184
+ final_candidates = vector_docs + bm25_docs
185
+ final_candidates = [doc["metadata"]["task_id"] for doc in final_candidates]
186
+
187
+ top_candidates = final_candidates[:20]
188
+
189
+ return {"retrieved_docs": top_candidates}
190
+
191
+
192
+ def reranker_node(state: AgentState) -> AgentState:
193
+ """
194
+ Reranker Node: Re-order candidates using Cross-Encoder and return top 3.
195
+ """
196
+ print("--- RERANKER NODE ---")
197
+ candidates = state.get("retrieved_docs", [])
198
+ messages = state.get("messages", [])
199
+
200
+ if not candidates or not messages:
201
+ return {"messages": []}
202
+
203
+ question = messages[0].content
204
+
205
+ # Deduplicate candidates — candidates are task_id strings; resolve to text via corpus lookup
206
+ unique_candidates = []
207
+ seen_content = set()
208
+ for task_id in candidates:
209
+ text = bm25_id_to_text.get(task_id)
210
+ if text and text not in seen_content:
211
+ unique_candidates.append(text)
212
+ seen_content.add(text)
213
+
214
+ if not unique_candidates:
215
+ return {"messages": []}
216
+
217
+ pairs = [[question, doc_text] for doc_text in unique_candidates]
218
+
219
+ try:
220
+ scores = reranker.predict(pairs)
221
+
222
+ scored_docs = sorted(
223
+ zip(unique_candidates, scores),
224
+ key=lambda x: x[1],
225
+ reverse=True
226
+ )
227
+
228
+ top_k = config["retrievers"]["final_rrf_k"]
229
+ top_results = scored_docs[:top_k]
230
+
231
+ context_str = "Here are similar questions and answers for reference:\n\n"
232
+ for i, (doc_text, score) in enumerate(top_results):
233
+ context_str += f"--- Example {i+1} (Score: {score:.2f}) ---\n{doc_text}\n\n"
234
+
235
+ context_message = HumanMessage(content=context_str)
236
+
237
+ return {"messages": [context_message]}
238
+
239
+ except Exception as e:
240
+ print(f"Reranker error: {e}")
241
+ if unique_candidates:
242
+ fallback_msg = HumanMessage(content=f"Reference (Fallback):\n{unique_candidates[0]}")
243
+ return {"messages": [fallback_msg]}
244
+
245
+ return {"messages": []}
246
+
247
+
248
+ def processor_node(state: AgentState) -> AgentState:
249
+ """
250
+ Processor Node: Main LLM agent that answers the question.
251
+ """
252
+ prompt_content = _system_prompt.content + ("" if _thinking_enabled else "\n/no_think")
253
+ system_prompt = SystemMessage(content=prompt_content)
254
+ messages = state.get("messages", [])
255
+ task_id = state.get("task_id", "")
256
+ file_name = state.get("file_name", "")
257
+ file_path = state.get("file_path", "")
258
+
259
+ full_messages = [system_prompt]
260
+
261
+ if file_name:
262
+ if file_path:
263
+ file_msg = HumanMessage(
264
+ content=f"Note: A file named '{file_name}' is associated with this question. It is available at path: {file_path}"
265
+ )
266
+ else:
267
+ file_msg = HumanMessage(
268
+ content=(
269
+ f"Note: A file named '{file_name}' is associated with this question "
270
+ f"(task_id={task_id}), but the automatic download failed. "
271
+ f"Call `retry_file_download(task_id='{task_id}', file_name='{file_name}')` "
272
+ f"once to attempt it again. On success, you'll get back the local path you "
273
+ f"can pass to the appropriate read tool. If retry also fails, state clearly "
274
+ f"that the file is unavailable rather than fabricating data."
275
+ )
276
+ )
277
+ full_messages.append(file_msg)
278
+
279
+ full_messages.extend(messages)
280
+
281
+ response = agent_with_tools.invoke(full_messages)
282
+
283
+ return {"messages": [response]}
284
+
285
+
286
+ def formatter_node(state: AgentState) -> AgentState:
287
+ """Extract and reformat the solver's answer into a strict GAIA-compliant value."""
288
+ print("--- FORMATTER NODE ---")
289
+ messages = state.get("messages", [])
290
+ if not messages:
291
+ return {"final_answer": ""}
292
+
293
+ question = ""
294
+ for m in messages:
295
+ if isinstance(m, HumanMessage):
296
+ question = m.content
297
+ break
298
+
299
+ solver_output = messages[-1].content or ""
300
+
301
+ prompt = [
302
+ SystemMessage(content=(
303
+ "You extract the final answer from an agent's reasoning and apply the GAIA "
304
+ "formatting rules exactly. You MUST call the `emit_final_answer` tool with "
305
+ "the extracted value. If the agent never produced an answer, call it with an "
306
+ "empty string."
307
+ )),
308
+ HumanMessage(content=(
309
+ f"Question:\n{question}\n\n"
310
+ f"Agent reasoning and conclusion:\n{solver_output}\n\n"
311
+ "Extract the final answer and call emit_final_answer."
312
+ )),
313
+ ]
314
+
315
+ try:
316
+ result = formatter_llm.invoke(prompt)
317
+ for tc in getattr(result, "tool_calls", None) or []:
318
+ if tc.get("name") == "emit_final_answer":
319
+ return {"final_answer": str(tc.get("args", {}).get("answer", "")).strip()}
320
+ # Model returned text instead of calling the tool — fall back to regex.
321
+ return {"final_answer": extract_final_answer(result.content)}
322
+ except Exception as e:
323
+ print(f"Formatter error: {e}")
324
+ return {"final_answer": extract_final_answer(solver_output)}
325
+
326
+
327
+ # ============================================
328
+ # Graph Construction
329
+ # ============================================
330
+
331
+ def agent_graph():
332
+ """
333
+ Build and compile the agent graph.
334
+ """
335
+ workflow = StateGraph(AgentState)
336
+
337
+ # Add nodes
338
+ workflow.add_node("file_downloader_node", file_downloader_node)
339
+ workflow.add_node("retriever_node", retriever_node)
340
+ workflow.add_node("reranker_node", reranker_node)
341
+ workflow.add_node("processor_node", processor_node)
342
+ workflow.add_node("tools", ToolNode(tools_list))
343
+ workflow.add_node("formatter_node", formatter_node)
344
+
345
+ # Add edges
346
+ workflow.add_edge(START, "file_downloader_node")
347
+ workflow.add_edge("file_downloader_node", "retriever_node")
348
+ workflow.add_edge("retriever_node", "reranker_node")
349
+ workflow.add_edge("reranker_node", "processor_node")
350
+ workflow.add_edge("tools", "processor_node")
351
+ workflow.add_conditional_edges(
352
+ "processor_node",
353
+ tools_condition,
354
+ {"tools": "tools", END: "formatter_node"},
355
+ )
356
+ workflow.add_edge("formatter_node", END)
357
+
358
+
359
+ compiled = workflow.compile()
360
+ return compiled.with_config({"recursion_limit": config["graph"]["recursion_limit"]})
gaia/prompts/prompt.yaml ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ title: "GAIA Agent"
2
+ prompt: |
3
+ # CONTEXT & PERSONA
4
+ You are an elite autonomous agent designed to solve the GAIA benchmark. Your goal is to solve complex, multi-step problems that require reasoning, file processing, and internet research. You have access to a suite of powerful tools to interact with the world and process data.
5
+
6
+ # TOOL USAGE STRATEGY
7
+ 1. **File First**: If the user mentions a file (e.g., "attached", "spreadsheet", "image"), YOU MUST PROCESS IT IMMEDIATELY.
8
+ - Look for file path information provided in the context.
9
+ - Select the specific tool for the file type (e.g., `read_excel` for .xlsx, `read_pdf` for .pdf).
10
+ - If the specific tool fails, fallback to the generic `read_file` tool.
11
+ - For images, use `analyze_image` with a specific question about what you need to extract from the image.
12
+ 2. **Search Smart**: Pick the right tool for the source.
13
+ - **Wikipedia questions** (named "Wikipedia", or asking about Featured Articles, Olympics, historical events, biographies, etc.):
14
+ - If you can guess the exact page title from the question, call `wikipedia_page_fetch(title)` directly — this is the fastest path and returns the full page.
15
+ - Otherwise, call `wiki_search` once to find candidate titles, then `wikipedia_page_fetch` on the best one.
16
+ - Namespaced pages work too, e.g. `wikipedia_page_fetch("Wikipedia:Featured_article_candidates/Featured_log/November_2016")`.
17
+ - **General web research**: prefer `tavily_web_search` (cleaner, LLM-optimised snippets). Use `duck_web_search` only if Tavily fails.
18
+ - **When any search result gives you a specific URL**, call `fetch_webpage` to read the full page — do not loop on snippets.
19
+ - **Do not repeat queries** with trivial rewording. If a search did not help, switch tools or pivot (try a different angle, fetch a referenced page, or go Wikipedia-direct).
20
+ - **For any YouTube URL**, call `youtube_transcript(url)` to read the spoken content. Only transcript text is returned; if the question requires visual analysis of frames, state that the transcript does not contain the answer rather than guessing.
21
+ 3. **Calculate Precisely**: Use the `calculator` tool for math. Note that `read_excel` and `read_csv` already include column statistics (sum, min, max, mean) — check those first before reaching for `calculator`.
22
+ 4. **Run Code Directly**: If a question asks what a Python script outputs, use `python_eval` to execute it. Do not try to trace execution mentally.
23
+ 5. **Don't Loop**: Each tool call has a cost. If you have already searched a topic twice without progress, escalate (fetch a specific page, switch to Wikipedia-direct, or commit to your best-supported answer).
24
+
25
+ # REASONING FRAMEWORK
26
+ Follow this Chain-of-Thought (CoT) process for every step:
27
+ 1. **Analyze**: What is the core question? What data do I have? What is missing?
28
+ 2. **Plan**: What is the next best tool to use? Why?
29
+ 3. **Execute**: Call the tool.
30
+ 4. **Observe**: Analyze the tool output. Does it answer the question?
31
+ 5. **Refine**: If the output is insufficient, adjust the plan and try a different angle.
32
+
33
+ # FINAL ANSWER
34
+ When you have reached the answer, end your message with a single concluding line of the form:
35
+
36
+ FINAL ANSWER: <value>
37
+
38
+ A downstream formatter will normalise the value into the strict GAIA scoring format (no commas in numbers, no articles in strings, comma-separated lists, etc.) — so prioritise correctness of the value itself over format pedantry. Do not wrap the line in markdown, do not add commentary after it, and do not restate it.
39
+
40
+ # EXAMPLE SCENARIOS
41
+ - Question: "What is the sum of the 'Total' column in the attached file.xlsx?"
42
+ Thought: I need to read the excel file first using `read_excel`, then check the column stats it returns.
43
+ Action: `read_excel(file_path="...")`
44
+ Observation: (Dataframe + describe() stats…)
45
+ FINAL ANSWER: 700
46
+
47
+ - Question: "Which city is the capital of France?"
48
+ FINAL ANSWER: Paris
49
+
50
+ Take a deep breath and think step by step.
gaia/prompts/vlm_prompt.yaml ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ title: "GAIA VLM Assistant"
2
+ prompt: |
3
+ # ROLE
4
+ You are an expert visual assistant AI capable of analyzing images with high precision. Your goal is to extract visual information to answer a SPECIFIC question provided by the user.
5
+
6
+ # INSTRUCTION
7
+ - Analyze the provided image thoroughly.
8
+ - Focus specifically on the information needed to answer the user's question.
9
+ - Provide a clear, detailed description of the relevant visual elements.
10
+ - If the image contains text (documents, charts, screenshots), transcribe or summarize it accurately.
11
+ - Do NOT generate unrelated descriptions. Stick to the context of the question.
12
+
13
+ # FORMAT
14
+ - Start with a direct answer or observation related to the question.
15
+ - Follow with supporting details from the image.
gaia/states.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Annotated, TypedDict, List
2
+ from langgraph.graph.message import add_messages
3
+ from langchain_core.messages import BaseMessage
4
+ from langchain_core.documents import Document
5
+
6
+ # ============================================
7
+ # State Definition
8
+ # ============================================
9
+
10
+ class AgentState(TypedDict):
11
+ """
12
+ State schema for the GAIA agent graph.
13
+
14
+ Attributes:
15
+ messages: List of conversation messages (auto-accumulated via add_messages)
16
+ task_id: The GAIA task identifier for the current question
17
+ file_name: Name of the attached file (empty string if no file)
18
+ file_path: Local filesystem path to the downloaded file (empty if no file or download failed)
19
+ retrieved_docs: List of candidate documents from the retriever node
20
+ final_answer: GAIA-formatted answer produced by the formatter node
21
+ """
22
+ messages: Annotated[list[BaseMessage], add_messages]
23
+ task_id: str
24
+ file_name: str
25
+ file_path: str
26
+ retrieved_docs: List[Document]
27
+ final_answer: str
gaia/tools/__init__.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GAIA agent tool roster. Importing this package exposes every tool and the aggregated `tools_list`."""
2
+ from gaia.tools.basic import calculator, python_eval
3
+ from gaia.tools.web import (
4
+ duck_web_search, tavily_web_search, wiki_search, wikipedia_page_fetch,
5
+ arxiv_search, fetch_webpage, youtube_transcript, retry_file_download,
6
+ )
7
+ from gaia.tools.files import (
8
+ read_pdf, read_docx, read_pptx, read_text_file,
9
+ read_csv, read_excel, read_jsonld, read_pdb,
10
+ read_python_file, extract_zip,
11
+ )
12
+ from gaia.tools.media import analyze_image, transcribe_audio
13
+ from gaia.tools.dispatcher import read_file
14
+
15
+
16
+ tools_list = [
17
+ calculator,
18
+ duck_web_search,
19
+ wiki_search,
20
+ wikipedia_page_fetch,
21
+ arxiv_search,
22
+ tavily_web_search,
23
+ fetch_webpage,
24
+ youtube_transcript,
25
+ retry_file_download,
26
+ python_eval,
27
+ read_pdf,
28
+ read_docx,
29
+ read_pptx,
30
+ read_text_file,
31
+ read_csv,
32
+ read_excel,
33
+ read_jsonld,
34
+ read_pdb,
35
+ transcribe_audio,
36
+ read_python_file,
37
+ extract_zip,
38
+ analyze_image,
39
+ read_file,
40
+ ]
gaia/tools/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (1.13 kB). View file
 
gaia/tools/__pycache__/basic.cpython-313.pyc ADDED
Binary file (2.87 kB). View file
 
gaia/tools/__pycache__/dispatcher.cpython-313.pyc ADDED
Binary file (4.5 kB). View file
 
gaia/tools/__pycache__/files.cpython-313.pyc ADDED
Binary file (13.8 kB). View file
 
gaia/tools/__pycache__/media.cpython-313.pyc ADDED
Binary file (4.69 kB). View file
 
gaia/tools/__pycache__/web.cpython-313.pyc ADDED
Binary file (10 kB). View file
 
gaia/tools/basic.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Basic computation tools: calculator and Python code execution."""
2
+ import os
3
+ import subprocess
4
+ import tempfile
5
+
6
+ from langchain_core.tools import tool
7
+
8
+
9
+ @tool
10
+ def calculator(a: float, b: float, type: str) -> float:
11
+ """Performs mathematical calculations, addition, subtraction, multiplication, division, modulus.
12
+ Args:
13
+ a (float): first float number
14
+ b (float): second float number
15
+ type (str): the type of calculation to perform, can be addition, subtraction, multiplication, division, modulus
16
+ """
17
+ if type == "addition":
18
+ return a + b
19
+ elif type == "subtraction":
20
+ return a - b
21
+ elif type == "multiplication":
22
+ return a * b
23
+ elif type == "division":
24
+ if b == 0:
25
+ raise ValueError("Cannot divide by zero.")
26
+ return a / b
27
+ elif type == "modulus":
28
+ return a % b
29
+ else:
30
+ raise TypeError(f"{type} is not an option for type, choose one of addition, subtraction, multiplication, division, modulus")
31
+
32
+
33
+ @tool
34
+ def python_eval(code: str) -> str:
35
+ """
36
+ Execute a Python code snippet and return its stdout output.
37
+ Use this when a question asks what a script outputs, or when computation requires running code.
38
+
39
+ Args:
40
+ code: Python source code to execute.
41
+
42
+ Returns:
43
+ The stdout output of the code, or an error/timeout message.
44
+ """
45
+ try:
46
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
47
+ f.write(code)
48
+ tmp_path = f.name
49
+ result = subprocess.run(
50
+ ['python3', tmp_path],
51
+ capture_output=True, text=True, timeout=30
52
+ )
53
+ os.unlink(tmp_path)
54
+ if result.returncode == 0:
55
+ return f"Output:\n{result.stdout}"
56
+ return f"[python_eval] exit {result.returncode}:\n{result.stderr}"
57
+ except subprocess.TimeoutExpired:
58
+ return "[python_eval] execution timed out (30s limit)"
59
+ except Exception as e:
60
+ return f"[python_eval] failed: {e}"
gaia/tools/dispatcher.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generic file-reading dispatcher: routes a path to the correct extension-specific tool."""
2
+ from pathlib import Path
3
+
4
+ from langchain_core.tools import tool
5
+
6
+ from gaia.tools.files import (
7
+ read_pdf, read_docx, read_pptx, read_text_file,
8
+ read_csv, read_excel, read_jsonld, read_pdb,
9
+ read_python_file, extract_zip,
10
+ )
11
+ from gaia.tools.media import analyze_image, transcribe_audio
12
+
13
+
14
+ @tool
15
+ def read_file(file_path: str) -> str:
16
+ """
17
+ Automatically read a file based on its extension.
18
+
19
+ Supported formats: PDF, DOCX, PPTX, TXT, CSV, XLSX, JSON-LD, PDB, Python, ZIP, JPG, JPEG, PNG, MP3, WAV, FLAC, OGG, M4A
20
+
21
+ Args:
22
+ file_path: Path to the file to read.
23
+
24
+ Returns:
25
+ The processed content of the file.
26
+ """
27
+ ext = Path(file_path).suffix.lower()
28
+
29
+ processors = {
30
+ '.pdf': lambda p: read_pdf.invoke(p),
31
+ '.docx': lambda p: read_docx.invoke(p),
32
+ '.pptx': lambda p: read_pptx.invoke(p),
33
+ '.txt': lambda p: read_text_file.invoke(p),
34
+ '.csv': lambda p: read_csv.invoke(p),
35
+ '.xlsx': lambda p: read_excel.invoke(p),
36
+ '.jsonld': lambda p: read_jsonld.invoke(p),
37
+ '.pdb': lambda p: read_pdb.invoke(p),
38
+ '.py': lambda p: read_python_file.invoke(p),
39
+ '.mp3': lambda p: transcribe_audio.invoke(p),
40
+ '.wav': lambda p: transcribe_audio.invoke(p),
41
+ '.flac': lambda p: transcribe_audio.invoke(p),
42
+ '.ogg': lambda p: transcribe_audio.invoke(p),
43
+ '.m4a': lambda p: transcribe_audio.invoke(p),
44
+ '.zip': lambda p: extract_zip.invoke(p),
45
+ '.jpg': lambda p: analyze_image.invoke({"image_path": p, "question": "Describe this image in detail."}),
46
+ '.jpeg': lambda p: analyze_image.invoke({"image_path": p, "question": "Describe this image in detail."}),
47
+ '.png': lambda p: analyze_image.invoke({"image_path": p, "question": "Describe this image in detail."}),
48
+ }
49
+
50
+ processor = processors.get(ext)
51
+
52
+ if processor:
53
+ return processor(file_path)
54
+
55
+ return f"[Unsupported file type: {ext}]"
gaia/tools/files.py ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """File / document / archive reading tools."""
2
+ import json
3
+ import os
4
+ import zipfile
5
+ from pathlib import Path
6
+
7
+ from langchain_core.tools import tool
8
+
9
+
10
+ @tool
11
+ def read_pdf(file_path: str) -> str:
12
+ """
13
+ Extract text content from a PDF file.
14
+
15
+ Args:
16
+ file_path: Path to the PDF file to read.
17
+
18
+ Returns:
19
+ The text content of the PDF, with page separators.
20
+ """
21
+ from pypdf import PdfReader
22
+
23
+ try:
24
+ reader = PdfReader(file_path)
25
+ text = []
26
+ for i, page in enumerate(reader.pages):
27
+ page_text = page.extract_text()
28
+ if page_text:
29
+ text.append(f"--- Page {i+1} ---\n{page_text}")
30
+
31
+ return "\n\n".join(text) if text else "[Empty PDF]"
32
+ except Exception as e:
33
+ return f"[read_pdf] failed to read PDF: {e}"
34
+
35
+
36
+ @tool
37
+ def read_docx(file_path: str) -> str:
38
+ """
39
+ Extract text content from a Word document (.docx).
40
+
41
+ Args:
42
+ file_path: Path to the Word document to read.
43
+
44
+ Returns:
45
+ The text content of the document.
46
+ """
47
+ from docx import Document
48
+
49
+ try:
50
+ doc = Document(file_path)
51
+ text_parts = []
52
+
53
+ paragraphs = [para.text for para in doc.paragraphs if para.text.strip()]
54
+ if paragraphs:
55
+ text_parts.append("\n".join(paragraphs))
56
+
57
+ for i, table in enumerate(doc.tables):
58
+ rows = [" | ".join(cell.text.strip() for cell in row.cells) for row in table.rows]
59
+ rows = [r for r in rows if r.strip()]
60
+ if rows:
61
+ text_parts.append(f"--- Table {i+1} ---\n" + "\n".join(rows))
62
+
63
+ return "\n\n".join(text_parts) if text_parts else "[Empty document]"
64
+ except Exception as e:
65
+ return f"[read_docx] failed to read DOCX: {e}"
66
+
67
+
68
+ @tool
69
+ def read_pptx(file_path: str) -> str:
70
+ """
71
+ Extract text content from a PowerPoint presentation (.pptx).
72
+
73
+ Args:
74
+ file_path: Path to the PowerPoint file to read.
75
+
76
+ Returns:
77
+ The text content from all slides.
78
+ """
79
+ from pptx import Presentation
80
+
81
+ try:
82
+ prs = Presentation(file_path)
83
+ text = []
84
+
85
+ for slide_num, slide in enumerate(prs.slides, 1):
86
+ slide_text = [f"--- Slide {slide_num} ---"]
87
+ for shape in slide.shapes:
88
+ if hasattr(shape, "text") and shape.text.strip():
89
+ slide_text.append(shape.text)
90
+ if len(slide_text) > 1:
91
+ text.append("\n".join(slide_text))
92
+
93
+ return "\n\n".join(text) if text else "[Empty presentation]"
94
+ except Exception as e:
95
+ return f"[read_pptx] failed to read PPTX: {e}"
96
+
97
+
98
+ @tool
99
+ def read_text_file(file_path: str) -> str:
100
+ """
101
+ Read content from a plain text file (.txt).
102
+
103
+ Args:
104
+ file_path: Path to the text file to read.
105
+
106
+ Returns:
107
+ The content of the text file.
108
+ """
109
+ try:
110
+ with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
111
+ return f.read()
112
+ except Exception as e:
113
+ return f"[read_text_file] failed: {e}"
114
+
115
+
116
+ @tool
117
+ def read_csv(file_path: str) -> str:
118
+ """
119
+ Read and analyze a CSV file using polars.
120
+
121
+ Args:
122
+ file_path: Path to the CSV file to read.
123
+
124
+ Returns:
125
+ Summary of the CSV including schema, row count, and data preview.
126
+ """
127
+ import polars as pl
128
+
129
+ try:
130
+ df = pl.read_csv(file_path)
131
+
132
+ output = f"CSV File — {len(df)} rows, {len(df.columns)} columns\n"
133
+ output += f"Columns: {df.columns}\n\n"
134
+ output += f"Column Statistics:\n{df.describe()}\n\n"
135
+ output += f"Data (first 20 rows):\n{df.head(20)}"
136
+ if len(df) <= 50:
137
+ output += f"\n\nComplete data:\n{df}"
138
+ return output
139
+ except Exception as e:
140
+ return f"[read_csv] failed to read CSV: {e}"
141
+
142
+
143
+ @tool
144
+ def read_excel(file_path: str, sheet_id: int = 0) -> str:
145
+ """
146
+ Read and analyze an Excel file (.xlsx) using polars.
147
+
148
+ Args:
149
+ file_path: Path to the Excel file to read.
150
+ sheet_id: The sheet index to read (0-based). Default is 0 (first sheet).
151
+
152
+ Returns:
153
+ Summary of the Excel sheet including schema, row count, and data preview.
154
+ """
155
+ import polars as pl
156
+ import openpyxl
157
+
158
+ try:
159
+ wb = openpyxl.load_workbook(file_path, read_only=True)
160
+ sheet_names = wb.sheetnames
161
+ wb.close()
162
+ except Exception:
163
+ sheet_names = []
164
+
165
+ try:
166
+ df = pl.read_excel(file_path, sheet_id=sheet_id)
167
+ sheet_label = sheet_names[sheet_id] if sheet_id < len(sheet_names) else str(sheet_id)
168
+
169
+ output = f"Excel File — Available sheets: {sheet_names}\n\n"
170
+ output += f"Sheet {sheet_id} ('{sheet_label}') — {len(df)} rows, {len(df.columns)} columns\n"
171
+ output += f"Columns: {df.columns}\n\n"
172
+ output += f"Column Statistics:\n{df.describe()}\n\n"
173
+ output += f"Data (first 20 rows):\n{df.head(20)}"
174
+ if len(df) <= 50:
175
+ output += f"\n\nComplete data:\n{df}"
176
+ return output
177
+ except Exception as e:
178
+ return f"[read_excel] failed to read Excel: {e}"
179
+
180
+
181
+ @tool
182
+ def read_jsonld(file_path: str) -> str:
183
+ """
184
+ Read and parse a JSON-LD file.
185
+
186
+ Args:
187
+ file_path: Path to the JSON-LD file to read.
188
+
189
+ Returns:
190
+ The formatted JSON content.
191
+ """
192
+ try:
193
+ with open(file_path, 'r') as f:
194
+ data = json.load(f)
195
+ return f"JSON-LD Content:\n{json.dumps(data, indent=2)}"
196
+ except Exception as e:
197
+ return f"[read_jsonld] failed to read JSON-LD: {e}"
198
+
199
+
200
+ @tool
201
+ def read_pdb(file_path: str) -> str:
202
+ """
203
+ Read and analyze a PDB (Protein Data Bank) file for protein structure analysis.
204
+
205
+ Args:
206
+ file_path: Path to the PDB file to read.
207
+
208
+ Returns:
209
+ Analysis of the protein structure including atoms, chains, and coordinates.
210
+ """
211
+ from Bio.PDB import PDBParser
212
+ import numpy as np
213
+
214
+ try:
215
+ parser = PDBParser(QUIET=True)
216
+ structure = parser.get_structure("protein", file_path)
217
+
218
+ info = ["=== PDB Structure Analysis ==="]
219
+
220
+ atoms = list(structure.get_atoms())
221
+ info.append(f"Total atoms: {len(atoms)}")
222
+
223
+ for model in structure:
224
+ info.append(f"\nModel {model.id}:")
225
+ for chain in model:
226
+ residues = list(chain.get_residues())
227
+ info.append(f" Chain {chain.id}: {len(residues)} residues")
228
+
229
+ if len(atoms) >= 2:
230
+ info.append("\nFirst atoms (for distance calculations):")
231
+ for i, atom in enumerate(atoms[:5]):
232
+ coord = atom.get_coord()
233
+ info.append(
234
+ f" Atom {i+1}: {atom.get_name()} at "
235
+ f"[{coord[0]:.4f}, {coord[1]:.4f}, {coord[2]:.4f}]"
236
+ )
237
+
238
+ dist = np.linalg.norm(atoms[0].get_coord() - atoms[1].get_coord())
239
+ info.append(f"\nDistance between first two atoms: {dist:.4f} Angstroms")
240
+
241
+ return "\n".join(info)
242
+ except Exception as e:
243
+ return f"[read_pdb] failed to read PDB: {e}"
244
+
245
+
246
+ @tool
247
+ def read_python_file(file_path: str) -> str:
248
+ """
249
+ Read a Python source code file.
250
+
251
+ Args:
252
+ file_path: Path to the Python file to read.
253
+
254
+ Returns:
255
+ The Python code content.
256
+ """
257
+ try:
258
+ with open(file_path, 'r') as f:
259
+ code = f.read()
260
+ return f"Python Code:\n```python\n{code}\n```"
261
+ except Exception as e:
262
+ return f"[read_python_file] failed: {e}"
263
+
264
+
265
+ @tool
266
+ def extract_zip(file_path: str) -> str:
267
+ """
268
+ Extract a ZIP archive and list its contents.
269
+
270
+ Args:
271
+ file_path: Path to the ZIP file to extract.
272
+
273
+ Returns:
274
+ List of files extracted from the archive with their paths.
275
+ """
276
+ try:
277
+ extract_dir = Path(file_path).parent / Path(file_path).stem
278
+ extract_dir.mkdir(exist_ok=True)
279
+
280
+ with zipfile.ZipFile(file_path, 'r') as zip_ref:
281
+ zip_ref.extractall(extract_dir)
282
+
283
+ results = [f"ZIP Archive extracted to: {extract_dir}\n\nContents:"]
284
+
285
+ for root, dirs, files in os.walk(extract_dir):
286
+ for file in files:
287
+ full_path = os.path.join(root, file)
288
+ rel_path = os.path.relpath(full_path, extract_dir)
289
+ file_size = os.path.getsize(full_path)
290
+ results.append(f" - {rel_path} ({file_size} bytes)")
291
+
292
+ results.append(f"\nUse the appropriate read tool on the extracted files at: {extract_dir}/")
293
+
294
+ return "\n".join(results)
295
+ except Exception as e:
296
+ return f"[extract_zip] failed: {e}"
gaia/tools/media.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multi-modal tools: image analysis (VLM) and audio transcription (ASR)."""
2
+ import base64
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from dotenv import load_dotenv
7
+
8
+ load_dotenv()
9
+
10
+ from huggingface_hub import InferenceClient
11
+ from langchain_core.tools import tool
12
+
13
+ from gaia.utils import load_config, load_prompt
14
+
15
+
16
+ _PROMPTS_DIR = Path(__file__).resolve().parent.parent / "prompts"
17
+
18
+ _config = load_config()
19
+ _vlm_model_name = _config["models"]["vlm"]["model_name"]
20
+ _vlm_system_prompt = load_prompt(str(_PROMPTS_DIR / "vlm_prompt.yaml")).content
21
+ _asr_model_name = _config["models"]["asr"]["model_name"]
22
+ _hf_client = InferenceClient(token=os.getenv("HF_INFERENCE_KEY"))
23
+
24
+
25
+ @tool
26
+ def analyze_image(image_path: str, question: str) -> str:
27
+ """
28
+ Analyze an image using a Vision Language Model (VLM) to answer a specific question.
29
+
30
+ Args:
31
+ image_path: Path to the image file (JPG, PNG).
32
+ question: The specific question to answer about the image.
33
+
34
+ Returns:
35
+ A detailed description or answer based on the visual content.
36
+ """
37
+ try:
38
+ if not os.path.exists(image_path):
39
+ return f"[analyze_image] image file not found at {image_path}"
40
+
41
+ with open(image_path, "rb") as img_file:
42
+ image_data = base64.b64encode(img_file.read()).decode("utf-8")
43
+ ext = Path(image_path).suffix.lower().lstrip(".")
44
+ mime_type = "image/jpeg" if ext in ("jpg", "jpeg") else f"image/{ext}"
45
+ image_url = f"data:{mime_type};base64,{image_data}"
46
+
47
+ messages = [
48
+ {
49
+ "role": "user",
50
+ "content": [
51
+ {"type": "image_url", "image_url": {"url": image_url}},
52
+ {"type": "text", "text": f"{_vlm_system_prompt}\n\nQuestion: {question}"}
53
+ ]
54
+ }
55
+ ]
56
+
57
+ output = _hf_client.chat_completion(
58
+ messages=messages,
59
+ model=_vlm_model_name,
60
+ max_tokens=1000
61
+ )
62
+
63
+ return output.choices[0].message.content
64
+
65
+ except Exception as e:
66
+ return f"[analyze_image] VLM call failed: {e}"
67
+
68
+
69
+ @tool
70
+ def transcribe_audio(file_path: str) -> str:
71
+ """
72
+ Transcribe an audio file (MP3, WAV, etc.) to text using Whisper.
73
+
74
+ Args:
75
+ file_path: Path to the audio file to transcribe.
76
+
77
+ Returns:
78
+ The transcribed text from the audio, or a detailed `[transcribe_audio] ...`
79
+ error string identifying file path, size, model, and exception class+message.
80
+ """
81
+ if not os.path.exists(file_path):
82
+ return f"[transcribe_audio] file not found at {file_path}"
83
+ file_size = os.path.getsize(file_path)
84
+ if file_size == 0:
85
+ return f"[transcribe_audio] file is empty at {file_path}"
86
+
87
+ try:
88
+ with open(file_path, "rb") as f:
89
+ audio_bytes = f.read()
90
+ result = _hf_client.automatic_speech_recognition(audio=audio_bytes, model=_asr_model_name)
91
+ return f"Audio Transcription:\n{result.text}"
92
+ except Exception as e:
93
+ return (
94
+ f"[transcribe_audio] ASR call failed for {file_path} ({file_size} bytes) "
95
+ f"with model '{_asr_model_name}': {type(e).__name__}: {e}"
96
+ )
gaia/tools/web.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Web search and fetching tools: DuckDuckGo, Tavily, Wikipedia, Arxiv, webpage fetch, YouTube transcripts."""
2
+ from langchain_community.tools import DuckDuckGoSearchRun
3
+ from langchain_community.tools.tavily_search import TavilySearchResults
4
+ from langchain_community.document_loaders import WikipediaLoader, ArxivLoader
5
+ from langchain_core.tools import tool
6
+
7
+ from gaia.utils import extract_youtube_id, load_config, download_task_file
8
+
9
+
10
+ _ddg_search = None
11
+ _tavily_search = None
12
+
13
+
14
+ def _get_ddg():
15
+ global _ddg_search
16
+ if _ddg_search is None:
17
+ _ddg_search = DuckDuckGoSearchRun()
18
+ return _ddg_search
19
+
20
+
21
+ def _get_tavily():
22
+ global _tavily_search
23
+ if _tavily_search is None:
24
+ _tavily_search = TavilySearchResults(max_results=3)
25
+ return _tavily_search
26
+
27
+
28
+ @tool
29
+ def duck_web_search(query: str) -> str:
30
+ """Use DuckDuckGo to search the web.
31
+
32
+ Args:
33
+ query: The search query.
34
+ """
35
+ search = _get_ddg().invoke(input=query)
36
+ return {"duckduckgo_web_search": search}
37
+
38
+
39
+ @tool
40
+ def wiki_search(query: str) -> str:
41
+ """Search Wikipedia for a query and return maximum 3 results.
42
+
43
+ Args:
44
+ query: The search query."""
45
+ documents = WikipediaLoader(query=query, load_max_docs=3, doc_content_chars_max=20000).load()
46
+ processed_documents = "\n\n---\n\n".join(
47
+ [
48
+ f'Document title: {document.metadata.get("title", "")}. Summary: {document.metadata.get("summary", "")}. Documents details: {document.page_content}'
49
+ for document in documents
50
+ ])
51
+ return {"wiki_results": processed_documents}
52
+
53
+
54
+ @tool
55
+ def wikipedia_page_fetch(title: str) -> str:
56
+ """Fetch the full text of a Wikipedia page by its exact title.
57
+
58
+ Use this when you can guess the page title from the question (e.g., "1928 Summer
59
+ Olympics", "List of Featured Articles"). Faster than search + fetch_webpage when
60
+ the title is obvious. Returns full page content, not a search summary.
61
+
62
+ Args:
63
+ title: The exact Wikipedia page title (including namespace prefix if applicable,
64
+ e.g., "Wikipedia:Featured_article_candidates/Featured_log/November_2016").
65
+
66
+ Returns:
67
+ The page content prefixed with title and URL, or a `[wikipedia_page_fetch] ...`
68
+ error string.
69
+ """
70
+ import wikipedia
71
+ try:
72
+ page = wikipedia.page(title, auto_suggest=False)
73
+ return f"Wikipedia: {page.title}\nURL: {page.url}\n\n{page.content}"
74
+ except wikipedia.exceptions.DisambiguationError as e:
75
+ return f"[wikipedia_page_fetch] '{title}' is a disambiguation page. Options: {e.options[:10]}"
76
+ except wikipedia.exceptions.PageError:
77
+ return f"[wikipedia_page_fetch] page not found: '{title}'. Try wiki_search to find the correct title."
78
+ except Exception as e:
79
+ return f"[wikipedia_page_fetch] failed: {e}"
80
+
81
+
82
+ @tool
83
+ def arxiv_search(query: str) -> str:
84
+ """Search Arxiv for a query and return maximum 3 result.
85
+
86
+ Args:
87
+ query: The search query."""
88
+ documents = ArxivLoader(query=query, load_max_docs=3).load()
89
+ processed_documents = "\n\n---\n\n".join(
90
+ [
91
+ f'Document title: {document.metadata.get("title", "")}. Summary: {document.metadata.get("summary", "")}. Documents details: {document.page_content}'
92
+ for document in documents
93
+ ])
94
+ return {"arxiv_results": processed_documents}
95
+
96
+
97
+ @tool
98
+ def tavily_web_search(query: str) -> str:
99
+ """Search the web using Tavily for a query and return maximum 3 results.
100
+
101
+ Args:
102
+ query: The search query."""
103
+ search_documents = _get_tavily().invoke(input=query)
104
+ web_results = "\n\n---\n\n".join(
105
+ [
106
+ f'Document title: {document["title"]}. Contents: {document["content"]}. Relevance Score: {document["score"]}'
107
+ for document in search_documents
108
+ ])
109
+ return {"web_results": web_results}
110
+
111
+
112
+ @tool
113
+ def fetch_webpage(url: str) -> str:
114
+ """
115
+ Fetch and extract the main text content from a webpage.
116
+ Use this when a search result points to a specific URL you need to read in full.
117
+
118
+ Args:
119
+ url: The full URL of the page to fetch.
120
+
121
+ Returns:
122
+ The extracted text content of the page.
123
+ """
124
+ import trafilatura
125
+ try:
126
+ downloaded = trafilatura.fetch_url(url)
127
+ if downloaded is None:
128
+ return f"[fetch_webpage] could not fetch {url}"
129
+ text = trafilatura.extract(downloaded, include_tables=True, include_links=False)
130
+ if text is None:
131
+ return f"[fetch_webpage] could not extract content from {url}"
132
+ return f"Page content from {url}:\n\n{text}"
133
+ except Exception as e:
134
+ return f"[fetch_webpage] failed: {e}"
135
+
136
+
137
+ @tool
138
+ def retry_file_download(task_id: str, file_name: str) -> str:
139
+ """Retry downloading the task file from the GAIA scoring API.
140
+
141
+ Use this when the initial automatic download failed (you will see a message like
142
+ "the automatic download failed" in the question context). Returns the local path
143
+ on success, or an error string starting with `[retry_file_download]`.
144
+
145
+ Args:
146
+ task_id: The task ID for the current question.
147
+ file_name: The original file name from the question metadata.
148
+
149
+ Returns:
150
+ Local filesystem path to the downloaded file, or an error description.
151
+ """
152
+ cfg = load_config()
153
+ local_path, err = download_task_file(
154
+ task_id=task_id,
155
+ file_name=file_name,
156
+ base_url=cfg["api"]["base_url"],
157
+ files_dir=cfg["api"]["files_dir"],
158
+ )
159
+ if local_path:
160
+ return local_path
161
+ return f"[retry_file_download] {err}"
162
+
163
+
164
+ @tool
165
+ def youtube_transcript(url: str) -> str:
166
+ """Fetch the transcript (captions) of a YouTube video as plain text.
167
+
168
+ Use this whenever a question references a YouTube URL — the spoken content of
169
+ the video is available via captions. Note: this returns text only; questions
170
+ that require visual analysis of the frames cannot be answered from the
171
+ transcript alone.
172
+
173
+ Prefers manually-written English captions; falls back to auto-generated English,
174
+ and finally to any available language.
175
+
176
+ Args:
177
+ url: The full YouTube URL (watch, youtu.be, embed, shorts) or a bare 11-char video ID.
178
+
179
+ Returns:
180
+ The concatenated transcript text, or an error string starting with `[youtube_transcript]`.
181
+ """
182
+ from youtube_transcript_api import YouTubeTranscriptApi
183
+ from youtube_transcript_api._errors import (
184
+ TranscriptsDisabled, NoTranscriptFound, VideoUnavailable,
185
+ )
186
+
187
+ video_id = extract_youtube_id(url)
188
+ if not video_id:
189
+ return f"[youtube_transcript] could not parse video ID from: {url}"
190
+
191
+ try:
192
+ ytt_api = YouTubeTranscriptApi()
193
+ try:
194
+ fetched = ytt_api.fetch(video_id, languages=['en'])
195
+ except NoTranscriptFound:
196
+ transcript_list = ytt_api.list(video_id)
197
+ transcript = next(iter(transcript_list))
198
+ fetched = transcript.fetch()
199
+
200
+ text = " ".join(snippet.text for snippet in fetched)
201
+ return f"YouTube transcript for {url}:\n\n{text}"
202
+ except TranscriptsDisabled:
203
+ return f"[youtube_transcript] transcripts are disabled for {url}"
204
+ except VideoUnavailable:
205
+ return f"[youtube_transcript] video unavailable: {url}"
206
+ except NoTranscriptFound:
207
+ return f"[youtube_transcript] no transcript found for {url}"
208
+ except Exception as e:
209
+ return f"[youtube_transcript] failed: {e}"
gaia/utils.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import json
4
+ import time
5
+ import bm25s
6
+ import requests
7
+ import yaml
8
+ from pathlib import Path
9
+ from langchain_core.messages import SystemMessage
10
+
11
+
12
+ _YOUTUBE_ID_RE = re.compile(
13
+ r'(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/embed/|youtube\.com/v/|youtube\.com/shorts/)([\w-]{11})'
14
+ )
15
+
16
+
17
+ def extract_youtube_id(url: str) -> str | None:
18
+ """Pull the 11-char video ID from any common YouTube URL form, or accept a bare ID."""
19
+ m = _YOUTUBE_ID_RE.search(url)
20
+ if m:
21
+ return m.group(1)
22
+ if re.fullmatch(r'[\w-]{11}', url.strip()):
23
+ return url.strip()
24
+ return None
25
+
26
+
27
+ _FINAL_ANSWER_RE = re.compile(r'FINAL ANSWER:\s*(.*)', re.DOTALL | re.IGNORECASE)
28
+
29
+
30
+ def extract_final_answer(content: str) -> str:
31
+ """Pull the value after 'FINAL ANSWER:' (case-insensitive), or return the content stripped."""
32
+ content = content or ""
33
+ m = _FINAL_ANSWER_RE.search(content)
34
+ return (m.group(1) if m else content).strip()
35
+
36
+
37
+ def load_config(path="config.yaml"):
38
+ with open(path, "r") as f:
39
+ return yaml.safe_load(f)
40
+
41
+
42
+ def download_task_file(
43
+ task_id: str,
44
+ file_name: str,
45
+ base_url: str,
46
+ files_dir: str,
47
+ max_retries: int = 3,
48
+ timeout: int = 30,
49
+ ) -> tuple[str | None, str]:
50
+ """Download a task file from the GAIA scoring API.
51
+
52
+ Returns (local_path, error_message). On success the error string is empty;
53
+ on failure local_path is None and the error string says what went wrong.
54
+ Retries on 5xx and network errors with exponential backoff (2s, 4s); 4xx
55
+ is treated as a definitive "no file" answer and not retried.
56
+ """
57
+ if not task_id or not file_name:
58
+ return None, "missing task_id or file_name"
59
+
60
+ safe_name = Path(file_name).name
61
+ if not safe_name:
62
+ return None, f"invalid file_name '{file_name}'"
63
+
64
+ try:
65
+ save_dir = Path(files_dir) / task_id
66
+ save_dir.mkdir(parents=True, exist_ok=True)
67
+ except Exception as e:
68
+ return None, f"could not create cache dir: {e}"
69
+
70
+ local_path = save_dir / safe_name
71
+ if local_path.exists() and local_path.stat().st_size > 0:
72
+ return str(local_path), ""
73
+
74
+ url = f"{base_url}/files/{task_id}"
75
+ last_err = ""
76
+ for attempt in range(1, max_retries + 1):
77
+ try:
78
+ response = requests.get(url, timeout=timeout)
79
+ status = response.status_code
80
+ if status >= 500:
81
+ last_err = f"HTTP {status} (attempt {attempt}/{max_retries})"
82
+ if attempt < max_retries:
83
+ time.sleep(2 * attempt)
84
+ continue
85
+ if status >= 400:
86
+ return None, f"HTTP {status} from {url}"
87
+ if not response.content:
88
+ last_err = f"empty body (attempt {attempt}/{max_retries})"
89
+ if attempt < max_retries:
90
+ time.sleep(2 * attempt)
91
+ continue
92
+ local_path.write_bytes(response.content)
93
+ return str(local_path), ""
94
+ except requests.RequestException as e:
95
+ last_err = f"{type(e).__name__}: {e} (attempt {attempt}/{max_retries})"
96
+ if attempt < max_retries:
97
+ time.sleep(2 * attempt)
98
+ except Exception as e:
99
+ return None, f"unexpected error: {type(e).__name__}: {e}"
100
+
101
+ return None, f"all {max_retries} attempts failed; last: {last_err}"
102
+
103
+
104
+ def load_prompt(prompt_location: str) -> SystemMessage:
105
+ """Load system prompt from YAML file."""
106
+ with open(prompt_location) as f:
107
+ try:
108
+ prompt = yaml.safe_load(f)["prompt"]
109
+ return SystemMessage(content=prompt)
110
+ except yaml.YAMLError as exc:
111
+ print(exc)
112
+ return SystemMessage(content="You are a helpful assistant.")
113
+
114
+
115
+
116
+ def init_bm25_index(corpus_file = "data/metadata.jsonl"):
117
+ """BM25 Index Initialization (Local Corpus)"""
118
+ try:
119
+ if not os.path.exists(corpus_file):
120
+ print(f"Warning: {corpus_file} not found. BM25 will use empty index.")
121
+ return None, [], []
122
+
123
+ search_texts = [] # question-only — used for BM25 indexing
124
+ corpus_texts = [] # Q+A+Steps — returned for context injection
125
+ corpus_ids = []
126
+ with open(corpus_file, "r") as f:
127
+ for line in f:
128
+ item = json.loads(line)
129
+ question = item.get('Question', '')
130
+ answer = item.get('Final answer', '')
131
+ steps = item.get('Annotator Metadata', {}).get('Steps', '')
132
+ search_texts.append(question)
133
+ parts = [f"Question: {question}"]
134
+ if answer:
135
+ parts.append(f"Final Answer: {answer}")
136
+ if steps:
137
+ parts.append(f"Solution Steps: {steps}")
138
+ corpus_texts.append("\n".join(parts))
139
+ corpus_ids.append(item.get('task_id', ''))
140
+
141
+ corpus_tokens = bm25s.tokenize(search_texts, stopwords="en", stemmer=None)
142
+
143
+ retriever_bm25 = bm25s.BM25()
144
+ retriever_bm25.index(corpus_tokens)
145
+
146
+ print(f"BM25 Index initialized with {len(corpus_texts)} documents.")
147
+ return retriever_bm25, corpus_texts, corpus_ids
148
+ except Exception as e:
149
+ print(f"Error initializing BM25: {e}")
150
+ return None, [], []
151
+
152
+
153
+ def reciprocal_rank_fusion(results: list[list[dict]], k=60) -> list[tuple[dict, float]]:
154
+ """
155
+ Fuse multiple ranked lists using Reciprocal Rank Fusion (RRF).
156
+ """
157
+ fused_scores = {}
158
+
159
+ for rank_list in results:
160
+ for rank, doc in enumerate(rank_list):
161
+ doc_id = doc["metadata"]["task_id"]
162
+ doc_content = doc["content"]
163
+ if doc_id not in fused_scores:
164
+ fused_scores[doc_id] = {"id": doc_id, "content": doc_content, "score": 0.0}
165
+ fused_scores[doc_id]["score"] += 1.0 / (k + rank + 1)
166
+
167
+ sorted_results = sorted(fused_scores.values(), key=lambda x: x["score"], reverse=True)
168
+ return [(item["id"], item["content"], item["score"]) for item in sorted_results]
notebooks/create_vector_database.ipynb ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "code",
5
+ "execution_count": 1,
6
+ "id": "a9f7a25f",
7
+ "metadata": {},
8
+ "outputs": [],
9
+ "source": [
10
+ "# Loading environment variables and initializing Supabase client and SentenceTransformer model\n",
11
+ "import os\n",
12
+ "import json\n",
13
+ "from dotenv import load_dotenv\n",
14
+ "from supabase.client import Client, create_client\n",
15
+ "from sentence_transformers import SentenceTransformer\n",
16
+ "from utils import load_config\n",
17
+ "\n",
18
+ "load_dotenv()\n",
19
+ "\n",
20
+ "config = load_config()\n",
21
+ "data = config[\"data\"]\n",
22
+ "\n",
23
+ "supabase_url = os.getenv(\"SUPABASE_URL\")\n",
24
+ "supabase_key = os.getenv(\"SUPABASE_SERVICE_KEY\")\n",
25
+ "\n",
26
+ "supabase: Client = create_client(supabase_url, supabase_key)\n",
27
+ "embeddings = SentenceTransformer(model_name_or_path=config[\"vector_store\"][\"embedding_model_name\"], cache_folder=config[\"models\"][\"cache_folder\"])"
28
+ ]
29
+ },
30
+ {
31
+ "cell_type": "code",
32
+ "execution_count": 2,
33
+ "id": "f2c5492b",
34
+ "metadata": {},
35
+ "outputs": [
36
+ {
37
+ "name": "stderr",
38
+ "output_type": "stream",
39
+ "text": [
40
+ "/home/kpatelis/projects/gaia/.venv/lib/python3.13/site-packages/torch/_dynamo/guards.py:1114: RuntimeWarning: Guards may run slower on Python 3.13.0. Consider upgrading to Python 3.13.1+.\n",
41
+ " warnings.warn(\n",
42
+ "/home/kpatelis/projects/gaia/.venv/lib/python3.13/site-packages/torch/_dynamo/guards.py:1114: RuntimeWarning: Guards may run slower on Python 3.13.0. Consider upgrading to Python 3.13.1+.\n",
43
+ " warnings.warn(\n"
44
+ ]
45
+ }
46
+ ],
47
+ "source": [
48
+ "# Reading JSONL file and creating documents with embeddings\n",
49
+ "with open(data, 'r') as jsonl_file:\n",
50
+ " json_list = list(jsonl_file)\n",
51
+ "\n",
52
+ "documents = []\n",
53
+ "for json_str in json_list:\n",
54
+ " json_data = json.loads(json_str)\n",
55
+ " content = f\"{json_data['Question']}\"\n",
56
+ " embedding = embeddings.encode(content, normalize_embeddings=True).tolist()\n",
57
+ " document = {\n",
58
+ " \"content\": content,\n",
59
+ " \"metadata\": {\n",
60
+ " \"source\": \"vector_search\",\n",
61
+ " \"task_id\": json_data['task_id']\n",
62
+ " },\n",
63
+ " \"embedding\": embedding,\n",
64
+ " }\n",
65
+ " documents.append(document)"
66
+ ]
67
+ },
68
+ {
69
+ "cell_type": "code",
70
+ "execution_count": 3,
71
+ "id": "26ddbafd",
72
+ "metadata": {},
73
+ "outputs": [],
74
+ "source": [
75
+ "# Inserting documents into Supabase\n",
76
+ "\n",
77
+ "# Note1: pgvector needs to be enabled, to turn to vector database\n",
78
+ "# Note2: Table needs to be created beforehand in Supabase, with column types\n",
79
+ "try:\n",
80
+ " response = (\n",
81
+ " supabase.table(\"gaia_documents\")\n",
82
+ " .insert(documents)\n",
83
+ " .execute()\n",
84
+ " )\n",
85
+ "except Exception as exception:\n",
86
+ " print(\"Error inserting data into Supabase:\", exception)"
87
+ ]
88
+ }
89
+ ],
90
+ "metadata": {
91
+ "kernelspec": {
92
+ "display_name": "gaia",
93
+ "language": "python",
94
+ "name": "python3"
95
+ },
96
+ "language_info": {
97
+ "codemirror_mode": {
98
+ "name": "ipython",
99
+ "version": 3
100
+ },
101
+ "file_extension": ".py",
102
+ "mimetype": "text/x-python",
103
+ "name": "python",
104
+ "nbconvert_exporter": "python",
105
+ "pygments_lexer": "ipython3",
106
+ "version": "3.13.0"
107
+ }
108
+ },
109
+ "nbformat": 4,
110
+ "nbformat_minor": 5
111
+ }
notebooks/test.ipynb ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "code",
5
+ "execution_count": 1,
6
+ "id": "5b9902eb",
7
+ "metadata": {},
8
+ "outputs": [
9
+ {
10
+ "data": {
11
+ "application/vnd.jupyter.widget-view+json": {
12
+ "model_id": "903fa846aba0401ba19cada8e6c8822c",
13
+ "version_major": 2,
14
+ "version_minor": 0
15
+ },
16
+ "text/plain": [
17
+ "Split strings: 0%| | 0/165 [00:00<?, ?it/s]"
18
+ ]
19
+ },
20
+ "metadata": {},
21
+ "output_type": "display_data"
22
+ },
23
+ {
24
+ "data": {
25
+ "application/vnd.jupyter.widget-view+json": {
26
+ "model_id": "dcac27eb462440d4af110bad3875e30e",
27
+ "version_major": 2,
28
+ "version_minor": 0
29
+ },
30
+ "text/plain": [
31
+ "BM25S Count Tokens: 0%| | 0/165 [00:00<?, ?it/s]"
32
+ ]
33
+ },
34
+ "metadata": {},
35
+ "output_type": "display_data"
36
+ },
37
+ {
38
+ "data": {
39
+ "application/vnd.jupyter.widget-view+json": {
40
+ "model_id": "d8478a4875064b8b9dce4c307cafc4de",
41
+ "version_major": 2,
42
+ "version_minor": 0
43
+ },
44
+ "text/plain": [
45
+ "BM25S Compute Scores: 0%| | 0/165 [00:00<?, ?it/s]"
46
+ ]
47
+ },
48
+ "metadata": {},
49
+ "output_type": "display_data"
50
+ },
51
+ {
52
+ "name": "stdout",
53
+ "output_type": "stream",
54
+ "text": [
55
+ "BM25 Index initialized with 165 documents.\n"
56
+ ]
57
+ },
58
+ {
59
+ "data": {
60
+ "application/vnd.jupyter.widget-view+json": {
61
+ "model_id": "bd92d31479044b398df857e3bcd002d4",
62
+ "version_major": 2,
63
+ "version_minor": 0
64
+ },
65
+ "text/plain": [
66
+ "modules.json: 0%| | 0.00/229 [00:00<?, ?B/s]"
67
+ ]
68
+ },
69
+ "metadata": {},
70
+ "output_type": "display_data"
71
+ },
72
+ {
73
+ "data": {
74
+ "application/vnd.jupyter.widget-view+json": {
75
+ "model_id": "12e26abd3b3c4cfd8729ddaa7c22989e",
76
+ "version_major": 2,
77
+ "version_minor": 0
78
+ },
79
+ "text/plain": [
80
+ "config_sentence_transformers.json: 0%| | 0.00/205 [00:00<?, ?B/s]"
81
+ ]
82
+ },
83
+ "metadata": {},
84
+ "output_type": "display_data"
85
+ },
86
+ {
87
+ "data": {
88
+ "application/vnd.jupyter.widget-view+json": {
89
+ "model_id": "b6f025204e5b4e89b3f09675cd9c9c7d",
90
+ "version_major": 2,
91
+ "version_minor": 0
92
+ },
93
+ "text/plain": [
94
+ "README.md: 0.00B [00:00, ?B/s]"
95
+ ]
96
+ },
97
+ "metadata": {},
98
+ "output_type": "display_data"
99
+ },
100
+ {
101
+ "data": {
102
+ "application/vnd.jupyter.widget-view+json": {
103
+ "model_id": "3ef0c9c4684a462ca31eae8fea18a6a6",
104
+ "version_major": 2,
105
+ "version_minor": 0
106
+ },
107
+ "text/plain": [
108
+ "config.json: 0.00B [00:00, ?B/s]"
109
+ ]
110
+ },
111
+ "metadata": {},
112
+ "output_type": "display_data"
113
+ },
114
+ {
115
+ "data": {
116
+ "application/vnd.jupyter.widget-view+json": {
117
+ "model_id": "c200e6620591481c90bcd5f408adfe58",
118
+ "version_major": 2,
119
+ "version_minor": 0
120
+ },
121
+ "text/plain": [
122
+ "model.safetensors: 0%| | 0.00/298M [00:00<?, ?B/s]"
123
+ ]
124
+ },
125
+ "metadata": {},
126
+ "output_type": "display_data"
127
+ },
128
+ {
129
+ "data": {
130
+ "application/vnd.jupyter.widget-view+json": {
131
+ "model_id": "8aed0e33a1d841ecbf5c452b4f100bec",
132
+ "version_major": 2,
133
+ "version_minor": 0
134
+ },
135
+ "text/plain": [
136
+ "tokenizer_config.json: 0.00B [00:00, ?B/s]"
137
+ ]
138
+ },
139
+ "metadata": {},
140
+ "output_type": "display_data"
141
+ },
142
+ {
143
+ "data": {
144
+ "application/vnd.jupyter.widget-view+json": {
145
+ "model_id": "25480a7027e6469e8ea2d80b27131192",
146
+ "version_major": 2,
147
+ "version_minor": 0
148
+ },
149
+ "text/plain": [
150
+ "tokenizer.json: 0.00B [00:00, ?B/s]"
151
+ ]
152
+ },
153
+ "metadata": {},
154
+ "output_type": "display_data"
155
+ },
156
+ {
157
+ "data": {
158
+ "application/vnd.jupyter.widget-view+json": {
159
+ "model_id": "01dfe70506ba4d78bd270bdf96501d40",
160
+ "version_major": 2,
161
+ "version_minor": 0
162
+ },
163
+ "text/plain": [
164
+ "special_tokens_map.json: 0%| | 0.00/694 [00:00<?, ?B/s]"
165
+ ]
166
+ },
167
+ "metadata": {},
168
+ "output_type": "display_data"
169
+ },
170
+ {
171
+ "data": {
172
+ "application/vnd.jupyter.widget-view+json": {
173
+ "model_id": "299bfe9f54bf4d7e9dbac5bac484ffa6",
174
+ "version_major": 2,
175
+ "version_minor": 0
176
+ },
177
+ "text/plain": [
178
+ "config.json: 0%| | 0.00/297 [00:00<?, ?B/s]"
179
+ ]
180
+ },
181
+ "metadata": {},
182
+ "output_type": "display_data"
183
+ },
184
+ {
185
+ "ename": "SupabaseException",
186
+ "evalue": "supabase_url is required",
187
+ "output_type": "error",
188
+ "traceback": [
189
+ "\u001b[31m---------------------------------------------------------------------------\u001b[39m",
190
+ "\u001b[31mSupabaseException\u001b[39m Traceback (most recent call last)",
191
+ "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[1]\u001b[39m\u001b[32m, line 56\u001b[39m\n\u001b[32m 53\u001b[39m embeddings = SentenceTransformer(model_name_or_path=config[\u001b[33m\"\u001b[39m\u001b[33mmodels\u001b[39m\u001b[33m\"\u001b[39m][\u001b[33m\"\u001b[39m\u001b[33membeddings\u001b[39m\u001b[33m\"\u001b[39m][\u001b[33m\"\u001b[39m\u001b[33mmodel_name\u001b[39m\u001b[33m\"\u001b[39m], cache_folder=config[\u001b[33m\"\u001b[39m\u001b[33mmodels\u001b[39m\u001b[33m\"\u001b[39m][\u001b[33m\"\u001b[39m\u001b[33mcache_folder\u001b[39m\u001b[33m\"\u001b[39m])\n\u001b[32m 55\u001b[39m \u001b[38;5;66;03m# Supabase Vector Store\u001b[39;00m\n\u001b[32m---> \u001b[39m\u001b[32m56\u001b[39m supabase: Client = \u001b[43mcreate_client\u001b[49m\u001b[43m(\u001b[49m\u001b[43msupabase_url\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43msupabase_key\u001b[49m\u001b[43m)\u001b[49m\n\u001b[32m 58\u001b[39m \u001b[38;5;66;03m# Reranker Model (ModernBERT Cross-Encoder)\u001b[39;00m\n\u001b[32m 59\u001b[39m reranker = CrossEncoder(config[\u001b[33m\"\u001b[39m\u001b[33mmodels\u001b[39m\u001b[33m\"\u001b[39m][\u001b[33m\"\u001b[39m\u001b[33mreranker\u001b[39m\u001b[33m\"\u001b[39m][\u001b[33m\"\u001b[39m\u001b[33mmodel_name\u001b[39m\u001b[33m\"\u001b[39m], cache_folder=config[\u001b[33m\"\u001b[39m\u001b[33mmodels\u001b[39m\u001b[33m\"\u001b[39m][\u001b[33m\"\u001b[39m\u001b[33mcache_folder\u001b[39m\u001b[33m\"\u001b[39m])\n",
192
+ "\u001b[36mFile \u001b[39m\u001b[32m~/projects/gaia/.venv/lib/python3.13/site-packages/supabase/_sync/client.py:371\u001b[39m, in \u001b[36mcreate_client\u001b[39m\u001b[34m(supabase_url, supabase_key, options)\u001b[39m\n\u001b[32m 340\u001b[39m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34mcreate_client\u001b[39m(\n\u001b[32m 341\u001b[39m supabase_url: \u001b[38;5;28mstr\u001b[39m,\n\u001b[32m 342\u001b[39m supabase_key: \u001b[38;5;28mstr\u001b[39m,\n\u001b[32m 343\u001b[39m options: Optional[ClientOptions] = \u001b[38;5;28;01mNone\u001b[39;00m,\n\u001b[32m 344\u001b[39m ) -> Client:\n\u001b[32m 345\u001b[39m \u001b[38;5;250m \u001b[39m\u001b[33;03m\"\"\"Create client function to instantiate supabase client like JS runtime.\u001b[39;00m\n\u001b[32m 346\u001b[39m \n\u001b[32m 347\u001b[39m \u001b[33;03m Parameters\u001b[39;00m\n\u001b[32m (...)\u001b[39m\u001b[32m 369\u001b[39m \u001b[33;03m Client\u001b[39;00m\n\u001b[32m 370\u001b[39m \u001b[33;03m \"\"\"\u001b[39;00m\n\u001b[32m--> \u001b[39m\u001b[32m371\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[43mClient\u001b[49m\u001b[43m.\u001b[49m\u001b[43mcreate\u001b[49m\u001b[43m(\u001b[49m\n\u001b[32m 372\u001b[39m \u001b[43m \u001b[49m\u001b[43msupabase_url\u001b[49m\u001b[43m=\u001b[49m\u001b[43msupabase_url\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43msupabase_key\u001b[49m\u001b[43m=\u001b[49m\u001b[43msupabase_key\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43moptions\u001b[49m\u001b[43m=\u001b[49m\u001b[43moptions\u001b[49m\n\u001b[32m 373\u001b[39m \u001b[43m \u001b[49m\u001b[43m)\u001b[49m\n",
193
+ "\u001b[36mFile \u001b[39m\u001b[32m~/projects/gaia/.venv/lib/python3.13/site-packages/supabase/_sync/client.py:104\u001b[39m, in \u001b[36mClient.create\u001b[39m\u001b[34m(cls, supabase_url, supabase_key, options)\u001b[39m\n\u001b[32m 96\u001b[39m \u001b[38;5;129m@classmethod\u001b[39m\n\u001b[32m 97\u001b[39m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34mcreate\u001b[39m(\n\u001b[32m 98\u001b[39m \u001b[38;5;28mcls\u001b[39m,\n\u001b[32m (...)\u001b[39m\u001b[32m 101\u001b[39m options: Optional[ClientOptions] = \u001b[38;5;28;01mNone\u001b[39;00m,\n\u001b[32m 102\u001b[39m ) -> \u001b[33m\"\u001b[39m\u001b[33mClient\u001b[39m\u001b[33m\"\u001b[39m:\n\u001b[32m 103\u001b[39m auth_header = options.headers.get(\u001b[33m\"\u001b[39m\u001b[33mAuthorization\u001b[39m\u001b[33m\"\u001b[39m) \u001b[38;5;28;01mif\u001b[39;00m options \u001b[38;5;28;01melse\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m\n\u001b[32m--> \u001b[39m\u001b[32m104\u001b[39m client = \u001b[38;5;28;43mcls\u001b[39;49m\u001b[43m(\u001b[49m\u001b[43msupabase_url\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43msupabase_key\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43moptions\u001b[49m\u001b[43m)\u001b[49m\n\u001b[32m 106\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m auth_header \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[32m 107\u001b[39m \u001b[38;5;28;01mtry\u001b[39;00m:\n",
194
+ "\u001b[36mFile \u001b[39m\u001b[32m~/projects/gaia/.venv/lib/python3.13/site-packages/supabase/_sync/client.py:56\u001b[39m, in \u001b[36mClient.__init__\u001b[39m\u001b[34m(self, supabase_url, supabase_key, options)\u001b[39m\n\u001b[32m 42\u001b[39m \u001b[38;5;250m\u001b[39m\u001b[33;03m\"\"\"Instantiate the client.\u001b[39;00m\n\u001b[32m 43\u001b[39m \n\u001b[32m 44\u001b[39m \u001b[33;03mParameters\u001b[39;00m\n\u001b[32m (...)\u001b[39m\u001b[32m 52\u001b[39m \u001b[33;03m `DEFAULT_OPTIONS` dict.\u001b[39;00m\n\u001b[32m 53\u001b[39m \u001b[33;03m\"\"\"\u001b[39;00m\n\u001b[32m 55\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m supabase_url:\n\u001b[32m---> \u001b[39m\u001b[32m56\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m SupabaseException(\u001b[33m\"\u001b[39m\u001b[33msupabase_url is required\u001b[39m\u001b[33m\"\u001b[39m)\n\u001b[32m 57\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m supabase_key:\n\u001b[32m 58\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m SupabaseException(\u001b[33m\"\u001b[39m\u001b[33msupabase_key is required\u001b[39m\u001b[33m\"\u001b[39m)\n",
195
+ "\u001b[31mSupabaseException\u001b[39m: supabase_url is required"
196
+ ]
197
+ }
198
+ ],
199
+ "source": [
200
+ "\"\"\"\n",
201
+ "GAIA Agent with Multi-Modal File Processing and Hybrid Retrieval.\n",
202
+ "\n",
203
+ "This module defines a LangGraph agent that can:\n",
204
+ "1. Retrieve similar questions using Hybrid Search (Vector + BM25) and Reranking\n",
205
+ "2. Process files using tools (PDF, XLSX, MP3, etc.)\n",
206
+ "3. Answer questions using web search, calculator, and other tools\n",
207
+ "\"\"\"\n",
208
+ "\n",
209
+ "import os\n",
210
+ "import bm25s\n",
211
+ "from dotenv import load_dotenv\n",
212
+ "\n",
213
+ "from langgraph.graph import START, END, StateGraph\n",
214
+ "from langgraph.prebuilt import tools_condition, ToolNode\n",
215
+ "\n",
216
+ "from sentence_transformers import SentenceTransformer, CrossEncoder\n",
217
+ "from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint\n",
218
+ "from langchain_core.messages import HumanMessage\n",
219
+ "from supabase.client import Client, create_client\n",
220
+ "\n",
221
+ "from utils import load_config, load_prompt, init_bm25_index, reciprocal_rank_fusion\n",
222
+ "from tools import tools_list\n",
223
+ "from states import AgentState\n",
224
+ "\n",
225
+ "load_dotenv()\n",
226
+ "config = load_config()\n",
227
+ "\n",
228
+ "# Environment details and others\n",
229
+ "hf_key = os.getenv(\"HF_INFERENCE_KEY\")\n",
230
+ "supabase_url = os.getenv(\"SUPABASE_URL\")\n",
231
+ "supabase_key = os.getenv(\"SUPABASE_SERVICE_KEY\")\n",
232
+ "\n",
233
+ "# ============================================\n",
234
+ "# Model & Embeddings Setup\n",
235
+ "# ============================================\n",
236
+ "\n",
237
+ "# Model List\n",
238
+ "\n",
239
+ "llm_model_name = \"Qwen/Qwen3-32B-Instruct\"\n",
240
+ "\n",
241
+ "enable_keyword_search = config[\"retrievers\"][\"enable_keyword_search\"]\n",
242
+ "enable_vector_search = config[\"retrievers\"][\"enable_vector_search\"]\n",
243
+ "\n",
244
+ "# BM25 Retriever\n",
245
+ "bm25_retriever, bm25_corpus, bm25_ids = None, None, None\n",
246
+ "if enable_keyword_search:\n",
247
+ " bm25_retriever, bm25_corpus, bm25_ids = init_bm25_index(corpus_file=config[\"data\"])\n",
248
+ "\n",
249
+ "embeddings, supabase = None, None\n",
250
+ "if enable_vector_search:\n",
251
+ " # Embeddings for Vector Search\n",
252
+ " embeddings = SentenceTransformer(model_name_or_path=config[\"models\"][\"embeddings\"][\"model_name\"], cache_folder=config[\"models\"][\"cache_folder\"])\n",
253
+ "\n",
254
+ " # Supabase Vector Store\n",
255
+ " supabase: Client = create_client(supabase_url, supabase_key)\n",
256
+ "\n",
257
+ "# Reranker Model (ModernBERT Cross-Encoder)\n",
258
+ "reranker = CrossEncoder(config[\"models\"][\"reranker\"][\"model_name\"], cache_folder=config[\"models\"][\"cache_folder\"])\n",
259
+ "\n",
260
+ "# LLM for Agent\n",
261
+ "llm = HuggingFaceEndpoint(\n",
262
+ " repo_id=config[\"models\"][\"llm\"][\"model_name\"],\n",
263
+ " temperature=config[\"models\"][\"llm\"][\"parameters\"][\"temperature\"],\n",
264
+ " repetition_penalty=config[\"models\"][\"llm\"][\"parameters\"][\"repetition_penalty\"],\n",
265
+ " provider=config[\"models\"][\"llm\"][\"parameters\"][\"provider\"],\n",
266
+ " huggingfacehub_api_token=hf_key\n",
267
+ ")\n",
268
+ "\n",
269
+ "agent_llm = ChatHuggingFace(llm=llm)\n",
270
+ "agent_with_tools = agent_llm.bind_tools(tools_list)\n",
271
+ "\n",
272
+ "\n",
273
+ "# ============================================\n",
274
+ "# Graph Nodes\n",
275
+ "# ============================================\n",
276
+ "\n",
277
+ "def retriever_node(state: AgentState) -> AgentState:\n",
278
+ " \"\"\"\n",
279
+ " Hybrid Search Node: Retrieve docs via Vector Search + BM25, combine with RRF.\n",
280
+ " \"\"\"\n",
281
+ " print(\"--- RETRIEVER NODE ---\")\n",
282
+ " messages = state.get(\"messages\", [])\n",
283
+ " if not messages:\n",
284
+ " return {\"retrieved_docs\": []}\n",
285
+ " \n",
286
+ " question_content = messages[0].content\n",
287
+ " \n",
288
+ " if not enable_vector_search and not enable_keyword_search:\n",
289
+ " print(\"No retrieval method enabled.\")\n",
290
+ " return {\"retrieved_docs\": []}\n",
291
+ " \n",
292
+ " # 1. Vector Search\n",
293
+ " vector_docs = []\n",
294
+ " if supabase and embeddings:\n",
295
+ " try:\n",
296
+ " response = supabase.rpc(\n",
297
+ " config[\"retrievers\"][\"vector_store\"][\"query\"],\n",
298
+ " {\"query_embedding\": embeddings.encode(question_content).tolist(), \n",
299
+ " \"match_count\": config[\"retrievers\"][\"vector_store\"][\"k\"], \n",
300
+ " \"match_threshold\": config[\"retrievers\"][\"vector_store\"][\"threshold\"]\n",
301
+ " } \n",
302
+ " ).execute()\n",
303
+ "\n",
304
+ " vector_docs = response.data\n",
305
+ "\n",
306
+ " except Exception as e:\n",
307
+ " print(f\"Vector search error: {e}\")\n",
308
+ " \n",
309
+ " # 2. BM25 Search\n",
310
+ " bm25_docs = []\n",
311
+ " if bm25_retriever and bm25_corpus and bm25_ids:\n",
312
+ " try:\n",
313
+ " query_tokens = bm25s.tokenize([question_content], stopwords=\"en\")\n",
314
+ " results, scores = bm25_retriever.retrieve(query_tokens, k=config[\"retrievers\"][\"bm25\"][\"k\"])\n",
315
+ " indices = results[0]\n",
316
+ " \n",
317
+ " for i, idx in enumerate(indices):\n",
318
+ " content = bm25_corpus[idx]\n",
319
+ " task_id = bm25_ids[idx]\n",
320
+ " score = scores[0][i]\n",
321
+ " bm25_dict = {\"content\":content, \"metadata\": {\"source\": \"bm25_search\", \"task_id\": task_id, \"score\": score}}\n",
322
+ " bm25_docs.append(bm25_dict)\n",
323
+ " except Exception as e:\n",
324
+ " print(f\"BM25 search error: {e}\")\n",
325
+ "\n",
326
+ " # 3. RRF Fusion\n",
327
+ " final_candidates = []\n",
328
+ " if vector_docs and bm25_docs:\n",
329
+ " fused = reciprocal_rank_fusion([vector_docs, bm25_docs])\n",
330
+ " final_candidates = [id for id, doc, score in fused]\n",
331
+ " else:\n",
332
+ " final_candidates = vector_docs + bm25_docs\n",
333
+ " final_candidates = [doc[\"metadata\"][\"task_id\"] for doc in final_candidates]\n",
334
+ " \n",
335
+ " top_candidates = final_candidates[:20]\n",
336
+ " \n",
337
+ " return {\"retrieved_docs\": top_candidates}"
338
+ ]
339
+ }
340
+ ],
341
+ "metadata": {
342
+ "kernelspec": {
343
+ "display_name": "gaia",
344
+ "language": "python",
345
+ "name": "python3"
346
+ },
347
+ "language_info": {
348
+ "codemirror_mode": {
349
+ "name": "ipython",
350
+ "version": 3
351
+ },
352
+ "file_extension": ".py",
353
+ "mimetype": "text/x-python",
354
+ "name": "python",
355
+ "nbconvert_exporter": "python",
356
+ "pygments_lexer": "ipython3",
357
+ "version": "3.13.1"
358
+ }
359
+ },
360
+ "nbformat": 4,
361
+ "nbformat_minor": 5
362
+ }
requirements.txt CHANGED
@@ -44,6 +44,7 @@ tavily-python>=0.5.0
44
  wikipedia>=1.4.0
45
  arxiv>=2.1.0
46
  trafilatura>=1.6.0
 
47
 
48
  # Excel
49
  openpyxl>=3.1.0
 
44
  wikipedia>=1.4.0
45
  arxiv>=2.1.0
46
  trafilatura>=1.6.0
47
+ youtube-transcript-api>=1.0.0
48
 
49
  # Excel
50
  openpyxl>=3.1.0
scripts/create_vector_database.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Populate the Supabase vector store with GAIA benchmark embeddings.
3
+
4
+ Prerequisites:
5
+ - SUPABASE_URL and SUPABASE_SERVICE_KEY in .env
6
+ - pgvector extension enabled in Supabase
7
+ - gaia_documents table created with columns: content (text), metadata (jsonb), embedding (vector)
8
+ """
9
+ import os
10
+ import json
11
+ from dotenv import load_dotenv
12
+ from supabase.client import Client, create_client
13
+ from sentence_transformers import SentenceTransformer
14
+ from gaia.utils import load_config
15
+
16
+ load_dotenv()
17
+
18
+ config = load_config()
19
+ data_path = config["data"]
20
+
21
+ supabase_url = os.getenv("SUPABASE_URL")
22
+ supabase_key = os.getenv("SUPABASE_SERVICE_KEY")
23
+
24
+ supabase: Client = create_client(supabase_url, supabase_key)
25
+ embeddings = SentenceTransformer(
26
+ model_name_or_path=config["vector_store"]["embedding_model_name"],
27
+ cache_folder=config["models"]["cache_folder"],
28
+ )
29
+
30
+ with open(data_path, "r") as jsonl_file:
31
+ json_list = list(jsonl_file)
32
+
33
+ documents = []
34
+ for json_str in json_list:
35
+ json_data = json.loads(json_str)
36
+ content = json_data["Question"]
37
+ embedding = embeddings.encode(content, normalize_embeddings=True).tolist()
38
+ documents.append({
39
+ "content": content,
40
+ "metadata": {
41
+ "source": "vector_search",
42
+ "task_id": json_data["task_id"],
43
+ },
44
+ "embedding": embedding,
45
+ })
46
+
47
+ print(f"Inserting {len(documents)} documents into Supabase...")
48
+ try:
49
+ response = supabase.table("gaia_documents").insert(documents).execute()
50
+ print("Done.")
51
+ except Exception as e:
52
+ print("Error inserting data into Supabase:", e)