jwlee-ai commited on
Commit
c2446d5
Β·
verified Β·
1 Parent(s): 81917a3

Upload folder using huggingface_hub

Browse files
CLAUDE.md ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project purpose
6
+
7
+ A Hugging Face Space (Gradio app) that runs an agent against the **GAIA benchmark Level 1**
8
+ evaluation server, then submits the answers for scoring. Deployed as an HF Space
9
+ (`sdk: gradio`, `app_file: app.py`, see `README.md` frontmatter).
10
+
11
+ The scoring server is hardcoded as `https://agents-course-unit4-scoring.hf.space`:
12
+ - `GET /questions` β€” returns the list of `{task_id, question, ...}` items.
13
+ - `GET /files/{task_id}` β€” returns any file attached to that task (404 if none).
14
+ - `POST /submit` β€” takes `{username, agent_code, answers:[{task_id, submitted_answer}]}`.
15
+
16
+ Grading is **exact string match**. Formatting (capitalization, punctuation, units, surrounding
17
+ quotes) matters as much as correctness β€” see `prompts.py`.
18
+
19
+ ## Run / develop
20
+
21
+ There is no test suite, linter, or build step. The only runnable entrypoint is the Gradio app:
22
+
23
+ ```powershell
24
+ pip install -r requirements.txt
25
+ python app.py
26
+ ```
27
+
28
+ Locally this opens a Gradio UI; the "Run Evaluation & Submit All Answers" button is disabled
29
+ until the user logs in via `gr.LoginButton()` (HF OAuth β€” only works when deployed as a Space,
30
+ or when `SPACE_ID` / `SPACE_HOST` env vars and an HF login flow are set up). For local
31
+ debugging of agent logic, instantiate `BasicAgent` and call it directly:
32
+
33
+ ```python
34
+ from app import BasicAgent
35
+ agent = BasicAgent()
36
+ print(agent("your question here"))
37
+ ```
38
+
39
+ `BasicAgent.__init__` does a network call to `/questions` to prefetch the question→task_id
40
+ index, so an internet connection is required even for local debugging.
41
+
42
+ After a successful agent run, answers are cached in `.cache/answers.json`. On re-runs the
43
+ loop in `run_and_submit_all` skips any `task_id` already in the cache, so you can iterate
44
+ on a single broken question without re-paying for the rest. Delete the file (or specific
45
+ entries) to force re-evaluation. See "Answer cache" section below.
46
+
47
+ ## Environment variables
48
+
49
+ | Variable | Required for | Behaviour without it |
50
+ |---|---|---|
51
+ | `HF_TOKEN` | All HF Inference calls β€” main agent loop (Qwen2.5-72B), decomposer, image VLM (Qwen2.5-VL-7B), audio Whisper (large-v3). | Decomposer returns `None` (proceed without plan); image/audio handlers return error strings; main loop fails outright. In a deployed Space, register as a Space secret. Locally, `huggingface-cli login` writes a token that `InferenceClient()` picks up. |
52
+ | `TAVILY_API_KEY` | Tavily search backend (optional; after SearXNG in `web_search`). | `_search_tavily` short-circuits to `None` and falls through to Brave / DDG. |
53
+ | `BRAVE_API_KEY` | Brave search backend (optional; after Tavily when keys set). | Same fall-through as Tavily. |
54
+ | `SPACE_ID`, `SPACE_HOST` | Set automatically when running as an HF Space; `SPACE_ID` builds the `agent_code` repo URL for `/submit`. | Locally `SPACE_ID` is unset β€” `run_and_submit_all` sends a Hub Spaces docs URL instead of `/spaces/None/...`. |
55
+
56
+ ## Architecture
57
+
58
+ ### Pre-decomposition step (`decomposer.py`)
59
+
60
+ Before each `agent.run`, `BasicAgent.__call__` makes one LLM call (same Qwen2.5-72B
61
+ model, via `huggingface_hub.InferenceClient`) to classify the question:
62
+
63
+ - Returns `SINGLE-HOP` β†’ no plan injected, original question runs as-is.
64
+ - Returns a numbered plan β†’ injected into the user message above the original question
65
+ with a "guidance β€” deviate as tool results show" framing.
66
+
67
+ Reason: GAIA Level 1 has many multi-hop questions ("X of the Y of Z"). Without a plan
68
+ the CodeAgent burns 1–2 of its 12 steps just orienting. The decomposition prompt and the
69
+ `SINGLE-HOP` short-circuit are in `prompts.py:DECOMPOSITION_PROMPT`. Before classification,
70
+ `decomposer.py` normalizes the model output (strip markdown fences, drop prose before the first
71
+ numbered step). The function is best-effort: any failure (HF rate limit, malformed output)
72
+ returns None and the agent proceeds with the original question.
73
+
74
+ ### The agent loop (smolagents.CodeAgent)
75
+
76
+ `BasicAgent` (in `app.py`) is a thin wrapper around `smolagents.CodeAgent`. CodeAgent is a
77
+ ReAct variant where the LLM **writes Python code** each step that calls the registered `@tool`
78
+ functions; that code runs in a sandbox, results go back to the LLM, and the loop ends when
79
+ the LLM calls `final_answer(...)`. Key knobs:
80
+
81
+ - Model: `Qwen/Qwen2.5-72B-Instruct` via `InferenceClientModel`. The comments note an earlier
82
+ 32B coder model was abandoned because it leaked markdown fences (`\`\`\``, `</code]`) that
83
+ broke smolagents' code parser. Do not switch to a coder-tuned model without re-checking
84
+ this.
85
+ - `max_steps=12`. 8 was too low β€” search retries used up the budget. The system prompt also
86
+ pushes the model to commit by step 6–8 so it doesn't hit the limit (smolagents falls back
87
+ to returning the model's last verbose thought, which scores zero on exact-match).
88
+ - `additional_authorized_imports`: pandas/openpyxl/json/re/math/statistics/itertools β€” these
89
+ are imports the sandbox is allowed to use, mainly so spreadsheet questions work.
90
+ - The CodeAgent's default system prompt is **post-pended** with `GAIA_ANSWER_GUIDELINES` from
91
+ `prompts.py` by mutating `self.agent.prompt_templates["system_prompt"]`. Don't replace it;
92
+ the smolagents default contains the code-generation contract.
93
+
94
+ Output post-processing in `__call__` (in this order):
95
+ 1. Strip a leading `FINAL ANSWER:` / `FINAL ANSWER -` prefix.
96
+ 2. Strip a single pair of surrounding quotes.
97
+ 3. `final_format_pass(question, answer)` from `formatter.py` β€” optional extra LLM pass to
98
+ normalize formatting for exact-match grading; on failure returns the draft unchanged.
99
+ 4. `coerce_answer(question, answer)` from `formatter.py` β€” applies *deterministic* format
100
+ coercion based on hints in the question (yes/no questions β†’ `Yes`/`No`, "How many"
101
+ questions β†’ first numeric token only, currency-pattern answers β†’ strip `$,€,Β£,Β₯` and
102
+ commas). Coercion is conservative: if the answer doesn't match the expected pattern
103
+ it is left untouched, because over-coercing destroys correct answers more often than
104
+ it salvages wrong ones.
105
+
106
+ These are tuned to common LLM mistakes against exact-match scoring; tighten/loosen carefully.
107
+
108
+ ### Tools (`tools/` package)
109
+
110
+ Six tools are registered with the agent, plus three helpers used only by `BasicAgent`:
111
+
112
+ | Symbol | Kind | Purpose |
113
+ |---|---|---|
114
+ | `web_search(query)` | `@tool` | Backend priority: SearXNG (public instances, no key) β†’ Tavily (`TAVILY_API_KEY`) β†’ Brave (`BRAVE_API_KEY`) β†’ DuckDuckGo. Tavily/Brave are skipped silently if their key is unset. DDG tries `ddgs` first then legacy `duckduckgo_search` (key names differ: `href`/`url`, `body`/`snippet`). |
115
+ | `visit_webpage(url)` | `@tool` | Generic page fetch β†’ BeautifulSoup β†’ markdownify, truncated to ~12k chars. Sends a non-empty User-Agent because some sites block blank UAs. |
116
+ | `wikipedia_search(query)` | `@tool` | Two-step: search API for top title, then `action=parse` for full HTML body. Tables are converted to `[TABLE]...[/TABLE]` blocks with `\|`-separated cells so the LLM can read rosters/winner lists/dates. The REST `summary` endpoint was insufficient β€” it only returns the lead paragraph. |
117
+ | `youtube_info(url)` | `@tool` | YouTube videos. Metadata via oEmbed (title, channel β€” no API key needed). Transcript via `youtube-transcript-api` (English first β†’ translate any translatable caption β†’ first available language). Truncates transcript to ~14k chars. Accepts watch/youtu.be/embed/shorts URLs and bare 11-char IDs. If the video has no captions, only metadata is returned and the agent is instructed (in `GAIA_ANSWER_GUIDELINES` rule 5b) to fall back to web/wikipedia search. |
118
+ | `exec_python_code(code)` | `@tool` | Subprocess μ‹€ν–‰μœΌλ‘œ μ²¨λΆ€Β·μΈμš© 파이썬 μ½”λ“œμ˜ stdout/stderrλ₯Ό μΊ‘μ²˜ν•΄ λ°˜ν™˜(GAIA μ½”λ“œ 좜λ ₯ 질문용). |
119
+ | `get_attached_file()` | `@tool` | **Takes no arguments** β€” see "task_id plumbing" below. Type-dispatches: UTF-8 text, `.xlsx` β†’ sheet-by-sheet CSV, PDF β†’ page-by-page text via `pypdf`, image β†’ Qwen2.5-VL-7B analysis (with the current question as VLM prompt context), audio β†’ Whisper-large-v3 transcription, else metadata. |
120
+ | `prefetch_question_index()` | helper | One-shot `/questions` GET, builds `{question_text: task_id}` dict. Duplicate question strings log a warning and keep the **last** task_id. Returns `{}` on failure (degraded mode: no attachments). |
121
+ | `set_question_index(idx)` | helper | Stores the prefetched dict in `tools.attachments._QUESTION_INDEX`. |
122
+ | `set_current_task(question)` | helper | Sets both `_CURRENT_TASK["id"]` and `_CURRENT_TASK["question"]`. The stored question is read by the image handler to scope VLM analysis to the current question (generic captions miss details). |
123
+
124
+ ### task_id plumbing (important architectural quirk)
125
+
126
+ `BasicAgent.__call__(question: str) -> str` has a fixed signature dictated by
127
+ `run_and_submit_all` in `app.py` (`agent(question_text)`). The agent therefore can't be
128
+ passed the `task_id` directly, but `get_attached_file` needs it to hit `/files/{task_id}`.
129
+
130
+ The workaround uses **module-level mutable state** in `tools/attachments.py`:
131
+
132
+ 1. `BasicAgent.__init__` calls `prefetch_question_index()` once β†’ builds `{question: task_id}`,
133
+ stores it via `set_question_index()` into `_QUESTION_INDEX`.
134
+ 2. Each call to `BasicAgent.__call__` runs `set_current_task(question)`, which looks up the
135
+ task_id and writes both `_CURRENT_TASK["id"]` and `_CURRENT_TASK["question"]`. The
136
+ stored question is read by the image handler and injected into the VLM prompt so the
137
+ model focuses on details relevant to the actual question.
138
+ 3. When the LLM calls `get_attached_file()` (no args), the tool reads `_CURRENT_TASK["id"]`
139
+ from module globals and fetches `/files/{task_id}`, then dispatches by Content-Type +
140
+ filename extension (Content-Disposition).
141
+
142
+ If you add tools that need other per-task context, follow this same prefetch + module-global
143
+ pattern rather than threading arguments through (the smolagents tool signature constraint
144
+ will fight you).
145
+
146
+ ### Answer cache (`answer_cache.py`)
147
+
148
+ `run_and_submit_all` loads `.cache/answers.json` once at start and consults it before
149
+ calling the agent for each task. On a hit, the cached answer is reused without any LLM
150
+ call. On a fresh agent call, `save_answer(task_id, question, answer)` is invoked
151
+ immediately after β€” written atomically (temp file + `os.replace`) so a crash mid-write
152
+ won't corrupt the cache.
153
+
154
+ `AGENT_ERROR:`-prefixed answers are *never* cached: those represent recoverable failures
155
+ (timeouts, rate limits) that should be retried on the next run, not memoized.
156
+
157
+ To force a re-run from scratch, delete `.cache/answers.json` (or call `clear_cache()`).
158
+ The cache key is `task_id`, so changing the question text alone won't invalidate an entry
159
+ β€” if the scoring server changes a question while keeping the same task_id, you must
160
+ clear manually.
161
+
162
+ ### Code style
163
+
164
+ All inline comments and docstrings (other than the `@tool` LLM-facing docstrings) are in
165
+ **Korean**. The `@tool` docstrings are deliberately in English because they are sent to the
166
+ LLM as part of the tool catalog. Keep this split when editing β€” Korean for human-facing
167
+ context, English for anything the LLM reads.
168
+
169
+ ## When editing
170
+
171
+ - Touching the answer-formatting rules: edit `GAIA_ANSWER_GUIDELINES` in `prompts.py`. These
172
+ are appended to smolagents' system prompt at runtime; reasons for each rule (especially
173
+ the "no markdown fences", "commit by step 6-8", and "no fabrication" rules) are documented
174
+ inline.
175
+ - Tweaking multi-hop decomposition: edit `DECOMPOSITION_PROMPT` in `prompts.py` for the
176
+ classifier behaviour, and `decomposer.py` if you want to change the model id or the
177
+ `SINGLE-HOP` short-circuit. The marker string is consumed by a regex in `decomposer.py`
178
+ (`SINGLE[\s\-]*HOP`); changing the marker requires updating both.
179
+ - Tightening / loosening deterministic answer coercion: edit `formatter.py:coerce_answer`.
180
+ Coercions are intentionally narrow (yes/no, "how many", currency-pattern); broader
181
+ rewrites historically destroyed more correct answers than they salvaged.
182
+ - Touching `__call__`'s output post-processing: any change can flip many answers between
183
+ pass/fail under exact-match. The current strip rules are conservative β€” only known LLM
184
+ artifacts (FINAL ANSWER prefix, single pair of surrounding quotes).
185
+ - Adding a new tool: register it in `tools/__init__.py`, then add it to the `tools=[...]`
186
+ list in `BasicAgent.__init__`. Keep its `@tool` docstring in English and write the
187
+ argument descriptions clearly β€” they become the LLM's only spec for the tool.
188
+ - The `additional_authorized_imports` list gates sandbox imports. If a tool returns CSV/JSON
189
+ that the agent will need to parse with a new library, add it here.
190
+ - Image and audio handlers in `get_attached_file` call HF Inference API (Qwen2.5-VL-7B,
191
+ Whisper-large-v3) and require `HF_TOKEN` to be set. In a deployed Space, register it as
192
+ a Space secret. Locally, `huggingface-cli login` works. If `HF_TOKEN` is missing or rate
193
+ limits hit, the handlers return a readable error string and the agent prompt instructs
194
+ it to fall back to web/wikipedia search.
answer_cache.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-task λ‹΅λ³€ μΊμ‹œ.
2
+
3
+ 채점 μ„œλ²„μ— 닡을 ν•œ 번 μ „μ†‘ν•˜λ”λΌλ„ 점수 확인·디버깅을 μœ„ν•΄ λ‹€μ‹œ λŒλ €μ•Ό ν•  λ•Œκ°€
4
+ μžˆλ‹€. ν•œ λ¬Έμ œλ§ˆλ‹€ LLM 호좜이 λΉ„μ‹Έλ―€λ‘œ task_id λ‹¨μœ„λ‘œ 닡을 λ””μŠ€ν¬μ— 보관해
5
+ μž¬μ‹€ν–‰ μ‹œ μΊμ‹œλœ λ¬Έμ œλŠ” μŠ€ν‚΅ν•œλ‹€. ν•œ λ¬Έμ œμ—μ„œ 죽으면 전체가 λ©ˆμΆ”λ˜ λΆ€μˆ˜ λ¬Έμ œλ„
6
+ ν•¨κ»˜ μ™„ν™” β€” λ‹€μŒ μ‹€ν–‰ λ•Œ κ·Έ 문제만 λ‹€μ‹œ ν’€λ©΄ λœλ‹€.
7
+
8
+ μ €μž₯ μœ„μΉ˜: .cache/answers.json (이미 디렉터리 쑴재).
9
+ ν˜•μ‹: {task_id: {"question": str, "answer": str}}
10
+ μ›μžμ  μ“°κΈ°: μž„μ‹œ 파일 β†’ os.replace 둜 λ™μ‹œμ“°κΈ°/ν¬λž˜μ‹œ μ•ˆμ „.
11
+
12
+ μΊμ‹œ λ¬΄νš¨ν™”λŠ” 파일 직접 μ‚­μ œ(rm .cache/answers.json) λ˜λŠ” clear_cache() μ‚¬μš©.
13
+ """
14
+ import json
15
+ import os
16
+ import tempfile
17
+ from pathlib import Path
18
+
19
+ _CACHE_PATH = Path(".cache") / "answers.json"
20
+
21
+
22
+ def load_cache() -> dict:
23
+ """λ””μŠ€ν¬μ—μ„œ μΊμ‹œ λ‘œλ“œ. 파일 μ—†κ±°λ‚˜ κΉ¨μ§€λ©΄ 빈 dict."""
24
+ if not _CACHE_PATH.exists():
25
+ return {}
26
+ try:
27
+ return json.loads(_CACHE_PATH.read_text(encoding="utf-8"))
28
+ except Exception as e:
29
+ print(f"Warning: cache load failed ({e}); starting empty.")
30
+ return {}
31
+
32
+
33
+ def save_answer(task_id: str, question: str, answer: str) -> None:
34
+ """task_id 닡을 μΊμ‹œμ— μΆ”κ°€ν•˜κ³  μ›μžμ μœΌλ‘œ μ €μž₯.
35
+ AGENT_ERROR κ²°κ³ΌλŠ” μΊμ‹œ μ•ˆ 함 β€” μž¬μ‹€ν–‰ μ‹œ λ‹€μ‹œ μ‹œλ„ν•΄μ•Ό ν•˜λŠ” ν•­λͺ©μ΄λΌ."""
36
+ if not task_id or is_retryable_answer(answer):
37
+ return
38
+ cache = load_cache()
39
+ cache[task_id] = {"question": question, "answer": answer}
40
+ _CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
41
+ # 같은 디렉터리에 μž„μ‹œ 파일 β†’ os.replace 둜 OS-level atomic rename.
42
+ fd, tmp = tempfile.mkstemp(prefix="answers.", suffix=".tmp", dir=str(_CACHE_PATH.parent))
43
+ try:
44
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
45
+ json.dump(cache, f, ensure_ascii=False, indent=2)
46
+ os.replace(tmp, _CACHE_PATH)
47
+ except Exception:
48
+ # μ‹€νŒ¨ μ‹œ μž„μ‹œ 파일 정리 ν›„ μž¬λ°œμƒ.
49
+ try:
50
+ os.unlink(tmp)
51
+ except OSError:
52
+ pass
53
+ raise
54
+
55
+
56
+ def get_cached_answer(task_id: str, cache: dict | None = None) -> str | None:
57
+ """μΊμ‹œμ—μ„œ task_id의 닡을 κΊΌλ‚΄κ±°λ‚˜ None.
58
+ cache 인자λ₯Ό μ£Όλ©΄ 맀번 λ””μŠ€ν¬ μž¬λ‘œλ”© μ•ˆ 함(λ£¨ν”„μ—μ„œ 유용)."""
59
+ if cache is None:
60
+ cache = load_cache()
61
+ entry = cache.get(task_id)
62
+ if entry and isinstance(entry, dict):
63
+ return entry.get("answer")
64
+ return None
65
+
66
+
67
+ def is_retryable_answer(answer: str | None) -> bool:
68
+ """λ‹€μŒ μ‹€ν–‰μ—μ„œ λ‹€μ‹œ μ‹œλ„ν•΄μ•Ό ν•  닡변인지 νŒλ³„."""
69
+ if answer is None:
70
+ return True
71
+ a = str(answer).strip()
72
+ if not a:
73
+ return True
74
+ upper = a.upper()
75
+ return (
76
+ upper == "UNKNOWN"
77
+ or upper == "UNK"
78
+ or upper.startswith("AGENT_ERROR:")
79
+ or upper.startswith("AGENT ERROR:")
80
+ or "CANNOT ANSWER" in upper
81
+ or "NO FINAL ANSWER" in upper
82
+ )
83
+
84
+
85
+ def clear_cache() -> None:
86
+ """μˆ˜λ™ 호좜용. μžλ™ 호좜 μ•ˆ 함."""
87
+ if _CACHE_PATH.exists():
88
+ _CACHE_PATH.unlink()
89
+
90
+
91
+ def invalidate_tasks(task_ids) -> int:
92
+ """μ£Όμ–΄μ§„ task_id λͺ©λ‘λ§Œ μΊμ‹œμ—μ„œ μ œκ±°ν•˜κ³  μ›μžμ μœΌλ‘œ μ €μž₯.
93
+
94
+ κ°œμ„  λ‹¨κ³„λ§ˆλ‹€ "μ˜€λ‹΅μ΄μ—ˆλ˜ task_id만 μž¬μ‹œλ„" ν•˜κΈ° μœ„ν•œ 헬퍼.
95
+ μ •λ‹΅μœΌλ‘œ μΊμ‹œλœ ν•­λͺ©μ€ 보쑴해 토큰을 μ•„λΌλ©΄μ„œ, λ³€κ²½ 효과만 깨끗이 μΈ‘μ •.
96
+
97
+ Returns:
98
+ μ‹€μ œλ‘œ 제거된 ν•­λͺ© 수.
99
+ """
100
+ cache = load_cache()
101
+ removed = 0
102
+ for tid in task_ids:
103
+ if tid in cache:
104
+ del cache[tid]
105
+ removed += 1
106
+ if removed == 0:
107
+ return 0
108
+ _CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
109
+ fd, tmp = tempfile.mkstemp(prefix="answers.", suffix=".tmp", dir=str(_CACHE_PATH.parent))
110
+ try:
111
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
112
+ json.dump(cache, f, ensure_ascii=False, indent=2)
113
+ os.replace(tmp, _CACHE_PATH)
114
+ except Exception:
115
+ try:
116
+ os.unlink(tmp)
117
+ except OSError:
118
+ pass
119
+ raise
120
+ return removed
app.py CHANGED
@@ -1,34 +1,175 @@
1
  import os
 
2
  import gradio as gr
3
  import requests
4
- import inspect
5
  import pandas as pd
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  # (Keep Constants as is)
8
  # --- Constants ---
 
9
  DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
10
 
 
11
  # --- Basic Agent Definition ---
12
- # ----- THIS IS WERE YOU CAN BUILD WHAT YOU WANT ------
13
  class BasicAgent:
 
 
 
 
 
 
 
 
14
  def __init__(self):
15
  print("BasicAgent initialized.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  def __call__(self, question: str) -> str:
 
 
17
  print(f"Agent received question (first 50 chars): {question[:50]}...")
18
- fixed_answer = "This is a default answer."
19
- print(f"Agent returning fixed answer: {fixed_answer}")
20
- return fixed_answer
 
 
 
 
21
 
22
- def run_and_submit_all( profile: gr.OAuthProfile | None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  """
24
  Fetches all questions, runs the BasicAgent on them, submits all answers,
