avi080704 commited on
Commit
f0eeb85
·
verified ·
1 Parent(s): 413f492

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +310 -468
app.py CHANGED
@@ -3,10 +3,10 @@ import re
3
  import json
4
  import io
5
  import time
6
- import base64
7
  import traceback
8
  import contextlib
9
  import tempfile
 
10
  from urllib.parse import urlparse, parse_qs
11
 
12
  import gradio as gr
@@ -16,30 +16,15 @@ import pandas as pd
16
  # --- Constants ---
17
  DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
18
 
19
- # Free-tier Groq TPM limits (per minute):
20
- # llama-3.1-8b-instant -> 30,000 TPM (lots of headroom)
21
- # llama-3.3-70b-versatile -> 6,000 TPM (tight; one big tool result busts it)
22
- # Default order: 8b first to avoid 413s, fall back to 70b only when necessary.
23
- GROQ_MODELS = [
24
- m.strip()
25
- for m in os.getenv(
26
- "GROQ_MODELS",
27
- # 8b only end-to-end. 70b is too tight on free tier and breaks synthesis.
28
- "llama-3.1-8b-instant",
29
- ).split(",")
30
- if m.strip()
31
- ]
32
- # Smarter model used for the final synthesis pass. Tried first, falls back to 8b.
33
- GROQ_FINAL_MODEL = os.getenv("GROQ_FINAL_MODEL", "llama-3.3-70b-versatile")
34
- GROQ_VISION_MODEL = os.getenv("GROQ_VISION_MODEL", "meta-llama/llama-4-scout-17b-16e-instruct")
35
- GROQ_WHISPER_MODEL = os.getenv("GROQ_WHISPER_MODEL", "whisper-large-v3-turbo")
36
-
37
- MAX_TOOL_ITERATIONS = 7
38
- TOOL_RESULT_MAX_CHARS = 1500
39
- HISTORY_TRIM_AFTER = 6
40
  ANSWER_CACHE_PATH = os.getenv("ANSWER_CACHE_PATH", "/tmp/answers_cache.json")
41
  RESULTS_CSV_PATH = "/tmp/gaia_results.csv"
42
- INTER_QUESTION_SLEEP = float(os.getenv("INTER_QUESTION_SLEEP", "3"))
43
 
44
  # Track downloaded task files so vision/audio tools can re-use them by task_id.
45
  _TASK_FILE_CACHE: dict[str, dict] = {}
@@ -66,7 +51,7 @@ def tool_web_search(query: str, max_results: int = 5) -> str:
66
  lines.append(f"Answer: {res['answer']}")
67
  for r in res.get("results", [])[:max_results]:
68
  lines.append(
69
- f"- {r.get('title', '')}\n {r.get('url', '')}\n {r.get('content', '')[:300]}"
70
  )
71
  if len(lines) > 1:
72
  return "\n".join(lines)
@@ -81,7 +66,7 @@ def tool_web_search(query: str, max_results: int = 5) -> str:
81
  with DDGS() as ddgs:
82
  for r in ddgs.text(query, max_results=max_results):
83
  results.append(
84
- f"- {r.get('title', '')}\n {r.get('href', '')}\n {r.get('body', '')[:300]}"
85
  )
86
  if len(results) == 1:
87
  return "[provider: duckduckgo] No results."
@@ -90,7 +75,7 @@ def tool_web_search(query: str, max_results: int = 5) -> str:
90
  return f"web_search error: {e}"
91
 
92
 
93
- def tool_fetch_url(url: str, max_chars: int = 1800) -> str:
94
  """Fetch a URL and return readable text (HTML stripped)."""
95
  try:
96
  from bs4 import BeautifulSoup
@@ -118,7 +103,7 @@ def tool_fetch_url(url: str, max_chars: int = 1800) -> str:
118
  return f"fetch_url error: {e}"
119
 
120
 
121
- def tool_wikipedia(query: str, sentences: int = 4) -> str:
122
  """Look up a topic on Wikipedia and return a summary."""
123
  try:
124
  import wikipedia
@@ -147,7 +132,7 @@ def tool_python(code: str) -> str:
147
  out = buf.getvalue().strip()
148
  if not out and "result" in local_ns:
149
  out = str(local_ns["result"])
150
- return (out or "(no output)")[:1500]
151
  except Exception as e:
152
  return f"python error: {e}\n{traceback.format_exc(limit=2)}"
153
 
@@ -167,7 +152,7 @@ def _extract_youtube_id(url: str) -> str | None:
167
  return None
168
 
169
 
170
- def tool_youtube_transcript(url: str, max_chars: int = 2500) -> str:
171
  """Fetch the transcript of a YouTube video by URL or ID."""
172
  try:
173
  from youtube_transcript_api import YouTubeTranscriptApi
@@ -187,77 +172,59 @@ def tool_youtube_transcript(url: str, max_chars: int = 2500) -> str:
187
  return f"youtube_transcript error: {e}"
188
 
189
 
190
- def tool_transcribe_audio(task_id: str) -> str:
191
- """Transcribe an audio file attached to a GAIA task using Groq Whisper."""
192
  try:
193
- from groq import Groq
194
- info = _TASK_FILE_CACHE.get(task_id)
195
- if not info:
196
- tool_get_task_file(task_id)
197
- info = _TASK_FILE_CACHE.get(task_id)
198
- if not info or not os.path.exists(info.get("path", "")):
199
- return "transcribe_audio error: no local file for task (file may not exist for this task_id)"
200
-
201
- client = Groq(api_key=os.getenv("GROQ_API_KEY"))
202
- with open(info["path"], "rb") as f:
203
- tr = client.audio.transcriptions.create(
204
- file=(os.path.basename(info["path"]), f.read()),
205
- model=GROQ_WHISPER_MODEL,
206
- response_format="text",
207
- )
208
- text = tr if isinstance(tr, str) else getattr(tr, "text", str(tr))
209
- text = text.strip()
210
- if len(text) > 3500:
211
- text = text[:3500] + " ...[truncated]"
212
- return text or "(empty transcript)"
213
- except Exception as e:
214
- return f"transcribe_audio error: {e}"
215
-
216
 
217
- def tool_view_image(task_id: str, question: str = "") -> str:
218
- """Describe / answer a question about an image attached to a GAIA task using Groq vision."""
219
- try:
220
- from groq import Groq
221
  info = _TASK_FILE_CACHE.get(task_id)
222
  if not info:
223
  tool_get_task_file(task_id)
224
  info = _TASK_FILE_CACHE.get(task_id)
225
  if not info or not os.path.exists(info.get("path", "")):
226
- return "view_image error: no local file for task (file may not exist for this task_id)"
227
-
228
- suffix = os.path.splitext(info["path"])[1].lower().lstrip(".")
229
- if suffix == "jpg":
230
- suffix = "jpeg"
231
- if suffix not in {"png", "jpeg", "gif", "webp"}:
232
- return f"view_image error: unsupported image type .{suffix}"
233
-
234
- with open(info["path"], "rb") as f:
235
- b64 = base64.b64encode(f.read()).decode("ascii")
236
- data_url = f"data:image/{suffix};base64,{b64}"
 
 
 
 
 
 
 
 
 
 
 
 
 
237
 
238
  prompt = (
239
  question.strip()
240
- or "Describe this image in detail, including any text, numbers, or symbols visible."
 
241
  )
242
 
243
- client = Groq(api_key=os.getenv("GROQ_API_KEY"))
244
- resp = client.chat.completions.create(
245
- model=GROQ_VISION_MODEL,
246
- messages=[
247
- {
248
- "role": "user",
249
- "content": [
250
- {"type": "text", "text": prompt},
251
- {"type": "image_url", "image_url": {"url": data_url}},
252
- ],
253
- }
254
  ],
255
- temperature=0.0,
256
- max_tokens=600,
257
  )
