Miladsaeedi70 commited on
Commit
54b321b
·
1 Parent(s): 316700e

Merge GPT-4.1 GAIA agent with ZeroGPU support

Browse files
Files changed (5) hide show
  1. README.md +31 -7
  2. agent.py +1546 -0
  3. app.py +306 -151
  4. packages.txt +2 -0
  5. requirements.txt +21 -2
README.md CHANGED
@@ -1,15 +1,39 @@
1
  ---
2
- title: Template Final Assignment
3
- emoji: 🕵🏻‍♂️
4
- colorFrom: indigo
5
  colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 5.25.2
8
  app_file: app.py
9
  pinned: false
 
10
  hf_oauth: true
11
- # optional, default duration is 8 hours/480 minutes. Max duration is 30 days/43200 minutes.
12
- hf_oauth_expiration_minutes: 480
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: GAIA Final Assignment Agent
3
+ emoji: 🤖
4
+ colorFrom: blue
5
  colorTo: indigo
6
  sdk: gradio
7
+ sdk_version: 5.49.1
8
  app_file: app.py
9
  pinned: false
10
+ python_version: 3.11
11
  hf_oauth: true
 
 
12
  ---
13
 
14
+ # GAIA Final Assignment Agent
15
+
16
+ LangGraph-based multimodal agent for the Hugging Face Agents Course final assignment.
17
+
18
+ ## Final graded model configuration
19
+
20
+ - `TEXT_MODEL=gpt-4.1`
21
+ - `VISION_MODEL=gpt-4.1`
22
+ - `AUDIO_MODEL=gpt-4o-mini-transcribe`
23
+
24
+ ## Required Space secret
25
+
26
+ - `OPENAI_API_KEY`
27
+
28
+ ## Recommended Space variables
29
+
30
+ - `SPACE_OWNER=Miladsaeedi70`
31
+ - `TEXT_MODEL=gpt-4.1`
32
+ - `VISION_MODEL=gpt-4.1`
33
+ - `AUDIO_MODEL=gpt-4o-mini-transcribe`
34
+ - `OPENAI_TIMEOUT=240`
35
+ - `OPENAI_MAX_RETRIES=3`
36
+ - `VIDEO_MAX_FRAMES=24`
37
+ - `VIDEO_BATCH_SIZE=8`
38
+
39
+ Keep the Space public so the submitted code link can be verified. Store the OpenAI API key only as a Hugging Face Space secret, never in a source file.
agent.py ADDED
@@ -0,0 +1,1546 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import base64
5
+ import json
6
+ import math
7
+ import operator
8
+ import os
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ from io import BytesIO
15
+ from pathlib import Path
16
+ from typing import Annotated, Literal
17
+ from urllib.parse import parse_qs, urlparse
18
+
19
+ import chess
20
+ import chess.engine
21
+ import cv2
22
+ import pandas as pd
23
+ import requests
24
+ import yt_dlp
25
+ from bs4 import BeautifulSoup
26
+ from ddgs import DDGS
27
+ from langchain_core.messages import (
28
+ AIMessage,
29
+ AnyMessage,
30
+ HumanMessage,
31
+ SystemMessage,
32
+ ToolMessage,
33
+ )
34
+ from langchain_core.tools import tool
35
+ from langchain_openai import ChatOpenAI
36
+ from langgraph.graph import END, START, StateGraph
37
+ from langgraph.graph.message import add_messages
38
+ from langgraph.prebuilt import ToolNode
39
+ from openai import OpenAI, RateLimitError
40
+ from PIL import Image as PILImage
41
+ from PIL import ImageOps
42
+ from pypdf import PdfReader
43
+ from typing_extensions import NotRequired, TypedDict
44
+ from youtube_transcript_api import YouTubeTranscriptApi
45
+
46
+
47
+ # -----------------------------------------------------------------------------
48
+ # Model configuration
49
+ # -----------------------------------------------------------------------------
50
+
51
+ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "").strip()
52
+
53
+ if not OPENAI_API_KEY:
54
+ raise RuntimeError(
55
+ "OPENAI_API_KEY is missing. Add it under the Hugging Face "
56
+ "Space Settings > Variables and secrets > Secrets."
57
+ )
58
+
59
+ TEXT_MODEL = os.getenv("TEXT_MODEL", "gpt-4.1").strip()
60
+ VISION_MODEL = os.getenv("VISION_MODEL", "gpt-4.1").strip()
61
+ AUDIO_MODEL = os.getenv("AUDIO_MODEL", "gpt-4o-mini-transcribe").strip()
62
+ OPENAI_TIMEOUT = float(os.getenv("OPENAI_TIMEOUT", "240"))
63
+ OPENAI_MAX_RETRIES = int(os.getenv("OPENAI_MAX_RETRIES", "3"))
64
+
65
+ llm = ChatOpenAI(
66
+ model=TEXT_MODEL,
67
+ api_key=OPENAI_API_KEY,
68
+ temperature=0,
69
+ max_tokens=1200,
70
+ timeout=OPENAI_TIMEOUT,
71
+ max_retries=OPENAI_MAX_RETRIES,
72
+ )
73
+
74
+ vision_llm = ChatOpenAI(
75
+ model=VISION_MODEL,
76
+ api_key=OPENAI_API_KEY,
77
+ temperature=0,
78
+ max_tokens=1400,
79
+ timeout=OPENAI_TIMEOUT,
80
+ max_retries=OPENAI_MAX_RETRIES,
81
+ )
82
+
83
+ vision_llm_chess = vision_llm
84
+
85
+ openai_client = OpenAI(
86
+ api_key=OPENAI_API_KEY,
87
+ timeout=OPENAI_TIMEOUT,
88
+ max_retries=OPENAI_MAX_RETRIES,
89
+ )
90
+
91
+ print(
92
+ "OpenAI models configured:",
93
+ {
94
+ "text": TEXT_MODEL,
95
+ "vision": VISION_MODEL,
96
+ "audio": AUDIO_MODEL,
97
+ },
98
+ )
99
+
100
+
101
+ # -----------------------------------------------------------------------------
102
+ # General tools
103
+ # -----------------------------------------------------------------------------
104
+
105
+ @tool("web_search")
106
+ def web_search_tool(query: str) -> str:
107
+ """Search the public web and return concise titles, URLs, and snippets."""
108
+
109
+ query = query.strip()
110
+ if not query:
111
+ return "ERROR: Search query is empty."
112
+
113
+ try:
114
+ raw_results = list(
115
+ DDGS().text(
116
+ query,
117
+ max_results=4,
118
+ )
119
+ )
120
+
121
+ results = []
122
+ for item in raw_results:
123
+ if not isinstance(item, dict):
124
+ continue
125
+
126
+ title = str(item.get("title", "")).strip()
127
+ url = str(
128
+ item.get("href")
129
+ or item.get("url")
130
+ or ""
131
+ ).strip()
132
+ snippet = str(
133
+ item.get("body")
134
+ or item.get("snippet")
135
+ or ""
136
+ ).strip()
137
+
138
+ if title or url or snippet:
139
+ results.append(
140
+ {
141
+ "title": title,
142
+ "url": url,
143
+ "snippet": snippet,
144
+ }
145
+ )
146
+
147
+ if not results:
148
+ return "ERROR: Web search returned no results."
149
+
150
+ return json.dumps(
151
+ results,
152
+ ensure_ascii=False,
153
+ )
154
+
155
+ except Exception as error:
156
+ return (
157
+ "ERROR: Web search failed: "
158
+ f"{type(error).__name__}: {error}"
159
+ )
160
+
161
+
162
+ @tool("read_webpage")
163
+ def read_webpage(url: str) -> str:
164
+ """Read visible text from a public webpage."""
165
+
166
+ if not url.startswith(("http://", "https://")):
167
+ return "ERROR: URL must begin with http:// or https://."
168
+
169
+ try:
170
+ response = requests.get(
171
+ url,
172
+ timeout=30,
173
+ headers={
174
+ "User-Agent": (
175
+ "Mozilla/5.0 (compatible; GAIAResearchAgent/1.0)"
176
+ )
177
+ },
178
+ )
179
+ response.raise_for_status()
180
+
181
+ soup = BeautifulSoup(response.text, "html.parser")
182
+
183
+ for element in soup(
184
+ ["script", "style", "nav", "footer", "header", "noscript", "svg"]
185
+ ):
186
+ element.decompose()
187
+
188
+ lines = [
189
+ line.strip()
190
+ for line in soup.get_text(separator="\n", strip=True).splitlines()
191
+ if line.strip()
192
+ ]
193
+
194
+ # Remove only consecutive duplicate lines. Global de-duplication can
195
+ # destroy repeated rows in tables.
196
+ cleaned_lines: list[str] = []
197
+ for line in lines:
198
+ if not cleaned_lines or line != cleaned_lines[-1]:
199
+ cleaned_lines.append(line)
200
+
201
+ cleaned_text = "\n".join(cleaned_lines)
202
+ normalized = cleaned_text.lower()
203
+
204
+ blocked_phrases = (
205
+ "checking your browser",
206
+ "access denied",
207
+ "enable javascript",
208
+ "captcha",
209
+ )
210
+
211
+ if len(cleaned_text) < 100 or any(
212
+ phrase in normalized for phrase in blocked_phrases
213
+ ):
214
+ return "ERROR: The webpage was blocked or contained no usable text."
215
+
216
+ return cleaned_text[:9000]
217
+
218
+ except requests.RequestException as error:
219
+ return f"ERROR: Could not read webpage: {type(error).__name__}: {error}"
220
+
221
+
222
+ WIKIPEDIA_API_URL = "https://en.wikipedia.org/w/api.php"
223
+ WIKIPEDIA_HEADERS = {
224
+ "User-Agent": "GAIA-LangGraph-Agent/1.0 (educational benchmark project)"
225
+ }
226
+
227
+
228
+ @tool("wikipedia_search")
229
+ def wikipedia_search(
230
+ query: str,
231
+ as_of_date: str = "2022-12-31",
232
+ ) -> str:
233
+ """
234
+ Search English Wikipedia and return the best page's content from the
235
+ latest revision on or before as_of_date.
236
+ """
237
+
238
+ try:
239
+ search_response = requests.get(
240
+ WIKIPEDIA_API_URL,
241
+ params={
242
+ "action": "query",
243
+ "list": "search",
244
+ "srsearch": query,
245
+ "srlimit": 5,
246
+ "format": "json",
247
+ "formatversion": 2,
248
+ },
249
+ headers=WIKIPEDIA_HEADERS,
250
+ timeout=30,
251
+ )
252
+ search_response.raise_for_status()
253
+ results = search_response.json().get("query", {}).get("search", [])
254
+
255
+ if not results:
256
+ return "ERROR: No English Wikipedia page matched the query."
257
+
258
+ query_words = set(re.findall(r"[a-z0-9]+", query.lower()))
259
+
260
+ def score(item: dict) -> tuple[int, int]:
261
+ title = str(item.get("title", ""))
262
+ title_words = set(re.findall(r"[a-z0-9]+", title.lower()))
263
+ exact = int(title.lower() == query.lower().strip())
264
+ overlap = len(query_words & title_words)
265
+ return exact, overlap
266
+
267
+ page_title = max(results, key=score)["title"]
268
+
269
+ revision_response = requests.get(
270
+ WIKIPEDIA_API_URL,
271
+ params={
272
+ "action": "query",
273
+ "prop": "revisions",
274
+ "titles": page_title,
275
+ "rvstart": f"{as_of_date}T23:59:59Z",
276
+ "rvdir": "older",
277
+ "rvlimit": 1,
278
+ "rvprop": "ids|timestamp",
279
+ "format": "json",
280
+ "formatversion": 2,
281
+ },
282
+ headers=WIKIPEDIA_HEADERS,
283
+ timeout=30,
284
+ )
285
+ revision_response.raise_for_status()
286
+ pages = revision_response.json().get("query", {}).get("pages", [])
287
+ revisions = pages[0].get("revisions", []) if pages else []
288
+
289
+ if not revisions:
290
+ return f"ERROR: No revision was found on or before {as_of_date}."
291
+
292
+ revision_id = revisions[0]["revid"]
293
+ revision_timestamp = revisions[0]["timestamp"]
294
+
295
+ page_response = requests.get(
296
+ WIKIPEDIA_API_URL,
297
+ params={
298
+ "action": "parse",
299
+ "oldid": revision_id,
300
+ "prop": "text",
301
+ "format": "json",
302
+ "formatversion": 2,
303
+ },
304
+ headers=WIKIPEDIA_HEADERS,
305
+ timeout=30,
306
+ )
307
+ page_response.raise_for_status()
308
+ html = page_response.json().get("parse", {}).get("text", "")
309
+
310
+ if not html:
311
+ return "ERROR: Wikipedia returned no page content."
312
+
313
+ soup = BeautifulSoup(html, "html.parser")
314
+ for element in soup.select(
315
+ "script, style, sup.reference, .mw-editsection, .navbox, "
316
+ ".vertical-navbox, .metadata"
317
+ ):
318
+ element.decompose()
319
+
320
+ blocks: list[str] = []
321
+ for element in soup.select("h2, h3, h4, p, li, tr"):
322
+ text = " ".join(element.stripped_strings)
323
+ if text:
324
+ blocks.append(text)
325
+
326
+ return json.dumps(
327
+ {
328
+ "title": page_title,
329
+ "revision_timestamp": revision_timestamp,
330
+ "content": "\n".join(blocks)[:18000],
331
+ },
332
+ ensure_ascii=False,
333
+ )
334
+
335
+ except Exception as error:
336
+ return f"ERROR: Wikipedia lookup failed: {type(error).__name__}: {error}"
337
+
338
+
339
+ BINARY_OPERATORS = {
340
+ ast.Add: operator.add,
341
+ ast.Sub: operator.sub,
342
+ ast.Mult: operator.mul,
343
+ ast.Div: operator.truediv,
344
+ ast.Pow: operator.pow,
345
+ ast.Mod: operator.mod,
346
+ }
347
+ UNARY_OPERATORS = {ast.UAdd: operator.pos, ast.USub: operator.neg}
348
+
349
+
350
+ def _evaluate_math_node(node):
351
+ if isinstance(node, ast.Expression):
352
+ return _evaluate_math_node(node.body)
353
+ if isinstance(node, ast.Constant):
354
+ if not isinstance(node.value, (int, float)):
355
+ raise ValueError("Only numbers are allowed.")
356
+ return node.value
357
+ if isinstance(node, ast.BinOp):
358
+ operation_type = type(node.op)
359
+ if operation_type not in BINARY_OPERATORS:
360
+ raise ValueError(f"Unsupported operation: {operation_type.__name__}")
361
+ left = _evaluate_math_node(node.left)
362
+ right = _evaluate_math_node(node.right)
363
+ if operation_type is ast.Pow and abs(right) > 100:
364
+ raise ValueError("Exponent is too large.")
365
+ return BINARY_OPERATORS[operation_type](left, right)
366
+ if isinstance(node, ast.UnaryOp):
367
+ operation_type = type(node.op)
368
+ if operation_type not in UNARY_OPERATORS:
369
+ raise ValueError("Unsupported unary operation.")
370
+ return UNARY_OPERATORS[operation_type](_evaluate_math_node(node.operand))
371
+ raise ValueError("Expression contains an unsupported element.")
372
+
373
+
374
+ @tool("calculator")
375
+ def calculator(expression: str) -> str:
376
+ """Evaluate arithmetic using +, -, *, /, %, **, and parentheses."""
377
+
378
+ if len(expression) > 200:
379
+ return "ERROR: Calculator expression is too long."
380
+
381
+ try:
382
+ parsed = ast.parse(expression, mode="eval")
383
+ return str(_evaluate_math_node(parsed))
384
+ except Exception as error:
385
+ return f"ERROR: Calculator failed: {type(error).__name__}: {error}"
386
+
387
+
388
+ @tool("python_executor")
389
+ def python_executor(code: str) -> str:
390
+ """Execute short Python code for deterministic data processing."""
391
+
392
+ if not code.strip():
393
+ return "ERROR: No Python code was provided."
394
+ if len(code) > 10000:
395
+ return "ERROR: Python code is too long."
396
+
397
+ try:
398
+ with tempfile.TemporaryDirectory() as directory:
399
+ completed = subprocess.run(
400
+ [sys.executable, "-I", "-c", code],
401
+ cwd=directory,
402
+ capture_output=True,
403
+ text=True,
404
+ timeout=20,
405
+ )
406
+
407
+ if completed.returncode != 0:
408
+ return f"ERROR: Python execution failed:\n{completed.stderr[:4000]}"
409
+
410
+ output = completed.stdout.strip()
411
+ if not output:
412
+ return "ERROR: Python ran but printed no output."
413
+ return output[:10000]
414
+
415
+ except subprocess.TimeoutExpired:
416
+ return "ERROR: Python execution exceeded 20 seconds."
417
+ except Exception as error:
418
+ return f"ERROR: Python execution failed: {type(error).__name__}: {error}"
419
+
420
+
421
+ # -----------------------------------------------------------------------------
422
+ # YouTube transcript tool
423
+ # -----------------------------------------------------------------------------
424
+
425
+
426
+ def extract_youtube_video_id(url: str) -> str:
427
+ parsed_url = urlparse(url.strip())
428
+ hostname = (parsed_url.hostname or "").lower().removeprefix("www.")
429
+ video_id = ""
430
+
431
+ if hostname == "youtu.be":
432
+ video_id = parsed_url.path.strip("/").split("/")[0]
433
+ elif hostname in {"youtube.com", "m.youtube.com", "music.youtube.com"}:
434
+ if parsed_url.path == "/watch":
435
+ video_id = parse_qs(parsed_url.query).get("v", [""])[0]
436
+ elif parsed_url.path.startswith(("/shorts/", "/embed/", "/live/")):
437
+ parts = parsed_url.path.strip("/").split("/")
438
+ if len(parts) >= 2:
439
+ video_id = parts[1]
440
+
441
+ if not re.fullmatch(r"[A-Za-z0-9_-]{11}", video_id):
442
+ raise ValueError("Could not extract a valid YouTube video ID.")
443
+ return video_id
444
+
445
+
446
+ def format_video_timestamp(seconds: float) -> str:
447
+ total_seconds = int(seconds)
448
+ minutes, seconds = divmod(total_seconds, 60)
449
+ hours, minutes = divmod(minutes, 60)
450
+ if hours:
451
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
452
+ return f"{minutes:02d}:{seconds:02d}"
453
+
454
+
455
+ @tool("youtube_transcript")
456
+ def youtube_transcript(url: str, languages: str = "en") -> str:
457
+ """Retrieve timestamped captions for dialogue or spoken-answer questions."""
458
+
459
+ try:
460
+ video_id = extract_youtube_video_id(url)
461
+ language_codes = [x.strip() for x in languages.split(",") if x.strip()]
462
+ transcript = YouTubeTranscriptApi().fetch(
463
+ video_id,
464
+ languages=language_codes or ["en"],
465
+ )
466
+
467
+ lines = [f"VIDEO ID: {video_id}", "TRANSCRIPT:"]
468
+ for snippet in transcript:
469
+ text = " ".join(snippet.text.split())
470
+ if text:
471
+ lines.append(f"[{format_video_timestamp(snippet.start)}] {text}")
472
+
473
+ result = "\n".join(lines)
474
+ return result[:18000] if result else "ERROR: No transcript was returned."
475
+
476
+ except Exception as error:
477
+ return f"ERROR: Transcript retrieval failed: {type(error).__name__}: {error}"
478
+
479
+
480
+ # -----------------------------------------------------------------------------
481
+ # Generic visual YouTube tool
482
+ # -----------------------------------------------------------------------------
483
+
484
+ VIDEO_MAX_FRAMES = int(os.getenv("VIDEO_MAX_FRAMES", "24"))
485
+ VIDEO_BATCH_SIZE = int(os.getenv("VIDEO_BATCH_SIZE", "8"))
486
+ VIDEO_MAX_IMAGE_SIDE = 768
487
+ VIDEO_JPEG_QUALITY = 82
488
+
489
+
490
+ def _remove_partial_video_files(output_directory: Path) -> None:
491
+ for file_path in output_directory.glob("video.*"):
492
+ try:
493
+ file_path.unlink()
494
+ except OSError:
495
+ pass
496
+
497
+
498
+ def _find_downloaded_video(output_directory: Path) -> Path | None:
499
+ ignored = {".part", ".ytdl", ".json", ".description"}
500
+ files = [
501
+ path
502
+ for path in output_directory.glob("video.*")
503
+ if path.is_file() and path.suffix not in ignored and path.stat().st_size > 0
504
+ ]
505
+ return max(files, key=lambda path: path.stat().st_size) if files else None
506
+
507
+
508
+ def download_youtube_video(url: str, output_directory: Path) -> Path:
509
+ """Download a public YouTube video, trying several player clients."""
510
+
511
+ output_directory.mkdir(parents=True, exist_ok=True)
512
+ base_options = {
513
+ "format": "best[ext=mp4][height<=480]/best[height<=480]/best",
514
+ "outtmpl": str(output_directory / "video.%(ext)s"),
515
+ "noplaylist": True,
516
+ "quiet": True,
517
+ "no_warnings": True,
518
+ "force_ipv4": True,
519
+ "retries": 2,
520
+ "fragment_retries": 2,
521
+ "socket_timeout": 30,
522
+ "overwrites": True,
523
+ }
524
+
525
+ attempts = [
526
+ ["default", "tv_simply"],
527
+ ["web_safari", "tv_simply"],
528
+ ]
529
+ errors: list[str] = []
530
+
531
+ for clients in attempts:
532
+ _remove_partial_video_files(output_directory)
533
+ options = dict(base_options)
534
+ options["extractor_args"] = {"youtube": {"player_client": clients}}
535
+
536
+ try:
537
+ with yt_dlp.YoutubeDL(options) as downloader:
538
+ downloader.download([url])
539
+ downloaded = _find_downloaded_video(output_directory)
540
+ if downloaded:
541
+ return downloaded
542
+ except Exception as error:
543
+ errors.append(f"{clients}: {type(error).__name__}: {error}")
544
+
545
+ raise RuntimeError("All YouTube download attempts failed: " + " | ".join(errors))
546
+
547
+
548
+ def resize_video_frame(frame, maximum_side: int = VIDEO_MAX_IMAGE_SIDE):
549
+ height, width = frame.shape[:2]
550
+ longest = max(width, height)
551
+ if longest <= maximum_side:
552
+ return frame
553
+ scale = maximum_side / longest
554
+ return cv2.resize(
555
+ frame,
556
+ (max(1, int(width * scale)), max(1, int(height * scale))),
557
+ interpolation=cv2.INTER_AREA,
558
+ )
559
+
560
+
561
+ def sample_video_frames(
562
+ video_path: Path,
563
+ maximum_frames: int = VIDEO_MAX_FRAMES,
564
+ ) -> list[dict]:
565
+ capture = cv2.VideoCapture(str(video_path))
566
+
567
+ try:
568
+ if not capture.isOpened():
569
+ raise ValueError("OpenCV could not open the video.")
570
+
571
+ fps = float(capture.get(cv2.CAP_PROP_FPS))
572
+ frame_count = float(capture.get(cv2.CAP_PROP_FRAME_COUNT))
573
+ if fps <= 0 or frame_count <= 0:
574
+ raise ValueError("Could not determine video duration.")
575
+
576
+ duration = frame_count / fps
577
+ sample_count = min(maximum_frames, max(12, math.ceil(duration)))
578
+ final_timestamp = max(duration - 0.05, 0.0)
579
+ timestamps = [
580
+ index * final_timestamp / max(sample_count - 1, 1)
581
+ for index in range(sample_count)
582
+ ]
583
+
584
+ sampled: list[dict] = []
585
+ for timestamp in timestamps:
586
+ capture.set(cv2.CAP_PROP_POS_MSEC, timestamp * 1000)
587
+ success, frame = capture.read()
588
+ if not success:
589
+ continue
590
+ frame = resize_video_frame(frame)
591
+ encoded_success, encoded = cv2.imencode(
592
+ ".jpg",
593
+ frame,
594
+ [int(cv2.IMWRITE_JPEG_QUALITY), VIDEO_JPEG_QUALITY],
595
+ )
596
+ if not encoded_success:
597
+ continue
598
+ sampled.append(
599
+ {
600
+ "timestamp_seconds": round(timestamp, 3),
601
+ "image_base64": base64.b64encode(encoded.tobytes()).decode(),
602
+ }
603
+ )
604
+ return sampled
605
+ finally:
606
+ capture.release()
607
+
608
+
609
+ def _model_content_to_text(content) -> str:
610
+ if isinstance(content, str):
611
+ return content.strip()
612
+ if isinstance(content, list):
613
+ return "\n".join(
614
+ str(block.get("text", ""))
615
+ for block in content
616
+ if isinstance(block, dict) and block.get("text")
617
+ ).strip()
618
+ return str(content).strip()
619
+
620
+
621
+ def _extract_json_object(text: str) -> dict:
622
+ start = text.find("{")
623
+ end = text.rfind("}")
624
+ if start == -1 or end <= start:
625
+ raise ValueError("The model did not return a JSON object.")
626
+ return json.loads(text[start : end + 1])
627
+
628
+
629
+ def _safe_float(value, default=None):
630
+ try:
631
+ return float(value)
632
+ except (TypeError, ValueError):
633
+ return default
634
+
635
+
636
+ def analyze_video_frame_batch(
637
+ frame_batch: list[dict],
638
+ question: str,
639
+ ) -> list[dict]:
640
+ prompt = f"""
641
+ Analyze each labeled frame independently for the original visual question.
642
+
643
+ ORIGINAL QUESTION:
644
+ {question}
645
+
646
+ For every frame:
647
+ 1. Decide whether it contains relevant visible evidence.
648
+ 2. Describe only what is visibly present.
649
+ 3. Never combine counts or objects across timestamps.
650
+ 4. For a count question, put the value supported by that frame in numeric_value.
651
+ 5. For an identification, color, text, object, person, animal, action, place,
652
+ or event question, put the possible answer in candidate_answer.
653
+ 6. Use null when the frame does not support a value.
654
+ 7. Be conservative when evidence is unclear.
655
+
656
+ Return JSON only:
657
+ {{
658
+ "frames": [
659
+ {{
660
+ "frame_label": "FRAME 1",
661
+ "relevant": true,
662
+ "observation": "visible evidence",
663
+ "candidate_answer": null,
664
+ "numeric_value": null,
665
+ "confidence": 0.0
666
+ }}
667
+ ]
668
+ }}
669
+ """.strip()
670
+
671
+ content: list[dict] = [{"type": "text", "text": prompt}]
672
+ for index, frame in enumerate(frame_batch, start=1):
673
+ content.append(
674
+ {
675
+ "type": "text",
676
+ "text": f"FRAME {index} — {frame['timestamp_seconds']:.2f} seconds",
677
+ }
678
+ )
679
+ content.append(
680
+ {
681
+ "type": "image_url",
682
+ "image_url": {
683
+ "url": "data:image/jpeg;base64," + frame["image_base64"]
684
+ },
685
+ }
686
+ )
687
+
688
+ response = vision_llm.invoke([HumanMessage(content=content)])
689
+ parsed = _extract_json_object(_model_content_to_text(response.content))
690
+ returned = {
691
+ item.get("frame_label"): item
692
+ for item in parsed.get("frames", [])
693
+ if isinstance(item, dict)
694
+ }
695
+
696
+ observations: list[dict] = []
697
+ for index, frame in enumerate(frame_batch, start=1):
698
+ result = returned.get(f"FRAME {index}", {})
699
+ observations.append(
700
+ {
701
+ "timestamp_seconds": frame["timestamp_seconds"],
702
+ "relevant": bool(result.get("relevant", False)),
703
+ "observation": str(result.get("observation", "")).strip(),
704
+ "candidate_answer": result.get("candidate_answer"),
705
+ "numeric_value": _safe_float(result.get("numeric_value")),
706
+ "confidence": _safe_float(result.get("confidence"), 0.0),
707
+ }
708
+ )
709
+ return observations
710
+
711
+
712
+ def synthesize_video_answer(question: str, observations: list[dict]) -> dict:
713
+ relevant = [item for item in observations if item.get("relevant")]
714
+ if not relevant:
715
+ return {"answer": "Unknown", "evidence_timestamps": [], "confidence": 0.0}
716
+
717
+ prompt = f"""
718
+ Answer the original question using only these timestamped visual observations.
719
+
720
+ ORIGINAL QUESTION:
721
+ {question}
722
+
723
+ OBSERVATIONS:
724
+ {json.dumps(relevant[:60], ensure_ascii=False)}
725
+
726
+ Rules:
727
+ - For highest/maximum/most simultaneously, use the largest value from one timestamp.
728
+ - For lowest/minimum, use the smallest value from one timestamp.
729
+ - For first, use the earliest relevant timestamp.
730
+ - For last, use the latest relevant timestamp.
731
+ - Do not add values across timestamps.
732
+ - Return Unknown when evidence is insufficient.
733
+
734
+ Return JSON only:
735
+ {{"answer": "concise answer", "evidence_timestamps": [0.0], "confidence": 0.0}}
736
+ """.strip()
737
+
738
+ response = vision_llm.invoke([HumanMessage(content=prompt)])
739
+ result = _extract_json_object(_model_content_to_text(response.content))
740
+ return {
741
+ "answer": str(result.get("answer", "Unknown")).strip(),
742
+ "evidence_timestamps": result.get("evidence_timestamps", []),
743
+ "confidence": _safe_float(result.get("confidence"), 0.0),
744
+ }
745
+
746
+
747
+ @tool("youtube_visual_analysis")
748
+ def youtube_visual_analysis(url: str, question: str) -> str:
749
+ """Analyze objects, counts, text, colors, actions, and events visible in video."""
750
+
751
+ try:
752
+ with tempfile.TemporaryDirectory() as directory:
753
+ video_path = download_youtube_video(url, Path(directory))
754
+ sampled_frames = sample_video_frames(video_path, VIDEO_MAX_FRAMES)
755
+ if not sampled_frames:
756
+ return json.dumps({"error": "No video frames could be extracted."})
757
+
758
+ observations: list[dict] = []
759
+ batch_errors: list[str] = []
760
+ completed_batches = 0
761
+
762
+ for batch_start in range(0, len(sampled_frames), VIDEO_BATCH_SIZE):
763
+ batch_number = batch_start // VIDEO_BATCH_SIZE + 1
764
+ batch = sampled_frames[batch_start : batch_start + VIDEO_BATCH_SIZE]
765
+ try:
766
+ observations.extend(analyze_video_frame_batch(batch, question))
767
+ completed_batches += 1
768
+ except RateLimitError as error:
769
+ return json.dumps(
770
+ {
771
+ "error": "Vision-model API rate limit reached.",
772
+ "stage": f"frame-analysis batch {batch_number}",
773
+ "provider_message": str(error),
774
+ },
775
+ ensure_ascii=False,
776
+ )
777
+ except Exception as error:
778
+ batch_errors.append(
779
+ f"Batch {batch_number}: {type(error).__name__}: {error}"
780
+ )
781
+
782
+ if not observations:
783
+ return json.dumps(
784
+ {
785
+ "error": "No video frames were successfully analyzed.",
786
+ "batch_errors": batch_errors,
787
+ },
788
+ ensure_ascii=False,
789
+ )
790
+
791
+ normalized = question.lower()
792
+ numeric = [
793
+ item
794
+ for item in observations
795
+ if item.get("relevant") and item.get("numeric_value") is not None
796
+ ]
797
+ final_result = None
798
+
799
+ if numeric and any(
800
+ phrase in normalized
801
+ for phrase in (
802
+ "highest number",
803
+ "maximum number",
804
+ "largest number",
805
+ "most simultaneously",
806
+ )
807
+ ):
808
+ best = max(numeric, key=lambda item: item["numeric_value"])
809
+ value = best["numeric_value"]
810
+ value = int(value) if float(value).is_integer() else value
811
+ final_result = {
812
+ "answer": str(value),
813
+ "evidence_timestamps": [best["timestamp_seconds"]],
814
+ "confidence": best.get("confidence", 0.0),
815
+ }
816
+ elif numeric and any(
817
+ phrase in normalized
818
+ for phrase in ("lowest number", "minimum number", "smallest number")
819
+ ):
820
+ best = min(numeric, key=lambda item: item["numeric_value"])
821
+ value = best["numeric_value"]
822
+ value = int(value) if float(value).is_integer() else value
823
+ final_result = {
824
+ "answer": str(value),
825
+ "evidence_timestamps": [best["timestamp_seconds"]],
826
+ "confidence": best.get("confidence", 0.0),
827
+ }
828
+
829
+ if final_result is None:
830
+ final_result = synthesize_video_answer(question, observations)
831
+
832
+ return json.dumps(
833
+ {
834
+ **final_result,
835
+ "frames_analyzed": len(observations),
836
+ "frames_sampled": len(sampled_frames),
837
+ "batches_completed": completed_batches,
838
+ "batch_errors": batch_errors,
839
+ },
840
+ ensure_ascii=False,
841
+ )
842
+
843
+ except RateLimitError as error:
844
+ return json.dumps(
845
+ {
846
+ "error": "Vision-model API rate limit reached.",
847
+ "provider_message": str(error),
848
+ }
849
+ )
850
+ except Exception as error:
851
+ return json.dumps(
852
+ {
853
+ "error": (
854
+ "YouTube visual analysis failed: "
855
+ f"{type(error).__name__}: {error}"
856
+ )
857
+ },
858
+ ensure_ascii=False,
859
+ )
860
+
861
+
862
+ # -----------------------------------------------------------------------------
863
+ # Agent state and attachment routing
864
+ # -----------------------------------------------------------------------------
865
+
866
+ RouteType = Literal[
867
+ "reasoning",
868
+ "audio",
869
+ "image",
870
+ "chess",
871
+ "spreadsheet",
872
+ "python_file",
873
+ "pdf",
874
+ ]
875
+
876
+
877
+ class AgentState(TypedDict):
878
+ question: str
879
+ messages: Annotated[list[AnyMessage], add_messages]
880
+ route: NotRequired[RouteType]
881
+ input_file: NotRequired[str]
882
+ attachment_content: NotRequired[str]
883
+ final_answer: NotRequired[str]
884
+ error: NotRequired[str]
885
+
886
+
887
+ AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".flac", ".ogg"}
888
+ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
889
+ SPREADSHEET_EXTENSIONS = {".csv", ".xlsx", ".xls", ".xlsm"}
890
+
891
+
892
+ def router_node(state: AgentState) -> dict:
893
+ question = state["question"].lower()
894
+ input_file = state.get("input_file")
895
+
896
+ if not input_file:
897
+ return {"route": "reasoning"}
898
+
899
+ extension = Path(input_file).suffix.lower()
900
+ if extension in AUDIO_EXTENSIONS:
901
+ return {"route": "audio"}
902
+ if extension in IMAGE_EXTENSIONS:
903
+ chess_keywords = (
904
+ "chess",
905
+ "black's turn",
906
+ "white's turn",
907
+ "algebraic notation",
908
+ "checkmate",
909
+ )
910
+ return {
911
+ "route": "chess" if any(x in question for x in chess_keywords) else "image"
912
+ }
913
+ if extension in SPREADSHEET_EXTENSIONS:
914
+ return {"route": "spreadsheet"}
915
+ if extension == ".py":
916
+ return {"route": "python_file"}
917
+ if extension == ".pdf":
918
+ return {"route": "pdf"}
919
+ return {"route": "reasoning"}
920
+
921
+
922
+ def choose_route(state: AgentState) -> RouteType:
923
+ return state.get("route", "reasoning")
924
+
925
+
926
+ # -----------------------------------------------------------------------------
927
+ # Image helpers and nodes
928
+ # -----------------------------------------------------------------------------
929
+
930
+ MAX_IMAGE_SIDE = 768
931
+ JPEG_QUALITY = 85
932
+
933
+
934
+ def prepare_image_for_vlm(file_path: Path) -> tuple[str, str, tuple[int, int]]:
935
+ with PILImage.open(file_path) as image:
936
+ image = ImageOps.exif_transpose(image)
937
+
938
+ if image.mode in ("RGBA", "LA"):
939
+ background = PILImage.new("RGB", image.size, "white")
940
+ background.paste(image.convert("RGB"), mask=image.getchannel("A"))
941
+ image = background
942
+ elif image.mode == "P" and "transparency" in image.info:
943
+ image = image.convert("RGBA")
944
+ background = PILImage.new("RGB", image.size, "white")
945
+ background.paste(image.convert("RGB"), mask=image.getchannel("A"))
946
+ image = background
947
+ else:
948
+ image = image.convert("RGB")
949
+
950
+ image.thumbnail((MAX_IMAGE_SIDE, MAX_IMAGE_SIDE), PILImage.Resampling.LANCZOS)
951
+ resized_size = image.size
952
+ buffer = BytesIO()
953
+ image.save(buffer, format="JPEG", quality=JPEG_QUALITY, optimize=True)
954
+ image_base64 = base64.b64encode(buffer.getvalue()).decode()
955
+
956
+ return image_base64, "image/jpeg", resized_size
957
+
958
+
959
+ def image_node(state: AgentState) -> dict:
960
+ input_file = state.get("input_file")
961
+ if not input_file:
962
+ return {"error": "No image file was supplied."}
963
+
964
+ file_path = Path(input_file)
965
+ try:
966
+ image_base64, mime_type, resized_size = prepare_image_for_vlm(file_path)
967
+ question = state.get("question", "Describe the image.").strip()
968
+ prompt = f"""
969
+ Analyze the attached image for the original question.
970
+
971
+ ORIGINAL QUESTION:
972
+ {question}
973
+
974
+ Extract only relevant visible evidence, including readable text, numbers,
975
+ symbols, labels, objects, positions, tables, and chart values. Do not invent
976
+ unclear details and do not use outside knowledge.
977
+ """.strip()
978
+ response = vision_llm.invoke(
979
+ [
980
+ HumanMessage(
981
+ content=[
982
+ {"type": "text", "text": prompt},
983
+ {
984
+ "type": "image_url",
985
+ "image_url": {
986
+ "url": f"data:{mime_type};base64,{image_base64}"
987
+ },
988
+ },
989
+ ]
990
+ )
991
+ ]
992
+ )
993
+ analysis = _model_content_to_text(response.content)
994
+ if not analysis:
995
+ raise ValueError("The vision model returned no image analysis.")
996
+ return {
997
+ "attachment_content": (
998
+ "IMAGE ANALYSIS\n\n"
999
+ f"FILE NAME: {file_path.name}\n"
1000
+ f"RESIZED DIMENSIONS: {resized_size[0]} x {resized_size[1]}\n\n"
1001
+ f"VISUAL CONTENT:\n{analysis}"
1002
+ )
1003
+ }
1004
+ except Exception as error:
1005
+ message = f"Image analysis failed: {type(error).__name__}: {error}"
1006
+ return {"attachment_content": message, "error": message}
1007
+
1008
+
1009
+ # -----------------------------------------------------------------------------
1010
+ # Audio node
1011
+ # -----------------------------------------------------------------------------
1012
+
1013
+
1014
+ def audio_node(state: AgentState) -> dict:
1015
+ input_file = state.get("input_file")
1016
+ if not input_file:
1017
+ return {"error": "No audio file was supplied."}
1018
+
1019
+ file_path = Path(input_file)
1020
+
1021
+ try:
1022
+ with file_path.open("rb") as audio_file:
1023
+ transcription = openai_client.audio.transcriptions.create(
1024
+ model=AUDIO_MODEL,
1025
+ file=audio_file,
1026
+ language="en",
1027
+ prompt=(
1028
+ "Transcribe accurately. Preserve names, numbers, page "
1029
+ "numbers, ingredient names, and punctuation."
1030
+ ),
1031
+ response_format="text",
1032
+ )
1033
+
1034
+ if isinstance(transcription, str):
1035
+ transcript = transcription.strip()
1036
+ else:
1037
+ transcript = str(
1038
+ getattr(transcription, "text", "")
1039
+ ).strip()
1040
+
1041
+ if not transcript:
1042
+ raise ValueError("The transcription API returned no text.")
1043
+
1044
+ return {
1045
+ "attachment_content": (
1046
+ "AUDIO TRANSCRIPTION\n\n"
1047
+ f"FILE NAME: {file_path.name}\n"
1048
+ f"TRANSCRIPTION MODEL: {AUDIO_MODEL}\n\n"
1049
+ f"TRANSCRIPT:\n{transcript}"
1050
+ )
1051
+ }
1052
+
1053
+ except RateLimitError as error:
1054
+ message = (
1055
+ "Audio transcription failed because the OpenAI rate limit "
1056
+ f"was reached: {error}"
1057
+ )
1058
+ return {"attachment_content": message, "error": message}
1059
+
1060
+ except Exception as error:
1061
+ message = (
1062
+ "Audio transcription failed: "
1063
+ f"{type(error).__name__}: {error}"
1064
+ )
1065
+ return {"attachment_content": message, "error": message}
1066
+
1067
+
1068
+ # -----------------------------------------------------------------------------
1069
+ # Chess node
1070
+ # -----------------------------------------------------------------------------
1071
+
1072
+ STOCKFISH_PATH = shutil.which("stockfish")
1073
+
1074
+
1075
+ def detect_side_from_question(question: str):
1076
+ normalized = question.lower().replace("’", "'")
1077
+ if any(x in normalized for x in ("black to move", "black's turn", "move for black")):
1078
+ return chess.BLACK
1079
+ if any(x in normalized for x in ("white to move", "white's turn", "move for white")):
1080
+ return chess.WHITE
1081
+ return None
1082
+
1083
+
1084
+ def chess_node(state: AgentState) -> dict:
1085
+ input_file = state.get("input_file")
1086
+ if not input_file:
1087
+ return {"error": "No chess image was supplied."}
1088
+ if not STOCKFISH_PATH:
1089
+ message = "Stockfish is not installed."
1090
+ return {"attachment_content": message, "error": message}
1091
+
1092
+ file_path = Path(input_file)
1093
+ question = state.get("question", "").strip()
1094
+
1095
+ try:
1096
+ image_base64, mime_type, resized_size = prepare_image_for_vlm(file_path)
1097
+ explicit_turn = detect_side_from_question(question)
1098
+ turn_instruction = (
1099
+ "Set side_to_move to black."
1100
+ if explicit_turn == chess.BLACK
1101
+ else "Set side_to_move to white."
1102
+ if explicit_turn == chess.WHITE
1103
+ else "Determine the side to move from the image."
1104
+ )
1105
+
1106
+ prompt = f"""
1107
+ Reconstruct the attached chessboard exactly.
1108
+
1109
+ ORIGINAL QUESTION:
1110
+ {question}
1111
+
1112
+ Inspect all 64 squares and board labels. Do not calculate a move.
1113
+ {turn_instruction}
1114
+
1115
+ Return JSON only:
1116
+ {{
1117
+ "white_pieces": ["Kg1"],
1118
+ "black_pieces": ["Kg8"],
1119
+ "fen": "complete FEN",
1120
+ "side_to_move": "black or white",
1121
+ "orientation": "black or white",
1122
+ "confidence": 0.0
1123
+ }}
1124
+ """.strip()
1125
+
1126
+ response = vision_llm_chess.invoke(
1127
+ [
1128
+ HumanMessage(
1129
+ content=[
1130
+ {"type": "text", "text": prompt},
1131
+ {
1132
+ "type": "image_url",
1133
+ "image_url": {
1134
+ "url": f"data:{mime_type};base64,{image_base64}"
1135
+ },
1136
+ },
1137
+ ]
1138
+ )
1139
+ ]
1140
+ )
1141
+ result = _extract_json_object(_model_content_to_text(response.content))
1142
+ fen = str(result.get("fen", "")).strip()
1143
+ if not fen:
1144
+ raise ValueError("The vision model did not return a FEN.")
1145
+
1146
+ board = chess.Board(fen)
1147
+ if explicit_turn is not None:
1148
+ board.turn = explicit_turn
1149
+ fen = board.fen()
1150
+ if not board.is_valid() or board.is_game_over():
1151
+ raise ValueError(f"Invalid or finished reconstructed position: {fen}")
1152
+
1153
+ engine = chess.engine.SimpleEngine.popen_uci(STOCKFISH_PATH, timeout=30.0)
1154
+ try:
1155
+ engine_result = engine.play(board, chess.engine.Limit(depth=18))
1156
+ if engine_result.move is None:
1157
+ raise ValueError("Stockfish did not return a move.")
1158
+ san = board.san(engine_result.move)
1159
+ uci = engine_result.move.uci()
1160
+ finally:
1161
+ engine.quit()
1162
+
1163
+ return {
1164
+ "attachment_content": (
1165
+ "CHESS POSITION ANALYSIS\n\n"
1166
+ f"FILE NAME: {file_path.name}\n"
1167
+ f"IMAGE DIMENSIONS: {resized_size[0]} x {resized_size[1]}\n"
1168
+ f"FEN: {fen}\n"
1169
+ f"BEST MOVE IN SAN: {san}\n"
1170
+ f"BEST MOVE IN UCI: {uci}\n"
1171
+ )
1172
+ }
1173
+ except Exception as error:
1174
+ message = f"Chess processing failed: {type(error).__name__}: {error}"
1175
+ return {"attachment_content": message, "error": message}
1176
+
1177
+
1178
+ # -----------------------------------------------------------------------------
1179
+ # Spreadsheet, Python, and PDF nodes
1180
+ # -----------------------------------------------------------------------------
1181
+
1182
+
1183
+ def _clean_dataframe(dataframe: pd.DataFrame) -> pd.DataFrame:
1184
+ cleaned = dataframe.copy().replace(r"^\s*$", pd.NA, regex=True)
1185
+ return cleaned.dropna(axis=0, how="all").dropna(axis=1, how="all")
1186
+
1187
+
1188
+ def _dataframe_to_text(sheet_name: str, dataframe: pd.DataFrame) -> str:
1189
+ dataframe = _clean_dataframe(dataframe)
1190
+ rows, columns = dataframe.shape
1191
+ section = [
1192
+ f"SHEET NAME: {sheet_name}",
1193
+ f"ROWS: {rows}",
1194
+ f"COLUMNS: {columns}",
1195
+ "COLUMN NAMES: " + " | ".join(str(x) for x in dataframe.columns),
1196
+ "SHEET DATA:",
1197
+ dataframe.to_csv(index=False, na_rep=""),
1198
+ ]
1199
+
1200
+ numeric = dataframe.apply(pd.to_numeric, errors="coerce")
1201
+ totals = numeric.sum(min_count=1).dropna()
1202
+ if not totals.empty:
1203
+ section.append("NUMERIC COLUMN TOTALS:")
1204
+ for column, total in totals.items():
1205
+ section.append(f"{column}: {total}")
1206
+
1207
+ return "\n".join(section)
1208
+
1209
+
1210
+ def spreadsheet_node(state: AgentState) -> dict:
1211
+ input_file = state.get("input_file")
1212
+ if not input_file:
1213
+ return {"error": "No spreadsheet file was supplied."}
1214
+
1215
+ file_path = Path(input_file)
1216
+ try:
1217
+ if file_path.suffix.lower() == ".csv":
1218
+ sheets = {"CSV": pd.read_csv(file_path, dtype=object, keep_default_na=False)}
1219
+ else:
1220
+ sheets = pd.read_excel(
1221
+ file_path,
1222
+ sheet_name=None,
1223
+ dtype=object,
1224
+ keep_default_na=False,
1225
+ )
1226
+
1227
+ content = "\n\n".join(
1228
+ _dataframe_to_text(name, frame) for name, frame in sheets.items()
1229
+ )
1230
+ return {
1231
+ "attachment_content": (
1232
+ "SPREADSHEET INFORMATION\n\n"
1233
+ f"FILE NAME: {file_path.name}\n\n{content[:30000]}"
1234
+ )
1235
+ }
1236
+ except Exception as error:
1237
+ message = f"Spreadsheet processing failed: {type(error).__name__}: {error}"
1238
+ return {"attachment_content": message, "error": message}
1239
+
1240
+
1241
+ def python_file_node(state: AgentState) -> dict:
1242
+ input_file = state.get("input_file")
1243
+ if not input_file:
1244
+ return {"error": "No Python file was supplied."}
1245
+
1246
+ file_path = Path(input_file)
1247
+ try:
1248
+ try:
1249
+ source = file_path.read_text(encoding="utf-8")
1250
+ except UnicodeDecodeError:
1251
+ source = file_path.read_text(encoding="latin-1")
1252
+
1253
+ tree = ast.parse(source)
1254
+ functions = sorted(
1255
+ {
1256
+ node.name
1257
+ for node in ast.walk(tree)
1258
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
1259
+ }
1260
+ )
1261
+ classes = sorted(
1262
+ {node.name for node in ast.walk(tree) if isinstance(node, ast.ClassDef)}
1263
+ )
1264
+
1265
+ return {
1266
+ "attachment_content": (
1267
+ "PYTHON FILE INFORMATION\n\n"
1268
+ f"FILE NAME: {file_path.name}\n"
1269
+ f"FUNCTIONS: {', '.join(functions) or 'None'}\n"
1270
+ f"CLASSES: {', '.join(classes) or 'None'}\n\n"
1271
+ "ANALYSIS INSTRUCTION: Trace execution from __main__ to the final "
1272
+ "printed value. Follow loops, recursion, exceptions, generators, "
1273
+ "returns, mutations, and stopping conditions.\n\n"
1274
+ f"SOURCE CODE:\n{source[:30000]}"
1275
+ )
1276
+ }
1277
+ except Exception as error:
1278
+ message = f"Python file processing failed: {type(error).__name__}: {error}"
1279
+ return {"attachment_content": message, "error": message}
1280
+
1281
+
1282
+ def pdf_node(state: AgentState) -> dict:
1283
+ input_file = state.get("input_file")
1284
+ if not input_file:
1285
+ return {"error": "No PDF file was supplied."}
1286
+
1287
+ file_path = Path(input_file)
1288
+ try:
1289
+ reader = PdfReader(str(file_path))
1290
+ pages: list[str] = []
1291
+ for page_number, page in enumerate(reader.pages[:50], start=1):
1292
+ try:
1293
+ text = page.extract_text(extraction_mode="layout") or ""
1294
+ except TypeError:
1295
+ text = page.extract_text() or ""
1296
+ pages.append(f"--- PAGE {page_number} ---\n{text.strip()}")
1297
+
1298
+ full_text = "\n\n".join(pages)
1299
+ if not full_text.strip():
1300
+ raise ValueError("No text could be extracted from the PDF.")
1301
+ return {
1302
+ "attachment_content": (
1303
+ "PDF INFORMATION\n\n"
1304
+ f"FILE NAME: {file_path.name}\n"
1305
+ f"TOTAL PAGES: {len(reader.pages)}\n\n"
1306
+ f"PDF CONTENT:\n{full_text[:15000]}"
1307
+ )
1308
+ }
1309
+ except Exception as error:
1310
+ message = f"PDF processing failed: {type(error).__name__}: {error}"
1311
+ return {"attachment_content": message, "error": message}
1312
+
1313
+
1314
+ # -----------------------------------------------------------------------------
1315
+ # Reasoning and graph
1316
+ # -----------------------------------------------------------------------------
1317
+
1318
+ GENERAL_TOOLS = [
1319
+ web_search_tool,
1320
+ wikipedia_search,
1321
+ read_webpage,
1322
+ calculator,
1323
+ python_executor,
1324
+ youtube_transcript,
1325
+ youtube_visual_analysis,
1326
+ ]
1327
+
1328
+ WIKIPEDIA_TOOLS = [wikipedia_search, calculator, python_executor]
1329
+
1330
+ llm_with_tools = llm.bind_tools(GENERAL_TOOLS)
1331
+ wikipedia_llm_with_tools = llm.bind_tools(WIKIPEDIA_TOOLS)
1332
+ tool_node = ToolNode(GENERAL_TOOLS, handle_tool_errors=True)
1333
+
1334
+ MAX_TOOL_RESULTS = 6
1335
+
1336
+ GAIA_REASONING_PROMPT = """
1337
+ You are solving a GAIA benchmark question. Use direct reasoning and the
1338
+ minimum necessary tool calls.
1339
+
1340
+ 1. Return exactly one block: <answer>YOUR ANSWER</answer>. Put no text outside it.
1341
+ 2. Follow exact formatting: number, name, IOC code, comma-separated list,
1342
+ alphabetical order, decimals, capitalization, punctuation, or chess SAN.
1343
+ 3. Solve reversed text, wordplay, simple logic, and short transformations
1344
+ directly without tools.
1345
+ 4. Treat attachment content as the primary source and preserve exact values.
1346
+ 5. For web research, use focused web_search and open a relevant result with
1347
+ read_webpage. Do not answer from snippets or blocked pages.
1348
+ 6. When the question mentions Wikipedia, use wikipedia_search with the main
1349
+ topic. For a latest-2022 request use as_of_date=2022-12-31. Do not switch to
1350
+ general web search unless the Wikipedia tool returns an error.
1351
+ 7. Use youtube_transcript for speech, dialogue, quotations, and what someone
1352
+ said. Use youtube_visual_analysis for visible objects, animals, people,
1353
+ colors, text, actions, counts, timestamps, and simultaneous events.
1354
+ 8. After a successful YouTube tool result, use its evidence instead of a web
1355
+ guess. Never add counts from different timestamps for a simultaneous count.
1356
+ 9. Use calculator for arithmetic and python_executor for sorting, filtering,
1357
+ counting, tables, comparisons, and multi-step verification.
1358
+ 10. For attached Python code, trace the actual entry point and final printed
1359
+ output through recursion, loops, exceptions, generators, and returns.
1360
+ 11. When attachment content contains BEST MOVE IN SAN, copy it exactly.
1361
+ 12. For counting and list questions, identify every qualifying record, apply
1362
+ every condition, verify dates/categories, then count or sort.
1363
+ 13. Never invent an answer because a tool failed. Avoid repeating the same
1364
+ failing call. Once evidence is sufficient, stop using tools.
1365
+ 14. Before answering, verify exact question, conditions, ordering, spelling,
1366
+ capitalization, symbols, units, and decimal places.
1367
+ """.strip()
1368
+
1369
+
1370
+ def reasoning_node(state: AgentState) -> dict:
1371
+ question = state["question"].strip()
1372
+ attachment_content = state.get("attachment_content", "").strip()
1373
+ messages = list(state.get("messages", []))
1374
+ if not messages:
1375
+ messages = [HumanMessage(content=question)]
1376
+
1377
+ tool_result_count = sum(isinstance(message, ToolMessage) for message in messages)
1378
+ system_content = f"{GAIA_REASONING_PROMPT}\n\nORIGINAL QUESTION:\n{question}"
1379
+ if attachment_content:
1380
+ system_content += (
1381
+ "\n\nCONTENT EXTRACTED FROM THE ATTACHMENT:\n" + attachment_content
1382
+ )
1383
+
1384
+ if tool_result_count >= MAX_TOOL_RESULTS:
1385
+ system_content += (
1386
+ "\n\nThe tool-use budget is exhausted. Do not call another tool. "
1387
+ "Use the reliable evidence already available and return the answer now."
1388
+ )
1389
+ selected_model = llm
1390
+ elif "wikipedia" in question.lower():
1391
+ selected_model = wikipedia_llm_with_tools
1392
+ else:
1393
+ selected_model = llm_with_tools
1394
+
1395
+ response = selected_model.invoke(
1396
+ [SystemMessage(content=system_content), *messages]
1397
+ )
1398
+ return {"messages": [response]}
1399
+
1400
+
1401
+ def content_to_text(content) -> str:
1402
+ return _model_content_to_text(content)
1403
+
1404
+
1405
+ def final_answer_formatter(state: AgentState) -> dict:
1406
+ for message in reversed(state.get("messages", [])):
1407
+ if not isinstance(message, AIMessage) or getattr(message, "tool_calls", None):
1408
+ continue
1409
+ text = content_to_text(message.content)
1410
+ if not text:
1411
+ continue
1412
+ tagged = re.search(
1413
+ r"<answer>\s*(.*?)\s*</answer>",
1414
+ text,
1415
+ flags=re.IGNORECASE | re.DOTALL,
1416
+ )
1417
+ answer = tagged.group(1).strip() if tagged else text
1418
+ answer = re.sub(
1419
+ r"^(final\s+answer|answer|result)\s*:\s*",
1420
+ "",
1421
+ answer,
1422
+ flags=re.IGNORECASE,
1423
+ )
1424
+ answer = re.sub(r"</?answer>", "", answer, flags=re.IGNORECASE)
1425
+ return {"final_answer": answer.strip("` \n")}
1426
+
1427
+ return {"final_answer": "", "error": "No completed AI answer was found."}
1428
+
1429
+
1430
+ def route_after_processor(state: AgentState) -> Literal["reason", "stop"]:
1431
+ return "stop" if state.get("error") else "reason"
1432
+
1433
+
1434
+ def route_after_reasoning(
1435
+ state: AgentState,
1436
+ ) -> Literal["use_tools", "format_answer"]:
1437
+ last_message = state["messages"][-1]
1438
+ return "use_tools" if getattr(last_message, "tool_calls", None) else "format_answer"
1439
+
1440
+
1441
+ graph_builder = StateGraph(AgentState)
1442
+ graph_builder.add_node("router", router_node)
1443
+ graph_builder.add_node("audio_node", audio_node)
1444
+ graph_builder.add_node("image_node", image_node)
1445
+ graph_builder.add_node("chess_node", chess_node)
1446
+ graph_builder.add_node("spreadsheet_node", spreadsheet_node)
1447
+ graph_builder.add_node("python_file_node", python_file_node)
1448
+ graph_builder.add_node("pdf_node", pdf_node)
1449
+ graph_builder.add_node("reasoning_node", reasoning_node)
1450
+ graph_builder.add_node("tools", tool_node)
1451
+ graph_builder.add_node("final_answer_formatter", final_answer_formatter)
1452
+
1453
+ graph_builder.add_edge(START, "router")
1454
+ graph_builder.add_conditional_edges(
1455
+ "router",
1456
+ choose_route,
1457
+ {
1458
+ "reasoning": "reasoning_node",
1459
+ "audio": "audio_node",
1460
+ "image": "image_node",
1461
+ "chess": "chess_node",
1462
+ "spreadsheet": "spreadsheet_node",
1463
+ "python_file": "python_file_node",
1464
+ "pdf": "pdf_node",
1465
+ },
1466
+ )
1467
+
1468
+ for processor in (
1469
+ "audio_node",
1470
+ "image_node",
1471
+ "chess_node",
1472
+ "spreadsheet_node",
1473
+ "python_file_node",
1474
+ "pdf_node",
1475
+ ):
1476
+ graph_builder.add_conditional_edges(
1477
+ processor,
1478
+ route_after_processor,
1479
+ {"reason": "reasoning_node", "stop": END},
1480
+ )
1481
+
1482
+ graph_builder.add_conditional_edges(
1483
+ "reasoning_node",
1484
+ route_after_reasoning,
1485
+ {"use_tools": "tools", "format_answer": "final_answer_formatter"},
1486
+ )
1487
+ graph_builder.add_edge("tools", "reasoning_node")
1488
+ graph_builder.add_edge("final_answer_formatter", END)
1489
+
1490
+ gaia_graph = graph_builder.compile()
1491
+
1492
+
1493
+ def clean_answer(answer: str) -> str:
1494
+ answer = str(answer or "").strip()
1495
+ match = re.search(
1496
+ r"<answer>\s*(.*?)\s*</answer>",
1497
+ answer,
1498
+ flags=re.IGNORECASE | re.DOTALL,
1499
+ )
1500
+ if match:
1501
+ answer = match.group(1).strip()
1502
+ return re.sub(
1503
+ r"^(final\s+answer|answer|result)\s*:\s*",
1504
+ "",
1505
+ answer,
1506
+ flags=re.IGNORECASE,
1507
+ ).strip()
1508
+
1509
+
1510
+ class GaiaAgent:
1511
+ """Wrapper called by app.py and the local dry-run script."""
1512
+
1513
+ def __init__(self):
1514
+ self.graph = gaia_graph
1515
+
1516
+ def health_check(self) -> dict:
1517
+ """Make one small paid request to validate the configured text model."""
1518
+
1519
+ response = llm.invoke(
1520
+ [HumanMessage(content="Return exactly the word OK and nothing else.")]
1521
+ )
1522
+ text = _model_content_to_text(response.content).strip()
1523
+
1524
+ return {
1525
+ "text_model": TEXT_MODEL,
1526
+ "vision_model": VISION_MODEL,
1527
+ "audio_model": AUDIO_MODEL,
1528
+ "text_response": text,
1529
+ "stockfish_available": bool(STOCKFISH_PATH),
1530
+ "ffmpeg_available": bool(shutil.which("ffmpeg")),
1531
+ }
1532
+
1533
+ def __call__(
1534
+ self,
1535
+ question: str,
1536
+ input_file: str | None = None,
1537
+ ) -> str:
1538
+ state: AgentState = {"question": question, "messages": []}
1539
+ if input_file:
1540
+ state["input_file"] = input_file
1541
+
1542
+ result = self.graph.invoke(state, config={"recursion_limit": 25})
1543
+ answer = clean_answer(result.get("final_answer", ""))
1544
+ if not answer:
1545
+ print("Agent returned no answer. Error:", result.get("error", ""))
1546
+ return answer
app.py CHANGED
@@ -1,200 +1,355 @@
1
  import os
 
 
 
