Melatonini commited on
Commit
3894cc5
·
1 Parent(s): 81917a3

Implement GAIA agent with multimodal tools for Unit 4 submission.

Browse files

Add SmolAgents CodeAgent with web, Wikipedia, file, audio, vision, and YouTube tools; wire attachment downloads via GAIA fallback; ignore local dev artifacts.

Files changed (6) hide show
  1. .gitignore +14 -0
  2. agent.py +165 -0
  3. app.py +18 -14
  4. files.py +134 -0
  5. requirements.txt +10 -2
  6. tools.py +182 -0
.gitignore ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+
6
+ # Local dev artifacts
7
+ task_files/
8
+ eval_output.txt
9
+ .env
10
+ .env.local
11
+
12
+ # Dev-only scripts (use locally; not needed on the Space)
13
+ run_eval_local.py
14
+ test_one_question.py
agent.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GAIA assignment agent — Wikipedia, web, files, audio, vision, YouTube."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+
9
+ from smolagents import CodeAgent, InferenceClientModel, VisitWebpageTool, WebSearchTool, WikipediaSearchTool
10
+
11
+ from files import file_context_block
12
+ from tools import build_custom_tools
13
+
14
+ ANSWER_INSTRUCTIONS = """
15
+ You are a precise GAIA benchmark agent. Solve the question using tools when needed.
16
+
17
+ Available capabilities:
18
+ - wikipedia_search: English Wikipedia articles
19
+ - web_search: DuckDuckGo web search
20
+ - visit_webpage: Read a specific URL as markdown
21
+ - read_spreadsheet: Parse Excel/CSV attachments
22
+ - execute_python_file: Run an attached .py file
23
+ - transcribe_audio: Speech-to-text for .mp3 attachments
24
+ - analyze_image: Vision Q&A for image attachments
25
+ - get_youtube_transcript: Captions from YouTube URLs in the question
26
+
27
+ Strategy:
28
+ 1. Read the question carefully — note required OUTPUT FORMAT (number, list, names only, etc.).
29
+ 2. If an attached file path is provided, use the matching file tool first.
30
+ 3. For YouTube links in the question, use get_youtube_transcript.
31
+ 4. Prefer wikipedia_search for Wikipedia-specific questions; otherwise web_search + visit_webpage.
32
+ 5. For logic/reversal/math in the question text, reason directly — no tool needed.
33
+ 6. Call final_answer(...) as soon as you are confident.
34
+
35
+ final_answer rules (CRITICAL — exact match grading):
36
+ - Return ONLY the answer string — no explanation, no preamble.
37
+ - Do NOT write "FINAL ANSWER" or "The answer is".
38
+ - Follow the question's format exactly (comma-separated, two decimals, last names only, etc.).
39
+ - Use the minimal text the question asks for.
40
+ """
41
+
42
+
43
+ def maybe_unreverse(question: str) -> str:
44
+ """Un-reverse GAIA-style reversed English prompts."""
45
+ stripped = question.strip()
46
+ if not stripped:
47
+ return question
48
+ reversed_q = stripped[::-1]
49
+ starters = ("if you", "what ", "how ", "who ", "when ", "where ", "which ", "give ")
50
+ if reversed_q.lower().startswith(starters):
51
+ return reversed_q
52
+ return question
53
+
54
+
55
+ def normalize_answer(text) -> str:
56
+ """Strip common LLM formatting so exact-match grading has a better chance."""
57
+ if text is None:
58
+ return ""
59
+
60
+ answer = str(text).strip()
61
+ if answer.lower() in ("none", "null", "n/a", "unknown"):
62
+ return ""
63
+
64
+ # CodeAgent sometimes returns final_answer(...) as text instead of executing it.
65
+ final_call = re.search(
66
+ r"final_answer\s*\(\s*(['\"])(.*?)\1\s*\)",
67
+ answer,
68
+ flags=re.IGNORECASE | re.DOTALL,
69
+ )
70
+ if final_call:
71
+ answer = final_call.group(2).strip()
72
+ else:
73
+ final_call = re.search(
74
+ r"final_answer\s*\(\s*([^)]+)\s*\)",
75
+ answer,
76
+ flags=re.IGNORECASE,
77
+ )
78
+ if final_call:
79
+ answer = final_call.group(1).strip().strip("'\"")
80
+
81
+ # Drop leading "Thought:" blocks if a cleaner tail exists.
82
+ if "Thought:" in answer and "\n" in answer:
83
+ lines = [line.strip() for line in answer.splitlines() if line.strip()]
84
+ if lines:
85
+ answer = lines[-1]
86
+
87
+ answer = re.sub(r"^```(?:\w+)?\s*|\s*```$", "", answer, flags=re.MULTILINE).strip()
88
+
89
+ for prefix in (
90
+ "FINAL ANSWER:",
91
+ "Final answer:",
92
+ "Final_answer",
93
+ "FINAL_ANSWER",
94
+ "Answer:",
95
+ "The answer is:",
96
+ "The answer is",
97
+ ):
98
+ if answer.lower().startswith(prefix.lower()):
99
+ answer = answer[len(prefix) :].strip(" :")
100
+ break
101
+
102
+ if len(answer) >= 2 and answer[0] == answer[-1] and answer[0] in "\"'":
103
+ answer = answer[1:-1].strip()
104
+
105
+ # Reject obvious failure messages.
106
+ lowered = answer.lower()
107
+ if lowered.startswith("unfortunately") or lowered.startswith("i was unable"):
108
+ return ""
109
+
110
+ return answer
111
+
112
+
113
+ class BasicAgent:
114
+ """Full GAIA agent with search, web, and multimodal file tools."""
115
+
116
+ def __init__(self):
117
+ model_id = os.getenv("HF_MODEL_ID", "Qwen/Qwen2.5-7B-Instruct")
118
+ token = os.getenv("HF_TOKEN")
119
+
120
+ model = InferenceClientModel(model_id=model_id, token=token)
121
+ wiki_tool = WikipediaSearchTool(
122
+ user_agent="HF-Agents-Course-Student (https://huggingface.co/agents-course)",
123
+ language="en",
124
+ content_type="text",
125
+ )
126
+
127
+ toolset = [
128
+ wiki_tool,
129
+ WebSearchTool(max_results=8),
130
+ VisitWebpageTool(max_output_length=30000),
131
+ *build_custom_tools(),
132
+ ]
133
+
134
+ self.agent = CodeAgent(
135
+ tools=toolset,
136
+ model=model,
137
+ max_steps=int(os.getenv("AGENT_MAX_STEPS", "25")),
138
+ verbosity_level=int(os.getenv("AGENT_VERBOSITY", "0")),
139
+ additional_authorized_imports=["re", "json", "math"],
140
+ )
141
+ tool_names = [getattr(t, "name", str(t)) for t in toolset]
142
+ print(f"BasicAgent initialized (model={model_id}, tools={tool_names}).")
143
+
144
+ def __call__(
145
+ self,
146
+ question: str,
147
+ task_id: str | None = None,
148
+ file_path: str | Path | None = None,
149
+ ) -> str:
150
+ print(f"Agent received question (first 50 chars): {question[:50]}...")
151
+ if file_path:
152
+ print(f"Attached file: {file_path}")
153
+
154
+ question = maybe_unreverse(question)
155
+ path = Path(file_path) if file_path else None
156
+ task = f"{ANSWER_INSTRUCTIONS.strip()}\n\nQuestion:\n{question}"
157
+ task += file_context_block(path)
158
+
159
+ raw = self.agent.run(task)
160
+ answer = normalize_answer(raw)
161
+ if not answer:
162
+ print("Agent did not produce a final answer (max steps or empty result).")
163
+ answer = "UNKNOWN"
164
+ print(f"Agent returning answer: {answer[:120]}...")
165
+ return answer
app.py CHANGED
@@ -1,24 +1,16 @@
1
  import os
 
