# app.py import os import math import requests import gradio as gr from typing import List, Dict, Tuple # ========== CONFIG ========== # Ottawa (Parliament Hill) as center CENTER_LAT, CENTER_LON = 45.4236, -75.7009 RADIUS_KM = 12.0 # scoring radius GEOAPIFY_KEY = os.getenv("GEOAPIFY_KEY", "") GEOAPIFY_PLACES_URL = "https://api.geoapify.com/v2/places" GEOAPIFY_STATICMAP_URL = "https://maps.geoapify.com/v1/staticmap" # Text generation model (will fallback automatically if too heavy) HF_MODEL_PRIMARY = "openai/gpt-oss-20b" HF_MODEL_FALLBACK = "gpt2" # ========== HUGGING FACE GENERATOR (lazy) ========== _generator = None def get_generator(): """Create (or reuse) a text-generation pipeline with a safe fallback.""" global _generator if _generator is not None: return _generator try: from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline tok = AutoTokenizer.from_pretrained(HF_MODEL_PRIMARY) mdl = AutoModelForCausalLM.from_pretrained( HF_MODEL_PRIMARY, device_map="auto", torch_dtype="auto" ) _generator = pipeline("text-generation", model=mdl, tokenizer=tok, return_full_text=False) return _generator except Exception: # Fallback (small CPU-friendly) from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline tok = AutoTokenizer.from_pretrained(HF_MODEL_FALLBACK) mdl = AutoModelForCausalLM.from_pretrained(HF_MODEL_FALLBACK) _generator = pipeline("text-generation", model=mdl, tokenizer=tok, return_full_text=False) return _generator def gen_text(prompt: str, max_new_tokens=70, temperature=0.6, top_p=0.9) -> str: try: gen = get_generator() out = gen(prompt, max_new_tokens=max_new_tokens, temperature=temperature, top_p=top_p)[0]["generated_text"] return out.strip() except Exception as e: return "" # if generation fails, silently skip blurbs # ========== GEO HELPERS ========== def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: R = 6371.0 dlat = math.radians(lat2 - lat1) dlon = math.radians(lon2 - lon1) a = math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon/2)**2 return 2 * R * math.asin(math.sqrt(a)) def normalize_rating(r) -> float: if r is None: return 0.5 # neutral if missing try: val = float(r) return max(0.0, min(val / 5.0, 1.0)) except: return 0.5 def amenities_points(props: Dict, is_hotel: bool) -> int: pts = 0 if props.get("website"): pts += 1 if props.get("opening_hours"): pts += 1 if props.get("wheelchair"): pts += 1 if props.get("phone"): pts += 1 # hotel "stars" sometimes appear in datasource.raw raw = (props.get("datasource") or {}).get("raw", {}) if is_hotel and raw.get("stars"): pts += 1 return min(pts, 5) def score_place(props: Dict, kind: str) -> float: """Weighted score: rating (50%), distance (30%), amenities (20%).""" lat, lon = props.get("lat"), props.get("lon") if lat is None or lon is None: return -1.0 dist = haversine_km(CENTER_LAT, CENTER_LON, lat, lon) distance_score = 1.0 - min(dist, RADIUS_KM) / RADIUS_KM rating_norm = normalize_rating(props.get("rating")) amen = amenities_points(props, is_hotel=(kind == "hotel")) / 5.0 w_rating, w_distance, w_amen = 0.50, 0.30, 0.20 return w_rating * rating_norm + w_distance * distance_score + w_amen * amen # ========== GEOAPIFY FETCH ========== def fetch_geoapify(kind: str, text: str | None, limit: int = 30) -> List[Dict]: if not GEOAPIFY_KEY: raise RuntimeError("Missing GEOAPIFY_KEY. Set env var GEOAPIFY_KEY.") categories = "accommodation.hotel" if kind == "hotel" else ",".join([ "tourism.attraction", "entertainment.museum", "entertainment.zoo", "heritage.sights", "entertainment.theme_park", "leisure.park", "natural.sights" ]) params = { "categories": categories, "filter": f"circle:{CENTER_LON},{CENTER_LAT},{int(RADIUS_KM*1000)}", "bias": f"proximity:{CENTER_LON},{CENTER_LAT}", "limit": limit, "apiKey": GEOAPIFY_KEY } if text: params["text"] = text r = requests.get(GEOAPIFY_PLACES_URL, params=params, timeout=30) r.raise_for_status() return r.json().get("features", []) # ========== RANK & SHAPE ========== def rank_and_shape(kind: str, features: List[Dict]) -> List[Dict]: scored: List[Tuple[float, Dict]] = [] for f in features: p = f.get("properties", {}) if p.get("lat") is None or p.get("lon") is None: continue s = score_place(p, kind) if s < 0: continue scored.append((s, p)) scored.sort(key=lambda t: t[0], reverse=True) top = [p for _, p in scored[:5]] shaped = [] for p in top: lat, lon = p["lat"], p["lon"] shaped.append({ "name": p.get("name") or "N/A", "address": p.get("formatted") or "N/A", "rating": p.get("rating"), "distance_km": round(haversine_km(CENTER_LAT, CENTER_LON, lat, lon), 2), "amenities": [k for k in ["website", "opening_hours", "wheelchair", "phone"] if p.get(k)], "map_url": f"https://www.google.com/maps/search/?api=1&query={lat},{lon}", "image_url": ( f"{GEOAPIFY_STATICMAP_URL}" f"?style=osm-carto&width=640&height=320" f"&marker=lonlat:{lon},{lat};size:medium" f"&zoom=15&apiKey={GEOAPIFY_KEY}" ) }) return shaped # ========== BLURBS ========== def list_blurb(kind: str) -> str: title = "Top Hotels in Ottawa" if kind == "hotel" else "Top Attractions in Ottawa" prompt = ( f"Write a warm, concise 2-sentence intro for a travel app list titled '{title}'. " f"Audience: families and solo travelers. Avoid hype; be factual." ) return gen_text(prompt, max_new_tokens=70, temperature=0.6, top_p=0.9) def item_blurb(name: str) -> str: prompt = ( f"In one helpful sentence (<=20 words), say why a traveler might like {name} in Ottawa. Avoid exaggeration." ) return gen_text(prompt, max_new_tokens=40, temperature=0.7, top_p=0.9) # ========== CONTROLLER ========== def controller(kind_choice: str, query_text: str, include_item_blurbs: bool): try: kind = "hotel" if kind_choice.lower().startswith("hotel") else "attraction" feats = fetch_geoapify(kind, (query_text or "").strip() or None, limit=30) results = rank_and_shape(kind, feats) header = list_blurb(kind) or ( "Discover highly-rated, centrally located picks in Ottawa." ) # Add per-item blurbs (can be slow with large models) if include_item_blurbs: for r in results: r["blurb"] = item_blurb(r["name"]) or "" # Render Markdown cards title = "Top 5 Hotels in Ottawa" if kind == "hotel" else "Top 5 Attractions in Ottawa" q_chip = f" _(filter: **{query_text.strip()}**)_ " if query_text and query_text.strip() else "" md = f"## {title}{q_chip}\n\n{header}\n\n" if not results: md += "> No results found. Try a different filter.\n" md += "\n\n---\nData © OpenStreetMap contributors, © Geoapify" return md for r in results: rating_txt = f"⭐ {r['rating']}" if r["rating"] is not None else "⭐ —" amen = " ".join([f"`{a}`" for a in r["amenities"]]) if r["amenities"] else "" blurb = f"\n\n_{r.get('blurb','')}_\n" if r.get("blurb") else "" md += ( f"### {r['name']} \n" f"{rating_txt} · {r['distance_km']} km from center \n" f"{r['address']} \n" f"{blurb}\n" f"![map]({r['image_url']}) \n" f"[Open in Google Maps]({r['map_url']}) \n" f"{amen}\n\n---\n" ) md += "\nData © OpenStreetMap contributors, © Geoapify" return md except requests.HTTPError as e: return f"**API error:** {e}" except Exception as e: return f"**Error:** {type(e).__name__}: {e}" # ========== GRADIO UI ========== with gr.Blocks(title="Ottawa Hotels & Attractions — Top 5") as demo: gr.Markdown("# 🗺️ Ottawa Hotels & Attractions — Top 5") gr.Markdown( "Search **hotels** or **attractions** in Ottawa using a free Geoapify API, " "ranked by rating, distance to Parliament Hill, and amenities. " "Optional blurbs are generated by a Hugging Face model." ) with gr.Row(): kind = gr.Radio(["Hotels", "Attractions"], value="Hotels", label="What to search") query = gr.Textbox(label="Filter text (optional)", placeholder="e.g., downtown, museum, spa") with_blurbs = gr.Checkbox(label="Generate per‑item blurbs (AI)", value=True) run_btn = gr.Button("Search", variant="primary") out_md = gr.Markdown() run_btn.click(controller, [kind, query, with_blurbs], out_md) if __name__ == "__main__": demo.launch()