ST-x-Tony commited on
Commit
48adbca
·
verified ·
1 Parent(s): 0fae2b4

Upload 2 files

Browse files
Files changed (2) hide show
  1. requirements.txt +11 -0
  2. web_search.py +1301 -0
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.115
2
+ uvicorn[standard]>=0.30
3
+ pydantic>=2.8
4
+ ddgs>=9.0
5
+ gradio_client>=1.0
6
+ httpx>=0.27
7
+ beautifulsoup4>=4.12
8
+ trafilatura>=2.0
9
+ python-dateutil>=2.9
10
+ scrapling>=0.3.13
11
+ playwright>=1.55
web_search.py ADDED
@@ -0,0 +1,1301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ X-RUDRA Web Search v2
3
+ Single-file web research engine.
4
+
5
+ Pipeline:
6
+ Model A + Model B -> keyword/query planning -> DuckDuckGo discovery
7
+ -> static HTTP -> Scrapling parsing -> DynamicFetcher/Playwright fallback
8
+ -> optional StealthyFetcher fallback -> evidence extraction -> ranking.
9
+
10
+ Important:
11
+ - Browser automation is for rendering pages that require JavaScript.
12
+ - It does NOT attempt to defeat CAPTCHAs, access controls, paywalls, or authentication.
13
+ - Webpage text is untrusted data and never becomes an instruction to the agent.
14
+ - SSRF checks are applied before navigation.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import hashlib
21
+ import ipaddress
22
+ import json
23
+ import logging
24
+ import os
25
+ import re
26
+ import socket
27
+ import sqlite3
28
+ import time
29
+ from collections import Counter
30
+ from datetime import datetime, timezone
31
+ from typing import Any, Optional
32
+ from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
33
+ from urllib.robotparser import RobotFileParser
34
+
35
+ import trafilatura
36
+ from bs4 import BeautifulSoup
37
+ from ddgs import DDGS
38
+ from fastapi import FastAPI, HTTPException
39
+ from pydantic import BaseModel, Field, ConfigDict
40
+
41
+ # Scrapling is the primary extraction/fetching layer.
42
+ # Playwright is used directly as a fallback when browser-level control is useful.
43
+ try:
44
+ from scrapling.fetchers import Fetcher, AsyncFetcher, DynamicFetcher, StealthyFetcher
45
+ SCRAPLING_AVAILABLE = True
46
+ except Exception:
47
+ Fetcher = AsyncFetcher = DynamicFetcher = StealthyFetcher = None
48
+ SCRAPLING_AVAILABLE = False
49
+
50
+ try:
51
+ from playwright.async_api import async_playwright
52
+ PLAYWRIGHT_AVAILABLE = True
53
+ except Exception:
54
+ async_playwright = None
55
+ PLAYWRIGHT_AVAILABLE = False
56
+
57
+
58
+ # ============================================================
59
+ # CONFIG
60
+ # ============================================================
61
+
62
+ M1_SPACE = os.getenv("M1_SPACE", "Shrijanagain/M1")
63
+ M2_SPACE = os.getenv("M2_SPACE", "Shrijanagain/M2")
64
+ HF_TOKEN = os.getenv("HF_TOKEN", "")
65
+
66
+ MAX_RESULTS = int(os.getenv("MAX_RESULTS", "12"))
67
+ MAX_ROUNDS = int(os.getenv("MAX_ROUNDS", "3"))
68
+ MAX_CONCURRENT = int(os.getenv("MAX_CONCURRENT", "4"))
69
+ HTTP_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "15"))
70
+ BROWSER_TIMEOUT_MS = int(os.getenv("BROWSER_TIMEOUT_MS", "25000"))
71
+ MAX_BODY = int(os.getenv("MAX_BODY", str(8 * 1024 * 1024)))
72
+ CACHE_TTL = int(os.getenv("CACHE_TTL", "3600"))
73
+ CACHE_DB = os.getenv("CACHE_DB", "x_rudra_web_v2.sqlite3")
74
+ BROWSER_MODE = os.getenv("BROWSER_MODE", "auto") # auto|never|browser
75
+ USE_STEALTH_FETCHER = os.getenv("USE_STEALTH_FETCHER", "1") == "1"
76
+ USER_AGENT = os.getenv(
77
+ "USER_AGENT",
78
+ "X-RUDRA-ResearchBot/2.0 (+research client)",
79
+ )
80
+
81
+ logging.basicConfig(
82
+ level=os.getenv("LOG_LEVEL", "INFO"),
83
+ format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
84
+ )
85
+ log = logging.getLogger("x-rudra-web")
86
+
87
+
88
+ # ============================================================
89
+ # SCHEMAS
90
+ # ============================================================
91
+
92
+ class SearchRequest(BaseModel):
93
+ model_config = ConfigDict(extra="ignore")
94
+ question: str = Field(min_length=1, max_length=12000)
95
+ max_results: int = Field(default=MAX_RESULTS, ge=1, le=30)
96
+ max_rounds: int = Field(default=MAX_ROUNDS, ge=1, le=5)
97
+ use_models: bool = True
98
+ freshness: str = "auto"
99
+
100
+
101
+ class SearchPlan(BaseModel):
102
+ intent: str = "general"
103
+ topic: str = ""
104
+ entities: list[str] = Field(default_factory=list)
105
+ keywords: list[str] = Field(default_factory=list)
106
+ queries: list[str] = Field(default_factory=list)
107
+ opposing_queries: list[str] = Field(default_factory=list)
108
+ time_constraint: Optional[str] = None
109
+ source_types: list[str] = Field(default_factory=list)
110
+
111
+
112
+ class SearchResult(BaseModel):
113
+ title: str
114
+ url: str
115
+ snippet: str = ""
116
+ query: str = ""
117
+ rank: int = 0
118
+
119
+
120
+ class PageDocument(BaseModel):
121
+ url: str
122
+ final_url: str
123
+ domain: str
124
+ title: str = ""
125
+ description: str = ""
126
+ author: Optional[str] = None
127
+ published_at: Optional[str] = None
128
+ modified_at: Optional[str] = None
129
+ text: str = ""
130
+ passages: list[dict[str, Any]] = Field(default_factory=list)
131
+ source_score: float = 0.0
132
+ freshness_score: float = 0.0
133
+ relevance_score: float = 0.0
134
+ fetch_method: str = "unknown"
135
+ status_code: Optional[int] = None
136
+ prompt_injection_detected: bool = False
137
+
138
+
139
+ class Claim(BaseModel):
140
+ claim: str
141
+ source_url: str
142
+ passage: str
143
+ support_score: float = 0.0
144
+
145
+
146
+ class SearchReport(BaseModel):
147
+ status: str
148
+ question: str
149
+ plan: SearchPlan
150
+ queries: list[str]
151
+ results: list[SearchResult]
152
+ sources: list[PageDocument]
153
+ claims: list[Claim]
154
+ contradictions: list[dict[str, Any]]
155
+ failures: list[dict[str, Any]]
156
+ rounds: int
157
+ stopping_reason: str
158
+
159
+
160
+ # ============================================================
161
+ # BASIC UTILITIES
162
+ # ============================================================
163
+
164
+ TRACKING = {
165
+ "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content",
166
+ "gclid", "fbclid", "mc_cid", "mc_eid", "ref", "ref_src",
167
+ }
168
+
169
+
170
+ def clean_text(value: str) -> str:
171
+ return re.sub(r"\s+", " ", value or "").strip()
172
+
173
+
174
+ def normalize_url(url: str) -> str:
175
+ try:
176
+ p = urlparse(url.strip())
177
+ if p.scheme.lower() not in {"http", "https"}:
178
+ return ""
179
+ query = [
180
+ (k, v) for k, v in parse_qsl(p.query, keep_blank_values=True)
181
+ if k.lower() not in TRACKING
182
+ ]
183
+ return urlunparse(
184
+ p._replace(
185
+ scheme=p.scheme.lower(),
186
+ netloc=p.netloc.lower(),
187
+ query=urlencode(query),
188
+ fragment="",
189
+ )
190
+ )
191
+ except Exception:
192
+ return ""
193
+
194
+
195
+ def domain(url: str) -> str:
196
+ return (urlparse(url).hostname or "").lower()
197
+
198
+
199
+ def tokens(text: str) -> set[str]:
200
+ return set(re.findall(r"[a-zA-Z0-9][a-zA-Z0-9_-]{2,}", text.lower()))
201
+
202
+
203
+ def similarity(a: str, b: str) -> float:
204
+ x, y = tokens(a), tokens(b)
205
+ return len(x & y) / max(1, len(x | y))
206
+
207
+
208
+ def sha(value: str) -> str:
209
+ return hashlib.sha256(value.encode()).hexdigest()
210
+
211
+
212
+ def private_host(host: str) -> bool:
213
+ if not host or host.lower() in {"localhost", "localhost.localdomain"}:
214
+ return True
215
+ try:
216
+ addresses = socket.getaddrinfo(host, None)
217
+ except OSError:
218
+ return True
219
+ for info in addresses:
220
+ try:
221
+ ip = ipaddress.ip_address(info[4][0])
222
+ except ValueError:
223
+ return True
224
+ if (
225
+ ip.is_private or ip.is_loopback or ip.is_link_local
226
+ or ip.is_multicast or ip.is_reserved or ip.is_unspecified
227
+ ):
228
+ return True
229
+ return False
230
+
231
+
232
+ def safe_url(url: str) -> bool:
233
+ url = normalize_url(url)
234
+ if not url:
235
+ return False
236
+ p = urlparse(url)
237
+ return p.scheme in {"http", "https"} and not private_host(p.hostname or "")
238
+
239
+
240
+ # ============================================================
241
+ # CACHE
242
+ # ============================================================
243
+
244
+ class Cache:
245
+ def __init__(self, path: str):
246
+ self.path = path
247
+ with sqlite3.connect(self.path) as c:
248
+ c.execute(
249
+ "CREATE TABLE IF NOT EXISTS cache "
250
+ "(k TEXT PRIMARY KEY, v TEXT, t REAL)"
251
+ )
252
+
253
+ def get(self, key: str) -> Any:
254
+ try:
255
+ with sqlite3.connect(self.path) as c:
256
+ row = c.execute(
257
+ "SELECT v,t FROM cache WHERE k=?", (key,)
258
+ ).fetchone()
259
+ if not row:
260
+ return None
261
+ if time.time() - row[1] > CACHE_TTL:
262
+ return None
263
+ return json.loads(row[0])
264
+ except Exception:
265
+ return None
266
+
267
+ def set(self, key: str, value: Any):
268
+ try:
269
+ with sqlite3.connect(self.path) as c:
270
+ c.execute(
271
+ "INSERT OR REPLACE INTO cache(k,v,t) VALUES(?,?,?)",
272
+ (key, json.dumps(value, ensure_ascii=False), time.time()),
273
+ )
274
+ except Exception as exc:
275
+ log.debug("cache write failed: %s", exc)
276
+
277
+
278
+ CACHE = Cache(CACHE_DB)
279
+
280
+
281
+ # ============================================================
282
+ # MODEL PLANNING
283
+ # ============================================================
284
+
285
+ class SpaceModel:
286
+ def __init__(self, space: str):
287
+ self.space = space
288
+ self.client = None
289
+
290
+ def _client(self):
291
+ if self.client is None:
292
+ from gradio_client import Client
293
+ kwargs = {"hf_token": HF_TOKEN} if HF_TOKEN else {}
294
+ self.client = Client(self.space, **kwargs)
295
+ return self.client
296
+
297
+ async def generate(self, prompt: str) -> Optional[str]:
298
+ def run():
299
+ try:
300
+ result = self._client().predict(
301
+ prompt, 1200, 0.2, 0.9, api_name="/generate"
302
+ )
303
+ return result if isinstance(result, str) else str(result)
304
+ except Exception as exc:
305
+ log.warning("Space %s unavailable: %s", self.space, exc)
306
+ return None
307
+ return await asyncio.to_thread(run)
308
+
309
+
310
+ class Planner:
311
+ def __init__(self):
312
+ self.a = SpaceModel(M1_SPACE)
313
+ self.b = SpaceModel(M2_SPACE)
314
+
315
+ @staticmethod
316
+ def fallback(q: str) -> SearchPlan:
317
+ stop = {
318
+ "what","when","where","which","who","why","how","is","are",
319
+ "was","were","the","a","an","of","to","for","in","on","and",
320
+ "or","with","about","tell","me","please"
321
+ }
322
+ words = [x for x in re.findall(r"[a-zA-Z0-9_-]{3,}", q.lower())
323
+ if x not in stop]
324
+ words = list(dict.fromkeys(words))
325
+ current = any(x in q.lower() for x in
326
+ ("latest","today","current","recent","2026"))
327
+ return SearchPlan(
328
+ intent="current_factual" if current else "general",
329
+ topic=" ".join(words[:10]),
330
+ keywords=words[:20],
331
+ time_constraint="recent" if current else "auto",
332
+ source_types=["official","primary","academic","reputable"],
333
+ )
334
+
335
+ @staticmethod
336
+ def parse(text: Optional[str]) -> Optional[SearchPlan]:
337
+ if not text:
338
+ return None
339
+ m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S)
340
+ raw = m.group(1) if m else None
341
+ if raw is None:
342
+ a, b = text.find("{"), text.rfind("}")
343
+ if a >= 0 and b > a:
344
+ raw = text[a:b + 1]
345
+ if not raw:
346
+ return None
347
+ try:
348
+ return SearchPlan.model_validate(json.loads(raw))
349
+ except Exception:
350
+ return None
351
+
352
+ async def one(self, model: SpaceModel, question: str):
353
+ prompt = f"""
354
+ X-RUDRA search planner. Return JSON only.
355
+ Schema:
356
+ {{
357
+ "intent":"general|current_factual|research|comparison|explanatory",
358
+ "topic":"...",
359
+ "entities":[],
360
+ "keywords":[],
361
+ "queries":[],
362
+ "opposing_queries":[],
363
+ "time_constraint":null,
364
+ "source_types":[]
365
+ }}
366
+ Extract search concepts and generate independent queries.
367
+ Never answer the user question.
368
+ QUESTION: {question}
369
+ """
370
+ return self.parse(await model.generate(prompt))
371
+
372
+ async def plan(self, question: str, use_models=True) -> SearchPlan:
373
+ fallback = self.fallback(question)
374
+ if not use_models:
375
+ return fallback
376
+ a, b = await asyncio.gather(
377
+ self.one(self.a, question),
378
+ self.one(self.b, question),
379
+ )
380
+ plans = [x for x in (a, b) if x]
381
+ if not plans:
382
+ return fallback
383
+
384
+ merged = fallback.model_copy(deep=True)
385
+ merged.keywords = []
386
+ merged.queries = []
387
+ merged.entities = []
388
+ merged.opposing_queries = []
389
+ merged.source_types = []
390
+
391
+ for p in plans:
392
+ for field in ("keywords","queries","entities",
393
+ "opposing_queries","source_types"):
394
+ for x in getattr(p, field):
395
+ if x and x not in getattr(merged, field):
396
+ getattr(merged, field).append(x)
397
+ if p.topic:
398
+ merged.topic = p.topic
399
+ if p.time_constraint:
400
+ merged.time_constraint = p.time_constraint
401
+ if p.intent:
402
+ merged.intent = p.intent
403
+
404
+ return merged
405
+
406
+
407
+ # ============================================================
408
+ # QUERY GENERATION + DDG
409
+ # ============================================================
410
+
411
+ class QueryBuilder:
412
+ @staticmethod
413
+ def build(question: str, plan: SearchPlan) -> list[str]:
414
+ out = []
415
+
416
+ def add(x):
417
+ x = clean_text(x)
418
+ if x and x not in out:
419
+ out.append(x)
420
+
421
+ for q in plan.queries:
422
+ add(q)
423
+
424
+ core = plan.topic or " ".join(plan.keywords[:10])
425
+ add(core)
426
+ add(f"{core} latest")
427
+ add(f"{core} official")
428
+ add(f"{core} primary source")
429
+ add(f"{core} research")
430
+ add(f"{core} report")
431
+ add(f"{core} evidence")
432
+ add(f"{core} criticism")
433
+ add(f"{core} limitations")
434
+
435
+ if plan.time_constraint not in (None, "auto"):
436
+ add(f"{core} {plan.time_constraint}")
437
+
438
+ for q in plan.opposing_queries:
439
+ add(q)
440
+
441
+ return list(dict.fromkeys(out))[:30]
442
+
443
+
444
+ class DDG:
445
+ async def search(self, query: str, limit: int) -> list[SearchResult]:
446
+ key = "ddg:" + sha(f"{query}:{limit}")
447
+ cached = CACHE.get(key)
448
+ if cached:
449
+ return [SearchResult.model_validate(x) for x in cached]
450
+
451
+ def run():
452
+ out = []
453
+ try:
454
+ with DDGS(timeout=int(HTTP_TIMEOUT)) as d:
455
+ for i, item in enumerate(
456
+ d.text(query, max_results=limit), 1
457
+ ):
458
+ url = normalize_url(item.get("href", ""))
459
+ if url:
460
+ out.append(SearchResult(
461
+ title=clean_text(item.get("title", "")),
462
+ url=url,
463
+ snippet=clean_text(item.get("body", "")),
464
+ query=query,
465
+ rank=i,
466
+ ))
467
+ except Exception as exc:
468
+ log.warning("DDG failed: %s", exc)
469
+ return out
470
+
471
+ result = await asyncio.to_thread(run)
472
+ CACHE.set(key, [x.model_dump() for x in result])
473
+ return result
474
+
475
+
476
+ # ============================================================
477
+ # ROBOTS
478
+ # ============================================================
479
+
480
+ class Robots:
481
+ def __init__(self):
482
+ self.cache = {}
483
+
484
+ async def allowed(self, url: str) -> bool:
485
+ host = domain(url)
486
+ if not host:
487
+ return False
488
+ if host in self.cache:
489
+ return self.cache[host]
490
+
491
+ robots_url = f"{urlparse(url).scheme}://{host}/robots.txt"
492
+
493
+ def read():
494
+ rp = RobotFileParser()
495
+ rp.set_url(robots_url)
496
+ try:
497
+ rp.read()
498
+ return rp.can_fetch(USER_AGENT, url)
499
+ except Exception:
500
+ return True
501
+
502
+ value = await asyncio.to_thread(read)
503
+ self.cache[host] = value
504
+ return value
505
+
506
+
507
+ # ============================================================
508
+ # SCRAPLING STATIC FETCH
509
+ # ============================================================
510
+
511
+ class ScraplingStatic:
512
+ async def fetch(self, url: str) -> Optional[dict[str, Any]]:
513
+ if not SCRAPLING_AVAILABLE or not safe_url(url):
514
+ return None
515
+
516
+ def run():
517
+ try:
518
+ page = Fetcher.get(
519
+ url,
520
+ timeout=int(HTTP_TIMEOUT),
521
+ retries=2,
522
+ follow_redirects="safe",
523
+ stealthy_headers=True,
524
+ impersonate="chrome",
525
+ )
526
+ html = getattr(page, "html_content", None)
527
+ if callable(html):
528
+ html = html()
529
+ if html is None:
530
+ html = getattr(page, "text", "") or ""
531
+ status = getattr(page, "status", None)
532
+ final = getattr(page, "url", None) or url
533
+ return {
534
+ "html": html,
535
+ "status": status,
536
+ "final_url": final,
537
+ "method": "scrapling-http",
538
+ }
539
+ except Exception as exc:
540
+ log.debug("Scrapling static failed %s: %s", url, exc)
541
+ return None
542
+
543
+ return await asyncio.to_thread(run)
544
+
545
+
546
+ # ============================================================
547
+ # PLAYWRIGHT DIRECT BROWSER
548
+ # ============================================================
549
+
550
+ class PlaywrightBrowser:
551
+ def __init__(self):
552
+ self.playwright = None
553
+ self.browser = None
554
+ self.lock = asyncio.Lock()
555
+
556
+ async def start(self):
557
+ if not PLAYWRIGHT_AVAILABLE:
558
+ return
559
+ async with self.lock:
560
+ if self.browser:
561
+ return
562
+ self.playwright = await async_playwright().start()
563
+ self.browser = await self.playwright.chromium.launch(
564
+ headless=True,
565
+ args=[
566
+ "--disable-dev-shm-usage",
567
+ "--no-first-run",
568
+ "--no-default-browser-check",
569
+ ],
570
+ )
571
+
572
+ async def close(self):
573
+ if self.browser:
574
+ await self.browser.close()
575
+ self.browser = None
576
+ if self.playwright:
577
+ await self.playwright.stop()
578
+ self.playwright = None
579
+
580
+ async def fetch(self, url: str) -> Optional[dict[str, Any]]:
581
+ if not PLAYWRIGHT_AVAILABLE or not safe_url(url):
582
+ return None
583
+
584
+ await self.start()
585
+ if not self.browser:
586
+ return None
587
+
588
+ context = await self.browser.new_context(
589
+ user_agent=USER_AGENT,
590
+ java_script_enabled=True,
591
+ ignore_https_errors=False,
592
+ )
593
+ page = await context.new_page()
594
+
595
+ try:
596
+ response = await page.goto(
597
+ url,
598
+ wait_until="domcontentloaded",
599
+ timeout=BROWSER_TIMEOUT_MS,
600
+ )
601
+ await page.wait_for_timeout(500)
602
+ html = await page.content()
603
+ final = page.url
604
+
605
+ if not safe_url(final):
606
+ return None
607
+
608
+ return {
609
+ "html": html[:MAX_BODY],
610
+ "status": response.status if response else None,
611
+ "final_url": final,
612
+ "method": "playwright",
613
+ }
614
+ except Exception as exc:
615
+ log.debug("Playwright failed %s: %s", url, exc)
616
+ return None
617
+ finally:
618
+ await page.close()
619
+ await context.close()
620
+
621
+
622
+ # ============================================================
623
+ # SCRAPLING DYNAMIC / STEALTH BROWSER
624
+ # ============================================================
625
+
626
+ class ScraplingBrowser:
627
+ async def dynamic(self, url: str) -> Optional[dict[str, Any]]:
628
+ if not SCRAPLING_AVAILABLE or not safe_url(url):
629
+ return None
630
+
631
+ def run():
632
+ try:
633
+ page = DynamicFetcher.fetch(
634
+ url,
635
+ headless=True,
636
+ timeout=BROWSER_TIMEOUT_MS,
637
+ disable_resources=True,
638
+ block_ads=True,
639
+ network_idle=False,
640
+ load_dom=True,
641
+ )
642
+ html = getattr(page, "html_content", "") or ""
643
+ return {
644
+ "html": html[:MAX_BODY],
645
+ "status": getattr(page, "status", None),
646
+ "final_url": getattr(page, "url", None) or url,
647
+ "method": "scrapling-dynamic",
648
+ }
649
+ except Exception as exc:
650
+ log.debug("Scrapling Dynamic failed %s: %s", url, exc)
651
+ return None
652
+
653
+ return await asyncio.to_thread(run)
654
+
655
+ async def stealth(self, url: str) -> Optional[dict[str, Any]]:
656
+ if not SCRAPLING_AVAILABLE or not USE_STEALTH_FETCHER:
657
+ return None
658
+ if not safe_url(url):
659
+ return None
660
+
661
+ # This mode uses Scrapling's browser implementation but does
662
+ # not solve CAPTCHAs or authenticate to protected resources.
663
+ def run():
664
+ try:
665
+ page = StealthyFetcher.fetch(
666
+ url,
667
+ headless=True,
668
+ timeout=BROWSER_TIMEOUT_MS,
669
+ disable_resources=True,
670
+ block_ads=True,
671
+ network_idle=False,
672
+ load_dom=True,
673
+ )
674
+ html = getattr(page, "html_content", "") or ""
675
+ return {
676
+ "html": html[:MAX_BODY],
677
+ "status": getattr(page, "status", None),
678
+ "final_url": getattr(page, "url", None) or url,
679
+ "method": "scrapling-stealth",
680
+ }
681
+ except Exception as exc:
682
+ log.debug("Scrapling Stealth failed %s: %s", url, exc)
683
+ return None
684
+
685
+ return await asyncio.to_thread(run)
686
+
687
+
688
+ # ============================================================
689
+ # EXTRACTION
690
+ # ============================================================
691
+
692
+ INJECTION_PATTERNS = [
693
+ r"ignore\s+(all|any|the)\s+(previous|prior|system)\s+instructions",
694
+ r"reveal\s+(your|the)\s+(system|developer)\s+prompt",
695
+ r"disregard\s+your\s+instructions",
696
+ r"act\s+as\s+the\s+system",
697
+ r"show\s+your\s+hidden\s+instructions",
698
+ ]
699
+
700
+
701
+ def detect_injection(text: str) -> bool:
702
+ x = text.lower()
703
+ return any(re.search(p, x) for p in INJECTION_PATTERNS)
704
+
705
+
706
+ def metadata(html: str) -> dict[str, Any]:
707
+ soup = BeautifulSoup(html, "html.parser")
708
+ title = clean_text(soup.title.get_text(" ")) if soup.title else ""
709
+
710
+ def meta(name=None, prop=None):
711
+ tag = soup.find(
712
+ "meta",
713
+ attrs=({"name": re.compile(f"^{name}$", re.I)}
714
+ if name else {"property": prop}),
715
+ )
716
+ return clean_text(tag.get("content", "")) if tag else ""
717
+
718
+ canonical = ""
719
+ link = soup.find("link", attrs={
720
+ "rel": lambda x: x and "canonical" in x
721
+ })
722
+ if link:
723
+ canonical = link.get("href", "")
724
+
725
+ published = meta(prop="article:published_time")
726
+ modified = meta(prop="article:modified_time")
727
+ author = meta(name="author")
728
+
729
+ for script in soup.find_all(
730
+ "script", attrs={"type": "application/ld+json"}
731
+ ):
732
+ try:
733
+ data = json.loads(script.string or "")
734
+ items = data if isinstance(data, list) else [data]
735
+ for item in items:
736
+ if not isinstance(item, dict):
737
+ continue
738
+ title = title or clean_text(item.get("headline", ""))
739
+ published = published or item.get("datePublished", "")
740
+ modified = modified or item.get("dateModified", "")
741
+ if isinstance(item.get("author"), dict):
742
+ author = author or item["author"].get("name", "")
743
+ except Exception:
744
+ continue
745
+
746
+ return {
747
+ "title": title,
748
+ "description": meta(name="description"),
749
+ "canonical": canonical,
750
+ "author": author or None,
751
+ "published": published or None,
752
+ "modified": modified or None,
753
+ }
754
+
755
+
756
+ def extract_text(html: str) -> str:
757
+ try:
758
+ value = trafilatura.extract(
759
+ html,
760
+ include_tables=True,
761
+ include_links=False,
762
+ include_images=False,
763
+ favor_precision=True,
764
+ )
765
+ if value:
766
+ return clean_text(value)
767
+ except Exception:
768
+ pass
769
+
770
+ soup = BeautifulSoup(html, "html.parser")
771
+ for tag in soup([
772
+ "script","style","noscript","svg","canvas",
773
+ "iframe","nav","footer","form"
774
+ ]):
775
+ tag.decompose()
776
+ return clean_text(soup.get_text(" ", strip=True))
777
+
778
+
779
+ def passages(question: str, text: str, limit=8):
780
+ sentences = re.split(r"(?<=[.!?])\s+", clean_text(text))
781
+ chunks, current = [], ""
782
+
783
+ for sentence in sentences:
784
+ if len(current) + len(sentence) < 1800:
785
+ current = f"{current} {sentence}".strip()
786
+ else:
787
+ if current:
788
+ chunks.append(current)
789
+ current = sentence
790
+ if current:
791
+ chunks.append(current)
792
+
793
+ scored = []
794
+ for chunk in chunks:
795
+ scored.append({
796
+ "text": chunk,
797
+ "relevance": similarity(question, chunk),
798
+ })
799
+
800
+ return sorted(
801
+ scored, key=lambda x: x["relevance"], reverse=True
802
+ )[:limit]
803
+
804
+
805
+ # ============================================================
806
+ # SOURCE SCORING
807
+ # ============================================================
808
+
809
+ def authority(host: str) -> float:
810
+ h = host.lower()
811
+ if h.endswith(".gov") or ".gov." in h:
812
+ return 1.0
813
+ if h.endswith(".edu") or ".edu." in h:
814
+ return 0.95
815
+ if h.endswith(".org"):
816
+ return 0.72
817
+
818
+ strong = {
819
+ "unesco.org","un.org","who.int","worldbank.org",
820
+ "oecd.org","nasa.gov","nih.gov","reuters.com",
821
+ "apnews.com","bbc.com"
822
+ }
823
+ return 0.95 if any(
824
+ h == x or h.endswith("." + x) for x in strong
825
+ ) else 0.45
826
+
827
+
828
+ def freshness(value: Optional[str], mode: str) -> float:
829
+ if not value:
830
+ return 0.5
831
+ try:
832
+ from dateutil import parser
833
+ dt = parser.parse(value)
834
+ if not dt.tzinfo:
835
+ dt = dt.replace(tzinfo=timezone.utc)
836
+ age = max(
837
+ 0,
838
+ (datetime.now(timezone.utc) - dt.astimezone(timezone.utc))
839
+ .total_seconds() / 86400,
840
+ )
841
+ if mode in {"latest","recent","current"}:
842
+ return max(0, 1 - age / 365)
843
+ return 0.7
844
+ except Exception:
845
+ return 0.5
846
+
847
+
848
+ def source_score(host, rel, fresh):
849
+ return round(
850
+ authority(host) * 0.35
851
+ + rel * 0.40
852
+ + fresh * 0.25,
853
+ 4,
854
+ )
855
+
856
+
857
+ # ============================================================
858
+ # MULTI-STRATEGY FETCH ORCHESTRATOR
859
+ # ============================================================
860
+
861
+ class FetchOrchestrator:
862
+ def __init__(self):
863
+ self.static = ScraplingStatic()
864
+ self.dynamic = ScraplingBrowser()
865
+ self.playwright = PlaywrightBrowser()
866
+ self.robots = Robots()
867
+ self.sem = asyncio.Semaphore(MAX_CONCURRENT)
868
+
869
+ async def fetch(self, url: str, browser_hint=False):
870
+ if not safe_url(url):
871
+ return None, "unsafe_url"
872
+
873
+ if not await self.robots.allowed(url):
874
+ return None, "robots_disallowed"
875
+
876
+ # 1. Fast Scrapling HTTP.
877
+ result = await self.static.fetch(url)
878
+ if result and result.get("html"):
879
+ return result, "scrapling-http"
880
+
881
+ if BROWSER_MODE == "never":
882
+ return None, "static_failed"
883
+
884
+ # 2. Scrapling dynamic browser.
885
+ if browser_hint or BROWSER_MODE == "browser":
886
+ result = await self.dynamic.dynamic(url)
887
+ if result and result.get("html"):
888
+ return result, "scrapling-dynamic"
889
+
890
+ # 3. Optional stealth browser as a renderer.
891
+ if browser_hint and USE_STEALTH_FETCHER:
892
+ result = await self.dynamic.stealth(url)
893
+ if result and result.get("html"):
894
+ return result, "scrapling-stealth"
895
+
896
+ # 4. Direct Playwright fallback.
897
+ if PLAYWRIGHT_AVAILABLE:
898
+ result = await self.playwright.fetch(url)
899
+ if result and result.get("html"):
900
+ return result, "playwright"
901
+
902
+ return None, "all_fetchers_failed"
903
+
904
+ async def close(self):
905
+ await self.playwright.close()
906
+
907
+
908
+ # ============================================================
909
+ # CLAIM / EVIDENCE
910
+ # ============================================================
911
+
912
+ class EvidenceEngine:
913
+ @staticmethod
914
+ def claims(question, sources):
915
+ claims = []
916
+ for source in sources:
917
+ for p in source.passages:
918
+ rel = float(p.get("relevance", 0))
919
+ if rel >= 0.18 and len(p["text"]) >= 50:
920
+ claims.append(
921
+ Claim(
922
+ claim=p["text"][:1500],
923
+ source_url=source.url,
924
+ passage=p["text"][:2000],
925
+ support_score=round(
926
+ rel * source.source_score, 4
927
+ ),
928
+ )
929
+ )
930
+ return claims
931
+
932
+ @staticmethod
933
+ def dedupe(claims):
934
+ out = []
935
+ for c in claims:
936
+ if not any(
937
+ similarity(c.claim, x.claim) >= 0.90
938
+ for x in out
939
+ ):
940
+ out.append(c)
941
+ return out
942
+
943
+ @staticmethod
944
+ def contradictions(claims):
945
+ result = []
946
+ for i, a in enumerate(claims):
947
+ for b in claims[i + 1:]:
948
+ if a.source_url == b.source_url:
949
+ continue
950
+ sim = similarity(a.claim, b.claim)
951
+ if sim < 0.45:
952
+ continue
953
+
954
+ na = bool(re.search(
955
+ r"\b(not|no|never|false|denied|did not)\b",
956
+ a.claim.lower(),
957
+ ))
958
+ nb = bool(re.search(
959
+ r"\b(not|no|never|false|denied|did not)\b",
960
+ b.claim.lower(),
961
+ ))
962
+
963
+ if na != nb:
964
+ result.append({
965
+ "claim_a": a.claim,
966
+ "source_a": a.source_url,
967
+ "claim_b": b.claim,
968
+ "source_b": b.source_url,
969
+ "status": "needs_review",
970
+ })
971
+ return result[:20]
972
+
973
+
974
+ # ============================================================
975
+ # MAIN ENGINE
976
+ # ============================================================
977
+
978
+ class XrudraWebSearch:
979
+ def __init__(self):
980
+ self.planner = Planner()
981
+ self.ddg = DDG()
982
+ self.fetcher = FetchOrchestrator()
983
+
984
+ async def search(
985
+ self,
986
+ question: str,
987
+ max_results=MAX_RESULTS,
988
+ max_rounds=MAX_ROUNDS,
989
+ use_models=True,
990
+ freshness_mode="auto",
991
+ ):
992
+ plan = await self.planner.plan(question, use_models)
993
+ queries = QueryBuilder.build(question, plan)
994
+
995
+ all_results = []
996
+ seen = set()
997
+ all_sources = []
998
+ failures = []
999
+
1000
+ for round_no in range(1, max_rounds + 1):
1001
+ if round_no > 1:
1002
+ followups = [
1003
+ f"{plan.topic} primary source",
1004
+ f"{plan.topic} official evidence",
1005
+ f"{plan.topic} independent evidence",
1006
+ f"{plan.topic} criticism",
1007
+ ]
1008
+ queries.extend(q for q in followups if q not in queries)
1009
+
1010
+ round_queries = queries[
1011
+ (round_no - 1) * 8: round_no * 8
1012
+ ]
1013
+
1014
+ if not round_queries:
1015
+ break
1016
+
1017
+ batches = await asyncio.gather(
1018
+ *[
1019
+ self.ddg.search(q, max_results)
1020
+ for q in round_queries
1021
+ ],
1022
+ return_exceptions=True,
1023
+ )
1024
+
1025
+ for batch in batches:
1026
+ if isinstance(batch, Exception):
1027
+ continue
1028
+ for r in batch:
1029
+ r.url = normalize_url(r.url)
1030
+ if r.url and r.url not in seen:
1031
+ seen.add(r.url)
1032
+ all_results.append(r)
1033
+
1034
+ # Search results with dynamic-looking signals get browser
1035
+ # priority; normal pages use fast HTTP first.
1036
+ ranked = sorted(
1037
+ all_results,
1038
+ key=lambda r: similarity(
1039
+ question,
1040
+ r.title + " " + r.snippet
1041
+ ),
1042
+ reverse=True,
1043
+ )
1044
+
1045
+ targets = ranked[:max_results]
1046
+ jobs = [
1047
+ self._fetch_one(
1048
+ r,
1049
+ question,
1050
+ plan,
1051
+ freshness_mode,
1052
+ )
1053
+ for r in targets
1054
+ ]
1055
+
1056
+ fetched = await asyncio.gather(
1057
+ *jobs,
1058
+ return_exceptions=True,
1059
+ )
1060
+
1061
+ for item in fetched:
1062
+ if isinstance(item, Exception):
1063
+ continue
1064
+ source, failure = item
1065
+ if source:
1066
+ if not any(
1067
+ x.final_url == source.final_url
1068
+ for x in all_sources
1069
+ ):
1070
+ all_sources.append(source)
1071
+ elif failure:
1072
+ failures.append(failure)
1073
+
1074
+ # Enough independent evidence -> stop.
1075
+ strong = [
1076
+ s for s in all_sources
1077
+ if s.source_score >= 0.45
1078
+ and s.relevance_score >= 0.18
1079
+ ]
1080
+ domains = {s.domain for s in strong}
1081
+
1082
+ if len(strong) >= 3 and len(domains) >= 2:
1083
+ return await self._report(
1084
+ question, plan, queries, all_results,
1085
+ all_sources, failures, round_no,
1086
+ "sufficient_independent_evidence",
1087
+ )
1088
+
1089
+ return await self._report(
1090
+ question, plan, queries, all_results,
1091
+ all_sources, failures, max_rounds,
1092
+ "max_rounds_reached",
1093
+ )
1094
+
1095
+ async def _fetch_one(
1096
+ self, result, question, plan, freshness_mode
1097
+ ):
1098
+ browser_hint = any(
1099
+ x in result.snippet.lower()
1100
+ for x in (
1101
+ "javascript", "dynamic", "interactive",
1102
+ "app", "dashboard"
1103
+ )
1104
+ )
1105
+
1106
+ try:
1107
+ async with self.fetcher.sem:
1108
+ payload, method = await self.fetcher.fetch(
1109
+ result.url,
1110
+ browser_hint=browser_hint,
1111
+ )
1112
+
1113
+ if not payload:
1114
+ return None, {
1115
+ "url": result.url,
1116
+ "error": method,
1117
+ }
1118
+
1119
+ final_url = normalize_url(
1120
+ payload.get("final_url") or result.url
1121
+ )
1122
+ if not safe_url(final_url):
1123
+ return None, {
1124
+ "url": result.url,
1125
+ "error": "unsafe_final_redirect",
1126
+ }
1127
+
1128
+ html = payload.get("html", "")
1129
+ if not html:
1130
+ return None, {
1131
+ "url": result.url,
1132
+ "error": "empty_html",
1133
+ }
1134
+
1135
+ meta = metadata(html)
1136
+ text = extract_text(html)
1137
+ if not text:
1138
+ return None, {
1139
+ "url": result.url,
1140
+ "error": "empty_text",
1141
+ }
1142
+
1143
+ p = passages(question, text)
1144
+ rel = max(
1145
+ [x["relevance"] for x in p],
1146
+ default=0.0,
1147
+ )
1148
+ fresh = freshness(
1149
+ meta.get("published") or meta.get("modified"),
1150
+ plan.time_constraint
1151
+ if plan.time_constraint not in (None, "auto")
1152
+ else freshness_mode,
1153
+ )
1154
+
1155
+ src = PageDocument(
1156
+ url=result.url,
1157
+ final_url=final_url,
1158
+ domain=domain(final_url),
1159
+ title=meta["title"] or result.title,
1160
+ description=meta["description"],
1161
+ author=meta["author"],
1162
+ published_at=meta["published"],
1163
+ modified_at=meta["modified"],
1164
+ text=text[:100000],
1165
+ passages=p,
1166
+ source_score=source_score(
1167
+ domain(final_url), rel, fresh
1168
+ ),
1169
+ freshness_score=fresh,
1170
+ relevance_score=rel,
1171
+ fetch_method=method,
1172
+ status_code=payload.get("status"),
1173
+ prompt_injection_detected=detect_injection(
1174
+ text[:100000]
1175
+ ),
1176
+ )
1177
+ return src, None
1178
+
1179
+ except Exception as exc:
1180
+ return None, {
1181
+ "url": result.url,
1182
+ "error": f"{type(exc).__name__}:{exc}",
1183
+ }
1184
+
1185
+ async def _report(
1186
+ self, question, plan, queries, results,
1187
+ sources, failures, rounds, reason
1188
+ ):
1189
+ # Source diversity: maximum two per domain.
1190
+ selected = []
1191
+ counts = Counter()
1192
+
1193
+ for s in sorted(
1194
+ sources,
1195
+ key=lambda x: x.source_score,
1196
+ reverse=True,
1197
+ ):
1198
+ if counts[s.domain] >= 2:
1199
+ continue
1200
+ counts[s.domain] += 1
1201
+ selected.append(s)
1202
+ if len(selected) >= MAX_RESULTS:
1203
+ break
1204
+
1205
+ claims = EvidenceEngine.dedupe(
1206
+ EvidenceEngine.claims(question, selected)
1207
+ )
1208
+ contradictions = EvidenceEngine.contradictions(claims)
1209
+
1210
+ return SearchReport(
1211
+ status="success",
1212
+ question=question,
1213
+ plan=plan,
1214
+ queries=list(dict.fromkeys(queries)),
1215
+ results=results[:MAX_RESULTS * 3],
1216
+ sources=selected,
1217
+ claims=claims[:100],
1218
+ contradictions=contradictions,
1219
+ failures=failures[:100],
1220
+ rounds=rounds,
1221
+ stopping_reason=reason,
1222
+ )
1223
+
1224
+
1225
+ # ============================================================
1226
+ # FASTAPI
1227
+ # ============================================================
1228
+
1229
+ app = FastAPI(
1230
+ title="X-RUDRA Web Search v2",
1231
+ version="2.0",
1232
+ )
1233
+
1234
+
1235
+ ENGINE = XrudraWebSearch()
1236
+
1237
+
1238
+ @app.get("/health")
1239
+ async def health():
1240
+ return {
1241
+ "status": "ok",
1242
+ "scrapling": SCRAPLING_AVAILABLE,
1243
+ "playwright": PLAYWRIGHT_AVAILABLE,
1244
+ "m1": M1_SPACE,
1245
+ "m2": M2_SPACE,
1246
+ "browser_mode": BROWSER_MODE,
1247
+ }
1248
+
1249
+
1250
+ @app.post("/v2/web-search", response_model=SearchReport)
1251
+ async def web_search(req: SearchRequest):
1252
+ try:
1253
+ return await ENGINE.search(
1254
+ question=req.question,
1255
+ max_results=req.max_results,
1256
+ max_rounds=req.max_rounds,
1257
+ use_models=req.use_models,
1258
+ freshness_mode=req.freshness,
1259
+ )
1260
+ except Exception as exc:
1261
+ log.exception("web search failed")
1262
+ raise HTTPException(
1263
+ status_code=500,
1264
+ detail="web search failed",
1265
+ ) from exc
1266
+
1267
+
1268
+ @app.on_event("shutdown")
1269
+ async def shutdown():
1270
+ await ENGINE.fetcher.close()
1271
+
1272
+
1273
+ # ============================================================
1274
+ # CLI
1275
+ # ============================================================
1276
+
1277
+ async def main():
1278
+ import argparse
1279
+
1280
+ p = argparse.ArgumentParser()
1281
+ p.add_argument("question")
1282
+ p.add_argument("--results", type=int, default=10)
1283
+ p.add_argument("--rounds", type=int, default=3)
1284
+ p.add_argument("--no-models", action="store_true")
1285
+ args = p.parse_args()
1286
+
1287
+ report = await ENGINE.search(
1288
+ args.question,
1289
+ max_results=args.results,
1290
+ max_rounds=args.rounds,
1291
+ use_models=not args.no_models,
1292
+ )
1293
+ print(json.dumps(
1294
+ report.model_dump(),
1295
+ ensure_ascii=False,
1296
+ indent=2,
1297
+ ))
1298
+
1299
+
1300
+ if __name__ == "__main__":
1301
+ asyncio.run(main())