wreck / tools.py
gilra's picture
io issue resolved
dda7900
Raw
History Blame Contribute Delete
18 kB
# tools.py — tool definitions only
import math
import uuid
import re
import requests
from bs4 import BeautifulSoup
from smolagents import tool, FinalAnswerTool
from sentence_transformers import SentenceTransformer
import chromadb
# ── Memory singletons ───────────────────────────────────────────────────────
_embedder = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
_chroma = chromadb.PersistentClient(path="./memory_db")
_col = _chroma.get_or_create_collection("agent_memory")
# ── Tools ───────────────────────────────────────────────────────────────────
@tool
def search_tool(query: str) -> str:
"""Search the web for current facts, news, prices, or data.
Args:
query: Short precise factual search query.
"""
try:
from ddgs import DDGS
results = list(DDGS().text(query, max_results=5))
if not results:
return "No results found."
return "\n\n".join(
f"{r['title']}\n{r['href']}\n{r['body']}" for r in results
)
except Exception as e:
return f"search_tool error: {e}"
@tool
def fetch_webpage(url: str) -> str:
"""Fetch the full readable plain text of a webpage. Already stripped of HTML tags.
Do NOT call BeautifulSoup on the result — it is already plain text, not HTML.
Args:
url: Full URL to fetch.
"""
try:
resp = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=15)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
for tag in soup(["script", "style", "nav", "footer", "header"]):
tag.decompose()
return soup.get_text(separator="\n", strip=True)[:15000]
except Exception as e:
return f"fetch_webpage error: {e}"
@tool
def wikipedia_search(query: str, sentences: int = 5) -> str:
"""Look up a topic on Wikipedia and return a summary. Use for factual questions
about people, places, history, science — faster than fetch_webpage for known entities.
Args:
query: Topic to look up (e.g. 'Australia', 'Albert Einstein').
sentences: Number of summary sentences to return (default 5).
"""
try:
import wikipedia
return wikipedia.summary(query, sentences=sentences, auto_suggest=True)
except Exception as e:
return f"wikipedia_search error: {e}"
@tool
def wikipedia_section(page_title: str, section: str) -> str:
"""Get a specific section from a Wikipedia page — use when you need a section
like 'Discography', 'Career', 'Albums' rather than a short summary.
Args:
page_title: Exact Wikipedia page title (e.g. 'Mercedes Sosa').
section: Section heading to find (e.g. 'Discography').
"""
try:
import wikipedia
page = wikipedia.page(page_title, auto_suggest=False)
text = page.content
idx = text.find(section)
return text[idx:idx + 3000] if idx != -1 else text[:3000]
except Exception as e:
return f"wikipedia_section error: {e}"
@tool
def read_pdf(file_path: str) -> str:
"""Extract all text from a PDF file.
Args:
file_path: Local path to the PDF.
"""
try:
from pypdf import PdfReader
text = "\n".join(p.extract_text() or "" for p in PdfReader(file_path).pages).strip()
return text[:12000] if text else "PDF has no extractable text (may be image-only)."
except Exception as e:
return f"read_pdf error: {e}"
@tool
def read_csv_file(file_path: str, max_rows: int = 200) -> str:
"""Load a CSV file and return its structure and data as text.
Args:
file_path: Local path to the CSV.
max_rows: Maximum rows to return (default 200).
"""
import pandas as pd
for enc in ("utf-8", "latin-1", "cp1252", "utf-8-sig"):
try:
df = pd.read_csv(file_path, encoding=enc)
return f"Shape: {df.shape[0]}×{df.shape[1]}\nColumns: {list(df.columns)}\n\n{df.head(max_rows).to_string(index=False)}"
except UnicodeDecodeError:
continue
except Exception as e:
return f"read_csv_file error: {e}"
return "read_csv_file error: could not decode file with any known encoding"
@tool
def read_excel_file(file_path: str, sheet_name: str = None, max_rows: int = 200) -> str:
"""Load an Excel file and return its structure and data as text.
Lists all sheet names first, then reads the requested sheet.
Args:
file_path: Local path to the Excel file (.xlsx or .xls).
sheet_name: Sheet name or index to read (default: first sheet).
max_rows: Maximum rows to return (default 200).
"""
try:
import pandas as pd
xl = pd.ExcelFile(file_path)
sheets = xl.sheet_names
target = sheet_name if sheet_name else sheets[0]
df = pd.read_excel(file_path, sheet_name=target)
header = f"Sheets: {sheets}\nReading: '{target}'\nShape: {df.shape[0]}×{df.shape[1]}\nColumns: {list(df.columns)}\n\n"
return header + df.head(max_rows).to_string(index=False)
except Exception as e:
return f"read_excel_file error: {e}"
@tool
def calculator(expression: str) -> str:
"""Evaluate a mathematical expression and return the exact result.
Supports: + - * / ** % sqrt sin cos tan log log10 pi e abs round.
Args:
expression: e.g. 'sqrt(144) + 2**10'
"""
try:
safe = {k: getattr(math, k) for k in dir(math) if not k.startswith("_")}
safe.update({"abs": abs, "round": round, "int": int, "float": float})
return str(eval(expression, {"__builtins__": {}}, safe))
except Exception as e:
return f"calculator error: {e}"
@tool
def count_and_find(text: str, count_type: str = "words", pattern: str = None) -> str:
"""Count elements in text or find regex pattern occurrences.
Args:
text: Text to analyse.
count_type: 'words' | 'characters' | 'lines' | 'sentences' | 'pattern'.
pattern: Regex string — required when count_type is 'pattern'.
"""
try:
if count_type == "words": return str(len(text.split()))
if count_type == "characters": return str(len(text))
if count_type == "lines": return str(len(text.splitlines()))
if count_type == "sentences": return str(len(re.split(r"[.!?]+", text.strip())))
if count_type == "pattern" and pattern:
m = re.findall(pattern, text)
return f"{len(m)} matches: {m[:20]}"
return "Invalid count_type. Use: words | characters | lines | sentences | pattern."
except Exception as e:
return f"count_and_find error: {e}"
@tool
def arxiv_search(query: str, max_results: int = 3) -> str:
"""Search arXiv for academic papers.
Args:
query: Search query.
max_results: Number of results (default 3).
"""
try:
import arxiv
search = arxiv.Search(query=query, max_results=max_results, sort_by=arxiv.SortCriterion.Relevance)
parts = []
for p in search.results():
authors = ", ".join(a.name for a in p.authors[:3])
parts.append(f"Title: {p.title}\nAuthors: {authors}\nDate: {p.published.strftime('%Y-%m')}\nAbstract: {p.summary[:400]}\nURL: {p.entry_id}")
return "\n\n---\n\n".join(parts) if parts else "No results."
except Exception as e:
return f"arxiv_search error: {e}"
@tool
def flight_time_from_delhi(latitude: float, longitude: float, speed_kmph: float = 850.0) -> str:
"""Calculate flight distance and time from Delhi (Haversine).
Args:
latitude: Destination latitude.
longitude: Destination longitude.
speed_kmph: Flight speed km/h (default 850).
"""
r = 6371.0
la1, lo1 = math.radians(28.6139), math.radians(77.2090)
la2, lo2 = math.radians(latitude), math.radians(longitude)
a = math.sin((la2-la1)/2)**2 + math.cos(la1)*math.cos(la2)*math.sin((lo2-lo1)/2)**2
d = r * 2 * math.asin(math.sqrt(a))
return f"{d:.0f} km, {d/speed_kmph:.2f} hours"
@tool
def transcribe_audio(file_path: str) -> str:
"""Transcribe an audio file to text using speech recognition.
Args:
file_path: Local path to audio file (.mp3, .wav, .m4a, etc.)
"""
import os
import requests as _req
try:
ext = os.path.splitext(file_path)[1].lower().lstrip(".")
mime_map = {
"mp3": "audio/mpeg", "wav": "audio/wav",
"m4a": "audio/mp4", "ogg": "audio/ogg", "flac": "audio/flac",
}
content_type = mime_map.get(ext, "audio/mpeg")
token = os.environ.get("HF_TOKEN", "")
with open(file_path, "rb") as f:
audio_bytes = f.read()
resp = _req.post(
"https://api-inference.huggingface.co/models/openai/whisper-large-v3",
headers={"Authorization": f"Bearer {token}", "Content-Type": content_type},
data=audio_bytes,
timeout=60,
)
resp.raise_for_status()
result = resp.json()
return result.get("text", str(result))
except Exception as e:
return f"transcribe_audio error: {e}"
@tool
def extract_text_from_image(file_path: str) -> str:
"""Extract all text and describe visual content from an image using a vision model.
Use for screenshots, diagrams, charts, tables, or any image containing text.
Args:
file_path: Local path to image file (.png, .jpg, .jpeg, .gif, .webp, etc.)
"""
import os
import base64
from huggingface_hub import InferenceClient
if not os.path.exists(file_path):
return f"extract_text_from_image error: file not found: {file_path}"
try:
ext = file_path.rsplit(".", 1)[-1].lower()
mime = {
"jpg": "image/jpeg", "jpeg": "image/jpeg",
"png": "image/png", "gif": "image/gif", "webp": "image/webp",
}.get(ext, "image/jpeg")
with open(file_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode("utf-8")
client = InferenceClient(token=os.environ.get("HF_TOKEN", ""))
prompt = (
"1. Extract EVERY piece of text visible in this image exactly as written.\n"
"2. If there is a table, reproduce it row by row with all values.\n"
"3. If there is a chart or graph, state all axis labels, data points, and exact values.\n"
"4. If there is a chess board, describe the position in FEN notation.\n"
"5. State any numbers, dates, names, or codes precisely."
)
resp = client.chat_completion(
model="Qwen/Qwen2.5-VL-72B-Instruct",
messages=[{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}},
{"type": "text", "text": prompt},
],
}],
max_tokens=2000,
)
return resp.choices[0].message.content
except Exception as e:
return f"extract_text_from_image error: {e}"
@tool
def get_youtube_transcript(video_url: str) -> str:
"""Get the full transcript of a YouTube video. Use for any question referencing a YouTube URL.
Args:
video_url: Full YouTube URL (e.g. https://www.youtube.com/watch?v=...)
"""
try:
import re as _re
from youtube_transcript_api import YouTubeTranscriptApi
vid = _re.search(r"(?:v=|youtu\.be/)([^&\n?#]+)", video_url)
if not vid:
return "get_youtube_transcript error: could not extract video ID from URL"
video_id = vid.group(1)
ytt = YouTubeTranscriptApi()
transcript = ytt.fetch(video_id)
return " ".join(t.text for t in transcript)[:10000]
except Exception as e:
return f"get_youtube_transcript error: {e}"
@tool
def extract_table_from_url(url: str) -> str:
"""Extract tables from a webpage as readable text.
Use when the answer is likely inside an HTML table (sports stats, rankings, schedules, etc.).
Args:
url: Full URL of the page containing the table.
"""
try:
import pandas as pd
tables = pd.read_html(url)
if not tables:
return "No tables found on this page."
parts = []
for i, t in enumerate(tables[:5]):
parts.append(f"[Table {i+1}]\n{t.to_string(index=False)}")
return "\n\n".join(parts)[:12000]
except Exception as e:
return f"extract_table_from_url error: {e}"
@tool
def download_and_read(url: str) -> str:
"""Download a file from a URL and return its content.
Supports PDF, CSV, Excel, plain text. Use when a question links directly to a file.
Args:
url: Direct URL to the file.
"""
import os
import tempfile
try:
resp = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=30)
resp.raise_for_status()
content_type = resp.headers.get("Content-Type", "")
suffix = ".bin"
if "pdf" in content_type:
suffix = ".pdf"
elif "csv" in content_type or url.endswith(".csv"):
suffix = ".csv"
elif "excel" in content_type or url.endswith((".xlsx", ".xls")):
suffix = ".xlsx"
elif "text" in content_type:
return resp.text[:12000]
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(resp.content)
tmp_path = tmp.name
try:
if suffix == ".pdf":
from pypdf import PdfReader
text = "\n".join(p.extract_text() or "" for p in PdfReader(tmp_path).pages)
return text[:12000]
elif suffix == ".csv":
import pandas as pd
df = pd.read_csv(tmp_path)
return f"Shape: {df.shape}\n{df.head(100).to_string(index=False)}"
elif suffix == ".xlsx":
import pandas as pd
df = pd.read_excel(tmp_path)
return f"Shape: {df.shape}\n{df.head(100).to_string(index=False)}"
else:
return resp.text[:12000]
finally:
os.unlink(tmp_path)
except Exception as e:
return f"download_and_read error: {e}"
@tool
def analyze_chess_position(fen: str, depth: int = 15) -> str:
"""Analyze a chess position and return the best move and evaluation.
Use for any question involving a chess board position or asking for the best move.
Args:
fen: FEN string of the chess position (e.g. 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1').
depth: Search depth (default 15).
"""
try:
import chess
import chess.engine
import shutil
board = chess.Board(fen)
# Try stockfish if available
stockfish_path = shutil.which("stockfish")
if stockfish_path:
with chess.engine.SimpleEngine.popen_uci(stockfish_path) as eng:
result = eng.analyse(board, chess.engine.Limit(depth=depth))
best_move = result["pv"][0] if result.get("pv") else None
score = result["score"].white()
move_san = board.san(best_move) if best_move else "unknown"
return f"Best move: {move_san}\nFEN: {fen}\nScore: {score}\nTurn: {'White' if board.turn else 'Black'}"
# Fallback: list legal moves and basic board info
legal = [board.san(m) for m in board.legal_moves]
checks = [m for m in legal if "+" in m or "#" in m]
return (
f"Position: {fen}\nTurn: {'White' if board.turn else 'Black'}\n"
f"Legal moves ({len(legal)}): {', '.join(legal[:30])}\n"
f"Checks/mates: {checks if checks else 'none'}"
)
except Exception as e:
return f"analyze_chess_position error: {e}"
@tool
def save_memory(text: str, key: str = "", tag: str = "") -> str:
"""Save a fact to long-term memory.
Call ONLY when user explicitly says: remember / save / store / note that.
Args:
text: Fact to save.
key: Memory key (e.g. 'office', 'son').
tag: Optional category.
"""
emb = _embedder.encode(text).tolist()
mid = key if key else str(uuid.uuid4())
_col.upsert(ids=[mid], embeddings=[emb], documents=[text], metadatas=[{"key": key, "tag": tag}])
return f"Saved: {text}"
@tool
def retrieve_memory(query: str) -> str:
"""Retrieve saved personal facts from long-term memory.
Call whenever user refers to 'my office / son / broker / trip / project / city'.
Args:
query: What to search for.
"""
q_emb = _embedder.encode(query).tolist()
res = _col.query(query_embeddings=[q_emb], n_results=3)
if not res["documents"] or not res["documents"][0]:
return "No saved memory found."
return res["documents"][0][0]
# ── Registry ────────────────────────────────────────────────────────────────
TOOL_LIST = [
search_tool, fetch_webpage,
wikipedia_search, wikipedia_section, FinalAnswerTool(),
read_pdf, read_csv_file, read_excel_file,
calculator, count_and_find, arxiv_search,
flight_time_from_delhi,
get_youtube_transcript, extract_table_from_url, download_and_read,
analyze_chess_position,
transcribe_audio, extract_text_from_image,
save_memory, retrieve_memory,
]
AUTHORIZED_IMPORTS = [
"pandas", "numpy", "math", "re", "json", "io",
"openpyxl", "requests", "bs4", "pypdf",
"datetime", "collections", "statistics", "arxiv",
]