avi080704 commited on
Commit
4556508
·
verified ·
1 Parent(s): 82939f8

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +446 -292
app.py CHANGED
@@ -3,6 +3,7 @@ import re
3
  import json
4
  import io
5
  import time
 
6
  import traceback
7
  import contextlib
8
  import tempfile
@@ -15,16 +16,32 @@ import pandas as pd
15
 
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] = {}
@@ -34,7 +51,7 @@ _TASK_FILE_CACHE: dict[str, dict] = {}
34
  # Tool implementations
35
  # ---------------------------------------------------------------------------
36
  def tool_web_search(query: str, max_results: int = 5) -> str:
37
- """Web search. Tries Tavily first (if TAVILY_API_KEY set), falls back to DuckDuckGo."""
38
  tavily_key = os.getenv("TAVILY_API_KEY")
39
  if tavily_key:
40
  try:
@@ -75,7 +92,7 @@ def tool_web_search(query: str, max_results: int = 5) -> str:
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
@@ -132,7 +149,7 @@ def tool_python(code: str) -> str:
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,8 +169,8 @@ def _extract_youtube_id(url: str) -> str | None:
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
159
  vid = _extract_youtube_id(url) or url.strip()
@@ -172,59 +189,123 @@ def tool_youtube_transcript(url: str, max_chars: int = 4000) -> str:
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,164 +344,198 @@ 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
 
415
  Worked examples of correct GAIA format:
416
  - Q: "How many albums..." -> "3" (NOT "3 albums" or "There were 3 albums")
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:
426
  - Reply with ONLY the answer. No preamble. No explanation. No quotes. No trailing period.
@@ -428,10 +543,8 @@ Strict rules:
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
 
@@ -452,62 +565,66 @@ def _maybe_reverse_text(question: str) -> str:
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)
@@ -519,118 +636,142 @@ class GeminiAgent:
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:
576
- result = f"unknown tool: {name}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
577
  else:
578
- try:
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)
@@ -645,20 +786,28 @@ class GeminiAgent:
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
@@ -729,7 +878,7 @@ def run_and_submit_all(profile: gr.OAuthProfile | None):
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
@@ -845,17 +994,20 @@ def run_and_submit_all(profile: gr.OAuthProfile | None):
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
 
@@ -884,10 +1036,12 @@ if __name__ == "__main__":
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
 
892
  print("-" * (60 + len(" App Starting ")) + "\n")
893
  demo.launch(debug=True, share=False)
 
3
  import json
4
  import io
5
  import time
6
+ import base64
7
  import traceback
8
  import contextlib
9
  import tempfile
 
16
 
17
  # --- Constants ---
18
  DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
19
+ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
20
+
21
+ # Fleet of free OpenRouter models. Tried in order. When one rate-limits or errors,
22
+ # we fall through to the next one. Mix of strong reasoning + tool use.
23
+ TEXT_MODELS = [
24
+ m.strip() for m in os.getenv(
25
+ "OPENROUTER_MODELS",
26
+ # Best free models for tool use as of 2025-2026.
27
+ "deepseek/deepseek-chat-v3-0324:free,"
28
+ "meta-llama/llama-3.3-70b-instruct:free,"
29
+ "mistralai/mistral-small-3.2-24b-instruct:free,"
30
+ "google/gemini-2.0-flash-exp:free,"
31
+ "qwen/qwen-2.5-72b-instruct:free,"
32
+ "deepseek/deepseek-r1:free"
33
+ ).split(",")
34
+ if m.strip()
35
+ ]
36
+ # Vision-capable free model. Gemini Flash is multimodal and free on OpenRouter.
37
+ VISION_MODEL = os.getenv("OPENROUTER_VISION_MODEL", "google/gemini-2.0-flash-exp:free")
38
+
39
+ MAX_TOOL_ITERATIONS = 7
40
+ TOOL_RESULT_MAX_CHARS = 3500
41
  ANSWER_CACHE_PATH = os.getenv("ANSWER_CACHE_PATH", "/tmp/answers_cache.json")
