Jan Krüger commited on
Commit
8d4d62e
·
1 Parent(s): 81917a3

QA Agent for Certification

Browse files
Files changed (11) hide show
  1. .gitignore +11 -0
  2. README.md +3 -3
  3. app.py +386 -46
  4. cache_manager.py +250 -0
  5. config.example.yaml +75 -0
  6. config.yaml +55 -0
  7. prompts.yaml +45 -0
  8. requirements.txt +12 -1
  9. tools/final_answer.py +54 -0
  10. tools/get_file.py +368 -0
  11. tools/web_scraping.py +221 -0
.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.so
4
+ .env
5
+ .Python
6
+ .venv
7
+ build/
8
+ dist/
9
+ cache/
10
+ GAIA/
11
+ google.json
README.md CHANGED
@@ -1,8 +1,8 @@
1
  ---
2
- title: Template Final Assignment
3
- emoji: 🕵🏻‍♂️
4
  colorFrom: indigo
5
- colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 5.25.2
8
  app_file: app.py
 
1
  ---
2
+ title: Q&A Agent with tool use to answer questions
3
+ emoji: 🤖
4
  colorFrom: indigo
5
+ colorTo: yellow
6
  sdk: gradio
7
  sdk_version: 5.25.2
8
  app_file: app.py
app.py CHANGED
@@ -1,34 +1,210 @@
1
  import os
2
  import gradio as gr
3
  import requests
4
- import inspect
5
  import pandas as pd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
- # (Keep Constants as is)
8
  # --- Constants ---
9
  DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
10
 
