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>
This commit is contained in:
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from core import restaurant, vector
|
||||
from core import restaurant, vector, cache
|
||||
from core.db import conn
|
||||
|
||||
router = APIRouter()
|
||||
@@ -17,8 +17,15 @@ def search_restaurants(
|
||||
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":
|
||||
return _semantic_search(q, limit)
|
||||
result = _semantic_search(q, limit)
|
||||
cache.set(key, result)
|
||||
return result
|
||||
elif mode == "hybrid":
|
||||
kw = _keyword_search(q, limit)
|
||||
sem = _semantic_search(q, limit)
|
||||
@@ -29,21 +36,31 @@ def search_restaurants(
|
||||
if r["id"] not in seen:
|
||||
merged.append(r)
|
||||
seen.add(r["id"])
|
||||
return merged[:limit]
|
||||
result = merged[:limit]
|
||||
cache.set(key, result)
|
||||
return result
|
||||
else:
|
||||
return _keyword_search(q, limit)
|
||||
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 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))
|
||||
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}%"
|
||||
@@ -51,18 +68,56 @@ def _keyword_search(q: str, limit: int) -> list[dict]:
|
||||
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()]
|
||||
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=limit)
|
||||
similar = vector.search_similar(q, top_k=max(30, limit * 3))
|
||||
if not similar:
|
||||
return []
|
||||
|
||||
rest_ids = list({s["restaurant_id"] for s in similar})
|
||||
# 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 rest_ids[:limit]:
|
||||
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"], [])
|
||||
|
||||
Reference in New Issue
Block a user