File size: 8,612 Bytes
9b55593
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
"""
solution_smolagents.py
======================
Unit 4 hands-on solution built on the **smolagents** framework (Unit 2.1).

Strategy
--------
A single `CodeAgent` equipped with research / multimodal / file tools. The agent
writes its own Python (native smolagents capability) so it can compute the
algebra / set-theory / Excel / attached-Python questions itself, while the tools
cover web research, page reading, audio transcription and image understanding.

The GAIA system prompt (paper Figure 2) is injected so that the model obeys the
strict final-answer format required for exact-match scoring.

Usage
-----
    from solution_smolagents import GAIAAgent
    agent = GAIAAgent()
    print(agent.answer_question("8e867cd7-...", "How many studio albums ..."))
"""

from __future__ import annotations

import importlib.resources

import yaml
from smolagents import CodeAgent, Tool

import gaia_common as gc


def _default_prompt_templates() -> dict:
    """Load smolagents' default CodeAgent prompt templates so we can override
    only the system prompt without losing the rest."""
    return yaml.safe_load(
        importlib.resources.files("smolagents.prompts").joinpath("code_agent.yaml").read_text()
    )


# ----------------------------------------------------------------------------
# Tools (smolagents `Tool` subclasses - robust, no source introspection needed)
# ----------------------------------------------------------------------------

class WebSearchTool(Tool):
    name = "web_search"
    description = (
        "Search the web (DuckDuckGo with fallbacks) and return the top results as "
        "numbered text with titles, URLs and snippets. Use for fact-finding and to "
        "discover pages to open with fetch_page."
    )
    inputs = {
        "query": {"type": "string", "description": "The search query."},
        "max_results": {"type": "integer", "description": "Number of results to return (default 5).", "nullable": True},
    }
    output_type = "string"

    def forward(self, query: str, max_results: int = 5) -> str:
        return gc.web_search(query, max_results=max_results)


class FetchPageTool(Tool):
    name = "fetch_page"
    description = (
        "Fetch a URL (HTML or PDF) and return its human-readable text content. "
        "Use after web_search to read the actual source of an answer."
    )
    inputs = {
        "url": {"type": "string", "description": "The full URL to fetch."},
    }
    output_type = "string"

    def forward(self, url: str) -> str:
        return gc.fetch_page(url)


class DownloadTaskFileTool(Tool):
    name = "download_task_file"
    description = (
        "Download the file attached to a GAIA question (image, audio, spreadsheet, "
        "Python file, PDF, ...). Returns the local path to the downloaded file, or "
        "'NO FILE' if the question has no attachment."
    )
    inputs = {
        "task_id": {"type": "string", "description": "The GAIA task id of the question."},
    }
    output_type = "string"

    def forward(self, task_id: str) -> str:
        path = gc.download_task_file(task_id)
        return path if path else "NO FILE"


class ReadFileTool(Tool):
    name = "read_file"
    description = (
        "Read a local file and return its content as text. Dispatches automatically on the "
        "extension: spreadsheets -> markdown table, PDF -> extracted text, images -> vision "
        "description, audio -> transcription, text/Python -> raw text."
    )
    inputs = {
        "path": {"type": "string", "description": "Local path to the file."},
        "question": {"type": "string", "description": "Optional: the original question, used to guide image analysis.", "nullable": True},
    }
    output_type = "string"

    def forward(self, path: str, question: str = "") -> str:
        return gc.read_any_file(path, question)


class TranscribeAudioTool(Tool):
    name = "transcribe_audio"
    description = (
        "Transcribe an audio file (local path) or a YouTube video (URL) to text using "
        "Whisper. Use for questions that reference .mp3 attachments or YouTube videos."
    )
    inputs = {
        "target": {"type": "string", "description": "Local audio file path OR a YouTube URL."},
    }
    output_type = "string"

    def forward(self, target: str) -> str:
        if target.startswith("http"):
            return gc.youtube_transcript(target)
        return gc.transcribe_audio(target)


