Respite-API / searx_bridge.py
cazyundee's picture
sync: from ael backend/space/
d409fec
Raw History Blame Contribute Delete
4.5 kB
"""SearXNG bridge β€” runs a SearXNG metasearch instance inside the Space
container (userspace venv, no root) and proxies queries to it.
Mounted under /respite/v2/searx/*:
/status β†’ bootstrap state of the runtime, and of the reranker
/search β†’ proxy ?q=... queries to SearXNG's JSON API
/rerank β†’ score/reorder/drop a result set for a query (see rerank.py)
Engine selection comes from searxng_settings.yml (DDG + Google enabled);
the frontend picks engines per request via the standard `engines` param.
/rerank lives under the same prefix as /search on purpose: the Space's key gate
guards /respite/v2/searx/*, so the reranker inherits that authentication rather
than needing a second one to be got right.
"""
import asyncio
import json
import httpx
from fastapi import Request
from fastapi.responses import JSONResponse
import rerank
import searx_runtime
# Load the reranker's checkpoint as soon as the container boots, not on the
# first search request. The first request has a ~1s deadline and the load takes
# minutes, so without this the reranker can never apply on a cold container and
# the only symptom is a timeout. A daemon thread, so a failure or a slow mirror
# cannot hold the Space's startup hostage.
rerank.warm()
def register_routes(fa_app):
@fa_app.get("/respite/v2/searx/status")
def _status():
return JSONResponse({
"runtime_dir": searx_runtime.RUNTIME_DIR,
"installed": searx_runtime.searxng_ready(),
"boot_error": searx_runtime.boot_error(),
"port": searx_runtime.SEARXNG_PORT,
"reranker": rerank.status(), })
@fa_app.get("/respite/v2/searx/search")
async def _search(request: Request):
if not searx_runtime.searxng_ready():
ok = searx_runtime.start_searxng()
if not ok:
return JSONResponse({"error": "searxng not running"}, status_code=503)
params = dict(request.query_params)
# SearXNG's JSON API lives at /search with format=json
params.setdefault("format", "json")
url = f"http://127.0.0.1:{searx_runtime.SEARXNG_PORT}/search"
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=5.0)) as client:
r = await client.get(url, params=params)
return JSONResponse(r.json(), status_code=r.status_code)
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=502)
@fa_app.post("/respite/v2/searx/rerank")
async def _rerank(request: Request):
"""Rerank a result set. Always answers 200 with the results in some
order β€” the client is a search path, and an exception here would look
like an outage to whoever is trying to read the page."""
try:
body = await request.body()
payload = json.loads(body or b"{}")
except Exception as e:
return JSONResponse({"ok": False, "error": f"bad request: {e}", "results": []})
q = str(payload.get("q") or "").strip()
results = payload.get("results")
if not q or not isinstance(results, list) or not results:
return JSONResponse({"ok": False, "error": "q and results are required", "results": results or []})
budget_ms = int(payload.get("budget_ms") or 800)
# rerank() is synchronous and CPU-bound (torch), so it must not run on
# the event loop β€” that would stall every other Space request, audio
# generation included, for as long as the model takes.
try:
out = await asyncio.to_thread(rerank.rerank, q, results, budget_ms)
except Exception as e: # noqa: BLE001
# `no_opinion` as well as `error`: the client reports the former, so
# a crash that never reached rerank() must not read as "the model
# had nothing to say about your query".
return JSONResponse(
{
"ok": False,
"error": f"rerank failed: {e}",
"no_opinion": f"{type(e).__name__}: {e}"[:300],
"results": results,
}
)
return JSONResponse(out)
@fa_app.get("/respite/v2/searx/rerank/recent")
def _rerank_recent(limit: int = 20):
"""Recent relevance scores, for choosing DROP_BELOW from real traffic
rather than from a guess."""
return JSONResponse({"scores": rerank.recent_scores(limit)})