grevy Claude Opus 4.7 commited on
Commit
6dc313e
·
1 Parent(s): 03da63b

Add tools for files, math, multimodal and video

Browse files

New tools in tools.py (all exposed via ALL_TOOLS):
- fetch_url, wikipedia_lookup: web/knowledge lookups
- download_task_file: pulls the file attached to a GAIA task_id
- read_pdf, read_excel, read_csv: parse attached documents
- python_exec, calculator: computation
- analyze_image: Llama 4 Scout VLM on local paths or URLs
- transcribe_audio: Whisper Turbo via Groq
- analyze_video: yt-dlp + ffmpeg frame sampling + VLM
- youtube_transcript: captions without downloading

app.py: BasicAgent binds ALL_TOOLS instead of just search_tool.
requirements.txt: add groq, pypdf, openpyxl, Wikipedia-API,
youtube-transcript-api, trafilatura, yt-dlp, imageio-ffmpeg;
drop unused langchain-community.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ryPUj8AtsFopnufGRSc4L

Files changed (3) hide show
  1. app.py +2 -2
  2. requirements.txt +8 -1
  3. tools.py +361 -5
app.py CHANGED
@@ -9,7 +9,7 @@ from typing import TypedDict, Annotated
9
  from langgraph.graph.message import add_messages
10
  from langgraph.graph import START, StateGraph
11
  from langchain_core.messages import AnyMessage, HumanMessage
12
- from tools import search_tool
13
  from langgraph.prebuilt import tools_condition
14
  from langgraph.prebuilt import ToolNode
15
 
@@ -37,7 +37,7 @@ class BasicAgent:
37
  print("BasicAgent initialized.")
38
 
39
  # Requires GROQ_API_KEY in the environment (set as a Space secret on HF).
