"""Web tools for the assistant (Notion-style "research" capabilities). Phase 1 of the documented web-toolset roadmap: * ``web_search`` — query the self-hosted SearXNG instance (no API key) and, when it returns nothing, fall back to keyless HTML providers (DuckDuckGo, then Bing) so a dead meta-search instance never leaves the assistant answering « je n'ai pas accès à internet ». * ``fetch_url`` — retrieve a public web page and return readable text. Phase 2 (#92) additions: * keyed providers — Tavily, Brave Search, SerpAPI and Exa are used first when their API key is configured (env, injected by Infisical in production); * SQLite cache — search/fetch results are cached with a TTL (:mod:`backend.tools.webcache`); * retry with backoff — transient network errors get one extra attempt; * dynamic rendering — ``fetch_url(render=True)`` uses an isolated Playwright worker (optional dependency, graceful degradation). All are READ-risk tools (no confirmation), rate-limited through the shared registry, SSRF-guarded (scheme + private-address rejection), and size-capped. Configuration (environment): * ``OBSIGATE_SEARXNG_URL`` — defaults to https://search.dracodev.net * ``OBSIGATE_WEB_TIMEOUT`` — seconds, default 10 * ``OBSIGATE_WEB_FALLBACK`` — ``0``/``false`` disables the keyless HTML fallbacks (SearXNG only), default enabled * ``OBSIGATE_TAVILY_API_KEY`` / ``OBSIGATE_BRAVE_API_KEY`` / ``OBSIGATE_SERPAPI_API_KEY`` / ``OBSIGATE_EXA_API_KEY`` — optional keyed providers, tried before SearXNG when set * ``OBSIGATE_WEB_PROVIDERS`` — optional comma-separated provider order (e.g. ``brave,searxng``); keyed providers without a key are skipped * ``OBSIGATE_WEB_RETRY`` — extra attempts for transient network errors (default 1) * ``OBSIGATE_WEB_CACHE_TTL`` — cache TTL seconds, ``0`` disables (default 900) """ from __future__ import annotations import base64 import binascii import html as html_lib import ipaddress import logging import os import re import socket import time from collections.abc import Callable from typing import Any from urllib.parse import parse_qs, urlparse import httpx from backend.tools import webcache from backend.tools.context import ToolError, ToolRisk, ToolScope from backend.tools.registry import tool from backend.tools.schemas import FetchUrlInput, WebSearchInput from backend.tools.secrets import get_tool_key logger = logging.getLogger("obsigate.tools.web") SEARXNG_URL = os.environ.get("OBSIGATE_SEARXNG_URL", "https://search.dracodev.net") WEB_TIMEOUT = float(os.environ.get("OBSIGATE_WEB_TIMEOUT", "10")) WEB_FALLBACK_ENABLED = os.environ.get("OBSIGATE_WEB_FALLBACK", "1").strip().lower() not in { "0", "false", "no", "off", } WEB_RETRY_ATTEMPTS = int(os.environ.get("OBSIGATE_WEB_RETRY", "1")) USER_AGENT = "ObsiGateAssistant/1.0 (+self-hosted vault AI)" # Search engines reject non-browser agents on their public HTML endpoints. BROWSER_UA = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" ) # A minimal UA is not enough: Bing serves decoy SERPs (unrelated results) to # requests missing the usual browser navigation headers. BROWSER_HEADERS = { "User-Agent": BROWSER_UA, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "fr-CA,fr;q=0.9,en-US;q=0.8,en;q=0.7", "Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Site": "none", "Sec-Fetch-User": "?1", "Upgrade-Insecure-Requests": "1", } MAX_FETCH_BYTES = 1_500_000 MAX_TEXT_CHARS = 20_000 _BLOCKED_TAGS_RE = re.compile( r"<(script|style|noscript|template|svg)\b.*?", re.IGNORECASE | re.DOTALL ) _TAG_RE = re.compile(r"<[^>]+>") _BLOCK_SPLIT_RE = re.compile( r"]*>", re.IGNORECASE, ) _DDG_RESULT_RE = re.compile( r']*class="result__a"[^>]*href="([^"]+)"[^>]*>(.*?)', re.IGNORECASE | re.DOTALL ) _DDG_SNIPPET_RE = re.compile( r']*class="result__snippet"[^>]*>(.*?)', re.IGNORECASE | re.DOTALL ) _BING_RESULT_RE = re.compile( r']*>\s*]*href="([^"]+)"[^>]*>(.*?)', re.IGNORECASE | re.DOTALL ) _BING_SNIPPET_RE = re.compile( r'