25
  and displays the results.
26
  """
27
  # --- Determine HF Space Runtime URL and Repo URL ---
28
- space_id = os.getenv("SPACE_ID") # Get the SPACE_ID for sending link to the code
29
 
30
  if profile:
31
- username= f"{profile.username}"
32
  print(f"User logged in: {username}")
33
  else:
34
  print("User not logged in.")
@@ -44,8 +185,12 @@ def run_and_submit_all( profile: gr.OAuthProfile | None):
44
  except Exception as e:
45
  print(f"Error instantiating agent: {e}")
46
  return f"Error initializing agent: {e}", None
47
- # In the case of an app running as a hugging Face space, this link points toward your codebase ( usefull for others so please keep it public)
48
- agent_code = f"https://huggingface.co/spaces/{space_id}/tree/main"
 
 
 
 
49
  print(agent_code)
50
 
51
  # 2. Fetch Questions
@@ -55,16 +200,16 @@ def run_and_submit_all( profile: gr.OAuthProfile | None):
55
  response.raise_for_status()
56
  questions_data = response.json()
57
  if not questions_data:
58
- print("Fetched questions list is empty.")
59
- return "Fetched questions list is empty or invalid format.", None
60
  print(f"Fetched {len(questions_data)} questions.")
61
  except requests.exceptions.RequestException as e:
62
  print(f"Error fetching questions: {e}")
63
  return f"Error fetching questions: {e}", None
64
  except requests.exceptions.JSONDecodeError as e:
65
- print(f"Error decoding JSON response from questions endpoint: {e}")
66
- print(f"Response text: {response.text[:500]}")
67
- return f"Error decoding server response for questions: {e}", None
68
  except Exception as e:
69
  print(f"An unexpected error occurred fetching questions: {e}")
70
  return f"An unexpected error occurred fetching questions: {e}", None
@@ -72,26 +217,56 @@ def run_and_submit_all( profile: gr.OAuthProfile | None):
72
  # 3. Run your Agent
73
  results_log = []
74
  answers_payload = []
75
- print(f"Running agent on {len(questions_data)} questions...")
 
 
 
76
  for item in questions_data:
77
  task_id = item.get("task_id")
78
  question_text = item.get("question")
79
  if not task_id or question_text is None:
80
  print(f"Skipping item with missing task_id or question: {item}")
81
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  try:
83
  submitted_answer = agent(question_text)
 
 
 
 
 
 
84
  answers_payload.append({"task_id": task_id, "submitted_answer": submitted_answer})
85
  results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": submitted_answer})
86
  except Exception as e:
87
- print(f"Error running agent on task {task_id}: {e}")
88
- results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": f"AGENT ERROR: {e}"})
 
 
 
 
 
 
 
89
 
90
  if not answers_payload:
91
  print("Agent did not produce any answers to submit.")
92
  return "Agent did not produce any answers to submit.", pd.DataFrame(results_log)
93
 
94
- # 4. Prepare Submission
95
  submission_data = {"username": username.strip(), "agent_code": agent_code, "answers": answers_payload}
96
  status_update = f"Agent finished. Submitting {len(answers_payload)} answers for user '{username}'..."
97
  print(status_update)
@@ -193,4 +368,4 @@ if __name__ == "__main__":
193
  print("-"*(60 + len(" App Starting ")) + "\n")
194
 
195
  print("Launching Gradio Interface for Basic Agent Evaluation...")
196
- demo.launch(debug=True, share=False)
 
1
  import os
2
+ import re
3
  import gradio as gr
4
  import requests
 
5
  import pandas as pd
6
 
7
+ # smolagents: HFκ°€ λ§Œλ“  μ—μ΄μ „νŠΈ ν”„λ ˆμž„μ›Œν¬. CodeAgentλŠ” LLM이 λ§€ μŠ€ν…λ§ˆλ‹€ 파이썬
8
+ # μ½”λ“œλ₯Ό 생성·싀행해 도ꡬλ₯Ό ν˜ΈμΆœν•˜λŠ” ReAct λ³€ν˜•μ΄λ‹€.
9
+ from smolagents import CodeAgent, InferenceClientModel
10
+
11
+ # λ„κ΅¬λŠ” tools/ νŒ¨ν‚€μ§€μ— λΆ„λ¦¬λ˜μ–΄ μžˆλ‹€. 각 파일이 ν•˜λ‚˜μ˜ @tool ν•¨μˆ˜λ₯Ό λ‹΄λ‹Ή.
12
+ from tools import (
13
+ web_search,
14
+ visit_webpage,
15
+ wikipedia_search,
16
+ youtube_info,
17
+ exec_python_code,
18
+ get_attached_file,
19
+ prefetch_question_index,
20
+ set_question_index,
21
+ set_current_task,
22
+ )
23
+ # GAIA exact-match 채점에 맞좘 μ‹œμŠ€ν…œ ν”„λ‘¬ν”„νŠΈ κ°€μ΄λ“œλΌμΈ.
24
+ from prompts import GAIA_ANSWER_GUIDELINES
25
+ # 멀티홉 질문 사전 λΆ„ν•΄(query decomposition).
26
+ from decomposer import decompose_question
27
+ # λ‹΅λ³€ 캐싱(μž¬μ‹€ν–‰ μ‹œ μ²˜λ¦¬ν•œ 문제 μŠ€ν‚΅, ν•œ 문제 μ‹€νŒ¨μ˜ cascade λ°©μ§€).
28
+ from answer_cache import load_cache, save_answer, is_retryable_answer
29
+ # λ‹΅λ³€ 포맷 ν›„μ²˜λ¦¬(exact-match 채점 보정).
30
+ from formatter import coerce_answer, final_format_pass
31
+
32
  # (Keep Constants as is)
33
  # --- Constants ---
34
+ # 채점 μ„œλ²„ 베이슀 URL. /questions 둜 문제λ₯Ό λ°›κ³ , /submit 으둜 닡을 μ œμΆœν•œλ‹€.
35
  DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
36
 
37
+
38
  # --- Basic Agent Definition ---
39
+ # ----- THIS IS WHERE YOU CAN BUILD WHAT YOU WANT ------
40
  class BasicAgent:
41
+ """GAIA Level 1 문제λ₯Ό ν‘ΈλŠ” μ—μ΄μ „νŠΈ.
42
+
43
+ μ‹€μ œ 좔둠은 smolagents.CodeAgent에 μœ„μž„ν•œλ‹€. CodeAgentλŠ” λ§€ μŠ€ν…λ§ˆλ‹€
44
+ LLM(InferenceClientModel)에 μ»¨ν…μŠ€νŠΈλ₯Ό 보내 파이썬 μ½”λ“œλ₯Ό λ°›μ•„μ˜€κ³ ,
45
+ κ·Έ μ½”λ“œλ₯Ό μ•ˆμ „ν•œ μƒŒλ“œλ°•μŠ€μ—μ„œ μ‹€ν–‰ν•΄ 도ꡬ κ²°κ³Όλ₯Ό λ‹€μ‹œ LLMμ—κ²Œ μ „λ‹¬ν•œλ‹€.
46
+ μ΅œμ’…μ μœΌλ‘œ LLM이 final_answer(...)λ₯Ό ν˜ΈμΆœν•˜λ©΄ κ·Έ 값이 self.agent.run의 λ°˜ν™˜κ°’μ΄ λœλ‹€.
47
+ """
48
+
49
  def __init__(self):
50
  print("BasicAgent initialized.")
51
+ # λͺ¨λΈ: Qwen2.5-72B-Instruct (μ˜€ν”ˆμ›¨μ΄νŠΈ, 32k ctx).
52
+ # provider="hf-inference"둜 λͺ…μ‹œ β€” HF λ„€μ΄ν‹°λΈŒ serverless 라인이라 무료 ν’€.
53
+ # μ‹œλ„ν–ˆλ˜ λ‹€λ₯Έ λͺ¨λΈλ“€μ˜ κ²°κ³Ό:
54
+ # - DeepSeek-V3 + provider="auto" β†’ Together둜 λΌμš°νŒ… β†’ 503/402 (ν¬λ ˆλ”§ μ†Œμ§„)
55
+ # - Llama-3.3-70B-Instruct + provider="hf-inference" β†’ 400 Bad request
56
+ # (hf-inference에 ν˜ΈμŠ€νŒ… μ•ˆ 됨, paid provider μ „μš©)
57
+ # Qwen2.5-72BλŠ” hf-inferenceμ—μ„œ ν˜ΈμŠ€νŒ…μ΄ ν™•μΈλœ λͺ¨λΈ 쀑 μΆ”λ‘ λ ₯ κ°€μž₯ 강함.
58
+ # 큐 λŒ€κΈ° 가끔 μžˆμ–΄λ„ ν‚€ μ •μ±… + 무료 μ œμ•½μ—μ„œλŠ” μ΅œμ„ μ˜ 선택.
59
+ # 코더 λͺ¨λΈλ‘œ λ°”κΎΈμ§€ 말 것: λ§€ μŠ€ν… λ§ˆν¬λ‹€μš΄ μž”μž¬(```, </code])λ₯Ό 흘렀
60
+ # smolagents μ½”λ“œ νŒŒμ„œκ°€ κΉ¨μ§„λ‹€(이전 32B 코더 μ‹œλ„μ—μ„œ 확인됨).
61
+ self.model = InferenceClientModel(
62
+ model_id="Qwen/Qwen2.5-72B-Instruct",
63
+ provider="hf-inference",
64
+ max_tokens=2048, # ν•œ μŠ€ν…λ‹Ή LLM 응닡 토큰 ν•œλ„
65
+ )
66
+
67
+ # /questions ν•œ 번 prefetch ν•΄μ„œ {질문본문: task_id} 인덱슀 λΉŒλ“œ.
68
+ # tools.attachments λͺ¨λ“ˆ 전역에 등둝 β†’ __call__ μ§„μž… μ‹œ set_current_task()κ°€ μ‚¬μš©.
69
+ idx = prefetch_question_index()
70
+ set_question_index(idx)
71
+ print(f"Prefetched question index: {len(idx)} entries")
72
+
73
+ # 도ꡬ 6μ’…: web_search, visit_webpage, wikipedia_search, youtube_info,
74
+ # exec_python_code, get_attached_file.
75
+ # max_steps=12: 8μŠ€ν…μ—μ„  검색 μ‹€νŒ¨λ‘œ λ‹€λ₯Έ 쿼리λ₯Ό μ‹œλ„ν•˜λ‹€ ν•œλ„μ— κ±Έλ¦¬λŠ” 일이 μž¦μ•˜λ‹€.
76
+ # additional_authorized_imports: μƒŒλ“œλ°•μŠ€μ—μ„œ ν‘œ 처리/계산이 ν•„μš”ν•  λ•Œ import ν—ˆμš©.
77
+ self.agent = CodeAgent(
78
+ tools=[
79
+ web_search,
80
+ visit_webpage,
81
+ wikipedia_search,
82
+ youtube_info,
83
+ exec_python_code,
84
+ get_attached_file,
85
+ ],
86
+ model=self.model,
87
+ max_steps=12,
88
+ additional_authorized_imports=[
89
+ "pandas", "openpyxl", "json", "re", "math", "statistics", "itertools",
90
+ "datetime", "collections", "urllib.parse",
91
+ ],
92
+ )
93
+ # CodeAgent의 κΈ°λ³Έ μ‹œμŠ€ν…œ ν”„λ‘¬ν”„νŠΈ 뒀에 GAIA용 채점 κ·œμΉ™μ„ 덧뢙인닀.
94
+ try:
95
+ current_sp = self.agent.prompt_templates.get("system_prompt", "")
96
+ self.agent.prompt_templates["system_prompt"] = (
97
+ current_sp + "\n\n" + GAIA_ANSWER_GUIDELINES
98
+ )
99
+ except Exception as e:
100
+ print(f"Warning: could not patch system prompt: {e}")
101
+
102
  def __call__(self, question: str) -> str:
103
+ # μ‹œκ·Έλ‹ˆμ²˜λŠ” (self, question: str) -> str둜 κ³ μ •. run_and_submit_all이
104
+ # `agent(question_text)` ν˜•νƒœλ‘œ ν˜ΈμΆœν•˜λ―€λ‘œ 인자 μΆ”κ°€ κΈˆμ§€.
105
  print(f"Agent received question (first 50 chars): {question[:50]}...")
106
+ # ν˜„μž¬ 문제의 task_idλ₯Ό tools.attachments 전역에 μ„ΈνŒ… β†’ get_attached_file() κ°€
107
+ # 인자 없이 λ™μž‘. λ§€μΉ­ μ‹€νŒ¨ μ‹œ None(첨뢀 μ—†λŠ” 문제처럼 처리됨).
108
+ tid = set_current_task(question)
109
+ if tid:
110
+ print(f" β†’ matched task_id: {tid}")
111
+ else:
112
+ print(" β†’ no matched task_id (question not in cache)")
113
 
114
+ # 멀티홉 μ§ˆλ¬Έμ€ 1콜둜 plan을 뽑아 prompt에 prepend ν•œλ‹€. λ³Έ 루프(12μŠ€ν…)κ°€
115
+ # 첫 μŠ€ν…λΆ€ν„° κ³§μž₯ 도ꡬ 호좜둜 듀어가도둝 μœ λ„. 단일 lookup이면 None이
116
+ # λ°˜ν™˜λ˜μ–΄ 원본 질문 κ·ΈλŒ€λ‘œ μ§„ν–‰. λΆ„ν•΄ μ‹€νŒ¨λ„ None β†’ degrade μ•ˆμ „.
117
+ plan = decompose_question(question)
118
+ if plan:
119
+ print(f" β†’ decomposition plan:\n{plan}")
120
+ prompt_question = (
121
+ f"{question}\n\n"
122
+ f"--- Suggested decomposition plan (guidance β€” deviate as tool results show) ---\n"
123
+ f"{plan}\n"
124
+ f"--- end plan ---\n"
125
+ f"The final answer must address the ORIGINAL question above, not the plan."
126
+ )
127
+ else:
128
+ prompt_question = question
129
+
130
+ try:
131
+ raw = self.agent.run(prompt_question)
132
+ answer = str(raw).strip()
133
+ # 1) "FINAL ANSWER:" / "FINAL ANSWER -" 같은 prefix 제거(case-insensitive).
134
+ answer = re.sub(
135
+ r"^\s*FINAL\s*ANSWER\s*[:\-]?\s*",
136
+ "",
137
+ answer,
138
+ flags=re.IGNORECASE,
139
+ ).strip()
140
+ # 2) 양끝을 λ‘˜λŸ¬μ‹Ό λ”°μ˜΄ν‘œ 제거. (LLM이 μ’…μ’… "Answer" ν˜•νƒœλ‘œ λ”°μ˜΄ν‘œλ₯Ό 뢙인닀.)
141
+ if len(answer) >= 2 and (
142
+ (answer[0] == '"' and answer[-1] == '"')
143
+ or (answer[0] == "'" and answer[-1] == "'")
144
+ ):
145
+ answer = answer[1:-1].strip()
146
+ # 3) Final-answer formatter pass β€” 별도 LLM 호좜둜 GAIA 포맷 κ°•μ œ.
147
+ # λ‚΄μš© 맞고 ν˜•μ‹ μœ„λ°˜μΈ B μΉ΄ν…Œκ³ λ¦¬ 회볡용. 호좜 μ‹€νŒ¨ μ‹œ raw μœ μ§€(graceful degrade).
148
+ answer = final_format_pass(question, answer)
149
+ # 4) 결정적 regex ν›„μ²˜λ¦¬(yes/no, 숫자, 톡화). final_format_passκ°€ λ†“μΉœ νŒ¨ν„΄ μ•ˆμ „λ§.
150
+ answer = coerce_answer(question, answer)
151
+ print(f"Agent returning answer: {answer}")
152
+ return answer
153
+ except Exception as e:
154
+ # ν•œ λ¬Έμ œμ—μ„œ raise되면 전체 채점이 λ©ˆμΆ”λ―€λ‘œ μ—¬κΈ°μ„œ ν‘μˆ˜ν•˜κ³ 
155
+ # AGENT_ERROR λ¬Έμžμ—΄μ„ λ‹΅μœΌλ‘œ μ œμΆœν•œλ‹€(μ–΄μ°¨ν”Ό μ˜€λ‹΅ 처리됨).
156
+ # 제좜 λ¬Έμžμ—΄μ€ νƒ€μž…λ§Œ λ…ΈμΆœ(μƒμ„ΈλŠ” λ‘œκ·Έμ—λ§Œ) β€” μ˜ˆμ™Έ λ©”μ‹œμ§€ 유좜 μ™„ν™”.
157
+ import traceback
158
+ err_type = type(e).__name__
159
+ print(f"Agent error ({err_type}): {e}")
160
+ print(traceback.format_exc()[-600:])
161
+ return f"AGENT_ERROR: {err_type}"
162
+
163
+ def run_and_submit_all(profile: gr.OAuthProfile | None):
164
  """
