vladd19 commited on
Commit
5b17f16
·
verified ·
1 Parent(s): a572640

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +479 -159
app.py CHANGED
@@ -7,6 +7,7 @@ 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
@@ -29,32 +30,67 @@ 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")
33
- GROQ_STRONG_MODEL = os.getenv("GROQ_STRONG_MODEL", GROQ_TEXT_MODEL)
34
- GROQ_RESEARCH_MODEL = os.getenv("GROQ_RESEARCH_MODEL", GROQ_TEXT_MODEL)
35
- GROQ_VISION_MODEL = os.getenv("GROQ_VISION_MODEL", "meta-llama/llama-4-scout-17b-16e-instruct")
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
- ENABLE_RESEARCH_RETRY = os.getenv("ENABLE_RESEARCH_RETRY", "0").lower() in {"1", "true", "yes"}
 
 
 
 
 
 
 
 
41
 
42
  MAX_CONTEXT_CHARS = int(os.getenv("MAX_CONTEXT_CHARS", "9000"))
43
  MAX_SEARCH_CONTEXT_CHARS = int(os.getenv("MAX_SEARCH_CONTEXT_CHARS", "7000"))
44
  SEARCH_RESULTS_PER_QUERY = int(os.getenv("SEARCH_RESULTS_PER_QUERY", "4"))
45
  SEARCH_FETCH_LIMIT = int(os.getenv("SEARCH_FETCH_LIMIT", "2200"))
 
 
46
 
47
  IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
48
- AUDIO_VIDEO_EXTS = {".mp3", ".wav", ".m4a", ".flac", ".ogg", ".webm", ".mp4", ".mov", ".mkv"}
 
 
 
 
 
 
 
 
 
 
49
  SPREADSHEET_EXTS = {".xlsx", ".xls"}
50
  PDF_EXTS = {".pdf"}
51
  CODE_EXTS = {".py", ".js", ".ts", ".java", ".cpp", ".c", ".rb", ".go", ".rs"}
52
- TEXT_EXTS = {".txt", ".md", ".csv", ".json", ".xml", ".html", ".htm", ".yaml", ".yml"} | CODE_EXTS
53
-
54
- logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper(), format="%(message)s")
55
- logger = logging.getLogger("gaia-space-agent")
56
- for noisy_logger in ("httpx", "httpcore", "ddgs", "duckduckgo_search", "primp"):
57
- logging.getLogger(noisy_logger).setLevel(logging.WARNING)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
 
60
  def error_text(message: str, exc: Exception | None = None) -> str:
@@ -83,14 +119,15 @@ def get_task_file_map() -> dict[str, str]:
83
  validation_dir = Path(GAIA_DIR) / "2023" / "validation"
84
 
85
  if not validation_dir.exists():
86
- logger.warning("Папка с validation-файлами GAIA не найдена: %s", validation_dir)
 
 
87
  return result
88
 
89
  for path in validation_dir.rglob("*"):
90
  if path.is_file() and path.suffix.lower() != ".parquet":
91
  result[path.stem] = str(path)
92
 
93
- logger.info("Найдено локальных файлов GAIA: %s (%s)", len(result), validation_dir)
94
  return result
95
 
96
 
@@ -183,9 +220,14 @@ def analyze_image(task_id: str, question: str = "") -> str:
183
  return error_text(f"не удалось открыть изображение для task_id={task_id}", exc)
184
 
185
  if not is_image(content_type, data):
186
- return error_text(f"файл task_id={task_id} не похож на изображение, content_type={content_type}")
 
 
187
 
188
- prompt = question or "Describe the image. Extract all visible text, numbers, symbols, and key details."
 
 
 
189
  if "chess" in prompt.lower():