2
  import gradio as gr
3
- import requests
4
- import inspect
5
  import pandas as pd
 
 
 
 
6
 
7
  # (Keep Constants as is)
8
  # --- Constants ---
9
  DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
10
 
11
- # --- Basic Agent Definition ---
12
- # ----- THIS IS WERE YOU CAN BUILD WHAT YOU WANT ------
13
- class BasicAgent:
14
- def __init__(self):
15
- print("BasicAgent initialized.")
16
- def __call__(self, question: str) -> str:
17
- print(f"Agent received question (first 50 chars): {question[:50]}...")
18
- fixed_answer = "This is a default answer."
19
- print(f"Agent returning fixed answer: {fixed_answer}")
20
- return fixed_answer
21
-
22
  def run_and_submit_all( profile: gr.OAuthProfile | None):
23
  """
24
  Fetches all questions, runs the BasicAgent on them, submits all answers,
@@ -76,11 +68,23 @@ def run_and_submit_all( profile: gr.OAuthProfile | None):
76
  for item in questions_data:
77
  task_id = item.get("task_id")
78
  question_text = item.get("question")
 
79
  if not task_id or question_text is None:
80
  print(f"Skipping item with missing task_id or question: {item}")
81
  continue
 
 
 
 
 
 
 
82
  try:
83
- submitted_answer = agent(question_text)
 
 
 
 
84
  answers_payload.append({"task_id": task_id, "submitted_answer": submitted_answer})
85
  results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": submitted_answer})
86
  except Exception as e:
 
1
  import os
2
+
3
  import gradio as gr
 
 
4
  import pandas as pd
5
+ import requests
6
+
7
+ from agent import BasicAgent
8
+ from files import download_task_file
9
 
10
  # (Keep Constants as is)
11
  # --- Constants ---
12
  DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
13
 
 
 
 
 
 
 
 
 
 
 
 
14
  def run_and_submit_all( profile: gr.OAuthProfile | None):
15
  """
