Spaces:
Sleeping
Sleeping
| import gradio as gr | |
| import requests | |
| import pandas as pd | |
| import os | |
| # ===== CONFIG ===== | |
| GEOAPIFY_KEY = os.getenv("GEOAPIFY_KEY", "YOUR_GEOAPIFY_API_KEY") # https://myprojects.geoapify.com | |
| OVERPASS_URL = "https://overpass-api.de/api/interpreter" | |
| # Ottawa center (lon, lat) and a city-sized radius (meters) | |
| OTTAWA_LON, OTTAWA_LAT = -75.6972, 45.4215 | |
| SEARCH_RADIUS_M = 12000 # ~12km | |
| # ===== OPTIONAL: HF text gen (summary blurb) ===== | |
| USE_HF = False # set True to enable generation | |
| HF_MODEL = "openai/gpt-oss-20b" | |
| HF_FALLBACK = "gpt2" # CPU-friendly fallback | |
| GEN_TEMP = 0.6 | |
| def _gen_blurb(kind: str, names: list[str]) -> str: | |
| if not USE_HF or not names: | |
| return "" | |
| try: | |
| from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline | |
| tok = AutoTokenizer.from_pretrained(HF_MODEL) | |
| mdl = AutoModelForCausalLM.from_pretrained(HF_MODEL, device_map="auto", torch_dtype="auto") | |
| pipe = pipeline("text-generation", model=mdl, tokenizer=tok) | |
| except Exception: | |
| from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline | |
| tok = AutoTokenizer.from_pretrained(HF_FALLBACK) | |
| mdl = AutoModelForCausalLM.from_pretrained(HF_FALLBACK) | |
| pipe = pipeline("text-generation", model=mdl, tokenizer=tok) | |
| title = "Top Hotels in Ottawa" if kind == "hotel" else "Top Attractions in Ottawa" | |
| lines = "\n".join([f"- {n}" for n in names[:5]]) | |
| prompt = ( | |
| f"Write a concise, friendly 3-sentence intro for a travel app.\n" | |
| f"Title: {title}\nPlaces:\n{lines}\n" | |
| f"Audience: families and solo travelers. Avoid hype; be helpful." | |
| ) | |
| out = pipe(prompt, max_new_tokens=100, temperature=GEN_TEMP, do_sample=True, top_p=0.92) | |
| return out[0]["generated_text"].split("Places:")[-1].strip() | |
| # ===== PRIMARY: GEOAPIFY ===== | |
| def search_geoapify(kind: str, query: str | None): | |
| """ | |
| kind: 'hotel' or 'attraction' | |
| """ | |
| if GEOAPIFY_KEY in (None, "", "YOUR_GEOAPIFY_API_KEY"): | |
| return None # force fallback if key not set | |
| # Categories per Geoapify taxonomy | |
| if kind == "hotel": | |
| categories = "accommodation.hotel" | |
| else: | |
| # cast a wider net for attractions | |
| categories = ",".join([ | |
| "tourism.attraction", | |
| "entertainment.museum", | |
| "entertainment.planetarium", | |
| "entertainment.zoo", | |
| "heritage.sights", | |
| "entertainment.theme_park", | |
| "leisure.park", | |
| "natural.sights" | |
| ]) | |
| url = "https://api.geoapify.com/v2/places" | |
| params = { | |
| "categories": categories, | |
| "filter": f"circle:{OTTAWA_LON},{OTTAWA_LAT},{SEARCH_RADIUS_M}", | |
| "bias": f"proximity:{OTTAWA_LON},{OTTAWA_LAT}", | |
| "limit": 20, | |
| "apiKey": GEOAPIFY_KEY | |
| } | |
| if query: | |
| params["text"] = query | |
| r = requests.get(url, params=params, timeout=30) | |
| if r.status_code != 200: | |
| return None | |
| data = r.json() | |
| feats = data.get("features") or [] | |
| if not feats: | |
| return None | |
| rows = [] | |
| for f in feats: | |
| p = f.get("properties", {}) | |
| name = p.get("name") or "N/A" | |
| addr = p.get("formatted") or "N/A" | |
| rating = p.get("rating") | |
| lat, lon = p.get("lat"), p.get("lon") | |
| rows.append({ | |
| "Name": name, | |
| "Address": addr, | |
| "Rating": rating if rating is not None else "—", | |
| "Map Link": f"https://www.google.com/maps/search/?api=1&query={lat},{lon}", | |
| "Source": "Geoapify" | |
| }) | |
| # Sort: rating desc (if present), then keep Geoapify order (which uses internal rank) | |
| def _key(row): | |
| r = row["Rating"] | |
| return (float(r) if isinstance(r, (int, float, str)) and str(r).replace('.', '', 1).isdigit() else -1.0) | |
| rows.sort(key=_key, reverse=True) | |
| return rows[:5] | |
| # ===== FALLBACK: OVERPASS (OSM) ===== | |
| def search_overpass(kind: str, query: str | None): | |
| # Ottawa bbox: south,west,north,east | |
| bbox = "45.20,-75.90,45.53,-75.40" | |
| if kind == "hotel": | |
| body = f""" | |
| [out:json][timeout:30]; | |
| ( | |
| node["tourism"="hotel"]({bbox}); | |
| way["tourism"="hotel"]({bbox}); | |
| relation["tourism"="hotel"]({bbox}); | |
| ); | |
| out center tags; | |
| """ | |
| else: | |
| body = f""" | |
| [out:json][timeout:35]; | |
| ( | |
| node["tourism"="attraction"]({bbox}); | |
| way["tourism"="attraction"]({bbox}); | |
| relation["tourism"="attraction"]({bbox}); | |
| node["tourism"="museum"]({bbox}); | |
| way["tourism"="museum"]({bbox}); | |
| relation["tourism"="museum"]({bbox}); | |
| node["amenity"="museum"]({bbox}); | |
| way["amenity"="museum"]({bbox}); | |
| relation["amenity"="museum"]({bbox}); | |
| node["tourism"="gallery"]({bbox}); | |
| way["tourism"="gallery"]({bbox}); | |
| relation["tourism"="gallery"]({bbox}); | |
| node["tourism"="theme_park"]({bbox}); | |
| node["tourism"="zoo"]({bbox}); | |
| node["tourism"="viewpoint"]({bbox}); | |
| node["historic"]({bbox}); | |
| ); | |
| out center tags; | |
| """ | |
| r = requests.post(OVERPASS_URL, data={'data': body}, timeout=60) | |
| if r.status_code != 200: | |
| return None | |
| data = r.json() | |
| elems = data.get("elements") or [] | |
| if not elems: | |
| return None | |
| def pick_name(tags): | |
| return tags.get("name") or tags.get("official_name") or tags.get("alt_name") or "Unnamed" | |
| # Simple heuristic score to approximate "top" | |
| def score(tags): | |
| s = 0 | |
| if "name" in tags: s += 2 | |
| if "wikidata" in tags: s += 3 | |
| if "wikipedia" in tags: s += 3 | |
| if "website" in tags: s += 2 | |
| if "brand" in tags: s += 1 | |
| if "operator" in tags: s += 1 | |
| if kind == "hotel" and "stars" in tags: | |
| try: s += 2 * float(tags["stars"]) | |
| except: pass | |
| return s | |
| rows_scored = [] | |
| for e in elems: | |
| tags = e.get("tags", {}) | |
| nm = pick_name(tags) | |
| if "center" in e: | |
| lat, lon = e["center"]["lat"], e["center"]["lon"] | |
| else: | |
| lat, lon = e.get("lat"), e.get("lon") | |
| if query and nm and query.lower() not in nm.lower(): | |
| # lightweight name filter when a query is provided | |
| continue | |
| rows_scored.append(( | |
| score(tags), | |
| { | |
| "Name": nm, | |
| "Address": ", ".join(filter(None, [ | |
| tags.get("addr:housenumber"), | |
| tags.get("addr:street"), | |
| tags.get("addr:city"), | |
| tags.get("addr:postcode") | |
| ])) or tags.get("addr:city") or "N/A", | |
| "Rating": (tags.get("stars") + "★") if tags.get("stars") else "—", | |
| "Map Link": f"https://www.google.com/maps?q={lat},{lon}", | |
| "Source": "Overpass(OSM)" | |
| } | |
| )) | |
| rows_scored.sort(key=lambda t: t[0], reverse=True) | |
| rows = [r for _, r in rows_scored[:5]] | |
| return rows or None | |
| # ===== WRAPPER ===== | |
| def find_places(kind: str, query: str): | |
| """ | |
| kind: 'Hotels' or 'Attractions' | |
| query: optional text (can be empty to just list top 5) | |
| """ | |
| k = "hotel" if kind.lower().startswith("hotel") else "attraction" | |
| data = search_geoapify(k, query.strip() or None) | |
| if not data: | |
| data = search_overpass(k, query.strip() or None) | |
| if not data: | |
| return "No results found.", pd.DataFrame([{"Message": "No results found"}]) | |
| # Optional summary blurb via HF | |
| blurb = _gen_blurb(k, [row["Name"] for row in data]) | |
| return blurb, pd.DataFrame(data) | |
| # ===== GRADIO UI ===== | |
| demo = gr.Interface( | |
| fn=find_places, | |
| inputs=[ | |
| gr.Radio(choices=["Hotels", "Attractions"], value="Hotels", label="What to search"), | |
| gr.Textbox(placeholder="(Optional) e.g. 'downtown', 'museum', 'spa' — leave empty for top 5", label="Filter text") | |
| ], | |
| outputs=[ | |
| gr.Markdown(label="Summary"), | |
| gr.Dataframe(headers=["Name", "Address", "Rating", "Map Link", "Source"], wrap=True) | |
| ], | |
| title="🗺️ Ottawa Hotels & Attractions — Free API Edition", | |
| description="Primary: Geoapify Places (free tier) • Fallback: Overpass (OpenStreetMap). Toggle HF text gen inside the code." | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch() | |