190
  prompt += (
191
  "\n\nThis is a chess task. Identify board coordinates, side to move, relevant pieces, "
@@ -219,6 +261,30 @@ def analyze_image(task_id: str, question: str = "") -> str:
219
  return error_text("ошибка vision-модели", exc)
220
 
221
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  @tool
223
  def transcribe_audio(task_id: str) -> str:
224
  """Transcribe the GAIA audio or video file attached to a task and return the transcript."""
@@ -226,24 +292,18 @@ def transcribe_audio(task_id: str) -> str:
226
  data, content_type = fetch_task_bytes(task_id)
227
  local_path = get_task_file(task_id) or ""
228
  except Exception as exc:
229
- return error_text(f"не удалось открыть аудио или видео для task_id={task_id}", exc)
 
 
230
 
231
  if not is_audio_or_video(content_type, local_path):
232
- return error_text(f"файл task_id={task_id} не похож на аудио или видео, content_type={content_type}")
233
-
234
- suffix = Path(local_path).suffix.lower().lstrip(".") or "mp3"
235
- if suffix == "mpeg":
236
- suffix = "mp3"
237
 
238
  try:
239
- client = get_groq_client()
240
- audio_file = (f"audio.{suffix}", io.BytesIO(data), content_type or f"audio/{suffix}")
241
- transcription = client.audio.transcriptions.create(
242
- file=audio_file,
243
- model=GROQ_AUDIO_MODEL,
244
- response_format="text",
245
- )
246
- return str(transcription).strip()
247
  except Exception as exc:
248
  return error_text("ошибка транскрибации аудио", exc)
249
 
@@ -289,7 +349,9 @@ def read_pdf_context(path: Path) -> str:
289
  try:
290
  text = page.extract_text() or ""
291
  except Exception as exc:
292
- text = f"[ошибка извлечения текста со страницы: {type(exc).__name__}: {exc}]"
 
 
293
  parts.append(f"\n--- Page {index} ---\n{text}")
294
  if len("\n".join(parts)) > MAX_CONTEXT_CHARS:
295
  break
@@ -347,14 +409,20 @@ def build_spreadsheet_summary(path: Path) -> str:
347
  try:
348
  df = pd.read_excel(path, sheet_name=sheet_name)
349
  except Exception as exc:
350
- parts.append(f"Sheet: {sheet_name}\n[ошибка чтения листа: {type(exc).__name__}: {exc}]")
 
 
351
  continue
352
  if df.empty:
353
  continue
354
 
355
  work = df.copy()
356
  work.columns = [str(column).strip() for column in work.columns]
357
- numeric_cols = [column for column in work.columns if pd.api.types.is_numeric_dtype(work[column])]
 
 
 
 
358
  categorical_cols = [
359
  column
360
  for column in work.columns
@@ -363,14 +431,18 @@ def build_spreadsheet_summary(path: Path) -> str:
363
 
364
  parts.append(f"Sheet: {sheet_name}")
365
  if numeric_cols:
366
- totals = work[numeric_cols].sum(numeric_only=True).sort_values(ascending=False)
 
 
367
  parts.append("Numeric column totals:")
368
  parts.append(totals.to_string())
369
 
370
  for category_col in categorical_cols[:6]:
371
  if not numeric_cols:
372
  break
373
- grouped = work.groupby(category_col, dropna=False)[numeric_cols].sum(numeric_only=True)
 
 
374
  if not grouped.empty:
375
  parts.append(f"Totals grouped by {category_col}:")
376
  parts.append(grouped.head(40).to_csv())
@@ -456,8 +528,7 @@ def likely_relevant_url(url: str) -> bool:
456
  def ddg_search(query: str, max_results: int = 5) -> list[dict[str, str]]:
457
  try:
458
  results = DDGS().text(query, max_results=max_results)
459
- except Exception as exc:
460
- logger.warning("DuckDuckGo-поиск не сработал: %s: %s", type(exc).__name__, exc)
461
  return []
462
 
463
  normalized: list[dict[str, str]] = []
@@ -481,7 +552,9 @@ def solve_reversed_english_task(question: str) -> str | None:
481
  return None
482
 
483
  reversed_text = question[::-1]
484
- match = re.search(r'opposite of the word ["“”\']?([A-Za-z]+)["“”\']?', reversed_text, flags=re.I)
 
 
485
  if not match:
486
  return None
487
 
@@ -502,10 +575,14 @@ def solve_reversed_english_task(question: str) -> str | None:
502
 
503
  def solve_commutativity_table(question: str) -> str | None:
504
  lower = question.lower()
505
- if "|---" not in question or ("commutative" not in lower and "commutativity" not in lower):
 
 
506
  return None
507
 
508
- lines = [line.strip() for line in question.splitlines() if line.strip().startswith("|")]
 
 
509
  if len(lines) < 3:
510
  return None
511
 
@@ -525,7 +602,11 @@ def solve_commutativity_table(question: str) -> str | None:
525
  continue
526
  left_right = table.get(left, {}).get(right)
527
  right_left = table.get(right, {}).get(left)
528
- if left_right is not None and right_left is not None and left_right != right_left:
 
 
 
 
529
  counterexample_elements.update({left, right})
530
 
531
  if counterexample_elements:
@@ -535,7 +616,11 @@ def solve_commutativity_table(question: str) -> str | None:
535
 
536
  def solve_botany_grocery_list(question: str) -> str | None:
537
  lower = question.lower()
538
- if "grocery list" not in lower or "vegetables" not in lower or "botanical fruits" not in lower:
 
 
 
 
539
  return None
540
 
541
  match = re.search(
@@ -545,7 +630,9 @@ def solve_botany_grocery_list(question: str) -> str | None:
545
  if not match:
546
  return None
547
 
548
- items = [re.sub(r"\s+", " ", item).strip(" .") for item in match.group(1).split(",")]
 
 
549
  vegetable_names = {
550
  "broccoli",
551
  "cabbage",
@@ -579,7 +666,11 @@ def is_direct_question(question: str) -> bool:
579
  return (
580
  is_reversed_english_task(question)
581
  or ("|---" in question and ("commutative" in lower or "commutativity" in lower))
582
- or ("grocery list" in lower and "botanical fruits" in lower and "vegetables" in lower)
 
 
 
 
583
  )
584
 
585
 
@@ -595,7 +686,9 @@ def build_research_queries(question: str, base_query: str) -> list[str]:
595
  if parsed.netloc:
596
  queries.append(f"site:{parsed.netloc} {base_query}")
597
 
598
- capitalized_terms = re.findall(r"\b[A-Z][\w.'-]*(?:\s+[A-Z][\w.'-]*){1,4}\b", question)
 
 
599
  if capitalized_terms:
600
  queries.append(" ".join(f'"{term}"' for term in capitalized_terms[:4]))
601
 
@@ -619,7 +712,9 @@ def build_research_queries(question: str, base_query: str) -> list[str]:
619
  return deduped[:5]
620
 
621
 
622
- def build_additional_research_queries(question: str, previous_queries: list[str], llm: ChatGroq) -> list[str]:
 
 
623
  messages = [
624
  SystemMessage(
625
  content=(
@@ -633,8 +728,7 @@ def build_additional_research_queries(question: str, previous_queries: list[str]
633
 
634
  try:
635
  raw = llm.invoke(messages).content
636
- except Exception as exc:
637
- logger.warning("Не удалось расширить поисковые запросы: %s: %s", type(exc).__name__, exc)
638
  return []
639
 
640
  previous = {query.lower() for query in previous_queries}
@@ -659,9 +753,17 @@ def build_research_context(question: str, base_query: str) -> str:
659
 
660
  for index, result in enumerate(results, 1):
661
  url = result["url"]
662
- parts.append(f"[{index}] {result['title']}\nURL: {url}\nSnippet: {result['body']}")
 
 
663
 
664
- if not url or url in seen_urls or not likely_relevant_url(url) or fetched_count >= 1 or total_fetched >= 4:
 
 
 
 
 
 
665
  continue
666
 
667
  seen_urls.add(url)
@@ -677,7 +779,9 @@ def build_research_context(question: str, base_query: str) -> str:
677
  return truncate_text("\n".join(parts), MAX_SEARCH_CONTEXT_CHARS)
678
 
679
 
680
- def extend_research_context(question: str, context: str, used_query: str, llm: ChatGroq) -> str:
 
 
681
  extra_queries = build_additional_research_queries(question, [used_query], llm)
682
  if not extra_queries:
683
  return context
@@ -692,8 +796,15 @@ def extend_research_context(question: str, context: str, used_query: str, llm: C
692
 
693
  for index, result in enumerate(results, 1):
694
  url = result["url"]
695
- parts.append(f"[{index}] {result['title']}\nURL: {url}\nSnippet: {result['body']}")
696
- if not url or url in seen_urls or not likely_relevant_url(url) or fetched_count >= 1:
 
 
 
 
 
 
 
697
  continue
698
 
699
  seen_urls.add(url)
@@ -726,36 +837,196 @@ def fetch_youtube_timedtext(video_id: str) -> str:
726
 
727
  for url in urls:
728
  try:
729
- response = requests.get(url, timeout=12, headers={"User-Agent": "GAIA-space-agent/1.0"})
 
 
730
  response.raise_for_status()
731
  chunks = re.findall(r"<text[^>]*>(.*?)</text>", response.text, flags=re.S)
732
  if not chunks:
733
  continue
734
- text = " ".join(html.unescape(re.sub(r"<[^>]+>", " ", chunk)) for chunk in chunks)
 
 
735
  text = re.sub(r"\s+", " ", text).strip()
736
  if text:
737
  return text
738
- except Exception as exc:
739
- logger.warning("Не удалось получить YouTube timedtext: %s: %s", type(exc).__name__, exc)
740
 
741
  return ""
742
 
743
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
744
  def build_youtube_context(question: str, video_id: str | None) -> str:
745
  queries = [question]
746
  quoted_phrases = re.findall(r'["“]([^"”]{3,120})["”]', question)
747
 
748
  if video_id:
749
- queries = [f'"{video_id}" transcript', f'"{video_id}" subtitles', f'"{video_id}"'] + queries
 
 
 
 
750
  queries.extend(f'"{video_id}" "{phrase}"' for phrase in quoted_phrases[:3])
751
  if quoted_phrases:
752
  queries.append(" ".join(f'"{phrase}"' for phrase in quoted_phrases[:3]))
753
 
754
  parts = [f"Question: {question}", f"YouTube video id: {video_id or 'unknown'}"]
755
- if video_id:
756
- transcript = fetch_youtube_timedtext(video_id)
757
- if transcript:
758
- parts.append(f"\n=== YouTube timedtext transcript ===\n{truncate_text(transcript, 8000)}")
759
 
760
  seen_urls: set[str] = set()
761
  total_fetched = 0
@@ -765,9 +1036,16 @@ def build_youtube_context(question: str, video_id: str | None) -> str:
765
 
766
  for index, result in enumerate(results, 1):
767
  url = result["url"]
768
- parts.append(f"[{index}] {result['title']}\nURL: {url}\nSnippet: {result['body']}")
 
 
769
  parsed = urlparse(url)
770
- if not url or url in seen_urls or parsed.scheme not in {"http", "https"} or total_fetched >= 3:
 
 
 
 
 
771
  continue
772
  if "youtube.com" in parsed.netloc or "youtu.be" in parsed.netloc:
773
  continue
@@ -858,20 +1136,20 @@ class AgentState(TypedDict, total=False):
858
 
859
  class BasicAgent:
860
  def __init__(self) -> None:
861
- self.answer_llm = make_chat_model(GROQ_TEXT_MODEL, max_tokens=192)
862
- self.final_llm = make_chat_model(GROQ_FINAL_MODEL, max_tokens=48)
863
- self.strong_llm = make_chat_model(GROQ_STRONG_MODEL, max_tokens=384)
864
- self.research_llm = make_chat_model(GROQ_RESEARCH_MODEL, max_tokens=384)
865
  self.graph = self.build_graph()
866
 
867
- logger.info("LangGraph-агент инициализирован.")
868
- logger.info("Текстовая модель: %s", GROQ_TEXT_MODEL)
869
- logger.info("Финальная модель: %s", GROQ_FINAL_MODEL)
870
- logger.info("Исследовательская модель: %s", GROQ_RESEARCH_MODEL)
871
- logger.info("Vision-модель: %s", GROQ_VISION_MODEL)
872
- logger.info("Audio-модель: %s", GROQ_AUDIO_MODEL)
873
- logger.info("Выполнение кода: %s", "включено" if ALLOW_CODE_EXECUTION else "отключено")
874
- logger.info("Повторный расширенный web-поиск: %s", "включен" if ENABLE_RESEARCH_RETRY else "отключен")
 
 
 
875
 
876
  def build_graph(self):
877
  graph = StateGraph(AgentState)
@@ -939,7 +1217,10 @@ class BasicAgent:
939
  else:
940
  route = "solve_research"
941
 
942
- logger.info("Маршрут задачи: file_kind=%s, route=%s, path=%s", file_kind, route, local_path)
 
 
 
943
  return {"file_kind": file_kind, "local_path": local_path, "route": route}
944
 
945
  def select_route(self, state: AgentState) -> str:
@@ -959,7 +1240,7 @@ class BasicAgent:
959
  question = state.get("question", "")
960
  task_id = state.get("task_id", "")
961
  context = analyze_image.invoke({"task_id": task_id, "question": question})
962
- raw_answer = self.answer_from_context(question, context, "Image analysis", self.strong_llm)
963
  return {"context": context, "raw_answer": raw_answer}
964
 
965
  def solve_audio(self, state: AgentState) -> dict[str, Any]:
@@ -967,14 +1248,18 @@ class BasicAgent:
967
  task_id = state.get("task_id", "")
968
  transcript = transcribe_audio.invoke({"task_id": task_id})
969
  context = f"Audio/video transcript:\n{transcript}"
970
- raw_answer = self.answer_from_context(question, context, "Audio transcript", self.answer_llm)
971
  return {"context": context, "raw_answer": raw_answer}
972
 
973
  def solve_spreadsheet(self, state: AgentState) -> dict[str, Any]:
974
  question = state.get("question", "")
975
  local_path = state.get("local_path")
976
  if not local_path:
977
- return {"raw_answer": error_text("для spreadsheet-маршрута не найден путь к файлу")}
 
 
 
 
978
 
979
  path = Path(local_path)
980
  context = read_spreadsheet_context(path)
@@ -986,7 +1271,6 @@ class BasicAgent:
986
  question,
987
  context,
988
  "Spreadsheet data and computed summary",
989
- self.strong_llm,
990
  )
991
  return {"context": context, "raw_answer": raw_answer}
992
 
@@ -994,20 +1278,33 @@ class BasicAgent:
994
  question = state.get("question", "")
995
  local_path = state.get("local_path")
996
  if not local_path:
997
- return {"raw_answer": error_text("для code-маршрута не найден путь к файлу")}
 
 
998
 
999
  path = Path(local_path)
1000
  code_context = read_code_context(path)
1001
- execution_context = run_python_file(path) if path.suffix.lower() == ".py" else "Выполнение пропущено: файл не Python."
 
 
 
 
1002
  context = f"{code_context}\n\n--- Execution result ---\n{execution_context}"
1003
 
1004
- if "final numeric output" in question.lower() and "STDOUT:" in execution_context:
1005
- stdout_block = execution_context.split("STDOUT:", 1)[1].split("STDERR:", 1)[0].strip()
 
 
 
 
 
1006
  candidate = last_nonempty_line(stdout_block)
1007
  if candidate and re.search(r"[-+]?\d", candidate):
1008
  return {"context": context, "raw_answer": candidate}
1009
 
1010
- raw_answer = self.answer_from_context(question, context, "Code and execution result", self.strong_llm)
 
 
1011
  return {"context": context, "raw_answer": raw_answer}
1012
 
1013
  def solve_direct(self, state: AgentState) -> dict[str, Any]:
@@ -1018,44 +1315,50 @@ class BasicAgent:
1018
 
1019
  direct_answer = solve_directly(question)
1020
  if direct_answer is not None:
1021
- return {"context": "Задача решена детерминированным Python-разбором.", "raw_answer": direct_answer}
 
 
 
1022
 
1023
  context = ""
1024
  if local_path and file_kind in {"pdf", "text", "binary"}:
1025
  context = read_text_file.invoke({"task_id": task_id})
1026
 
1027
- raw_answer = self.answer_from_context(question, context, f"Direct context; file_kind={file_kind}", self.answer_llm)
 
 
1028
  return {"context": context, "raw_answer": raw_answer}
1029
 
1030
  def solve_research(self, state: AgentState) -> dict[str, Any]:
1031
  question = state.get("question", "")
1032
  query = self.make_search_query(question)
1033
- logger.info("Поисковый запрос: %s", query)
1034
-
1035
  context = build_research_context(question, query)
1036
- logger.info("Размер поискового контекста: %s", len(context))
1037
 
1038
- raw_answer = self.answer_from_context(question, context, "Web research results", self.research_llm)
1039
  if is_bad_answer(raw_answer) and ENABLE_RESEARCH_RETRY:
1040
- context = extend_research_context(question, context, query, self.final_llm)
1041
- logger.info("Размер расширенного поискового контекста: %s", len(context))
1042
- raw_answer = self.answer_from_context(question, context, "Extended web research results", self.strong_llm)
 
1043
 
1044
  return {"context": context, "raw_answer": raw_answer}
1045
 
1046
  def solve_youtube(self, state: AgentState) -> dict[str, Any]:
1047
  question = state.get("question", "")
1048
  video_id = extract_youtube_id(question)
1049
- context = build_youtube_context(question, video_id)
1050
- raw_answer = self.answer_from_context(question, context, "YouTube/web transcript search results", self.research_llm)
 
 
 
 
1051
 
1052
  if is_bad_answer(raw_answer) and ENABLE_RESEARCH_RETRY:
1053
- context = extend_research_context(question, context, question, self.final_llm)
 
 
1054
  raw_answer = self.answer_from_context(
1055
- question,
1056
- context,
1057
- "Extended YouTube/web transcript search results",
1058
- self.strong_llm,
1059
  )
1060
 
1061
  return {"context": context, "raw_answer": raw_answer}
@@ -1070,10 +1373,18 @@ class BasicAgent:
1070
  if is_bad_answer(raw_answer):
1071
  return {"verified_answer": "", "error": raw_answer or "пустой ответ"}
1072
 
1073
- if route not in {"solve_research", "solve_youtube"} or file_kind in {"code", "spreadsheet", "audio"}:
 
 
 
 
1074
  return {"verified_answer": raw_answer}
1075
 
1076
- if "\n" not in raw_answer and len(raw_answer.split()) <= 12 and len(raw_answer) <= 120:
 
 
 
 
1077
  return {"verified_answer": raw_answer}
1078
 
1079
  messages = [
@@ -1095,9 +1406,8 @@ class BasicAgent:
1095
  ]
1096
 
1097
  try:
1098
- verified = self.final_llm.invoke(messages).content.strip()
1099
- except Exception as exc:
1100
- logger.warning("Проверка ответа не сработала: %s: %s", type(exc).__name__, exc)
1101
  verified = raw_answer
1102
 
1103
  if is_bad_answer(verified):
@@ -1106,20 +1416,34 @@ class BasicAgent:
1106
 
1107
  def final_cleaner(self, state: AgentState) -> dict[str, Any]:
1108
  question = state.get("question", "")
1109
- answer = clean_answer(state.get("verified_answer") or state.get("raw_answer") or "")
 
 
1110
 
1111
  if is_bad_answer(answer):
1112
- return {"final_answer": "", "error": state.get("error") or answer or "плохой ответ"}
 
 
 
1113
 
1114
  if "\n" in answer or len(answer.split()) > 12 or len(answer) > 120:
1115
  answer = self.extract_final_answer(question, answer)
1116
 
1117
  answer = clean_answer(answer)
1118
  if is_bad_answer(answer):
1119
- return {"final_answer": "", "error": state.get("error") or answer or "плохой ответ"}
 
 
 
1120
  return {"final_answer": answer}
1121
 
1122
- def answer_from_context(self, question: str, context: str, context_label: str, llm: ChatGroq) -> str:
 
 
 
 
 
 
1123
  system = (
1124
  "You answer GAIA benchmark questions.\n"
1125
  "Return ONLY the final answer: a number, name, word, date, or short phrase.\n"
@@ -1139,38 +1463,20 @@ class BasicAgent:
1139
  user = f"Question:\n{question}\n\nFinal answer only:"
1140
 
1141
  try:
1142
- return llm.invoke([SystemMessage(content=system), HumanMessage(content=user)]).content.strip()
 
 
 
1143
  except Exception as exc:
1144
- message = str(exc)
1145
- model_rejected_tools = (
1146
- "tool_use_failed" in message
1147
- or "Tool choice is none" in message
1148
- or "model called a tool" in message
1149
- )
1150
- if model_rejected_tools:
1151
- try:
1152
- logger.warning("LLM дала tool-use ошибку, повторяю через модель %s", GROQ_TEXT_MODEL)
1153
- return self.answer_llm.invoke([SystemMessage(content=system), HumanMessage(content=user)]).content.strip()
1154
- except Exception as fallback_exc:
1155
- return error_text("ошибка fallback-вызова LLM", fallback_exc)
1156
- if llm is not self.answer_llm:
1157
- try:
1158
- logger.warning("LLM-вызов не сработал, повторяю через легкую модель %s", GROQ_TEXT_MODEL)
1159
- fallback_user = (
1160
- f"Question:\n{question}\n\n"
1161
- f"{context_label}:\n{truncate_text(context, 4000)}\n\n"
1162
- "Final answer only:"
1163
- )
1164
- return self.answer_llm.invoke(
1165
- [SystemMessage(content=system), HumanMessage(content=fallback_user)]
1166
- ).content.strip()
1167
- except Exception as fallback_exc:
1168
- return error_text("ошибка fallback-вызова LLM", fallback_exc)
1169
  return error_text("ошибка вызова LLM", exc)
1170
 
1171
  def extract_final_answer(self, question: str, raw_answer: str) -> str:
1172
  raw_answer = clean_answer(raw_answer)
1173
- if "\n" not in raw_answer and len(raw_answer.split()) <= 12 and len(raw_answer) <= 120:
 
 
 
 
1174
  return raw_answer
1175
 
1176
  messages = [
@@ -1190,9 +1496,8 @@ class BasicAgent:
1190
  ]
1191
 
1192
  try:
1193
- return clean_answer(self.final_llm.invoke(messages).content.strip())
1194
- except Exception as exc:
1195
- logger.warning("Финальное извлечение ответа не сработало: %s: %s", type(exc).__name__, exc)
1196
  return clean_answer(last_nonempty_line(raw_answer))
1197
 
1198
  def make_search_query(self, question: str) -> str:
@@ -1211,9 +1516,9 @@ class BasicAgent:
1211
  return query[:220]
1212
 
1213
  def __call__(self, question: str, task_id: str = "") -> str:
1214
- logger.info("\n%s", "-" * 60)
1215
- logger.info("ID задачи: %s", task_id)
1216
- logger.info("Вопрос: %s", question[:160])
1217
 
1218
  try:
1219
  result = self.graph.invoke(
@@ -1223,10 +1528,12 @@ class BasicAgent:
1223
  answer = clean_answer(result.get("final_answer", ""))
1224
  if not answer:
1225
  answer = error_text(result.get("error", "финальный ответ не получен"))
1226
- logger.info("Итоговый ответ: %s", answer)
1227
  return answer
1228
  except Exception as exc:
1229
- logger.error("Агент завершился с ошибкой: %s: %s", type(exc).__name__, exc)
 
 
1230
  return error_text("агент завершился с ошибкой", exc)
1231
 
1232
 
@@ -1237,7 +1544,7 @@ def run_and_submit_all(profile: gr.OAuthProfile | None):
1237
  return "Сначала войдите в Hugging Face.", None
1238
 
1239
  username = profile.username
1240
- logger.info("Пользователь HF: %s", username)
1241
 
1242
  try:
1243
  agent = BasicAgent()
@@ -1246,13 +1553,15 @@ def run_and_submit_all(profile: gr.OAuthProfile | None):
1246
 
1247
  questions_url = f"{DEFAULT_API_URL}/questions"
1248
  submit_url = f"{DEFAULT_API_URL}/submit"
1249
- agent_code = f"https://huggingface.co/spaces/{space_id}/tree/main" if space_id else ""
 
 
1250
 
1251
  try:
1252
  response = requests.get(questions_url, timeout=20)
1253
  response.raise_for_status()
1254
  questions_data = response.json()
1255
- logger.info("Получено вопросов: %s", len(questions_data))
1256
  except Exception as exc:
1257
  return error_text("не удалось получить список вопросов", exc), None
1258
 
@@ -1267,20 +1576,29 @@ def run_and_submit_all(profile: gr.OAuthProfile | None):
1267
 
1268
  try:
1269
  answer = agent(question_text, task_id=task_id)
1270
- results_log.append({"ID задачи": task_id, "Вопрос": question_text[:120], "Ответ": answer})
 
 
1271
 
1272
  if answer and not is_bad_answer(answer):
1273
  answers_payload.append({"task_id": task_id, "submitted_answer": answer})
1274
  else:
1275
- logger.info("Ответ не отправлен для task_id=%s: %s", task_id, answer)
1276
  except Exception as exc:
1277
  answer = error_text("ошибка обработки вопроса", exc)
1278
- results_log.append({"ID задачи": task_id, "Вопрос": question_text[:120], "Ответ": answer})
1279
- logger.error("Ошибка вопроса task_id=%s: %s: %s", task_id, type(exc).__name__, exc)
 
 
 
 
 
1280
  time.sleep(1)
1281
 
1282
  if not answers_payload:
1283
- return "Агент не подготовил ни одного ответа для отправки.", pd.DataFrame(results_log)
 
 
1284
 
1285
  payload = {
1286
  "username": username.strip(),
@@ -1316,7 +1634,9 @@ with gr.Blocks(title="GAIA LangGraph Agent") as demo:
1316
  if oauth_available:
1317
  gr.LoginButton()
1318
  else:
1319
- gr.Markdown("OAuth Hugging Face недоступен вне Space. Для сабмита нужен вход в HF.")
 
 
1320
 
1321
  run_button = gr.Button("Запустить оценку и отправить ответы")
1322
  status_output = gr.Textbox(label="Статус", lines=6, interactive=False)
@@ -1325,10 +1645,10 @@ with gr.Blocks(title="GAIA LangGraph Agent") as demo:
1325
  run_button.click(fn=run_and_submit_all, outputs=[status_output, results_table])
1326
 
1327
  if space_host_startup:
1328
- logger.info("SPACE_HOST найден: %s", space_host_startup)
1329
  if space_id_startup:
1330
- logger.info("SPACE_ID найден: %s", space_id_startup)
1331
 
1332
  if __name__ == "__main__":
1333
- logger.info("Запускаю Gradio-интерфейс LangGraph-агента.")
1334
  demo.launch(debug=os.getenv("GRADIO_DEBUG", "0") == "1", share=False)
 
7
  import re
8
  import subprocess
9
  import sys
10
+ import tempfile
11
  import time
12
  from functools import lru_cache
13
  from pathlib import Path
 
30
  ERROR_PREFIX = "ОШИБКА:"
31
 
32
  GROQ_TEXT_MODEL = os.getenv("GROQ_TEXT_MODEL", "llama-3.1-8b-instant")
33
+ GROQ_VISION_MODEL = os.getenv(
34
+ "GROQ_VISION_MODEL", "meta-llama/llama-4-scout-17b-16e-instruct"
35
+ )
 
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 {
40
+ "0",
41
+ "false",
42
+ "no",
43
+ }
44
+ ENABLE_RESEARCH_RETRY = os.getenv("ENABLE_RESEARCH_RETRY", "0").lower() in {
45
+ "1",
46
+ "true",
47
+ "yes",
48
+ }
49
 
50
  MAX_CONTEXT_CHARS = int(os.getenv("MAX_CONTEXT_CHARS", "9000"))
51
  MAX_SEARCH_CONTEXT_CHARS = int(os.getenv("MAX_SEARCH_CONTEXT_CHARS", "7000"))
52
  SEARCH_RESULTS_PER_QUERY = int(os.getenv("SEARCH_RESULTS_PER_QUERY", "4"))
53
  SEARCH_FETCH_LIMIT = int(os.getenv("SEARCH_FETCH_LIMIT", "2200"))
54
+ YOUTUBE_MAX_BYTES = int(os.getenv("YOUTUBE_MAX_BYTES", str(24 * 1024 * 1024)))
55
+ YOUTUBE_FRAME_COUNT = int(os.getenv("YOUTUBE_FRAME_COUNT", "8"))
56
 
57
  IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
58
+ AUDIO_VIDEO_EXTS = {
59
+ ".mp3",
60
+ ".wav",
61
+ ".m4a",
62
+ ".flac",
63
+ ".ogg",
64
+ ".webm",
65
+ ".mp4",
66
+ ".mov",
67
+ ".mkv",
68
+ }
69
  SPREADSHEET_EXTS = {".xlsx", ".xls"}
70
  PDF_EXTS = {".pdf"}
71
  CODE_EXTS = {".py", ".js", ".ts", ".java", ".cpp", ".c", ".rb", ".go", ".rs"}
72
+ TEXT_EXTS = {
73
+ ".txt",
74
+ ".md",
75
+ ".csv",
76
+ ".json",
77
+ ".xml",
78
+ ".html",
79
+ ".htm",
80
+ ".yaml",
81
+ ".yml",
82
+ } | CODE_EXTS
83
+
84
+ for noisy_logger in (
85
+ "openai",
86
+ "groq",
87
+ "httpx",
88
+ "httpcore",
89
+ "ddgs",
90
+ "duckduckgo_search",
91
+ "primp",
92
+ ):
93
+ logging.getLogger(noisy_logger).setLevel(logging.ERROR)
94
 
95
 
96
  def error_text(message: str, exc: Exception | None = None) -> str:
 
119
  validation_dir = Path(GAIA_DIR) / "2023" / "validation"
120
 
121
  if not validation_dir.exists():
122
+ print(
123
+ f"Папка с validation-файлами GAIA не найдена: {validation_dir}", flush=True
124
+ )
125
  return result
126
 
127
  for path in validation_dir.rglob("*"):
128
  if path.is_file() and path.suffix.lower() != ".parquet":
129
  result[path.stem] = str(path)
130
 
 
131
  return result
132
 
133
 
 
220
  return error_text(f"не удалось открыть изображение для task_id={task_id}", exc)
221
 
222
  if not is_image(content_type, data):
223
+ return error_text(
224
+ f"файл task_id={task_id} не похож на изображение, content_type={content_type}"
225
+ )
226
 
227
+ prompt = (
228
+ question
229
+ or "Describe the image. Extract all visible text, numbers, symbols, and key details."
230
+ )
231
  if "chess" in prompt.lower():
232
  prompt += (
233
  "\n\nThis is a chess task. Identify board coordinates, side to move, relevant pieces, "
 
261
  return error_text("ошибка vision-модели", exc)
262
 
263
 
264
+ def transcribe_media_bytes(data: bytes, filename: str, content_type: str) -> str:
265
+ client = get_groq_client()
266
+ audio_file = (
267
+ filename,
268
+ io.BytesIO(data),
269
+ content_type or "application/octet-stream",
270
+ )
271
+ transcription = client.audio.transcriptions.create(
272
+ file=audio_file,
273
+ model=GROQ_AUDIO_MODEL,
274
+ response_format="text",
275
+ )
276
+ return str(transcription).strip()
277
+
278
+
279
+ def transcribe_media_file(path: Path) -> str:
280
+ content_type, _ = mimetypes.guess_type(str(path))
281
+ return transcribe_media_bytes(
282
+ path.read_bytes(),
283
+ path.name,
284
+ content_type or "application/octet-stream",
285
+ )
286
+
287
+
288
  @tool
289
  def transcribe_audio(task_id: str) -> str:
290
  """Transcribe the GAIA audio or video file attached to a task and return the transcript."""
 
292
  data, content_type = fetch_task_bytes(task_id)
293
  local_path = get_task_file(task_id) or ""
294
  except Exception as exc:
295
+ return error_text(
296
+ f"не удалось открыть аудио или видео для task_id={task_id}", exc
297
+ )
298
 
299
  if not is_audio_or_video(content_type, local_path):
300
+ return error_text(
301
+ f"файл task_id={task_id} не похож на аудио или видео, content_type={content_type}"
302
+ )
 
 
303
 
304
  try:
305
+ filename = Path(local_path).name or "audio.mp3"
306
+ return transcribe_media_bytes(data, filename, content_type)
 
 
 
 
 
 
307
  except Exception as exc:
308
  return error_text("ошибка транскрибации аудио", exc)
309
 
 
349
  try:
350
  text = page.extract_text() or ""
351
  except Exception as exc:
352
+ text = (
353
+ f"[ошибка извлечения текста со страницы: {type(exc).__name__}: {exc}]"
354
+ )
355
  parts.append(f"\n--- Page {index} ---\n{text}")
356
  if len("\n".join(parts)) > MAX_CONTEXT_CHARS:
357
  break
 
409
  try:
410
  df = pd.read_excel(path, sheet_name=sheet_name)
411
  except Exception as exc:
412
+ parts.append(
413
+ f"Sheet: {sheet_name}\n[ошибка чтения листа: {type(exc).__name__}: {exc}]"
414
+ )
415
  continue
416
  if df.empty:
417
  continue
418
 
419
  work = df.copy()
420
  work.columns = [str(column).strip() for column in work.columns]
421
+ numeric_cols = [
422
+ column
423
+ for column in work.columns
424
+ if pd.api.types.is_numeric_dtype(work[column])
425
+ ]
426
  categorical_cols = [
427
  column
428
  for column in work.columns
 
431
 
432
  parts.append(f"Sheet: {sheet_name}")
433
  if numeric_cols:
434
+ totals = (
435
+ work[numeric_cols].sum(numeric_only=True).sort_values(ascending=False)
436
+ )
437
  parts.append("Numeric column totals:")
438
  parts.append(totals.to_string())
439
 
440
  for category_col in categorical_cols[:6]:
441
  if not numeric_cols:
442
  break
443
+ grouped = work.groupby(category_col, dropna=False)[numeric_cols].sum(
444
+ numeric_only=True
445
+ )
446
  if not grouped.empty:
447
  parts.append(f"Totals grouped by {category_col}:")
448
  parts.append(grouped.head(40).to_csv())
 
528
  def ddg_search(query: str, max_results: int = 5) -> list[dict[str, str]]:
529
  try:
530
  results = DDGS().text(query, max_results=max_results)
531
+ except Exception:
 
532
  return []
533
 
534
  normalized: list[dict[str, str]] = []
 
552
  return None
553
 
554
  reversed_text = question[::-1]
555
+ match = re.search(
556
+ r'opposite of the word ["“”\']?([A-Za-z]+)["“”\']?', reversed_text, flags=re.I
557
+ )
558
  if not match:
559
  return None
560
 
 
575
 
576
  def solve_commutativity_table(question: str) -> str | None:
577
  lower = question.lower()
578
+ if "|---" not in question or (
579
+ "commutative" not in lower and "commutativity" not in lower
580
+ ):
581
  return None
582
 
583
+ lines = [
584
+ line.strip() for line in question.splitlines() if line.strip().startswith("|")
585
+ ]
586
  if len(lines) < 3:
587
  return None
588
 
 
602
  continue
603
  left_right = table.get(left, {}).get(right)
604
  right_left = table.get(right, {}).get(left)
605
+ if (
606
+ left_right is not None
607
+ and right_left is not None
608
+ and left_right != right_left
609
+ ):
610
  counterexample_elements.update({left, right})
611
 
612
  if counterexample_elements:
 
616
 
617
  def solve_botany_grocery_list(question: str) -> str | None:
618
  lower = question.lower()
619
+ if (
620
+ "grocery list" not in lower
621
+ or "vegetables" not in lower
622
+ or "botanical fruits" not in lower
623
+ ):
624
  return None
625
 
626
  match = re.search(
 
630
  if not match:
631
  return None
632
 
633
+ items = [
634
+ re.sub(r"\s+", " ", item).strip(" .") for item in match.group(1).split(",")
635
+ ]
636
  vegetable_names = {
637
  "broccoli",
638
  "cabbage",
 
666
  return (
667
  is_reversed_english_task(question)
668
  or ("|---" in question and ("commutative" in lower or "commutativity" in lower))
669
+ or (
670
+ "grocery list" in lower
671
+ and "botanical fruits" in lower
672
+ and "vegetables" in lower
673
+ )
674
  )
675
 
676
 
 
686
  if parsed.netloc:
687
  queries.append(f"site:{parsed.netloc} {base_query}")
688
 
689
+ capitalized_terms = re.findall(
690
+ r"\b[A-Z][\w.'-]*(?:\s+[A-Z][\w.'-]*){1,4}\b", question
691
+ )
692
  if capitalized_terms:
693
  queries.append(" ".join(f'"{term}"' for term in capitalized_terms[:4]))
694
 
 
712
  return deduped[:5]
713
 
714
 
715
+ def build_additional_research_queries(
716
+ question: str, previous_queries: list[str], llm: ChatGroq
717
+ ) -> list[str]:
718
  messages = [
719
  SystemMessage(
720
  content=(
 
728
 
729
  try:
730
  raw = llm.invoke(messages).content
731
+ except Exception:
 
732
  return []
733
 
734
  previous = {query.lower() for query in previous_queries}
 
753
 
754
  for index, result in enumerate(results, 1):
755
  url = result["url"]
756
+ parts.append(
757
+ f"[{index}] {result['title']}\nURL: {url}\nSnippet: {result['body']}"
758
+ )
759
 
760
+ if (
761
+ not url
762
+ or url in seen_urls
763
+ or not likely_relevant_url(url)
764
+ or fetched_count >= 1
765
+ or total_fetched >= 4
766
+ ):
767
  continue
768
 
769
  seen_urls.add(url)
 
779
  return truncate_text("\n".join(parts), MAX_SEARCH_CONTEXT_CHARS)
780
 
781
 
782
+ def extend_research_context(
783
+ question: str, context: str, used_query: str, llm: ChatGroq
784
+ ) -> str:
785
  extra_queries = build_additional_research_queries(question, [used_query], llm)
786
  if not extra_queries:
787
  return context
 
796
 
797
  for index, result in enumerate(results, 1):
798
  url = result["url"]
799
+ parts.append(
800
+ f"[{index}] {result['title']}\nURL: {url}\nSnippet: {result['body']}"
801
+ )
802
+ if (
803
+ not url
804
+ or url in seen_urls
805
+ or not likely_relevant_url(url)
806
+ or fetched_count >= 1
807
+ ):
808
  continue
809
 
810
  seen_urls.add(url)
 
837
 
838
  for url in urls:
839
  try:
840
+ response = requests.get(
841
+ url, timeout=12, headers={"User-Agent": "GAIA-space-agent/1.0"}
842
+ )
843
  response.raise_for_status()
844
  chunks = re.findall(r"<text[^>]*>(.*?)</text>", response.text, flags=re.S)
845
  if not chunks:
846
  continue
847
+ text = " ".join(
848
+ html.unescape(re.sub(r"<[^>]+>", " ", chunk)) for chunk in chunks
849
+ )
850
  text = re.sub(r"\s+", " ", text).strip()
851
  if text:
852
  return text
853
+ except Exception:
854
+ pass
855
 
856
  return ""
857
 
858
 
859
+ def download_youtube_media(
860
+ video_id: str, target_dir: Path, kind: str
861
+ ) -> tuple[Path | None, str]:
862
+ url = f"https://www.youtube.com/watch?v={video_id}"
863
+ fmt = "worstaudio/worst" if kind == "audio" else "worst[ext=mp4]/worst"
864
+ output_template = str(target_dir / f"{kind}.%(ext)s")
865
+
866
+ try:
867
+ from yt_dlp import YoutubeDL
868
+ except ImportError:
869
+ return None, f"{kind}: yt-dlp не установлен"
870
+
871
+ try:
872
+ with YoutubeDL(
873
+ {
874
+ "format": fmt,
875
+ "outtmpl": output_template,
876
+ "noplaylist": True,
877
+ "quiet": True,
878
+ "no_warnings": True,
879
+ "max_filesize": YOUTUBE_MAX_BYTES,
880
+ "socket_timeout": 20,
881
+ "retries": 1,
882
+ }
883
+ ) as downloader:
884
+ downloader.download([url])
885
+ except Exception as exc:
886
+ return None, f"{kind}: yt-dlp не смог скачать видео ({type(exc).__name__})"
887
+
888
+ files = sorted(
889
+ target_dir.glob(f"{kind}.*"),
890
+ key=lambda path: path.stat().st_size if path.exists() else 0,
891
+ reverse=True,
892
+ )
893
+ return (files[0], "") if files else (None, f"{kind}: файл не был скачан")
894
+
895
+
896
+ def extract_video_frames(
897
+ video_path: Path, frame_dir: Path, frame_count: int = YOUTUBE_FRAME_COUNT
898
+ ) -> list[Path]:
899
+ frame_dir.mkdir(parents=True, exist_ok=True)
900
+ try:
901
+ subprocess.run(
902
+ [
903
+ "ffmpeg",
904
+ "-y",
905
+ "-i",
906
+ str(video_path),
907
+ "-vf",
908
+ "fps=1/10,scale=640:-1",
909
+ "-frames:v",
910
+ str(frame_count),
911
+ str(frame_dir / "frame_%03d.jpg"),
912
+ ],
913
+ capture_output=True,
914
+ text=True,
915
+ timeout=45,
916
+ check=False,
917
+ )
918
+ except Exception:
919
+ return []
920
+
921
+ return sorted(frame_dir.glob("frame_*.jpg"))[:frame_count]
922
+
923
+
924
+ def analyze_video_frames(frame_paths: list[Path], question: str) -> str:
925
+ if not frame_paths:
926
+ return ""
927
+
928
+ content: list[dict[str, Any]] = [
929
+ {
930
+ "type": "text",
931
+ "text": (
932
+ "These are sampled frames from a YouTube video. "
933
+ "Extract only visual evidence useful for answering the question. "
934
+ "Mention counts, visible species/objects, text, actions, and timestamps implied by frame order.\n\n"
935
+ f"Question: {question}"
936
+ ),
937
+ }
938
+ ]
939
+
940
+ for index, frame_path in enumerate(frame_paths, 1):
941
+ data = frame_path.read_bytes()
942
+ content.append({"type": "text", "text": f"Frame {index}:"})
943
+ content.append(
944
+ {
945
+ "type": "image_url",
946
+ "image_url": {
947
+ "url": f"data:{image_mime(data, 'image/jpeg')};base64,"
948
+ f"{base64.standard_b64encode(data).decode('utf-8')}"
949
+ },
950
+ }
951
+ )
952
+
953
+ try:
954
+ response = get_groq_client().chat.completions.create(
955
+ model=GROQ_VISION_MODEL,
956
+ messages=[{"role": "user", "content": content}],
957
+ temperature=0,
958
+ max_tokens=768,
959
+ )
960
+ return response.choices[0].message.content.strip()
961
+ except Exception as exc:
962
+ return error_text("ошибка vision-декодирования видео", exc)
963
+
964
+
965
+ @tool
966
+ def decode_youtube_video(question: str) -> str:
967
+ """Decode a YouTube video with audio and vision models, returning transcript and visual observations."""
968
+ video_id = extract_youtube_id(question)
969
+ if not video_id:
970
+ return error_text("в вопросе не найден YouTube video id")
971
+
972
+ parts = [f"Question: {question}", f"YouTube video id: {video_id}"]
973
+ timedtext = fetch_youtube_timedtext(video_id)
974
+ if timedtext:
975
+ parts.append(f"Timedtext transcript:\n{truncate_text(timedtext, 5000)}")
976
+
977
+ notes: list[str] = []
978
+ with tempfile.TemporaryDirectory() as temp_dir:
979
+ temp_path = Path(temp_dir)
980
+
981
+ audio_path, audio_note = download_youtube_media(video_id, temp_path, "audio")
982
+ if audio_path:
983
+ try:
984
+ transcript = transcribe_media_file(audio_path)
985
+ if transcript and not is_bad_answer(transcript):
986
+ parts.append(
987
+ f"Audio model transcript:\n{truncate_text(transcript, 6000)}"
988
+ )
989
+ except Exception as exc:
990
+ notes.append(f"audio: {type(exc).__name__}")
991
+ elif audio_note:
992
+ notes.append(audio_note)
993
+
994
+ video_path, video_note = download_youtube_media(video_id, temp_path, "video")
995
+ if video_path:
996
+ frames = extract_video_frames(video_path, temp_path / "frames")
997
+ frame_summary = analyze_video_frames(frames, question)
998
+ if frame_summary and not is_bad_answer(frame_summary):
999
+ parts.append(
1000
+ f"Vision model frame analysis:\n{truncate_text(frame_summary, 3000)}"
1001
+ )
1002
+ elif video_note:
1003
+ notes.append(video_note)
1004
+
1005
+ if len(parts) == 2:
1006
+ return error_text(
1007
+ "; ".join(notes) if notes else "не удалось декодировать YouTube-видео"
1008
+ )
1009
+
1010
+ if notes:
1011
+ parts.append("Decode notes: " + "; ".join(notes[:2]))
1012
+ return truncate_text("\n\n".join(parts), MAX_CONTEXT_CHARS)
1013
+
1014
+
1015
  def build_youtube_context(question: str, video_id: str | None) -> str:
1016
  queries = [question]
1017
  quoted_phrases = re.findall(r'["“]([^"”]{3,120})["”]', question)
1018
 
1019
  if video_id:
1020
+ queries = [
1021
+ f'"{video_id}" transcript',
1022
+ f'"{video_id}" subtitles',
1023
+ f'"{video_id}"',
1024
+ ] + queries
1025
  queries.extend(f'"{video_id}" "{phrase}"' for phrase in quoted_phrases[:3])
1026
  if quoted_phrases:
1027
  queries.append(" ".join(f'"{phrase}"' for phrase in quoted_phrases[:3]))
1028
 
1029
  parts = [f"Question: {question}", f"YouTube video id: {video_id or 'unknown'}"]
 
 
 
 
1030
 
1031
  seen_urls: set[str] = set()
1032
  total_fetched = 0
 
1036
 
1037
  for index, result in enumerate(results, 1):
1038
  url = result["url"]
1039
+ parts.append(
1040
+ f"[{index}] {result['title']}\nURL: {url}\nSnippet: {result['body']}"
1041
+ )
1042
  parsed = urlparse(url)
1043
+ if (
1044
+ not url
1045
+ or url in seen_urls
1046
+ or parsed.scheme not in {"http", "https"}
1047
+ or total_fetched >= 3
1048
+ ):
1049
  continue
1050
  if "youtube.com" in parsed.netloc or "youtu.be" in parsed.netloc:
1051
  continue
 
1136
 
1137
  class BasicAgent:
1138
  def __init__(self) -> None:
1139
+ self.text_llm = make_chat_model(GROQ_TEXT_MODEL, max_tokens=384)
 
 
 
1140
  self.graph = self.build_graph()
1141
 
1142
+ print("LangGraph-агент инициализирован.", flush=True)
1143
+ print(
1144
+ f"Модели: text={GROQ_TEXT_MODEL}; vision={GROQ_VISION_MODEL}; audio={GROQ_AUDIO_MODEL}",
1145
+ flush=True,
1146
+ )
1147
+ print(
1148
+ "Выполнение кода: "
1149
+ f"{'включено' if ALLOW_CODE_EXECUTION else 'отключено'}; "
1150
+ f"повторный web-поиск: {'включен' if ENABLE_RESEARCH_RETRY else 'отключен'}",
1151
+ flush=True,
1152
+ )
1153
 
1154
  def build_graph(self):
1155
  graph = StateGraph(AgentState)
 
1217
  else:
1218
  route = "solve_research"
1219
 
1220
+ print(
1221
+ f"Маршрут задачи: file_kind={file_kind}, route={route}, path={local_path}",
1222
+ flush=True,
1223
+ )
1224
  return {"file_kind": file_kind, "local_path": local_path, "route": route}
1225
 
1226
  def select_route(self, state: AgentState) -> str:
 
1240
  question = state.get("question", "")
1241
  task_id = state.get("task_id", "")
1242
  context = analyze_image.invoke({"task_id": task_id, "question": question})
1243
+ raw_answer = self.answer_from_context(question, context, "Image analysis")
1244
  return {"context": context, "raw_answer": raw_answer}
1245
 
1246
  def solve_audio(self, state: AgentState) -> dict[str, Any]:
 
1248
  task_id = state.get("task_id", "")
1249
  transcript = transcribe_audio.invoke({"task_id": task_id})
1250
  context = f"Audio/video transcript:\n{transcript}"
1251
+ raw_answer = self.answer_from_context(question, context, "Audio transcript")
1252
  return {"context": context, "raw_answer": raw_answer}
1253
 
1254
  def solve_spreadsheet(self, state: AgentState) -> dict[str, Any]:
1255
  question = state.get("question", "")
1256
  local_path = state.get("local_path")
1257
  if not local_path:
1258
+ return {
1259
+ "raw_answer": error_text(
1260
+ "для spreadsheet-маршрута не найден путь к файлу"
1261
+ )
1262
+ }
1263
 
1264
  path = Path(local_path)
1265
  context = read_spreadsheet_context(path)
 
1271
  question,
1272
  context,
1273
  "Spreadsheet data and computed summary",
 
1274
  )
1275
  return {"context": context, "raw_answer": raw_answer}
1276
 
 
1278
  question = state.get("question", "")
1279
  local_path = state.get("local_path")
1280
  if not local_path:
1281
+ return {
1282
+ "raw_answer": error_text("для code-маршрута не найден путь к файлу")
1283
+ }
1284
 
1285
  path = Path(local_path)
1286
  code_context = read_code_context(path)
1287
+ execution_context = (
1288
+ run_python_file(path)
1289
+ if path.suffix.lower() == ".py"
1290
+ else "Выполнение пропущено: файл не Python."
1291
+ )
1292
  context = f"{code_context}\n\n--- Execution result ---\n{execution_context}"
1293
 
1294
+ if (
1295
+ "final numeric output" in question.lower()
1296
+ and "STDOUT:" in execution_context
1297
+ ):
1298
+ stdout_block = (
1299
+ execution_context.split("STDOUT:", 1)[1].split("STDERR:", 1)[0].strip()
1300
+ )
1301
  candidate = last_nonempty_line(stdout_block)
1302
  if candidate and re.search(r"[-+]?\d", candidate):
1303
  return {"context": context, "raw_answer": candidate}
1304
 
1305
+ raw_answer = self.answer_from_context(
1306
+ question, context, "Code and execution result"
1307
+ )
1308
  return {"context": context, "raw_answer": raw_answer}
1309
 
1310
  def solve_direct(self, state: AgentState) -> dict[str, Any]:
 
1315
 
1316
  direct_answer = solve_directly(question)
1317
  if direct_answer is not None:
1318
+ return {
1319
+ "context": "Задача решена детерминированным Python-разбором.",
1320
+ "raw_answer": direct_answer,
1321
+ }
1322
 
1323
  context = ""
1324
  if local_path and file_kind in {"pdf", "text", "binary"}:
1325
  context = read_text_file.invoke({"task_id": task_id})
1326
 
1327
+ raw_answer = self.answer_from_context(
1328
+ question, context, f"Direct context; file_kind={file_kind}"
1329
+ )
1330
  return {"context": context, "raw_answer": raw_answer}
1331
 
1332
  def solve_research(self, state: AgentState) -> dict[str, Any]:
1333
  question = state.get("question", "")
1334
  query = self.make_search_query(question)
 
 
1335
  context = build_research_context(question, query)
 
1336
 
1337
+ raw_answer = self.answer_from_context(question, context, "Web research results")
1338
  if is_bad_answer(raw_answer) and ENABLE_RESEARCH_RETRY:
1339
+ context = extend_research_context(question, context, query, self.text_llm)
1340
+ raw_answer = self.answer_from_context(
1341
+ question, context, "Extended web research results"
1342
+ )
1343
 
1344
  return {"context": context, "raw_answer": raw_answer}
1345
 
1346
  def solve_youtube(self, state: AgentState) -> dict[str, Any]:
1347
  question = state.get("question", "")
1348
  video_id = extract_youtube_id(question)
1349
+ decoded_context = decode_youtube_video.invoke({"question": question})
1350
+ web_context = build_youtube_context(question, video_id)
1351
+ context = f"{decoded_context}\n\n--- Web context ---\n{web_context}"
1352
+ raw_answer = self.answer_from_context(
1353
+ question, context, "Decoded YouTube video and web context"
1354
+ )
1355
 
1356
  if is_bad_answer(raw_answer) and ENABLE_RESEARCH_RETRY:
1357
+ context = extend_research_context(
1358
+ question, context, question, self.text_llm
1359
+ )
1360
  raw_answer = self.answer_from_context(
1361
+ question, context, "Extended YouTube/video context"
 
 
 
1362
  )
1363
 
1364
  return {"context": context, "raw_answer": raw_answer}
 
1373
  if is_bad_answer(raw_answer):
1374
  return {"verified_answer": "", "error": raw_answer or "пустой ответ"}
1375
 
1376
+ if route not in {"solve_research", "solve_youtube"} or file_kind in {
1377
+ "code",
1378
+ "spreadsheet",
1379
+ "audio",
1380
+ }:
1381
  return {"verified_answer": raw_answer}
1382
 
1383
+ if (
1384
+ "\n" not in raw_answer
1385
+ and len(raw_answer.split()) <= 12
1386
+ and len(raw_answer) <= 120
1387
+ ):
1388
  return {"verified_answer": raw_answer}
1389
 
1390
  messages = [
 
1406
  ]
1407
 
1408
  try:
1409
+ verified = self.text_llm.invoke(messages).content.strip()
1410
+ except Exception:
 
1411
  verified = raw_answer
1412
 
1413
  if is_bad_answer(verified):
 
1416
 
1417
  def final_cleaner(self, state: AgentState) -> dict[str, Any]:
1418
  question = state.get("question", "")
1419
+ answer = clean_answer(
1420
+ state.get("verified_answer") or state.get("raw_answer") or ""
1421
+ )
1422
 
1423
  if is_bad_answer(answer):
1424
+ return {
1425
+ "final_answer": "",
1426
+ "error": state.get("error") or answer or "плохой ответ",
1427
+ }
1428
 
1429
  if "\n" in answer or len(answer.split()) > 12 or len(answer) > 120:
1430
  answer = self.extract_final_answer(question, answer)
1431
 
1432
  answer = clean_answer(answer)
1433
  if is_bad_answer(answer):
1434
+ return {
1435
+ "final_answer": "",
1436
+ "error": state.get("error") or answer or "плохой ответ",
1437
+ }
1438
  return {"final_answer": answer}
1439
 
1440
+ def answer_from_context(
1441
+ self,
1442
+ question: str,
1443
+ context: str,
1444
+ context_label: str,
1445
+ llm: ChatGroq | None = None,
1446
+ ) -> str:
1447
  system = (
1448
  "You answer GAIA benchmark questions.\n"
1449
  "Return ONLY the final answer: a number, name, word, date, or short phrase.\n"
 
1463
  user = f"Question:\n{question}\n\nFinal answer only:"
1464
 
1465
  try:
1466
+ llm = llm or self.text_llm
1467
+ return llm.invoke(
1468
+ [SystemMessage(content=system), HumanMessage(content=user)]
1469
+ ).content.strip()
1470
  except Exception as exc:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1471
  return error_text("ошибка вызова LLM", exc)
1472
 
1473
  def extract_final_answer(self, question: str, raw_answer: str) -> str:
1474
  raw_answer = clean_answer(raw_answer)
1475
+ if (
1476
+ "\n" not in raw_answer
1477
+ and len(raw_answer.split()) <= 12
1478
+ and len(raw_answer) <= 120
1479
+ ):
1480
  return raw_answer
1481
 
1482
  messages = [
 
1496
  ]
1497
 
1498
  try:
1499
+ return clean_answer(self.text_llm.invoke(messages).content.strip())
1500
+ except Exception:
 
1501
  return clean_answer(last_nonempty_line(raw_answer))
1502
 
1503
  def make_search_query(self, question: str) -> str:
 
1516
  return query[:220]
1517
 
1518
  def __call__(self, question: str, task_id: str = "") -> str:
1519
+ print(f"\n{'-' * 60}", flush=True)
1520
+ print(f"ID задачи: {task_id}", flush=True)
1521
+ print(f"Вопрос: {question[:160]}", flush=True)
1522
 
1523
  try:
1524
  result = self.graph.invoke(
 
1528
  answer = clean_answer(result.get("final_answer", ""))
1529
  if not answer:
1530
  answer = error_text(result.get("error", "финальный ответ не получен"))
1531
+ print(f"Итоговый ответ: {answer}", flush=True)
1532
  return answer
1533
  except Exception as exc:
1534
+ print(
1535
+ f"Агент завершился с ошибкой: {type(exc).__name__}: {exc}", flush=True
1536
+ )
1537
  return error_text("агент завершился с ошибкой", exc)
1538
 
1539
 
 
1544
  return "Сначала войдите в Hugging Face.", None
1545
 
1546
  username = profile.username
1547
+ print(f"Пользователь HF: {username}", flush=True)
1548
 
1549
  try:
1550
  agent = BasicAgent()
 
1553
 
1554
  questions_url = f"{DEFAULT_API_URL}/questions"
1555
  submit_url = f"{DEFAULT_API_URL}/submit"
1556
+ agent_code = (
1557
+ f"https://huggingface.co/spaces/{space_id}/tree/main" if space_id else ""
1558
+ )
1559
 
1560
  try:
1561
  response = requests.get(questions_url, timeout=20)
1562
  response.raise_for_status()
1563
  questions_data = response.json()
1564
+ print(f"Получено вопросов: {len(questions_data)}", flush=True)
1565
  except Exception as exc:
1566
  return error_text("не удалось получить список вопросов", exc), None
1567
 
 
1576
 
1577
  try:
1578
  answer = agent(question_text, task_id=task_id)
1579
+ results_log.append(
1580
+ {"ID задачи": task_id, "Вопрос": question_text[:120], "Ответ": answer}
1581
+ )
1582
 
1583
  if answer and not is_bad_answer(answer):
1584
  answers_payload.append({"task_id": task_id, "submitted_answer": answer})
1585
  else:
1586
+ print(f"Ответ не отправлен для task_id={task_id}: {answer}", flush=True)
1587
  except Exception as exc:
1588
  answer = error_text("ошибка обработки вопроса", exc)
1589
+ results_log.append(
1590
+ {"ID задачи": task_id, "Вопрос": question_text[:120], "Ответ": answer}
1591
+ )
1592
+ print(
1593
+ f"Ошибка вопроса task_id={task_id}: {type(exc).__name__}: {exc}",
1594
+ flush=True,
1595
+ )
1596
  time.sleep(1)
1597
 
1598
  if not answers_payload:
1599
+ return "Агент не подготовил ни одного ответа для отправки.", pd.DataFrame(
1600
+ results_log
1601
+ )
1602
 
1603
  payload = {
1604
  "username": username.strip(),
 
1634
  if oauth_available:
1635
  gr.LoginButton()
1636
  else:
1637
+ gr.Markdown(
1638
+ "OAuth Hugging Face недоступен вне Space. Для сабмита нужен вход в HF."
1639
+ )
1640
 
1641
  run_button = gr.Button("Запустить оценку и отправить ответы")
1642
  status_output = gr.Textbox(label="Статус", lines=6, interactive=False)
 
1645
  run_button.click(fn=run_and_submit_all, outputs=[status_output, results_table])
1646
 
1647
  if space_host_startup:
1648
+ print(f"SPACE_HOST найден: {space_host_startup}", flush=True)
1649
  if space_id_startup:
1650
+ print(f"SPACE_ID найден: {space_id_startup}", flush=True)
1651
 
1652
  if __name__ == "__main__":
1653
+ print("Запускаю Gradio-интерфейс LangGraph-агента.", flush=True)
1654
  demo.launch(debug=os.getenv("GRADIO_DEBUG", "0") == "1", share=False)