feat(suggest): Typesense rows for autosuggest, no DataFrame
search_schools_typesense returns URNs, which forces the caller to hydrate from the 25,000-row in-memory frame. Every field a suggestion needs is already in the Typesense document, so this returns documents and the caller needs no pandas at all — the difference between a query that can run per keystroke and one that cannot. Never raises. Typesense unreachable or erroring gives an empty list, because a dropdown that quietly stops appearing is the right failure for a keystroke path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
This commit is contained in:
1 parent
ff041544f2
commit
75d3534d82
2 files changed
+111
No files matched your search
@@ -100,6 +100,46 @@ def search_schools_typesense(query: str, limit: int = 250) -> List[int]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# The most a public endpoint will return in one response.
|
||||||
|
SUGGEST_MAX_LIMIT = 20
|
||||||
|
|
||||||
|
# Fields a suggestion row carries, and the default when the document omits an
|
||||||
|
# optional one. phase and school_type are optional in the Typesense schema.
|
||||||
|
_SUGGEST_FIELDS = ("school_name", "local_authority", "postcode",
|
||||||
|
"phase", "school_type")
|
||||||
|
|
||||||
|
|
||||||
|
def suggest_schools_typesense(query: str, limit: int = 8) -> List[dict]:
|
||||||
|
"""Autosuggest rows straight from Typesense. Never raises.
|
||||||
|
|
||||||
|
Returns documents rather than URNs, unlike search_schools_typesense, so the
|
||||||
|
caller needs no DataFrame. Every field below is already in the index — see
|
||||||
|
pipeline/scripts/sync_typesense.py — which is what makes this cheap enough
|
||||||
|
to run per keystroke.
|
||||||
|
"""
|
||||||
|
client = _get_typesense_client()
|
||||||
|
if client is None:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
result = client.collections["schools"].documents.search({
|
||||||
|
"q": query,
|
||||||
|
"query_by": "school_name,local_authority",
|
||||||
|
"per_page": max(1, min(limit, SUGGEST_MAX_LIMIT)),
|
||||||
|
"typo_tokens_threshold": 1,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
# A dropdown that quietly stops appearing is the right failure here.
|
||||||
|
return []
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for hit in result.get("hits", []):
|
||||||
|
doc = hit.get("document", {})
|
||||||
|
row = {"urn": int(doc.get("urn", 0))}
|
||||||
|
row.update({f: str(doc.get(f, "") or "") for f in _SUGGEST_FIELDS})
|
||||||
|
rows.append(row)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def normalize_school_type(school_type: Optional[str]) -> Optional[str]:
|
def normalize_school_type(school_type: Optional[str]) -> Optional[str]:
|
||||||
"""Convert cryptic school type codes to user-friendly names."""
|
"""Convert cryptic school type codes to user-friendly names."""
|
||||||
if not school_type:
|
if not school_type:
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Tests for school autosuggest (spec 2026-08-26)."""
|
||||||
|
|
||||||
|
from backend import data_loader
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDocs:
|
||||||
|
def __init__(self, hits, explode=False):
|
||||||
|
self._hits = hits
|
||||||
|
self._explode = explode
|
||||||
|
self.last_params = None
|
||||||
|
|
||||||
|
def search(self, params):
|
||||||
|
self.last_params = params
|
||||||
|
if self._explode:
|
||||||
|
raise RuntimeError("typesense is down")
|
||||||
|
return {"hits": [{"document": d} for d in self._hits]}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
def __init__(self, hits, explode=False):
|
||||||
|
self.docs = _FakeDocs(hits, explode)
|
||||||
|
self.collections = {"schools": type("C", (), {"documents": self.docs})()}
|
||||||
|
|
||||||
|
|
||||||
|
_HIT = {
|
||||||
|
"urn": 100010, "school_name": "Brecknock Primary School",
|
||||||
|
"local_authority": "Camden", "postcode": "NW1 1AA",
|
||||||
|
"phase": "Primary", "school_type": "Community school",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _use(monkeypatch, client):
|
||||||
|
monkeypatch.setattr(data_loader, "_get_typesense_client", lambda: client)
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_the_fields_a_suggestion_needs(monkeypatch):
|
||||||
|
# Local authority is not decoration: there are many schools called
|
||||||
|
# "St Mary's", and a list without it cannot be chosen between.
|
||||||
|
_use(monkeypatch, _FakeClient([_HIT]))
|
||||||
|
out = data_loader.suggest_schools_typesense("breck")
|
||||||
|
assert out == [{
|
||||||
|
"urn": 100010, "school_name": "Brecknock Primary School",
|
||||||
|
"local_authority": "Camden", "postcode": "NW1 1AA",
|
||||||
|
"phase": "Primary", "school_type": "Community school",
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_optional_field_becomes_an_empty_string(monkeypatch):
|
||||||
|
# phase and school_type are optional in the Typesense schema. A missing
|
||||||
|
# key must not KeyError in the keystroke path.
|
||||||
|
_use(monkeypatch, _FakeClient([{"urn": 1, "school_name": "X",
|
||||||
|
"local_authority": "Y", "postcode": "Z"}]))
|
||||||
|
out = data_loader.suggest_schools_typesense("x")
|
||||||
|
assert out[0]["phase"] == "" and out[0]["school_type"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_typesense_unavailable_gives_no_suggestions_rather_than_raising(monkeypatch):
|
||||||
|
_use(monkeypatch, None)
|
||||||
|
assert data_loader.suggest_schools_typesense("anything") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_typesense_error_gives_no_suggestions_rather_than_raising(monkeypatch):
|
||||||
|
_use(monkeypatch, _FakeClient([], explode=True))
|
||||||
|
assert data_loader.suggest_schools_typesense("anything") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_limit_is_passed_through_and_clamped(monkeypatch):
|
||||||
|
client = _FakeClient([])
|
||||||
|
_use(monkeypatch, client)
|
||||||
|
data_loader.suggest_schools_typesense("x", limit=500)
|
||||||
|
assert client.docs.last_params["per_page"] == 20
|
||||||
Reference in new issue
Block a user