258
- return (resp.choices[0].message.content or "").strip()
259
  except Exception as e:
260
- return f"view_image error: {e}"
261
 
262
 
263
  def tool_get_task_file(task_id: str, api_url: str = DEFAULT_API_URL) -> str:
@@ -296,185 +263,152 @@ def tool_get_task_file(task_id: str, api_url: str = DEFAULT_API_URL) -> str:
296
  text = resp.content.decode("utf-8", errors="replace")
297
  except Exception:
298
  text = resp.text
299
- return info + "\n--- preview ---\n" + text[:2500]
300
 
301
  if suffix in {".xlsx", ".xls"}:
302
  try:
303
  df = pd.read_excel(tmp.name)
304
- # Show full table to allow exact summing.
305
  csv = df.to_csv(index=False)
306
- if len(csv) > 2500:
307
- csv = csv[:2500] + "\n...[truncated]"
308
  return info + "\n--- excel as csv ---\n" + csv
309
  except Exception as e:
310
  return info + f"\n(excel parse error: {e})"
311
 
312
  if suffix == ".pdf":
313
- try:
314
- from pypdf import PdfReader
315
- reader = PdfReader(tmp.name)
316
- pages = [p.extract_text() or "" for p in reader.pages[:6]]
317
- return info + "\n--- pdf text ---\n" + "\n".join(pages)[:2500]
318
- except Exception as e:
319
- return info + f"\n(pdf parse error: {e})"
320
 
321
  if suffix in {".mp3", ".wav", ".m4a", ".ogg", ".flac", ".webm"}:
322
- return info + "\nThis is an audio file. Call transcribe_audio(task_id) to read it."
323
 
324
  if suffix in {".png", ".jpg", ".jpeg", ".gif", ".webp"}:
325
- return info + "\nThis is an image. Call view_image(task_id, question='...') to inspect it."
326
 
327
- return info + "\n(binary file; no preview)"
328
  except Exception as e:
329
  return f"get_task_file error: {e}"
330
 
331
 
332
  # ---------------------------------------------------------------------------
333
- # Tool schema for Groq function calling
334
  # ---------------------------------------------------------------------------
335
- TOOLS_SPEC = [
336
- {
337
- "type": "function",
338
- "function": {
339
- "name": "web_search",
340
- "description": "Search the web (Tavily preferred, DuckDuckGo fallback). Returns titles, URLs, snippets.",
341
- "parameters": {
342
- "type": "object",
343
- "properties": {
344
- "query": {"type": "string"},
345
- "max_results": {"type": "integer", "default": 5},
346
- },
347
- "required": ["query"],
348
- },
349
- },
350
- },
351
- {
352
- "type": "function",
353
- "function": {
354
- "name": "fetch_url",
355
- "description": "Fetch a URL and return cleaned page text. Use after web_search to read a result.",
356
- "parameters": {
357
- "type": "object",
358
- "properties": {
359
- "url": {"type": "string"},
360
- "max_chars": {"type": "integer", "default": 1800},
361
- },
362
- "required": ["url"],
363
- },
364
- },
365
- },
366
- {
367
- "type": "function",
368
- "function": {
369
- "name": "wikipedia",
370
- "description": "Get a Wikipedia summary for a topic.",
371
- "parameters": {
372
- "type": "object",
373
- "properties": {
374
- "query": {"type": "string"},
375
- "sentences": {"type": "integer", "default": 4},
376
- },
377
- "required": ["query"],
378
- },
379
- },
380
- },
381
- {
382
- "type": "function",
383
- "function": {
384
- "name": "python",
385
- "description": "Execute a short Python snippet for math, dates, parsing CSV, list/string work, reversing text. Use print() or assign to `result`.",
386
- "parameters": {
387
- "type": "object",
388
- "properties": {"code": {"type": "string"}},
389
- "required": ["code"],
390
- },
391
- },
392
- },
393
- {
394
- "type": "function",
395
- "function": {
396
- "name": "get_task_file",
397
- "description": "Download the file attached to a GAIA task by task_id. ONLY call when the question explicitly mentions an attached file/image/audio/Excel/PDF/code. Returns NO_FILE if none exists.",
398
- "parameters": {
399
- "type": "object",
400
- "properties": {"task_id": {"type": "string"}},
401
- "required": ["task_id"],
402
- },
403
- },
404
- },
405
- {
406
- "type": "function",
407
- "function": {
408
- "name": "transcribe_audio",
409
- "description": "Transcribe an attached audio file (mp3/wav/m4a/ogg/flac) for the given task_id.",
410
- "parameters": {
411
- "type": "object",
412
- "properties": {"task_id": {"type": "string"}},
413
- "required": ["task_id"],
414
- },
415
- },
416
- },
417
- {
418
- "type": "function",
419
- "function": {
420
- "name": "view_image",
421
- "description": "Inspect an attached image (png/jpg/gif/webp) using a vision model. Pass a focused question for best results.",
422
- "parameters": {
423
- "type": "object",
424
- "properties": {
425
- "task_id": {"type": "string"},
426
- "question": {"type": "string"},
427
- },
428
- "required": ["task_id"],
429
- },
430
- },
431
- },
432
- {
433
- "type": "function",
434
- "function": {
435
- "name": "youtube_transcript",
436
- "description": "Fetch the transcript text of a YouTube video given its URL or ID.",
437
- "parameters": {
438
- "type": "object",
439
- "properties": {
440
- "url": {"type": "string"},
441
- "max_chars": {"type": "integer", "default": 2500},
442
- },
443
- "required": ["url"],
444
- },
445
- },
446
- },
447
- ]
448
 
449
  TOOL_FUNCTIONS = {
450
  "web_search": lambda args: tool_web_search(args["query"], int(args.get("max_results", 5))),
451
- "fetch_url": lambda args: tool_fetch_url(args["url"], int(args.get("max_chars", 1800))),
452
- "wikipedia": lambda args: tool_wikipedia(args["query"], int(args.get("sentences", 4))),
453
  "python": lambda args: tool_python(args["code"]),
454
  "get_task_file": lambda args: tool_get_task_file(args["task_id"]),
455
- "transcribe_audio": lambda args: tool_transcribe_audio(args["task_id"]),
456
- "view_image": lambda args: tool_view_image(args["task_id"], args.get("question", "")),
457
  "youtube_transcript": lambda args: tool_youtube_transcript(
458
- args["url"], int(args.get("max_chars", 2500))
459
  ),
460
  }
461
 
462
 
