vladd19 commited on
Commit
2c5bc68
·
verified ·
1 Parent(s): 75784cd

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +488 -682
app.py CHANGED
@@ -1,12 +1,13 @@
1
- import os
2
- import re
3
- import io
4
- import sys
5
- import time
6
  import base64
7
  import html
 
 
8
  import mimetypes
 
 
9
  import subprocess
 
 
10
  from functools import lru_cache
11
  from pathlib import Path
12
  from typing import Any, TypedDict
@@ -17,16 +18,15 @@ import pandas as pd
17
  import pypdf
18
  import requests
19
  from ddgs import DDGS
20
-
21
  from groq import Groq
22
  from langchain_core.messages import HumanMessage, SystemMessage
23
  from langchain_core.tools import tool
24
  from langchain_groq import ChatGroq
25
- from langchain_community.tools import DuckDuckGoSearchRun
26
  from langgraph.graph import END, StateGraph
27
 
28
 
29
  DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
 
30
 
31
  GROQ_TEXT_MODEL = os.getenv("GROQ_TEXT_MODEL", "llama-3.1-8b-instant")
32
  GROQ_FINAL_MODEL = os.getenv("GROQ_FINAL_MODEL", "llama-3.1-8b-instant")
@@ -36,96 +36,90 @@ GROQ_VISION_MODEL = os.getenv("GROQ_VISION_MODEL", "meta-llama/llama-4-scout-17b
36
  GROQ_AUDIO_MODEL = os.getenv("GROQ_AUDIO_MODEL", "whisper-large-v3-turbo")
37
 
38
  GAIA_DIR = os.getenv("GAIA_DIR", "./data/gaia")
39
- ALLOW_CODE_EXECUTION = 1
40
 
41
  MAX_CONTEXT_CHARS = 24_000
42
  MAX_SEARCH_CONTEXT_CHARS = 20_000
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
  def get_groq_client() -> Groq:
46
  key = os.getenv("GROQ_API_KEY")
47
  if not key:
48
- raise ValueError("GROQ_API_KEY secret not set!")
49
  return Groq(api_key=key)
50
 
51
 
52
  def make_chat_model(model: str, max_tokens: int) -> ChatGroq:
53
  key = os.getenv("GROQ_API_KEY")
54
  if not key:
55
- raise ValueError("GROQ_API_KEY secret not set!")
56
- return ChatGroq(
57
- model=model,
58
- api_key=key,
59
- temperature=0,
60
- max_tokens=max_tokens,
61
- )
62
 
63
 
64
  @lru_cache(maxsize=1)
65
- def _get_task_file_map() -> dict[str, str]:
66
  result: dict[str, str] = {}
67
  validation_dir = Path(GAIA_DIR) / "2023" / "validation"
68
 
69
  if not validation_dir.exists():
70
- print(f"[warn] GAIA validation dir not found: {validation_dir}")
71
  return result
72
 
73
- for p in validation_dir.rglob("*"):
74
- if not p.is_file():
75
- continue
76
- if p.suffix.lower() == ".parquet":
77
- continue
78
- result[p.stem] = str(p)
79
 
80
- print(f"[file-map] mapped {len(result)} local GAIA files from {validation_dir}")
81
  return result
82
 
83
 
84
  def get_task_file(task_id: str) -> str | None:
85
  if not task_id:
86
  return None
87
- return _get_task_file_map().get(task_id)
88
 
89
 
90
- def _fetch_task_bytes(task_id: str) -> tuple[bytes, str]:
91
  local_path = get_task_file(task_id)
92
  if not local_path:
93
- raise FileNotFoundError(f"No local file mapped for task_id: {task_id}")
94
 
95
  path = Path(local_path)
96
  if not path.exists():
97
- raise FileNotFoundError(f"File not found at path: {local_path}")
98
 
99
- data = path.read_bytes()
100
  content_type, _ = mimetypes.guess_type(str(path))
101
- if not content_type:
102
- content_type = "application/octet-stream"
103
- return data, content_type
104
-
105
-
106
- IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
107
- AUDIO_VIDEO_EXTS = {".mp3", ".wav", ".m4a", ".flac", ".ogg", ".webm", ".mp4", ".mov", ".mkv"}
108
- SPREADSHEET_EXTS = {".xlsx", ".xls"}
109
- PDF_EXTS = {".pdf"}
110
- CODE_EXTS = {".py", ".js", ".ts", ".java", ".cpp", ".c", ".rb", ".go", ".rs"}
111
- TEXT_EXTS = {".txt", ".md", ".csv", ".json", ".xml", ".html", ".htm", ".yaml", ".yml"} | CODE_EXTS
112
 
113
 
114
- def _is_image(ct: str, data: bytes) -> bool:
115
- if ct.startswith("image/"):
116
- return True
117
- if data.startswith(b"\x89PNG"):
118
- return True
119
- if data.startswith(b"\xff\xd8\xff"):
120
- return True
121
- if data.startswith(b"GIF87a") or data.startswith(b"GIF89a"):
122
- return True
123
- if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
124
- return True
125
- return False
126
 
127
 
128
- def _image_mime(data: bytes, ct: str) -> str:
129
  if data.startswith(b"\x89PNG"):
130
  return "image/png"
131
  if data.startswith(b"\xff\xd8\xff"):
@@ -134,19 +128,12 @@ def _image_mime(data: bytes, ct: str) -> str:
134
  return "image/webp"
135
  if data.startswith(b"GIF87a") or data.startswith(b"GIF89a"):
136
  return "image/gif"
137
- if ct.startswith("image/"):
138
- return ct
139
- return "image/png"
140
 
141
 
142
- def _is_audio_or_video(ct: str, path: str) -> bool:
143
- ext = Path(path).suffix.lower()
144
- return ct.startswith("audio/") or ct.startswith("video/") or ext in AUDIO_VIDEO_EXTS
145
-
146
-
147
- def is_youtube_question(question: str) -> bool:
148
- q = question.lower()
149
- return "youtube.com/watch" in q or "youtu.be/" in q
150
 
151
 
152
  def detect_local_file_kind(task_id: str) -> tuple[str, str | None]:
@@ -155,15 +142,14 @@ def detect_local_file_kind(task_id: str) -> tuple[str, str | None]:
155
  return "none", None
156
 
157
  suffix = Path(local_path).suffix.lower()
158
-
159
  try:
160
- data, ct = _fetch_task_bytes(task_id)
161
  except Exception:
162
  return "binary", local_path
163
 
164
- if suffix in IMAGE_EXTS or _is_image(ct, data):
165
  return "image", local_path
166
- if suffix in AUDIO_VIDEO_EXTS or _is_audio_or_video(ct, local_path):
167
  return "audio", local_path
168
  if suffix in SPREADSHEET_EXTS:
169
  return "spreadsheet", local_path
@@ -176,42 +162,46 @@ def detect_local_file_kind(task_id: str) -> tuple[str, str | None]:
176
  return "binary", local_path
177
 
178
 
179
- def truncate_text(text: str, limit: int = MAX_CONTEXT_CHARS) -> str:
180
- text = str(text)
181
- if len(text) <= limit:
182
- return text
183
- return text[:limit] + f"\n\n[TRUNCATED to {limit} characters]"
 
184
 
185
  @tool
186
  def analyze_image(task_id: str, question: str = "") -> str:
187
- """Analyze a local GAIA imae using the Groq vision model."""
188
  try:
189
- data, ct = _fetch_task_bytes(task_id)
190
- except Exception as e:
191
- return f"ERROR: Could not fetch image for task {task_id}: {type(e).__name__}: {e}"
192
 
193
- if not _is_image(ct, data):
194
- return f"ERROR: File for task {task_id} does not appear to be an image. content_type={ct}"
195
 
196
- b64 = base64.standard_b64encode(data).decode("utf-8")
197
- mime = _image_mime(data, ct)
198
- prompt = question or "Describe this image. Extract all visible text, numbers, symbols, and key details."
199
  if "chess" in prompt.lower():
200
- prompt = (
201
- f"{prompt}\n\n"
202
- "This is a chess task. Identify the board coordinates, side to move, relevant pieces, checks, "
203
- "mate threats, and the best move. Return the move in standard chess notation if possible."
204
  )
205
 
206
  try:
207
  client = get_groq_client()
208
- resp = client.chat.completions.create(
209
  model=GROQ_VISION_MODEL,
210
  messages=[
211
  {
212
  "role": "user",
213
  "content": [
214
- {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}},
 
 
 
 
 
 
215
  {"type": "text", "text": prompt},
216
  ],
217
  }
@@ -219,22 +209,22 @@ def analyze_image(task_id: str, question: str = "") -> str:
219
  temperature=0,
220
  max_tokens=768,
221
  )
222
- return resp.choices[0].message.content.strip()
223
- except Exception as e:
224
- return f"ERROR: Vision model error: {type(e).__name__}: {e}"
225
 
226
 
227
  @tool
228
  def transcribe_audio(task_id: str) -> str:
229
- """Transcribe a local GAIA audio/video file using Groq Whisper."""
230
  try:
231
- data, ct = _fetch_task_bytes(task_id)
232
  local_path = get_task_file(task_id) or ""
233
- except Exception as e:
234
- return f"ERROR: Could not fetch audio for task {task_id}: {type(e).__name__}: {e}"
235
 
236
- if not _is_audio_or_video(ct, local_path):
237
- return f"ERROR: File for task {task_id} does not appear to be audio/video. content_type={ct}"
238
 
239
  suffix = Path(local_path).suffix.lower().lstrip(".") or "mp3"
240
  if suffix == "mpeg":
@@ -242,64 +232,81 @@ def transcribe_audio(task_id: str) -> str:
242
 
243
  try:
244
  client = get_groq_client()
245
- audio_file = (f"audio.{suffix}", io.BytesIO(data), ct or f"audio/{suffix}")
246
  transcription = client.audio.transcriptions.create(
247
  file=audio_file,
248
  model=GROQ_AUDIO_MODEL,
249
  response_format="text",
250
  )
251
  return str(transcription).strip()
252
- except Exception as e:
253
- return f"ERROR: Audio transcription error: {type(e).__name__}: {e}"
254
 
255
 
256
  @tool
257
  def read_text_file(task_id: str) -> str:
258
- """Read a local GAIA text/PDF/spreadsheet/code file and return compact text context."""
259
  try:
260
  local_path = get_task_file(task_id)
261
  if not local_path:
262
- return f"ERROR: No local file attached for task {task_id}."
263
 
264
  path = Path(local_path)
265
  suffix = path.suffix.lower()
266
 
267
  if suffix in SPREADSHEET_EXTS:
268
  return read_spreadsheet_context(path)
269
- if suffix == ".pdf":
270
  return read_pdf_context(path)
271
  if suffix in CODE_EXTS:
272
  return read_code_context(path)
273
 
274
- data, ct = _fetch_task_bytes(task_id)
275
- if _is_image(ct, data) or _is_audio_or_video(ct, local_path):
276
- return f"ERROR: File is binary image/audio/video; use analyze_image or transcribe_audio instead. content_type={ct}"
 
 
 
277
 
278
- return truncate_text(data.decode("utf-8", errors="replace"), MAX_CONTEXT_CHARS)
279
- except Exception as e:
280
- return f"ERROR: Error reading file for task {task_id}: {type(e).__name__}: {e}"
281
 
282
 
283
  def read_pdf_context(path: Path) -> str:
284
- parts: list[str] = [f"PDF file: {path.name}"]
285
- reader = pypdf.PdfReader(str(path))
286
- for i, page in enumerate(reader.pages):
 
 
 
 
287
  try:
288
  text = page.extract_text() or ""
289
- except Exception as e:
290
- text = f"[page extraction error: {type(e).__name__}: {e}]"
291
- parts.append(f"\n--- Page {i + 1} ---\n{text}")
292
  if len("\n".join(parts)) > MAX_CONTEXT_CHARS:
293
  break
294
- return truncate_text("\n".join(parts), MAX_CONTEXT_CHARS)
 
295
 
296
 
297
  def read_spreadsheet_context(path: Path) -> str:
298
- parts: list[str] = [f"Spreadsheet file: {path.name}"]
299
- xls = pd.ExcelFile(path)
 
 
 
 
 
 
 
 
 
 
 
300
 
301
- for sheet_name in xls.sheet_names:
302
- df = pd.read_excel(path, sheet_name=sheet_name)
303
  parts.append(f"\n--- Sheet: {sheet_name} ---")
304
  parts.append(f"Shape: {df.shape}")
305
  parts.append(f"Columns: {list(df.columns)}")
@@ -312,38 +319,41 @@ def read_spreadsheet_context(path: Path) -> str:
312
  else:
313
  parts.append("Head 40 rows:")
314
  parts.append(df.head(40).to_csv(index=False))
315
- parts.append("Numeric summary:")
316
  try:
 
317
  parts.append(str(df.describe(include="all")))
318
- except Exception:
319
- pass
320
 
321
  if len("\n".join(parts)) > MAX_CONTEXT_CHARS:
322
  break
323
 
324
- return truncate_text("\n".join(parts), MAX_CONTEXT_CHARS)
325
 
326
 
327
  def build_spreadsheet_summary(path: Path) -> str:
328
  parts: list[str] = []
329
- xls = pd.ExcelFile(path)
 
 
 
330
 
331
- for sheet_name in xls.sheet_names:
332
- df = pd.read_excel(path, sheet_name=sheet_name)
 
 
 
 
333
  if df.empty:
334
  continue
335
 
336
  work = df.copy()
337
- work.columns = [str(col).strip() for col in work.columns]
338
- numeric_cols = [
339
- col
340
- for col in work.columns
341
- if pd.api.types.is_numeric_dtype(work[col])
342
- ]
343
  categorical_cols = [
344
- col
345
- for col in work.columns
346
- if col not in numeric_cols and work[col].nunique(dropna=True) <= 40
347
  ]
348
 
349
  parts.append(f"Sheet: {sheet_name}")
@@ -356,31 +366,32 @@ def build_spreadsheet_summary(path: Path) -> str:
356
  if not numeric_cols:
357
  break
358
  grouped = work.groupby(category_col, dropna=False)[numeric_cols].sum(numeric_only=True)
359
- if grouped.empty:
360
- continue
361
- parts.append(f"Totals grouped by {category_col}:")
362
- parts.append(grouped.head(40).to_csv())
363
 
364
  if len("\n".join(parts)) > MAX_CONTEXT_CHARS:
365
  break
366
 
367
- return truncate_text("\n".join(parts), MAX_CONTEXT_CHARS)
368
 
369
 
370
  def read_code_context(path: Path) -> str:
371
- source = path.read_text(encoding="utf-8", errors="replace")
372
- parts = [f"Code file: {path.name}", "--- Source code ---", source]
373
- return truncate_text("\n".join(parts), MAX_CONTEXT_CHARS)
 
 
374
 
375
 
376
  def run_python_file(path: Path, timeout_seconds: int = 45) -> str:
377
  if not ALLOW_CODE_EXECUTION:
378
- return "execution skipped"
379
  if path.suffix.lower() != ".py":
380
- return "not a Python file"
381
 
382
  try:
383
- proc = subprocess.run(
384
  [sys.executable, str(path)],
385
  cwd=str(path.parent),
386
  capture_output=True,
@@ -388,29 +399,17 @@ def run_python_file(path: Path, timeout_seconds: int = 45) -> str:
388
  timeout=timeout_seconds,
389
  env={**os.environ, "PYTHONIOENCODING": "utf-8"},
390
  )
391
- stdout = proc.stdout.strip()
392
- stderr = proc.stderr.strip()
393
  return (
394
- f"Return code: {proc.returncode}\n"
395
  f"STDOUT:\n{stdout[-6000:]}\n\n"
396
  f"STDERR:\n{stderr[-3000:]}"
397
  )
398
  except subprocess.TimeoutExpired:
399
- return f"ERROR: Code execution timed out after {timeout_seconds} seconds."
400
- except Exception as e:
401
- return f"ERROR: Code execution failed: {type(e).__name__}: {e}"
402
-
403
- web_search_tool = DuckDuckGoSearchRun(name="web_search")
404
-
405
- def safe_tool_run(tool_obj: Any, query: str, limit: int = 6000) -> str:
406
- try:
407
- if hasattr(tool_obj, "run"):
408
- out = tool_obj.run(query)
409
- else:
410
- out = tool_obj.invoke(query)
411
- return truncate_text(str(out), limit)
412
- except Exception as e:
413
- return f"[tool error: {type(e).__name__}: {e}]"
414
 
415
 
416
  def html_to_text(markup: str, limit: int = 8000) -> str:
@@ -427,68 +426,58 @@ def html_to_text(markup: str, limit: int = 8000) -> str:
427
 
428
  def fetch_url_text(url: str, limit: int = 8000) -> str:
429
  try:
430
- resp = requests.get(
431
  url,
432
  timeout=12,
433
- headers={
434
- "User-Agent": (
435
- "Mozilla/5.0 (compatible; GAIA-course-agent/1.0; "
436
- "+https://huggingface.co/spaces)"
437
- )
438
- },
439
  )
440
- resp.raise_for_status()
441
- content_type = resp.headers.get("content-type", "")
442
  if "pdf" in content_type or url.lower().endswith(".pdf"):
443
  return f"[PDF source: {url}]"
444
- return html_to_text(resp.text, limit=limit)
445
- except Exception as e:
446
- return f"[fetch error: {type(e).__name__}: {e}]"
447
 
448
 
449
  def likely_relevant_url(url: str) -> bool:
450
  parsed = urlparse(url)
451
  if parsed.scheme not in {"http", "https"}:
452
  return False
453
- if any(skip in parsed.netloc for skip in ["youtube.com", "youtu.be", "facebook.com", "x.com"]):
454
- return False
455
- return True
456
 
457
 
458
  def ddg_search(query: str, max_results: int = 5) -> list[dict[str, str]]:
459
  try:
460
  results = DDGS().text(query, max_results=max_results)
461
- except Exception as e:
462
- print(f"[ddgs warning] {type(e).__name__}: {e}")
463
  return []
464
 
465
  normalized: list[dict[str, str]] = []
466
  for item in results or []:
467
- href = str(item.get("href") or item.get("url") or "").strip()
468
  title = str(item.get("title") or "").strip()
469
  body = str(item.get("body") or item.get("snippet") or "").strip()
470
- if not href and not body:
471
- continue
472
- normalized.append({"title": title, "url": href, "body": body})
473
  return normalized
474
 
475
 
476
  def build_research_queries(question: str, base_query: str) -> list[str]:
477
  queries: list[str] = []
 
478
  quoted_phrases = re.findall(r'["“]([^"”]{3,120})["”]', question)
479
  if quoted_phrases:
480
  queries.append(" ".join(f'"{phrase}"' for phrase in quoted_phrases[:4]))
481
 
482
- urls = re.findall(r"https?://[^\s)>\]]+", question)
483
- for url in urls[:3]:
484
  parsed = urlparse(url.rstrip(".,;"))
485
  if parsed.netloc:
486
  queries.append(f"site:{parsed.netloc} {base_query}")
487
 
488
- capitalized_terms = re.findall(
489
- r"\b[A-Z][\w.'-]*(?:\s+[A-Z][\w.'-]*){1,4}\b",
490
- question,
491
- )
492
  if capitalized_terms:
493
  queries.append(" ".join(f'"{term}"' for term in capitalized_terms[:4]))
494
 
@@ -499,13 +488,10 @@ def build_research_queries(question: str, base_query: str) -> list[str]:
499
  queries.append(f"{' '.join(capitalized_terms[:3])} {year_text}")
500
  queries.append(f"{base_query} {year_text}")
501
 
502
- q_lower = question.lower()
503
- if "wikipedia" in q_lower and capitalized_terms:
504
- queries.append(f"site:en.wikipedia.org {' '.join(capitalized_terms[:4])}")
505
- if "wikipedia" in q_lower:
506
  queries.append(f"site:en.wikipedia.org {base_query}")
507
 
508
- queries += [base_query, question]
509
 
510
  deduped: list[str] = []
511
  for query in queries:
@@ -515,24 +501,26 @@ def build_research_queries(question: str, base_query: str) -> list[str]:
515
  return deduped[:5]
516
 
517
 
518
- def build_additional_research_queries(question: str, previous_queries: list[str]) -> list[str]:
519
  messages = [
520
- SystemMessage(content=(
521
- "Create 3 concise web search queries for answering the task. "
522
- "Prefer exact entity names, dates, source names, and required answer type. "
523
- "Return one query per line, no numbering."
524
- )),
 
 
525
  HumanMessage(content=question),
526
  ]
 
527
  try:
528
- llm = make_chat_model(GROQ_FINAL_MODEL, max_tokens=160)
529
  raw = llm.invoke(messages).content
530
- except Exception as e:
531
- print(f"[query expansion warning] {type(e).__name__}: {e}")
532
  return []
533
 
 
534
  queries: list[str] = []
535
- previous = {q.lower() for q in previous_queries}
536
  for line in raw.splitlines():
537
  query = clean_answer(re.sub(r"^\s*[-*\d.)]+\s*", "", line))
538
  query = re.sub(r"\s+", " ", query).strip()
@@ -541,151 +529,141 @@ def build_additional_research_queries(question: str, previous_queries: list[str]
541
  return queries[:3]
542
 
543
 
544
- def fetch_youtube_timedtext(video_id: str) -> str:
545
- urls = [
546
- f"https://video.google.com/timedtext?lang=en&v={video_id}",
547
- f"https://www.youtube.com/api/timedtext?lang=en&v={video_id}",
548
- ]
549
- for url in urls:
550
- try:
551
- resp = requests.get(url, timeout=12, headers={"User-Agent": "GAIA-course-agent/1.0"})
552
- resp.raise_for_status()
553
- if not resp.text.strip():
554
- continue
555
- chunks = re.findall(r"<text[^>]*>(.*?)</text>", resp.text, flags=re.S)
556
- if chunks:
557
- text = " ".join(html.unescape(re.sub(r"<[^>]+>", " ", chunk)) for chunk in chunks)
558
- text = re.sub(r"\s+", " ", text).strip()
559
- if text:
560
- return text
561
- except Exception as e:
562
- print(f"[youtube timedtext warning] {type(e).__name__}: {e}")
563
- return ""
564
-
565
-
566
- def build_youtube_context(question: str, video_id: str | None) -> str:
567
- queries: list[str] = []
568
- quoted_phrases = re.findall(r'["“]([^"”]{3,120})["”]', question)
569
- if video_id:
570
- queries += [
571
- f'"{video_id}" transcript',
572
- f'"{video_id}" subtitles',
573
- f'"{video_id}"',
574
- ]
575
- for phrase in quoted_phrases[:3]:
576
- queries.append(f'"{video_id}" "{phrase}"')
577
- if quoted_phrases:
578
- queries.append(" ".join(f'"{phrase}"' for phrase in quoted_phrases[:3]))
579
-
580
- queries.append(question)
581
-
582
- parts = [f"Question: {question}", f"YouTube video id: {video_id or 'unknown'}"]
583
- if video_id:
584
- transcript = fetch_youtube_timedtext(video_id)
585
- if transcript:
586
- parts.append(f"\n=== YouTube timedtext transcript ===\n{truncate_text(transcript, 8000)}")
587
  seen_urls: set[str] = set()
588
 
589
- for query in queries[:8]:
590
  parts.append(f"\n=== Search query: {query} ===")
591
  results = ddg_search(query, max_results=6)
592
- if not results:
593
- parts.append(safe_tool_run(web_search_tool, query, limit=2000))
594
- continue
595
 
596
- for i, result in enumerate(results, 1):
597
  url = result["url"]
598
- parts.append(f"[{i}] {result['title']}\nURL: {url}\nSnippet: {result['body']}")
599
- if not url or url in seen_urls:
600
- continue
601
- parsed = urlparse(url)
602
- if parsed.scheme not in {"http", "https"}:
603
- continue
604
- if "youtube.com" in parsed.netloc or "youtu.be" in parsed.netloc:
605
  continue
 
606
  seen_urls.add(url)
607
- fetched = fetch_url_text(url, limit=4000)
608
- if fetched and not fetched.startswith("[fetch error"):
 
609
  parts.append(f"Fetched text from {url}:\n{fetched}")
 
610
  if len("\n".join(parts)) > MAX_SEARCH_CONTEXT_CHARS:
611
  return truncate_text("\n".join(parts), MAX_SEARCH_CONTEXT_CHARS)
612
 
613
  return truncate_text("\n".join(parts), MAX_SEARCH_CONTEXT_CHARS)
614
 
615
 
616
- def build_research_context(question: str, base_query: str) -> str:
617
- parts = [f"Question: {question}", f"Primary query: {base_query}"]
618
- seen_urls: set[str] = set()
619
- queries = build_research_queries(question, base_query)
620
 
621
- for query in queries:
 
 
 
622
  parts.append(f"\n=== Search query: {query} ===")
623
  results = ddg_search(query, max_results=6)
624
- if not results:
625
- parts.append(safe_tool_run(web_search_tool, query, limit=2000))
626
- continue
627
-
628
  fetched_count = 0
629
- for i, result in enumerate(results, 1):
630
- url = result["url"]
631
- title = result["title"]
632
- body = result["body"]
633
- parts.append(f"[{i}] {title}\nURL: {url}\nSnippet: {body}")
634
 
635
- if not url or url in seen_urls:
636
- continue
637
- if not likely_relevant_url(url):
638
- continue
639
- if fetched_count >= 2:
640
  continue
641
 
642
  seen_urls.add(url)
643
  fetched = fetch_url_text(url, limit=5000)
644
- if fetched and not fetched.startswith("[fetch error"):
645
  fetched_count += 1
646
  parts.append(f"Fetched text from {url}:\n{fetched}")
 
647
  if len("\n".join(parts)) > MAX_SEARCH_CONTEXT_CHARS:
648
  return truncate_text("\n".join(parts), MAX_SEARCH_CONTEXT_CHARS)
649
 
650
  return truncate_text("\n".join(parts), MAX_SEARCH_CONTEXT_CHARS)
651
 
652
 
653
- def extend_research_context(question: str, context: str, used_query: str) -> str:
654
- extra_queries = build_additional_research_queries(question, [used_query])
655
- if not extra_queries:
656
- return context
657
 
658
- parts = [context, "\n=== Additional focused searches ==="]
659
- seen_urls = set(re.findall(r"URL: (https?://\S+)", context))
660
- for query in extra_queries:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
661
  parts.append(f"\n=== Search query: {query} ===")
662
  results = ddg_search(query, max_results=6)
663
- if not results:
664
- parts.append(safe_tool_run(web_search_tool, query, limit=2000))
665
- continue
666
 
667
- fetched_count = 0
668
- for i, result in enumerate(results, 1):
669
  url = result["url"]
670
- parts.append(f"[{i}] {result['title']}\nURL: {url}\nSnippet: {result['body']}")
671
- if not url or url in seen_urls or not likely_relevant_url(url):
 
672
  continue
673
- if fetched_count >= 2:
674
  continue
 
675
  seen_urls.add(url)
676
- fetched = fetch_url_text(url, limit=5000)
677
- if fetched and not fetched.startswith("[fetch error"):
678
- fetched_count += 1
679
  parts.append(f"Fetched text from {url}:\n{fetched}")
 
680
  if len("\n".join(parts)) > MAX_SEARCH_CONTEXT_CHARS:
681
  return truncate_text("\n".join(parts), MAX_SEARCH_CONTEXT_CHARS)
682
 
683
  return truncate_text("\n".join(parts), MAX_SEARCH_CONTEXT_CHARS)
684
 
685
 
686
- def clean_answer(answer: str) -> str:
687
- answer = str(answer or "").strip()
688
-
689
  prefixes = [
690
  "FINAL ANSWER:",
691
  "Final Answer:",
@@ -694,22 +672,30 @@ def clean_answer(answer: str) -> str:
694
  "the answer is:",
695
  "Answer:",
696
  "answer:",
 
 
 
 
697
  ]
698
- for p in prefixes:
699
- if answer.lower().startswith(p.lower()):
700
- answer = answer[len(p):].strip()
701
 
702
- answer = answer.strip().strip("`*").strip()
703
- answer = answer.strip('"').strip("'").strip()
 
704
 
705
- return answer
706
 
707
 
708
- def is_bad_answer(answer: str) -> bool:
709
- a = clean_answer(answer).lower()
710
- if not a:
711
  return True
 
712
  bad_markers = [
 
 
 
 
 
713
  "error:",
714
  "i don't know",
715
  "i do not know",
@@ -720,121 +706,14 @@ def is_bad_answer(answer: str) -> bool:
720
  "no answer",
721
  "no answer found",
722
  "no information found",
723
- "i could not find",
724
- "i couldn't find",
725
  "could not find",
726
  "couldn't find",
727
  "not found",
728
- "not in the search results",
729
- "not in the provided",
730
- "this answer is not",
731
- "unknown",
732
  "insufficient information",
733
- "cannot determine",
734
- ]
735
- return any(m in a for m in bad_markers)
736
-
737
-
738
- def reversed_english_question(question: str) -> bool:
739
- rev = question[::-1].lower()
740
- markers = ["if you understand", "the answer", "opposite", "write", "word"]
741
- return sum(1 for m in markers if m in rev) >= 2
742
-
743
-
744
- def solve_directly_with_python(question: str) -> str | None:
745
- q = question.strip()
746
-
747
- if reversed_english_question(q):
748
- rev = q[::-1]
749
- m = re.search(r'opposite of the word ["“”\']?([A-Za-z]+)["“”\']?', rev, flags=re.I)
750
- if m:
751
- word = m.group(1).lower()
752
- opposites = {
753
- "left": "right",
754
- "right": "left",
755
- "up": "down",
756
- "down": "up",
757
- "yes": "no",
758
- "no": "yes",
759
- "true": "false",
760
- "false": "true",
761
- "hot": "cold",
762
- "cold": "hot",
763
- }
764
- if word in opposites:
765
- return opposites[word]
766
-
767
- return None
768
-
769
-
770
- def solve_commutativity_table(question: str) -> str | None:
771
- q = question.lower()
772
-
773
- if "|---" not in question:
774
- return None
775
-
776
- if "commutative" not in q and "commutativity" not in q:
777
- return None
778
-
779
- lines = [
780
- line.strip()
781
- for line in question.splitlines()
782
- if line.strip().startswith("|")
783
  ]
784
-
785
- if len(lines) < 3:
786
- return None
787
-
788
- header = [x.strip() for x in lines[0].strip("|").split("|")]
789
- cols = header[1:]
790
-
791
- table = {}
792
-
793
- for line in lines[2:]:
794
- cells = [x.strip() for x in line.strip("|").split("|")]
795
- if len(cells) != len(cols) + 1:
796
- continue
797
-
798
- row = cells[0]
799
- values = cells[1:]
800
- table[row] = dict(zip(cols, values))
801
-
802
- for a in cols:
803
- for b in cols:
804
- if a == b:
805
- continue
806
-
807
- ab = table.get(a, {}).get(b)
808
- ba = table.get(b, {}).get(a)
809
-
810
- if ab is not None and ba is not None and ab != ba:
811
- return ", ".join(sorted([a, b]))
812
-
813
- return "commutative"
814
-
815
-
816
- def direct_question(question: str) -> bool:
817
- q = question.lower()
818
- rev = q[::-1]
819
-
820
- if reversed_english_question(question):
821
- return True
822
- if "|---" in question or question.count("|") >= 8:
823
- return True
824
- if any(marker in q for marker in [
825
- "grocery list",
826
- "shopping list",
827
- "given this table",
828
- "opposite of",
829
- "reverse",
830
- "what is the final numeric output",
831
- ]):
832
- return True
833
- if any(marker in rev for marker in ["opposite", "the answer", "write"]):
834
- return True
835
- if "http://" in q or "https://" in q or "youtube.com" in q or "youtu.be" in q:
836
- return False
837
- return False
838
 
839
 
840
  def last_nonempty_line(text: str) -> str:
@@ -842,12 +721,7 @@ def last_nonempty_line(text: str) -> str:
842
  return lines[-1] if lines else ""
843
 
844
 
845
- def extract_youtube_id(question: str) -> str | None:
846
- m = re.search(r"(?:v=|youtu\.be/)([A-Za-z0-9_-]{11})", question)
847
- return m.group(1) if m else None
848
-
849
-
850
- class AgentState(TypedDict):
851
  question: str
852
  task_id: str
853
  route: str
@@ -861,44 +735,39 @@ class AgentState(TypedDict):
861
 
862
 
863
  class BasicAgent:
864
- def __init__(self):
865
  self.answer_llm = make_chat_model(GROQ_TEXT_MODEL, max_tokens=256)
866
- self.final_llm = make_chat_model(GROQ_FINAL_MODEL, max_tokens=48)
867
  self.strong_llm = make_chat_model(GROQ_STRONG_MODEL, max_tokens=512)
868
  self.research_llm = make_chat_model(GROQ_RESEARCH_MODEL, max_tokens=512)
869
-
870
  self.graph = self.build_graph()
871
 
872
- print(f" Text model : {GROQ_TEXT_MODEL}")
873
- print(f" Final model : {GROQ_FINAL_MODEL}")
874
- print(f" Research model: {GROQ_RESEARCH_MODEL}")
875
- print(f" Vision model : {GROQ_VISION_MODEL}")
876
- print(f" Audio model : {GROQ_AUDIO_MODEL}")
877
- print(f" Code execution: {ALLOW_CODE_EXECUTION}")
 
878
 
879
  def build_graph(self):
880
- g = StateGraph(AgentState)
881
-
882
- g.add_node("classify_task", self.classify_task)
883
- g.add_node("route_by_type", self.route_by_type_node)
884
-
885
- g.add_node("solve_image", self.solve_image)
886
- g.add_node("solve_audio", self.solve_audio)
887
- g.add_node("solve_spreadsheet", self.solve_spreadsheet)
888
- g.add_node("solve_code", self.solve_code)
889
- g.add_node("solve_direct", self.solve_direct)
890
- g.add_node("solve_research", self.solve_research)
891
- g.add_node("solve_youtube", self.solve_youtube)
892
-
893
- g.add_node("verify_answer", self.verify_answer)
894
- g.add_node("final_cleaner", self.final_cleaner)
895
-
896
- g.set_entry_point("classify_task")
897
- g.add_edge("classify_task", "route_by_type")
898
-
899
- g.add_conditional_edges(
900
- "route_by_type",
901
- self.route_by_type,
902
  {
903
  "solve_image": "solve_image",
904
  "solve_audio": "solve_audio",
@@ -910,7 +779,7 @@ class BasicAgent:
910
  },
911
  )
912
 
913
- for node in [
914
  "solve_image",
915
  "solve_audio",
916
  "solve_spreadsheet",
@@ -918,18 +787,16 @@ class BasicAgent:
918
  "solve_direct",
919
  "solve_research",
920
  "solve_youtube",
921
- ]:
922
- g.add_edge(node, "verify_answer")
923
 
924
- g.add_edge("verify_answer", "final_cleaner")
925
- g.add_edge("final_cleaner", END)
926
-
927
- return g.compile()
928
 
929
  def classify_task(self, state: AgentState) -> dict[str, Any]:
930
  question = state.get("question", "")
931
  task_id = state.get("task_id", "")
932
-
933
  file_kind, local_path = detect_local_file_kind(task_id)
934
 
935
  if file_kind == "image":
@@ -942,21 +809,15 @@ class BasicAgent:
942
  route = "solve_code"
943
  elif file_kind in {"pdf", "text", "binary"}:
944
  route = "solve_direct"
945
- elif direct_question(question):
946
- route = "solve_direct"
947
  elif is_youtube_question(question):
948
  route = "solve_youtube"
949
  else:
950
  route = "solve_research"
951
 
952
- print(f"[classify] file_kind={file_kind}, route={route}, path={local_path}")
953
  return {"file_kind": file_kind, "local_path": local_path, "route": route}
954
 
955
- def route_by_type_node(self, state: AgentState) -> dict[str, Any]:
956
- print(f"[route_by_type] {state.get('route')}")
957
- return {}
958
-
959
- def route_by_type(self, state: AgentState) -> str:
960
  route = state.get("route", "solve_research")
961
  allowed = {
962
  "solve_image",
@@ -972,58 +833,47 @@ class BasicAgent:
972
  def solve_image(self, state: AgentState) -> dict[str, Any]:
973
  question = state.get("question", "")
974
  task_id = state.get("task_id", "")
975
-
976
  context = analyze_image.invoke({"task_id": task_id, "question": question})
977
- raw_answer = self.answer_from_context(
978
- question=question,
979
- context=context,
980
- context_label="Image analysis",
981
- llm=self.strong_llm,
982
- )
983
  return {"context": context, "raw_answer": raw_answer}
984
 
985
  def solve_audio(self, state: AgentState) -> dict[str, Any]:
986
  question = state.get("question", "")
987
  task_id = state.get("task_id", "")
988
-
989
  transcript = transcribe_audio.invoke({"task_id": task_id})
990
  context = f"Audio/video transcript:\n{transcript}"
991
- raw_answer = self.answer_from_context(
992
- question=question,
993
- context=context,
994
- context_label="Audio transcript",
995
- llm=self.answer_llm,
996
- )
997
  return {"context": context, "raw_answer": raw_answer}
998
 
999
- def solve_spreadsheet(self, state: AgentState) -> dict:
1000
- path = state["local_path"]
1001
- question = state["question"]
 
 
1002
 
1003
- context = read_spreadsheet_context(Path(path))
1004
- summary = build_spreadsheet_summary(Path(path))
 
1005
  if summary:
1006
  context = f"{context}\n\n--- Computed spreadsheet summary ---\n{summary}"
1007
 
1008
  raw_answer = self.answer_from_context(
1009
- question=question,
1010
- context=context,
1011
- context_label="Spreadsheet data and computed summary",
1012
- llm=self.strong_llm,
1013
  )
1014
-
1015
  return {"context": context, "raw_answer": raw_answer}
1016
 
1017
  def solve_code(self, state: AgentState) -> dict[str, Any]:
1018
  question = state.get("question", "")
1019
  local_path = state.get("local_path")
1020
-
1021
  if not local_path:
1022
- return {"raw_answer": "ERROR: code route selected but no local file path found"}
1023
 
1024
  path = Path(local_path)
1025
  code_context = read_code_context(path)
1026
- execution_context = run_python_file(path) if path.suffix.lower() == ".py" else "[execution skipped: not Python]"
1027
  context = f"{code_context}\n\n--- Execution result ---\n{execution_context}"
1028
 
1029
  if "final numeric output" in question.lower() and "STDOUT:" in execution_context:
@@ -1032,12 +882,7 @@ class BasicAgent:
1032
  if candidate and re.search(r"[-+]?\d", candidate):
1033
  return {"context": context, "raw_answer": candidate}
1034
 
1035
- raw_answer = self.answer_from_context(
1036
- question=question,
1037
- context=context,
1038
- context_label="Code and execution result",
1039
- llm=self.strong_llm,
1040
- )
1041
  return {"context": context, "raw_answer": raw_answer}
1042
 
1043
  def solve_direct(self, state: AgentState) -> dict[str, Any]:
@@ -1046,83 +891,45 @@ class BasicAgent:
1046
  file_kind = state.get("file_kind", "none")
1047
  local_path = state.get("local_path")
1048
 
1049
- shortcut = solve_directly_with_python(question)
1050
- if shortcut is not None:
1051
- return {"context": "Solved by deterministic Python shortcut.", "raw_answer": shortcut}
1052
-
1053
- direct = solve_commutativity_table(question)
1054
- if direct is not None:
1055
- return {"raw_answer": direct}
1056
-
1057
  context = ""
1058
  if local_path and file_kind in {"pdf", "text", "binary"}:
1059
  context = read_text_file.invoke({"task_id": task_id})
1060
 
1061
- raw_answer = self.answer_from_context(
1062
- question=question,
1063
- context=context,
1064
- context_label=f"Direct context; file_kind={file_kind}",
1065
- llm=self.answer_llm,
1066
- )
1067
  return {"context": context, "raw_answer": raw_answer}
1068
 
1069
  def solve_research(self, state: AgentState) -> dict[str, Any]:
1070
  question = state.get("question", "")
1071
-
1072
  query = self.make_search_query(question)
1073
- print(f"[research query] {query}")
1074
 
1075
  context = build_research_context(question, query)
 
1076
 
1077
- print(f"[research context len] {len(context)}")
1078
- print(f"[research context preview] {repr(context[:500])}")
1079
-
1080
- raw_answer = self.answer_from_context(
1081
- question=question,
1082
- context=context,
1083
- context_label="Web research results",
1084
- llm=self.research_llm,
1085
- )
1086
-
1087
  if is_bad_answer(raw_answer):
1088
- context = extend_research_context(question, context, query)
1089
- print(f"[research extended context len] {len(context)}")
1090
- raw_answer = self.answer_from_context(
1091
- question=question,
1092
- context=context,
1093
- context_label="Extended web research results",
1094
- llm=self.strong_llm,
1095
- )
1096
 
1097
- print(f"[research raw_answer] {repr(raw_answer[:500])}")
1098
  return {"context": context, "raw_answer": raw_answer}
1099
-
1100
- def solve_youtube(self, state: AgentState) -> dict:
1101
- question = state["question"]
1102
- video_id = extract_youtube_id(question)
1103
 
 
 
 
1104
  context = build_youtube_context(question, video_id)
1105
-
1106
- raw_answer = self.answer_from_context(
1107
- question=question,
1108
- context=context,
1109
- context_label="YouTube/web transcript search results",
1110
- llm=self.research_llm,
1111
- )
1112
 
1113
  if is_bad_answer(raw_answer):
1114
- context = extend_research_context(question, context, question)
1115
  raw_answer = self.answer_from_context(
1116
- question=question,
1117
- context=context,
1118
- context_label="Extended YouTube/web transcript search results",
1119
- llm=self.strong_llm,
1120
  )
1121
 
1122
- return {
1123
- "context": context,
1124
- "raw_answer": raw_answer,
1125
- }
1126
 
1127
  def verify_answer(self, state: AgentState) -> dict[str, Any]:
1128
  question = state.get("question", "")
@@ -1132,10 +939,7 @@ class BasicAgent:
1132
  file_kind = state.get("file_kind", "none")
1133
 
1134
  if is_bad_answer(raw_answer):
1135
- return {"verified_answer": "", "error": raw_answer or "empty answer"}
1136
-
1137
- if context.startswith("Solved by deterministic"):
1138
- return {"verified_answer": raw_answer}
1139
 
1140
  if route not in {"solve_research", "solve_youtube"} or file_kind in {"code", "spreadsheet", "audio"}:
1141
  return {"verified_answer": raw_answer}
@@ -1144,27 +948,31 @@ class BasicAgent:
1144
  return {"verified_answer": raw_answer}
1145
 
1146
  messages = [
1147
- SystemMessage(content=(
1148
- "You verify a draft answer for a GAIA benchmark task. "
1149
- "Use only the provided context. Return only the corrected final answer. "
1150
- "If the context does not support an answer, return ERROR: insufficient evidence."
1151
- )),
1152
- HumanMessage(content=(
1153
- f"Question:\n{question}\n\n"
1154
- f"Context, if any:\n{truncate_text(context, 5000)}\n\n"
1155
- f"Draft answer:\n{truncate_text(raw_answer, 3000)}\n\n"
1156
- "Correct final answer only:"
1157
- )),
 
 
 
 
1158
  ]
 
1159
  try:
1160
  verified = self.final_llm.invoke(messages).content.strip()
1161
- except Exception as e:
 
1162
  verified = raw_answer
1163
- print(f"[verify warning] {type(e).__name__}: {e}")
1164
 
1165
  if is_bad_answer(verified):
1166
  return {"verified_answer": "", "error": clean_answer(verified)}
1167
-
1168
  return {"verified_answer": clean_answer(verified)}
1169
 
1170
  def final_cleaner(self, state: AgentState) -> dict[str, Any]:
@@ -1172,17 +980,16 @@ class BasicAgent:
1172
  answer = clean_answer(state.get("verified_answer") or state.get("raw_answer") or "")
1173
 
1174
  if is_bad_answer(answer):
1175
- return {"final_answer": "", "error": state.get("error") or answer or "bad answer"}
1176
 
1177
  if "\n" in answer or len(answer.split()) > 12 or len(answer) > 120:
1178
  answer = self.extract_final_answer(question, answer)
1179
 
1180
  answer = clean_answer(answer)
1181
  if is_bad_answer(answer):
1182
- return {"final_answer": "", "error": state.get("error") or answer or "bad answer"}
1183
  return {"final_answer": answer}
1184
 
1185
-
1186
  def answer_from_context(self, question: str, context: str, context_label: str, llm: ChatGroq) -> str:
1187
  system = (
1188
  "You answer GAIA benchmark questions.\n"
@@ -1203,15 +1010,20 @@ class BasicAgent:
1203
 
1204
  try:
1205
  return llm.invoke([SystemMessage(content=system), HumanMessage(content=user)]).content.strip()
1206
- except Exception as e:
1207
- message = str(e)
1208
- if "tool_use_failed" in message or "Tool choice is none" in message or "model called a tool" in message:
 
 
 
 
 
1209
  try:
1210
- print(f"[llm fallback] {type(e).__name__}: retrying with {GROQ_TEXT_MODEL}")
1211
  return self.answer_llm.invoke([SystemMessage(content=system), HumanMessage(content=user)]).content.strip()
1212
- except Exception as fallback_error:
1213
- return f"ERROR: LLM answer error: {type(fallback_error).__name__}: {fallback_error}"
1214
- return f"ERROR: LLM answer error: {type(e).__name__}: {e}"
1215
 
1216
  def extract_final_answer(self, question: str, raw_answer: str) -> str:
1217
  raw_answer = clean_answer(raw_answer)
@@ -1219,42 +1031,47 @@ class BasicAgent:
1219
  return raw_answer
1220
 
1221
  messages = [
1222
- SystemMessage(content=(
1223
- "Extract the final answer from the draft. "
1224
- "Return ONLY the answer itself. No explanation. No prefix. No quotes."
1225
- )),
1226
- HumanMessage(content=(
1227
- f"Question:\n{question}\n\n"
1228
- f"Draft answer:\n{truncate_text(raw_answer, 3000)}\n\n"
1229
- "Final answer only:"
1230
- )),
 
 
 
 
1231
  ]
 
1232
  try:
1233
  return clean_answer(self.final_llm.invoke(messages).content.strip())
1234
- except Exception as e:
1235
- print(f"[final extractor warning] {type(e).__name__}: {e}")
1236
  return clean_answer(last_nonempty_line(raw_answer))
1237
 
1238
  def make_search_query(self, question: str) -> str:
1239
- q = re.sub(r"\s+", " ", question).strip()
1240
- if len(q) <= 220:
1241
- return q
1242
 
1243
  messages = [
1244
  SystemMessage(content="Rewrite the task as a concise web search query. Output only the query."),
1245
- HumanMessage(content=q[:1000]),
1246
  ]
1247
  try:
1248
- query = self.final_llm.invoke(messages).content.strip()
1249
- query = clean_answer(query)
1250
- return query[:220] if query else q[:220]
1251
- except Exception:
1252
- return q[:220]
1253
 
1254
  def __call__(self, question: str, task_id: str = "") -> str:
1255
- print(f"\n{'─' * 60}")
1256
- print(f"[task_id] {task_id}")
1257
- print(f"[question] {question[:160]}...")
1258
 
1259
  try:
1260
  result = self.graph.invoke(
@@ -1262,44 +1079,40 @@ class BasicAgent:
1262
  config={"recursion_limit": 12},
1263
  )
1264
  answer = clean_answer(result.get("final_answer", ""))
1265
-
1266
  if not answer:
1267
- error = result.get("error", "no final answer")
1268
- answer = f"ERROR: {error}"
1269
-
1270
- print(f"[final] {answer}")
1271
  return answer
1272
- except Exception as e:
1273
- print(f"[agent error] {type(e).__name__}: {e}")
1274
- return f"ERROR: {type(e).__name__}: {e}"
1275
 
1276
 
1277
  def run_and_submit_all(profile: gr.OAuthProfile | None):
1278
  space_id = os.getenv("SPACE_ID")
1279
 
1280
  if not profile:
1281
- return "Please log in to Hugging Face first.", None
1282
 
1283
  username = profile.username
1284
- print(f"Logged in: {username}")
1285
-
1286
- questions_url = f"{DEFAULT_API_URL}/questions"
1287
- submit_url = f"{DEFAULT_API_URL}/submit"
1288
 
1289
  try:
1290
  agent = BasicAgent()
1291
- except Exception as e:
1292
- return f"Agent init error: {type(e).__name__}: {e}", None
1293
 
 
 
1294
  agent_code = f"https://huggingface.co/spaces/{space_id}/tree/main" if space_id else ""
1295
 
1296
  try:
1297
- resp = requests.get(questions_url, timeout=20)
1298
- resp.raise_for_status()
1299
- questions_data = resp.json()
1300
- print(f"Fetched {len(questions_data)} questions.")
1301
- except Exception as e:
1302
- return f"Error fetching questions: {type(e).__name__}: {e}", None
1303
 
1304
  results_log: list[dict[str, str]] = []
1305
  answers_payload: list[dict[str, str]] = []
@@ -1312,38 +1125,41 @@ def run_and_submit_all(profile: gr.OAuthProfile | None):
1312
 
1313
  try:
1314
  answer = agent(question_text, task_id=task_id)
1315
- results_log.append({"Task ID": task_id, "Question": question_text[:120], "Answer": answer})
1316
 
1317
- if answer and not answer.startswith("ERROR:"):
1318
  answers_payload.append({"task_id": task_id, "submitted_answer": answer})
1319
  else:
1320
- print(f"[skip submit] {task_id}: {answer}")
1321
-
1322
- except Exception as e:
1323
- err = f"ERROR: {type(e).__name__}: {e}"
1324
- results_log.append({"Task ID": task_id, "Question": question_text[:120], "Answer": err})
1325
- print(f"[question error] {task_id}: {err}")
1326
  time.sleep(1)
1327
 
1328
  if not answers_payload:
1329
- return "Agent produced no submittable answers.", pd.DataFrame(results_log)
1330
 
1331
- payload = {"username": username.strip(), "agent_code": agent_code, "answers": answers_payload}
 
 
 
 
1332
 
1333
  try:
1334
- resp = requests.post(submit_url, json=payload, timeout=60)
1335
- resp.raise_for_status()
1336
- r = resp.json()
1337
  status = (
1338
- f" Submission successful!\n"
1339
- f"User : {r.get('username')}\n"
1340
- f"Score: {r.get('score', 'N/A')}% "
1341
- f"({r.get('correct_count', '?')}/{r.get('total_attempted', '?')} correct)\n"
1342
- f"Msg : {r.get('message', '')}\n"
1343
- f"Submitted answers: {len(answers_payload)}/{len(questions_data)}"
1344
  )
1345
- except Exception as e:
1346
- status = f"Submission error: {type(e).__name__}: {e}"
1347
 
1348
  return status, pd.DataFrame(results_log)
1349
 
@@ -1352,35 +1168,25 @@ space_host_startup = os.getenv("SPACE_HOST")
1352
  space_id_startup = os.getenv("SPACE_ID")
1353
  oauth_available = bool(space_host_startup or space_id_startup or os.getenv("HF_TOKEN"))
1354
 
1355
-
1356
- with gr.Blocks() as demo:
1357
- gr.Markdown("# Basic Agent Evaluation Runner — Routed LangGraph")
1358
- gr.Markdown(
1359
- """
1360
- **Architecture:** `classify_task → route_by_type → solve_* → verify_answer → final_cleaner`.
1361
-
1362
- Local files are routed deterministically by Python. Web are called only inside `solve_research`, without automatic LLM tool-calling.
1363
- """
1364
- )
1365
 
1366
  if oauth_available:
1367
  gr.LoginButton()
1368
  else:
1369
- gr.Markdown("Hugging Face OAuth is disabled locally. Run inside a Space or set `HF_TOKEN`.")
1370
- run_button = gr.Button("Run Evaluation & Submit All Answers")
1371
- status_output = gr.Textbox(label="Run Status / Submission Result", lines=6, interactive=False)
1372
- results_table = gr.DataFrame(label="Questions and Agent Answers", wrap=True)
1373
-
1374
- run_button.click(
1375
- fn=run_and_submit_all,
1376
- outputs=[status_output, results_table],
1377
- )
1378
 
1379
  if space_host_startup:
1380
- print(f"SPACE_HOST found: {space_host_startup}")
1381
  if space_id_startup:
1382
- print(f"SPACE_ID found: {space_id_startup}")
1383
 
1384
  if __name__ == "__main__":
1385
- print("Launching Gradio Interface for Routed LangGraph Agent Evaluation...")
1386
- demo.launch(debug=True, share=False)
 
 
 
 
 
 
1
  import base64
2
  import html
3
+ import io
4
+ import logging
5
  import mimetypes
6
+ import os
7
+ import re
8
  import subprocess
9
+ import sys
10
+ import time
11
  from functools import lru_cache
12
  from pathlib import Path
13
  from typing import Any, TypedDict
 
18
  import pypdf
19
  import requests
20
  from ddgs import DDGS
 
21
  from groq import Groq
22
  from langchain_core.messages import HumanMessage, SystemMessage
23
  from langchain_core.tools import tool
24
  from langchain_groq import ChatGroq
 
25
  from langgraph.graph import END, StateGraph
26
 
27
 
28
  DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
29
+ ERROR_PREFIX = "ОШИБКА:"
30
 
31
  GROQ_TEXT_MODEL = os.getenv("GROQ_TEXT_MODEL", "llama-3.1-8b-instant")
32
  GROQ_FINAL_MODEL = os.getenv("GROQ_FINAL_MODEL", "llama-3.1-8b-instant")
 
36
  GROQ_AUDIO_MODEL = os.getenv("GROQ_AUDIO_MODEL", "whisper-large-v3-turbo")
37
 
38
  GAIA_DIR = os.getenv("GAIA_DIR", "./data/gaia")
39
+ ALLOW_CODE_EXECUTION = os.getenv("ALLOW_CODE_EXECUTION", "1").lower() not in {"0", "false", "no"}
40
 
41
  MAX_CONTEXT_CHARS = 24_000
42
  MAX_SEARCH_CONTEXT_CHARS = 20_000
43
 
44
+ IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
45
+ AUDIO_VIDEO_EXTS = {".mp3", ".wav", ".m4a", ".flac", ".ogg", ".webm", ".mp4", ".mov", ".mkv"}
46
+ SPREADSHEET_EXTS = {".xlsx", ".xls"}
47
+ PDF_EXTS = {".pdf"}
48
+ CODE_EXTS = {".py", ".js", ".ts", ".java", ".cpp", ".c", ".rb", ".go", ".rs"}
49
+ TEXT_EXTS = {".txt", ".md", ".csv", ".json", ".xml", ".html", ".htm", ".yaml", ".yml"} | CODE_EXTS
50
+
51
+ logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper(), format="%(message)s")
52
+ logger = logging.getLogger("gaia-space-agent")
53
+
54
+
55
+ def error_text(message: str, exc: Exception | None = None) -> str:
56
+ if exc is None:
57
+ return f"{ERROR_PREFIX} {message}"
58
+ return f"{ERROR_PREFIX} {message}: {type(exc).__name__}: {exc}"
59
+
60
 
61
  def get_groq_client() -> Groq:
62
  key = os.getenv("GROQ_API_KEY")
63
  if not key:
64
+ raise ValueError("Не задан секрет GROQ_API_KEY.")
65
  return Groq(api_key=key)
66
 
67
 
68
  def make_chat_model(model: str, max_tokens: int) -> ChatGroq:
69
  key = os.getenv("GROQ_API_KEY")
70
  if not key:
71
+ raise ValueError("Не задан секрет GROQ_API_KEY.")
72
+ return ChatGroq(model=model, api_key=key, temperature=0, max_tokens=max_tokens)
 
 
 
 
 
73
 
74
 
75
  @lru_cache(maxsize=1)
76
+ def get_task_file_map() -> dict[str, str]:
77
  result: dict[str, str] = {}
78
  validation_dir = Path(GAIA_DIR) / "2023" / "validation"
79
 
80
  if not validation_dir.exists():
81
+ logger.warning("Папка с validation-файлами GAIA не найдена: %s", validation_dir)
82
  return result
83
 
84
+ for path in validation_dir.rglob("*"):
85
+ if path.is_file() and path.suffix.lower() != ".parquet":
86
+ result[path.stem] = str(path)
 
 
 
87
 
88
+ logger.info("Найдено локальных файлов GAIA: %s (%s)", len(result), validation_dir)
89
  return result
90
 
91
 
92
  def get_task_file(task_id: str) -> str | None:
93
  if not task_id:
94
  return None
95
+ return get_task_file_map().get(task_id)
96
 
97
 
98
+ def fetch_task_bytes(task_id: str) -> tuple[bytes, str]:
99
  local_path = get_task_file(task_id)
100
  if not local_path:
101
+ raise FileNotFoundError(f"для task_id={task_id} не найден локальный файл")
102
 
103
  path = Path(local_path)
104
  if not path.exists():
105
+ raise FileNotFoundError(f"файл не найден: {local_path}")
106
 
 
107
  content_type, _ = mimetypes.guess_type(str(path))
108
+ return path.read_bytes(), content_type or "application/octet-stream"
 
 
 
 
 
 
 
 
 
 
109
 
110
 
111
+ def is_image(content_type: str, data: bytes) -> bool:
112
+ return (
113
+ content_type.startswith("image/")
114
+ or data.startswith(b"\x89PNG")
115
+ or data.startswith(b"\xff\xd8\xff")
116
+ or data.startswith(b"GIF87a")
117
+ or data.startswith(b"GIF89a")
118
+ or (data[:4] == b"RIFF" and data[8:12] == b"WEBP")
119
+ )
 
 
 
120
 
121
 
122
+ def image_mime(data: bytes, content_type: str) -> str:
123
  if data.startswith(b"\x89PNG"):
124
  return "image/png"
125
  if data.startswith(b"\xff\xd8\xff"):
 
128
  return "image/webp"
129
  if data.startswith(b"GIF87a") or data.startswith(b"GIF89a"):
130
  return "image/gif"
131
+ return content_type if content_type.startswith("image/") else "image/png"
 
 
132
 
133
 
134
+ def is_audio_or_video(content_type: str, path: str) -> bool:
135
+ suffix = Path(path).suffix.lower()
136
+ return content_type.startswith(("audio/", "video/")) or suffix in AUDIO_VIDEO_EXTS
 
 
 
 
 
137
 
138
 
139
  def detect_local_file_kind(task_id: str) -> tuple[str, str | None]:
 
142
  return "none", None
143
 
144
  suffix = Path(local_path).suffix.lower()
 
145
  try:
146
+ data, content_type = fetch_task_bytes(task_id)
147
  except Exception:
148
  return "binary", local_path
149
 
150
+ if suffix in IMAGE_EXTS or is_image(content_type, data):
151
  return "image", local_path
152
+ if suffix in AUDIO_VIDEO_EXTS or is_audio_or_video(content_type, local_path):
153
  return "audio", local_path
154
  if suffix in SPREADSHEET_EXTS:
155
  return "spreadsheet", local_path
 
162
  return "binary", local_path
163
 
164
 
165
+ def truncate_text(text: Any, limit: int = MAX_CONTEXT_CHARS) -> str:
166
+ value = str(text)
167
+ if len(value) <= limit:
168
+ return value
169
+ return value[:limit] + f"\n\n[TRUNCATED to {limit} characters]"
170
+
171
 
172
  @tool
173
  def analyze_image(task_id: str, question: str = "") -> str:
174
+ """Analyze the GAIA image attached to a task and return visual facts for answer generation."""
175
  try:
176
+ data, content_type = fetch_task_bytes(task_id)
177
+ except Exception as exc:
178
+ return error_text(f"не удалось открыть изображение для task_id={task_id}", exc)
179
 
180
+ if not is_image(content_type, data):
181
+ return error_text(f"файл task_id={task_id} не похож на изображение, content_type={content_type}")
182
 
183
+ prompt = question or "Describe the image. Extract all visible text, numbers, symbols, and key details."
 
 
184
  if "chess" in prompt.lower():
185
+ prompt += (
186
+ "\n\nThis is a chess task. Identify board coordinates, side to move, relevant pieces, "
187
+ "checks, mate threats, and the best move in standard notation if possible."
 
188
  )
189
 
190
  try:
191
  client = get_groq_client()
192
+ response = client.chat.completions.create(
193
  model=GROQ_VISION_MODEL,
194
  messages=[
195
  {
196
  "role": "user",
197
  "content": [
198
+ {
199
+ "type": "image_url",
200
+ "image_url": {
201
+ "url": f"data:{image_mime(data, content_type)};base64,"
202
+ f"{base64.standard_b64encode(data).decode('utf-8')}"
203
+ },
204
+ },
205
  {"type": "text", "text": prompt},
206
  ],
207
  }
 
209
  temperature=0,
210
  max_tokens=768,
211
  )
212
+ return response.choices[0].message.content.strip()
213
+ except Exception as exc:
214
+ return error_text("ошибка vision-модели", exc)
215
 
216
 
217
  @tool
218
  def transcribe_audio(task_id: str) -> str:
219
+ """Transcribe the GAIA audio or video file attached to a task and return the transcript."""
220
  try:
221
+ data, content_type = fetch_task_bytes(task_id)
222
  local_path = get_task_file(task_id) or ""
223
+ except Exception as exc:
224
+ return error_text(f"не удалось открыть аудио или видео для task_id={task_id}", exc)
225
 
226
+ if not is_audio_or_video(content_type, local_path):
227
+ return error_text(f"файл task_id={task_id} не похож на аудио или видео, content_type={content_type}")
228
 
229
  suffix = Path(local_path).suffix.lower().lstrip(".") or "mp3"
230
  if suffix == "mpeg":
 
232
 
233
  try:
234
  client = get_groq_client()
235
+ audio_file = (f"audio.{suffix}", io.BytesIO(data), content_type or f"audio/{suffix}")
236
  transcription = client.audio.transcriptions.create(
237
  file=audio_file,
238
  model=GROQ_AUDIO_MODEL,
239
  response_format="text",
240
  )
241
  return str(transcription).strip()
242
+ except Exception as exc:
243
+ return error_text("ошибка транскрибации аудио", exc)
244
 
245
 
246
  @tool
247
  def read_text_file(task_id: str) -> str:
248
+ """Read the GAIA text, PDF, spreadsheet, or source file attached to a task and return compact context."""
249
  try:
250
  local_path = get_task_file(task_id)
251
  if not local_path:
252
+ return error_text(f"для task_id={task_id} не найден локальный файл")
253
 
254
  path = Path(local_path)
255
  suffix = path.suffix.lower()
256
 
257
  if suffix in SPREADSHEET_EXTS:
258
  return read_spreadsheet_context(path)
259
+ if suffix in PDF_EXTS:
260
  return read_pdf_context(path)
261
  if suffix in CODE_EXTS:
262
  return read_code_context(path)
263
 
264
+ data, content_type = fetch_task_bytes(task_id)
265
+ if is_image(content_type, data) or is_audio_or_video(content_type, local_path):
266
+ return error_text(
267
+ "файл является бинарным изображением, аудио или видео; "
268
+ "используйте analyze_image или transcribe_audio"
269
+ )
270
 
271
+ return truncate_text(data.decode("utf-8", errors="replace"))
272
+ except Exception as exc:
273
+ return error_text(f"ошибка чтения файла для task_id={task_id}", exc)
274
 
275
 
276
  def read_pdf_context(path: Path) -> str:
277
+ parts = [f"PDF file: {path.name}"]
278
+ try:
279
+ reader = pypdf.PdfReader(str(path))
280
+ except Exception as exc:
281
+ return error_text(f"ошибка открытия PDF-файла {path.name}", exc)
282
+
283
+ for index, page in enumerate(reader.pages, 1):
284
  try:
285
  text = page.extract_text() or ""
286
+ except Exception as exc:
287
+ text = f"[ошибка извлечения текста со страницы: {type(exc).__name__}: {exc}]"
288
+ parts.append(f"\n--- Page {index} ---\n{text}")
289
  if len("\n".join(parts)) > MAX_CONTEXT_CHARS:
290
  break
291
+
292
+ return truncate_text("\n".join(parts))
293
 
294
 
295
  def read_spreadsheet_context(path: Path) -> str:
296
+ parts = [f"Spreadsheet file: {path.name}"]
297
+ try:
298
+ workbook = pd.ExcelFile(path)
299
+ except Exception as exc:
300
+ return error_text(f"ошибка открытия таблицы {path.name}", exc)
301
+
302
+ for sheet_name in workbook.sheet_names:
303
+ try:
304
+ df = pd.read_excel(path, sheet_name=sheet_name)
305
+ except Exception as exc:
306
+ parts.append(f"\n--- Sheet: {sheet_name} ---")
307
+ parts.append(f"[ошибка чтения листа: {type(exc).__name__}: {exc}]")
308
+ continue
309
 
 
 
310
  parts.append(f"\n--- Sheet: {sheet_name} ---")
311
  parts.append(f"Shape: {df.shape}")
312
  parts.append(f"Columns: {list(df.columns)}")
 
319
  else:
320
  parts.append("Head 40 rows:")
321
  parts.append(df.head(40).to_csv(index=False))
 
322
  try:
323
+ parts.append("Numeric summary:")
324
  parts.append(str(df.describe(include="all")))
325
+ except Exception as exc:
326
+ parts.append(f"[ошибка построения сводки: {type(exc).__name__}: {exc}]")
327
 
328
  if len("\n".join(parts)) > MAX_CONTEXT_CHARS:
329
  break
330
 
331
+ return truncate_text("\n".join(parts))
332
 
333
 
334
  def build_spreadsheet_summary(path: Path) -> str:
335
  parts: list[str] = []
336
+ try:
337
+ workbook = pd.ExcelFile(path)
338
+ except Exception as exc:
339
+ return error_text(f"ошибка открытия таблицы для сводки {path.name}", exc)
340
 
341
+ for sheet_name in workbook.sheet_names:
342
+ try:
343
+ df = pd.read_excel(path, sheet_name=sheet_name)
344
+ except Exception as exc:
345
+ parts.append(f"Sheet: {sheet_name}\n[ошибка чтения листа: {type(exc).__name__}: {exc}]")
346
+ continue
347
  if df.empty:
348
  continue
349
 
350
  work = df.copy()
351
+ work.columns = [str(column).strip() for column in work.columns]
352
+ numeric_cols = [column for column in work.columns if pd.api.types.is_numeric_dtype(work[column])]
 
 
 
 
353
  categorical_cols = [
354
+ column
355
+ for column in work.columns
356
+ if column not in numeric_cols and work[column].nunique(dropna=True) <= 40
357
  ]
358
 
359
  parts.append(f"Sheet: {sheet_name}")
 
366
  if not numeric_cols:
367
  break
368
  grouped = work.groupby(category_col, dropna=False)[numeric_cols].sum(numeric_only=True)
369
+ if not grouped.empty:
370
+ parts.append(f"Totals grouped by {category_col}:")
371
+ parts.append(grouped.head(40).to_csv())
 
372
 
373
  if len("\n".join(parts)) > MAX_CONTEXT_CHARS:
374
  break
375
 
376
+ return truncate_text("\n".join(parts))
377
 
378
 
379
  def read_code_context(path: Path) -> str:
380
+ try:
381
+ source = path.read_text(encoding="utf-8", errors="replace")
382
+ except Exception as exc:
383
+ return error_text(f"ошибка чтения кода {path.name}", exc)
384
+ return truncate_text(f"Code file: {path.name}\n--- Source code ---\n{source}")
385
 
386
 
387
  def run_python_file(path: Path, timeout_seconds: int = 45) -> str:
388
  if not ALLOW_CODE_EXECUTION:
389
+ return "Выполнение Python-файла отключено через ALLOW_CODE_EXECUTION."
390
  if path.suffix.lower() != ".py":
391
+ return "Файл не является Python-файлом."
392
 
393
  try:
394
+ process = subprocess.run(
395
  [sys.executable, str(path)],
396
  cwd=str(path.parent),
397
  capture_output=True,
 
399
  timeout=timeout_seconds,
400
  env={**os.environ, "PYTHONIOENCODING": "utf-8"},
401
  )
402
+ stdout = process.stdout.strip()
403
+ stderr = process.stderr.strip()
404
  return (
405
+ f"Return code: {process.returncode}\n"
406
  f"STDOUT:\n{stdout[-6000:]}\n\n"
407
  f"STDERR:\n{stderr[-3000:]}"
408
  )
409
  except subprocess.TimeoutExpired:
410
+ return error_text(f"выполнение кода превысило лимит {timeout_seconds} секунд")
411
+ except Exception as exc:
412
+ return error_text("ошибка выполнения кода", exc)
 
 
 
 
 
 
 
 
 
 
 
 
413
 
414
 
415
  def html_to_text(markup: str, limit: int = 8000) -> str:
 
426
 
427
  def fetch_url_text(url: str, limit: int = 8000) -> str:
428
  try:
429
+ response = requests.get(
430
  url,
431
  timeout=12,
432
+ headers={"User-Agent": "Mozilla/5.0 (compatible; GAIA-space-agent/1.0)"},
 
 
 
 
 
433
  )
434
+ response.raise_for_status()
435
+ content_type = response.headers.get("content-type", "")
436
  if "pdf" in content_type or url.lower().endswith(".pdf"):
437
  return f"[PDF source: {url}]"
438
+ return html_to_text(response.text, limit=limit)
439
+ except Exception as exc:
440
+ return f"[ошибка загрузки URL: {type(exc).__name__}: {exc}]"
441
 
442
 
443
  def likely_relevant_url(url: str) -> bool:
444
  parsed = urlparse(url)
445
  if parsed.scheme not in {"http", "https"}:
446
  return False
447
+ blocked_hosts = ("youtube.com", "youtu.be", "facebook.com", "x.com")
448
+ return not any(host in parsed.netloc for host in blocked_hosts)
 
449
 
450
 
451
  def ddg_search(query: str, max_results: int = 5) -> list[dict[str, str]]:
452
  try:
453
  results = DDGS().text(query, max_results=max_results)
454
+ except Exception as exc:
455
+ logger.warning("DuckDuckGo-поиск не сработал: %s: %s", type(exc).__name__, exc)
456
  return []
457
 
458
  normalized: list[dict[str, str]] = []
459
  for item in results or []:
460
+ url = str(item.get("href") or item.get("url") or "").strip()
461
  title = str(item.get("title") or "").strip()
462
  body = str(item.get("body") or item.get("snippet") or "").strip()
463
+ if url or body:
464
+ normalized.append({"title": title, "url": url, "body": body})
 
465
  return normalized
466
 
467
 
468
  def build_research_queries(question: str, base_query: str) -> list[str]:
469
  queries: list[str] = []
470
+
471
  quoted_phrases = re.findall(r'["“]([^"”]{3,120})["”]', question)
472
  if quoted_phrases:
473
  queries.append(" ".join(f'"{phrase}"' for phrase in quoted_phrases[:4]))
474
 
475
+ for url in re.findall(r"https?://[^\s)>\]]+", question)[:3]:
 
476
  parsed = urlparse(url.rstrip(".,;"))
477
  if parsed.netloc:
478
  queries.append(f"site:{parsed.netloc} {base_query}")
479
 
480
+ capitalized_terms = re.findall(r"\b[A-Z][\w.'-]*(?:\s+[A-Z][\w.'-]*){1,4}\b", question)
 
 
 
481
  if capitalized_terms:
482
  queries.append(" ".join(f'"{term}"' for term in capitalized_terms[:4]))
483
 
 
488
  queries.append(f"{' '.join(capitalized_terms[:3])} {year_text}")
489
  queries.append(f"{base_query} {year_text}")
490
 
491
+ if "wikipedia" in question.lower():
 
 
 
492
  queries.append(f"site:en.wikipedia.org {base_query}")
493
 
494
+ queries.extend([base_query, question])
495
 
496
  deduped: list[str] = []
497
  for query in queries:
 
501
  return deduped[:5]
502
 
503
 
504
+ def build_additional_research_queries(question: str, previous_queries: list[str], llm: ChatGroq) -> list[str]:
505
  messages = [
506
+ SystemMessage(
507
+ content=(
508
+ "Create 3 concise web search queries for answering the task. "
509
+ "Prefer exact entity names, dates, source names, and required answer type. "
510
+ "Return one query per line, no numbering."
511
+ )
512
+ ),
513
  HumanMessage(content=question),
514
  ]
515
+
516
  try:
 
517
  raw = llm.invoke(messages).content
518
+ except Exception as exc:
519
+ logger.warning("Не удалось расширить поисковые запросы: %s: %s", type(exc).__name__, exc)
520
  return []
521
 
522
+ previous = {query.lower() for query in previous_queries}
523
  queries: list[str] = []
 
524
  for line in raw.splitlines():
525
  query = clean_answer(re.sub(r"^\s*[-*\d.)]+\s*", "", line))
526
  query = re.sub(r"\s+", " ", query).strip()
 
529
  return queries[:3]
530
 
531
 
532
+ def build_research_context(question: str, base_query: str) -> str:
533
+ parts = [f"Question: {question}", f"Primary query: {base_query}"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
534
  seen_urls: set[str] = set()
535
 
536
+ for query in build_research_queries(question, base_query):
537
  parts.append(f"\n=== Search query: {query} ===")
538
  results = ddg_search(query, max_results=6)
539
+ fetched_count = 0
 
 
540
 
541
+ for index, result in enumerate(results, 1):
542
  url = result["url"]
543
+ parts.append(f"[{index}] {result['title']}\nURL: {url}\nSnippet: {result['body']}")
544
+
545
+ if not url or url in seen_urls or not likely_relevant_url(url) or fetched_count >= 2:
 
 
 
 
546
  continue
547
+
548
  seen_urls.add(url)
549
+ fetched = fetch_url_text(url, limit=5000)
550
+ if fetched and not fetched.startswith("[ошибка загрузки"):
551
+ fetched_count += 1
552
  parts.append(f"Fetched text from {url}:\n{fetched}")
553
+
554
  if len("\n".join(parts)) > MAX_SEARCH_CONTEXT_CHARS:
555
  return truncate_text("\n".join(parts), MAX_SEARCH_CONTEXT_CHARS)
556
 
557
  return truncate_text("\n".join(parts), MAX_SEARCH_CONTEXT_CHARS)
558
 
559
 
560
+ def extend_research_context(question: str, context: str, used_query: str, llm: ChatGroq) -> str:
561
+ extra_queries = build_additional_research_queries(question, [used_query], llm)
562
+ if not extra_queries:
563
+ return context
564
 
565
+ parts = [context, "\n=== Additional focused searches ==="]
566
+ seen_urls = set(re.findall(r"URL: (https?://\S+)", context))
567
+
568
+ for query in extra_queries:
569
  parts.append(f"\n=== Search query: {query} ===")
570
  results = ddg_search(query, max_results=6)
 
 
 
 
571
  fetched_count = 0
 
 
 
 
 
572
 
573
+ for index, result in enumerate(results, 1):
574
+ url = result["url"]
575
+ parts.append(f"[{index}] {result['title']}\nURL: {url}\nSnippet: {result['body']}")
576
+ if not url or url in seen_urls or not likely_relevant_url(url) or fetched_count >= 2:
 
577
  continue
578
 
579
  seen_urls.add(url)
580
  fetched = fetch_url_text(url, limit=5000)
581
+ if fetched and not fetched.startswith("[ошибка загрузки"):
582
  fetched_count += 1
583
  parts.append(f"Fetched text from {url}:\n{fetched}")
584
+
585
  if len("\n".join(parts)) > MAX_SEARCH_CONTEXT_CHARS:
586
  return truncate_text("\n".join(parts), MAX_SEARCH_CONTEXT_CHARS)
587
 
588
  return truncate_text("\n".join(parts), MAX_SEARCH_CONTEXT_CHARS)
589
 
590
 
591
+ def extract_youtube_id(question: str) -> str | None:
592
+ match = re.search(r"(?:v=|youtu\.be/)([A-Za-z0-9_-]{11})", question)
593
+ return match.group(1) if match else None
 
594
 
595
+
596
+ def is_youtube_question(question: str) -> bool:
597
+ lower = question.lower()
598
+ return "youtube.com/watch" in lower or "youtu.be/" in lower
599
+
600
+
601
+ def fetch_youtube_timedtext(video_id: str) -> str:
602
+ urls = [
603
+ f"https://video.google.com/timedtext?lang=en&v={video_id}",
604
+ f"https://www.youtube.com/api/timedtext?lang=en&v={video_id}",
605
+ ]
606
+
607
+ for url in urls:
608
+ try:
609
+ response = requests.get(url, timeout=12, headers={"User-Agent": "GAIA-space-agent/1.0"})
610
+ response.raise_for_status()
611
+ chunks = re.findall(r"<text[^>]*>(.*?)</text>", response.text, flags=re.S)
612
+ if not chunks:
613
+ continue
614
+ text = " ".join(html.unescape(re.sub(r"<[^>]+>", " ", chunk)) for chunk in chunks)
615
+ text = re.sub(r"\s+", " ", text).strip()
616
+ if text:
617
+ return text
618
+ except Exception as exc:
619
+ logger.warning("Не удалось получить YouTube timedtext: %s: %s", type(exc).__name__, exc)
620
+
621
+ return ""
622
+
623
+
624
+ def build_youtube_context(question: str, video_id: str | None) -> str:
625
+ queries = [question]
626
+ quoted_phrases = re.findall(r'["“]([^"”]{3,120})["”]', question)
627
+
628
+ if video_id:
629
+ queries = [f'"{video_id}" transcript', f'"{video_id}" subtitles', f'"{video_id}"'] + queries
630
+ queries.extend(f'"{video_id}" "{phrase}"' for phrase in quoted_phrases[:3])
631
+ if quoted_phrases:
632
+ queries.append(" ".join(f'"{phrase}"' for phrase in quoted_phrases[:3]))
633
+
634
+ parts = [f"Question: {question}", f"YouTube video id: {video_id or 'unknown'}"]
635
+ if video_id:
636
+ transcript = fetch_youtube_timedtext(video_id)
637
+ if transcript:
638
+ parts.append(f"\n=== YouTube timedtext transcript ===\n{truncate_text(transcript, 8000)}")
639
+
640
+ seen_urls: set[str] = set()
641
+ for query in queries[:8]:
642
  parts.append(f"\n=== Search query: {query} ===")
643
  results = ddg_search(query, max_results=6)
 
 
 
644
 
645
+ for index, result in enumerate(results, 1):
 
646
  url = result["url"]
647
+ parts.append(f"[{index}] {result['title']}\nURL: {url}\nSnippet: {result['body']}")
648
+ parsed = urlparse(url)
649
+ if not url or url in seen_urls or parsed.scheme not in {"http", "https"}:
650
  continue
651
+ if "youtube.com" in parsed.netloc or "youtu.be" in parsed.netloc:
652
  continue
653
+
654
  seen_urls.add(url)
655
+ fetched = fetch_url_text(url, limit=4000)
656
+ if fetched and not fetched.startswith("[ошибка загрузки"):
 
657
  parts.append(f"Fetched text from {url}:\n{fetched}")
658
+
659
  if len("\n".join(parts)) > MAX_SEARCH_CONTEXT_CHARS:
660
  return truncate_text("\n".join(parts), MAX_SEARCH_CONTEXT_CHARS)
661
 
662
  return truncate_text("\n".join(parts), MAX_SEARCH_CONTEXT_CHARS)
663
 
664
 
665
+ def clean_answer(answer: Any) -> str:
666
+ value = str(answer or "").strip()
 
667
  prefixes = [
668
  "FINAL ANSWER:",
669
  "Final Answer:",
 
672
  "the answer is:",
673
  "Answer:",
674
  "answer:",
675
+ "ФИНАЛЬНЫЙ ОТВЕТ:",
676
+ "Финальный ответ:",
677
+ "Ответ:",
678
+ "ответ:",
679
  ]
 
 
 
680
 
681
+ for prefix in prefixes:
682
+ if value.lower().startswith(prefix.lower()):
683
+ value = value[len(prefix) :].strip()
684
 
685
+ return value.strip().strip("`*").strip().strip('"').strip("'").strip()
686
 
687
 
688
+ def is_bad_answer(answer: Any) -> bool:
689
+ value = clean_answer(answer).lower()
690
+ if not value:
691
  return True
692
+
693
  bad_markers = [
694
+ "ошибка:",
695
+ "не хватает данных",
696
+ "недостаточно данных",
697
+ "не удалось",
698
+ "не найден",
699
  "error:",
700
  "i don't know",
701
  "i do not know",
 
706
  "no answer",
707
  "no answer found",
708
  "no information found",
 
 
709
  "could not find",
710
  "couldn't find",
711
  "not found",
 
 
 
 
712
  "insufficient information",
713
+ "insufficient evidence",
714
+ "unknown",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
715
  ]
716
+ return any(marker in value for marker in bad_markers)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
717
 
718
 
719
  def last_nonempty_line(text: str) -> str:
 
721
  return lines[-1] if lines else ""
722
 
723
 
724
+ class AgentState(TypedDict, total=False):
 
 
 
 
 
725
  question: str
726
  task_id: str
727
  route: str
 
735
 
736
 
737
  class BasicAgent:
738
+ def __init__(self) -> None:
739
  self.answer_llm = make_chat_model(GROQ_TEXT_MODEL, max_tokens=256)
740
+ self.final_llm = make_chat_model(GROQ_FINAL_MODEL, max_tokens=64)
741
  self.strong_llm = make_chat_model(GROQ_STRONG_MODEL, max_tokens=512)
742
  self.research_llm = make_chat_model(GROQ_RESEARCH_MODEL, max_tokens=512)
 
743
  self.graph = self.build_graph()
744
 
745
+ logger.info("LangGraph-агент инициализирован.")
746
+ logger.info("Текстовая модель: %s", GROQ_TEXT_MODEL)
747
+ logger.info("Финальная модель: %s", GROQ_FINAL_MODEL)
748
+ logger.info("Исследовательская модель: %s", GROQ_RESEARCH_MODEL)
749
+ logger.info("Vision-модель: %s", GROQ_VISION_MODEL)
750
+ logger.info("Audio-модель: %s", GROQ_AUDIO_MODEL)
751
+ logger.info("Выполнение кода: %s", "включено" if ALLOW_CODE_EXECUTION else "отключено")
752
 
753
  def build_graph(self):
754
+ graph = StateGraph(AgentState)
755
+
756
+ graph.add_node("classify_task", self.classify_task)
757
+ graph.add_node("solve_image", self.solve_image)
758
+ graph.add_node("solve_audio", self.solve_audio)
759
+ graph.add_node("solve_spreadsheet", self.solve_spreadsheet)
760
+ graph.add_node("solve_code", self.solve_code)
761
+ graph.add_node("solve_direct", self.solve_direct)
762
+ graph.add_node("solve_research", self.solve_research)
763
+ graph.add_node("solve_youtube", self.solve_youtube)
764
+ graph.add_node("verify_answer", self.verify_answer)
765
+ graph.add_node("final_cleaner", self.final_cleaner)
766
+
767
+ graph.set_entry_point("classify_task")
768
+ graph.add_conditional_edges(
769
+ "classify_task",
770
+ self.select_route,
 
 
 
 
 
771
  {
772
  "solve_image": "solve_image",
773
  "solve_audio": "solve_audio",
 
779
  },
780
  )
781
 
782
+ for node in (
783
  "solve_image",
784
  "solve_audio",
785
  "solve_spreadsheet",
 
787
  "solve_direct",
788
  "solve_research",
789
  "solve_youtube",
790
+ ):
791
+ graph.add_edge(node, "verify_answer")
792
 
793
+ graph.add_edge("verify_answer", "final_cleaner")
794
+ graph.add_edge("final_cleaner", END)
795
+ return graph.compile()
 
796
 
797
  def classify_task(self, state: AgentState) -> dict[str, Any]:
798
  question = state.get("question", "")
799
  task_id = state.get("task_id", "")
 
800
  file_kind, local_path = detect_local_file_kind(task_id)
801
 
802
  if file_kind == "image":
 
809
  route = "solve_code"
810
  elif file_kind in {"pdf", "text", "binary"}:
811
  route = "solve_direct"
 
 
812
  elif is_youtube_question(question):
813
  route = "solve_youtube"
814
  else:
815
  route = "solve_research"
816
 
817
+ logger.info("Маршрут задачи: file_kind=%s, route=%s, path=%s", file_kind, route, local_path)
818
  return {"file_kind": file_kind, "local_path": local_path, "route": route}
819
 
820
+ def select_route(self, state: AgentState) -> str:
 
 
 
 
821
  route = state.get("route", "solve_research")
822
  allowed = {
823
  "solve_image",
 
833
  def solve_image(self, state: AgentState) -> dict[str, Any]:
834
  question = state.get("question", "")
835
  task_id = state.get("task_id", "")
 
836
  context = analyze_image.invoke({"task_id": task_id, "question": question})
837
+ raw_answer = self.answer_from_context(question, context, "Image analysis", self.strong_llm)
 
 
 
 
 
838
  return {"context": context, "raw_answer": raw_answer}
839
 
840
  def solve_audio(self, state: AgentState) -> dict[str, Any]:
841
  question = state.get("question", "")
842
  task_id = state.get("task_id", "")
 
843
  transcript = transcribe_audio.invoke({"task_id": task_id})
844
  context = f"Audio/video transcript:\n{transcript}"
845
+ raw_answer = self.answer_from_context(question, context, "Audio transcript", self.answer_llm)
 
 
 
 
 
846
  return {"context": context, "raw_answer": raw_answer}
847
 
848
+ def solve_spreadsheet(self, state: AgentState) -> dict[str, Any]:
849
+ question = state.get("question", "")
850
+ local_path = state.get("local_path")
851
+ if not local_path:
852
+ return {"raw_answer": error_text("для spreadsheet-маршрута не найден путь к файлу")}
853
 
854
+ path = Path(local_path)
855
+ context = read_spreadsheet_context(path)
856
+ summary = build_spreadsheet_summary(path)
857
  if summary:
858
  context = f"{context}\n\n--- Computed spreadsheet summary ---\n{summary}"
859
 
860
  raw_answer = self.answer_from_context(
861
+ question,
862
+ context,
863
+ "Spreadsheet data and computed summary",
864
+ self.strong_llm,
865
  )
 
866
  return {"context": context, "raw_answer": raw_answer}
867
 
868
  def solve_code(self, state: AgentState) -> dict[str, Any]:
869
  question = state.get("question", "")
870
  local_path = state.get("local_path")
 
871
  if not local_path:
872
+ return {"raw_answer": error_text("для code-маршрута не найден путь к файлу")}
873
 
874
  path = Path(local_path)
875
  code_context = read_code_context(path)
876
+ execution_context = run_python_file(path) if path.suffix.lower() == ".py" else "Выполнение пропущено: файл не Python."
877
  context = f"{code_context}\n\n--- Execution result ---\n{execution_context}"
878
 
879
  if "final numeric output" in question.lower() and "STDOUT:" in execution_context:
 
882
  if candidate and re.search(r"[-+]?\d", candidate):
883
  return {"context": context, "raw_answer": candidate}
884
 
885
+ raw_answer = self.answer_from_context(question, context, "Code and execution result", self.strong_llm)
 
 
 
 
 
886
  return {"context": context, "raw_answer": raw_answer}
887
 
888
  def solve_direct(self, state: AgentState) -> dict[str, Any]:
 
891
  file_kind = state.get("file_kind", "none")
892
  local_path = state.get("local_path")
893
 
 
 
 
 
 
 
 
 
894
  context = ""
895
  if local_path and file_kind in {"pdf", "text", "binary"}:
896
  context = read_text_file.invoke({"task_id": task_id})
897
 
898
+ raw_answer = self.answer_from_context(question, context, f"Direct context; file_kind={file_kind}", self.answer_llm)
 
 
 
 
 
899
  return {"context": context, "raw_answer": raw_answer}
900
 
901
  def solve_research(self, state: AgentState) -> dict[str, Any]:
902
  question = state.get("question", "")
 
903
  query = self.make_search_query(question)
904
+ logger.info("Поисковый запрос: %s", query)
905
 
906
  context = build_research_context(question, query)
907
+ logger.info("Размер поискового контекста: %s", len(context))
908
 
909
+ raw_answer = self.answer_from_context(question, context, "Web research results", self.research_llm)
 
 
 
 
 
 
 
 
 
910
  if is_bad_answer(raw_answer):
911
+ context = extend_research_context(question, context, query, self.final_llm)
912
+ logger.info("Размер расширенного поискового контекста: %s", len(context))
913
+ raw_answer = self.answer_from_context(question, context, "Extended web research results", self.strong_llm)
 
 
 
 
 
914
 
 
915
  return {"context": context, "raw_answer": raw_answer}
 
 
 
 
916
 
917
+ def solve_youtube(self, state: AgentState) -> dict[str, Any]:
918
+ question = state.get("question", "")
919
+ video_id = extract_youtube_id(question)
920
  context = build_youtube_context(question, video_id)
921
+ raw_answer = self.answer_from_context(question, context, "YouTube/web transcript search results", self.research_llm)
 
 
 
 
 
 
922
 
923
  if is_bad_answer(raw_answer):
924
+ context = extend_research_context(question, context, question, self.final_llm)
925
  raw_answer = self.answer_from_context(
926
+ question,
927
+ context,
928
+ "Extended YouTube/web transcript search results",
929
+ self.strong_llm,
930
  )
931
 
932
+ return {"context": context, "raw_answer": raw_answer}
 
 
 
933
 
934
  def verify_answer(self, state: AgentState) -> dict[str, Any]:
935
  question = state.get("question", "")
 
939
  file_kind = state.get("file_kind", "none")
940
 
941
  if is_bad_answer(raw_answer):
942
+ return {"verified_answer": "", "error": raw_answer or "пустой ответ"}
 
 
 
943
 
944
  if route not in {"solve_research", "solve_youtube"} or file_kind in {"code", "spreadsheet", "audio"}:
945
  return {"verified_answer": raw_answer}
 
948
  return {"verified_answer": raw_answer}
949
 
950
  messages = [
951
+ SystemMessage(
952
+ content=(
953
+ "You verify a draft answer for a GAIA benchmark task. "
954
+ "Use only the provided context. Return only the corrected final answer. "
955
+ "If the context does not support an answer, return ERROR: insufficient evidence."
956
+ )
957
+ ),
958
+ HumanMessage(
959
+ content=(
960
+ f"Question:\n{question}\n\n"
961
+ f"Context:\n{truncate_text(context, 5000)}\n\n"
962
+ f"Draft answer:\n{truncate_text(raw_answer, 3000)}\n\n"
963
+ "Correct final answer only:"
964
+ )
965
+ ),
966
  ]
967
+
968
  try:
969
  verified = self.final_llm.invoke(messages).content.strip()
970
+ except Exception as exc:
971
+ logger.warning("Проверка ответа не сработала: %s: %s", type(exc).__name__, exc)
972
  verified = raw_answer
 
973
 
974
  if is_bad_answer(verified):
975
  return {"verified_answer": "", "error": clean_answer(verified)}
 
976
  return {"verified_answer": clean_answer(verified)}
977
 
978
  def final_cleaner(self, state: AgentState) -> dict[str, Any]:
 
980
  answer = clean_answer(state.get("verified_answer") or state.get("raw_answer") or "")
981
 
982
  if is_bad_answer(answer):
983
+ return {"final_answer": "", "error": state.get("error") or answer or "плохой ответ"}
984
 
985
  if "\n" in answer or len(answer.split()) > 12 or len(answer) > 120:
986
  answer = self.extract_final_answer(question, answer)
987
 
988
  answer = clean_answer(answer)
989
  if is_bad_answer(answer):
990
+ return {"final_answer": "", "error": state.get("error") or answer or "плохой ответ"}
991
  return {"final_answer": answer}
992
 
 
993
  def answer_from_context(self, question: str, context: str, context_label: str, llm: ChatGroq) -> str:
994
  system = (
995
  "You answer GAIA benchmark questions.\n"
 
1010
 
1011
  try:
1012
  return llm.invoke([SystemMessage(content=system), HumanMessage(content=user)]).content.strip()
1013
+ except Exception as exc:
1014
+ message = str(exc)
1015
+ model_rejected_tools = (
1016
+ "tool_use_failed" in message
1017
+ or "Tool choice is none" in message
1018
+ or "model called a tool" in message
1019
+ )
1020
+ if model_rejected_tools:
1021
  try:
1022
+ logger.warning("LLM дала tool-use ошибку, повторяю через модель %s", GROQ_TEXT_MODEL)
1023
  return self.answer_llm.invoke([SystemMessage(content=system), HumanMessage(content=user)]).content.strip()
1024
+ except Exception as fallback_exc:
1025
+ return error_text("ошибка fallback-вызова LLM", fallback_exc)
1026
+ return error_text("ошибка вызова LLM", exc)
1027
 
1028
  def extract_final_answer(self, question: str, raw_answer: str) -> str:
1029
  raw_answer = clean_answer(raw_answer)
 
1031
  return raw_answer
1032
 
1033
  messages = [
1034
+ SystemMessage(
1035
+ content=(
1036
+ "Extract the final answer from the draft. "
1037
+ "Return ONLY the answer itself. No explanation. No prefix. No quotes."
1038
+ )
1039
+ ),
1040
+ HumanMessage(
1041
+ content=(
1042
+ f"Question:\n{question}\n\n"
1043
+ f"Draft answer:\n{truncate_text(raw_answer, 3000)}\n\n"
1044
+ "Final answer only:"
1045
+ )
1046
+ ),
1047
  ]
1048
+
1049
  try:
1050
  return clean_answer(self.final_llm.invoke(messages).content.strip())
1051
+ except Exception as exc:
1052
+ logger.warning("Финальное извлечение ответа не сработало: %s: %s", type(exc).__name__, exc)
1053
  return clean_answer(last_nonempty_line(raw_answer))
1054
 
1055
  def make_search_query(self, question: str) -> str:
1056
+ question = re.sub(r"\s+", " ", question).strip()
1057
+ if len(question) <= 220:
1058
+ return question
1059
 
1060
  messages = [
1061
  SystemMessage(content="Rewrite the task as a concise web search query. Output only the query."),
1062
+ HumanMessage(content=question[:1000]),
1063
  ]
1064
  try:
1065
+ query = clean_answer(self.final_llm.invoke(messages).content.strip())
1066
+ return query[:220] if query else question[:220]
1067
+ except Exception as exc:
1068
+ logger.warning("Не удалось сжать вопрос в поисковый запрос: %s: %s", type(exc).__name__, exc)
1069
+ return question[:220]
1070
 
1071
  def __call__(self, question: str, task_id: str = "") -> str:
1072
+ logger.info("\n%s", "-" * 60)
1073
+ logger.info("ID задачи: %s", task_id)
1074
+ logger.info("Вопрос: %s", question[:160])
1075
 
1076
  try:
1077
  result = self.graph.invoke(
 
1079
  config={"recursion_limit": 12},
1080
  )
1081
  answer = clean_answer(result.get("final_answer", ""))
 
1082
  if not answer:
1083
+ answer = error_text(result.get("error", "финальный ответ не получен"))
1084
+ logger.info("Итоговый ответ: %s", answer)
 
 
1085
  return answer
1086
+ except Exception as exc:
1087
+ logger.error("Агент завершился с ошибкой: %s: %s", type(exc).__name__, exc)
1088
+ return error_text("агент завершился с ошибкой", exc)
1089
 
1090
 
1091
  def run_and_submit_all(profile: gr.OAuthProfile | None):
1092
  space_id = os.getenv("SPACE_ID")
1093
 
1094
  if not profile:
1095
+ return "Сначала войдите в Hugging Face.", None
1096
 
1097
  username = profile.username
1098
+ logger.info("Пользователь HF: %s", username)
 
 
 
1099
 
1100
  try:
1101
  agent = BasicAgent()
1102
+ except Exception as exc:
1103
+ return error_text("не удалось инициализировать агента", exc), None
1104
 
1105
+ questions_url = f"{DEFAULT_API_URL}/questions"
1106
+ submit_url = f"{DEFAULT_API_URL}/submit"
1107
  agent_code = f"https://huggingface.co/spaces/{space_id}/tree/main" if space_id else ""
1108
 
1109
  try:
1110
+ response = requests.get(questions_url, timeout=20)
1111
+ response.raise_for_status()
1112
+ questions_data = response.json()
1113
+ logger.info("Получено вопросов: %s", len(questions_data))
1114
+ except Exception as exc:
1115
+ return error_text("не удалось получить список вопросов", exc), None
1116
 
1117
  results_log: list[dict[str, str]] = []
1118
  answers_payload: list[dict[str, str]] = []
 
1125
 
1126
  try:
1127
  answer = agent(question_text, task_id=task_id)
1128
+ results_log.append({"ID задачи": task_id, "Вопрос": question_text[:120], "Ответ": answer})
1129
 
1130
+ if answer and not is_bad_answer(answer):
1131
  answers_payload.append({"task_id": task_id, "submitted_answer": answer})
1132
  else:
1133
+ logger.info("Ответ не отправлен для task_id=%s: %s", task_id, answer)
1134
+ except Exception as exc:
1135
+ answer = error_text("ошибка обработки вопроса", exc)
1136
+ results_log.append({"ID задачи": task_id, "Вопрос": question_text[:120], "Ответ": answer})
1137
+ logger.error("Ошибка вопроса task_id=%s: %s: %s", task_id, type(exc).__name__, exc)
 
1138
  time.sleep(1)
1139
 
1140
  if not answers_payload:
1141
+ return "Агент не подготовил ни одного ответа для отправки.", pd.DataFrame(results_log)
1142
 
1143
+ payload = {
1144
+ "username": username.strip(),
1145
+ "agent_code": agent_code,
1146
+ "answers": answers_payload,
1147
+ }
1148
 
1149
  try:
1150
+ response = requests.post(submit_url, json=payload, timeout=60)
1151
+ response.raise_for_status()
1152
+ result = response.json()
1153
  status = (
1154
+ "Сабмит выполнен.\n"
1155
+ f"Пользователь: {result.get('username')}\n"
1156
+ f"Счет: {result.get('score', 'N/A')}% "
1157
+ f"({result.get('correct_count', '?')}/{result.get('total_attempted', '?')})\n"
1158
+ f"Сообщение: {result.get('message', '')}\n"
1159
+ f"Отправлено ответов: {len(answers_payload)}/{len(questions_data)}"
1160
  )
1161
+ except Exception as exc:
1162
+ status = error_text("ошибка отправки сабмита", exc)
1163
 
1164
  return status, pd.DataFrame(results_log)
1165
 
 
1168
  space_id_startup = os.getenv("SPACE_ID")
1169
  oauth_available = bool(space_host_startup or space_id_startup or os.getenv("HF_TOKEN"))
1170
 
1171
+ with gr.Blocks(title="GAIA LangGraph Agent") as demo:
1172
+ gr.Markdown("# GAIA LangGraph Agent")
 
 
 
 
 
 
 
 
1173
 
1174
  if oauth_available:
1175
  gr.LoginButton()
1176
  else:
1177
+ gr.Markdown("OAuth Hugging Face недоступен вне Space. Для сабмита нужен вход в HF.")
1178
+
1179
+ run_button = gr.Button("Запустить оценку и отправить ответы")
1180
+ status_output = gr.Textbox(label="Статус", lines=6, interactive=False)
1181
+ results_table = gr.DataFrame(label="Ответы агента", wrap=True)
1182
+
1183
+ run_button.click(fn=run_and_submit_all, outputs=[status_output, results_table])
 
 
1184
 
1185
  if space_host_startup:
1186
+ logger.info("SPACE_HOST найден: %s", space_host_startup)
1187
  if space_id_startup:
1188
+ logger.info("SPACE_ID найден: %s", space_id_startup)
1189
 
1190
  if __name__ == "__main__":
1191
+ logger.info("Запускаю Gradio-интерфейс LangGraph-агента.")
1192
+ demo.launch(debug=os.getenv("GRADIO_DEBUG", "0") == "1", share=False)