Update app.py
Browse files
app.py
CHANGED
|
@@ -2,7 +2,6 @@ import os
|
|
| 2 |
import json
|
| 3 |
import requests
|
| 4 |
from datetime import datetime, timedelta
|
| 5 |
-
from functools import lru_cache
|
| 6 |
import gradio as gr
|
| 7 |
import openai
|
| 8 |
import re
|
|
@@ -12,100 +11,117 @@ from typing import List, Dict, Any
|
|
| 12 |
# -------------------- CONFIGURATION --------------------
|
| 13 |
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
| 14 |
OPENWEATHER_API_KEY = os.getenv("OPENWEATHER_API_KEY", "")
|
| 15 |
-
SERP_API_KEY = os.getenv("SERPAPI_API_KEY", "")
|
| 16 |
|
| 17 |
if not OPENAI_API_KEY:
|
| 18 |
-
print("⚠️ OPENAI_API_KEY not set. AI itinerary generation will fall back to
|
| 19 |
client = None
|
| 20 |
else:
|
| 21 |
client = openai.OpenAI(api_key=OPENAI_API_KEY)
|
| 22 |
|
| 23 |
-
# Global
|
| 24 |
ATTRACTIONS_CACHE = {}
|
| 25 |
-
|
| 26 |
-
# Unsplash API for free images (optional but recommended)
|
| 27 |
-
UNSPLASH_API_KEY = os.getenv("UNSPLASH_API_KEY", "")
|
| 28 |
-
|
| 29 |
-
# Image cache to avoid repeated API calls
|
| 30 |
IMAGE_CACHE = {}
|
| 31 |
|
| 32 |
-
# -------------------- IMAGE FUNCTIONS --------------------
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
| 38 |
if cache_key in IMAGE_CACHE:
|
| 39 |
return IMAGE_CACHE[cache_key]
|
| 40 |
-
|
| 41 |
-
# Try
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
try:
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
"
|
| 48 |
-
"
|
| 49 |
-
"
|
|
|
|
|
|
|
|
|
|
| 50 |
}
|
| 51 |
-
|
| 52 |
-
if
|
| 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 |
# -------------------- WEATHER FUNCTION --------------------
|
| 111 |
def get_weather(city: str) -> dict:
|
|
@@ -118,152 +134,199 @@ def get_weather(city: str) -> dict:
|
|
| 118 |
"humidity": 60,
|
| 119 |
"wind_speed": 10,
|
| 120 |
"precipitation": 0,
|
| 121 |
-
"note": "Demo mode
|
| 122 |
}
|
| 123 |
try:
|
| 124 |
url = "https://api.openweathermap.org/data/2.5/weather"
|
| 125 |
-
params = {
|
| 126 |
response = requests.get(url, params=params, timeout=10)
|
| 127 |
data = response.json()
|
| 128 |
if response.status_code != 200:
|
| 129 |
return {"error": f"Weather API error: {data.get('message', 'unknown')}"}
|
| 130 |
-
|
| 131 |
return {
|
| 132 |
"city": city,
|
| 133 |
-
"temperature": data[
|
| 134 |
-
"feels_like": data[
|
| 135 |
-
"condition": data[
|
| 136 |
-
"humidity": data[
|
| 137 |
-
"wind_speed": data[
|
| 138 |
-
"precipitation": data.get(
|
| 139 |
}
|
| 140 |
except Exception as e:
|
| 141 |
return {"error": f"Weather service unavailable: {str(e)}"}
|
| 142 |
|
| 143 |
-
|
|
|
|
| 144 |
def fetch_attractions_via_openai(city: str) -> List[Dict]:
|
| 145 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 146 |
if not client:
|
| 147 |
-
return
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
Example for Paris:
|
| 162 |
[
|
| 163 |
{{
|
| 164 |
-
"name": "
|
| 165 |
-
"description": "
|
| 166 |
-
"entry_fee":
|
| 167 |
-
"duration_hours": 2
|
| 168 |
-
"best_time": "
|
| 169 |
}}
|
| 170 |
]
|
| 171 |
|
| 172 |
-
|
| 173 |
"""
|
|
|
|
|
|
|
| 174 |
response = client.chat.completions.create(
|
| 175 |
model="gpt-3.5-turbo",
|
| 176 |
messages=[{"role": "user", "content": prompt}],
|
| 177 |
-
temperature=0.
|
| 178 |
-
max_tokens=
|
| 179 |
)
|
| 180 |
-
content = response.choices[0].message.content
|
| 181 |
-
|
| 182 |
-
#
|
| 183 |
-
content =
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
content = content[3:]
|
| 188 |
-
if content.endswith('```'):
|
| 189 |
-
content = content[:-3]
|
| 190 |
-
|
| 191 |
attractions = json.loads(content)
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
elif not isinstance(attractions, list):
|
| 196 |
-
attractions = [attractions]
|
| 197 |
-
|
| 198 |
result = []
|
| 199 |
-
for a in attractions[:
|
| 200 |
fee = a.get("entry_fee", 0)
|
| 201 |
-
if
|
| 202 |
-
if fee == 0:
|
| 203 |
-
fee_display = "Free"
|
| 204 |
-
else:
|
| 205 |
-
fee_display = f"${fee}"
|
| 206 |
-
else:
|
| 207 |
-
fee_display = str(fee)
|
| 208 |
-
|
| 209 |
result.append({
|
| 210 |
-
"name": a.get("name", "
|
| 211 |
"entry_fee": fee_display,
|
| 212 |
"duration_hours": a.get("duration_hours", 2),
|
| 213 |
-
"description": a.get("description",
|
| 214 |
-
"best_time": a.get("best_time", "anytime")
|
| 215 |
})
|
| 216 |
-
|
| 217 |
-
#
|
| 218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
return result
|
| 220 |
-
|
| 221 |
except Exception as e:
|
| 222 |
print(f"OpenAI attractions error: {e}")
|
| 223 |
-
return
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
"paris": [
|
| 232 |
-
{"name": "Eiffel Tower", "description": "Iconic iron lattice tower offering breathtaking panoramic views of Paris
|
| 233 |
-
{"name": "Louvre Museum", "description": "World's largest art museum
|
| 234 |
-
{"name": "
|
| 235 |
-
{"name": "
|
| 236 |
-
{"name": "
|
| 237 |
-
{"name": "
|
| 238 |
],
|
| 239 |
"tokyo": [
|
| 240 |
-
{"name": "Senso-ji Temple", "description": "
|
| 241 |
-
{"name": "Shibuya Crossing", "description": "
|
| 242 |
-
{"name": "
|
| 243 |
-
{"name": "
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
}
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
|
|
|
| 249 |
return attractions
|
| 250 |
-
|
| 251 |
-
#
|
| 252 |
-
return [
|
| 253 |
-
|
| 254 |
-
{"name": f"{city} Main Square", "description": f"The central gathering place surrounded by beautiful buildings and local culture.", "entry_fee": "Free", "duration_hours": 1.5, "best_time": "afternoon"},
|
| 255 |
-
{"name": f"{city} Cathedral", "description": f"A magnificent religious site showcasing stunning architecture and centuries of history.", "entry_fee": "Free", "duration_hours": 1, "best_time": "morning"},
|
| 256 |
-
{"name": f"{city} Museum", "description": f"Discover the rich cultural heritage and artistic treasures of {city}.", "entry_fee": "$12", "duration_hours": 2, "best_time": "afternoon"}
|
| 257 |
-
]
|
| 258 |
|
| 259 |
def get_attractions(city: str) -> Dict:
|
| 260 |
"""Get attractions from cache or fetch new ones."""
|
| 261 |
city_clean = city.strip().lower()
|
| 262 |
if city_clean in ATTRACTIONS_CACHE:
|
| 263 |
return {"city": city, "attractions": ATTRACTIONS_CACHE[city_clean], "source": "cache"}
|
| 264 |
-
|
| 265 |
attractions = fetch_attractions_via_openai(city)
|
| 266 |
-
return {"city": city, "attractions": attractions, "source": "
|
|
|
|
| 267 |
|
| 268 |
# -------------------- BUDGET CALCULATION --------------------
|
| 269 |
def calculate_budget(num_days: int, budget_amount: float = None) -> Dict:
|
|
@@ -285,291 +348,297 @@ def calculate_budget(num_days: int, budget_amount: float = None) -> Dict:
|
|
| 285 |
else:
|
| 286 |
level = "moderate"
|
| 287 |
daily_rates = {"accommodation": 100, "food": 60, "transport": 25, "activities": 20}
|
| 288 |
-
|
| 289 |
-
accommodation = daily_rates["accommodation"] * num_days
|
| 290 |
-
food = daily_rates["food"] * num_days
|
| 291 |
-
transport = daily_rates["transport"] * num_days
|
| 292 |
-
activities = daily_rates["activities"] * num_days
|
| 293 |
-
total = accommodation + food + transport + activities
|
| 294 |
-
|
| 295 |
return {
|
| 296 |
"level": level,
|
| 297 |
-
"accommodation": accommodation,
|
| 298 |
-
"food": food,
|
| 299 |
-
"transport": transport,
|
| 300 |
-
"activities": activities,
|
| 301 |
-
"total":
|
| 302 |
-
"daily": daily_rates
|
| 303 |
}
|
| 304 |
|
|
|
|
| 305 |
# -------------------- ITINERARY GENERATION --------------------
|
| 306 |
-
def generate_itinerary(destination: str, start_date: str, num_days: int,
|
| 307 |
budget_amount: float, budget_currency: str, departure_city: str = ""):
|
| 308 |
-
"""Main itinerary generation function
|
| 309 |
try:
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
return "<div style='color: red; padding: 20px; text-align: center;'>❌ Please enter a destination city.</div>"
|
| 313 |
-
|
| 314 |
if num_days < 1 or num_days > 14:
|
| 315 |
-
return "<div style='color:
|
| 316 |
-
|
| 317 |
-
|
| 318 |
weather = get_weather(destination)
|
| 319 |
if "error" in weather:
|
| 320 |
-
return f"<div style='color:
|
| 321 |
-
|
| 322 |
attractions_data = get_attractions(destination)
|
| 323 |
attractions = attractions_data["attractions"]
|
| 324 |
-
|
| 325 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
budget_data = calculate_budget(num_days, budget_amount)
|
| 327 |
-
|
| 328 |
-
# Format dates
|
| 329 |
start = datetime.strptime(start_date, "%Y-%m-%d")
|
| 330 |
end = start + timedelta(days=int(num_days) - 1)
|
| 331 |
-
|
| 332 |
-
#
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
|
|
|
|
|
|
|
|
|
| 336 |
)
|
| 337 |
-
|
| 338 |
-
return html
|
| 339 |
-
|
| 340 |
except Exception as e:
|
| 341 |
-
return f"<div style='color:
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
weather_temp = f"{weather['temperature']:.1f}°C" if isinstance(weather['temperature'], (int, float)) else str(weather['temperature'])
|
| 350 |
-
weather_condition = weather[
|
| 351 |
-
|
| 352 |
-
# Budget warning
|
| 353 |
budget_warning = ""
|
| 354 |
-
if budget_amount and budget_data[
|
| 355 |
budget_warning = f"""
|
| 356 |
-
<div style="background:
|
| 357 |
-
<strong>⚠️ Budget Alert:</strong> Estimated costs (${budget_data['total']:.0f}) exceed your budget
|
| 358 |
-
|
| 359 |
-
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
"""
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
</div>
|
| 386 |
</div>
|
| 387 |
</div>
|
| 388 |
-
</div>
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
daily_html = ""
|
| 394 |
per_day = max(1, len(attractions) // max(1, num_days))
|
| 395 |
-
|
| 396 |
for day in range(1, num_days + 1):
|
| 397 |
-
current_date = start_date + timedelta(days=day-1)
|
| 398 |
date_str = current_date.strftime("%A, %B %d")
|
| 399 |
-
|
| 400 |
-
start_idx = (day-1) * per_day
|
| 401 |
end_idx = min(day * per_day, len(attractions))
|
| 402 |
day_attractions = attractions[start_idx:end_idx]
|
| 403 |
-
|
|
|
|
|
|
|
| 404 |
if day_attractions:
|
| 405 |
daily_html += f"""
|
| 406 |
-
<div style="background:
|
| 407 |
-
|
| 408 |
-
|
|
|
|
|
|
|
| 409 |
</div>
|
| 410 |
-
<div style="padding:
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
daily_html += f"""
|
| 416 |
-
<div style="display: flex; gap: 15px; margin-bottom: 20px; padding: 10px; background: #f8f9fa; border-radius: 8px;">
|
| 417 |
-
<img src="{img_url}" style="width: 80px; height: 80px; object-fit: cover; border-radius: 8px;" alt="{attr['name']}">
|
| 418 |
-
<div style="flex: 1;">
|
| 419 |
-
<strong>{attr['name']}</strong><br>
|
| 420 |
-
<span style="font-size: 0.85em; color: #666;">{attr['description'][:100]}...</span>
|
| 421 |
-
<div style="font-size: 0.75em; color: #888; margin-top: 5px;">
|
| 422 |
-
⏱️ {attr['duration_hours']} hrs | 🎟️ {attr['entry_fee']}
|
| 423 |
-
</div>
|
| 424 |
-
</div>
|
| 425 |
-
</div>
|
| 426 |
-
"""
|
| 427 |
-
|
| 428 |
-
daily_html += f"""
|
| 429 |
-
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #e0e0e0;">
|
| 430 |
-
<div style="margin-bottom: 10px;">
|
| 431 |
-
<span style="font-size: 1.1em;">🍽️</span> <strong>Lunch Recommendation:</strong> Try authentic local cuisine at a nearby restaurant
|
| 432 |
-
</div>
|
| 433 |
-
<div>
|
| 434 |
-
<span style="font-size: 1.1em;">🌙</span> <strong>Evening Activity:</strong> Explore local markets, enjoy a cultural show, or relax at a cafe
|
| 435 |
-
</div>
|
| 436 |
</div>
|
| 437 |
</div>
|
| 438 |
-
</div>
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
# Budget breakdown
|
| 442 |
-
level_names = {"budget": "Budget", "moderate": "Moderate", "comfortable": "Comfortable", "luxury": "Luxury"}
|
| 443 |
-
level_display = level_names.get(budget_data['level'], "Moderate")
|
| 444 |
-
|
| 445 |
-
budget_html = f"""
|
| 446 |
-
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 12px; color: white; margin: 20px 0;">
|
| 447 |
-
<h3 style="margin: 0 0 15px 0;">💰 Budget Breakdown ({level_display} Travel Style)</h3>
|
| 448 |
-
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 15px;">
|
| 449 |
-
<div>
|
| 450 |
-
<strong>🏨 Accommodation</strong><br>
|
| 451 |
-
${budget_data['accommodation']:.0f}<br>
|
| 452 |
-
<small>(${budget_data['daily']['accommodation']}/day)</small>
|
| 453 |
-
</div>
|
| 454 |
-
<div>
|
| 455 |
-
<strong>🍽️ Food & Dining</strong><br>
|
| 456 |
-
${budget_data['food']:.0f}<br>
|
| 457 |
-
<small>(${budget_data['daily']['food']}/day)</small>
|
| 458 |
-
</div>
|
| 459 |
-
<div>
|
| 460 |
-
<strong>🚗 Local Transport</strong><br>
|
| 461 |
-
${budget_data['transport']:.0f}<br>
|
| 462 |
-
<small>(${budget_data['daily']['transport']}/day)</small>
|
| 463 |
-
</div>
|
| 464 |
-
<div>
|
| 465 |
-
<strong>🎟️ Activities & Tours</strong><br>
|
| 466 |
-
${budget_data['activities']:.0f}<br>
|
| 467 |
-
<small>(${budget_data['daily']['activities']}/day)</small>
|
| 468 |
-
</div>
|
| 469 |
-
<div style="border-top: 2px solid rgba(255,255,255,0.3); padding-top: 10px; grid-column: 1/-1;">
|
| 470 |
-
<strong>💰 Total Estimated Cost</strong><br>
|
| 471 |
-
<span style="font-size: 1.2em;">${budget_data['total']:.0f}</span>
|
| 472 |
-
{f" (Your budget: ${budget_amount:.0f})" if budget_amount else ""}
|
| 473 |
-
</div>
|
| 474 |
-
</div>
|
| 475 |
-
</div>
|
| 476 |
-
"""
|
| 477 |
-
|
| 478 |
-
# Travel tips with icons
|
| 479 |
tips_html = """
|
| 480 |
-
<div style="background:
|
| 481 |
-
<h3 style="margin:
|
| 482 |
-
<div style="display:
|
| 483 |
-
<div>🎫 <strong>Book in Advance</strong><br>Save time and money by booking popular
|
| 484 |
-
<div>🚇 <strong>Public Transport</strong><br>
|
| 485 |
-
<div>📱 <strong>Offline Maps</strong><br>Download Google Maps offline
|
| 486 |
-
<div>💵 <strong>Local Currency</strong><br>Carry cash for markets and
|
| 487 |
-
<div>🌍 <strong>Learn Basic Phrases</strong><br>A few local words
|
| 488 |
-
<div>📸 <strong>
|
| 489 |
-
</div>
|
| 490 |
-
</div>
|
| 491 |
-
"""
|
| 492 |
-
|
| 493 |
-
# Complete HTML
|
| 494 |
-
full_html = f"""
|
| 495 |
-
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; max-width: 100%;">
|
| 496 |
-
<!-- Hero Section -->
|
| 497 |
-
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px; border-radius: 20px; color: white; margin-bottom: 30px; text-align: center;">
|
| 498 |
-
<h1 style="margin: 0; font-size: 2.5em;">�� {destination}</h1>
|
| 499 |
-
<p style="margin: 10px 0 0; opacity: 0.9; font-size: 1.1em;">
|
| 500 |
-
{num_days} Days of Adventure • {start_date.strftime('%B %d')} - {end_date.strftime('%B %d, %Y')}
|
| 501 |
-
</p>
|
| 502 |
-
{f"<p style='margin: 5px 0 0; opacity: 0.8;'>✈️ From: {departure_city}</p>" if departure_city else ""}
|
| 503 |
-
</div>
|
| 504 |
-
|
| 505 |
-
<!-- Quick Stats -->
|
| 506 |
-
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 30px;">
|
| 507 |
-
<div style="background: #f8f9fa; padding: 15px; border-radius: 12px; text-align: center;">
|
| 508 |
-
<div style="font-size: 2em;">🌤️</div>
|
| 509 |
-
<strong>{weather_temp}</strong><br>
|
| 510 |
-
<small>{weather_condition}</small>
|
| 511 |
-
</div>
|
| 512 |
-
<div style="background: #f8f9fa; padding: 15px; border-radius: 12px; text-align: center;">
|
| 513 |
-
<div style="font-size: 2em;">📅</div>
|
| 514 |
-
<strong>{num_days} Days</strong><br>
|
| 515 |
-
<small>Full Itinerary</small>
|
| 516 |
-
</div>
|
| 517 |
-
<div style="background: #f8f9fa; padding: 15px; border-radius: 12px; text-align: center;">
|
| 518 |
-
<div style="font-size: 2em;">🎯</div>
|
| 519 |
-
<strong>{len(attractions)}+ Attractions</strong><br>
|
| 520 |
-
<small>To Explore</small>
|
| 521 |
-
</div>
|
| 522 |
-
<div style="background: #f8f9fa; padding: 15px; border-radius: 12px; text-align: center;">
|
| 523 |
-
<div style="font-size: 2em;">💰</div>
|
| 524 |
-
<strong>${budget_data['total']:.0f}</strong><br>
|
| 525 |
-
<small>Estimated Total</small>
|
| 526 |
-
</div>
|
| 527 |
</div>
|
| 528 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 529 |
{budget_warning}
|
| 530 |
-
|
| 531 |
-
<!-- Budget Section -->
|
| 532 |
{budget_html}
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
<div style="background: white; padding: 20px; border-radius: 12px; margin: 20px 0; box-shadow: 0 2px 10px rgba(0,0,0,0.05);">
|
| 536 |
-
<h2 style="margin: 0 0 15px 0; color: #667eea;">✨ Top Attractions in {destination}</h2>
|
| 537 |
{attractions_html}
|
| 538 |
</div>
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
<div style="background: white; padding: 20px; border-radius: 12px; margin: 20px 0; box-shadow: 0 2px 10px rgba(0,0,0,0.05);">
|
| 542 |
-
<h2 style="margin: 0 0 15px 0; color: #667eea;">📅 Your {num_days}-Day Itinerary</h2>
|
| 543 |
{daily_html}
|
| 544 |
</div>
|
| 545 |
-
|
| 546 |
-
<!-- Travel Tips -->
|
| 547 |
{tips_html}
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
<p>
|
| 553 |
-
🛏️ <a href="https://www.booking.com/searchresults.html?ss={destination.replace(' ', '+')}" target="_blank" style="color: #667eea; text-decoration: none;">Search Hotels</a> |
|
| 554 |
-
✈️ <a href="https://www.skyscanner.net/transport/flights/{departure_city.lower() if departure_city else ''}/{destination.lower()}/" target="_blank" style="color: #667eea; text-decoration: none;">Search Flights</a> |
|
| 555 |
-
🎟️ <a href="https://www.tripadvisor.com/Search?q={destination}" target="_blank" style="color: #667eea; text-decoration: none;">Read Reviews</a>
|
| 556 |
-
</p>
|
| 557 |
-
</div>
|
| 558 |
-
|
| 559 |
-
<!-- Footer -->
|
| 560 |
-
<div style="text-align: center; padding: 20px; margin-top: 20px; font-size: 0.85em; color: #666;">
|
| 561 |
-
<p>✨ TravelBuddy AI • Powered by OpenAI • Real-time Weather • Beautiful Images • Smart Recommendations</p>
|
| 562 |
-
<p>🌍 Plan your perfect adventure with confidence</p>
|
| 563 |
-
</div>
|
| 564 |
-
</div>
|
| 565 |
-
"""
|
| 566 |
-
|
| 567 |
-
return full_html
|
| 568 |
|
| 569 |
# -------------------- GRADIO INTERFACE --------------------
|
| 570 |
css = """
|
| 571 |
.gradio-container {
|
| 572 |
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
| 573 |
max-width: 1200px;
|
| 574 |
margin: 0 auto;
|
| 575 |
}
|
|
@@ -577,44 +646,31 @@ css = """
|
|
| 577 |
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
|
| 578 |
border: none !important;
|
| 579 |
font-weight: bold !important;
|
| 580 |
-
padding: 12px 30px !important;
|
| 581 |
font-size: 1.1em !important;
|
| 582 |
-
transition: transform 0.2s !important;
|
| 583 |
-
}
|
| 584 |
-
.gr-button-primary:hover {
|
| 585 |
-
transform: translateY(-2px) !important;
|
| 586 |
-
}
|
| 587 |
-
input, select, textarea {
|
| 588 |
-
border-radius: 8px !important;
|
| 589 |
-
border: 1px solid #e0e0e0 !important;
|
| 590 |
-
}
|
| 591 |
-
label {
|
| 592 |
-
font-weight: 500 !important;
|
| 593 |
-
color: #333 !important;
|
| 594 |
}
|
|
|
|
|
|
|
| 595 |
"""
|
| 596 |
|
| 597 |
with gr.Blocks(css=css, title="✨ TravelBuddy AI - Smart Travel Planner") as demo:
|
| 598 |
gr.HTML("""
|
| 599 |
-
<div style="text-align:
|
| 600 |
-
|
| 601 |
-
<
|
| 602 |
-
|
|
|
|
| 603 |
</p>
|
| 604 |
-
</div>
|
| 605 |
-
|
| 606 |
-
|
| 607 |
with gr.Row(equal_height=True):
|
| 608 |
with gr.Column(scale=2):
|
| 609 |
with gr.Group():
|
| 610 |
gr.Markdown("### 🎯 Where's Your Next Adventure?")
|
| 611 |
destination = gr.Textbox(
|
| 612 |
label="Destination",
|
| 613 |
-
placeholder="e.g., Paris, Tokyo,
|
| 614 |
-
lines=1,
|
| 615 |
-
show_label=False
|
| 616 |
)
|
| 617 |
-
|
| 618 |
with gr.Group():
|
| 619 |
gr.Markdown("### 📅 When Are You Traveling?")
|
| 620 |
with gr.Row():
|
|
@@ -624,70 +680,71 @@ with gr.Blocks(css=css, title="✨ TravelBuddy AI - Smart Travel Planner") as de
|
|
| 624 |
placeholder="YYYY-MM-DD"
|
| 625 |
)
|
| 626 |
num_days = gr.Slider(
|
| 627 |
-
label="Duration (Days)",
|
| 628 |
-
minimum=1,
|
| 629 |
-
maximum=14,
|
| 630 |
-
value=3,
|
| 631 |
-
step=1
|
| 632 |
)
|
| 633 |
-
|
| 634 |
with gr.Column(scale=1):
|
| 635 |
with gr.Group():
|
| 636 |
gr.Markdown("### 💰 Your Budget")
|
| 637 |
with gr.Row():
|
| 638 |
budget_amount = gr.Number(
|
| 639 |
-
label="Total Budget (Optional)",
|
| 640 |
-
placeholder="Enter amount",
|
| 641 |
-
value=None
|
| 642 |
)
|
| 643 |
budget_currency = gr.Dropdown(
|
| 644 |
-
["USD", "EUR", "GBP", "JPY", "
|
| 645 |
-
|
| 646 |
-
value="USD"
|
| 647 |
)
|
| 648 |
gr.HTML("""
|
| 649 |
-
<div style="background:
|
| 650 |
-
<small>💡
|
| 651 |
-
</div>
|
| 652 |
-
""")
|
| 653 |
-
|
| 654 |
with gr.Group():
|
| 655 |
gr.Markdown("### ✈️ Departure Info")
|
| 656 |
departure_city = gr.Textbox(
|
| 657 |
label="Departure City (Optional)",
|
| 658 |
-
placeholder="e.g.,
|
| 659 |
lines=1
|
| 660 |
)
|
| 661 |
-
|
| 662 |
with gr.Row():
|
| 663 |
-
generate_btn = gr.Button(
|
| 664 |
-
|
|
|
|
|
|
|
| 665 |
output = gr.HTML()
|
| 666 |
-
|
| 667 |
generate_btn.click(
|
| 668 |
fn=generate_itinerary,
|
| 669 |
inputs=[destination, start_date, num_days, budget_amount, budget_currency, departure_city],
|
| 670 |
-
outputs=output
|
| 671 |
)
|
| 672 |
-
|
| 673 |
-
# Examples section
|
| 674 |
gr.HTML("""
|
| 675 |
-
<div style="text-align:
|
| 676 |
<h3>🌟 Popular Destinations to Try</h3>
|
| 677 |
-
<div style="display:
|
| 678 |
-
<button onclick="document.querySelector('
|
| 679 |
-
|
| 680 |
-
<button onclick="document.querySelector('
|
| 681 |
-
|
| 682 |
-
<button onclick="document.querySelector('
|
| 683 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 684 |
</div>
|
| 685 |
</div>
|
| 686 |
-
|
| 687 |
-
<div style="text-align:
|
| 688 |
-
<small>Powered by OpenAI
|
| 689 |
-
</div>
|
| 690 |
-
""")
|
| 691 |
|
| 692 |
if __name__ == "__main__":
|
| 693 |
-
demo.launch(share=False, server_name="0.0.0.0")
|
|
|
|
| 2 |
import json
|
| 3 |
import requests
|
| 4 |
from datetime import datetime, timedelta
|
|
|
|
| 5 |
import gradio as gr
|
| 6 |
import openai
|
| 7 |
import re
|
|
|
|
| 11 |
# -------------------- CONFIGURATION --------------------
|
| 12 |
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
| 13 |
OPENWEATHER_API_KEY = os.getenv("OPENWEATHER_API_KEY", "")
|
|
|
|
| 14 |
|
| 15 |
if not OPENAI_API_KEY:
|
| 16 |
+
print("⚠️ OPENAI_API_KEY not set. AI itinerary generation will fall back to curated data.")
|
| 17 |
client = None
|
| 18 |
else:
|
| 19 |
client = openai.OpenAI(api_key=OPENAI_API_KEY)
|
| 20 |
|
| 21 |
+
# Global caches
|
| 22 |
ATTRACTIONS_CACHE = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
IMAGE_CACHE = {}
|
| 24 |
|
| 25 |
+
# -------------------- REAL IMAGE FUNCTIONS --------------------
|
| 26 |
+
|
| 27 |
+
def get_wikimedia_image(attraction_name: str, city: str) -> str | None:
|
| 28 |
+
"""
|
| 29 |
+
Fetch a REAL image from Wikipedia/Wikimedia Commons.
|
| 30 |
+
Returns a URL string if found, None if not available.
|
| 31 |
+
Never returns a fake or placeholder image.
|
| 32 |
+
"""
|
| 33 |
+
cache_key = f"{attraction_name}_{city}".lower().replace(" ", "_")
|
| 34 |
if cache_key in IMAGE_CACHE:
|
| 35 |
return IMAGE_CACHE[cache_key]
|
| 36 |
+
|
| 37 |
+
# Try searching Wikipedia for the attraction
|
| 38 |
+
search_queries = [
|
| 39 |
+
attraction_name,
|
| 40 |
+
f"{attraction_name} {city}",
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
for query in search_queries:
|
| 44 |
try:
|
| 45 |
+
# Step 1: Search Wikipedia for the page
|
| 46 |
+
search_url = "https://en.wikipedia.org/w/api.php"
|
| 47 |
+
search_params = {
|
| 48 |
+
"action": "query",
|
| 49 |
+
"list": "search",
|
| 50 |
+
"srsearch": query,
|
| 51 |
+
"srlimit": 1,
|
| 52 |
+
"format": "json",
|
| 53 |
+
"origin": "*"
|
| 54 |
}
|
| 55 |
+
resp = requests.get(search_url, params=search_params, timeout=8)
|
| 56 |
+
if resp.status_code != 200:
|
| 57 |
+
continue
|
| 58 |
+
|
| 59 |
+
results = resp.json().get("query", {}).get("search", [])
|
| 60 |
+
if not results:
|
| 61 |
+
continue
|
| 62 |
+
|
| 63 |
+
page_title = results[0]["title"]
|
| 64 |
+
|
| 65 |
+
# Step 2: Get the main image for that Wikipedia page
|
| 66 |
+
image_params = {
|
| 67 |
+
"action": "query",
|
| 68 |
+
"titles": page_title,
|
| 69 |
+
"prop": "pageimages",
|
| 70 |
+
"pithumbsize": 800,
|
| 71 |
+
"format": "json",
|
| 72 |
+
"origin": "*"
|
| 73 |
+
}
|
| 74 |
+
img_resp = requests.get(search_url, params=image_params, timeout=8)
|
| 75 |
+
if img_resp.status_code != 200:
|
| 76 |
+
continue
|
| 77 |
+
|
| 78 |
+
pages = img_resp.json().get("query", {}).get("pages", {})
|
| 79 |
+
for page in pages.values():
|
| 80 |
+
thumbnail = page.get("thumbnail", {})
|
| 81 |
+
if thumbnail.get("source"):
|
| 82 |
+
url = thumbnail["source"]
|
| 83 |
+
IMAGE_CACHE[cache_key] = url
|
| 84 |
+
return url
|
| 85 |
+
|
| 86 |
+
except Exception:
|
| 87 |
+
continue
|
| 88 |
+
|
| 89 |
+
# No real image found — return None (never fake)
|
| 90 |
+
IMAGE_CACHE[cache_key] = None
|
| 91 |
+
return None
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def get_city_hero_image(city: str) -> str | None:
|
| 95 |
+
"""Get a real Wikipedia image for the city itself as a hero/banner."""
|
| 96 |
+
cache_key = f"city_hero_{city}".lower().replace(" ", "_")
|
| 97 |
+
if cache_key in IMAGE_CACHE:
|
| 98 |
+
return IMAGE_CACHE[cache_key]
|
| 99 |
+
|
| 100 |
+
try:
|
| 101 |
+
search_url = "https://en.wikipedia.org/w/api.php"
|
| 102 |
+
image_params = {
|
| 103 |
+
"action": "query",
|
| 104 |
+
"titles": city,
|
| 105 |
+
"prop": "pageimages",
|
| 106 |
+
"pithumbsize": 1200,
|
| 107 |
+
"format": "json",
|
| 108 |
+
"origin": "*"
|
| 109 |
+
}
|
| 110 |
+
resp = requests.get(search_url, params=image_params, timeout=8)
|
| 111 |
+
if resp.status_code == 200:
|
| 112 |
+
pages = resp.json().get("query", {}).get("pages", {})
|
| 113 |
+
for page in pages.values():
|
| 114 |
+
thumbnail = page.get("thumbnail", {})
|
| 115 |
+
if thumbnail.get("source"):
|
| 116 |
+
url = thumbnail["source"]
|
| 117 |
+
IMAGE_CACHE[cache_key] = url
|
| 118 |
+
return url
|
| 119 |
+
except Exception:
|
| 120 |
+
pass
|
| 121 |
+
|
| 122 |
+
IMAGE_CACHE[cache_key] = None
|
| 123 |
+
return None
|
| 124 |
+
|
| 125 |
|
| 126 |
# -------------------- WEATHER FUNCTION --------------------
|
| 127 |
def get_weather(city: str) -> dict:
|
|
|
|
| 134 |
"humidity": 60,
|
| 135 |
"wind_speed": 10,
|
| 136 |
"precipitation": 0,
|
| 137 |
+
"note": "Demo mode – add OpenWeather API key for real data"
|
| 138 |
}
|
| 139 |
try:
|
| 140 |
url = "https://api.openweathermap.org/data/2.5/weather"
|
| 141 |
+
params = {"q": city, "appid": OPENWEATHER_API_KEY, "units": "metric"}
|
| 142 |
response = requests.get(url, params=params, timeout=10)
|
| 143 |
data = response.json()
|
| 144 |
if response.status_code != 200:
|
| 145 |
return {"error": f"Weather API error: {data.get('message', 'unknown')}"}
|
|
|
|
| 146 |
return {
|
| 147 |
"city": city,
|
| 148 |
+
"temperature": data["main"]["temp"],
|
| 149 |
+
"feels_like": data["main"]["feels_like"],
|
| 150 |
+
"condition": data["weather"][0]["description"],
|
| 151 |
+
"humidity": data["main"]["humidity"],
|
| 152 |
+
"wind_speed": data["wind"]["speed"],
|
| 153 |
+
"precipitation": data.get("rain", {}).get("1h", 0),
|
| 154 |
}
|
| 155 |
except Exception as e:
|
| 156 |
return {"error": f"Weather service unavailable: {str(e)}"}
|
| 157 |
|
| 158 |
+
|
| 159 |
+
# -------------------- ATTRACTIONS VIA OPENAI --------------------
|
| 160 |
def fetch_attractions_via_openai(city: str) -> List[Dict]:
|
| 161 |
+
"""
|
| 162 |
+
Fetch REAL, specific attractions for ANY city using OpenAI.
|
| 163 |
+
The prompt strictly forbids generic placeholders.
|
| 164 |
+
"""
|
| 165 |
if not client:
|
| 166 |
+
return get_curated_fallback(city)
|
| 167 |
+
|
| 168 |
+
prompt = f"""
|
| 169 |
+
You are an expert travel guide with deep knowledge of {city}.
|
| 170 |
+
|
| 171 |
+
List the 6 most famous and iconic real tourist attractions in {city}.
|
| 172 |
+
|
| 173 |
+
STRICT RULES:
|
| 174 |
+
- Use ONLY real, named, well-known attractions that actually exist in {city}.
|
| 175 |
+
- Do NOT use generic names like "City Center", "Main Square", "Cathedral", "Museum", or "{city} [generic word]".
|
| 176 |
+
- Each attraction must be a specific, recognizable landmark or site.
|
| 177 |
+
- Entry fees should be realistic for {city} in USD (0 for free).
|
| 178 |
+
|
| 179 |
+
Return ONLY a JSON array (no markdown, no extra text) with this exact structure:
|
|
|
|
| 180 |
[
|
| 181 |
{{
|
| 182 |
+
"name": "Exact Real Attraction Name",
|
| 183 |
+
"description": "Vivid 2-sentence description of this specific place.",
|
| 184 |
+
"entry_fee": 0,
|
| 185 |
+
"duration_hours": 2,
|
| 186 |
+
"best_time": "morning"
|
| 187 |
}}
|
| 188 |
]
|
| 189 |
|
| 190 |
+
best_time must be one of: morning, afternoon, evening, anytime.
|
| 191 |
"""
|
| 192 |
+
|
| 193 |
+
try:
|
| 194 |
response = client.chat.completions.create(
|
| 195 |
model="gpt-3.5-turbo",
|
| 196 |
messages=[{"role": "user", "content": prompt}],
|
| 197 |
+
temperature=0.5,
|
| 198 |
+
max_tokens=1800,
|
| 199 |
)
|
| 200 |
+
content = response.choices[0].message.content.strip()
|
| 201 |
+
|
| 202 |
+
# Strip markdown fences if present
|
| 203 |
+
content = re.sub(r"^```json\s*", "", content)
|
| 204 |
+
content = re.sub(r"^```\s*", "", content)
|
| 205 |
+
content = re.sub(r"\s*```$", "", content)
|
| 206 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
attractions = json.loads(content)
|
| 208 |
+
if isinstance(attractions, dict):
|
| 209 |
+
attractions = attractions.get("attractions", [attractions])
|
| 210 |
+
|
|
|
|
|
|
|
|
|
|
| 211 |
result = []
|
| 212 |
+
for a in attractions[:6]:
|
| 213 |
fee = a.get("entry_fee", 0)
|
| 214 |
+
fee_display = "Free" if fee == 0 else f"${fee}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
result.append({
|
| 216 |
+
"name": a.get("name", ""),
|
| 217 |
"entry_fee": fee_display,
|
| 218 |
"duration_hours": a.get("duration_hours", 2),
|
| 219 |
+
"description": a.get("description", ""),
|
| 220 |
+
"best_time": a.get("best_time", "anytime"),
|
| 221 |
})
|
| 222 |
+
|
| 223 |
+
# Filter out any that look generic (safety net)
|
| 224 |
+
generic_keywords = ["city center", "main square", "cathedral", "museum", "old town", "market"]
|
| 225 |
+
result = [
|
| 226 |
+
a for a in result
|
| 227 |
+
if not any(
|
| 228 |
+
a["name"].lower() == f"{city.lower()} {kw}" or a["name"].lower() == kw
|
| 229 |
+
for kw in generic_keywords
|
| 230 |
+
)
|
| 231 |
+
]
|
| 232 |
+
|
| 233 |
+
ATTRACTIONS_CACHE[city.strip().lower()] = result
|
| 234 |
return result
|
| 235 |
+
|
| 236 |
except Exception as e:
|
| 237 |
print(f"OpenAI attractions error: {e}")
|
| 238 |
+
return get_curated_fallback(city)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def get_curated_fallback(city: str) -> List[Dict]:
|
| 242 |
+
"""
|
| 243 |
+
Curated real attractions for major cities when OpenAI is unavailable.
|
| 244 |
+
Only real named places — no generics.
|
| 245 |
+
"""
|
| 246 |
+
curated = {
|
| 247 |
+
"nairobi": [
|
| 248 |
+
{"name": "Nairobi National Park", "description": "The world's only national park within a capital city, home to lions, rhinos, giraffes and over 400 bird species against a dramatic city skyline backdrop.", "entry_fee": "$60", "duration_hours": 4, "best_time": "morning"},
|
| 249 |
+
{"name": "David Sheldrick Wildlife Trust", "description": "World-famous elephant orphanage where you can watch baby elephants being fed and bathed — an unforgettable wildlife encounter.", "entry_fee": "$7", "duration_hours": 1, "best_time": "morning"},
|
| 250 |
+
{"name": "Giraffe Centre", "description": "A sanctuary for endangered Rothschild giraffes where you can hand-feed them from an elevated platform just inches from their faces.", "entry_fee": "$15", "duration_hours": 1, "best_time": "morning"},
|
| 251 |
+
{"name": "Karen Blixen Museum", "description": "The beautifully preserved farmhouse of 'Out of Africa' author Karen Blixen, offering a glimpse into colonial Kenya's history and culture.", "entry_fee": "$10", "duration_hours": 1.5, "best_time": "afternoon"},
|
| 252 |
+
{"name": "Nairobi National Museum", "description": "Kenya's flagship museum showcasing natural history, cultural heritage, prehistoric fossils and stunning art collections.", "entry_fee": "$12", "duration_hours": 2, "best_time": "afternoon"},
|
| 253 |
+
{"name": "Bomas of Kenya", "description": "A vibrant cultural center featuring traditional homesteads of Kenya's 42 tribes and daily performances of traditional music and dance.", "entry_fee": "$8", "duration_hours": 2, "best_time": "afternoon"},
|
| 254 |
+
],
|
| 255 |
"paris": [
|
| 256 |
+
{"name": "Eiffel Tower", "description": "Iconic iron lattice tower offering breathtaking panoramic views of Paris from three observation levels. Built in 1889, it remains the world's most visited paid monument.", "entry_fee": "$25", "duration_hours": 2.5, "best_time": "evening"},
|
| 257 |
+
{"name": "Louvre Museum", "description": "World's largest art museum housing over 35,000 works including the Mona Lisa and Venus de Milo, set in a stunning 12th-century palace.", "entry_fee": "$20", "duration_hours": 4, "best_time": "morning"},
|
| 258 |
+
{"name": "Musée d'Orsay", "description": "Housed in a stunning Beaux-Arts railway station, this museum holds the world's finest collection of Impressionist and Post-Impressionist art.", "entry_fee": "$16", "duration_hours": 3, "best_time": "morning"},
|
| 259 |
+
{"name": "Sacré-Cœur Basilica", "description": "Magnificent white-domed basilica crowning Montmartre hill, offering panoramic views across Paris and a peaceful spiritual atmosphere.", "entry_fee": "Free", "duration_hours": 1.5, "best_time": "morning"},
|
| 260 |
+
{"name": "Palace of Versailles", "description": "Opulent royal palace with stunning Hall of Mirrors, magnificent formal gardens and the grandeur of French royal history.", "entry_fee": "$20", "duration_hours": 5, "best_time": "morning"},
|
| 261 |
+
{"name": "Seine River Cruise", "description": "Relaxing Bateaux Mouches cruise past Notre-Dame, the Louvre and the Eiffel Tower — Paris's landmarks look magical from the water.", "entry_fee": "$15", "duration_hours": 1, "best_time": "evening"},
|
| 262 |
],
|
| 263 |
"tokyo": [
|
| 264 |
+
{"name": "Senso-ji Temple", "description": "Tokyo's oldest and most famous Buddhist temple in Asakusa, with its iconic Thunder Gate and bustling Nakamise shopping street.", "entry_fee": "Free", "duration_hours": 2, "best_time": "morning"},
|
| 265 |
+
{"name": "Shibuya Crossing", "description": "The world's busiest pedestrian crossing, a mesmerizing spectacle of thousands of people crossing simultaneously in perfect choreography.", "entry_fee": "Free", "duration_hours": 1, "best_time": "evening"},
|
| 266 |
+
{"name": "Meiji Shrine", "description": "Serene Shinto shrine set in 70 hectares of forested parkland, dedicated to Emperor Meiji — a peaceful escape from city life.", "entry_fee": "Free", "duration_hours": 1.5, "best_time": "morning"},
|
| 267 |
+
{"name": "Tokyo Skytree", "description": "The world's second-tallest structure at 634m, offering unparalleled 360° views of Tokyo and on clear days, Mount Fuji.", "entry_fee": "$18", "duration_hours": 2, "best_time": "evening"},
|
| 268 |
+
{"name": "Tsukiji Outer Market", "description": "Famous seafood and food market where you can enjoy the freshest sushi breakfast, street food and browse unique Japanese kitchen goods.", "entry_fee": "Free", "duration_hours": 2, "best_time": "morning"},
|
| 269 |
+
{"name": "Shinjuku Gyoen National Garden", "description": "Stunning garden blending Japanese, French and English landscape styles — the most popular cherry blossom viewing spot in Tokyo.", "entry_fee": "$2", "duration_hours": 2, "best_time": "afternoon"},
|
| 270 |
+
],
|
| 271 |
+
"new york": [
|
| 272 |
+
{"name": "Statue of Liberty", "description": "America's most iconic landmark, a gift from France standing 93 meters tall on Liberty Island, welcoming visitors since 1886.", "entry_fee": "$24", "duration_hours": 4, "best_time": "morning"},
|
| 273 |
+
{"name": "Central Park", "description": "Manhattan's 843-acre green oasis with iconic landmarks, lakes, meadows and world-class attractions — New York's living room.", "entry_fee": "Free", "duration_hours": 3, "best_time": "morning"},
|
| 274 |
+
{"name": "The Metropolitan Museum of Art", "description": "One of the world's greatest art museums with over 2 million works spanning 5,000 years of history across 17 curatorial departments.", "entry_fee": "$30", "duration_hours": 4, "best_time": "morning"},
|
| 275 |
+
{"name": "Brooklyn Bridge", "description": "Iconic 1883 suspension bridge offering spectacular Manhattan skyline views — best experienced by walking across it.", "entry_fee": "Free", "duration_hours": 1, "best_time": "morning"},
|
| 276 |
+
{"name": "Times Square", "description": "The glittering 'Crossroads of the World' — an electrifying spectacle of towering billboards, Broadway theaters and constant energy.", "entry_fee": "Free", "duration_hours": 1.5, "best_time": "evening"},
|
| 277 |
+
{"name": "9/11 Memorial & Museum", "description": "A deeply moving tribute to the 2001 attacks, featuring twin reflecting pools where the towers once stood and a powerful underground museum.", "entry_fee": "$29", "duration_hours": 3, "best_time": "morning"},
|
| 278 |
+
],
|
| 279 |
+
"london": [
|
| 280 |
+
{"name": "Tower of London", "description": "Historic royal palace and fortress housing the Crown Jewels, with 1,000 years of history as a palace, prison and place of execution.", "entry_fee": "$32", "duration_hours": 3, "best_time": "morning"},
|
| 281 |
+
{"name": "British Museum", "description": "One of the world's greatest museums with over 8 million works spanning human history, including the Rosetta Stone and Elgin Marbles.", "entry_fee": "Free", "duration_hours": 3, "best_time": "morning"},
|
| 282 |
+
{"name": "Buckingham Palace", "description": "The official London residence of the British monarch, famous for the Changing of the Guard ceremony and State Rooms open in summer.", "entry_fee": "$30", "duration_hours": 2, "best_time": "morning"},
|
| 283 |
+
{"name": "Westminster Abbey", "description": "UNESCO-listed Gothic church where British monarchs are crowned and buried, with 1,000 years of royal history within its walls.", "entry_fee": "$28", "duration_hours": 2, "best_time": "morning"},
|
| 284 |
+
{"name": "Tate Modern", "description": "World-class modern and contemporary art gallery in a spectacular converted power station on the South Bank of the Thames.", "entry_fee": "Free", "duration_hours": 2.5, "best_time": "afternoon"},
|
| 285 |
+
{"name": "Hyde Park", "description": "London's most famous Royal Park covering 350 acres, home to the Serpentine Gallery, Speaker's Corner and the Diana Memorial Fountain.", "entry_fee": "Free", "duration_hours": 2, "best_time": "afternoon"},
|
| 286 |
+
],
|
| 287 |
+
"rome": [
|
| 288 |
+
{"name": "Colosseum", "description": "The world's greatest amphitheater, built in 70-80 AD and capable of holding 50,000 spectators for gladiatorial contests and public spectacles.", "entry_fee": "$18", "duration_hours": 3, "best_time": "morning"},
|
| 289 |
+
{"name": "Vatican Museums & Sistine Chapel", "description": "One of the world's most important art collections, culminating in Michelangelo's breathtaking ceiling fresco in the Sistine Chapel.", "entry_fee": "$20", "duration_hours": 4, "best_time": "morning"},
|
| 290 |
+
{"name": "Trevi Fountain", "description": "Rome's most famous Baroque fountain, where legend says tossing a coin guarantees your return to Rome — best visited at dawn.", "entry_fee": "Free", "duration_hours": 1, "best_time": "morning"},
|
| 291 |
+
{"name": "Roman Forum", "description": "The ancient heart of Rome, a sprawling ruined complex of temples, arches and government buildings that was once the center of the Roman Empire.", "entry_fee": "$18", "duration_hours": 2, "best_time": "morning"},
|
| 292 |
+
{"name": "Pantheon", "description": "The best-preserved ancient building in Rome, a 2,000-year-old temple with a remarkable unreinforced concrete dome and open oculus.", "entry_fee": "$6", "duration_hours": 1, "best_time": "morning"},
|
| 293 |
+
{"name": "Borghese Gallery", "description": "A world-class museum in a stunning villa park, housing a breathtaking collection of Bernini sculptures and Caravaggio paintings.", "entry_fee": "$15", "duration_hours": 2, "best_time": "afternoon"},
|
| 294 |
+
],
|
| 295 |
+
"bali": [
|
| 296 |
+
{"name": "Tanah Lot Temple", "description": "Bali's most iconic sea temple perched dramatically on a rocky outcrop in the ocean, best seen at sunset when the sky turns golden.", "entry_fee": "$4", "duration_hours": 2, "best_time": "evening"},
|
| 297 |
+
{"name": "Ubud Monkey Forest", "description": "A sacred sanctuary home to over 700 Balinese long-tailed macaques, set among ancient Hindu temples and lush tropical forest.", "entry_fee": "$5", "duration_hours": 1.5, "best_time": "morning"},
|
| 298 |
+
{"name": "Tegalalang Rice Terraces", "description": "Stunning UNESCO-recognized rice terraces north of Ubud, carved into the hillside with traditional Balinese subak irrigation — breathtaking at sunrise.", "entry_fee": "$2", "duration_hours": 2, "best_time": "morning"},
|
| 299 |
+
{"name": "Uluwatu Temple", "description": "Clifftop sea temple perched 70m above the Indian Ocean, famous for its nightly Kecak fire dance performances at sunset.", "entry_fee": "$3", "duration_hours": 2, "best_time": "evening"},
|
| 300 |
+
{"name": "Mount Batur", "description": "Active volcano offering a spectacular pre-dawn trek rewarded with breathtaking sunrise views over the caldera lake and surrounding mountains.", "entry_fee": "$40", "duration_hours": 6, "best_time": "morning"},
|
| 301 |
+
{"name": "Besakih Temple", "description": "Bali's most important and largest Hindu temple, the 'Mother Temple' complex of 23 temples sprawling up the slopes of Mount Agung.", "entry_fee": "$15", "duration_hours": 3, "best_time": "morning"},
|
| 302 |
+
],
|
| 303 |
+
"bangkok": [
|
| 304 |
+
{"name": "Wat Pho (Temple of the Reclining Buddha)", "description": "Home to a magnificent 46-meter gilded reclining Buddha statue and one of Bangkok's oldest temples, also the birthplace of traditional Thai massage.", "entry_fee": "$4", "duration_hours": 1.5, "best_time": "morning"},
|
| 305 |
+
{"name": "Grand Palace", "description": "Bangkok's most dazzling complex of ornate buildings, temples and halls that served as the official residence of Thai kings since 1782.", "entry_fee": "$15", "duration_hours": 3, "best_time": "morning"},
|
| 306 |
+
{"name": "Wat Arun (Temple of Dawn)", "description": "Stunning riverside temple adorned with colorful porcelain mosaic spires — most spectacular at dawn and dusk from across the Chao Phraya.", "entry_fee": "$2", "duration_hours": 1.5, "best_time": "morning"},
|
| 307 |
+
{"name": "Chatuchak Weekend Market", "description": "One of the world's largest markets with over 15,000 stalls selling everything from street food to antiques, clothing and exotic pets.", "entry_fee": "Free", "duration_hours": 3, "best_time": "morning"},
|
| 308 |
+
{"name": "Chao Phraya River Cruise", "description": "Scenic boat ride along Bangkok's 'River of Kings', passing temples, palaces and traditional wooden houses on stilts.", "entry_fee": "$10", "duration_hours": 2, "best_time": "evening"},
|
| 309 |
+
{"name": "Jim Thompson House", "description": "Beautifully preserved traditional Thai house museum of the American silk entrepreneur, showcasing Asian art and antiquities in a lush garden setting.", "entry_fee": "$6", "duration_hours": 1.5, "best_time": "afternoon"},
|
| 310 |
+
],
|
| 311 |
}
|
| 312 |
+
|
| 313 |
+
city_lower = city.strip().lower()
|
| 314 |
+
for key, attractions in curated.items():
|
| 315 |
+
if key in city_lower or city_lower in key:
|
| 316 |
return attractions
|
| 317 |
+
|
| 318 |
+
# If city not in curated list and OpenAI is unavailable, return empty with message
|
| 319 |
+
return []
|
| 320 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
|
| 322 |
def get_attractions(city: str) -> Dict:
|
| 323 |
"""Get attractions from cache or fetch new ones."""
|
| 324 |
city_clean = city.strip().lower()
|
| 325 |
if city_clean in ATTRACTIONS_CACHE:
|
| 326 |
return {"city": city, "attractions": ATTRACTIONS_CACHE[city_clean], "source": "cache"}
|
|
|
|
| 327 |
attractions = fetch_attractions_via_openai(city)
|
| 328 |
+
return {"city": city, "attractions": attractions, "source": "api"}
|
| 329 |
+
|
| 330 |
|
| 331 |
# -------------------- BUDGET CALCULATION --------------------
|
| 332 |
def calculate_budget(num_days: int, budget_amount: float = None) -> Dict:
|
|
|
|
| 348 |
else:
|
| 349 |
level = "moderate"
|
| 350 |
daily_rates = {"accommodation": 100, "food": 60, "transport": 25, "activities": 20}
|
| 351 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
return {
|
| 353 |
"level": level,
|
| 354 |
+
"accommodation": daily_rates["accommodation"] * num_days,
|
| 355 |
+
"food": daily_rates["food"] * num_days,
|
| 356 |
+
"transport": daily_rates["transport"] * num_days,
|
| 357 |
+
"activities": daily_rates["activities"] * num_days,
|
| 358 |
+
"total": sum(daily_rates[k] * num_days for k in daily_rates),
|
| 359 |
+
"daily": daily_rates,
|
| 360 |
}
|
| 361 |
|
| 362 |
+
|
| 363 |
# -------------------- ITINERARY GENERATION --------------------
|
| 364 |
+
def generate_itinerary(destination: str, start_date: str, num_days: int,
|
| 365 |
budget_amount: float, budget_currency: str, departure_city: str = ""):
|
| 366 |
+
"""Main itinerary generation function."""
|
| 367 |
try:
|
| 368 |
+
if not destination or not destination.strip():
|
| 369 |
+
return "<div style='color:red;padding:20px;text-align:center;'>❌ Please enter a destination city.</div>"
|
|
|
|
|
|
|
| 370 |
if num_days < 1 or num_days > 14:
|
| 371 |
+
return "<div style='color:red;padding:20px;text-align:center;'>❌ Number of days must be between 1 and 14.</div>"
|
| 372 |
+
|
| 373 |
+
destination = destination.strip()
|
| 374 |
weather = get_weather(destination)
|
| 375 |
if "error" in weather:
|
| 376 |
+
return f"<div style='color:red;padding:20px;text-align:center;'>❌ {weather['error']}</div>"
|
| 377 |
+
|
| 378 |
attractions_data = get_attractions(destination)
|
| 379 |
attractions = attractions_data["attractions"]
|
| 380 |
+
|
| 381 |
+
if not attractions:
|
| 382 |
+
return f"""
|
| 383 |
+
<div style='color:#856404;background:#fff3cd;border:1px solid #ffc107;padding:20px;border-radius:12px;text-align:center;'>
|
| 384 |
+
⚠️ Could not find specific attractions for <strong>{destination}</strong>.<br>
|
| 385 |
+
Please check the city name or add an OpenAI API key for AI-powered recommendations.
|
| 386 |
+
</div>"""
|
| 387 |
+
|
| 388 |
budget_data = calculate_budget(num_days, budget_amount)
|
|
|
|
|
|
|
| 389 |
start = datetime.strptime(start_date, "%Y-%m-%d")
|
| 390 |
end = start + timedelta(days=int(num_days) - 1)
|
| 391 |
+
|
| 392 |
+
# Fetch city hero image (real, from Wikipedia)
|
| 393 |
+
city_img_url = get_city_hero_image(destination)
|
| 394 |
+
|
| 395 |
+
return generate_beautiful_itinerary(
|
| 396 |
+
destination, weather, attractions, budget_data,
|
| 397 |
+
num_days, start, end, budget_amount, budget_currency,
|
| 398 |
+
departure_city, city_img_url
|
| 399 |
)
|
| 400 |
+
|
|
|
|
|
|
|
| 401 |
except Exception as e:
|
| 402 |
+
return f"<div style='color:red;padding:20px;text-align:center;'>❌ Unexpected error: {str(e)}</div>"
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
def generate_beautiful_itinerary(destination, weather, attractions, budget_data,
|
| 406 |
+
num_days, start_date, end_date, budget_amount,
|
| 407 |
+
budget_currency, departure_city, city_img_url):
|
| 408 |
+
"""Create a beautiful itinerary HTML — only showing REAL images."""
|
| 409 |
+
|
| 410 |
weather_temp = f"{weather['temperature']:.1f}°C" if isinstance(weather['temperature'], (int, float)) else str(weather['temperature'])
|
| 411 |
+
weather_condition = weather["condition"].capitalize()
|
| 412 |
+
|
|
|
|
| 413 |
budget_warning = ""
|
| 414 |
+
if budget_amount and budget_data["total"] > budget_amount:
|
| 415 |
budget_warning = f"""
|
| 416 |
+
<div style="background:#fff3cd;border-left:4px solid #ffc107;padding:15px;border-radius:8px;margin:15px 0;">
|
| 417 |
+
<strong>⚠️ Budget Alert:</strong> Estimated costs (${budget_data['total']:.0f}) exceed your budget
|
| 418 |
+
({budget_currency} {budget_amount:.0f}). Consider choosing budget-friendly options.
|
| 419 |
+
</div>"""
|
| 420 |
+
|
| 421 |
+
# Hero banner — show real city image or a clean text-only banner
|
| 422 |
+
if city_img_url:
|
| 423 |
+
hero_style = f"""
|
| 424 |
+
background: linear-gradient(rgba(40,0,80,0.55), rgba(40,0,80,0.55)),
|
| 425 |
+
url('{city_img_url}') center/cover no-repeat;
|
| 426 |
"""
|
| 427 |
+
else:
|
| 428 |
+
hero_style = "background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);"
|
| 429 |
+
|
| 430 |
+
hero_html = f"""
|
| 431 |
+
<div style="{hero_style} padding:50px 30px; border-radius:20px; color:white;
|
| 432 |
+
margin-bottom:30px; text-align:center;">
|
| 433 |
+
<h1 style="margin:0;font-size:2.8em;text-shadow:0 2px 8px rgba(0,0,0,0.4);">🌍 {destination}</h1>
|
| 434 |
+
<p style="margin:10px 0 0;font-size:1.15em;opacity:0.95;">
|
| 435 |
+
{num_days} Days of Adventure ·
|
| 436 |
+
{start_date.strftime('%B %d')} – {end_date.strftime('%B %d, %Y')}
|
| 437 |
+
</p>
|
| 438 |
+
{f"<p style='margin:6px 0 0;opacity:0.85;'>✈️ Departing from: {departure_city}</p>" if departure_city else ""}
|
| 439 |
+
</div>"""
|
| 440 |
+
|
| 441 |
+
# Quick stats
|
| 442 |
+
stats_html = f"""
|
| 443 |
+
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-bottom:28px;">
|
| 444 |
+
<div style="background:#f8f9fa;padding:18px;border-radius:12px;text-align:center;">
|
| 445 |
+
<div style="font-size:2em;">🌤️</div>
|
| 446 |
+
<strong>{weather_temp}</strong><br><small>{weather_condition}</small>
|
| 447 |
+
</div>
|
| 448 |
+
<div style="background:#f8f9fa;padding:18px;border-radius:12px;text-align:center;">
|
| 449 |
+
<div style="font-size:2em;">📅</div>
|
| 450 |
+
<strong>{num_days} Days</strong><br><small>Full Itinerary</small>
|
| 451 |
+
</div>
|
| 452 |
+
<div style="background:#f8f9fa;padding:18px;border-radius:12px;text-align:center;">
|
| 453 |
+
<div style="font-size:2em;">🎯</div>
|
| 454 |
+
<strong>{len(attractions)} Attractions</strong><br><small>To Explore</small>
|
| 455 |
+
</div>
|
| 456 |
+
<div style="background:#f8f9fa;padding:18px;border-radius:12px;text-align:center;">
|
| 457 |
+
<div style="font-size:2em;">💰</div>
|
| 458 |
+
<strong>{budget_currency} {budget_data['total']:.0f}</strong><br><small>Estimated Total</small>
|
| 459 |
+
</div>
|
| 460 |
+
</div>"""
|
| 461 |
+
|
| 462 |
+
# Budget breakdown
|
| 463 |
+
level_names = {"budget": "Budget", "moderate": "Moderate", "comfortable": "Comfortable", "luxury": "Luxury"}
|
| 464 |
+
level_display = level_names.get(budget_data["level"], "Moderate")
|
| 465 |
+
budget_html = f"""
|
| 466 |
+
<div style="background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);padding:22px;border-radius:12px;color:white;margin:20px 0;">
|
| 467 |
+
<h3 style="margin:0 0 15px 0;">💰 Budget Breakdown ({level_display} Travel Style)</h3>
|
| 468 |
+
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:15px;">
|
| 469 |
+
<div><strong>🏨 Accommodation</strong><br>{budget_currency} {budget_data['accommodation']:.0f}<br><small>({budget_currency} {budget_data['daily']['accommodation']}/day)</small></div>
|
| 470 |
+
<div><strong>🍽️ Food & Dining</strong><br>{budget_currency} {budget_data['food']:.0f}<br><small>({budget_currency} {budget_data['daily']['food']}/day)</small></div>
|
| 471 |
+
<div><strong>🚗 Local Transport</strong><br>{budget_currency} {budget_data['transport']:.0f}<br><small>({budget_currency} {budget_data['daily']['transport']}/day)</small></div>
|
| 472 |
+
<div><strong>🎟️ Activities</strong><br>{budget_currency} {budget_data['activities']:.0f}<br><small>({budget_currency} {budget_data['daily']['activities']}/day)</small></div>
|
| 473 |
+
<div style="border-top:2px solid rgba(255,255,255,0.3);padding-top:10px;grid-column:1/-1;">
|
| 474 |
+
<strong>💰 Total Estimated Cost:</strong>
|
| 475 |
+
<span style="font-size:1.25em;"> {budget_currency} {budget_data['total']:.0f}</span>
|
| 476 |
+
{f" (Your budget: {budget_currency} {budget_amount:.0f})" if budget_amount else ""}
|
| 477 |
+
</div>
|
| 478 |
+
</div>
|
| 479 |
+
</div>"""
|
| 480 |
+
|
| 481 |
+
# Top Attractions — real images only
|
| 482 |
+
def attraction_image_html(attr, size="large"):
|
| 483 |
+
"""Render attraction with real image or image-free card."""
|
| 484 |
+
img_url = get_wikimedia_image(attr["name"], destination)
|
| 485 |
+
time_icon = {"morning": "🌅", "afternoon": "☀️", "evening": "🌙"}.get(attr.get("best_time", ""), "🕐")
|
| 486 |
+
|
| 487 |
+
if size == "large" and img_url:
|
| 488 |
+
return f"""
|
| 489 |
+
<div style="background:white;border-radius:12px;margin-bottom:20px;overflow:hidden;
|
| 490 |
+
box-shadow:0 2px 10px rgba(0,0,0,0.08);">
|
| 491 |
+
<div style="display:flex;flex-wrap:wrap;">
|
| 492 |
+
<div style="flex:0 0 220px;">
|
| 493 |
+
<img src="{img_url}" style="width:220px;height:160px;object-fit:cover;display:block;"
|
| 494 |
+
alt="{attr['name']}" onerror="this.parentElement.style.display='none'">
|
| 495 |
+
</div>
|
| 496 |
+
<div style="flex:1;padding:18px;min-width:200px;">
|
| 497 |
+
<strong style="font-size:1.1em;color:#333;">📍 {attr['name']}</strong>
|
| 498 |
+
<div style="color:#555;margin:8px 0;font-size:0.95em;line-height:1.5;">{attr['description']}</div>
|
| 499 |
+
<div style="display:flex;gap:15px;font-size:0.85em;color:#777;">
|
| 500 |
+
<span>⏱️ {attr['duration_hours']} hrs</span>
|
| 501 |
+
<span>🎟️ {attr['entry_fee']}</span>
|
| 502 |
+
<span>{time_icon} Best: {attr.get('best_time','anytime')}</span>
|
| 503 |
</div>
|
| 504 |
</div>
|
| 505 |
</div>
|
| 506 |
+
</div>"""
|
| 507 |
+
elif size == "large":
|
| 508 |
+
# No image available — clean text card
|
| 509 |
+
return f"""
|
| 510 |
+
<div style="background:white;border-radius:12px;margin-bottom:20px;overflow:hidden;
|
| 511 |
+
box-shadow:0 2px 10px rgba(0,0,0,0.08);padding:18px;">
|
| 512 |
+
<strong style="font-size:1.1em;color:#333;">📍 {attr['name']}</strong>
|
| 513 |
+
<div style="color:#555;margin:8px 0;font-size:0.95em;line-height:1.5;">{attr['description']}</div>
|
| 514 |
+
<div style="display:flex;gap:15px;font-size:0.85em;color:#777;">
|
| 515 |
+
<span>⏱️ {attr['duration_hours']} hrs</span>
|
| 516 |
+
<span>🎟️ {attr['entry_fee']}</span>
|
| 517 |
+
<span>{time_icon} Best: {attr.get('best_time','anytime')}</span>
|
| 518 |
+
</div>
|
| 519 |
+
</div>"""
|
| 520 |
+
|
| 521 |
+
# Small card for daily schedule
|
| 522 |
+
if img_url:
|
| 523 |
+
return f"""
|
| 524 |
+
<div style="display:flex;gap:14px;margin-bottom:18px;padding:12px;
|
| 525 |
+
background:#f8f9fa;border-radius:10px;align-items:flex-start;">
|
| 526 |
+
<img src="{img_url}" style="width:90px;height:90px;object-fit:cover;border-radius:8px;flex-shrink:0;"
|
| 527 |
+
alt="{attr['name']}" onerror="this.style.display='none'">
|
| 528 |
+
<div>
|
| 529 |
+
<strong style="color:#333;">{attr['name']}</strong><br>
|
| 530 |
+
<span style="font-size:0.87em;color:#666;">{attr['description'][:110]}…</span>
|
| 531 |
+
<div style="font-size:0.78em;color:#888;margin-top:5px;">
|
| 532 |
+
⏱️ {attr['duration_hours']} hrs | 🎟️ {attr['entry_fee']}
|
| 533 |
+
</div>
|
| 534 |
+
</div>
|
| 535 |
+
</div>"""
|
| 536 |
+
else:
|
| 537 |
+
return f"""
|
| 538 |
+
<div style="display:flex;gap:14px;margin-bottom:18px;padding:12px;
|
| 539 |
+
background:#f8f9fa;border-radius:10px;">
|
| 540 |
+
<div>
|
| 541 |
+
<strong style="color:#333;">📍 {attr['name']}</strong><br>
|
| 542 |
+
<span style="font-size:0.87em;color:#666;">{attr['description'][:110]}…</span>
|
| 543 |
+
<div style="font-size:0.78em;color:#888;margin-top:5px;">
|
| 544 |
+
⏱️ {attr['duration_hours']} hrs | 🎟️ {attr['entry_fee']}
|
| 545 |
+
</div>
|
| 546 |
+
</div>
|
| 547 |
+
</div>"""
|
| 548 |
+
|
| 549 |
+
attractions_html = "".join(attraction_image_html(a, "large") for a in attractions[:6])
|
| 550 |
+
|
| 551 |
+
# Daily itinerary
|
| 552 |
daily_html = ""
|
| 553 |
per_day = max(1, len(attractions) // max(1, num_days))
|
|
|
|
| 554 |
for day in range(1, num_days + 1):
|
| 555 |
+
current_date = start_date + timedelta(days=day - 1)
|
| 556 |
date_str = current_date.strftime("%A, %B %d")
|
| 557 |
+
start_idx = (day - 1) * per_day
|
|
|
|
| 558 |
end_idx = min(day * per_day, len(attractions))
|
| 559 |
day_attractions = attractions[start_idx:end_idx]
|
| 560 |
+
if not day_attractions and day <= len(attractions):
|
| 561 |
+
day_attractions = [attractions[day - 1]]
|
| 562 |
+
|
| 563 |
if day_attractions:
|
| 564 |
daily_html += f"""
|
| 565 |
+
<div style="background:white;border-radius:12px;margin-bottom:20px;overflow:hidden;
|
| 566 |
+
box-shadow:0 2px 8px rgba(0,0,0,0.06);">
|
| 567 |
+
<div style="background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);
|
| 568 |
+
padding:12px 20px;color:white;">
|
| 569 |
+
<h3 style="margin:0;font-size:1.2em;">Day {day} · {date_str}</h3>
|
| 570 |
</div>
|
| 571 |
+
<div style="padding:20px;">
|
| 572 |
+
{"".join(attraction_image_html(a, "small") for a in day_attractions)}
|
| 573 |
+
<div style="margin-top:15px;padding-top:15px;border-top:1px solid #eee;">
|
| 574 |
+
<div style="margin-bottom:8px;">🍽️ <strong>Lunch:</strong> Ask locals for the best neighbourhood restaurant</div>
|
| 575 |
+
<div>🌙 <strong>Evening:</strong> Explore night markets, enjoy live music or a cultural performance</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 576 |
</div>
|
| 577 |
</div>
|
| 578 |
+
</div>"""
|
| 579 |
+
|
| 580 |
+
# Tips
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
tips_html = """
|
| 582 |
+
<div style="background:#f0f4ff;padding:22px;border-radius:12px;margin:20px 0;">
|
| 583 |
+
<h3 style="margin:0 0 15px 0;color:#667eea;">💡 Smart Travel Tips</h3>
|
| 584 |
+
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;">
|
| 585 |
+
<div>🎫 <strong>Book in Advance</strong><br>Save time and money by booking popular sites online</div>
|
| 586 |
+
<div>🚇 <strong>Public Transport</strong><br>A day pass often gives unlimited travel at lower cost</div>
|
| 587 |
+
<div>📱 <strong>Offline Maps</strong><br>Download Google Maps offline before you arrive</div>
|
| 588 |
+
<div>💵 <strong>Local Currency</strong><br>Carry some cash for markets and smaller vendors</div>
|
| 589 |
+
<div>🌍 <strong>Learn Basic Phrases</strong><br>A few local words are always warmly received</div>
|
| 590 |
+
<div>📸 <strong>Go Early</strong><br>Visit popular attractions first thing to beat the crowds</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 591 |
</div>
|
| 592 |
+
</div>"""
|
| 593 |
+
|
| 594 |
+
dest_encoded = destination.replace(" ", "+")
|
| 595 |
+
dep_encoded = departure_city.lower().replace(" ", "-") if departure_city else ""
|
| 596 |
+
|
| 597 |
+
booking_html = f"""
|
| 598 |
+
<div style="text-align:center;padding:22px;background:#f8f9fa;border-radius:12px;margin-top:20px;">
|
| 599 |
+
<h3 style="margin:0 0 12px 0;">Ready to Book Your Trip?</h3>
|
| 600 |
+
<p style="margin:0;">
|
| 601 |
+
🛏️ <a href="https://www.booking.com/searchresults.html?ss={dest_encoded}" target="_blank"
|
| 602 |
+
style="color:#667eea;text-decoration:none;font-weight:600;">Search Hotels</a>
|
| 603 |
+
|
|
| 604 |
+
✈️ <a href="https://www.skyscanner.net/transport/flights/{dep_encoded}/{destination.lower().replace(' ','-')}/"
|
| 605 |
+
target="_blank" style="color:#667eea;text-decoration:none;font-weight:600;">Search Flights</a>
|
| 606 |
+
|
|
| 607 |
+
🎟️ <a href="https://www.tripadvisor.com/Search?q={dest_encoded}" target="_blank"
|
| 608 |
+
style="color:#667eea;text-decoration:none;font-weight:600;">Read Reviews</a>
|
| 609 |
+
</p>
|
| 610 |
+
</div>"""
|
| 611 |
+
|
| 612 |
+
footer_html = """
|
| 613 |
+
<div style="text-align:center;padding:20px;margin-top:16px;font-size:0.85em;color:#888;">
|
| 614 |
+
<p>✨ TravelBuddy AI — Powered by OpenAI · Real-time Weather · Wikipedia Images · Smart Recommendations</p>
|
| 615 |
+
<p>🌍 Plan your perfect adventure with confidence</p>
|
| 616 |
+
</div>"""
|
| 617 |
+
|
| 618 |
+
return f"""
|
| 619 |
+
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:100%;">
|
| 620 |
+
{hero_html}
|
| 621 |
+
{stats_html}
|
| 622 |
{budget_warning}
|
|
|
|
|
|
|
| 623 |
{budget_html}
|
| 624 |
+
<div style="background:white;padding:22px;border-radius:12px;margin:20px 0;box-shadow:0 2px 10px rgba(0,0,0,0.05);">
|
| 625 |
+
<h2 style="margin:0 0 18px 0;color:#667eea;">✨ Top Attractions in {destination}</h2>
|
|
|
|
|
|
|
| 626 |
{attractions_html}
|
| 627 |
</div>
|
| 628 |
+
<div style="background:white;padding:22px;border-radius:12px;margin:20px 0;box-shadow:0 2px 10px rgba(0,0,0,0.05);">
|
| 629 |
+
<h2 style="margin:0 0 18px 0;color:#667eea;">📅 Your {num_days}-Day Itinerary</h2>
|
|
|
|
|
|
|
| 630 |
{daily_html}
|
| 631 |
</div>
|
|
|
|
|
|
|
| 632 |
{tips_html}
|
| 633 |
+
{booking_html}
|
| 634 |
+
{footer_html}
|
| 635 |
+
</div>"""
|
| 636 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 637 |
|
| 638 |
# -------------------- GRADIO INTERFACE --------------------
|
| 639 |
css = """
|
| 640 |
.gradio-container {
|
| 641 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 642 |
max-width: 1200px;
|
| 643 |
margin: 0 auto;
|
| 644 |
}
|
|
|
|
| 646 |
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
|
| 647 |
border: none !important;
|
| 648 |
font-weight: bold !important;
|
|
|
|
| 649 |
font-size: 1.1em !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 650 |
}
|
| 651 |
+
input, select, textarea { border-radius: 8px !important; border: 1px solid #e0e0e0 !important; }
|
| 652 |
+
label { font-weight: 500 !important; color: #333 !important; }
|
| 653 |
"""
|
| 654 |
|
| 655 |
with gr.Blocks(css=css, title="✨ TravelBuddy AI - Smart Travel Planner") as demo:
|
| 656 |
gr.HTML("""
|
| 657 |
+
<div style="text-align:center;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);
|
| 658 |
+
padding:40px;border-radius:20px;margin-bottom:30px;">
|
| 659 |
+
<h1 style="color:white;margin:0;font-size:2.5em;">✨ TravelBuddy AI</h1>
|
| 660 |
+
<p style="color:white;margin:10px 0 0;opacity:0.95;font-size:1.1em;">
|
| 661 |
+
Your Intelligent Travel Companion — Personalized Itineraries for Any Destination Worldwide
|
| 662 |
</p>
|
| 663 |
+
</div>""")
|
| 664 |
+
|
|
|
|
| 665 |
with gr.Row(equal_height=True):
|
| 666 |
with gr.Column(scale=2):
|
| 667 |
with gr.Group():
|
| 668 |
gr.Markdown("### 🎯 Where's Your Next Adventure?")
|
| 669 |
destination = gr.Textbox(
|
| 670 |
label="Destination",
|
| 671 |
+
placeholder="e.g., Nairobi, Paris, Tokyo, Cairo, Buenos Aires, Sydney…",
|
| 672 |
+
lines=1, show_label=False
|
|
|
|
| 673 |
)
|
|
|
|
| 674 |
with gr.Group():
|
| 675 |
gr.Markdown("### 📅 When Are You Traveling?")
|
| 676 |
with gr.Row():
|
|
|
|
| 680 |
placeholder="YYYY-MM-DD"
|
| 681 |
)
|
| 682 |
num_days = gr.Slider(
|
| 683 |
+
label="Duration (Days)", minimum=1, maximum=14, value=3, step=1
|
|
|
|
|
|
|
|
|
|
|
|
|
| 684 |
)
|
|
|
|
| 685 |
with gr.Column(scale=1):
|
| 686 |
with gr.Group():
|
| 687 |
gr.Markdown("### 💰 Your Budget")
|
| 688 |
with gr.Row():
|
| 689 |
budget_amount = gr.Number(
|
| 690 |
+
label="Total Budget (Optional)", placeholder="Enter amount", value=None
|
|
|
|
|
|
|
| 691 |
)
|
| 692 |
budget_currency = gr.Dropdown(
|
| 693 |
+
["USD", "EUR", "GBP", "JPY", "KES", "RWF", "ZAR", "NGN",
|
| 694 |
+
"EGP", "CAD", "AUD", "CHF", "INR", "BRL", "MXN"],
|
| 695 |
+
label="Currency", value="USD"
|
| 696 |
)
|
| 697 |
gr.HTML("""
|
| 698 |
+
<div style="background:#e8f0fe;padding:10px;border-radius:8px;margin-top:10px;">
|
| 699 |
+
<small>💡 We'll suggest the best travel style based on your budget.</small>
|
| 700 |
+
</div>""")
|
|
|
|
|
|
|
| 701 |
with gr.Group():
|
| 702 |
gr.Markdown("### ✈️ Departure Info")
|
| 703 |
departure_city = gr.Textbox(
|
| 704 |
label="Departure City (Optional)",
|
| 705 |
+
placeholder="e.g., Kigali, London, Lagos, Mumbai",
|
| 706 |
lines=1
|
| 707 |
)
|
| 708 |
+
|
| 709 |
with gr.Row():
|
| 710 |
+
generate_btn = gr.Button(
|
| 711 |
+
"✨ Generate My Personalized Itinerary", variant="primary", size="lg"
|
| 712 |
+
)
|
| 713 |
+
|
| 714 |
output = gr.HTML()
|
| 715 |
+
|
| 716 |
generate_btn.click(
|
| 717 |
fn=generate_itinerary,
|
| 718 |
inputs=[destination, start_date, num_days, budget_amount, budget_currency, departure_city],
|
| 719 |
+
outputs=output,
|
| 720 |
)
|
| 721 |
+
|
|
|
|
| 722 |
gr.HTML("""
|
| 723 |
+
<div style="text-align:center;padding:20px;margin-top:20px;border-top:1px solid #eee;">
|
| 724 |
<h3>🌟 Popular Destinations to Try</h3>
|
| 725 |
+
<div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap;">
|
| 726 |
+
<button onclick="document.querySelector('input[placeholder*=Nairobi]').value='Nairobi';"
|
| 727 |
+
style="background:#f0f4ff;border:none;padding:8px 16px;border-radius:20px;cursor:pointer;">🦒 Nairobi</button>
|
| 728 |
+
<button onclick="document.querySelector('input[placeholder*=Nairobi]').value='Paris';"
|
| 729 |
+
style="background:#f0f4ff;border:none;padding:8px 16px;border-radius:20px;cursor:pointer;">🗼 Paris</button>
|
| 730 |
+
<button onclick="document.querySelector('input[placeholder*=Nairobi]').value='Tokyo';"
|
| 731 |
+
style="background:#f0f4ff;border:none;padding:8px 16px;border-radius:20px;cursor:pointer;">🗾 Tokyo</button>
|
| 732 |
+
<button onclick="document.querySelector('input[placeholder*=Nairobi]').value='New York';"
|
| 733 |
+
style="background:#f0f4ff;border:none;padding:8px 16px;border-radius:20px;cursor:pointer;">🗽 New York</button>
|
| 734 |
+
<button onclick="document.querySelector('input[placeholder*=Nairobi]').value='Bali';"
|
| 735 |
+
style="background:#f0f4ff;border:none;padding:8px 16px;border-radius:20px;cursor:pointer;">🏝️ Bali</button>
|
| 736 |
+
<button onclick="document.querySelector('input[placeholder*=Nairobi]').value='Cairo';"
|
| 737 |
+
style="background:#f0f4ff;border:none;padding:8px 16px;border-radius:20px;cursor:pointer;">🏛️ Cairo</button>
|
| 738 |
+
<button onclick="document.querySelector('input[placeholder*=Nairobi]').value='Cape Town';"
|
| 739 |
+
style="background:#f0f4ff;border:none;padding:8px 16px;border-radius:20px;cursor:pointer;">🌍 Cape Town</button>
|
| 740 |
+
<button onclick="document.querySelector('input[placeholder*=Nairobi]').value='Bangkok';"
|
| 741 |
+
style="background:#f0f4ff;border:none;padding:8px 16px;border-radius:20px;cursor:pointer;">🍜 Bangkok</button>
|
| 742 |
</div>
|
| 743 |
</div>
|
| 744 |
+
|
| 745 |
+
<div style="text-align:center;padding:16px;margin-top:10px;border-top:1px solid #eee;color:#888;">
|
| 746 |
+
<small>Powered by OpenAI · OpenWeather · Wikipedia Images · For travellers from every corner of the world ✨</small>
|
| 747 |
+
</div>""")
|
|
|
|
| 748 |
|
| 749 |
if __name__ == "__main__":
|
| 750 |
+
demo.launch(share=False, server_name="0.0.0.0")
|