import os import io import re import time import json import base64 import zipfile import gradio as gr import requests import pandas as pd import spaces from types import SimpleNamespace from openai import OpenAI from smolagents import CodeAgent, DuckDuckGoSearchTool, OpenAIServerModel, tool from huggingface_hub import InferenceClient # --- Constants --- DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space" GROQ_BASE_URL = "https://api.groq.com/openai/v1" PREFERRED_GROQ_MODELS = [ "qwen/qwen3.8-27b", "openai/gpt-oss-120b", "openai/gpt-oss-20b", "allam-2-7b" ] BROWSER_HEADERS = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/124.0.0.0 Safari/537.36" ), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9" } # Satisfies ZeroGPU scanner requirement @spaces.GPU def check_gpu_startup(): return True # --- Dedicated Tools (Hardened against 403, PDF binary dumps, and Truncation) --- @tool def search_wikipedia(query: str) -> str: """Search English Wikipedia for a topic and return the full relevant article content (including discography/tables). Args: query: The topic, person, or article title to search on Wikipedia. """ import wikipedia wikipedia.set_lang("en") wikipedia.set_user_agent("GAIAResearchAgent/1.0 (https://huggingface.co; contact@huggingface.co)") try: search_results = wikipedia.search(query, results=5) if not search_results: return f"No Wikipedia pages found for '{query}'." page = None for title in search_results: try: page = wikipedia.page(title, auto_suggest=False) break except wikipedia.exceptions.DisambiguationError as e: if e.options: try: page = wikipedia.page(e.options[0], auto_suggest=False) break except Exception: continue except Exception: continue if page is None: return f"Could not load Wikipedia page for '{query}'." content = page.content # If article is short enough, return all of it (up to 14,000 chars) if len(content) <= 14000: return f"Title: {page.title}\nURL: {page.url}\n\n{content}" # If article is very long, keep intro + matching sections (e.g., Discography, Albums, Career) intro = content[:3500] sections = re.split(r"(?=\n==+\s*[^=]+\s*==+)", content) query_words = [w.lower() for w in re.findall(r"\w+", query) if len(w) > 3] priority_keywords = query_words + ["discography", "album", "studio", "list", "work", "award", "career", "filmography", "result"] matched_sections = [] for sec in sections: sec_lower = sec.lower() if any(k in sec_lower for k in priority_keywords): matched_sections.append(sec) combined = f"Title: {page.title}\nURL: {page.url}\n\n[INTRO]\n{intro}\n\n[RELEVANT SECTIONS]\n" + "\n".join(matched_sections) if len(combined) > 14000: # Also include the tail of the article where discographies/lists usually live combined = combined[:9000] + "\n...\n[END OF ARTICLE]\n" + content[-5000:] return combined except Exception as e: return f"Wikipedia search error: {e}" @tool def visit_webpage(url: str) -> str: """Fetch and extract clean readable text from a webpage URL or online PDF (safe against binary dumps and 403 blocks). Args: url: The full HTTP/HTTPS URL to read. """ from markdownify import markdownify from pypdf import PdfReader try: resp = requests.get(url, headers=BROWSER_HEADERS, timeout=15) resp.raise_for_status() content_type = resp.headers.get("Content-Type", "").lower() # Safely parse online PDFs instead of dumping raw binary bytes! if "application/pdf" in content_type or url.lower().endswith(".pdf") or resp.content.startswith(b"%PDF"): reader = PdfReader(io.BytesIO(resp.content)) pdf_text = [] for i, page in enumerate(reader.pages[:10]): txt = page.extract_text() or "" if txt.strip(): pdf_text.append(f"--- Page {i+1} ---\n{txt.strip()}") joined_pdf = "\n".join(pdf_text) return joined_pdf[:6000] if joined_pdf else "PDF parsed but no extractable text found." # Parse HTML cleanly md_text = markdownify(resp.text) lines = [line.strip() for line in md_text.splitlines() if line.strip()] cleaned = "\n".join(lines) return cleaned[:6000] except Exception as e: return f"Could not fetch webpage ({url}): {e}. Try using web_search or search_wikipedia instead." @tool def transcribe_audio(file_path: str) -> str: """Transcribe a local audio file (.mp3, .wav) into text using Whisper speech-to-text. Args: file_path: The local path to the audio file. """ groq_key = os.getenv("GROQ_API_KEY") if groq_key: try: client = OpenAI(base_url=GROQ_BASE_URL, api_key=groq_key) for whisper_model in ["whisper-large-v3-turbo", "whisper-large-v3"]: try: with open(file_path, "rb") as audio_file: transcription = client.audio.transcriptions.create( model=whisper_model, file=audio_file ) return transcription.text except Exception as inner_e: print(f"Groq Whisper ({whisper_model}) error: {inner_e}") except Exception as e: print(f"Groq audio transcription error: {e}") token = os.getenv("HF_TOKEN") client = InferenceClient(token=token) try: with open(file_path, "rb") as f: audio_bytes = f.read() res = client.automatic_speech_recognition( audio_bytes, model="openai/whisper-large-v3-turbo" ) if hasattr(res, "text"): return res.text if isinstance(res, dict) and "text" in res: return res["text"] return str(res) except Exception as e: return f"Speech-to-text transcription failed: {e}" @tool def analyze_image(file_path: str, question: str) -> str: """Analyze a local image file (.png, .jpg) to answer visual questions (e.g., chess boards, charts, text in images). Args: file_path: Local path to the image file. question: Specific question or instruction about the image content. """ groq_key = os.getenv("GROQ_API_KEY") try: with open(file_path, "rb") as f: b64_img = base64.b64encode(f.read()).decode("utf-8") ext = os.path.splitext(file_path)[-1].lower().lstrip(".") mime = "image/jpeg" if ext in ["jpg", "jpeg"] else "image/png" if groq_key: client = OpenAI(base_url=GROQ_BASE_URL, api_key=groq_key) models_resp = client.models.list() avail = [m.id for m in models_resp.data] vision_candidates = [ m for m in avail if any(k in m.lower() for k in ["vision", "scout", "maverick", "llava"]) ] for v_model in vision_candidates: try: res = client.chat.completions.create( model=v_model, messages=[ { "role": "user", "content": [ {"type": "text", "text": question}, {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64_img}"}} ] } ], max_tokens=500 ) return res.choices[0].message.content except Exception as ve: print(f"Vision model {v_model} failed: {ve}") return "No vision model available on account to inspect image pixels directly." except Exception as e: return f"Image analysis error: {e}" # --- Dynamic Model Discovery & Context-Safe Resilient Wrapper --- def discover_groq_models(api_key: str) -> list[str]: """Identify active Groq chat models and prioritize Qwen.""" client = OpenAI(base_url=GROQ_BASE_URL, api_key=api_key) try: models_resp = client.models.list() available_ids = [m.id for m in models_resp.data] except Exception as e: print(f"Could not list Groq models: {e}") available_ids = PREFERRED_GROQ_MODELS verified = [] for pref in PREFERRED_GROQ_MODELS: if pref in available_ids: try: client.chat.completions.create( model=pref, messages=[{"role": "user", "content": "1"}], max_tokens=5 ) print(f"Verified working Groq model: {pref}") verified.append(pref) except Exception as e: print(f"Model {pref} ping failed: {e}") return verified if verified else ["qwen/qwen3.8-27b"] def sanitize_messages_length(messages: list) -> list: """Truncate overly long tool observations in message history to prevent Error 400 Context Overflow.""" if not isinstance(messages, list): return messages sanitized = [] for idx, msg in enumerate(messages): if isinstance(msg, dict): msg_copy = dict(msg) content = msg_copy.get("content", "") # Keep system prompt (idx 0) intact, cap any oversized observation message if idx > 0 and isinstance(content, str) and len(content) > 4500: msg_copy["content"] = content[:4500] + "\n...[TRUNCATED TO FIT CONTEXT WINDOW]..." sanitized.append(msg_copy) else: sanitized.append(msg) return sanitized def create_resilient_groq_model(api_key: str, verified_models: list[str]) -> OpenAIServerModel: """Create OpenAIServerModel with automatic context trimming, tool rescue, and failover.""" primary_model = verified_models[0] model = OpenAIServerModel( model_id=primary_model, api_base=GROQ_BASE_URL, api_key=api_key ) original_create = model.client.chat.completions.create def resilient_create(*args, **kwargs): time.sleep(1.2) if "messages" in kwargs: kwargs["messages"] = sanitize_messages_length(kwargs["messages"]) last_err = None for attempt in range(4): active_model = verified_models[attempt % len(verified_models)] kwargs["model"] = active_model try: return original_create(*args, **kwargs) except Exception as e: last_err = e err_str = str(e) # 1. Rescue if context length still exceeded (Error 400 reduce length) if "reduce the length" in err_str.lower() or "context_length" in err_str.lower(): print("Context overflow detected! Aggressively trimming intermediate messages...") msgs = kwargs.get("messages", []) if len(msgs) > 3: # Keep system prompt, first user task, and last 2 messages kwargs["messages"] = [msgs[0], msgs[1]] + msgs[-2:] continue # 2. Rescue Groq's 'Tool choice is none, but model called a tool' bug if "tool choice is none" in err_str.lower() or "tool_use_failed" in err_str.lower(): print("Intercepted Groq tool-choice error. Rescuing code from failed_generation...") try: extracted = None if hasattr(e, "body") and isinstance(e.body, dict): fg = e.body.get("error", {}).get("failed_generation", "") else: m = re.search(r"'failed_generation':\s*'({.*?})'", err_str, re.DOTALL) fg = m.group(1) if m else "" if '"arguments":' in fg: code_candidate = fg.split('"arguments":', 1)[-1].strip() if code_candidate.endswith("}"): code_candidate = code_candidate[:-1].strip() extracted = code_candidate.strip("'\"") if extracted: return SimpleNamespace( choices=[ SimpleNamespace( message=SimpleNamespace( role="assistant", content=f"```python\n{extracted}\n```" ) ) ] ) except Exception as rescue_err: print(f"Rescue parse failed: {rescue_err}") # 3. Rate-limit or capacity retry if any(k in err_str.lower() for k in ["429", "rate limit", "503", "413"]): wait_time = 8 * (attempt + 1) print(f"Rate limit on {active_model}. Sleeping {wait_time}s and switching...") time.sleep(wait_time) else: raise e raise last_err model.client.chat.completions.create = resilient_create return model # --- Answer Post-Processing (Fixes Monologue Leaks) --- def clean_gaia_answer(raw_result: str) -> str: """Extract pure exact-match value even if the model leaked a chain-of-thought paragraph.""" if raw_result is None: return "" text = str(raw_result).strip() # Remove ... blocks if emitted by reasoning models text = re.sub(r".*?", "", text, flags=re.DOTALL).strip() # If the model leaked a monologue ending with 'raw final value. X' or 'FINAL ANSWER: X' patterns = [ r"(?:final answer|raw final value|the answer)\s*(?:is|:|-|\.)\s*([^\n\.]+)\.?$", r"Wait, the rules say output ONLY the raw final value\.?\s*(.+)$", ] for pat in patterns: m = re.search(pat, text, flags=re.IGNORECASE | re.MULTILINE) if m: text = m.group(1).strip() break # If still multi-line, take the last non-empty line lines = [line.strip() for line in text.splitlines() if line.strip()] if len(lines) > 1: text = lines[-1] # If it's a long reasoning paragraph (>120 chars) with multiple sentences, extract the final sentence/fragment if len(text) > 120 and ". " in text: parts = [p.strip() for p in text.split(". ") if p.strip()] if parts: text = parts[-1] # Strip prefixes for prefix in ["final answer:", "answer:", "the answer is", "raw final value:"]: if text.lower().startswith(prefix): text = text[len(prefix):].strip(" :\n*`") text = text.strip(" '\"`*") # Strip trailing period unless it's an abbreviation if text.endswith(".") and not text.endswith(".."): text = text[:-1].strip() # Normalize whole-number floats ('42.0' -> '42') try: as_float = float(text) if as_float == int(as_float): text = str(int(as_float)) except ValueError: pass return text class GAIAgentWrapper: """Wrapper that initializes a fresh CodeAgent per question to prevent memory bleed.""" def __init__(self): groq_key = os.getenv("GROQ_API_KEY") if not groq_key: raise RuntimeError("GROQ_API_KEY secret is missing in Space settings.") self.groq_key = groq_key self.verified_models = discover_groq_models(groq_key) self.model = create_resilient_groq_model(groq_key, self.verified_models) self.search_tool = DuckDuckGoSearchTool() def __call__(self, prompt: str) -> str: # Fresh CodeAgent instance per question for clean state agent = CodeAgent( tools=[self.search_tool, visit_webpage, search_wikipedia, transcribe_audio, analyze_image], model=self.model, additional_authorized_imports=[ "pandas", "numpy", "openpyxl", "pypdf", "PIL", "math", "re", "datetime", "json", "requests", "bs4", "csv", "statistics", "os", "io", "urllib", "collections", "itertools", "docx", "pptx", "wikipedia", "time", "zipfile", "base64" ], max_steps=8 ) strict_instruction = ( f"{prompt}\n\n" "CRITICAL EXECUTION & FORMAT INSTRUCTIONS:\n" "1. ALWAYS call `final_answer(your_answer)` inside a Python code block as soon as you know the answer.\n" "2. For Wikipedia questions, use `search_wikipedia(query=...)` first.\n" "3. NEVER print raw binary bytes or full base64 strings of files.\n" "4. Output ONLY the raw final value inside `final_answer(...)`: NO explanations, NO units, NO comma separators in numbers (write 16000, not 16,000), and for lists use comma + single space ('a, b, c')." ) try: raw_result = agent.run(strict_instruction) cleaned = clean_gaia_answer(raw_result) print(f"==> Final Cleaned Answer: {cleaned}") return cleaned except Exception as e: print(f"Agent execution error: {e}") return "unknown" # --- File Handling --- def detect_file_extension(content: bytes) -> str: """Detect file extension accurately by inspecting magic bytes and ZIP structure.""" if content.startswith(b"%PDF"): return ".pdf" if content.startswith(b"\x89PNG\r\n\x1a\n"): return ".png" if content.startswith(b"\xff\xd8\xff"): return ".jpg" if content.startswith(b"ID3") or content.startswith(b"\xff\xfb"): return ".mp3" if content.startswith(b"RIFF") and b"WAVE" in content[:12]: return ".wav" if content.startswith(b"PK\x03\x04"): try: with zipfile.ZipFile(io.BytesIO(content)) as z: names = z.namelist() if any(n.startswith("word/") for n in names): return ".docx" if any(n.startswith("ppt/") for n in names): return ".pptx" if any(n.startswith("xl/") for n in names): return ".xlsx" except Exception: pass return ".xlsx" return ".txt" def download_task_file(api_url: str, task_id: str, suggested_name: str | None = None, save_dir: str = "./downloads") -> str | None: """Download task attachment from API with accurate extension.""" os.makedirs(save_dir, exist_ok=True) file_url = f"{api_url}/files/{task_id}" try: response = requests.get(file_url, stream=True, timeout=30) if response.status_code == 200: content = response.content if suggested_name and "." in suggested_name: fname = suggested_name else: cd = response.headers.get("content-disposition", "") if "filename=" in cd: fname = cd.split("filename=")[-1].strip('"\'; ') else: ext = detect_file_extension(content) fname = f"{task_id}{ext}" file_path = os.path.join(save_dir, fname) with open(file_path, "wb") as f: f.write(content) print(f"Downloaded file for task {task_id}: {file_path}") return file_path except Exception as e: print(f"File download error for task {task_id}: {e}") return None # --- Main Evaluation Logic --- def run_and_submit_all(profile: gr.OAuthProfile | None): """Fetch questions, run CodeAgent, submit answers, and display results.""" space_id = os.getenv("SPACE_ID") if profile: username = f"{profile.username}" print(f"User logged in: {username}") else: print("User not logged in.") return "Please Login to Hugging Face with the button.", None api_url = DEFAULT_API_URL questions_url = f"{api_url}/questions" submit_url = f"{api_url}/submit" try: agent = GAIAgentWrapper() except Exception as e: print(f"Error instantiating agent: {e}") return f"Error initializing agent: {e}", None agent_code = f"https://huggingface.co/spaces/{space_id}/tree/main" print(f"Agent code URL: {agent_code}") print(f"Fetching questions from: {questions_url}") try: response = requests.get(questions_url, timeout=15) response.raise_for_status() questions_data = response.json() if not questions_data: return "Fetched questions list is empty or invalid format.", None print(f"Fetched {len(questions_data)} questions.") except Exception as e: print(f"Error fetching questions: {e}") return f"Error fetching questions: {e}", None results_log = [] answers_payload = [] print(f"Running agent on {len(questions_data)} questions...") for idx, item in enumerate(questions_data, 1): task_id = item.get("task_id") question_text = item.get("question") suggested_filename = item.get("file_name") if not task_id or question_text is None: continue print(f"\n==========================================") print(f"Solving Question {idx}/{len(questions_data)} (Task {task_id})") print(f"==========================================") file_path = download_task_file(api_url, task_id, suggested_name=suggested_filename) if file_path: file_ext = os.path.splitext(file_path)[-1].lower() if file_ext in [".mp3", ".wav"]: full_prompt = ( f"{question_text}\n\n" f"An audio file is attached at: '{file_path}'. " f"Call `transcribe_audio(file_path='{file_path}')` to get its transcript." ) elif file_ext in [".png", ".jpg", ".jpeg"]: full_prompt = ( f"{question_text}\n\n" f"An image file is attached at: '{file_path}'. " f"Call `analyze_image(file_path='{file_path}', question=...)` to inspect it. Do NOT print raw image bytes." ) else: full_prompt = ( f"{question_text}\n\n" f"A task file is provided at local path: '{file_path}' (Detected type: {file_ext}). " f"Inspect and parse this file using Python (pandas, openpyxl, pypdf, docx, pptx, etc.)." ) else: full_prompt = question_text try: submitted_answer = agent(full_prompt) answers_payload.append({"task_id": task_id, "submitted_answer": submitted_answer}) results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": submitted_answer}) except Exception as e: print(f"Error running agent on task {task_id}: {e}") results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": "unknown"}) time.sleep(1.5) if not answers_payload: return "Agent did not produce any answers to submit.", pd.DataFrame(results_log) submission_data = {"username": username.strip(), "agent_code": agent_code, "answers": answers_payload} print(f"Submitting {len(answers_payload)} answers to: {submit_url}") try: response = requests.post(submit_url, json=submission_data, timeout=60) response.raise_for_status() result_data = response.json() final_status = ( f"Submission Successful!\n" f"User: {result_data.get('username')}\n" f"Overall Score: {result_data.get('score', 'N/A')}%\n" f"({result_data.get('correct_count', '?')}/{result_data.get('total_attempted', '?')} correct)\n" f"Message: {result_data.get('message', 'No message received.')}" ) print("Submission successful.") return final_status, pd.DataFrame(results_log) except Exception as e: status_message = f"Submission error: {e}" print(status_message) return status_message, pd.DataFrame(results_log) # --- UI Setup --- with gr.Blocks() as demo: gr.Markdown("# GAIA CodeAgent Evaluation Runner") gr.LoginButton() run_button = gr.Button("Run Evaluation & Submit All Answers") status_output = gr.Textbox(label="Run Status / Submission Result", lines=5, interactive=False) results_table = gr.DataFrame(label="Questions and Agent Answers", wrap=True) run_button.click( fn=run_and_submit_all, outputs=[status_output, results_table] ) if __name__ == "__main__": demo.launch(debug=True, share=False)