""" 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()