40
- self.tools = [search_tool]
41
  self.chat = ChatGroq(
42
  model="openai/gpt-oss-120b",
43
  temperature=0.1,
 
9
  from langgraph.graph.message import add_messages
10
  from langgraph.graph import START, StateGraph
11
  from langchain_core.messages import AnyMessage, HumanMessage
12
+ from tools import ALL_TOOLS
13
  from langgraph.prebuilt import tools_condition
14
  from langgraph.prebuilt import ToolNode
15
 
 
37
  print("BasicAgent initialized.")
38
 
39
  # Requires GROQ_API_KEY in the environment (set as a Space secret on HF).
40
+ self.tools = ALL_TOOLS
41
  self.chat = ChatGroq(
42
  model="openai/gpt-oss-120b",
43
  temperature=0.1,
requirements.txt CHANGED
@@ -3,8 +3,15 @@ requests
3
  pandas
4
  spaces
5
  tavily-python
 
 
 
 
 
 
 
 
6
  langgraph
7
  langchain
8
  langchain-core
9
  langchain-groq
10
- langchain-community
 
3
  pandas
4
  spaces
5
  tavily-python
6
+ groq
7
+ pypdf
8
+ openpyxl
9
+ Wikipedia-API
10
+ youtube-transcript-api
11
+ trafilatura
12
+ yt-dlp
13
+ imageio-ffmpeg
14
  langgraph
15
  langchain
16
  langchain-core
17
  langchain-groq
 
tools.py CHANGED
@@ -1,11 +1,23 @@
 
 
1
  import os
 
 
 
 
 
2
 
 
 
3
  from langchain_core.tools import tool
 
 
4
 
5
  from agents import ResearchAgent
6
- from tavily import TavilyClient
7
 
8
- client = TavilyClient(api_key=os.environ["TAVILY_API_KEY"])
 
 
9
 
10
 
11
  @tool
@@ -16,20 +28,346 @@ def search_tool(query: str) -> str:
16
  Args:
17
  query: Search query.
18
  """
19
- results = client.search(query=query, max_results=5, search_depth="basic")
20
 
21
  output = []
22
-
23
  for r in results["results"]:
24
  output.append(f"""
25
  Title: {r['title']}
26
  URL: {r['url']}
27
  Content: {r['content']}
28
  """)
29
-
30
  return "\n".join(output)
31
 
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  research_agent = ResearchAgent()
34
 
35
 
@@ -46,3 +384,21 @@ def research(request: str) -> str:
46
  Prize in Physics?')
47
  """
48
  return research_agent(request)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import math
3
  import os
4
+ import re
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ from pathlib import Path
9
 
10
+ import requests
11
+ from langchain_core.messages import HumanMessage
12
  from langchain_core.tools import tool
13
+ from langchain_groq import ChatGroq
14
+ from tavily import TavilyClient
15
 
16
  from agents import ResearchAgent
 
17
 
18
+ DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
19
+
20
+ tavily_client = TavilyClient(api_key=os.environ["TAVILY_API_KEY"])
21
 
22
 
23
  @tool
 
28
  Args:
29
  query: Search query.
30
  """
31
+ results = tavily_client.search(query=query, max_results=5, search_depth="basic")
32
 
33
  output = []
 
34
  for r in results["results"]:
35
  output.append(f"""
36
  Title: {r['title']}
37
  URL: {r['url']}
38
  Content: {r['content']}
39
  """)
 
40
  return "\n".join(output)
41
 
42
 
43
+ @tool
44
+ def fetch_url(url: str) -> str:
45
+ """Fetch a web page and extract its main readable text.
46
+
47
+ Use this when a search snippet is not enough and you need the full page
48
+ content.
49
+
50
+ Args:
51
+ url: HTTP(S) URL.
52
+ """
53
+ import trafilatura
54
+
55
+ downloaded = trafilatura.fetch_url(url)
56
+ if not downloaded:
57
+ return f"Failed to fetch {url}"
58
+ text = trafilatura.extract(downloaded)
59
+ return text or "(no extractable content)"
60
+
61
+
62
+ @tool
63
+ def wikipedia_lookup(title: str) -> str:
64
+ """Fetch the summary of an English Wikipedia article.
65
+
66
+ More reliable than web search for historical facts, dates, and biographies.
67
+
68
+ Args:
69
+ title: Article title, e.g. "Marie Curie".
70
+ """
71
+ import wikipediaapi
72
+
73
+ wiki = wikipediaapi.Wikipedia(
74
+ user_agent="hf-agents-course-final/1.0 (batberni@gmail.com)",
75
+ language="en",
76
+ )
77
+ page = wiki.page(title)
78
+ if not page.exists():
79
+ return f"No Wikipedia page found for '{title}'"
80
+ return f"{page.summary}\n\nURL: {page.fullurl}"
81
+
82
+
83
+ @tool
84
+ def download_task_file(task_id: str) -> str:
85
+ """Download the file attached to a task from the HF agents course API.
86
+
87
+ Many evaluation questions come with an attached file (image, audio, pdf,
88
+ xlsx, csv, ...). Call this first to get a local path, then pass that path
89
+ to read_pdf / read_excel / analyze_image / transcribe_audio, etc.
90
+
91
+ Args:
92
+ task_id: The task_id from the questions endpoint.
93
+
94
+ Returns:
95
+ The local file path where the file was saved.
96
+ """
97
+ url = f"{DEFAULT_API_URL}/files/{task_id}"
98
+ resp = requests.get(url, timeout=30)
99
+ resp.raise_for_status()
100
+
101
+ cd = resp.headers.get("Content-Disposition", "")
102
+ m = re.search(r'filename="?([^"]+)"?', cd)
103
+ filename = m.group(1) if m else task_id
104
+
105
+ path = Path(tempfile.gettempdir()) / filename
106
+ path.write_bytes(resp.content)
107
+ return str(path)
108
+
109
+
110
+ @tool
111
+ def read_pdf(path: str) -> str:
112
+ """Extract text content from a PDF file.
113
+
114
+ Args:
115
+ path: Local file path to the PDF.
116
+ """
117
+ from pypdf import PdfReader
118
+
119
+ reader = PdfReader(path)
120
+ return "\n".join(page.extract_text() or "" for page in reader.pages)
121
+
122
+
123
+ @tool
124
+ def read_excel(path: str, sheet: str | None = None) -> str:
125
+ """Read an Excel workbook and return its contents as text.
126
+
127
+ Args:
128
+ path: Local path to .xlsx / .xls file.
129
+ sheet: Optional sheet name. Omit to dump every sheet.
130
+ """
131
+ import pandas as pd
132
+
133
+ if sheet:
134
+ return pd.read_excel(path, sheet_name=sheet).to_string()
135
+ sheets = pd.read_excel(path, sheet_name=None)
136
+ return "\n\n".join(
137
+ f"=== {name} ===\n{df.to_string()}" for name, df in sheets.items()
138
+ )
139
+
140
+
141
+ @tool
142
+ def read_csv(path: str) -> str:
143
+ """Read a CSV file and return its contents as text.
144
+
145
+ Args:
146
+ path: Local path to .csv file.
147
+ """
148
+ import pandas as pd
149
+
150
+ return pd.read_csv(path).to_string()
151
+
152
+
153
+ @tool
154
+ def python_exec(code: str) -> str:
155
+ """Execute Python code in a subprocess and return stdout (and stderr).
156
+
157
+ Use this for arithmetic on lists, date math, pandas manipulations, parsing,
158
+ or anything that is easier to *compute* than to reason about. You must
159
+ `print(...)` any value you want to see back.
160
+
161
+ Available: the standard library plus pandas, numpy (if installed in the env).
162
+
163
+ Args:
164
+ code: Python source to execute.
165
+ """
166
+ try:
167
+ result = subprocess.run(
168
+ [sys.executable, "-c", code],
169
+ capture_output=True,
170
+ text=True,
171
+ timeout=30,
172
+ )
173
+ out = result.stdout
174
+ if result.stderr:
175
+ out += f"\n[stderr]\n{result.stderr}"
176
+ return out.strip() or "(no output)"
177
+ except subprocess.TimeoutExpired:
178
+ return "(timeout after 30s)"
179
+
180
+
181
+ _CALC_ENV = {
182
+ name: getattr(math, name) for name in dir(math) if not name.startswith("_")
183
+ }
184
+ _CALC_ENV.update({"abs": abs, "round": round, "min": min, "max": max, "sum": sum})
185
+
186
+
187
+ @tool
188
+ def calculator(expression: str) -> str:
189
+ """Evaluate a math expression.
190
+
191
+ Supports +, -, *, /, //, %, **, parentheses, and the math module
192
+ (sin, cos, sqrt, log, pi, e, ...). No variables, no assignment.
193
+
194
+ Args:
195
+ expression: e.g. "2 * (3 + 4) ** 0.5" or "sqrt(2) + log(10)".
196
+ """
197
+ try:
198
+ return str(eval(expression, {"__builtins__": {}}, _CALC_ENV))
199
+ except Exception as e:
200
+ return f"Error: {e}"
201
+
202
+
203
+ _IMAGE_MIME = {
204
+ "jpg": "jpeg",
205
+ "jpeg": "jpeg",
206
+ "png": "png",
207
+ "gif": "gif",
208
+ "webp": "webp",
209
+ "bmp": "bmp",
210
+ }
211
+
212
+
213
+ @tool
214
+ def analyze_image(image_path_or_url: str, question: str) -> str:
215
+ """Answer a question about an image using a vision-language model.
216
+
217
+ Handles both local files and http(s) URLs. Use this for chess positions,
218
+ charts, screenshots, photos, diagrams, etc.
219
+
220
+ Args:
221
+ image_path_or_url: Local file path or HTTP(S) URL.
222
+ question: What you want to know about the image.
223
+ """
224
+ if image_path_or_url.startswith(("http://", "https://")):
225
+ image_url = image_path_or_url
226
+ else:
227
+ with open(image_path_or_url, "rb") as f:
228
+ b64 = base64.b64encode(f.read()).decode()
229
+ ext = image_path_or_url.rsplit(".", 1)[-1].lower()
230
+ mime = _IMAGE_MIME.get(ext, "jpeg")
231
+ image_url = f"data:image/{mime};base64,{b64}"
232
+
233
+ vlm = ChatGroq(
234
+ model="meta-llama/llama-4-scout-17b-16e-instruct",
235
+ temperature=0.1,
236
+ api_key=os.getenv("GROQ_API_KEY"),
237
+ )
238
+ msg = HumanMessage(
239
+ content=[
240
+ {"type": "text", "text": question},
241
+ {"type": "image_url", "image_url": {"url": image_url}},
242
+ ]
243
+ )
244
+ return vlm.invoke([msg]).content
245
+
246
+
247
+ @tool
248
+ def transcribe_audio(audio_path: str) -> str:
249
+ """Transcribe an audio file to text with Whisper (Groq).
250
+
251
+ Args:
252
+ audio_path: Local file path to the audio (mp3, wav, m4a, flac, ...).
253
+ """
254
+ from groq import Groq
255
+
256
+ client = Groq(api_key=os.getenv("GROQ_API_KEY"))
257
+ with open(audio_path, "rb") as f:
258
+ transcription = client.audio.transcriptions.create(
259
+ file=(os.path.basename(audio_path), f.read()),
260
+ model="whisper-large-v3-turbo",
261
+ )
262
+ return transcription.text
263
+
264
+
265
+ @tool
266
+ def analyze_video(url_or_path: str, question: str, num_frames: int = 5) -> str:
267
+ """Answer a question about a video by sampling frames and asking a VLM.
268
+
269
+ Downloads the video if a URL is given (YouTube supported), samples frames
270
+ evenly along its duration, and asks the vision model. Pair with
271
+ ``youtube_transcript`` for full audio+visual understanding.
272
+
273
+ Args:
274
+ url_or_path: YouTube URL, other video URL, or local file path.
275
+ question: What you want to know about the video.
276
+ num_frames: How many frames to sample (default 5, max 5).
277
+ """
278
+ from imageio_ffmpeg import get_ffmpeg_exe
279
+
280
+ num_frames = max(1, min(num_frames, 5))
281
+ tmpdir = Path(tempfile.mkdtemp())
282
+
283
+ if url_or_path.startswith(("http://", "https://")):
284
+ import yt_dlp
285
+
286
+ video_path = str(tmpdir / "video.mp4")
287
+ ydl_opts = {
288
+ "outtmpl": video_path,
289
+ "format": "worst[ext=mp4]/worst",
290
+ "quiet": True,
291
+ "no_warnings": True,
292
+ }
293
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
294
+ ydl.download([url_or_path])
295
+ else:
296
+ video_path = url_or_path
297
+
298
+ ffmpeg = get_ffmpeg_exe()
299
+ probe = subprocess.run([ffmpeg, "-i", video_path], capture_output=True, text=True)
300
+ dur_match = re.search(r"Duration: (\d+):(\d+):(\d+)", probe.stderr)
301
+ if dur_match:
302
+ h, m, s = map(int, dur_match.groups())
303
+ duration = max(1, h * 3600 + m * 60 + s)
304
+ else:
305
+ duration = 30
306
+
307
+ frames_b64 = []
308
+ for i in range(num_frames):
309
+ t = duration * (i + 0.5) / num_frames
310
+ frame_path = tmpdir / f"frame_{i}.jpg"
311
+ subprocess.run(
312
+ [
313
+ ffmpeg,
314
+ "-y",
315
+ "-ss",
316
+ str(t),
317
+ "-i",
318
+ video_path,
319
+ "-vframes",
320
+ "1",
321
+ "-q:v",
322
+ "3",
323
+ str(frame_path),
324
+ ],
325
+ capture_output=True,
326
+ )
327
+ if frame_path.exists():
328
+ b64 = base64.b64encode(frame_path.read_bytes()).decode()
329
+ frames_b64.append(f"data:image/jpeg;base64,{b64}")
330
+
331
+ if not frames_b64:
332
+ return "Failed to extract any frames from the video."
333
+
334
+ vlm = ChatGroq(
335
+ model="meta-llama/llama-4-scout-17b-16e-instruct",
336
+ temperature=0.1,
337
+ api_key=os.getenv("GROQ_API_KEY"),
338
+ )
339
+ content = [
340
+ {
341
+ "type": "text",
342
+ "text": f"Below are {len(frames_b64)} frames sampled evenly from a "
343
+ f"video (duration ~{duration}s). {question}",
344
+ }
345
+ ]
346
+ for url in frames_b64:
347
+ content.append({"type": "image_url", "image_url": {"url": url}})
348
+ return vlm.invoke([HumanMessage(content=content)]).content
349
+
350
+
351
+ @tool
352
+ def youtube_transcript(url_or_id: str) -> str:
353
+ """Fetch the transcript of a YouTube video.
354
+
355
+ Falls back gracefully when captions are unavailable.
356
+
357
+ Args:
358
+ url_or_id: Full YouTube URL or the 11-char video id.
359
+ """
360
+ from youtube_transcript_api import YouTubeTranscriptApi
361
+
362
+ m = re.search(r"(?:v=|youtu\.be/|/shorts/|/embed/)([0-9A-Za-z_-]{11})", url_or_id)
363
+ video_id = m.group(1) if m else url_or_id
364
+ try:
365
+ entries = YouTubeTranscriptApi.get_transcript(video_id)
366
+ except Exception as e:
367
+ return f"No transcript available for {video_id}: {e}"
368
+ return " ".join(e["text"] for e in entries)
369
+
370
+
371
  research_agent = ResearchAgent()
372
 
373
 
 
384
  Prize in Physics?')
385
  """
386
  return research_agent(request)
387
+
388
+
389
+ ALL_TOOLS = [
390
+ search_tool,
391
+ fetch_url,
392
+ wikipedia_lookup,
393
+ download_task_file,
394
+ read_pdf,
395
+ read_excel,
396
+ read_csv,
397
+ python_exec,
398
+ calculator,
399
+ analyze_image,
400
+ analyze_video,
401
+ transcribe_audio,
402
+ youtube_transcript,
403
+ search_tool,
404
+ ]