Spaces:
Sleeping
Sleeping
File size: 6,938 Bytes
347b1d2 7c9bf9d 347b1d2 7c9bf9d 347b1d2 7c9bf9d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 | """Tools for the GAIA agent: web search, arithmetic, and image recognition."""
import ast
import base64
import math
import operator
import re
from typing import Any
import requests
from ddgs import DDGS
from langchain_core.messages import HumanMessage
from prompts import IMAGE_ANALYSIS_PROMPT
MAX_IMAGES_PER_QUESTION = 3
_IMAGE_EXTENSIONS = r"(?:jpg|jpeg|png|gif|webp|bmp|svg)"
_URL_TAIL = r"(?:\?[^\s\)\]\"\'<>]*)?"
# Safe operators for GAIA-style numeric questions
_SAFE_OPERATORS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.FloorDiv: operator.floordiv,
ast.Pow: operator.pow,
ast.Mod: operator.mod,
ast.USub: operator.neg,
ast.UAdd: operator.pos,
}
def _eval_ast(node: ast.AST) -> float:
if isinstance(node, ast.Constant):
if isinstance(node.value, (int, float)):
return float(node.value)
raise ValueError(f"Unsupported constant: {node.value}")
if isinstance(node, ast.Num): # pragma: no cover - py<3.8
return float(node.n)
if isinstance(node, ast.BinOp):
op = _SAFE_OPERATORS.get(type(node.op))
if not op:
raise ValueError(f"Unsupported operator: {type(node.op).__name__}")
return op(_eval_ast(node.left), _eval_ast(node.right))
if isinstance(node, ast.UnaryOp):
op = _SAFE_OPERATORS.get(type(node.op))
if not op:
raise ValueError(f"Unsupported unary op: {type(node.op).__name__}")
return op(_eval_ast(node.operand))
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Name):
name = node.func.id
args = [_eval_ast(a) for a in node.args]
if name == "sqrt" and len(args) == 1:
return math.sqrt(args[0])
if name == "round" and len(args) in (1, 2):
return float(round(args[0], int(args[1]) if len(args) == 2 else 0))
if name == "abs" and len(args) == 1:
return abs(args[0])
raise ValueError("Unsupported function call")
raise ValueError(f"Unsupported expression node: {type(node).__name__}")
def _normalize_expression(expr: str) -> str:
expr = expr.strip()
expr = expr.replace("×", "*").replace("÷", "/").replace("^", "**")
expr = re.sub(r"\s+", "", expr)
return expr
def calculate(expression: str) -> str:
"""
Evaluate a numeric expression safely (numbers, + - * / // ** %, sqrt, abs, round).
Returns a string result for the agent to use as evidence.
"""
expr = _normalize_expression(expression)
if not expr:
return "Error: empty expression"
try:
tree = ast.parse(expr, mode="eval")
result = _eval_ast(tree.body)
if result == int(result) and "." not in str(expr).split("/")[-1]:
return str(int(result))
return str(round(result, 10)).rstrip("0").rstrip(".")
except Exception as e:
return f"Error: {e}"
def run_calculations(expressions: list[str]) -> list[str]:
"""Run multiple calculations; skip empty or duplicate expressions."""
seen: set[str] = set()
outputs: list[str] = []
for raw in expressions:
expr = str(raw).strip()
if not expr or expr in seen:
continue
seen.add(expr)
result = calculate(expr)
outputs.append(f"{expr} = {result}")
return outputs
def _message_content(response: Any) -> str:
content = response.content
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("text"):
parts.append(str(block["text"]))
return "\n".join(parts).strip()
return str(content).strip()
def extract_image_urls(text: str) -> list[str]:
"""Find image URLs in question text (markdown, HTML, plain links)."""
candidates: list[str] = []
patterns = [
rf"https?://[^\s\)\]\"\'<>]+\.{_IMAGE_EXTENSIONS}{_URL_TAIL}",
r"!\[[^\]]*\]\((https?://[^\)]+)\)",
r"https?://huggingface\.co/datasets/[^\s\)\]\"\'<>]+",
r"https?://[^\s\)\]\"\'<>]*(?:/image|/images|/file|/files)[^\s\)\]\"\'<>]*",
]
for pattern in patterns:
for match in re.finditer(pattern, text, re.IGNORECASE):
url = match.group(1) if match.lastindex else match.group(0)
url = url.rstrip(".,;:")
if url.startswith("http"):
candidates.append(url)
seen: set[str] = set()
deduped: list[str] = []
for url in candidates:
key = url.lower()
if key not in seen:
seen.add(key)
deduped.append(url)
if len(deduped) >= MAX_IMAGES_PER_QUESTION:
break
return deduped
def _fetch_image_base64(url: str) -> tuple[str, str]:
"""Download image and return (media_type, base64_data)."""
headers = {"User-Agent": "Mozilla/5.0 (compatible; GAIA-Agent/1.0)"}
resp = requests.get(url, timeout=45, headers=headers)
resp.raise_for_status()
content_type = (resp.headers.get("content-type") or "image/jpeg").split(";")[0].strip()
if not content_type.startswith("image/"):
content_type = "image/jpeg"
return content_type, base64.standard_b64encode(resp.content).decode("ascii")
def describe_image(url: str, question: str, vision_model: Any) -> str:
"""
Analyze an image URL with Claude vision and return a text description.
"""
try:
media_type, data = _fetch_image_base64(url)
prompt = IMAGE_ANALYSIS_PROMPT.format(question=question, url=url)
message = HumanMessage(
content=[
{"type": "text", "text": prompt},
{
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": data,
},
},
]
)
return _message_content(vision_model.invoke([message]))
except Exception as e:
return f"Vision error: {e}"
def analyze_images(urls: list[str], question: str, vision_model: Any) -> list[str]:
"""Run vision on up to MAX_IMAGES_PER_QUESTION URLs."""
results: list[str] = []
for url in urls[:MAX_IMAGES_PER_QUESTION]:
description = describe_image(url, question, vision_model)
results.append(f"[Image: {url}]\n{description}")
return results
def search_web(query: str) -> str:
"""Search the web for information related to the query."""
results = DDGS().text(query, max_results=8)
if not results:
return "No results found."
return "\n\n".join(
f"{r.get('title', '')}: {r.get('body', r.get('href', ''))}"
for r in results
)
|