2
  import gradio as gr
3
- import requests
4
- import inspect
5
  import pandas as pd
 
6
  import spaces
7
 
8
-
9
- # (Keep Constants as is)
10
- # --- Constants ---
11
  DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
- # --- Basic Agent Definition ---
14
- # ----- THIS IS WERE YOU CAN BUILD WHAT YOU WANT ------
15
- class BasicAgent:
16
- def __init__(self):
17
- print("BasicAgent initialized.")
18
- def __call__(self, question: str) -> str:
19
- print(f"Agent received question (first 50 chars): {question[:50]}...")
20
- fixed_answer = "This is a default answer."
21
- print(f"Agent returning fixed answer: {fixed_answer}")
22
- return fixed_answer
 
 
 
 
 
 
 
 
 
 
 
23
 
24
 
25
  @spaces.GPU
26
- def run_and_submit_all( profile: gr.OAuthProfile | None):
27
  """
28
- Fetches all questions, runs the BasicAgent on them, submits all answers,
29
- and displays the results.
 
 
30
  """
31
- # --- Determine HF Space Runtime URL and Repo URL ---
32
- space_id = os.getenv("SPACE_ID") # Get the SPACE_ID for sending link to the code
 
 
 
 
 
33
 
34
- if profile:
35
- username= f"{profile.username}"
36
- print(f"User logged in: {username}")
37
- else:
38
- print("User not logged in.")
39
- return "Please Login to Hugging Face with the button.", None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  api_url = DEFAULT_API_URL
42
  questions_url = f"{api_url}/questions"
