File size: 4,500 Bytes
42f6abb
 
 
 
184d40c
42f6abb
184d40c
42f6abb
 
 
184d40c
 
 
 
42f6abb
 
184d40c
 
 
42f6abb
 
 
 
184d40c
42f6abb
 
d409fec
 
 
 
 
 
 
42f6abb
 
 
 
 
 
 
79019c5
42f6abb
d409fec
42f6abb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184d40c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
558fffa
 
 
 
 
 
 
 
 
 
 
184d40c
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
"""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)})