42
  RESULTS_CSV_PATH = "/tmp/gaia_results.csv"
43
+ INTER_QUESTION_SLEEP = float(os.getenv("INTER_QUESTION_SLEEP", "2"))
44
+ INTER_TOOL_SLEEP = float(os.getenv("INTER_TOOL_SLEEP", "0.5"))
45
 
46
  # Track downloaded task files so vision/audio tools can re-use them by task_id.
47
  _TASK_FILE_CACHE: dict[str, dict] = {}
 
51
  # Tool implementations
52
  # ---------------------------------------------------------------------------
53
  def tool_web_search(query: str, max_results: int = 5) -> str:
54
+ """Web search. Tries Tavily first, falls back to DuckDuckGo."""
55
  tavily_key = os.getenv("TAVILY_API_KEY")
56
  if tavily_key:
57
  try:
 
92
  return f"web_search error: {e}"
93
 
94
 
95
+ def tool_fetch_url(url: str, max_chars: int = 3500) -> str:
96
  """Fetch a URL and return readable text (HTML stripped)."""
97
  try:
98
  from bs4 import BeautifulSoup
 
149
  out = buf.getvalue().strip()
150
  if not out and "result" in local_ns:
151
  out = str(local_ns["result"])
152
+ return (out or "(no output)")[:2500]
153
  except Exception as e:
154
  return f"python error: {e}\n{traceback.format_exc(limit=2)}"
155
 
 
169
  return None
170
 
171
 
172
+ def tool_youtube_transcript(url: str, max_chars: int = 3500) -> str:
173
+ """Fetch the spoken transcript of a YouTube video."""
174
  try:
175
  from youtube_transcript_api import YouTubeTranscriptApi
176
  vid = _extract_youtube_id(url) or url.strip()
 
189
  return f"youtube_transcript error: {e}"
190
 
191
 
192
+ def _hf_inference(model: str, data: bytes, content_type: str) -> str:
193
+ """Call HF Inference API with raw bytes (used for Whisper audio transcription)."""
194
+ hf_token = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACEHUB_API_TOKEN")
195
+ headers = {"Content-Type": content_type}
196
+ if hf_token:
197
+ headers["Authorization"] = f"Bearer {hf_token}"
198
+ url = f"https://api-inference.huggingface.co/models/{model}"
199
+ # HF inference can be cold-started; retry a few times.
200
+ for attempt in range(3):
201
+ resp = requests.post(url, headers=headers, data=data, timeout=120)
202
+ if resp.status_code == 503:
203
+ # Model loading — wait per estimated_time.
204
+ try:
205
+ wait = float(resp.json().get("estimated_time", 10))
206
+ except Exception:
207
+ wait = 10
208
+ wait = min(max(wait, 3), 30)
209
+ print(f"HF model {model} loading; waiting {wait}s...")
210
+ time.sleep(wait)
211
+ continue
212
+ resp.raise_for_status()
213
+ return resp.text
214
+ raise RuntimeError(f"HF model {model} not ready after retries")
215
+
216
 
217
+ def tool_transcribe_audio(task_id: str) -> str:
218
+ """Transcribe an attached audio file using HF Whisper Inference API."""
219
+ try:
220
  info = _TASK_FILE_CACHE.get(task_id)
221
  if not info:
222
  tool_get_task_file(task_id)
223
  info = _TASK_FILE_CACHE.get(task_id)
224
  if not info or not os.path.exists(info.get("path", "")):
225
+ return "transcribe_audio error: no local file for task"
226
 
227
  path = info["path"]
228
+ ext = os.path.splitext(path)[1].lower().lstrip(".")
229
+ ctype_map = {
230
+ "mp3": "audio/mpeg", "wav": "audio/wav", "m4a": "audio/mp4",
231
+ "ogg": "audio/ogg", "flac": "audio/flac", "webm": "audio/webm",
232
+ }
233
+ ctype = ctype_map.get(ext, "audio/mpeg")
 
 
 
 
 
 
 
 
 
 
 
 
234
 
