Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- CLAUDE.md +194 -0
- answer_cache.py +120 -0
- app.py +195 -20
- decomposer.py +79 -0
- formatter.py +154 -0
- prompts.py +98 -0
- requirements.txt +12 -2
- tools/__init__.py +30 -0
- tools/__pycache__/__init__.cpython-312.pyc +0 -0
- tools/__pycache__/attachments.cpython-312.pyc +0 -0
- tools/__pycache__/exec_code.cpython-312.pyc +0 -0
- tools/__pycache__/search.cpython-312.pyc +0 -0
- tools/__pycache__/webpage.cpython-312.pyc +0 -0
- tools/__pycache__/wikipedia.cpython-312.pyc +0 -0
- tools/__pycache__/youtube.cpython-312.pyc +0 -0
- tools/attachments.py +301 -0
- tools/exec_code.py +57 -0
- tools/search.py +197 -0
- tools/webpage.py +67 -0
- tools/wikipedia.py +117 -0
- tools/youtube.py +124 -0
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
|
| 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 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|
| 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 |
-
#
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 59 |
-
|
| 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 |
-
|
| 66 |
-
|
| 67 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
)
|