feat(geocode): add viewport bias for location-aware search

- Add lat/lon/zoom params to geocode() and _retrieve_photon_freetext()
- Update nav_tools.py wrapper to pass through viewport params
- Add /api/geocode handler support for lat/lon/zoom query params
- Add _safe_float() helper for param validation
- Cast zoom to int for Photon compatibility

Allows the frontend to pass current map center/zoom to bias
search results toward the visible area.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-04-26 04:03:44 +00:00
commit 2ed9335f4e
3 changed files with 29 additions and 12 deletions

View file

@ -334,21 +334,20 @@ def _retrieve_photon_structured(parsed, limit=10):
return _parse_photon_features(data.get('features', []), 'photon') return _parse_photon_features(data.get('features', []), 'photon')
def _retrieve_photon_freetext(query, limit=10): def _retrieve_photon_freetext(query, limit=10, lat=None, lon=None, zoom=None):
"""Query Photon /api for free-text search with location bias.""" """Query Photon /api for free-text search with location bias."""
try: try:
params = { params = {
'q': query, 'q': query,
'limit': limit, 'limit': limit,
'lat': GEOCODE_BIAS_LAT, 'lat': lat if lat is not None else GEOCODE_BIAS_LAT,
'lon': GEOCODE_BIAS_LON, 'lon': lon if lon is not None else GEOCODE_BIAS_LON,
'zoom': GEOCODE_BIAS_ZOOM, 'zoom': int(zoom) if zoom is not None else GEOCODE_BIAS_ZOOM,
} }
resp = requests.get(f"{PHOTON_URL}/api", params=params, timeout=5) resp = requests.get(f"{PHOTON_URL}/api", params=params, timeout=5)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
except Exception as e: except Exception as e:
logger.debug("Photon /api failed: %s", e)
return [] return []
return _parse_photon_features(data.get('features', []), 'photon') return _parse_photon_features(data.get('features', []), 'photon')
@ -663,7 +662,7 @@ def _annotate_with_address_book(results):
# PUBLIC API # PUBLIC API
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
def geocode(query, limit=10): def geocode(query, limit=10, lat=None, lon=None, zoom=None):
""" """
Structured geocoding with multi-source retrieval and reranking. Structured geocoding with multi-source retrieval and reranking.
@ -731,7 +730,7 @@ def geocode(query, limit=10):
# Parallel: Netsyms (structured) + Photon (freetext with expanded query) # Parallel: Netsyms (structured) + Photon (freetext with expanded query)
netsyms_results = _retrieve_netsyms(parsed, limit=limit) netsyms_results = _retrieve_netsyms(parsed, limit=limit)
photon_results = _retrieve_photon_freetext( photon_results = _retrieve_photon_freetext(
parsed.get('expanded_query', q), limit=limit parsed.get('expanded_query', q), limit=limit, lat=lat, lon=lon, zoom=zoom
) )
# Also try Photon /structured for addresses # Also try Photon /structured for addresses
photon_struct = _retrieve_photon_structured(parsed, limit=5) photon_struct = _retrieve_photon_structured(parsed, limit=5)
@ -739,11 +738,11 @@ def geocode(query, limit=10):
elif intent == 'POSTCODE': elif intent == 'POSTCODE':
netsyms_results = _retrieve_netsyms(parsed, limit=limit) netsyms_results = _retrieve_netsyms(parsed, limit=limit)
photon_results = _retrieve_photon_freetext(q, limit=limit) photon_results = _retrieve_photon_freetext(q, limit=limit, lat=lat, lon=lon, zoom=zoom)
candidates = netsyms_results + photon_results candidates = netsyms_results + photon_results
elif intent in ('LOCALITY', 'POI', 'UNKNOWN'): elif intent in ('LOCALITY', 'POI', 'UNKNOWN'):
candidates = _retrieve_photon_freetext(q, limit=limit) candidates = _retrieve_photon_freetext(q, limit=limit, lat=lat, lon=lon, zoom=zoom)
# ── Deduplicate by (lat, lon) proximity ── # ── Deduplicate by (lat, lon) proximity ──
deduped = [] deduped = []

View file

@ -50,10 +50,10 @@ def _haversine_m(lat1, lon1, lat2, lon2):
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def geocode(query: str, limit: int = 10): def geocode(query: str, limit: int = 10, lat=None, lon=None, zoom=None):
"""Delegate to the structured geocode module. See lib/geocode.py.""" """Delegate to the structured geocode module. See lib/geocode.py."""
from . import geocode as geocode_mod from . import geocode as geocode_mod
return geocode_mod.geocode(query, limit=limit) return geocode_mod.geocode(query, limit=limit, lat=lat, lon=lon, zoom=zoom)
def _geocode(query: str): def _geocode(query: str):

View file

@ -35,6 +35,19 @@ def api_netsyms_health():
return jsonify(netsyms.health()) return jsonify(netsyms.health())
def _safe_float(val, lo, hi):
"""Parse val as float; return None if missing, non-numeric, or out of [lo, hi]."""
if val is None:
return None
try:
f = float(val)
if lo <= f <= hi:
return f
except (ValueError, TypeError):
pass
return None
@geocode_bp.route('/api/geocode') @geocode_bp.route('/api/geocode')
def api_geocode(): def api_geocode():
""" """
@ -58,7 +71,12 @@ def api_geocode():
except (ValueError, TypeError): except (ValueError, TypeError):
limit = 10 limit = 10
result = nav_tools.geocode(q, limit=limit) # Viewport bias parameters (optional)
lat = _safe_float(request.args.get("lat"), -90, 90)
lon = _safe_float(request.args.get("lon"), -180, 180)
zoom = _safe_float(request.args.get("zoom"), 0, 22)
result = nav_tools.geocode(q, limit=limit, lat=lat, lon=lon, zoom=zoom)
return jsonify(result) return jsonify(result)