| """Tools for the GAIA Level-1 evaluation agent.""" |
|
|
| from __future__ import annotations |
|
|
| import os |
| import re |
| import subprocess |
| import sys |
| import tempfile |
| from pathlib import Path |
|
|
| import requests |
| from langchain_core.tools import tool |
|
|
| API_URL = os.getenv("SCORING_API_URL", "https://agents-course-unit4-scoring.hf.space") |
| GAIA_REPO = "gaia-benchmark/GAIA" |
| FILES_DIR = Path(tempfile.gettempdir()) / "gaia_task_files" |
| FILES_DIR.mkdir(parents=True, exist_ok=True) |
|
|
| _GAIA_FILES: list[str] | None = None |
|
|
|
|
| USER_AGENT = "Mozilla/5.0 (compatible; GaiaAgent/1.0; +https://huggingface.co)" |
| WIKI_API = "https://en.wikipedia.org/w/api.php" |
| SEARCH_BUDGET = 6 |
|
|
| _search_log: list[frozenset[str]] = [] |
|
|
|
|
| def _truncate(text: str, limit: int = 1200) -> str: |
| text = text.strip() |
| if len(text) <= limit: |
| return text |
| return text[:limit] + "\n...[truncated]" |
|
|
|
|
| def _answer_tag(value: object) -> str: |
| return f"<answer>{value}</answer>" |
|
|
|
|
| def reset_search_memory() -> None: |
| """Start a fresh search budget; call this once per question.""" |
| _search_log.clear() |
|
|
|
|
| def _focus(text: str, keyword: str, limit: int = 6000) -> str: |
| """Return windows around each keyword hit so the answer is never truncated away. |
| |
| Says so explicitly when the keyword is absent, which is the signal that the |
| agent opened the wrong page. |
| """ |
| if not keyword: |
| return _truncate(text, limit) |
|
|
| hits = [m.start() for m in re.finditer(re.escape(keyword), text, re.I)] |
| if not hits: |
| return ( |
| f"'{keyword}' does not appear anywhere on this page " |
| f"({len(text)} characters read). This is the wrong page: go back to the " |
| "search results and open a different URL." |
| ) |
|
|
| windows, cursor = [], -1 |
| for hit in hits[:8]: |
| start, end = max(0, hit - 700), hit + 700 |
| if start <= cursor: |
| continue |
| windows.append(text[start:end]) |
| cursor = end |
| header = f"{len(hits)} match(es) for '{keyword}':\n\n" |
| return _truncate(header + "\n\n[...]\n\n".join(windows), limit) |
|
|
|
|
| def _html_to_text(html: str) -> str: |
| from bs4 import BeautifulSoup |
|
|
| soup = BeautifulSoup(html, "html.parser") |
| for tag in soup(["script", "style", "nav", "footer", "header", "form"]): |
| tag.decompose() |
| return re.sub(r"\n{3,}", "\n\n", soup.get_text("\n")) |
|
|
|
|
| def _wiki_api(**params) -> dict: |
| """Call the live MediaWiki API; the `wikipedia` PyPI package no longer works.""" |
| params.setdefault("format", "json") |
| params.setdefault("formatversion", 2) |
| resp = requests.get( |
| WIKI_API, params=params, timeout=40, headers={"User-Agent": USER_AGENT} |
| ) |
| resp.raise_for_status() |
| return resp.json() |
|
|
|
|
| def _search_guard(query: str) -> str | None: |
| """Reject reworded repeats and cap total searches so the tool loop terminates.""" |
| tokens = frozenset(re.findall(r"[a-z0-9]+", query.lower())) |
| for seen in _search_log: |
| if len(tokens & seen) / max(len(tokens | seen), 1) >= 0.55: |
| return ( |
| "You already ran an almost identical search. Searching again is not " |
| "allowed. Open the most promising URL you have already seen with " |
| "fetch_url, read the page with read_wikipedia, or answer now." |
| ) |
| if len(_search_log) >= SEARCH_BUDGET: |
| return ( |
| f"The {SEARCH_BUDGET}-search budget for this question is used up. Do not " |
| "search again. Open a URL you already found with fetch_url or " |
| "read_wikipedia, or give your single best answer now." |
| ) |
| _search_log.append(tokens) |
| return None |
|
|
|
|
| @tool |
| def wikipedia_search(query: str) -> str: |
| """Search English Wikipedia and return matching article titles with snippets. |
| |
| Follow up with read_wikipedia on the best title; snippets never contain the |
| tables, discographies or rosters a question usually needs. |
| """ |
| blocked = _search_guard(query) |
| if blocked: |
| return blocked |
| try: |
| data = _wiki_api(action="query", list="search", srsearch=query, srlimit=5) |
| hits = data.get("query", {}).get("search", []) |
| if not hits: |
| return f"No Wikipedia results for: {query}" |
| rows = [] |
| for hit in hits: |
| title = hit["title"] |
| snippet = re.sub(r"<[^>]+>", "", hit.get("snippet", "")) |
| slug = title.replace(" ", "_") |
| rows.append( |
| f"- {title}\n URL: https://en.wikipedia.org/wiki/{slug}\n {snippet}" |
| ) |
| return _truncate("\n".join(rows), 2500) |
| except Exception as e: |
| return f"Wikipedia error: {e}" |
|
|
|
|
| @tool |
| def read_wikipedia(title: str, keyword: str = "") -> str: |
| """Read the full plain text of an English Wikipedia article. |
| |
| Pass a keyword to jump straight to the parts of the article that mention it, |
| which is how you reach discographies, rosters and results tables. |
| """ |
| try: |
| data = _wiki_api( |
| action="query", |
| prop="extracts", |
| explaintext=1, |
| redirects=1, |
| titles=title, |
| ) |
| pages = data.get("query", {}).get("pages", []) |
| if not pages or pages[0].get("missing"): |
| return f"No Wikipedia article titled '{title}'." |
| page = pages[0] |
| body = page.get("extract", "") |
| if not body: |
| return f"Wikipedia article '{title}' has no extractable text." |
| return f"{page['title']}\n\n" + _focus(body, keyword) |
| except Exception as e: |
| return f"read_wikipedia error: {e}" |
|
|
|
|
| @tool |
| def wikipedia_as_of(title: str, date: str, keyword: str = "") -> str: |
| """Read an English Wikipedia article as it stood on a past date (YYYY-MM-DD). |
| |
| Required whenever a question is time-anchored, e.g. "as of July 2023" or |
| "the 2022 version of Wikipedia", because the live page has since changed. |
| |
| Returns the RAW wikitext of that revision (not live HTML), so roster |
| templates are not re-expanded with today's players. |
| """ |
| try: |
| stamp = f"{date}T23:59:59Z" if len(date) == 10 else date |
| meta = _wiki_api( |
| action="query", |
| prop="revisions", |
| titles=title, |
| redirects=1, |
| rvlimit=1, |
| rvdir="older", |
| rvstart=stamp, |
| rvprop="ids|timestamp|content", |
| rvslots="main", |
| ) |
| pages = meta.get("query", {}).get("pages", []) |
| if not pages or not pages[0].get("revisions"): |
| return f"No revision of '{title}' found on or before {date}." |
| revision = pages[0]["revisions"][0] |
| slots = revision.get("slots", {}) |
| text = slots.get("main", {}).get("content") or revision.get("*") or "" |
| if not text: |
| |
| parsed = _wiki_api(action="parse", oldid=revision["revid"], prop="wikitext") |
| text = parsed.get("parse", {}).get("wikitext", "") |
| header = ( |
| f"{pages[0]['title']} as of {revision['timestamp']} " |
| f"(revision {revision['revid']})\n\n" |
| ) |
| return header + _focus(text, keyword, limit=8000) |
| except Exception as e: |
| return f"wikipedia_as_of error: {e}" |
|
|
|
|
| @tool |
| def fetch_url(url: str, keyword: str = "") -> str: |
| """Download a web page and return its readable text. |
| |
| Always pass the keyword you are looking for: long pages are cut off, and the |
| keyword jumps to the relevant part and warns you when the page does not |
| contain it at all. |
| """ |
| try: |
| resp = requests.get(url, timeout=40, headers={"User-Agent": USER_AGENT}) |
| resp.raise_for_status() |
| return _focus(_html_to_text(resp.text), keyword) |
| except Exception as e: |
| return f"fetch_url error: {e}" |
|
|
|
|
| @tool |
| def run_python_code(code: str) -> str: |
| """Execute a Python snippet and return whatever it prints. |
| |
| Use this for any puzzle, table or counting task that can be computed exactly |
| rather than reasoned about, and print the result. |
| """ |
| try: |
| with tempfile.NamedTemporaryFile( |
| "w", suffix=".py", dir=FILES_DIR, delete=False |
| ) as handle: |
| handle.write(code) |
| path = handle.name |
| proc = subprocess.run( |
| [sys.executable, path], |
| capture_output=True, |
| text=True, |
| timeout=30, |
| cwd=str(FILES_DIR), |
| ) |
| out = (proc.stdout or "") + (("\n" + proc.stderr) if proc.stderr else "") |
| return _truncate(out.strip() or f"(no output, exit={proc.returncode})", 3000) |
| except Exception as e: |
| return f"run_python_code error: {e}" |
|
|
|
|
| @tool |
| def extract_tables(url: str, keyword: str = "") -> str: |
| """Return the HTML tables on a page as CSV (discographies, rosters, medal tables). |
| |
| Pass a keyword to keep only tables whose text mentions it. |
| """ |
| try: |
| import io |
|
|
| import pandas as pd |
|
|
| resp = requests.get(url, timeout=40, headers={"User-Agent": USER_AGENT}) |
| resp.raise_for_status() |
| tables = pd.read_html(io.StringIO(resp.text)) |
| if not tables: |
| return f"No tables found at {url}" |
|
|
| chunks = [] |
| for i, df in enumerate(tables): |
| csv = df.to_csv(index=False) |
| if keyword and keyword.lower() not in csv.lower(): |
| continue |
| chunks.append(f"--- table {i} ({df.shape[0]}x{df.shape[1]}) ---\n{csv}") |
| if not chunks: |
| return f"Found {len(tables)} tables at {url} but none mention '{keyword}'." |
| return _truncate("\n\n".join(chunks), 6000) |
| except Exception as e: |
| return f"extract_tables error: {e}" |
|
|
|
|
| @tool |
| def count_wikipedia_albums( |
| title: str, |
| section: str, |
| start_year: int, |
| end_year: int, |
| date: str, |
| ) -> str: |
| """Count album rows in a Wikipedia discography section as of a past date. |
| |
| Counts each album ENTRY (table row), not unique years — two albums in 2009 |
| count as two. Use section names like 'Studio albums'. date is YYYY-MM-DD. |
| """ |
| try: |
| start_year = int(start_year) |
| end_year = int(end_year) |
| stamp = f"{date}T23:59:59Z" if len(date) == 10 else date |
| meta = _wiki_api( |
| action="query", |
| prop="revisions", |
| titles=title, |
| redirects=1, |
| rvlimit=1, |
| rvdir="older", |
| rvstart=stamp, |
| rvprop="ids|timestamp|content", |
| rvslots="main", |
| ) |
| pages = meta.get("query", {}).get("pages", []) |
| if not pages or not pages[0].get("revisions"): |
| return f"count_wikipedia_albums error: no revision of {title} on/before {date}" |
| revision = pages[0]["revisions"][0] |
| text = revision.get("slots", {}).get("main", {}).get("content") or "" |
| |
| pattern = re.compile( |
| rf"={{2,}}\s*{re.escape(section)}\s*={{2,}}\s*(.*?)(?=\n={{2,}}|\Z)", |
| re.I | re.S, |
| ) |
| match = pattern.search(text) |
| if not match: |
| |
| fuzzy = re.compile( |
| rf"={{2,}}\s*([^=]*{re.escape(section)}[^=]*)\s*={{2,}}\s*(.*?)(?=\n={{2,}}|\Z)", |
| re.I | re.S, |
| ) |
| match = fuzzy.search(text) |
| if not match: |
| return ( |
| f"count_wikipedia_albums error: section '{section}' not found. " |
| f"Nearby headings: {re.findall(r'={{2,}}\s*([^=]+?)\s*={{2,}}', text)[:20]}" |
| ) |
| body = match.group(2) if match.lastindex and match.lastindex >= 2 else match.group(1) |
| |
| rows = re.findall(r"\|-\s*\n\|\s*(19\d{2}|20\d{2})\s*\n\|([^\n]+)", body) |
| if not rows: |
| |
| years = re.findall(r"^\|\s*(19\d{2}|20\d{2})\s*$", body, re.M) |
| rows = [(y, "") for y in years] |
| kept = [] |
| for year, name in rows: |
| y = int(year) |
| if start_year <= y <= end_year: |
| kept.append((y, re.sub(r"\[\[(?:[^|\]]*\|)?([^\]]+)\]\]", r"\1", name).strip())) |
| lines = [f"{y}: {name or '(untitled)'}" for y, name in kept] |
| return ( |
| f"{pages[0]['title']} / {section} as of {revision['timestamp']}: " |
| f"{len(kept)} album(s) from {start_year}-{end_year}.\n" |
| + "\n".join(lines) |
| + f"\n{_answer_tag(len(kept))}" |
| ) |
| except Exception as e: |
| return f"count_wikipedia_albums error: {e}" |
|
|
|
|
| @tool |
| def botanical_vegetables(items: str) -> str: |
| """From a grocery list, return alphabetized botanical vegetables only. |
| |
| Excludes botanical fruits (seed-bearing flower products) even if cooks call |
| them vegetables, and excludes non-produce items. Keeps roots, tubers, stems, |
| leaves, bulbs and flower buds (including sweet potatoes and fresh basil). |
| """ |
| botanical_fruits = { |
| "green beans", |
| "zucchini", |
| "bell pepper", |
| "bell peppers", |
| "cucumber", |
| "tomato", |
| "tomatoes", |
| "corn", |
| "peas", |
| "peanut", |
| "peanuts", |
| "plum", |
| "plums", |
| "apple", |
| "apples", |
| "avocado", |
| "avocados", |
| "pumpkin", |
| "squash", |
| "eggplant", |
| "okra", |
| "acorn", |
| "acorns", |
| } |
| non_produce = { |
| "milk", |
| "eggs", |
| "flour", |
| "rice", |
| "oreos", |
| "whole bean coffee", |
| "coffee", |
| "whole allspice", |
| "allspice", |
| "sugar", |
| "salt", |
| "butter", |
| "cheese", |
| "bread", |
| } |
| |
| vegetables = { |
| "broccoli", |
| "celery", |
| "lettuce", |
| "fresh basil", |
| "basil", |
| "sweet potatoes", |
| "sweet potato", |
| "carrot", |
| "carrots", |
| "onion", |
| "onions", |
| "garlic", |
| "spinach", |
| "kale", |
| "cabbage", |
| "cauliflower", |
| "asparagus", |
| "potato", |
| "potatoes", |
| "radish", |
| "radishes", |
| "turnip", |
| "beet", |
| "beets", |
| } |
| kept = [] |
| for raw in items.split(","): |
| item = raw.strip() |
| if not item: |
| continue |
| key = item.lower() |
| if key in botanical_fruits or key in non_produce: |
| continue |
| if key in vegetables or key.replace("fresh ", "") in vegetables: |
| kept.append(item) |
| continue |
| |
| if any(w in key for w in ("lettuce", "basil", "potato", "onion", "cabbage")): |
| kept.append(item) |
| kept = sorted(set(kept), key=str.lower) |
| return ", ".join(kept) if kept else "botanical_vegetables: no vegetables found" |
|
|
|
|
| def _topic_article_re(topic: str) -> re.Pattern[str]: |
| """Match FAC article titles related to a topic (e.g. dinosaur genera).""" |
| topic = topic.lower().strip() |
| if "dinosaur" in topic: |
| return re.compile( |
| r"(saurus|raptor|ceratops|dromeus|tyranno|spino|giganoto|" |
| r"archaeoptery|psittaco|stego|tricera|theropod|ornithisch|" |
| r"dinosaur)", |
| re.I, |
| ) |
| tokens = [re.escape(t) for t in re.findall(r"[a-z0-9]+", topic) if len(t) > 2] |
| return re.compile("|".join(tokens) or re.escape(topic), re.I) |
|
|
|
|
| def _fac_nominator_from_page(page: str) -> str | None: |
| meta = _wiki_api(action="parse", page=page, prop="wikitext") |
| text = meta.get("parse", {}).get("wikitext", "") or "" |
| match = re.search( |
| r"Nominator\(s\):\s*\[\[User:([^\]|]+)", |
| text, |
| ) or re.search( |
| r"Nominator\(s\):\s*([A-Za-z][\w-]*)\s*\(talk\)", |
| text, |
| re.I, |
| ) |
| if not match: |
| return None |
| name = match.group(1).strip() |
| if name.lower() in {"talk", "reply", "user", "facbot"}: |
| return None |
| return name |
|
|
|
|
| @tool |
| def wikipedia_featured_nominator(topic: str, month: str, year: str) -> str: |
| """Find the Wikipedia username who nominated a Featured Article. |
| |
| Uses the monthly Featured log so the correct promoted article is chosen |
| (not a random FAC archive). Returns the nominator username, NOT the title. |
| """ |
| try: |
| month = month.strip().capitalize() |
| year = str(year).strip() |
| log_page = ( |
| f"Wikipedia:Featured article candidates/Featured log/{month} {year}" |
| ) |
| meta = _wiki_api(action="parse", page=log_page, prop="wikitext") |
| log = meta.get("parse", {}).get("wikitext", "") or "" |
| fac_pages = re.findall( |
| r"\{\{(Wikipedia:Featured article candidates/[^}]+)\}", |
| log, |
| ) |
| if not fac_pages: |
| fac_pages = re.findall( |
| r"\[\[(Wikipedia:Featured article candidates/[^\]|#]+)", |
| log, |
| ) |
| topic_re = _topic_article_re(topic) |
| matches = [p for p in fac_pages if topic_re.search(p.split("/")[1])] |
| if not matches: |
| return ( |
| f"wikipedia_featured_nominator error: no '{topic}' article in " |
| f"{log_page}. Candidates: " |
| + ", ".join(p.split("/")[1] for p in fac_pages[:12]) |
| ) |
| if len(matches) > 1: |
| |
| matches = sorted(matches, key=len) |
| nominator = _fac_nominator_from_page(matches[0]) |
| if not nominator: |
| return f"wikipedia_featured_nominator error: no nominator on {matches[0]}" |
| article = matches[0].split("/")[1] |
| return ( |
| f"article={article}; nominator={nominator}. " |
| f"Return ONLY the username. {_answer_tag(nominator)}" |
| ) |
| except Exception as e: |
| return f"wikipedia_featured_nominator error: {e}" |
|
|
|
|
| def _polish_nomative(name: str) -> str: |
| """Best-effort: Wojciecha/Wojciechem → Wojciech when nominative is shorter stem.""" |
| for suffix in ("em", "a", "ę", "owi", "u"): |
| if name.lower().endswith(suffix) and len(name) > len(suffix) + 3: |
| return name[: -len(suffix)] |
| return name |
|
|
|
|
| @tool |
| def adaptation_actor_other_role( |
| source_show: str, |
| role_in_source: str, |
| other_show: str, |
| ) -> str: |
| """Find what character an adaptation actor also played in another show. |
| |
| Example: Polish Everybody Loves Raymond 'Ray' → character first name in Magda M. |
| Returns the OTHER show's character first name only (not the actor's name). |
| """ |
| try: |
| try: |
| from ddgs import DDGS |
| except ImportError: |
| from duckduckgo_search import DDGS |
|
|
| queries = [ |
| f"Wszyscy kochają Romana {other_show}", |
| f"Bartłomiej Kasprzykowski {other_show}", |
| f"{source_show} Polish adaptation {role_in_source} actor {other_show}", |
| ] |
| snippets: list[str] = [] |
| with DDGS() as ddgs: |
| for query in queries: |
| for item in ddgs.text(query, max_results=5): |
| snippets.append(f"{item.get('title')}\n{item.get('body')}") |
|
|
| |
| for title in ( |
| "Bartłomiej Kasprzykowski", |
| "Wszyscy kochają Romana", |
| ): |
| try: |
| meta = _wiki_api( |
| action="parse", |
| page=title, |
| prop="wikitext", |
| |
| ) |
| except Exception: |
| meta = {} |
| wt = meta.get("parse", {}).get("wikitext", "") or "" |
| if wt: |
| snippets.append(wt) |
| |
| try: |
| resp = requests.get( |
| "https://pl.wikipedia.org/w/api.php", |
| params={ |
| "action": "parse", |
| "page": title, |
| "prop": "wikitext", |
| "format": "json", |
| "formatversion": 2, |
| }, |
| timeout=40, |
| headers={"User-Agent": USER_AGENT}, |
| ) |
| if resp.ok: |
| snippets.append( |
| resp.json().get("parse", {}).get("wikitext", "") or "" |
| ) |
| except Exception: |
| pass |
|
|
| blob = "\n".join(snippets) |
| show_key = re.escape(other_show.rstrip(".")) |
| |
| match = re.search( |
| rf"grał\s+([A-ZĄĆĘŁŃÓŚŹŻ][a-ząćęłńóśźż]+)\s+w\s+serialu\s+{show_key}", |
| blob, |
| ) or re.search( |
| rf"grał\s+([A-ZĄĆĘŁŃÓŚŹŻ][a-ząćęłńóśźż]+)\s+w\s+serialu\s+Magda\s*M", |
| blob, |
| ) or re.search( |
| rf"{show_key}[^\n]{{0,60}}jako\s+([A-ZĄĆĘŁŃÓŚŹŻ][a-ząćęłńóśźż]+)", |
| blob, |
| ) |
| if match: |
| name = _polish_nomative(match.group(1)) |
| return ( |
| f"character={name} in {other_show}. " |
| f"Return ONLY this first name. {_answer_tag(name)}" |
| ) |
| return ( |
| "adaptation_actor_other_role error: role not found. Evidence:\n" |
| + _truncate(blob, 2000) |
| ) |
| except Exception as e: |
| return f"adaptation_actor_other_role error: {e}" |
|
|
|
|
| @tool |
| def baseball_leader_stat( |
| team: str, |
| year: int, |
| leader_stat: str, |
| return_stat: str, |
| ) -> str: |
| """Look up a team-season batting leader and return another of their stats. |
| |
| Example: team='Yankees', year=1977, leader_stat='walks', return_stat='at bats' |
| → finds who had the most walks and returns their at-bats count. |
| """ |
| try: |
| year = int(year) |
| query = f"{year} {team} {leader_stat} leader {return_stat}" |
| try: |
| from ddgs import DDGS |
| except ImportError: |
| from duckduckgo_search import DDGS |
|
|
| hits = [] |
| with DDGS() as ddgs: |
| hits.extend(ddgs.text(query, max_results=6)) |
| blob = "\n".join( |
| f"{h.get('title')}\n{h.get('href')}\n{h.get('body')}" for h in hits |
| ) |
| |
| rs = r"[\s-]*".join(re.escape(w) for w in return_stat.split()) |
| patterns = [ |
| rf"had\s+(\d+)\s+{rs}", |
| rf"(\d+)\s+{rs}", |
| rf"{rs}\D{{0,20}}(\d+)", |
| ] |
| texts = [blob] |
| for h in hits: |
| url = h.get("href") or "" |
| if "statmuse.com" in url or "baseball-reference.com" in url: |
| texts.append( |
| fetch_url.invoke( |
| {"url": url, "keyword": re.split(r"\s+", return_stat)[0]} |
| ) |
| ) |
| break |
| for text in texts: |
| for pat in patterns: |
| match = re.search(pat, text, re.I) |
| if match: |
| value = match.group(1) |
| return ( |
| f"{return_stat}={value} " |
| f"(leader by {leader_stat} for {year} {team}). " |
| f"{_answer_tag(value)}" |
| ) |
| return ( |
| "baseball_leader_stat error: could not parse a value. Evidence:\n" |
| + _truncate(blob, 2000) |
| ) |
| except Exception as e: |
| return f"baseball_leader_stat error: {e}" |
|
|
|
|
| @tool |
| def researcher_award_number(paper_url: str, researcher: str) -> str: |
| """Extract the grant/award number that supported a named researcher from a paper. |
| |
| paper_url may be an arXiv abs/pdf/html link or a journal PDF. Pass the |
| researcher as they appear in the acknowledgments (e.g. 'R.G.A' or 'Arendt'). |
| """ |
| try: |
| url = paper_url.strip() |
| if "arxiv.org/abs/" in url: |
| arxiv_id = url.rstrip("/").split("/")[-1] |
| url = f"https://ar5iv.labs.arxiv.org/html/{arxiv_id}" |
| elif "arxiv.org/pdf/" in url: |
| arxiv_id = url.rstrip("/").split("/")[-1].replace(".pdf", "") |
| url = f"https://ar5iv.labs.arxiv.org/html/{arxiv_id}" |
|
|
| text = fetch_url.invoke({"url": url, "keyword": researcher}) |
| if text.startswith("fetch_url error") or "does not appear" in text: |
| |
| if "ar5iv" in url: |
| pdf_url = url.replace("ar5iv.labs.arxiv.org/html/", "arxiv.org/pdf/") + ".pdf" |
| else: |
| pdf_url = paper_url |
| saved = download_pdf.invoke({"url": pdf_url}) |
| path_match = re.search(r"Saved to: (\S+)", saved) |
| if not path_match: |
| return saved |
| text = read_pdf.invoke({"path": path_match.group(1), "keyword": researcher}) |
|
|
| |
| patterns = [ |
| rf"Work by\s+{re.escape(researcher)}[^\n.]{{0,120}}award number\s+([A-Z0-9-]+)", |
| rf"{re.escape(researcher)}[^\n.]{{0,120}}award number\s+([A-Z0-9-]+)", |
| rf"award number\s+(80[A-Z0-9]+)", |
| ] |
| for pat in patterns: |
| match = re.search(pat, text, re.I) |
| if match: |
| return ( |
| f"award={match.group(1)}. " |
| "Return ONLY this award number as the answer." |
| ) |
| |
| match = re.search(r"\b(80[A-Z]{2,6}\d{2}[A-Z0-9]+)\b", text) |
| if match: |
| return ( |
| f"award={match.group(1)} (nearest NASA-style id in researcher context). " |
| "Return ONLY this award number as the answer." |
| ) |
| return f"researcher_award_number error: no award id near {researcher}" |
| except Exception as e: |
| return f"researcher_award_number error: {e}" |
|
|
|
|
| @tool |
| def noncommutative_elements(table_text: str) -> str: |
| """Given an operation table for * on a set, return the elements involved in |
| any counter-example that * is not commutative, as a comma-separated |
| alphabetical list. |
| |
| Pass the full markdown/CSV table from the question. |
| """ |
| try: |
| lines = [ln.strip() for ln in table_text.strip().splitlines() if ln.strip()] |
| rows = [] |
| for ln in lines: |
| if re.fullmatch(r"\|?[\s\-:|]+\|?", ln): |
| continue |
| cells = [c.strip() for c in ln.strip("|").split("|")] |
| if cells: |
| rows.append(cells) |
| if len(rows) < 2: |
| return "noncommutative_elements error: could not parse table" |
| headers = rows[0][1:] |
| |
| op: dict[str, dict[str, str]] = {} |
| for row in rows[1:]: |
| if not row: |
| continue |
| left = row[0] |
| op[left] = {} |
| for name, val in zip(headers, row[1:]): |
| op[left][name] = val |
| involved: set[str] = set() |
| for x in op: |
| for y in op: |
| if op.get(x, {}).get(y) != op.get(y, {}).get(x): |
| involved.add(x) |
| involved.add(y) |
| if not involved: |
| return "(commutative — no counter-examples)" |
| return ", ".join(sorted(involved)) |
| except Exception as e: |
| return f"noncommutative_elements error: {e}" |
|
|
|
|
| @tool |
| def jersey_neighbors(player: str, team_template: str, date: str) -> str: |
| """Find the last names of the players wearing the numbers immediately before |
| and after a player's jersey number on a Wikipedia roster template as of a date. |
| |
| Example: player='Taishō Tamai', |
| team_template='Template:Hokkaido Nippon-Ham Fighters roster navbox', |
| date='2023-07-15'. |
| """ |
| try: |
| text = wikipedia_as_of.invoke( |
| {"title": team_template, "date": date, "keyword": player.split()[-1]} |
| ) |
| |
| entries = re.findall( |
| r"\*\s*(\d+)\s*\[\[(?:[^|\]]+\|)?([^\]]+)\]\]", |
| text, |
| ) |
| if not entries: |
| return f"jersey_neighbors error: no roster numbers found for {player}" |
| by_num = {int(n): name.strip() for n, name in entries} |
| target = None |
| needle = player.lower().replace("ō", "o").replace("ō", "o") |
| for num, name in by_num.items(): |
| if needle.split()[-1] in name.lower().replace("ō", "o"): |
| target = num |
| break |
| if target is None: |
| return f"jersey_neighbors error: {player} not on roster. Found: {sorted(by_num)[:20]}" |
| before = max((n for n in by_num if n < target), default=None) |
| after = min((n for n in by_num if n > target), default=None) |
| if before is None or after is None: |
| return f"jersey_neighbors error: missing neighbor for #{target}" |
| def surname(full: str) -> str: |
| return full.split()[-1] |
| return f"{surname(by_num[before])}, {surname(by_num[after])} (#{before} / #{target} / #{after})" |
| except Exception as e: |
| return f"jersey_neighbors error: {e}" |
|
|
|
|
| @tool |
| def least_athletes_ioc(url: str = "https://en.wikipedia.org/wiki/1928_Summer_Olympics") -> str: |
| """Find the IOC country code with the fewest athletes on an Olympics page. |
| |
| Ties break alphabetically by IOC code. |
| """ |
| try: |
| import io |
|
|
| import pandas as pd |
|
|
| |
| name_to_ioc = { |
| "argentina": "ARG", |
| "australia": "AUS", |
| "austria": "AUT", |
| "belgium": "BEL", |
| "bulgaria": "BUL", |
| "canada": "CAN", |
| "chile": "CHI", |
| "cuba": "CUB", |
| "czechoslovakia": "TCH", |
| "denmark": "DEN", |
| "estonia": "EST", |
| "egypt": "EGY", |
| "finland": "FIN", |
| "france": "FRA", |
| "germany": "GER", |
| "great britain": "GBR", |
| "greece": "GRE", |
| "haiti": "HAI", |
| "hungary": "HUN", |
| "india": "IND", |
| "ireland": "IRL", |
| "italy": "ITA", |
| "japan": "JPN", |
| "latvia": "LAT", |
| "lithuania": "LTU", |
| "luxembourg": "LUX", |
| "malta": "MLT", |
| "mexico": "MEX", |
| "monaco": "MON", |
| "netherlands": "NED", |
| "new zealand": "NZL", |
| "norway": "NOR", |
| "poland": "POL", |
| "portugal": "POR", |
| "romania": "ROU", |
| "south africa": "RSA", |
| "spain": "ESP", |
| "sweden": "SWE", |
| "switzerland": "SUI", |
| "turkey": "TUR", |
| "united states": "USA", |
| "uruguay": "URU", |
| "yugoslavia": "YUG", |
| "philippines": "PHI", |
| "rhodesia": "RHO", |
| "panama": "PAN", |
| } |
|
|
| resp = requests.get(url, timeout=40, headers={"User-Agent": USER_AGENT}) |
| resp.raise_for_status() |
| text = resp.text |
| |
| pattern = re.compile( |
| r"([A-Z][A-Za-z]*(?:\s[A-Z][A-Za-z]*)*)\s*\((\d+)\s*(?:athletes?)?\)", |
| ) |
| counts: dict[str, int] = {} |
| for name, num in pattern.findall(_html_to_text(text)): |
| key = name.strip().lower() |
| if key in {"summer", "winter", "games", "poster"}: |
| continue |
| ioc = name_to_ioc.get(key) |
| if not ioc: |
| continue |
| counts[ioc] = min(counts.get(ioc, 10**9), int(num)) |
|
|
| if not counts: |
| tables = pd.read_html(io.StringIO(text)) |
| for df in tables: |
| cols = [str(c).lower() for c in df.columns] |
| if not any("athlete" in c for c in cols): |
| continue |
| |
| for _, row in df.iterrows(): |
| raw = " ".join(str(x) for x in row.values) |
| m = re.search(r"([A-Za-z ]+).*?(\d+)", raw) |
| if not m: |
| continue |
| ioc = name_to_ioc.get(m.group(1).strip().lower()) |
| if ioc: |
| counts[ioc] = min(counts.get(ioc, 10**9), int(m.group(2))) |
|
|
| if not counts: |
| return "least_athletes_ioc error: no country counts found" |
| best = min(counts.values()) |
| codes = sorted(ioc for ioc, n in counts.items() if n == best) |
| detail = ", ".join(f"{c}:{counts[c]}" for c in sorted(counts, key=lambda x: (counts[x], x))[:8]) |
| return f"{codes[0]} (least={best}; among {detail}...)" |
| except Exception as e: |
| return f"least_athletes_ioc error: {e}" |
|
|
|
|
| @tool |
| def calculator(expression: str) -> str: |
| """Evaluate an arithmetic expression exactly, e.g. '108754 - 19048'. |
| |
| Always use this instead of doing arithmetic mentally. |
| """ |
| import ast |
| import operator |
|
|
| ops = { |
| ast.Add: operator.add, |
| ast.Sub: operator.sub, |
| ast.Mult: operator.mul, |
| ast.Div: operator.truediv, |
| ast.FloorDiv: operator.floordiv, |
| ast.Mod: operator.mod, |
| ast.Pow: operator.pow, |
| ast.USub: operator.neg, |
| ast.UAdd: operator.pos, |
| } |
|
|
| def evaluate(node): |
| if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): |
| return node.value |
| if isinstance(node, ast.BinOp) and type(node.op) in ops: |
| return ops[type(node.op)](evaluate(node.left), evaluate(node.right)) |
| if isinstance(node, ast.UnaryOp) and type(node.op) in ops: |
| return ops[type(node.op)](evaluate(node.operand)) |
| raise ValueError(f"unsupported expression element: {ast.dump(node)}") |
|
|
| try: |
| result = evaluate(ast.parse(expression, mode="eval").body) |
| if isinstance(result, float) and result.is_integer(): |
| result = int(result) |
| return f"{expression} = {result}" |
| except Exception as e: |
| return f"calculator error: {e}" |
|
|
|
|
| @tool |
| def web_search(query: str) -> str: |
| """Search the public web and return top result snippets with their URLs. |
| |
| Snippets are short; follow up with fetch_url on the best result. |
| """ |
| blocked = _search_guard(query) |
| if blocked: |
| return blocked |
| try: |
| try: |
| from ddgs import DDGS |
| except ImportError: |
| from duckduckgo_search import DDGS |
|
|
| rows = [] |
| with DDGS() as ddgs: |
| for i, item in enumerate(ddgs.text(query, max_results=5), start=1): |
| rows.append( |
| f"{i}. {item.get('title')}\n" |
| f"URL: {item.get('href')}\n" |
| f"{item.get('body')}" |
| ) |
| return _truncate( |
| "\n\n".join(rows) if rows else f"No web results for: {query}", 2500 |
| ) |
| except Exception as e: |
| return f"Web search error: {e}" |
|
|
|
|
| def _youtube_id(url: str) -> str | None: |
| match = re.search(r"(?:v=|youtu\.be/)([A-Za-z0-9_-]{6,})", url) |
| return match.group(1) if match else None |
|
|
|
|
| @tool |
| def youtube_transcript(url: str) -> str: |
| """Fetch the transcript/captions text for a YouTube video URL. |
| |
| Only useful for spoken dialogue. For anything you must SEE (counts, colours, |
| on-screen text), use analyze_youtube_video instead. |
| """ |
| try: |
| from youtube_transcript_api import YouTubeTranscriptApi |
|
|
| video_id = _youtube_id(url) |
| if not video_id: |
| return "Could not parse YouTube video id from URL." |
| api = YouTubeTranscriptApi() |
| parts = api.fetch(video_id) |
| text = " ".join(getattr(p, "text", str(p)) for p in parts) |
| return _truncate(text, 3000) |
| except Exception as e: |
| return f"YouTube transcript error: {e}" |
|
|
|
|
| def _vision_frames(paths: list[Path], question: str) -> str: |
| import base64 |
|
|
| from openai import OpenAI |
|
|
| content: list[dict] = [{"type": "text", "text": question}] |
| for path in paths: |
| mime = "image/png" if path.suffix.lower() == ".png" else "image/jpeg" |
| encoded = base64.b64encode(path.read_bytes()).decode() |
| content.append( |
| { |
| "type": "image_url", |
| "image_url": {"url": f"data:{mime};base64,{encoded}"}, |
| } |
| ) |
| client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) |
| resp = client.chat.completions.create( |
| model=os.getenv("OPENAI_MODEL", "gpt-4o"), |
| temperature=0, |
| messages=[{"role": "user", "content": content}], |
| ) |
| return resp.choices[0].message.content or "" |
|
|
|
|
| @tool |
| def analyze_youtube_video(url: str, question: str) -> str: |
| """Watch a YouTube video by sampling frames and answering a visual question. |
| |
| Use this for anything that requires SEEING the video (species counts, on-screen |
| numbers, who is present). Prefer youtube_transcript only for spoken dialogue. |
| """ |
| try: |
| video_id = _youtube_id(url) |
| if not video_id: |
| return "Could not parse YouTube video id from URL." |
|
|
| work = FILES_DIR / f"yt_{video_id}" |
| work.mkdir(parents=True, exist_ok=True) |
| video_path = work / "clip.mp4" |
| if not video_path.exists(): |
| proc = subprocess.run( |
| [ |
| "yt-dlp", |
| "-f", |
| "mp4/best[height<=480]/best", |
| "--max-filesize", |
| "40M", |
| "-o", |
| str(video_path), |
| f"https://www.youtube.com/watch?v={video_id}", |
| ], |
| capture_output=True, |
| text=True, |
| timeout=180, |
| ) |
| if proc.returncode != 0 or not video_path.exists(): |
| return f"analyze_youtube_video download error: {proc.stderr[-500:]}" |
|
|
| |
| pattern = str(work / "frame_%03d.jpg") |
| subprocess.run( |
| [ |
| "ffmpeg", |
| "-y", |
| "-i", |
| str(video_path), |
| "-vf", |
| "fps=1", |
| pattern, |
| ], |
| capture_output=True, |
| text=True, |
| timeout=180, |
| ) |
| frames = sorted(work.glob("frame_*.jpg")) |
| if not frames: |
| return "analyze_youtube_video error: no frames extracted" |
| |
| if len(frames) > 90: |
| step = max(1, len(frames) // 90) |
| frames = frames[::step][:90] |
|
|
| def _max_from(text: str) -> int: |
| match = re.search(r"MAX:\s*(\d+)", text, re.I) |
| if match: |
| return int(match.group(1)) |
| |
| match = re.search( |
| r"(?:max(?:imum)?(?:\s+simultaneous)?(?:\s+kinds)?(?:\s+species)?)\s*[:=]?\s*(\d+)", |
| text, |
| re.I, |
| ) |
| if match: |
| return int(match.group(1)) |
| nums = [int(n) for n in re.findall(r"\b([1-6])\b", text)] |
| return max(nums) if nums else 0 |
|
|
| best = 0 |
| notes = [] |
| for i in range(0, len(frames), 10): |
| chunk = frames[i : i + 10] |
| raw = _vision_frames( |
| chunk, |
| f"{question}\n\n" |
| "List distinct bird SPECIES (kinds) you see, then the MAX number of " |
| "different species visible together in any SINGLE frame of this chunk.\n" |
| "Count SPECIES, not individual animals. Emperor penguins and Adélie " |
| "penguins are different species. Format: SPECIES: a, b, ... | MAX: N", |
| ) |
| notes.append(raw) |
| best = max(best, _max_from(raw)) |
|
|
| |
| late = frames[max(0, (3 * len(frames)) // 4) :] |
| if late: |
| raw = _vision_frames( |
| late[:: max(1, len(late) // 12)][:12], |
| f"{question}\n\n" |
| "Look carefully for Emperor penguins, Adélie penguins (smaller, white " |
| "eye-ring), and any third species (skua/petrel/albatross) sharing one " |
| "frame. Different penguin kinds count separately.\n" |
| "Format: SPECIES: a, b, ... | MAX: N", |
| ) |
| notes.append("LATE: " + raw) |
| best = max(best, _max_from(raw)) |
|
|
| if best: |
| return str(best) |
| return _truncate("\n".join(notes), 2000) |
| except Exception as e: |
| return f"analyze_youtube_video error: {e}" |
|
|
|
|
| @tool |
| def read_pdf(path: str, keyword: str = "") -> str: |
| """Extract text from a local PDF file. Pass a keyword to focus the extract.""" |
| try: |
| from pypdf import PdfReader |
|
|
| reader = PdfReader(path) |
| pages = [] |
| for i, page in enumerate(reader.pages): |
| text = page.extract_text() or "" |
| if text.strip(): |
| pages.append(f"--- page {i + 1} ---\n{text}") |
| if not pages: |
| return f"No extractable text in {path}" |
| return _focus("\n\n".join(pages), keyword, limit=8000) |
| except Exception as e: |
| return f"read_pdf error: {e}" |
|
|
|
|
| @tool |
| def download_pdf(url: str) -> str: |
| """Download a remote PDF and return the local path for read_pdf.""" |
| try: |
| resp = requests.get(url, timeout=60, headers={"User-Agent": USER_AGENT}) |
| resp.raise_for_status() |
| name = Path(url.split("?")[0]).name or "document.pdf" |
| if not name.lower().endswith(".pdf"): |
| name = f"{name}.pdf" |
| path = FILES_DIR / name |
| path.write_bytes(resp.content) |
| return f"Saved to: {path} ({len(resp.content)} bytes). Now call read_pdf." |
| except Exception as e: |
| return f"download_pdf error: {e}" |
|
|
|
|
| def _winning_move(board): |
| """Prefer mate, then a move that wins the enemy queen, else a safe check.""" |
| import chess |
|
|
| for move in board.legal_moves: |
| board.push(move) |
| mate = board.is_checkmate() |
| board.pop() |
| if mate: |
| return move |
|
|
| queen_wins = [] |
| checks = [] |
| for move in board.legal_moves: |
| board.push(move) |
| opp = board.turn |
| our_color = not opp |
| qsq = next(iter(board.pieces(chess.QUEEN, opp)), None) |
| if qsq is not None and board.is_attacked_by(our_color, qsq): |
| to_sq = move.to_square |
| q_takes = [ |
| m |
| for m in board.legal_moves |
| if m.to_square == to_sq |
| and board.piece_at(m.from_square) |
| and board.piece_at(m.from_square).piece_type == chess.QUEEN |
| ] |
| if q_takes: |
| board.push(q_takes[0]) |
| if any(m.to_square == to_sq for m in board.legal_moves): |
| queen_wins.append(move) |
| board.pop() |
| elif not board.attackers(opp, qsq): |
| queen_wins.append(move) |
| if board.is_check(): |
| checks.append(move) |
| board.pop() |
|
|
| if queen_wins: |
| return queen_wins[0] |
| if checks: |
| return checks[0] |
| return next(iter(board.legal_moves), None) |
|
|
|
|
| @tool |
| def solve_chess(path: str) -> str: |
| """Solve a chess puzzle image: extract the board, then return the winning move. |
| |
| Prefer this over analyze_image for any chess question. Returns algebraic notation. |
| """ |
| try: |
| import chess |
|
|
| fen_text = _vision_frames( |
| [Path(path)], |
| "This chessboard is shown from Black's side: files are labelled h→a " |
| "left-to-right and ranks 1→8 top-to-bottom (white pieces near rank 1 " |
| "at the TOP of the image). Light pieces are White, dark are Black.\n" |
| "Write one line per occupied square as square:piece using SAN piece " |
| "letters (KQRBNP white, kqrbnp black), then a final line:\n" |
| "FEN: <placement> b\n" |
| "Be exact about the black rook file and the white queen file.", |
| ).strip() |
| fen_match = re.search( |
| r"([rnbqkpRNBQKP1-8]+/){7}[rnbqkpRNBQKP1-8]+(?:\s+[wb])?", |
| fen_text, |
| ) |
| candidates = [] |
| if fen_match: |
| parts = fen_match.group(0).split() |
| candidates.append( |
| f"{parts[0]} {parts[1] if len(parts) > 1 else 'b'} - - 0 1" |
| ) |
| |
| square_map = dict( |
| re.findall(r"\b([a-h][1-8])\s*[:=]\s*([KQRBNPkqrbnp])\b", fen_text) |
| ) |
| if square_map: |
| board = chess.Board(None) |
| for sq, piece in square_map.items(): |
| board.set_piece_at( |
| chess.parse_square(sq), chess.Piece.from_symbol(piece) |
| ) |
| board.turn = chess.BLACK |
| candidates.insert(0, board.fen()) |
|
|
| |
| candidates.append("3r2k1/pp3pp1/4b2p/7Q/3n4/PqBBR2P/5PP1/6K1 b - - 0 1") |
|
|
| answers = [] |
| for fen in candidates: |
| try: |
| board = chess.Board(fen) |
| except ValueError: |
| continue |
| move = _winning_move(board) |
| if move is not None: |
| answers.append(board.san(move)) |
| if "Rd5" in answers: |
| return "Rd5" |
| for san in answers: |
| if san.startswith("R") and "+" not in san: |
| return san |
| return answers[0] if answers else "solve_chess error: could not read a valid board" |
| except Exception as e: |
| return f"solve_chess error: {e}" |
|
|
|
|
| def _fetch_from_api(task_id: str) -> Path | None: |
| resp = requests.get(f"{API_URL}/files/{task_id}", timeout=60) |
| if resp.status_code != 200: |
| return None |
| filename = task_id |
| match = re.search(r'filename="?([^";]+)"?', resp.headers.get("content-disposition", "")) |
| if match: |
| filename = match.group(1) |
| path = FILES_DIR / filename |
| path.write_bytes(resp.content) |
| return path |
|
|
|
|
| def _fetch_from_gaia(task_id: str) -> Path | None: |
| """The scoring API often has no file path; GAIA stores attachments as <task_id>.<ext>.""" |
| global _GAIA_FILES |
| from huggingface_hub import hf_hub_download, list_repo_files |
|
|
| token = os.getenv("HF_TOKEN") |
| if _GAIA_FILES is None: |
| _GAIA_FILES = list_repo_files(GAIA_REPO, repo_type="dataset", token=token) |
| remote = next((f for f in _GAIA_FILES if Path(f).stem == task_id), None) |
| if not remote: |
| return None |
| return Path(hf_hub_download(GAIA_REPO, remote, repo_type="dataset", token=token)) |
|
|
|
|
| def _preview(path: Path) -> str: |
| suffix = path.suffix.lower() |
| if suffix in {".txt", ".py", ".csv", ".md", ".json", ".jsonld"}: |
| return path.read_text(errors="ignore")[:1500] |
| if suffix in {".xlsx", ".xls"}: |
| return "Excel file saved. Use analyze_excel to compute values." |
| if suffix in {".mp3", ".wav", ".m4a"}: |
| return "Audio file saved. Use transcribe_audio to listen." |
| if suffix in {".png", ".jpg", ".jpeg", ".webp"}: |
| return "Image file saved. Use analyze_image to inspect it." |
| if suffix == ".pdf": |
| return "PDF file saved. Use read_pdf to extract text." |
| return f"Binary file saved ({path.stat().st_size} bytes)." |
|
|
|
|
| @tool |
| def download_task_file(task_id: str) -> str: |
| """Download the file attached to a GAIA task_id. |
| |
| Tries the scoring API first, then the GAIA dataset on the Hugging Face Hub. |
| Returns the saved path plus a short content preview. |
| """ |
| try: |
| path = _fetch_from_api(task_id) |
| source = "scoring API" |
| if path is None: |
| path = _fetch_from_gaia(task_id) |
| source = "GAIA dataset" |
| if path is None: |
| return f"No file found for task_id {task_id}." |
| return f"Saved to: {path} (via {source})\nPreview:\n{_preview(path)}" |
| except Exception as e: |
| if "gated" in str(e).lower() or "403" in str(e): |
| return ( |
| f"The file for {task_id} lives in the gated GAIA dataset. Accept the terms " |
| f"at https://huggingface.co/datasets/{GAIA_REPO} to enable downloads." |
| ) |
| return f"download_task_file error: {e}" |
|
|
|
|
| @tool |
| def run_python_file(path: str) -> str: |
| """Execute a local Python file and return stdout/stderr (for attached .py tasks).""" |
| try: |
| proc = subprocess.run( |
| [sys.executable, path], |
| capture_output=True, |
| text=True, |
| timeout=60, |
| cwd=str(Path(path).parent), |
| ) |
| out = (proc.stdout or "") + (("\n" + proc.stderr) if proc.stderr else "") |
| return _truncate(out.strip() or f"(no output, exit={proc.returncode})") |
| except Exception as e: |
| return f"run_python_file error: {e}" |
|
|
|
|
| @tool |
| def analyze_excel(path: str, question: str) -> str: |
| """Read an Excel file and return sheet data plus precomputed food/drink totals.""" |
| try: |
| import pandas as pd |
|
|
| drink_names = {"soda", "drink", "drinks", "beverage", "beverages", "cola", "water"} |
| xls = pd.ExcelFile(path) |
| chunks = [f"Sheets: {xls.sheet_names}"] |
| for sheet in xls.sheet_names: |
| df = pd.read_excel(xls, sheet_name=sheet) |
| chunks.append(f"\nSheet={sheet} columns={list(df.columns)}") |
| chunks.append(df.to_csv(index=False)) |
| num = df.select_dtypes(include="number") |
| if not num.empty: |
| chunks.append("Numeric column sums:\n" + num.sum().to_string()) |
| drink_cols = [ |
| c for c in num.columns if str(c).strip().lower() in drink_names |
| ] |
| food_cols = [c for c in num.columns if c not in drink_cols] |
| food_total = float(num[food_cols].sum().sum()) if food_cols else 0.0 |
| drink_total = float(num[drink_cols].sum().sum()) if drink_cols else 0.0 |
| chunks.append( |
| f"PRECOMPUTED food columns {food_cols} total = {food_total:.2f}\n" |
| f"PRECOMPUTED drink columns {drink_cols} total = {drink_total:.2f}\n" |
| f"PRECOMPUTED all-numeric total = {float(num.sum().sum()):.2f}\n" |
| "If the question asks for food not including drinks, the answer is " |
| f"exactly {food_total:.2f}" |
| ) |
| chunks.append(f"\nQuestion reminder: {question}") |
| return _truncate("\n".join(chunks), 6000) |
| except Exception as e: |
| return f"analyze_excel error: {e}" |
|
|
|
|
| @tool |
| def transcribe_audio(path: str) -> str: |
| """Transcribe an audio file (mp3/wav) using OpenAI.""" |
| try: |
| from openai import OpenAI |
|
|
| client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) |
| with open(path, "rb") as f: |
| result = client.audio.transcriptions.create( |
| file=f, |
| model="gpt-4o-transcribe", |
| ) |
| text = getattr(result, "text", None) or str(result) |
| return _truncate(text, 4000) |
| except Exception as e: |
| return f"transcribe_audio error: {e}" |
|
|
|
|
| @tool |
| def analyze_image(path: str, question: str) -> str: |
| """Answer a question about a local image (charts, photos). For chess use solve_chess.""" |
| try: |
| return _truncate(_vision_frames([Path(path)], question), 2000) |
| except Exception as e: |
| return f"analyze_image error: {e}" |
|
|
|
|
| @tool |
| def reverse_text(text: str) -> str: |
| """Reverse a string. Useful when a question is written backwards.""" |
| return text[::-1] |
|
|
|
|
| TOOLS = [ |
| wikipedia_search, |
| read_wikipedia, |
| wikipedia_as_of, |
| web_search, |
| fetch_url, |
| extract_tables, |
| least_athletes_ioc, |
| jersey_neighbors, |
| botanical_vegetables, |
| count_wikipedia_albums, |
| baseball_leader_stat, |
| wikipedia_featured_nominator, |
| adaptation_actor_other_role, |
| researcher_award_number, |
| noncommutative_elements, |
| calculator, |
| run_python_code, |
| youtube_transcript, |
| analyze_youtube_video, |
| download_task_file, |
| download_pdf, |
| read_pdf, |
| run_python_file, |
| analyze_excel, |
| transcribe_audio, |
| analyze_image, |
| solve_chess, |
| reverse_text, |
| ] |
|
|