| """Client for an optional local corpus mirror (SQLite FTS5). |
| |
| Why this exists: the public corpus API is heavily rate limited, and this |
| mirror is the only fully self-hosted path through the pipeline. The mirror |
| is the same underlying Semantic Scholar data, pre-filtered to the |
| benchmark's snapshot date, so it is date-legal by construction and answers |
| reliably. |
| |
| Measured on the 46 gold-bearing validation queries: ONE OR query reaches |
| 37.3% pool recall, against 43.2% for IRIS's ~168-remote-call fanout. The |
| match is deliberately WIDE -- narrowing it to an AND of the rarest terms |
| collapsed recall to 1.6%, because BM25 ranking is what discriminates and a |
| narrow match just deletes candidates before the ranker sees them. |
| |
| Transport: HTTP to an SSH-tunnelled loopback port. Every failure mode is |
| soft: no service, timeout, or bad payload returns [] so callers fall back to |
| the remote corpus rather than losing the query. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import os |
| import urllib.error |
| import urllib.parse |
| import urllib.request |
| from types import SimpleNamespace |
|
|
| DEFAULT_URL = "http://127.0.0.1:8899" |
| TIMEOUT_S = 120.0 |
|
|
|
|
| def _url() -> str: |
| return os.environ.get("PFBMAX_LOCAL_SEARCH_URL", DEFAULT_URL).rstrip("/") |
|
|
|
|
| def available(timeout: float = 5.0) -> bool: |
| try: |
| with urllib.request.urlopen(_url() + "/health", timeout=timeout) as r: |
| return bool(json.loads(r.read()).get("ok")) |
| except Exception: |
| return False |
|
|
|
|
| def _to_paper(d: dict) -> SimpleNamespace: |
| """Shape a row like an AstaClient Paper (duck-typed by every consumer).""" |
| return SimpleNamespace( |
| corpusId=str(d.get("corpusId")), |
| corpus_id=str(d.get("corpusId")), |
| title=d.get("title") or "", |
| abstract=d.get("abstract") or "", |
| year=d.get("year"), |
| venue=d.get("venue") or "", |
| authors=d.get("authors") or [], |
| citationCount=d.get("citationCount") or 0, |
| publicationDate=d.get("publicationDate"), |
| text=d.get("abstract") or "", |
| score=None, |
| extra={"citationCount": d.get("citationCount") or 0}, |
| ) |
|
|
|
|
| def _get(path: str, params: dict, timeout: float) -> list[dict]: |
| url = _url() + path + "?" + urllib.parse.urlencode(params) |
| try: |
| with urllib.request.urlopen(url, timeout=timeout) as r: |
| return json.loads(r.read()).get("data") or [] |
| except (urllib.error.URLError, OSError, ValueError, TimeoutError): |
| return [] |
|
|
|
|
| def search(query: str, limit: int = 1000, |
| timeout: float = TIMEOUT_S) -> list[SimpleNamespace]: |
| """BM25 search over title+abstract; [] on any failure.""" |
| if not (query or "").strip(): |
| return [] |
| return [_to_paper(d) for d in _get("/search", {"q": query, "limit": limit}, timeout)] |
|
|
|
|
| def batch(corpus_ids, timeout: float = TIMEOUT_S) -> list[SimpleNamespace]: |
| ids = [str(c) for c in (corpus_ids or []) if str(c).strip()][:500] |
| if not ids: |
| return [] |
| return [_to_paper(d) for d in _get("/batch", {"cids": ",".join(ids)}, timeout)] |
|
|