Spaces:
Sleeping
Sleeping
update app.py and route_planner_tool.py
Browse files- app.py +149 -135
- tools/route_planner_tool.py +42 -26
app.py
CHANGED
|
@@ -114,67 +114,139 @@ writer_agent = Agent(
|
|
| 114 |
)
|
| 115 |
|
| 116 |
# Core logic
|
| 117 |
-
def generate_itinerary(location, start_date, end_date, preferences, transport_modes
|
| 118 |
t0 = time.time()
|
| 119 |
print("✅ Button clicked:", location, start_date, end_date, transport_modes, preferences)
|
| 120 |
-
|
| 121 |
-
try:
|
|
|
|
| 122 |
if isinstance(start_date, str):
|
| 123 |
start_date = start_date.replace("/", "-")
|
| 124 |
start_date = datetime.fromisoformat(start_date).date().isoformat()
|
| 125 |
elif hasattr(start_date, "date"):
|
| 126 |
start_date = start_date.date().isoformat()
|
| 127 |
-
|
| 128 |
if isinstance(end_date, str):
|
| 129 |
end_date = end_date.replace("/", "-")
|
| 130 |
end_date = datetime.fromisoformat(end_date).date().isoformat()
|
| 131 |
elif hasattr(end_date, "date"):
|
| 132 |
end_date = end_date.date().isoformat()
|
|
|
|
| 133 |
days, date_list = expand_dates(start_date, end_date)
|
| 134 |
trip_duration_days = days
|
| 135 |
-
|
| 136 |
if isinstance(transport_modes, str):
|
| 137 |
transport_modes = [transport_modes]
|
| 138 |
-
|
| 139 |
transport_modes_str = ", ".join(transport_modes)
|
| 140 |
modes_json = json.dumps(transport_modes)
|
| 141 |
|
| 142 |
-
#
|
| 143 |
-
|
| 144 |
retrieval_task = Task(
|
| 145 |
description=f"Gather restaurants, landmarks, and activities for the trip in {location}.",
|
| 146 |
expected_output="A JSON containing categorized lists of places.",
|
| 147 |
-
agent=retriever_agent
|
| 148 |
)
|
| 149 |
|
| 150 |
weather_task = Task(
|
| 151 |
-
description=(
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
| 154 |
expected_output="A JSON object mapping each date to temperature, precipitation, and condition summaries.",
|
| 155 |
agent=weather_agent,
|
| 156 |
)
|
| 157 |
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
)
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
planning_task = Task(
|
| 179 |
description=(
|
| 180 |
"You are the itinerary planning agent responsible for creating an optimized, personalized multi-day trip plan.\n\n"
|
|
@@ -185,96 +257,47 @@ def generate_itinerary(location, start_date, end_date, preferences, transport_mo
|
|
| 185 |
"2️⃣ Review the weather forecast (from WeatherTask) to determine which days are best for outdoor vs indoor activities. "
|
| 186 |
"If heavy rain or extreme temperatures are forecast, prioritize indoor attractions or museums.\n"
|
| 187 |
"3️⃣ For each category (breakfast, lunch, dinner, activities), call the Semantic Ranking Tool to identify the best matches "
|
| 188 |
-
"from the places retrieved by the RetrieverTask. Use the user's preferences to compute semantic similarity and select top results.\n"
|
| 189 |
-
|
| 190 |
-
"4️⃣ Use the route and distance data (
|
| 191 |
-
"
|
| 192 |
-
"
|
| 193 |
-
" - stops
|
| 194 |
-
"
|
| 195 |
-
"
|
| 196 |
-
"
|
| 197 |
-
"
|
| 198 |
-
"
|
| 199 |
-
"
|
| 200 |
-
"
|
| 201 |
-
"
|
| 202 |
-
"
|
| 203 |
-
"
|
| 204 |
-
"
|
| 205 |
-
"
|
| 206 |
-
"
|
| 207 |
-
"
|
| 208 |
-
"
|
| 209 |
-
"
|
| 210 |
-
"
|
| 211 |
-
" • duration_minutes = matrix[chosen_mode].duration_min[i][j]\n"
|
| 212 |
-
" • travel_mode = chosen_mode\n"
|
| 213 |
-
" E) For the first activity of the day:\n"
|
| 214 |
-
" - Use origin_routes if provided, OR treat the origin as the previous stop only if it is included in stops.\n"
|
| 215 |
-
" Always use the matrix for inter-stop travel times (not origin_routes).\n"
|
| 216 |
|
| 217 |
"5️⃣ **Adjust activity timestamps dynamically based on route durations.**\n"
|
| 218 |
-
" -
|
| 219 |
-
" matrix[travel_mode].duration_min[i][j] between consecutive selected stops.\n"
|
| 220 |
" - If travel between two events exceeds 20 minutes, delay the next event’s start time accordingly.\n"
|
| 221 |
-
" -
|
| 222 |
-
"6️⃣ Assign realistic timestamps
|
| 223 |
-
"activities between meals, dinner after 19:00) and dynamically adjust based on travel time.\n"
|
| 224 |
"7️⃣ Ensure variety across days (don’t repeat the same activities).\n"
|
| 225 |
"8️⃣ Include timestamps and travel metadata in the JSON output for each event.\n"
|
| 226 |
-
"9️⃣ For each selected item
|
| 227 |
-
"
|
| 228 |
-
"
|
| 229 |
-
"
|
| 230 |
-
" - location (address) (MUST be verbatim from RouteTask.stops)\n"
|
| 231 |
-
" - rating (if available)\n"
|
| 232 |
-
" - reasoning (why chosen)\n"
|
| 233 |
-
" - weather_forecast (if applicable)\n"
|
| 234 |
-
" - distance_from_prev (km)\n"
|
| 235 |
-
" - duration_minutes (from matrix)\n"
|
| 236 |
-
" - travel_mode (optional)\n\n"
|
| 237 |
-
"🔟 Output format (MUST match ItineraryModel exactly):\n"
|
| 238 |
-
"{\n"
|
| 239 |
-
' "destination": "<city/region>",\n'
|
| 240 |
-
' "trip_duration_days": <int>,\n'
|
| 241 |
-
f' "transport_modes": {modes_json},\n'
|
| 242 |
-
' "start_date": "YYYY-MM-DD",\n'
|
| 243 |
-
' "end_date": "YYYY-MM-DD",\n'
|
| 244 |
-
' "traveler_profile": "<short preference summary>",\n'
|
| 245 |
-
' "days": [\n'
|
| 246 |
-
" {\n"
|
| 247 |
-
' "date": "YYYY-MM-DD",\n'
|
| 248 |
-
' "weather_summary": "<string>",\n'
|
| 249 |
-
' "summary": "<string>",\n'
|
| 250 |
-
' "activities": [\n'
|
| 251 |
-
" {\n"
|
| 252 |
-
' "name": "<place name>",\n'
|
| 253 |
-
' "category": "<breakfast|lunch|dinner|museum|park|landmark|...>",\n'
|
| 254 |
-
' "start_time": "HH:MM",\n'
|
| 255 |
-
' "end_time": "HH:MM",\n'
|
| 256 |
-
' "location": "<address>",\n'
|
| 257 |
-
' "map_url": "<optional>",\n'
|
| 258 |
-
' "rating": <optional float>,\n'
|
| 259 |
-
' "reasoning": "<optional>",\n'
|
| 260 |
-
' "weather_forecast": "<optional>",\n'
|
| 261 |
-
' "travel_mode": "<optional walking|public_transport|driving|cycling>",\n'
|
| 262 |
-
' "distance_from_prev": <optional float>,\n'
|
| 263 |
-
' "duration_minutes": <optional int>\n'
|
| 264 |
-
" }\n"
|
| 265 |
-
" ]\n"
|
| 266 |
-
" }\n"
|
| 267 |
-
" ],\n"
|
| 268 |
-
' "total_distance_km": <optional float>,\n'
|
| 269 |
-
' "notes": "<optional>"\n'
|
| 270 |
-
"}\n"
|
| 271 |
-
" Use field name 'activities' (NOT events). Use 'duration_minutes' (NOT travel_duration_min). Use 'transport_modes' (NOT transport_mode)."
|
| 272 |
),
|
| 273 |
expected_output="A structured JSON itinerary with complete metadata and travel-aware timestamps for each activity.",
|
| 274 |
-
context=[retrieval_task, weather_task
|
| 275 |
agent=planner_agent,
|
| 276 |
output_pydantic=ItineraryModel,
|
| 277 |
-
reasoning=False
|
| 278 |
)
|
| 279 |
|
| 280 |
writing_task = Task(
|
|
@@ -282,45 +305,36 @@ def generate_itinerary(location, start_date, end_date, preferences, transport_mo
|
|
| 282 |
"You are a professional travel writer. Given a structured itinerary JSON with detailed fields "
|
| 283 |
"(rating, reasoning, distance_from_prev, weather_forecast), write an engaging Markdown itinerary.\n\n"
|
| 284 |
f"Make sure the itinerary is covering the period {start_date} to {end_date}.\n\n"
|
| 285 |
-
"At the top
|
| 286 |
-
f"
|
| 287 |
"Each day must include:\n"
|
| 288 |
"- The date and weather summary from the JSON.\n"
|
| 289 |
-
"- Chronological itinerary entries with start
|
| 290 |
-
"
|
| 291 |
-
"
|
| 292 |
-
"
|
| 293 |
-
"
|
| 294 |
-
"- Distance/Travel time between items (e.g., '*Travel to next: 22-minute walk, 1.1 km*')\n"
|
| 295 |
-
"- Reasoning when relevant ('Chosen for excellent reviews or unique view of the lake')\n\n"
|
| 296 |
-
"Ensure each event lists time, name (with Google Maps link if available), address, and category."
|
| 297 |
-
"Do not invent fictional places or events. "
|
| 298 |
-
"If weather indicates rain, mention it contextually (e.g., 'Since the afternoon may rain, head indoors.').\n\n"
|
| 299 |
-
"⚠️ Important: The itinerary must remain consistent with the city and locations in the JSON (e.g., Florence, Italy). "
|
| 300 |
-
"Do not add generic beach or island content unless explicitly present in the JSON.\n\n"
|
| 301 |
-
"Return your final output in **Markdown** format with headers for each day and bold timestamps."
|
| 302 |
),
|
| 303 |
expected_output="A richly detailed Markdown itinerary with times, ratings, and reasoning per event.",
|
| 304 |
context=[planning_task],
|
| 305 |
-
agent=writer_agent
|
| 306 |
)
|
| 307 |
|
| 308 |
-
|
| 309 |
-
agents=[
|
| 310 |
-
tasks=[
|
| 311 |
-
verbose=True
|
| 312 |
)
|
| 313 |
|
| 314 |
-
result =
|
| 315 |
"location": location,
|
| 316 |
"start_date": start_date,
|
| 317 |
"end_date": end_date,
|
| 318 |
"transport_modes": transport_modes,
|
| 319 |
"transport_modes_str": transport_modes_str,
|
| 320 |
-
"trip_duration_days": trip_duration_days
|
| 321 |
})
|
| 322 |
|
| 323 |
-
# Safely extract the result
|
| 324 |
markdown_itinerary = (
|
| 325 |
result if isinstance(result, str)
|
| 326 |
else getattr(result, "raw", None) or str(result)
|
|
@@ -328,7 +342,7 @@ def generate_itinerary(location, start_date, end_date, preferences, transport_mo
|
|
| 328 |
|
| 329 |
print(f"✅ Done in {time.time() - t0:.1f}s")
|
| 330 |
return markdown_itinerary
|
| 331 |
-
|
| 332 |
except Exception:
|
| 333 |
tb = traceback.format_exc()
|
| 334 |
print(tb)
|
|
|
|
| 114 |
)
|
| 115 |
|
| 116 |
# Core logic
|
| 117 |
+
def generate_itinerary(location, start_date, end_date, preferences, transport_modes):
|
| 118 |
t0 = time.time()
|
| 119 |
print("✅ Button clicked:", location, start_date, end_date, transport_modes, preferences)
|
| 120 |
+
|
| 121 |
+
try:
|
| 122 |
+
# -------------------- normalize dates --------------------
|
| 123 |
if isinstance(start_date, str):
|
| 124 |
start_date = start_date.replace("/", "-")
|
| 125 |
start_date = datetime.fromisoformat(start_date).date().isoformat()
|
| 126 |
elif hasattr(start_date, "date"):
|
| 127 |
start_date = start_date.date().isoformat()
|
| 128 |
+
|
| 129 |
if isinstance(end_date, str):
|
| 130 |
end_date = end_date.replace("/", "-")
|
| 131 |
end_date = datetime.fromisoformat(end_date).date().isoformat()
|
| 132 |
elif hasattr(end_date, "date"):
|
| 133 |
end_date = end_date.date().isoformat()
|
| 134 |
+
|
| 135 |
days, date_list = expand_dates(start_date, end_date)
|
| 136 |
trip_duration_days = days
|
| 137 |
+
|
| 138 |
if isinstance(transport_modes, str):
|
| 139 |
transport_modes = [transport_modes]
|
| 140 |
+
|
| 141 |
transport_modes_str = ", ".join(transport_modes)
|
| 142 |
modes_json = json.dumps(transport_modes)
|
| 143 |
|
| 144 |
+
# -------------------- tasks (phase 1: retrieve + weather) --------------------
|
|
|
|
| 145 |
retrieval_task = Task(
|
| 146 |
description=f"Gather restaurants, landmarks, and activities for the trip in {location}.",
|
| 147 |
expected_output="A JSON containing categorized lists of places.",
|
| 148 |
+
agent=retriever_agent,
|
| 149 |
)
|
| 150 |
|
| 151 |
weather_task = Task(
|
| 152 |
+
description=(
|
| 153 |
+
f"Use the Weather Forecast Tool **once** to fetch a 7-day weather forecast covering the full period "
|
| 154 |
+
f"from {start_date} to {end_date} for {location}. "
|
| 155 |
+
"Do not call the tool multiple times. Instead, request all days in one forecast. "
|
| 156 |
+
"Return a single JSON mapping each date → weather summary (max/min temperature, precipitation, and general condition)."
|
| 157 |
+
),
|
| 158 |
expected_output="A JSON object mapping each date to temperature, precipitation, and condition summaries.",
|
| 159 |
agent=weather_agent,
|
| 160 |
)
|
| 161 |
|
| 162 |
+
prefetch_crew = Crew(
|
| 163 |
+
agents=[retriever_agent, weather_agent],
|
| 164 |
+
tasks=[retrieval_task, weather_task],
|
| 165 |
+
verbose=True,
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
_ = prefetch_crew.kickoff(inputs={
|
| 169 |
+
"location": location,
|
| 170 |
+
"start_date": start_date,
|
| 171 |
+
"end_date": end_date,
|
| 172 |
+
})
|
| 173 |
+
|
| 174 |
+
# -------------------- extract retrieval output --------------------
|
| 175 |
+
def _get_task_raw(task: Task) -> Any:
|
| 176 |
+
out = getattr(task, "output", None)
|
| 177 |
+
if out is None:
|
| 178 |
+
return None
|
| 179 |
+
raw = getattr(out, "raw", out)
|
| 180 |
+
# Some versions store a dict already; others store JSON string
|
| 181 |
+
if isinstance(raw, str):
|
| 182 |
+
try:
|
| 183 |
+
return json.loads(raw)
|
| 184 |
+
except Exception:
|
| 185 |
+
return raw
|
| 186 |
+
return raw
|
| 187 |
+
|
| 188 |
+
retrieval_data = _get_task_raw(retrieval_task)
|
| 189 |
+
weather_data = _get_task_raw(weather_task)
|
| 190 |
+
|
| 191 |
+
if retrieval_data is None:
|
| 192 |
+
raise RuntimeError("RetrieverTask produced no output; cannot build route destinations.")
|
| 193 |
+
|
| 194 |
+
# -------------------- build top-10 destinations across categories --------------------
|
| 195 |
+
# expected shape from your log:
|
| 196 |
+
# {
|
| 197 |
+
# "restaurants": {"breakfast":[...], "lunch":[...], "dinner":[...]},
|
| 198 |
+
# "landmarks":[...], "museums":[...], "parks":[...]
|
| 199 |
+
# }
|
| 200 |
+
destinations: List[str] = []
|
| 201 |
+
|
| 202 |
+
def _pick_addr(item: Any) -> Optional[str]:
|
| 203 |
+
if not isinstance(item, dict):
|
| 204 |
+
s = str(item).strip()
|
| 205 |
+
return s if s else None
|
| 206 |
+
addr = (item.get("formatted_address") or item.get("address") or "").strip()
|
| 207 |
+
if addr:
|
| 208 |
+
return addr
|
| 209 |
+
name = (item.get("name") or "").strip()
|
| 210 |
+
return f"{name}, {location}".strip(", ") if name else None
|
| 211 |
+
|
| 212 |
+
# pull in a balanced sample: breakfast/lunch/dinner + landmarks/museums/parks
|
| 213 |
+
restaurants = (retrieval_data.get("restaurants") or {}) if isinstance(retrieval_data, dict) else {}
|
| 214 |
+
for meal in ("breakfast", "lunch", "dinner"):
|
| 215 |
+
for x in (restaurants.get(meal) or [])[:2]: # 2 each = 6
|
| 216 |
+
addr = _pick_addr(x)
|
| 217 |
+
if addr:
|
| 218 |
+
destinations.append(addr)
|
| 219 |
+
|
| 220 |
+
for cat in ("landmarks", "museums", "parks"):
|
| 221 |
+
for x in (retrieval_data.get(cat) or [])[:2]: # 2 each = 6 (we'll trim to 10)
|
| 222 |
+
addr = _pick_addr(x)
|
| 223 |
+
if addr:
|
| 224 |
+
destinations.append(addr)
|
| 225 |
+
|
| 226 |
+
# de-dup while preserving order
|
| 227 |
+
seen = set()
|
| 228 |
+
destinations = [d for d in destinations if not (d in seen or seen.add(d))]
|
| 229 |
+
|
| 230 |
+
# cap to 10
|
| 231 |
+
destinations = destinations[:10]
|
| 232 |
+
|
| 233 |
+
if not destinations:
|
| 234 |
+
raise RuntimeError("No usable destinations built from RetrieverTask output.")
|
| 235 |
+
|
| 236 |
+
# -------------------- call Route Planner Tool ONCE in Python (guaranteed) --------------------
|
| 237 |
+
print("🧭 Calling Route Planner Tool once (return_matrix=True)...")
|
| 238 |
+
route_data_dict = route_tool.run(
|
| 239 |
+
origin=location,
|
| 240 |
+
destinations=destinations,
|
| 241 |
+
modes=transport_modes,
|
| 242 |
+
max_results=10,
|
| 243 |
+
return_matrix=True,
|
| 244 |
)
|
| 245 |
|
| 246 |
+
# Make sure it's JSON-serializable string for injection into planner prompt
|
| 247 |
+
route_data_json = json.dumps(route_data_dict, ensure_ascii=False)
|
| 248 |
+
|
| 249 |
+
# -------------------- tasks (phase 2: plan + write) --------------------
|
| 250 |
planning_task = Task(
|
| 251 |
description=(
|
| 252 |
"You are the itinerary planning agent responsible for creating an optimized, personalized multi-day trip plan.\n\n"
|
|
|
|
| 257 |
"2️⃣ Review the weather forecast (from WeatherTask) to determine which days are best for outdoor vs indoor activities. "
|
| 258 |
"If heavy rain or extreme temperatures are forecast, prioritize indoor attractions or museums.\n"
|
| 259 |
"3️⃣ For each category (breakfast, lunch, dinner, activities), call the Semantic Ranking Tool to identify the best matches "
|
| 260 |
+
"from the places retrieved by the RetrieverTask. Use the user's preferences to compute semantic similarity and select top results.\n\n"
|
| 261 |
+
|
| 262 |
+
"4️⃣ Use the route and distance data provided below (this is REAL tool output, not a suggestion):\n"
|
| 263 |
+
f"ROUTE_DATA_JSON = {route_data_json}\n\n"
|
| 264 |
+
" IMPORTANT: ROUTE_DATA_JSON contains:\n"
|
| 265 |
+
" - stops: list[str] (indexable; includes origin at index 0)\n"
|
| 266 |
+
" - matrix: dict keyed by mode, each containing distance_km[i][j] and duration_min[i][j]\n\n"
|
| 267 |
+
" How to use it correctly:\n"
|
| 268 |
+
" A) stops = ROUTE_DATA_JSON['stops']\n"
|
| 269 |
+
" B) matrix = ROUTE_DATA_JSON['matrix']\n"
|
| 270 |
+
" C) Build idx once: idx = { stops[i]: i for i in range(len(stops)) }\n"
|
| 271 |
+
" D) Matching rule (critical): Each activity 'location' MUST be exactly one of the strings in stops (verbatim).\n"
|
| 272 |
+
" E) For each consecutive activity A -> B:\n"
|
| 273 |
+
" - i = idx[A.location], j = idx[B.location]\n"
|
| 274 |
+
" - choose mode:\n"
|
| 275 |
+
" • if walking allowed and matrix['walking'].distance_km[i][j] <= 2.0 -> walking\n"
|
| 276 |
+
" • else if public_transport allowed -> public_transport\n"
|
| 277 |
+
" • else if cycling allowed -> cycling\n"
|
| 278 |
+
" • else -> driving\n"
|
| 279 |
+
" - set:\n"
|
| 280 |
+
" • distance_from_prev = matrix[chosen_mode].distance_km[i][j]\n"
|
| 281 |
+
" • duration_minutes = matrix[chosen_mode].duration_min[i][j]\n"
|
| 282 |
+
" • travel_mode = chosen_mode\n\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
|
| 284 |
"5️⃣ **Adjust activity timestamps dynamically based on route durations.**\n"
|
| 285 |
+
" - Use matrix[travel_mode].duration_min[i][j] between consecutive selected stops.\n"
|
|
|
|
| 286 |
" - If travel between two events exceeds 20 minutes, delay the next event’s start time accordingly.\n"
|
| 287 |
+
" - Keep the day within 08:00–22:00; reschedule or drop the lowest-ranked activity if needed.\n"
|
| 288 |
+
"6️⃣ Assign realistic timestamps (breakfast 08:00–09:00, lunch 13:00–14:00, dinner after 19:00) and adjust based on travel time.\n"
|
|
|
|
| 289 |
"7️⃣ Ensure variety across days (don’t repeat the same activities).\n"
|
| 290 |
"8️⃣ Include timestamps and travel metadata in the JSON output for each event.\n"
|
| 291 |
+
"9️⃣ For each selected item include: name, category, start/end time, location, rating (if any), reasoning, weather_forecast, "
|
| 292 |
+
"distance_from_prev, duration_minutes, travel_mode.\n\n"
|
| 293 |
+
"🔟 Output MUST match ItineraryModel exactly (activities field, duration_minutes field, transport_modes list).\n"
|
| 294 |
+
f"transport_modes must equal: {modes_json}\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
),
|
| 296 |
expected_output="A structured JSON itinerary with complete metadata and travel-aware timestamps for each activity.",
|
| 297 |
+
context=[retrieval_task, weather_task],
|
| 298 |
agent=planner_agent,
|
| 299 |
output_pydantic=ItineraryModel,
|
| 300 |
+
reasoning=False,
|
| 301 |
)
|
| 302 |
|
| 303 |
writing_task = Task(
|
|
|
|
| 305 |
"You are a professional travel writer. Given a structured itinerary JSON with detailed fields "
|
| 306 |
"(rating, reasoning, distance_from_prev, weather_forecast), write an engaging Markdown itinerary.\n\n"
|
| 307 |
f"Make sure the itinerary is covering the period {start_date} to {end_date}.\n\n"
|
| 308 |
+
"At the top include:\n"
|
| 309 |
+
f"**Trip Dates:** {start_date} → {end_date}\n\n"
|
| 310 |
"Each day must include:\n"
|
| 311 |
"- The date and weather summary from the JSON.\n"
|
| 312 |
+
"- Chronological itinerary entries with start/end times.\n"
|
| 313 |
+
"- Only describe places present in the JSON. No invention.\n"
|
| 314 |
+
"- Include rating inline (⭐ 4.7) when present.\n"
|
| 315 |
+
"- Include travel line when present (e.g. '*Travel to next: 22-minute walk, 1.1 km*').\n"
|
| 316 |
+
"Return Markdown with headers per day and bold timestamps."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
),
|
| 318 |
expected_output="A richly detailed Markdown itinerary with times, ratings, and reasoning per event.",
|
| 319 |
context=[planning_task],
|
| 320 |
+
agent=writer_agent,
|
| 321 |
)
|
| 322 |
|
| 323 |
+
planning_crew = Crew(
|
| 324 |
+
agents=[planner_agent, writer_agent],
|
| 325 |
+
tasks=[planning_task, writing_task],
|
| 326 |
+
verbose=True,
|
| 327 |
)
|
| 328 |
|
| 329 |
+
result = planning_crew.kickoff(inputs={
|
| 330 |
"location": location,
|
| 331 |
"start_date": start_date,
|
| 332 |
"end_date": end_date,
|
| 333 |
"transport_modes": transport_modes,
|
| 334 |
"transport_modes_str": transport_modes_str,
|
| 335 |
+
"trip_duration_days": trip_duration_days,
|
| 336 |
})
|
| 337 |
|
|
|
|
| 338 |
markdown_itinerary = (
|
| 339 |
result if isinstance(result, str)
|
| 340 |
else getattr(result, "raw", None) or str(result)
|
|
|
|
| 342 |
|
| 343 |
print(f"✅ Done in {time.time() - t0:.1f}s")
|
| 344 |
return markdown_itinerary
|
| 345 |
+
|
| 346 |
except Exception:
|
| 347 |
tb = traceback.format_exc()
|
| 348 |
print(tb)
|
tools/route_planner_tool.py
CHANGED
|
@@ -20,7 +20,7 @@ class RoutePlannerToolSchema(BaseModel):
|
|
| 20 |
# Optional NxN matrix output
|
| 21 |
return_matrix: bool = Field(
|
| 22 |
default=False,
|
| 23 |
-
description="If true, also return an NxN distance/duration matrix over [origin] + destinations."
|
| 24 |
)
|
| 25 |
|
| 26 |
@field_validator("origin", mode="before")
|
|
@@ -81,7 +81,6 @@ class RoutePlannerToolSchema(BaseModel):
|
|
| 81 |
flat.append(x)
|
| 82 |
v = flat
|
| 83 |
|
| 84 |
-
# If already a list sanitize items
|
| 85 |
if not isinstance(v, list):
|
| 86 |
return v
|
| 87 |
|
|
@@ -118,7 +117,6 @@ class RoutePlannerToolSchema(BaseModel):
|
|
| 118 |
|
| 119 |
if isinstance(v, str):
|
| 120 |
s = v.strip()
|
| 121 |
-
# handle stringified list like '["walking","public_transport"]' or "['walking','public_transport']"
|
| 122 |
if s.startswith("[") and s.endswith("]"):
|
| 123 |
try:
|
| 124 |
s_json = s.replace("'", '"')
|
|
@@ -151,13 +149,15 @@ class RoutePlannerTool(BaseTool):
|
|
| 151 |
|
| 152 |
args_schema = RoutePlannerToolSchema
|
| 153 |
|
|
|
|
|
|
|
| 154 |
def _distance_matrix(
|
| 155 |
self,
|
| 156 |
client: httpx.Client,
|
| 157 |
origins: List[str],
|
| 158 |
destinations: List[str],
|
| 159 |
mode: str,
|
| 160 |
-
api_key: str
|
| 161 |
) -> Dict[str, Any]:
|
| 162 |
"""Call Google Distance Matrix for origins -> destinations in one request."""
|
| 163 |
url = "https://maps.googleapis.com/maps/api/distancematrix/json"
|
|
@@ -191,13 +191,11 @@ class RoutePlannerTool(BaseTool):
|
|
| 191 |
r.raise_for_status()
|
| 192 |
data = r.json()
|
| 193 |
|
| 194 |
-
# clean status/error handling
|
| 195 |
status = data.get("status")
|
| 196 |
if status != "OK":
|
| 197 |
err = data.get("error_message", "")
|
| 198 |
raise RuntimeError(f"DistanceMatrix status={status}: {err}")
|
| 199 |
|
| 200 |
-
# TTL: transit is short-lived; others are longer
|
| 201 |
ttl = 30 * 60 if mode_k == "transit" else 24 * 3600
|
| 202 |
cache.set(cache_key, data, ttl_seconds=ttl)
|
| 203 |
return data
|
|
@@ -226,21 +224,17 @@ class RoutePlannerTool(BaseTool):
|
|
| 226 |
|
| 227 |
return {"distance_km": dist_km, "duration_min": dur_min}
|
| 228 |
|
|
|
|
|
|
|
| 229 |
def _run(
|
| 230 |
self,
|
| 231 |
origin: str,
|
| 232 |
destinations: List[str],
|
| 233 |
modes: Union[str, List[str]] = "walking",
|
| 234 |
max_results: int = 10,
|
| 235 |
-
return_matrix: bool = False,
|
| 236 |
) -> Union[List[Dict[str, Union[str, float]]], Dict[str, Any]]:
|
| 237 |
-
"""
|
| 238 |
-
Calculates travel routes between a starting location and multiple destinations.
|
| 239 |
|
| 240 |
-
Returns:
|
| 241 |
-
- default: List[Dict] (same as before)
|
| 242 |
-
- if return_matrix=True: Dict with stops + origin_routes + matrix
|
| 243 |
-
"""
|
| 244 |
api_key = os.getenv("GOOGLE_MAPS_API_KEY")
|
| 245 |
if not api_key:
|
| 246 |
raise ValueError("Missing GOOGLE_MAPS_API_KEY in environment variables")
|
|
@@ -257,7 +251,8 @@ class RoutePlannerTool(BaseTool):
|
|
| 257 |
else:
|
| 258 |
modes_list = [str(m).strip() for m in modes if m is not None]
|
| 259 |
|
| 260 |
-
# normalize
|
|
|
|
| 261 |
norm_modes: List[str] = []
|
| 262 |
for m in modes_list:
|
| 263 |
if m == "public_transport":
|
|
@@ -269,15 +264,34 @@ class RoutePlannerTool(BaseTool):
|
|
| 269 |
|
| 270 |
norm_modes = [m for m in norm_modes if m] # sanitize
|
| 271 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
# Destination list
|
| 273 |
MAX_DM_DESTS = 25
|
| 274 |
-
dests = [str(d).strip() for d in destinations if str(d).strip()][
|
|
|
|
|
|
|
|
|
|
| 275 |
if not dests:
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
|
| 278 |
-
# NxN
|
| 279 |
-
|
| 280 |
-
MAX_STOPS_FOR_NXN = 10
|
| 281 |
|
| 282 |
with httpx.Client(timeout=20.0) as client:
|
| 283 |
# 1) origin -> dests (1xN) per requested mode
|
|
@@ -338,23 +352,25 @@ class RoutePlannerTool(BaseTool):
|
|
| 338 |
best["destination"] = dest
|
| 339 |
results.append(best)
|
| 340 |
|
| 341 |
-
# 2) Optional: NxN matrix over [origin] + destinations (still within
|
| 342 |
if not return_matrix:
|
| 343 |
return results
|
| 344 |
|
| 345 |
stops = [origin] + dests
|
| 346 |
if len(stops) > MAX_STOPS_FOR_NXN:
|
| 347 |
-
stops = stops[:MAX_STOPS_FOR_NXN]
|
| 348 |
|
| 349 |
nxn_by_mode: Dict[str, Any] = {}
|
| 350 |
for m in ("walking", "transit", "driving", "bicycling"):
|
| 351 |
if m in norm_modes:
|
| 352 |
dm_nxn = self._distance_matrix(client, stops, stops, m, api_key)
|
| 353 |
-
nxn_by_mode[m] = self._parse_nxn(dm_nxn)
|
| 354 |
|
| 355 |
return {
|
| 356 |
-
"
|
| 357 |
-
"
|
| 358 |
-
"
|
| 359 |
-
"
|
|
|
|
|
|
|
| 360 |
}
|
|
|
|
| 20 |
# Optional NxN matrix output
|
| 21 |
return_matrix: bool = Field(
|
| 22 |
default=False,
|
| 23 |
+
description="If true, also return an NxN distance/duration matrix over [origin] + destinations.",
|
| 24 |
)
|
| 25 |
|
| 26 |
@field_validator("origin", mode="before")
|
|
|
|
| 81 |
flat.append(x)
|
| 82 |
v = flat
|
| 83 |
|
|
|
|
| 84 |
if not isinstance(v, list):
|
| 85 |
return v
|
| 86 |
|
|
|
|
| 117 |
|
| 118 |
if isinstance(v, str):
|
| 119 |
s = v.strip()
|
|
|
|
| 120 |
if s.startswith("[") and s.endswith("]"):
|
| 121 |
try:
|
| 122 |
s_json = s.replace("'", '"')
|
|
|
|
| 149 |
|
| 150 |
args_schema = RoutePlannerToolSchema
|
| 151 |
|
| 152 |
+
# -------------------- INTERNAL HELPERS --------------------
|
| 153 |
+
|
| 154 |
def _distance_matrix(
|
| 155 |
self,
|
| 156 |
client: httpx.Client,
|
| 157 |
origins: List[str],
|
| 158 |
destinations: List[str],
|
| 159 |
mode: str,
|
| 160 |
+
api_key: str,
|
| 161 |
) -> Dict[str, Any]:
|
| 162 |
"""Call Google Distance Matrix for origins -> destinations in one request."""
|
| 163 |
url = "https://maps.googleapis.com/maps/api/distancematrix/json"
|
|
|
|
| 191 |
r.raise_for_status()
|
| 192 |
data = r.json()
|
| 193 |
|
|
|
|
| 194 |
status = data.get("status")
|
| 195 |
if status != "OK":
|
| 196 |
err = data.get("error_message", "")
|
| 197 |
raise RuntimeError(f"DistanceMatrix status={status}: {err}")
|
| 198 |
|
|
|
|
| 199 |
ttl = 30 * 60 if mode_k == "transit" else 24 * 3600
|
| 200 |
cache.set(cache_key, data, ttl_seconds=ttl)
|
| 201 |
return data
|
|
|
|
| 224 |
|
| 225 |
return {"distance_km": dist_km, "duration_min": dur_min}
|
| 226 |
|
| 227 |
+
# -------------------- MAIN ENTRY --------------------
|
| 228 |
+
|
| 229 |
def _run(
|
| 230 |
self,
|
| 231 |
origin: str,
|
| 232 |
destinations: List[str],
|
| 233 |
modes: Union[str, List[str]] = "walking",
|
| 234 |
max_results: int = 10,
|
| 235 |
+
return_matrix: bool = False,
|
| 236 |
) -> Union[List[Dict[str, Union[str, float]]], Dict[str, Any]]:
|
|
|
|
|
|
|
| 237 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
api_key = os.getenv("GOOGLE_MAPS_API_KEY")
|
| 239 |
if not api_key:
|
| 240 |
raise ValueError("Missing GOOGLE_MAPS_API_KEY in environment variables")
|
|
|
|
| 251 |
else:
|
| 252 |
modes_list = [str(m).strip() for m in modes if m is not None]
|
| 253 |
|
| 254 |
+
# normalize input labels -> Google API labels
|
| 255 |
+
# user: public_transport/cycling -> api: transit/bicycling
|
| 256 |
norm_modes: List[str] = []
|
| 257 |
for m in modes_list:
|
| 258 |
if m == "public_transport":
|
|
|
|
| 264 |
|
| 265 |
norm_modes = [m for m in norm_modes if m] # sanitize
|
| 266 |
|
| 267 |
+
# output labels expected by planner
|
| 268 |
+
api_to_user = {
|
| 269 |
+
"walking": "walking",
|
| 270 |
+
"transit": "public_transport",
|
| 271 |
+
"driving": "driving",
|
| 272 |
+
"bicycling": "cycling",
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
# Destination list
|
| 276 |
MAX_DM_DESTS = 25
|
| 277 |
+
dests = [str(d).strip() for d in destinations if str(d).strip()][
|
| 278 |
+
: min(max_results, MAX_DM_DESTS)
|
| 279 |
+
]
|
| 280 |
+
|
| 281 |
if not dests:
|
| 282 |
+
if not return_matrix:
|
| 283 |
+
return []
|
| 284 |
+
return {
|
| 285 |
+
"origin": origin,
|
| 286 |
+
"destinations": [],
|
| 287 |
+
"stops": [origin],
|
| 288 |
+
"modes_requested": [api_to_user[m] for m in norm_modes if m in api_to_user],
|
| 289 |
+
"origin_routes": [],
|
| 290 |
+
"matrix": {},
|
| 291 |
+
}
|
| 292 |
|
| 293 |
+
# NxN safety: origins*destinations should stay <= ~100 elements
|
| 294 |
+
MAX_STOPS_FOR_NXN = 10 # origin + first 9 destinations
|
|
|
|
| 295 |
|
| 296 |
with httpx.Client(timeout=20.0) as client:
|
| 297 |
# 1) origin -> dests (1xN) per requested mode
|
|
|
|
| 352 |
best["destination"] = dest
|
| 353 |
results.append(best)
|
| 354 |
|
| 355 |
+
# 2) Optional: NxN matrix over [origin] + destinations (still within single tool run)
|
| 356 |
if not return_matrix:
|
| 357 |
return results
|
| 358 |
|
| 359 |
stops = [origin] + dests
|
| 360 |
if len(stops) > MAX_STOPS_FOR_NXN:
|
| 361 |
+
stops = stops[:MAX_STOPS_FOR_NXN]
|
| 362 |
|
| 363 |
nxn_by_mode: Dict[str, Any] = {}
|
| 364 |
for m in ("walking", "transit", "driving", "bicycling"):
|
| 365 |
if m in norm_modes:
|
| 366 |
dm_nxn = self._distance_matrix(client, stops, stops, m, api_key)
|
| 367 |
+
nxn_by_mode[api_to_user[m]] = self._parse_nxn(dm_nxn)
|
| 368 |
|
| 369 |
return {
|
| 370 |
+
"origin": origin,
|
| 371 |
+
"destinations": dests,
|
| 372 |
+
"stops": stops, # indexable list used by planner
|
| 373 |
+
"modes_requested": [api_to_user[m] for m in norm_modes if m in api_to_user],
|
| 374 |
+
"origin_routes": results, # origin -> each stop (best mode per stop)
|
| 375 |
+
"matrix": nxn_by_mode, # NxN per mode (planner keys!)
|
| 376 |
}
|