|
|
|
|
|
import os |
|
|
import math |
|
|
import requests |
|
|
import gradio as gr |
|
|
from typing import List, Dict, Tuple |
|
|
|
|
|
|
|
|
|
|
|
CENTER_LAT, CENTER_LON = 45.4236, -75.7009 |
|
|
RADIUS_KM = 12.0 |
|
|
GEOAPIFY_KEY = os.getenv("GEOAPIFY_KEY", "") |
|
|
GEOAPIFY_PLACES_URL = "https://api.geoapify.com/v2/places" |
|
|
GEOAPIFY_STATICMAP_URL = "https://maps.geoapify.com/v1/staticmap" |
|
|
|
|
|
HF_MODEL_PRIMARY = "openai/gpt-oss-20b" |
|
|
HF_MODEL_FALLBACK = "gpt2" |
|
|
|
|
|
|
|
|
_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: |
|
|
|
|
|
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 "" |
|
|
|
|
|
|
|
|
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 |
|
|
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 |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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", []) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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." ) |
|
|
|
|
|
if include_item_blurbs: |
|
|
for r in results: |
|
|
r["blurb"] = item_blurb(r["name"]) or "" |
|
|
|
|
|
|
|
|
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---\n<sub>Data © OpenStreetMap contributors, © Geoapify</sub>" |
|
|
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 += "\n<sub>Data © OpenStreetMap contributors, © Geoapify</sub>" |
|
|
return md |
|
|
except requests.HTTPError as e: |
|
|
return f"**API error:** {e}" |
|
|
except Exception as e: |
|
|
return f"**Error:** {type(e).__name__}: {e}" |
|
|
|
|
|
|
|
|
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() |
|
|
|