KPatelis commited on
Commit
2834b30
·
verified ·
1 Parent(s): 625a548

Upload 15 files

Browse files
.python-version CHANGED
@@ -1 +1 @@
1
- 3.12
 
1
+ 3.13
agent.py CHANGED
@@ -1,81 +1,299 @@
 
 
 
 
 
 
 
 
 
1
  import os
 
 
 
2
  from dotenv import load_dotenv
3
- from langgraph.graph import START, StateGraph, MessagesState
4
- from langgraph.prebuilt import tools_condition
5
- from langgraph.prebuilt import ToolNode
6
- from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint, HuggingFaceEmbeddings
7
- from langchain_community.vectorstores import SupabaseVectorStore
8
- from langchain_core.messages import HumanMessage
9
- from langchain_core.tools.retriever import create_retriever_tool
10
  from supabase.client import Client, create_client
11
- from utils import load_prompt
12
- from tools import calculator, duck_web_search, wiki_search, arxiv_search
 
 
13
 
14
  load_dotenv()
 
15
 
16
- # Create retriever
17
- embeddings = HuggingFaceEmbeddings(model_name="Alibaba-NLP/gte-modernbert-base") # dim=768
 
 
18
 
19
- supabase: Client = create_client(os.getenv("SUPABASE_URL"), os.getenv("SUPABASE_SERVICE_KEY"))
20
- vector_store = SupabaseVectorStore(
21
- client=supabase,
22
- embedding= embeddings,
23
- table_name="gaia_documents",
24
- query_name="match_documents_langchain",
25
- )
26
 
27
- retriever = create_retriever_tool(
28
- retriever=vector_store.as_retriever(),
29
- name="ModernBERT Retriever",
30
- description="A retriever of similar questions from a vector store.",
31
- )
 
 
 
 
 
 
 
 
 
 
 
32
 
33
- tools = [calculator, duck_web_search, wiki_search, arxiv_search]
 
34
 
35
- model_id = "Qwen/Qwen3-32B"
 
36
 
 
37
  llm = HuggingFaceEndpoint(
38
- repo_id=model_id,
39
- temperature=0,
40
- repetition_penalty=1.03,
41
- provider="auto",
42
- huggingfacehub_api_token=os.getenv("HF_INFERENCE_KEY")
43
  )
44
 
45
- agent = ChatHuggingFace(llm=llm)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
- agent_with_tools = agent.bind_tools(tools)
48
 
49
- def retriever_node(state: MessagesState):
50
- """RAG node"""
51
- similar_question = vector_store.similarity_search(state["messages"][0].content)
52
- response = [HumanMessage(f"Here I provide a similar question and answer for reference: \n\n{similar_question[0].page_content}")]
53
- return {"messages": response}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
- def processor_node(state: MessagesState):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
- system_prompt = load_prompt("prompt.yaml")
 
 
58
 
59
- messages = state.get("messages", [])
60
- response = [agent_with_tools.invoke([system_prompt] + messages)]
61
- """Agent node that answers questions"""
62
- return {"messages": response}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
  def agent_graph():
65
- workflow = StateGraph(MessagesState)
 
 
 
66
 
67
- ## Add nodes
 
68
  workflow.add_node("retriever_node", retriever_node)
 
69
  workflow.add_node("processor_node", processor_node)
70
- workflow.add_node("tools", ToolNode(tools))
71
-
72
- ## Add edges
73
- workflow.add_edge(START, "retriever_node")
74
- workflow.add_edge("retriever_node", "processor_node")
75
- workflow.add_conditional_edges("processor_node", tools_condition)
76
- workflow.add_edge("tools", "processor_node")
77
-
78
- # Compile graph
79
- graph = workflow.compile()
80
 
81
- return graph
 
 
 
 
 
 
 
 
 
 
 
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
+ import requests
13
+ from pathlib import Path
14
  from dotenv import load_dotenv
15
+
16
+ from langgraph.graph import START, END, StateGraph
17
+ from langgraph.prebuilt import tools_condition, ToolNode
18
+
19
+ from sentence_transformers import SentenceTransformer, CrossEncoder
20
+ from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
21
+ from langchain_core.messages import HumanMessage, SystemMessage
22
  from supabase.client import Client, create_client
23
+
24
+ from utils import load_config, load_prompt, init_bm25_index, reciprocal_rank_fusion
25
+ from tools import tools_list
26
+ from states import AgentState
27
 
28
  load_dotenv()
29
+ config = load_config()
30
 
31
+ # Environment details and others
32
+ hf_key = os.getenv("HF_INFERENCE_KEY")
33
+ supabase_url = os.getenv("SUPABASE_URL")
34
+ supabase_key = os.getenv("SUPABASE_SERVICE_KEY")
35
 
36
+ # ============================================
37
+ # Model & Embeddings Setup
38
+ # ============================================
 
 
 
 
39
 
40
+ enable_keyword_search = config["retrievers"]["enable_keyword_search"]
41
+ enable_vector_search = config["retrievers"]["enable_vector_search"]
42
+
43
+ # BM25 Retriever
44
+ bm25_retriever, bm25_corpus, bm25_ids = None, None, None
45
+ if enable_keyword_search:
46
+ bm25_retriever, bm25_corpus, bm25_ids = init_bm25_index(corpus_file=config["data"])
47
+
48
+ bm25_id_to_text = {}
49
+ if bm25_corpus and bm25_ids:
50
+ bm25_id_to_text = dict(zip(bm25_ids, bm25_corpus))
51
+
52
+ embeddings, supabase = None, None
53
+ if enable_vector_search:
54
+ # Embeddings for Vector Search
55
+ embeddings = SentenceTransformer(model_name_or_path=config["models"]["embeddings"]["model_name"], cache_folder=config["models"]["cache_folder"])
56
 
57
+ # Supabase Vector Store
58
+ supabase: Client = create_client(supabase_url, supabase_key)
59
 
60
+ # Reranker Model (ModernBERT Cross-Encoder)
61
+ reranker = CrossEncoder(config["models"]["reranker"]["model_name"], cache_folder=config["models"]["cache_folder"])
62
 
63
+ # LLM for Agent
64
  llm = HuggingFaceEndpoint(
65
+ repo_id=config["models"]["llm"]["model_name"],
66
+ temperature=config["models"]["llm"]["parameters"]["temperature"],
67
+ repetition_penalty=config["models"]["llm"]["parameters"]["repetition_penalty"],
68
+ provider=config["models"]["llm"]["parameters"]["provider"],
69
+ huggingfacehub_api_token=hf_key
70
  )
71
 
