Spaces:
Sleeping
Sleeping
Upload 9 files
Browse files- planmate/__init__.py +0 -0
- planmate/attractions.py +79 -0
- planmate/config.py +42 -0
- planmate/flights.py +100 -0
- planmate/itinerary.py +15 -0
- planmate/llm.py +136 -0
- planmate/utils.py +40 -0
- planmate/validation.py +37 -0
- planmate/weather.py +90 -0
planmate/__init__.py
ADDED
|
File without changes
|
planmate/attractions.py
CHANGED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# planmate/attractions.py
|
| 2 |
+
|
| 3 |
+
import requests
|
| 4 |
+
from typing import List, Dict
|
| 5 |
+
from .config import OPENTRIPMAP_BASE, get_opentripmap_key
|
| 6 |
+
|
| 7 |
+
def _get(path: str, params: Dict):
|
| 8 |
+
url = f"{OPENTRIPMAP_BASE}{path}"
|
| 9 |
+
params = dict(params or {})
|
| 10 |
+
params["apikey"] = get_opentripmap_key()
|
| 11 |
+
r = requests.get(url, params=params, timeout=30)
|
| 12 |
+
try:
|
| 13 |
+
r.raise_for_status()
|
| 14 |
+
except requests.HTTPError as e:
|
| 15 |
+
# make API errors easier to debug in Streamlit
|
| 16 |
+
snippet = (r.text or "")[:300]
|
| 17 |
+
raise RuntimeError(f"OpenTripMap error {r.status_code}: {snippet}") from e
|
| 18 |
+
return r.json()
|
| 19 |
+
|
| 20 |
+
def get_poi_radius(lat: float, lon: float, radius=7000, kinds="interesting_places", limit=50) -> List[Dict]:
|
| 21 |
+
return _get(
|
| 22 |
+
"/places/radius",
|
| 23 |
+
{
|
| 24 |
+
"lat": lat,
|
| 25 |
+
"lon": lon,
|
| 26 |
+
"radius": radius,
|
| 27 |
+
"kinds": kinds,
|
| 28 |
+
"format": "json",
|
| 29 |
+
"limit": limit,
|
| 30 |
+
"rate": 2,
|
| 31 |
+
},
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
def get_details(xid: str) -> Dict:
|
| 35 |
+
return _get(f"/places/xid/{xid}", {})
|
| 36 |
+
|
| 37 |
+
def enrich_pois(pois: List[Dict], fetch_details=False) -> List[Dict]:
|
| 38 |
+
out = []
|
| 39 |
+
for p in pois:
|
| 40 |
+
item = {
|
| 41 |
+
"name": p.get("name") or "(Unnamed)",
|
| 42 |
+
"dist": p.get("dist"),
|
| 43 |
+
"rate": p.get("rate"),
|
| 44 |
+
"kinds": p.get("kinds", ""),
|
| 45 |
+
"xid": p.get("xid"),
|
| 46 |
+
"point": p.get("point", {}),
|
| 47 |
+
}
|
| 48 |
+
if fetch_details and item["xid"]:
|
| 49 |
+
try:
|
| 50 |
+
det = get_details(item["xid"])
|
| 51 |
+
item["wikipedia"] = det.get("wikipedia")
|
| 52 |
+
item["url"] = det.get("url")
|
| 53 |
+
item["address"] = det.get("address", {})
|
| 54 |
+
item["otm"] = det.get("otm")
|
| 55 |
+
except Exception:
|
| 56 |
+
pass
|
| 57 |
+
out.append(item)
|
| 58 |
+
return out
|
| 59 |
+
|
| 60 |
+
def get_attractions_and_stays(lat: float, lon: float, radius=7000, limit=40):
|
| 61 |
+
# Attractions remain unchanged
|
| 62 |
+
attractions_kinds = "interesting_places,cultural,historic,museums,architecture"
|
| 63 |
+
|
| 64 |
+
# IMPORTANT: Use taxonomy accepted by OpenTripMap.
|
| 65 |
+
# "accomodations" (sic) is the umbrella category; for hotels use "other_hotels", not "hotels".
|
| 66 |
+
# See OTM catalog: Accomodations (accomodations), Hotels (other_hotels), Hostels (hostels).
|
| 67 |
+
# https://dev.opentripmap.org/catalog
|
| 68 |
+
stays_kinds = (
|
| 69 |
+
"accomodations,other_hotels,hostels,apartments,guest_houses,"
|
| 70 |
+
"resorts,motels,villas_and_chalet,alpine_hut"
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
attractions = get_poi_radius(lat, lon, radius, attractions_kinds, limit)
|
| 74 |
+
stays = get_poi_radius(lat, lon, radius, stays_kinds, limit=30)
|
| 75 |
+
|
| 76 |
+
return {
|
| 77 |
+
"attractions": enrich_pois(attractions, fetch_details=False),
|
| 78 |
+
"stays": enrich_pois(stays, fetch_details=False),
|
| 79 |
+
}
|
planmate/config.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
|
| 4 |
+
# Load .env if present (local dev convenience)
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
APP_TITLE = "PlanMate"
|
| 8 |
+
APP_TAGLINE = "AI Power smart trip planner"
|
| 9 |
+
|
| 10 |
+
THEME = {
|
| 11 |
+
"bg": "#fcfcfc",
|
| 12 |
+
"text": "#383838",
|
| 13 |
+
"label": "#153d15",
|
| 14 |
+
"border": "#153d15",
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
CURRENCY = "PKR"
|
| 18 |
+
UNITS = "metric"
|
| 19 |
+
LANGUAGE = "en"
|
| 20 |
+
|
| 21 |
+
# API Base URLs
|
| 22 |
+
AMADEUS_BASE = "https://test.api.amadeus.com"
|
| 23 |
+
OPENWEATHER_BASE = "https://api.openweathermap.org"
|
| 24 |
+
OPENTRIPMAP_BASE = "https://api.opentripmap.com/0.1/en"
|
| 25 |
+
|
| 26 |
+
def get_env(key: str) -> str:
|
| 27 |
+
val = os.getenv(key)
|
| 28 |
+
if not val:
|
| 29 |
+
raise RuntimeError(f"Missing required environment variable: {key}")
|
| 30 |
+
return val
|
| 31 |
+
|
| 32 |
+
def get_gemini_key():
|
| 33 |
+
return get_env("GEMINI_API_KEY")
|
| 34 |
+
|
| 35 |
+
def get_amadeus_credentials():
|
| 36 |
+
return get_env("AMADEUS_CLIENT_ID"), get_env("AMADEUS_CLIENT_SECRET")
|
| 37 |
+
|
| 38 |
+
def get_openweather_key():
|
| 39 |
+
return get_env("OPENWEATHER_API_KEY")
|
| 40 |
+
|
| 41 |
+
def get_opentripmap_key():
|
| 42 |
+
return get_env("OPENTRIPMAP_API_KEY")
|
planmate/flights.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Dict, Tuple
|
| 2 |
+
from .clients.amadeus_client import AmadeusClient
|
| 3 |
+
from .utils import format_price
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def resolve_city_to_iata(city_name: str) -> Tuple[str, str]:
|
| 7 |
+
"""Resolve a human city name to an IATA city code using Amadeus.
|
| 8 |
+
Returns (iata_code, display_label). Raises RuntimeError if not found.
|
| 9 |
+
"""
|
| 10 |
+
client = AmadeusClient()
|
| 11 |
+
res = client.locations(city_name, subtypes="CITY")
|
| 12 |
+
data = res.get("data", [])
|
| 13 |
+
if not data:
|
| 14 |
+
# try airports as fallback and take the cityName + iataCode of airport
|
| 15 |
+
res = client.locations(city_name, subtypes="AIRPORT")
|
| 16 |
+
data = res.get("data", [])
|
| 17 |
+
if not data:
|
| 18 |
+
raise RuntimeError(f"Could not resolve city to IATA: {city_name}")
|
| 19 |
+
|
| 20 |
+
# Prefer exact name match then highest relevance (first)
|
| 21 |
+
city_lower = city_name.strip().lower()
|
| 22 |
+
chosen = None
|
| 23 |
+
for d in data:
|
| 24 |
+
name = (d.get("name") or "").lower()
|
| 25 |
+
address_city = (d.get("address", {}).get("cityName") or "").lower()
|
| 26 |
+
if name == city_lower or address_city == city_lower:
|
| 27 |
+
chosen = d
|
| 28 |
+
break
|
| 29 |
+
if not chosen:
|
| 30 |
+
chosen = data[0]
|
| 31 |
+
|
| 32 |
+
code = chosen.get("iataCode")
|
| 33 |
+
label = f"{chosen.get('name')} ({code})"
|
| 34 |
+
if not code:
|
| 35 |
+
raise RuntimeError(f"No IATA code found for: {city_name}")
|
| 36 |
+
return code, label
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def search_airports(keyword: str) -> List[Dict]:
|
| 40 |
+
client = AmadeusClient()
|
| 41 |
+
res = client.locations(keyword)
|
| 42 |
+
items = []
|
| 43 |
+
for d in res.get("data", []):
|
| 44 |
+
items.append(
|
| 45 |
+
{
|
| 46 |
+
"name": d.get("name"),
|
| 47 |
+
"iataCode": d.get("iataCode"),
|
| 48 |
+
"subType": d.get("subType"),
|
| 49 |
+
"address": d.get("address", {}).get("cityName")
|
| 50 |
+
or d.get("address", {}).get("countryName"),
|
| 51 |
+
}
|
| 52 |
+
)
|
| 53 |
+
return items
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def search_flights(
|
| 57 |
+
origin_code: str,
|
| 58 |
+
dest_code: str,
|
| 59 |
+
depart: str,
|
| 60 |
+
ret: str,
|
| 61 |
+
adults=1,
|
| 62 |
+
currency="PKR",
|
| 63 |
+
non_stop: bool = False,
|
| 64 |
+
) -> Dict:
|
| 65 |
+
client = AmadeusClient()
|
| 66 |
+
res = client.flight_offers(
|
| 67 |
+
origin_code, dest_code, depart, ret, adults, currency, non_stop
|
| 68 |
+
)
|
| 69 |
+
dicts = res.get("dictionaries", {})
|
| 70 |
+
carriers = dicts.get("carriers", {})
|
| 71 |
+
result = []
|
| 72 |
+
for offer in res.get("data", []):
|
| 73 |
+
price = offer.get("price", {}).get("total")
|
| 74 |
+
itineraries = offer.get("itineraries", [])
|
| 75 |
+
legs = []
|
| 76 |
+
for it in itineraries:
|
| 77 |
+
segs = []
|
| 78 |
+
for s in it.get("segments", []):
|
| 79 |
+
carrier = s.get("carrierCode")
|
| 80 |
+
segs.append(
|
| 81 |
+
{
|
| 82 |
+
"from": s.get("departure", {}).get("iataCode"),
|
| 83 |
+
"to": s.get("arrival", {}).get("iataCode"),
|
| 84 |
+
"dep": s.get("departure", {}).get("at"),
|
| 85 |
+
"arr": s.get("arrival", {}).get("at"),
|
| 86 |
+
"carrier": carriers.get(carrier, carrier),
|
| 87 |
+
"number": s.get("number"),
|
| 88 |
+
"duration": it.get("duration", ""),
|
| 89 |
+
}
|
| 90 |
+
)
|
| 91 |
+
legs.append(segs)
|
| 92 |
+
result.append(
|
| 93 |
+
{
|
| 94 |
+
"price": price,
|
| 95 |
+
"price_label": format_price(price, currency),
|
| 96 |
+
"legs": legs,
|
| 97 |
+
"oneWay": (ret is None or ret == ""),
|
| 98 |
+
}
|
| 99 |
+
)
|
| 100 |
+
return {"flights": result, "carriers": carriers}
|
planmate/itinerary.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Dict
|
| 2 |
+
from .llm import generate_itinerary
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def build_itinerary(
|
| 6 |
+
city: str,
|
| 7 |
+
start_date: str,
|
| 8 |
+
days: int,
|
| 9 |
+
selected_attractions: List[Dict],
|
| 10 |
+
selected_stay: Dict | None,
|
| 11 |
+
weather_summary: str,
|
| 12 |
+
) -> str:
|
| 13 |
+
return generate_itinerary(
|
| 14 |
+
city, start_date, days, selected_attractions, selected_stay, weather_summary
|
| 15 |
+
)
|
planmate/llm.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from typing import List, Dict, Optional, Tuple
|
| 3 |
+
import google.generativeai as genai
|
| 4 |
+
from .config import get_gemini_key
|
| 5 |
+
from .utils import is_iata_code
|
| 6 |
+
|
| 7 |
+
# Initialize once
|
| 8 |
+
_gen_inited = False
|
| 9 |
+
|
| 10 |
+
def _ensure_init():
|
| 11 |
+
global _gen_inited
|
| 12 |
+
if not _gen_inited:
|
| 13 |
+
genai.configure(api_key=get_gemini_key())
|
| 14 |
+
_gen_inited = True
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _model():
|
| 18 |
+
_ensure_init()
|
| 19 |
+
return genai.GenerativeModel(
|
| 20 |
+
model_name="gemini-2.0-flash-lite",
|
| 21 |
+
generation_config={
|
| 22 |
+
"temperature": 0.2,
|
| 23 |
+
"top_p": 0.9,
|
| 24 |
+
"top_k": 40,
|
| 25 |
+
"max_output_tokens": 600,
|
| 26 |
+
},
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def resolve_city_to_iata_ai(city: str, country_hint: Optional[str] = None) -> Tuple[str, str, str]:
|
| 31 |
+
"""Use the LLM to resolve a human-readable city to an IATA code.
|
| 32 |
+
Returns (code, canonical_name, kind) where kind is 'CITY' or 'AIRPORT'.
|
| 33 |
+
Raises RuntimeError on failure.
|
| 34 |
+
"""
|
| 35 |
+
m = _model()
|
| 36 |
+
prompt = f"""
|
| 37 |
+
You convert a city name into a 3-letter IATA code for flight search.
|
| 38 |
+
Rules:
|
| 39 |
+
- If the metro area has multiple major airports with an aggregate IATA city code, return the CITY code (e.g., New York→NYC, London→LON, Paris→PAR, Tokyo→TYO).
|
| 40 |
+
- If the city typically uses a single primary commercial airport, return that AIRPORT code (e.g., Dubai→DXB, Doha→DOH, Lahore→LHE, Karachi→KHI, Istanbul→IST).
|
| 41 |
+
- Return empty code "" if no suitable airport exists.
|
| 42 |
+
- The code MUST be exactly 3 uppercase letters A–Z.
|
| 43 |
+
- Respond with ONLY a compact JSON object, no additional text, using this schema:
|
| 44 |
+
{{"code":"XXX","name":"Canonical City or Airport Name","kind":"CITY|AIRPORT","alternates":["AAA","BBB"]}}
|
| 45 |
+
|
| 46 |
+
Input:
|
| 47 |
+
- city: {city}
|
| 48 |
+
- country_hint: {country_hint or 'unknown'}
|
| 49 |
+
"""
|
| 50 |
+
resp = m.generate_content(prompt)
|
| 51 |
+
text = (resp.text or "").strip()
|
| 52 |
+
|
| 53 |
+
# Extract JSON block
|
| 54 |
+
mjson = None
|
| 55 |
+
if "{" in text:
|
| 56 |
+
try:
|
| 57 |
+
start = text.find("{")
|
| 58 |
+
end = text.rfind("}") + 1
|
| 59 |
+
mjson = json.loads(text[start:end])
|
| 60 |
+
except Exception:
|
| 61 |
+
pass
|
| 62 |
+
if not mjson or not isinstance(mjson, dict):
|
| 63 |
+
raise RuntimeError("AI could not produce a valid JSON mapping for the city.")
|
| 64 |
+
|
| 65 |
+
code = str(mjson.get("code", "")).strip().upper()
|
| 66 |
+
name = str(mjson.get("name", city)).strip()
|
| 67 |
+
kind = str(mjson.get("kind", "")).strip().upper()
|
| 68 |
+
|
| 69 |
+
if not is_iata_code(code):
|
| 70 |
+
raise RuntimeError(f"AI returned an invalid IATA code: {code}")
|
| 71 |
+
if kind not in ("CITY", "AIRPORT"):
|
| 72 |
+
kind = "AIRPORT"
|
| 73 |
+
return code, name, kind
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def rank_accommodations(accommodations: List[Dict], prefs: str = "") -> List[Dict]:
|
| 77 |
+
"""Add an 'llm_score' to each accommodation and sort by it."""
|
| 78 |
+
if not accommodations:
|
| 79 |
+
return []
|
| 80 |
+
m = _model()
|
| 81 |
+
lines = "\n".join(
|
| 82 |
+
[
|
| 83 |
+
f"- {a.get('name','(no name)')} | rate:{a.get('rate')} | dist:{int(a.get('dist',0))}m | kinds:{a.get('kinds','')}"
|
| 84 |
+
for a in accommodations[:50]
|
| 85 |
+
]
|
| 86 |
+
)
|
| 87 |
+
prompt = f"""Rank the following accommodations for a tourist trip. Prefer central, well-rated options.
|
| 88 |
+
User preferences: {prefs or 'not specified'}.
|
| 89 |
+
Return a JSON array of objects with 'name' and an integer 'score' 1-100.
|
| 90 |
+
|
| 91 |
+
Items:
|
| 92 |
+
{lines}
|
| 93 |
+
"""
|
| 94 |
+
resp = m.generate_content(prompt)
|
| 95 |
+
try:
|
| 96 |
+
text = resp.text or ""
|
| 97 |
+
if "```json" in text:
|
| 98 |
+
data = json.loads(text.split("```json")[-1].split("```")[0])
|
| 99 |
+
else:
|
| 100 |
+
data = json.loads(text)
|
| 101 |
+
except Exception:
|
| 102 |
+
data = []
|
| 103 |
+
score_map = {d.get("name", ""): int(d.get("score", 50)) for d in data if isinstance(d, dict)}
|
| 104 |
+
for a in accommodations:
|
| 105 |
+
a["llm_score"] = score_map.get(a.get("name", ""), 50)
|
| 106 |
+
return sorted(accommodations, key=lambda x: x.get("llm_score", 50), reverse=True)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def generate_itinerary(
|
| 110 |
+
city: str,
|
| 111 |
+
start_date: str,
|
| 112 |
+
days: int,
|
| 113 |
+
selected_attractions: List[Dict],
|
| 114 |
+
selected_stay: Dict | None,
|
| 115 |
+
weather_summary: str,
|
| 116 |
+
) -> str:
|
| 117 |
+
m = _model()
|
| 118 |
+
attractions_text = "\n".join([f"- {a.get('name')} ({a.get('kinds','')})" for a in selected_attractions])
|
| 119 |
+
hotel_text = selected_stay.get("name") if selected_stay else "TBD"
|
| 120 |
+
prompt = f"""
|
| 121 |
+
Create a practical, day-by-day itinerary for a trip.
|
| 122 |
+
|
| 123 |
+
City: {city}
|
| 124 |
+
Start Date: {start_date}
|
| 125 |
+
Days: {days}
|
| 126 |
+
Hotel: {hotel_text}
|
| 127 |
+
Weather (summary): {weather_summary}
|
| 128 |
+
|
| 129 |
+
Attractions to consider:
|
| 130 |
+
{attractions_text}
|
| 131 |
+
|
| 132 |
+
Constraints: group nearby sights, account for weather, add meal/time suggestions, include commute notes.
|
| 133 |
+
Return Markdown with sections Day 1, Day 2, ..., and a brief daily plan (morning/afternoon/evening).
|
| 134 |
+
"""
|
| 135 |
+
resp = m.generate_content(prompt)
|
| 136 |
+
return resp.text
|
planmate/utils.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import date, datetime, timedelta
|
| 2 |
+
from typing import List
|
| 3 |
+
import pytz
|
| 4 |
+
import re
|
| 5 |
+
|
| 6 |
+
def clamp_days(n: int, min_v=1, max_v=30):
|
| 7 |
+
return max(min_v, min(max_v, n))
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def to_iso(d: date) -> str:
|
| 11 |
+
return d.strftime("%Y-%m-%d")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def parse_date_str(s: str) -> date:
|
| 15 |
+
return datetime.strptime(s, "%Y-%m-%d").date()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def compute_return_date(start: date, days: int) -> date:
|
| 19 |
+
days = clamp_days(days)
|
| 20 |
+
return start + timedelta(days=days)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def format_price(amount: str, currency: str = "PKR") -> str:
|
| 24 |
+
try:
|
| 25 |
+
f = float(amount)
|
| 26 |
+
return f"{currency} {f:,.0f}"
|
| 27 |
+
except Exception:
|
| 28 |
+
return f"{currency} {amount}"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def day_list(start: date, days: int) -> List[date]:
|
| 32 |
+
days = clamp_days(days)
|
| 33 |
+
return [start + timedelta(days=i) for i in range(days)]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def as_local(dt: datetime, tz="Asia/Karachi") -> datetime:
|
| 37 |
+
return dt.astimezone(pytz.timezone(tz))
|
| 38 |
+
|
| 39 |
+
def is_iata_code(s: str) -> bool:
|
| 40 |
+
return bool(re.fullmatch(r"[A-Z]{3}", s or ""))
|
planmate/validation.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict
|
| 2 |
+
from .clients.amadeus_client import AmadeusClient
|
| 3 |
+
from .weather import geocode_city
|
| 4 |
+
from .config import MAX_AIRPORT_KM
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def nearest_airports_for_city(city: str, max_km: int = MAX_AIRPORT_KM) -> Dict:
|
| 8 |
+
"""
|
| 9 |
+
Returns nearest airports to the given city (within max_km).
|
| 10 |
+
Output: {"location": {...}, "airports": [airport_dicts_with__distance_km], "raw": raw_list}
|
| 11 |
+
Raises on geocoding or network errors.
|
| 12 |
+
"""
|
| 13 |
+
loc = geocode_city(city) # {name, lat, lon, country}
|
| 14 |
+
client = AmadeusClient()
|
| 15 |
+
res = client.airports_nearby(lat=loc["lat"], lon=loc["lon"])
|
| 16 |
+
|
| 17 |
+
filtered = []
|
| 18 |
+
for a in res.get("data", []):
|
| 19 |
+
dist = a.get("distance", {})
|
| 20 |
+
val = dist.get("value")
|
| 21 |
+
unit = (dist.get("unit") or "").upper()
|
| 22 |
+
if val is None:
|
| 23 |
+
continue
|
| 24 |
+
km = float(val) if unit == "KM" else float(val) * 1.60934
|
| 25 |
+
a["_distance_km"] = km
|
| 26 |
+
if km <= max_km:
|
| 27 |
+
filtered.append(a)
|
| 28 |
+
|
| 29 |
+
return {"location": loc, "airports": filtered, "raw": res.get("data", [])}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def assert_city_has_airport(city: str, max_km: int = MAX_AIRPORT_KM) -> Dict:
|
| 33 |
+
"""Validates a city has at least one airport within max_km. Raises RuntimeError otherwise."""
|
| 34 |
+
data = nearest_airports_for_city(city, max_km)
|
| 35 |
+
if len(data["airports"]) == 0:
|
| 36 |
+
raise RuntimeError(f"No airport found within {max_km} km of '{city}'. Please try a nearby larger city.")
|
| 37 |
+
return data
|
planmate/weather.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from typing import Dict
|
| 4 |
+
from .config import OPENWEATHER_BASE, get_openweather_key, UNITS
|
| 5 |
+
from .utils import day_list
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def geocode_city(city: str) -> Dict:
|
| 9 |
+
url = f"{OPENWEATHER_BASE}/geo/1.0/direct"
|
| 10 |
+
r = requests.get(
|
| 11 |
+
url,
|
| 12 |
+
params={"q": city, "limit": 1, "appid": get_openweather_key()},
|
| 13 |
+
timeout=20,
|
| 14 |
+
)
|
| 15 |
+
r.raise_for_status()
|
| 16 |
+
arr = r.json()
|
| 17 |
+
if not arr:
|
| 18 |
+
raise RuntimeError(f"City not found: {city}")
|
| 19 |
+
d = arr[0]
|
| 20 |
+
return {
|
| 21 |
+
"name": d.get("name"),
|
| 22 |
+
"lat": d.get("lat"),
|
| 23 |
+
"lon": d.get("lon"),
|
| 24 |
+
"country": d.get("country"),
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def forecast_5day(lat: float, lon: float, units=UNITS) -> Dict:
|
| 29 |
+
# 5 day / 3-hour forecast
|
| 30 |
+
url = f"{OPENWEATHER_BASE}/data/2.5/forecast"
|
| 31 |
+
r = requests.get(
|
| 32 |
+
url,
|
| 33 |
+
params={"lat": lat, "lon": lon, "appid": get_openweather_key(), "units": units},
|
| 34 |
+
timeout=25,
|
| 35 |
+
)
|
| 36 |
+
r.raise_for_status()
|
| 37 |
+
return r.json()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def summarize_forecast_for_range(city: str, start_date, days: int) -> Dict:
|
| 41 |
+
loc = geocode_city(city)
|
| 42 |
+
fc = forecast_5day(loc["lat"], loc["lon"])
|
| 43 |
+
daily = {}
|
| 44 |
+
for item in fc.get("list", []):
|
| 45 |
+
dt_txt = item.get("dt_txt") # 'YYYY-MM-DD HH:MM:SS'
|
| 46 |
+
dt = datetime.strptime(dt_txt, "%Y-%m-%d %H:%M:%S")
|
| 47 |
+
dkey = dt.date().isoformat()
|
| 48 |
+
temp = item.get("main", {}).get("temp")
|
| 49 |
+
weather = item.get("weather", [{}])[0].get("description", "")
|
| 50 |
+
wind = item.get("wind", {}).get("speed", 0)
|
| 51 |
+
if dkey not in daily:
|
| 52 |
+
daily[dkey] = {"temps": [], "desc": {}, "wind": []}
|
| 53 |
+
daily[dkey]["temps"].append(temp)
|
| 54 |
+
daily[dkey]["wind"].append(wind)
|
| 55 |
+
daily[dkey]["desc"][weather] = daily[dkey]["desc"].get(weather, 0) + 1
|
| 56 |
+
|
| 57 |
+
dates = [d.isoformat() for d in day_list(start_date, days)]
|
| 58 |
+
rows = []
|
| 59 |
+
for dkey in dates:
|
| 60 |
+
if dkey in daily:
|
| 61 |
+
temps = daily[dkey]["temps"]
|
| 62 |
+
avg = sum(temps) / len(temps) if temps else None
|
| 63 |
+
wavg = (
|
| 64 |
+
sum(daily[dkey]["wind"]) / len(daily[dkey]["wind"]) if daily[dkey]["wind"] else 0
|
| 65 |
+
)
|
| 66 |
+
desc = max(daily[dkey]["desc"], key=daily[dkey]["desc"].get)
|
| 67 |
+
rows.append(
|
| 68 |
+
{
|
| 69 |
+
"date": dkey,
|
| 70 |
+
"temp_avg": round(avg, 1) if avg is not None else None,
|
| 71 |
+
"wind": round(wavg, 1),
|
| 72 |
+
"desc": desc,
|
| 73 |
+
}
|
| 74 |
+
)
|
| 75 |
+
else:
|
| 76 |
+
rows.append(
|
| 77 |
+
{
|
| 78 |
+
"date": dkey,
|
| 79 |
+
"temp_avg": None,
|
| 80 |
+
"wind": None,
|
| 81 |
+
"desc": "No forecast (beyond 5 days)",
|
| 82 |
+
}
|
| 83 |
+
)
|
| 84 |
+
summary_text = "\n".join(
|
| 85 |
+
[
|
| 86 |
+
f"{r['date']}: {r['desc']}, avg {r['temp_avg']}°C, wind {r['wind']} m/s"
|
| 87 |
+
for r in rows
|
| 88 |
+
]
|
| 89 |
+
)
|
| 90 |
+
return {"location": loc, "daily": rows, "summary_text": summary_text}
|