omarequalmars
commited on
Commit
·
7319d31
1
Parent(s):
81917a3
Initial submission
Browse files- .gitignore +9 -0
- __pycache__/app.cpython-313.pyc +0 -0
- __pycache__/test_math_tools.cpython-313.pyc +0 -0
- __pycache__/test_multimodal_tools.cpython-313.pyc +0 -0
- __pycache__/test_search_tools.cpython-313.pyc +0 -0
- __pycache__/test_youtube_tools.cpython-313.pyc +0 -0
- app.py +90 -27
- example_usage.py +14 -0
- graph/__init__.py +0 -0
- graph/__pycache__/__init__.cpython-313.pyc +0 -0
- graph/__pycache__/graph_builder.cpython-313.pyc +0 -0
- graph/graph_builder.py +24 -0
- local_test.py +17 -0
- nodes/__init__.py +0 -0
- nodes/__pycache__/__init__.cpython-313.pyc +0 -0
- nodes/__pycache__/core.cpython-313.pyc +0 -0
- nodes/core.py +90 -0
- requirements.txt +12 -1
- states/__init__.py +0 -0
- states/__pycache__/__init__.cpython-313.pyc +0 -0
- states/__pycache__/state.cpython-313.pyc +0 -0
- states/state.py +7 -0
- test_agent.py +50 -0
- test_all_tools.py +63 -0
- test_math_tools.py +78 -0
- test_multimodal_tools.py +145 -0
- test_search_tools.py +96 -0
- test_youtube_tools.py +111 -0
- tools/__init__.py +46 -0
- tools/__pycache__/__init__.cpython-313.pyc +0 -0
- tools/__pycache__/langchain_tools.cpython-313.pyc +0 -0
- tools/__pycache__/math_tools.cpython-313.pyc +0 -0
- tools/__pycache__/multimodal_tools.cpython-313.pyc +0 -0
- tools/__pycache__/search_tools.cpython-313.pyc +0 -0
- tools/__pycache__/utils.cpython-313.pyc +0 -0
- tools/__pycache__/youtube_tools.cpython-313.pyc +0 -0
- tools/langchain_tools.py +128 -0
- tools/math_tools.py +206 -0
- tools/multimodal_tools.py +166 -0
- tools/search_tools.py +223 -0
- tools/utils.py +36 -0
- tools/youtube_tools.py +315 -0
.gitignore
ADDED
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.env
|
2 |
+
local_test
|
3 |
+
example_usage
|
4 |
+
test_agent
|
5 |
+
test_all_tools
|
6 |
+
test_math_tools
|
7 |
+
test_multimodal_tools
|
8 |
+
test_search_tools
|
9 |
+
test_youtube_tools
|
__pycache__/app.cpython-313.pyc
ADDED
Binary file (12.1 kB). View file
|
|
__pycache__/test_math_tools.cpython-313.pyc
ADDED
Binary file (4.73 kB). View file
|
|
__pycache__/test_multimodal_tools.cpython-313.pyc
ADDED
Binary file (6.33 kB). View file
|
|
__pycache__/test_search_tools.cpython-313.pyc
ADDED
Binary file (4.95 kB). View file
|
|
__pycache__/test_youtube_tools.cpython-313.pyc
ADDED
Binary file (5.84 kB). View file
|
|
app.py
CHANGED
@@ -3,23 +3,86 @@ 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 |
-
# ----- THIS IS
|
13 |
class BasicAgent:
|
14 |
def __init__(self):
|
15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
16 |
def __call__(self, question: str) -> str:
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
21 |
|
22 |
-
|
|
|
23 |
"""
|
24 |
Fetches all questions, runs the BasicAgent on them, submits all answers,
|
25 |
and displays the results.
|
@@ -38,13 +101,14 @@ def run_and_submit_all( profile: gr.OAuthProfile | None):
|
|
38 |
questions_url = f"{api_url}/questions"
|
39 |
submit_url = f"{api_url}/submit"
|
40 |
|
41 |
-
# 1. Instantiate 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 |
-
|
|
|
48 |
agent_code = f"https://huggingface.co/spaces/{space_id}/tree/main"
|
49 |
print(agent_code)
|
50 |
|
@@ -61,10 +125,6 @@ def run_and_submit_all( profile: gr.OAuthProfile | None):
|
|
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
|
@@ -139,22 +199,26 @@ 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 |
-
|
151 |
-
|
152 |
-
|
|
|
153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
154 |
---
|
155 |
-
**
|
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 |
|
@@ -163,7 +227,6 @@ with gr.Blocks() as demo:
|
|
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(
|
@@ -175,7 +238,7 @@ if __name__ == "__main__":
|
|
175 |
print("\n" + "-"*30 + " App Starting " + "-"*30)
|
176 |
# Check for SPACE_HOST and SPACE_ID at startup for information
|
177 |
space_host_startup = os.getenv("SPACE_HOST")
|
178 |
-
space_id_startup = os.getenv("SPACE_ID")
|
179 |
|
180 |
if space_host_startup:
|
181 |
print(f"✅ SPACE_HOST found: {space_host_startup}")
|
@@ -183,7 +246,7 @@ if __name__ == "__main__":
|
|
183 |
else:
|
184 |
print("ℹ️ SPACE_HOST environment variable not found (running locally?).")
|
185 |
|
186 |
-
if space_id_startup:
|
187 |
print(f"✅ SPACE_ID found: {space_id_startup}")
|
188 |
print(f" Repo URL: https://huggingface.co/spaces/{space_id_startup}")
|
189 |
print(f" Repo Tree URL: https://huggingface.co/spaces/{space_id_startup}/tree/main")
|
@@ -192,5 +255,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)
|
|
|
3 |
import requests
|
4 |
import inspect
|
5 |
import pandas as pd
|
6 |
+
from dotenv import load_dotenv
|
7 |
+
|
8 |
+
# Load environment variables
|
9 |
+
load_dotenv()
|
10 |
+
|
11 |
+
# Import your LangGraph agent
|
12 |
+
from graph.graph_builder import graph
|
13 |
+
from langchain_core.messages import HumanMessage
|
14 |
|
15 |
# (Keep Constants as is)
|
16 |
# --- Constants ---
|
17 |
DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
|
18 |
|
19 |
+
# --- Your LangGraph Agent Definition ---
|
20 |
+
# ----- THIS IS WHERE YOU BUILD YOUR AGENT ------
|
21 |
class BasicAgent:
|
22 |
def __init__(self):
|
23 |
+
"""Initialize the LangGraph agent"""
|
24 |
+
print("LangGraph Agent initialized with multimodal, search, math, and YouTube tools.")
|
25 |
+
|
26 |
+
# Verify environment variables
|
27 |
+
if not os.getenv("OPENROUTER_API_KEY"):
|
28 |
+
raise ValueError("OPENROUTER_API_KEY not found in environment variables")
|
29 |
+
|
30 |
+
# The graph is already compiled and ready to use
|
31 |
+
self.graph = graph
|
32 |
+
print("✅ Agent ready with tools: multimodal, search, math, YouTube")
|
33 |
+
|
34 |
def __call__(self, question: str) -> str:
|
35 |
+
"""
|
36 |
+
Process a question using the LangGraph agent and return just the answer
|
37 |
+
|
38 |
+
Args:
|
39 |
+
question: The question to answer
|
40 |
+
|
41 |
+
Returns:
|
42 |
+
str: The final answer (formatted for evaluation)
|
43 |
+
"""
|
44 |
+
print(f"🤖 Processing question: {question[:50]}...")
|
45 |
+
|
46 |
+
try:
|
47 |
+
# Create initial state with the question
|
48 |
+
initial_state = {"messages": [HumanMessage(content=question)]}
|
49 |
+
|
50 |
+
# Run the LangGraph agent
|
51 |
+
result = self.graph.invoke(initial_state)
|
52 |
+
|
53 |
+
# Extract the final message content
|
54 |
+
final_message = result["messages"][-1]
|
55 |
+
answer = final_message.content
|
56 |
+
|
57 |
+
# Clean up the answer for evaluation (remove any extra formatting)
|
58 |
+
# The evaluation system expects just the answer, no explanations
|
59 |
+
if isinstance(answer, str):
|
60 |
+
answer = answer.strip()
|
61 |
+
|
62 |
+
# Remove common prefixes that might interfere with evaluation
|
63 |
+
prefixes_to_remove = [
|
64 |
+
"The answer is: ",
|
65 |
+
"Answer: ",
|
66 |
+
"The result is: ",
|
67 |
+
"Result: ",
|
68 |
+
"The final answer is: ",
|
69 |
+
]
|
70 |
+
|
71 |
+
for prefix in prefixes_to_remove:
|
72 |
+
if answer.startswith(prefix):
|
73 |
+
answer = answer[len(prefix):].strip()
|
74 |
+
break
|
75 |
+
|
76 |
+
print(f"✅ Agent answer: {answer}")
|
77 |
+
return answer
|
78 |
+
|
79 |
+
except Exception as e:
|
80 |
+
error_msg = f"Error processing question: {str(e)}"
|
81 |
+
print(f"❌ {error_msg}")
|
82 |
+
return error_msg
|
83 |
|
84 |
+
# Keep the rest of the file unchanged (run_and_submit_all function and Gradio interface)
|
85 |
+
def run_and_submit_all(profile: gr.OAuthProfile | None):
|
86 |
"""
|
87 |
Fetches all questions, runs the BasicAgent on them, submits all answers,
|
88 |
and displays the results.
|
|
|
101 |
questions_url = f"{api_url}/questions"
|
102 |
submit_url = f"{api_url}/submit"
|
103 |
|
104 |
+
# 1. Instantiate Agent (using your LangGraph agent)
|
105 |
try:
|
106 |
agent = BasicAgent()
|
107 |
except Exception as e:
|
108 |
print(f"Error instantiating agent: {e}")
|
109 |
return f"Error initializing agent: {e}", None
|
110 |
+
|
111 |
+
# In the case of an app running as a hugging Face space, this link points toward your codebase
|
112 |
agent_code = f"https://huggingface.co/spaces/{space_id}/tree/main"
|
113 |
print(agent_code)
|
114 |
|
|
|
125 |
except requests.exceptions.RequestException as e:
|
126 |
print(f"Error fetching questions: {e}")
|
127 |
return f"Error fetching questions: {e}", None
|
|
|
|
|
|
|
|
|
128 |
except Exception as e:
|
129 |
print(f"An unexpected error occurred fetching questions: {e}")
|
130 |
return f"An unexpected error occurred fetching questions: {e}", None
|
|
|
199 |
results_df = pd.DataFrame(results_log)
|
200 |
return status_message, results_df
|
201 |
|
|
|
202 |
# --- Build Gradio Interface using Blocks ---
|
203 |
with gr.Blocks() as demo:
|
204 |
+
gr.Markdown("# LangGraph Agent Evaluation Runner")
|
205 |
gr.Markdown(
|
206 |
"""
|
207 |
**Instructions:**
|
208 |
|
209 |
+
This space uses a LangGraph agent with multimodal, search, math, and YouTube tools powered by OpenRouter.
|
210 |
+
|
211 |
+
1. Log in to your Hugging Face account using the button below.
|
212 |
+
2. Click 'Run Evaluation & Submit All Answers' to fetch questions, run your agent, submit answers, and see the score.
|
213 |
|
214 |
+
**Agent Capabilities:**
|
215 |
+
- 🎨 **Multimodal**: Analyze images, extract text (OCR), process audio transcripts
|
216 |
+
- 🔍 **Search**: Web search using multiple providers (DuckDuckGo, Tavily, SerpAPI)
|
217 |
+
- 🧮 **Math**: Basic arithmetic, complex calculations, percentages, factorials
|
218 |
+
- 📺 **YouTube**: Extract captions, get video information
|
219 |
+
|
220 |
---
|
221 |
+
**Note:** Processing all questions may take some time as the agent carefully analyzes each question and uses appropriate tools.
|
|
|
|
|
222 |
"""
|
223 |
)
|
224 |
|
|
|
227 |
run_button = gr.Button("Run Evaluation & Submit All Answers")
|
228 |
|
229 |
status_output = gr.Textbox(label="Run Status / Submission Result", lines=5, interactive=False)
|
|
|
230 |
results_table = gr.DataFrame(label="Questions and Agent Answers", wrap=True)
|
231 |
|
232 |
run_button.click(
|
|
|
238 |
print("\n" + "-"*30 + " App Starting " + "-"*30)
|
239 |
# Check for SPACE_HOST and SPACE_ID at startup for information
|
240 |
space_host_startup = os.getenv("SPACE_HOST")
|
241 |
+
space_id_startup = os.getenv("SPACE_ID")
|
242 |
|
243 |
if space_host_startup:
|
244 |
print(f"✅ SPACE_HOST found: {space_host_startup}")
|
|
|
246 |
else:
|
247 |
print("ℹ️ SPACE_HOST environment variable not found (running locally?).")
|
248 |
|
249 |
+
if space_id_startup:
|
250 |
print(f"✅ SPACE_ID found: {space_id_startup}")
|
251 |
print(f" Repo URL: https://huggingface.co/spaces/{space_id_startup}")
|
252 |
print(f" Repo Tree URL: https://huggingface.co/spaces/{space_id_startup}/tree/main")
|
|
|
255 |
|
256 |
print("-"*(60 + len(" App Starting ")) + "\n")
|
257 |
|
258 |
+
print("Launching Gradio Interface for LangGraph Agent Evaluation...")
|
259 |
+
demo.launch(debug=True, share=False)
|
example_usage.py
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# example_usage.py
|
2 |
+
from graph.graph_builder import graph
|
3 |
+
from langchain_core.messages import HumanMessage
|
4 |
+
|
5 |
+
# Simple example
|
6 |
+
def ask_agent(question: str):
|
7 |
+
initial_state = {"messages": [HumanMessage(content=question)]}
|
8 |
+
result = graph.invoke(initial_state)
|
9 |
+
return result["messages"][-1].content
|
10 |
+
|
11 |
+
# Usage
|
12 |
+
if __name__ == "__main__":
|
13 |
+
response = ask_agent("How many albums did Michael Jackson release in his lifetime?")
|
14 |
+
print(f"Agent: {response}")
|
graph/__init__.py
ADDED
File without changes
|
graph/__pycache__/__init__.cpython-313.pyc
ADDED
Binary file (180 Bytes). View file
|
|
graph/__pycache__/graph_builder.cpython-313.pyc
ADDED
Binary file (892 Bytes). View file
|
|
graph/graph_builder.py
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# graph/graph_builder.py (unchanged)
|
2 |
+
from langgraph.graph import START, StateGraph
|
3 |
+
from langgraph.prebuilt import tools_condition
|
4 |
+
from langgraph.prebuilt import ToolNode
|
5 |
+
from nodes.core import assistant, tools
|
6 |
+
from states.state import AgentState
|
7 |
+
|
8 |
+
## The graph
|
9 |
+
builder = StateGraph(AgentState)
|
10 |
+
|
11 |
+
# Define nodes: these do the work
|
12 |
+
builder.add_node("assistant", assistant)
|
13 |
+
builder.add_node("tools", ToolNode(tools))
|
14 |
+
|
15 |
+
# Define edges: these determine how the control flow moves
|
16 |
+
builder.add_edge(START, "assistant")
|
17 |
+
builder.add_conditional_edges(
|
18 |
+
"assistant",
|
19 |
+
# If the latest message requires a tool, route to tools
|
20 |
+
# Otherwise, provide a direct response
|
21 |
+
tools_condition,
|
22 |
+
)
|
23 |
+
builder.add_edge("tools", "assistant")
|
24 |
+
graph = builder.compile()
|
local_test.py
ADDED
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# local_test.py
|
2 |
+
from app import BasicAgent
|
3 |
+
|
4 |
+
agent = BasicAgent()
|
5 |
+
|
6 |
+
# Test questions
|
7 |
+
test_questions = [
|
8 |
+
"What is 15 + 27?",
|
9 |
+
"Calculate the square root of 144",
|
10 |
+
"What is 25% of 200?"
|
11 |
+
]
|
12 |
+
|
13 |
+
for q in test_questions:
|
14 |
+
answer = agent(q)
|
15 |
+
print(f"Q: {q}")
|
16 |
+
print(f"A: {answer}")
|
17 |
+
print("-" * 30)
|
nodes/__init__.py
ADDED
File without changes
|
nodes/__pycache__/__init__.cpython-313.pyc
ADDED
Binary file (180 Bytes). View file
|
|
nodes/__pycache__/core.cpython-313.pyc
ADDED
Binary file (2.33 kB). View file
|
|
nodes/core.py
ADDED
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# nodes/core.py
|
2 |
+
from states.state import AgentState
|
3 |
+
import os
|
4 |
+
from dotenv import load_dotenv
|
5 |
+
from langchain_openai import ChatOpenAI # Using OpenAI-compatible API for OpenRouter
|
6 |
+
from tools.langchain_tools import (
|
7 |
+
extract_text,
|
8 |
+
analyze_image_tool,
|
9 |
+
analyze_audio_tool,
|
10 |
+
add,
|
11 |
+
subtract,
|
12 |
+
multiply,
|
13 |
+
divide,
|
14 |
+
search_tool,
|
15 |
+
extract_youtube_transcript,
|
16 |
+
get_youtube_info,
|
17 |
+
calculate_expression,
|
18 |
+
factorial,
|
19 |
+
square_root,
|
20 |
+
percentage,
|
21 |
+
average
|
22 |
+
)
|
23 |
+
|
24 |
+
load_dotenv()
|
25 |
+
|
26 |
+
# Read your API key from the environment variable
|
27 |
+
openrouter_api_key = os.getenv("OPENROUTER_API_KEY")
|
28 |
+
|
29 |
+
if not openrouter_api_key:
|
30 |
+
raise ValueError("OPENROUTER_API_KEY not found in environment variables")
|
31 |
+
|
32 |
+
# Initialize OpenRouter ChatOpenAI with OpenRouter-specific configuration
|
33 |
+
chat = ChatOpenAI(
|
34 |
+
model="google/gemini-2.5-flash-preview-05-20", # Free multimodal model
|
35 |
+
# Alternative models you can use:
|
36 |
+
# model="mistralai/mistral-7b-instruct:free", # Fast, free text model
|
37 |
+
# model="google/gemma-2-9b-it:free", # Google's free model
|
38 |
+
# model="qwen/qwen-2.5-72b-instruct:free", # High-quality free model
|
39 |
+
|
40 |
+
temperature=0,
|
41 |
+
max_retries=2,
|
42 |
+
base_url="https://openrouter.ai/api/v1",
|
43 |
+
api_key=openrouter_api_key,
|
44 |
+
default_headers={
|
45 |
+
"HTTP-Referer": "https://your-app.com", # Optional: for analytics
|
46 |
+
"X-Title": "LangGraph Agent", # Optional: for analytics
|
47 |
+
}
|
48 |
+
)
|
49 |
+
|
50 |
+
# Core tools list (matching original structure)
|
51 |
+
tools = [
|
52 |
+
extract_text,
|
53 |
+
analyze_image_tool,
|
54 |
+
analyze_audio_tool,
|
55 |
+
extract_youtube_transcript,
|
56 |
+
add,
|
57 |
+
subtract,
|
58 |
+
multiply,
|
59 |
+
divide,
|
60 |
+
search_tool
|
61 |
+
]
|
62 |
+
|
63 |
+
# Extended tools list (if you want more capabilities)
|
64 |
+
extended_tools = tools + [
|
65 |
+
get_youtube_info,
|
66 |
+
calculate_expression,
|
67 |
+
factorial,
|
68 |
+
square_root,
|
69 |
+
percentage,
|
70 |
+
average
|
71 |
+
]
|
72 |
+
|
73 |
+
# Use core tools by default (matching original), but you can switch to extended_tools
|
74 |
+
chat_with_tools = chat.bind_tools(tools)
|
75 |
+
|
76 |
+
def assistant(state: AgentState):
|
77 |
+
"""
|
78 |
+
Assistant node - maintains the exact same system prompt for evaluation compatibility
|
79 |
+
"""
|
80 |
+
sys_msg = (
|
81 |
+
"You are a helpful assistant with access to tools. Understand user requests accurately. "
|
82 |
+
"Use your tools when needed to answer effectively. Strictly follow all user instructions and constraints. "
|
83 |
+
"Pay attention: your output needs to contain only the final answer without any reasoning since it will be "
|
84 |
+
"strictly evaluated against a dataset which contains only the specific response. "
|
85 |
+
"Your final output needs to be just the string or integer containing the answer, not an array or technical stuff."
|
86 |
+
)
|
87 |
+
|
88 |
+
return {
|
89 |
+
"messages": [chat_with_tools.invoke([sys_msg] + state["messages"])]
|
90 |
+
}
|
requirements.txt
CHANGED
@@ -1,2 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
gradio
|
2 |
-
|
|
|
|
|
|
1 |
+
python-dotenv
|
2 |
+
requests
|
3 |
+
pytubefix
|
4 |
+
pillow
|
5 |
+
langgraph
|
6 |
+
langchain
|
7 |
+
langchain-openai
|
8 |
+
langchain-core
|
9 |
+
langchain-community
|
10 |
gradio
|
11 |
+
pandas
|
12 |
+
gradio[oauth]
|
13 |
+
|
states/__init__.py
ADDED
File without changes
|
states/__pycache__/__init__.cpython-313.pyc
ADDED
Binary file (181 Bytes). View file
|
|
states/__pycache__/state.cpython-313.pyc
ADDED
Binary file (692 Bytes). View file
|
|
states/state.py
ADDED
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# states/state.py (unchanged)
|
2 |
+
from typing import TypedDict, Annotated
|
3 |
+
from langchain_core.messages import AnyMessage
|
4 |
+
from langgraph.graph.message import add_messages
|
5 |
+
|
6 |
+
class AgentState(TypedDict):
|
7 |
+
messages: Annotated[list[AnyMessage], add_messages]
|
test_agent.py
ADDED
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# test_agent.py
|
2 |
+
import os
|
3 |
+
from dotenv import load_dotenv
|
4 |
+
from graph.graph_builder import graph
|
5 |
+
from langchain_core.messages import HumanMessage
|
6 |
+
|
7 |
+
load_dotenv()
|
8 |
+
|
9 |
+
def test_agent():
|
10 |
+
"""Test the LangGraph agent with various queries"""
|
11 |
+
|
12 |
+
# Check if OpenRouter API key is available
|
13 |
+
if not os.getenv("OPENROUTER_API_KEY"):
|
14 |
+
print("❌ OPENROUTER_API_KEY not found in environment")
|
15 |
+
return
|
16 |
+
|
17 |
+
print("🤖 Testing LangGraph Agent with OpenRouter")
|
18 |
+
print("=" * 50)
|
19 |
+
|
20 |
+
# Test cases
|
21 |
+
test_queries = [
|
22 |
+
"What is 15 + 27?",
|
23 |
+
"Calculate the square root of 144",
|
24 |
+
"Search for information about artificial intelligence",
|
25 |
+
"What is 25% of 200?"
|
26 |
+
]
|
27 |
+
|
28 |
+
for i, query in enumerate(test_queries, 1):
|
29 |
+
print(f"\n🧪 Test {i}: {query}")
|
30 |
+
print("-" * 30)
|
31 |
+
|
32 |
+
try:
|
33 |
+
# Create initial state
|
34 |
+
initial_state = {"messages": [HumanMessage(content=query)]}
|
35 |
+
|
36 |
+
# Run the agent
|
37 |
+
result = graph.invoke(initial_state)
|
38 |
+
|
39 |
+
# Get the final message
|
40 |
+
final_message = result["messages"][-1]
|
41 |
+
print(f"🤖 Response: {final_message.content}")
|
42 |
+
|
43 |
+
except Exception as e:
|
44 |
+
print(f"❌ Error: {str(e)}")
|
45 |
+
|
46 |
+
print("\n" + "=" * 50)
|
47 |
+
print("✅ Agent testing completed!")
|
48 |
+
|
49 |
+
if __name__ == "__main__":
|
50 |
+
test_agent()
|
test_all_tools.py
ADDED
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# test_all_tools.py
|
2 |
+
import os
|
3 |
+
import sys
|
4 |
+
from dotenv import load_dotenv
|
5 |
+
from pathlib import Path
|
6 |
+
|
7 |
+
# Load environment variables
|
8 |
+
load_dotenv()
|
9 |
+
|
10 |
+
# Add tools directory to path
|
11 |
+
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
|
12 |
+
|
13 |
+
def main():
|
14 |
+
"""Run all tool tests"""
|
15 |
+
print("=" * 60)
|
16 |
+
print("🧪 TESTING ALL TOOLS")
|
17 |
+
print("=" * 60)
|
18 |
+
|
19 |
+
# Check if OpenRouter API key is loaded
|
20 |
+
openrouter_key = os.getenv("OPENROUTER_API_KEY")
|
21 |
+
if openrouter_key:
|
22 |
+
print(f"✅ OpenRouter API Key loaded: {openrouter_key[:8]}...")
|
23 |
+
else:
|
24 |
+
print("❌ OpenRouter API Key not found in environment")
|
25 |
+
print("Please add OPENROUTER_API_KEY to your .env file")
|
26 |
+
|
27 |
+
# Import and run individual test modules
|
28 |
+
try:
|
29 |
+
print("\n🔧 Testing Math Tools...")
|
30 |
+
from test_math_tools import test_math_tools
|
31 |
+
test_math_tools()
|
32 |
+
|
33 |
+
print("\n🔍 Testing Search Tools...")
|
34 |
+
from test_search_tools import test_search_tools
|
35 |
+
test_search_tools()
|
36 |
+
|
37 |
+
print("\n🎨 Testing Multimodal Tools...")
|
38 |
+
from test_multimodal_tools import test_multimodal_tools
|
39 |
+
test_multimodal_tools()
|
40 |
+
|
41 |
+
print("\n📺 Testing YouTube Tools...")
|
42 |
+
from test_youtube_tools import test_youtube_tools
|
43 |
+
test_youtube_tools()
|
44 |
+
|
45 |
+
print("\n" + "=" * 60)
|
46 |
+
print("✅ ALL TESTS COMPLETED SUCCESSFULLY!")
|
47 |
+
print("=" * 60)
|
48 |
+
|
49 |
+
# Usage examples
|
50 |
+
print("\n📚 Quick Usage Examples:")
|
51 |
+
print("from tools import get_video_info, search_web, add, analyze_image")
|
52 |
+
print("video_info = get_video_info('https://youtube.com/watch?v=...')")
|
53 |
+
print("search_results = search_web('Python programming')")
|
54 |
+
print("result = add(10, 5)")
|
55 |
+
print("image_desc = analyze_image('photo.jpg', 'What is in this image?')")
|
56 |
+
|
57 |
+
except Exception as e:
|
58 |
+
print(f"\n❌ Error running tests: {str(e)}")
|
59 |
+
import traceback
|
60 |
+
traceback.print_exc()
|
61 |
+
|
62 |
+
if __name__ == "__main__":
|
63 |
+
main()
|
test_math_tools.py
ADDED
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# test_math_tools.py
|
2 |
+
import sys
|
3 |
+
import os
|
4 |
+
from dotenv import load_dotenv
|
5 |
+
|
6 |
+
# Load environment variables
|
7 |
+
load_dotenv()
|
8 |
+
|
9 |
+
# Add tools to path
|
10 |
+
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
|
11 |
+
|
12 |
+
def test_math_tools():
|
13 |
+
"""Test all math tool functions"""
|
14 |
+
try:
|
15 |
+
from tools.math_tools import MathTools, add, subtract, multiply, divide, factorial, square_root, average, calculate_expression
|
16 |
+
|
17 |
+
print(" 📊 Testing basic arithmetic...")
|
18 |
+
|
19 |
+
# Test basic operations
|
20 |
+
assert add(5, 3) == 8, "Addition failed"
|
21 |
+
assert subtract(10, 4) == 6, "Subtraction failed"
|
22 |
+
assert multiply(6, 7) == 42, "Multiplication failed"
|
23 |
+
assert divide(15, 3) == 5, "Division failed"
|
24 |
+
print(" ✅ Basic arithmetic: PASSED")
|
25 |
+
|
26 |
+
# Test advanced operations
|
27 |
+
print(" 🧮 Testing advanced operations...")
|
28 |
+
assert factorial(5) == 120, "Factorial failed"
|
29 |
+
assert square_root(16) == 4.0, "Square root failed"
|
30 |
+
assert average([1, 2, 3, 4, 5]) == 3.0, "Average failed"
|
31 |
+
print(" ✅ Advanced operations: PASSED")
|
32 |
+
|
33 |
+
# Test error handling
|
34 |
+
print(" 🚨 Testing error handling...")
|
35 |
+
assert "Error" in str(divide(5, 0)), "Division by zero should return error"
|
36 |
+
assert "Error" in str(factorial(-1)), "Negative factorial should return error"
|
37 |
+
assert "Error" in str(square_root(-1)), "Negative square root should return error"
|
38 |
+
print(" ✅ Error handling: PASSED")
|
39 |
+
|
40 |
+
# Test expression calculator
|
41 |
+
print(" 🧠 Testing expression calculator...")
|
42 |
+
assert calculate_expression("2 + 3 * 4") == 14, "Expression calculation failed"
|
43 |
+
assert calculate_expression("2^3") == 8, "Power expression failed"
|
44 |
+
print(" ✅ Expression calculator: PASSED")
|
45 |
+
|
46 |
+
# Test quadratic solver
|
47 |
+
print(" 📐 Testing quadratic solver...")
|
48 |
+
solutions = MathTools.solve_quadratic(1, -5, 6)
|
49 |
+
assert isinstance(solutions, tuple) and len(solutions) == 2, "Quadratic solver failed"
|
50 |
+
print(" ✅ Quadratic solver: PASSED")
|
51 |
+
|
52 |
+
# Test practical examples
|
53 |
+
print(" 💰 Testing practical calculations...")
|
54 |
+
compound_interest = MathTools.calculate_compound_interest(1000, 0.05, 2)
|
55 |
+
assert compound_interest > 1000, "Compound interest should be greater than principal"
|
56 |
+
|
57 |
+
percentage_result = MathTools.percentage(25, 100)
|
58 |
+
assert percentage_result == 25.0, "Percentage calculation failed"
|
59 |
+
print(" ✅ Practical calculations: PASSED")
|
60 |
+
|
61 |
+
print(" ✅ ALL MATH TESTS PASSED!")
|
62 |
+
|
63 |
+
# Demo output
|
64 |
+
print("\n 📋 Math Tools Demo:")
|
65 |
+
print(f" 5 + 3 = {add(5, 3)}")
|
66 |
+
print(f" 10! = {factorial(10)}")
|
67 |
+
print(f" √144 = {square_root(144)}")
|
68 |
+
print(f" Average of [10,20,30] = {average([10,20,30])}")
|
69 |
+
print(f" Expression '2^3 + 4*5' = {calculate_expression('2^3 + 4*5')}")
|
70 |
+
|
71 |
+
except ImportError as e:
|
72 |
+
print(f" ❌ Import error: {e}")
|
73 |
+
except Exception as e:
|
74 |
+
print(f" ❌ Test failed: {e}")
|
75 |
+
raise
|
76 |
+
|
77 |
+
if __name__ == "__main__":
|
78 |
+
test_math_tools()
|
test_multimodal_tools.py
ADDED
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# test_multimodal_tools.py
|
2 |
+
import sys
|
3 |
+
import os
|
4 |
+
from dotenv import load_dotenv
|
5 |
+
import tempfile
|
6 |
+
import base64
|
7 |
+
|
8 |
+
# Load environment variables
|
9 |
+
load_dotenv()
|
10 |
+
|
11 |
+
# Add tools to path
|
12 |
+
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
|
13 |
+
|
14 |
+
def create_test_image():
|
15 |
+
"""Create a simple test image for testing"""
|
16 |
+
try:
|
17 |
+
from PIL import Image, ImageDraw
|
18 |
+
|
19 |
+
# Create a simple test image
|
20 |
+
img = Image.new('RGB', (200, 100), color='white')
|
21 |
+
draw = ImageDraw.Draw(img)
|
22 |
+
draw.text((10, 40), "Test Image", fill='black')
|
23 |
+
|
24 |
+
# Save to temporary file
|
25 |
+
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.png')
|
26 |
+
img.save(temp_file.name)
|
27 |
+
return temp_file.name
|
28 |
+
except ImportError:
|
29 |
+
print(" ⚠️ PIL not available, creating mock image file")
|
30 |
+
# Create a mock image file for testing
|
31 |
+
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.txt')
|
32 |
+
temp_file.write(b"This is a mock image file for testing")
|
33 |
+
temp_file.close()
|
34 |
+
return temp_file.name
|
35 |
+
|
36 |
+
def test_multimodal_tools():
|
37 |
+
"""Test multimodal tool functions"""
|
38 |
+
try:
|
39 |
+
from tools.multimodal_tools import MultimodalTools, analyze_image, extract_text, analyze_transcript
|
40 |
+
|
41 |
+
# Check OpenRouter API key
|
42 |
+
openrouter_key = os.getenv("OPENROUTER_API_KEY")
|
43 |
+
if not openrouter_key:
|
44 |
+
print(" ❌ OpenRouter API key not found!")
|
45 |
+
print(" Please add OPENROUTER_API_KEY to your .env file")
|
46 |
+
return
|
47 |
+
|
48 |
+
print(" 🎨 Initializing multimodal tools...")
|
49 |
+
multimodal_tools = MultimodalTools()
|
50 |
+
print(f" ✅ API key loaded: {openrouter_key[:8]}...")
|
51 |
+
|
52 |
+
# Test with transcript (doesn't require image)
|
53 |
+
print(" 🎙️ Testing audio transcript analysis...")
|
54 |
+
test_transcript = "Hello, this is a test transcript. We're discussing artificial intelligence and machine learning technologies."
|
55 |
+
|
56 |
+
transcript_result = multimodal_tools.analyze_audio_transcript(
|
57 |
+
test_transcript,
|
58 |
+
"What is the main topic of this transcript?"
|
59 |
+
)
|
60 |
+
|
61 |
+
if transcript_result and "Error" not in transcript_result:
|
62 |
+
print(" ✅ Transcript analysis: PASSED")
|
63 |
+
print(f" Result: {transcript_result[:100]}...")
|
64 |
+
else:
|
65 |
+
print(f" ❌ Transcript analysis failed: {transcript_result}")
|
66 |
+
|
67 |
+
# Test convenience function
|
68 |
+
print(" 🔧 Testing convenience functions...")
|
69 |
+
convenience_result = analyze_transcript(
|
70 |
+
"This is a test about Python programming and software development.",
|
71 |
+
"Summarize the main topics"
|
72 |
+
)
|
73 |
+
|
74 |
+
if convenience_result and "Error" not in convenience_result:
|
75 |
+
print(" ✅ Convenience function: PASSED")
|
76 |
+
else:
|
77 |
+
print(f" ⚠️ Convenience function result: {convenience_result}")
|
78 |
+
|
79 |
+
# Test image analysis (if we can create a test image)
|
80 |
+
print(" 🖼️ Testing image analysis...")
|
81 |
+
try:
|
82 |
+
test_image_path = create_test_image()
|
83 |
+
|
84 |
+
# Test image description
|
85 |
+
image_result = multimodal_tools.analyze_image(
|
86 |
+
test_image_path,
|
87 |
+
"Describe what you see in this image"
|
88 |
+
)
|
89 |
+
|
90 |
+
if image_result and "Error" not in image_result:
|
91 |
+
print(" ✅ Image analysis: PASSED")
|
92 |
+
print(f" Result: {image_result[:100]}...")
|
93 |
+
else:
|
94 |
+
print(f" ⚠️ Image analysis result: {image_result}")
|
95 |
+
|
96 |
+
# Test OCR functionality
|
97 |
+
ocr_result = multimodal_tools.extract_text_from_image(test_image_path)
|
98 |
+
if ocr_result and "Error" not in ocr_result:
|
99 |
+
print(" ✅ OCR extraction: PASSED")
|
100 |
+
print(f" Extracted: {ocr_result[:50]}...")
|
101 |
+
else:
|
102 |
+
print(f" ⚠️ OCR result: {ocr_result}")
|
103 |
+
|
104 |
+
# Clean up test image
|
105 |
+
os.unlink(test_image_path)
|
106 |
+
|
107 |
+
except Exception as e:
|
108 |
+
print(f" ⚠️ Image testing skipped: {str(e)}")
|
109 |
+
|
110 |
+
# Test error handling
|
111 |
+
print(" 🚨 Testing error handling...")
|
112 |
+
error_result = multimodal_tools.analyze_image(
|
113 |
+
"nonexistent_file.jpg",
|
114 |
+
"This should fail"
|
115 |
+
)
|
116 |
+
|
117 |
+
if "Error" in error_result:
|
118 |
+
print(" ✅ Error handling: PASSED")
|
119 |
+
else:
|
120 |
+
print(" ⚠️ Error handling unexpected result")
|
121 |
+
|
122 |
+
# Test empty transcript
|
123 |
+
empty_result = multimodal_tools.analyze_audio_transcript("", "Question")
|
124 |
+
if "Error" in empty_result:
|
125 |
+
print(" ✅ Empty input handling: PASSED")
|
126 |
+
|
127 |
+
print(" ✅ MULTIMODAL TESTS COMPLETED!")
|
128 |
+
|
129 |
+
# Demo output
|
130 |
+
print("\n ���� Multimodal Tools Demo:")
|
131 |
+
demo_transcript = "We discussed the implementation of AI agents using CrewAI framework for automated workflows."
|
132 |
+
demo_result = analyze_transcript(demo_transcript, "What framework was mentioned?")
|
133 |
+
print(f" Question: What framework was mentioned?")
|
134 |
+
print(f" Answer: {demo_result[:150]}...")
|
135 |
+
|
136 |
+
except ImportError as e:
|
137 |
+
print(f" ❌ Import error: {e}")
|
138 |
+
print(" Make sure all dependencies are installed")
|
139 |
+
except Exception as e:
|
140 |
+
print(f" ❌ Test failed: {e}")
|
141 |
+
import traceback
|
142 |
+
traceback.print_exc()
|
143 |
+
|
144 |
+
if __name__ == "__main__":
|
145 |
+
test_multimodal_tools()
|
test_search_tools.py
ADDED
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# test_search_tools.py
|
2 |
+
import sys
|
3 |
+
import os
|
4 |
+
from dotenv import load_dotenv
|
5 |
+
|
6 |
+
# Load environment variables
|
7 |
+
load_dotenv()
|
8 |
+
|
9 |
+
# Add tools to path
|
10 |
+
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
|
11 |
+
|
12 |
+
def test_search_tools():
|
13 |
+
"""Test search tool functions"""
|
14 |
+
try:
|
15 |
+
from tools.search_tools import SearchTools, search_web, search_news
|
16 |
+
|
17 |
+
print(" 🔍 Initializing search tools...")
|
18 |
+
search_tools = SearchTools()
|
19 |
+
|
20 |
+
# Test DuckDuckGo search (free)
|
21 |
+
print(" 🦆 Testing DuckDuckGo search...")
|
22 |
+
ddg_results = search_tools.search_duckduckgo("Python programming", max_results=3)
|
23 |
+
if ddg_results:
|
24 |
+
print(f" ✅ DuckDuckGo returned {len(ddg_results)} results")
|
25 |
+
for i, result in enumerate(ddg_results[:2]):
|
26 |
+
print(f" {i+1}. {result.get('title', 'No title')[:50]}...")
|
27 |
+
else:
|
28 |
+
print(" ⚠️ DuckDuckGo returned no results")
|
29 |
+
|
30 |
+
# Test Tavily search (if API key available)
|
31 |
+
tavily_key = os.getenv("TAVILY_API_KEY")
|
32 |
+
if tavily_key:
|
33 |
+
print(" 🔎 Testing Tavily search...")
|
34 |
+
tavily_results = search_tools.search_tavily("AI news 2025", max_results=3)
|
35 |
+
if tavily_results:
|
36 |
+
print(f" ✅ Tavily returned {len(tavily_results)} results")
|
37 |
+
else:
|
38 |
+
print(" ⚠️ Tavily returned no results")
|
39 |
+
else:
|
40 |
+
print(" ⚠️ Tavily API key not found, skipping Tavily tests")
|
41 |
+
|
42 |
+
# Test SerpAPI search (if API key available)
|
43 |
+
serpapi_key = os.getenv("SERPAPI_KEY")
|
44 |
+
if serpapi_key:
|
45 |
+
print(" 🐍 Testing SerpAPI search...")
|
46 |
+
serp_results = search_tools.search_serpapi("machine learning", max_results=2)
|
47 |
+
if serp_results:
|
48 |
+
print(f" ✅ SerpAPI returned {len(serp_results)} results")
|
49 |
+
else:
|
50 |
+
print(" ⚠️ SerpAPI returned no results")
|
51 |
+
else:
|
52 |
+
print(" ⚠️ SerpAPI key not found, skipping SerpAPI tests")
|
53 |
+
|
54 |
+
# Test main search function (with fallback)
|
55 |
+
print(" 🎯 Testing main search function...")
|
56 |
+
main_results = search_tools.search("CrewAI framework", max_results=3)
|
57 |
+
if main_results:
|
58 |
+
print(f" ✅ Main search returned {len(main_results)} results")
|
59 |
+
print(f" Provider used: {main_results[0].get('source', 'Unknown')}")
|
60 |
+
else:
|
61 |
+
print(" ❌ Main search failed")
|
62 |
+
|
63 |
+
# Test convenience functions
|
64 |
+
print(" 🚀 Testing convenience functions...")
|
65 |
+
web_results = search_web("OpenRouter API", max_results=2)
|
66 |
+
news_results = search_news("artificial intelligence", max_results=2)
|
67 |
+
|
68 |
+
if web_results:
|
69 |
+
print(f" ✅ Web search returned {len(web_results)} results")
|
70 |
+
if news_results:
|
71 |
+
print(f" ✅ News search returned {len(news_results)} results")
|
72 |
+
|
73 |
+
# Test specialized searches
|
74 |
+
print(" 🎓 Testing specialized searches...")
|
75 |
+
academic_results = search_tools.search_academic("machine learning algorithms", max_results=2)
|
76 |
+
if academic_results:
|
77 |
+
print(f" ✅ Academic search returned {len(academic_results)} results")
|
78 |
+
|
79 |
+
print(" ✅ ALL SEARCH TESTS COMPLETED!")
|
80 |
+
|
81 |
+
# Demo output
|
82 |
+
if main_results:
|
83 |
+
print("\n 📋 Search Results Demo:")
|
84 |
+
for i, result in enumerate(main_results[:2]):
|
85 |
+
title = result.get('title', 'No title')[:60]
|
86 |
+
source = result.get('source', 'Unknown')
|
87 |
+
print(f" {i+1}. [{source}] {title}...")
|
88 |
+
|
89 |
+
except ImportError as e:
|
90 |
+
print(f" ❌ Import error: {e}")
|
91 |
+
except Exception as e:
|
92 |
+
print(f" ❌ Test failed: {e}")
|
93 |
+
print(f" Error details: {str(e)}")
|
94 |
+
|
95 |
+
if __name__ == "__main__":
|
96 |
+
test_search_tools()
|
test_youtube_tools.py
ADDED
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# test_youtube_tools.py (Updated with working test URLs)
|
2 |
+
import sys
|
3 |
+
import os
|
4 |
+
import tempfile
|
5 |
+
import shutil
|
6 |
+
from dotenv import load_dotenv
|
7 |
+
|
8 |
+
load_dotenv()
|
9 |
+
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
|
10 |
+
|
11 |
+
def test_youtube_tools():
|
12 |
+
"""Test YouTube tool functions with reliable test URLs"""
|
13 |
+
|
14 |
+
# Updated test URLs - using more reliable, public content
|
15 |
+
test_video_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" # Rick Roll
|
16 |
+
# Use a known public playlist
|
17 |
+
test_playlist_url = "https://www.youtube.com/playlist?list=PLNVhQQnz1F5f11NXuOKF0oULKWWM2HfZS" # Small public playlist
|
18 |
+
|
19 |
+
try:
|
20 |
+
from tools.youtube_tools import YouTubeTools, get_video_info, download_video, get_captions
|
21 |
+
|
22 |
+
print(" 📺 Initializing YouTube tools...")
|
23 |
+
youtube_tools = YouTubeTools(max_retries=2, retry_delay=1.0) # Configure retry settings
|
24 |
+
|
25 |
+
# Test video info retrieval
|
26 |
+
print(" 📋 Testing video info retrieval...")
|
27 |
+
video_info = youtube_tools.get_video_info(test_video_url)
|
28 |
+
|
29 |
+
if video_info:
|
30 |
+
print(" ✅ Video info retrieval: PASSED")
|
31 |
+
print(f" Title: {video_info.get('title', 'Unknown')[:50]}...")
|
32 |
+
print(f" Author: {video_info.get('author', 'Unknown')}")
|
33 |
+
print(f" Duration: {video_info.get('length', 0)} seconds")
|
34 |
+
print(f" Views: {video_info.get('views', 0):,}")
|
35 |
+
print(f" Available captions: {video_info.get('captions_available', [])}")
|
36 |
+
else:
|
37 |
+
print(" ❌ Video info retrieval failed")
|
38 |
+
|
39 |
+
# Test available qualities with retry
|
40 |
+
print(" 🎥 Testing available qualities...")
|
41 |
+
qualities = youtube_tools.get_available_qualities(test_video_url)
|
42 |
+
|
43 |
+
if qualities:
|
44 |
+
print(" ✅ Quality detection: PASSED")
|
45 |
+
resolutions = [q['resolution'] for q in qualities if q['resolution'] != 'unknown'][:3]
|
46 |
+
print(f" Available resolutions: {resolutions}")
|
47 |
+
else:
|
48 |
+
print(" ⚠️ Quality detection failed (network issue)")
|
49 |
+
|
50 |
+
# Test captions (should work without deprecation warning)
|
51 |
+
print(" 📝 Testing caption retrieval...")
|
52 |
+
captions = youtube_tools.get_captions(test_video_url, 'en')
|
53 |
+
|
54 |
+
if captions:
|
55 |
+
print(" ✅ Caption retrieval: PASSED")
|
56 |
+
print(f" Caption preview: {captions[:100]}...")
|
57 |
+
elif captions is None:
|
58 |
+
print(" ⚠️ No captions available for this video")
|
59 |
+
else:
|
60 |
+
print(" ❌ Caption retrieval failed")
|
61 |
+
|
62 |
+
# Test playlist info with better URL
|
63 |
+
print(" 📂 Testing playlist info...")
|
64 |
+
try:
|
65 |
+
playlist_info = youtube_tools.get_playlist_info(test_playlist_url)
|
66 |
+
|
67 |
+
if playlist_info:
|
68 |
+
print(" ✅ Playlist info: PASSED")
|
69 |
+
print(f" Playlist: {playlist_info.get('title', 'Unknown')[:50]}...")
|
70 |
+
print(f" Videos: {playlist_info.get('video_count', 0)}")
|
71 |
+
else:
|
72 |
+
print(" ⚠️ Playlist info failed")
|
73 |
+
except Exception as e:
|
74 |
+
print(f" ⚠️ Playlist test error: {str(e)}")
|
75 |
+
|
76 |
+
# Test convenience functions
|
77 |
+
print(" 🔧 Testing convenience functions...")
|
78 |
+
convenience_info = get_video_info(test_video_url)
|
79 |
+
if convenience_info:
|
80 |
+
print(" ✅ Convenience functions: PASSED")
|
81 |
+
|
82 |
+
# Test error handling
|
83 |
+
print(" 🚨 Testing error handling...")
|
84 |
+
invalid_info = youtube_tools.get_video_info("https://invalid-url")
|
85 |
+
if invalid_info is None:
|
86 |
+
print(" ✅ Error handling: PASSED")
|
87 |
+
|
88 |
+
print(" ✅ YOUTUBE TOOLS TESTS COMPLETED!")
|
89 |
+
|
90 |
+
# Demo output
|
91 |
+
if video_info:
|
92 |
+
print("\n 📋 YouTube Tools Demo:")
|
93 |
+
print(f" Video: {video_info.get('title', 'Unknown')}")
|
94 |
+
print(f" Channel: {video_info.get('author', 'Unknown')}")
|
95 |
+
print(f" Published: {video_info.get('publish_date', 'Unknown')}")
|
96 |
+
print(f" Length: {video_info.get('length', 0)} seconds")
|
97 |
+
|
98 |
+
if qualities:
|
99 |
+
best_quality = qualities[0] if qualities else {}
|
100 |
+
print(f" Best quality: {best_quality.get('resolution', 'unknown')}")
|
101 |
+
|
102 |
+
except ImportError as e:
|
103 |
+
print(f" ❌ Import error: {e}")
|
104 |
+
print(" Make sure pytubefix is installed: pip install pytubefix")
|
105 |
+
except Exception as e:
|
106 |
+
print(f" ❌ Test failed: {e}")
|
107 |
+
import traceback
|
108 |
+
traceback.print_exc()
|
109 |
+
|
110 |
+
if __name__ == "__main__":
|
111 |
+
test_youtube_tools()
|
tools/__init__.py
ADDED
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# tools/__init__.py
|
2 |
+
"""
|
3 |
+
Tools package for AI agents
|
4 |
+
Provides multimodal, search, math, and YouTube capabilities
|
5 |
+
"""
|
6 |
+
|
7 |
+
from .multimodal_tools import MultimodalTools, analyze_image, extract_text, analyze_transcript
|
8 |
+
from .search_tools import SearchTools, search_web, search_news
|
9 |
+
from .math_tools import MathTools, add, subtract, multiply, divide, power, factorial, square_root, percentage, average, calculate_expression
|
10 |
+
from .youtube_tools import YouTubeTools, get_video_info, download_video, download_audio, get_captions, get_playlist_info
|
11 |
+
|
12 |
+
__all__ = [
|
13 |
+
# Multimodal tools
|
14 |
+
'MultimodalTools',
|
15 |
+
'analyze_image',
|
16 |
+
'extract_text',
|
17 |
+
'analyze_transcript',
|
18 |
+
|
19 |
+
# Search tools
|
20 |
+
'SearchTools',
|
21 |
+
'search_web',
|
22 |
+
'search_news',
|
23 |
+
|
24 |
+
# Math tools
|
25 |
+
'MathTools',
|
26 |
+
'add',
|
27 |
+
'subtract',
|
28 |
+
'multiply',
|
29 |
+
'divide',
|
30 |
+
'power',
|
31 |
+
'factorial',
|
32 |
+
'square_root',
|
33 |
+
'percentage',
|
34 |
+
'average',
|
35 |
+
'calculate_expression',
|
36 |
+
|
37 |
+
# YouTube tools
|
38 |
+
'YouTubeTools',
|
39 |
+
'get_video_info',
|
40 |
+
'download_video',
|
41 |
+
'download_audio',
|
42 |
+
'get_captions',
|
43 |
+
'get_playlist_info'
|
44 |
+
]
|
45 |
+
|
46 |
+
__version__ = "1.0.0"
|
tools/__pycache__/__init__.cpython-313.pyc
ADDED
Binary file (1.15 kB). View file
|
|
tools/__pycache__/langchain_tools.cpython-313.pyc
ADDED
Binary file (6.91 kB). View file
|
|
tools/__pycache__/math_tools.cpython-313.pyc
ADDED
Binary file (10.5 kB). View file
|
|
tools/__pycache__/multimodal_tools.cpython-313.pyc
ADDED
Binary file (7.46 kB). View file
|
|
tools/__pycache__/search_tools.cpython-313.pyc
ADDED
Binary file (8.96 kB). View file
|
|
tools/__pycache__/utils.cpython-313.pyc
ADDED
Binary file (2.44 kB). View file
|
|
tools/__pycache__/youtube_tools.cpython-313.pyc
ADDED
Binary file (17.1 kB). View file
|
|
tools/langchain_tools.py
ADDED
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# tools/langchain_tools.py (Updated)
|
2 |
+
"""
|
3 |
+
LangChain-compatible tool wrappers for our existing tools
|
4 |
+
"""
|
5 |
+
|
6 |
+
from langchain_core.tools import tool
|
7 |
+
from typing import Optional
|
8 |
+
import os
|
9 |
+
from dotenv import load_dotenv
|
10 |
+
|
11 |
+
# Load environment variables FIRST, before any tool imports
|
12 |
+
load_dotenv()
|
13 |
+
|
14 |
+
from .multimodal_tools import MultimodalTools, analyze_transcript as _analyze_transcript
|
15 |
+
from .search_tools import SearchTools
|
16 |
+
from .math_tools import MathTools
|
17 |
+
from .youtube_tools import YouTubeTools
|
18 |
+
|
19 |
+
# Initialize tool instances (now env vars are available)
|
20 |
+
multimodal_tools = MultimodalTools()
|
21 |
+
search_tools = SearchTools()
|
22 |
+
youtube_tools = YouTubeTools()
|
23 |
+
|
24 |
+
# Rest of the file remains the same...
|
25 |
+
@tool
|
26 |
+
def extract_text(image_path: str) -> str:
|
27 |
+
"""Extract text from an image using OCR"""
|
28 |
+
return multimodal_tools.extract_text_from_image(image_path)
|
29 |
+
|
30 |
+
@tool
|
31 |
+
def analyze_image_tool(image_path: str, question: str = "Describe this image in detail") -> str:
|
32 |
+
"""Analyze an image and answer questions about it"""
|
33 |
+
return multimodal_tools.analyze_image(image_path, question)
|
34 |
+
|
35 |
+
@tool
|
36 |
+
def analyze_audio_tool(transcript: str, question: str = "Summarize this audio content") -> str:
|
37 |
+
"""Analyze audio content via transcript"""
|
38 |
+
return multimodal_tools.analyze_audio_transcript(transcript, question)
|
39 |
+
|
40 |
+
@tool
|
41 |
+
def search_tool(query: str, max_results: int = 5) -> str:
|
42 |
+
"""Search the web for information"""
|
43 |
+
results = search_tools.search(query, max_results)
|
44 |
+
if not results:
|
45 |
+
return "No search results found"
|
46 |
+
|
47 |
+
# Format results for the LLM
|
48 |
+
formatted_results = []
|
49 |
+
for i, result in enumerate(results, 1):
|
50 |
+
title = result.get('title', 'No title')
|
51 |
+
content = result.get('content', 'No content')
|
52 |
+
url = result.get('url', 'No URL')
|
53 |
+
formatted_results.append(f"{i}. {title}\n{content[:200]}...\nSource: {url}\n")
|
54 |
+
|
55 |
+
return "\n".join(formatted_results)
|
56 |
+
|
57 |
+
@tool
|
58 |
+
def extract_youtube_transcript(url: str, language_code: str = 'en') -> str:
|
59 |
+
"""Extract transcript/captions from a YouTube video"""
|
60 |
+
captions = youtube_tools.get_captions(url, language_code)
|
61 |
+
if captions:
|
62 |
+
return captions
|
63 |
+
else:
|
64 |
+
return "No captions available for this video"
|
65 |
+
|
66 |
+
@tool
|
67 |
+
def add(a: float, b: float) -> float:
|
68 |
+
"""Add two numbers"""
|
69 |
+
return MathTools.add(a, b)
|
70 |
+
|
71 |
+
@tool
|
72 |
+
def subtract(a: float, b: float) -> float:
|
73 |
+
"""Subtract two numbers"""
|
74 |
+
return MathTools.subtract(a, b)
|
75 |
+
|
76 |
+
@tool
|
77 |
+
def multiply(a: float, b: float) -> float:
|
78 |
+
"""Multiply two numbers"""
|
79 |
+
return MathTools.multiply(a, b)
|
80 |
+
|
81 |
+
@tool
|
82 |
+
def divide(a: float, b: float) -> str:
|
83 |
+
"""Divide two numbers"""
|
84 |
+
result = MathTools.divide(a, b)
|
85 |
+
return str(result)
|
86 |
+
|
87 |
+
@tool
|
88 |
+
def get_youtube_info(url: str) -> str:
|
89 |
+
"""Get information about a YouTube video"""
|
90 |
+
info = youtube_tools.get_video_info(url)
|
91 |
+
if info:
|
92 |
+
return f"Title: {info.get('title', 'Unknown')}\nAuthor: {info.get('author', 'Unknown')}\nDuration: {info.get('length', 0)} seconds\nViews: {info.get('views', 0):,}"
|
93 |
+
else:
|
94 |
+
return "Could not retrieve video information"
|
95 |
+
|
96 |
+
@tool
|
97 |
+
def calculate_expression(expression: str) -> str:
|
98 |
+
"""Calculate a mathematical expression safely"""
|
99 |
+
from .math_tools import calculate_expression as calc_expr
|
100 |
+
return str(calc_expr(expression))
|
101 |
+
|
102 |
+
@tool
|
103 |
+
def factorial(n: int) -> str:
|
104 |
+
"""Calculate factorial of a number"""
|
105 |
+
result = MathTools.factorial(n)
|
106 |
+
return str(result)
|
107 |
+
|
108 |
+
@tool
|
109 |
+
def square_root(n: float) -> str:
|
110 |
+
"""Calculate square root of a number"""
|
111 |
+
result = MathTools.square_root(n)
|
112 |
+
return str(result)
|
113 |
+
|
114 |
+
@tool
|
115 |
+
def percentage(part: float, whole: float) -> str:
|
116 |
+
"""Calculate percentage"""
|
117 |
+
result = MathTools.percentage(part, whole)
|
118 |
+
return str(result)
|
119 |
+
|
120 |
+
@tool
|
121 |
+
def average(numbers: str) -> str:
|
122 |
+
"""Calculate average of numbers (provide as comma-separated string)"""
|
123 |
+
try:
|
124 |
+
number_list = [float(x.strip()) for x in numbers.split(',')]
|
125 |
+
result = MathTools.average(number_list)
|
126 |
+
return str(result)
|
127 |
+
except Exception as e:
|
128 |
+
return f"Error parsing numbers: {str(e)}"
|
tools/math_tools.py
ADDED
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# tools/math_tools.py
|
2 |
+
import math
|
3 |
+
from typing import Union, List, Any
|
4 |
+
from .utils import logger
|
5 |
+
|
6 |
+
Number = Union[int, float]
|
7 |
+
|
8 |
+
class MathTools:
|
9 |
+
"""Simple math tools for basic calculations and utilities"""
|
10 |
+
|
11 |
+
@staticmethod
|
12 |
+
def add(a: Number, b: Number) -> Number:
|
13 |
+
"""Return the sum of a and b"""
|
14 |
+
return a + b
|
15 |
+
|
16 |
+
@staticmethod
|
17 |
+
def subtract(a: Number, b: Number) -> Number:
|
18 |
+
"""Return the difference of a and b"""
|
19 |
+
return a - b
|
20 |
+
|
21 |
+
@staticmethod
|
22 |
+
def multiply(a: Number, b: Number) -> Number:
|
23 |
+
"""Return the product of a and b"""
|
24 |
+
return a * b
|
25 |
+
|
26 |
+
@staticmethod
|
27 |
+
def divide(a: Number, b: Number) -> Union[Number, str]:
|
28 |
+
"""Return the division of a by b, handle division by zero"""
|
29 |
+
if b == 0:
|
30 |
+
return 'Error: Division by zero'
|
31 |
+
return a / b
|
32 |
+
|
33 |
+
@staticmethod
|
34 |
+
def power(base: Number, exponent: Number) -> Number:
|
35 |
+
"""Return base raised to the power of exponent"""
|
36 |
+
return base ** exponent
|
37 |
+
|
38 |
+
@staticmethod
|
39 |
+
def factorial(n: int) -> Union[int, str]:
|
40 |
+
"""Return factorial of n (non-negative integer)"""
|
41 |
+
if not isinstance(n, int) or n < 0:
|
42 |
+
return 'Error: Input must be a non-negative integer'
|
43 |
+
if n == 0 or n == 1:
|
44 |
+
return 1
|
45 |
+
result = 1
|
46 |
+
for i in range(2, n + 1):
|
47 |
+
result *= i
|
48 |
+
return result
|
49 |
+
|
50 |
+
@staticmethod
|
51 |
+
def square_root(n: Number) -> Union[float, str]:
|
52 |
+
"""Return square root of n"""
|
53 |
+
if n < 0:
|
54 |
+
return 'Error: Cannot calculate square root of negative number'
|
55 |
+
return math.sqrt(n)
|
56 |
+
|
57 |
+
@staticmethod
|
58 |
+
def percentage(part: Number, whole: Number) -> Union[float, str]:
|
59 |
+
"""Calculate percentage: (part/whole) * 100"""
|
60 |
+
if whole == 0:
|
61 |
+
return 'Error: Cannot calculate percentage with zero denominator'
|
62 |
+
return (part / whole) * 100
|
63 |
+
|
64 |
+
@staticmethod
|
65 |
+
def average(numbers: List[Number]) -> Union[float, str]:
|
66 |
+
"""Calculate average of a list of numbers"""
|
67 |
+
if not numbers:
|
68 |
+
return 'Error: Cannot calculate average of empty list'
|
69 |
+
return sum(numbers) / len(numbers)
|
70 |
+
|
71 |
+
@staticmethod
|
72 |
+
def round_number(n: Number, decimals: int = 2) -> Number:
|
73 |
+
"""Round number to specified decimal places"""
|
74 |
+
return round(n, decimals)
|
75 |
+
|
76 |
+
@staticmethod
|
77 |
+
def absolute(n: Number) -> Number:
|
78 |
+
"""Return absolute value of n"""
|
79 |
+
return abs(n)
|
80 |
+
|
81 |
+
@staticmethod
|
82 |
+
def min_value(numbers: List[Number]) -> Union[Number, str]:
|
83 |
+
"""Find minimum value in list"""
|
84 |
+
if not numbers:
|
85 |
+
return 'Error: Cannot find minimum of empty list'
|
86 |
+
return min(numbers)
|
87 |
+
|
88 |
+
@staticmethod
|
89 |
+
def max_value(numbers: List[Number]) -> Union[Number, str]:
|
90 |
+
"""Find maximum value in list"""
|
91 |
+
if not numbers:
|
92 |
+
return 'Error: Cannot find maximum of empty list'
|
93 |
+
return max(numbers)
|
94 |
+
|
95 |
+
@staticmethod
|
96 |
+
def calculate_compound_interest(principal: Number, rate: Number, time: Number, compounds_per_year: int = 1) -> float:
|
97 |
+
"""
|
98 |
+
Calculate compound interest
|
99 |
+
Formula: A = P(1 + r/n)^(nt)
|
100 |
+
"""
|
101 |
+
return principal * (1 + rate/compounds_per_year) ** (compounds_per_year * time)
|
102 |
+
|
103 |
+
@staticmethod
|
104 |
+
def solve_quadratic(a: Number, b: Number, c: Number) -> Union[tuple, str]:
|
105 |
+
"""
|
106 |
+
Solve quadratic equation ax² + bx + c = 0
|
107 |
+
Returns tuple of solutions or error message
|
108 |
+
"""
|
109 |
+
if a == 0:
|
110 |
+
return 'Error: Not a quadratic equation (a cannot be 0)'
|
111 |
+
|
112 |
+
discriminant = b**2 - 4*a*c
|
113 |
+
|
114 |
+
if discriminant < 0:
|
115 |
+
return 'Error: No real solutions (negative discriminant)'
|
116 |
+
elif discriminant == 0:
|
117 |
+
solution = -b / (2*a)
|
118 |
+
return (solution, solution)
|
119 |
+
else:
|
120 |
+
sqrt_discriminant = math.sqrt(discriminant)
|
121 |
+
solution1 = (-b + sqrt_discriminant) / (2*a)
|
122 |
+
solution2 = (-b - sqrt_discriminant) / (2*a)
|
123 |
+
return (solution1, solution2)
|
124 |
+
|
125 |
+
# Convenience functions for direct use
|
126 |
+
def add(a: Number, b: Number) -> Number:
|
127 |
+
"""Add two numbers"""
|
128 |
+
return MathTools.add(a, b)
|
129 |
+
|
130 |
+
def subtract(a: Number, b: Number) -> Number:
|
131 |
+
"""Subtract two numbers"""
|
132 |
+
return MathTools.subtract(a, b)
|
133 |
+
|
134 |
+
def multiply(a: Number, b: Number) -> Number:
|
135 |
+
"""Multiply two numbers"""
|
136 |
+
return MathTools.multiply(a, b)
|
137 |
+
|
138 |
+
def divide(a: Number, b: Number) -> Union[Number, str]:
|
139 |
+
"""Divide two numbers"""
|
140 |
+
return MathTools.divide(a, b)
|
141 |
+
|
142 |
+
def power(base: Number, exponent: Number) -> Number:
|
143 |
+
"""Raise base to power of exponent"""
|
144 |
+
return MathTools.power(base, exponent)
|
145 |
+
|
146 |
+
def factorial(n: int) -> Union[int, str]:
|
147 |
+
"""Calculate factorial of n"""
|
148 |
+
return MathTools.factorial(n)
|
149 |
+
|
150 |
+
def square_root(n: Number) -> Union[float, str]:
|
151 |
+
"""Calculate square root"""
|
152 |
+
return MathTools.square_root(n)
|
153 |
+
|
154 |
+
def percentage(part: Number, whole: Number) -> Union[float, str]:
|
155 |
+
"""Calculate percentage"""
|
156 |
+
return MathTools.percentage(part, whole)
|
157 |
+
|
158 |
+
def average(numbers: List[Number]) -> Union[float, str]:
|
159 |
+
"""Calculate average of numbers"""
|
160 |
+
return MathTools.average(numbers)
|
161 |
+
|
162 |
+
def calculate_expression(expression: str) -> Union[Number, str]:
|
163 |
+
"""
|
164 |
+
Safely evaluate mathematical expressions
|
165 |
+
WARNING: Only use with trusted input
|
166 |
+
"""
|
167 |
+
try:
|
168 |
+
# Only allow safe mathematical operations
|
169 |
+
allowed_chars = set('0123456789+-*/().^ ')
|
170 |
+
if not all(c in allowed_chars for c in expression.replace('**', '^')):
|
171 |
+
return 'Error: Invalid characters in expression'
|
172 |
+
|
173 |
+
# Replace ^ with ** for Python exponentiation
|
174 |
+
safe_expression = expression.replace('^', '**')
|
175 |
+
|
176 |
+
# Evaluate the expression
|
177 |
+
result = eval(safe_expression)
|
178 |
+
return result
|
179 |
+
except ZeroDivisionError:
|
180 |
+
return 'Error: Division by zero in expression'
|
181 |
+
except Exception as e:
|
182 |
+
return f'Error: Invalid expression - {str(e)}'
|
183 |
+
|
184 |
+
# Example usage and testing
|
185 |
+
if __name__ == "__main__":
|
186 |
+
# Test basic operations
|
187 |
+
print("Basic Operations:")
|
188 |
+
print(f"5 + 3 = {add(5, 3)}")
|
189 |
+
print(f"10 - 4 = {subtract(10, 4)}")
|
190 |
+
print(f"6 * 7 = {multiply(6, 7)}")
|
191 |
+
print(f"15 / 3 = {divide(15, 3)}")
|
192 |
+
print(f"2^8 = {power(2, 8)}")
|
193 |
+
|
194 |
+
print("\nAdvanced Operations:")
|
195 |
+
print(f"√16 = {square_root(16)}")
|
196 |
+
print(f"5! = {factorial(5)}")
|
197 |
+
print(f"Average of [1,2,3,4,5] = {average([1,2,3,4,5])}")
|
198 |
+
percent_result = percentage(25, 100)
|
199 |
+
if isinstance(percent_result, float):
|
200 |
+
print(f"25% of 200 = {percent_result * 200 / 100}")
|
201 |
+
else:
|
202 |
+
print(f"25% of 200 = {percent_result}")
|
203 |
+
|
204 |
+
print("\nQuadratic Equation (x² - 5x + 6 = 0):")
|
205 |
+
solutions = MathTools.solve_quadratic(1, -5, 6)
|
206 |
+
print(f"Solutions: {solutions}")
|
tools/multimodal_tools.py
ADDED
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# tools/multimodal_tools.py
|
2 |
+
import requests
|
3 |
+
import json
|
4 |
+
from typing import Optional, Dict, Any
|
5 |
+
from .utils import encode_image_to_base64, validate_file_exists, get_env_var, logger
|
6 |
+
|
7 |
+
class MultimodalTools:
|
8 |
+
"""Free multimodal AI tools using OpenRouter and other free services"""
|
9 |
+
|
10 |
+
def __init__(self, openrouter_key: Optional[str] = None):
|
11 |
+
self.openrouter_key = openrouter_key or get_env_var("OPENROUTER_API_KEY", None)
|
12 |
+
self.openrouter_url = "https://openrouter.ai/api/v1/chat/completions"
|
13 |
+
self.headers = {
|
14 |
+
"Authorization": f"Bearer {self.openrouter_key}",
|
15 |
+
"Content-Type": "application/json",
|
16 |
+
"HTTP-Referer": "https://your-app.com", # Optional: for analytics
|
17 |
+
"X-Title": "Multimodal Tools" # Optional: for analytics
|
18 |
+
}
|
19 |
+
|
20 |
+
# Available free multimodal models
|
21 |
+
self.vision_model = "moonshotai/kimi-vl-a3b-thinking:free"
|
22 |
+
self.text_model = "meta-llama/llama-4-maverick:free"
|
23 |
+
|
24 |
+
def _make_openrouter_request(self, payload: Dict[str, Any]) -> str:
|
25 |
+
"""Make request to OpenRouter API with error handling"""
|
26 |
+
try:
|
27 |
+
response = requests.post(
|
28 |
+
self.openrouter_url,
|
29 |
+
headers=self.headers,
|
30 |
+
json=payload,
|
31 |
+
timeout=30
|
32 |
+
)
|
33 |
+
response.raise_for_status()
|
34 |
+
|
35 |
+
result = response.json()
|
36 |
+
if 'choices' in result and len(result['choices']) > 0:
|
37 |
+
return result['choices'][0]['message']['content']
|
38 |
+
else:
|
39 |
+
logger.error(f"Unexpected response format: {result}")
|
40 |
+
return "Error: Invalid response format"
|
41 |
+
|
42 |
+
except requests.exceptions.RequestException as e:
|
43 |
+
logger.error(f"OpenRouter API request failed: {str(e)}")
|
44 |
+
return f"Error making API request: {str(e)}"
|
45 |
+
except Exception as e:
|
46 |
+
logger.error(f"Unexpected error: {str(e)}")
|
47 |
+
return f"Unexpected error: {str(e)}"
|
48 |
+
|
49 |
+
def analyze_image(self, image_path: str, question: str = "Describe this image in detail") -> str:
|
50 |
+
"""
|
51 |
+
Analyze image content using multimodal AI
|
52 |
+
|
53 |
+
Args:
|
54 |
+
image_path: Path to image file
|
55 |
+
question: Question about the image
|
56 |
+
|
57 |
+
Returns:
|
58 |
+
AI analysis of the image
|
59 |
+
"""
|
60 |
+
if not validate_file_exists(image_path):
|
61 |
+
return f"Error: Image file not found at {image_path}"
|
62 |
+
|
63 |
+
try:
|
64 |
+
encoded_image = encode_image_to_base64(image_path)
|
65 |
+
|
66 |
+
payload = {
|
67 |
+
"model": self.vision_model,
|
68 |
+
"messages": [
|
69 |
+
{
|
70 |
+
"role": "user",
|
71 |
+
"content": [
|
72 |
+
{"type": "text", "text": question},
|
73 |
+
{
|
74 |
+
"type": "image_url",
|
75 |
+
"image_url": {"url": f"data:image/jpeg;base64,{encoded_image}"}
|
76 |
+
}
|
77 |
+
]
|
78 |
+
}
|
79 |
+
],
|
80 |
+
"temperature": 0,
|
81 |
+
"max_tokens": 1024
|
82 |
+
}
|
83 |
+
|
84 |
+
return self._make_openrouter_request(payload)
|
85 |
+
|
86 |
+
except Exception as e:
|
87 |
+
error_msg = f"Error analyzing image: {str(e)}"
|
88 |
+
logger.error(error_msg)
|
89 |
+
return error_msg
|
90 |
+
|
91 |
+
def extract_text_from_image(self, image_path: str) -> str:
|
92 |
+
"""
|
93 |
+
Extract text from image using OCR via multimodal AI
|
94 |
+
|
95 |
+
Args:
|
96 |
+
image_path: Path to image file
|
97 |
+
|
98 |
+
Returns:
|
99 |
+
Extracted text from image
|
100 |
+
"""
|
101 |
+
ocr_prompt = """Extract all visible text from this image.
|
102 |
+
Return only the text content without any additional commentary or formatting.
|
103 |
+
If no text is visible, return 'No text found'."""
|
104 |
+
|
105 |
+
return self.analyze_image(image_path, ocr_prompt)
|
106 |
+
|
107 |
+
def analyze_audio_transcript(self, transcript: str, question: str = "Summarize this audio content") -> str:
|
108 |
+
"""
|
109 |
+
Analyze audio content via transcript
|
110 |
+
|
111 |
+
Args:
|
112 |
+
transcript: Audio transcript text
|
113 |
+
question: Question about the audio content
|
114 |
+
|
115 |
+
Returns:
|
116 |
+
AI analysis of the audio content
|
117 |
+
"""
|
118 |
+
if not transcript.strip():
|
119 |
+
return "Error: Empty transcript provided"
|
120 |
+
|
121 |
+
try:
|
122 |
+
payload = {
|
123 |
+
"model": self.text_model,
|
124 |
+
"messages": [
|
125 |
+
{
|
126 |
+
"role": "user",
|
127 |
+
"content": f"Audio transcript: {transcript}\n\nQuestion: {question}"
|
128 |
+
}
|
129 |
+
],
|
130 |
+
"temperature": 0,
|
131 |
+
"max_tokens": 1024
|
132 |
+
}
|
133 |
+
|
134 |
+
return self._make_openrouter_request(payload)
|
135 |
+
|
136 |
+
except Exception as e:
|
137 |
+
error_msg = f"Error analyzing audio transcript: {str(e)}"
|
138 |
+
logger.error(error_msg)
|
139 |
+
return error_msg
|
140 |
+
|
141 |
+
def describe_image(self, image_path: str) -> str:
|
142 |
+
"""Get a detailed description of an image"""
|
143 |
+
return self.analyze_image(
|
144 |
+
image_path,
|
145 |
+
"Provide a detailed, objective description of this image including objects, people, colors, setting, and any notable details."
|
146 |
+
)
|
147 |
+
|
148 |
+
def answer_visual_question(self, image_path: str, question: str) -> str:
|
149 |
+
"""Answer a specific question about an image"""
|
150 |
+
return self.analyze_image(image_path, question)
|
151 |
+
|
152 |
+
# Convenience functions for direct use
|
153 |
+
def analyze_image(image_path: str, question: str = "Describe this image in detail") -> str:
|
154 |
+
"""Standalone function to analyze an image"""
|
155 |
+
tools = MultimodalTools()
|
156 |
+
return tools.analyze_image(image_path, question)
|
157 |
+
|
158 |
+
def extract_text(image_path: str) -> str:
|
159 |
+
"""Standalone function to extract text from an image"""
|
160 |
+
tools = MultimodalTools()
|
161 |
+
return tools.extract_text_from_image(image_path)
|
162 |
+
|
163 |
+
def analyze_transcript(transcript: str, question: str = "Summarize this content") -> str:
|
164 |
+
"""Standalone function to analyze audio transcript"""
|
165 |
+
tools = MultimodalTools()
|
166 |
+
return tools.analyze_audio_transcript(transcript, question)
|
tools/search_tools.py
ADDED
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# tools/search_tools.py
|
2 |
+
import requests
|
3 |
+
import os
|
4 |
+
from typing import List, Dict, Any, Optional
|
5 |
+
from .utils import get_env_var, logger
|
6 |
+
|
7 |
+
class SearchTools:
|
8 |
+
"""Free and cost-effective search tools with multiple providers"""
|
9 |
+
|
10 |
+
def __init__(self):
|
11 |
+
# Primary: Free alternatives
|
12 |
+
self.duckduckgo_enabled = True
|
13 |
+
|
14 |
+
# Secondary: Tavily (cost-effective)
|
15 |
+
self.tavily_api_key = os.getenv("TAVILY_API_KEY")
|
16 |
+
|
17 |
+
# Tertiary: SerpAPI (expensive, fallback only)
|
18 |
+
self.serpapi_key = os.getenv("SERPAPI_KEY")
|
19 |
+
|
20 |
+
def search_duckduckgo(self, query: str, max_results: int = 5) -> List[Dict[str, Any]]:
|
21 |
+
"""
|
22 |
+
Free search using DuckDuckGo Instant Answer API
|
23 |
+
|
24 |
+
Args:
|
25 |
+
query: Search query
|
26 |
+
max_results: Maximum number of results
|
27 |
+
|
28 |
+
Returns:
|
29 |
+
List of search results
|
30 |
+
"""
|
31 |
+
try:
|
32 |
+
# DuckDuckGo Instant Answer API (free)
|
33 |
+
url = "https://api.duckduckgo.com/"
|
34 |
+
params = {
|
35 |
+
'q': query,
|
36 |
+
'format': 'json',
|
37 |
+
'no_html': '1',
|
38 |
+
'skip_disambig': '1'
|
39 |
+
}
|
40 |
+
|
41 |
+
response = requests.get(url, params=params, timeout=10)
|
42 |
+
response.raise_for_status()
|
43 |
+
|
44 |
+
data = response.json()
|
45 |
+
results = []
|
46 |
+
|
47 |
+
# Process abstract
|
48 |
+
if data.get('Abstract'):
|
49 |
+
results.append({
|
50 |
+
'title': data.get('Heading', 'DuckDuckGo Result'),
|
51 |
+
'url': data.get('AbstractURL', ''),
|
52 |
+
'content': data.get('Abstract', ''),
|
53 |
+
'source': 'DuckDuckGo'
|
54 |
+
})
|
55 |
+
|
56 |
+
# Process related topics
|
57 |
+
for topic in data.get('RelatedTopics', [])[:max_results-len(results)]:
|
58 |
+
if isinstance(topic, dict) and 'Text' in topic:
|
59 |
+
results.append({
|
60 |
+
'title': topic.get('Text', '')[:100],
|
61 |
+
'url': topic.get('FirstURL', ''),
|
62 |
+
'content': topic.get('Text', ''),
|
63 |
+
'source': 'DuckDuckGo'
|
64 |
+
})
|
65 |
+
|
66 |
+
return results[:max_results]
|
67 |
+
|
68 |
+
except Exception as e:
|
69 |
+
logger.error(f"DuckDuckGo search failed: {str(e)}")
|
70 |
+
return []
|
71 |
+
|
72 |
+
def search_tavily(self, query: str, max_results: int = 5) -> List[Dict[str, Any]]:
|
73 |
+
"""
|
74 |
+
Search using Tavily API (cost-effective)
|
75 |
+
|
76 |
+
Args:
|
77 |
+
query: Search query
|
78 |
+
max_results: Maximum number of results
|
79 |
+
|
80 |
+
Returns:
|
81 |
+
List of search results
|
82 |
+
"""
|
83 |
+
if not self.tavily_api_key:
|
84 |
+
logger.warning("Tavily API key not provided")
|
85 |
+
return []
|
86 |
+
|
87 |
+
try:
|
88 |
+
url = "https://api.tavily.com/search"
|
89 |
+
payload = {
|
90 |
+
"api_key": self.tavily_api_key,
|
91 |
+
"query": query,
|
92 |
+
"search_depth": "basic",
|
93 |
+
"include_answer": False,
|
94 |
+
"include_images": False,
|
95 |
+
"include_raw_content": False,
|
96 |
+
"max_results": max_results
|
97 |
+
}
|
98 |
+
|
99 |
+
response = requests.post(url, json=payload, timeout=15)
|
100 |
+
response.raise_for_status()
|
101 |
+
|
102 |
+
data = response.json()
|
103 |
+
results = []
|
104 |
+
|
105 |
+
for result in data.get('results', []):
|
106 |
+
results.append({
|
107 |
+
'title': result.get('title', ''),
|
108 |
+
'url': result.get('url', ''),
|
109 |
+
'content': result.get('content', ''),
|
110 |
+
'source': 'Tavily'
|
111 |
+
})
|
112 |
+
|
113 |
+
return results
|
114 |
+
|
115 |
+
except Exception as e:
|
116 |
+
logger.error(f"Tavily search failed: {str(e)}")
|
117 |
+
return []
|
118 |
+
|
119 |
+
def search_serpapi(self, query: str, max_results: int = 5) -> List[Dict[str, Any]]:
|
120 |
+
"""
|
121 |
+
Search using SerpAPI (expensive, fallback only)
|
122 |
+
|
123 |
+
Args:
|
124 |
+
query: Search query
|
125 |
+
max_results: Maximum number of results
|
126 |
+
|
127 |
+
Returns:
|
128 |
+
List of search results
|
129 |
+
"""
|
130 |
+
if not self.serpapi_key:
|
131 |
+
logger.warning("SerpAPI key not provided")
|
132 |
+
return []
|
133 |
+
|
134 |
+
try:
|
135 |
+
url = "https://serpapi.com/search"
|
136 |
+
params = {
|
137 |
+
'api_key': self.serpapi_key,
|
138 |
+
'engine': 'google',
|
139 |
+
'q': query,
|
140 |
+
'num': max_results,
|
141 |
+
'gl': 'us', # Geolocation
|
142 |
+
'hl': 'en' # Language
|
143 |
+
}
|
144 |
+
|
145 |
+
response = requests.get(url, params=params, timeout=15)
|
146 |
+
response.raise_for_status()
|
147 |
+
|
148 |
+
data = response.json()
|
149 |
+
results = []
|
150 |
+
|
151 |
+
for result in data.get('organic_results', []):
|
152 |
+
results.append({
|
153 |
+
'title': result.get('title', ''),
|
154 |
+
'url': result.get('link', ''),
|
155 |
+
'content': result.get('snippet', ''),
|
156 |
+
'source': 'Google (SerpAPI)'
|
157 |
+
})
|
158 |
+
|
159 |
+
return results
|
160 |
+
|
161 |
+
except Exception as e:
|
162 |
+
logger.error(f"SerpAPI search failed: {str(e)}")
|
163 |
+
return []
|
164 |
+
|
165 |
+
def search(self, query: str, max_results: int = 5) -> List[Dict[str, Any]]:
|
166 |
+
"""
|
167 |
+
Comprehensive search using multiple providers with fallback strategy
|
168 |
+
|
169 |
+
Args:
|
170 |
+
query: Search query
|
171 |
+
max_results: Maximum number of results
|
172 |
+
|
173 |
+
Returns:
|
174 |
+
List of search results from best available provider
|
175 |
+
"""
|
176 |
+
if not query.strip():
|
177 |
+
return []
|
178 |
+
|
179 |
+
# Try providers in order of preference (free -> cheap -> expensive)
|
180 |
+
providers = [
|
181 |
+
("DuckDuckGo", self.search_duckduckgo),
|
182 |
+
("Tavily", self.search_tavily),
|
183 |
+
("SerpAPI", self.search_serpapi)
|
184 |
+
]
|
185 |
+
|
186 |
+
for provider_name, search_func in providers:
|
187 |
+
try:
|
188 |
+
logger.info(f"Attempting search with {provider_name}")
|
189 |
+
results = search_func(query, max_results)
|
190 |
+
|
191 |
+
if results:
|
192 |
+
logger.info(f"Successfully retrieved {len(results)} results from {provider_name}")
|
193 |
+
return results
|
194 |
+
else:
|
195 |
+
logger.warning(f"No results from {provider_name}")
|
196 |
+
|
197 |
+
except Exception as e:
|
198 |
+
logger.error(f"Error with {provider_name}: {str(e)}")
|
199 |
+
continue
|
200 |
+
|
201 |
+
logger.error("All search providers failed")
|
202 |
+
return []
|
203 |
+
|
204 |
+
def search_news(self, query: str, max_results: int = 5) -> List[Dict[str, Any]]:
|
205 |
+
"""Search for news articles"""
|
206 |
+
news_query = f"news {query}"
|
207 |
+
return self.search(news_query, max_results)
|
208 |
+
|
209 |
+
def search_academic(self, query: str, max_results: int = 5) -> List[Dict[str, Any]]:
|
210 |
+
"""Search for academic content"""
|
211 |
+
academic_query = f"academic research {query} site:scholar.google.com OR site:arxiv.org OR site:researchgate.net"
|
212 |
+
return self.search(academic_query, max_results)
|
213 |
+
|
214 |
+
# Convenience functions
|
215 |
+
def search_web(query: str, max_results: int = 5) -> List[Dict[str, Any]]:
|
216 |
+
"""Standalone function for web search"""
|
217 |
+
tools = SearchTools()
|
218 |
+
return tools.search(query, max_results)
|
219 |
+
|
220 |
+
def search_news(query: str, max_results: int = 5) -> List[Dict[str, Any]]:
|
221 |
+
"""Standalone function for news search"""
|
222 |
+
tools = SearchTools()
|
223 |
+
return tools.search_news(query, max_results)
|
tools/utils.py
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# tools/utils.py
|
2 |
+
import base64
|
3 |
+
import os
|
4 |
+
import mimetypes
|
5 |
+
from typing import Optional, Dict, Any
|
6 |
+
import logging
|
7 |
+
|
8 |
+
# Configure logging
|
9 |
+
logging.basicConfig(level=logging.INFO)
|
10 |
+
logger = logging.getLogger(__name__)
|
11 |
+
|
12 |
+
def encode_image_to_base64(image_path: str) -> str:
|
13 |
+
"""Convert image file to base64 encoding"""
|
14 |
+
try:
|
15 |
+
with open(image_path, "rb") as image_file:
|
16 |
+
encoded = base64.b64encode(image_file.read()).decode('utf-8')
|
17 |
+
return encoded
|
18 |
+
except Exception as e:
|
19 |
+
logger.error(f"Error encoding image {image_path}: {str(e)}")
|
20 |
+
raise
|
21 |
+
|
22 |
+
def get_mime_type(file_path: str) -> str:
|
23 |
+
"""Get MIME type for file"""
|
24 |
+
mime_type, _ = mimetypes.guess_type(file_path)
|
25 |
+
return mime_type or 'application/octet-stream'
|
26 |
+
|
27 |
+
def validate_file_exists(file_path: str) -> bool:
|
28 |
+
"""Validate that file exists and is readable"""
|
29 |
+
return os.path.exists(file_path) and os.path.isfile(file_path)
|
30 |
+
|
31 |
+
def get_env_var(var_name: str, default: Optional[str] = None) -> str:
|
32 |
+
"""Get environment variable with optional default"""
|
33 |
+
value = os.getenv(var_name, default)
|
34 |
+
if value is None:
|
35 |
+
raise ValueError(f"Environment variable {var_name} is required")
|
36 |
+
return value
|
tools/youtube_tools.py
ADDED
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# tools/youtube_tools.py (Updated with fixes)
|
2 |
+
"""
|
3 |
+
YouTube Tools Module - Fixed version using pytubefix
|
4 |
+
Addresses network issues, deprecation warnings, and playlist errors
|
5 |
+
"""
|
6 |
+
|
7 |
+
from pytubefix import YouTube, Playlist
|
8 |
+
from pytubefix.cli import on_progress
|
9 |
+
from typing import Optional, Dict, Any, List
|
10 |
+
import os
|
11 |
+
import time
|
12 |
+
import logging
|
13 |
+
from .utils import logger, validate_file_exists
|
14 |
+
|
15 |
+
class YouTubeTools:
|
16 |
+
"""YouTube tools with improved error handling and network resilience"""
|
17 |
+
|
18 |
+
def __init__(self, max_retries: int = 3, retry_delay: float = 1.0):
|
19 |
+
self.supported_formats = ['mp4', '3gp', 'webm']
|
20 |
+
self.supported_audio_formats = ['mp3', 'mp4', 'webm']
|
21 |
+
self.max_retries = max_retries
|
22 |
+
self.retry_delay = retry_delay
|
23 |
+
|
24 |
+
def _retry_operation(self, operation, *args, **kwargs):
|
25 |
+
"""Retry operation with exponential backoff for network issues"""
|
26 |
+
for attempt in range(self.max_retries):
|
27 |
+
try:
|
28 |
+
return operation(*args, **kwargs)
|
29 |
+
except Exception as e:
|
30 |
+
if attempt == self.max_retries - 1:
|
31 |
+
raise e
|
32 |
+
|
33 |
+
error_msg = str(e).lower()
|
34 |
+
if any(term in error_msg for term in ['network', 'socket', 'timeout', 'connection']):
|
35 |
+
wait_time = self.retry_delay * (2 ** attempt)
|
36 |
+
logger.warning(f"Network error (attempt {attempt + 1}/{self.max_retries}): {e}")
|
37 |
+
logger.info(f"Retrying in {wait_time} seconds...")
|
38 |
+
time.sleep(wait_time)
|
39 |
+
else:
|
40 |
+
raise e
|
41 |
+
|
42 |
+
def get_video_info(self, url: str) -> Optional[Dict[str, Any]]:
|
43 |
+
"""
|
44 |
+
Retrieve comprehensive metadata about a YouTube video using pytubefix
|
45 |
+
"""
|
46 |
+
try:
|
47 |
+
def _get_info():
|
48 |
+
yt = YouTube(url, on_progress_callback=on_progress)
|
49 |
+
|
50 |
+
# Get available streams info with better error handling
|
51 |
+
video_streams = []
|
52 |
+
try:
|
53 |
+
streams = yt.streams.filter(progressive=True, file_extension='mp4')
|
54 |
+
for stream in streams:
|
55 |
+
try:
|
56 |
+
video_streams.append({
|
57 |
+
'resolution': getattr(stream, 'resolution', 'unknown'),
|
58 |
+
'fps': getattr(stream, 'fps', 'unknown'),
|
59 |
+
'video_codec': getattr(stream, 'video_codec', 'unknown'),
|
60 |
+
'audio_codec': getattr(stream, 'audio_codec', 'unknown'),
|
61 |
+
'filesize': getattr(stream, 'filesize', None),
|
62 |
+
'mime_type': getattr(stream, 'mime_type', 'unknown')
|
63 |
+
})
|
64 |
+
except Exception as stream_error:
|
65 |
+
logger.debug(f"Error processing stream: {stream_error}")
|
66 |
+
continue
|
67 |
+
except Exception as e:
|
68 |
+
logger.warning(f"Could not retrieve stream details: {e}")
|
69 |
+
|
70 |
+
# Get caption languages safely
|
71 |
+
captions_available = []
|
72 |
+
try:
|
73 |
+
if yt.captions:
|
74 |
+
captions_available = list(yt.captions.keys())
|
75 |
+
except Exception as e:
|
76 |
+
logger.warning(f"Could not retrieve captions list: {e}")
|
77 |
+
|
78 |
+
info = {
|
79 |
+
'title': getattr(yt, 'title', 'Unknown'),
|
80 |
+
'author': getattr(yt, 'author', 'Unknown'),
|
81 |
+
'channel_url': getattr(yt, 'channel_url', 'Unknown'),
|
82 |
+
'length': getattr(yt, 'length', 0),
|
83 |
+
'views': getattr(yt, 'views', 0),
|
84 |
+
'description': getattr(yt, 'description', ''),
|
85 |
+
'thumbnail_url': getattr(yt, 'thumbnail_url', ''),
|
86 |
+
'publish_date': yt.publish_date.isoformat() if getattr(yt, 'publish_date', None) else None,
|
87 |
+
'keywords': getattr(yt, 'keywords', []),
|
88 |
+
'video_id': getattr(yt, 'video_id', ''),
|
89 |
+
'watch_url': getattr(yt, 'watch_url', url),
|
90 |
+
'available_streams': video_streams,
|
91 |
+
'captions_available': captions_available
|
92 |
+
}
|
93 |
+
|
94 |
+
return info
|
95 |
+
|
96 |
+
info = self._retry_operation(_get_info)
|
97 |
+
if info is not None:
|
98 |
+
logger.info(f"Retrieved info for video: {info.get('title', 'Unknown')}")
|
99 |
+
return info
|
100 |
+
|
101 |
+
except Exception as e:
|
102 |
+
logger.error(f"Failed to get video info for {url}: {e}")
|
103 |
+
return None
|
104 |
+
|
105 |
+
def download_video(self, url: str, output_path: str = './downloads',
|
106 |
+
resolution: str = 'highest', filename: Optional[str] = None) -> Optional[str]:
|
107 |
+
"""Download a YouTube video with retry logic"""
|
108 |
+
try:
|
109 |
+
def _download():
|
110 |
+
os.makedirs(output_path, exist_ok=True)
|
111 |
+
|
112 |
+
yt = YouTube(url, on_progress_callback=on_progress)
|
113 |
+
|
114 |
+
# Select stream based on resolution preference
|
115 |
+
if resolution == 'highest':
|
116 |
+
stream = yt.streams.get_highest_resolution()
|
117 |
+
elif resolution == 'lowest':
|
118 |
+
stream = yt.streams.get_lowest_resolution()
|
119 |
+
else:
|
120 |
+
stream = yt.streams.filter(res=resolution, progressive=True, file_extension='mp4').first()
|
121 |
+
if not stream:
|
122 |
+
logger.warning(f"Resolution {resolution} not found, downloading highest instead")
|
123 |
+
stream = yt.streams.get_highest_resolution()
|
124 |
+
|
125 |
+
if not stream:
|
126 |
+
raise Exception("No suitable stream found for download")
|
127 |
+
|
128 |
+
# Download with custom filename if provided
|
129 |
+
if filename:
|
130 |
+
safe_filename = "".join(c for c in filename if c.isalnum() or c in (' ', '-', '_')).rstrip()
|
131 |
+
file_path = stream.download(output_path=output_path, filename=f"{safe_filename}.{stream.subtype}")
|
132 |
+
else:
|
133 |
+
file_path = stream.download(output_path=output_path)
|
134 |
+
|
135 |
+
return file_path
|
136 |
+
|
137 |
+
file_path = self._retry_operation(_download)
|
138 |
+
logger.info(f"Downloaded video to {file_path}")
|
139 |
+
return file_path
|
140 |
+
|
141 |
+
except Exception as e:
|
142 |
+
logger.error(f"Failed to download video from {url}: {e}")
|
143 |
+
return None
|
144 |
+
|
145 |
+
def download_audio(self, url: str, output_path: str = './downloads',
|
146 |
+
filename: Optional[str] = None) -> Optional[str]:
|
147 |
+
"""Download only audio from a YouTube video with retry logic"""
|
148 |
+
try:
|
149 |
+
def _download_audio():
|
150 |
+
os.makedirs(output_path, exist_ok=True)
|
151 |
+
|
152 |
+
yt = YouTube(url, on_progress_callback=on_progress)
|
153 |
+
audio_stream = yt.streams.get_audio_only()
|
154 |
+
|
155 |
+
if not audio_stream:
|
156 |
+
raise Exception("No audio stream found")
|
157 |
+
|
158 |
+
if filename:
|
159 |
+
safe_filename = "".join(c for c in filename if c.isalnum() or c in (' ', '-', '_')).rstrip()
|
160 |
+
file_path = audio_stream.download(output_path=output_path, filename=f"{safe_filename}.{audio_stream.subtype}")
|
161 |
+
else:
|
162 |
+
file_path = audio_stream.download(output_path=output_path)
|
163 |
+
|
164 |
+
return file_path
|
165 |
+
|
166 |
+
file_path = self._retry_operation(_download_audio)
|
167 |
+
logger.info(f"Downloaded audio to {file_path}")
|
168 |
+
return file_path
|
169 |
+
|
170 |
+
except Exception as e:
|
171 |
+
logger.error(f"Failed to download audio from {url}: {e}")
|
172 |
+
return None
|
173 |
+
|
174 |
+
def get_captions(self, url: str, language_code: str = 'en') -> Optional[str]:
|
175 |
+
"""
|
176 |
+
Get captions/subtitles - FIXED: No more deprecation warning
|
177 |
+
"""
|
178 |
+
try:
|
179 |
+
def _get_captions():
|
180 |
+
yt = YouTube(url, on_progress_callback=on_progress)
|
181 |
+
|
182 |
+
if not yt.captions:
|
183 |
+
logger.warning("No captions available for this video")
|
184 |
+
return None
|
185 |
+
|
186 |
+
# Use modern dictionary-style access instead of deprecated method
|
187 |
+
if language_code in yt.captions:
|
188 |
+
caption = yt.captions[language_code]
|
189 |
+
captions_text = caption.generate_srt_captions()
|
190 |
+
return captions_text
|
191 |
+
else:
|
192 |
+
available_langs = list(yt.captions.keys())
|
193 |
+
logger.warning(f"Captions not found for language {language_code}. Available: {available_langs}")
|
194 |
+
return None
|
195 |
+
|
196 |
+
result = self._retry_operation(_get_captions)
|
197 |
+
if result:
|
198 |
+
logger.info(f"Retrieved captions in {language_code}")
|
199 |
+
return result
|
200 |
+
|
201 |
+
except Exception as e:
|
202 |
+
logger.error(f"Failed to get captions from {url}: {e}")
|
203 |
+
return None
|
204 |
+
|
205 |
+
def get_playlist_info(self, playlist_url: str) -> Optional[Dict[str, Any]]:
|
206 |
+
"""
|
207 |
+
Get information about a YouTube playlist - FIXED: Better error handling
|
208 |
+
"""
|
209 |
+
try:
|
210 |
+
def _get_playlist_info():
|
211 |
+
playlist = Playlist(playlist_url)
|
212 |
+
|
213 |
+
# Get video URLs first (this triggers the playlist loading)
|
214 |
+
video_urls = list(playlist.video_urls)
|
215 |
+
|
216 |
+
# Safely access playlist properties with fallbacks
|
217 |
+
info = {
|
218 |
+
'video_count': len(video_urls),
|
219 |
+
'video_urls': video_urls[:10], # Limit to first 10 for performance
|
220 |
+
'total_videos': len(video_urls)
|
221 |
+
}
|
222 |
+
|
223 |
+
# Try to get additional info, but don't fail if unavailable
|
224 |
+
try:
|
225 |
+
info['title'] = getattr(playlist, 'title', 'Unknown Playlist')
|
226 |
+
except:
|
227 |
+
info['title'] = 'Private/Unavailable Playlist'
|
228 |
+
|
229 |
+
try:
|
230 |
+
info['description'] = getattr(playlist, 'description', '')
|
231 |
+
except:
|
232 |
+
info['description'] = 'Description unavailable'
|
233 |
+
|
234 |
+
try:
|
235 |
+
info['owner'] = getattr(playlist, 'owner', 'Unknown')
|
236 |
+
except:
|
237 |
+
info['owner'] = 'Owner unavailable'
|
238 |
+
|
239 |
+
return info
|
240 |
+
|
241 |
+
info = self._retry_operation(_get_playlist_info)
|
242 |
+
if info is not None:
|
243 |
+
logger.info(f"Retrieved playlist info: {info['title']} ({info['video_count']} videos)")
|
244 |
+
return info
|
245 |
+
|
246 |
+
except Exception as e:
|
247 |
+
logger.error(f"Failed to get playlist info from {playlist_url}: {e}")
|
248 |
+
return None
|
249 |
+
|
250 |
+
def get_available_qualities(self, url: str) -> Optional[List[Dict[str, Any]]]:
|
251 |
+
"""
|
252 |
+
Get all available download qualities - FIXED: Better network handling
|
253 |
+
"""
|
254 |
+
try:
|
255 |
+
def _get_qualities():
|
256 |
+
yt = YouTube(url, on_progress_callback=on_progress)
|
257 |
+
streams = []
|
258 |
+
|
259 |
+
# Get progressive streams (video + audio)
|
260 |
+
for stream in yt.streams.filter(progressive=True):
|
261 |
+
try:
|
262 |
+
streams.append({
|
263 |
+
'resolution': getattr(stream, 'resolution', 'unknown'),
|
264 |
+
'fps': getattr(stream, 'fps', 'unknown'),
|
265 |
+
'filesize_mb': round(stream.filesize / (1024 * 1024), 2) if getattr(stream, 'filesize', None) else None,
|
266 |
+
'mime_type': getattr(stream, 'mime_type', 'unknown'),
|
267 |
+
'video_codec': getattr(stream, 'video_codec', 'unknown'),
|
268 |
+
'audio_codec': getattr(stream, 'audio_codec', 'unknown')
|
269 |
+
})
|
270 |
+
except Exception as stream_error:
|
271 |
+
logger.debug(f"Error processing stream: {stream_error}")
|
272 |
+
continue
|
273 |
+
|
274 |
+
# Sort by resolution (numeric part)
|
275 |
+
def sort_key(x):
|
276 |
+
res = x['resolution']
|
277 |
+
if res and res != 'unknown' and res[:-1].isdigit():
|
278 |
+
return int(res[:-1])
|
279 |
+
return 0
|
280 |
+
|
281 |
+
return sorted(streams, key=sort_key, reverse=True)
|
282 |
+
|
283 |
+
return self._retry_operation(_get_qualities)
|
284 |
+
|
285 |
+
except Exception as e:
|
286 |
+
logger.error(f"Failed to get qualities for {url}: {e}")
|
287 |
+
return None
|
288 |
+
|
289 |
+
# Convenience functions (unchanged)
|
290 |
+
def get_video_info(url: str) -> Optional[Dict[str, Any]]:
|
291 |
+
"""Standalone function to get video information"""
|
292 |
+
tools = YouTubeTools()
|
293 |
+
return tools.get_video_info(url)
|
294 |
+
|
295 |
+
def download_video(url: str, output_path: str = './downloads',
|
296 |
+
resolution: str = 'highest', filename: Optional[str] = None) -> Optional[str]:
|
297 |
+
"""Standalone function to download a video"""
|
298 |
+
tools = YouTubeTools()
|
299 |
+
return tools.download_video(url, output_path, resolution, filename)
|
300 |
+
|
301 |
+
def download_audio(url: str, output_path: str = './downloads',
|
302 |
+
filename: Optional[str] = None) -> Optional[str]:
|
303 |
+
"""Standalone function to download audio only"""
|
304 |
+
tools = YouTubeTools()
|
305 |
+
return tools.download_audio(url, output_path, filename)
|
306 |
+
|
307 |
+
def get_captions(url: str, language_code: str = 'en') -> Optional[str]:
|
308 |
+
"""Standalone function to get video captions"""
|
309 |
+
tools = YouTubeTools()
|
310 |
+
return tools.get_captions(url, language_code)
|
311 |
+
|
312 |
+
def get_playlist_info(playlist_url: str) -> Optional[Dict[str, Any]]:
|
313 |
+
"""Standalone function to get playlist information"""
|
314 |
+
tools = YouTubeTools()
|
315 |
+
return tools.get_playlist_info(playlist_url)
|