16
  Fetches all questions, runs the BasicAgent on them, submits all answers,
 
68
  for item in questions_data:
69
  task_id = item.get("task_id")
70
  question_text = item.get("question")
71
+ file_name = item.get("file_name") or ""
72
  if not task_id or question_text is None:
73
  print(f"Skipping item with missing task_id or question: {item}")
74
  continue
75
+ file_path = None
76
+ if file_name:
77
+ try:
78
+ file_path = download_task_file(task_id, file_name, api_url=api_url)
79
+ print(f"Downloaded attachment for {task_id}: {file_path}")
80
+ except Exception as e:
81
+ print(f"Error downloading file for task {task_id}: {e}")
82
  try:
83
+ submitted_answer = agent(
84
+ question_text,
85
+ task_id=task_id,
86
+ file_path=file_path,
87
+ )
88
  answers_payload.append({"task_id": task_id, "submitted_answer": submitted_answer})
89
  results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": submitted_answer})
90
  except Exception as e:
files.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Download and manage GAIA task attachments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ from functools import lru_cache
8
+ from pathlib import Path
9
+
10
+ import requests
11
+ from huggingface_hub import hf_hub_download
12
+ from huggingface_hub.errors import GatedRepoError, HfHubHTTPError
13
+
14
+ DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
15
+ GAIA_REPO = "gaia-benchmark/GAIA"
16
+ ATTACHMENTS_DIR = Path(os.getenv("GAIA_ATTACHMENTS_DIR", "task_files"))
17
+
18
+
19
+ def ensure_attachments_dir() -> Path:
20
+ ATTACHMENTS_DIR.mkdir(parents=True, exist_ok=True)
21
+ return ATTACHMENTS_DIR
22
+
23
+
24
+ def _copy_to_workspace(source: str | Path, file_name: str) -> Path:
25
+ dest_dir = ensure_attachments_dir()
26
+ dest_path = dest_dir / file_name
27
+ if Path(source).resolve() == dest_path.resolve():
28
+ return dest_path
29
+ shutil.copy2(source, dest_path)
30
+ return dest_path
31
+
32
+
33
+ def _download_from_scoring_api(task_id: str, file_name: str, api_url: str) -> Path | None:
34
+ url = f"{api_url.rstrip('/')}/files/{task_id}"
35
+ response = requests.get(url, timeout=60)
36
+ if response.status_code == 404:
37
+ return None
38
+ response.raise_for_status()
39
+ dest_path = ensure_attachments_dir() / file_name
40
+ dest_path.write_bytes(response.content)
41
+ return dest_path
42
+
43
+
44
+ @lru_cache(maxsize=256)
45
+ def _gaia_relative_candidates(file_name: str) -> tuple[str, ...]:
46
+ """Common GAIA repo-relative paths for a given attachment name."""
47
+ return tuple(
48
+ {
49
+ f"2023/validation/{file_name}",
50
+ f"2023/test/{file_name}",
51
+ f"2023/level1/{file_name}",
52
+ file_name,
53
+ }
54
+ )
55
+
56
+
57
+ def _download_from_gaia(file_name: str) -> Path | None:
58
+ token = os.getenv("HF_TOKEN")
59
+ last_error: Exception | None = None
60
+
61
+ for relative_path in _gaia_relative_candidates(file_name):
62
+ try:
63
+ cached = hf_hub_download(
64
+ repo_id=GAIA_REPO,
65
+ filename=relative_path,
66
+ repo_type="dataset",
67
+ token=token,
68
+ )
69
+ return _copy_to_workspace(cached, file_name)
70
+ except GatedRepoError as exc:
71
+ raise RuntimeError(
72
+ "GAIA dataset access required for file attachments. "
73
+ "Accept the terms at https://huggingface.co/datasets/gaia-benchmark/GAIA "
74
+ "and ensure HF_TOKEN is set on your Space."
75
+ ) from exc
76
+ except HfHubHTTPError as exc:
77
+ last_error = exc
78
+ continue
79
+
80
+ if last_error:
81
+ print(f"GAIA download failed for {file_name}: {last_error}")
82
+ return None
83
+
84
+
85
+ def download_task_file(
86
+ task_id: str,
87
+ file_name: str,
88
+ api_url: str = DEFAULT_API_URL,
89
+ ) -> Path:
90
+ """Download a task attachment via scoring API or GAIA dataset fallback."""
91
+ if not file_name:
92
+ raise ValueError("file_name is required")
93
+
94
+ dest_path = ensure_attachments_dir() / file_name
95
+ if dest_path.exists() and dest_path.stat().st_size > 0:
96
+ return dest_path
97
+
98
+ from_api = _download_from_scoring_api(task_id, file_name, api_url)
99
+ if from_api is not None:
100
+ return from_api
101
+
102
+ from_gaia = _download_from_gaia(file_name)
103
+ if from_gaia is not None:
104
+ return from_gaia
105
+
106
+ raise FileNotFoundError(
107
+ f"Could not download attachment '{file_name}' for task {task_id}. "
108
+ "Scoring API returned 404; GAIA fallback also failed."
109
+ )
110
+
111
+
112
+ def file_context_block(file_path: Path | None) -> str:
113
+ if file_path is None:
114
+ return ""
115
+ suffix = file_path.suffix.lower()
116
+ hints = {
117
+ ".png": "PNG image — use analyze_image.",
118
+ ".jpg": "JPEG image — use analyze_image.",
119
+ ".jpeg": "JPEG image — use analyze_image.",
120
+ ".webp": "WebP image — use analyze_image.",
121
+ ".mp3": "MP3 audio — use transcribe_audio.",
122
+ ".wav": "WAV audio — use transcribe_audio.",
123
+ ".xlsx": "Excel spreadsheet — use read_spreadsheet.",
124
+ ".xls": "Excel spreadsheet — use read_spreadsheet.",
125
+ ".csv": "CSV file — use read_spreadsheet.",
126
+ ".py": "Python script — use execute_python_file.",
127
+ }
128
+ hint = hints.get(suffix, "Use the appropriate file tool.")
129
+ return (
130
+ f"\n\nAttached file:\n"
131
+ f"- path: {file_path.resolve()}\n"
132
+ f"- name: {file_path.name}\n"
133
+ f"- hint: {hint}\n"
134
+ )
requirements.txt CHANGED
@@ -1,2 +1,10 @@
1
- gradio
2
- requests
 
 
 
 
 
 
 
 
 