165
  Fetches all questions, runs the BasicAgent on them, submits all answers,
166
  and displays the results.
167
  """
168
  # --- Determine HF Space Runtime URL and Repo URL ---
169
+ space_id = os.getenv("SPACE_ID") # Space 배포 μ‹œ μžλ™ μ„€μ •; λ‘œμ»¬μ—μ„œλŠ” 보톡 μ—†μŒ
170
 
171
  if profile:
172
+ username = f"{profile.username}"
173
  print(f"User logged in: {username}")
174
  else:
175
  print("User not logged in.")
 
185
  except Exception as e:
186
  print(f"Error instantiating agent: {e}")
187
  return f"Error initializing agent: {e}", None
188
+ # SPACE_ID μ—†μœΌλ©΄ /spaces/None/... 둜 κΉ¨μ§€μ§€ μ•Šλ„λ‘ κ³ μ • λ¬Έμ„œ URL μ‚¬μš©.
189
+ if space_id:
190
+ agent_code = f"https://huggingface.co/spaces/{space_id}/tree/main"
191
+ else:
192
+ agent_code = "https://huggingface.co/docs/hub/spaces"
193
+ print("SPACE_ID unset β€” using docs URL for agent_code (set when deploying to HF Spaces).")
194
  print(agent_code)
195
 
196
  # 2. Fetch Questions
 
200
  response.raise_for_status()
201
  questions_data = response.json()
202
  if not questions_data:
203
+ print("Fetched questions list is empty.")
204
+ return "Fetched questions list is empty or invalid format.", None
205
  print(f"Fetched {len(questions_data)} questions.")
206
  except requests.exceptions.RequestException as e:
207
  print(f"Error fetching questions: {e}")
208
  return f"Error fetching questions: {e}", None
209
  except requests.exceptions.JSONDecodeError as e:
210
+ print(f"Error decoding JSON response from questions endpoint: {e}")
211
+ print(f"Response text: {response.text[:500]}")
212
+ return f"Error decoding server response for questions: {e}", None
213
  except Exception as e:
214
  print(f"An unexpected error occurred fetching questions: {e}")
215
  return f"An unexpected error occurred fetching questions: {e}", None
 
217
  # 3. Run your Agent
218
  results_log = []
219
  answers_payload = []
220
+ # μΊμ‹œλŠ” .cache/answers.json. ν•œ 번 닡을 받은 task_idλŠ” μž¬μ‹€ν–‰ μ‹œ LLM 호좜
221
+ # 없이 κ·ΈλŒ€λ‘œ μž¬μ‚¬μš© β€” 전체 채점 μž¬μ‹œλ„ λΉ„μš© 절감 + ν•œ 문제 μ‹€νŒ¨ cascade λ°©μ§€.
222
+ cache = load_cache()
223
+ print(f"Running agent on {len(questions_data)} questions... (cache: {len(cache)} entries)")
224
  for item in questions_data:
225
  task_id = item.get("task_id")
226
  question_text = item.get("question")
227
  if not task_id or question_text is None:
228
  print(f"Skipping item with missing task_id or question: {item}")
229
  continue
230
+
231
+ cached = cache.get(task_id)
232
+ if cached and isinstance(cached, dict) and "answer" in cached:
233
+ submitted_answer = cached["answer"]
234
+ if is_retryable_answer(submitted_answer):
235
+ print(
236
+ f" [cache stale] task_id={task_id}: retrying "
237
+ f"instead of reusing {submitted_answer!r}"
238
+ )
239
+ else:
240
+ print(f" [cache hit] task_id={task_id}: {submitted_answer[:80]}")
241
+ answers_payload.append({"task_id": task_id, "submitted_answer": submitted_answer})
242
+ results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": submitted_answer})
243
+ continue
244
  try:
245
  submitted_answer = agent(question_text)
246
+ # AGENT_ERROR κ²°κ³ΌλŠ” save_answer λ‚΄λΆ€μ—μ„œ μΊμ‹œ μ•ˆ 함(λ‹€μŒ μ‹€ν–‰ λ•Œ μž¬μ‹œλ„).
247
+ save_answer(task_id, question_text, submitted_answer)
248
+ if is_retryable_answer(submitted_answer):
249
+ print(f" [skip retryable answer] task_id={task_id}: {submitted_answer!r}")
250
+ results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": submitted_answer})
251
+ continue
252
  answers_payload.append({"task_id": task_id, "submitted_answer": submitted_answer})
253
  results_log.append({"Task ID": task_id, "Question": question_text, "Submitted Answer": submitted_answer})
254
  except Exception as e:
255
+ err_type = type(e).__name__
256
+ print(f"Error running agent on task {task_id} ({err_type}): {e}")
257
+ results_log.append(
258
+ {
259
+ "Task ID": task_id,
260
+ "Question": question_text,
261
+ "Submitted Answer": f"AGENT_ERROR: {err_type}",
262
+ }
263
+ )
264
 
265
  if not answers_payload:
266
  print("Agent did not produce any answers to submit.")
267
  return "Agent did not produce any answers to submit.", pd.DataFrame(results_log)
268
 
269
+ # 4. Prepare Submission
270
  submission_data = {"username": username.strip(), "agent_code": agent_code, "answers": answers_payload}
271
  status_update = f"Agent finished. Submitting {len(answers_payload)} answers for user '{username}'..."
272
  print(status_update)
 
368
  print("-"*(60 + len(" App Starting ")) + "\n")
369
 
370
  print("Launching Gradio Interface for Basic Agent Evaluation...")
371
+ demo.launch(debug=True, share=False)
decomposer.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """멀티홉 질문 λΆ„ν•΄ (query decomposition).
2
+
3
+ GAIA λ¬Έμ œλŠ” μ’…μ’… "Xκ°€ νƒœμ–΄λ‚œ λ„μ‹œμ˜ 인ꡬ" 처럼 μ—¬λŸ¬ 단계 lookup이 ν•„μš”ν•˜λ‹€.
4
+ 원본 μ§ˆλ¬Έμ„ κ·ΈλŒ€λ‘œ CodeAgent에 λ˜μ§€λ©΄ 첫 1-2 μŠ€ν…μ„ "μ–΄λ–»κ²Œ ν’€μ§€" κ³ λ―Όν•˜λŠ” 데
5
+ μ“°λŠ”λ°, 사전에 1콜둜 plan을 뽑아 prompt에 λ°•μ•„μ£Όλ©΄ λ³Έ 루프가 κ³§μž₯ μ‹€ν–‰
6
+ 단계(검색/λ°©λ¬Έ/μœ„ν‚€)둜 λ„˜μ–΄κ°„λ‹€.
7
+
8
+ λΉ„μš©/이득:
9
+ - 단일 lookup λ¬Έμ œλŠ” SINGLE-HOP으둜 λΆ„κΈ° β†’ 토큰 ~500 λ‚­λΉ„(μŠ€ν… μ ˆμ•½μ€ 0).
10
+ - 멀티홉 λ¬Έμ œμ—μ„œλŠ” 1-2 μŠ€ν… μ ˆμ•½(μŠ€ν…λ‹Ή ~1k+ 토큰) + 검색 쿼리 ν’ˆμ§ˆ ν–₯상.
11
+
12
+ 이 ν•¨μˆ˜λŠ” best-effort 보쑰 단계라 μ‹€νŒ¨ν•˜λ©΄ 항상 None을 λ°˜ν™˜ν•΄ ν˜ΈμΆœμžκ°€
13
+ 원본 질문 κ·ΈλŒ€λ‘œ μ§„ν–‰ν•˜λ„λ‘ ν•œλ‹€(degrade μ•ˆμ „).
14
+ """
15
+ import re
16
+ from huggingface_hub import InferenceClient
17
+
18
+ from prompts import DECOMPOSITION_PROMPT
19
+
20
+
21
+ def _normalize_decomposition_text(raw: str) -> str:
22
+ """λΆ„ν•΄ λͺ¨λΈ 좜λ ₯μ—μ„œ λ§ˆν¬λ‹€μš΄ νŽœμŠ€Β·λ¨Έλ¦¬λ§μ„ κ±·μ–΄λ‚΄ 번호 ν”Œλžœ 본문만 남긴닀.
23
+
24
+ SINGLE-HOP ν•œ 쀄 응닡은 κ·ΈλŒ€λ‘œ 두고, 1. 2. … 번호 ν”Œλžœμ€ 첫 번호 μ€„λΆ€ν„°λ§Œ
25
+ 잘라 λ„£μ–΄ CodeAgent ν”„λ‘¬ν”„νŠΈ λ…Έμ΄μ¦ˆλ₯Ό 쀄인닀.
26
+ """
27
+ t = (raw or "").strip()
28
+ if not t:
29
+ return t
30
+ if t.startswith("```"):
31
+ t = re.sub(r"^```[a-zA-Z]*\n?", "", t)
32
+ if "```" in t:
33
+ t = t.split("```", 1)[0]
34
+ t = t.strip()
35
+ if re.match(r"^\s*SINGLE[\s\-]*HOP\s*$", t, re.IGNORECASE):
36
+ return t
37
+ m = re.search(r"(?m)^\s*\d+[\.\)]\s+", t)
38
+ if m:
39
+ return t[m.start() :].strip()
40
+ return t
41
+
42
+
43
+ def decompose_question(
44
+ question: str,
45
+ model_id: str = "Qwen/Qwen2.5-72B-Instruct",
46
+ ) -> str | None:
47
+ """μ§ˆλ¬Έμ„ 뢄석해 멀티홉이면 plan ν…μŠ€νŠΈ, 단일 lookup이면 None을 λ°˜ν™˜.
48
+
49
+ Args:
50
+ question: GAIA 질문 본문.
51
+ model_id: 뢄해에 μ“Έ LLM. 기본값은 BasicAgent의 λ³Έ λͺ¨λΈκ³Ό λ™μΌν•˜κ²Œ 두어
52
+ ν’ˆμ§ˆμ„ μΌμΉ˜μ‹œν‚΄(μž‘μ€ λͺ¨λΈμ€ SINGLE-HOP ν˜•μ‹μ„ 자주 μ–΄κΉ€).
53
+
54
+ Returns:
55
+ 멀티홉 plan(numbered list ν…μŠ€νŠΈ) λ˜λŠ” None(단일 lookup/μ‹€νŒ¨).
56
+ """
57
+ try:
58
+ # provider="hf-inference": HF Inference Providers 쀑 κ°€μš©ν•œ 곳으둜 λΌμš°νŒ….
59
+ client = InferenceClient(provider="hf-inference") # HF_TOKEN ν™˜κ²½λ³€μˆ˜ μ‚¬μš©
60
+ resp = client.chat_completion(
61
+ model=model_id,
62
+ messages=[
63
+ {"role": "system", "content": DECOMPOSITION_PROMPT},
64
+ {"role": "user", "content": question},
65
+ ],
66
+ max_tokens=512, # plan은 보톡 5단계 미만 β†’ 넉넉
67
+ )
68
+ text = _normalize_decomposition_text(resp.choices[0].message.content or "")
69
+
70
+ # "SINGLE-HOP" / "SINGLE HOP" / "SINGLEHOP" λ“± λ³€ν˜• λͺ¨λ‘ 처리.
71
+ if re.match(r"^\s*SINGLE[\s\-]*HOP", text, re.IGNORECASE):
72
+ return None
73
+ if not text:
74
+ return None
75
+ return text
76
+ except Exception as e:
77
+ # λΆ„ν•΄ μ‹€νŒ¨λŠ” 채점을 막지 μ•ŠλŠ”λ‹€. 원본 질문으둜 κ·ΈλŒ€λ‘œ μ§„ν–‰.
78
+ print(f"Decomposition failed (proceeding without plan): {e}")
79
+ return None
formatter.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GAIA exact-match 채점에 맞좘 λ‹΅λ³€ 포맷 ν›„μ²˜λ¦¬.
2
+
3
+ 두 λ‹¨κ³„λ‘œ ꡬ성:
4
+ 1. final_format_pass(question, raw): LLM ν•œ 번 더 ν˜ΈμΆœν•΄μ„œ GAIA 포맷으둜만 λ³€ν™˜.
5
+ B μΉ΄ν…Œκ³ λ¦¬(λ‚΄μš© 맞고 ν˜•μ‹ μœ„λ°˜) 회볡용. 짧은 reformat μ „μš© μ‹œμŠ€ν…œ ν”„λ‘¬ν”„νŠΈ.
6
+ 2. coerce_answer(question, ans): 결정적 regex ν›„μ²˜λ¦¬. yes/no, 숫자, 톡화 λ“±
7
+ ν™•μ‹€ν•œ νŒ¨ν„΄λ§Œ κ°•μ œ. λ§€μΉ­ μ‹€νŒ¨ μ‹œ 원본 μœ μ§€(잘λͺ» κ°•μ œν•˜λ©΄ 더 망침).
8
+
9
+ μˆœμ„œ: __call__μ—μ„œ raw β†’ strip prefixes/quotes β†’ final_format_pass β†’ coerce_answer.
10
+ """
11
+ import re
12
+ import unicodedata
13
+
14
+
15
+ # yes/no 질문 μ‹œμž‘ 후보 ν‚€μ›Œλ“œ. μ˜μ–΄ 의문문이 이 λ³΄μ‘°λ™μ‚¬λ‘œ μ‹œμž‘ν•˜κ³  ?둜 λλ‚˜λ©΄
16
+ # λŒ€κ°œ yes/no 닡을 κΈ°λŒ€ν•˜λŠ” ν˜•νƒœ.
17
+ _YES_NO_STARTS = (
18
+ "is ", "are ", "was ", "were ", "do ", "does ", "did ",
19
+ "has ", "have ", "had ", "can ", "could ", "should ",
20
+ "will ", "would ", "may ", "might ",
21
+ )
22
+
23
+
24
+ def _looks_yes_no(question: str) -> bool:
25
+ q = question.strip().lower()
26
+ if "yes or no" in q or "yes/no" in q:
27
+ return True
28
+ if not q.endswith("?"):
29
+ return False
30
+ return any(q.startswith(s) for s in _YES_NO_STARTS)
31
+
32
+
33
+ def _looks_numeric(question: str) -> bool:
34
+ q = question.lower()
35
+ return (
36
+ "how many" in q
37
+ or "what number" in q
38
+ or "what is the number of" in q
39
+ # "how much" λŠ” λ‹¨μœ„ 포함 닡을 원할 μˆ˜λ„ μžˆμ–΄ μ œμ™Έ(예: "how much money" β†’ "$1.5M").
40
+ )
41
+
42
+
43
+ def coerce_answer(question: str, answer: str) -> str:
44
+ """질문 ν˜•μ‹ νžŒνŠΈμ— 맞좰 LLM 닡을 보정. νžŒνŠΈκ°€ μ—†κ±°λ‚˜ λ§€μΉ­ μ‹€νŒ¨ μ‹œ 원본 λ°˜ν™˜."""
45
+ a = answer.strip()
46
+ if not a:
47
+ return a
48
+
49
+ # 1) Yes/No 질문 β€” 첫 λ‹¨μ–΄λ‘œ κ²°μ •.
50
+ if _looks_yes_no(question):
51
+ first = a.split(None, 1)[0].rstrip(",.").lower() if a.split() else ""
52
+ if first == "yes":
53
+ return "Yes"
54
+ if first == "no":
55
+ return "No"
56
+ # λ§€μΉ­ μ‹€νŒ¨ μ‹œ 원본 μœ μ§€(잘λͺ» κ°•μ œν•˜λ©΄ 더 망침).
57
+ return a
58
+
59
+ # 2) 순수 숫자 질문 β€” λ‹΅ μ•ˆμ˜ 첫 μ •μˆ˜/μ‹€μˆ˜λ§Œ μΆ”μΆœ.
60
+ if _looks_numeric(question):
61
+ m = re.search(r"-?\d+(?:\.\d+)?", a.replace(",", ""))
62
+ if m:
63
+ num = m.group(0)
64
+ try:
65
+ f = float(num)
66
+ if f.is_integer():
67
+ return str(int(f))
68
+ return num
69
+ except ValueError:
70
+ pass
71
+ return a
72
+
73
+ # 3) 닡이 ν†΅ν™”κΈ°ν˜Έ+숫자 νŒ¨ν„΄μ΄λ©΄ 기호/콀마/곡백만 제거.
74
+ # "$1,234" β†’ "1234", "1,234.5" β†’ "1234.5"
75
+ if re.fullmatch(r"\s*[\$€£Β₯]?\s*-?[\d,]+(?:\.\d+)?\s*", a):
76
+ cleaned = re.sub(r"[\$€£Β₯,\s]", "", a)
77
+ if cleaned:
78
+ return cleaned
79
+
80
+ return a
81
+
82
+
83
+ # Final-answer formatter pass용 μ‹œμŠ€ν…œ ν”„λ‘¬ν”„νŠΈ. μ§§κ³  λΆ€μ •ν˜• μ΅œμ†Œν™”.
84
+ _FORMAT_SYSTEM_PROMPT = """You reformat agent answers to match the GAIA benchmark
85
+ exact-match grading rules. You receive a question and a draft answer, and output the
86
+ final answer string ONLY (no explanation, no preamble).
87
+
88
+ Rules:
89
+ - Numbers: plain digits, no commas, no currency/units unless the question asks for them.
90
+ - Strings: minimal exact form. No articles ("the", "a"), no abbreviations unless
91
+ abbreviation is the expected form. No surrounding quotes.
92
+ - Lists: comma + single space ("apple, banana, cherry"), in the order requested.
93
+ - Yes/no questions: exactly "Yes" or "No".
94
+ - "Give only the first name" β†’ output only the first name, no surname.
95
+ - "Give only the city name" β†’ only the city, no country/state.
96
+ - If the draft already matches all applicable rules, output it unchanged.
97
+ - If the draft is "UNKNOWN" or admits inability, output "UNKNOWN".
98
+
99
+ Output only the answer string, nothing else.
100
+ """
101
+
102
+
103
+ def final_format_pass(
104
+ question: str,
105
+ raw_answer: str,
106
+ model_id: str = "Qwen/Qwen2.5-72B-Instruct",
107
+ ) -> str:
108
+ """LLM ν•œ 번 더 ν˜ΈμΆœν•΄ raw 닡을 GAIA 포맷으둜만 λ³€ν™˜.
109
+
110
+ 호좜 μ‹€νŒ¨(rate-limit, νƒ€μž„μ•„μ›ƒ λ“±) μ‹œ raw_answerλ₯Ό κ·ΈλŒ€λ‘œ λ°˜ν™˜ β€” graceful
111
+ degrade. coerce_answerκ°€ λ§ˆμ§€λ§‰ μ•ˆμ „λ§μ΄λ―€λ‘œ 이 단계가 μ‹€νŒ¨ν•΄λ„ 큰 μ†ν•΄λŠ” μ—†μŒ.
112
+
113
+ μœ λ‹ˆμ½”λ“œ μ •κ·œν™”(NFC)도 같이 μˆ˜ν–‰ν•΄μ„œ 보이지 μ•ŠλŠ” λ³€ν˜• κΈ€μž(예: κ²°ν•© κΈ€μž
114
+ λΆ„ν•΄λœ ν˜•νƒœ)둜 μΈν•œ mismatch λ°©μ§€.
115
+
116
+ Args:
117
+ question: 원본 질문 λ³Έλ¬Έ.
118
+ raw_answer: μ—μ΄μ „νŠΈκ°€ final_answer둜 λ„˜κΈ΄ raw λ‹΅.
119
+ model_id: 포맷 λ³€ν™˜μ— μ“Έ λͺ¨λΈ (기본은 메인 λͺ¨λΈκ³Ό 동일).
120
+
121
+ Returns:
122
+ 포맷 μ •λ¦¬λœ λ‹΅ λ˜λŠ” raw_answer (호좜 μ‹€νŒ¨ μ‹œ).
123
+ """
124
+ if not raw_answer or raw_answer.strip().upper() == "UNKNOWN":
125
+ return raw_answer
126
+ try:
127
+ from huggingface_hub import InferenceClient
128
+ client = InferenceClient(provider="hf-inference")
129
+ resp = client.chat_completion(
130
+ model=model_id,
131
+ messages=[
132
+ {"role": "system", "content": _FORMAT_SYSTEM_PROMPT},
133
+ {
134
+ "role": "user",
135
+ "content": f"Question: {question}\n\nDraft answer: {raw_answer}\n\nFinal answer:",
136
+ },
137
+ ],
138
+ max_tokens=200, # λ‹΅λ³€ μžμ²΄λŠ” 짧음
139
+ )
140
+ formatted = (resp.choices[0].message.content or "").strip()
141
+ if not formatted:
142
+ return raw_answer
143
+ # 양끝 λ”°μ˜΄ν‘œ ν•œ 쌍 제거 (λͺ¨λΈμ΄ μ’…μ’… "X" ν˜•νƒœλ‘œ λ‘˜λŸ¬μŒˆ)
144
+ if len(formatted) >= 2 and (
145
+ (formatted[0] == '"' and formatted[-1] == '"')
146
+ or (formatted[0] == "'" and formatted[-1] == "'")
147
+ ):
148
+ formatted = formatted[1:-1].strip()
149
+ # NFC μ •κ·œν™”: κ²°ν•© κΈ€μž(예: Ε‚, Γ©) λ³€ν˜• 톡일
150
+ formatted = unicodedata.normalize("NFC", formatted)
151
+ return formatted
152
+ except Exception as e:
153
+ print(f"final_format_pass failed (using raw): {e}")
154
+ return raw_answer
prompts.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GAIA 채점 ν˜•μ‹μ— 맞좘 λ‹΅λ³€ κ°€μ΄λ“œλΌμΈ.
2
+
3
+ CodeAgent의 κΈ°λ³Έ μ‹œμŠ€ν…œ ν”„λ‘¬ν”„νŠΈ 뒀에 append λœλ‹€. exact-match 채점이라 ν˜•μ‹
4
+ μœ„λ°˜ ν•œ κΈ€μžλ§ŒμœΌλ‘œ μ˜€λ‹΅μ΄ λ˜λ―€λ‘œ κ°•ν•˜κ²Œ λ°•μ•„λ‘”λ‹€.
5
+
6
+ λ£° λ³€κ²½ 이λ ₯:
7
+ - κ³Όκ±° "μ½”λ“œ 좜λ ₯에 <code> νƒœκ·Έ μ‚¬μš© κΈˆμ§€" 룰이 μžˆμ—ˆμŒ. 32B 코더 λͺ¨λΈμ΄ λ§€ μŠ€ν…
8
+ `</code]` 같은 κΉ¨μ§„ νƒœκ·Έλ₯Ό 흘렀 νŒŒμ„œκ°€ κΉ¨μ§„ λ°μ„œ μΆœλ°œν•œ λ°©μ–΄ μ½”λ“œμ§€λ§Œ, smolagentsκ°€
9
+ 정상적인 <code>...</code> νƒœκ·Έλ₯Ό *μš”κ΅¬*ν•˜λŠ” ꡬ쑰라 우리 룰이 정상 νƒœκ·ΈκΉŒμ§€ κΈˆμ§€
10
+ β†’ λ‹€λ₯Έ λͺ¨λΈ(DeepSeek-V3, Llama λ“±)μ—μ„œ νŒŒμ„œκ°€ 깨짐. λͺ¨λΈ 선택(코더 λͺ¨λΈ νšŒν”Ό)으둜만
11
+ λ‹€λ£¨κΈ°λ‘œ ν•˜κ³  λ£°μ—μ„œ 제거. LLM-facing λ¬Έμžμ—΄μ— 메타 μ„€λͺ… μ ˆλŒ€ λ„£μ§€ 말 것
12
+ (토큰 λ‚­λΉ„ + λ‹€κ΅­μ–΄ 메타 ν…μŠ€νŠΈκ°€ λͺ¨λΈμ„ ν˜Όλž€μ‹œν‚¬ 수 있음).
13
+ """
14
+
15
+ GAIA_ANSWER_GUIDELINES = """
16
+ You are answering questions from the GAIA benchmark. Your final answer will be graded
17
+ by EXACT STRING MATCH, so formatting matters as much as correctness.
18
+
19
+ CRITICAL β€” never violate these:
20
+ 1. NEVER fabricate data. If a tool returns "No file attached", "No results", or an error,
21
+ try a DIFFERENT query or DIFFERENT tool. Do NOT invent placeholder data and do NOT write
22
+ "Since I can't actually read the file, I'll simulate..." β€” that produces wrong answers.
23
+ If after multiple genuine attempts (different queries, Wikipedia + web_search + visit_webpage)
24
+ you still cannot find the answer, call final_answer("UNKNOWN").
25
+ 2. Always verify a fact against an authoritative source (Wikipedia article body, official site)
26
+ before committing β€” do not commit based on a search-result snippet alone.
27
+ 3. For questions about lists/tables (winners, rosters, dates, etc.), call wikipedia_search
28
+ first; it returns the full article body including [TABLE]...[/TABLE] blocks.
29
+ 4. If the question mentions an attached file, spreadsheet, image, audio, PDF, code listing,
30
+ or "the file I gave you", IMMEDIATELY call get_attached_file() with NO arguments.
31
+ It auto-resolves the current task β€” never pass a placeholder task_id, never simulate
32
+ the file's contents.
33
+ 4b. If the question contains a YouTube URL or asks about a YouTube video's contents,
34
+ call youtube_info(url) with the URL. It returns the video's title, channel, and full
35
+ transcript text. If the transcript is unavailable, fall back to web_search/wikipedia
36
+ for the question's specific facts β€” never simulate transcript content.
37
+ 5. DECIDE AND COMMIT EARLY. As soon as you have enough evidence (typically by step 6-8 out
38
+ of the 12-step budget), call final_answer(...) immediately. Do NOT spend more steps
39
+ re-verifying after you already have a confident answer. Running out of steps means
40
+ smolagents returns your last verbose thought as the answer β€” verbose prose scores ZERO
41
+ on exact-match grading.
42
+
43
+ Answer formatting rules (apply only to the value passed to final_answer):
44
+ - Return ONLY the final answer β€” no explanations, no preamble, no trailing punctuation.
45
+ - Numbers: plain digits, no commas, no currency symbols, no units (unless the question asks for them).
46
+ Use an integer if the answer is a whole number.
47
+ - Strings: minimal exact form, no surrounding quotes, no "The answer is...".
48
+ - Lists: comma-separated with a single space after each comma (e.g., "apple, banana, cherry"),
49
+ in the order the question requests.
50
+ - Yes/no questions: answer exactly "Yes" or "No".
51
+ - Match capitalization, abbreviations, and spelling exactly as the question implies.
52
+
53
+ When you call final_answer(...), pass the bare answer string (or number) only β€”
54
+ do not wrap it in a sentence and do not prefix it with "FINAL ANSWER:".
55
+ """
56
+
57
+
58
+ # 멀티홉 질문 λΆ„ν•΄μš© μ‹œμŠ€ν…œ ν”„λ‘¬ν”„νŠΈ. decomposer.py κ°€ μ‚¬μš©ν•œλ‹€.
59
+ # 좜λ ₯은 numbered plan λ˜λŠ” μ •ν™•νžˆ "SINGLE-HOP" λ‘˜ 쀑 ν•˜λ‚˜λ§Œ ν—ˆμš©.
60
+ DECOMPOSITION_PROMPT = """You are a planner for a GAIA benchmark agent. Your job is to decide whether a question requires multiple sequential lookups, and if so, lay out the minimal sequence.
61
+
62
+ OUTPUT FORMAT:
63
+ - If the question can be answered with ONE lookup or fact, respond with exactly:
64
+ SINGLE-HOP
65
+ - Otherwise, respond with a numbered plan. Each step is a self-contained sub-question answerable by one Wikipedia/web/file lookup. Use placeholders like [step1_answer] when later steps depend on earlier results.
66
+
67
+ GUIDELINES:
68
+ - Prefer fewer steps. Don't pad with verification.
69
+ - A question mentioning an attached file/spreadsheet/image/audio/PDF is usually multi-hop: step 1 = read the file, then combine with question logic.
70
+ - A question asking for a list, count, sum, or aggregation is usually multi-hop.
71
+ - DO NOT answer the question yourself. Output ONLY the plan or SINGLE-HOP β€” no preamble, no trailing text.
72
+
73
+ EXAMPLES:
74
+
75
+ Q: Who directed the 2003 film "Lost in Translation"?
76
+ A: SINGLE-HOP
77
+
78
+ Q: In what year was the author of "The Old Man and the Sea" born?
79
+ A: SINGLE-HOP
80
+
81
+ Q: What is the population of the birthplace of the actor who played Captain Jack Sparrow?
82
+ A:
83
+ 1. Who played Captain Jack Sparrow?
84
+ 2. Where was [step1_answer] born?
85
+ 3. What is the current population of [step2_answer]?
86
+
87
+ Q: How many studio albums did Pink Floyd release before The Dark Side of the Moon?
88
+ A:
89
+ 1. List Pink Floyd's studio albums chronologically.
90
+ 2. Count how many were released before The Dark Side of the Moon (1973).
91
+
92
+ Q: The attached spreadsheet contains 2023 sales by region. What was the total for the South region?
93
+ A:
94
+ 1. Read the attached spreadsheet.
95
+ 2. Filter rows where region = "South" and sum the sales column.
96
+
97
+ Now decompose the user's question. Output ONLY the plan or "SINGLE-HOP".
98
+ """
requirements.txt CHANGED
@@ -1,2 +1,12 @@
1
- gradio
2
- requests
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio[oauth]>=4.44
2
+ requests
3
+ pandas
4
+ openpyxl
5
+ smolagents>=1.10
6
+ ddgs
7
+ duckduckgo-search
8
+ markdownify
9
+ beautifulsoup4
10
+ pypdf>=4.0
11
+ huggingface_hub>=0.28
12
+ youtube-transcript-api>=0.6
tools/__init__.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """μ—μ΄μ „νŠΈ 도ꡬ 묢음.
2
+
3
+ `from tools import web_search, visit_webpage, ...` ν•œ 번으둜 λͺ¨λ‘ import κ°€λŠ₯.
4
+ μ„ΈλΆ€ κ΅¬ν˜„μ€ 각 λͺ¨λ“ˆ νŒŒμΌμ— λΆ„λ¦¬λ˜μ–΄ μžˆλ‹€.
5
+ """
6
+ from .search import web_search
7
+ from .webpage import visit_webpage
8
+ from .wikipedia import wikipedia_search
9
+ from .youtube import youtube_info
10
+ from .exec_code import exec_python_code
11
+ from .attachments import (
12
+ get_attached_file,
13
+ prefetch_question_index,
14
+ set_question_index,
15
+ set_current_task,
16
+ )
17
+
18
+ __all__ = [
19
+ # @tool ν•¨μˆ˜ (CodeAgent에 직접 등둝)
20
+ "web_search",
21
+ "visit_webpage",
22
+ "wikipedia_search",
23
+ "youtube_info",
24
+ "exec_python_code",
25
+ "get_attached_file",
26
+ # 일반 헬퍼 (BasicAgent의 init/__call__μ—μ„œ μ‚¬μš©)
27
+ "prefetch_question_index",
28
+ "set_question_index",
29
+ "set_current_task",
30
+ ]
tools/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (773 Bytes). View file
 
tools/__pycache__/attachments.cpython-312.pyc ADDED
Binary file (14.9 kB). View file
 
tools/__pycache__/exec_code.cpython-312.pyc ADDED
Binary file (3.25 kB). View file
 
tools/__pycache__/search.cpython-312.pyc ADDED
Binary file (8.27 kB). View file
 
tools/__pycache__/webpage.cpython-312.pyc ADDED
Binary file (3.65 kB). View file
 
tools/__pycache__/wikipedia.cpython-312.pyc ADDED
Binary file (4.83 kB). View file
 
tools/__pycache__/youtube.cpython-312.pyc ADDED
Binary file (5.92 kB). View file
 
tools/attachments.py ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GAIA 첨뢀 파일 처리 + μ§ˆλ¬Έβ†”task_id 인덱슀.
2
+
3
+ CodeAgent의 μ‹œκ·Έλ‹ˆμ²˜ μ œμ•½(__call__이 question만 λ°›μŒ) λ•Œλ¬Έμ— task_idλ₯Ό 직접
4
+ μ£Όμž…ν•  수 μ—†μ–΄, λͺ¨λ“ˆ μ „μ—­ mutable μ»¨ν…Œμ΄λ„ˆ + prefetch 인덱슀둜 μš°νšŒν•œλ‹€.
5
+
6
+ 흐름:
7
+ 1) BasicAgent.__init__ 단계에 prefetch_question_index() β†’ /questions 1회 호좜
8
+ ν•΄μ„œ {질문본문: task_id} 사전을 λ§Œλ“€κ³  set_question_index() 둜 등둝.
9
+ 2) BasicAgent.__call__ μ§„μž… μ‹œ set_current_task(question) 으둜 ν˜„μž¬ 문제의
10
+ task_id와 질문 본문을 _CURRENT_TASK 에 μ„ΈνŒ….
11
+ 3) μ—μ΄μ „νŠΈκ°€ get_attached_file() 을 인자 없이 ν˜ΈμΆœν•˜λ©΄ _CURRENT_TASK 의
12
+ task_id둜 채점 μ„œλ²„μ—μ„œ νŒŒμΌμ„ λ°›μ•„μ˜€κ³ , νƒ€μž…λ³„λ‘œ 처리:
13
+ - ν…μŠ€νŠΈ/CSV/JSON/code: UTF-8 λ””μ½”λ”©
14
+ - Excel(.xlsx): μ‹œνŠΈλ³„ CSV
15
+ - PDF: νŽ˜μ΄μ§€λ³„ ν…μŠ€νŠΈ μΆ”μΆœ (pypdf)
16
+ - 이미지: VLM(Qwen2.5-VL-7B)으둜 ν˜„μž¬ 질문 μ»¨ν…μŠ€νŠΈμ— 맞좰 뢄석
17
+ - μ˜€λ””μ˜€: Whisper(large-v3) 전사
18
+ """
19
+ import io
20
+ import re
21
+ import requests
22
+ from smolagents import tool
23
+
24
+ # 채점 μ„œλ²„ URL을 μ—¬κΈ°μ„œλ„ ν•œ 번 μ •μ˜ (app.py와 동일 κ°’).
25
+ # tools λͺ¨λ“ˆμ„ λ…λ¦½μ μœΌλ‘œ μ‚¬μš©ν•˜λ”λΌλ„ μ˜λ―Έκ°€ ν†΅ν•˜λ„λ‘ 뢄리해 λ‘”λ‹€.
26
+ _DEFAULT_API_URL = "https://agents-course-unit4-scoring.hf.space"
27
+
28
+ # BasicAgent.__call__ μ§„μž… μ‹œ κ°±μ‹ λ˜λŠ” mutable μ»¨ν…Œμ΄λ„ˆ.
29
+ # question은 이미지 VLM 호좜 μ‹œ μ»¨ν…μŠ€νŠΈ(prompt)둜 μ‚¬μš©λœλ‹€.
30
+ _CURRENT_TASK = {"id": None, "question": None}
31
+ # question.strip() -> task_id 사전.
32
+ _QUESTION_INDEX: dict = {}
33
+
34
+
35
+ def prefetch_question_index() -> dict:
36
+ """채점 μ„œλ²„ /questions λ₯Ό ν•œ 번 ν˜ΈμΆœν•΄ {질문본문: task_id} 사전을 λΉŒλ“œν•œλ‹€.
37
+ μ‹€νŒ¨ν•΄λ„ 빈 dictλ₯Ό λ°˜ν™˜ν•΄ μ—μ΄μ „νŠΈκ°€ 첨뢀 μ—†λŠ” λ¬Έμ œλ§Œμ΄λΌλ„ ν’€ 수 있게 ν•œλ‹€."""
38
+ try:
39
+ r = requests.get(f"{_DEFAULT_API_URL}/questions", timeout=15)
40
+ r.raise_for_status()
41
+ idx = {}
42
+ for item in r.json():
43
+ qt = (item.get("question") or "").strip()
44
+ tid = item.get("task_id")
45
+ if qt and tid:
46
+ if qt in idx and idx[qt] != tid:
47
+ print(
48
+ "Warning: duplicate question text in prefetch index β€” "
49
+ f"task_id {idx[qt]!r} will be overwritten by {tid!r}"
50
+ )
51
+ idx[qt] = tid
52
+ return idx
53
+ except Exception as e:
54
+ print(f"Warning: could not prefetch question index: {e}")
55
+ return {}
56
+
57
+
58
+ def set_question_index(idx: dict) -> None:
59
+ """BasicAgent.__init__μ—μ„œ prefetch κ²°κ³Όλ₯Ό λͺ¨λ“ˆ 전역에 λ°•μ•„μ£ΌλŠ” μ„Έν„°."""
60
+ global _QUESTION_INDEX
61
+ _QUESTION_INDEX = idx
62
+
63
+
64
+ def set_current_task(question: str):
65
+ """BasicAgent.__call__ μ§„μž… μ‹œ ν˜„μž¬ 문제의 task_id와 질문 본문을 λͺ¨λ“ˆ 전역에 μ„ΈνŒ….
66
+ 질문 본문은 이미지 μ²¨λΆ€μ˜ VLM ν˜ΈμΆœμ— prompt μ»¨ν…μŠ€νŠΈλ‘œ μ „λ‹¬λœλ‹€.
67
+ λ§€μΉ­ μ‹€νŒ¨ μ‹œ task_id둜 None이 λ“€μ–΄κ°€μ§€λ§Œ question은 κ·ΈλŒ€λ‘œ μ €μž₯λœλ‹€."""
68
+ tid = _QUESTION_INDEX.get(question.strip())
69
+ _CURRENT_TASK["id"] = tid
70
+ _CURRENT_TASK["question"] = question
71
+ return tid
72
+
73
+
74
+ # --- 파일 νƒ€μž… λΆ„κΈ° 헬퍼 ---
75
+
76
+ def _extract_filename(headers, url: str) -> str:
77
+ """Content-Disposition ν—€λ”μ—μ„œ filename을 λ½‘κ±°λ‚˜, URL λλΆ€λΆ„μœΌλ‘œ 폴백.
78
+ 채점 μ„œλ²„κ°€ Content-Type을 octet-stream으둜 쀄 λ•Œ ν™•μž₯자둜 λ³΄κ°•ν•˜κΈ° μœ„ν•¨."""
79
+ cd = headers.get("Content-Disposition", "")
80
+ # filename* (RFC 5987) 와 filename= μ–‘μͺ½ λ‹€ 처리.
81
+ m = re.search(r'filename\*?=(?:UTF-8\'\')?"?([^";\r\n]+)"?', cd)
82
+ if m:
83
+ return m.group(1).strip().strip('"')
84
+ return url.rsplit("/", 1)[-1]
85
+
86
+
87
+ def _is_excel(content_type: str, ext: str) -> bool:
88
+ if ext in ("xlsx", "xls"):
89
+ return True
90
+ ct = content_type.lower()
91
+ return "spreadsheet" in ct or ct.endswith("xlsx") or ct.endswith("xls") or "excel" in ct
92
+
93
+
94
+ def _is_pdf(content_type: str, ext: str) -> bool:
95
+ return ext == "pdf" or "pdf" in content_type.lower()
96
+
97
+
98
+ def _is_image(content_type: str, ext: str) -> bool:
99
+ return ext in ("png", "jpg", "jpeg", "webp", "gif", "bmp") \
100
+ or content_type.lower().startswith("image/")
101
+
102
+
103
+ def _is_audio(content_type: str, ext: str) -> bool:
104
+ return ext in ("mp3", "wav", "m4a", "ogg", "flac") \
105
+ or content_type.lower().startswith("audio/")
106
+
107
+
108
+ # --- νƒ€μž…λ³„ ν•Έλ“€λŸ¬ ---
109
+
110
+ def _handle_excel(content: bytes, content_type: str) -> str:
111
+ """xlsx β†’ μ‹œνŠΈλ³„ CSV둜 직렬화. GAIA에 맀좜/판맀 데이터 λ¬Έμ œκ°€ 자주 λ‚˜μ˜¨λ‹€."""
112
+ try:
113
+ import pandas as _pd
114
+ bio = io.BytesIO(content)
115
+ sheets = _pd.read_excel(bio, sheet_name=None)
116
+ parts = []
117
+ for name, df in sheets.items():
118
+ parts.append(f"--- Sheet: {name} ---\n{df.to_csv(index=False)}")
119
+ combined = "\n\n".join(parts)
120
+ if len(combined) > 12000:
121
+ combined = combined[:12000] + "\n...[truncated]"
122
+ return f"[Content-Type: {content_type}]\n{combined}"
123
+ except Exception as e:
124
+ return f"Excel parse error: {e}"
125
+
126
+
127
+ def _handle_pdf(content: bytes, content_type: str) -> str:
128
+ """pypdf둜 PDF λ³Έλ¬Έ ν…μŠ€νŠΈ μΆ”μΆœ. νŽ˜μ΄μ§€λ³„λ‘œ κ΅¬λΆ„ν•΄μ„œ λ°˜ν™˜.
129
+ μŠ€μΊ” PDF(μ΄λ―Έμ§€λ‘œ 된)λŠ” ν…μŠ€νŠΈκ°€ λΉ„κ±°λ‚˜ 깨질 수 μžˆλŠ”λ°, κ·Έ κ²½μš°λŠ”
130
+ LLM이 μœ„ν‚€/μ›Ήκ²€μƒ‰μœΌλ‘œ ν΄λ°±ν•˜λ„λ‘ μ‹œμŠ€ν…œ ν”„λ‘¬ν”„νŠΈκ°€ μœ λ„ν•œλ‹€."""
131
+ try:
132
+ from pypdf import PdfReader
133
+ bio = io.BytesIO(content)
134
+ reader = PdfReader(bio)
135
+ parts = []
136
+ for i, page in enumerate(reader.pages):
137
+ try:
138
+ txt = page.extract_text() or ""
139
+ except Exception as pe:
140
+ txt = f"(extraction failed: {pe})"
141
+ parts.append(f"--- Page {i+1} ---\n{txt}")
142
+ combined = "\n\n".join(parts)
143
+ if len(combined) > 12000:
144
+ combined = combined[:12000] + "\n...[truncated]"
145
+ return f"[PDF, {len(reader.pages)} pages, Content-Type: {content_type}]\n{combined}"
146
+ except Exception as e:
147
+ return f"PDF parse error: {e}"
148
+
149
+
150
+ def _handle_image(content: bytes, content_type: str) -> str:
151
+ """VLM(Qwen2.5-VL-7B)으둜 ν˜„μž¬ 질문 μ»¨ν…μŠ€νŠΈμ— 맞좰 이미지λ₯Ό λΆ„μ„ν•œλ‹€.
152
+
153
+ HF Inference API의 OpenAI ν˜Έν™˜ chat_completion으둜 base64 data URL을 μ „μ†‘ν•œλ‹€.
154
+ 질문 μ»¨ν…μŠ€νŠΈκ°€ 있으면 κ·Έκ±Έ κ·ΈλŒ€λ‘œ prompt에 λ°•μ•„ 정닡에 직접 도움이 λ˜λŠ”
155
+ λΆ€λΆ„λ§Œ 뽑아내도둝 μœ λ„(generic μΊ‘μ…˜μ€ λ””ν…ŒμΌμ„ 놓침). 호좜 μ‹€νŒ¨ μ‹œ μ—λŸ¬
156
+ λ¬Έμžμ—΄μ„ λ°˜ν™˜ν•΄ μ—μ΄μ „νŠΈκ°€ λ‹€λ₯Έ μ „λž΅μœΌλ‘œ 폴백할 수 있게 ν•œλ‹€.
157
+
158
+ HF_TOKEN ν™˜κ²½λ³€μˆ˜κ°€ ν•„μš”ν•˜λ‹€. Space 배포 μ‹œμ—λŠ” Space secrets에 등둝해야 함.
159
+ """
160
+ try:
161
+ import base64
162
+ from huggingface_hub import InferenceClient
163
+
164
+ question = (_CURRENT_TASK.get("question") or "").strip()
165
+ # 데이터 URL ꡬ성. content_type이 image/* κ°€ 아닐 μˆ˜λ„ μžˆμ–΄ μ•ˆμ „ν•˜κ²Œ 폴백.
166
+ mime = content_type.split(";")[0].strip()
167
+ if not mime.startswith("image/"):
168
+ mime = "image/png"
169
+ b64 = base64.b64encode(content).decode("utf-8")
170
+ data_url = f"data:{mime};base64,{b64}"
171
+
172
+ if question:
173
+ prompt = (
174
+ "Analyze the attached image and answer the following question. "
175
+ "Read any text, numbers, or labels visible in the image. "
176
+ "If it is a chart or table, extract the relevant data values precisely.\n\n"
177
+ f"Question: {question}"
178
+ )
179
+ else:
180
+ prompt = (
181
+ "Describe the attached image in detail, including any visible text, "
182
+ "numbers, or labels."
183
+ )
184
+
185
+ client = InferenceClient(provider="hf-inference") # HF_TOKEN ν™˜κ²½λ³€μˆ˜ μ‚¬μš©
186
+ resp = client.chat_completion(
187
+ model="Qwen/Qwen2.5-VL-7B-Instruct",
188
+ messages=[
189
+ {
190
+ "role": "user",
191
+ "content": [
192
+ {"type": "text", "text": prompt},
193
+ {"type": "image_url", "image_url": {"url": data_url}},
194
+ ],
195
+ }
196
+ ],
197
+ max_tokens=1024,
198
+ )
199
+ analysis = resp.choices[0].message.content
200
+ return (
201
+ f"[Image analysis (Content-Type: {content_type}, {len(content)} bytes)]\n"
202
+ f"{analysis}"
203
+ )
204
+ except Exception as e:
205
+ return (
206
+ f"Image attached (Content-Type: {content_type}, {len(content)} bytes). "
207
+ f"VLM analysis failed: {e}"
208
+ )
209
+
210
+
211
+ def _handle_audio(content: bytes, content_type: str) -> str:
212
+ """Whisper(large-v3)둜 μ˜€λ””μ˜€ 전사. GAIA μ˜€λ””μ˜€λŠ” 보톡 짧은 λ°œν™”λΌ ν•œ 번 호좜둜 μΆ©λΆ„.
213
+
214
+ HF_TOKEN ν™˜κ²½λ³€μˆ˜κ°€ ν•„μš”ν•˜λ‹€. Space 배포 μ‹œμ—λŠ” Space secrets에 등둝해야 함.
215
+ """
216
+ try:
217
+ from huggingface_hub import InferenceClient
218
+ client = InferenceClient(provider="hf-inference")
219
+ result = client.automatic_speech_recognition(
220
+ audio=content,
221
+ model="openai/whisper-large-v3",
222
+ )
223
+ # huggingface_hub 버전에 따라 dict λ˜λŠ” dataclass-like 객체둜 λ°˜ν™˜λ˜λ―€λ‘œ
224
+ # μ–‘μͺ½ λͺ¨λ‘ μ²˜λ¦¬ν•œλ‹€.
225
+ if hasattr(result, "text"):
226
+ transcription = result.text
227
+ elif isinstance(result, dict):
228
+ transcription = result.get("text", str(result))
229
+ else:
230
+ transcription = str(result)
231
+ return (
232
+ f"[Audio transcription (Content-Type: {content_type}, {len(content)} bytes)]\n"
233
+ f"{transcription}"
234
+ )
235
+ except Exception as e:
236
+ return (
237
+ f"Audio attached (Content-Type: {content_type}, {len(content)} bytes). "
238
+ f"Transcription failed: {e}"
239
+ )
240
+
241
+
242
+ @tool
243
+ def get_attached_file() -> str:
244
+ """Download the file attached to the CURRENT GAIA task and return its content.
245
+ Takes no arguments β€” the current task_id is auto-resolved from the question.
246
+
247
+ Use this whenever the question references a file, spreadsheet, image, audio, PDF, code listing,
248
+ CSV, or any external resource. Returns:
249
+ - Text/CSV/JSON/code: the decoded text (truncated to ~12k chars).
250
+ - Excel (.xlsx): each sheet rendered as CSV (truncated).
251
+ - PDF: extracted text per page (truncated).
252
+ - Image (PNG/JPEG/WEBP/GIF/BMP): a vision-language model analysis focused on the current question.
253
+ - Audio (MP3/WAV/M4A/OGG/FLAC): a Whisper transcription.
254
+ - Other binary: a metadata description (size + content-type).
255
+ """
256
+ # μ‹œκ·Έλ‹ˆμ²˜ μ œμ•½ λ•Œλ¬Έμ— task_id 인자λ₯Ό λ°›μ§€ μ•Šκ³ , λͺ¨λ“ˆ μ „μ—­ _CURRENT_TASK μ—μ„œ κ°€μ Έμ˜¨λ‹€.
257
+ # 이 값은 BasicAgent.__call__ μ§„μž… μ‹œ set_current_task()둜 μ„ΈνŒ…λœλ‹€.
258
+ task_id = _CURRENT_TASK.get("id")
259
+ if not task_id:
260
+ return "No task context available β€” likely no file attached for this question."
261
+ try:
262
+ url = f"{_DEFAULT_API_URL}/files/{task_id}"
263
+ r = requests.get(url, timeout=30)
264
+ if r.status_code == 404:
265
+ return "No file attached to this task."
266
+ r.raise_for_status()
267
+ content_type = r.headers.get("Content-Type", "")
268
+ filename = _extract_filename(r.headers, url)
269
+ ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
270
+
271
+ # 1) λͺ…ν™•ν•œ λ°”μ΄λ„ˆλ¦¬ νƒ€μž…μ€ λ¨Όμ € 처리.
272
+ # 일뢀 PDF/SVGλŠ” UTF-8 decodeκ°€ λ˜μ–΄λ„ μ›μ‹œ ν…μŠ€νŠΈλ‘œ λ°˜ν™˜ν•˜λ©΄ ν’ˆμ§ˆμ΄ 크게 λ–¨μ–΄μ§„λ‹€.
273
+ if _is_excel(content_type, ext):
274
+ return _handle_excel(r.content, content_type)
275
+
276
+ if _is_pdf(content_type, ext):
277
+ return _handle_pdf(r.content, content_type)
278
+
279
+ if _is_image(content_type, ext):
280
+ return _handle_image(r.content, content_type)
281
+
282
+ if _is_audio(content_type, ext):
283
+ return _handle_audio(r.content, content_type)
284
+
285
+ # 2) ν…μŠ€νŠΈ 계열이면 UTF-8둜 λ°˜ν™˜.
286
+ try:
287
+ text = r.content.decode("utf-8")
288
+ if len(text) > 12000:
289
+ text = text[:12000] + "\n...[truncated]"
290
+ return f"[Content-Type: {content_type}]\n{text}"
291
+ except UnicodeDecodeError:
292
+ pass
293
+
294
+ # 3) μ•Œ 수 μ—†λŠ” λ°”μ΄λ„ˆλ¦¬ β€” λ©”νƒ€λ°μ΄ν„°λ§Œ λ°˜ν™˜.
295
+ return (
296
+ f"Binary file (Content-Type: {content_type}, "
297
+ f"size: {len(r.content)} bytes). Cannot display as text. "
298
+ f"URL: {url}"
299
+ )
300
+ except Exception as e:
301
+ return f"get_attached_file error: {e}"
tools/exec_code.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """파이썬 μ½”λ“œ μ‹€ν–‰ 도ꡬ.
2
+
3
+ GAIA에 "attached Python code"의 좜λ ₯을 λ¬»λŠ” 질문(예: f918266a)이 λ“±μž₯.
4
+ 첨뢀 νŒŒμΌμ€ ν…μŠ€νŠΈλ‘œ λ°›μ§€λ§Œ 좜λ ₯을 μ•Œλ €λ©΄ μ‹€μ œ 싀행이 ν•„μš”. smolagents의 자체
5
+ μ½”λ“œ sandbox둜 μ²˜λ¦¬ν•  μˆ˜λ„ μžˆμ§€λ§Œ λͺ…μ‹œ λ„κ΅¬λ‘œ λΆ„λ¦¬ν•˜λ©΄ (a) LLM이 μ˜λ„λ₯Ό λͺ…ν™•νžˆ
6
+ ν‘œν˜„ (b) stdout 캑처λ₯Ό λ‹¨μˆœν™”ν•œλ‹€.
7
+
8
+ μ•ˆμ „: μ²¨λΆ€λœ μ½”λ“œλ₯Ό 직접 μ‹€ν–‰ν•˜λ―€λ‘œ μ‹ λ’°λ˜μ§€ μ•Šμ€ μž…λ ₯에 λ…ΈμΆœ. GAIA 채점
9
+ μ»¨ν…μŠ€νŠΈ ν•œμ •μœΌλ‘œ μ‚¬μš©. μ‹€μ„œλΉ„μŠ€μ—λŠ” sandbox κ°•ν™” ν•„μš”.
10
+ """
11
+ import os
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+ from smolagents import tool
16
+
17
+
18
+ @tool
19
+ def exec_python_code(code: str) -> str:
20
+ """Execute Python source code and return its captured stdout.
21
+ Use this when the question asks for the output of a piece of attached or referenced
22
+ Python code (e.g., "What is the final numeric output of the code?"). Pass the code
23
+ body verbatim. Captures both stdout and stderr; returns up to ~12k characters.
24
+
25
+ Args:
26
+ code: The Python source code to execute.
27
+ """
28
+ if len(code) > 50000:
29
+ return "exec_python_code error: code is too large to execute safely"
30
+
31
+ try:
32
+ with tempfile.TemporaryDirectory(prefix="gaia_exec_") as tmpdir:
33
+ script_path = os.path.join(tmpdir, "snippet.py")
34
+ with open(script_path, "w", encoding="utf-8") as f:
35
+ f.write(code)
36
+ result = subprocess.run(
37
+ [sys.executable, "-I", script_path],
38
+ cwd=tmpdir,
39
+ capture_output=True,
40
+ text=True,
41
+ timeout=8,
42
+ )
43
+ out = (result.stdout or "") + (result.stderr or "")
44
+ if result.returncode != 0:
45
+ out = f"exec_python_code exited with status {result.returncode}\n{out}"
46
+ except subprocess.TimeoutExpired as e:
47
+ partial = ((e.stdout or "") + (e.stderr or ""))[:4000]
48
+ return f"exec_python_code error: TimeoutExpired after 8s\n--- partial output ---\n{partial}"
49
+ except Exception as e:
50
+ return (
51
+ f"exec_python_code error: {type(e).__name__}: {e}\n"
52
+ f"--- partial output ---\n"
53
+ )
54
+
55
+ if len(out) > 12000:
56
+ out = out[:12000] + "\n...[truncated]"
57
+ return out or "(no stdout output)"
tools/search.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """μ›Ή 검색 툴. λ°±μ—”λ“œ μš°μ„ μˆœμœ„: SearXNG β†’ Tavily β†’ Brave β†’ DuckDuckGo.
2
+
3
+ SearXNG λŠ” ν‚€ 없이 λ™μž‘ν•˜λŠ” 메타검색(곡개 μΈμŠ€ν„΄μŠ€ 폴백). Tavily/Brave λŠ” ν™˜κ²½λ³€μˆ˜λ‘œ
4
+ API ν‚€κ°€ μ„€μ •λœ κ²½μš°μ—λ§Œ μ‚¬μš©. λ‘˜ λ‹€ 무료 ν‹°μ–΄κ°€ μžˆλ‹€ (Tavily 1k/μ›”, Brave 2k/μ›”
5
+ κ°€λŸ‰). DDGλŠ” λ§ˆμ§€λ§‰ μ•ˆμ „λ§μ΄μ§€λ§Œ μ’…μ’… 차단/λ””μ½”λ”© μ—λŸ¬κ°€ λ‚˜λ―€λ‘œ μš°μ„  λ°±μ—”λ“œλ“€μ„
6
+ μ•žμ— λ‘λŠ” 게 μ•ˆμ •μ μ΄λ‹€.
7
+
8
+ 각 λ°±μ—”λ“œλŠ” κ²°κ³Όκ°€ 있으면 포맷된 λ¬Έμžμ—΄, μ—†μœΌλ©΄ None 을 λ°˜ν™˜. 호좜자(web_search)λŠ”
9
+ None 을 λ§Œλ‚˜λ©΄ λ‹€μŒ λ°±μ—”λ“œλ‘œ ν΄λ°±ν•œλ‹€. DDG λŠ” λ§ˆμ§€λ§‰ 폴백이라 None λŒ€μ‹  항상
10
+ λ¬Έμžμ—΄(μ—λŸ¬ λ©”μ‹œμ§€ λ˜λŠ” "No results found.")을 λ°˜ν™˜ν•œλ‹€.
11
+
12
+ ν™˜κ²½λ³€μˆ˜:
13
+ TAVILY_API_KEY Tavily Search API ν‚€ (μ˜΅μ…˜)
14
+ BRAVE_API_KEY Brave Search API ν‚€ (μ˜΅μ…˜)
15
+ """
16
+ import os
17
+ import random
18
+ import requests
19
+ from smolagents import tool
20
+
21
+ _TAVILY_URL = "https://api.tavily.com/search"
22
+ _BRAVE_URL = "https://api.search.brave.com/res/v1/web/search"
23
+
24
+ # SearXNG 곡개 μΈμŠ€ν„΄μŠ€ ν’€. ν‚€ λΆˆν•„μš”. ν˜ΈμΆœλ§ˆλ‹€ μΌλΆ€λ§Œ λ¬΄μž‘μœ„λ‘œ 골라 μ‹œλ„ν•΄μ„œ
25
+ # (a) ν•œ μΈμŠ€ν„΄μŠ€κ°€ IP 차단 κ°€μ†λ˜λŠ” κ±Έ λΆ„μ‚°ν•˜κ³  (b) λˆ„μ  timeout μƒν•œμ„ ν†΅μ œν•œλ‹€.
26
+ # searx.space κ°€μš© λͺ©λ‘μ„ 주기적으둜 κ°±μ‹ ν•  것.
27
+ _SEARXNG_INSTANCES = (
28
+ "https://searx.be",
29
+ "https://searx.tiekoetter.com",
30
+ "https://search.inetol.net",
31
+ "https://searxng.online",
32
+ "https://priv.au",
33
+ )
34
+ _SEARXNG_TRY_COUNT = 3 # ν˜ΈμΆœλ‹Ή μ‹œλ„ν•  μΈμŠ€ν„΄μŠ€ 수
35
+ _SEARXNG_TIMEOUT = 5 # μΈμŠ€ν„΄μŠ€λ‹Ή νƒ€μž„μ•„μ›ƒ(초) β€” λˆ„μ  μƒν•œ ~15s
36
+
37
+
38
+ def _format_results(items) -> str:
39
+ """곡톡 포맀터: (title, url, snippet) νŠœν”Œ 리슀트λ₯Ό LLM-friendly ν…μŠ€νŠΈλ‘œ."""
40
+ lines = [f"- {t}\n {u}\n {b}" for t, u, b in items if (t or u or b)]
41
+ return "\n".join(lines) if lines else ""
42
+
43
+
44
+ def _search_searxng(query: str) -> str | None:
45
+ """SearXNG 메타검색. Google/Bing/DDG λ“± 70+ 엔진을 λ¬Άμ–΄ λ°˜ν™˜. ν‚€ λΆˆν•„μš”.
46
+ 곡개 μΈμŠ€ν„΄μŠ€ 폴백 β€” ν•œ κ³³ 죽으면 λ‹€μŒμœΌλ‘œ. λͺ¨λ‘ μ‹€νŒ¨ν•˜λ©΄ None λ°˜ν™˜ν•΄
47
+ ν˜ΈμΆœμžκ°€ λ‹€μŒ λ°±μ—”λ“œ(Tavily/Brave/DDG)둜 ν΄λ°±ν•˜κ²Œ ν•œλ‹€.
48
+
49
+ 일뢀 μΈμŠ€ν„΄μŠ€λŠ” 빈 UA λ˜λŠ” λ΄‡μ²˜λŸΌ λ³΄μ΄λŠ” μš”μ²­μ„ μ°¨λ‹¨ν•˜λ―€λ‘œ λΈŒλΌμš°μ € UAλ₯Ό λͺ…μ‹œ.
50
+ """
51
+ headers = {
52
+ "User-Agent": (
53
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
54
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
55
+ ),
56
+ "Accept": "application/json",
57
+ }
58
+ # ν˜ΈμΆœλ§ˆλ‹€ λ¬΄μž‘μœ„ λΆ€λΆ„μ§‘ν•© β†’ λΆ€ν•˜ λΆ„μ‚° + λˆ„μ  timeout ν†΅μ œ.
59
+ candidates = random.sample(_SEARXNG_INSTANCES, _SEARXNG_TRY_COUNT)
60
+ for base in candidates:
61
+ try:
62
+ r = requests.get(
63
+ f"{base}/search",
64
+ params={"q": query, "format": "json", "language": "en"},
65
+ headers=headers,
66
+ timeout=_SEARXNG_TIMEOUT,
67
+ )
68
+ if r.status_code != 200:
69
+ continue
70
+ results = r.json().get("results", [])
71
+ if not results:
72
+ continue
73
+ items = [
74
+ (x.get("title", ""), x.get("url", ""), x.get("content", ""))
75
+ for x in results[:8] # 토큰 μ œμ–΄, DDG와 λ™μΌν•œ max_results=8
76
+ ]
77
+ formatted = _format_results(items)
78
+ if formatted:
79
+ return formatted
80
+ except Exception as e:
81
+ print(f"SearXNG ({base}) failed: {e}")
82
+ continue
83
+ return None
84
+
85
+
86
+ def _search_tavily(query: str) -> str | None:
87
+ """Tavily Search API. TAVILY_API_KEY κ°€ μžˆμ–΄μ•Ό 호좜."""
88
+ api_key = os.getenv("TAVILY_API_KEY")
89
+ if not api_key:
90
+ return None
91
+ try:
92
+ r = requests.post(
93
+ _TAVILY_URL,
94
+ json={"api_key": api_key, "query": query, "max_results": 8},
95
+ timeout=15,
96
+ )
97
+ r.raise_for_status()
98
+ results = r.json().get("results", [])
99
+ if not results:
100
+ return None
101
+ items = [
102
+ (x.get("title", ""), x.get("url", ""), x.get("content", ""))
103
+ for x in results
104
+ ]
105
+ formatted = _format_results(items)
106
+ return formatted or None
107
+ except Exception as e:
108
+ print(f"Tavily search failed (falling back): {e}")
109
+ return None
110
+
111
+
112
+ def _search_brave(query: str) -> str | None:
113
+ """Brave Search API. BRAVE_API_KEY κ°€ μžˆμ–΄μ•Ό 호좜."""
114
+ api_key = os.getenv("BRAVE_API_KEY")
115
+ if not api_key:
116
+ return None
117
+ try:
118
+ r = requests.get(
119
+ _BRAVE_URL,
120
+ params={"q": query, "count": 8},
121
+ headers={
122
+ "X-Subscription-Token": api_key,
123
+ "Accept": "application/json",
124
+ },
125
+ timeout=15,
126
+ )
127
+ r.raise_for_status()
128
+ results = r.json().get("web", {}).get("results", [])
129
+ if not results:
130
+ return None
131
+ items = [
132
+ (x.get("title", ""), x.get("url", ""), x.get("description", ""))
133
+ for x in results
134
+ ]
135
+ formatted = _format_results(items)
136
+ return formatted or None
137
+ except Exception as e:
138
+ print(f"Brave search failed (falling back): {e}")
139
+ return None
140
+
141
+
142
+ def _search_ddg(query: str) -> str:
143
+ """DuckDuckGo. ddgs νŒ¨ν‚€μ§€ μš°μ„ , μ‹€νŒ¨ μ‹œ ꡬ duckduckgo_search 폴백.
144
+ λ§ˆμ§€λ§‰ 폴백이라 None λŒ€μ‹  항상 λ¬Έμžμ—΄μ„ λ°˜ν™˜ν•œλ‹€(μ—λŸ¬ λ©”μ‹œμ§€ λ˜λŠ” "No results found.")."""
145
+ # DDG ν΄λΌμ΄μ–ΈνŠΈ νŒ¨ν‚€μ§€ 이름이 `duckduckgo_search` β†’ `ddgs`둜 λ°”λ€Œμ—ˆκ³ 
146
+ # ꡬ νŒ¨ν‚€μ§€μ—μ„œλŠ” "Body collection error: ..." 같은 λ””μ½”λ”© μ—λŸ¬κ°€ λΉˆλ²ˆν–ˆλ‹€.
147
+ last_err = None
148
+ for module_name in ("ddgs", "duckduckgo_search"):
149
+ try:
150
+ mod = __import__(module_name, fromlist=["DDGS"])
151
+ DDGS = getattr(mod, "DDGS")
152
+ with DDGS() as ddgs:
153
+ # max_results=8: λ„ˆλ¬΄ 적으면 μ •λ‹΅ μ‚¬μ΄νŠΈ λˆ„λ½, λ„ˆλ¬΄ 많으면 μ»¨ν…μŠ€νŠΈ λ‚­λΉ„.
154
+ results = list(ddgs.text(query, max_results=8))
155
+ if not results:
156
+ continue
157
+ # 두 νŒ¨ν‚€μ§€κ°€ ν‚€ 이름이 λ―Έλ¬˜ν•˜κ²Œ λ‹€λ₯΄λ―€λ‘œ μ–‘μͺ½ λͺ¨λ‘ 처리.
158
+ items = [
159
+ (
160
+ r.get("title", ""),
161
+ r.get("href", "") or r.get("url", ""),
162
+ r.get("body", "") or r.get("snippet", ""),
163
+ )
164
+ for r in results
165
+ ]
166
+ formatted = _format_results(items)
167
+ if formatted:
168
+ return formatted
169
+ except Exception as e:
170
+ last_err = e
171
+ continue
172
+ if last_err:
173
+ return f"web_search error: {last_err}"
174
+ return "No results found."
175
+
176
+
177
+ @tool
178
+ def web_search(query: str) -> str:
179
+ """Search the web and return a list of titles, URLs, and snippets.
180
+ Backend priority: SearXNG public instances (no key) -> Tavily/Brave (only if their
181
+ API keys are set in environment variables TAVILY_API_KEY, BRAVE_API_KEY) ->
182
+ DuckDuckGo fallback.
183
+
184
+ Args:
185
+ query: The search query string.
186
+ """
187
+ # SearXNGκ°€ 1μˆœμœ„: ν‚€ 없이 κ°€μž₯ μ–‘μ§ˆμ˜ κ²°κ³Όλ₯Ό μ£ΌλŠ” λ°±μ—”λ“œ.
188
+ # Tavily/BraveλŠ” ν‚€κ°€ ν™˜κ²½λ³€μˆ˜μ— μžˆμ„ λ•Œλ§Œ μ‹œλ„(μ—†μœΌλ©΄ None λ°˜ν™˜ν•˜κ³  톡과).
189
+ # DDGλŠ” λ§ˆμ§€λ§‰ μ•ˆμ „λ§.
190
+ out = _search_searxng(query)
191
+ if out:
192
+ return out
193
+ for backend in (_search_tavily, _search_brave):
194
+ out = backend(query)
195
+ if out:
196
+ return out
197
+ return _search_ddg(query)
tools/webpage.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """일반 μ›ΉνŽ˜μ΄μ§€ fetch + λ§ˆν¬λ‹€μš΄ λ³€ν™˜ 툴.
2
+
3
+ Content-Type λΆ„κΈ°:
4
+ - text/html β†’ BeautifulSoup β†’ markdownify
5
+ - application/pdf β†’ pypdf둜 νŽ˜μ΄μ§€λ³„ ν…μŠ€νŠΈ μΆ”μΆœ (arxivΒ·NASA TR λ“± μ™ΈλΆ€ PDF용)
6
+ """
7
+ import io
8
+ import re
9
+ import requests
10
+ from bs4 import BeautifulSoup
11
+ from markdownify import markdownify as md
12
+ from smolagents import tool
13
+
14
+
15
+ def _handle_pdf_url(content: bytes) -> str:
16
+ """μ™ΈλΆ€ PDF URL 본문을 νŽ˜μ΄μ§€λ³„ ν…μŠ€νŠΈλ‘œ λ³€ν™˜. attachments._handle_pdf와 동일 νŒ¨ν„΄."""
17
+ try:
18
+ from pypdf import PdfReader
19
+ reader = PdfReader(io.BytesIO(content))
20
+ parts = []
21
+ for i, page in enumerate(reader.pages):
22
+ try:
23
+ txt = page.extract_text() or ""
24
+ except Exception as pe:
25
+ txt = f"(extraction failed: {pe})"
26
+ parts.append(f"--- Page {i+1} ---\n{txt}")
27
+ combined = "\n\n".join(parts)
28
+ if len(combined) > 12000:
29
+ combined = combined[:12000] + "\n...[truncated]"
30
+ return f"[PDF, {len(reader.pages)} pages]\n{combined}"
31
+ except Exception as e:
32
+ return f"PDF parse error: {e}"
33
+
34
+
35
+ @tool
36
+ def visit_webpage(url: str) -> str:
37
+ """Fetch a web page (HTML or PDF) and return its readable text (truncated to ~12k chars).
38
+
39
+ HTML pages are converted to markdown. PDF URLs are parsed page-by-page via pypdf β€”
40
+ useful for arxiv papers, NASA technical reports, and other linked PDF documents.
41
+
42
+ Args:
43
+ url: The full URL of the webpage or PDF to fetch.
44
+ """
45
+ try:
46
+ # 일뢀 μ‚¬μ΄νŠΈ(특히 μœ„ν‚€λ―Έλ””μ–΄ μ™Έ)κ°€ 빈 User-Agentλ₯Ό μ°¨λ‹¨ν•˜λ―€λ‘œ 헀더λ₯Ό λͺ…μ‹œν•œλ‹€.
47
+ headers = {"User-Agent": "Mozilla/5.0 (compatible; GAIA-Agent/1.0)"}
48
+ resp = requests.get(url, headers=headers, timeout=20)
49
+ resp.raise_for_status()
50
+ content_type = resp.headers.get("Content-Type", "").lower()
51
+ # PDF: pypdf둜 ν…μŠ€νŠΈ μΆ”μΆœ. arxiv λ…Όλ¬Έ λ“± GAIA에 자주 λ“±μž₯.
52
+ if "application/pdf" in content_type or url.lower().endswith(".pdf"):
53
+ return _handle_pdf_url(resp.content)
54
+ # HTML: κΈ°μ‘΄ 흐름.
55
+ soup = BeautifulSoup(resp.text, "html.parser")
56
+ # λ³Έλ¬Έκ³Ό λ¬΄κ΄€ν•œ λ…Έμ΄μ¦ˆ 제거: 슀크립트/μŠ€νƒ€μΌ/noscript 블둝.
57
+ for tag in soup(["script", "style", "noscript"]):
58
+ tag.decompose()
59
+ markdown = md(str(soup))
60
+ # markdownifyκ°€ μ’…μ’… 빈 쀄을 쀄쀄이 λ§Œλ“€μ–΄λ‚΄λ―€λ‘œ μ••μΆ•ν•΄μ„œ 토큰을 μ ˆμ•½ν•œλ‹€.
61
+ markdown = re.sub(r"\n{3,}", "\n\n", markdown).strip()
62
+ # LLM μ»¨ν…μŠ€νŠΈ 보호: λ„ˆλ¬΄ 큰 νŽ˜μ΄μ§€λŠ” μž˜λΌμ„œ λ°˜ν™˜ν•œλ‹€.
63
+ if len(markdown) > 12000:
64
+ markdown = markdown[:12000] + "\n...[truncated]"
65
+ return markdown
66
+ except Exception as e:
67
+ return f"visit_webpage error: {e}"
tools/wikipedia.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """μœ„ν‚€ν”Όλ””μ•„ λ³Έλ¬Έ(ν‘œ 포함) μΆ”μΆœ 툴.
2
+
3
+ REST `summary` μ—”λ“œν¬μΈνŠΈλŠ” lead paragraph만 λ°˜ν™˜ν•΄μ„œ ν‘œ 기반 사싀(μˆ˜μƒμž λͺ…단,
4
+ μ„ μˆ˜ λͺ…λΆ€ λ“±)을 λͺ» λ³Έλ‹€. action=parse 둜 ν’€ HTML을 λ°›μ•„ ν‘œλ₯Ό [TABLE]/[/TABLE]
5
+ λΈ”λ‘μœΌλ‘œ ν…μŠ€νŠΈν™”ν•œλ‹€.
6
+ """
7
+ import re
8
+ import requests
9
+ from bs4 import BeautifulSoup
10
+ from smolagents import tool
11
+
12
+ # Wikipedia User-Agent μ •μ±…: 빈 UA / "python-requests/..." 같은 κΈ°λ³Έ UA λŠ” 403 차단됨.
13
+ # https://meta.wikimedia.org/wiki/User-Agent_policy μ°Έκ³ . 이름 + μš©λ„κ°€ μ‹λ³„λ˜λ©΄ μΆ©λΆ„.
14
+ _HEADERS = {
15
+ "User-Agent": "GAIA-Agent/1.0 (HF agents course unit 4; https://huggingface.co/spaces)"
16
+ }
17
+
18
+
19
+ @tool
20
+ def wikipedia_search(query: str) -> str:
21
+ """Search English Wikipedia and return the FULL article body (text + tables) of the top matching
22
+ article, rendered as readable text and truncated to ~14k chars. Includes section headers and
23
+ table rows so factual lookups (winners lists, rosters, dates, etc.) are answerable.
24
+
25
+ Args:
26
+ query: The search term or article title.
27
+ """
28
+ try:
29
+ # 1단계: 검색 API둜 κ°€μž₯ λ§€μΉ­ κ°€λŠ₯성이 높은 λ¬Έμ„œ 제λͺ©μ„ μ°ΎλŠ”λ‹€.
30
+ s = requests.get(
31
+ "https://en.wikipedia.org/w/api.php",
32
+ params={
33
+ "action": "query",
34
+ "format": "json",
35
+ "list": "search",
36
+ "srsearch": query,
37
+ "srlimit": 3,
38
+ },
39
+ headers=_HEADERS,
40
+ timeout=15,
41
+ )
42
+ s.raise_for_status()
43
+ hits = s.json().get("query", {}).get("search", [])
44
+ if not hits:
45
+ return "No Wikipedia results."
46
+ title = hits[0]["title"]
47
+ # λ™μŒμ΄μ˜ disambiguation: μƒμœ„ 3개 후보λ₯Ό 헀더에 λ…ΈμΆœ. 1μœ„κ°€ μ •λ‹΅ νŽ˜μ΄μ§€κ°€
48
+ # μ•„λ‹Œ 경우(예: "Mercury" ν–‰μ„± vs μ‹  vs νšŒμ‚¬) μ—μ΄μ „νŠΈκ°€ λ‹€λ₯Έ 후보 제λͺ©μœΌλ‘œ
49
+ # μž¬κ²€μƒ‰ν•˜κ±°λ‚˜ visit_webpage둜 직접 μ ‘κ·Όν•  수 μžˆλ„λ‘ μ‹ ν˜Έ.
50
+ candidates = [h.get("title", "") for h in hits[:3]]
51
+
52
+ # 2단계: parse API둜 λ³Έλ¬Έ HTML을 λ°›λŠ”λ‹€(ν‘œΒ·μΈν¬λ°•μŠ€ 포함).
53
+ page = requests.get(
54
+ "https://en.wikipedia.org/w/api.php",
55
+ params={
56
+ "action": "parse",
57
+ "format": "json",
58
+ "page": title,
59
+ "prop": "text",
60
+ "redirects": True,
61
+ },
62
+ headers=_HEADERS,
63
+ timeout=20,
64
+ )
65
+ page.raise_for_status()
66
+ html = page.json().get("parse", {}).get("text", {}).get("*", "")
67
+ if not html:
68
+ return f"Top hit: {title} (no body available)."
69
+
70
+ soup = BeautifulSoup(html, "html.parser")
71
+ # 각주, νŽΈμ§‘ 링크, λ„€λΉ„κ²Œμ΄μ…˜ λ°•μŠ€ λ“± μ •λ‹΅κ³Ό λ¬΄κ΄€ν•œ λ…Έμ΄μ¦ˆλ₯Ό μ œκ±°ν•œλ‹€.
72
+ for tag in soup.select(
73
+ "sup.reference, .mw-editsection, .reference, .navbox, "
74
+ ".infobox.metadata, .hatnote, .printfooter, script, style"
75
+ ):
76
+ tag.decompose()
77
+
78
+ # ν‘œλ₯Ό Markdown ν‘œ ν˜•μ‹μœΌλ‘œ λ³€ν™˜. LLM이 ν•™μŠ΅ λ°μ΄ν„°μ—μ„œ markdown ν‘œλ₯Ό
79
+ # μ••λ„μ μœΌλ‘œ 많이 λ΄μ„œ 헀더/데이터 뢄리가 μžμ—°μŠ€λŸ¬μ›€. 헀더 후보(th-only
80
+ # ν–‰)κ°€ 있으면 `| --- |` ꡬ뢄 행을 λΌμ›Œ 데이터와 μ‹œκ°Β·κ΅¬μ‘°μ μœΌλ‘œ λΆ„λ¦¬ν•œλ‹€.
81
+ # [TABLE]/[/TABLE] λ§ˆμ»€λŠ” LLM이 "μ—¬κΈ° ꡬ쑰화 데이터" 라고 μΈμ‹ν•˜λ„λ‘ μœ μ§€.
82
+ for tbl in soup.find_all("table"):
83
+ rows_text = []
84
+ header_emitted = False
85
+ for tr in tbl.find_all("tr"):
86
+ ths = tr.find_all("th")
87
+ tds = tr.find_all("td")
88
+ cells = [c.get_text(" ", strip=True) for c in tr.find_all(["th", "td"])]
89
+ if not cells:
90
+ continue
91
+ # 첫 'th 만으둜 κ΅¬μ„±λœ 닀쀑-μ…€ ν–‰' 을 markdown ν—€λ”λ‘œ μ·¨κΈ‰.
92
+ # 단일 th 행은 보톡 μΈν¬λ°•μŠ€ 라벨이라 헀더가 μ•„λ‹˜.
93
+ if not header_emitted and ths and not tds and len(ths) >= 2:
94
+ rows_text.append("| " + " | ".join(cells) + " |")
95
+ rows_text.append("| " + " | ".join(["---"] * len(cells)) + " |")
96
+ header_emitted = True
97
+ else:
98
+ rows_text.append("| " + " | ".join(cells) + " |")
99
+ if rows_text:
100
+ tbl.replace_with("\n[TABLE]\n" + "\n".join(rows_text) + "\n[/TABLE]\n")
101
+ else:
102
+ tbl.replace_with("")
103
+
104
+ text = soup.get_text("\n", strip=True)
105
+ text = re.sub(r"\n{3,}", "\n\n", text).strip()
106
+ if len(text) > 14000:
107
+ text = text[:14000] + "\n...[truncated]"
108
+ url = f"https://en.wikipedia.org/wiki/{requests.utils.quote(title.replace(' ', '_'))}"
109
+ candidates_str = ", ".join(candidates) if len(candidates) > 1 else title
110
+ return (
111
+ f"Title: {title}\n"
112
+ f"URL: {url}\n"
113
+ f"Candidates (top 3): {candidates_str}\n\n"
114
+ f"{text}"
115
+ )
116
+ except Exception as e:
117
+ return f"wikipedia_search error: {e}"
tools/youtube.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """YouTube μ˜μƒ 메타데이터 + μžλ§‰ μΆ”μΆœ 툴.
2
+
3
+ GAIA λ¬Έμ œμ— 유튜브 링크가 λ“±μž₯ν•˜λŠ” μΌ€μ΄μŠ€(μ˜μƒ λ‚΄μš©/제λͺ©/채널/연도 등을 λ¬»λŠ”
4
+ 질문)에 ν™œμš©. μ˜μƒ 자체λ₯Ό λ‹€μš΄λ‘œλ“œν•˜μ§€ μ•Šκ³  μžλ§‰ ν…μŠ€νŠΈλ§Œ μˆ˜μ§‘ν•΄ LLM에 전달.
5
+
6
+ μˆ˜μ§‘ 방법:
7
+ - 메타데이터: YouTube oEmbed (API ν‚€ λΆˆν•„μš”) β€” title, author 만 μΆ”μΆœ.
8
+ - μžλ§‰: youtube-transcript-api νŒ¨ν‚€μ§€ β€” μžλ™ 생성 μžλ§‰λ„ κ°€μ Έμ˜΄. μ˜μ–΄λ₯Ό μš°μ„ 
9
+ μ‹œλ„ν•˜κ³ , μ•ˆ 되면 λ²ˆμ—­ κ°€λŠ₯ν•œ μžλ§‰μ„ μ˜μ–΄λ‘œ λ²ˆμ—­, 그것도 μ—†μœΌλ©΄ 첫 κ°€μš© μ–Έμ–΄.
10
+ - μžλ§‰ μ—†λŠ” μ˜μƒ(μŒμ•… λΉ„λ””μ˜€, μΊ‘μ…˜ λΉ„ν™œμ„± λ“±)은 "Transcript unavailable" λ°˜ν™˜
11
+ ν•˜κ³  λ©”νƒ€λ°μ΄ν„°λŠ” κ·ΈλŒ€λ‘œ λ…ΈμΆœ β†’ μ—μ΄μ „νŠΈκ°€ λ‹€λ₯Έ λ„κ΅¬λ‘œ 폴백 κ°€λŠ₯.
12
+ """
13
+ import re
14
+ import requests
15
+ from smolagents import tool
16
+
17
+
18
+ def _extract_video_id(url_or_id: str) -> str | None:
19
+ """λ‹€μ–‘ν•œ YouTube URL ν˜•μ‹ λ˜λŠ” raw 11자 ID μ—μ„œ video ID μΆ”μΆœ.
20
+ None 이면 LLM이 λ‹€μ‹œ μ‹œλ„ν•˜κ±°λ‚˜ λ‹€λ₯Έ λ„κ΅¬λ‘œ λ„˜μ–΄κ°€λ„λ‘ 함."""
21
+ s = url_or_id.strip()
22
+ # Raw ID (μ˜μƒ IDλŠ” μ •ν™•νžˆ 11자: [A-Za-z0-9_-]).
23
+ if re.fullmatch(r"[A-Za-z0-9_-]{11}", s):
24
+ return s
25
+ # ν‘œμ€€ watch / youtu.be 단좕 / embed / shorts URL.
26
+ m = re.search(
27
+ r"(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/embed/|youtube\.com/shorts/)([A-Za-z0-9_-]{11})",
28
+ s,
29
+ )
30
+ if m:
31
+ return m.group(1)
32
+ # ?v=... or &v=... κ°€ λ‹€λ₯Έ μœ„μΉ˜μ— μžˆμ„ λ•Œ.
33
+ m = re.search(r"[?&]v=([A-Za-z0-9_-]{11})", s)
34
+ if m:
35
+ return m.group(1)
36
+ return None
37
+
38
+
39
+ def _fetch_metadata(video_id: str) -> str:
40
+ """oEmbed 둜 title/author λ₯Ό λ°›μ•„ ν…μŠ€νŠΈλ‘œ λ°˜ν™˜. μ‹€νŒ¨ν•΄λ„ μ•ˆμ „."""
41
+ try:
42
+ r = requests.get(
43
+ "https://www.youtube.com/oembed",
44
+ params={
45
+ "url": f"https://www.youtube.com/watch?v={video_id}",
46
+ "format": "json",
47
+ },
48
+ timeout=15,
49
+ )
50
+ r.raise_for_status()
51
+ meta = r.json()
52
+ title = meta.get("title", "")
53
+ author = meta.get("author_name", "")
54
+ return f"Title: {title}\nChannel: {author}"
55
+ except Exception as e:
56
+ return f"Metadata fetch failed: {e}"
57
+
58
+
59
+ def _fetch_transcript(video_id: str) -> str:
60
+ """youtube-transcript-api 둜 μžλ§‰ ν…μŠ€νŠΈλ₯Ό κ²°ν•©ν•΄ λ°˜ν™˜.
61
+ μ˜μ–΄ β†’ μ˜μ–΄ λ²ˆμ—­ β†’ 첫 κ°€μš© μžλ§‰ 순으둜 폴백. μžλ§‰ μ „ν˜€ μ—†μœΌλ©΄ μ•ˆλ‚΄ λ¬Έμžμ—΄.
62
+ """
63
+ try:
64
+ from youtube_transcript_api import YouTubeTranscriptApi
65
+ # 1) μ˜μ–΄ μžλ§‰ 직접 μ‹œλ„(μžλ™ 생성도 포함).
66
+ try:
67
+ segments = YouTubeTranscriptApi.get_transcript(video_id, languages=["en"])
68
+ except Exception:
69
+ # 2) μ–΄λ–€ μ–Έμ–΄λ“  κ°€λŠ₯ν•œ 것을 κ°€μ Έμ˜€κΈ°. κ°€λŠ₯ν•˜λ©΄ μ˜μ–΄λ‘œ λ²ˆμ—­.
70
+ transcript_list = YouTubeTranscriptApi.list_transcripts(video_id)
71
+ chosen = None
72
+ for t in transcript_list:
73
+ try:
74
+ if getattr(t, "is_translatable", False):
75
+ chosen = t.translate("en").fetch()
76
+ break
77
+ except Exception:
78
+ continue
79
+ if chosen is None:
80
+ # 3) λ²ˆμ—­ λΆˆκ°€ μ‹œ 첫 번째 κ°€μš© μžλ§‰μ„ κ·ΈλŒ€λ‘œ μ‚¬μš©.
81
+ first = next(iter(transcript_list))
82
+ chosen = first.fetch()
83
+ segments = chosen
84
+
85
+ # 라이브러리 버전에 따라 dict λ˜λŠ” 객체둜 λ°˜ν™˜ β†’ μ–‘μͺ½ 처리.
86
+ texts = []
87
+ for seg in segments:
88
+ if isinstance(seg, dict):
89
+ texts.append(seg.get("text", ""))
90
+ else:
91
+ texts.append(getattr(seg, "text", ""))
92
+ text = " ".join(t for t in texts if t).strip()
93
+ if not text:
94
+ return "Transcript unavailable (empty)."
95
+ if len(text) > 14000:
96
+ text = text[:14000] + "\n...[truncated]"
97
+ return text
98
+ except Exception as e:
99
+ return f"Transcript unavailable: {e}"
100
+
101
+
102
+ @tool
103
+ def youtube_info(url: str) -> str:
104
+ """Fetch a YouTube video's title, channel, and transcript text.
105
+ Use this whenever a question references a YouTube link, video, or asks about its contents.
106
+ The transcript covers spoken content (auto-generated if no manual captions exist) and is
107
+ truncated to ~14k chars. If the video has no captions, only metadata is returned and you
108
+ should fall back to web/wikipedia searches for the question's specific facts.
109
+
110
+ Args:
111
+ url: A full YouTube URL (watch, youtu.be, embed, shorts) or a bare 11-character video ID.
112
+ """
113
+ video_id = _extract_video_id(url)
114
+ if not video_id:
115
+ return f"Could not parse YouTube video ID from: {url}"
116
+ metadata = _fetch_metadata(video_id)
117
+ transcript = _fetch_transcript(video_id)
118
+ return (
119
+ f"Video ID: {video_id}\n"
120
+ f"URL: https://www.youtube.com/watch?v={video_id}\n"
121
+ f"{metadata}\n\n"
122
+ f"--- Transcript ---\n"
123
+ f"{transcript}"
124
+ )