199 lines
6.5 KiB
Python
199 lines
6.5 KiB
Python
import httpx
|
|
import json
|
|
import re
|
|
import asyncio
|
|
from typing import Any, Dict, List, Optional
|
|
from .utils import ToolError
|
|
import config
|
|
|
|
API_URL = "https://en.wikipedia.org/w/api.php"
|
|
|
|
def strip_html(text: str) -> str:
|
|
"""Removes HTML tags from a string using regex to provide clean text to the LLM."""
|
|
return re.sub(r'<[^>]*>', '', text)
|
|
|
|
def clean_wikitext(text: str) -> str:
|
|
"""
|
|
Removes the most distracting elements of raw Wikitext:
|
|
1. HTML comments (<!-- ... -->)
|
|
2. Citations (<ref /> and <ref>...</ref>)
|
|
"""
|
|
# Remove HTML comments
|
|
text = re.sub(r'<!--.*?-->', '', text, flags=re.DOTALL)
|
|
# Remove self-closing citations FIRST to prevent them being seen as opening tags
|
|
text = re.sub(r'<ref[^>]*/>', '', text)
|
|
# Remove paired citations
|
|
text = re.sub(r'<ref[^>]*>.*?</ref>', '', text, flags=re.DOTALL)
|
|
return text
|
|
|
|
async def _make_request(params: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
Asynchronous helper to make the API request using httpx.
|
|
A custom User-Agent is used to avoid bot detection.
|
|
"""
|
|
headers = {
|
|
"User-Agent": config.USER_AGENT
|
|
}
|
|
|
|
async with httpx.AsyncClient(headers=headers, timeout=15.0) as client:
|
|
response = await client.get(API_URL, params=params)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
async def _search(title: str, limit: int = 5, fallback: bool = False) -> str:
|
|
"""
|
|
Searches Wikipedia for a title.
|
|
fallback=True: Used when a direct page request fails, providing 'Article not found' header.
|
|
"""
|
|
params = {
|
|
"action": "query",
|
|
"list": "search",
|
|
"srsearch": title,
|
|
"format": "json",
|
|
"srlimit": limit
|
|
}
|
|
# Directly await the async request
|
|
data = await _make_request(params)
|
|
|
|
search_results = data.get("query", {}).get("search", [])
|
|
if not search_results:
|
|
return f"No Wikipedia results found for '{title}'."
|
|
|
|
if fallback:
|
|
header = f"Article not found. You MUST select one of the following articles and submit another query:"
|
|
else:
|
|
header = f"Search results for '{title}':"
|
|
|
|
lines = [header]
|
|
for i, res in enumerate(search_results, 1):
|
|
title_res = res.get("title")
|
|
snippet = strip_html(res.get("snippet", ""))
|
|
# Brackets around title help the AI identify the exact string for subsequent calls
|
|
lines.append(f"{i}. [{title_res}] - Snippet: {snippet}")
|
|
|
|
if fallback:
|
|
lines.append("\nUse the title in brackets [ ] to explore the selected page.")
|
|
|
|
return "\n".join(lines)
|
|
|
|
async def _fetch_content(title: str, section_index: int) -> tuple[Optional[str], Optional[str]]:
|
|
"""
|
|
Retrieves raw Wikitext for a specific section.
|
|
section_index=0 returns the lead section.
|
|
Returns a tuple of (content, final_title).
|
|
"""
|
|
params = {
|
|
"action": "query",
|
|
"prop": "revisions",
|
|
"rvprop": "content",
|
|
"rvsection": section_index,
|
|
"titles": title,
|
|
"redirects": 1,
|
|
"format": "json"
|
|
}
|
|
data = await _make_request(params)
|
|
|
|
pages = data.get("query", {}).get("pages", {})
|
|
if not pages:
|
|
return None, None
|
|
|
|
page_id = next(iter(pages))
|
|
page = pages[page_id]
|
|
final_title = page.get("title")
|
|
|
|
if "missing" in page:
|
|
return None, None
|
|
|
|
revisions = page.get("revisions", [])
|
|
if not revisions:
|
|
return None, final_title
|
|
|
|
content = revisions[0].get("*")
|
|
return (clean_wikitext(content) if content else None), final_title
|
|
|
|
async def _fetch_toc(title: str) -> tuple[Optional[str], Optional[str]]:
|
|
"""
|
|
Retrieves the Table of Contents data and formats it hierarchically.
|
|
Returns a tuple of (toc, final_title).
|
|
"""
|
|
params = {
|
|
"action": "parse",
|
|
"page": title,
|
|
"prop": "tocdata",
|
|
"format": "json",
|
|
"redirects": 1
|
|
}
|
|
data = await _make_request(params)
|
|
|
|
parse_data = data.get("parse")
|
|
if not parse_data:
|
|
return None, None
|
|
|
|
final_title = parse_data.get("title")
|
|
toc_data = parse_data.get("tocdata", {})
|
|
sections = toc_data.get("sections", [])
|
|
if not sections:
|
|
return "No table of contents found for this page.", final_title
|
|
|
|
lines = [f"Table of Contents for \"{final_title}\":"]
|
|
for s in sections:
|
|
level = s.get("tocLevel", 1)
|
|
index = s.get("index")
|
|
line = s.get("line")
|
|
indent = " " * (level - 1)
|
|
lines.append(f"{indent}[{index}] {line}")
|
|
|
|
return "\n".join(lines), final_title
|
|
|
|
async def handle(args: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
"""
|
|
Main handler for the browse_wikipedia tool.
|
|
Supports modes: summary, toc, section, and search.
|
|
"""
|
|
title = args.get("title")
|
|
if not title:
|
|
raise ToolError("Missing required parameter: 'title'")
|
|
|
|
mode = args.get("mode", "summary")
|
|
section_index = args.get("section_index")
|
|
search_limit = args.get("search_limit", 5)
|
|
|
|
final_title = None
|
|
|
|
if mode == "search":
|
|
result = await _search(title, search_limit, fallback=False)
|
|
elif mode == "summary":
|
|
# Lead section + ToC is the default 'summary' to guide the AI's next steps
|
|
content, title_from_content = await _fetch_content(title, 0)
|
|
final_title = title_from_content
|
|
if content is None:
|
|
result = await _search(title, search_limit, fallback=True)
|
|
else:
|
|
toc, title_from_toc = await _fetch_toc(title)
|
|
if title_from_toc:
|
|
final_title = title_from_toc
|
|
result = f"{content}\n\n---\n\n{toc}"
|
|
elif mode == "toc":
|
|
toc, title_from_toc = await _fetch_toc(title)
|
|
final_title = title_from_toc
|
|
if toc is None:
|
|
result = await _search(title, search_limit, fallback=True)
|
|
else:
|
|
result = toc
|
|
elif mode == "section":
|
|
if section_index is None:
|
|
raise ToolError("Missing required parameter 'section_index' for mode='section'")
|
|
content, title_from_content = await _fetch_content(title, int(section_index))
|
|
final_title = title_from_content
|
|
if content is None:
|
|
result = await _search(title, search_limit, fallback=True)
|
|
else:
|
|
result = content
|
|
else:
|
|
raise ToolError(f"Invalid mode '{mode}'. Supported modes: summary, toc, section, search")
|
|
|
|
if final_title and final_title != title:
|
|
result = f"Redirected to \"{final_title}\"\n\n{result}"
|
|
|
|
return [{"type": "text", "text": result}]
|