mirror of
https://github.com/hanzoskill/event-planner.git
synced 2026-10-30 21:03:54 +00:00
800 lines
25 KiB
Python
800 lines
25 KiB
Python
#!/usr/bin/env python3
|
|
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = [
|
|
# "requests>=2.31.0",
|
|
# ]
|
|
# ///
|
|
"""
|
|
Event planner using Google Places API.
|
|
|
|
Usage:
|
|
uv run plan_event.py EVENT_TYPE --location LOCATION [OPTIONS]
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
import urllib.parse
|
|
from dataclasses import dataclass, asdict
|
|
from datetime import datetime, timedelta
|
|
from typing import Literal
|
|
|
|
import requests
|
|
|
|
|
|
# Data structures
|
|
@dataclass
|
|
class Coordinates:
|
|
lat: float
|
|
lng: float
|
|
address: str
|
|
|
|
|
|
@dataclass
|
|
class Venue:
|
|
name: str
|
|
address: str
|
|
location: Coordinates
|
|
rating: float
|
|
price_level: int
|
|
place_id: str
|
|
types: list[str]
|
|
open_now: bool | None # None means unknown
|
|
hours_known: bool
|
|
user_ratings_total: int
|
|
|
|
|
|
@dataclass
|
|
class BudgetConstraints:
|
|
min_price_level: int
|
|
max_price_level: int
|
|
total_estimate: float | None
|
|
|
|
|
|
@dataclass
|
|
class ItineraryItem:
|
|
venue: Venue
|
|
arrival_time: str
|
|
duration_minutes: int
|
|
departure_time: str
|
|
travel_from_previous_minutes: int
|
|
travel_distance_km: float = 0.0 # Walking distance in km
|
|
travel_time_source: str = "" # 'directions_api' or 'estimated'
|
|
|
|
|
|
@dataclass
|
|
class Itinerary:
|
|
event_type: str
|
|
location: str
|
|
party_size: int
|
|
budget: str
|
|
items: list[ItineraryItem]
|
|
total_duration_hours: float
|
|
total_cost_estimate: str
|
|
map_link: str
|
|
preferences_applied: list[str]
|
|
warnings: list[str]
|
|
|
|
|
|
# Event templates
|
|
EVENT_TEMPLATES = {
|
|
"night-out": [
|
|
{"type": "restaurant", "query": "dinner restaurant", "duration": 90, "filters": {"min_rating": 4.0}},
|
|
{"type": "bar", "query": "cocktail bar", "duration": 60, "filters": {"min_rating": 4.0}},
|
|
],
|
|
"weekend-day": [
|
|
{"type": "restaurant", "query": "brunch", "duration": 90, "filters": {"min_rating": 4.0}},
|
|
{"type": "activity", "query": "museum", "duration": 180, "filters": {}},
|
|
{"type": "restaurant", "query": "dinner restaurant", "duration": 90, "filters": {"min_rating": 4.0}},
|
|
],
|
|
"date-night": [
|
|
{"type": "restaurant", "query": "romantic restaurant", "duration": 120, "filters": {"min_rating": 4.2}},
|
|
{"type": "dessert", "query": "dessert cafe", "duration": 45, "filters": {"min_rating": 4.0}},
|
|
],
|
|
"team-event": [
|
|
{"type": "activity", "query": "bowling", "duration": 120, "filters": {}},
|
|
{"type": "restaurant", "query": "restaurant", "duration": 90, "filters": {"min_rating": 4.0}},
|
|
],
|
|
"lunch": [
|
|
{"type": "restaurant", "query": "lunch restaurant", "duration": 60, "filters": {"min_rating": 4.0}},
|
|
],
|
|
"dinner": [
|
|
{"type": "restaurant", "query": "dinner restaurant", "duration": 90, "filters": {"min_rating": 4.0}},
|
|
],
|
|
}
|
|
|
|
# Venue types with variable hours or event-dependent operation
|
|
EVENT_DEPENDENT_TYPES = {
|
|
"cultural_center",
|
|
"performing_arts_theater",
|
|
"event_venue",
|
|
"community_center",
|
|
"concert_hall",
|
|
"theater",
|
|
"night_club",
|
|
"movie_theater",
|
|
"amusement_park",
|
|
}
|
|
|
|
# Google Places API configuration
|
|
BASE_URL = "https://places.googleapis.com/v1"
|
|
DIRECTIONS_URL = "https://maps.googleapis.com/maps/api/directions/json"
|
|
API_KEY = os.environ.get("GOOGLE_PLACES_API_KEY", "")
|
|
|
|
|
|
def parse_budget(budget_str: str, party_size: int) -> BudgetConstraints:
|
|
"""Parse budget string into price level constraints."""
|
|
# Check for dollar amount
|
|
dollar_match = re.match(r'\$?(\d+(?:\.\d+)?)\s*(?:per\s+person)?', budget_str.lower())
|
|
if dollar_match:
|
|
amount = float(dollar_match.group(1))
|
|
total = amount * party_size
|
|
|
|
# Map to Google price levels (0-4)
|
|
# Rough mapping: 0-20: level 1, 20-40: level 2, 40-60: level 3, 60+: level 4
|
|
if amount < 20:
|
|
return BudgetConstraints(1, 2, total)
|
|
elif amount < 40:
|
|
return BudgetConstraints(2, 3, total)
|
|
elif amount < 60:
|
|
return BudgetConstraints(3, 4, total)
|
|
else:
|
|
return BudgetConstraints(3, 4, total)
|
|
|
|
# Text levels
|
|
budget_lower = budget_str.lower()
|
|
if budget_lower == "low":
|
|
return BudgetConstraints(1, 2, None)
|
|
elif budget_lower == "medium":
|
|
return BudgetConstraints(2, 3, None)
|
|
elif budget_lower == "high":
|
|
return BudgetConstraints(3, 4, None)
|
|
else:
|
|
# Default to medium
|
|
return BudgetConstraints(2, 3, None)
|
|
|
|
|
|
def call_places_api(endpoint: str, body: dict, retry_count: int = 3) -> dict:
|
|
"""Call Google Places API with retry logic."""
|
|
headers = {
|
|
"Content-Type": "application/json",
|
|
"X-Goog-Api-Key": API_KEY,
|
|
"X-Goog-FieldMask": "places.displayName,places.formattedAddress,places.location,places.rating,places.priceLevel,places.id,places.types,places.currentOpeningHours,places.userRatingCount"
|
|
}
|
|
|
|
url = f"{BASE_URL}/{endpoint}"
|
|
|
|
for attempt in range(retry_count):
|
|
try:
|
|
response = requests.post(url, json=body, headers=headers, timeout=30)
|
|
|
|
if response.status_code == 200:
|
|
return response.json()
|
|
elif response.status_code == 429: # Rate limit
|
|
if attempt < retry_count - 1:
|
|
wait_time = 2 ** attempt
|
|
print(f"Rate limit hit, waiting {wait_time}s...", file=sys.stderr)
|
|
time.sleep(wait_time)
|
|
continue
|
|
else:
|
|
print(f"API error: {response.status_code} - {response.text}", file=sys.stderr)
|
|
if attempt < retry_count - 1:
|
|
time.sleep(2 ** attempt)
|
|
continue
|
|
return {}
|
|
|
|
except Exception as e:
|
|
if attempt < retry_count - 1:
|
|
print(f"Request failed: {e}, retrying...", file=sys.stderr)
|
|
time.sleep(2 ** attempt)
|
|
else:
|
|
print(f"Request failed after {retry_count} attempts: {e}", file=sys.stderr)
|
|
return {}
|
|
|
|
return {}
|
|
|
|
|
|
def resolve_location(location: str) -> Coordinates | None:
|
|
"""Resolve location string to coordinates."""
|
|
body = {
|
|
"textQuery": location
|
|
}
|
|
|
|
result = call_places_api("places:searchText", body)
|
|
|
|
if not result or "places" not in result or not result["places"]:
|
|
return None
|
|
|
|
place = result["places"][0]
|
|
|
|
if "location" not in place:
|
|
return None
|
|
|
|
loc = place["location"]
|
|
address = place.get("formattedAddress", location)
|
|
|
|
return Coordinates(
|
|
lat=loc["latitude"],
|
|
lng=loc["longitude"],
|
|
address=address
|
|
)
|
|
|
|
|
|
def search_venues_with_fallback(query: str, location: Coordinates, filters: dict) -> list[Venue]:
|
|
"""Search with expanding radius if too few results."""
|
|
venues = search_venues(query, location, filters, radius=5000)
|
|
|
|
if len(venues) < 3:
|
|
# Try larger radius
|
|
venues = search_venues(query, location, filters, radius=10000)
|
|
|
|
if len(venues) < 3:
|
|
# Try without min_rating filter
|
|
relaxed = {k: v for k, v in filters.items() if k != "min_rating"}
|
|
venues = search_venues(query, location, relaxed, radius=10000)
|
|
|
|
return venues
|
|
|
|
|
|
def search_venues(query: str, location: Coordinates, filters: dict, radius: int = 5000) -> list[Venue]:
|
|
"""Search for venues near a location."""
|
|
body = {
|
|
"textQuery": f"{query} near {location.address}",
|
|
"locationBias": {
|
|
"circle": {
|
|
"center": {"latitude": location.lat, "longitude": location.lng},
|
|
"radius": radius
|
|
}
|
|
},
|
|
"maxResultCount": 20
|
|
}
|
|
|
|
result = call_places_api("places:searchText", body)
|
|
|
|
if not result or "places" not in result:
|
|
return []
|
|
|
|
venues = []
|
|
for place in result.get("places", []):
|
|
if "location" not in place:
|
|
continue
|
|
|
|
# Extract data
|
|
name = place.get("displayName", {}).get("text", "Unknown")
|
|
address = place.get("formattedAddress", "")
|
|
loc = place["location"]
|
|
rating = place.get("rating", 0.0)
|
|
price_level = place.get("priceLevel", "PRICE_LEVEL_UNSPECIFIED")
|
|
place_id = place.get("id", "")
|
|
types = place.get("types", [])
|
|
user_rating_count = place.get("userRatingCount", 0)
|
|
|
|
# Convert price level string to int
|
|
price_map = {
|
|
"PRICE_LEVEL_FREE": 0,
|
|
"PRICE_LEVEL_INEXPENSIVE": 1,
|
|
"PRICE_LEVEL_MODERATE": 2,
|
|
"PRICE_LEVEL_EXPENSIVE": 3,
|
|
"PRICE_LEVEL_VERY_EXPENSIVE": 4,
|
|
"PRICE_LEVEL_UNSPECIFIED": 2, # Default to medium
|
|
}
|
|
price_int = price_map.get(price_level, 2)
|
|
|
|
# Check open status - don't assume open if unknown
|
|
current_hours = place.get("currentOpeningHours")
|
|
if current_hours is not None:
|
|
open_now = current_hours.get("openNow") # May still be None if not present
|
|
hours_known = True
|
|
else:
|
|
open_now = None
|
|
hours_known = False
|
|
|
|
# Apply filters
|
|
min_rating = filters.get("min_rating", 0.0)
|
|
if rating < min_rating:
|
|
continue
|
|
|
|
venue = Venue(
|
|
name=name,
|
|
address=address,
|
|
location=Coordinates(loc["latitude"], loc["longitude"], address),
|
|
rating=rating,
|
|
price_level=price_int,
|
|
place_id=place_id,
|
|
types=types,
|
|
open_now=open_now,
|
|
hours_known=hours_known,
|
|
user_ratings_total=user_rating_count
|
|
)
|
|
venues.append(venue)
|
|
|
|
return venues
|
|
|
|
|
|
def calculate_distance(coord1: Coordinates, coord2: Coordinates) -> float:
|
|
"""Calculate distance in miles using Haversine formula."""
|
|
R = 3959 # Earth radius in miles
|
|
|
|
lat1 = math.radians(coord1.lat)
|
|
lat2 = math.radians(coord2.lat)
|
|
dlat = math.radians(coord2.lat - coord1.lat)
|
|
dlng = math.radians(coord2.lng - coord1.lng)
|
|
|
|
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlng/2)**2
|
|
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
|
|
|
|
return R * c
|
|
|
|
|
|
def get_directions_info(origin: Coordinates, dest: Coordinates, mode: str = "walking") -> tuple[int, float] | None:
|
|
"""Get travel time (minutes) and distance (km) via Google Directions API."""
|
|
params = {
|
|
"origin": f"{origin.lat},{origin.lng}",
|
|
"destination": f"{dest.lat},{dest.lng}",
|
|
"mode": mode,
|
|
"key": API_KEY,
|
|
}
|
|
try:
|
|
resp = requests.get(DIRECTIONS_URL, params=params, timeout=10)
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
if data.get("status") == "OK":
|
|
leg = data["routes"][0]["legs"][0]
|
|
duration_min = leg["duration"]["value"] // 60
|
|
distance_km = leg["distance"]["value"] / 1000
|
|
return (duration_min, distance_km)
|
|
except Exception as e:
|
|
print(f"Directions API error: {e}", file=sys.stderr)
|
|
return None
|
|
|
|
|
|
def calculate_travel_time(origin: Coordinates, destination: Coordinates) -> tuple[int, float, str]:
|
|
"""Calculate travel time and distance using Directions API with Haversine fallback.
|
|
|
|
Returns (minutes, distance_km, source) where source is 'directions_api' or 'estimated'.
|
|
"""
|
|
# Try Directions API first
|
|
result = get_directions_info(origin, destination)
|
|
if result is not None:
|
|
return (result[0], result[1], "directions_api")
|
|
|
|
# Fallback: Haversine with 30% buffer for realistic routes
|
|
distance_miles = calculate_distance(origin, destination)
|
|
distance_km = distance_miles * 1.609
|
|
haversine_time = int(distance_miles * 20 * 1.3) # 3mph + 30% buffer
|
|
return (haversine_time, distance_km * 1.3, "estimated")
|
|
|
|
|
|
def select_best_venues(venues: list[Venue], origin: Coordinates, budget: BudgetConstraints, count: int = 1) -> list[Venue]:
|
|
"""Select best venues based on scoring algorithm."""
|
|
if not venues:
|
|
return []
|
|
|
|
scores = []
|
|
for venue in venues:
|
|
# Rating score (40%)
|
|
rating_score = (venue.rating / 5.0) * 0.4
|
|
|
|
# Distance score (30%) - closer is better
|
|
distance = calculate_distance(origin, venue.location)
|
|
distance_score = max(0, (1 - distance / 5.0)) * 0.3 # Normalize to 5 miles
|
|
|
|
# Price match score (20%)
|
|
if budget.min_price_level <= venue.price_level <= budget.max_price_level:
|
|
price_score = 0.2
|
|
else:
|
|
price_score = 0.05
|
|
|
|
# Popularity score (10%)
|
|
popularity_score = min(venue.user_ratings_total / 1000, 1.0) * 0.1
|
|
|
|
total_score = rating_score + distance_score + price_score + popularity_score
|
|
scores.append((total_score, venue))
|
|
|
|
# Sort by score descending
|
|
scores.sort(key=lambda x: x[0], reverse=True)
|
|
|
|
return [venue for _, venue in scores[:count]]
|
|
|
|
|
|
def check_venue_warnings(venue: Venue) -> list[str]:
|
|
"""Generate warnings for venues with variable availability."""
|
|
warnings = []
|
|
|
|
# Event-dependent venues
|
|
venue_types = set(venue.types or [])
|
|
event_types = venue_types & EVENT_DEPENDENT_TYPES
|
|
if event_types:
|
|
warnings.append(f"{venue.name} may have variable hours - check schedule before visiting")
|
|
|
|
# Unknown opening hours
|
|
if not venue.hours_known:
|
|
warnings.append(f"Opening hours for {venue.name} could not be verified")
|
|
elif venue.open_now is False:
|
|
warnings.append(f"{venue.name} appears to be currently closed")
|
|
|
|
return warnings
|
|
|
|
|
|
def generate_google_maps_link(start_location: str, venues: list[Venue]) -> str:
|
|
"""Generate Google Maps link with waypoints."""
|
|
if not venues:
|
|
return ""
|
|
|
|
# URL encode addresses
|
|
origin = urllib.parse.quote(start_location)
|
|
destination = urllib.parse.quote(venues[-1].address)
|
|
|
|
# Waypoints (all except last)
|
|
waypoints = []
|
|
for venue in venues[:-1]:
|
|
waypoints.append(urllib.parse.quote(venue.address))
|
|
|
|
waypoints_str = "|".join(waypoints) if waypoints else ""
|
|
|
|
# Build URL
|
|
base = "https://www.google.com/maps/dir/?api=1"
|
|
url = f"{base}&origin={origin}&destination={destination}"
|
|
|
|
if waypoints_str:
|
|
url += f"&waypoints={waypoints_str}"
|
|
|
|
url += "&travelmode=walking"
|
|
|
|
return url
|
|
|
|
|
|
def generate_itinerary(event_type: str, location: str, party_size: int, budget_str: str, venues: list[Venue], start_time_str: str = None) -> Itinerary:
|
|
"""Generate full itinerary from selected venues."""
|
|
if not venues:
|
|
return Itinerary(
|
|
event_type=event_type,
|
|
location=location,
|
|
party_size=party_size,
|
|
budget=budget_str,
|
|
items=[],
|
|
total_duration_hours=0.0,
|
|
total_cost_estimate="N/A",
|
|
map_link="",
|
|
preferences_applied=[],
|
|
warnings=["No venues found"]
|
|
)
|
|
|
|
# Get event template
|
|
template = EVENT_TEMPLATES.get(event_type.lower(), EVENT_TEMPLATES["dinner"])
|
|
|
|
# Parse start time
|
|
if start_time_str:
|
|
try:
|
|
current_time = datetime.strptime(start_time_str, "%H:%M")
|
|
except:
|
|
current_time = datetime.now().replace(minute=0, second=0, microsecond=0)
|
|
else:
|
|
# Default times based on event type
|
|
defaults = {
|
|
"night-out": 19, # 7 PM
|
|
"date-night": 19,
|
|
"lunch": 12,
|
|
"dinner": 19,
|
|
"weekend-day": 10, # 10 AM
|
|
"team-event": 18,
|
|
}
|
|
hour = defaults.get(event_type.lower(), 19)
|
|
current_time = datetime.now().replace(hour=hour, minute=0, second=0, microsecond=0)
|
|
|
|
# Build itinerary items
|
|
items = []
|
|
warnings = []
|
|
total_duration = 0
|
|
|
|
prev_location = None
|
|
|
|
for i, venue in enumerate(venues):
|
|
# Check venue-specific warnings
|
|
venue_warnings = check_venue_warnings(venue)
|
|
warnings.extend(venue_warnings)
|
|
|
|
# Travel time and distance from previous
|
|
travel_time = 0
|
|
travel_distance_km = 0.0
|
|
travel_source = ""
|
|
if prev_location:
|
|
travel_time, travel_distance_km, travel_source = calculate_travel_time(prev_location, venue.location)
|
|
if travel_time > 20:
|
|
source_note = " (estimated)" if travel_source == "estimated" else ""
|
|
warnings.append(f"Long walk to {venue.name}: {travel_time} minutes{source_note}")
|
|
|
|
# Arrival time
|
|
if i > 0:
|
|
current_time += timedelta(minutes=travel_time)
|
|
|
|
arrival_time = current_time.strftime("%I:%M %p")
|
|
|
|
# Duration
|
|
duration = template[min(i, len(template)-1)].get("duration", 60)
|
|
|
|
# Departure time
|
|
departure_time = (current_time + timedelta(minutes=duration)).strftime("%I:%M %p")
|
|
|
|
item = ItineraryItem(
|
|
venue=venue,
|
|
arrival_time=arrival_time,
|
|
duration_minutes=duration,
|
|
departure_time=departure_time,
|
|
travel_from_previous_minutes=travel_time,
|
|
travel_distance_km=travel_distance_km,
|
|
travel_time_source=travel_source
|
|
)
|
|
items.append(item)
|
|
|
|
total_duration += duration + travel_time
|
|
current_time += timedelta(minutes=duration)
|
|
prev_location = venue.location
|
|
|
|
# Calculate cost estimate
|
|
avg_price = sum(v.price_level for v in venues) / len(venues)
|
|
price_ranges = {
|
|
1: "$10-20",
|
|
2: "$20-40",
|
|
3: "$40-60",
|
|
4: "$60+"
|
|
}
|
|
cost_estimate = f"{price_ranges.get(round(avg_price), '$20-40')} per person"
|
|
|
|
# Generate map link
|
|
map_link = generate_google_maps_link(location, venues)
|
|
|
|
return Itinerary(
|
|
event_type=event_type,
|
|
location=location,
|
|
party_size=party_size,
|
|
budget=budget_str,
|
|
items=items,
|
|
total_duration_hours=total_duration / 60,
|
|
total_cost_estimate=cost_estimate,
|
|
map_link=map_link,
|
|
preferences_applied=[],
|
|
warnings=warnings
|
|
)
|
|
|
|
|
|
def format_output_text(itinerary: Itinerary) -> str:
|
|
"""Format itinerary as Markdown text."""
|
|
lines = [
|
|
f"# Your {itinerary.event_type.title()} Itinerary",
|
|
"",
|
|
f"**Location**: {itinerary.location}",
|
|
f"**Party Size**: {itinerary.party_size} {'person' if itinerary.party_size == 1 else 'people'}",
|
|
f"**Budget**: {itinerary.budget}",
|
|
f"**Total Duration**: {itinerary.total_duration_hours:.1f} hours",
|
|
"",
|
|
"## Schedule",
|
|
"",
|
|
"| Time | Venue | Type | Rating | Price | Notes |",
|
|
"|------|-------|------|--------|-------|-------|",
|
|
]
|
|
|
|
price_symbols = {0: "Free", 1: "$", 2: "$$", 3: "$$$", 4: "$$$$"}
|
|
|
|
for i, item in enumerate(itinerary.items):
|
|
venue = item.venue
|
|
price_str = price_symbols.get(venue.price_level, "$$")
|
|
|
|
# Venue row
|
|
venue_type = venue.types[0] if venue.types else "venue"
|
|
notes = f"{item.duration_minutes} min"
|
|
|
|
lines.append(
|
|
f"| {item.arrival_time} | {venue.name} | {venue_type.title()} | {venue.rating:.1f} ⭐ | {price_str} | {notes} |"
|
|
)
|
|
|
|
# Travel row (if not last)
|
|
if i < len(itinerary.items) - 1:
|
|
next_item = itinerary.items[i+1]
|
|
next_travel = next_item.travel_from_previous_minutes
|
|
if next_travel > 0:
|
|
distance_km = next_item.travel_distance_km
|
|
source_note = " ~" if next_item.travel_time_source == "estimated" else ""
|
|
lines.append(
|
|
f"| *Walk {next_travel} min{source_note}* | - | - | - | - | {distance_km:.1f} km |"
|
|
)
|
|
|
|
# Total walking distance
|
|
total_distance_km = sum(item.travel_distance_km for item in itinerary.items)
|
|
|
|
lines.extend([
|
|
"",
|
|
f"**Total Walking**: {total_distance_km:.1f} km",
|
|
f"**Estimated Cost**: {itinerary.total_cost_estimate}",
|
|
"",
|
|
f"🗺️ **[View Route on Google Maps]({itinerary.map_link})**",
|
|
])
|
|
|
|
if itinerary.warnings:
|
|
lines.extend([
|
|
"",
|
|
"⚠️ **Warnings**:",
|
|
])
|
|
for warning in itinerary.warnings:
|
|
lines.append(f"- {warning}")
|
|
|
|
lines.extend([
|
|
"",
|
|
"---",
|
|
"💡 *Generated by Hanzo Bot Event Planner*"
|
|
])
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def format_output_json(itinerary: Itinerary) -> str:
|
|
"""Format itinerary as JSON."""
|
|
# Convert dataclasses to dicts
|
|
items_dicts = []
|
|
for item in itinerary.items:
|
|
items_dicts.append({
|
|
"venue": {
|
|
"name": item.venue.name,
|
|
"address": item.venue.address,
|
|
"location": asdict(item.venue.location),
|
|
"rating": item.venue.rating,
|
|
"price_level": item.venue.price_level,
|
|
"place_id": item.venue.place_id,
|
|
"types": item.venue.types,
|
|
"open_now": item.venue.open_now,
|
|
"hours_known": item.venue.hours_known,
|
|
"user_ratings_total": item.venue.user_ratings_total,
|
|
},
|
|
"arrival_time": item.arrival_time,
|
|
"duration_minutes": item.duration_minutes,
|
|
"departure_time": item.departure_time,
|
|
"travel_from_previous_minutes": item.travel_from_previous_minutes,
|
|
"travel_distance_km": item.travel_distance_km,
|
|
"travel_time_source": item.travel_time_source,
|
|
})
|
|
|
|
output = {
|
|
"event_type": itinerary.event_type,
|
|
"location": itinerary.location,
|
|
"party_size": itinerary.party_size,
|
|
"budget": itinerary.budget,
|
|
"items": items_dicts,
|
|
"total_duration_hours": itinerary.total_duration_hours,
|
|
"total_cost_estimate": itinerary.total_cost_estimate,
|
|
"map_link": itinerary.map_link,
|
|
"preferences_applied": itinerary.preferences_applied,
|
|
"warnings": itinerary.warnings,
|
|
}
|
|
|
|
return json.dumps(output, indent=2)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Plan events using Google Places API"
|
|
)
|
|
parser.add_argument(
|
|
"event_type",
|
|
choices=["night-out", "weekend-day", "date-night", "team-event", "lunch", "dinner", "trip"],
|
|
help="Type of event to plan"
|
|
)
|
|
parser.add_argument(
|
|
"--location",
|
|
required=True,
|
|
help="Location for the event (city, address, landmark)"
|
|
)
|
|
parser.add_argument(
|
|
"--party-size",
|
|
type=int,
|
|
default=2,
|
|
help="Number of people (default: 2)"
|
|
)
|
|
parser.add_argument(
|
|
"--budget",
|
|
default="medium",
|
|
help="Budget: 'low/medium/high' or '$X per person' (default: medium)"
|
|
)
|
|
parser.add_argument(
|
|
"--duration",
|
|
help="Duration (e.g., '3h', '4h', 'full day') - currently unused"
|
|
)
|
|
parser.add_argument(
|
|
"--preferences",
|
|
help="Comma-separated preferences (e.g., 'vegetarian, outdoor seating')"
|
|
)
|
|
parser.add_argument(
|
|
"--start-time",
|
|
help="Start time in HH:MM format (e.g., '19:00')"
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
choices=["text", "json"],
|
|
default="text",
|
|
help="Output format (default: text)"
|
|
)
|
|
parser.add_argument(
|
|
"--date",
|
|
help="Target date in YYYY-MM-DD format for day-specific checks (default: today)"
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Check API key
|
|
if not API_KEY:
|
|
print("Error: GOOGLE_PLACES_API_KEY environment variable not set", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Resolve location
|
|
print(f"Resolving location: {args.location}", file=sys.stderr)
|
|
coords = resolve_location(args.location)
|
|
|
|
if not coords:
|
|
print(f"Error: Could not resolve location '{args.location}'", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
print(f"Found: {coords.address} ({coords.lat}, {coords.lng})", file=sys.stderr)
|
|
|
|
# Parse budget
|
|
budget = parse_budget(args.budget, args.party_size)
|
|
|
|
# Get event template
|
|
template = EVENT_TEMPLATES.get(args.event_type, EVENT_TEMPLATES["dinner"])
|
|
|
|
# Search for venues for each template item (with deduplication)
|
|
all_venues = []
|
|
selected_place_ids: set[str] = set()
|
|
|
|
for template_item in template:
|
|
query = template_item["query"]
|
|
filters = template_item["filters"]
|
|
|
|
print(f"Searching for: {query}", file=sys.stderr)
|
|
venues = search_venues_with_fallback(query, coords, filters)
|
|
|
|
# Filter out already-selected venues
|
|
available = [v for v in venues if v.place_id not in selected_place_ids]
|
|
|
|
if available:
|
|
# Select best venue from available
|
|
best = select_best_venues(available, coords, budget, count=1)
|
|
if best:
|
|
all_venues.append(best[0])
|
|
selected_place_ids.add(best[0].place_id)
|
|
print(f" Found: {best[0].name} ({best[0].rating}⭐, {best[0].price_level})", file=sys.stderr)
|
|
elif venues:
|
|
# All filtered out - use best if not already selected
|
|
best = select_best_venues(venues, coords, budget, count=1)
|
|
if best and best[0].place_id not in selected_place_ids:
|
|
all_venues.append(best[0])
|
|
selected_place_ids.add(best[0].place_id)
|
|
print(f" Found (fallback): {best[0].name} ({best[0].rating}⭐)", file=sys.stderr)
|
|
else:
|
|
print(f" No unique venues available for: {query}", file=sys.stderr)
|
|
else:
|
|
print(f" No venues found for: {query}", file=sys.stderr)
|
|
|
|
# Generate itinerary
|
|
itinerary = generate_itinerary(
|
|
args.event_type,
|
|
args.location,
|
|
args.party_size,
|
|
args.budget,
|
|
all_venues,
|
|
args.start_time
|
|
)
|
|
|
|
# Output
|
|
if args.output == "json":
|
|
print(format_output_json(itinerary))
|
|
else:
|
|
print(format_output_text(itinerary))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|