"""Search API routes — keyword + semantic search.""" from fastapi import APIRouter, Query from core import restaurant, vector from core.db import conn router = APIRouter() @router.get("") def search_restaurants( q: str = Query(..., min_length=1), mode: str = Query("keyword", pattern="^(keyword|semantic|hybrid)$"), limit: int = Query(20, le=100), ): """Search restaurants by keyword, semantic similarity, or hybrid.""" if mode == "semantic": return _semantic_search(q, limit) elif mode == "hybrid": kw = _keyword_search(q, limit) sem = _semantic_search(q, limit) # merge: keyword results first, then semantic results not already in keyword seen = {r["id"] for r in kw} merged = list(kw) for r in sem: if r["id"] not in seen: merged.append(r) seen.add(r["id"]) return merged[:limit] else: return _keyword_search(q, limit) def _keyword_search(q: str, limit: int) -> list[dict]: sql = """ SELECT id, name, address, region, latitude, longitude, cuisine_type, price_range FROM restaurants WHERE latitude IS NOT NULL AND (UPPER(name) LIKE UPPER(:q) OR UPPER(address) LIKE UPPER(:q) OR UPPER(region) LIKE UPPER(:q) OR UPPER(cuisine_type) LIKE UPPER(:q)) FETCH FIRST :lim ROWS ONLY """ pattern = f"%{q}%" with conn() as c: cur = c.cursor() cur.execute(sql, {"q": pattern, "lim": limit}) cols = [d[0].lower() for d in cur.description] return [dict(zip(cols, row)) for row in cur.fetchall()] def _semantic_search(q: str, limit: int) -> list[dict]: similar = vector.search_similar(q, top_k=limit) if not similar: return [] rest_ids = list({s["restaurant_id"] for s in similar}) results = [] for rid in rest_ids[:limit]: r = restaurant.get_by_id(rid) if r and r.get("latitude"): results.append(r) return results