1
+ gradio[oauth]
2
+ requests
3
+ pandas
4
+ smolagents
5
+ wikipedia-api
6
+ markdownify
7
+ openpyxl
8
+ youtube-transcript-api
9
+ Pillow
10
+ datasets
tools.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Custom smolagents tools for GAIA multimodal and file tasks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import os
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import pandas as pd
13
+ from huggingface_hub import InferenceClient
14
+ from smolagents import tool
15
+ from youtube_transcript_api import YouTubeTranscriptApi
16
+
17
+
18
+ def _hf_client() -> InferenceClient:
19
+ token = os.getenv("HF_TOKEN")
20
+ return InferenceClient(token=token)
21
+
22
+
23
+ def _vision_model() -> str:
24
+ return os.getenv("HF_VISION_MODEL", "Qwen/Qwen2-VL-7B-Instruct")
25
+
26
+
27
+ def _asr_model() -> str:
28
+ return os.getenv("HF_ASR_MODEL", "openai/whisper-large-v3")
29
+
30
+
31
+ @tool
32
+ def read_spreadsheet(file_path: str) -> str:
33
+ """Read an Excel or CSV file and return its contents as text for analysis.
34
+
35
+ Args:
36
+ file_path: Absolute or relative path to .xlsx, .xls, or .csv file.
37
+ """
38
+ path = Path(file_path)
39
+ if not path.exists():
40
+ return f"File not found: {file_path}"
41
+
42
+ suffix = path.suffix.lower()
43
+ if suffix == ".csv":
44
+ df = pd.read_csv(path)
45
+ elif suffix in {".xlsx", ".xls"}:
46
+ df = pd.read_excel(path)
47
+ else:
48
+ return f"Unsupported spreadsheet type: {suffix}"
49
+
50
+ buffer = []
51
+ buffer.append(f"Shape: {df.shape[0]} rows x {df.shape[1]} columns")
52
+ buffer.append(f"Columns: {', '.join(str(c) for c in df.columns)}")
53
+ buffer.append("\n--- data ---")
54
+ buffer.append(df.to_string(index=False))
55
+ text = "\n".join(buffer)
56
+ return text[:50000]
57
+
58
+
59
+ @tool
60
+ def execute_python_file(file_path: str) -> str:
61
+ """Execute a Python file in a subprocess and return stdout/stderr.
62
+
63
+ Args:
64
+ file_path: Path to a .py file to run.
65
+ """
66
+ path = Path(file_path)
67
+ if not path.exists():
68
+ return f"File not found: {file_path}"
69
+ if path.suffix.lower() != ".py":
70
+ return f"Not a Python file: {file_path}"
71
+
72
+ try:
73
+ completed = subprocess.run(
74
+ [sys.executable, str(path.resolve())],
75
+ capture_output=True,
76
+ text=True,
77
+ timeout=45,
78
+ cwd=str(path.parent.resolve()),
79
+ )
80
+ except subprocess.TimeoutExpired:
81
+ return "Execution timed out after 45 seconds."
82
+
83
+ parts = []
84
+ if completed.stdout:
85
+ parts.append(f"STDOUT:\n{completed.stdout}")
86
+ if completed.stderr:
87
+ parts.append(f"STDERR:\n{completed.stderr}")
88
+ parts.append(f"Exit code: {completed.returncode}")
89
+ return "\n".join(parts)[:20000]
90
+
91
+
92
+ @tool
93
+ def transcribe_audio(file_path: str) -> str:
94
+ """Transcribe speech from an audio file (mp3/wav) to text.
95
+
96
+ Args:
97
+ file_path: Path to the audio file.
98
+ """
99
+ path = Path(file_path)
100
+ if not path.exists():
101
+ return f"File not found: {file_path}"
102
+
103
+ client = _hf_client()
104
+ with path.open("rb") as audio_file:
105
+ result = client.automatic_speech_recognition(
106
+ audio=audio_file.read(),
107
+ model=_asr_model(),
108
+ )
109
+
110
+ if isinstance(result, dict):
111
+ return str(result.get("text", result))[:20000]
112
+ return str(getattr(result, "text", result))[:20000]
113
+
114
+
115
+ @tool
116
+ def analyze_image(file_path: str, question: str) -> str:
117
+ """Analyze an image file to answer a specific question about it.
118
+
119
+ Args:
120
+ file_path: Path to png/jpg/jpeg/webp image.
121
+ question: What to determine from the image.
122
+ """
123
+ path = Path(file_path)
124
+ if not path.exists():
125
+ return f"File not found: {file_path}"
126
+
127
+ mime = {
128
+ ".png": "image/png",
129
+ ".jpg": "image/jpeg",
130
+ ".jpeg": "image/jpeg",
131
+ ".webp": "image/webp",
132
+ }.get(path.suffix.lower(), "image/png")
133
+
134
+ encoded = base64.b64encode(path.read_bytes()).decode("ascii")
135
+ client = _hf_client()
136
+ response = client.chat_completion(
137
+ model=_vision_model(),
138
+ messages=[
139
+ {
140
+ "role": "user",
141
+ "content": [
142
+ {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{encoded}"}},
143
+ {"type": "text", "text": question},
144
+ ],
145
+ }
146
+ ],
147
+ max_tokens=1024,
148
+ )
149
+ return response.choices[0].message.content.strip()[:10000]
150
+
151
+
152
+ @tool
153
+ def get_youtube_transcript(video_url: str) -> str:
154
+ """Fetch the transcript/captions of a YouTube video.
155
+
156
+ Args:
157
+ video_url: Full YouTube URL or 11-character video ID.
158
+ """
159
+ match = re.search(
160
+ r"(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/embed/)([A-Za-z0-9_-]{11})",
161
+ video_url,
162
+ )
163
+ video_id = match.group(1) if match else video_url.strip()
164
+
165
+ try:
166
+ api = YouTubeTranscriptApi()
167
+ fetched = api.fetch(video_id)
168
+ lines = [snippet.text for snippet in fetched.snippets]
169
+ except Exception as exc:
170
+ return f"Could not fetch transcript: {exc}"
171
+
172
+ return " ".join(lines)[:30000]
173
+
174
+
175
+ def build_custom_tools() -> list:
176
+ return [
177
+ read_spreadsheet,
178
+ execute_python_file,
179
+ transcribe_audio,
180
+ analyze_image,
181
+ get_youtube_transcript,
182
+ ]