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