235
  with open(path, "rb") as f:
236
  data = f.read()
237
 
238
+ raw = _hf_inference("openai/whisper-large-v3", data, ctype)
239
+ try:
240
+ obj = json.loads(raw)
241
+ if isinstance(obj, dict) and "text" in obj:
242
+ text = obj["text"]
243
+ elif isinstance(obj, list) and obj and "text" in obj[0]:
244
+ text = obj[0]["text"]
245
+ else:
246
+ text = raw
247
+ except Exception:
248
+ text = raw
249
+ text = (text or "").strip()
250
+ if len(text) > 4000:
251
+ text = text[:4000] + " ...[truncated]"
252
+ return text or "(empty transcript)"
253
+ except Exception as e:
254
+ return f"transcribe_audio error: {e}"
255
+
256
+
257
+ def tool_view_image(task_id: str, question: str = "") -> str:
258
+ """Inspect an image attached to a GAIA task using a vision-capable LLM via OpenRouter."""
259
+ try:
260
+ from openai import OpenAI
261
+
262
+ info = _TASK_FILE_CACHE.get(task_id)
263
+ if not info:
264
+ tool_get_task_file(task_id)
265
+ info = _TASK_FILE_CACHE.get(task_id)
266
+ if not info or not os.path.exists(info.get("path", "")):
267
+ return "view_image error: no local file for task"
268
+
269
+ suffix = os.path.splitext(info["path"])[1].lower().lstrip(".")
270
+ if suffix == "jpg":
271
+ suffix = "jpeg"
272
+ if suffix not in {"png", "jpeg", "gif", "webp"}:
273
+ return f"view_image error: unsupported image type .{suffix}"
274
+
275
+ with open(info["path"], "rb") as f:
276
+ b64 = base64.b64encode(f.read()).decode("ascii")
277
+ data_url = f"data:image/{suffix};base64,{b64}"
278
+
279
  prompt = (
280
  question.strip()
281
+ or "Describe this image in detail, including any text, numbers, or symbols visible."
 
282
  )
283
 
284
+ client = OpenAI(
285
+ base_url=OPENROUTER_BASE_URL,
286
+ api_key=os.getenv("OPENROUTER_API_KEY"),
287
+ )
288
+ resp = client.chat.completions.create(
289
+ model=VISION_MODEL,
290
+ messages=[
291
+ {
292
+ "role": "user",
293
+ "content": [
294
+ {"type": "text", "text": prompt},
295
+ {"type": "image_url", "image_url": {"url": data_url}},
296
+ ],
297
+ }
298
  ],
299
+ temperature=0.0,
300
+ max_tokens=600,
301
+ extra_headers={
302
+ "HTTP-Referer": "https://huggingface.co/learn/agents-course",
303
+ "X-Title": "GAIA Agent",
304
+ },
305
  )
306
+ return (resp.choices[0].message.content or "").strip()
307
  except Exception as e:
308
+ return f"view_image error: {e}"
309
 
310
 
311
  def tool_get_task_file(task_id: str, api_url: str = DEFAULT_API_URL) -> str:
 
344
  text = resp.content.decode("utf-8", errors="replace")
345
  except Exception:
346
  text = resp.text
347
+ return info + "\n--- preview ---\n" + text[:3000]
348
 
349
  if suffix in {".xlsx", ".xls"}:
350
  try:
351
  df = pd.read_excel(tmp.name)
352
  csv = df.to_csv(index=False)
353
+ if len(csv) > 3000:
354
+ csv = csv[:3000] + "\n...[truncated]"
355
  return info + "\n--- excel as csv ---\n" + csv
356
  except Exception as e:
357
  return info + f"\n(excel parse error: {e})"
