File size: 9,279 Bytes
6700575
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# 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---\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"![map]({r['image_url']})  \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}"

# ========== 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()