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:
@@ -3,12 +3,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import oracledb
|
||||
|
||||
from core.db import conn
|
||||
|
||||
|
||||
# ── Region parser: address → "나라|시|구" ──
|
||||
|
||||
_CITY_MAP = {
|
||||
"서울특별시": "서울", "서울": "서울",
|
||||
"부산광역시": "부산", "부산": "부산",
|
||||
"대구광역시": "대구", "대구": "대구",
|
||||
"인천광역시": "인천", "인천": "인천",
|
||||
"광주광역시": "광주", "광주": "광주",
|
||||
"대전광역시": "대전", "대전": "대전",
|
||||
"울산광역시": "울산", "울산": "울산",
|
||||
"세종특별자치시": "세종",
|
||||
"경기도": "경기", "경기": "경기",
|
||||
"강원특별자치도": "강원", "강원도": "강원",
|
||||
"충청북도": "충북", "충청남도": "충남",
|
||||
"전라북도": "전북", "전북특별자치도": "전북",
|
||||
"전라남도": "전남",
|
||||
"경상북도": "경북", "경상남도": "경남",
|
||||
"제주특별자치도": "제주",
|
||||
}
|
||||
|
||||
|
||||
def parse_region_from_address(address: str | None) -> str | None:
|
||||
"""Parse address into 'country|city|district' format."""
|
||||
if not address:
|
||||
return None
|
||||
addr = address.strip()
|
||||
|
||||
# Japanese
|
||||
if addr.startswith("일본") or "Japan" in addr:
|
||||
city = None
|
||||
if "Tokyo" in addr: city = "도쿄"
|
||||
elif "Osaka" in addr: city = "오사카"
|
||||
elif "Sapporo" in addr or "Hokkaido" in addr: city = "삿포로"
|
||||
elif "Kyoto" in addr: city = "교토"
|
||||
elif "Fukuoka" in addr: city = "후쿠오카"
|
||||
return f"일본|{city}" if city else "일본"
|
||||
|
||||
# Singapore
|
||||
if "Singapore" in addr or "싱가포르" in addr:
|
||||
return "싱가포르"
|
||||
|
||||
# Korean standard: "대한민국 시/도 구/시 ..."
|
||||
if "대한민국" in addr:
|
||||
m = re.match(r"대한민국\s+(\S+)\s+(\S+)", addr)
|
||||
if m:
|
||||
city = _CITY_MAP.get(m.group(1))
|
||||
if city:
|
||||
gu = m.group(2)
|
||||
if gu.endswith(("구", "군", "시")):
|
||||
return f"한국|{city}|{gu}"
|
||||
# Not a district — just city level
|
||||
return f"한국|{city}"
|
||||
# Reversed: "... 구 시 대한민국" / "... 시 KR"
|
||||
parts = addr.split()
|
||||
for i, p in enumerate(parts):
|
||||
if p in _CITY_MAP:
|
||||
city = _CITY_MAP[p]
|
||||
gu = parts[i - 1] if i > 0 and parts[i - 1].endswith(("구", "군", "시")) else None
|
||||
return f"한국|{city}|{gu}" if gu else f"한국|{city}"
|
||||
return "한국"
|
||||
|
||||
# Korean without prefix
|
||||
parts = addr.split()
|
||||
if parts:
|
||||
city = _CITY_MAP.get(parts[0])
|
||||
if city and len(parts) > 1 and parts[1].endswith(("구", "군", "시")):
|
||||
return f"한국|{city}|{parts[1]}"
|
||||
elif city:
|
||||
return f"한국|{city}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _truncate_bytes(val: str | None, max_bytes: int) -> str | None:
|
||||
"""Truncate a string to fit within max_bytes when encoded as UTF-8."""
|
||||
if not val:
|
||||
@@ -19,6 +93,21 @@ def _truncate_bytes(val: str | None, max_bytes: int) -> str | None:
|
||||
return encoded[:max_bytes].decode("utf-8", errors="ignore").rstrip()
|
||||
|
||||
|
||||
def find_by_place_id(google_place_id: str) -> dict | None:
|
||||
"""Find a restaurant by Google Place ID."""
|
||||
sql = "SELECT id, name, address, region, latitude, longitude FROM restaurants WHERE google_place_id = :gid"
|
||||
with conn() as c:
|
||||
cur = c.cursor()
|
||||
cur.execute(sql, {"gid": google_place_id})
|
||||
r = cur.fetchone()
|
||||
if r:
|
||||
return {
|
||||
"id": r[0], "name": r[1], "address": r[2],
|
||||
"region": r[3], "latitude": r[4], "longitude": r[5],
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def find_by_name(name: str) -> dict | None:
|
||||
"""Find a restaurant by exact name match."""
|
||||
sql = "SELECT id, name, address, region, latitude, longitude FROM restaurants WHERE name = :n"
|
||||
@@ -50,17 +139,27 @@ def upsert(
|
||||
rating_count: int | None = None,
|
||||
) -> str:
|
||||
"""Insert or update a restaurant. Returns row id."""
|
||||
# Auto-derive region from address if not provided
|
||||
if not region and address:
|
||||
region = parse_region_from_address(address)
|
||||
|
||||
# Truncate fields to fit DB column byte limits (VARCHAR2 is byte-based)
|
||||
price_range = _truncate_bytes(price_range, 50)
|
||||
cuisine_type = _truncate_bytes(cuisine_type, 100)
|
||||
region = _truncate_bytes(region, 100)
|
||||
website = _truncate_bytes(website, 500)
|
||||
|
||||
existing = find_by_name(name)
|
||||
# 1) google_place_id로 먼저 찾고, 2) 이름으로 찾기
|
||||
existing = None
|
||||
if google_place_id:
|
||||
existing = find_by_place_id(google_place_id)
|
||||
if not existing:
|
||||
existing = find_by_name(name)
|
||||
if existing:
|
||||
sql = """
|
||||
UPDATE restaurants
|
||||
SET address = COALESCE(:addr, address),
|
||||
SET name = :name,
|
||||
address = COALESCE(:addr, address),
|
||||
region = COALESCE(:reg, region),
|
||||
latitude = COALESCE(:lat, latitude),
|
||||
longitude = COALESCE(:lng, longitude),
|
||||
@@ -77,6 +176,7 @@ def upsert(
|
||||
"""
|
||||
with conn() as c:
|
||||
c.cursor().execute(sql, {
|
||||
"name": name,
|
||||
"addr": address, "reg": region,
|
||||
"lat": latitude, "lng": longitude,
|
||||
"cuisine": cuisine_type, "price": price_range,
|
||||
|
||||
Reference in New Issue
Block a user