358
 
359
  if suffix == ".pdf":
360
+ try:
361
+ from pypdf import PdfReader
362
+ reader = PdfReader(tmp.name)
363
+ pages = [p.extract_text() or "" for p in reader.pages[:6]]
364
+ return info + "\n--- pdf text ---\n" + "\n".join(pages)[:3000]
365
+ except Exception as e:
366
+ return info + f"\n(pdf parse error: {e})"
367
 
368
  if suffix in {".mp3", ".wav", ".m4a", ".ogg", ".flac", ".webm"}:
369
+ return info + "\nAudio file. Call transcribe_audio(task_id) to read it."
370
 
371
  if suffix in {".png", ".jpg", ".jpeg", ".gif", ".webp"}:
372
+ return info + "\nImage file. Call view_image(task_id, question='...')."
373
 
374
+ return info + "\n(binary file; no preview)"
375
  except Exception as e:
376
  return f"get_task_file error: {e}"
377
 
378
 
379
  # ---------------------------------------------------------------------------
380
+ # Tool schema (OpenAI-compatible)
381
  # ---------------------------------------------------------------------------
382
+ TOOLS_SPEC = [
383
+ {
384
+ "type": "function",
385
+ "function": {
386
+ "name": "web_search",
387
+ "description": "Search the web (Tavily preferred, DuckDuckGo fallback). Returns titles, URLs, snippets, and Tavily's synthesized answer.",
388
+ "parameters": {
389
+ "type": "object",
390
+ "properties": {
391
+ "query": {"type": "string"},
392
+ "max_results": {"type": "integer"},
393
+ },
394
+ "required": ["query"],
395
+ },
396
+ },
397
+ },
398
+ {
399
+ "type": "function",
400
+ "function": {
401
+ "name": "fetch_url",
402
+ "description": "Fetch a URL and return cleaned page text. Use after web_search to read a result page.",
403
+ "parameters": {
404
+ "type": "object",
405
+ "properties": {
406
+ "url": {"type": "string"},
407
+ "max_chars": {"type": "integer"},
408
+ },
409
+ "required": ["url"],
410
+ },
411
+ },
412
+ },
413
+ {
414
+ "type": "function",
415
+ "function": {
416
+ "name": "wikipedia",
417
+ "description": "Get a Wikipedia summary for a person, place, work, or topic. Use FIRST for biographical or list questions.",
418
+ "parameters": {
419
+ "type": "object",
420
+ "properties": {
421
+ "query": {"type": "string"},
422
+ "sentences": {"type": "integer"},
423
+ },
424
+ "required": ["query"],
425
+ },
426
+ },
427
+ },
428
+ {
429
+ "type": "function",
430
+ "function": {
431
+ "name": "python",
432
+ "description": "Execute a Python snippet for math, sums, dates, sorting, alphabetizing, parsing, string reversal, set logic. Use print() or assign to `result`.",
433
+ "parameters": {
434
+ "type": "object",
435
+ "properties": {"code": {"type": "string"}},
436
+ "required": ["code"],
437
+ },
438
+ },
439
+ },
440
+ {
441
+ "type": "function",
442
+ "function": {
443
+ "name": "get_task_file",
444
+ "description": "Download the file attached to a GAIA task by task_id. Returns NO_FILE if no file exists.",
445
+ "parameters": {
446
+ "type": "object",
447
+ "properties": {"task_id": {"type": "string"}},
448
+ "required": ["task_id"],
449
+ },
450
+ },
451
+ },
452
+ {
453
+ "type": "function",
454
+ "function": {
455
+ "name": "transcribe_audio",
456
+ "description": "Transcribe an attached audio file (.mp3/.wav/.m4a/.ogg/.flac) using Whisper.",
457
+ "parameters": {
458
+ "type": "object",
459
+ "properties": {"task_id": {"type": "string"}},
460
+ "required": ["task_id"],
461
+ },
462
+ },
463
+ },
464
+ {
465
+ "type": "function",
466
+ "function": {
467
+ "name": "view_image",
468
+ "description": "Inspect an attached image (.png/.jpg/.gif/.webp) using a vision model. Pass a focused question.",
469
+ "parameters": {
470
+ "type": "object",
471
+ "properties": {
472
+ "task_id": {"type": "string"},
473
+ "question": {"type": "string"},
474
+ },
475
+ "required": ["task_id"],
476
+ },
477
+ },
478
+ },
479
+ {
480
+ "type": "function",
481
+ "function": {
482
+ "name": "youtube_transcript",
483
+ "description": "Fetch the spoken transcript of a YouTube video given its URL. Only captures speech, not visual content.",
484
+ "parameters": {
485
+ "type": "object",
486
+ "properties": {
487
+ "url": {"type": "string"},
488
+ "max_chars": {"type": "integer"},
489
+ },
490
+ "required": ["url"],
491
+ },
492
+ },
493
+ },
494
+ ]
495
 