72
+ agent_llm = ChatHuggingFace(llm=llm)
73
+ agent_with_tools = agent_llm.bind_tools(tools_list)
74
+
75
+ _system_prompt = load_prompt("prompts/prompt.yaml")
76
+ _thinking_enabled = config["models"]["llm"]["parameters"].get("thinking_enabled", True)
77
+
78
+
79
+ # ============================================
80
+ # Graph Nodes
81
+ # ============================================
82
+
83
+ def file_downloader_node(state: AgentState) -> AgentState:
84
+ """
85
+ Download the task file from the scoring API if one is associated with the question.
86
+ Saves to a local directory and stores the path in state.
87
+ """
88
+ print("--- FILE DOWNLOADER NODE ---")
89
+ file_name = state.get("file_name", "")
90
+ task_id = state.get("task_id", "")
91
+
92
+ if not file_name or not task_id:
93
+ return {"file_path": ""}
94
+
95
+ safe_name = Path(file_name).name
96
+ if not safe_name:
97
+ print(f"File download skipped: invalid file_name '{file_name}'")
98
+ return {"file_path": ""}
99
+
100
+ save_dir = Path(config["api"]["files_dir"]) / task_id
101
+ save_dir.mkdir(parents=True, exist_ok=True)
102
+ local_path = save_dir / safe_name
103
+
104
+ if local_path.exists():
105
+ print(f"File already cached: {local_path}")
106
+ return {"file_path": str(local_path)}
107
+
108
+ file_url = f"{config['api']['base_url']}/files/{task_id}"
109
+ try:
110
+ response = requests.get(file_url, timeout=30)
111
+ response.raise_for_status()
112
+ if not response.content:
113
+ print(f"File download failed ({file_url}): empty response body")
114
+ return {"file_path": ""}
115
+ local_path.write_bytes(response.content)
116
+ print(f"Downloaded: {safe_name} → {local_path}")
117
+ return {"file_path": str(local_path)}
118
+ except Exception as e:
119
+ print(f"File download failed ({file_url}): {e}")
120
+ return {"file_path": ""}
121
 
 
122
 
123
+ def retriever_node(state: AgentState) -> AgentState:
124
+ """
125
+ Hybrid Search Node: Retrieve docs via Vector Search + BM25, combine with RRF.
126
+ """
127
+ print("--- RETRIEVER NODE ---")
128
+ messages = state.get("messages", [])
129
+ if not messages:
130
+ return {"retrieved_docs": []}
131
+
132
+ question_content = messages[0].content
133
+
134
+ if not enable_vector_search and not enable_keyword_search:
135
+ print("No retrieval method enabled.")
136
+ return {"retrieved_docs": []}
137
+
138
+ # 1. Vector Search
139
+ vector_docs = []
140
+ if supabase and embeddings:
141
+ try:
142
+ response = supabase.rpc(
143
+ config["retrievers"]["vector_store"]["query"],
144
+ {"query_embedding": embeddings.encode(question_content).tolist(),
145
+ "match_count": config["retrievers"]["vector_store"]["k"],
146
+ "match_threshold": config["retrievers"]["vector_store"]["threshold"]
147
+ }
148
+ ).execute()
149
 
150
+ vector_docs = response.data
151
+
152
+ except Exception as e:
153
+ print(f"Vector search error: {e}")
154
+
155
+ # 2. BM25 Search
156
+ bm25_docs = []
157
+ if bm25_retriever and bm25_corpus and bm25_ids:
158
+ try:
159
+ query_tokens = bm25s.tokenize([question_content], stopwords="en")
160
+ results, scores = bm25_retriever.retrieve(query_tokens, k=config["retrievers"]["bm25"]["k"])
161
+ indices = results[0]
162
+
163
+ for i, idx in enumerate(indices):
164
+ content = bm25_corpus[idx]
165
+ task_id = bm25_ids[idx]
166
+ score = scores[0][i]
167
+ bm25_dict = {"content":content, "metadata": {"source": "bm25_search", "task_id": task_id, "score": score}}
168
+ bm25_docs.append(bm25_dict)
169
+ except Exception as e:
170
+ print(f"BM25 search error: {e}")
171
+
172
+ # 3. RRF Fusion
173
+ final_candidates = []
174
+ if vector_docs and bm25_docs:
175
+ fused = reciprocal_rank_fusion([vector_docs, bm25_docs])
176
+ final_candidates = [id for id, doc, score in fused]
177
+ else:
178
+ final_candidates = vector_docs + bm25_docs
179
+ final_candidates = [doc["metadata"]["task_id"] for doc in final_candidates]
180
 
181
+ top_candidates = final_candidates[:20]
182
+
183
+ return {"retrieved_docs": top_candidates}
184
 
185
+
186
+ def reranker_node(state: AgentState) -> AgentState:
187
+ """
188
+ Reranker Node: Re-order candidates using Cross-Encoder and return top 3.
189
+ """
190
+ print("--- RERANKER NODE ---")
191
+ candidates = state.get("retrieved_docs", [])
192
+ messages = state.get("messages", [])
193
+
194
+ if not candidates or not messages:
195
+ return {"messages": []}
196
+
197
+ question = messages[0].content
198
+
199
+ # Deduplicate candidates — candidates are task_id strings; resolve to text via corpus lookup
200
+ unique_candidates = []
201
+ seen_content = set()
202
+ for task_id in candidates:
203
+ text = bm25_id_to_text.get(task_id)
204
+ if text and text not in seen_content:
205
+ unique_candidates.append(text)
206
+ seen_content.add(text)
207
+
208
+ if not unique_candidates:
209
+ return {"messages": []}
210
+
211
+ pairs = [[question, doc_text] for doc_text in unique_candidates]
212
+
213
+ try:
214
+ scores = reranker.predict(pairs)
215
+
216
+ scored_docs = sorted(
217
+ zip(unique_candidates, scores),
218
+ key=lambda x: x[1],
219
+ reverse=True
220
+ )
221
+
222
+ top_k = config["retrievers"]["final_rrf_k"]
223
+ top_results = scored_docs[:top_k]
224
+
225
+ context_str = "Here are similar questions and answers for reference:\n\n"
226
+ for i, (doc_text, score) in enumerate(top_results):
227
+ context_str += f"--- Example {i+1} (Score: {score:.2f}) ---\n{doc_text}\n\n"
228
+
229
+ context_message = HumanMessage(content=context_str)
230
+
231
+ return {"messages": [context_message]}
232
+
233
+ except Exception as e:
234
+ print(f"Reranker error: {e}")
235
+ if unique_candidates:
236
+ fallback_msg = HumanMessage(content=f"Reference (Fallback):\n{unique_candidates[0]}")
237
+ return {"messages": [fallback_msg]}
238
+
239
+ return {"messages": []}
240
+
241
+
242
+ def processor_node(state: AgentState) -> AgentState:
243
+ """
244
+ Processor Node: Main LLM agent that answers the question.
245
+ """
246
+ prompt_content = _system_prompt.content + ("" if _thinking_enabled else "\n/no_think")
247
+ system_prompt = SystemMessage(content=prompt_content)
248
+ messages = state.get("messages", [])
249
+ file_name = state.get("file_name", "")
250
+ file_path = state.get("file_path", "")
251
+
252
+ full_messages = [system_prompt]
253
+
254
+ if file_name:
255
+ if file_path:
256
+ file_msg = HumanMessage(
257
+ content=f"Note: A file named '{file_name}' is associated with this question. It is available at path: {file_path}"
258
+ )
259
+ else:
260
+ file_msg = HumanMessage(
261
+ content=f"Note: A file named '{file_name}' is associated with this question, but it could not be downloaded."
262
+ )
263
+ full_messages.append(file_msg)
264
+
265
+ full_messages.extend(messages)
266
+
267
+ response = agent_with_tools.invoke(full_messages)
268
+
269
+ return {"messages": [response]}
270
+
271
+
272
+ # ============================================
273
+ # Graph Construction
274
+ # ============================================
275
 
