Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
| 1 |
import os
|
| 2 |
import re
|
| 3 |
import base64
|
|
|
|
|
|
|
|
|
|
| 4 |
from io import BytesIO
|
| 5 |
from pathlib import Path
|
| 6 |
from zipfile import ZipFile
|
|
@@ -21,13 +24,93 @@ from smolagents import (
|
|
| 21 |
DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
|
| 22 |
RESULT_COLUMNS = ["Task ID", "Question", "Submitted Answer"]
|
| 23 |
HTTP_TIMEOUT = 45
|
| 24 |
-
MAX_EXTRACTED_CHARS =
|
| 25 |
WEBPAGE_CONNECT_TIMEOUT = 8
|
| 26 |
WEBPAGE_READ_TIMEOUT = 20
|
| 27 |
-
MAX_WEBPAGE_CHARS =
|
| 28 |
DEFAULT_MAIN_MODEL = "groq/qwen/qwen3.6-27b"
|
| 29 |
DEFAULT_GROQ_REVIEW_MODEL = "groq/qwen/qwen3.6-27b"
|
| 30 |
TASK_FILE_CACHE = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
def clean_filename(value: str) -> str:
|
|
@@ -70,10 +153,15 @@ def filename_from_response(response: requests.Response, task_id: str) -> str:
|
|
| 70 |
def download_gaia_attachment(task_id: str) -> tuple[bytes, str]:
|
| 71 |
"""Baixa um anexo pela API do curso, com fallback para o dataset oficial."""
|
| 72 |
task_id = str(task_id).strip()
|
|
|
|
|
|
|
|
|
|
| 73 |
course_url = f"{DEFAULT_API_URL}/files/{task_id}"
|
| 74 |
response = requests.get(course_url, timeout=HTTP_TIMEOUT)
|
| 75 |
if response.ok:
|
| 76 |
-
|
|
|
|
|
|
|
| 77 |
if response.status_code != 404:
|
| 78 |
response.raise_for_status()
|
| 79 |
|
|
@@ -114,7 +202,9 @@ def download_gaia_attachment(task_id: str) -> tuple[bytes, str]:
|
|
| 114 |
filename=dataset_path,
|
| 115 |
token=token,
|
| 116 |
)
|
| 117 |
-
|
|
|
|
|
|
|
| 118 |
except Exception as exc:
|
| 119 |
errors.append(str(exc))
|
| 120 |
|
|
@@ -151,7 +241,9 @@ def get_task_file_name(task_id: str) -> str:
|
|
| 151 |
return TASK_FILE_CACHE.get(task_id, "")
|
| 152 |
|
| 153 |
|
| 154 |
-
def extract_attachment_text(
|
|
|
|
|
|
|
| 155 |
"""Extrai conteúdo legível dos formatos mais comuns do GAIA."""
|
| 156 |
suffix = Path(filename).suffix.lower()
|
| 157 |
|
|
@@ -205,20 +297,10 @@ def extract_attachment_text(data: bytes, filename: str) -> str:
|
|
| 205 |
|
| 206 |
text = BeautifulSoup(text, "html.parser").get_text("\n")
|
| 207 |
elif suffix in {".mp3", ".wav", ".flac", ".m4a", ".ogg"}:
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
text = "Audio transcription failed: HF_TOKEN is not configured."
|
| 213 |
-
else:
|
| 214 |
-
client = InferenceClient(api_key=token, provider="auto")
|
| 215 |
-
asr_model = os.getenv(
|
| 216 |
-
"GAIA_ASR_MODEL", "distil-whisper/distil-large-v3"
|
| 217 |
-
)
|
| 218 |
-
transcript = client.automatic_speech_recognition(
|
| 219 |
-
data, model=asr_model
|
| 220 |
-
)
|
| 221 |
-
text = f"Audio transcript:\n{transcript.text}"
|
| 222 |
elif suffix == ".zip":
|
| 223 |
with ZipFile(BytesIO(data)) as archive:
|
| 224 |
text = "Files inside ZIP:\n" + "\n".join(archive.namelist())
|
|
@@ -241,8 +323,16 @@ def extract_attachment_text(data: bytes, filename: str) -> str:
|
|
| 241 |
text = text.strip()
|
| 242 |
if not text:
|
| 243 |
return f"The attachment {filename} was downloaded but contained no extractable text."
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
return text
|
| 247 |
|
| 248 |
|
|
@@ -259,16 +349,26 @@ class OpenWebPageTool(Tool):
|
|
| 259 |
"url": {
|
| 260 |
"type": "string",
|
| 261 |
"description": "The complete HTTP or HTTPS URL to open.",
|
| 262 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
}
|
| 264 |
output_type = "string"
|
| 265 |
|
| 266 |
-
def forward(self, url: str) -> str:
|
| 267 |
from markdownify import markdownify
|
| 268 |
|
| 269 |
url = str(url or "").strip()
|
|
|
|
| 270 |
if not re.match(r"^https?://", url, flags=re.I):
|
| 271 |
return "Invalid URL: visit_webpage requires a full HTTP/HTTPS URL."
|
|
|
|
|
|
|
| 272 |
|
| 273 |
headers = {
|
| 274 |
"User-Agent": (
|
|
@@ -358,47 +458,488 @@ class OpenWebPageTool(Tool):
|
|
| 358 |
+ "\n".join(source_links[-25:])
|
| 359 |
)
|
| 360 |
text = re.sub(r"\n{3,}", "\n\n", text).strip()
|
| 361 |
-
if
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
text[:head_size]
|
| 366 |
-
+ "\n\n[page middle omitted to conserve tokens]\n\n"
|
| 367 |
-
+ text[-tail_size:]
|
| 368 |
-
)
|
| 369 |
-
return text or "The page was retrieved but contained no text."
|
| 370 |
except Exception as exc:
|
| 371 |
-
errors.append(f"{target}: {exc}")
|
| 372 |
|
| 373 |
return "Error fetching the webpage: " + " | ".join(errors)
|
| 374 |
|
| 375 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
class InspectGaiaAttachmentTool(Tool):
|
| 377 |
name = "inspect_gaia_attachment"
|
| 378 |
description = (
|
| 379 |
"Downloads and reads the official attachment associated with a GAIA "
|
| 380 |
-
"task. Use it
|
| 381 |
-
"
|
| 382 |
)
|
| 383 |
inputs = {
|
| 384 |
"task_id": {
|
| 385 |
"type": "string",
|
| 386 |
"description": "The exact GAIA task_id supplied in the user task.",
|
| 387 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
}
|
| 389 |
output_type = "string"
|
| 390 |
|
| 391 |
-
def forward(self, task_id: str) -> str:
|
| 392 |
task_id = str(task_id).strip()
|
| 393 |
if not task_id:
|
| 394 |
return "No task_id was supplied."
|
| 395 |
|
| 396 |
try:
|
| 397 |
data, filename = download_gaia_attachment(task_id)
|
| 398 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
return f"Attachment filename: {filename}\n\n{extracted}"
|
| 400 |
except Exception as exc:
|
| 401 |
-
return
|
|
|
|
|
|
|
|
|
|
| 402 |
|
| 403 |
|
| 404 |
class YouTubeTranscriptTool(Tool):
|
|
@@ -412,11 +953,15 @@ class YouTubeTranscriptTool(Tool):
|
|
| 412 |
"url": {
|
| 413 |
"type": "string",
|
| 414 |
"description": "Full YouTube URL or the 11-character video ID.",
|
| 415 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
}
|
| 417 |
output_type = "string"
|
| 418 |
|
| 419 |
-
def forward(self, url: str) -> str:
|
| 420 |
from youtube_transcript_api import YouTubeTranscriptApi
|
| 421 |
|
| 422 |
value = str(url or "").strip()
|
|
@@ -438,9 +983,16 @@ class YouTubeTranscriptTool(Tool):
|
|
| 438 |
if text:
|
| 439 |
lines.append(str(text))
|
| 440 |
result = " ".join(lines).strip()
|
| 441 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
except Exception as exc:
|
| 443 |
-
return
|
|
|
|
|
|
|
|
|
|
| 444 |
|
| 445 |
|
| 446 |
class AnalyzeGaiaImageTool(Tool):
|
|
@@ -499,8 +1051,9 @@ class AnalyzeGaiaImageTool(Tool):
|
|
| 499 |
"type": "text",
|
| 500 |
"text": (
|
| 501 |
"Analyze the supplied image carefully and "
|
| 502 |
-
"answer this task.
|
| 503 |
-
"
|
|
|
|
| 504 |
f"{question}"
|
| 505 |
),
|
| 506 |
},
|
|
@@ -514,7 +1067,7 @@ class AnalyzeGaiaImageTool(Tool):
|
|
| 514 |
}
|
| 515 |
],
|
| 516 |
"temperature": 0,
|
| 517 |
-
"max_tokens":
|
| 518 |
},
|
| 519 |
timeout=HTTP_TIMEOUT,
|
| 520 |
)
|
|
@@ -522,7 +1075,10 @@ class AnalyzeGaiaImageTool(Tool):
|
|
| 522 |
payload = response.json()
|
| 523 |
return str(payload["choices"][0]["message"]["content"]).strip()
|
| 524 |
except Exception as exc:
|
| 525 |
-
return
|
|
|
|
|
|
|
|
|
|
| 526 |
|
| 527 |
|
| 528 |
class BasicAgent:
|
|
@@ -559,7 +1115,7 @@ class BasicAgent:
|
|
| 559 |
print(f"Modelo principal selecionado: {model_id}")
|
| 560 |
|
| 561 |
web_search_tool = DuckDuckGoSearchTool(
|
| 562 |
-
max_results=
|
| 563 |
)
|
| 564 |
web_search_tool.description = (
|
| 565 |
"Searches the public web and returns result titles, URLs, and short "
|
|
@@ -581,10 +1137,14 @@ class BasicAgent:
|
|
| 581 |
agent_tools = [
|
| 582 |
web_search_tool,
|
| 583 |
visit_page_tool,
|
|
|
|
| 584 |
wikipedia_tool,
|
| 585 |
InspectGaiaAttachmentTool(),
|
|
|
|
|
|
|
| 586 |
YouTubeTranscriptTool(),
|
| 587 |
AnalyzeGaiaImageTool(),
|
|
|
|
| 588 |
]
|
| 589 |
|
| 590 |
# Qwen returns native tool calls. ToolCallingAgent handles that
|
|
@@ -592,7 +1152,7 @@ class BasicAgent:
|
|
| 592 |
self.agent = ToolCallingAgent(
|
| 593 |
tools=agent_tools,
|
| 594 |
model=self.model,
|
| 595 |
-
max_steps=
|
| 596 |
planning_interval=None,
|
| 597 |
description=(
|
| 598 |
"Agent designed to solve GAIA benchmark questions with "
|
|
@@ -605,22 +1165,25 @@ You are an expert AI assistant solving tasks from the GAIA benchmark.
|
|
| 605 |
|
| 606 |
TOOL ROUTING POLICY:
|
| 607 |
1. web_search discovers URLs and snippets. It does not read full pages.
|
| 608 |
-
2. visit_webpage
|
| 609 |
-
|
| 610 |
-
3.
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
|
|
|
| 619 |
|
| 620 |
Research carefully, prefer primary or official sources, and cross-check
|
| 621 |
uncertain facts. A search snippet alone is insufficient when the source page
|
| 622 |
can be opened. Never invent a tool, use subprocess, or use shell commands.
|
| 623 |
-
Do not repeat nearly identical searches
|
|
|
|
|
|
|
| 624 |
|
| 625 |
FINAL RESPONSE POLICY:
|
| 626 |
Call the final_answer tool with only the requested value. Never write the
|
|
@@ -647,15 +1210,36 @@ include reasoning, explanations, labels, Markdown, citations, or the words
|
|
| 647 |
|
| 648 |
if task_id:
|
| 649 |
attachment_name = get_task_file_name(task_id)
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
"
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
"Official attachment:
|
| 656 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 657 |
)
|
| 658 |
-
)
|
| 659 |
task_context = (
|
| 660 |
f"GAIA task_id: {task_id}\n"
|
| 661 |
f"{attachment_context}\n\nQuestion: {question}"
|
|
@@ -755,6 +1339,18 @@ include reasoning, explanations, labels, Markdown, citations, or the words
|
|
| 755 |
|
| 756 |
text = cls.deterministic_answer_cleanup(text)
|
| 757 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 758 |
quantity_question = (
|
| 759 |
"how many" in question_lower
|
| 760 |
or "numeric output" in question_lower
|
|
@@ -899,41 +1495,18 @@ include reasoning, explanations, labels, Markdown, citations, or the words
|
|
| 899 |
if not reviewer_model.lower().startswith("groq/qwen/"):
|
| 900 |
reviewer_model = DEFAULT_GROQ_REVIEW_MODEL
|
| 901 |
review_prompt = f"""
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
{question}
|
| 907 |
-
|
| 908 |
-
|
| 909 |
-
{candidate}
|
| 910 |
-
|
| 911 |
-
Review the candidate for both likely correctness and exact requested format.
|
| 912 |
-
Preserve it unless there is a clear factual, logical, ordering, spelling, or
|
| 913 |
-
formatting error. Never add explanations to final_answer.
|
| 914 |
-
|
| 915 |
-
Formatting rules:
|
| 916 |
-
- If asked "how many", for a count, or for a numeric output: final_answer must
|
| 917 |
-
contain only the number, unless the question explicitly requests currency,
|
| 918 |
-
units, decimals, or another representation.
|
| 919 |
-
- If asked for a first name, surname, city, country, or IOC code: return only
|
| 920 |
-
that requested value.
|
| 921 |
-
- If asked for a list: return only the list, with the exact requested separator,
|
| 922 |
-
ordering, capitalization, and plurality.
|
| 923 |
-
- If asked for a quote: return only the requested spoken words.
|
| 924 |
-
- If asked for a chess move: return only algebraic notation.
|
| 925 |
-
- Never include labels, Markdown, citations, rationale, or "FINAL ANSWER" inside
|
| 926 |
-
the final_answer value.
|
| 927 |
-
|
| 928 |
-
Return exactly these two XML-style fields:
|
| 929 |
<final_answer>exact value to submit</final_answer>
|
| 930 |
-
<review_note>brief reason, maximum 20 words</review_note>
|
| 931 |
-
|
| 932 |
-
Do not return JSON. Do not wrap the fields in Markdown or a code block.
|
| 933 |
""".strip()
|
| 934 |
|
| 935 |
last_error = None
|
| 936 |
-
for attempt in range(
|
| 937 |
try:
|
| 938 |
retry_instruction = (
|
| 939 |
""
|
|
@@ -950,7 +1523,8 @@ Do not return JSON. Do not wrap the fields in Markdown or a code block.
|
|
| 950 |
}
|
| 951 |
],
|
| 952 |
temperature=0,
|
| 953 |
-
max_tokens=
|
|
|
|
| 954 |
)
|
| 955 |
content = str(response.choices[0].message.content).strip()
|
| 956 |
content = re.sub(
|
|
|
|
| 1 |
import os
|
| 2 |
import re
|
| 3 |
import base64
|
| 4 |
+
import ast
|
| 5 |
+
import math
|
| 6 |
+
import operator
|
| 7 |
from io import BytesIO
|
| 8 |
from pathlib import Path
|
| 9 |
from zipfile import ZipFile
|
|
|
|
| 24 |
DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
|
| 25 |
RESULT_COLUMNS = ["Task ID", "Question", "Submitted Answer"]
|
| 26 |
HTTP_TIMEOUT = 45
|
| 27 |
+
MAX_EXTRACTED_CHARS = 8_000
|
| 28 |
WEBPAGE_CONNECT_TIMEOUT = 8
|
| 29 |
WEBPAGE_READ_TIMEOUT = 20
|
| 30 |
+
MAX_WEBPAGE_CHARS = 6_000
|
| 31 |
DEFAULT_MAIN_MODEL = "groq/qwen/qwen3.6-27b"
|
| 32 |
DEFAULT_GROQ_REVIEW_MODEL = "groq/qwen/qwen3.6-27b"
|
| 33 |
TASK_FILE_CACHE = {}
|
| 34 |
+
ATTACHMENT_CACHE = {}
|
| 35 |
+
WEBPAGE_CACHE = {}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def compact_error(exc: Exception) -> str:
|
| 39 |
+
message = str(exc).strip()
|
| 40 |
+
return message or repr(exc)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def focus_text(text: str, query: str, max_chars: int = 5_000) -> str:
|
| 44 |
+
"""Selects high-signal passages locally, before text reaches the LLM."""
|
| 45 |
+
text = re.sub(r"\r\n?", "\n", str(text or ""))
|
| 46 |
+
text = re.sub(r"[ \t]+", " ", text)
|
| 47 |
+
text = re.sub(r"\n{3,}", "\n\n", text).strip()
|
| 48 |
+
if not text or len(text) <= max_chars:
|
| 49 |
+
return text
|
| 50 |
+
|
| 51 |
+
stopwords = {
|
| 52 |
+
"about", "after", "again", "also", "article", "attached", "before",
|
| 53 |
+
"could", "find", "from", "have", "into", "just", "mentions", "please",
|
| 54 |
+
"provide", "question", "should", "that", "their", "there", "these",
|
| 55 |
+
"this", "under", "what", "when", "where", "which", "with", "work",
|
| 56 |
+
"would", "your",
|
| 57 |
+
}
|
| 58 |
+
terms = {
|
| 59 |
+
token.lower()
|
| 60 |
+
for token in re.findall(r"[A-Za-z0-9][A-Za-z0-9._-]{2,}", query or "")
|
| 61 |
+
if token.lower() not in stopwords
|
| 62 |
+
}
|
| 63 |
+
blocks = [
|
| 64 |
+
block.strip()
|
| 65 |
+
for block in re.split(r"\n\s*\n", text)
|
| 66 |
+
if block.strip()
|
| 67 |
+
]
|
| 68 |
+
if not blocks:
|
| 69 |
+
return text[:max_chars]
|
| 70 |
+
|
| 71 |
+
scored = []
|
| 72 |
+
for index, block in enumerate(blocks):
|
| 73 |
+
lowered = block.lower()
|
| 74 |
+
hits = sum(lowered.count(term) for term in terms)
|
| 75 |
+
exact_bonus = 4 if query and query.lower() in lowered else 0
|
| 76 |
+
signal_bonus = 2 if re.search(
|
| 77 |
+
r"\b(acknowledg|award|grant|answer|result|total|page|pages)\b",
|
| 78 |
+
lowered,
|
| 79 |
+
) else 0
|
| 80 |
+
scored.append((hits * 3 + exact_bonus + signal_bonus, index))
|
| 81 |
+
|
| 82 |
+
best_indices = [
|
| 83 |
+
index
|
| 84 |
+
for score, index in sorted(scored, reverse=True)
|
| 85 |
+
if score > 0
|
| 86 |
+
][:10]
|
| 87 |
+
if not best_indices:
|
| 88 |
+
return (
|
| 89 |
+
text[: max_chars * 2 // 3]
|
| 90 |
+
+ "\n\n[content omitted]\n\n"
|
| 91 |
+
+ text[-max_chars // 3 :]
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
selected = {}
|
| 95 |
+
used = 0
|
| 96 |
+
for best_index in best_indices:
|
| 97 |
+
# Reserve space for the matching block before optional neighbors.
|
| 98 |
+
for index in (best_index, best_index - 1, best_index + 1):
|
| 99 |
+
if (
|
| 100 |
+
index in selected
|
| 101 |
+
or not 0 <= index < len(blocks)
|
| 102 |
+
or used >= max_chars
|
| 103 |
+
):
|
| 104 |
+
continue
|
| 105 |
+
block = blocks[index]
|
| 106 |
+
if index != best_index and len(block) > 1_000:
|
| 107 |
+
block = block[:1_000]
|
| 108 |
+
remaining = max_chars - used
|
| 109 |
+
if remaining < 100:
|
| 110 |
+
break
|
| 111 |
+
selected[index] = block[:remaining]
|
| 112 |
+
used += len(selected[index]) + 2
|
| 113 |
+
return "\n\n".join(selected[index] for index in sorted(selected)).strip()
|
| 114 |
|
| 115 |
|
| 116 |
def clean_filename(value: str) -> str:
|
|
|
|
| 153 |
def download_gaia_attachment(task_id: str) -> tuple[bytes, str]:
|
| 154 |
"""Baixa um anexo pela API do curso, com fallback para o dataset oficial."""
|
| 155 |
task_id = str(task_id).strip()
|
| 156 |
+
if task_id in ATTACHMENT_CACHE:
|
| 157 |
+
return ATTACHMENT_CACHE[task_id]
|
| 158 |
+
|
| 159 |
course_url = f"{DEFAULT_API_URL}/files/{task_id}"
|
| 160 |
response = requests.get(course_url, timeout=HTTP_TIMEOUT)
|
| 161 |
if response.ok:
|
| 162 |
+
result = (response.content, filename_from_response(response, task_id))
|
| 163 |
+
ATTACHMENT_CACHE[task_id] = result
|
| 164 |
+
return result
|
| 165 |
if response.status_code != 404:
|
| 166 |
response.raise_for_status()
|
| 167 |
|
|
|
|
| 202 |
filename=dataset_path,
|
| 203 |
token=token,
|
| 204 |
)
|
| 205 |
+
result = (Path(local_path).read_bytes(), filename)
|
| 206 |
+
ATTACHMENT_CACHE[task_id] = result
|
| 207 |
+
return result
|
| 208 |
except Exception as exc:
|
| 209 |
errors.append(str(exc))
|
| 210 |
|
|
|
|
| 241 |
return TASK_FILE_CACHE.get(task_id, "")
|
| 242 |
|
| 243 |
|
| 244 |
+
def extract_attachment_text(
|
| 245 |
+
data: bytes, filename: str, query: str = ""
|
| 246 |
+
) -> str:
|
| 247 |
"""Extrai conteúdo legível dos formatos mais comuns do GAIA."""
|
| 248 |
suffix = Path(filename).suffix.lower()
|
| 249 |
|
|
|
|
| 297 |
|
| 298 |
text = BeautifulSoup(text, "html.parser").get_text("\n")
|
| 299 |
elif suffix in {".mp3", ".wav", ".flac", ".m4a", ".ogg"}:
|
| 300 |
+
text = (
|
| 301 |
+
"Audio attachment detected. Use transcribe_gaia_audio with the "
|
| 302 |
+
"task_id instead of inspect_gaia_attachment."
|
| 303 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
elif suffix == ".zip":
|
| 305 |
with ZipFile(BytesIO(data)) as archive:
|
| 306 |
text = "Files inside ZIP:\n" + "\n".join(archive.namelist())
|
|
|
|
| 323 |
text = text.strip()
|
| 324 |
if not text:
|
| 325 |
return f"The attachment {filename} was downloaded but contained no extractable text."
|
| 326 |
+
tabular = suffix in {".xlsx", ".xlsm", ".csv", ".tsv"}
|
| 327 |
+
if query and not tabular:
|
| 328 |
+
text = focus_text(text, query, MAX_EXTRACTED_CHARS)
|
| 329 |
+
elif len(text) > MAX_EXTRACTED_CHARS:
|
| 330 |
+
head_size = MAX_EXTRACTED_CHARS * 2 // 3
|
| 331 |
+
text = (
|
| 332 |
+
text[:head_size]
|
| 333 |
+
+ "\n\n[attachment middle omitted]\n\n"
|
| 334 |
+
+ text[-(MAX_EXTRACTED_CHARS - head_size) :]
|
| 335 |
+
)
|
| 336 |
return text
|
| 337 |
|
| 338 |
|
|
|
|
| 349 |
"url": {
|
| 350 |
"type": "string",
|
| 351 |
"description": "The complete HTTP or HTTPS URL to open.",
|
| 352 |
+
},
|
| 353 |
+
"query": {
|
| 354 |
+
"type": "string",
|
| 355 |
+
"description": (
|
| 356 |
+
"Short terms describing the exact fact to find on the page. "
|
| 357 |
+
"Do not repeat the full task."
|
| 358 |
+
),
|
| 359 |
+
},
|
| 360 |
}
|
| 361 |
output_type = "string"
|
| 362 |
|
| 363 |
+
def forward(self, url: str, query: str) -> str:
|
| 364 |
from markdownify import markdownify
|
| 365 |
|
| 366 |
url = str(url or "").strip()
|
| 367 |
+
query = str(query or "").strip()
|
| 368 |
if not re.match(r"^https?://", url, flags=re.I):
|
| 369 |
return "Invalid URL: visit_webpage requires a full HTTP/HTTPS URL."
|
| 370 |
+
if url in WEBPAGE_CACHE:
|
| 371 |
+
return focus_text(WEBPAGE_CACHE[url], query, MAX_WEBPAGE_CHARS)
|
| 372 |
|
| 373 |
headers = {
|
| 374 |
"User-Agent": (
|
|
|
|
| 458 |
+ "\n".join(source_links[-25:])
|
| 459 |
)
|
| 460 |
text = re.sub(r"\n{3,}", "\n\n", text).strip()
|
| 461 |
+
if text:
|
| 462 |
+
WEBPAGE_CACHE[url] = text[:50_000]
|
| 463 |
+
return focus_text(text, query, MAX_WEBPAGE_CHARS)
|
| 464 |
+
return "The page was retrieved but contained no text."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
except Exception as exc:
|
| 466 |
+
errors.append(f"{target}: {compact_error(exc)}")
|
| 467 |
|
| 468 |
return "Error fetching the webpage: " + " | ".join(errors)
|
| 469 |
|
| 470 |
|
| 471 |
+
class ReadDocumentUrlTool(Tool):
|
| 472 |
+
name = "read_document_url"
|
| 473 |
+
description = (
|
| 474 |
+
"Downloads and reads a PDF, DOCX, CSV, or text document from an exact "
|
| 475 |
+
"public URL, then returns only passages relevant to the supplied query. "
|
| 476 |
+
"Use it for linked papers and reports; use visit_webpage for HTML."
|
| 477 |
+
)
|
| 478 |
+
inputs = {
|
| 479 |
+
"url": {
|
| 480 |
+
"type": "string",
|
| 481 |
+
"description": "Direct public URL of the document.",
|
| 482 |
+
},
|
| 483 |
+
"query": {
|
| 484 |
+
"type": "string",
|
| 485 |
+
"description": "Short terms for the exact fact to find.",
|
| 486 |
+
},
|
| 487 |
+
}
|
| 488 |
+
output_type = "string"
|
| 489 |
+
|
| 490 |
+
def forward(self, url: str, query: str) -> str:
|
| 491 |
+
url = str(url or "").strip()
|
| 492 |
+
query = str(query or "").strip()
|
| 493 |
+
if not re.match(r"^https?://", url, flags=re.I):
|
| 494 |
+
return "Invalid document URL."
|
| 495 |
+
|
| 496 |
+
try:
|
| 497 |
+
response = requests.get(
|
| 498 |
+
url,
|
| 499 |
+
headers={"User-Agent": "GAIA-Course-Agent/1.0"},
|
| 500 |
+
timeout=(WEBPAGE_CONNECT_TIMEOUT, HTTP_TIMEOUT),
|
| 501 |
+
allow_redirects=True,
|
| 502 |
+
)
|
| 503 |
+
response.raise_for_status()
|
| 504 |
+
if len(response.content) > 25 * 1024 * 1024:
|
| 505 |
+
return "Document exceeds the 25 MB safety limit."
|
| 506 |
+
|
| 507 |
+
content_type = response.headers.get("content-type", "").lower()
|
| 508 |
+
if "text/html" in content_type:
|
| 509 |
+
return (
|
| 510 |
+
"This URL returned HTML. Use visit_webpage with the same "
|
| 511 |
+
"URL and a short query."
|
| 512 |
+
)
|
| 513 |
+
filename = filename_from_response(response, "web_document")
|
| 514 |
+
if not Path(filename).suffix:
|
| 515 |
+
from urllib.parse import urlparse
|
| 516 |
+
|
| 517 |
+
filename = Path(urlparse(response.url).path).name or filename
|
| 518 |
+
extracted = extract_attachment_text(
|
| 519 |
+
response.content, filename, query=query
|
| 520 |
+
)
|
| 521 |
+
return f"Document: {filename}\n\n{extracted}"
|
| 522 |
+
except Exception as exc:
|
| 523 |
+
return f"Could not read document URL: {compact_error(exc)}"
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
class TranscribeGaiaAudioTool(Tool):
|
| 527 |
+
name = "transcribe_gaia_audio"
|
| 528 |
+
description = (
|
| 529 |
+
"Downloads and transcribes the official GAIA audio attachment. Use it "
|
| 530 |
+
"first whenever the attachment is MP3, WAV, FLAC, M4A, OGG, or WEBM. "
|
| 531 |
+
"Speech transcription uses the separate audio quota, not chat tokens."
|
| 532 |
+
)
|
| 533 |
+
inputs = {
|
| 534 |
+
"task_id": {
|
| 535 |
+
"type": "string",
|
| 536 |
+
"description": "Exact GAIA task_id associated with the audio.",
|
| 537 |
+
}
|
| 538 |
+
}
|
| 539 |
+
output_type = "string"
|
| 540 |
+
|
| 541 |
+
def forward(self, task_id: str) -> str:
|
| 542 |
+
task_id = str(task_id or "").strip()
|
| 543 |
+
try:
|
| 544 |
+
data, filename = download_gaia_attachment(task_id)
|
| 545 |
+
suffix = Path(filename).suffix.lower()
|
| 546 |
+
if suffix not in {
|
| 547 |
+
".mp3", ".wav", ".flac", ".m4a", ".ogg", ".webm", ".mp4"
|
| 548 |
+
}:
|
| 549 |
+
return (
|
| 550 |
+
f"Attachment {filename} is not an audio file. Use "
|
| 551 |
+
"inspect_gaia_attachment."
|
| 552 |
+
)
|
| 553 |
+
|
| 554 |
+
errors = []
|
| 555 |
+
groq_api_key = os.getenv("GROQ_API_KEY")
|
| 556 |
+
if groq_api_key:
|
| 557 |
+
mime_types = {
|
| 558 |
+
".mp3": "audio/mpeg",
|
| 559 |
+
".wav": "audio/wav",
|
| 560 |
+
".flac": "audio/flac",
|
| 561 |
+
".m4a": "audio/mp4",
|
| 562 |
+
".ogg": "audio/ogg",
|
| 563 |
+
".webm": "audio/webm",
|
| 564 |
+
".mp4": "video/mp4",
|
| 565 |
+
}
|
| 566 |
+
try:
|
| 567 |
+
response = requests.post(
|
| 568 |
+
"https://api.groq.com/openai/v1/audio/transcriptions",
|
| 569 |
+
headers={"Authorization": f"Bearer {groq_api_key}"},
|
| 570 |
+
files={
|
| 571 |
+
"file": (
|
| 572 |
+
filename,
|
| 573 |
+
data,
|
| 574 |
+
mime_types.get(suffix, "application/octet-stream"),
|
| 575 |
+
)
|
| 576 |
+
},
|
| 577 |
+
data={
|
| 578 |
+
"model": "whisper-large-v3-turbo",
|
| 579 |
+
"response_format": "json",
|
| 580 |
+
"temperature": "0",
|
| 581 |
+
"language": "en",
|
| 582 |
+
},
|
| 583 |
+
timeout=(WEBPAGE_CONNECT_TIMEOUT, 90),
|
| 584 |
+
)
|
| 585 |
+
response.raise_for_status()
|
| 586 |
+
transcript = str(response.json().get("text") or "").strip()
|
| 587 |
+
if transcript:
|
| 588 |
+
return (
|
| 589 |
+
f"Audio transcript ({filename}):\n"
|
| 590 |
+
f"{transcript[:6_000]}"
|
| 591 |
+
)
|
| 592 |
+
errors.append("Groq returned an empty transcript.")
|
| 593 |
+
except Exception as exc:
|
| 594 |
+
detail = compact_error(exc)
|
| 595 |
+
if "response" in locals() and response is not None:
|
| 596 |
+
detail += f" Response: {response.text[:500]}"
|
| 597 |
+
errors.append(f"Groq speech-to-text: {detail}")
|
| 598 |
+
|
| 599 |
+
hf_token = os.getenv("HF_TOKEN")
|
| 600 |
+
if hf_token:
|
| 601 |
+
try:
|
| 602 |
+
from huggingface_hub import InferenceClient
|
| 603 |
+
|
| 604 |
+
client = InferenceClient(api_key=hf_token, provider="auto")
|
| 605 |
+
transcript_result = client.automatic_speech_recognition(
|
| 606 |
+
data,
|
| 607 |
+
model=os.getenv(
|
| 608 |
+
"GAIA_ASR_MODEL", "openai/whisper-large-v3"
|
| 609 |
+
),
|
| 610 |
+
)
|
| 611 |
+
transcript = str(
|
| 612 |
+
getattr(transcript_result, "text", transcript_result)
|
| 613 |
+
).strip()
|
| 614 |
+
if transcript:
|
| 615 |
+
return (
|
| 616 |
+
f"Audio transcript ({filename}):\n"
|
| 617 |
+
f"{transcript[:6_000]}"
|
| 618 |
+
)
|
| 619 |
+
errors.append("Hugging Face returned an empty transcript.")
|
| 620 |
+
except Exception as exc:
|
| 621 |
+
errors.append(
|
| 622 |
+
f"Hugging Face speech-to-text: {compact_error(exc)}"
|
| 623 |
+
)
|
| 624 |
+
|
| 625 |
+
return "Audio transcription failed. " + " | ".join(errors)
|
| 626 |
+
except Exception as exc:
|
| 627 |
+
return (
|
| 628 |
+
f"Could not transcribe audio for task {task_id}: "
|
| 629 |
+
f"{compact_error(exc)}"
|
| 630 |
+
)
|
| 631 |
+
|
| 632 |
+
|
| 633 |
+
class CalculatorTool(Tool):
|
| 634 |
+
name = "calculator"
|
| 635 |
+
description = (
|
| 636 |
+
"Evaluates arithmetic locally without an LLM. Supports +, -, *, /, //, "
|
| 637 |
+
"**, %, parentheses, pi, e, sqrt, log, exp, sin, cos, tan, abs, and round."
|
| 638 |
+
)
|
| 639 |
+
inputs = {
|
| 640 |
+
"expression": {
|
| 641 |
+
"type": "string",
|
| 642 |
+
"description": "Arithmetic expression to evaluate.",
|
| 643 |
+
}
|
| 644 |
+
}
|
| 645 |
+
output_type = "string"
|
| 646 |
+
|
| 647 |
+
def forward(self, expression: str) -> str:
|
| 648 |
+
binary_ops = {
|
| 649 |
+
ast.Add: operator.add,
|
| 650 |
+
ast.Sub: operator.sub,
|
| 651 |
+
ast.Mult: operator.mul,
|
| 652 |
+
ast.Div: operator.truediv,
|
| 653 |
+
ast.FloorDiv: operator.floordiv,
|
| 654 |
+
ast.Mod: operator.mod,
|
| 655 |
+
ast.Pow: operator.pow,
|
| 656 |
+
}
|
| 657 |
+
unary_ops = {ast.UAdd: operator.pos, ast.USub: operator.neg}
|
| 658 |
+
functions = {
|
| 659 |
+
"abs": abs,
|
| 660 |
+
"round": round,
|
| 661 |
+
"sqrt": math.sqrt,
|
| 662 |
+
"log": math.log,
|
| 663 |
+
"exp": math.exp,
|
| 664 |
+
"sin": math.sin,
|
| 665 |
+
"cos": math.cos,
|
| 666 |
+
"tan": math.tan,
|
| 667 |
+
}
|
| 668 |
+
constants = {"pi": math.pi, "e": math.e}
|
| 669 |
+
|
| 670 |
+
def evaluate(node):
|
| 671 |
+
if isinstance(node, ast.Expression):
|
| 672 |
+
return evaluate(node.body)
|
| 673 |
+
if isinstance(node, ast.Constant) and isinstance(
|
| 674 |
+
node.value, (int, float)
|
| 675 |
+
):
|
| 676 |
+
return node.value
|
| 677 |
+
if isinstance(node, ast.BinOp) and type(node.op) in binary_ops:
|
| 678 |
+
return binary_ops[type(node.op)](
|
| 679 |
+
evaluate(node.left), evaluate(node.right)
|
| 680 |
+
)
|
| 681 |
+
if isinstance(node, ast.UnaryOp) and type(node.op) in unary_ops:
|
| 682 |
+
return unary_ops[type(node.op)](evaluate(node.operand))
|
| 683 |
+
if isinstance(node, ast.Name) and node.id in constants:
|
| 684 |
+
return constants[node.id]
|
| 685 |
+
if (
|
| 686 |
+
isinstance(node, ast.Call)
|
| 687 |
+
and isinstance(node.func, ast.Name)
|
| 688 |
+
and node.func.id in functions
|
| 689 |
+
and not node.keywords
|
| 690 |
+
):
|
| 691 |
+
return functions[node.func.id](
|
| 692 |
+
*(evaluate(argument) for argument in node.args)
|
| 693 |
+
)
|
| 694 |
+
raise ValueError("Unsupported expression.")
|
| 695 |
+
|
| 696 |
+
try:
|
| 697 |
+
parsed = ast.parse(str(expression), mode="eval")
|
| 698 |
+
result = evaluate(parsed)
|
| 699 |
+
return str(result)
|
| 700 |
+
except Exception as exc:
|
| 701 |
+
return f"Calculation failed: {compact_error(exc)}"
|
| 702 |
+
|
| 703 |
+
|
| 704 |
+
class QueryGaiaSpreadsheetTool(Tool):
|
| 705 |
+
name = "query_gaia_spreadsheet"
|
| 706 |
+
description = (
|
| 707 |
+
"Analyzes an attached XLSX, XLSM, CSV, or TSV locally. Use operation "
|
| 708 |
+
"'describe' first, then sum, mean, min, max, count, unique, or rows. "
|
| 709 |
+
"This avoids sending the entire spreadsheet to the language model."
|
| 710 |
+
)
|
| 711 |
+
inputs = {
|
| 712 |
+
"task_id": {
|
| 713 |
+
"type": "string",
|
| 714 |
+
"description": "Exact GAIA task_id for the spreadsheet.",
|
| 715 |
+
},
|
| 716 |
+
"operation": {
|
| 717 |
+
"type": "string",
|
| 718 |
+
"description": "describe, sum, mean, min, max, count, unique, or rows.",
|
| 719 |
+
},
|
| 720 |
+
"sheet": {
|
| 721 |
+
"type": "string",
|
| 722 |
+
"description": "Sheet name, or an empty string for the first sheet.",
|
| 723 |
+
},
|
| 724 |
+
"column": {
|
| 725 |
+
"type": "string",
|
| 726 |
+
"description": "Target column, or empty for describe/count rows.",
|
| 727 |
+
},
|
| 728 |
+
"filters": {
|
| 729 |
+
"type": "string",
|
| 730 |
+
"description": (
|
| 731 |
+
"Optional exact filters as column=value;column=value. "
|
| 732 |
+
"Use an empty string for no filter."
|
| 733 |
+
),
|
| 734 |
+
},
|
| 735 |
+
}
|
| 736 |
+
output_type = "string"
|
| 737 |
+
|
| 738 |
+
def forward(
|
| 739 |
+
self,
|
| 740 |
+
task_id: str,
|
| 741 |
+
operation: str,
|
| 742 |
+
sheet: str,
|
| 743 |
+
column: str,
|
| 744 |
+
filters: str,
|
| 745 |
+
) -> str:
|
| 746 |
+
try:
|
| 747 |
+
data, filename = download_gaia_attachment(str(task_id).strip())
|
| 748 |
+
suffix = Path(filename).suffix.lower()
|
| 749 |
+
if suffix in {".xlsx", ".xlsm"}:
|
| 750 |
+
tables = pd.read_excel(BytesIO(data), sheet_name=None)
|
| 751 |
+
elif suffix in {".csv", ".tsv"}:
|
| 752 |
+
separator = "\t" if suffix == ".tsv" else ","
|
| 753 |
+
tables = {
|
| 754 |
+
"data": pd.read_csv(
|
| 755 |
+
BytesIO(data),
|
| 756 |
+
sep=separator,
|
| 757 |
+
encoding_errors="replace",
|
| 758 |
+
)
|
| 759 |
+
}
|
| 760 |
+
else:
|
| 761 |
+
return f"Attachment {filename} is not a supported spreadsheet."
|
| 762 |
+
|
| 763 |
+
requested_sheet = str(sheet or "").strip()
|
| 764 |
+
sheet_name = next(iter(tables))
|
| 765 |
+
if requested_sheet:
|
| 766 |
+
matching_sheet = next(
|
| 767 |
+
(
|
| 768 |
+
name
|
| 769 |
+
for name in tables
|
| 770 |
+
if str(name).lower() == requested_sheet.lower()
|
| 771 |
+
),
|
| 772 |
+
None,
|
| 773 |
+
)
|
| 774 |
+
if matching_sheet is None:
|
| 775 |
+
return (
|
| 776 |
+
f"Unknown sheet {requested_sheet}. Available: "
|
| 777 |
+
+ ", ".join(map(str, tables))
|
| 778 |
+
)
|
| 779 |
+
sheet_name = matching_sheet
|
| 780 |
+
|
| 781 |
+
frame = tables[sheet_name].copy()
|
| 782 |
+
frame.columns = [str(value).strip() for value in frame.columns]
|
| 783 |
+
requested_column = str(column or "").strip()
|
| 784 |
+
|
| 785 |
+
for filter_expression in str(filters or "").split(";"):
|
| 786 |
+
filter_expression = filter_expression.strip()
|
| 787 |
+
if not filter_expression:
|
| 788 |
+
continue
|
| 789 |
+
if "=" not in filter_expression:
|
| 790 |
+
return f"Invalid filter: {filter_expression}"
|
| 791 |
+
filter_column, filter_value = (
|
| 792 |
+
part.strip() for part in filter_expression.split("=", 1)
|
| 793 |
+
)
|
| 794 |
+
actual_filter_column = next(
|
| 795 |
+
(
|
| 796 |
+
name
|
| 797 |
+
for name in frame.columns
|
| 798 |
+
if name.lower() == filter_column.lower()
|
| 799 |
+
),
|
| 800 |
+
None,
|
| 801 |
+
)
|
| 802 |
+
if actual_filter_column is None:
|
| 803 |
+
return (
|
| 804 |
+
f"Unknown filter column {filter_column}. Columns: "
|
| 805 |
+
+ ", ".join(frame.columns)
|
| 806 |
+
)
|
| 807 |
+
numeric_value = pd.to_numeric(
|
| 808 |
+
pd.Series([filter_value]), errors="coerce"
|
| 809 |
+
).iloc[0]
|
| 810 |
+
numeric_column = pd.to_numeric(
|
| 811 |
+
frame[actual_filter_column], errors="coerce"
|
| 812 |
+
)
|
| 813 |
+
if pd.notna(numeric_value) and numeric_column.notna().any():
|
| 814 |
+
frame = frame[numeric_column == numeric_value]
|
| 815 |
+
else:
|
| 816 |
+
frame = frame[
|
| 817 |
+
frame[actual_filter_column]
|
| 818 |
+
.astype(str)
|
| 819 |
+
.str.strip()
|
| 820 |
+
.str.casefold()
|
| 821 |
+
== filter_value.casefold()
|
| 822 |
+
]
|
| 823 |
+
|
| 824 |
+
operation = str(operation or "describe").strip().lower()
|
| 825 |
+
if operation == "describe":
|
| 826 |
+
preview = frame.head(5).to_csv(index=False)
|
| 827 |
+
return (
|
| 828 |
+
f"Workbook: {filename}\n"
|
| 829 |
+
f"Sheets: {', '.join(map(str, tables))}\n"
|
| 830 |
+
f"Selected sheet: {sheet_name}\n"
|
| 831 |
+
f"Rows: {len(frame)}; Columns: {len(frame.columns)}\n"
|
| 832 |
+
f"Column names: {', '.join(frame.columns)}\n"
|
| 833 |
+
f"First rows:\n{preview[:2_500]}"
|
| 834 |
+
)
|
| 835 |
+
|
| 836 |
+
if requested_column:
|
| 837 |
+
actual_column = next(
|
| 838 |
+
(
|
| 839 |
+
name
|
| 840 |
+
for name in frame.columns
|
| 841 |
+
if name.lower() == requested_column.lower()
|
| 842 |
+
),
|
| 843 |
+
None,
|
| 844 |
+
)
|
| 845 |
+
if actual_column is None:
|
| 846 |
+
return (
|
| 847 |
+
f"Unknown target column {requested_column}. Columns: "
|
| 848 |
+
+ ", ".join(frame.columns)
|
| 849 |
+
)
|
| 850 |
+
else:
|
| 851 |
+
actual_column = ""
|
| 852 |
+
|
| 853 |
+
if operation == "count":
|
| 854 |
+
result = (
|
| 855 |
+
int(frame[actual_column].notna().sum())
|
| 856 |
+
if actual_column
|
| 857 |
+
else len(frame)
|
| 858 |
+
)
|
| 859 |
+
elif operation in {"sum", "mean", "min", "max"}:
|
| 860 |
+
if not actual_column:
|
| 861 |
+
return f"Operation {operation} requires a target column."
|
| 862 |
+
series = pd.to_numeric(frame[actual_column], errors="coerce").dropna()
|
| 863 |
+
if series.empty:
|
| 864 |
+
return f"Column {actual_column} has no numeric values."
|
| 865 |
+
result = getattr(series, operation)()
|
| 866 |
+
elif operation == "unique":
|
| 867 |
+
if not actual_column:
|
| 868 |
+
return "Operation unique requires a target column."
|
| 869 |
+
values = frame[actual_column].dropna().astype(str).unique().tolist()
|
| 870 |
+
return ", ".join(values[:100])
|
| 871 |
+
elif operation == "rows":
|
| 872 |
+
columns = [actual_column] if actual_column else list(frame.columns)
|
| 873 |
+
return frame[columns].head(30).to_csv(index=False)[:4_000]
|
| 874 |
+
else:
|
| 875 |
+
return (
|
| 876 |
+
"Unknown operation. Use describe, sum, mean, min, max, "
|
| 877 |
+
"count, unique, or rows."
|
| 878 |
+
)
|
| 879 |
+
|
| 880 |
+
if hasattr(result, "item"):
|
| 881 |
+
result = result.item()
|
| 882 |
+
return (
|
| 883 |
+
f"Operation: {operation}; sheet: {sheet_name}; "
|
| 884 |
+
f"rows matched: {len(frame)}; result: {result}"
|
| 885 |
+
)
|
| 886 |
+
except Exception as exc:
|
| 887 |
+
return f"Spreadsheet analysis failed: {compact_error(exc)}"
|
| 888 |
+
|
| 889 |
+
|
| 890 |
class InspectGaiaAttachmentTool(Tool):
|
| 891 |
name = "inspect_gaia_attachment"
|
| 892 |
description = (
|
| 893 |
"Downloads and reads the official attachment associated with a GAIA "
|
| 894 |
+
"task. Use it for PDF, DOCX, text, code, or ZIP files. Do not use it "
|
| 895 |
+
"for audio, images, or spreadsheets; those have specialized tools."
|
| 896 |
)
|
| 897 |
inputs = {
|
| 898 |
"task_id": {
|
| 899 |
"type": "string",
|
| 900 |
"description": "The exact GAIA task_id supplied in the user task.",
|
| 901 |
+
},
|
| 902 |
+
"query": {
|
| 903 |
+
"type": "string",
|
| 904 |
+
"description": "Short terms for the exact fact to extract.",
|
| 905 |
+
},
|
| 906 |
}
|
| 907 |
output_type = "string"
|
| 908 |
|
| 909 |
+
def forward(self, task_id: str, query: str) -> str:
|
| 910 |
task_id = str(task_id).strip()
|
| 911 |
if not task_id:
|
| 912 |
return "No task_id was supplied."
|
| 913 |
|
| 914 |
try:
|
| 915 |
data, filename = download_gaia_attachment(task_id)
|
| 916 |
+
suffix = Path(filename).suffix.lower()
|
| 917 |
+
if suffix in {
|
| 918 |
+
".mp3", ".wav", ".flac", ".m4a", ".ogg", ".webm", ".mp4"
|
| 919 |
+
}:
|
| 920 |
+
return (
|
| 921 |
+
f"Attachment {filename} is audio. Call "
|
| 922 |
+
"transcribe_gaia_audio with this task_id."
|
| 923 |
+
)
|
| 924 |
+
if suffix in {".png", ".jpg", ".jpeg", ".webp", ".gif"}:
|
| 925 |
+
return (
|
| 926 |
+
f"Attachment {filename} is an image. Call "
|
| 927 |
+
"analyze_gaia_image with this task_id and question."
|
| 928 |
+
)
|
| 929 |
+
if suffix in {".xlsx", ".xlsm", ".csv", ".tsv"}:
|
| 930 |
+
return (
|
| 931 |
+
f"Attachment {filename} is tabular. Call "
|
| 932 |
+
"query_gaia_spreadsheet with operation='describe' first."
|
| 933 |
+
)
|
| 934 |
+
extracted = extract_attachment_text(
|
| 935 |
+
data, filename, query=str(query or "")
|
| 936 |
+
)
|
| 937 |
return f"Attachment filename: {filename}\n\n{extracted}"
|
| 938 |
except Exception as exc:
|
| 939 |
+
return (
|
| 940 |
+
f"Could not inspect attachment for task {task_id}: "
|
| 941 |
+
f"{compact_error(exc)}"
|
| 942 |
+
)
|
| 943 |
|
| 944 |
|
| 945 |
class YouTubeTranscriptTool(Tool):
|
|
|
|
| 953 |
"url": {
|
| 954 |
"type": "string",
|
| 955 |
"description": "Full YouTube URL or the 11-character video ID.",
|
| 956 |
+
},
|
| 957 |
+
"query": {
|
| 958 |
+
"type": "string",
|
| 959 |
+
"description": "Short terms describing the spoken fact or quote.",
|
| 960 |
+
},
|
| 961 |
}
|
| 962 |
output_type = "string"
|
| 963 |
|
| 964 |
+
def forward(self, url: str, query: str) -> str:
|
| 965 |
from youtube_transcript_api import YouTubeTranscriptApi
|
| 966 |
|
| 967 |
value = str(url or "").strip()
|
|
|
|
| 983 |
if text:
|
| 984 |
lines.append(str(text))
|
| 985 |
result = " ".join(lines).strip()
|
| 986 |
+
return (
|
| 987 |
+
focus_text(result, str(query or ""), 5_000)
|
| 988 |
+
if result
|
| 989 |
+
else "The video has no available transcript."
|
| 990 |
+
)
|
| 991 |
except Exception as exc:
|
| 992 |
+
return (
|
| 993 |
+
"Could not retrieve YouTube transcript: "
|
| 994 |
+
f"{compact_error(exc)}"
|
| 995 |
+
)
|
| 996 |
|
| 997 |
|
| 998 |
class AnalyzeGaiaImageTool(Tool):
|
|
|
|
| 1051 |
"type": "text",
|
| 1052 |
"text": (
|
| 1053 |
"Analyze the supplied image carefully and "
|
| 1054 |
+
"answer this task. Return the likely exact "
|
| 1055 |
+
"answer plus at most one short evidence "
|
| 1056 |
+
"sentence:\n"
|
| 1057 |
f"{question}"
|
| 1058 |
),
|
| 1059 |
},
|
|
|
|
| 1067 |
}
|
| 1068 |
],
|
| 1069 |
"temperature": 0,
|
| 1070 |
+
"max_tokens": 300,
|
| 1071 |
},
|
| 1072 |
timeout=HTTP_TIMEOUT,
|
| 1073 |
)
|
|
|
|
| 1075 |
payload = response.json()
|
| 1076 |
return str(payload["choices"][0]["message"]["content"]).strip()
|
| 1077 |
except Exception as exc:
|
| 1078 |
+
return (
|
| 1079 |
+
"Could not analyze the GAIA image: "
|
| 1080 |
+
f"{compact_error(exc)}"
|
| 1081 |
+
)
|
| 1082 |
|
| 1083 |
|
| 1084 |
class BasicAgent:
|
|
|
|
| 1115 |
print(f"Modelo principal selecionado: {model_id}")
|
| 1116 |
|
| 1117 |
web_search_tool = DuckDuckGoSearchTool(
|
| 1118 |
+
max_results=5, rate_limit=1.0
|
| 1119 |
)
|
| 1120 |
web_search_tool.description = (
|
| 1121 |
"Searches the public web and returns result titles, URLs, and short "
|
|
|
|
| 1137 |
agent_tools = [
|
| 1138 |
web_search_tool,
|
| 1139 |
visit_page_tool,
|
| 1140 |
+
ReadDocumentUrlTool(),
|
| 1141 |
wikipedia_tool,
|
| 1142 |
InspectGaiaAttachmentTool(),
|
| 1143 |
+
QueryGaiaSpreadsheetTool(),
|
| 1144 |
+
TranscribeGaiaAudioTool(),
|
| 1145 |
YouTubeTranscriptTool(),
|
| 1146 |
AnalyzeGaiaImageTool(),
|
| 1147 |
+
CalculatorTool(),
|
| 1148 |
]
|
| 1149 |
|
| 1150 |
# Qwen returns native tool calls. ToolCallingAgent handles that
|
|
|
|
| 1152 |
self.agent = ToolCallingAgent(
|
| 1153 |
tools=agent_tools,
|
| 1154 |
model=self.model,
|
| 1155 |
+
max_steps=7,
|
| 1156 |
planning_interval=None,
|
| 1157 |
description=(
|
| 1158 |
"Agent designed to solve GAIA benchmark questions with "
|
|
|
|
| 1165 |
|
| 1166 |
TOOL ROUTING POLICY:
|
| 1167 |
1. web_search discovers URLs and snippets. It does not read full pages.
|
| 1168 |
+
2. visit_webpage reads focused passages from HTML; always provide short query
|
| 1169 |
+
terms. read_document_url reads linked PDFs or documents by query.
|
| 1170 |
+
3. transcribe_gaia_audio is the only tool for attached audio. Call it first
|
| 1171 |
+
for MP3, WAV, FLAC, M4A, OGG, WEBM, or MP4.
|
| 1172 |
+
4. inspect_gaia_attachment handles attached PDF, DOCX, text, code, or ZIP.
|
| 1173 |
+
Pass short target terms. For XLSX/CSV use
|
| 1174 |
+
query_gaia_spreadsheet instead: describe columns, then run one calculation.
|
| 1175 |
+
Never send a whole table to the model.
|
| 1176 |
+
5. analyze_gaia_image handles attached images. youtube_transcript handles
|
| 1177 |
+
spoken YouTube content. calculator evaluates arithmetic locally.
|
| 1178 |
+
6. wikipedia_search is for Wikipedia or encyclopedic facts. Verify historical,
|
| 1179 |
+
nomination, revision, or archive details with an exact webpage.
|
| 1180 |
|
| 1181 |
Research carefully, prefer primary or official sources, and cross-check
|
| 1182 |
uncertain facts. A search snippet alone is insufficient when the source page
|
| 1183 |
can be opened. Never invent a tool, use subprocess, or use shell commands.
|
| 1184 |
+
Do not repeat nearly identical searches. Stop as soon as primary evidence
|
| 1185 |
+
answers the exact question. Never pass an entire task as a webpage query; use
|
| 1186 |
+
only names, identifiers, and the target fact.
|
| 1187 |
|
| 1188 |
FINAL RESPONSE POLICY:
|
| 1189 |
Call the final_answer tool with only the requested value. Never write the
|
|
|
|
| 1210 |
|
| 1211 |
if task_id:
|
| 1212 |
attachment_name = get_task_file_name(task_id)
|
| 1213 |
+
attachment_suffix = Path(attachment_name).suffix.lower()
|
| 1214 |
+
if attachment_suffix in {
|
| 1215 |
+
".mp3", ".wav", ".flac", ".m4a", ".ogg", ".webm", ".mp4"
|
| 1216 |
+
}:
|
| 1217 |
+
attachment_context = (
|
| 1218 |
+
f"Official attachment: {attachment_name}. Call "
|
| 1219 |
+
"transcribe_gaia_audio first."
|
| 1220 |
+
)
|
| 1221 |
+
elif attachment_suffix in {
|
| 1222 |
+
".png", ".jpg", ".jpeg", ".webp", ".gif"
|
| 1223 |
+
}:
|
| 1224 |
+
attachment_context = (
|
| 1225 |
+
f"Official attachment: {attachment_name}. Call "
|
| 1226 |
+
"analyze_gaia_image with this task_id and question."
|
| 1227 |
+
)
|
| 1228 |
+
elif attachment_suffix in {".xlsx", ".xlsm", ".csv", ".tsv"}:
|
| 1229 |
+
attachment_context = (
|
| 1230 |
+
f"Official attachment: {attachment_name}. Call "
|
| 1231 |
+
"query_gaia_spreadsheet with operation='describe' first."
|
| 1232 |
+
)
|
| 1233 |
+
elif attachment_name:
|
| 1234 |
+
attachment_context = (
|
| 1235 |
+
f"Official attachment: {attachment_name}. Call "
|
| 1236 |
+
"inspect_gaia_attachment with this task_id and short target terms."
|
| 1237 |
+
)
|
| 1238 |
+
else:
|
| 1239 |
+
attachment_context = (
|
| 1240 |
+
"Official attachment: NONE. Do not call any GAIA "
|
| 1241 |
+
"attachment tool."
|
| 1242 |
)
|
|
|
|
| 1243 |
task_context = (
|
| 1244 |
f"GAIA task_id: {task_id}\n"
|
| 1245 |
f"{attachment_context}\n\nQuestion: {question}"
|
|
|
|
| 1339 |
|
| 1340 |
text = cls.deterministic_answer_cleanup(text)
|
| 1341 |
|
| 1342 |
+
if "page numbers" in question_lower or "page number" in question_lower:
|
| 1343 |
+
page_groups = re.findall(
|
| 1344 |
+
r"\b(?:pages?|pp\.?)\s*(?:are|is|:|-)?\s*"
|
| 1345 |
+
r"(\d+(?:(?:\s*,\s*(?:and\s+)?|\s+and\s+|-)\d+)*)",
|
| 1346 |
+
text,
|
| 1347 |
+
flags=re.I,
|
| 1348 |
+
)
|
| 1349 |
+
if page_groups:
|
| 1350 |
+
pages = [int(value) for value in re.findall(r"\d+", page_groups[-1])]
|
| 1351 |
+
if pages:
|
| 1352 |
+
return ", ".join(str(value) for value in sorted(set(pages)))
|
| 1353 |
+
|
| 1354 |
quantity_question = (
|
| 1355 |
"how many" in question_lower
|
| 1356 |
or "numeric output" in question_lower
|
|
|
|
| 1495 |
if not reviewer_model.lower().startswith("groq/qwen/"):
|
| 1496 |
reviewer_model = DEFAULT_GROQ_REVIEW_MODEL
|
| 1497 |
review_prompt = f"""
|
| 1498 |
+
Review this GAIA exact-match candidate. Preserve it unless clearly wrong.
|
| 1499 |
+
Return only the requested value: no explanation, label, Markdown, or citation.
|
| 1500 |
+
Respect requested number, currency, name, list separator/order, quote, or chess
|
| 1501 |
+
notation format.
|
| 1502 |
+
Question: {question}
|
| 1503 |
+
Candidate: {candidate}
|
| 1504 |
+
Return:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1505 |
<final_answer>exact value to submit</final_answer>
|
|
|
|
|
|
|
|
|
|
| 1506 |
""".strip()
|
| 1507 |
|
| 1508 |
last_error = None
|
| 1509 |
+
for attempt in range(1):
|
| 1510 |
try:
|
| 1511 |
retry_instruction = (
|
| 1512 |
""
|
|
|
|
| 1523 |
}
|
| 1524 |
],
|
| 1525 |
temperature=0,
|
| 1526 |
+
max_tokens=120,
|
| 1527 |
+
reasoning_effort="none",
|
| 1528 |
)
|
| 1529 |
content = str(response.choices[0].message.content).strip()
|
| 1530 |
content = re.sub(
|