Files
tasteby/backend/api/routes/search.py
joungmin 2bddb0f764 UX improvements: mobile bottom sheet, cuisine taxonomy, search enhancements
- Add BottomSheet component for Google Maps-style restaurant detail on mobile
  (3-snap drag: 40%/55%/92%, velocity-based close, backdrop overlay)
- Mobile map mode now full-screen with bottom sheet overlay for details
- Collapsible filter panel on mobile with active filter badge count
- Standardized cuisine taxonomy (46 categories: 한식|국밥, 일식|스시 etc.)
  with LLM remap endpoint and admin UI button
- Enhanced search: keyword search now includes foods_mentioned + video title
- Search results include channels array for frontend filtering
- Channel filter moved to frontend filteredRestaurants (not API-level)
- LLM extraction prompt updated for pipe-delimited region + cuisine taxonomy
- Vector rebuild endpoint with rich JSON chunks per restaurant
- Geolocation-based auto region selection on page load
- Desktop filters split into two clean rows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:54:28 +09:00

124 lines
3.9 KiB
Python

"""Search API routes — keyword + semantic search."""
from __future__ import annotations
from fastapi import APIRouter, Query
from core import restaurant, vector, cache
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."""
key = cache.make_key("search", f"q={q}", f"m={mode}", f"l={limit}")
cached = cache.get(key)
if cached is not None:
return cached
if mode == "semantic":
result = _semantic_search(q, limit)
cache.set(key, result)
return result
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"])
result = merged[:limit]
cache.set(key, result)
return result
else:
result = _keyword_search(q, limit)
cache.set(key, result)
return result
def _keyword_search(q: str, limit: int) -> list[dict]:
# JOIN video_restaurants to also search foods_mentioned and video title
sql = """
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
r.cuisine_type, r.price_range, r.google_place_id,
r.business_status, r.rating, r.rating_count
FROM restaurants r
JOIN video_restaurants vr ON vr.restaurant_id = r.id
JOIN videos v ON v.id = vr.video_id
WHERE r.latitude IS NOT NULL
AND (UPPER(r.name) LIKE UPPER(:q)
OR UPPER(r.address) LIKE UPPER(:q)
OR UPPER(r.region) LIKE UPPER(:q)
OR UPPER(r.cuisine_type) LIKE UPPER(:q)
OR UPPER(vr.foods_mentioned) LIKE UPPER(:q)
OR UPPER(v.title) 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]
rows = [dict(zip(cols, row)) for row in cur.fetchall()]
# Attach channel names
if rows:
_attach_channels(rows)
return rows
def _semantic_search(q: str, limit: int) -> list[dict]:
similar = vector.search_similar(q, top_k=max(30, limit * 3))
if not similar:
return []
# Deduplicate by restaurant_id, preserving distance order (best first)
seen: set[str] = set()
ordered_ids: list[str] = []
for s in similar:
rid = s["restaurant_id"]
if rid not in seen:
seen.add(rid)
ordered_ids.append(rid)
results = []
for rid in ordered_ids[:limit]:
r = restaurant.get_by_id(rid)
if r and r.get("latitude"):
results.append(r)
if results:
_attach_channels(results)
return results
def _attach_channels(rows: list[dict]):
"""Attach channel names to each restaurant dict."""
ids = [r["id"] for r in rows]
placeholders = ", ".join(f":id{i}" for i in range(len(ids)))
sql = f"""
SELECT DISTINCT vr.restaurant_id, c.channel_name
FROM video_restaurants vr
JOIN videos v ON v.id = vr.video_id
JOIN channels c ON c.id = v.channel_id
WHERE vr.restaurant_id IN ({placeholders})
"""
params = {f"id{i}": rid for i, rid in enumerate(ids)}
ch_map: dict[str, list[str]] = {}
with conn() as c:
cur = c.cursor()
cur.execute(sql, params)
for row in cur.fetchall():
ch_map.setdefault(row[0], []).append(row[1])
for r in rows:
r["channels"] = ch_map.get(r["id"], [])