43
  submit_url = f"{api_url}/submit"
44
 
45
- # 1. Instantiate Agent ( modify this part to create your agent)
46
- try:
47
- agent = BasicAgent()
48
- except Exception as e:
49
- print(f"Error instantiating agent: {e}")
50
- return f"Error initializing agent: {e}", None
51
- # In the case of an app running as a hugging Face space, this link points toward your codebase ( usefull for others so please keep it public)
52
  agent_code = f"https://huggingface.co/spaces/{space_id}/tree/main"
53
- print(agent_code)
54
 
55
- # 2. Fetch Questions
56
- print(f"Fetching questions from: {questions_url}")
57
  try:
58
- response = requests.get(questions_url, timeout=15)
 
 
 
 
 
 
 
 
 
 
59
  response.raise_for_status()
60
  questions_data = response.json()
61
- if not questions_data:
62
- print("Fetched questions list is empty.")
63
- return "Fetched questions list is empty or invalid format.", None
64
- print(f"Fetched {len(questions_data)} questions.")
65
- except requests.exceptions.RequestException as e:
66
- print(f"Error fetching questions: {e}")
67
- return f"Error fetching questions: {e}", None
68
- except requests.exceptions.JSONDecodeError as e:
69
- print(f"Error decoding JSON response from questions endpoint: {e}")
70
- print(f"Response text: {response.text[:500]}")
71
- return f"Error decoding server response for questions: {e}", None
72
- except Exception as e:
73
- print(f"An unexpected error occurred fetching questions: {e}")
74
- return f"An unexpected error occurred fetching questions: {e}", None
75
-
76
- # 3. Run your Agent
77
- results_log = []
78
- answers_payload = []
79
- print(f"Running agent on {len(questions_data)} questions...")
80
- for item in questions_data:
81
- task_id = item.get("task_id")
82
- question_text = item.get("question")
83
- if not task_id or question_text is None:
84
- print(f"Skipping item with missing task_id or question: {item}")
85
- continue
86
- try:
87
- submitted_answer = agent(question_text)
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:
91
- print(f"Error running agent on task {task_id}: {e}")
92
- results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": f"AGENT ERROR: {e}"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
  if not answers_payload:
