alaa wael commited on
Commit ·
cb32952
1
Parent(s): f6b59b5
add travel utils
Browse files- app/api/routes.py +18 -2
- app/utils/travel_utils.py +125 -0
app/api/routes.py
CHANGED
|
@@ -32,6 +32,7 @@ from app.services.preprocessing import (
|
|
| 32 |
from app.services.postprocessing import agent_response_to_dict
|
| 33 |
from app.utils.helpers import utc_now_iso
|
| 34 |
from app.core.config import settings
|
|
|
|
| 35 |
|
| 36 |
logger = logging.getLogger(__name__)
|
| 37 |
|
|
@@ -179,10 +180,25 @@ async def chat_structured(request: ChatRequest) -> Dict[str, Any]:
|
|
| 179 |
@router.post("/plan", response_model=AgentResponse, tags=["Planning"])
|
| 180 |
async def plan(request: PlanRequest) -> AgentResponse:
|
| 181 |
"""
|
| 182 |
-
Generate a day-by-day itinerary from a fixed list of places
|
|
|
|
| 183 |
"""
|
| 184 |
try:
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
|
| 187 |
except Exception as exc:
|
| 188 |
logger.exception("Error in /plan: %s", exc)
|
|
|
|
| 32 |
from app.services.postprocessing import agent_response_to_dict
|
| 33 |
from app.utils.helpers import utc_now_iso
|
| 34 |
from app.core.config import settings
|
| 35 |
+
from app.utils.travel_utils import adjust_itinerary_times
|
| 36 |
|
| 37 |
logger = logging.getLogger(__name__)
|
| 38 |
|
|
|
|
| 180 |
@router.post("/plan", response_model=AgentResponse, tags=["Planning"])
|
| 181 |
async def plan(request: PlanRequest) -> AgentResponse:
|
| 182 |
"""
|
| 183 |
+
Generate a day-by-day itinerary from a fixed list of places,
|
| 184 |
+
calculate travel times, and adjust the schedules accordingly.
|
| 185 |
"""
|
| 186 |
try:
|
| 187 |
+
# 1. Run the core planning logic/agents
|
| 188 |
+
agent_response = generate_plan(request)
|
| 189 |
+
|
| 190 |
+
# 2. Convert response to a dictionary for modifications
|
| 191 |
+
# Use model_dump() for Pydantic v2, or dict() for Pydantic v1
|
| 192 |
+
if hasattr(agent_response, "model_dump"):
|
| 193 |
+
response_dict = agent_response.model_dump()
|
| 194 |
+
else:
|
| 195 |
+
response_dict = agent_response.dict()
|
| 196 |
+
|
| 197 |
+
# 3. Adjust time ranges based on spatial travel times
|
| 198 |
+
adjusted_dict = adjust_itinerary_times(response_dict)
|
| 199 |
+
|
| 200 |
+
# 4. Reconstruct the response model with modified data
|
| 201 |
+
return AgentResponse(**adjusted_dict)
|
| 202 |
|
| 203 |
except Exception as exc:
|
| 204 |
logger.exception("Error in /plan: %s", exc)
|
app/utils/travel_utils.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import math
|
| 2 |
+
from datetime import datetime, timedelta
|
| 3 |
+
import requests
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
# ----------------------------------------------------
|
| 7 |
+
# 1. TRAVEL TIME CALCULATION
|
| 8 |
+
# ----------------------------------------------------
|
| 9 |
+
def get_travel_time_minutes(lat1, lon1, lat2, lon2):
|
| 10 |
+
"""
|
| 11 |
+
Calculates travel time using OSRM Routing API, with a fallback
|
| 12 |
+
to Haversine (straight-line) distance if the API call fails.
|
| 13 |
+
"""
|
| 14 |
+
# Try using OSRM API (Free road-routing service)
|
| 15 |
+
url = f"http://router.project-osrm.org/route/v1/driving/{lon1},{lat1};{lon2},{lat2}?overview=false"
|
| 16 |
+
try:
|
| 17 |
+
response = requests.get(url, timeout=3)
|
| 18 |
+
if response.status_code == 200:
|
| 19 |
+
data = response.json()
|
| 20 |
+
if data.get("code") == "Ok":
|
| 21 |
+
duration_seconds = data["routes"][0]["duration"]
|
| 22 |
+
return round(duration_seconds / 60)
|
| 23 |
+
except Exception:
|
| 24 |
+
pass # Fail silently to trigger the mathematical fallback
|
| 25 |
+
|
| 26 |
+
# Fallback: Haversine Formula with an assumed average speed of 30 km/h
|
| 27 |
+
R = 6371.0 # Earth's radius in km
|
| 28 |
+
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
| 29 |
+
delta_phi = math.radians(lat2 - lat1)
|
| 30 |
+
delta_lambda = math.radians(lon2 - lon1)
|
| 31 |
+
|
| 32 |
+
a = (
|
| 33 |
+
math.sin(delta_phi / 2.0) ** 2
|
| 34 |
+
+ math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2.0) ** 2
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
| 38 |
+
distance_km = R * c
|
| 39 |
+
|
| 40 |
+
avg_speed_kmh = 30.0
|
| 41 |
+
time_minutes = (distance_km / avg_speed_kmh) * 60
|
| 42 |
+
return max(5, round(time_minutes)) # Ensure a minimum of 5 minutes travel time
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ----------------------------------------------------
|
| 46 |
+
# 2. TIME FORMATTING AND PARSING HELPERS
|
| 47 |
+
# ----------------------------------------------------
|
| 48 |
+
def parse_time_str(time_str: str) -> datetime:
|
| 49 |
+
"""Parses time strings like '9:00 PM' or '10:30 AM' into a datetime object."""
|
| 50 |
+
clean_str = time_str.strip().upper()
|
| 51 |
+
return datetime.strptime(clean_str, "%I:%M %p")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def format_time_obj(dt: datetime) -> str:
|
| 55 |
+
"""Formats datetime objects back to '9:00 PM' style string."""
|
| 56 |
+
return dt.strftime("%I:%M %p").lstrip("0")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# ----------------------------------------------------
|
| 60 |
+
# 3. ITINERARY ADJUSTMENT ENGINE
|
| 61 |
+
# ----------------------------------------------------
|
| 62 |
+
def adjust_itinerary_times(itinerary_dict: dict) -> dict:
|
| 63 |
+
"""
|
| 64 |
+
Iterates through each day in the itinerary, calculates travel times,
|
| 65 |
+
and updates the 'time_range' fields for all activities.
|
| 66 |
+
"""
|
| 67 |
+
daily_itinerary = (
|
| 68 |
+
itinerary_dict.get("active_trip", {})
|
| 69 |
+
.get("itinerary", {})
|
| 70 |
+
.get("daily_itinerary", [])
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
for day in daily_itinerary:
|
| 74 |
+
activities = day.get("activities", [])
|
| 75 |
+
if not activities:
|
| 76 |
+
continue
|
| 77 |
+
|
| 78 |
+
# Step 1: Process the first activity
|
| 79 |
+
first_act = activities[0]
|
| 80 |
+
duration_hours = first_act.get("estimated_duration_hours", 1.0)
|
| 81 |
+
|
| 82 |
+
# Extract initial start time
|
| 83 |
+
time_range = first_act.get("time_range", "09:00 AM - 10:00 AM")
|
| 84 |
+
try:
|
| 85 |
+
start_time_str = time_range.split("-")[0].strip()
|
| 86 |
+
current_start = parse_time_str(start_time_str)
|
| 87 |
+
except Exception:
|
| 88 |
+
current_start = parse_time_str("09:00 AM")
|
| 89 |
+
|
| 90 |
+
current_end = current_start + timedelta(hours=duration_hours)
|
| 91 |
+
first_act["time_range"] = (
|
| 92 |
+
f"{format_time_obj(current_start)} - {format_time_obj(current_end)}"
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
# Step 2: Sequentially process subsequent activities
|
| 96 |
+
for i in range(1, len(activities)):
|
| 97 |
+
prev_act = activities[i - 1]
|
| 98 |
+
curr_act = activities[i]
|
| 99 |
+
|
| 100 |
+
# Extract coordinates (Note spelling: 'late' and 'long' from your JSON structure)
|
| 101 |
+
prev_loc = prev_act.get("place", {}).get("location", {})
|
| 102 |
+
curr_loc = curr_act.get("place", {}).get("location", {})
|
| 103 |
+
|
| 104 |
+
lat1, lon1 = prev_loc.get("late"), prev_loc.get("long")
|
| 105 |
+
lat2, lon2 = curr_loc.get("late"), curr_loc.get("long")
|
| 106 |
+
|
| 107 |
+
# Calculate travel time (default to 15 minutes if coordinates are missing)
|
| 108 |
+
if None not in (lat1, lon1, lat2, lon2):
|
| 109 |
+
travel_minutes = get_travel_time_minutes(lat1, lon1, lat2, lon2)
|
| 110 |
+
else:
|
| 111 |
+
travel_minutes = 15
|
| 112 |
+
|
| 113 |
+
# Current activity starts after previous ends + travel time
|
| 114 |
+
curr_start = current_end + timedelta(minutes=travel_minutes)
|
| 115 |
+
curr_duration = curr_act.get("estimated_duration_hours", 1.0)
|
| 116 |
+
curr_end = curr_start + timedelta(hours=curr_duration)
|
| 117 |
+
|
| 118 |
+
curr_act["time_range"] = (
|
| 119 |
+
f"{format_time_obj(curr_start)} - {format_time_obj(curr_end)}"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# Advance pointers
|
| 123 |
+
current_end = curr_end
|
| 124 |
+
|
| 125 |
+
return itinerary_dict
|