463
  SYSTEM_PROMPT = """You are a careful research agent answering GAIA benchmark questions.
464
 
465
- Tools: web_search, fetch_url, wikipedia, python, get_task_file, transcribe_audio, view_image, youtube_transcript.
466
 
467
  Decision rules:
468
- - If the question literally references "attached file/image/audio/Excel/PDF/.mp3/.xlsx/.py/code/recording", call get_task_file FIRST. If it returns NO_FILE, do NOT call it again.
469
- - Audio file -> transcribe_audio(task_id).
470
- - Image file -> view_image(task_id, question="<focused question>").
471
- - YouTube URL -> youtube_transcript(url) directly (no get_task_file needed).
472
- - For factual lookups about people / places / artists / albums / animals / Wikipedia featured articles, START with wikipedia. Then fetch_url the relevant page if more detail needed.
473
- - For everything else research-y, web_search then fetch_url.
474
- - Use python for ALL arithmetic, sums (e.g. summing Excel rows), date math, sorting, alphabetizing, set/group operations, string reversal. Never compute by hand.
475
- - For Excel/CSV totals, after get_task_file shows you the data, ALWAYS use python to compute the sum precisely.
476
-
477
- You have 6 tool turns. Be decisive. Do not loop on the same query.
478
 
479
  ANSWER FORMATTING (the grader does an exact-match comparison; sentence answers ALWAYS lose):
480
 
@@ -483,10 +417,9 @@ Worked examples of correct GAIA format:
483
  - Q: "Express your answer in USD with two decimal places" -> "89706.00" (NOT "$89,706" or "89706")
484
  - Q: "Give the IOC country code" -> "MLT" (NOT "Malta" or "Malta (MLT)")
485
  - Q: "Just the city name without abbreviations" -> "Saint Petersburg"
486
- - Q: "Give only the first name" -> "Bartek" (NOT "Bartlomiej" or "Bartek Kasprzykowski")
487
  - Q: "Comma separated list ... in alphabetical order" -> "broccoli, celery, fresh basil, lettuce, sweet potatoes, zucchini"
488
  - Q: "Under what NASA award number..." -> "80NSSC21K1130" (just the code, NO surrounding sentence)
489
- - Q: "Final numeric output from the attached Python code" -> "0" (just the number)
490
  - Q: opposite of "left" -> "right" (one word)
491
 
492
  Strict rules:
@@ -494,22 +427,21 @@ Strict rules:
494
  - Do NOT include "FINAL ANSWER", "Answer:", or any label.
495
  - Numbers: digits only, no commas, no units, no $ — UNLESS the question asks for the unit.
496
  - Currency "two decimal places": e.g. "89706.00".
497
- - Strings: no leading articles ("the", "a") unless required; no abbreviations (write "Saint" not "St."); digits as digits.
498
  - Names: read the question carefully. "First name only" / "last name only" / "surname" / "full name". Match exactly.
499
- - Lists: comma-separated, ONE space after each comma. Apply formatting rules to each element. Sort if asked.
 
 
500
  """
501
 
502
 
503
  def _maybe_reverse_text(question: str) -> str:
504
- """If the question text looks reversed, flip it. Returns possibly-modified question."""
505
- # Heuristic: a normal English sentence has many word-frequencies like 'the', 'a', 'of'.
506
- # A reversed one has 'eht', 'fo', 'sa', etc., and often starts with punctuation like '.'.
507
  q = question.strip()
508
  if not q:
509
  return question
510
  starts_with_punct = q[0] in ".,;:!?"
511
  reversed_text = q[::-1]
512
- # Look for common English words in the reversed version.
513
  common = (" the ", " of ", " and ", " to ", " is ", " a ", " in ", " for ")
514
  hits = sum(1 for w in common if w in (" " + reversed_text.lower() + " "))
515
  if starts_with_punct and hits >= 2:
@@ -520,94 +452,64 @@ def _maybe_reverse_text(question: str) -> str:
520
  # ---------------------------------------------------------------------------
521
  # Agent
522
  # ---------------------------------------------------------------------------
523
- class GroqAgent:
524
  def __init__(self):
525
  try:
526
- from groq import Groq
 
527
  except ImportError as e:
528
- raise RuntimeError("groq package not installed") from e
529
 
530
- api_key = os.getenv("GROQ_API_KEY")
531
  if not api_key:
532
  raise RuntimeError(
533
- "GROQ_API_KEY is not set. Add it as a Secret in your HF Space settings."
 
534
  )
535
- self.client = Groq(api_key=api_key)
536
- self.models = list(GROQ_MODELS)
537
- self.exhausted_models: set[str] = set()
538
- print(f"GroqAgent initialized with models={self.models}")
539
-
540
- def _chat(self, messages, use_tools: bool = True, max_tokens: int = 800, model: str | None = None):
541
- """Try the configured models in order. Handles 429 (retry), 413 (trim & next), TPD (skip)."""
 
 
542
  last_error: Exception | None = None
543
- models = [model] if model else self.models
544
- for m in models:
545
- if m in self.exhausted_models:
546
  continue
547
  for attempt in range(3):
548
  try:
549
- kwargs = dict(
550
- model=m,
551
- messages=messages,
552
  temperature=0.0,
553
- max_tokens=max_tokens,
 
 
 
 
 
554
  )
555
- if use_tools:
556
- kwargs["tools"] = TOOLS_SPEC
557
- kwargs["tool_choice"] = "auto"
558
- return self.client.chat.completions.create(**kwargs)
559
  except Exception as e:
560
  msg = str(e)
561
  last_error = e
562
- is_429 = "429" in msg or "rate_limit" in msg.lower()
563
- is_413 = "413" in msg or "too large" in msg.lower()
564
- is_tpd = "per day" in msg.lower() or "tpd" in msg.lower()
565
- if is_413:
566
- print(f"[{m}] 413 too large; trying next model.")
567
- break
568
- if is_429 and is_tpd:
569
- print(f"[{m}] daily token limit exhausted; switching model.")
570
- self.exhausted_models.add(m)
571
  break
572
- if is_429:
573
- wait = self._parse_retry_seconds(msg)
574
- wait = min(max(wait, 2), 30)
575
- print(f"[{m}] 429; sleeping {wait}s (attempt {attempt + 1}/3)")
576
  time.sleep(wait)
577
  continue
578
- print(f"[{m}] API error: {e}")
579
  break
580
- # Use repr() so empty exception messages still show useful info.
581
  err_str = repr(last_error) if last_error else "no error captured"
582
- raise RuntimeError(f"All Groq models failed. {err_str}")
583
-
584
- @staticmethod
585
- def _parse_retry_seconds(error_msg: str) -> float:
586
- m = re.search(r"in\s+(?:(\d+)m)?([\d.]+)s", error_msg)
587
- if not m:
588
- return 5.0
589
- minutes = int(m.group(1)) if m.group(1) else 0
590
- seconds = float(m.group(2)) if m.group(2) else 0.0
591
- return minutes * 60 + seconds
592
-
593
- @staticmethod
594
- def _trim_messages(messages: list) -> list:
595
- """Keep system + user(0) + last 4 turns. Older tool/assistant turns get summarized."""
596
- if len(messages) <= HISTORY_TRIM_AFTER:
597
- return messages
598
- head = messages[:2] # system + first user
599
- tail = messages[-4:]
600
- # Summarize what was dropped so model has continuity.
601
- dropped = len(messages) - len(head) - len(tail)
602
- summary = {
603
- "role": "user",
604
- "content": f"[Note: {dropped} earlier tool turns omitted to save tokens. Continue with the latest results.]",
605
- }
606
- return head + [summary] + tail
607
 
608
  def __call__(self, question: str, task_id: str | None = None) -> str:
609
- # Deterministic preprocess: detect & flip reversed-text trick questions.
610
- original_q = question
611
  flipped = _maybe_reverse_text(question)
612
  if flipped != question:
613
  print("[reversed-text detected, flipping question]")
@@ -617,60 +519,57 @@ class GroqAgent:
617
  if task_id:
618
  user_content = f"task_id: {task_id}\n\nQuestion: {question}"
619
 
620
- messages = [
621
- {"role": "system", "content": SYSTEM_PROMPT},
622
- {"role": "user", "content": user_content},
 
 
 
 
623
  ]
624
 
625
- # Track tool outputs to feed into the synthesis pass even if loop fails.
626
  collected_facts: list[str] = []
627
 
628
  for step in range(MAX_TOOL_ITERATIONS):
629
  try:
630
- resp = self._chat(self._trim_messages(messages), use_tools=True, max_tokens=800)
631
  except Exception as e:
632
- print(f"chat iteration {step} failed: {e} — trimming and retrying once.")
633
- # Aggressive trim: keep only system + user + last 2 messages.
634
- short_msgs = [messages[0], messages[1]]
635
- if len(messages) > 2:
636
- short_msgs += messages[-2:]
637
- try:
638
- resp = self._chat(short_msgs, use_tools=True, max_tokens=600)
639
- except Exception as e2:
640
- print(f"retry also failed: {e2}; falling through to synthesis.")
641
- break
642
-
643
- msg = resp.choices[0].message
644
- tool_calls = getattr(msg, "tool_calls", None)
645
-
646
- if not tool_calls:
647
- answer = (msg.content or "").strip()
648
- return self._finalize(answer, question, collected_facts)
649
-
650
- messages.append(
651
- {
652
- "role": "assistant",
653
- "content": msg.content or "",
654
- "tool_calls": [
655
- {
656
- "id": tc.id,
657
- "type": "function",
658
- "function": {
659
- "name": tc.function.name,
660
- "arguments": tc.function.arguments,
661
- },
662
- }
663
- for tc in tool_calls
664
- ],
665
- }
666
- )
667
 
