cicboy commited on
Commit
c4ff8c9
·
1 Parent(s): d7ab2e2

update app.py and route_planner_tool.py

Browse files
Files changed (2) hide show
  1. app.py +149 -135
  2. 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
- #Define the tasks
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=(f"Use the Weather Forecast Tool **once** to fetch a 7-day weather forecast covering the full period from {start_date} to {end_date} for {location}."
152
- "Do not call the tool multiple times. Instead, request all days in one forecast."
153
- "Return a single JSON mapping each date → weather summary (max/min temperature, precipitation, and general condition)."),
 
 
 
154
  expected_output="A JSON object mapping each date to temperature, precipitation, and condition summaries.",
155
  agent=weather_agent,
156
  )
157
 
158
- route_task = Task(
159
- description=(
160
- f"From the RetrieverTask output, build a destinations list (strings) using the "
161
- f"top 10 places across categories (prefer formatted address if present, else name + city). "
162
- f"Use origin='{location}'. Then call Route Planner Tool exactly once with:\n"
163
- f"- origin: '{location}'\n"
164
- f"- destinations: <that list>\n"
165
- f"- modes: {transport_modes}\n"
166
- f"- return_matrix: true\n"
167
- f"Return ONLY the tool output as valid JSON (no extra commentary)."
168
- ),
169
- expected_output=(
170
- "A single JSON object with keys: "
171
- "'stops' (list), 'modes_requested' (list), 'origin_routes' (list of routes), "
172
- "and 'matrix' (per-mode NxN distance_km/duration_min matrices)."
173
- ),
174
- agent=route_agent,
175
- context=[retrieval_task]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 (from RouteTask) to minimize total travel time.\n"
191
- " IMPORTANT: RouteTask returns BOTH:\n"
192
- " - origin_routes: origin → each stop (useful for first hop)\n"
193
- " - stops + matrix: an NxN matrix for true inter-stop travel times\n\n"
194
- " How to use the matrix correctly:\n"
195
- " A) Read RouteTask JSON:\n"
196
- " - stops = RouteTask.stops (list of stop strings)\n"
197
- " - matrix = RouteTask.matrix (dict keyed by mode)\n"
198
- " B) Build an index map once: idx = { stops[i]: i for i in range(len(stops)) }\n"
199
- " C) Matching rule (critical): Each activity 'location' MUST be exactly one of the strings in RouteTask.stops "
200
- "(verbatim), so the index lookup works. Do NOT invent combined locations. "
201
- "If you want to mention two nearby landmarks, create two separate activities with their own stop locations.\n"
202
- " D) For each pair of consecutive itinerary activities A -> B:\n"
203
- " - Find i = idx[A.location] and j = idx[B.location]\n"
204
- " - Choose travel mode as follows:\n"
205
- " If walking is allowed AND matrix['walking'].distance_km[i][j] <= 2.0 → use walking\n"
206
- " Else if public_transport is allowed → use public_transport\n"
207
- " Else if cycling is allowed → use cycling\n"
208
- " Else use driving\n"
209
- " - Then set travel metadata using the matrix (NOT guesses):\n"
210
- " distance_from_prev = matrix[chosen_mode].distance_km[i][j]\n"
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
- " - After computing routes, calculate cumulative travel time between activities using:\n"
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
- " - Ensure total daily schedule remains within 08:00–22:00. If an activity exceeds this range, reschedule or drop the lowest-ranked one.\n"
222
- "6️⃣ Assign realistic timestamps to each event (e.g., breakfast 8:00–9:00, lunch 13:00–14:00, "
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 (breakfast, activity, lunch, dinner), include:\n"
227
- " - name\n"
228
- " - category\n"
229
- " - start_time, end_time\n"
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, route_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 of the Markdown output, include a summary line like:\n"
286
- f"'**Trip Dates:** {start_date} → {end_date} \n"
287
  "Each day must include:\n"
288
  "- The date and weather summary from the JSON.\n"
289
- "- Chronological itinerary entries with start and end times.\n"
290
- "Follow the timestamps, preserve logical sequencing, and describe only the actual restaurants, landmarks, "
291
- "and activities from the structured itinerary. "
292
- "For each activity, place or meal, include these details when present:\n"
293
- "- Rating: '⭐ 4.7' inline with restaurant or activity name\n"
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
- trip_crew = Crew(
309
- agents=[retriever_agent, weather_agent, route_agent, planner_agent, writer_agent],
310
- tasks=[retrieval_task, weather_task, route_task, planning_task, writing_task],
311
- verbose=True
312
  )
313
 
314
- result = trip_crew.kickoff(inputs = {
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, # ✅ NEW
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 mode labels
 
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()][:min(max_results, MAX_DM_DESTS)]
 
 
 
275
  if not dests:
276
- return [] if not return_matrix else {"stops": [origin], "origin_routes": [], "matrix": {}}
 
 
 
 
 
 
 
 
 
277
 
278
- # NxN matrix safety: Distance Matrix elements = origins*destinations (cap ~100)
279
- # => stops_total <= 10 keeps NxN <= 100
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 this single tool run)
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] # origin + first 9 destinations
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
- "stops": stops,
357
- "modes_requested": norm_modes,
358
- "origin_routes": results,
359
- "matrix": nxn_by_mode,
 
 
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
  }