11
- # --- Basic Agent Definition ---
12
- # ----- THIS IS WERE YOU CAN BUILD WHAT YOU WANT ------
13
- class BasicAgent:
14
- def __init__(self):
15
- print("BasicAgent initialized.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  def __call__(self, question: str) -> str:
17
  print(f"Agent received question (first 50 chars): {question[:50]}...")
18
- fixed_answer = "This is a default answer."
19
- print(f"Agent returning fixed answer: {fixed_answer}")
20
- return fixed_answer
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
- def run_and_submit_all( profile: gr.OAuthProfile | None):
23
  """
24
- Fetches all questions, runs the BasicAgent on them, submits all answers,
25
- and displays the results.
26
  """
27
  # --- Determine HF Space Runtime URL and Repo URL ---
28
  space_id = os.getenv("SPACE_ID") # Get the SPACE_ID for sending link to the code
29
 
30
  if profile:
31
- username= f"{profile.username}"
32
  print(f"User logged in: {username}")
33
  else:
34
  print("User not logged in.")
@@ -36,17 +212,13 @@ def run_and_submit_all( profile: gr.OAuthProfile | None):
36
 
37
  api_url = DEFAULT_API_URL
38
  questions_url = f"{api_url}/questions"
39
- submit_url = f"{api_url}/submit"
40
 
41
- # 1. Instantiate Agent ( modify this part to create your agent)
42
  try:
43
- agent = BasicAgent()
44
  except Exception as e:
45
  print(f"Error instantiating agent: {e}")
46
  return f"Error initializing agent: {e}", None
47
- # In the case of an app running as a hugging Face space, this link points toward your codebase ( usefull for others so please keep it public)
48
- agent_code = f"https://huggingface.co/spaces/{space_id}/tree/main"
49
- print(agent_code)
50
 
51
  # 2. Fetch Questions
52
  print(f"Fetching questions from: {questions_url}")
@@ -58,45 +230,193 @@ def run_and_submit_all( profile: gr.OAuthProfile | None):
58
  print("Fetched questions list is empty.")
59
  return "Fetched questions list is empty or invalid format.", None
60
  print(f"Fetched {len(questions_data)} questions.")
61
- except requests.exceptions.RequestException as e:
62
- print(f"Error fetching questions: {e}")
63
- return f"Error fetching questions: {e}", None
64
  except requests.exceptions.JSONDecodeError as e:
65
  print(f"Error decoding JSON response from questions endpoint: {e}")
66
  print(f"Response text: {response.text[:500]}")
67
  return f"Error decoding server response for questions: {e}", None
 
 
 
68
  except Exception as e:
69
  print(f"An unexpected error occurred fetching questions: {e}")
70
  return f"An unexpected error occurred fetching questions: {e}", None
71
 
72
- # 3. Run your Agent
73
  results_log = []
74
- answers_payload = []
 
75
  print(f"Running agent on {len(questions_data)} questions...")
 
76
  for item in questions_data:
77
  task_id = item.get("task_id")
78
  question_text = item.get("question")
 
 
79
  if not task_id or question_text is None:
80
  print(f"Skipping item with missing task_id or question: {item}")
81
  continue
82
- try:
83
- submitted_answer = agent(question_text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  answers_payload.append({"task_id": task_id, "submitted_answer": submitted_answer})
85
- results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": submitted_answer})
86
- except Exception as e:
87
- print(f"Error running agent on task {task_id}: {e}")
88
- results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": f"AGENT ERROR: {e}"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
  if not answers_payload:
91
- print("Agent did not produce any answers to submit.")
92
- return "Agent did not produce any answers to submit.", pd.DataFrame(results_log)
93
 
94
- # 4. Prepare Submission
95
  submission_data = {"username": username.strip(), "agent_code": agent_code, "answers": answers_payload}
96
- status_update = f"Agent finished. Submitting {len(answers_payload)} answers for user '{username}'..."
97
  print(status_update)
98
 
99
- # 5. Submit
100
  print(f"Submitting {len(answers_payload)} answers to: {submit_url}")
101
  try:
102
  response = requests.post(submit_url, json=submission_data, timeout=60)
@@ -139,35 +459,55 @@ def run_and_submit_all( profile: gr.OAuthProfile | None):
139
  results_df = pd.DataFrame(results_log)
140
  return status_message, results_df
141
 
 
 
 
 
142
 
143
  # --- Build Gradio Interface using Blocks ---
144
  with gr.Blocks() as demo:
145
- gr.Markdown("# Basic Agent Evaluation Runner")
146
  gr.Markdown(
147
  """
148
  **Instructions:**
149
 
150
  1. Please clone this space, then modify the code to define your agent's logic, the tools, the necessary packages, etc ...
151
  2. Log in to your Hugging Face account using the button below. This uses your HF username for submission.
152
- 3. Click 'Run Evaluation & Submit All Answers' to fetch questions, run your agent, submit answers, and see the score.
 
 
153
 
154
  ---
155
- **Disclaimers:**
156
- Once clicking on the "submit button, it can take quite some time ( this is the time for the agent to go through all the questions).
157
- This space provides a basic setup and is intentionally sub-optimal to encourage you to develop your own, more robust solution. For instance for the delay process of the submit button, a solution could be to cache the answers and submit in a seperate action or even to answer the questions in async.
 
 
158
  """
159
  )
160
 
161
  gr.LoginButton()
162
 
163
- run_button = gr.Button("Run Evaluation & Submit All Answers")
 
 
 
164
 
165
- status_output = gr.Textbox(label="Run Status / Submission Result", lines=5, interactive=False)
166
- # Removed max_rows=10 from DataFrame constructor
167
  results_table = gr.DataFrame(label="Questions and Agent Answers", wrap=True)
168
 
169
  run_button.click(
170
- fn=run_and_submit_all,
 
 
 
 
 
 
 
 
 
 
171
  outputs=[status_output, results_table]
172
  )
173
 
@@ -192,5 +532,5 @@ if __name__ == "__main__":
192
 
193
  print("-"*(60 + len(" App Starting ")) + "\n")
194
 
195
- print("Launching Gradio Interface for Basic Agent Evaluation...")
196
  demo.launch(debug=True, share=False)
 
1
  import os
2
  import gradio as gr
3
  import requests
 
4
  import pandas as pd
5
+ import yaml
6
+ from smolagents import CodeAgent, LiteLLMModel, DuckDuckGoSearchTool, WikipediaSearchTool
7
+ from datasets import load_dataset
8
+ from cache_manager import CacheManager
9
+ from tools.final_answer import final_answer
10
+ from tools.get_file import get_file
11
+ from tools.web_scraping import (
12
+ scrape_webpage_content,
13
+ extract_links_from_webpage,
14
+ get_webpage_metadata
15
+ )
16
+
17
+ # Load the GAIA dataset
18
+ dataset = load_dataset("gaia-benchmark/GAIA", "2023_level1", trust_remote_code=True, cache_dir="GAIA")
19
+ print("GAIA dataset loaded successfully.")
20
+
21
+ # Initialize cache manager
22
+ cache_manager = CacheManager()
23
 
 
24
  # --- Constants ---
25
  DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
26
 
27
+ # --- QA Agent Definition ---
28
+ class QAAgent:
29
+ def __init__(self, temperature=None, max_tokens=None, max_steps=None):
30
+ """
31
+ Initialize the QA Agent with configuration from config.yaml.
32
+
33
+ Args:
34
+ temperature: Temperature for text generation (overrides config)
35
+ max_tokens: Maximum number of tokens for the model (overrides config)
36
+ max_steps: Maximum number of steps the agent can take (overrides config)
37
+ """
38
+ print("Initializing QA Agent with configuration...")
39
+
40
+ try:
41
+ # Load configuration
42
+ config = self._load_config()
43
+
44
+ # Load prompts
45
+ prompts = self._load_prompts()
46
+
47
+ # Get model configuration with overrides
48
+ model_config = config.get('model', {})
49
+ model_id = model_config.get('model_id', 'anthropic/claude-sonnet-4-20250514')
50
+ temp = temperature if temperature is not None else model_config.get('temperature', 0.2)
51
+ max_tok = max_tokens if max_tokens is not None else model_config.get('max_tokens', 2096)
52
+
53
+ # Get agent configuration with overrides
54
+ agent_config = config.get('agent', {})
55
+ self.max_steps = max_steps if max_steps is not None else agent_config.get('max_steps', 5)
56
+
57
+ print(f"Model: {model_id}")
58
+ print(f"Temperature: {temp}")
59
+ print(f"Max tokens: {max_tok}")
60
+ print(f"Max steps: {self.max_steps}")
61
+
62
+ # Prepare model initialization parameters
63
+ model_params = {
64
+ 'model_id': model_id,
65
+ 'temperature': temp,
66
+ 'max_tokens': max_tok
67
+ }
68
+
69
+ # Add Vertex AI specific parameters if using a vertex_ai model
70
+ if model_id.startswith('vertex_ai/'):
71
+ print("Configuring Vertex AI parameters...")
72
+ vertex_config = config.get('vertex_ai', {})
73
+
74
+ # Add vertex project if specified
75
+ if 'vertex_project' in vertex_config and vertex_config['vertex_project'] != 'your-gcp-project-id':
76
+ model_params['vertex_project'] = vertex_config['vertex_project']
77
+ print(f" Vertex Project: {vertex_config['vertex_project']}")
78
+
79
+ # Add vertex location if specified
80
+ if 'vertex_location' in vertex_config:
81
+ model_params['vertex_location'] = vertex_config['vertex_location']
82
+ print(f" Vertex Location: {vertex_config['vertex_location']}")
83
+
84
+ # Add vertex credentials if specified and valid
85
+ creds_path = vertex_config.get('vertex_credentials')
86
+ if creds_path and creds_path not in ['/path/to/service-account.json', './google.json']:
87
+ if os.path.exists(creds_path):
88
+ try:
89
+ # Validate it's a proper JSON file
90
+ import json
91
+ with open(creds_path, 'r') as f:
92
+ json.load(f)
93
+ model_params['vertex_credentials'] = creds_path
94
+ print(f" Vertex Credentials: {creds_path}")
95
+ except (json.JSONDecodeError, Exception) as e:
96
+ print(f" Warning: Invalid credentials file {creds_path}: {e}")
97
+ else:
98
+ print(f" Warning: Credentials file not found: {creds_path}")
99
+
100
+ # Add safety settings if specified
101
+ if 'safety_settings' in vertex_config:
102
+ model_params['safety_settings'] = vertex_config['safety_settings']
103
+ print(f" Safety Settings: {len(vertex_config['safety_settings'])} categories configured")
104
+
105
+ # Initialize the LiteLLM model
106
+ model = LiteLLMModel(**model_params)
107
+
108
+ # Available tools for the agent
109
+ tools = [
110
+ DuckDuckGoSearchTool(),
111
+ WikipediaSearchTool(),
112
+ get_file,
113
+ scrape_webpage_content,
114
+ extract_links_from_webpage,
115
+ get_webpage_metadata,
116
+ final_answer
117
+ ]
118
+
119
+ # Create the agent without prompt_templates (they'll be used in question processing)
120
+ self.agent = CodeAgent(
121
+ tools=tools,
122
+ model=model,
123
+ max_steps=self.max_steps
124
+ )
125
+
126
+ # Store prompts for use in question processing
127
+ self.prompts = prompts
128
+
129
+ print("Agent initialized successfully!")
130
+
131
+ except Exception as e:
132
+ # Provide helpful error messages based on the model type
133
+ error_msg = f"Error initializing QA Agent: {e}"
134
+
135
+ if "authentication" in str(e).lower() or "api" in str(e).lower() or "credentials" in str(e).lower():
136
+ if hasattr(self, '_load_config'):
137
+ config = self._load_config()
138
+ model_id = config.get('model', {}).get('model_id', '')
139
+
140
+ if "vertex_ai" in model_id.lower() or "gemini" in model_id.lower():
141
+ error_msg += "\n\nFor Vertex AI models, please:"
142
+ error_msg += "\n1. Set up authentication:"
143
+ error_msg += "\n Option A: gcloud auth application-default login"
144
+ error_msg += "\n Option B: export GOOGLE_APPLICATION_CREDENTIALS='/path/to/service-account.json'"
145
+ error_msg += "\n Option C: Set vertex_credentials in config.yaml"
146
+ error_msg += "\n2. Update config.yaml with your:"
147
+ error_msg += "\n - vertex_project: 'your-gcp-project-id'"
148
+ error_msg += "\n - vertex_location: 'us-central1' (or your preferred region)"
149
+ elif "anthropic" in model_id.lower():
150
+ error_msg += "\n\nFor Anthropic models, please set: export ANTHROPIC_API_KEY='your-key-here'"
151
+ elif "openai" in model_id.lower() or "gpt" in model_id.lower():
152
+ error_msg += "\n\nFor OpenAI models, please set: export OPENAI_API_KEY='your-key-here'"
153
+
154
+ print(error_msg)
155
+ raise Exception(error_msg)
156
+
157
+ def _load_config(self):
158
+ """Load configuration from config.yaml"""
159
+ try:
160
+ with open('config.yaml', 'r') as f:
161
+ return yaml.safe_load(f)
162
+ except FileNotFoundError:
163
+ print("Warning: config.yaml not found, using default configuration")
164
+ return {}
165
+ except Exception as e:
166
+ print(f"Error loading config.yaml: {e}")
167
+ return {}
168
+
169
+ def _load_prompts(self):
170
+ """Load prompts from prompts.yaml"""
171
+ try:
172
+ with open('prompts.yaml', 'r') as f:
173
+ return yaml.safe_load(f)
174
+ except FileNotFoundError:
175
+ print("Warning: prompts.yaml not found, using default prompts")
176
+ return {}
177
+ except Exception as e:
178
+ print(f"Error loading prompts.yaml: {e}")
179
+ return {}
180
+
181
  def __call__(self, question: str) -> str:
182
  print(f"Agent received question (first 50 chars): {question[:50]}...")
183
+ try:
184
+ # Get system prompt from loaded prompts and combine with question
185
+ system_prompt = self.prompts.get('system_prompt', '')
186
+ if system_prompt:
187
+ enhanced_question = f"{system_prompt}\n\n{question}"
188
+ else:
189
+ enhanced_question = question
190
+
191
+ # Use the agent to run and answer the enhanced question
192
+ answer = self.agent.run(enhanced_question)
193
+ print(f"Agent returning answer (first 100 chars): {str(answer)[:100]}...")
194
+ return str(answer)
195
+ except Exception as e:
196
+ print(f"Error running agent: {e}")
197
+ return f"Error processing question: {e}"
198
 
199
+ def run_questions(profile: gr.OAuthProfile | None):
200
  """
201
+ Fetches all questions, runs the QAAgent on them, and caches the answers.
 
202
  """
203
  # --- Determine HF Space Runtime URL and Repo URL ---
204
  space_id = os.getenv("SPACE_ID") # Get the SPACE_ID for sending link to the code
205
 
206
  if profile:
207
+ username = f"{profile.username}"
208
  print(f"User logged in: {username}")
209
  else:
210
  print("User not logged in.")
 
212
 
213
  api_url = DEFAULT_API_URL
214
  questions_url = f"{api_url}/questions"
 
215
 
216
+ # 1. Instantiate Agent
217
  try:
218
+ agent = QAAgent()
219
  except Exception as e:
220
  print(f"Error instantiating agent: {e}")
221
  return f"Error initializing agent: {e}", None
 
 
 
222
 
223
  # 2. Fetch Questions
224
  print(f"Fetching questions from: {questions_url}")
 
230
  print("Fetched questions list is empty.")
231
  return "Fetched questions list is empty or invalid format.", None
232
  print(f"Fetched {len(questions_data)} questions.")
 
 
 
233
  except requests.exceptions.JSONDecodeError as e:
234
  print(f"Error decoding JSON response from questions endpoint: {e}")
235
  print(f"Response text: {response.text[:500]}")
236
  return f"Error decoding server response for questions: {e}", None
237
+ except requests.exceptions.RequestException as e:
238
+ print(f"Error fetching questions: {e}")
239
+ return f"Error fetching questions: {e}", None
240
  except Exception as e:
241
  print(f"An unexpected error occurred fetching questions: {e}")
242
  return f"An unexpected error occurred fetching questions: {e}", None
243
 
244
+ # 3. Run Agent and Cache Results
245
  results_log = []
246
+ cached_count = 0
247
+ processed_count = 0
248
  print(f"Running agent on {len(questions_data)} questions...")
249
+
250
  for item in questions_data:
251
  task_id = item.get("task_id")
252
  question_text = item.get("question")
253
+ file_name = item.get("file_name")
254
+
255
  if not task_id or question_text is None:
256
  print(f"Skipping item with missing task_id or question: {item}")
257
  continue
258
+
259
+ # Check if answer is already cached
260
+ cached_result = cache_manager.get_cached_answer(question_text)
261
+ if cached_result and cached_result.get('cache_valid', False):
262
+ print(f"Using cached answer for task {task_id}")
263
+ submitted_answer = cached_result['answer']
264
+ cached_count += 1
265
+ results_log.append({
266
+ "Task ID": task_id,
267
+ "Question": question_text,
268
+ "Submitted Answer": submitted_answer,
269
+ "Status": "Cached"
270
+ })
271
+ else:
272
+ # Run agent and cache the result
273
+ try:
274
+ print(f"Processing task {task_id} with agent...")
275
+
276
+ # Enhance question with file information if file is present
277
+ enhanced_question = question_text
278
+ if file_name:
279
+ enhanced_question = f"{question_text}\n\nNote: This question references a file named '{file_name}'. Use the get_file tool to retrieve its content."
280
+
281
+ submitted_answer = agent(enhanced_question)
282
+
283
+ # Cache the answer
284
+ cache_success = cache_manager.cache_answer(
285
+ question=question_text,
286
+ answer=submitted_answer,
287
+ iterations=1,
288
+ file_name=file_name
289
+ )
290
+
291
+ processed_count += 1
292
+ status = "Processed & Cached" if cache_success else "Processed (Cache Failed)"
293
+ results_log.append({
294
+ "Task ID": task_id,
295
+ "Question": question_text,
296
+ "Submitted Answer": submitted_answer,
297
+ "Status": status
298
+ })
299
+
300
+ except Exception as e:
301
+ print(f"Error running agent on task {task_id}: {e}")
302
+ error_answer = f"AGENT ERROR: {e}"
303
+
304
+ # Cache the error (will be marked as invalid)
305
+ cache_manager.cache_answer(
306
+ question=question_text,
307
+ answer=error_answer,
308
+ iterations=1,
309
+ file_name=file_name
310
+ )
311
+
312
+ results_log.append({
313
+ "Task ID": task_id,
314
+ "Question": question_text,
315
+ "Submitted Answer": error_answer,
316
+ "Status": "Error"
317
+ })
318
+
319
+ status_message = (
320
+ f"Questions processing completed!\n"
321
+ f"Total questions: {len(questions_data)}\n"
322
+ f"Used cached answers: {cached_count}\n"
323
+ f"Newly processed: {processed_count}\n"
324
+ f"Answers are cached and ready for submission."
325
+ )
326
+
327
+ print(status_message)
328
+ results_df = pd.DataFrame(results_log)
329
+ return status_message, results_df
330
+
331
+ def submit_answers(profile: gr.OAuthProfile | None):
332
+ """
333
+ Loads cached answers and submits them to the evaluation server.
334
+ """
335
+ # --- Determine HF Space Runtime URL and Repo URL ---
336
+ space_id = os.getenv("SPACE_ID")
337
+
338
+ if profile:
339
+ username = f"{profile.username}"
340
+ print(f"User logged in: {username}")
341
+ else:
342
+ print("User not logged in.")
343
+ return "Please Login to Hugging Face with the button.", None
344
+
345
+ api_url = DEFAULT_API_URL
346
+ questions_url = f"{api_url}/questions"
347
+ submit_url = f"{api_url}/submit"
348
+
349
+ # In the case of an app running as a Hugging Face space, this link points toward your codebase
350
+ agent_code = f"https://huggingface.co/spaces/{space_id}/tree/main"
351
+ print(agent_code)
352
+
353
+ # 1. Fetch Questions to get task_ids
354
+ print(f"Fetching questions from: {questions_url}")
355
+ try:
356
+ response = requests.get(questions_url, timeout=15)
357
+ response.raise_for_status()
358
+ questions_data = response.json()
359
+ if not questions_data:
360
+ print("Fetched questions list is empty.")
361
+ return "Fetched questions list is empty or invalid format.", None
362
+ print(f"Fetched {len(questions_data)} questions.")
363
+ except requests.exceptions.RequestException as e:
364
+ print(f"Error fetching questions: {e}")
365
+ return f"Error fetching questions: {e}", None
366
+
367
+ # 2. Load Cached Answers
368
+ answers_payload = []
369
+ results_log = []
370
+ missing_answers = []
371
+
372
+ for item in questions_data:
373
+ task_id = item.get("task_id")
374
+ question_text = item.get("question")
375
+
376
+ if not task_id or question_text is None:
377
+ print(f"Skipping item with missing task_id or question: {item}")
378
+ continue
379
+
380
+ # Try to get cached answer
381
+ cached_result = cache_manager.get_cached_answer(question_text)
382
+ if cached_result and cached_result.get('cache_valid', False):
383
+ submitted_answer = cached_result['answer']
384
  answers_payload.append({"task_id": task_id, "submitted_answer": submitted_answer})
385
+ results_log.append({
386
+ "Task ID": task_id,
387
+ "Question": question_text,
388
+ "Submitted Answer": submitted_answer,
389
+ "Status": "Ready for Submission"
390
+ })
391
+ else:
392
+ missing_answers.append(task_id)
393
+ results_log.append({
394
+ "Task ID": task_id,
395
+ "Question": question_text,
396
+ "Submitted Answer": "NO CACHED ANSWER",
397
+ "Status": "Missing Answer"
398
+ })
399
+
400
+ if missing_answers:
401
+ status_message = (
402
+ f"Cannot submit: Missing cached answers for {len(missing_answers)} questions.\n"
403
+ f"Missing task IDs: {missing_answers[:5]}{'...' if len(missing_answers) > 5 else ''}\n"
404
+ f"Please run the questions first to generate and cache answers."
405
+ )
406
+ print(status_message)
407
+ results_df = pd.DataFrame(results_log)
408
+ return status_message, results_df
409
 
410
  if not answers_payload:
411
+ print("No valid cached answers found for submission.")
412
+ return "No valid cached answers found for submission.", pd.DataFrame(results_log)
413
 
414
+ # 3. Prepare Submission
415
  submission_data = {"username": username.strip(), "agent_code": agent_code, "answers": answers_payload}
416
+ status_update = f"Submitting {len(answers_payload)} cached answers for user '{username}'..."
417
  print(status_update)
418
 
419
+ # 4. Submit
420
  print(f"Submitting {len(answers_payload)} answers to: {submit_url}")
421
  try:
422
  response = requests.post(submit_url, json=submission_data, timeout=60)
 
459
  results_df = pd.DataFrame(results_log)
460
  return status_message, results_df
461
 
462
+ def clear_cache():
463
+ """Clear all cached answers."""
464
+ cache_manager.clear_cache()
465
+ return "Cache cleared successfully!", pd.DataFrame()
466
 
467
  # --- Build Gradio Interface using Blocks ---
468
  with gr.Blocks() as demo:
469
+ gr.Markdown("# QA Agent Evaluation Runner")
470
  gr.Markdown(
471
  """
472
  **Instructions:**
473
 
474
  1. Please clone this space, then modify the code to define your agent's logic, the tools, the necessary packages, etc ...
475
  2. Log in to your Hugging Face account using the button below. This uses your HF username for submission.
476
+ 3. Click 'Run Questions' to fetch questions and run your agent (answers will be cached).
477
+ 4. Click 'Submit Answers' to submit the cached answers and see your score.
478
+ 5. Use 'Clear Cache' to remove all cached answers if needed.
479
 
480
  ---
481
+ **Benefits of Separate Run/Submit:**
482
+ - Answers are cached, so you can run questions once and submit multiple times
483
+ - Faster submission since answers are pre-computed
484
+ - Better error handling and recovery
485
+ - Ability to review answers before submission
486
  """
487
  )
488
 
489
  gr.LoginButton()
490
 
491
+ with gr.Row():
492
+ run_button = gr.Button("Run Questions", variant="primary")
493
+ submit_button = gr.Button("Submit Answers", variant="secondary")
494
+ clear_button = gr.Button("Clear Cache", variant="stop")
495
 
496
+ status_output = gr.Textbox(label="Status / Result", lines=5, interactive=False)
 
497
  results_table = gr.DataFrame(label="Questions and Agent Answers", wrap=True)
498
 
499
  run_button.click(
500
+ fn=run_questions,
501
+ outputs=[status_output, results_table]
502
+ )
503
+
504
+ submit_button.click(
505
+ fn=submit_answers,
506
+ outputs=[status_output, results_table]
507
+ )
508
+
509
+ clear_button.click(
510
+ fn=clear_cache,
511
  outputs=[status_output, results_table]
512
  )
513
 
 
532
 
533
  print("-"*(60 + len(" App Starting ")) + "\n")
534
 
535
+ print("Launching Gradio Interface for QA Agent Evaluation...")
536
  demo.launch(debug=True, share=False)
cache_manager.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Cache manager for storing and retrieving agent answers.
3
+ """
4
+
5
+ import os
6
+ import json
7
+ import hashlib
8
+ from typing import Optional, Dict, Any, List
9
+ from datetime import datetime
10
+
11
+ class CacheManager:
12
+ """Manages caching of agent answers to avoid redundant processing."""
13
+
14
+ def __init__(self, cache_dir: str = "cache"):
15
+ self.cache_dir = cache_dir
16
+ self.ensure_cache_dir()
17
+
18
+ def ensure_cache_dir(self):
19
+ """Create cache directory if it doesn't exist."""
20
+ if not os.path.exists(self.cache_dir):
21
+ os.makedirs(self.cache_dir)
22
+
23
+ def _get_question_hash(self, question: str) -> str:
24
+ """Generate a hash for the question to use as filename."""
25
+ return hashlib.md5(question.encode('utf-8')).hexdigest()[:12]
26
+
27
+ def _get_cache_path(self, question: str) -> str:
28
+ """Get the cache file path for a question."""
29
+ question_hash = self._get_question_hash(question)
30
+ return os.path.join(self.cache_dir, f"question_{question_hash}.json")
31
+
32
+ def get_cached_answer(self, question: str) -> Optional[Dict[str, Any]]:
33
+ """
34
+ Retrieve cached answer for a question.
35
+
36
+ Args:
37
+ question: The question to look up
38
+
39
+ Returns:
40
+ Dictionary with answer, iterations, and metadata if cached, None otherwise
41
+ """
42
+ cache_path = self._get_cache_path(question)
43
+ if not os.path.exists(cache_path):
44
+ return None
45
+ try:
46
+ with open(cache_path, 'r', encoding='utf-8') as f:
47
+ data = json.load(f)
48
+ answers = data.get('answers', [])
49
+ if not answers:
50
+ return None
51
+ last_answer = answers[-1]
52
+ return {
53
+ 'answer': last_answer.get('answer', ''),
54
+ 'iterations': last_answer.get('iterations', 0),
55
+ 'timestamp': last_answer.get('timestamp', ''),
56
+ 'cache_valid': data.get('cache_valid', False),
57
+ 'file_name': data.get('file_name', None)
58
+ }
59
+ except Exception as e:
60
+ print(f"Error reading cache: {e}")
61
+ return None
62
+
63
+ def cache_answer(self, question: str, answer: Optional[str], iterations: int = 1, file_name: Optional[str] = None) -> bool:
64
+ """
65
+ Cache an answer for a question with iteration count.
66
+
67
+ Args:
68
+ question: The question that was asked
69
+ answer: The answer to cache
70
+ iterations: Number of iterations/steps used (should be 1-10 typically)
71
+
72
+ Returns:
73
+ True if cached successfully, False otherwise
74
+ """
75
+ cache_path = self._get_cache_path(question)
76
+ cache_valid = bool(answer and self.validate_answer_content(answer))
77
+ now = datetime.now().isoformat()
78
+ try:
79
+ if os.path.exists(cache_path):
80
+ with open(cache_path, 'r', encoding='utf-8') as f:
81
+ data = json.load(f)
82
+ else:
83
+ data = {
84
+ 'question': question,
85
+ 'answers': [],
86
+ 'cache_valid': False,
87
+ 'file_name': file_name
88
+ }
89
+ # Always update file_name for logging
90
+ if file_name:
91
+ data['file_name'] = file_name
92
+ print(f"[CacheManager] file_name submitted: {file_name}")
93
+ # Add answer if available, else just update cache_valid
94
+ if cache_valid:
95
+ data['answers'].append({
96
+ 'answer': answer,
97
+ 'iterations': iterations,
98
+ 'timestamp': now
99
+ })
100
+ data['cache_valid'] = True
101
+ else:
102
+ # Even if no answer, mark cache_valid false and add a stub answer
103
+ data['answers'].append({
104
+ 'answer': answer if answer else "",
105
+ 'iterations': iterations,
106
+ 'timestamp': now
107
+ })
108
+ data['cache_valid'] = False
109
+ with open(cache_path, 'w', encoding='utf-8') as f:
110
+ json.dump(data, f, indent=2)
111
+ return True
112
+ except Exception as e:
113
+ print(f"Error caching answer: {e}")
114
+ return False
115
+
116
+ def validate_answer_content(self, answer: str) -> bool:
117
+ """
118
+ Validate that answer content is reasonable to cache.
119
+ Error messages and corrupted responses should NOT be cached as valid.
120
+
121
+ Args:
122
+ answer: The answer content to validate
123
+
124
+ Returns:
125
+ True if answer is valid to cache, False otherwise
126
+ """
127
+ if not answer or not isinstance(answer, str):
128
+ return False
129
+
130
+ clean_answer = answer.strip()
131
+ if len(clean_answer) < 3:
132
+ return False
133
+
134
+ # Check for error patterns - these should NEVER be cached as valid answers
135
+ error_patterns = [
136
+ 'error calling llm',
137
+ 'error running agent',
138
+ 'error in',
139
+ 'error processing',
140
+ 'litellm.badrequest',
141
+ 'litellm.exception',
142
+ 'vertexaiexception',
143
+ 'badrequest',
144
+ 'invalid_argument',
145
+ 'authentication',
146
+ 'credentials',
147
+ 'api key',
148
+ 'traceback',
149
+ 'exception occurred',
150
+ 'failed to',
151
+ 'unable to submit',
152
+ 'mimetype parameter',
153
+ 'not supported'
154
+ ]
155
+
156
+ # Check if answer contains any error patterns (case insensitive)
157
+ lower_answer = clean_answer.lower()
158
+ for pattern in error_patterns:
159
+ if pattern in lower_answer:
160
+ print(f"[CacheManager] Rejecting answer containing error pattern: '{pattern}'")
161
+ return False
162
+
163
+ # Check for corrupt/empty patterns
164
+ corrupt_patterns = [']', '[', '{}', '()', '""', "''", 'null', 'undefined']
165
+ if clean_answer in corrupt_patterns:
166
+ return False
167
+
168
+ # Check if answer is only brackets/punctuation
169
+ if all(c in '[]{}()' for c in clean_answer):
170
+ return False
171
+
172
+ return True
173
+
174
+ def clear_cache(self):
175
+ """Clear all cached answers."""
176
+ try:
177
+ for filename in os.listdir(self.cache_dir):
178
+ file_path = os.path.join(self.cache_dir, filename)
179
+ if os.path.isfile(file_path):
180
+ os.remove(file_path)
181
+ print("Cache cleared successfully")
182
+ except Exception as e:
183
+ print(f"Error clearing cache: {e}")
184
+
185
+ def list_cached_questions(self) -> List[Dict[str, Any]]:
186
+ """List all cached questions with metadata."""
187
+ cached_questions = []
188
+ try:
189
+ for filename in os.listdir(self.cache_dir):
190
+ if filename.startswith('question_') and filename.endswith('.json'):
191
+ cache_path = os.path.join(self.cache_dir, filename)
192
+ with open(cache_path, 'r', encoding='utf-8') as f:
193
+ data = json.load(f)
194
+ cached_questions.append({
195
+ 'question': data.get('question', ''),
196
+ 'cache_valid': data.get('cache_valid', False),
197
+ 'file_name': data.get('file_name', None),
198
+ 'last_timestamp': data['answers'][-1]['timestamp'] if data.get('answers') else None
199
+ })
200
+ except Exception as e:
201
+ print(f"Error listing cached questions: {e}")
202
+ return sorted(cached_questions, key=lambda x: x.get('last_timestamp', ''), reverse=True)
203
+
204
+ def cleanup_invalid_cache_entries(self) -> int:
205
+ """
206
+ Clean up cache entries that contain error messages or invalid content.
207
+
208
+ Returns:
209
+ Number of entries cleaned up
210
+ """
211
+ cleaned_count = 0
212
+ try:
213
+ for filename in os.listdir(self.cache_dir):
214
+ if filename.startswith('question_') and filename.endswith('.json'):
215
+ cache_path = os.path.join(self.cache_dir, filename)
216
+
217
+ try:
218
+ with open(cache_path, 'r', encoding='utf-8') as f:
219
+ data = json.load(f)
220
+
221
+ # Check if this entry should be cleaned up
222
+ should_cleanup = False
223
+
224
+ # Check if cache_valid is True but contains invalid content
225
+ if data.get('cache_valid', False):
226
+ answers = data.get('answers', [])
227
+ for answer_entry in answers:
228
+ answer_text = answer_entry.get('answer', '')
229
+ if not self.validate_answer_content(answer_text):
230
+ print(f"Found invalid cached answer in {filename}: {answer_text[:100]}...")
231
+ should_cleanup = True
232
+ break
233
+
234
+ if should_cleanup:
235
+ # Mark as invalid instead of deleting to preserve history
236
+ data['cache_valid'] = False
237
+ with open(cache_path, 'w', encoding='utf-8') as f:
238
+ json.dump(data, f, indent=2)
239
+ cleaned_count += 1
240
+ print(f"Cleaned up invalid cache entry: {filename}")
241
+
242
+ except Exception as e:
243
+ print(f"Error processing cache file {filename}: {e}")
244
+ continue
245
+
246
+ except Exception as e:
247
+ print(f"Error during cache cleanup: {e}")
248
+
249
+ print(f"Cache cleanup completed. {cleaned_count} entries cleaned up.")
250
+ return cleaned_count
config.example.yaml ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Example Enhanced GAIA Agent Configuration with Vertex AI Parameters
2
+ # Copy this file to config.yaml and update with your specific values
3
+
4
+ model:
5
+ # Choose your preferred model
6
+ model_id: vertex_ai/gemini-2.5-pro # Vertex AI Gemini model
7
+ #model_id: vertex_ai/gemini-1.5-pro # Alternative Gemini version
8
+ #model_id: anthropic/claude-sonnet-4 # Alternative: Anthropic Claude
9
+ #model_id: openai/gpt-4 # Alternative: OpenAI GPT-4
10
+ temperature: 0.2
11
+ max_tokens: 8096
12
+
13
+ # Vertex AI specific configuration (REQUIRED for vertex_ai models)
14
+ vertex_ai:
15
+ # REQUIRED: Replace with your actual GCP project ID
16
+ vertex_project: "your-gcp-project-id"
17
+
18
+ # REQUIRED: Choose your preferred region
19
+ vertex_location: "us-central1"
20
+ # Other popular regions: "us-east1", "europe-west1", "asia-southeast1"
21
+
22
+ # Authentication: Choose ONE of the following options:
23
+
24
+ # Option 1: Service account file (recommended for local development)
25
+ vertex_credentials: "/path/to/your-service-account.json"
26
+
27
+ # Option 2: Environment variables (comment out vertex_credentials above)
28
+ # Set these in your shell:
29
+ # export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json"
30
+ # export VERTEXAI_PROJECT="your-gcp-project-id"
31
+ # export VERTEXAI_LOCATION="us-central1"
32
+
33
+ # Option 3: GCP SDK authentication (comment out vertex_credentials above)
34
+ # Run: gcloud auth application-default login
35
+
36
+ # Safety settings for content filtering
37
+ # Adjust thresholds based on your use case:
38
+ # BLOCK_NONE, BLOCK_LOW_AND_ABOVE, BLOCK_MEDIUM_AND_ABOVE, BLOCK_ONLY_HIGH
39
+ safety_settings:
40
+ - category: "HARM_CATEGORY_HARASSMENT"
41
+ threshold: "BLOCK_MEDIUM_AND_ABOVE"
42
+ - category: "HARM_CATEGORY_HATE_SPEECH"
43
+ threshold: "BLOCK_MEDIUM_AND_ABOVE"
44
+ - category: "HARM_CATEGORY_SEXUALLY_EXPLICIT"
45
+ threshold: "BLOCK_MEDIUM_AND_ABOVE"
46
+ - category: "HARM_CATEGORY_DANGEROUS_CONTENT"
47
+ threshold: "BLOCK_MEDIUM_AND_ABOVE"
48
+
49
+ # Optional: Enable grounding with Google Search (experimental)
50
+ # This adds real-time web search capabilities to responses
51
+ enable_grounding: false
52
+
53
+ # Agent configuration
54
+ agent:
55
+ name: GAIA-Agent
56
+ description: Agent using LiteLLM with enhanced Vertex AI features
57
+ max_steps: 5
58
+ verbosity_level: 1
59
+
60
+ # GAIA dataset settings
61
+ gaia:
62
+ local_path: "./GAIA"
63
+
64
+ # Cache settings
65
+ cache:
66
+ enabled: true
67
+ directory: cache
68
+
69
+ # Setup Instructions:
70
+ # 1. Copy this file to config.yaml
71
+ # 2. Replace "your-gcp-project-id" with your actual GCP project ID
72
+ # 3. Choose and configure one authentication method
73
+ # 4. Adjust safety settings as needed
74
+ # 5. Set vertex_location to your preferred region
75
+ # 6. Test with: python agent.py or python app.py
config.yaml ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Enhanced GAIA Agent Configuration with Vertex AI Parameters
2
+
3
+ model:
4
+ #model_id: anthropic/claude-sonnet-4-20250514
5
+ model_id: vertex_ai/gemini-2.5-pro
6
+ temperature: 0.2
7
+ max_tokens: 8096
8
+
9
+ # Vertex AI specific configuration
10
+ vertex_ai:
11
+ # Project and location settings (REQUIRED - update with your values)
12
+ vertex_project: "gen-lang-client-0348172727" # Replace with your actual GCP project ID
13
+ vertex_location: "europe-west1" # Or your preferred region (us-east1, europe-west1, etc.)
14
+
15
+ # Authentication options (choose one):
16
+ # Option 1: Service account file path (recommended for local development)
17
+ vertex_credentials: "google.json" # Replace with actual path
18
+
19
+ # Option 2: Use environment variables (RECOMMENDED - currently active):
20
+ # Set these environment variables:
21
+ # export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json"
22
+ # export VERTEXAI_PROJECT="gen-lang-client-0348172727"
23
+ # export VERTEXAI_LOCATION="europe-west1"
24
+
25
+ # Option 3: Use gcloud auth (simplest for development):
26
+ # Run: gcloud auth application-default login
27
+
28
+ # Safety settings for content filtering
29
+ safety_settings:
30
+ - category: "HARM_CATEGORY_HARASSMENT"
31
+ threshold: "BLOCK_MEDIUM_AND_ABOVE"
32
+ - category: "HARM_CATEGORY_HATE_SPEECH"
33
+ threshold: "BLOCK_MEDIUM_AND_ABOVE"
34
+ - category: "HARM_CATEGORY_SEXUALLY_EXPLICIT"
35
+ threshold: "BLOCK_MEDIUM_AND_ABOVE"
36
+ - category: "HARM_CATEGORY_DANGEROUS_CONTENT"
37
+ threshold: "BLOCK_MEDIUM_AND_ABOVE"
38
+
39
+ # Optional: Enable grounding with Google Search (experimental)
40
+ enable_grounding: false
41
+
42
+ agent:
43
+ name: QA-Agent
44
+ description: Agent using LiteLLM with enhanced Vertex AI features
45
+ max_steps: 5
46
+ verbosity_level: 1
47
+
48
+ # GAIA dataset settings
49
+ gaia:
50
+ local_path: "./GAIA"
51
+
52
+ # Cache settings
53
+ cache:
54
+ enabled: true
55
+ directory: cache
prompts.yaml ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ system_prompt: |
2
+ You are a precise test question answering agent designed to provide accurate, concise answers.
3
+
4
+ CRITICAL: When a question references a file (e.g. "file.xlsx"), you MUST use the get_file tool as first option/step to load the file content before doing any code, analysis, or calculations. Do NOT attempt to open or read files directly in code. Only use the content returned by get_file.
5
+
6
+ Look for file references like "analyze file.xlsx", "check image.png", "review document.pdf", etc.
7
+
8
+ Remember: You are being evaluated on answer accuracy and precision, not explanation quality.
9
+
10
+ user_prompt: |
11
+ Answer this question with maximum precision and minimum words: {task}
12
+
13
+ If the question references any files, use the get_file tool to load them first.
14
+ Use other tools efficiently to gather additional information if needed.
15
+ Provide only the essential answer.
16
+
17
+ final_answer: |
18
+ {answer}
19
+
20
+ planning: |
21
+ Question: {task}
22
+
23
+ Plan:
24
+ 1. Check if question references any files by name
25
+ 2. If yes: use get_file tool to load the file
26
+ 3. If no: identify what specific information is needed from web
27
+ 4. Use minimum tools to get accurate data
28
+ 5. Extract the essential answer only
29
+ 6. Provide direct response without explanation
30
+
31
+ Target: Use get_file when files are referenced, web tools when needed.
32
+
33
+ managed_agent: |
34
+ You are a test question answering agent optimized for ultra-fast precision.
35
+
36
+ Your mission:
37
+ 1. Check if question references files by name (e.g., "analyze data.xlsx", "check image.png")
38
+ 2. If files referenced: use get_file tool to load them
39
+ 3. If no files: use web search and other tools efficiently (1-2 maximum)
40
+ 4. Extract the precise answer without explanations
41
+ 5. Return only the essential information requested
42
+
43
+ Current question: {task}
44
+
45
+ Remember: File references = use get_file tool. Web content = efficient tool usage.
requirements.txt CHANGED
@@ -1,2 +1,13 @@
 
 
1
  gradio
2
- requests
 
 
 
 
 
 
 
 
 
 
1
+ datasets
2
+ beautifulsoup4
3
  gradio
4
+ gradio[oauth]
5
+ requests
6
+ smolagents
7
+ smolagents[litellm]
8
+ wikipedia-api
9
+ duckduckgo-search
10
+ pandasPyPDF2
11
+ openpyxl
12
+ huggingface_hub
13
+ pandas
tools/final_answer.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from smolagents import tool
2
+
3
+ @tool
4
+ def final_answer(answer: str) -> str:
5
+ """
6
+ Tool to provide the final, precise answer to a test question.
7
+
8
+ IMPORTANT: This tool should receive only the direct answer without any explanations,
9
+ prefixes like "The answer is", or additional formatting.
10
+
11
+ Args:
12
+ answer: The precise, direct answer (e.g., "42", "Paris", "Yes", "2023-10-15")
13
+
14
+ Returns:
15
+ The clean final answer string
16
+ """
17
+ # Clean the answer to ensure precision
18
+ clean_answer = str(answer).strip()
19
+
20
+ # Remove common prefixes that add unnecessary verbosity
21
+ prefixes_to_remove = [
22
+ "the answer is ",
23
+ "based on my research, ",
24
+ "according to my findings, ",
25
+ "the result is ",
26
+ "my answer is ",
27
+ "i found that ",
28
+ "the correct answer is ",
29
+ "after searching, ",
30
+ ]
31
+
32
+ lower_answer = clean_answer.lower()
33
+ for prefix in prefixes_to_remove:
34
+ if lower_answer.startswith(prefix):
35
+ clean_answer = clean_answer[len(prefix):]
36
+ break
37
+
38
+ # Remove trailing periods for single-word answers (but keep for sentences)
39
+ if len(clean_answer.split()) == 1 and clean_answer.endswith('.'):
40
+ clean_answer = clean_answer[:-1]
41
+
42
+ return clean_answer.strip()
43
+
44
+ class FinalAnswerTool:
45
+ """Compatibility class for the final answer tool"""
46
+
47
+ def __call__(self, answer: str) -> str:
48
+ result = final_answer(answer)
49
+ if isinstance(result, str):
50
+ return result
51
+ elif isinstance(result, NotImplementedError):
52
+ raise result
53
+ else:
54
+ return str(result)
tools/get_file.py ADDED
@@ -0,0 +1,368 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ File retrieval tool for accessing files from the GAIA dataset.
3
+ Handles multiple file formats including audio, text, PDFs, images, spreadsheets, and structured data.
4
+ Enhanced with content transformation capabilities for better LLM readability.
5
+
6
+ Required Dependencies:
7
+ pip install PyPDF2 openpyxl huggingface_hub pandas
8
+
9
+ For audio transcription, set HF_TOKEN environment variable.
10
+ """
11
+
12
+ from smolagents import tool
13
+ from datasets import load_dataset
14
+ import os
15
+ import json
16
+ import csv
17
+ import io
18
+ import base64
19
+ from typing import Optional, Dict, Any
20
+ import mimetypes
21
+
22
+ # Direct imports - install these packages for full functionality
23
+ import PyPDF2
24
+ import openpyxl
25
+ import pandas as pd
26
+ from huggingface_hub import InferenceClient
27
+ import requests
28
+
29
+ # Global dataset variable to avoid reloading
30
+ _dataset = None
31
+
32
+ def get_dataset():
33
+ """Get or load the GAIA dataset."""
34
+ global _dataset
35
+ if _dataset is None:
36
+ _dataset = load_dataset("gaia-benchmark/GAIA", "2023_level1", trust_remote_code=True, cache_dir="GAIA")
37
+ return _dataset
38
+
39
+ @tool
40
+ def get_file(filename: str) -> str:
41
+ """
42
+ Retrieve file content by filename.
43
+
44
+ Args:
45
+ filename: The name of the file to retrieve from
46
+
47
+ Returns:
48
+ A string containing the file content information and metadata.
49
+ For binary files, returns metadata and base64-encoded content when appropriate.
50
+ """
51
+ try:
52
+ # Load the dataset
53
+ dataset = get_dataset()
54
+
55
+ # Search for the file in the validation split
56
+ file_item = None
57
+
58
+ # Handle both iterable and indexable datasets
59
+ try:
60
+ # Access validation split using proper datasets API
61
+ validation_data = dataset["validation"] # type: ignore
62
+
63
+ # Try to iterate through the dataset
64
+ for item in validation_data:
65
+ if isinstance(item, dict) and item.get("file_name") == filename:
66
+ file_item = item
67
+ break
68
+ except Exception as e:
69
+ # If direct access fails, try alternative approaches
70
+ try:
71
+ # Try accessing as attribute
72
+ validation_data = dataset.validation # type: ignore
73
+ for item in validation_data:
74
+ if isinstance(item, dict) and item.get("file_name") == filename:
75
+ file_item = item
76
+ break
77
+ except Exception as e2:
78
+ return f"Error accessing dataset: {str(e)} / {str(e2)}"
79
+
80
+ if not file_item:
81
+ return f"File '{filename}' not found in the GAIA dataset. Available files can be found by examining the dataset validation split."
82
+
83
+ # Get file path from dataset item
84
+ file_path = file_item.get("file_path") if isinstance(file_item, dict) else None
85
+ if not file_path:
86
+ return f"File '{filename}' found in dataset but no file_path available."
87
+
88
+ # Check if file exists at the specified path
89
+ if not os.path.exists(file_path):
90
+ return f"File '{filename}' not found at expected path: {file_path}"
91
+
92
+ # Determine file type and MIME type
93
+ mime_type, _ = mimetypes.guess_type(filename)
94
+ file_extension = os.path.splitext(filename)[1].lower()
95
+
96
+ # Prepare result with metadata
97
+ result = f"File: {filename}\n"
98
+ result += f"MIME Type: {mime_type or 'unknown'}\n"
99
+ result += f"Extension: {file_extension}\n"
100
+
101
+ # Add any additional metadata from the dataset item
102
+ if isinstance(file_item, dict) and "task_id" in file_item:
103
+ result += f"Associated Task ID: {file_item['task_id']}\n"
104
+
105
+ result += "\n" + "="*50 + "\n"
106
+ result += "FILE CONTENT:\n"
107
+ result += "="*50 + "\n\n"
108
+
109
+ # Handle different file types
110
+ try:
111
+ if _is_text_file(filename, mime_type):
112
+ with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
113
+ content = f.read()
114
+ if len(content) > 10000:
115
+ content = content[:10000] + "\n\n... [Content truncated - showing first 10,000 characters]"
116
+ result += content
117
+
118
+ elif _is_pdf_file(filename, mime_type):
119
+ result += _handle_pdf_file(file_path, filename)
120
+
121
+ elif _is_excel_file(filename, mime_type):
122
+ result += _handle_excel_file(file_path, filename)
123
+
124
+ elif _is_csv_file(filename, mime_type):
125
+ result += _handle_csv_file(file_path, filename)
126
+
127
+ elif _is_audio_file(filename, mime_type):
128
+ result += _handle_audio_file(file_path, filename)
129
+
130
+ elif _is_image_file(filename, mime_type):
131
+ with open(file_path, 'rb') as f:
132
+ file_content = f.read()
133
+ result += _handle_image_file(file_content, filename)
134
+
135
+ elif _is_structured_data_file(filename, mime_type):
136
+ with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
137
+ content = f.read()
138
+ result += _handle_structured_data(content, filename)
139
+
140
+ else:
141
+ with open(file_path, 'rb') as f:
142
+ file_content = f.read()
143
+ result += _handle_binary_file(file_content, filename)
144
+
145
+ except Exception as e:
146
+ return f"Error reading file '{filename}': {str(e)}"
147
+
148
+ return result
149
+
150
+ except Exception as e:
151
+ return f"Error retrieving file '{filename}': {str(e)}"
152
+
153
+ def _is_text_file(filename: str, mime_type: Optional[str]) -> bool:
154
+ """Check if file is a text file."""
155
+ text_extensions = {'.txt', '.md', '.rtf', '.log', '.cfg', '.ini', '.conf', '.py', '.js', '.html', '.css', '.sql', '.sh', '.bat', '.r', '.cpp', '.c', '.java', '.php', '.rb', '.go', '.rs', '.ts', '.jsx', '.tsx', '.vue', '.svelte'}
156
+ return (
157
+ filename.lower().endswith(tuple(text_extensions)) or
158
+ (mime_type is not None and mime_type.startswith('text/'))
159
+ )
160
+
161
+ def _is_pdf_file(filename: str, mime_type: Optional[str]) -> bool:
162
+ """Check if file is a PDF file."""
163
+ return filename.lower().endswith('.pdf') or (mime_type == 'application/pdf')
164
+
165
+ def _is_excel_file(filename: str, mime_type: Optional[str]) -> bool:
166
+ """Check if file is an Excel file."""
167
+ return filename.lower().endswith(('.xlsx', '.xls'))
168
+
169
+ def _is_csv_file(filename: str, mime_type: Optional[str]) -> bool:
170
+ """Check if file is a CSV file."""
171
+ return filename.lower().endswith('.csv') or (mime_type == 'text/csv')
172
+
173
+ def _is_audio_file(filename: str, mime_type: Optional[str]) -> bool:
174
+ """Check if file is an audio file."""
175
+ audio_extensions = {'.mp3', '.wav', '.m4a', '.aac', '.ogg', '.flac', '.wma'}
176
+ return filename.lower().endswith(tuple(audio_extensions)) or (mime_type is not None and mime_type.startswith('audio/'))
177
+
178
+ def _is_image_file(filename: str, mime_type: Optional[str]) -> bool:
179
+ """Check if file is an image file."""
180
+ image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.svg', '.webp', '.tiff', '.ico'}
181
+ return filename.lower().endswith(tuple(image_extensions)) or (mime_type is not None and mime_type.startswith('image/'))
182
+
183
+ def _is_structured_data_file(filename: str, mime_type: Optional[str]) -> bool:
184
+ """Check if file is a structured data file."""
185
+ return filename.lower().endswith(('.json', '.xml', '.yaml', '.yml'))
186
+
187
+ def _handle_pdf_file(file_path: str, filename: str) -> str:
188
+ """Extract text from PDF file."""
189
+ try:
190
+ result = f"PDF TEXT CONTENT:\n"
191
+ result += "="*50 + "\n"
192
+
193
+ with open(file_path, 'rb') as pdf_file:
194
+ pdf_reader = PyPDF2.PdfReader(pdf_file)
195
+ page_count = len(pdf_reader.pages)
196
+ result += f"Total pages: {page_count}\n\n"
197
+
198
+ text_content = ""
199
+ for page_num in range(min(10, page_count)): # First 10 pages
200
+ page = pdf_reader.pages[page_num]
201
+ page_text = page.extract_text()
202
+ if page_text:
203
+ text_content += f"--- PAGE {page_num + 1} ---\n"
204
+ text_content += page_text + "\n\n"
205
+
206
+ if page_count > 10:
207
+ text_content += f"... [Showing first 10 pages out of {page_count} total]\n"
208
+
209
+ if len(text_content) > 15000:
210
+ text_content = text_content[:15000] + "\n\n... [Content truncated]"
211
+
212
+ result += text_content
213
+
214
+ return result
215
+ except Exception as e:
216
+ return f"Error extracting PDF text: {str(e)}"
217
+
218
+ def _handle_excel_file(file_path: str, filename: str) -> str:
219
+ """Extract data from Excel file."""
220
+ try:
221
+ result = f"EXCEL CONTENT:\n"
222
+ result += "="*50 + "\n"
223
+
224
+ # Use pandas for Excel reading
225
+ excel_file = pd.ExcelFile(file_path)
226
+ sheet_names = excel_file.sheet_names
227
+
228
+ result += f"Number of sheets: {len(sheet_names)}\n"
229
+ result += f"Sheet names: {', '.join(str(name) for name in sheet_names)}\n\n"
230
+
231
+ for sheet_name in sheet_names[:3]: # First 3 sheets
232
+ df = pd.read_excel(file_path, sheet_name=sheet_name)
233
+ result += f"SHEET: {sheet_name}\n"
234
+ result += "="*30 + "\n"
235
+ result += f"Dimensions: {df.shape[0]} rows × {df.shape[1]} columns\n"
236
+ result += f"Columns: {list(df.columns)}\n\n"
237
+
238
+ result += "First 5 rows:\n"
239
+ result += df.head().to_string(index=True) + "\n\n"
240
+
241
+ if len(sheet_names) > 3:
242
+ result += f"... and {len(sheet_names) - 3} more sheets\n"
243
+
244
+ return result
245
+ except Exception as e:
246
+ return f"Error reading Excel file: {str(e)}"
247
+
248
+ def _handle_csv_file(file_path: str, filename: str) -> str:
249
+ """Extract data from CSV file."""
250
+ try:
251
+ result = f"CSV CONTENT:\n"
252
+ result += "="*50 + "\n"
253
+
254
+ df = pd.read_csv(file_path)
255
+ result += f"Dimensions: {df.shape[0]} rows × {df.shape[1]} columns\n"
256
+ result += f"Columns: {list(df.columns)}\n\n"
257
+
258
+ result += "First 10 rows:\n"
259
+ result += df.head(10).to_string(index=True) + "\n"
260
+
261
+ return result
262
+ except Exception as e:
263
+ return f"Error reading CSV file: {str(e)}"
264
+
265
+ def _handle_audio_file(file_path: str, filename: str) -> str:
266
+ """Transcribe audio file."""
267
+ try:
268
+ result = f"AUDIO TRANSCRIPTION:\n"
269
+ result += "="*50 + "\n"
270
+
271
+ if not os.environ.get("HF_TOKEN"):
272
+ return "Audio transcription requires HF_TOKEN environment variable to be set."
273
+
274
+ # Determine content type based on file extension
275
+ file_ext = os.path.splitext(filename)[1].lower()
276
+ content_type_map = {
277
+ '.mp3': 'audio/mpeg',
278
+ '.wav': 'audio/wav',
279
+ '.flac': 'audio/flac',
280
+ '.m4a': 'audio/m4a',
281
+ '.ogg': 'audio/ogg',
282
+ '.webm': 'audio/webm'
283
+ }
284
+ content_type = content_type_map.get(file_ext, 'audio/mpeg')
285
+
286
+ headers = {
287
+ "Authorization": f"Bearer {os.environ['HF_TOKEN']}",
288
+ "Content-Type": content_type
289
+ }
290
+
291
+ # Read the audio file
292
+ with open(file_path, 'rb') as audio_file:
293
+ audio_data = audio_file.read()
294
+
295
+ # Make direct API call to HuggingFace
296
+ api_url = "https://api-inference.huggingface.co/models/openai/whisper-large-v3"
297
+ response = requests.post(api_url, headers=headers, data=audio_data)
298
+
299
+ if response.status_code == 200:
300
+ transcription_output = response.json()
301
+ else:
302
+ return f"Error from HuggingFace API: {response.status_code} - {response.text}"
303
+
304
+
305
+ if isinstance(transcription_output, dict) and 'text' in transcription_output:
306
+ transcription_text = transcription_output['text']
307
+ else:
308
+ transcription_text = str(transcription_output)
309
+
310
+ result += transcription_text + "\n"
311
+ result += "\n" + "="*50 + "\n"
312
+ result += "Transcription completed using Whisper Large v3"
313
+
314
+ return result
315
+ except Exception as e:
316
+ return f"Error transcribing audio: {str(e)}"
317
+
318
+ def _handle_image_file(file_content: bytes, filename: str) -> str:
319
+ """Handle image file with base64 encoding."""
320
+ try:
321
+ result = f"IMAGE CONTENT:\n"
322
+ result += "="*50 + "\n"
323
+ result += f"Image file: {filename}\n"
324
+ result += f"File size: {len(file_content)} bytes\n"
325
+ result += f"Format: {os.path.splitext(filename)[1].upper().lstrip('.')}\n\n"
326
+
327
+ # Encode image as base64
328
+ base64_content = base64.b64encode(file_content).decode('utf-8')
329
+ result += "Base64 encoded content:\n"
330
+ result += base64_content + "\n\n"
331
+
332
+ result += "Note: This is the base64 encoded image data that can be decoded and analyzed."
333
+ return result
334
+ except Exception as e:
335
+ return f"Error handling image: {str(e)}"
336
+
337
+ def _handle_binary_file(file_content: bytes, filename: str) -> str:
338
+ """Handle binary files with base64 encoding."""
339
+ try:
340
+ result = f"BINARY FILE CONTENT:\n"
341
+ result += "="*50 + "\n"
342
+ result += f"Binary file: {filename}\n"
343
+ result += f"File size: {len(file_content)} bytes\n"
344
+ result += f"File extension: {os.path.splitext(filename)[1]}\n\n"
345
+
346
+ # Encode binary content as base64
347
+ base64_content = base64.b64encode(file_content).decode('utf-8')
348
+ result += "Base64 encoded content:\n"
349
+ result += base64_content + "\n\n"
350
+
351
+ result += "Note: This is the base64 encoded binary data."
352
+ return result
353
+ except Exception as e:
354
+ return f"Error handling binary file: {str(e)}"
355
+
356
+ def _handle_structured_data(content: str, filename: str) -> str:
357
+ """Handle structured data files."""
358
+ try:
359
+ if filename.lower().endswith('.json'):
360
+ try:
361
+ data = json.loads(content)
362
+ return json.dumps(data, indent=2, ensure_ascii=False)
363
+ except json.JSONDecodeError:
364
+ return content
365
+ else:
366
+ return content
367
+ except Exception as e:
368
+ return f"Error handling structured data: {str(e)}"
tools/web_scraping.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Web scraping tools for extracting content from web pages.
3
+ """
4
+
5
+ from smolagents import tool
6
+ import requests
7
+ from bs4 import BeautifulSoup
8
+ import urllib.parse
9
+
10
+
11
+ @tool
12
+ def scrape_webpage_content(url: str, content_selector: str = None) -> str:
13
+ """
14
+ Scrape content from a webpage and extract the main text content.
15
+
16
+ Args:
17
+ url: The URL of the webpage to scrape
18
+ content_selector: Optional CSS selector to target specific content (e.g., '.article__content', '#main-content')
19
+
20
+ Returns:
21
+ The extracted text content from the webpage
22
+ """
23
+ try:
24
+ # Validate URL
25
+ parsed_url = urllib.parse.urlparse(url)
26
+ if not parsed_url.scheme or not parsed_url.netloc:
27
+ return f"Invalid URL: {url}"
28
+
29
+ # Set headers to mimic a real browser
30
+ headers = {
31
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
32
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
33
+ 'Accept-Language': 'en-US,en;q=0.5',
34
+ 'Accept-Encoding': 'gzip, deflate',
35
+ 'Connection': 'keep-alive',
36
+ }
37
+
38
+ # Make the request
39
+ response = requests.get(url, headers=headers, timeout=15)
40
+ response.raise_for_status()
41
+
42
+ # Parse the HTML
43
+ soup = BeautifulSoup(response.content, 'html.parser')
44
+
45
+ # Remove script and style elements
46
+ for script in soup(["script", "style", "nav", "header", "footer", "aside"]):
47
+ script.decompose()
48
+
49
+ # Extract content based on selector or find main content
50
+ if content_selector:
51
+ # Use the provided CSS selector
52
+ content_element = soup.select_one(content_selector)
53
+ if content_element:
54
+ text_content = content_element.get_text(strip=True, separator=' ')
55
+ else:
56
+ return f"No content found with selector '{content_selector}' on {url}"
57
+ else:
58
+ # Try common content selectors
59
+ content_selectors = [
60
+ 'article',
61
+ '.article__content',
62
+ '.content',
63
+ '.post-content',
64
+ '.entry-content',
65
+ '#content',
66
+ 'main',
67
+ '.main-content',
68
+ '[role="main"]'
69
+ ]
70
+
71
+ text_content = None
72
+ for selector in content_selectors:
73
+ element = soup.select_one(selector)
74
+ if element:
75
+ text_content = element.get_text(strip=True, separator=' ')
76
+ break
77
+
78
+ # If no specific content area found, get body text
79
+ if not text_content:
80
+ body = soup.find('body')
81
+ if body:
82
+ text_content = body.get_text(strip=True, separator=' ')
83
+ else:
84
+ text_content = soup.get_text(strip=True, separator=' ')
85
+
86
+ # Clean up the text
87
+ if text_content:
88
+ # Remove excessive whitespace
89
+ lines = [line.strip() for line in text_content.split('\n') if line.strip()]
90
+ cleaned_text = '\n'.join(lines)
91
+
92
+ # Limit length to prevent overwhelming responses
93
+ if len(cleaned_text) > 5000:
94
+ cleaned_text = cleaned_text[:5000] + "... [Content truncated]"
95
+
96
+ return f"Content from {url}:\n\n{cleaned_text}"
97
+ else:
98
+ return f"No readable content found on {url}"
99
+
100
+ except requests.exceptions.RequestException as e:
101
+ return f"Error fetching webpage {url}: {str(e)}"
102
+ except Exception as e:
103
+ return f"Error scraping webpage {url}: {str(e)}"
104
+
105
+
106
+ @tool
107
+ def extract_links_from_webpage(url: str, link_text_filter: str = None) -> str:
108
+ """
109
+ Extract links from a webpage, optionally filtering by link text.
110
+
111
+ Args:
112
+ url: The URL of the webpage to scrape
113
+ link_text_filter: Optional text to filter links by (case-insensitive)
114
+
115
+ Returns:
116
+ A formatted string containing the extracted links
117
+ """
118
+ try:
119
+ headers = {
120
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
121
+ }
122
+
123
+ response = requests.get(url, headers=headers, timeout=15)
124
+ response.raise_for_status()
125
+
126
+ soup = BeautifulSoup(response.content, 'html.parser')
127
+
128
+ # Find all links
129
+ links = soup.find_all('a', href=True)
130
+
131
+ extracted_links = []
132
+ for link in links:
133
+ href = link['href']
134
+ text = link.get_text(strip=True)
135
+
136
+ # Convert relative URLs to absolute
137
+ if href.startswith('/'):
138
+ parsed_base = urllib.parse.urlparse(url)
139
+ href = f"{parsed_base.scheme}://{parsed_base.netloc}{href}"
140
+ elif href.startswith('#'):
141
+ continue # Skip anchor links
142
+
143
+ # Filter by text if specified
144
+ if link_text_filter:
145
+ if link_text_filter.lower() not in text.lower():
146
+ continue
147
+
148
+ if text and href.startswith('http'):
149
+ extracted_links.append(f"• {text}: {href}")
150
+
151
+ if extracted_links:
152
+ result = f"Links extracted from {url}:\n\n" + '\n'.join(extracted_links[:20]) # Limit to 20 links
153
+ if len(extracted_links) > 20:
154
+ result += f"\n... and {len(extracted_links) - 20} more links"
155
+ return result
156
+ else:
157
+ return f"No links found on {url}"
158
+
159
+ except Exception as e:
160
+ return f"Error extracting links from {url}: {str(e)}"
161
+
162
+
163
+ @tool
164
+ def get_webpage_metadata(url: str) -> str:
165
+ """
166
+ Extract metadata from a webpage (title, description, etc.).
167
+
168
+ Args:
169
+ url: The URL of the webpage to analyze
170
+
171
+ Returns:
172
+ A formatted string containing the webpage metadata
173
+ """
174
+ try:
175
+ headers = {
176
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
177
+ }
178
+
179
+ response = requests.get(url, headers=headers, timeout=15)
180
+ response.raise_for_status()
181
+
182
+ soup = BeautifulSoup(response.content, 'html.parser')
183
+
184
+ metadata = []
185
+
186
+ # Title
187
+ title = soup.find('title')
188
+ if title:
189
+ metadata.append(f"Title: {title.get_text(strip=True)}")
190
+
191
+ # Meta description
192
+ meta_desc = soup.find('meta', attrs={'name': 'description'})
193
+ if meta_desc and meta_desc.get('content'):
194
+ metadata.append(f"Description: {meta_desc['content']}")
195
+
196
+ # Meta keywords
197
+ meta_keywords = soup.find('meta', attrs={'name': 'keywords'})
198
+ if meta_keywords and meta_keywords.get('content'):
199
+ metadata.append(f"Keywords: {meta_keywords['content']}")
200
+
201
+ # Author
202
+ meta_author = soup.find('meta', attrs={'name': 'author'})
203
+ if meta_author and meta_author.get('content'):
204
+ metadata.append(f"Author: {meta_author['content']}")
205
+
206
+ # Open Graph metadata
207
+ og_title = soup.find('meta', attrs={'property': 'og:title'})
208
+ if og_title and og_title.get('content'):
209
+ metadata.append(f"OG Title: {og_title['content']}")
210
+
211
+ og_desc = soup.find('meta', attrs={'property': 'og:description'})
212
+ if og_desc and og_desc.get('content'):
213
+ metadata.append(f"OG Description: {og_desc['content']}")
214
+
215
+ if metadata:
216
+ return f"Metadata from {url}:\n\n" + '\n'.join(metadata)
217
+ else:
218
+ return f"No metadata found on {url}"
219
+
220
+ except Exception as e:
221
+ return f"Error extracting metadata from {url}: {str(e)}"