95
- print("Agent did not produce any answers to submit.")
96
- return "Agent did not produce any answers to submit.", pd.DataFrame(results_log)
 
 
97
 
98
- # 4. Prepare Submission
99
- submission_data = {"username": username.strip(), "agent_code": agent_code, "answers": answers_payload}
100
- status_update = f"Agent finished. Submitting {len(answers_payload)} answers for user '{username}'..."
101
- print(status_update)
 
102
 
103
- # 5. Submit
104
- print(f"Submitting {len(answers_payload)} answers to: {submit_url}")
105
  try:
106
- response = requests.post(submit_url, json=submission_data, timeout=60)
 
 
 
 
107
  response.raise_for_status()
108
  result_data = response.json()
 
109
  final_status = (
110
- f"Submission Successful!\n"
111
- f"User: {result_data.get('username')}\n"
112
- f"Overall Score: {result_data.get('score', 'N/A')}% "
113
- f"({result_data.get('correct_count', '?')}/{result_data.get('total_attempted', '?')} correct)\n"
 
114
  f"Message: {result_data.get('message', 'No message received.')}"
115
  )
116
- print("Submission successful.")
117
- results_df = pd.DataFrame(results_log)
118
- return final_status, results_df
119
- except requests.exceptions.HTTPError as e:
120
- error_detail = f"Server responded with status {e.response.status_code}."
121
- try:
122
- error_json = e.response.json()
123
- error_detail += f" Detail: {error_json.get('detail', e.response.text)}"
124
- except requests.exceptions.JSONDecodeError:
125
- error_detail += f" Response: {e.response.text[:500]}"
126
- status_message = f"Submission Failed: {error_detail}"
127
- print(status_message)
128
- results_df = pd.DataFrame(results_log)
129
- return status_message, results_df
130
- except requests.exceptions.Timeout:
131
- status_message = "Submission Failed: The request timed out."
132
- print(status_message)
133
- results_df = pd.DataFrame(results_log)
134
- return status_message, results_df
135
- except requests.exceptions.RequestException as e:
136
- status_message = f"Submission Failed: Network error - {e}"
137
- print(status_message)
138
- results_df = pd.DataFrame(results_log)
139
- return status_message, results_df
140
- except Exception as e:
141
- status_message = f"An unexpected error occurred during submission: {e}"
142
- print(status_message)
143
- results_df = pd.DataFrame(results_log)
144
- return status_message, results_df
145
-
146
-
147
- # --- Build Gradio Interface using Blocks ---
148
  with gr.Blocks() as demo:
