Download web_search.py from ST-x-Tony/X-RUDRA: direct link, hf CLI and curl.
- Browser
- Download file 39.6 kB
-
https://huggingface.co/spaces/ST-x-Tony/X-RUDRA/resolve/main/web_search.py
- Command line
-
hf download hf://spaces/ST-x-Tony/X-RUDRA/web_search.py
-
curl -L -o web_search.py https://huggingface.co/spaces/ST-x-Tony/X-RUDRA/resolve/main/web_search.py
39.6 kB
| """ | |
| X-RUDRA Web Search v2 | |
| Single-file web research engine. | |
| Pipeline: | |
| Model A + Model B -> keyword/query planning -> DuckDuckGo discovery | |
| -> static HTTP -> Scrapling parsing -> DynamicFetcher/Playwright fallback | |
| -> optional StealthyFetcher fallback -> evidence extraction -> ranking. | |
| Important: | |
| - Browser automation is for rendering pages that require JavaScript. | |
| - It does NOT attempt to defeat CAPTCHAs, access controls, paywalls, or authentication. | |
| - Webpage text is untrusted data and never becomes an instruction to the agent. | |
| - SSRF checks are applied before navigation. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import hashlib | |
| import ipaddress | |
| import json | |
| import logging | |
| import os | |
| import re | |
| import socket | |
| import sqlite3 | |
| import time | |
| from collections import Counter | |
| from datetime import datetime, timezone | |
| from typing import Any, Optional | |
| from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse | |
| from urllib.robotparser import RobotFileParser | |
| import trafilatura | |
| from bs4 import BeautifulSoup | |
| from ddgs import DDGS | |
| from fastapi import FastAPI, HTTPException | |
| from pydantic import BaseModel, Field, ConfigDict | |
| # Scrapling is the primary extraction/fetching layer. | |
| # Playwright is used directly as a fallback when browser-level control is useful. | |
| try: | |
| from scrapling.fetchers import Fetcher, AsyncFetcher, DynamicFetcher, StealthyFetcher | |
| SCRAPLING_AVAILABLE = True | |
| except Exception: | |
| Fetcher = AsyncFetcher = DynamicFetcher = StealthyFetcher = None | |
| SCRAPLING_AVAILABLE = False | |
| try: | |
| from playwright.async_api import async_playwright | |
| PLAYWRIGHT_AVAILABLE = True | |
| except Exception: | |
| async_playwright = None | |
| PLAYWRIGHT_AVAILABLE = False | |
| # ============================================================ | |
| # CONFIG | |
| # ============================================================ | |
| M1_SPACE = os.getenv("M1_SPACE", "Shrijanagain/M1") | |
| M2_SPACE = os.getenv("M2_SPACE", "Shrijanagain/M2") | |
| HF_TOKEN = os.getenv("HF_TOKEN", "") | |
| MAX_RESULTS = int(os.getenv("MAX_RESULTS", "12")) | |
| MAX_ROUNDS = int(os.getenv("MAX_ROUNDS", "3")) | |
| MAX_CONCURRENT = int(os.getenv("MAX_CONCURRENT", "4")) | |
| HTTP_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "15")) | |
| BROWSER_TIMEOUT_MS = int(os.getenv("BROWSER_TIMEOUT_MS", "25000")) | |
| MAX_BODY = int(os.getenv("MAX_BODY", str(8 * 1024 * 1024))) | |
| CACHE_TTL = int(os.getenv("CACHE_TTL", "3600")) | |
| CACHE_DB = os.getenv("CACHE_DB", "x_rudra_web_v2.sqlite3") | |
| BROWSER_MODE = os.getenv("BROWSER_MODE", "auto") # auto|never|browser | |
| USE_STEALTH_FETCHER = os.getenv("USE_STEALTH_FETCHER", "1") == "1" | |
| USER_AGENT = os.getenv( | |
| "USER_AGENT", | |
| "X-RUDRA-ResearchBot/2.0 (+research client)", | |
| ) | |
| logging.basicConfig( | |
| level=os.getenv("LOG_LEVEL", "INFO"), | |
| format="%(asctime)s | %(levelname)s | %(name)s | %(message)s", | |
| ) | |
| log = logging.getLogger("x-rudra-web") | |
| # ============================================================ | |
| # SCHEMAS | |
| # ============================================================ | |
| class SearchRequest(BaseModel): | |
| model_config = ConfigDict(extra="ignore") | |
| question: str = Field(min_length=1, max_length=12000) | |
| max_results: int = Field(default=MAX_RESULTS, ge=1, le=30) | |
| max_rounds: int = Field(default=MAX_ROUNDS, ge=1, le=5) | |
| use_models: bool = True | |
| freshness: str = "auto" | |
| class SearchPlan(BaseModel): | |
| intent: str = "general" | |
| topic: str = "" | |
| entities: list[str] = Field(default_factory=list) | |
| keywords: list[str] = Field(default_factory=list) | |
| queries: list[str] = Field(default_factory=list) | |
| opposing_queries: list[str] = Field(default_factory=list) | |
| time_constraint: Optional[str] = None | |
| source_types: list[str] = Field(default_factory=list) | |
| class SearchResult(BaseModel): | |
| title: str | |
| url: str | |
| snippet: str = "" | |
| query: str = "" | |
| rank: int = 0 | |
| class PageDocument(BaseModel): | |
| url: str | |
| final_url: str | |
| domain: str | |
| title: str = "" | |
| description: str = "" | |
| author: Optional[str] = None | |
| published_at: Optional[str] = None | |
| modified_at: Optional[str] = None | |
| text: str = "" | |
| passages: list[dict[str, Any]] = Field(default_factory=list) | |
| source_score: float = 0.0 | |
| freshness_score: float = 0.0 | |
| relevance_score: float = 0.0 | |
| fetch_method: str = "unknown" | |
| status_code: Optional[int] = None | |
| prompt_injection_detected: bool = False | |
| class Claim(BaseModel): | |
| claim: str | |
| source_url: str | |
| passage: str | |
| support_score: float = 0.0 | |
| class SearchReport(BaseModel): | |
| status: str | |
| question: str | |
| plan: SearchPlan | |
| queries: list[str] | |
| results: list[SearchResult] | |
| sources: list[PageDocument] | |
| claims: list[Claim] | |
| contradictions: list[dict[str, Any]] | |
| failures: list[dict[str, Any]] | |
| rounds: int | |
| stopping_reason: str | |
| # ============================================================ | |
| # BASIC UTILITIES | |
| # ============================================================ | |
| TRACKING = { | |
| "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", | |
| "gclid", "fbclid", "mc_cid", "mc_eid", "ref", "ref_src", | |
| } | |
| def clean_text(value: str) -> str: | |
| return re.sub(r"\s+", " ", value or "").strip() | |
| def normalize_url(url: str) -> str: | |
| try: | |
| p = urlparse(url.strip()) | |
| if p.scheme.lower() not in {"http", "https"}: | |
| return "" | |
| query = [ | |
| (k, v) for k, v in parse_qsl(p.query, keep_blank_values=True) | |
| if k.lower() not in TRACKING | |
| ] | |
| return urlunparse( | |
| p._replace( | |
| scheme=p.scheme.lower(), | |
| netloc=p.netloc.lower(), | |
| query=urlencode(query), | |
| fragment="", | |
| ) | |
| ) | |
| except Exception: | |
| return "" | |
| def domain(url: str) -> str: | |
| return (urlparse(url).hostname or "").lower() | |
| def tokens(text: str) -> set[str]: | |
| return set(re.findall(r"[a-zA-Z0-9][a-zA-Z0-9_-]{2,}", text.lower())) | |
| def similarity(a: str, b: str) -> float: | |
| x, y = tokens(a), tokens(b) | |
| return len(x & y) / max(1, len(x | y)) | |
| def sha(value: str) -> str: | |
| return hashlib.sha256(value.encode()).hexdigest() | |
| def private_host(host: str) -> bool: | |
| if not host or host.lower() in {"localhost", "localhost.localdomain"}: | |
| return True | |
| try: | |
| addresses = socket.getaddrinfo(host, None) | |
| except OSError: | |
| return True | |
| for info in addresses: | |
| try: | |
| ip = ipaddress.ip_address(info[4][0]) | |
| except ValueError: | |
| return True | |
| if ( | |
| ip.is_private or ip.is_loopback or ip.is_link_local | |
| or ip.is_multicast or ip.is_reserved or ip.is_unspecified | |
| ): | |
| return True | |
| return False | |
| def safe_url(url: str) -> bool: | |
| url = normalize_url(url) | |
| if not url: | |
| return False | |
| p = urlparse(url) | |
| return p.scheme in {"http", "https"} and not private_host(p.hostname or "") | |
| # ============================================================ | |
| # CACHE | |
| # ============================================================ | |
| class Cache: | |
| def __init__(self, path: str): | |
| self.path = path | |
| with sqlite3.connect(self.path) as c: | |
| c.execute( | |
| "CREATE TABLE IF NOT EXISTS cache " | |
| "(k TEXT PRIMARY KEY, v TEXT, t REAL)" | |
| ) | |
| def get(self, key: str) -> Any: | |
| try: | |
| with sqlite3.connect(self.path) as c: | |
| row = c.execute( | |
| "SELECT v,t FROM cache WHERE k=?", (key,) | |
| ).fetchone() | |
| if not row: | |
| return None | |
| if time.time() - row[1] > CACHE_TTL: | |
| return None | |
| return json.loads(row[0]) | |
| except Exception: | |
| return None | |
| def set(self, key: str, value: Any): | |
| try: | |
| with sqlite3.connect(self.path) as c: | |
| c.execute( | |
| "INSERT OR REPLACE INTO cache(k,v,t) VALUES(?,?,?)", | |
| (key, json.dumps(value, ensure_ascii=False), time.time()), | |
| ) | |
| except Exception as exc: | |
| log.debug("cache write failed: %s", exc) | |
| CACHE = Cache(CACHE_DB) | |
| # ============================================================ | |
| # MODEL PLANNING | |
| # ============================================================ | |
| class SpaceModel: | |
| def __init__(self, space: str): | |
| self.space = space | |
| self.client = None | |
| def _client(self): | |
| if self.client is None: | |
| from gradio_client import Client | |
| kwargs = {"hf_token": HF_TOKEN} if HF_TOKEN else {} | |
| self.client = Client(self.space, **kwargs) | |
| return self.client | |
| async def generate(self, prompt: str) -> Optional[str]: | |
| def run(): | |
| try: | |
| result = self._client().predict( | |
| prompt, 1200, 0.2, 0.9, api_name="/generate" | |
| ) | |
| return result if isinstance(result, str) else str(result) | |
| except Exception as exc: | |
| log.warning("Space %s unavailable: %s", self.space, exc) | |
| return None | |
| return await asyncio.to_thread(run) | |
| class Planner: | |
| def __init__(self): | |
| self.a = SpaceModel(M1_SPACE) | |
| self.b = SpaceModel(M2_SPACE) | |
| def fallback(q: str) -> SearchPlan: | |
| stop = { | |
| "what","when","where","which","who","why","how","is","are", | |
| "was","were","the","a","an","of","to","for","in","on","and", | |
| "or","with","about","tell","me","please" | |
| } | |
| words = [x for x in re.findall(r"[a-zA-Z0-9_-]{3,}", q.lower()) | |
| if x not in stop] | |
| words = list(dict.fromkeys(words)) | |
| current = any(x in q.lower() for x in | |
| ("latest","today","current","recent","2026")) | |
| return SearchPlan( | |
| intent="current_factual" if current else "general", | |
| topic=" ".join(words[:10]), | |
| keywords=words[:20], | |
| time_constraint="recent" if current else "auto", | |
| source_types=["official","primary","academic","reputable"], | |
| ) | |
| def parse(text: Optional[str]) -> Optional[SearchPlan]: | |
| if not text: | |
| return None | |
| m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S) | |
| raw = m.group(1) if m else None | |
| if raw is None: | |
| a, b = text.find("{"), text.rfind("}") | |
| if a >= 0 and b > a: | |
| raw = text[a:b + 1] | |
| if not raw: | |
| return None | |
| try: | |
| return SearchPlan.model_validate(json.loads(raw)) | |
| except Exception: | |
| return None | |
| async def one(self, model: SpaceModel, question: str): | |
| prompt = f""" | |
| X-RUDRA search planner. Return JSON only. | |
| Schema: | |
| {{ | |
| "intent":"general|current_factual|research|comparison|explanatory", | |
| "topic":"...", | |
| "entities":[], | |
| "keywords":[], | |
| "queries":[], | |
| "opposing_queries":[], | |
| "time_constraint":null, | |
| "source_types":[] | |
| }} | |
| Extract search concepts and generate independent queries. | |
| Never answer the user question. | |
| QUESTION: {question} | |
| """ | |
| return self.parse(await model.generate(prompt)) | |
| async def plan(self, question: str, use_models=True) -> SearchPlan: | |
| fallback = self.fallback(question) | |
| if not use_models: | |
| return fallback | |
| a, b = await asyncio.gather( | |
| self.one(self.a, question), | |
| self.one(self.b, question), | |
| ) | |
| plans = [x for x in (a, b) if x] | |
| if not plans: | |
| return fallback | |
| merged = fallback.model_copy(deep=True) | |
| merged.keywords = [] | |
| merged.queries = [] | |
| merged.entities = [] | |
| merged.opposing_queries = [] | |
| merged.source_types = [] | |
| for p in plans: | |
| for field in ("keywords","queries","entities", | |
| "opposing_queries","source_types"): | |
| for x in getattr(p, field): | |
| if x and x not in getattr(merged, field): | |
| getattr(merged, field).append(x) | |
| if p.topic: | |
| merged.topic = p.topic | |
| if p.time_constraint: | |
| merged.time_constraint = p.time_constraint | |
| if p.intent: | |
| merged.intent = p.intent | |
| return merged | |
| # ============================================================ | |
| # QUERY GENERATION + DDG | |
| # ============================================================ | |
| class QueryBuilder: | |
| def build(question: str, plan: SearchPlan) -> list[str]: | |
| out = [] | |
| def add(x): | |
| x = clean_text(x) | |
| if x and x not in out: | |
| out.append(x) | |
| for q in plan.queries: | |
| add(q) | |
| core = plan.topic or " ".join(plan.keywords[:10]) | |
| add(core) | |
| add(f"{core} latest") | |
| add(f"{core} official") | |
| add(f"{core} primary source") | |
| add(f"{core} research") | |
| add(f"{core} report") | |
| add(f"{core} evidence") | |
| add(f"{core} criticism") | |
| add(f"{core} limitations") | |
| if plan.time_constraint not in (None, "auto"): | |
| add(f"{core} {plan.time_constraint}") | |
| for q in plan.opposing_queries: | |
| add(q) | |
| return list(dict.fromkeys(out))[:30] | |
| class DDG: | |
| async def search(self, query: str, limit: int) -> list[SearchResult]: | |
| key = "ddg:" + sha(f"{query}:{limit}") | |
| cached = CACHE.get(key) | |
| if cached: | |
| return [SearchResult.model_validate(x) for x in cached] | |
| def run(): | |
| out = [] | |
| try: | |
| with DDGS(timeout=int(HTTP_TIMEOUT)) as d: | |
| for i, item in enumerate( | |
| d.text(query, max_results=limit), 1 | |
| ): | |
| url = normalize_url(item.get("href", "")) | |
| if url: | |
| out.append(SearchResult( | |
| title=clean_text(item.get("title", "")), | |
| url=url, | |
| snippet=clean_text(item.get("body", "")), | |
| query=query, | |
| rank=i, | |
| )) | |
| except Exception as exc: | |
| log.warning("DDG failed: %s", exc) | |
| return out | |
| result = await asyncio.to_thread(run) | |
| CACHE.set(key, [x.model_dump() for x in result]) | |
| return result | |
| # ============================================================ | |
| # ROBOTS | |
| # ============================================================ | |
| class Robots: | |
| def __init__(self): | |
| self.cache = {} | |
| async def allowed(self, url: str) -> bool: | |
| host = domain(url) | |
| if not host: | |
| return False | |
| if host in self.cache: | |
| return self.cache[host] | |
| robots_url = f"{urlparse(url).scheme}://{host}/robots.txt" | |
| def read(): | |
| rp = RobotFileParser() | |
| rp.set_url(robots_url) | |
| try: | |
| rp.read() | |
| return rp.can_fetch(USER_AGENT, url) | |
| except Exception: | |
| return True | |
| value = await asyncio.to_thread(read) | |
| self.cache[host] = value | |
| return value | |
| # ============================================================ | |
| # SCRAPLING STATIC FETCH | |
| # ============================================================ | |
| class ScraplingStatic: | |
| async def fetch(self, url: str) -> Optional[dict[str, Any]]: | |
| if not SCRAPLING_AVAILABLE or not safe_url(url): | |
| return None | |
| def run(): | |
| try: | |
| page = Fetcher.get( | |
| url, | |
| timeout=int(HTTP_TIMEOUT), | |
| retries=2, | |
| follow_redirects="safe", | |
| stealthy_headers=True, | |
| impersonate="chrome", | |
| ) | |
| html = getattr(page, "html_content", None) | |
| if callable(html): | |
| html = html() | |
| if html is None: | |
| html = getattr(page, "text", "") or "" | |
| status = getattr(page, "status", None) | |
| final = getattr(page, "url", None) or url | |
| return { | |
| "html": html, | |
| "status": status, | |
| "final_url": final, | |
| "method": "scrapling-http", | |
| } | |
| except Exception as exc: | |
| log.debug("Scrapling static failed %s: %s", url, exc) | |
| return None | |
| return await asyncio.to_thread(run) | |
| # ============================================================ | |
| # PLAYWRIGHT DIRECT BROWSER | |
| # ============================================================ | |
| class PlaywrightBrowser: | |
| def __init__(self): | |
| self.playwright = None | |
| self.browser = None | |
| self.lock = asyncio.Lock() | |
| async def start(self): | |
| if not PLAYWRIGHT_AVAILABLE: | |
| return | |
| async with self.lock: | |
| if self.browser: | |
| return | |
| self.playwright = await async_playwright().start() | |
| self.browser = await self.playwright.chromium.launch( | |
| headless=True, | |
| args=[ | |
| "--disable-dev-shm-usage", | |
| "--no-first-run", | |
| "--no-default-browser-check", | |
| ], | |
| ) | |
| async def close(self): | |
| if self.browser: | |
| await self.browser.close() | |
| self.browser = None | |
| if self.playwright: | |
| await self.playwright.stop() | |
| self.playwright = None | |
| async def fetch(self, url: str) -> Optional[dict[str, Any]]: | |
| if not PLAYWRIGHT_AVAILABLE or not safe_url(url): | |
| return None | |
| await self.start() | |
| if not self.browser: | |
| return None | |
| context = await self.browser.new_context( | |
| user_agent=USER_AGENT, | |
| java_script_enabled=True, | |
| ignore_https_errors=False, | |
| ) | |
| page = await context.new_page() | |
| try: | |
| response = await page.goto( | |
| url, | |
| wait_until="domcontentloaded", | |
| timeout=BROWSER_TIMEOUT_MS, | |
| ) | |
| await page.wait_for_timeout(500) | |
| html = await page.content() | |
| final = page.url | |
| if not safe_url(final): | |
| return None | |
| return { | |
| "html": html[:MAX_BODY], | |
| "status": response.status if response else None, | |
| "final_url": final, | |
| "method": "playwright", | |
| } | |
| except Exception as exc: | |
| log.debug("Playwright failed %s: %s", url, exc) | |
| return None | |
| finally: | |
| await page.close() | |
| await context.close() | |
| # ============================================================ | |
| # SCRAPLING DYNAMIC / STEALTH BROWSER | |
| # ============================================================ | |
| class ScraplingBrowser: | |
| async def dynamic(self, url: str) -> Optional[dict[str, Any]]: | |
| if not SCRAPLING_AVAILABLE or not safe_url(url): | |
| return None | |
| def run(): | |
| try: | |
| page = DynamicFetcher.fetch( | |
| url, | |
| headless=True, | |
| timeout=BROWSER_TIMEOUT_MS, | |
| disable_resources=True, | |
| block_ads=True, | |
| network_idle=False, | |
| load_dom=True, | |
| ) | |
| html = getattr(page, "html_content", "") or "" | |
| return { | |
| "html": html[:MAX_BODY], | |
| "status": getattr(page, "status", None), | |
| "final_url": getattr(page, "url", None) or url, | |
| "method": "scrapling-dynamic", | |
| } | |
| except Exception as exc: | |
| log.debug("Scrapling Dynamic failed %s: %s", url, exc) | |
| return None | |
| return await asyncio.to_thread(run) | |
| async def stealth(self, url: str) -> Optional[dict[str, Any]]: | |
| if not SCRAPLING_AVAILABLE or not USE_STEALTH_FETCHER: | |
| return None | |
| if not safe_url(url): | |
| return None | |
| # This mode uses Scrapling's browser implementation but does | |
| # not solve CAPTCHAs or authenticate to protected resources. | |
| def run(): | |
| try: | |
| page = StealthyFetcher.fetch( | |
| url, | |
| headless=True, | |
| timeout=BROWSER_TIMEOUT_MS, | |
| disable_resources=True, | |
| block_ads=True, | |
| network_idle=False, | |
| load_dom=True, | |
| ) | |
| html = getattr(page, "html_content", "") or "" | |
| return { | |
| "html": html[:MAX_BODY], | |
| "status": getattr(page, "status", None), | |
| "final_url": getattr(page, "url", None) or url, | |
| "method": "scrapling-stealth", | |
| } | |
| except Exception as exc: | |
| log.debug("Scrapling Stealth failed %s: %s", url, exc) | |
| return None | |
| return await asyncio.to_thread(run) | |
| # ============================================================ | |
| # EXTRACTION | |
| # ============================================================ | |
| INJECTION_PATTERNS = [ | |
| r"ignore\s+(all|any|the)\s+(previous|prior|system)\s+instructions", | |
| r"reveal\s+(your|the)\s+(system|developer)\s+prompt", | |
| r"disregard\s+your\s+instructions", | |
| r"act\s+as\s+the\s+system", | |
| r"show\s+your\s+hidden\s+instructions", | |
| ] | |
| def detect_injection(text: str) -> bool: | |
| x = text.lower() | |
| return any(re.search(p, x) for p in INJECTION_PATTERNS) | |
| def metadata(html: str) -> dict[str, Any]: | |
| soup = BeautifulSoup(html, "html.parser") | |
| title = clean_text(soup.title.get_text(" ")) if soup.title else "" | |
| def meta(name=None, prop=None): | |
| tag = soup.find( | |
| "meta", | |
| attrs=({"name": re.compile(f"^{name}$", re.I)} | |
| if name else {"property": prop}), | |
| ) | |
| return clean_text(tag.get("content", "")) if tag else "" | |
| canonical = "" | |
| link = soup.find("link", attrs={ | |
| "rel": lambda x: x and "canonical" in x | |
| }) | |
| if link: | |
| canonical = link.get("href", "") | |
| published = meta(prop="article:published_time") | |
| modified = meta(prop="article:modified_time") | |
| author = meta(name="author") | |
| for script in soup.find_all( | |
| "script", attrs={"type": "application/ld+json"} | |
| ): | |
| try: | |
| data = json.loads(script.string or "") | |
| items = data if isinstance(data, list) else [data] | |
| for item in items: | |
| if not isinstance(item, dict): | |
| continue | |
| title = title or clean_text(item.get("headline", "")) | |
| published = published or item.get("datePublished", "") | |
| modified = modified or item.get("dateModified", "") | |
| if isinstance(item.get("author"), dict): | |
| author = author or item["author"].get("name", "") | |
| except Exception: | |
| continue | |
| return { | |
| "title": title, | |
| "description": meta(name="description"), | |
| "canonical": canonical, | |
| "author": author or None, | |
| "published": published or None, | |
| "modified": modified or None, | |
| } | |
| def extract_text(html: str) -> str: | |
| try: | |
| value = trafilatura.extract( | |
| html, | |
| include_tables=True, | |
| include_links=False, | |
| include_images=False, | |
| favor_precision=True, | |
| ) | |
| if value: | |
| return clean_text(value) | |
| except Exception: | |
| pass | |
| soup = BeautifulSoup(html, "html.parser") | |
| for tag in soup([ | |
| "script","style","noscript","svg","canvas", | |
| "iframe","nav","footer","form" | |
| ]): | |
| tag.decompose() | |
| return clean_text(soup.get_text(" ", strip=True)) | |
| def passages(question: str, text: str, limit=8): | |
| sentences = re.split(r"(?<=[.!?])\s+", clean_text(text)) | |
| chunks, current = [], "" | |
| for sentence in sentences: | |
| if len(current) + len(sentence) < 1800: | |
| current = f"{current} {sentence}".strip() | |
| else: | |
| if current: | |
| chunks.append(current) | |
| current = sentence | |
| if current: | |
| chunks.append(current) | |
| scored = [] | |
| for chunk in chunks: | |
| scored.append({ | |
| "text": chunk, | |
| "relevance": similarity(question, chunk), | |
| }) | |
| return sorted( | |
| scored, key=lambda x: x["relevance"], reverse=True | |
| )[:limit] | |
| # ============================================================ | |
| # SOURCE SCORING | |
| # ============================================================ | |
| def authority(host: str) -> float: | |
| h = host.lower() | |
| if h.endswith(".gov") or ".gov." in h: | |
| return 1.0 | |
| if h.endswith(".edu") or ".edu." in h: | |
| return 0.95 | |
| if h.endswith(".org"): | |
| return 0.72 | |
| strong = { | |
| "unesco.org","un.org","who.int","worldbank.org", | |
| "oecd.org","nasa.gov","nih.gov","reuters.com", | |
| "apnews.com","bbc.com" | |
| } | |
| return 0.95 if any( | |
| h == x or h.endswith("." + x) for x in strong | |
| ) else 0.45 | |
| def freshness(value: Optional[str], mode: str) -> float: | |
| if not value: | |
| return 0.5 | |
| try: | |
| from dateutil import parser | |
| dt = parser.parse(value) | |
| if not dt.tzinfo: | |
| dt = dt.replace(tzinfo=timezone.utc) | |
| age = max( | |
| 0, | |
| (datetime.now(timezone.utc) - dt.astimezone(timezone.utc)) | |
| .total_seconds() / 86400, | |
| ) | |
| if mode in {"latest","recent","current"}: | |
| return max(0, 1 - age / 365) | |
| return 0.7 | |
| except Exception: | |
| return 0.5 | |
| def source_score(host, rel, fresh): | |
| return round( | |
| authority(host) * 0.35 | |
| + rel * 0.40 | |
| + fresh * 0.25, | |
| 4, | |
| ) | |
| # ============================================================ | |
| # MULTI-STRATEGY FETCH ORCHESTRATOR | |
| # ============================================================ | |
| class FetchOrchestrator: | |
| def __init__(self): | |
| self.static = ScraplingStatic() | |
| self.dynamic = ScraplingBrowser() | |
| self.playwright = PlaywrightBrowser() | |
| self.robots = Robots() | |
| self.sem = asyncio.Semaphore(MAX_CONCURRENT) | |
| async def fetch(self, url: str, browser_hint=False): | |
| if not safe_url(url): | |
| return None, "unsafe_url" | |
| if not await self.robots.allowed(url): | |
| return None, "robots_disallowed" | |
| # 1. Fast Scrapling HTTP. | |
| result = await self.static.fetch(url) | |
| if result and result.get("html"): | |
| return result, "scrapling-http" | |
| if BROWSER_MODE == "never": | |
| return None, "static_failed" | |
| # 2. Scrapling dynamic browser. | |
| if browser_hint or BROWSER_MODE == "browser": | |
| result = await self.dynamic.dynamic(url) | |
| if result and result.get("html"): | |
| return result, "scrapling-dynamic" | |
| # 3. Optional stealth browser as a renderer. | |
| if browser_hint and USE_STEALTH_FETCHER: | |
| result = await self.dynamic.stealth(url) | |
| if result and result.get("html"): | |
| return result, "scrapling-stealth" | |
| # 4. Direct Playwright fallback. | |
| if PLAYWRIGHT_AVAILABLE: | |
| result = await self.playwright.fetch(url) | |
| if result and result.get("html"): | |
| return result, "playwright" | |
| return None, "all_fetchers_failed" | |
| async def close(self): | |
| await self.playwright.close() | |
| # ============================================================ | |
| # CLAIM / EVIDENCE | |
| # ============================================================ | |
| class EvidenceEngine: | |
| def claims(question, sources): | |
| claims = [] | |
| for source in sources: | |
| for p in source.passages: | |
| rel = float(p.get("relevance", 0)) | |
| if rel >= 0.18 and len(p["text"]) >= 50: | |
| claims.append( | |
| Claim( | |
| claim=p["text"][:1500], | |
| source_url=source.url, | |
| passage=p["text"][:2000], | |
| support_score=round( | |
| rel * source.source_score, 4 | |
| ), | |
| ) | |
| ) | |
| return claims | |
| def dedupe(claims): | |
| out = [] | |
| for c in claims: | |
| if not any( | |
| similarity(c.claim, x.claim) >= 0.90 | |
| for x in out | |
| ): | |
| out.append(c) | |
| return out | |
| def contradictions(claims): | |
| result = [] | |
| for i, a in enumerate(claims): | |
| for b in claims[i + 1:]: | |
| if a.source_url == b.source_url: | |
| continue | |
| sim = similarity(a.claim, b.claim) | |
| if sim < 0.45: | |
| continue | |
| na = bool(re.search( | |
| r"\b(not|no|never|false|denied|did not)\b", | |
| a.claim.lower(), | |
| )) | |
| nb = bool(re.search( | |
| r"\b(not|no|never|false|denied|did not)\b", | |
| b.claim.lower(), | |
| )) | |
| if na != nb: | |
| result.append({ | |
| "claim_a": a.claim, | |
| "source_a": a.source_url, | |
| "claim_b": b.claim, | |
| "source_b": b.source_url, | |
| "status": "needs_review", | |
| }) | |
| return result[:20] | |
| # ============================================================ | |
| # MAIN ENGINE | |
| # ============================================================ | |
| class XrudraWebSearch: | |
| def __init__(self): | |
| self.planner = Planner() | |
| self.ddg = DDG() | |
| self.fetcher = FetchOrchestrator() | |
| async def search( | |
| self, | |
| question: str, | |
| max_results=MAX_RESULTS, | |
| max_rounds=MAX_ROUNDS, | |
| use_models=True, | |
| freshness_mode="auto", | |
| ): | |
| plan = await self.planner.plan(question, use_models) | |
| queries = QueryBuilder.build(question, plan) | |
| all_results = [] | |
| seen = set() | |
| all_sources = [] | |
| failures = [] | |
| for round_no in range(1, max_rounds + 1): | |
| if round_no > 1: | |
| followups = [ | |
| f"{plan.topic} primary source", | |
| f"{plan.topic} official evidence", | |
| f"{plan.topic} independent evidence", | |
| f"{plan.topic} criticism", | |
| ] | |
| queries.extend(q for q in followups if q not in queries) | |
| round_queries = queries[ | |
| (round_no - 1) * 8: round_no * 8 | |
| ] | |
| if not round_queries: | |
| break | |
| batches = await asyncio.gather( | |
| *[ | |
| self.ddg.search(q, max_results) | |
| for q in round_queries | |
| ], | |
| return_exceptions=True, | |
| ) | |
| for batch in batches: | |
| if isinstance(batch, Exception): | |
| continue | |
| for r in batch: | |
| r.url = normalize_url(r.url) | |
| if r.url and r.url not in seen: | |
| seen.add(r.url) | |
| all_results.append(r) | |
| # Search results with dynamic-looking signals get browser | |
| # priority; normal pages use fast HTTP first. | |
| ranked = sorted( | |
| all_results, | |
| key=lambda r: similarity( | |
| question, | |
| r.title + " " + r.snippet | |
| ), | |
| reverse=True, | |
| ) | |
| targets = ranked[:max_results] | |
| jobs = [ | |
| self._fetch_one( | |
| r, | |
| question, | |
| plan, | |
| freshness_mode, | |
| ) | |
| for r in targets | |
| ] | |
| fetched = await asyncio.gather( | |
| *jobs, | |
| return_exceptions=True, | |
| ) | |
| for item in fetched: | |
| if isinstance(item, Exception): | |
| continue | |
| source, failure = item | |
| if source: | |
| if not any( | |
| x.final_url == source.final_url | |
| for x in all_sources | |
| ): | |
| all_sources.append(source) | |
| elif failure: | |
| failures.append(failure) | |
| # Enough independent evidence -> stop. | |
| strong = [ | |
| s for s in all_sources | |
| if s.source_score >= 0.45 | |
| and s.relevance_score >= 0.18 | |
| ] | |
| domains = {s.domain for s in strong} | |
| if len(strong) >= 3 and len(domains) >= 2: | |
| return await self._report( | |
| question, plan, queries, all_results, | |
| all_sources, failures, round_no, | |
| "sufficient_independent_evidence", | |
| ) | |
| return await self._report( | |
| question, plan, queries, all_results, | |
| all_sources, failures, max_rounds, | |
| "max_rounds_reached", | |
| ) | |
| async def _fetch_one( | |
| self, result, question, plan, freshness_mode | |
| ): | |
| browser_hint = any( | |
| x in result.snippet.lower() | |
| for x in ( | |
| "javascript", "dynamic", "interactive", | |
| "app", "dashboard" | |
| ) | |
| ) | |
| try: | |
| async with self.fetcher.sem: | |
| payload, method = await self.fetcher.fetch( | |
| result.url, | |
| browser_hint=browser_hint, | |
| ) | |
| if not payload: | |
| return None, { | |
| "url": result.url, | |
| "error": method, | |
| } | |
| final_url = normalize_url( | |
| payload.get("final_url") or result.url | |
| ) | |
| if not safe_url(final_url): | |
| return None, { | |
| "url": result.url, | |
| "error": "unsafe_final_redirect", | |
| } | |
| html = payload.get("html", "") | |
| if not html: | |
| return None, { | |
| "url": result.url, | |
| "error": "empty_html", | |
| } | |
| meta = metadata(html) | |
| text = extract_text(html) | |
| if not text: | |
| return None, { | |
| "url": result.url, | |
| "error": "empty_text", | |
| } | |
| p = passages(question, text) | |
| rel = max( | |
| [x["relevance"] for x in p], | |
| default=0.0, | |
| ) | |
| fresh = freshness( | |
| meta.get("published") or meta.get("modified"), | |
| plan.time_constraint | |
| if plan.time_constraint not in (None, "auto") | |
| else freshness_mode, | |
| ) | |
| src = PageDocument( | |
| url=result.url, | |
| final_url=final_url, | |
| domain=domain(final_url), | |
| title=meta["title"] or result.title, | |
| description=meta["description"], | |
| author=meta["author"], | |
| published_at=meta["published"], | |
| modified_at=meta["modified"], | |
| text=text[:100000], | |
| passages=p, | |
| source_score=source_score( | |
| domain(final_url), rel, fresh | |
| ), | |
| freshness_score=fresh, | |
| relevance_score=rel, | |
| fetch_method=method, | |
| status_code=payload.get("status"), | |
| prompt_injection_detected=detect_injection( | |
| text[:100000] | |
| ), | |
| ) | |
| return src, None | |
| except Exception as exc: | |
| return None, { | |
| "url": result.url, | |
| "error": f"{type(exc).__name__}:{exc}", | |
| } | |
| async def _report( | |
| self, question, plan, queries, results, | |
| sources, failures, rounds, reason | |
| ): | |
| # Source diversity: maximum two per domain. | |
| selected = [] | |
| counts = Counter() | |
| for s in sorted( | |
| sources, | |
| key=lambda x: x.source_score, | |
| reverse=True, | |
| ): | |
| if counts[s.domain] >= 2: | |
| continue | |
| counts[s.domain] += 1 | |
| selected.append(s) | |
| if len(selected) >= MAX_RESULTS: | |
| break | |
| claims = EvidenceEngine.dedupe( | |
| EvidenceEngine.claims(question, selected) | |
| ) | |
| contradictions = EvidenceEngine.contradictions(claims) | |
| return SearchReport( | |
| status="success", | |
| question=question, | |
| plan=plan, | |
| queries=list(dict.fromkeys(queries)), | |
| results=results[:MAX_RESULTS * 3], | |
| sources=selected, | |
| claims=claims[:100], | |
| contradictions=contradictions, | |
| failures=failures[:100], | |
| rounds=rounds, | |
| stopping_reason=reason, | |
| ) | |
| # ============================================================ | |
| # FASTAPI | |
| # ============================================================ | |
| app = FastAPI( | |
| title="X-RUDRA Web Search v2", | |
| version="2.0", | |
| ) | |
| ENGINE = XrudraWebSearch() | |
| async def health(): | |
| return { | |
| "status": "ok", | |
| "scrapling": SCRAPLING_AVAILABLE, | |
| "playwright": PLAYWRIGHT_AVAILABLE, | |
| "m1": M1_SPACE, | |
| "m2": M2_SPACE, | |
| "browser_mode": BROWSER_MODE, | |
| } | |
| async def web_search(req: SearchRequest): | |
| try: | |
| return await ENGINE.search( | |
| question=req.question, | |
| max_results=req.max_results, | |
| max_rounds=req.max_rounds, | |
| use_models=req.use_models, | |
| freshness_mode=req.freshness, | |
| ) | |
| except Exception as exc: | |
| log.exception("web search failed") | |
| raise HTTPException( | |
| status_code=500, | |
| detail="web search failed", | |
| ) from exc | |
| async def shutdown(): | |
| await ENGINE.fetcher.close() | |
| # ============================================================ | |
| # CLI | |
| # ============================================================ | |
| async def main(): | |
| import argparse | |
| p = argparse.ArgumentParser() | |
| p.add_argument("question") | |
| p.add_argument("--results", type=int, default=10) | |
| p.add_argument("--rounds", type=int, default=3) | |
| p.add_argument("--no-models", action="store_true") | |
| args = p.parse_args() | |
| report = await ENGINE.search( | |
| args.question, | |
| max_results=args.results, | |
| max_rounds=args.rounds, | |
| use_models=not args.no_models, | |
| ) | |
| print(json.dumps( | |
| report.model_dump(), | |
| ensure_ascii=False, | |
| indent=2, | |
| )) | |
| if __name__ == "__main__": | |
| asyncio.run(main()) | |