X-RUDRA / web_search.py
ST-x-Tony's picture
Upload 2 files
48adbca verified
Raw
History Blame Contribute Delete
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)
@staticmethod
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"],
)
@staticmethod
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:
@staticmethod
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:
@staticmethod
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
@staticmethod
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
@staticmethod
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()
@app.get("/health")
async def health():
return {
"status": "ok",
"scrapling": SCRAPLING_AVAILABLE,
"playwright": PLAYWRIGHT_AVAILABLE,
"m1": M1_SPACE,
"m2": M2_SPACE,
"browser_mode": BROWSER_MODE,
}
@app.post("/v2/web-search", response_model=SearchReport)
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
@app.on_event("shutdown")
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())