668
- for tc in tool_calls:
669
- name = tc.function.name
670
- try:
671
- args = json.loads(tc.function.arguments or "{}")
672
- except json.JSONDecodeError:
673
- args = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
674
  fn = TOOL_FUNCTIONS.get(name)
675
  print(f"[tool] {name}({str(args)[:200]})")
676
  if fn is None:
@@ -680,134 +579,86 @@ class GroqAgent:
680
  result = fn(args)
681
  except Exception as e:
682
  result = f"{name} error: {e}"
683
-
684
  if not isinstance(result, str):
685
  result = str(result)
686
  if len(result) > TOOL_RESULT_MAX_CHARS:
687
  result = result[:TOOL_RESULT_MAX_CHARS] + "\n...[truncated]"
688
-
689
- # Save to facts (cap each at 800 chars for synthesis pass).
690
- collected_facts.append(f"[{name}] {result[:800]}")
691
-
692
- messages.append(
693
- {
694
- "role": "tool",
695
- "tool_call_id": tc.id,
696
- "name": name,
697
- "content": result,
698
- }
699
  )
700
 
701
- # Loop ended (either ran out of iterations OR chat repeatedly failed).
702
- # Do a final synthesis pass on a SHORT context using the smarter model.
 
703
  return self._synthesize(question, collected_facts)
704
 
705
  def _synthesize(self, question: str, facts: list[str]) -> str:
706
- """Final answer pass on a short context. Tries smarter model first."""
707
- # Keep total facts well under any TPM cap.
708
- joined = "\n\n".join(facts[-6:])
709
- if len(joined) > 2500:
710
- joined = joined[-2500:]
711
-
712
- synth_messages = [
713
- {
714
- "role": "system",
715
- "content": (
716
- "You are a strict GAIA answer formatter. Read the question and the "
717
- "research notes below, then output ONLY the final answer string. "
718
- "No preamble, no labels, no explanation, no quotes, no trailing period. "
719
- "Match the question's required format exactly (number-only / IOC code / "
720
- "first name only / surname only / two-decimal currency / comma-space list). "
721
- "If the notes are insufficient, give your single best guess based on "
722
- "general knowledge in the same strict format. Never refuse, never apologize, "
723
- "never reply with an empty string."
724
- ),
725
- },
726
- {
727
- "role": "user",
728
- "content": (
729
- f"Question:\n{question}\n\n"
730
- f"Research notes:\n{joined or '(no notes)'}\n\n"
731
- f"Final answer:"
732
- ),
733
- },
734
- ]
735
- # Try the smarter final model first; fall back to the regular pool.
736
- attempts = []
737
- for model_choice in (GROQ_FINAL_MODEL, *self.models):
738
- if model_choice in attempts:
739
- continue
740
- attempts.append(model_choice)
741
- try:
742
- resp = self._chat(synth_messages, use_tools=False, max_tokens=120, model=model_choice)
743
- ans = (resp.choices[0].message.content or "").strip()
744
- ans = self._postprocess_answer(ans, question)
745
- if ans:
746
- return ans
747
- except Exception as e:
748
- print(f"synth with {model_choice} failed: {e}")
749
- continue
750
- # Last-resort: zero-shot guess with no notes, smallest possible prompt.
751
  try:
752
- resp = self._chat(
753
- [
754
- {"role": "system", "content": "Answer in 1-5 words. No explanation."},
755
- {"role": "user", "content": question[:500]},
756
- ],
757
  use_tools=False,
758
- max_tokens=40,
759
- model=self.models[0],
760
- )
761
- return self._postprocess_answer(
762
- (resp.choices[0].message.content or "").strip(), question
763
  )
 
 
 
 
 
 
 
 
 
764
  except Exception as e:
765
- print(f"last-resort guess failed: {e}")
766
  return "unknown"
767
 
768
- def _finalize(self, raw: str, question: str, facts: list[str] | None = None) -> str:
769
- """Post-process and, if the answer still looks like a sentence, ask the model to reformat."""
770
  cleaned = self._postprocess_answer(raw, question)
771
  if not cleaned:
772
- # Empty answer? Try synthesis from collected facts.
773
- if facts:
774
- return self._synthesize(question, facts)
775
- return cleaned
776
- # If the cleaned answer is suspiciously long or contains explanation-y patterns,
777
- # do a single tiny reformat pass.
778
  looks_sentence = (
779
  len(cleaned.split()) > 12
780
  or re.search(
781
  r"\b(because|received|grant|seems|unable|sorry|cannot|provides|indicating|"
782
- r"web_search|youtube_transcript|fetch_url|task_id)\b",
783
  cleaned,
784
  re.IGNORECASE,
785
  )
786
  )
787
  if looks_sentence:
788
  try:
789
- resp = self._chat(
790
- [
791
- {
792
- "role": "system",
793
- "content": (
794
- "Extract ONLY the final answer from the assistant text below, "
795
- "matching the question's required format exactly. No preamble, "
796
- "no explanation, no quotes, no trailing period, no labels."
797
- ),
798
- },
799
- {
800
- "role": "user",
801
- "content": f"Question: {question}\n\nAssistant text: {cleaned}\n\nFinal answer:",
802
- },
803
- ],
804
  use_tools=False,
805
- max_tokens=80,
806
  )
807
- reformat = (resp.choices[0].message.content or "").strip()
808
- reformat = self._postprocess_answer(reformat, question)
809
- if reformat:
810
- return reformat
811
  except Exception as e:
812
  print(f"reformat pass failed: {e}")
813
  return cleaned
@@ -817,8 +668,6 @@ class GroqAgent:
817
  if not text:
818
  return ""
819
  text = text.strip()