(.*?)

', re.IGNORECASE | re.DOTALL ) class SSRFError(ToolError): """Raised for a URL whose host is private/loopback or scheme unsupported.""" def _assert_public_http_url(url: str) -> str: """Reject non-http(s) schemes and private/loopback/link-local targets.""" try: parsed = urlparse(url) except ValueError as e: raise SSRFError("URL invalide", code="invalid_url") from e if parsed.scheme not in ("http", "https"): raise SSRFError("Seuls les schémas http/https sont autorisés", code="invalid_scheme") host = parsed.hostname if not host: raise SSRFError("URL sans hôte", code="invalid_url") # Resolve the host so DNS-rebinding to internal IPs is also caught. try: infos = socket.getaddrinfo(host, None) except socket.gaierror as e: raise SSRFError(f"Hôte introuvable: {host}", code="dns_error") from e for info in infos: ip = ipaddress.ip_address(info[4][0]) if ( ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast or ip.is_unspecified ): raise SSRFError("Accès aux adresses internes interdit", code="ssrf_blocked") return url def _html_to_text(raw: str) -> str: """Cheap HTML → readable text: strip scripts/styles, tags, then compress. Comments (which may carry script-like payloads) are removed first. """ text = re.sub(r"", " ", raw, flags=re.DOTALL) text = _BLOCKED_TAGS_RE.sub(" ", text) # Keep block boundaries as newlines before dropping the remaining tags. text = _BLOCK_SPLIT_RE.sub("\n", text) text = _TAG_RE.sub("", text) text = html_lib.unescape(text) text = re.sub(r"[ \t]+", " ", text) text = re.sub(r" ?\n ?", "\n", text) text = re.sub(r"\n{3,}", "\n\n", text) return text.strip() def _response_text(resp: httpx.Response) -> str: """Decode a response body without relying on ``resp.text`` (easier to mock).""" return resp.content.decode(resp.encoding or "utf-8", errors="replace") def _clean_fragment(fragment: str) -> str: return html_lib.unescape(_TAG_RE.sub("", fragment)).strip() def _result( title: str, url: str, snippet: str, published: Any = None, score: Any = None ) -> dict[str, Any]: return { "title": (title or "")[:300], "url": url or "", "snippet": (snippet or "")[:600], "published": published, "score": score, } def _with_retry(call: Callable[[], Any]) -> Any: """Run *call* with one extra attempt on transient network errors. House-made backoff (the roadmap's « tenacity ou boucle maison »): DNS blips and rate-limit hiccups are the common failure mode, and a single retry keeps the fallback chain from being consumed too early. """ for attempt in range(1 + max(0, WEB_RETRY_ATTEMPTS)): try: return call() except httpx.TransportError: if attempt >= max(0, WEB_RETRY_ATTEMPTS): raise time.sleep(0.2 * (attempt + 1)) raise RuntimeError("unreachable") # pragma: no cover def _env_key(name: str) -> str: """Read an API key: configuration-page store first, then environment.""" return get_tool_key(name) def _search_tavily(query: str, params: WebSearchInput) -> tuple[list[dict[str, Any]], list[str]]: """Tavily Search API (agent-oriented results, key required).""" resp = httpx.post( "https://api.tavily.com/search", json={ "api_key": _env_key("OBSIGATE_TAVILY_API_KEY"), "query": query, "max_results": params.max_results, "search_depth": "basic", "include_answer": False, }, headers={"User-Agent": USER_AGENT}, timeout=WEB_TIMEOUT, ) resp.raise_for_status() data = resp.json() return [ _result(item.get("title") or "", item.get("url") or "", item.get("content") or "") for item in (data.get("results") or []) ], [] def _search_brave(query: str, params: WebSearchInput) -> tuple[list[dict[str, Any]], list[str]]: """Brave Search API (key required).""" resp = httpx.get( "https://api.search.brave.com/res/v1/web/search", params={"q": query, "count": params.max_results, "safesearch": "moderate"}, headers={ "X-Subscription-Id": _env_key("OBSIGATE_BRAVE_API_KEY"), "Accept": "application/json", "User-Agent": USER_AGENT, }, timeout=WEB_TIMEOUT, ) resp.raise_for_status() data = resp.json() return [ _result(item.get("title") or "", item.get("url") or "", item.get("description") or "") for item in ((data.get("web") or {}).get("results") or []) ], [] def _search_serpapi(query: str, params: WebSearchInput) -> tuple[list[dict[str, Any]], list[str]]: """SerpAPI (Google SERP, key required).""" resp = httpx.get( "https://serpapi.com/search", params={"q": query, "api_key": _env_key("OBSIGATE_SERPAPI_API_KEY"), "num": params.max_results}, headers={"User-Agent": USER_AGENT}, timeout=WEB_TIMEOUT, ) resp.raise_for_status() data = resp.json() return [ _result(item.get("title") or "", item.get("link") or "", item.get("snippet") or "") for item in (data.get("organic_results") or []) ], [] def _search_exa(query: str, params: WebSearchInput) -> tuple[list[dict[str, Any]], list[str]]: """Exa neural search (key required).""" resp = httpx.post( "https://api.exa.ai/search", json={"query": query, "numResults": params.max_results}, headers={ "x-api-key": _env_key("OBSIGATE_EXA_API_KEY"), "User-Agent": USER_AGENT, }, timeout=WEB_TIMEOUT, ) resp.raise_for_status() data = resp.json() return [ _result(item.get("title") or "", item.get("url") or "", (item.get("text") or "")[:600]) for item in (data.get("results") or []) ], [] # Keyed providers: name -> (implementation, API key env var) _KEYED_PROVIDERS: dict[str, tuple[_Provider, str]] = { "tavily": (_search_tavily, "OBSIGATE_TAVILY_API_KEY"), "brave": (_search_brave, "OBSIGATE_BRAVE_API_KEY"), "serpapi": (_search_serpapi, "OBSIGATE_SERPAPI_API_KEY"), "exa": (_search_exa, "OBSIGATE_EXA_API_KEY"), } def _search_searxng( query: str, params: WebSearchInput ) -> tuple[list[dict[str, Any]], list[str]]: """Query the self-hosted SearXNG instance (JSON API).""" url = SEARXNG_URL.rstrip("/") + "/search" resp = httpx.get( url, params={ "q": query, "format": "json", "categories": params.category or "general", "pageno": max(1, params.page), **({"language": params.language} if params.language else {}), "safesearch": "1", }, headers={"User-Agent": USER_AGENT}, timeout=WEB_TIMEOUT, follow_redirects=False, ) resp.raise_for_status() data = resp.json() results = [ _result( item.get("title") or "", item.get("url") or "", item.get("content") or "", item.get("publishedDate"), item.get("score"), ) for item in (data.get("results") or [])[: params.max_results] ] unresponsive = [ name for entry in (data.get("unresponsive_engines") or []) for name in ([entry[0]] if isinstance(entry, (list, tuple)) and entry else [entry]) if isinstance(name, str) ] return results, unresponsive def _unwrap_duckduckgo_url(href: str) -> str: """DuckDuckGo HTML wraps hits in ``/l/?uddg=``.""" href = html_lib.unescape(href) if href.startswith("//"): href = "https:" + href if "uddg=" in href: values = parse_qs(urlparse(href).query).get("uddg") if values: return values[0] return href def _search_duckduckgo( query: str, params: WebSearchInput ) -> tuple[list[dict[str, Any]], list[str]]: """Keyless fallback: scrape the DuckDuckGo no-JS HTML endpoint.""" resp = httpx.get( "https://html.duckduckgo.com/html/", params={"q": query, **({"kl": params.language} if params.language else {})}, headers=BROWSER_HEADERS, timeout=WEB_TIMEOUT, follow_redirects=False, ) resp.raise_for_status() body = _response_text(resp) snippets = [_clean_fragment(m.group(1)) for m in _DDG_SNIPPET_RE.finditer(body)] results: list[dict[str, Any]] = [] for index, match in enumerate(_DDG_RESULT_RE.finditer(body)): results.append( _result( _clean_fragment(match.group(2)), _unwrap_duckduckgo_url(match.group(1)), snippets[index] if index < len(snippets) else "", ) ) if len(results) >= params.max_results: break return results, [] def _unwrap_bing_url(href: str) -> str: """Bing wraps hits in ``/ck/a?...&u=a1``.""" href = html_lib.unescape(href) match = re.search(r"[?&]u=a1([A-Za-z0-9_\-]+)", href) if not match: return href token = match.group(1).replace("-", "+").replace("_", "/") token += "=" * (-len(token) % 4) try: return base64.b64decode(token).decode("utf-8", errors="replace") except (ValueError, binascii.Error): return href def _search_bing( query: str, params: WebSearchInput ) -> tuple[list[dict[str, Any]], list[str]]: """Last-resort keyless fallback: scrape Bing's result page.""" resp = httpx.get( "https://www.bing.com/search", params={"q": query, **({"setlang": params.language} if params.language else {})}, headers=BROWSER_HEADERS, timeout=WEB_TIMEOUT, follow_redirects=False, ) resp.raise_for_status() body = _response_text(resp) snippets = [_clean_fragment(m.group(1)) for m in _BING_SNIPPET_RE.finditer(body)] results: list[dict[str, Any]] = [] for index, match in enumerate(_BING_RESULT_RE.finditer(body)): results.append( _result( _clean_fragment(match.group(2)), _unwrap_bing_url(match.group(1)), snippets[index] if index < len(snippets) else "", ) ) if len(results) >= params.max_results: break return results, [] _Provider = Callable[[str, WebSearchInput], "tuple[list[dict[str, Any]], list[str]]"] def _provider_chain() -> list[tuple[str, _Provider]]: """Ordered providers: keyed APIs first, then self-hosted, then keyless. ``OBSIGATE_WEB_PROVIDERS`` (comma-separated) overrides the default order; unknown names are ignored and keyed providers without their key are skipped. """ chain: list[tuple[str, _Provider]] = [] configured = [ name.strip().lower() for name in os.environ.get("OBSIGATE_WEB_PROVIDERS", "").split(",") if name.strip() ] for name in configured or list(_KEYED_PROVIDERS): entry = _KEYED_PROVIDERS.get(name) if entry and _env_key(entry[1]): chain.append((name, entry[0])) chain.append(("searxng", _search_searxng)) if WEB_FALLBACK_ENABLED: chain.append(("duckduckgo", _search_duckduckgo)) chain.append(("bing", _search_bing)) return chain @tool( name="web_search", description=( "Search the public web for current information and return ranked results " "(title, url, snippet). Use for facts outside the vault: weather, news, " "documentation, versions, prices, anything that needs live sources." ), input_model=WebSearchInput, risk=ToolRisk.READ, scopes=(ToolScope.IN_APP,), ) def web_search(ctx, params: WebSearchInput) -> dict[str, Any]: """Try each configured provider and return the first non-empty result set.""" query = params.query.strip() if not query: raise ToolError("Requête vide", code="invalid_arguments") key = webcache.cache_key("search", { "q": query, "max_results": params.max_results, "category": params.category, "language": params.language, "page": params.page, }) cached = webcache.cache_get(key) if cached is not None: return {**cached, "cached": True} attempts: list[str] = [] unresponsive: list[str] = [] reachable = False last_error: Exception | None = None for name, provider in _provider_chain(): attempts.append(name) def _attempt(p: _Provider = provider) -> tuple[list[dict[str, Any]], list[str]]: return p(query, params) try: results, engines = _with_retry(_attempt) except (httpx.HTTPError, ValueError, AttributeError) as e: logger.warning("web_search provider %s failed: %s", name, e) last_error = e continue reachable = True if engines: unresponsive = engines if results: payload: dict[str, Any] = { "query": query, "provider": name, "results": results, "count": len(results), } if unresponsive: payload["unresponsive_engines"] = unresponsive[:8] webcache.cache_set(key, payload) return payload if not reachable: raise ToolError( "Le moteur de recherche web est momentanément indisponible.", code="web_search_unavailable", ) from last_error # Every provider answered but returned nothing: tell the model explicitly # so it stops retrying the same query until its tool quota burns out. payload = { "query": query, "provider": attempts[-1], "results": [], "count": 0, "warning": ( "Aucun résultat : les fournisseurs de recherche web sont " f"indisponibles ({', '.join(attempts)}). " "Ne relance pas la même recherche — dis-le à l'utilisateur." ), } if unresponsive: payload["unresponsive_engines"] = unresponsive[:8] return payload @tool( name="fetch_url", description=( "Fetch a public web page (http/https) and return its readable text. " "Use after web_search to read a promising result in detail. HTML is " "converted to plain text; binary pages are rejected." ), input_model=FetchUrlInput, risk=ToolRisk.READ, scopes=(ToolScope.IN_APP,), ) def fetch_url(ctx, params: FetchUrlInput) -> dict[str, Any]: """Retrieve one page, guard against SSRF, and extract its text.""" url = _assert_public_http_url(params.url.strip()) key = webcache.cache_key("fetch", {"url": url, "render": params.render}) cached = webcache.cache_get(key) if cached is not None: return {**cached, "cached": True} if params.render: # Dynamic pages (SPA/React): delegated to the isolated Playwright # worker; the browser dependency stays optional (graceful error). from backend.tools.webrender import render_page payload = render_page(url) webcache.cache_set(key, payload) return payload try: # Follow redirects manually so every hop is re-checked against the # private-address SSRF guard (a public page can redirect to 127.0.0.1). resp = None for _hop in range(5): resp = httpx.get( url, headers={"User-Agent": USER_AGENT, "Accept": "text/html,application/xhtml+xml,*/*"}, timeout=WEB_TIMEOUT, follow_redirects=False, ) if resp.status_code in (301, 302, 303, 307, 308): location = resp.headers.get("location") or "" if not location: break url = str(httpx.URL(url).join(location)) url = _assert_public_http_url(url) continue break assert resp is not None resp.raise_for_status() except SSRFError: raise except httpx.HTTPError as e: logger.warning("fetch_url failed for %s: %s", url, e) raise ToolError("Impossible de récupérer la page.", code="fetch_unavailable") from e ctype = (resp.headers.get("content-type") or "").lower() if not any(t in ctype for t in ("html", "xml", "text", "json", "markdown")): raise ToolError( f"Type de contenu non pris en charge: {ctype.split(';')[0] or 'inconnu'}", code="unsupported_content_type", ) raw = (resp.content[:MAX_FETCH_BYTES]).decode(resp.encoding or "utf-8", errors="replace") title_match = re.search(r"]*>(.*?)", raw, re.IGNORECASE | re.DOTALL) title = html_lib.unescape(title_match.group(1)).strip()[:300] if title_match else "" text = _html_to_text(raw)[:MAX_TEXT_CHARS] payload = { "url": str(resp.url), "status": resp.status_code, "title": title, "text": text, "truncated": len(raw) > MAX_TEXT_CHARS, } webcache.cache_set(key, payload) return payload