from __future__ import annotations import os from typing import Annotated, Any, Literal import httpx import gradio as gr from app import _log_call_end, _log_call_start, _truncate_for_log from ._docstrings import autodoc # =========================================================================== # Constants # =========================================================================== BASE_URL = "https://text.pollinations.ai" # Model mappings for different depth levels MODEL_MAPPING = { "fast": "gemini-search", "normal": "perplexity-fast", "deep": "perplexity-reasoning", } # System prompts for different detail levels SYSTEM_PROMPTS = { True: "Search the web and provide a comprehensive answer with sources. Include relevant details and cite your sources.", False: "Search the web and provide a concise, accurate answer. Include source URLs.", } # Timeout settings (seconds) REQUEST_TIMEOUT = 30.0 # Single source of truth for the LLM-facing tool description TOOL_SUMMARY = ( "Search the web using AI-powered search models with source citations. " "Supports different depth levels: fast (Gemini with Google Search), normal (Perplexity Sonar), " "and deep (Perplexity Sonar Reasoning). Returns answers with source URLs." ) # =========================================================================== # Core Client # =========================================================================== class PollinationsClient: """ HTTP client for Pollinations AI web search API. Provides web search functionality with different depth levels and citation support. """ def __init__( self, base_url: str = BASE_URL, timeout: float = REQUEST_TIMEOUT, api_key: str | None = None, ) -> None: """ Initialize the Pollinations client. Args: base_url: Base URL for the Pollinations API (default: https://text.pollinations.ai) timeout: Request timeout in seconds (default: 30) api_key: Optional API key (reads from POLLINATIONS_API_KEY env var if not provided) """ self.base_url = base_url.rstrip("/") self.timeout = timeout self.api_key = api_key or os.getenv("POLLINATIONS_API_KEY") def _get_headers(self) -> dict[str, str]: """Get request headers including API key if available.""" headers = { "Content-Type": "application/json", } if self.api_key: headers["Authorization"] = f"Bearer {self.api_key}" return headers def _resolve_model(self, depth: str) -> str: """ Resolve depth level to actual model name. Args: depth: Depth level ('fast', 'normal', or 'deep') Returns: The model identifier for the Pollinations API """ return MODEL_MAPPING.get(depth, "perplexity-fast") async def web_search( self, query: str, depth: str = "normal", detailed: bool = False, ) -> dict[str, Any]: """ Perform web search using Pollinations AI. Args: query: The search query depth: Search depth level ('fast', 'normal', or 'deep') detailed: Whether to request a comprehensive answer Returns: Dictionary with keys: - answer: The generated answer - sources: List of source URLs (citations) - model: The model used - query: The original query Raises: httpx.HTTPError: For network/HTTP errors ValueError: For invalid parameters """ if not query or not query.strip(): raise ValueError("Query cannot be empty") if depth not in MODEL_MAPPING: raise ValueError(f"Invalid depth: {depth}. Must be one of {list(MODEL_MAPPING.keys())}") model = self._resolve_model(depth) system_prompt = SYSTEM_PROMPTS.get(detailed, SYSTEM_PROMPTS[False]) # Prepare OpenAI-compatible request payload = { "model": model, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": query}, ], } url = f"{self.base_url}/v1/chat/completions" async with httpx.AsyncClient(timeout=self.timeout) as client: try: response = await client.post( url, json=payload, headers=self._get_headers(), ) response.raise_for_status() except httpx.TimeoutException as exc: raise httpx.HTTPError(f"Request timed out after {self.timeout}s") from exc except httpx.HTTPStatusError as exc: # Handle rate limiting specifically if exc.response.status_code == 429: raise httpx.HTTPError("Rate limited. Please try again later.") from exc raise data = response.json() # Extract answer and citations from response answer = "" sources = [] # OpenAI-compatible response format if "choices" in data and data["choices"]: answer = data["choices"][0].get("message", {}).get("content", "") # Extract citations if present (Pollinations-specific extension) if "citations" in data: sources = data["citations"] # Also check if citations are embedded in the message if not sources and isinstance(answer, str): # Try to extract URLs from the answer import re url_pattern = r'https?://[^\s<>"\'\)]+' sources = list(dict.fromkeys(re.findall(url_pattern, answer))) # Unique URLs return { "answer": answer, "sources": sources, "model": model, "query": query, } def web_search_sync( self, query: str, depth: str = "normal", detailed: bool = False, ) -> dict[str, Any]: """ Synchronous version of web_search. Args: query: The search query depth: Search depth level ('fast', 'normal', or 'deep') detailed: Whether to request a comprehensive answer Returns: Dictionary with answer, sources, model, and query """ import asyncio return asyncio.run(self.web_search(query, depth, detailed)) # =========================================================================== # Gradio Tool Function # =========================================================================== @autodoc( summary=TOOL_SUMMARY, ) def Pollinations_Web_Search( query: Annotated[str, "The search query string"], depth: Annotated[ Literal["fast", "normal", "deep"], "Search depth: 'fast' (Gemini with Google Search), 'normal' (Perplexity Sonar), or 'deep' (Perplexity Sonar Reasoning).", ] = "normal", detailed: Annotated[bool, "Request a comprehensive answer instead of concise summary"] = False, ) -> str: """ Search the web using Pollinations AI with source citations. Uses AI-powered search models that provide direct answers with source citations. Supports three depth levels for different search capabilities. """ _log_call_start("Pollinations_Web_Search", query=query, depth=depth, detailed=detailed) try: client = PollinationsClient() result = client.web_search_sync(query, depth, detailed) # Format the result for display lines = [ f"Query: {result['query']}", f"Model: {result['model']}", f"Depth: {depth}", "", "Answer:", result["answer"] or "No answer generated.", ] if result["sources"]: lines.append("") lines.append("Sources:") for i, source in enumerate(result["sources"], 1): lines.append(f" {i}. {source}") else: lines.append("") lines.append("(No sources provided)") formatted_result = "\n".join(lines) _log_call_end("Pollinations_Web_Search", _truncate_for_log(formatted_result)) return formatted_result except ValueError as exc: error_msg = f"Invalid input: {exc}" _log_call_end("Pollinations_Web_Search", error_msg) return error_msg except httpx.HTTPError as exc: error_msg = f"Search failed: {exc}" _log_call_end("Pollinations_Web_Search", error_msg) return error_msg except Exception as exc: error_msg = f"Unexpected error: {exc}" _log_call_end("Pollinations_Web_Search", error_msg) return error_msg # =========================================================================== # Gradio Interface # =========================================================================== def build_interface() -> gr.Interface: """Build the Gradio interface for Pollinations web search.""" return gr.Interface( fn=Pollinations_Web_Search, inputs=[ gr.Textbox( label="Query", placeholder="Enter your search query here...", max_lines=2, info="The search query", ), gr.Radio( label="Search Depth", choices=["fast", "normal", "deep"], value="normal", info="Search depth level: fast (Gemini), normal (Perplexity), deep (Reasoning)", ), gr.Checkbox( label="Detailed Answer", value=False, info="Request a comprehensive answer instead of concise summary", ), ], outputs=gr.Textbox( label="Search Results", interactive=False, lines=15, max_lines=20, ), title="Pollinations Web Search", description=( "
AI-powered web search with source citations. " "Uses Google Search, Perplexity Sonar, and Perplexity Sonar Reasoning models " "to provide direct answers with reliable source URLs.
" ), api_description=TOOL_SUMMARY, flagging_mode="never", submit_btn="Search", ) # =========================================================================== # Public API # =========================================================================== __all__ = [ "PollinationsClient", "Pollinations_Web_Search", "build_interface", ]