palate / server.py
KassimYassin's picture
Deploy Palate multi-city culinary engine with Gradio and FastAPI
fb8df06 verified
Raw History Blame Contribute Delete
15.8 kB
"""
Lightweight, Zero-Dependency HTTP API & Static Server for Palate / TasteOS Global Multi-City Engine.
Supports Amsterdam, Dubai, Istanbul with zero paid Google APIs.
Runs on localhost:3050.
"""
import json
import os
import sys
import urllib.parse
from http.server import HTTPServer, SimpleHTTPRequestHandler, ThreadingHTTPServer
from engine import MultiCityEngineManager
from spatial_copilot_service import copilot_service
# Ensure UTF-8 output
if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
PORT = int(os.environ.get("PORT", 3050))
PUBLIC_DIR = os.path.join(os.path.dirname(__file__), "public")
manager = MultiCityEngineManager()
class PalateRequestHandler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=PUBLIC_DIR, **kwargs)
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
query = urllib.parse.parse_qs(parsed.query)
# 0. API: List Available Global Cities
if path == "/api/cities":
cities = manager.list_cities()
self.send_json(cities)
return
# Extract city context (defaults to Amsterdam for backward compatibility)
city_id = query.get("city", ["amsterdam"])[0].lower().strip()
engine = manager.get_engine(city_id)
default_center = engine.city_config.get("center", {"lat": 52.3680, "lon": 4.8900})
# 1. API: Search with Rich Dietary & Amenity Filters
if path == "/api/search":
q = query.get("q", [""])[0]
has_user_coords = "lat" in query and "lon" in query
lat = float(query.get("lat", [default_center["lat"]])[0])
lon = float(query.get("lon", [default_center["lon"]])[0])
offset = int(query.get("offset", [0])[0])
limit = int(query.get("limit", [40])[0])
cuisine = query.get("cuisine", [""])[0]
diet = query.get("diet", [""])[0]
outdoor = query.get("outdoor", ["false"])[0].lower() == "true"
canal = query.get("canal", ["false"])[0].lower() == "true"
wheelchair = query.get("wheelchair", ["false"])[0].lower() == "true"
accredited = query.get("accredited", ["false"])[0].lower() == "true"
monument = query.get("monument", ["false"])[0].lower() == "true"
independent = query.get("independent", ["false"])[0].lower() == "true"
celiac = query.get("celiac", ["false"])[0].lower() == "true"
if diet and diet.lower().strip() in ("celiac", "celiac_safe"):
celiac = True
diet = ""
scratch = query.get("scratch", ["false"])[0].lower() == "true"
water = query.get("water", ["false"])[0].lower() == "true"
clean = query.get("clean", ["false"])[0].lower() == "true"
late_night = query.get("late_night", ["false"])[0].lower() == "true"
roastery = query.get("roastery", ["false"])[0].lower() == "true"
historic = query.get("historic", ["false"])[0].lower() == "true"
price_raw = query.get("price", [""])[0]
price_tiers = None
if price_raw:
try:
price_tiers = [int(p.strip()) for p in price_raw.split(",") if p.strip().isdigit()]
if not price_tiers:
price_tiers = None
except Exception:
price_tiers = None
min_score = int(query.get("min_score", [0])[0])
sort_by = query.get("sort", ["quality"])[0]
# Bounding box filter (min_lat, min_lon, max_lat, max_lon)
bbox_raw = query.get("bbox", [""])[0]
bbox = None
if bbox_raw:
try:
parts = [float(x.strip()) for x in bbox_raw.split(",") if x.strip()]
if len(parts) == 4:
bbox = parts
except Exception:
bbox = None
# Walking / Transit distance radius filter in meters
max_dist_raw = query.get("max_dist", [""])[0]
max_dist = None
if max_dist_raw:
try:
max_dist = int(max_dist_raw.strip())
except Exception:
max_dist = None
results, total_count = engine.search(
query=q,
user_lat=lat,
user_lon=lon,
offset=offset,
max_results=limit,
cuisine=cuisine if cuisine else None,
diet=diet if diet else None,
outdoor=outdoor,
canal=canal,
wheelchair=wheelchair,
accredited_only=accredited,
monuments_only=monument,
independent_only=independent,
celiac_safe_only=celiac,
scratch_only=scratch,
tap_water_only=water,
clean_only=clean,
late_night_only=late_night,
roastery_only=roastery,
historic_only=historic,
price_tiers=price_tiers,
min_score=min_score,
sort_by=sort_by,
has_user_coords=has_user_coords,
return_total=True,
bbox=bbox,
max_dist_m=max_dist
)
include_facets = query.get("facets", ["false"])[0].lower() in ["true", "1"]
headers = {
"X-Total-Count": str(total_count),
"X-Offset": str(offset),
"X-Limit": str(limit),
"Access-Control-Expose-Headers": "X-Total-Count, X-Offset, X-Limit"
}
if include_facets:
active_filters = {
"price": price_tiers,
"cuisine": cuisine if cuisine else None,
"diet": diet if diet else None,
"outdoor": outdoor,
"canal": canal,
"wheelchair": wheelchair,
"accredited": accredited,
"monument": monument,
"independent": independent,
"celiac": celiac,
"scratch": scratch,
"water": water,
"clean": clean,
"late_night": late_night,
"roastery": roastery,
"historic": historic,
"bbox": bbox,
"max_dist": max_dist,
"user_lat": lat if has_user_coords else None,
"user_lon": lon if has_user_coords else None
}
taxonomy = engine.get_city_taxonomy(city_id, active_filters=active_filters)
self.send_json({
"places": results,
"total": total_count,
"taxonomy": taxonomy
}, headers=headers)
return
else:
self.send_json(results, headers=headers)
return
# 2. API: Head-to-Head Compare
elif path == "/api/compare":
id1 = query.get("id1", [""])[0]
id2 = query.get("id2", [""])[0]
comparison = engine.compare_head_to_head(id1, id2)
if comparison:
self.send_json(comparison)
else:
self.send_json({"error": "Places not found for comparison"}, status=404)
return
# 3. API: Dynamic City Taxonomy Engine
elif path == "/api/taxonomy":
price_raw = query.get("price", [""])[0]
price_tier = None
if price_raw and price_raw.strip().isdigit():
price_tier = int(price_raw.strip())
cuisine = query.get("cuisine", [""])[0]
diet = query.get("diet", [""])[0]
outdoor = query.get("outdoor", ["false"])[0].lower() == "true"
canal = query.get("canal", ["false"])[0].lower() == "true"
wheelchair = query.get("wheelchair", ["false"])[0].lower() == "true"
accredited = query.get("accredited", ["false"])[0].lower() == "true"
monument = query.get("monument", ["false"])[0].lower() == "true"
independent = query.get("independent", ["false"])[0].lower() == "true"
celiac = query.get("celiac", ["false"])[0].lower() == "true"
if diet and diet.lower().strip() in ("celiac", "celiac_safe"):
celiac = True
diet = ""
scratch = query.get("scratch", ["false"])[0].lower() == "true"
water = query.get("water", ["false"])[0].lower() == "true"
clean = query.get("clean", ["false"])[0].lower() == "true"
late_night = query.get("late_night", ["false"])[0].lower() == "true"
roastery = query.get("roastery", ["false"])[0].lower() == "true"
historic = query.get("historic", ["false"])[0].lower() == "true"
has_filters = any([
price_tier is not None, cuisine, diet, outdoor, canal, wheelchair,
accredited, monument, independent, celiac, scratch, water, clean,
late_night, roastery, historic
])
active_filters = None
if has_filters:
active_filters = {
"price": price_tier,
"cuisine": cuisine if cuisine else None,
"diet": diet if diet else None,
"outdoor": outdoor,
"canal": canal,
"wheelchair": wheelchair,
"accredited": accredited,
"monument": monument,
"independent": independent,
"celiac": celiac,
"scratch": scratch,
"water": water,
"clean": clean,
"late_night": late_night,
"roastery": roastery,
"historic": historic
}
taxonomy = engine.get_city_taxonomy(city_id, active_filters=active_filters)
self.send_json(taxonomy)
return
# 4. API: Facets & Aggregations
elif path == "/api/facets":
facets = engine.get_facets(authentic_only=True)
self.send_json(facets)
return
# 5. API: Stats & System Metrics
elif path == "/api/stats":
facets = engine.get_facets()
self.send_json({
"city": engine.city_config.get("name", city_id.capitalize()),
"city_id": city_id,
"country": engine.city_config.get("country", ""),
"flag": engine.city_config.get("flag", ""),
"total_eateries_indexed": facets["total_eateries"],
"craft_artisans_detected": facets["craft_artisans_detected"],
"tourist_traps_flagged": facets["tourist_traps_flagged"],
"mono_specialists": facets["mono_specialists"],
"halal_eateries": facets["halal_count"],
"vegan_eateries": facets["vegan_count"],
"canal_terraces": facets["canal_terrace_count"],
"protected_monuments": facets["monuments_count"],
"independent_artisans": facets["independent_count"],
"celiac_safe_kitchens": facets["celiac_safe_count"],
"scratch_kitchens": facets["scratch_count"],
"tap_water_welcoming": facets["tap_water_count"],
"clean_kitchens_verified": facets.get("clean_kitchen_count", 0),
"data_source": "Overture Maps, OpenStreetMap & Government Registries",
"google_api_cost": "$0.00"
})
return
# 6. API: Spatial God Mode AI Copilot
elif path == "/api/copilot/ask":
q = query.get("q", [""])[0]
city_id = query.get("city", ["dubai"])[0].lower().strip()
if not q:
self.send_json({"error": "Query parameter 'q' is required"}, status=400)
return
result = copilot_service.ask(q, city=city_id)
self.send_json(result)
return
# Serve static assets (index.html, etc.)
return super().do_GET()
def do_POST(self):
parsed = urllib.parse.urlparse(self.path)
if parsed.path == "/api/copilot/ask":
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode("utf-8")
try:
data = json.loads(body)
q = data.get("q", "")
city_id = data.get("city", "dubai").lower().strip()
if not q:
self.send_json({"error": "Field 'q' is required"}, status=400)
return
result = copilot_service.ask(q, city=city_id)
self.send_json(result)
except Exception as e:
self.send_json({"error": str(e)}, status=400)
return
elif parsed.path == "/api/endorse":
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode("utf-8")
try:
data = json.loads(body)
place_id = data.get("place_id")
positive = data.get("positive", True)
city_id = data.get("city", "amsterdam").lower().strip()
engine = manager.get_engine(city_id)
# Find place and record endorsement
p = next((x for x in engine.places if x["id"] == place_id), None)
if p:
p["endorsement_count"] = p.get("endorsement_count", 0) + 1
pos_count = (p.get("endorsement_positive_pct", 85) / 100.0) * (p["endorsement_count"] - 1)
if positive:
pos_count += 1
p["endorsement_positive_pct"] = int((pos_count / p["endorsement_count"]) * 100)
self.send_json({"success": True, "place": p})
else:
self.send_json({"error": "Place not found"}, status=404)
except Exception as e:
self.send_json({"error": str(e)}, status=400)
return
self.send_json({"error": "Not found"}, status=404)
def send_json(self, data, status=200, headers=None):
body = json.dumps(data, ensure_ascii=False, default=str).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*")
if headers:
for k, v in headers.items():
self.send_header(k, v)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def run(port=PORT):
server_address = ("", port)
httpd = ThreadingHTTPServer(server_address, PalateRequestHandler)
print(f"==================================================")
print(f" Palate / TasteOS Global Engine is RUNNING on {port}!")
for c in manager.cities:
print(f" - {c.get('flag', '🌐')} {c['name']}")
print(f" Google API Cost: $0.00 ")
print(f"==================================================")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nServer stopped.")
if __name__ == "__main__":
run()