496
  TOOL_FUNCTIONS = {
497
  "web_search": lambda args: tool_web_search(args["query"], int(args.get("max_results", 5))),
498
+ "fetch_url": lambda args: tool_fetch_url(args["url"], int(args.get("max_chars", 3500))),
499
  "wikipedia": lambda args: tool_wikipedia(args["query"], int(args.get("sentences", 6))),
500
  "python": lambda args: tool_python(args["code"]),
501
  "get_task_file": lambda args: tool_get_task_file(args["task_id"]),
502
+ "transcribe_audio": lambda args: tool_transcribe_audio(args["task_id"]),
503
+ "view_image": lambda args: tool_view_image(args["task_id"], args.get("question", "")),
504
  "youtube_transcript": lambda args: tool_youtube_transcript(
505
+ args["url"], int(args.get("max_chars", 3500))
506
  ),
507
  }
508
 
509
 
510
  SYSTEM_PROMPT = """You are a careful research agent answering GAIA benchmark questions.
511
 
512
+ Tools: web_search, fetch_url, wikipedia, python, get_task_file, transcribe_audio, view_image, youtube_transcript.
513
 
514
  Decision rules:
515
+ - If the question references "attached file/image/audio/Excel/PDF/.mp3/.xlsx/.py/recording/photo/image", call get_task_file FIRST.
516
+ - Audio (.mp3, .wav, etc.) -> transcribe_audio(task_id) after get_task_file.
517
+ - Image (.png, .jpg, etc.) -> view_image(task_id, question="<focused question>") after get_task_file.
518
+ - Excel/CSV/text/PDF the get_task_file preview is enough; use python to compute on it.
519
+ - If get_task_file returns NO_FILE, do NOT call it again.
520
+ - For YouTube URLs, use youtube_transcript(url) directly. (No get_task_file needed.) The transcript is speech only — for visual questions, give your best estimate.
521
  - For factual lookups about people, places, artists, albums, animals, Wikipedia featured articles: START with wikipedia.
522
  - For everything else research-y: web_search then fetch_url the most relevant URL.
523
  - Use python for ALL arithmetic, sums, date math, sorting, alphabetizing, set/group operations, string reversal. Never compute by hand.
524
  - For Excel/CSV totals, after get_task_file shows the data, ALWAYS use python to compute the sum precisely.
525
 
526
+ Be decisive — don't repeat the same tool with the same args. You have 7 tool turns.
527
+
528
  ANSWER FORMATTING (the grader does an exact-match comparison; sentence answers ALWAYS lose):
529
 
530
  Worked examples of correct GAIA format:
531
  - Q: "How many albums..." -> "3" (NOT "3 albums" or "There were 3 albums")
532
+ - Q: "Express your answer in USD with two decimal places" -> "89706.00"
533
+ - Q: "Give the IOC country code" -> "MLT"
534
  - Q: "Just the city name without abbreviations" -> "Saint Petersburg"
535
  - Q: "Give only the first name" -> "Bartek"
536
  - Q: "Comma separated list ... in alphabetical order" -> "broccoli, celery, fresh basil, lettuce, sweet potatoes, zucchini"
537
+ - Q: "Under what NASA award number..." -> "80NSSC21K1130"
538
+ - Q: opposite of "left" -> "right"
539
 
540
  Strict rules:
541
  - Reply with ONLY the answer. No preamble. No explanation. No quotes. No trailing period.
 
543
  - Numbers: digits only, no commas, no units, no $ — UNLESS the question asks for the unit.
544
  - Currency "two decimal places": e.g. "89706.00".
545
  - Strings: no leading articles ("the", "a") unless required; no abbreviations ("Saint" not "St."); digits as digits.
546
+ - Names: read the question carefully ("first name only" / "last name only" / "surname" / "full name").
547
+ - Lists: comma-separated, ONE space after each comma. Sort if asked.
 
 
548
  """
