fix(chat): stop 429 retry spiral and add get_weather coord fallback

- search_places: detect HTTP 429 and mark retryable=False to stop the
  retry loop immediately instead of spiraling until MAX_ITERATIONS
- get_weather: extract collection coordinates (lat/lng from first
  location with coords) and retry when LLM omits required params;
  uses sync_to_async for the DB query in the async view
- AITravelChat: deduplicate context-only tools (get_trip_details,
  get_weather) in the render pipeline to prevent duplicate place cards
  from appearing when the retry loop causes multiple get_trip_details calls
- Tests: 5 new tests covering 429 non-retryable path and weather
  coord fallback; all 39 chat tests pass
This commit is contained in:
2026-03-10 19:18:55 +00:00
parent de8625c17f
commit 635e0df0ab
4 changed files with 321 additions and 3 deletions

View File

@@ -248,6 +248,34 @@ class ChatViewSet(viewsets.ModelViewSet):
result,
) or cls._is_search_places_geocode_error(tool_name, result)
@classmethod
def _is_get_weather_missing_latlong_error(cls, tool_name, result):
"""True when get_weather was called without latitude/longitude."""
if tool_name != "get_weather" or not cls._is_required_param_tool_error(result):
return False
error_text = (result or {}).get("error") if isinstance(result, dict) else ""
if not isinstance(error_text, str):
return False
normalized_error = error_text.strip().lower()
return "latitude" in normalized_error or "longitude" in normalized_error
@staticmethod
def _extract_collection_coordinates(collection):
"""Return (lat, lon) from the first geocoded location in the collection, or None."""
if collection is None:
return None
for location in collection.locations.all():
lat = getattr(location, "latitude", None)
lon = getattr(location, "longitude", None)
if lat is not None and lon is not None:
try:
return float(lat), float(lon)
except (TypeError, ValueError):
continue
return None
@staticmethod
def _build_search_places_location_clarification_message():
return (
@@ -703,6 +731,54 @@ class ChatViewSet(viewsets.ModelViewSet):
"error": "Could not search places at the provided itinerary locations"
}
attempted_weather_coord_retry = False
if self._is_get_weather_missing_latlong_error(
function_name, result
):
coords = await sync_to_async(
self._extract_collection_coordinates,
thread_sensitive=True,
)(collection)
if coords is not None:
retry_lat, retry_lon = coords
retry_arguments = dict(prepared_arguments)
retry_arguments["latitude"] = retry_lat
retry_arguments["longitude"] = retry_lon
attempted_weather_coord_retry = True
retry_result = await sync_to_async(
execute_tool,
thread_sensitive=True,
)(
function_name,
request.user,
**retry_arguments,
)
if not self._is_required_param_tool_error(
retry_result
) and not self._is_execution_failure_tool_error(
retry_result
):
result = retry_result
tool_call_for_history = {
**tool_call,
"function": {
**function_payload,
"name": function_name,
"arguments": json.dumps(retry_arguments),
},
}
# If retry was attempted but still failed, convert to an
# execution failure — never ask the user for coordinates
# they implied via collection context.
if (
attempted_weather_coord_retry
and self._is_required_param_tool_error(result)
):
result = {
"error": "Could not fetch weather for the collection locations"
}
if self._is_required_param_tool_error(result):
assistant_message_kwargs = {
"conversation": conversation,