276
  def agent_graph():
277
+ """
278
+ Build and compile the agent graph.
279
+ """
280
+ workflow = StateGraph(AgentState)
281
 
282
+ # Add nodes
283
+ workflow.add_node("file_downloader_node", file_downloader_node)
284
  workflow.add_node("retriever_node", retriever_node)
285
+ workflow.add_node("reranker_node", reranker_node)
286
  workflow.add_node("processor_node", processor_node)
287
+ workflow.add_node("tools", ToolNode(tools_list))
 
 
 
 
 
 
 
 
 
288
 
289
+ # Add edges
290
+ workflow.add_edge(START, "file_downloader_node")
291
+ workflow.add_edge("file_downloader_node", "retriever_node")
292
+ workflow.add_edge("retriever_node", "reranker_node")
293
+ workflow.add_edge("reranker_node", "processor_node")
294
+ workflow.add_edge("tools", "processor_node")
295
+ workflow.add_conditional_edges("processor_node", tools_condition, {"tools": "tools", END: END})
296
+
297
+
298
+ compiled = workflow.compile()
299
+ return compiled.with_config({"recursion_limit": config["graph"]["recursion_limit"]})
app.py CHANGED
@@ -1,4 +1,5 @@
1
  import os
 
2
  import gradio as gr
3
  import requests
4
  import inspect
@@ -16,10 +17,16 @@ class BasicAgent:
16
  def __init__(self):
17
  self.agent = agent_graph()
18
  print("BasicAgent initialized.")
19
- def __call__(self, question: str) -> str:
20
  messages = [HumanMessage(content=question)]
21
- response = self.agent.invoke({"messages": messages})
22
- fixed_answer = response['messages'][-1].content[16:]
 
 
 
 
 
 
23
  print(f"Agent returning fixed answer: {fixed_answer}")
24
  return fixed_answer
25
 
@@ -80,11 +87,12 @@ def run_and_submit_all( profile: gr.OAuthProfile | None):
80
  for item in questions_data:
81
  task_id = item.get("task_id")
82
  question_text = item.get("question")
 
83
  if not task_id or question_text is None:
84
  print(f"Skipping item with missing task_id or question: {item}")
85
  continue
86
  try:
87
- submitted_answer = agent(question_text)
88
  answers_payload.append({"task_id": task_id, "submitted_answer": submitted_answer})
89
  results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": submitted_answer})
90
  except Exception as e:
 
1
  import os
2
+ import re
3
  import gradio as gr
4
  import requests
5
  import inspect
 
17
  def __init__(self):
18
  self.agent = agent_graph()
19
  print("BasicAgent initialized.")
20
+ def __call__(self, question: str, task_id: str = "", file_name: str = "") -> str:
21
  messages = [HumanMessage(content=question)]
22
+ response = self.agent.invoke({
23
+ "messages": messages,
24
+ "task_id": task_id,
25
+ "file_name": file_name
26
+ })
27
+ content = response['messages'][-1].content
28
+ match = re.search(r'FINAL ANSWER:\s*(.*)', content, re.DOTALL)
29
+ fixed_answer = match.group(1).strip() if match else content.strip()
30
  print(f"Agent returning fixed answer: {fixed_answer}")
31
  return fixed_answer
32
 
 
87
  for item in questions_data:
88
  task_id = item.get("task_id")
89
  question_text = item.get("question")
90
+ file_name = item.get("file_name", "")
91
  if not task_id or question_text is None:
92
  print(f"Skipping item with missing task_id or question: {item}")
93
  continue
94
  try:
95
+ submitted_answer = agent(question_text, task_id=task_id, file_name=file_name)
96
  answers_payload.append({"task_id": task_id, "submitted_answer": submitted_answer})
97
  results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": submitted_answer})
98
  except Exception as e:
config.yaml ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project: LangGraph HF Agent for GAIA
2
+ # config.yaml
3
+ data: "data/metadata.jsonl" # Path to the GAIA documents dataset
4
+ retrievers:
5
+ enable_vector_search: true # Enable vector-based document retrieval
6
+ enable_keyword_search: true # Enable keyword-based document retrieval
7
+ final_rrf_k: 3 # Number of top documents to consider after reciprocal rank fusion
8
+ vector_store:
9
+ table: "gaia_documents" # Type of vector store (e.g., faiss, chroma)
10
+ query: "match_documents" # Method to query the vector store
11
+ k: 10 # Number of top documents to retrieve
12
+ threshold: 0.5 # Similarity threshold for document retrieval
13
+ bm25:
14
+ k: 5 # Number of top documents to retrieve using keyword search
15
+ models:
16
+ cache_folder: "./models/hf_cache" # Directory to cache Hugging Face models
17
+ embeddings:
18
+ model_name: "Alibaba-NLP/gte-modernbert-base" # Hugging Face embedding model ID
19
+ reranker:
20
+ model_name: "Alibaba-NLP/gte-reranker-modernbert-base" # Hugging Face model ID for reranking
21
+ llm:
22
+ model_name: "Qwen/Qwen3-32B-Instruct" # Hugging Face model ID
23
+ parameters:
24
+ temperature: 0
25
+ repetition_penalty: 1.3
26
+ provider: "auto"
27
+ thinking_enabled: false
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
35
+ # max_new_tokens: 512
36
+ # repetition_penalty: 1.1
37
+
38
+ graph:
39
+ recursion_limit: 20 # Max steps before the graph terminates
40
+ thread_id: "default-user" # Default session identifier
41
+ memory_type: "sqlite" # Persistence method for checkpointers
42
+
43
+ api:
44
+ base_url: "https://agents-course-unit4-scoring.hf.space"
45
+ files_dir: "./data/task_files" # Local directory for downloaded task files
46
+
create_vector_database.ipynb CHANGED
@@ -2,78 +2,80 @@
2
  "cells": [
3
  {
4
  "cell_type": "code",
5
- "execution_count": null,
6
  "id": "a9f7a25f",
7
  "metadata": {},
8
- "outputs": [
9
- {
10
- "name": "stderr",
11
- "output_type": "stream",
12
- "text": [
13
- "/home/kpatelis/projects/Agents_Course_Assignment/.venv/lib/python3.12/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n",
14
- " from .autonotebook import tqdm as notebook_tqdm\n"
15
- ]
16
- }
17
- ],
18
  "source": [
 
19
  "import os\n",
20
  "import json\n",
21
  "from dotenv import load_dotenv\n",
22
  "from supabase.client import Client, create_client\n",
23
- "from langchain_huggingface import HuggingFaceEmbeddings\n",
24
- "from langchain.schema import Document\n",
25
  "\n",
26
- "load_dotenv()"
27
- ]
28
- },
29
- {
30
- "cell_type": "code",
31
- "execution_count": null,
32
- "id": "2c948d46",
33
- "metadata": {},
34
- "outputs": [],
35
- "source": [
36
- "supabase: Client = create_client(\n",
37
- " os.environ.get(\"SUPABASE_URL\"), \n",
38
- " os.environ.get(\"SUPABASE_SERVICE_KEY\"))\n",
39
  "\n",
40
- "embeddings = HuggingFaceEmbeddings(model_name=\"Alibaba-NLP/gte-modernbert-base\")"
 
41
  ]
42
  },
43
  {
44
  "cell_type": "code",
45
- "execution_count": 15,
46
  "id": "f2c5492b",
47
  "metadata": {},
48
- "outputs": [],
 
 
 
 
 
 
 
 
 
 
 
49
  "source": [
50
- "with open('metadata.jsonl', 'r') as jsonl_file:\n",
 
51
  " json_list = list(jsonl_file)\n",
52
  "\n",
53
  "documents = []\n",
54
  "for json_str in json_list:\n",
55
  " json_data = json.loads(json_str)\n",
56
- " content = f\"Question : {json_data['Question']}\\n\\nFinal answer : {json_data['Final answer']}\"\n",
57
- " embedding = embeddings.embed_query(content)\n",
58
  " document = {\n",
59
- " \"content\" : content,\n",
60
- " \"metadata\" : {\n",
61
- " \"source\" : 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": null,
71
  "id": "26ddbafd",
72
  "metadata": {},
73
  "outputs": [],
74
  "source": [
75
- "# pgvector needs to be enabled, to turn to vector database\n",
76
- "# Table needs to be created beforehand in Supabase, with column types\n",
 
 
77
  "try:\n",
78
  " response = (\n",
79
  " supabase.table(\"gaia_documents\")\n",
@@ -87,7 +89,7 @@
87
  ],
88
  "metadata": {
89
  "kernelspec": {
90
- "display_name": ".venv",
91
  "language": "python",
92
  "name": "python3"
93
  },
@@ -101,7 +103,7 @@
101
  "name": "python",
102
  "nbconvert_exporter": "python",
103
  "pygments_lexer": "ipython3",
104
- "version": "3.12.3"
105
  }
106
  },
107
  "nbformat": 4,
 
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",
 
89
  ],
90
  "metadata": {
91
  "kernelspec": {
92
+ "display_name": "gaia",
93
  "language": "python",
94
  "name": "python3"
95
  },
 
103
  "name": "python",
104
  "nbconvert_exporter": "python",
105
  "pygments_lexer": "ipython3",
106
+ "version": "3.13.0"
107
  }
108
  },