549
 
550
 
 
565
  # ---------------------------------------------------------------------------
566
  # Agent
567
  # ---------------------------------------------------------------------------
568
+ class OpenRouterAgent:
569
  def __init__(self):
570
  try:
571
+ from openai import OpenAI
 
572
  except ImportError as e:
573
+ raise RuntimeError("openai package not installed") from e
574
 
575
+ api_key = os.getenv("OPENROUTER_API_KEY")
576
  if not api_key:
577
  raise RuntimeError(
578
+ "OPENROUTER_API_KEY is not set. Get one free at https://openrouter.ai/keys "
579
  "and add it as a Secret in your HF Space settings."
580
  )
581
+ self.client = OpenAI(base_url=OPENROUTER_BASE_URL, api_key=api_key)
582
+ self.models = list(TEXT_MODELS)
 
583
  self.exhausted: set[str] = set()
584
+ self.extra_headers = {
585
+ "HTTP-Referer": "https://huggingface.co/learn/agents-course",
586
+ "X-Title": "GAIA Agent",
587
+ }
588
+ print(f"OpenRouterAgent initialized with model fleet: {self.models}")
589
 
590
+ def _chat(self, messages, use_tools: bool = True, max_tokens: int = 800):
591
+ """Try each model in the fleet. Falls through on rate limit / error."""
 
592
  last_error: Exception | None = None
593
+ for m in self.models:
594
  if m in self.exhausted:
595
  continue
596
+ for attempt in range(2):
597
  try:
598
+ kwargs = dict(
 
 
 
 
599
  model=m,
600
+ messages=messages,
601
+ temperature=0.0,
602
+ max_tokens=max_tokens,
603
+ extra_headers=self.extra_headers,
604
  )
605
+ if use_tools:
606
+ kwargs["tools"] = TOOLS_SPEC
607
+ kwargs["tool_choice"] = "auto"
608
+ return self.client.chat.completions.create(**kwargs)
609
  except Exception as e:
610
  msg = str(e)
611
  last_error = e
612
+ is_rate = "429" in msg or "rate" in msg.lower() or "limit" in msg.lower()
613
+ is_quota = ("daily" in msg.lower() or "quota" in msg.lower()
614
+ or "exhausted" in msg.lower())
615
  if is_rate and is_quota:
616
  print(f"[{m}] daily quota exhausted; switching model.")
617
  self.exhausted.add(m)
618
  break
619
  if is_rate:
620
+ wait = 4 * (attempt + 1)
621
+ print(f"[{m}] rate-limited; sleeping {wait}s (attempt {attempt + 1}/2)")
622
  time.sleep(wait)
623
  continue
624
+ print(f"[{m}] API error: {repr(e)[:240]} — trying next model.")
625
  break
626
  err_str = repr(last_error) if last_error else "no error captured"
627
+ raise RuntimeError(f"All OpenRouter models failed. {err_str}")
628
 