class AnalyzeImageTool(Tool):
    name = "analyze_image"
    description = (
        "Analyze an image file with a vision-language model and return a detailed text "
        "description. Use for images (e.g. chess positions, figures, screenshots)."
    )
    inputs = {
        "path": {"type": "string", "description": "Local path to the image file."},
        "question": {"type": "string", "description": "The question or specific instruction for the image."},
    }
    output_type = "string"

    def forward(self, path: str, question: str) -> str:
        return gc.analyze_image(path, question)


class ExecutePythonTool(Tool):
    name = "execute_python"
    description = (
        "Run a self-contained Python program in a fresh subprocess and return its stdout. "
        "Use for exact arithmetic, data munging, set/group theory checks, and for running "
        "an attached .py file (read it first with read_file, then execute its code)."
    )
    inputs = {
        "code": {"type": "string", "description": "The complete Python code to run. It must be self-contained (imports inside)."},
    }
    output_type = "string"

    def forward(self, code: str) -> str:
        return gc.execute_python(code)


# ----------------------------------------------------------------------------
# The agent
# ----------------------------------------------------------------------------

DEFAULT_TOOLS = [
    WebSearchTool(),
    FetchPageTool(),
    DownloadTaskFileTool(),
    ReadFileTool(),
    TranscribeAudioTool(),
    AnalyzeImageTool(),
    ExecutePythonTool(),
]

EXTRA_AUTHORIZED_IMPORTS = [
    "pandas", "numpy", "math", "statistics", "json", "re", "datetime",
    "collections", "itertools", "fractions", "urllib", "requests", "csv", "html",
]


class GAIAAgent:
    """smolagents CodeAgent specialised for the GAIA level-1 leaderboard."""

    def __init__(
        self,
        model=None,
        tools: list[Tool] | None = None,
        max_steps: int = 14,
        additional_authorized_imports: list[str] | None = None,
    ):
        self.model = model or gc.make_smolagents_model()
        templates = _default_prompt_templates()
        templates["system_prompt"] = gc.GAIA_SYSTEM_PROMPT
        self.agent = CodeAgent(
            tools=tools or DEFAULT_TOOLS,
            model=self.model,
            max_steps=max_steps,
            additional_authorized_imports=additional_authorized_imports or EXTRA_AUTHORIZED_IMPORTS,
            prompt_templates=templates,
            verbosity_level=1,
        )

    # -- public API ---------------------------------------------------------
    def answer_question(self, task_id: str, question: str) -> str:
        """Full pipeline for one question: download attachment, run the agent,
        extract and normalize the final answer."""
        file_path = gc.download_task_file(task_id)
        prompt = self._build_prompt(question, file_path)
        try:
            output = self.agent.run(prompt)
        finally:
            self.agent.memory.reset()  # fresh memory per question
        answer = gc.extract_final_answer(str(output))
        return gc.normalize_final_answer(answer)

    def __call__(self, task_id: str, question: str) -> str:
        return self.answer_question(task_id, question)

    # -- helpers ------------------------------------------------------------
    @staticmethod
    def _build_prompt(question: str, file_path: str | None) -> str:
        file_hint = ""
        if file_path:
            file_hint = (
                f"\n\nAn attachment for this question has already been downloaded to: {file_path}\n"
                f"Use the read_file tool on it if you need it. The question asks: {question}"
            )
        return f"{question}{file_hint}{gc.ANSWER_ONLY_PROMPT}"


def run_demo(subset: int | None = 3):
    """Quick smoke test on the first `subset` questions."""
    agent = GAIAAgent()
    for item in gc.fetch_questions()[:subset]:
        print("\n---", item["task_id"], "---")
        print(item["question"][:150])
        print("ANSWER:", agent.answer_question(item["task_id"], item["question"]))


if __name__ == "__main__":
    run_demo()