109
  "nbformat": 4,
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 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)
data/metadata.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
prompts/prompt.yaml ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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**: If you need external information, use `duck_web_search` or `tavily_web_search`.
13
+ - Be specific in your queries.
14
+ - Use `wiki_search` for broad factual context.
15
+ - If a search result points to a specific URL that likely contains the answer, use `fetch_webpage` to read the full page — do not rely on the search snippet alone.
16
+ 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`.
17
+ 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.
18
+
19
+ # REASONING FRAMEWORK
20
+ Follow this Chain-of-Thought (CoT) process for every step:
21
+ 1. **Analyze**: What is the core question? What data do I have? What is missing?
22
+ 2. **Plan**: What is the next best tool to use? Why?
23
+ 3. **Execute**: Call the tool.
24
+ 4. **Observe**: Analyze the tool output. Does it answer the question?
25
+ 5. **Refine**: If the output is insufficient, adjust the plan and try a different angle.
26
+
27
+ # CRITICAL OUTPUT RULES
28
+ The automated scoring system is EXTREMELY STRICT. You must follow these formatting rules exactly:
29
+ - **Numeric Answers**:
30
+ - Output ONLY the number.
31
+ - NO commas (e.g., write `1000000`, NOT `1,000,000`).
32
+ - NO units or symbols (e.g., write `50`, NOT `$50` or `50%`, unless explicitly asked for the unit string).
33
+ - **String Answers**:
34
+ - Be concise.
35
+ - NO articles (a, an, the).
36
+ - NO abbreviations usually, unless standard (e.g., 'USA' might be okay, but 'Sept' for September is risky).
37
+ - **Final Format**:
38
+ - Your final line MUST be exactly: `FINAL ANSWER: <your_answer>`
39
+ - Do not put proper sentences in the final answer, just the raw value.
40
+
41
+ # EXAMPLE SCENARIOS
42
+ - Question: "What is the sum of the 'Total' column in the attached file.xlsx?"
43
+ Thought: I need to read the excel file first using `read_excel`. Then I will sum the values using `calculator`.
44
+ Action: `read_excel(file_path="...")`
45
+ Observation: (Dataframe output...)
46
+ Thought: I have the numbers. Sum is 500 + 200...
47
+ Action: `calculator(a=500, b=200, type="addition")`
48
+ FINAL ANSWER: 700
49
+
50
+ - Question: "Which city is the capital of France?"
51
+ FINAL ANSWER: Paris
52
+
53
+ Failure to follow these rules will result in a score of 0. Take a deep breath and think step by step.
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.
pyproject.toml CHANGED
@@ -1,7 +1,54 @@
1
  [project]
2
- name = "agents-course-assignment"
3
  version = "0.1.0"
4
- description = "Add your description here"
5
  readme = "README.md"
6
- requires-python = ">=3.12"
7
- dependencies = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  [project]
2
+ name = "gaia"
3
  version = "0.1.0"
4
+ description = "GAIA Benchmark Agent with Multi-Modal File Processing"
5
  readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ # Core LangChain
9
+ "langchain>=1.1.3",
10
+ "langchain-huggingface>=1.1.0",
11
+ "langchain-community>=0.4.1",
12
+ "langgraph>=1.0.4",
13
+ # HuggingFace
14
+ "huggingface-hub>=0.36.0",
15
+ "transformers>=4.46.0",
16
+ "accelerate>=1.0.0",
17
+ # Vector store
18
+ "sentence-transformers>=5.2.0",
19
+ "supabase>=2.25.1",
20
+ # Document processing
21
+ "pypdf>=4.0.0",
22
+ "python-docx>=1.1.0",
23
+ "python-pptx>=0.6.23",
24
+ # Data processing (polars)
25
+ "polars>=1.0.0",
26
+ "xlsx2csv>=0.8.0",
27
+ # Science
28
+ "biopython>=1.82",
29
+ "numpy>=2.0.0",
30
+ # Image
31
+ "pillow>=10.0.0",
32
+ # Audio
33
+ "librosa>=0.10.0",
34
+ "soundfile>=0.12.0",
35
+ # Web tools
36
+ "ddgs>=9.0.0",
37
+ "tavily-python>=0.5.0",
38
+ "wikipedia>=1.4.0",
39
+ "arxiv>=2.1.0",
40
+ "trafilatura>=1.6.0",
41
+ "openpyxl>=3.1.0",
42
+ # Utilities
43
+ "python-dotenv>=1.2.1",
44
+ "pyyaml>=6.0.0",
45
+ "requests>=2.31.0",
46
+ "tqdm>=4.67.1",
47
+ "gradio>=4.0.0",
48
+ "ipykernel>=7.1.0",
49
+ "bm25s>=0.2.14",
50
+ "jieba>=0.42.1",
51
+ "jupyter>=1.1.1",
52
+ "ipywidgets>=8.1.8",
53
+ "torch>=2.9.1",
54
+ ]
requirements.txt CHANGED
@@ -1,16 +1,55 @@
1
- gradio
2
- requests
3
- python-dotenv
4
- pgvector
5
- supabase
6
- huggingface_hub
7
- sentence-transformers
8
- langchain
9
  langchain-core
10
- langchain-huggingface
 
11
  langchain-tavily
12
- langchain-community
13
- langgraph
14
- arxiv
 
 
 
 
 
 
 
 
 
 
 
15
  pymupdf
16
- wikipedia
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core LangChain
2
+ langchain>=1.1.3
 
 
 
 
 
 
3
  langchain-core
4
+ langchain-huggingface>=1.1.0
5
+ langchain-community>=0.4.1
6
  langchain-tavily
7
+ langgraph>=1.0.4
8
+
9
+ # HuggingFace
10
+ huggingface-hub>=0.36.0
11
+ transformers>=4.46.0
12
+ accelerate>=1.0.0
13
+
14
+ # Vector store
15
+ sentence-transformers>=5.2.0
16
+ supabase>=2.25.1
17
+ pgvector
18
+
19
+ # Document processing
20
+ pypdf>=4.0.0
21
  pymupdf
22
+ python-docx>=1.1.0
23
+ python-pptx>=0.6.23
24
+
25
+ # Data processing (polars)
26
+ polars>=1.0.0
27
+ xlsx2csv>=0.8.0
28
+
29
+ # Science
30
+ biopython>=1.82
31
+ numpy>=2.0.0
32
+
33
+ # Image
34
+ pillow>=10.0.0
35
+
36
+ # Audio
37
+ librosa>=0.10.0
38
+ soundfile>=0.12.0
39
+
40
+ # Web tools
41
+ ddgs>=9.0.0
42
+ tavily-python>=0.5.0
43
+ wikipedia>=1.4.0
44
+ arxiv>=2.1.0
45
+ trafilatura>=1.6.0
46
+
47
+ # Excel
48
+ openpyxl>=3.1.0
49
+
50
+ # Utilities
51
+ python-dotenv>=1.2.1
52
+ pyyaml>=6.0.0
53
+ requests>=2.31.0
54
+ tqdm>=4.67.1
55
+ gradio>=4.0.0
states.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ """
21
+ messages: Annotated[list[BaseMessage], add_messages]
22
+ task_id: str
23
+ file_name: str
24
+ file_path: str
25
+ retrieved_docs: List[Document]
tools.py CHANGED
@@ -1,4 +1,10 @@
1
  import os
 
 
 
 
 
 
2
 
3
  from langchain_community.tools import DuckDuckGoSearchRun
4
  from langchain_community.tools.tavily_search import TavilySearchResults
@@ -6,6 +12,35 @@ from langchain_community.document_loaders import WikipediaLoader
6
  from langchain_community.document_loaders import ArxivLoader
7
  from langchain_core.tools import tool
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  @tool
10
  def calculator(a: float, b: float, type: str) -> float:
11
  """Performs mathematical calculations, addition, subtraction, multiplication, division, modulus.
@@ -26,9 +61,9 @@ def calculator(a: float, b: float, type: str) -> float:
26
  raise ValueError("Cannot divide by zero.")
27
  return a / b
28
  elif type == "modulus":
29
- a % b
30
  else:
31
- TypeError(f"{type} is not an option for type, choose one of addition, subtraction, multiplication, division, modulus")
32
 
33
  @tool
34
  def duck_web_search(query: str) -> str:
@@ -37,7 +72,7 @@ def duck_web_search(query: str) -> str:
37
  Args:
38
  query: The search query.
39
  """
40
- search = DuckDuckGoSearchRun().invoke(query=query)
41
 
42
  return {"duckduckgo_web_search": search}
43
 
@@ -75,11 +110,519 @@ def tavily_web_search(query: str) -> str:
75
 
76
  Args:
77
  query: The search query."""
78
- search_engine = TavilySearchResults(max_results=3)
79
- search_documents = search_engine.invoke(input=query)
80
  web_results = "\n\n---\n\n".join(
81
  [
82
  f'Document title: {document["title"]}. Contents: {document["content"]}. Relevance Score: {document["score"]}'
83
  for document in search_documents
84
  ])
85
- return {"web_results": web_results}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
+ import json
3
+ import base64
4
+ from pathlib import Path
5
+
6
+ from dotenv import load_dotenv
7
+ load_dotenv()
8
 
9
  from langchain_community.tools import DuckDuckGoSearchRun
10
  from langchain_community.tools.tavily_search import TavilySearchResults
 
12
  from langchain_community.document_loaders import ArxivLoader
13
  from langchain_core.tools import tool
14
 
15
+ from huggingface_hub import InferenceClient
16
+
17
+ from utils import load_config, load_prompt
18
+
19
+ _config = load_config()
20
+ _vlm_model_name = _config["models"]["vlm"]["model_name"]
21
+ _vlm_system_prompt = load_prompt("prompts/vlm_prompt.yaml").content
22
+ _asr_model_name = _config["models"]["asr"]["model_name"]
23
+ _hf_client = InferenceClient(token=os.getenv("HF_INFERENCE_KEY"))
24
+
25
+ _ddg_search = None
26
+ _tavily_search = None
27
+
28
+ def _get_ddg():
29
+ global _ddg_search
30
+ if _ddg_search is None:
31
+ _ddg_search = DuckDuckGoSearchRun()
32
+ return _ddg_search
33
+
34
+ def _get_tavily():
35
+ global _tavily_search
36
+ if _tavily_search is None:
37
+ _tavily_search = TavilySearchResults(max_results=3)
38
+ return _tavily_search
39
+
40
+ # ============================================
41
+ # Basic Tools
42
+ # ============================================
43
+
44
  @tool
45
  def calculator(a: float, b: float, type: str) -> float:
46
  """Performs mathematical calculations, addition, subtraction, multiplication, division, modulus.
 
61
  raise ValueError("Cannot divide by zero.")
62
  return a / b
63
  elif type == "modulus":
64
+ return a % b
65
  else:
66
+ raise TypeError(f"{type} is not an option for type, choose one of addition, subtraction, multiplication, division, modulus")
67
 
68
  @tool
69
  def duck_web_search(query: str) -> str:
 
72
  Args:
73
  query: The search query.
74
  """
75
+ search = _get_ddg().invoke(query=query)
76
 
77
  return {"duckduckgo_web_search": search}
78
 
 
110
 
111
  Args:
112
  query: The search query."""
113
+ search_documents = _get_tavily().invoke(input=query)
 
114
  web_results = "\n\n---\n\n".join(
115
  [
116
  f'Document title: {document["title"]}. Contents: {document["content"]}. Relevance Score: {document["score"]}'
117
  for document in search_documents
118
  ])
119
+ return {"web_results": web_results}
120
+
121
+
122
+ @tool
123
+ def fetch_webpage(url: str) -> str:
124
+ """
125
+ Fetch and extract the main text content from a webpage.
126
+ Use this when a search result points to a specific URL you need to read in full.
127
+
128
+ Args:
129
+ url: The full URL of the page to fetch.
130
+
131
+ Returns:
132
+ The extracted text content of the page.
133
+ """
134
+ import trafilatura
135
+ try:
136
+ downloaded = trafilatura.fetch_url(url)
137
+ if downloaded is None:
138
+ return f"Error: Could not fetch {url}"
139
+ text = trafilatura.extract(downloaded, include_tables=True, include_links=False)
140
+ if text is None:
141
+ return f"Error: Could not extract content from {url}"
142
+ return f"Page content from {url}:\n\n{text}"
143
+ except Exception as e:
144
+ return f"Error fetching webpage: {e}"
145
+
146
+
147
+ @tool
148
+ def python_eval(code: str) -> str:
149
+ """
150
+ Execute a Python code snippet and return its stdout output.
151
+ Use this when a question asks what a script outputs, or when computation requires running code.
152
+
153
+ Args:
154
+ code: Python source code to execute.
155
+
156
+ Returns:
157
+ The stdout output of the code, or an error/timeout message.
158
+ """
159
+ import subprocess
160
+ import tempfile
161
+ try:
162
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
163
+ f.write(code)
164
+ tmp_path = f.name
165
+ result = subprocess.run(
166
+ ['python3', tmp_path],
167
+ capture_output=True, text=True, timeout=30
168
+ )
169
+ os.unlink(tmp_path)
170
+ if result.returncode == 0:
171
+ return f"Output:\n{result.stdout}"
172
+ return f"Error (exit {result.returncode}):\n{result.stderr}"
173
+ except subprocess.TimeoutExpired:
174
+ return "Error: execution timed out (30s limit)"
175
+ except Exception as e:
176
+ return f"Error: {e}"
177
+
178
+
179
+ # ============================================
180
+ # VLM Tool
181
+ # ============================================
182
+
183
+ @tool
184
+ def analyze_image(image_path: str, question: str) -> str:
185
+ """
186
+ Analyze an image using a Vision Language Model (VLM) to answer a specific question.
187
+
188
+ Args:
189
+ image_path: Path to the image file (JPG, PNG).
190
+ question: The specific question to answer about the image.
191
+
192
+ Returns:
193
+ A detailed description or answer based on the visual content.
194
+ """
195
+ try:
196
+ if not os.path.exists(image_path):
197
+ return f"Error: Image file not found at {image_path}"
198
+
199
+ with open(image_path, "rb") as img_file:
200
+ image_data = base64.b64encode(img_file.read()).decode("utf-8")
201
+ ext = Path(image_path).suffix.lower().lstrip(".")
202
+ mime_type = "image/jpeg" if ext in ("jpg", "jpeg") else f"image/{ext}"
203
+ image_url = f"data:{mime_type};base64,{image_data}"
204
+
205
+ messages = [
206
+ {
207
+ "role": "user",
208
+ "content": [
209
+ {"type": "image_url", "image_url": {"url": image_url}},
210
+ {"type": "text", "text": f"{_vlm_system_prompt}\n\nQuestion: {question}"}
211
+ ]
212
+ }
213
+ ]
214
+
215
+ output = _hf_client.chat_completion(
216
+ messages=messages,
217
+ model=_vlm_model_name,
218
+ max_tokens=1000
219
+ )
220
+
221
+ return output.choices[0].message.content
222
+
223
+ except Exception as e:
224
+ return f"Error analyzing image with VLM: {str(e)}"
225
+
226
+
227
+ # ============================================
228
+ # Document Processing Tools
229
+ # ============================================
230
+
231
+ @tool
232
+ def read_pdf(file_path: str) -> str:
233
+ """
234
+ Extract text content from a PDF file.
235
+
236
+ Args:
237
+ file_path: Path to the PDF file to read.
238
+
239
+ Returns:
240
+ The text content of the PDF, with page separators.
241
+ """
242
+ from pypdf import PdfReader
243
+
244
+ try:
245
+ reader = PdfReader(file_path)
246
+ text = []
247
+ for i, page in enumerate(reader.pages):
248
+ page_text = page.extract_text()
249
+ if page_text:
250
+ text.append(f"--- Page {i+1} ---\n{page_text}")
251
+
252
+ return "\n\n".join(text) if text else "[Empty PDF]"
253
+ except Exception as e:
254
+ return f"Error reading PDF: {e}"
255
+
256
+
257
+ @tool
258
+ def read_docx(file_path: str) -> str:
259
+ """
260
+ Extract text content from a Word document (.docx).
261
+
262
+ Args:
263
+ file_path: Path to the Word document to read.
264
+
265
+ Returns:
266
+ The text content of the document.
267
+ """
268
+ from docx import Document
269
+
270
+ try:
271
+ doc = Document(file_path)
272
+ text_parts = []
273
+
274
+ paragraphs = [para.text for para in doc.paragraphs if para.text.strip()]
275
+ if paragraphs:
276
+ text_parts.append("\n".join(paragraphs))
277
+
278
+ for i, table in enumerate(doc.tables):
279
+ rows = [" | ".join(cell.text.strip() for cell in row.cells) for row in table.rows]
280
+ rows = [r for r in rows if r.strip()]
281
+ if rows:
282
+ text_parts.append(f"--- Table {i+1} ---\n" + "\n".join(rows))
283
+
284
+ return "\n\n".join(text_parts) if text_parts else "[Empty document]"
285
+ except Exception as e:
286
+ return f"Error reading DOCX: {e}"
287
+
288
+
289
+ @tool
290
+ def read_pptx(file_path: str) -> str:
291
+ """
292
+ Extract text content from a PowerPoint presentation (.pptx).
293
+
294
+ Args:
295
+ file_path: Path to the PowerPoint file to read.
296
+
297
+ Returns:
298
+ The text content from all slides.
299
+ """
300
+ from pptx import Presentation
301
+
302
+ try:
303
+ prs = Presentation(file_path)
304
+ text = []
305
+
306
+ for slide_num, slide in enumerate(prs.slides, 1):
307
+ slide_text = [f"--- Slide {slide_num} ---"]
308
+ for shape in slide.shapes:
309
+ if hasattr(shape, "text") and shape.text.strip():
310
+ slide_text.append(shape.text)
311
+ if len(slide_text) > 1:
312
+ text.append("\n".join(slide_text))
313
+
314
+ return "\n\n".join(text) if text else "[Empty presentation]"
315
+ except Exception as e:
316
+ return f"Error reading PPTX: {e}"
317
+
318
+
319
+ @tool
320
+ def read_text_file(file_path: str) -> str:
321
+ """
322
+ Read content from a plain text file (.txt).
323
+
324
+ Args:
325
+ file_path: Path to the text file to read.
326
+
327
+ Returns:
328
+ The content of the text file.
329
+ """
330
+ try:
331
+ with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
332
+ return f.read()
333
+ except Exception as e:
334
+ return f"Error reading text file: {e}"
335
+
336
+
337
+ # ============================================
338
+ # Data Processing Tools (using polars)
339
+ # ============================================
340
+
341
+ @tool
342
+ def read_csv(file_path: str) -> str:
343
+ """
344
+ Read and analyze a CSV file using polars.
345
+
346
+ Args:
347
+ file_path: Path to the CSV file to read.
348
+
349
+ Returns:
350
+ Summary of the CSV including schema, row count, and data preview.
351
+ """
352
+ import polars as pl
353
+
354
+ try:
355
+ df = pl.read_csv(file_path)
356
+
357
+ output = f"CSV File — {len(df)} rows, {len(df.columns)} columns\n"
358
+ output += f"Columns: {df.columns}\n\n"
359
+ output += f"Column Statistics:\n{df.describe()}\n\n"
360
+ output += f"Data (first 20 rows):\n{df.head(20)}"
361
+ if len(df) <= 50:
362
+ output += f"\n\nComplete data:\n{df}"
363
+ return output
364
+ except Exception as e:
365
+ return f"Error reading CSV: {e}"
366
+
367
+
368
+ @tool
369
+ def read_excel(file_path: str, sheet_id: int = 0) -> str:
370
+ """
371
+ Read and analyze an Excel file (.xlsx) using polars.
372
+
373
+ Args:
374
+ file_path: Path to the Excel file to read.
375
+ sheet_id: The sheet index to read (0-based). Default is 0 (first sheet).
376
+
377
+ Returns:
378
+ Summary of the Excel sheet including schema, row count, and data preview.
379
+ """
380
+ import polars as pl
381
+ import openpyxl
382
+
383
+ try:
384
+ wb = openpyxl.load_workbook(file_path, read_only=True)
385
+ sheet_names = wb.sheetnames
386
+ wb.close()
387
+ except Exception:
388
+ sheet_names = []
389
+
390
+ try:
391
+ df = pl.read_excel(file_path, sheet_id=sheet_id)
392
+ sheet_label = sheet_names[sheet_id] if sheet_id < len(sheet_names) else str(sheet_id)
393
+
394
+ output = f"Excel File — Available sheets: {sheet_names}\n\n"
395
+ output += f"Sheet {sheet_id} ('{sheet_label}') — {len(df)} rows, {len(df.columns)} columns\n"
396
+ output += f"Columns: {df.columns}\n\n"
397
+ output += f"Column Statistics:\n{df.describe()}\n\n"
398
+ output += f"Data (first 20 rows):\n{df.head(20)}"
399
+ if len(df) <= 50:
400
+ output += f"\n\nComplete data:\n{df}"
401
+ return output
402
+ except Exception as e:
403
+ return f"Error reading Excel: {e}"
404
+
405
+
406
+ @tool
407
+ def read_jsonld(file_path: str) -> str:
408
+ """
409
+ Read and parse a JSON-LD file.
410
+
411
+ Args:
412
+ file_path: Path to the JSON-LD file to read.
413
+
414
+ Returns:
415
+ The formatted JSON content.
416
+ """
417
+ try:
418
+ with open(file_path, 'r') as f:
419
+ data = json.load(f)
420
+ return f"JSON-LD Content:\n{json.dumps(data, indent=2)}"
421
+ except Exception as e:
422
+ return f"Error reading JSON-LD: {e}"
423
+
424
+
425
+ @tool
426
+ def read_pdb(file_path: str) -> str:
427
+ """
428
+ Read and analyze a PDB (Protein Data Bank) file for protein structure analysis.
429
+
430
+ Args:
431
+ file_path: Path to the PDB file to read.
432
+
433
+ Returns:
434
+ Analysis of the protein structure including atoms, chains, and coordinates.
435
+ """
436
+ from Bio.PDB import PDBParser
437
+ import numpy as np
438
+
439
+ try:
440
+ parser = PDBParser(QUIET=True)
441
+ structure = parser.get_structure("protein", file_path)
442
+
443
+ info = ["=== PDB Structure Analysis ==="]
444
+
445
+ atoms = list(structure.get_atoms())
446
+ info.append(f"Total atoms: {len(atoms)}")
447
+
448
+ for model in structure:
449
+ info.append(f"\nModel {model.id}:")
450
+ for chain in model:
451
+ residues = list(chain.get_residues())
452
+ info.append(f" Chain {chain.id}: {len(residues)} residues")
453
+
454
+ if len(atoms) >= 2:
455
+ info.append("\nFirst atoms (for distance calculations):")
456
+ for i, atom in enumerate(atoms[:5]):
457
+ coord = atom.get_coord()
458
+ info.append(
459
+ f" Atom {i+1}: {atom.get_name()} at "
460
+ f"[{coord[0]:.4f}, {coord[1]:.4f}, {coord[2]:.4f}]"
461
+ )
462
+
463
+ dist = np.linalg.norm(atoms[0].get_coord() - atoms[1].get_coord())
464
+ info.append(f"\nDistance between first two atoms: {dist:.4f} Angstroms")
465
+
466
+ return "\n".join(info)
467
+ except Exception as e:
468
+ return f"Error reading PDB: {e}"
469
+
470
+
471
+ # ============================================
472
+ # Audio Processing Tools
473
+ # ============================================
474
+
475
+ @tool
476
+ def transcribe_audio(file_path: str) -> str:
477
+ """
478
+ Transcribe an audio file (MP3, WAV, etc.) to text using Whisper.
479
+
480
+ Args:
481
+ file_path: Path to the audio file to transcribe.
482
+
483
+ Returns:
484
+ The transcribed text from the audio.
485
+ """
486
+ try:
487
+ result = _hf_client.automatic_speech_recognition(audio=file_path, model=_asr_model_name)
488
+ return f"Audio Transcription:\n{result.text}"
489
+ except Exception as e:
490
+ return f"Error transcribing audio: {e}"
491
+
492
+
493
+ # ============================================
494
+ # Code Processing Tools
495
+ # ============================================
496
+
497
+ @tool
498
+ def read_python_file(file_path: str) -> str:
499
+ """
500
+ Read a Python source code file.
501
+
502
+ Args:
503
+ file_path: Path to the Python file to read.
504
+
505
+ Returns:
506
+ The Python code content.
507
+ """
508
+ try:
509
+ with open(file_path, 'r') as f:
510
+ code = f.read()
511
+ return f"Python Code:\n```python\n{code}\n```"
512
+ except Exception as e:
513
+ return f"Error reading Python file: {e}"
514
+
515
+
516
+ # ============================================
517
+ # Archive Processing Tools
518
+ # ============================================
519
+
520
+ @tool
521
+ def extract_zip(file_path: str) -> str:
522
+ """
523
+ Extract a ZIP archive and list its contents.
524
+
525
+ Args:
526
+ file_path: Path to the ZIP file to extract.
527
+
528
+ Returns:
529
+ List of files extracted from the archive with their paths.
530
+ """
531
+ import zipfile
532
+
533
+ try:
534
+ extract_dir = Path(file_path).parent / Path(file_path).stem
535
+ extract_dir.mkdir(exist_ok=True)
536
+
537
+ with zipfile.ZipFile(file_path, 'r') as zip_ref:
538
+ zip_ref.extractall(extract_dir)
539
+
540
+ results = [f"ZIP Archive extracted to: {extract_dir}\n\nContents:"]
541
+
542
+ for root, dirs, files in os.walk(extract_dir):
543
+ for file in files:
544
+ full_path = os.path.join(root, file)
545
+ rel_path = os.path.relpath(full_path, extract_dir)
546
+ file_size = os.path.getsize(full_path)
547
+ results.append(f" - {rel_path} ({file_size} bytes)")
548
+
549
+ results.append(f"\nUse the appropriate read tool on the extracted files at: {extract_dir}/")
550
+
551
+ return "\n".join(results)
552
+ except Exception as e:
553
+ return f"Error extracting ZIP: {e}"
554
+
555
+
556
+ # ============================================
557
+ # Generic File Processing
558
+ # ============================================
559
+
560
+ @tool
561
+ def read_file(file_path: str) -> str:
562
+ """
563
+ Automatically read a file based on its extension.
564
+
565
+ Supported formats: PDF, DOCX, PPTX, TXT, CSV, XLSX, JSON-LD, PDB, Python, ZIP, JPG, JPEG, PNG, MP3, WAV, FLAC, OGG, M4A
566
+
567
+ Args:
568
+ file_path: Path to the file to read.
569
+
570
+ Returns:
571
+ The processed content of the file.
572
+ """
573
+ ext = Path(file_path).suffix.lower()
574
+
575
+ processors = {
576
+ '.pdf': lambda p: read_pdf.invoke(p),
577
+ '.docx': lambda p: read_docx.invoke(p),
578
+ '.pptx': lambda p: read_pptx.invoke(p),
579
+ '.txt': lambda p: read_text_file.invoke(p),
580
+ '.csv': lambda p: read_csv.invoke(p),
581
+ '.xlsx': lambda p: read_excel.invoke(p),
582
+ '.jsonld': lambda p: read_jsonld.invoke(p),
583
+ '.pdb': lambda p: read_pdb.invoke(p),
584
+ '.py': lambda p: read_python_file.invoke(p),
585
+ '.mp3': lambda p: transcribe_audio.invoke(p),
586
+ '.wav': lambda p: transcribe_audio.invoke(p),
587
+ '.flac': lambda p: transcribe_audio.invoke(p),
588
+ '.ogg': lambda p: transcribe_audio.invoke(p),
589
+ '.m4a': lambda p: transcribe_audio.invoke(p),
590
+ '.zip': lambda p: extract_zip.invoke(p),
591
+ '.jpg': lambda p: analyze_image.invoke({"image_path": p, "question": "Describe this image in detail."}),
592
+ '.jpeg': lambda p: analyze_image.invoke({"image_path": p, "question": "Describe this image in detail."}),
593
+ '.png': lambda p: analyze_image.invoke({"image_path": p, "question": "Describe this image in detail."}),
594
+ }
595
+
596
+ processor = processors.get(ext)
597
+
598
+ if processor:
599
+ return processor(file_path)
600
+
601
+ return f"[Unsupported file type: {ext}]"
602
+
603
+ # ============================================
604
+ # List of all tools
605
+ # ============================================
606
+
607
+ tools_list = [
608
+ calculator,
609
+ duck_web_search,
610
+ wiki_search,
611
+ arxiv_search,
612
+ tavily_web_search,
613
+ fetch_webpage,
614
+ python_eval,
615
+ read_pdf,
616
+ read_docx,
617
+ read_pptx,
618
+ read_text_file,
619
+ read_csv,
620
+ read_excel,
621
+ read_jsonld,
622
+ read_pdb,
623
+ transcribe_audio,
624
+ read_python_file,
625
+ extract_zip,
626
+ analyze_image,
627
+ read_file,
628
+ ]
utils.py CHANGED
@@ -1,10 +1,78 @@
 
 
 
1
  import yaml
 
2
  from langchain_core.messages import SystemMessage
3
 
4
- def load_prompt(prompt_location):
 
 
 
 
 
 
 
5
  with open(prompt_location) as f:
6
  try:
7
  prompt = yaml.safe_load(f)["prompt"]
8
  return SystemMessage(content=prompt)
9
  except yaml.YAMLError as exc:
10
- print(exc)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import bm25s
4
  import yaml
5
+ from pathlib import Path
6
  from langchain_core.messages import SystemMessage
7
 
8
+
9
+ def load_config(path="config.yaml"):
10
+ with open(path, "r") as f:
11
+ return yaml.safe_load(f)
12
+
13
+
14
+ def load_prompt(prompt_location: str) -> SystemMessage:
15
+ """Load system prompt from YAML file."""
16
  with open(prompt_location) as f:
17
  try:
18
  prompt = yaml.safe_load(f)["prompt"]
19
  return SystemMessage(content=prompt)
20
  except yaml.YAMLError as exc:
21
+ print(exc)
22
+ return SystemMessage(content="You are a helpful assistant.")
23
+
24
+
25
+
26
+ def init_bm25_index(corpus_file = "data/metadata.jsonl"):
27
+ """BM25 Index Initialization (Local Corpus)"""
28
+ try:
29
+ if not os.path.exists(corpus_file):
30
+ print(f"Warning: {corpus_file} not found. BM25 will use empty index.")
31
+ return None, [], []
32
+
33
+ search_texts = [] # question-only — used for BM25 indexing
34
+ corpus_texts = [] # Q+A+Steps — returned for context injection
35
+ corpus_ids = []
36
+ with open(corpus_file, "r") as f:
37
+ for line in f:
38
+ item = json.loads(line)
39
+ question = item.get('Question', '')
40
+ answer = item.get('Final answer', '')
41
+ steps = item.get('Annotator Metadata', {}).get('Steps', '')
42
+ search_texts.append(question)
43
+ parts = [f"Question: {question}"]
44
+ if answer:
45
+ parts.append(f"Final Answer: {answer}")
46
+ if steps:
47
+ parts.append(f"Solution Steps: {steps}")
48
+ corpus_texts.append("\n".join(parts))
49
+ corpus_ids.append(item.get('task_id', ''))
50
+
51
+ corpus_tokens = bm25s.tokenize(search_texts, stopwords="en", stemmer=None)
52
+
53
+ retriever_bm25 = bm25s.BM25()
54
+ retriever_bm25.index(corpus_tokens)
55
+
56
+ print(f"BM25 Index initialized with {len(corpus_texts)} documents.")
57
+ return retriever_bm25, corpus_texts, corpus_ids
58
+ except Exception as e:
59
+ print(f"Error initializing BM25: {e}")
60
+ return None, [], []
61
+
62
+
63
+ def reciprocal_rank_fusion(results: list[list[dict]], k=60) -> list[tuple[dict, float]]:
64
+ """
65
+ Fuse multiple ranked lists using Reciprocal Rank Fusion (RRF).
66
+ """
67
+ fused_scores = {}
68
+
69
+ for rank_list in results:
70
+ for rank, doc in enumerate(rank_list):
71
+ doc_id = doc["metadata"]["task_id"]
72
+ doc_content = doc["content"]
73
+ if doc_id not in fused_scores:
74
+ fused_scores[doc_id] = {"id": doc_id, "content": doc_content, "score": 0.0}
75
+ fused_scores[doc_id]["score"] += 1.0 / (k + rank + 1)
76
+
77
+ sorted_results = sorted(fused_scores.values(), key=lambda x: x["score"], reverse=True)
78
+ return [(item["id"], item["content"], item["score"]) for item in sorted_results]
uv.lock ADDED
The diff for this file is too large to render. See raw diff