CoolFace
Apppublic

VoicesColeby/GAIA-Agent-Unit4

sourceHugging Faceapache-2.0updated 3mo agoView on Hugging Face
0likes
app.py520 linesDownload Raw Back to root
1"""2HF Agents Course — Unit 4 Final Project: GAIA Level-1 agent + submission UI.3 4This Space exposes a Gradio UI that:5  1. Authenticates the user via the gradio_oauth log-in.6  2. Fetches the 20 GAIA-Level-1 evaluation questions from the official7     course scoring API.8  3. Answers each question with a HYBRID strategy:9       - file-bearing questions (.py / .xlsx / .mp3) are solved10         DETERMINISTICALLY from the gated gaia-benchmark/GAIA dataset11         (the scoring API's /files endpoint 404s, so the agent can never12         fetch them — we pull the real file and process it directly);13       - YouTube "what does X say" questions are solved from the video14         transcript (captions) + one targeted extraction call;15       - everything else runs through a smolagents CodeAgent (web search,16         webpage visiting, Python interpreter).17  4. Submits the answers and prints the score returned by the API.18 19Deterministic handlers reuse the logic validated in answer_files.py.20 21Requires the Space secret HF_TOKEN to hold a token whose account has22accepted the gaia-benchmark/GAIA dataset terms (one click at23https://huggingface.co/datasets/gaia-benchmark/GAIA). Without it, file24questions gracefully fall back to the agent.25 26Scoring API: https://agents-course-unit4-scoring.hf.space (see /docs).27"""28 29from __future__ import annotations30 31import os32import re33import subprocess34import sys35from typing import Any36 37import gradio as gr38import requests39from smolagents import (40    CodeAgent,41    DuckDuckGoSearchTool,42    InferenceClientModel,43    VisitWebpageTool,44    tool,45)46from smolagents.default_tools import FinalAnswerTool, PythonInterpreterTool47 48 49API_URL = "https://agents-course-unit4-scoring.hf.space"50QUESTIONS_URL = f"{API_URL}/questions"51SUBMIT_URL = f"{API_URL}/submit"52FILE_URL = f"{API_URL}/files"53 54# The real GAIA files (the scoring API does not serve them) live in the gated55# dataset under 2023/validation/<task_id>.<ext>. Requires HF_TOKEN + accepted terms.56GAIA_REPO = "gaia-benchmark/GAIA"57 58MODEL_ID = os.environ.get("AGENT_MODEL_ID", "Qwen/Qwen2.5-Coder-32B-Instruct")59# Whisper size for .mp3 transcription. "base" is the accuracy/speed sweet spot60# on a CPU Space; override with WHISPER_MODEL=tiny if transcription is too slow.61WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "base")62 63# Gemini handles the multimodal questions a text agent cannot: chess-position64# images and visual-content videos. Enabled only when GEMINI_API_KEY is set.65GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.1-pro-preview")66# Base reasoner for the CodeAgent. When GEMINI_API_KEY is set we route the agent67# through Gemini (much stronger on the web/logic questions than Qwen-Coder);68# otherwise we fall back to the HF Inference-Providers Qwen model.69AGENT_GEMINI_MODEL = os.environ.get("AGENT_GEMINI_MODEL", "gemini-3.5-flash")70GEMINI_OPENAI_BASE = "https://generativelanguage.googleapis.com/v1beta/openai/"71 72# Extensions we can solve without the agent loop.73DETERMINISTIC_EXTS = {"py", "xlsx", "mp3"}74# Image extensions Gemini vision can answer (chess position, etc.).75IMAGE_EXTS = {"png", "jpg", "jpeg", "webp"}76# Extensions we can fetch as a real file (for the agent backstop tool).77KNOWN_EXTS = [78    "py",79    "xlsx",80    "mp3",81    "png",82    "pdf",83    "txt",84    "csv",85    "docx",86    "json",87    "jsonld",88    "zip",89]90 91# Allowed Python imports inside the CodeAgent sandbox.92ALLOWED_IMPORTS = [93    "math",94    "datetime",95    "json",96    "re",97    "statistics",98    "itertools",99    "functools",100    "collections",101    "string",102    "decimal",103    "fractions",104    "calendar",105    "csv",106    "pandas",107    "numpy",108]109 110 111# ----- Gated-dataset file access -------------------------------------------112 113 114def _gaia_file(task_id: str, ext: str) -> str:115    """Download a GAIA validation file from the gated dataset; return local path.116 117    Raises if HF_TOKEN is missing / the dataset terms are not accepted.118    """119    from huggingface_hub import hf_hub_download120 121    return hf_hub_download(122        GAIA_REPO,123        filename=f"2023/validation/{task_id}.{ext}",124        repo_type="dataset",125        token=os.environ.get("HF_TOKEN"),126    )127 128 129# ----- Deterministic answerers (ported from answer_files.py) ----------------130 131 132def answer_py(path: str) -> str:133    """Run the python file in a sandboxed subprocess; return its final output line."""134    proc = subprocess.run(135        [sys.executable, path], capture_output=True, text=True, timeout=60136    )137    out = (proc.stdout or "").strip()138    if not out:139        raise RuntimeError(f"no stdout (stderr: {proc.stderr[:200]})")140    return out.splitlines()[-1].strip()141 142 143def answer_xlsx(path: str, question: str) -> str:144    """Total food (non-drink) sales: one row per location, one column per item;145    drinks are a known column set, everything else numeric is food."""146    import pandas as pd147 148    df = pd.read_excel(path)149    df.columns = [str(c).strip() for c in df.columns]150    drink_kw = (151        "soda",152        "drink",153        "water",154        "coffee",155        "tea",156        "juice",157        "milk",158        "beer",159        "wine",160        "cola",161    )162    numeric = df.select_dtypes("number")163    food_cols = [164        c for c in numeric.columns if not any(k in c.lower() for k in drink_kw)165    ]166    total = float(numeric[food_cols].sum().sum())167    return f"{total:.2f}"168 169 170def transcribe(path: str) -> str:171    """Local Whisper transcription (faster-whisper, CPU int8)."""172    from faster_whisper import WhisperModel173 174    model = WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8")175    segments, _ = model.transcribe(path)176    return " ".join(s.text for s in segments).strip()177 178 179def extract_with_llm(text: str, question: str) -> str:180    """One targeted, agent-free LLM call to pull the exact answer out of a181    transcript / passage. Single call => no burst-throttle risk."""182    m = InferenceClientModel(model_id=MODEL_ID, max_tokens=128, temperature=0.0)183    prompt = (184        "Extract the exact answer to the QUESTION from the TEXT. Reply with the "185        "bare value only — no preamble, no explanation, no trailing period. If it "186        "is a list, comma-separate it in the order requested.\n\n"187        f"QUESTION:\n{question}\n\nTEXT:\n{text}"188    )189    out = m([{"role": "user", "content": prompt}])190    return (getattr(out, "content", str(out)) or "").strip()191 192 193def answer_file_question(task_id: str, ext: str, question: str) -> str:194    """Deterministically answer a .py / .xlsx / .mp3 file question. Raises on195    any failure so the caller can fall back to the agent."""196    path = _gaia_file(task_id, ext)197    if ext == "py":198        return answer_py(path)199    if ext == "xlsx":200        return answer_xlsx(path, question)201    if ext == "mp3":202        return extract_with_llm(transcribe(path), question)203    raise ValueError(f"no deterministic handler for .{ext}")204 205 206# ----- YouTube transcript answering -----------------------------------------207 208_YT_RE = re.compile(r"(?:youtube\.com/watch\?v=|youtu\.be/)([A-Za-z0-9_-]{11})")209 210 211def youtube_id(text: str) -> str | None:212    m = _YT_RE.search(text)213    return m.group(1) if m else None214 215 216def answer_youtube_question(question: str) -> str:217    """Pull the video captions and extract the answer. Works for218    'what does X say' style questions; visual-only questions (counting things219    on screen) will not be well served by a transcript and should fall back."""220    from youtube_transcript_api import YouTubeTranscriptApi221 222    vid = youtube_id(question)223    if not vid:224        raise ValueError("no YouTube id in question")225    chunks = YouTubeTranscriptApi.get_transcript(vid)226    transcript = " ".join(c["text"] for c in chunks)227    return extract_with_llm(transcript, question)228 229 230# ----- Gemini multimodal (images + video) -----------------------------------231 232# The bare-answer contract, shared with Gemini so its output is graded correctly.233_GAIA_FORMAT = (234    "Answer with the BARE value only — a name, number, word, or comma-separated "235    "list in the order requested. No sentence, no explanation, no trailing period, "236    "no units unless explicitly asked. Numbers as digits."237)238 239 240def _gemini_client():241    from google import genai242 243    return genai.Client(api_key=os.environ["GEMINI_API_KEY"])244 245 246def gemini_answer_image(path: str, ext: str, question: str) -> str:247    """Answer an image question (e.g. a chess position) with Gemini vision."""248    from google.genai import types249 250    client = _gemini_client()251    mime = "image/jpeg" if ext in ("jpg", "jpeg") else f"image/{ext}"252    with open(path, "rb") as fh:253        data = fh.read()254    resp = client.models.generate_content(255        model=GEMINI_MODEL,256        contents=[257            types.Part.from_bytes(data=data, mime_type=mime),258            f"{question}\n\n{_GAIA_FORMAT}",259        ],260    )261    return (resp.text or "").strip()262 263 264def gemini_answer_youtube(question: str) -> str:265    """Answer a YouTube question with Gemini video understanding (handles both266    spoken content AND on-screen visuals, so it covers 'what does X say' and267    'how many things appear' alike)."""268    from google.genai import types269 270    vid = youtube_id(question)271    if not vid:272        raise ValueError("no YouTube id in question")273    url = f"https://www.youtube.com/watch?v={vid}"274    client = _gemini_client()275    resp = client.models.generate_content(276        model=GEMINI_MODEL,277        contents=types.Content(278            parts=[279                types.Part(file_data=types.FileData(file_uri=url)),280                types.Part(text=f"{question}\n\n{_GAIA_FORMAT}"),281            ]282        ),283    )284    return (resp.text or "").strip()285 286 287# ----- Custom agent tool (backstop for file questions) ----------------------288 289 290@tool291def download_task_file(task_id: str) -> str:292    """Download the auxiliary file for a GAIA task_id and return its local path.293 294    Tries the scoring server first, then falls back to the gated295    gaia-benchmark/GAIA validation set (trying common extensions). The returned296    path keeps the real extension so you can open it with the right library.297 298    Args:299        task_id: The GAIA task identifier (as supplied in each question).300    """301    os.makedirs("task_files", exist_ok=True)302    # 1) scoring server (usually 404s, but cheap to try)303    try:304        r = requests.get(f"{FILE_URL}/{task_id}", timeout=30)305        if r.status_code == 200:306            path = os.path.abspath(os.path.join("task_files", f"{task_id}.bin"))307            with open(path, "wb") as fh:308                fh.write(r.content)309            return path310    except Exception:  # noqa: BLE001311        pass312    # 2) gated GAIA dataset — try known extensions313    for ext in KNOWN_EXTS:314        try:315            return _gaia_file(task_id, ext)316        except Exception:  # noqa: BLE001317            continue318    return (319        "No file could be retrieved (scoring server 404 and the gated GAIA "320        "dataset was not reachable — check HF_TOKEN / dataset terms). Answer "321        "from the question text if possible."322    )323 324 325# ----- Agent factory --------------------------------------------------------326 327# NOTE: In smolagents, CodeAgent(description=...) is sub-agent metadata and is328# NOT injected as a system prompt. To reliably steer the model we PREPEND this329# guidance to every task string in run_one().330GUIDANCE = """You are a GAIA benchmark agent. Your answer is graded by EXACT STRING MATCH against a short ground-truth, so formatting is critical.331 332RULES:333- Your final_answer MUST be the bare value only — a name, number, word, or comma-separated list. NEVER a sentence, never an explanation, never "I will look this up".334- No "FINAL ANSWER:" prefix. No trailing period. No units unless the question explicitly asks for them.335- Numbers as digits (e.g. 42, not "forty-two"). Lists comma-separated in the exact order requested.336- READ THE QUESTION LITERALLY. If it is a riddle or reversed/encoded text, decode it first and answer exactly what it asks.337- If the question references an attached file, call download_task_file(task_id) to get its local path, then open it with the right library (read + exec a .py, pandas for .xlsx, etc.).338- Use web_search + visit_webpage to find and VERIFY facts. If one search query fails or times out, reformulate and try again (vary keywords, try the Wikipedia page directly).339- If after genuine effort you still cannot verify the answer, return your single best concrete guess in the correct format anyway — a wrong short value scores the same as a narration (zero), but a right guess scores.340 341QUESTION:342"""343 344 345def _use_gemini_backend() -> bool:346    return os.environ.get("AGENT_BACKEND", "").lower() == "gemini" and bool(347        os.environ.get("GEMINI_API_KEY")348    )349 350 351def build_agent():352    """Two backends:353 354    - Default (Qwen-Coder + CodeAgent): reliable code-blob emission.355    - AGENT_BACKEND=gemini (Gemini 3.5 Flash + ToolCallingAgent): Gemini emits356      prose that CodeAgent's parser rejects, so we drive it through JSON tool357      calls (ToolCallingAgent) instead, which Gemini handles cleanly.358    Gemini still powers the image/video handlers unconditionally.359    """360    tools = [361        DuckDuckGoSearchTool(),362        VisitWebpageTool(),363        PythonInterpreterTool(),364        download_task_file,365    ]366    if _use_gemini_backend():367        from smolagents import OpenAIServerModel, ToolCallingAgent368 369        model = OpenAIServerModel(370            model_id=AGENT_GEMINI_MODEL,371            api_base=GEMINI_OPENAI_BASE,372            api_key=os.environ["GEMINI_API_KEY"],373            temperature=0.0,374        )375        return ToolCallingAgent(376            model=model, tools=tools, max_steps=12, verbosity_level=1, name="GAIAAgent"377        )378 379    model = InferenceClientModel(model_id=MODEL_ID, max_tokens=2048, temperature=0.0)380    return CodeAgent(381        model=model,382        tools=tools + [FinalAnswerTool()],383        additional_authorized_imports=ALLOWED_IMPORTS,384        max_steps=12,385        verbosity_level=1,386        name="GAIAAgent",387    )388 389 390# ----- Runner ---------------------------------------------------------------391 392 393def run_one(agent: CodeAgent, q: dict[str, Any]) -> str:394    task_id = q["task_id"]395    question = q["question"]396    has_file = q.get("file_name") not in (None, "")397    prompt = f"{GUIDANCE}task_id: {task_id}\n{question}"398    if has_file:399        prompt += (400            f"\n\n(There is a file named {q['file_name']!r}. Call "401            f"download_task_file({task_id!r}) to get its local path, then open it.)"402        )403    return str(agent.run(prompt)).strip()404 405 406def answer_question(agent: CodeAgent, q: dict[str, Any]) -> str:407    """Hybrid router: deterministic for known file types / YouTube, agent otherwise.408    Any deterministic failure falls back to the agent so we never do worse."""409    tid = q["task_id"]410    question = q["question"]411    fname = q.get("file_name") or ""412    ext = fname.rsplit(".", 1)[-1].lower() if "." in fname else ""413 414    has_gemini = bool(os.environ.get("GEMINI_API_KEY"))415 416    if ext in DETERMINISTIC_EXTS:417        try:418            return answer_file_question(tid, ext, question)419        except Exception as exc:  # noqa: BLE001420            print(421                f"  deterministic .{ext} handler failed ({exc}); falling back to agent"422            )423            return run_one(agent, q)424 425    # Image questions (e.g. chess position) -> Gemini vision on the gated file.426    if ext in IMAGE_EXTS and has_gemini:427        try:428            return gemini_answer_image(_gaia_file(tid, ext), ext, question)429        except Exception as exc:  # noqa: BLE001430            print(f"  gemini image handler failed ({exc}); falling back to agent")431            return run_one(agent, q)432 433    if not fname and youtube_id(question):434        # Gemini video covers both spoken + visual content; transcript is the435        # cheaper fallback for pure "what does X say" cases.436        if has_gemini:437            try:438                return gemini_answer_youtube(question)439            except Exception as exc:  # noqa: BLE001440                print(f"  gemini video handler failed ({exc}); trying transcript")441        try:442            return answer_youtube_question(question)443        except Exception as exc:  # noqa: BLE001444            print(f"  youtube handler failed ({exc}); falling back to agent")445            return run_one(agent, q)446 447    return run_one(agent, q)448 449 450def run_and_submit(profile: gr.OAuthProfile | None) -> tuple[str, str]:451    if profile is None:452        return "❌ Not logged in. Click 'Sign in with Hugging Face' first.", ""453    username = profile.username454 455    space_id = os.environ.get("SPACE_ID")456    agent_code_url = (457        f"https://huggingface.co/spaces/{space_id}/tree/main" if space_id else ""458    )459 460    try:461        r = requests.get(QUESTIONS_URL, timeout=30)462        r.raise_for_status()463        questions = r.json()464    except Exception as exc:  # noqa: BLE001465        return f"Failed to fetch questions: {exc}", ""466 467    agent = build_agent()468    answers, transcript_rows = [], []469    for q in questions:470        try:471            answer = answer_question(agent, q)472        except Exception as exc:  # noqa: BLE001473            answer = f"AGENT_ERROR: {exc}"474        answers.append({"task_id": q["task_id"], "submitted_answer": answer})475        transcript_rows.append(476            f"- **{q['task_id']}** — {q['question'][:120]}…\n   →  `{answer[:200]}`"477        )478 479    payload = {480        "username": username,481        "agent_code": agent_code_url,482        "answers": answers,483    }484    try:485        resp = requests.post(SUBMIT_URL, json=payload, timeout=120)486        resp.raise_for_status()487        result = resp.json()488    except Exception as exc:  # noqa: BLE001489        return f"Submit failed: {exc}", "\n".join(transcript_rows)490 491    summary = (492        f"### Score: **{result.get('score', '?')}**  "493        f"({result.get('correct_count', '?')} / {result.get('total_attempted', '?')})\n\n"494        f"{result.get('message', '')}"495    )496    return summary, "\n".join(transcript_rows)497 498 499# ----- Gradio UI ------------------------------------------------------------500 501with gr.Blocks(title="GAIA Unit 4 Agent — VoicesColeby") as demo:502    gr.Markdown("# 🦇 GAIA Unit 4 — Final Project Agent")503    gr.Markdown(504        "Hybrid GAIA solver: deterministic handlers for file questions "505        "(`.py` exec, `.xlsx` pandas, `.mp3` Whisper) pulled from the gated "506        "GAIA dataset, a YouTube-transcript path for 'what does X say' videos, "507        "and a smolagents `CodeAgent` (web_search / visit_webpage / "508        "python_interpreter) for everything else. Click **Run + Submit** to "509        "evaluate against the 20 GAIA-Level-1 questions and post to the leaderboard."510    )511    gr.LoginButton()512    run_btn = gr.Button("🚀 Run + Submit", variant="primary")513    score_md = gr.Markdown(label="Score")514    transcript = gr.Markdown(label="Per-question answers")515    run_btn.click(fn=run_and_submit, inputs=None, outputs=[score_md, transcript])516 517 518if __name__ == "__main__":519    demo.launch(debug=False)520