149
- gr.Markdown("# Basic Agent Evaluation Runner")
150
  gr.Markdown(
151
  """
152
- **Instructions:**
153
-
154
- 1. Please clone this space, then modify the code to define your agent's logic, the tools, the necessary packages, etc ...
155
- 2. Log in to your Hugging Face account using the button below. This uses your HF username for submission.
156
- 3. Click 'Run Evaluation & Submit All Answers' to fetch questions, run your agent, submit answers, and see the score.
157
 
158
- ---
159
- **Disclaimers:**
160
- 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).
161
- 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.
162
  """
163
  )
164
 
165
  gr.LoginButton()
166
 
167
- run_button = gr.Button("Run Evaluation & Submit All Answers")
 
 
 
 
 
 
 
 
 
168
 
169
- status_output = gr.Textbox(label="Run Status / Submission Result", lines=5, interactive=False)
170
- # Removed max_rows=10 from DataFrame constructor
171
- results_table = gr.DataFrame(label="Questions and Agent Answers", wrap=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
 
173
  run_button.click(
174
  fn=run_and_submit_all,
175
- outputs=[status_output, results_table]
176
  )
177
 
 
 
 
 
178
  if __name__ == "__main__":
179
- print("\n" + "-"*30 + " App Starting " + "-"*30)
180
- # Check for SPACE_HOST and SPACE_ID at startup for information
181
- space_host_startup = os.getenv("SPACE_HOST")
182
- space_id_startup = os.getenv("SPACE_ID") # Get SPACE_ID at startup
183
-
184
- if space_host_startup:
185
- print(f"✅ SPACE_HOST found: {space_host_startup}")
186
- print(f" Runtime URL should be: https://{space_host_startup}.hf.space")
187
- else:
188
- print("ℹ️ SPACE_HOST environment variable not found (running locally?).")
189
-
190
- if space_id_startup: # Print repo URLs if SPACE_ID is found
191
- print(f"✅ SPACE_ID found: {space_id_startup}")
192
- print(f" Repo URL: https://huggingface.co/spaces/{space_id_startup}")
193
- print(f" Repo Tree URL: https://huggingface.co/spaces/{space_id_startup}/tree/main")
194
- else:
195
- print("ℹ️ SPACE_ID environment variable not found (running locally?). Repo URL cannot be determined.")
196
-
197
- print("-"*(60 + len(" App Starting ")) + "\n")
198
-
199
- print("Launching Gradio Interface for Basic Agent Evaluation...")
200
- demo.launch(debug=True, share=False)
 
1
  import os
2
+ import tempfile
3
+ from pathlib import Path
4
+
5
  import gradio as gr
 
 
6
  import pandas as pd
7
+ import requests
8
  import spaces
9
 
 
 
 
10
  DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
11
+ DEFAULT_SPACE_ID = "Miladsaeedi70/Final_Assignment_Template"
12
+ SPACE_OWNER = os.getenv("SPACE_OWNER", "Miladsaeedi70").strip()
13
+
14
+ _AGENT_INSTANCE = None
15
+
16
+
17
+ def get_agent():
18
+ """Import and initialize the production OpenAI agent lazily."""
19
+
20
+ global _AGENT_INSTANCE
21
+
22
+ if _AGENT_INSTANCE is not None:
23
+ return _AGENT_INSTANCE
24
+
25
+ from agent import GaiaAgent
26
+
27
+ _AGENT_INSTANCE = GaiaAgent()
28
+ return _AGENT_INSTANCE
29
 
30
+
31
+ def validate_profile(
32
+ profile: gr.OAuthProfile | None,
33
+ ) -> tuple[str | None, str | None]:
34
+ """Return (username, error_message) for the authenticated Space user."""
35
+
36
+ if profile is None:
37
+ return None, "Please log in to Hugging Face first."
38
+
39
+ username = str(profile.username).strip()
40
+ if not username:
41
+ return None, "Hugging Face login did not return a username."
42
+
43
+ if SPACE_OWNER and username.lower() != SPACE_OWNER.lower():
44
+ return (
45
+ None,
46
+ "This public Space is restricted to its owner to prevent "
47
+ "unauthorized OpenAI API usage.",
48
+ )
49
+
50
+ return username, None
51
 
52
 
53
  @spaces.GPU
54
+ def test_zero_gpu() -> str:
55
  """
56
+ Small ZeroGPU probe required by the Space hardware configuration.
57
+
58
+ The full GAIA evaluation is intentionally not decorated because GPT-4.1
59
+ runs through the OpenAI API and does not use the allocated Hugging Face GPU.
60
  """
61
+ return "ZeroGPU function executed successfully."
62
+
63
+
64
+ def run_preflight(
65
+ profile: gr.OAuthProfile | None,
66
+ ) -> str:
67
+ """Validate authentication, dependencies, API key, and model access."""
68
 
69
+ username, error_message = validate_profile(profile)
70
+ if error_message:
71
+ return error_message
72
+
73
+ try:
74
+ agent = get_agent()
75
+ result = agent.health_check()
76
+ except Exception as error:
77
+ return (
78
+ "Preflight failed: "
79
+ f"{type(error).__name__}: {error}"
80
+ )
81
+
82
+ checks = [
83
+ f"User: {username}",
84
+ f"Text model: {result['text_model']}",
85
+ f"Vision model: {result['vision_model']}",
86
+ f"Audio model: {result['audio_model']}",
87
+ f"Text response: {result['text_response']}",
88
+ f"Stockfish available: {result['stockfish_available']}",
89
+ f"FFmpeg available: {result['ffmpeg_available']}",
90
+ ]
91
+
92
+ if result["text_response"].strip().upper() != "OK":
93
+ checks.append(
94
+ "Warning: the model responded, but not with the expected exact word OK."
95
+ )
96
+
97
+ return "Preflight completed.\n" + "\n".join(checks)
98
+
99
+
100
+ def download_task_attachment(
101
+ api_url: str,
102
+ task_id: str,
103
+ file_name: str,
104
+ output_directory: Path,
105
+ ) -> str:
106
+ """Download one GAIA attachment and return its local path."""
107
+
108
+ safe_name = Path(file_name).name
109
+ output_path = output_directory / f"{task_id}_{safe_name}"
110
+
111
+ response = requests.get(
112
+ f"{api_url}/files/{task_id}",
113
+ timeout=120,
114
+ )
115
+ response.raise_for_status()
116
+
117
+ if not response.content:
118
+ raise RuntimeError("The attachment response was empty.")
119
+
120
+ content_type = response.headers.get("Content-Type", "").lower()
121
+ if "application/json" in content_type:
122
+ try:
123
+ payload = response.json()
124
+ except ValueError:
125
+ payload = {}
126
+
127
+ detail = payload.get("detail")
128
+ if detail:
129
+ raise RuntimeError(f"Attachment API error: {detail}")
130
+
131
+ output_path.write_bytes(response.content)
132
+ return str(output_path)
133
+
134
+
135
+ def run_and_submit_all(
136
+ profile: gr.OAuthProfile | None,
137
+ ):
138
+ """Run the LangGraph agent on all GAIA questions and submit answers."""
139
+
140
+ username, error_message = validate_profile(profile)
141
+ if error_message:
142
+ return error_message, None
143
+
144
+ print(f"User logged in: {username}")
145
 
146
  api_url = DEFAULT_API_URL
147
  questions_url = f"{api_url}/questions"
148
  submit_url = f"{api_url}/submit"
149
 
150
+ space_id = os.getenv("SPACE_ID", DEFAULT_SPACE_ID)
 
 
 
 
 
 
151
  agent_code = f"https://huggingface.co/spaces/{space_id}/tree/main"
 
152
 
 
 
153
  try:
154
+ agent = get_agent()
155
+ except Exception as error:
156
+ message = (
157
+ "Agent initialization failed: "
158
+ f"{type(error).__name__}: {error}"
159
+ )
160
+ print(message)
161
+ return message, None
162
+
163
+ try:
164
+ response = requests.get(questions_url, timeout=30)
165
  response.raise_for_status()
166
  questions_data = response.json()
167
+ except Exception as error:
168
+ message = (
169
+ "Could not fetch the questions: "
170
+ f"{type(error).__name__}: {error}"
171
+ )
172
+ print(message)
173
+ return message, None
174
+
175
+ if not isinstance(questions_data, list) or not questions_data:
176
+ return "The questions endpoint returned no questions.", None
177
+
178
+ results_log: list[dict] = []
179
+ answers_payload: list[dict] = []
180
+
181
+ with tempfile.TemporaryDirectory(prefix="gaia_attachments_") as directory:
182
+ attachment_directory = Path(directory)
183
+
184
+ for question_number, item in enumerate(questions_data, start=1):
185
+ task_id = str(item.get("task_id", "")).strip()
186
+ question_text = str(item.get("question", "")).strip()
187
+ file_name = str(item.get("file_name", "") or "").strip()
188
+
189
+ if not task_id or not question_text:
190
+ print(f"Skipping invalid question item: {item}")
191
+ continue
192
+
193
+ print("\n" + "=" * 80)
194
+ print(f"QUESTION {question_number}/{len(questions_data)}")
195
+ print(f"Task ID: {task_id}")
196
+ print(f"Attachment: {file_name or 'None'}")
197
+ print(f"Question: {question_text}")
198
+ print("=" * 80)
199
+
200
+ input_file: str | None = None
201
+ submitted_answer = ""
202
+ error_text = ""
203
+
204
+ try:
205
+ if file_name:
206
+ input_file = download_task_attachment(
207
+ api_url=api_url,
208
+ task_id=task_id,
209
+ file_name=file_name,
210
+ output_directory=attachment_directory,
211
+ )
212
+ print(f"Downloaded attachment: {input_file}")
213
+
214
+ submitted_answer = agent(
215
+ question=question_text,
216
+ input_file=input_file,
217
+ )
218
+
219
+ except Exception as error:
220
+ error_text = f"{type(error).__name__}: {error}"
221
+ print(f"Agent error for {task_id}: {error_text}")
222
+ submitted_answer = ""
223
+
224
+ submitted_answer = str(submitted_answer or "").strip()
225
+
226
+ answers_payload.append(
227
+ {
228
+ "task_id": task_id,
229
+ "submitted_answer": submitted_answer,
230
+ }
231
+ )
232
+
233
+ results_log.append(
234
+ {
235
+ "Task ID": task_id,
236
+ "Question": question_text,
237
+ "Attachment": file_name,
238
+ "Submitted Answer": submitted_answer,
239
+ "Error": error_text,
240
+ }
241
+ )
242
+
243
+ print(f"Submitted answer: {submitted_answer or '[blank]'}")
244
 
245
  if not answers_payload:
246
+ return (
247
+ "The agent did not produce any submission records.",
248
+ pd.DataFrame(results_log),
249
+ )
250
 
251
+ submission_data = {
252
+ "username": username,
253
+ "agent_code": agent_code,
254
+ "answers": answers_payload,
255
+ }
256
 
 
 
257
  try:
258
+ response = requests.post(
259
+ submit_url,
260
+ json=submission_data,
261
+ timeout=180,
262
+ )
263
  response.raise_for_status()
264
  result_data = response.json()
265
+
266
  final_status = (
267
+ "Submission successful!\n"
268
+ f"User: {result_data.get('username', username)}\n"
269
+ f"Overall score: {result_data.get('score', 'N/A')}% "
270
+ f"({result_data.get('correct_count', '?')}/"
271
+ f"{result_data.get('total_attempted', '?')} correct)\n"
272
  f"Message: {result_data.get('message', 'No message received.')}"
273
  )
274
+
275
+ return final_status, pd.DataFrame(results_log)
276
+
277
+ except requests.HTTPError as error:
278
+ response_text = error.response.text[:1000] if error.response else ""
279
+ message = (
280
+ "Submission failed: "
281
+ f"HTTP {getattr(error.response, 'status_code', 'unknown')} - "
282
+ f"{response_text}"
283
+ )
284
+ return message, pd.DataFrame(results_log)
285
+
286
+ except Exception as error:
287
+ message = (
288
+ "Submission failed: "
289
+ f"{type(error).__name__}: {error}"
290
+ )
291
+ return message, pd.DataFrame(results_log)
292
+
293
+
 
 
 
 
 
 
 
 
 
 
 
 
294
  with gr.Blocks() as demo:
295
+ gr.Markdown("# GAIA Final Assignment Agent")
296
  gr.Markdown(
297
  """
298
+ Log in with Hugging Face, then run the complete 20-question evaluation.
299
+ The Space downloads task attachments, runs the LangGraph agent, and submits
300
+ only the final answers to the course scorer.
 
 
301
 
302
+ Only the Space owner can run the evaluation, which protects the private
303
+ OpenAI API key used by this public Space.
 
 
304
  """
305
  )
306
 
307
  gr.LoginButton()
308
 
309
+ zero_gpu_button = gr.Button(
310
+ "1. Test ZeroGPU",
311
+ )
312
+ preflight_button = gr.Button(
313
+ "2. Test OpenAI Configuration",
314
+ )
315
+ run_button = gr.Button(
316
+ "3. Run Evaluation & Submit All Answers",
317
+ variant="primary",
318
+ )
319
 
320
+ status_output = gr.Textbox(
321
+ label="Preflight / Submission Status",
322
+ lines=9,
323
+ interactive=False,
324
+ )
325
+
326
+ results_table = gr.DataFrame(
327
+ label="Questions and Agent Answers",
328
+ wrap=True,
329
+ )
330
+
331
+ zero_gpu_button.click(
332
+ fn=test_zero_gpu,
333
+ outputs=status_output,
334
+ )
335
+
336
+ preflight_button.click(
337
+ fn=run_preflight,
338
+ outputs=status_output,
339
+ )
340
 
341
  run_button.click(
342
  fn=run_and_submit_all,
343
+ outputs=[status_output, results_table],
344
  )
345
 
346
+
347
+ demo.queue(default_concurrency_limit=1)
348
+
349
+
350
  if __name__ == "__main__":
351
+ print("Starting GAIA Final Assignment Space")
352
+ print("SPACE_ID:", os.getenv("SPACE_ID", DEFAULT_SPACE_ID))
353
+ print("SPACE_OWNER:", SPACE_OWNER or "[not restricted]")
354
+ print("OPENAI_API_KEY configured:", bool(os.getenv("OPENAI_API_KEY")))
355
+ demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
packages.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ ffmpeg
2
+ stockfish
requirements.txt CHANGED
@@ -1,2 +1,21 @@
1
- gradio
2
- requests
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio[oauth]==5.49.1
2
+ requests>=2.32
3
+ pandas>=2.2
4
+ numpy>=1.26
5
+ beautifulsoup4>=4.12
6
+ lxml>=5.0
7
+ Pillow>=10.0
8
+ pypdf>=5.0
9
+ openpyxl>=3.1
10
+ xlrd>=2.0
11
+ python-chess>=1.999
12
+ youtube-transcript-api>=1.0
13
+ yt-dlp[default]
14
+ opencv-python-headless>=4.10
15
+ ddgs>=9.0
16
+ openai>=1.0
17
+ langchain>=0.3
18
+ langchain-core>=0.3
19
+ langchain-openai>=0.3
20
+ langgraph>=0.4
21
+ huggingface_hub>=1.0