Source code for src.tools.search

"""
Search tools for web search capabilities.
"""

import logging
from typing import Any
from urllib.parse import parse_qs, urlparse

import requests
from bs4 import BeautifulSoup
from ddgs import DDGS
from langchain_core.tools import BaseTool, StructuredTool
from langchain_tavily import TavilySearch
from pydantic import BaseModel, Field

from config import config

logger = logging.getLogger(__name__)

DUCKDUCKGO_LITE_URL = "https://lite.duckduckgo.com/lite/"
# DDGS no longer exposes Bing text search; Yahoo still provides that index.
KEYLESS_FALLBACK_BACKENDS = "brave,google,yahoo"
SEARCH_USER_AGENT = (
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
    "AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36"
)


[docs] class WebSearchInput(BaseModel): """Input accepted by the provider-neutral web-search tool.""" query: str = Field(description="A specific query to search for on the web")
def _search_error(provider: str, error: Exception) -> list[dict[str, str]]: """Return a recoverable tool result instead of crashing the agent graph.""" logger.warning("%s web search failed: %s", provider, error) return [ { "error": ( f"{provider} web search is temporarily unavailable. " "Try again later or refine the query." ) } ] def _duckduckgo_result_url(href: str) -> str: """Extract the destination from a DuckDuckGo redirect URL.""" absolute_href = f"https:{href}" if href.startswith("//") else href redirect_target = parse_qs(urlparse(absolute_href).query).get("uddg") return redirect_target[0] if redirect_target else absolute_href def _search_duckduckgo_lite(query: str, max_results: int) -> list[dict[str, str]]: """Search DuckDuckGo's lightweight endpoint and normalize its HTML results.""" response = requests.get( DUCKDUCKGO_LITE_URL, params={"q": query, "kl": config.search_region}, headers={"User-Agent": SEARCH_USER_AGENT}, timeout=config.search_timeout_seconds, ) response.raise_for_status() soup = BeautifulSoup(response.text, "html.parser") results: list[dict[str, str]] = [] seen_urls: set[str] = set() for link in soup.select("a.result-link"): href = link.get("href") if not isinstance(href, str): continue url = _duckduckgo_result_url(href) if not url.startswith(("http://", "https://")) or url in seen_urls: continue row = link.find_parent("tr") snippet_row = row.find_next_sibling("tr") if row else None snippet = snippet_row.select_one("td.result-snippet") if snippet_row else None results.append( { "title": link.get_text(" ", strip=True), "url": url, "snippet": snippet.get_text(" ", strip=True) if snippet else "", } ) seen_urls.add(url) if len(results) >= max_results: break if not results: raise RuntimeError("DuckDuckGo Lite returned no results") return results def _search_keyless_fallback(query: str, max_results: int) -> list[dict[str, str]]: """Use free search backends if DuckDuckGo temporarily blocks automation.""" results = DDGS(timeout=config.search_timeout_seconds).text( query, region=config.search_region, safesearch="moderate", max_results=max_results, backend=KEYLESS_FALLBACK_BACKENDS, ) if not results: raise RuntimeError("Keyless fallback returned no results") return [ { "title": str(result.get("title", "")), "url": str(result.get("href", "")), "snippet": str(result.get("body", "")), } for result in results ] def _create_duckduckgo_tool(max_results: int) -> BaseTool: """Create the keyless DuckDuckGo search implementation.""" def search_duckduckgo(query: str) -> list[dict[str, str]]: if not query.strip(): return [{"error": "A non-empty web-search query is required."}] duckduckgo_error: Exception | None = None for attempt in range(2): try: return _search_duckduckgo_lite(query, max_results) except Exception as error: # Search failures must not abort the graph. duckduckgo_error = error if attempt == 0: logger.info("Retrying DuckDuckGo Lite search after: %s", error) logger.warning( "DuckDuckGo Lite failed; trying free search fallback: %s", duckduckgo_error, ) try: return _search_keyless_fallback(query, max_results) except Exception as fallback_error: return _search_error("DuckDuckGo", fallback_error) return StructuredTool.from_function( func=search_duckduckgo, name="web_search", description=( "Search the public web with keyless DuckDuckGo-first search. Returns " "titles, URLs, and short snippets. Cite the returned URLs in the answer." ), args_schema=WebSearchInput, ) def _create_tavily_tool(max_results: int) -> BaseTool: """Create the optional Tavily implementation behind the same tool contract.""" if not config.tavily_api_key: raise ValueError("TAVILY_API_KEY must be set when SEARCH_PROVIDER=tavily") tavily = TavilySearch( api_key=config.tavily_api_key, max_results=max_results, ) def search_tavily(query: str) -> Any: if not query.strip(): return [{"error": "A non-empty web-search query is required."}] try: return tavily.invoke({"query": query}) except Exception as error: # Search failures must not abort the conversation. return _search_error("Tavily", error) return StructuredTool.from_function( func=search_tavily, name="web_search", description=( "Search the public web with Tavily. Returns sources and content. " "Cite the returned URLs in the answer." ), args_schema=WebSearchInput, )
[docs] def create_search_tool(max_results: int = 5) -> BaseTool: """Create a web-search tool using the configured provider. Args: max_results: Maximum number of search results to return Returns: Provider-neutral LangChain search tool """ if max_results <= 0: raise ValueError("max_results must be positive") if config.search_provider == "duckduckgo": return _create_duckduckgo_tool(max_results) if config.search_provider == "tavily": return _create_tavily_tool(max_results) raise ValueError(f"Unsupported search provider: {config.search_provider}")