820
-
821
- # Drop common labels.
822
  text = re.sub(
823
  r"^(final\s*answer|answer|the\s*answer\s*is)\s*[:\-]?\s*",
824
  "",
@@ -838,7 +687,6 @@ class GroqAgent:
838
  if m:
839
  text = m.group(0)
840
 
841
- # Strip a single trailing period if the text is short / single token.
842
  if text.endswith(".") and " " not in text:
843
  text = text[:-1]
844
 
@@ -846,7 +694,7 @@ class GroqAgent:
846
 
847
 
848
  # ---------------------------------------------------------------------------
849
- # Answer cache
850
  # ---------------------------------------------------------------------------
851
  def _load_cache() -> dict:
852
  try:
@@ -881,7 +729,7 @@ def run_and_submit_all(profile: gr.OAuthProfile | None):
881
  submit_url = f"{api_url}/submit"
882
 
883
  try:
884
- agent = GroqAgent()
885
  except Exception as e:
886
  print(f"Error instantiating agent: {e}")
887
  return f"Error initializing agent: {e}", None, None
@@ -916,7 +764,7 @@ def run_and_submit_all(profile: gr.OAuthProfile | None):
916
  continue
917
  print(f"\n=== [{idx}/{len(questions_data)}] task_id={task_id} ===")
918
  cached = cache.get(task_id)
919
- if cached and not str(cached).startswith("AGENT ERROR"):
920
  submitted_answer = cached
921
  print(f"(cache hit) {submitted_answer[:80]}")
922
  else:
@@ -931,7 +779,6 @@ def run_and_submit_all(profile: gr.OAuthProfile | None):
931
  results_log.append(
932
  {"Task ID": task_id, "Question": question_text, "Submitted Answer": submitted_answer}
933
  )
934
- # Pace requests so per-minute Groq limits reset between questions.
935
  if INTER_QUESTION_SLEEP > 0 and idx < len(questions_data):
936
  time.sleep(INTER_QUESTION_SLEEP)
937
 
@@ -940,7 +787,6 @@ def run_and_submit_all(profile: gr.OAuthProfile | None):
940
  df.to_csv(RESULTS_CSV_PATH, index=False)
941
  return "Agent did not produce any answers to submit.", df, RESULTS_CSV_PATH
942
 
943
- # Save results CSV before submission so the user can download even if submit fails.
944
  df = pd.DataFrame(results_log)
945
  df.to_csv(RESULTS_CSV_PATH, index=False)
946
  print(f"Results CSV written to {RESULTS_CSV_PATH}")
@@ -991,8 +837,7 @@ def run_and_submit_all(profile: gr.OAuthProfile | None):
991
  continue
992
 
993
  return (
994
- f"Submission Failed after retries: {last_error}. Answers are cached at "
995
- f"{ANSWER_CACHE_PATH} — re-run to retry without re-querying the model.",
996
  df,
997
  RESULTS_CSV_PATH,
998
  )
@@ -1000,20 +845,17 @@ def run_and_submit_all(profile: gr.OAuthProfile | None):
1000
 
1001
  # --- Gradio UI ---
1002
  with gr.Blocks() as demo:
1003
- gr.Markdown("# GAIA Agent (Groq) — Evaluation Runner")
1004
  gr.Markdown(
1005
  """
1006
  **Setup**
1007
- 1. Add a Space secret named `GROQ_API_KEY` (free at console.groq.com).
1008
- 2. *Optional but recommended:* `TAVILY_API_KEY` (free 1000/mo at tavily.com) for better search.
1009
- 3. Optional env vars: `GROQ_MODELS`, `GROQ_VISION_MODEL`, `GROQ_WHISPER_MODEL`, `INTER_QUESTION_SLEEP`.
1010
  4. Log in to Hugging Face below and click **Run Evaluation & Submit All Answers**.
1011
 
1012
  Tools: `web_search`, `fetch_url`, `wikipedia`, `python`, `get_task_file`,
1013
- `transcribe_audio`, `view_image`, `youtube_transcript`.
1014
-
1015
- Tip: if you get rate-limit errors, the answers cache lets you click Run again to
1016
- re-attempt only the failed questions without re-querying ones that already worked.
1017
  """
1018
  )
1019
 
@@ -1021,7 +863,7 @@ with gr.Blocks() as demo:
1021
  run_button = gr.Button("Run Evaluation & Submit All Answers")
1022
  status_output = gr.Textbox(label="Run Status / Submission Result", lines=5, interactive=False)
1023
  results_table = gr.DataFrame(label="Questions and Agent Answers", wrap=True)
1024
- results_csv = gr.File(label="Download Results CSV (paste this back to me for tuning)")
1025
 
1026
  run_button.click(fn=run_and_submit_all, outputs=[status_output, results_table, results_csv])
1027
 
@@ -1042,8 +884,8 @@ if __name__ == "__main__":
1042
  else:
1043
  print("ℹ️ SPACE_ID not found (running locally?).")
1044
 
1045
- if not os.getenv("GROQ_API_KEY"):
1046
- print("⚠️ GROQ_API_KEY is not set. Set it before running evaluation.")
1047
  if not os.getenv("TAVILY_API_KEY"):
1048
  print("ℹ️ TAVILY_API_KEY not set — search will use DuckDuckGo (less reliable).")
1049
 
 
3
  import json
4
  import io
5
  import time
 
6
  import traceback
7
  import contextlib
8
  import tempfile
9
+ import mimetypes
10
  from urllib.parse import urlparse, parse_qs
11
 
12
  import gradio as gr
 
16
  # --- Constants ---
17
  DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
18
 
19
+ # Gemini free-tier models. Flash is fast & smart; "lite" used as fallback only if needed.
20
+ GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
21
+ GEMINI_FALLBACK_MODEL = os.getenv("GEMINI_FALLBACK_MODEL", "gemini-2.0-flash")
22
+
23
+ MAX_TOOL_ITERATIONS = 8
24
+ TOOL_RESULT_MAX_CHARS = 4000
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  ANSWER_CACHE_PATH = os.getenv("ANSWER_CACHE_PATH", "/tmp/answers_cache.json")
26
  RESULTS_CSV_PATH = "/tmp/gaia_results.csv"
27
+ INTER_QUESTION_SLEEP = float(os.getenv("INTER_QUESTION_SLEEP", "1"))
28
 
29
  # Track downloaded task files so vision/audio tools can re-use them by task_id.
30
  _TASK_FILE_CACHE: dict[str, dict] = {}
 
51
  lines.append(f"Answer: {res['answer']}")
52
  for r in res.get("results", [])[:max_results]:
53
  lines.append(
54
+ f"- {r.get('title', '')}\n {r.get('url', '')}\n {r.get('content', '')[:400]}"
55
  )
56
  if len(lines) > 1:
57
  return "\n".join(lines)
 
66
  with DDGS() as ddgs:
67
  for r in ddgs.text(query, max_results=max_results):
68
  results.append(
69
+ f"- {r.get('title', '')}\n {r.get('href', '')}\n {r.get('body', '')[:400]}"
70
  )
71
  if len(results) == 1:
72
  return "[provider: duckduckgo] No results."
 
75
  return f"web_search error: {e}"
76
 
77
 
78
+ def tool_fetch_url(url: str, max_chars: int = 4000) -> str:
79
  """Fetch a URL and return readable text (HTML stripped)."""
80
  try:
81
  from bs4 import BeautifulSoup
 
103
  return f"fetch_url error: {e}"
104
 
105
 
106
+ def tool_wikipedia(query: str, sentences: int = 6) -> str:
107
  """Look up a topic on Wikipedia and return a summary."""
108
  try:
109
  import wikipedia
 
132
  out = buf.getvalue().strip()
133
  if not out and "result" in local_ns:
134
  out = str(local_ns["result"])
135
+ return (out or "(no output)")[:3000]
136
  except Exception as e:
137
  return f"python error: {e}\n{traceback.format_exc(limit=2)}"
138
 
 
152
  return None
153
 
154
 
155
+ def tool_youtube_transcript(url: str, max_chars: int = 4000) -> str:
156
  """Fetch the transcript of a YouTube video by URL or ID."""
157
  try:
158
  from youtube_transcript_api import YouTubeTranscriptApi
 
172
  return f"youtube_transcript error: {e}"
173
 
174
 
175
+ def tool_understand_media(task_id: str, question: str = "") -> str:
176
+ """Use Gemini's native multimodal understanding on an attached image, audio, video, or PDF."""
177
  try:
178
+ from google import genai
179
+ from google.genai import types
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
 
 
 
 
 
181
  info = _TASK_FILE_CACHE.get(task_id)
182
  if not info:
183
  tool_get_task_file(task_id)
184
  info = _TASK_FILE_CACHE.get(task_id)
185
  if not info or not os.path.exists(info.get("path", "")):
186
+ return "understand_media error: no local file for task"
187
+
188
+ path = info["path"]
189
+ mime = info.get("ctype") or mimetypes.guess_type(path)[0] or "application/octet-stream"
190
+ if not mime or mime == "application/octet-stream":
191
+ ext = os.path.splitext(path)[1].lower().lstrip(".")
192
+ mime_map = {
193
+ "mp3": "audio/mp3",
194
+ "wav": "audio/wav",
195
+ "m4a": "audio/mp4",
196
+ "ogg": "audio/ogg",
197
+ "flac": "audio/flac",
198
+ "png": "image/png",
199
+ "jpg": "image/jpeg",
200
+ "jpeg": "image/jpeg",
201
+ "gif": "image/gif",
202
+ "webp": "image/webp",
203
+ "pdf": "application/pdf",
204
+ "mp4": "video/mp4",
205
+ }
206
+ mime = mime_map.get(ext, "application/octet-stream")
207
+
208
+ with open(path, "rb") as f:
209
+ data = f.read()
210
 
211
  prompt = (
212
  question.strip()
213
+ or "Describe the contents of this file in full detail. Transcribe any audio. "
214
+ "Read any text in images. Identify all visible objects/people/numbers."
215
  )
216
 
217
+ client = genai.Client(api_key=os.getenv("GEMINI_API_KEY"))
218
+ resp = client.models.generate_content(
219
+ model=GEMINI_MODEL,
220
+ contents=[
221
+ types.Part.from_bytes(data=data, mime_type=mime),
222
+ prompt,
 
 
 
 
 
223
  ],
 
 
224
  )
225
+ return (resp.text or "").strip() or "(no response)"
226
  except Exception as e:
227
+ return f"understand_media error: {e}"
228
 
229
 
230
  def tool_get_task_file(task_id: str, api_url: str = DEFAULT_API_URL) -> str:
 
263
  text = resp.content.decode("utf-8", errors="replace")
264
  except Exception:
265
  text = resp.text
266
+ return info + "\n--- preview ---\n" + text[:3500]
267
 
268
  if suffix in {".xlsx", ".xls"}:
269
  try:
270
  df = pd.read_excel(tmp.name)
 
271
  csv = df.to_csv(index=False)
272
+ if len(csv) > 3500:
273
+ csv = csv[:3500] + "\n...[truncated]"
274
  return info + "\n--- excel as csv ---\n" + csv
275
  except Exception as e:
276
  return info + f"\n(excel parse error: {e})"
277
 
278
  if suffix == ".pdf":
279
+ return info + "\nPDF file. Call understand_media(task_id, question='...') for full content."
 
 
 
 
 
 
280
 
281
  if suffix in {".mp3", ".wav", ".m4a", ".ogg", ".flac", ".webm"}:
282
+ return info + "\nAudio file. Call understand_media(task_id, question='Transcribe this and answer: ...')."
283
 
284
  if suffix in {".png", ".jpg", ".jpeg", ".gif", ".webp"}:
285
+ return info + "\nImage file. Call understand_media(task_id, question='...')."
286
 
287
+ return info + "\n(binary file; call understand_media if it's media)"
288
  except Exception as e:
289
  return f"get_task_file error: {e}"
290
 
291
 
292
  # ---------------------------------------------------------------------------
293
+ # Tool schema for Gemini function calling
294
  # ---------------------------------------------------------------------------
295
+ def _build_tools_spec():
296
+ """Build google.genai Tool objects."""
297
+ from google.genai import types
298
+
299
+ return [
300
+ types.Tool(
301
+ function_declarations=[
302
+ types.FunctionDeclaration(
303
+ name="web_search",
304
+ description="Search the web (Tavily preferred, DuckDuckGo fallback). Returns titles, URLs, snippets, and Tavily's synthesized answer.",
305
+ parameters=types.Schema(
306
+ type="OBJECT",
307
+ properties={
308
+ "query": types.Schema(type="STRING"),
309
+ "max_results": types.Schema(type="INTEGER"),
310
+ },
311
+ required=["query"],
312
+ ),
313
+ ),
314
+ types.FunctionDeclaration(
315
+ name="fetch_url",
316
+ description="Fetch a URL and return cleaned page text. Use after web_search to read a result page.",
317
+ parameters=types.Schema(
318
+ type="OBJECT",
319
+ properties={
320
+ "url": types.Schema(type="STRING"),
321
+ "max_chars": types.Schema(type="INTEGER"),
322
+ },
323
+ required=["url"],
324
+ ),
325
+ ),
326
+ types.FunctionDeclaration(
327
+ name="wikipedia",
328
+ description="Get a Wikipedia summary for a person, place, work, or topic. Use FIRST for biographical or list questions.",
329
+ parameters=types.Schema(
330
+ type="OBJECT",
331
+ properties={
332
+ "query": types.Schema(type="STRING"),
333
+ "sentences": types.Schema(type="INTEGER"),
334
+ },
335
+ required=["query"],
336
+ ),
337
+ ),
338
+ types.FunctionDeclaration(
339
+ name="python",
340
+ description="Execute a Python snippet for math, sums, dates, sorting, alphabetizing, parsing, string reversal, set logic. Use print() or assign to `result`.",
341
+ parameters=types.Schema(
342
+ type="OBJECT",
343
+ properties={"code": types.Schema(type="STRING")},
344
+ required=["code"],
345
+ ),
346
+ ),
347
+ types.FunctionDeclaration(
348
+ name="get_task_file",
349
+ description="Download the file attached to a GAIA task by task_id. Returns a text preview for text/CSV/Excel/JSON. Returns NO_FILE if no file exists.",
350
+ parameters=types.Schema(
351
+ type="OBJECT",
352
+ properties={"task_id": types.Schema(type="STRING")},
353
+ required=["task_id"],
354
+ ),
355
+ ),
356
+ types.FunctionDeclaration(
357
+ name="understand_media",
358
+ description="Analyze an attached image, audio, video, or PDF using multimodal AI. Pass a focused question. Use for chess images, audio recordings, photos, etc.",
359
+ parameters=types.Schema(
360
+ type="OBJECT",
361
+ properties={
362
+ "task_id": types.Schema(type="STRING"),
363
+ "question": types.Schema(type="STRING"),
364
+ },
365
+ required=["task_id"],
366
+ ),
367
+ ),
368
+ types.FunctionDeclaration(
369
+ name="youtube_transcript",
370
+ description="Fetch the spoken transcript of a YouTube video given its URL or ID. NOTE: only captures speech, not visual content.",
371
+ parameters=types.Schema(
372
+ type="OBJECT",
373
+ properties={
374
+ "url": types.Schema(type="STRING"),
375
+ "max_chars": types.Schema(type="INTEGER"),
376
+ },
377
+ required=["url"],
378
+ ),
379
+ ),
380
+ ]
381
+ )
382
+ ]
383
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
 
385
  TOOL_FUNCTIONS = {
386
  "web_search": lambda args: tool_web_search(args["query"], int(args.get("max_results", 5))),
387
+ "fetch_url": lambda args: tool_fetch_url(args["url"], int(args.get("max_chars", 4000))),
388
+ "wikipedia": lambda args: tool_wikipedia(args["query"], int(args.get("sentences", 6))),
389
  "python": lambda args: tool_python(args["code"]),
390
  "get_task_file": lambda args: tool_get_task_file(args["task_id"]),
391
+ "understand_media": lambda args: tool_understand_media(args["task_id"], args.get("question", "")),
 
392
  "youtube_transcript": lambda args: tool_youtube_transcript(
393
+ args["url"], int(args.get("max_chars", 4000))
394
  ),
395
  }
396
 
397
 
398
  SYSTEM_PROMPT = """You are a careful research agent answering GAIA benchmark questions.
399
 
400
+ Tools: web_search, fetch_url, wikipedia, python, get_task_file, understand_media, youtube_transcript.
401
 
402
  Decision rules:
403
+ - If the question references "attached file/image/audio/Excel/PDF/.mp3/.xlsx/.py/recording/image/photo", call get_task_file FIRST.
404
+ - For audio (.mp3, .wav, etc.) or images (.png, .jpg, etc.) or PDF or video, after get_task_file, call understand_media(task_id, question="<focused question>") to read the content.
405
+ - For Excel/CSV/text, the get_task_file preview is enough; use python to compute on it.
406
+ - For YouTube URLs, use understand_media is NOT applicable (we don't download videos). Use youtube_transcript(url) for audio captions only.
407
+ - If a YouTube question requires VISUAL info (e.g. counting birds on screen), the transcript won't help; make your best estimate from research and the transcript.
408
+ - For factual lookups about people, places, artists, albums, animals, Wikipedia featured articles: START with wikipedia.
409
+ - For everything else research-y: web_search then fetch_url the most relevant URL.
410
+ - Use python for ALL arithmetic, sums, date math, sorting, alphabetizing, set/group operations, string reversal. Never compute by hand.
411
+ - For Excel/CSV totals, after get_task_file shows the data, ALWAYS use python to compute the sum precisely.
 
412
 
413
  ANSWER FORMATTING (the grader does an exact-match comparison; sentence answers ALWAYS lose):
414
 
 
417
  - Q: "Express your answer in USD with two decimal places" -> "89706.00" (NOT "$89,706" or "89706")
418
  - Q: "Give the IOC country code" -> "MLT" (NOT "Malta" or "Malta (MLT)")
419
  - Q: "Just the city name without abbreviations" -> "Saint Petersburg"
420
+ - Q: "Give only the first name" -> "Bartek"
421
  - Q: "Comma separated list ... in alphabetical order" -> "broccoli, celery, fresh basil, lettuce, sweet potatoes, zucchini"
422
  - Q: "Under what NASA award number..." -> "80NSSC21K1130" (just the code, NO surrounding sentence)
 
423
  - Q: opposite of "left" -> "right" (one word)
424
 
425
  Strict rules:
 
427
  - Do NOT include "FINAL ANSWER", "Answer:", or any label.
428
  - Numbers: digits only, no commas, no units, no $ — UNLESS the question asks for the unit.
429
  - Currency "two decimal places": e.g. "89706.00".
430
+ - Strings: no leading articles ("the", "a") unless required; no abbreviations ("Saint" not "St."); digits as digits.
431
  - Names: read the question carefully. "First name only" / "last name only" / "surname" / "full name". Match exactly.
432
+ - Lists: comma-separated, ONE space after each comma. Apply rules to each element. Sort if asked.
433
+
434
+ You have 8 tool turns. Be decisive — don't loop on the same query.
435
  """
436
 
437
 
438
  def _maybe_reverse_text(question: str) -> str:
439
+ """If the question text looks reversed, flip it."""
 
 
440
  q = question.strip()
441
  if not q:
442
  return question
443
  starts_with_punct = q[0] in ".,;:!?"
444
  reversed_text = q[::-1]
 
445
  common = (" the ", " of ", " and ", " to ", " is ", " a ", " in ", " for ")
446
  hits = sum(1 for w in common if w in (" " + reversed_text.lower() + " "))
447
  if starts_with_punct and hits >= 2:
 
452
  # ---------------------------------------------------------------------------
453
  # Agent
454
  # ---------------------------------------------------------------------------
455
+ class GeminiAgent:
456
  def __init__(self):
457
  try:
458
+ from google import genai
459
+ from google.genai import types
460
  except ImportError as e:
461
+ raise RuntimeError("google-genai package not installed") from e
462
 
463
+ api_key = os.getenv("GEMINI_API_KEY")
464
  if not api_key:
465
  raise RuntimeError(
466
+ "GEMINI_API_KEY is not set. Get one free at https://aistudio.google.com/apikey "
467
+ "and add it as a Secret in your HF Space settings."
468
  )
469
+ self.client = genai.Client(api_key=api_key)
470
+ self.types = types
471
+ self.tools = _build_tools_spec()
472
+ self.exhausted: set[str] = set()
473
+ print(f"GeminiAgent initialized with model={GEMINI_MODEL}, fallback={GEMINI_FALLBACK_MODEL}")
474
+
475
+ def _call_model(self, contents, model: str | None = None, use_tools: bool = True):
476
+ """One call to Gemini with retry & fallback. Returns response object."""
477
+ models_to_try = [model] if model else [GEMINI_MODEL, GEMINI_FALLBACK_MODEL]
478
  last_error: Exception | None = None
479
+ for m in models_to_try:
480
+ if m in self.exhausted:
 
481
  continue
482
  for attempt in range(3):
483
  try:
484
+ config = self.types.GenerateContentConfig(
 
 
485
  temperature=0.0,
486
+ tools=self.tools if use_tools else None,
487
+ )
488
+ return self.client.models.generate_content(
489
+ model=m,
490
+ contents=contents,
491
+ config=config,
492
  )
 
 
 
 
493
  except Exception as e:
494
  msg = str(e)
495
  last_error = e
496
+ is_rate = "429" in msg or "RESOURCE_EXHAUSTED" in msg or "rate" in msg.lower()
497
+ is_quota = "quota" in msg.lower() or "exhausted" in msg.lower()
498
+ if is_rate and is_quota:
499
+ print(f"[{m}] daily quota exhausted; switching model.")
500
+ self.exhausted.add(m)
 
 
 
 
501
  break
502
+ if is_rate:
503
+ wait = 5 * (attempt + 1)
504
+ print(f"[{m}] rate-limited; sleeping {wait}s (attempt {attempt + 1}/3)")
 
505
  time.sleep(wait)
506
  continue
507
+ print(f"[{m}] API error: {repr(e)[:300]}")
508
  break
 
509
  err_str = repr(last_error) if last_error else "no error captured"
510
+ raise RuntimeError(f"All Gemini models failed. {err_str}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
511
 
512
  def __call__(self, question: str, task_id: str | None = None) -> str:
 
 
513
  flipped = _maybe_reverse_text(question)
514
  if flipped != question:
515
  print("[reversed-text detected, flipping question]")
 
519
  if task_id:
520
  user_content = f"task_id: {task_id}\n\nQuestion: {question}"
521
 
522
+ # Gemini uses a Content list with role/parts. System instruction is separate but
523
+ # we'll prepend it as the first user message for simplicity / model compatibility.
524
+ contents = [
525
+ self.types.Content(
526
+ role="user",
527
+ parts=[self.types.Part.from_text(text=SYSTEM_PROMPT + "\n\n---\n\n" + user_content)],
528
+ ),
529
  ]
530
 
 
531
  collected_facts: list[str] = []
532
 
533
  for step in range(MAX_TOOL_ITERATIONS):
534
  try:
535
+ resp = self._call_model(contents, use_tools=True)
536
  except Exception as e:
537
+ print(f"call_model failed at step {step}: {e}")
538
+ break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
539
 
540
+ # Check for function calls.
541
+ fcs = []
542
+ try:
543
+ if resp.candidates and resp.candidates[0].content and resp.candidates[0].content.parts:
544
+ for part in resp.candidates[0].content.parts:
545
+ if getattr(part, "function_call", None):
546
+ fcs.append(part.function_call)
547
+ except Exception as e:
548
+ print(f"parse error: {e}")
549
+
550
+ if not fcs:
551
+ # Final text answer.
552
+ text = (resp.text or "").strip() if hasattr(resp, "text") else ""
553
+ if not text and resp.candidates:
554
+ # Pull text parts manually.
555
+ parts_text = []
556
+ for p in resp.candidates[0].content.parts or []:
557
+ if getattr(p, "text", None):
558
+ parts_text.append(p.text)
559
+ text = "".join(parts_text).strip()
560
+ if text:
561
+ return self._finalize(text, question, collected_facts)
562
+ # No text and no function call: fall through to synthesis.
563
+ break
564
+
565
+ # Append assistant turn (model's function_call response).
566
+ contents.append(resp.candidates[0].content)
567
+
568
+ # Execute each function call and append a function response part.
569
+ tool_response_parts = []
570
+ for fc in fcs:
571
+ name = fc.name
572
+ args = dict(fc.args or {})
573
  fn = TOOL_FUNCTIONS.get(name)
574
  print(f"[tool] {name}({str(args)[:200]})")
575
  if fn is None:
 
579
  result = fn(args)
580
  except Exception as e:
581
  result = f"{name} error: {e}"
 
582
  if not isinstance(result, str):
583
  result = str(result)
584
  if len(result) > TOOL_RESULT_MAX_CHARS:
585
  result = result[:TOOL_RESULT_MAX_CHARS] + "\n...[truncated]"
586
+ collected_facts.append(f"[{name}] {result[:1200]}")
587
+ tool_response_parts.append(
588
+ self.types.Part.from_function_response(
589
+ name=name,
590
+ response={"result": result},
591
+ )
 
 
 
 
 
592
  )
593
 
594
+ contents.append(self.types.Content(role="user", parts=tool_response_parts))
595
+
596
+ # Out of iterations or model gave up: synthesize from collected facts.
597
  return self._synthesize(question, collected_facts)
598
 
599
  def _synthesize(self, question: str, facts: list[str]) -> str:
600
+ """Final answer pass on a short context. No tools."""
601
+ joined = "\n\n".join(facts[-8:])
602
+ if len(joined) > 6000:
603
+ joined = joined[-6000:]
604
+
605
+ synth_prompt = (
606
+ "You are a strict GAIA answer formatter. Read the question and the research notes, "
607
+ "then output ONLY the final answer string. No preamble, no labels, no explanation, "
608
+ "no quotes, no trailing period. Match the question's required format exactly "
609
+ "(number-only / IOC code / first name only / surname only / two-decimal currency / "
610
+ "comma-space list). If notes are insufficient, give your single best guess based on "
611
+ "general knowledge. Never refuse, never apologize, never reply with empty string.\n\n"
612
+ f"Question:\n{question}\n\nResearch notes:\n{joined or '(no notes)'}\n\nFinal answer:"
613
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
614
  try:
615
+ resp = self._call_model(
616
+ [self.types.Content(role="user", parts=[self.types.Part.from_text(text=synth_prompt)])],
 
 
 
617
  use_tools=False,
 
 
 
 
 
618
  )
619
+ text = ""
620
+ if hasattr(resp, "text") and resp.text:
621
+ text = resp.text.strip()
622
+ elif resp.candidates:
623
+ for p in resp.candidates[0].content.parts or []:
624
+ if getattr(p, "text", None):
625
+ text += p.text
626
+ text = text.strip()
627
+ return self._postprocess_answer(text, question) or "unknown"
628
  except Exception as e:
629
+ print(f"synthesis failed: {e}")
630
  return "unknown"
631
 
632
+ def _finalize(self, raw: str, question: str, facts: list[str]) -> str:
633
+ """Post-process; reformat if it still looks like a sentence."""
634
  cleaned = self._postprocess_answer(raw, question)
635
  if not cleaned:
636
+ return self._synthesize(question, facts)
 
 
 
 
 
637
  looks_sentence = (
638
  len(cleaned.split()) > 12
639
  or re.search(
640
  r"\b(because|received|grant|seems|unable|sorry|cannot|provides|indicating|"
641
+ r"web_search|youtube_transcript|fetch_url)\b",
642
  cleaned,
643
  re.IGNORECASE,
644
  )
645
  )
646
  if looks_sentence:
647
  try:
648
+ prompt = (
649
+ "Extract ONLY the final answer from the assistant text below, matching the "
650
+ "question's required format exactly. No preamble, no explanation, no quotes, "
651
+ "no trailing period, no labels.\n\n"
652
+ f"Question: {question}\n\nAssistant text: {cleaned}\n\nFinal answer:"
653
+ )
654
+ resp = self._call_model(
655
+ [self.types.Content(role="user", parts=[self.types.Part.from_text(text=prompt)])],
 
 
 
 
 
 
 
656
  use_tools=False,
 
657
  )
658
+ text = (resp.text or "").strip() if hasattr(resp, "text") and resp.text else ""
659
+ text = self._postprocess_answer(text, question)
660
+ if text:
661
+ return text
662
  except Exception as e:
663
  print(f"reformat pass failed: {e}")
664
  return cleaned
 
668
  if not text:
669
  return ""
670
  text = text.strip()
 
 
671
  text = re.sub(
672
  r"^(final\s*answer|answer|the\s*answer\s*is)\s*[:\-]?\s*",
673
  "",
 
687
  if m:
688
  text = m.group(0)
689
 
 
690
  if text.endswith(".") and " " not in text:
691
  text = text[:-1]
692
 
 
694
 
695
 
696
  # ---------------------------------------------------------------------------
697
+ # Cache
698
  # ---------------------------------------------------------------------------
699
  def _load_cache() -> dict:
700
  try:
 
729
  submit_url = f"{api_url}/submit"
730
 
731
  try:
732
+ agent = GeminiAgent()
733
  except Exception as e:
734
  print(f"Error instantiating agent: {e}")
735
  return f"Error initializing agent: {e}", None, None
 
764
  continue
765
  print(f"\n=== [{idx}/{len(questions_data)}] task_id={task_id} ===")
766
  cached = cache.get(task_id)
767
+ if cached and not str(cached).startswith("AGENT ERROR") and cached not in {"", "unknown"}:
768
  submitted_answer = cached
769
  print(f"(cache hit) {submitted_answer[:80]}")
770
  else:
 
779
  results_log.append(
780
  {"Task ID": task_id, "Question": question_text, "Submitted Answer": submitted_answer}
781
  )
 
782
  if INTER_QUESTION_SLEEP > 0 and idx < len(questions_data):
783
  time.sleep(INTER_QUESTION_SLEEP)
784
 
 
787
  df.to_csv(RESULTS_CSV_PATH, index=False)
788
  return "Agent did not produce any answers to submit.", df, RESULTS_CSV_PATH
789
 
 
790
  df = pd.DataFrame(results_log)
791
  df.to_csv(RESULTS_CSV_PATH, index=False)
792
  print(f"Results CSV written to {RESULTS_CSV_PATH}")
 
837
  continue
838
 
839
  return (
840
+ f"Submission Failed after retries: {last_error}.",
 
841
  df,
842
  RESULTS_CSV_PATH,
843
  )
 
845
 
846
  # --- Gradio UI ---
847
  with gr.Blocks() as demo:
848
+ gr.Markdown("# GAIA Agent (Gemini) — Evaluation Runner")
849
  gr.Markdown(
850
  """
851
  **Setup**
852
+ 1. Add a Space secret named `GEMINI_API_KEY` (free at [aistudio.google.com/apikey](https://aistudio.google.com/apikey)).
853
+ 2. *Optional but recommended:* `TAVILY_API_KEY` (free tier at tavily.com) for better search.
854
+ 3. Optional env vars: `GEMINI_MODEL` (default `gemini-2.5-flash`), `GEMINI_FALLBACK_MODEL`.
855
  4. Log in to Hugging Face below and click **Run Evaluation & Submit All Answers**.
856
 
857
  Tools: `web_search`, `fetch_url`, `wikipedia`, `python`, `get_task_file`,
858
+ `understand_media` (handles images/audio/PDFs natively via Gemini), `youtube_transcript`.
 
 
 
859
  """
860
  )
861
 
 
863
  run_button = gr.Button("Run Evaluation & Submit All Answers")
864
  status_output = gr.Textbox(label="Run Status / Submission Result", lines=5, interactive=False)
865
  results_table = gr.DataFrame(label="Questions and Agent Answers", wrap=True)
866
+ results_csv = gr.File(label="Download Results CSV (paste back to me for tuning)")
867
 
868
  run_button.click(fn=run_and_submit_all, outputs=[status_output, results_table, results_csv])
869
 
 
884
  else:
885
  print("ℹ️ SPACE_ID not found (running locally?).")
886
 
887
+ if not os.getenv("GEMINI_API_KEY"):
888
+ print("⚠️ GEMINI_API_KEY is not set. Set it before running evaluation.")
889
  if not os.getenv("TAVILY_API_KEY"):
890
  print("ℹ️ TAVILY_API_KEY not set — search will use DuckDuckGo (less reliable).")
891