629
  def __call__(self, question: str, task_id: str | None = None) -> str:
630
  flipped = _maybe_reverse_text(question)
 
636
  if task_id:
637
  user_content = f"task_id: {task_id}\n\nQuestion: {question}"
638
 
639
+ messages = [
640
+ {"role": "system", "content": SYSTEM_PROMPT},
641
+ {"role": "user", "content": user_content},
 
 
 
 
642
  ]
643
 
644
  collected_facts: list[str] = []
645
+ seen_calls: set[str] = set()
646
 
647
  for step in range(MAX_TOOL_ITERATIONS):
648
  try:
649
+ resp = self._chat(messages, use_tools=True, max_tokens=800)
650
  except Exception as e:
651
+ print(f"chat at step {step} failed: {e}")
652
  break
653
 
654
+ msg = resp.choices[0].message
655
+ tool_calls = getattr(msg, "tool_calls", None)
656
+
657
+ if not tool_calls:
658
+ answer = (msg.content or "").strip()
659
+ if answer:
660
+ return self._finalize(answer, question, collected_facts)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
661
  break
662
 
663
+ messages.append(
664
+ {
665
+ "role": "assistant",
666
+ "content": msg.content or "",
667
+ "tool_calls": [
668
+ {
669
+ "id": tc.id,
670
+ "type": "function",
671
+ "function": {
672
+ "name": tc.function.name,
673
+ "arguments": tc.function.arguments,
674
+ },
675
+ }
676
+ for tc in tool_calls
677
+ ],
678
+ }
679
+ )
680
+
681
+ for tc in tool_calls:
682
+ name = tc.function.name
683
+ try:
684
+ args = json.loads(tc.function.arguments or "{}")
685
+ except json.JSONDecodeError:
686
+ args = {}
687
+
688
+ call_key = f"{name}|{json.dumps(args, sort_keys=True, default=str)[:300]}"
689
+ if call_key in seen_calls:
690
+ print(f"[tool] {name}({str(args)[:120]}) [DUPLICATE — skipping]")
691
+ result = "DUPLICATE_CALL: you already called this with the same args. Try a different query, a different tool, or give your final answer."
692
  else:
693
+ seen_calls.add(call_key)
694
+ fn = TOOL_FUNCTIONS.get(name)
695
+ print(f"[tool] {name}({str(args)[:200]})")
696
+ if fn is None:
697
+ result = f"unknown tool: {name}"
698
+ else:
699
+ try:
700
+ result = fn(args)
701
+ except Exception as e:
702
+ result = f"{name} error: {e}"
703
+
704
  if not isinstance(result, str):
705
  result = str(result)
706
  if len(result) > TOOL_RESULT_MAX_CHARS:
707
  result = result[:TOOL_RESULT_MAX_CHARS] + "\n...[truncated]"
708
+
709
  collected_facts.append(f"[{name}] {result[:1200]}")
710
+
711
+ messages.append(
712
+ {
713
+ "role": "tool",
714
+ "tool_call_id": tc.id,
715
+ "name": name,
716
+ "content": result,
717
+ }
718
  )
719
 
720
+ if INTER_TOOL_SLEEP > 0:
721
+ time.sleep(INTER_TOOL_SLEEP)
722
 
 
723
  return self._synthesize(question, collected_facts)
724
 
725
  def _synthesize(self, question: str, facts: list[str]) -> str:
726
  """Final answer pass on a short context. No tools."""
727
  joined = "\n\n".join(facts[-8:])
