Jan Krüger
commited on
Commit
·
8d4d62e
1
Parent(s):
81917a3
QA Agent for Certification
Browse files- .gitignore +11 -0
- README.md +3 -3
- app.py +386 -46
- cache_manager.py +250 -0
- config.example.yaml +75 -0
- config.yaml +55 -0
- prompts.yaml +45 -0
- requirements.txt +12 -1
- tools/final_answer.py +54 -0
- tools/get_file.py +368 -0
- 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:
|
3 |
-
emoji:
|
4 |
colorFrom: indigo
|
5 |
-
colorTo:
|
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 |
-
# ---
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
16 |
def __call__(self, question: str) -> str:
|
17 |
print(f"Agent received question (first 50 chars): {question[:50]}...")
|
18 |
-
|
19 |
-
|
20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
21 |
|
22 |
-
def
|
23 |
"""
|
24 |
-
Fetches all questions, runs the
|
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
|
42 |
try:
|
43 |
-
agent =
|
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
|
73 |
results_log = []
|
74 |
-
|
|
|
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 |
-
|
83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
84 |
answers_payload.append({"task_id": task_id, "submitted_answer": submitted_answer})
|
85 |
-
results_log.append({
|
86 |
-
|
87 |
-
|
88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
89 |
|
90 |
if not answers_payload:
|
91 |
-
print("
|
92 |
-
return "
|
93 |
|
94 |
-
#
|
95 |
submission_data = {"username": username.strip(), "agent_code": agent_code, "answers": answers_payload}
|
96 |
-
status_update = f"
|
97 |
print(status_update)
|
98 |
|
99 |
-
#
|
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("#
|
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
|
|
|
|
|
153 |
|
154 |
---
|
155 |
-
**
|
156 |
-
|
157 |
-
|
|
|
|
|
158 |
"""
|
159 |
)
|
160 |
|
161 |
gr.LoginButton()
|
162 |
|
163 |
-
|
|
|
|
|
|
|
164 |
|
165 |
-
status_output = gr.Textbox(label="
|
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=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)}"
|