From 1b004b9e6566dde99302ccd05d5d13f1d432f74c Mon Sep 17 00:00:00 2001 From: alex wiesner Date: Sat, 14 Mar 2026 11:57:14 +0000 Subject: [PATCH] add place link metadata for suggestions --- backend/server/chat/agent_tools.py | 48 +++++ backend/server/chat/tests.py | 198 +++++++++++++++++++ backend/server/chat/views/day_suggestions.py | 48 +++-- 3 files changed, 273 insertions(+), 21 deletions(-) diff --git a/backend/server/chat/agent_tools.py b/backend/server/chat/agent_tools.py index 992b949e..b55c458e 100644 --- a/backend/server/chat/agent_tools.py +++ b/backend/server/chat/agent_tools.py @@ -228,6 +228,47 @@ def _parse_address(tags): return ", ".join([p for p in parts if p]) +def _extract_official_website(tags): + if not isinstance(tags, dict): + return None + + website_fields = [ + "website", + "contact:website", + "official_website", + "url", + ] + for field in website_fields: + value = tags.get(field) + if isinstance(value, str): + normalized = value.strip() + if normalized: + return normalized + return None + + +def _build_osm_map_url(item): + if not isinstance(item, dict): + return None + + osm_type = item.get("type") + osm_id = item.get("id") + if osm_type in {"node", "way", "relation"} and osm_id is not None: + return f"https://www.openstreetmap.org/{osm_type}/{osm_id}" + + latitude = item.get("lat") + longitude = item.get("lon") + if latitude is None or longitude is None: + center = item.get("center") or {} + latitude = center.get("lat") + longitude = center.get("lon") + + if latitude is None or longitude is None: + return None + + return f"https://www.openstreetmap.org/?mlat={latitude}&mlon={longitude}" + + @agent_tool( name="search_places", description=( @@ -309,6 +350,10 @@ def search_places( if latitude is None or longitude is None: continue + official_website = _extract_official_website(tags) + map_url = _build_osm_map_url(item) + preferred_link = official_website or map_url + results.append( { "name": name, @@ -316,6 +361,9 @@ def search_places( "latitude": latitude, "longitude": longitude, "category": category, + "link": preferred_link, + "official_website": official_website, + "map_url": map_url, } ) diff --git a/backend/server/chat/tests.py b/backend/server/chat/tests.py index bdd79c76..b0826980 100644 --- a/backend/server/chat/tests.py +++ b/backend/server/chat/tests.py @@ -1521,6 +1521,91 @@ class SearchPlaces429NonRetryableTests(TestCase): ) +class SearchPlacesLinkMetadataTests(TestCase): + @patch("chat.agent_tools.requests.post") + @patch("chat.agent_tools.requests.get") + def test_search_places_prefers_official_website_over_map_fallback( + self, + mock_get, + mock_post, + ): + mock_get_response = MagicMock() + mock_get_response.json.return_value = [{"lat": "48.85837", "lon": "2.294481"}] + mock_get_response.raise_for_status.return_value = None + mock_get.return_value = mock_get_response + + mock_post_response = MagicMock() + mock_post_response.json.return_value = { + "elements": [ + { + "type": "node", + "id": 12345, + "lat": 48.85837, + "lon": 2.294481, + "tags": { + "name": "Eiffel Tower", + "website": "https://www.toureiffel.paris/en", + "contact:website": "https://fallback.example.com", + }, + } + ] + } + mock_post_response.raise_for_status.return_value = None + mock_post.return_value = mock_post_response + + result = search_places(user=None, location="Paris, France", category="tourism") + + self.assertIn("results", result) + self.assertEqual(len(result["results"]), 1) + place = result["results"][0] + self.assertEqual(place.get("link"), "https://www.toureiffel.paris/en") + self.assertEqual( + place.get("official_website"), + "https://www.toureiffel.paris/en", + ) + self.assertTrue(place.get("map_url", "").startswith("https://")) + + @patch("chat.agent_tools.requests.post") + @patch("chat.agent_tools.requests.get") + def test_search_places_uses_map_fallback_when_no_official_website( + self, + mock_get, + mock_post, + ): + mock_get_response = MagicMock() + mock_get_response.json.return_value = [{"lat": "41.9028", "lon": "12.4964"}] + mock_get_response.raise_for_status.return_value = None + mock_get.return_value = mock_get_response + + mock_post_response = MagicMock() + mock_post_response.json.return_value = { + "elements": [ + { + "type": "way", + "id": 98765, + "center": {"lat": 41.8902, "lon": 12.4922}, + "tags": { + "name": "Colosseum", + }, + } + ] + } + mock_post_response.raise_for_status.return_value = None + mock_post.return_value = mock_post_response + + result = search_places(user=None, location="Rome, Italy", category="tourism") + + self.assertIn("results", result) + self.assertEqual(len(result["results"]), 1) + place = result["results"][0] + self.assertIsNone(place.get("official_website")) + self.assertEqual(place.get("link"), place.get("map_url")) + self.assertEqual( + place.get("map_url"), + "https://www.openstreetmap.org/way/98765", + ) + + class GetWeatherCoordFallbackTests(APITransactionTestCase): """get_weather lat/lng required param should be retried with collection location coords.""" @@ -2015,3 +2100,116 @@ class DaySuggestionsCoordinateEnrichmentTests(TestCase): self.assertEqual(len(enriched), 1) self.assertNotIn("latitude", enriched[0]) self.assertNotIn("longitude", enriched[0]) + + def test_preserves_existing_coordinates_and_link_when_present(self): + suggestions = [ + { + "name": "Known Place", + "location": "Somewhere", + "latitude": 10.5, + "longitude": 20.5, + "link": "https://existing.example.com", + } + ] + place_candidates = [ + { + "name": "Known Place", + "address": "Somewhere", + "latitude": 1.0, + "longitude": 2.0, + "link": "https://candidate.example.com", + } + ] + + enriched = self.view._enrich_suggestions_with_coordinates( + suggestions, + place_candidates, + ) + + self.assertEqual(enriched[0]["latitude"], 10.5) + self.assertEqual(enriched[0]["longitude"], 20.5) + self.assertEqual(enriched[0]["link"], "https://existing.example.com") + + def test_fills_missing_coordinates_and_link_from_place_match(self): + suggestions = [ + { + "name": "Roscioli", + "location": "Via dei Giubbonari, Rome", + } + ] + place_candidates = [ + { + "name": "Roscioli", + "address": "Via dei Giubbonari, Rome", + "latitude": 41.8933, + "longitude": 12.4722, + "link": "https://www.roscioli.com", + } + ] + + enriched = self.view._enrich_suggestions_with_coordinates( + suggestions, + place_candidates, + ) + + self.assertEqual(len(enriched), 1) + self.assertEqual(enriched[0]["latitude"], 41.8933) + self.assertEqual(enriched[0]["longitude"], 12.4722) + self.assertEqual(enriched[0]["link"], "https://www.roscioli.com") + + def test_preserves_existing_link_while_filling_missing_coordinates(self): + suggestions = [ + { + "name": "Borough Market", + "location": "Southwark", + "link": "https://existing.example.com/borough", + } + ] + place_candidates = [ + { + "name": "Borough Market", + "address": "8 Southwark St, London", + "latitude": 51.5055, + "longitude": -0.0904, + "link": "https://candidate.example.com/borough", + } + ] + + enriched = self.view._enrich_suggestions_with_coordinates( + suggestions, + place_candidates, + ) + + self.assertEqual(len(enriched), 1) + self.assertEqual(enriched[0]["latitude"], 51.5055) + self.assertEqual(enriched[0]["longitude"], -0.0904) + self.assertEqual(enriched[0]["link"], "https://existing.example.com/borough") + + def test_preserves_existing_coordinates_while_filling_missing_link(self): + suggestions = [ + { + "name": "City Museum", + "location": "Old Town", + "latitude": 40.7128, + "longitude": -74.006, + } + ] + place_candidates = [ + { + "name": "City Museum", + "address": "Old Town", + "latitude": 40.7128, + "longitude": -74.006, + "link": "https://citymuseum.example.com", + } + ] + + enriched = self.view._enrich_suggestions_with_coordinates( + suggestions, + place_candidates, + ) + + self.assertEqual(len(enriched), 1) + self.assertEqual(enriched[0]["latitude"], 40.7128) + self.assertEqual(enriched[0]["longitude"], -74.006) + self.assertEqual(enriched[0]["link"], "https://citymuseum.example.com") diff --git a/backend/server/chat/views/day_suggestions.py b/backend/server/chat/views/day_suggestions.py index ceebb9e1..885102b0 100644 --- a/backend/server/chat/views/day_suggestions.py +++ b/backend/server/chat/views/day_suggestions.py @@ -370,28 +370,34 @@ class DaySuggestionsView(APIView): if not isinstance(suggestion, dict): continue - if ( - suggestion.get("latitude") is not None - and suggestion.get("longitude") is not None - ): - enriched.append(suggestion) - continue - - matched_place = self._best_place_match(suggestion, place_candidates) - if not matched_place: - enriched.append(suggestion) - continue - - if ( - matched_place.get("latitude") is None - or matched_place.get("longitude") is None - ): - enriched.append(suggestion) - continue - merged = dict(suggestion) - merged["latitude"] = matched_place.get("latitude") - merged["longitude"] = matched_place.get("longitude") + has_coordinates = ( + merged.get("latitude") is not None + and merged.get("longitude") is not None + ) + has_link = bool(merged.get("link")) + + if has_coordinates and has_link: + enriched.append(merged) + continue + + matched_place = self._best_place_match(merged, place_candidates) + if not matched_place: + enriched.append(merged) + continue + + if not has_coordinates and ( + matched_place.get("latitude") is not None + and matched_place.get("longitude") is not None + ): + merged["latitude"] = matched_place.get("latitude") + merged["longitude"] = matched_place.get("longitude") + + if not has_link: + matched_link = matched_place.get("link") + if matched_link: + merged["link"] = matched_link + merged["location"] = merged.get("location") or matched_place.get("address") enriched.append(merged)