728
+ if len(joined) > 5000:
729
+ joined = joined[-5000:]
730
+
731
+ synth_messages = [
732
+ {
733
+ "role": "system",
734
+ "content": (
735
+ "You are a strict GAIA answer formatter. Read the question and the research "
736
+ "notes, then output ONLY the final answer string. No preamble, no labels, no "
737
+ "explanation, no quotes, no trailing period. Match the question's required "
738
+ "format exactly. If notes are insufficient, give your single best guess based "
739
+ "on general knowledge. Never refuse, never apologize, never reply with empty."
740
+ ),
741
+ },
742
+ {
743
+ "role": "user",
744
+ "content": (
745
+ f"Question:\n{question}\n\n"
746
+ f"Research notes:\n{joined or '(no notes)'}\n\nFinal answer:"
747
+ ),
748
+ },
749
+ ]
750
  try:
751
+ resp = self._chat(synth_messages, use_tools=False, max_tokens=120)
752
+ return self._postprocess_answer(
753
+ (resp.choices[0].message.content or "").strip(), question
754
+ ) or "unknown"
 
 
 
 
 
 
 
 
 
755
  except Exception as e:
756
  print(f"synthesis failed: {e}")
757
+ # Last-resort: tiny zero-shot guess
758
+ try:
759
+ resp = self._chat(
760
+ [
761
+ {"role": "system", "content": "Answer in 1-5 words. No explanation."},
762
+ {"role": "user", "content": question[:500]},
763
+ ],
764
+ use_tools=False,
765
+ max_tokens=40,
766
+ )
767
+ return self._postprocess_answer(
768
+ (resp.choices[0].message.content or "").strip(), question
769
+ ) or "unknown"
770
+ except Exception as e2:
771
+ print(f"last-resort guess failed: {e2}")
772
+ return "unknown"
773
 
774
  def _finalize(self, raw: str, question: str, facts: list[str]) -> str:
 
775
  cleaned = self._postprocess_answer(raw, question)
776
  if not cleaned:
777
  return self._synthesize(question, facts)
 
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
 
878
  submit_url = f"{api_url}/submit"
879
 
880
  try:
881
+ agent = OpenRouterAgent()
882
  except Exception as e:
883
  print(f"Error instantiating agent: {e}")
884
  return f"Error initializing agent: {e}", None, None
 
994
 
995
  # --- Gradio UI ---
996
  with gr.Blocks() as demo:
997
+ gr.Markdown("# GAIA Agent (OpenRouter) — Evaluation Runner")
998
  gr.Markdown(
999
  """
1000
  **Setup**
1001
+ 1. Add a Space secret named `OPENROUTER_API_KEY` (free at [openrouter.ai/keys](https://openrouter.ai/keys)).
1002
+ 2. *Optional but recommended:* `TAVILY_API_KEY` for better search.
1003
+ 3. Optional: `HF_TOKEN` for Whisper audio transcription via HF Inference API.
1004
+ 4. Optional env vars: `OPENROUTER_MODELS` (comma-separated fleet), `OPENROUTER_VISION_MODEL`.
1005
+ 5. Log in to Hugging Face below and click **Run Evaluation & Submit All Answers**.
1006
 
1007
  Tools: `web_search`, `fetch_url`, `wikipedia`, `python`, `get_task_file`,
1008
+ `transcribe_audio` (HF Whisper), `view_image` (Gemini Flash via OpenRouter), `youtube_transcript`.
1009
+
1010
+ Model fleet falls through automatically when one rate-limits.
1011
  """
1012
  )
1013
 
 
1036
  else:
1037
  print("ℹ️ SPACE_ID not found (running locally?).")
1038
 
1039
+ if not os.getenv("OPENROUTER_API_KEY"):
1040
+ print("⚠️ OPENROUTER_API_KEY is not set. Set it before running evaluation.")
1041
  if not os.getenv("TAVILY_API_KEY"):
1042
  print("ℹ️ TAVILY_API_KEY not set — search will use DuckDuckGo (less reliable).")
1043
+ if not os.getenv("HF_TOKEN"):
1044
+ print("ℹ️ HF_TOKEN not set — audio transcription may rate-limit on cold starts.")
1045
 
1046
  print("-" * (60 + len(" App Starting ")) + "\n")
1047
  demo.launch(debug=True, share=False)