diff --git a/.memory/decisions.md b/.memory/decisions.md index d2fe7df5..c5b60bf5 100644 --- a/.memory/decisions.md +++ b/.memory/decisions.md @@ -1,3 +1,9 @@ +--- +title: decisions +type: note +permalink: voyage/decisions +--- + # Voyage — Decisions Log ## Fork from AdventureLog @@ -391,4 +397,4 @@ - **Regression tests verified**: `test_get_trip_details_owner_also_in_shared_with_avoids_duplicates` (tests.py:53-59) and `test_add_to_itinerary_owner_also_in_shared_with_avoids_duplicates` (tests.py:81-96) both add owner to `shared_with` and exercise the exact codepath that would raise `MultipleObjectsReturned` without `.distinct()`. - **No new issues introduced**: `.distinct()` placement in ORM chain is correct, no logic changes to error handling or return shapes, no mutations to other code paths. - **Reference**: See [Plan: Chat provider fixes](plans/chat-provider-fixes.md#shared-trip-tool-access) -- **Date**: 2026-03-09 +- **Date**: 2026-03-09 \ No newline at end of file diff --git a/.memory/knowledge.md b/.memory/knowledge.md index a425d772..a4a86f9c 100644 --- a/.memory/knowledge.md +++ b/.memory/knowledge.md @@ -1,3 +1,9 @@ +--- +title: knowledge +type: note +permalink: voyage/knowledge +--- + # DEPRECATED — Migrated to nested structure (2026-03-09) This file has been superseded. Content has been migrated to: @@ -10,4 +16,4 @@ This file has been superseded. Content has been migrated to: - **[knowledge/domain/collections-and-sharing.md](knowledge/domain/collections-and-sharing.md)** — Collection sharing, itinerary, user preferences - **[knowledge/domain/ai-configuration.md](knowledge/domain/ai-configuration.md)** — WS1 config infrastructure, frontend gaps -See [manifest.yaml](manifest.yaml) for the full index. +See [manifest.yaml](manifest.yaml) for the full index. \ No newline at end of file diff --git a/.memory/knowledge/conventions.md b/.memory/knowledge/conventions.md index a548599e..3f96a86e 100644 --- a/.memory/knowledge/conventions.md +++ b/.memory/knowledge/conventions.md @@ -1,3 +1,9 @@ +--- +title: conventions +type: note +permalink: voyage/knowledge/conventions +--- + # Coding Conventions & Patterns ## Frontend Patterns @@ -18,4 +24,4 @@ - Do **not** attempt to fix known test/configuration issues as part of feature work - Use `bun` for frontend commands, `uv` for local Python tooling where applicable - Commit and merge completed feature branches promptly once validation passes (avoid leaving finished work unmerged) -- See [decisions.md](../decisions.md#workflow-preference-commit--merge-when-done) for rationale +- See [decisions.md](../decisions.md#workflow-preference-commit--merge-when-done) for rationale \ No newline at end of file diff --git a/.memory/knowledge/domain/ai-configuration.md b/.memory/knowledge/domain/ai-configuration.md index 6106255a..b4fcbe8a 100644 --- a/.memory/knowledge/domain/ai-configuration.md +++ b/.memory/knowledge/domain/ai-configuration.md @@ -1,3 +1,9 @@ +--- +title: ai-configuration +type: note +permalink: voyage/knowledge/domain/ai-configuration +--- + # AI Configuration Domain ## WS1 Configuration Infrastructure @@ -41,4 +47,4 @@ Three compounding issues (all resolved): | Fallback key | `backend/server/chat/llm_client.py` | `get_llm_api_key()` | | UserAISettings model | `backend/server/integrations/models.py` | after UserAPIKey | | Catalog user flags | `backend/server/chat/llm_client.py` | `get_provider_catalog()` | -| Provider view | `backend/server/chat/views/__init__.py` | `ChatProviderCatalogViewSet` | +| Provider view | `backend/server/chat/views/__init__.py` | `ChatProviderCatalogViewSet` | \ No newline at end of file diff --git a/.memory/knowledge/domain/collections-and-sharing.md b/.memory/knowledge/domain/collections-and-sharing.md index f0e25589..f30ac2dc 100644 --- a/.memory/knowledge/domain/collections-and-sharing.md +++ b/.memory/knowledge/domain/collections-and-sharing.md @@ -1,3 +1,9 @@ +--- +title: collections-and-sharing +type: note +permalink: voyage/knowledge/domain/collections-and-sharing +--- + # Collections & Sharing Domain ## Collection Sharing Architecture @@ -65,4 +71,4 @@ Backend-only feature: model, API, and system-prompt integration exist, but **no ### Frontend Gap - No settings tab for manual preference editing - TypeScript type available as `UserRecommendationPreferenceProfile` in `src/lib/types.ts` -- See [Plan: AI travel agent redesign](../../plans/ai-travel-agent-redesign.md#ws2-user-preference-learning) +- See [Plan: AI travel agent redesign](../../plans/ai-travel-agent-redesign.md#ws2-user-preference-learning) \ No newline at end of file diff --git a/.memory/knowledge/overview.md b/.memory/knowledge/overview.md index 29d4fd6a..e87f0f91 100644 --- a/.memory/knowledge/overview.md +++ b/.memory/knowledge/overview.md @@ -1,3 +1,9 @@ +--- +title: overview +type: note +permalink: voyage/knowledge/overview +--- + # Architecture Overview ## API Proxy Pattern @@ -40,4 +46,4 @@ Session-based via `django-allauth`. CSRF tokens from `/auth/csrf/`, passed as `X - i18n: `frontend/src/locales/` - Docker config: `docker-compose.yml`, `docker-compose.dev.yml` - CI/CD: `.github/workflows/` -- Public docs: `documentation/` (VitePress) +- Public docs: `documentation/` (VitePress) \ No newline at end of file diff --git a/.memory/knowledge/patterns/chat-and-llm.md b/.memory/knowledge/patterns/chat-and-llm.md index 0630a0b8..3ed90abb 100644 --- a/.memory/knowledge/patterns/chat-and-llm.md +++ b/.memory/knowledge/patterns/chat-and-llm.md @@ -1,3 +1,9 @@ +--- +title: chat-and-llm +type: note +permalink: voyage/knowledge/patterns/chat-and-llm +--- + # Chat & LLM Patterns ## Default AI Settings & Model Override @@ -164,4 +170,4 @@ All tool functions: `def tool_name(user, **kwargs) -> dict`. Return `{"error": " ## WS4-F3 Add-to-itinerary from Chat - `search_places` card results can be added directly to itinerary when collection context exists - Flow: date selector modal -> `POST /api/locations/` -> `POST /api/itineraries/` -> `itemAdded` event -- Coordinate guard (`hasPlaceCoordinates`) required +- Coordinate guard (`hasPlaceCoordinates`) required \ No newline at end of file diff --git a/.memory/knowledge/tech-stack.md b/.memory/knowledge/tech-stack.md index bc16217b..ca5ba561 100644 --- a/.memory/knowledge/tech-stack.md +++ b/.memory/knowledge/tech-stack.md @@ -1,3 +1,9 @@ +--- +title: tech-stack +type: note +permalink: voyage/knowledge/tech-stack +--- + # Tech Stack & Development ## Stack @@ -62,4 +68,4 @@ LLM provider keys stored per-user in DB (`UserAPIKey` model, `integrations/model - Docker dev setup has frontend-backend communication issues (500 errors beyond homepage) - Frontend check: 0 errors, 6 warnings expected (pre-existing in `CollectionRecommendationView.svelte` + `RegionCard.svelte`) - Backend tests: 6/30 pre-existing failures (2 user email key errors + 4 geocoding API mocks) -- Local Python pip install fails (network timeouts) — use Docker +- Local Python pip install fails (network timeouts) — use Docker \ No newline at end of file diff --git a/.memory/plans/ai-travel-agent-collections-integration.md b/.memory/plans/ai-travel-agent-collections-integration.md index f584b932..716874cd 100644 --- a/.memory/plans/ai-travel-agent-collections-integration.md +++ b/.memory/plans/ai-travel-agent-collections-integration.md @@ -1,3 +1,9 @@ +--- +title: ai-travel-agent-collections-integration +type: note +permalink: voyage/plans/ai-travel-agent-collections-integration +--- + # Plan: AI travel agent in Collections Recommendations ## Clarified requirements @@ -105,4 +111,4 @@ - [x] WS2-F2 OpenCode Zen provider support (Tier 2) - [x] WS1-F1 Embed AI chat into Collections Recommendations (Tier 2) - [x] WS1-F2 Remove standalone `/chat` route (Tier 2) -- [x] Documentation coverage + knowledge sync (Librarian) +- [x] Documentation coverage + knowledge sync (Librarian) \ No newline at end of file diff --git a/.memory/plans/ai-travel-agent-redesign.md b/.memory/plans/ai-travel-agent-redesign.md index 6a1961bf..0d13672c 100644 --- a/.memory/plans/ai-travel-agent-redesign.md +++ b/.memory/plans/ai-travel-agent-redesign.md @@ -1,3 +1,9 @@ +--- +title: ai-travel-agent-redesign +type: note +permalink: voyage/plans/ai-travel-agent-redesign +--- + # AI Travel Agent Redesign Plan ## Vision Summary @@ -335,4 +341,4 @@ See [Project Knowledge — WS4-F4 Chat UI Rendering](../knowledge.md#ws4-f4-chat - WSGI→ASGI migration (keep current async-in-sync pattern) - Role-based permissions (all shared users have same access) - Real-time collaboration (WebSocket sync) -- Mobile-specific optimizations +- Mobile-specific optimizations \ No newline at end of file diff --git a/.memory/plans/chat-provider-fixes.md b/.memory/plans/chat-provider-fixes.md index b11419e7..eae167ae 100644 --- a/.memory/plans/chat-provider-fixes.md +++ b/.memory/plans/chat-provider-fixes.md @@ -1,3 +1,9 @@ +--- +title: chat-provider-fixes +type: note +permalink: voyage/plans/chat-provider-fixes +--- + # Chat Provider Fixes ## Problem Statement @@ -580,4 +586,4 @@ See [decisions.md](../decisions.md#correctness-review-chat-regression-tests). - Prior tester finding (chat-tool-grounding-and-confirmation adversarial item 5): shared-member `get_trip_details` returns DoesNotExist — **CONTRADICTED / RESOLVED** by `shared-trip-tool-access` fix. Confirmed by `test_get_trip_details_allows_shared_user_access` passing in this run. - Prior tester finding (chat-loop-hardening): `get_weather` "dates must be a non-empty list" did not short-circuit — **RESOLVED** by `chat-tool-grounding-and-confirmation`. Confirmed by `test_dates_is_required_matches_required_param_short_circuit` passing. -**Reviewer optional suggestions** (not blocking, not addressed): (1) split `test_non_member_access_remains_denied` into two test methods; (2) add explicit multi-param positive regex case. Neither represents a coverage gap for the fixed behavior. +**Reviewer optional suggestions** (not blocking, not addressed): (1) split `test_non_member_access_remains_denied` into two test methods; (2) add explicit multi-param positive regex case. Neither represents a coverage gap for the fixed behavior. \ No newline at end of file diff --git a/.memory/plans/opencode-zen-connection-error.md b/.memory/plans/opencode-zen-connection-error.md index 18df2ddb..073d195b 100644 --- a/.memory/plans/opencode-zen-connection-error.md +++ b/.memory/plans/opencode-zen-connection-error.md @@ -1,3 +1,9 @@ +--- +title: opencode-zen-connection-error +type: note +permalink: voyage/plans/opencode-zen-connection-error +--- + # Plan: Fix OpenCode Zen connection errors in AI travel chat ## Clarified requirements @@ -398,4 +404,4 @@ The plan is well-scoped, targets a verified root cause with clear code reference - `docs/docs/configuration/advanced_configuration.md` — Chat uses per-user API keys (no server-side env vars); no config changes to document. ### Task tracker -- [x] Documentation and knowledge sync for provider troubleshooting notes (Agent: librarian) +- [x] Documentation and knowledge sync for provider troubleshooting notes (Agent: librarian) \ No newline at end of file diff --git a/.memory/plans/pre-release-and-memory-migration.md b/.memory/plans/pre-release-and-memory-migration.md index 394c4f65..50697f15 100644 --- a/.memory/plans/pre-release-and-memory-migration.md +++ b/.memory/plans/pre-release-and-memory-migration.md @@ -1,3 +1,9 @@ +--- +title: pre-release-and-memory-migration +type: note +permalink: voyage/plans/pre-release-and-memory-migration +--- + # Plan: Pre-release policy + .memory migration ## Scope @@ -33,4 +39,4 @@ | `knowledge.md` §WS1 Config, §Frontend Gaps | `knowledge/domain/ai-configuration.md` | AI configuration domain | | (new) | `sessions/continuity.md` | Session continuity notes | | (new) | `gates/.gitkeep` | Quality gates directory placeholder | -| `knowledge.md` | `knowledge.md` (DEPRECATED) | Deprecation notice with pointers to new locations | +| `knowledge.md` | `knowledge.md` (DEPRECATED) | Deprecation notice with pointers to new locations | \ No newline at end of file diff --git a/.memory/plans/travel-agent-context-and-models.md b/.memory/plans/travel-agent-context-and-models.md index e053dedf..048231d6 100644 --- a/.memory/plans/travel-agent-context-and-models.md +++ b/.memory/plans/travel-agent-context-and-models.md @@ -1,3 +1,9 @@ +--- +title: travel-agent-context-and-models +type: note +permalink: voyage/plans/travel-agent-context-and-models +--- + # Plan: Travel Agent Context + Models Follow-up ## Scope @@ -672,4 +678,4 @@ Replacing LiteLLM is not warranted. Every Voyage issue is in the integration lay - **No production project does universal runtime model discovery** — all use curated/admin-managed lists - **Every production LiteLLM user has retry logic** — Voyage is the outlier with zero retries - **Tool-call capability guards** are standard (`litellm.supports_function_calling()` used by PraisonAI, open-interpreter, mem0, ragbits, dspy) -- **Rate-limit resilience** ranges from simple `num_retries` to full `litellm.Router` with `RetryPolicy` and cross-model fallbacks +- **Rate-limit resilience** ranges from simple `num_retries` to full `litellm.Router` with `RetryPolicy` and cross-model fallbacks \ No newline at end of file diff --git a/.memory/research/auto-learn-preference-signals.md b/.memory/research/auto-learn-preference-signals.md index 6f1efcb6..821c38a6 100644 --- a/.memory/research/auto-learn-preference-signals.md +++ b/.memory/research/auto-learn-preference-signals.md @@ -1,3 +1,9 @@ +--- +title: auto-learn-preference-signals +type: note +permalink: voyage/research/auto-learn-preference-signals +--- + # Research: Auto-Learn User Preference Signals ## Purpose @@ -127,4 +133,4 @@ def build_auto_preference_profile(user) -> dict: flow directly into AI context with zero additional changes needed there. See: [knowledge.md — User Recommendation Preference Profile](../knowledge.md#user-recommendation-preference-profile) -See: [plans/ai-travel-agent-redesign.md — WS2](../plans/ai-travel-agent-redesign.md#ws2-user-preference-learning) +See: [plans/ai-travel-agent-redesign.md — WS2](../plans/ai-travel-agent-redesign.md#ws2-user-preference-learning) \ No newline at end of file diff --git a/.memory/research/litellm-zen-provider-catalog.md b/.memory/research/litellm-zen-provider-catalog.md index c351a500..c5e317f5 100644 --- a/.memory/research/litellm-zen-provider-catalog.md +++ b/.memory/research/litellm-zen-provider-catalog.md @@ -1,3 +1,9 @@ +--- +title: litellm-zen-provider-catalog +type: note +permalink: voyage/research/litellm-zen-provider-catalog +--- + # Research: LiteLLM provider catalog and OpenCode Zen support Date: 2026-03-08 @@ -32,4 +38,4 @@ Related plan: [AI travel agent in Collections Recommendations](../plans/ai-trave ## Risks - Zen model names may evolve; keep default model configurable in backend mapping. -- Full provider list is large; UI should communicate unavailable-for-chat providers clearly. +- Full provider list is large; UI should communicate unavailable-for-chat providers clearly. \ No newline at end of file diff --git a/.memory/research/opencode-zen-connection-debug.md b/.memory/research/opencode-zen-connection-debug.md index 2d6b5b83..6c319dd1 100644 --- a/.memory/research/opencode-zen-connection-debug.md +++ b/.memory/research/opencode-zen-connection-debug.md @@ -1,3 +1,9 @@ +--- +title: opencode-zen-connection-debug +type: note +permalink: voyage/research/opencode-zen-connection-debug +--- + # OpenCode Zen Connection Debug — Research Findings **Date**: 2026-03-08 @@ -300,4 +306,4 @@ Exception: `opencode_zen` and `openrouter` accept any prefix (they're routing ga - See [Plan: OpenCode Zen connection error](../plans/opencode-zen-connection-error.md) - See [Research: LiteLLM provider catalog](litellm-zen-provider-catalog.md) -- See [Knowledge: AI Chat](../knowledge.md#ai-chat-collections--recommendations) +- See [Knowledge: AI Chat](../knowledge.md#ai-chat-collections--recommendations) \ No newline at end of file diff --git a/.memory/research/provider-strategy.md b/.memory/research/provider-strategy.md index 7781f71d..48d8240b 100644 --- a/.memory/research/provider-strategy.md +++ b/.memory/research/provider-strategy.md @@ -1,3 +1,9 @@ +--- +title: provider-strategy +type: note +permalink: voyage/research/provider-strategy +--- + # Research: Multi-Provider Strategy for Voyage AI Chat **Date**: 2026-03-09 @@ -195,4 +201,4 @@ Should use provider_config default or user-selected model. - See [Research: LiteLLM provider catalog](litellm-zen-provider-catalog.md) - See [Research: OpenCode Zen connection debug](opencode-zen-connection-debug.md) - See [Plan: Travel agent context + models](../plans/travel-agent-context-and-models.md) -- See [Decisions: Critic Gate](../decisions.md#critic-gate-travel-agent-context--models-follow-up) +- See [Decisions: Critic Gate](../decisions.md#critic-gate-travel-agent-context--models-follow-up) \ No newline at end of file diff --git a/.memory/sessions/continuity.md b/.memory/sessions/continuity.md index 059fc8d6..65a98fc4 100644 --- a/.memory/sessions/continuity.md +++ b/.memory/sessions/continuity.md @@ -1,3 +1,9 @@ +--- +title: continuity +type: note +permalink: voyage/sessions/continuity +--- + # Session Continuity ## Last Session (2026-03-09) @@ -21,4 +27,4 @@ - LLM-generated name/location fields not truncated to `max_length=200` before `LocationSerializer` (low risk) - Non-English locale `chat_a11y` values are English placeholders — requires human translation (separate concern) - `outsideEvents` array includes both `pointerdown` and `mousedown` — double-fires but idempotent; could simplify to `['pointerdown', 'touchstart']` -- Escape handler in settings dropdown lacks `settingsOpen` guard — idempotent no-op, no functional consequence +- Escape handler in settings dropdown lacks `settingsOpen` guard — idempotent no-op, no functional consequence \ No newline at end of file diff --git a/.memory/system.md b/.memory/system.md index c44fcf76..c652fd85 100644 --- a/.memory/system.md +++ b/.memory/system.md @@ -1 +1,7 @@ -Voyage is a self-hosted travel companion web app (fork of AdventureLog) built with SvelteKit 2 (TypeScript) frontend, Django REST Framework (Python) backend, PostgreSQL/PostGIS, Memcached, and Docker. It provides trip planning with collections/itineraries, AI-powered travel chat with multi-provider LLM support (via LiteLLM), location/lodging/transportation management, user preference learning, and collaborative trip sharing. The project is pre-release — architecture-level changes are allowed. See [knowledge/overview.md](knowledge/overview.md) for architecture and [decisions.md](decisions.md) for ADRs. +--- +title: system +type: note +permalink: voyage/system +--- + +Voyage is a self-hosted travel companion web app (fork of AdventureLog) built with SvelteKit 2 (TypeScript) frontend, Django REST Framework (Python) backend, PostgreSQL/PostGIS, Memcached, and Docker. It provides trip planning with collections/itineraries, AI-powered travel chat with multi-provider LLM support (via LiteLLM), location/lodging/transportation management, user preference learning, and collaborative trip sharing. The project is pre-release — architecture-level changes are allowed. See [knowledge/overview.md](knowledge/overview.md) for architecture and [decisions.md](decisions.md) for ADRs. \ No newline at end of file diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 4640ff56..e3feb1fd 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -1,4 +1,5 @@ import os +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from django.db.models import Q from .models import ( @@ -345,6 +346,8 @@ class CalendarLocationSerializer(serializers.ModelSerializer): class LocationSerializer(CustomModelSerializer): + name = serializers.CharField(required=True) + location = serializers.CharField(required=False, allow_blank=True, allow_null=True) images = serializers.SerializerMethodField() visits = VisitSerializer(many=True, read_only=False, required=False) attachments = AttachmentSerializer(many=True, read_only=True) @@ -426,6 +429,19 @@ class LocationSerializer(CustomModelSerializer): # Filter out None values from the serialized data return [image for image in serializer.data if image is not None] + @staticmethod + def _truncate_to_model_max_length(value, field_name): + if value is None: + return value + max_length = Location._meta.get_field(field_name).max_length + return value[:max_length] + + def validate_name(self, value): + return self._truncate_to_model_max_length(value, "name") + + def validate_location(self, value): + return self._truncate_to_model_max_length(value, "location") + def validate_collections(self, collections): """Validate that collections are compatible with the location being created/updated""" @@ -511,6 +527,33 @@ class LocationSerializer(CustomModelSerializer): category_data["name"] = name return category_data + @staticmethod + def _normalize_coordinate_input(value): + if value in (None, ""): + return value + + try: + coordinate = Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError): + return value + + return coordinate.quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP) + + def to_internal_value(self, data): + if self.instance is None: + normalized_data = data.copy() + + for field_name in ("latitude", "longitude"): + if field_name not in normalized_data: + continue + normalized_data[field_name] = self._normalize_coordinate_input( + normalized_data.get(field_name) + ) + + data = normalized_data + + return super().to_internal_value(data) + def get_or_create_category(self, category_data): user = self.context["request"].user diff --git a/backend/server/adventures/tests.py b/backend/server/adventures/tests.py index 8a904673..22e9af35 100644 --- a/backend/server/adventures/tests.py +++ b/backend/server/adventures/tests.py @@ -23,6 +23,7 @@ from adventures.models import ( Note, Transportation, ) +from adventures.utils.weather import fetch_daily_temperature User = get_user_model() @@ -61,7 +62,11 @@ class WeatherViewTests(APITestCase): mock_fetch_temperature.return_value = { "date": future_date, "available": True, + "temperature_low_c": 19.0, + "temperature_high_c": 26.0, "temperature_c": 22.5, + "is_estimate": False, + "source": "forecast", } response = self.client.post( @@ -73,18 +78,44 @@ class WeatherViewTests(APITestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["results"][0]["date"], future_date) self.assertTrue(response.json()["results"][0]["available"]) + self.assertEqual(response.json()["results"][0]["temperature_low_c"], 19.0) + self.assertEqual(response.json()["results"][0]["temperature_high_c"], 26.0) + self.assertFalse(response.json()["results"][0]["is_estimate"]) + self.assertEqual(response.json()["results"][0]["source"], "forecast") self.assertEqual(response.json()["results"][0]["temperature_c"], 22.5) mock_fetch_temperature.assert_called_once_with(future_date, 12.34, 56.78) - @patch("adventures.views.weather_view.requests.get") - def test_daily_temperatures_far_future_returns_unavailable_when_upstream_has_no_data( + @patch("adventures.utils.weather.requests.get") + def test_daily_temperatures_far_future_uses_historical_estimate( self, mock_requests_get ): future_date = (timezone.now().date() + timedelta(days=3650)).isoformat() - mocked_response = Mock() - mocked_response.raise_for_status.return_value = None - mocked_response.json.return_value = {"daily": {}} - mock_requests_get.return_value = mocked_response + + archive_no_data = Mock() + archive_no_data.raise_for_status.return_value = None + archive_no_data.json.return_value = {"daily": {}} + + forecast_no_data = Mock() + forecast_no_data.raise_for_status.return_value = None + forecast_no_data.json.return_value = {"daily": {}} + + historical_data = Mock() + historical_data.raise_for_status.return_value = None + historical_data.json.return_value = { + "daily": { + "temperature_2m_max": [15.0, 18.0, 20.0], + "temperature_2m_min": [7.0, 9.0, 11.0], + } + } + + call_sequence = [archive_no_data, forecast_no_data, historical_data] + + def mock_get(*args, **kwargs): + if call_sequence: + return call_sequence.pop(0) + return historical_data + + mock_requests_get.side_effect = mock_get response = self.client.post( "/api/weather/daily-temperatures/", @@ -93,13 +124,17 @@ class WeatherViewTests(APITestCase): ) self.assertEqual(response.status_code, 200) - self.assertEqual( - response.json()["results"][0], - {"date": future_date, "available": False, "temperature_c": None}, - ) - self.assertEqual(mock_requests_get.call_count, 2) + result = response.json()["results"][0] + self.assertTrue(result["available"]) + self.assertEqual(result["date"], future_date) + self.assertEqual(result["temperature_low_c"], 9.0) + self.assertEqual(result["temperature_high_c"], 17.7) + self.assertEqual(result["temperature_c"], 13.3) + self.assertTrue(result["is_estimate"]) + self.assertEqual(result["source"], "historical_estimate") + self.assertGreaterEqual(mock_requests_get.call_count, 3) - @patch("adventures.views.weather_view.requests.get") + @patch("adventures.utils.weather.requests.get") def test_daily_temperatures_accepts_zero_lat_lon(self, mock_requests_get): today = timezone.now().date().isoformat() mocked_response = Mock() @@ -121,9 +156,43 @@ class WeatherViewTests(APITestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["results"][0]["date"], today) self.assertTrue(response.json()["results"][0]["available"]) + self.assertEqual(response.json()["results"][0]["temperature_low_c"], 10.0) + self.assertEqual(response.json()["results"][0]["temperature_high_c"], 20.0) + self.assertFalse(response.json()["results"][0]["is_estimate"]) + self.assertEqual(response.json()["results"][0]["source"], "archive") self.assertEqual(response.json()["results"][0]["temperature_c"], 15.0) +class WeatherHelperTests(TestCase): + @patch("adventures.utils.weather.requests.get") + def test_fetch_daily_temperature_returns_unavailable_when_all_sources_fail( + self, mock_requests_get + ): + mocked_response = Mock() + mocked_response.raise_for_status.return_value = None + mocked_response.json.return_value = {"daily": {}} + mock_requests_get.return_value = mocked_response + + result = fetch_daily_temperature( + date=(timezone.now().date() + timedelta(days=6000)).isoformat(), + latitude=40.7128, + longitude=-74.0060, + ) + + self.assertEqual( + result, + { + "date": result["date"], + "available": False, + "temperature_low_c": None, + "temperature_high_c": None, + "temperature_c": None, + "is_estimate": False, + "source": None, + }, + ) + + class MCPAuthTests(APITestCase): def test_mcp_unauthenticated_access_is_rejected(self): unauthenticated_client = APIClient() @@ -131,6 +200,52 @@ class MCPAuthTests(APITestCase): self.assertIn(response.status_code, [401, 403]) +class LocationPayloadHardeningTests(APITestCase): + def setUp(self): + self.user = User.objects.create_user( + username="location-hardening-user", + email="location-hardening@example.com", + password="password123", + ) + self.client.force_authenticate(user=self.user) + + def test_create_location_truncates_overlong_name_and_location(self): + overlong_name = "N" * 250 + overlong_location = "L" * 250 + + response = self.client.post( + "/api/locations/", + { + "name": overlong_name, + "location": overlong_location, + "is_public": False, + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(len(response.data["name"]), 200) + self.assertEqual(len(response.data["location"]), 200) + self.assertEqual(response.data["name"], overlong_name[:200]) + self.assertEqual(response.data["location"], overlong_location[:200]) + + def test_create_location_accepts_high_precision_coordinates(self): + response = self.client.post( + "/api/locations/", + { + "name": "Precision test", + "is_public": False, + "latitude": 51.5007292, + "longitude": -0.1246254, + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["latitude"], "51.500729") + self.assertEqual(response.data["longitude"], "-0.124625") + + class CollectionViewSetTests(APITestCase): def setUp(self): self.owner = User.objects.create_user( diff --git a/backend/server/adventures/utils/weather.py b/backend/server/adventures/utils/weather.py new file mode 100644 index 00000000..c272efc8 --- /dev/null +++ b/backend/server/adventures/utils/weather.py @@ -0,0 +1,172 @@ +import logging +from datetime import date as date_cls + +import requests + + +logger = logging.getLogger(__name__) + +OPEN_METEO_ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive" +OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast" + +HISTORICAL_YEARS_BACK = 5 +HISTORICAL_WINDOW_DAYS = 7 + + +def _base_payload(date: str) -> dict: + return { + "date": date, + "available": False, + "temperature_low_c": None, + "temperature_high_c": None, + "temperature_c": None, + "is_estimate": False, + "source": None, + } + + +def _coerce_temperature(max_values, min_values): + if not max_values or not min_values: + return None + + try: + low = float(min_values[0]) + high = float(max_values[0]) + except (TypeError, ValueError, IndexError): + return None + + avg = (low + high) / 2 + return { + "temperature_low_c": round(low, 1), + "temperature_high_c": round(high, 1), + "temperature_c": round(avg, 1), + } + + +def _request_daily_range( + url: str, latitude: float, longitude: float, start_date: str, end_date: str +): + try: + response = requests.get( + url, + params={ + "latitude": latitude, + "longitude": longitude, + "start_date": start_date, + "end_date": end_date, + "daily": "temperature_2m_max,temperature_2m_min", + "timezone": "UTC", + }, + timeout=8, + ) + response.raise_for_status() + return response.json() + except requests.RequestException: + return None + except ValueError: + return None + + +def _fetch_direct_temperature(date: str, latitude: float, longitude: float): + for source, url in ( + ("archive", OPEN_METEO_ARCHIVE_URL), + ("forecast", OPEN_METEO_FORECAST_URL), + ): + data = _request_daily_range(url, latitude, longitude, date, date) + if not data: + continue + + daily = data.get("daily") or {} + temperatures = _coerce_temperature( + daily.get("temperature_2m_max") or [], + daily.get("temperature_2m_min") or [], + ) + if not temperatures: + continue + + return { + **temperatures, + "available": True, + "is_estimate": False, + "source": source, + } + + return None + + +def _fetch_historical_estimate(date: str, latitude: float, longitude: float): + try: + target_date = date_cls.fromisoformat(date) + except ValueError: + return None + + all_max: list[float] = [] + all_min: list[float] = [] + + for years_back in range(1, HISTORICAL_YEARS_BACK + 1): + year = target_date.year - years_back + try: + same_day = target_date.replace(year=year) + except ValueError: + # Leap-day fallback: use Feb 28 for non-leap years + same_day = target_date.replace(year=year, day=28) + + start = same_day.fromordinal(same_day.toordinal() - HISTORICAL_WINDOW_DAYS) + end = same_day.fromordinal(same_day.toordinal() + HISTORICAL_WINDOW_DAYS) + data = _request_daily_range( + OPEN_METEO_ARCHIVE_URL, + latitude, + longitude, + start.isoformat(), + end.isoformat(), + ) + if not data: + continue + + daily = data.get("daily") or {} + max_values = daily.get("temperature_2m_max") or [] + min_values = daily.get("temperature_2m_min") or [] + pair_count = min(len(max_values), len(min_values)) + + for index in range(pair_count): + try: + all_max.append(float(max_values[index])) + all_min.append(float(min_values[index])) + except (TypeError, ValueError): + continue + + if not all_max or not all_min: + return None + + avg_max = sum(all_max) / len(all_max) + avg_min = sum(all_min) / len(all_min) + avg = (avg_max + avg_min) / 2 + + return { + "available": True, + "temperature_low_c": round(avg_min, 1), + "temperature_high_c": round(avg_max, 1), + "temperature_c": round(avg, 1), + "is_estimate": True, + "source": "historical_estimate", + } + + +def fetch_daily_temperature(date: str, latitude: float, longitude: float): + payload = _base_payload(date) + + direct = _fetch_direct_temperature(date, latitude, longitude) + if direct: + return {**payload, **direct} + + historical_estimate = _fetch_historical_estimate(date, latitude, longitude) + if historical_estimate: + return {**payload, **historical_estimate} + + logger.info( + "No weather data available for date=%s lat=%s lon=%s", + date, + latitude, + longitude, + ) + return payload diff --git a/backend/server/adventures/views/weather_view.py b/backend/server/adventures/views/weather_view.py index 0452412e..d956f950 100644 --- a/backend/server/adventures/views/weather_view.py +++ b/backend/server/adventures/views/weather_view.py @@ -1,22 +1,17 @@ import hashlib -import logging from datetime import date as date_cls -import requests from django.core.cache import cache from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response - -logger = logging.getLogger(__name__) +from adventures.utils.weather import fetch_daily_temperature class WeatherViewSet(viewsets.ViewSet): permission_classes = [IsAuthenticated] - OPEN_METEO_ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive" - OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast" CACHE_TIMEOUT_SECONDS = 60 * 60 * 6 MAX_DAYS_PER_REQUEST = 60 @@ -39,7 +34,15 @@ class WeatherViewSet(viewsets.ViewSet): for entry in days: if not isinstance(entry, dict): results.append( - {"date": None, "available": False, "temperature_c": None} + { + "date": None, + "available": False, + "temperature_low_c": None, + "temperature_high_c": None, + "temperature_c": None, + "is_estimate": False, + "source": None, + } ) continue @@ -49,14 +52,30 @@ class WeatherViewSet(viewsets.ViewSet): if not date or latitude is None or longitude is None: results.append( - {"date": date, "available": False, "temperature_c": None} + { + "date": date, + "available": False, + "temperature_low_c": None, + "temperature_high_c": None, + "temperature_c": None, + "is_estimate": False, + "source": None, + } ) continue parsed_date = self._parse_date(date) if parsed_date is None: results.append( - {"date": date, "available": False, "temperature_c": None} + { + "date": date, + "available": False, + "temperature_low_c": None, + "temperature_high_c": None, + "temperature_c": None, + "is_estimate": False, + "source": None, + } ) continue @@ -65,7 +84,15 @@ class WeatherViewSet(viewsets.ViewSet): lon = float(longitude) except (TypeError, ValueError): results.append( - {"date": date, "available": False, "temperature_c": None} + { + "date": date, + "available": False, + "temperature_low_c": None, + "temperature_high_c": None, + "temperature_c": None, + "is_estimate": False, + "source": None, + } ) continue @@ -82,57 +109,9 @@ class WeatherViewSet(viewsets.ViewSet): return Response({"results": results}, status=status.HTTP_200_OK) def _fetch_daily_temperature(self, date: str, latitude: float, longitude: float): - base_payload = { - "date": date, - "available": False, - "temperature_c": None, - } - - for url in (self.OPEN_METEO_ARCHIVE_URL, self.OPEN_METEO_FORECAST_URL): - try: - response = requests.get( - url, - params={ - "latitude": latitude, - "longitude": longitude, - "start_date": date, - "end_date": date, - "daily": "temperature_2m_max,temperature_2m_min", - "timezone": "UTC", - }, - timeout=8, - ) - response.raise_for_status() - data = response.json() - except requests.RequestException: - continue - except ValueError: - continue - - daily = data.get("daily") or {} - max_values = daily.get("temperature_2m_max") or [] - min_values = daily.get("temperature_2m_min") or [] - if not max_values or not min_values: - continue - - try: - avg = (float(max_values[0]) + float(min_values[0])) / 2 - except (TypeError, ValueError, IndexError): - continue - - return { - "date": date, - "available": True, - "temperature_c": round(avg, 1), - } - - logger.info( - "No weather data available for date=%s lat=%s lon=%s", - date, - latitude, - longitude, + return fetch_daily_temperature( + date=date, latitude=latitude, longitude=longitude ) - return base_payload def _cache_key(self, date: str, latitude: float, longitude: float) -> str: rounded_lat = round(latitude, 3) diff --git a/backend/server/chat/agent_tools.py b/backend/server/chat/agent_tools.py index 21a97338..e73ff8a2 100644 --- a/backend/server/chat/agent_tools.py +++ b/backend/server/chat/agent_tools.py @@ -1,14 +1,24 @@ import json import inspect import logging -from datetime import date as date_cls +from datetime import date as date_cls, datetime import requests from django.contrib.contenttypes.models import ContentType from django.db import models from django.db.models import Q +from django.utils import timezone -from adventures.models import Collection, CollectionItineraryItem, Location +from adventures.models import ( + Collection, + CollectionItineraryItem, + Lodging, + Location, + Transportation, + Visit, +) +from adventures.utils.itinerary import reorder_itinerary_items +from adventures.utils.weather import fetch_daily_temperature logger = logging.getLogger(__name__) @@ -65,6 +75,108 @@ REQUEST_HEADERS = {"User-Agent": "Voyage/1.0"} LOCATION_COORD_TOLERANCE = 0.00001 +def _get_accessible_collection(user, collection_id: str): + return ( + Collection.objects.filter(Q(user=user) | Q(shared_with=user)) + .distinct() + .get(id=collection_id) + ) + + +def _normalize_date_input(value): + if value is None: + return None + if isinstance(value, date_cls): + return value + + raw = str(value).strip() + if not raw: + return None + + try: + return date_cls.fromisoformat(raw[:10]) + except ValueError: + return None + + +def _normalize_datetime_input(value): + if value is None: + return None + if isinstance(value, datetime): + return value + + raw = str(value).strip() + if not raw: + return None + + parsed = None + try: + parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + parsed = None + + if parsed is None: + parsed_date = _normalize_date_input(raw) + if parsed_date is None: + return None + parsed = datetime.combine(parsed_date, datetime.min.time()) + + if timezone.is_naive(parsed): + parsed = timezone.make_aware(parsed, timezone.get_current_timezone()) + + return parsed + + +def _parse_float(value): + if value is None or value == "": + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _serialize_lodging(lodging: Lodging): + return { + "id": str(lodging.id), + "name": lodging.name, + "type": lodging.type, + "check_in": lodging.check_in.isoformat() if lodging.check_in else None, + "check_out": lodging.check_out.isoformat() if lodging.check_out else None, + "location": lodging.location or "", + "latitude": float(lodging.latitude) if lodging.latitude is not None else None, + "longitude": float(lodging.longitude) + if lodging.longitude is not None + else None, + } + + +def _serialize_transportation(transportation: Transportation): + return { + "id": str(transportation.id), + "name": transportation.name, + "type": transportation.type, + "date": transportation.date.isoformat() if transportation.date else None, + "end_date": transportation.end_date.isoformat() + if transportation.end_date + else None, + "from_location": transportation.from_location or "", + "to_location": transportation.to_location or "", + "origin_latitude": float(transportation.origin_latitude) + if transportation.origin_latitude is not None + else None, + "origin_longitude": float(transportation.origin_longitude) + if transportation.origin_longitude is not None + else None, + "destination_latitude": float(transportation.destination_latitude) + if transportation.destination_latitude is not None + else None, + "destination_longitude": float(transportation.destination_longitude) + if transportation.destination_longitude is not None + else None, + } + + def _build_overpass_query(latitude, longitude, radius_meters, category): if category == "food": node_filter = '["amenity"~"restaurant|cafe|bar|fast_food"]' @@ -581,50 +693,891 @@ def add_to_itinerary( return {"error": "An unexpected error occurred while adding to itinerary"} -def _fetch_temperature_for_date(latitude, longitude, date_value): - for url in (OPEN_METEO_ARCHIVE_URL, OPEN_METEO_FORECAST_URL): - try: - response = requests.get( - url, - params={ - "latitude": latitude, - "longitude": longitude, - "start_date": date_value, - "end_date": date_value, - "daily": "temperature_2m_max,temperature_2m_min", - "timezone": "UTC", - }, - timeout=8, +@agent_tool( + name="move_itinerary_item", + description="Move or reorder an existing itinerary item to another day/order in a trip", + parameters={ + "collection_id": { + "type": "string", + "description": "UUID of the collection/trip", + "required": True, + }, + "itinerary_item_id": { + "type": "string", + "description": "UUID of the itinerary item to move", + "required": True, + }, + "date": { + "type": "string", + "description": "Target date in YYYY-MM-DD format", + "required": True, + }, + "order": { + "type": "number", + "description": "Optional zero-based position for the target day", + }, + }, +) +def move_itinerary_item( + user, + collection_id: str | None = None, + itinerary_item_id: str | None = None, + date: str | None = None, + order: int | None = None, +): + try: + if not collection_id or not itinerary_item_id or not date: + return {"error": "collection_id, itinerary_item_id, and date are required"} + + collection = _get_accessible_collection(user, collection_id) + target_date = _normalize_date_input(date) + if target_date is None: + return {"error": "date must be in YYYY-MM-DD format"} + + itinerary_item = CollectionItineraryItem.objects.filter( + collection=collection, + id=itinerary_item_id, + ).first() + if itinerary_item is None: + return {"error": "Itinerary item not found"} + + desired_order = None + if order is not None: + try: + desired_order = int(order) + except (TypeError, ValueError): + return {"error": "order must be numeric"} + desired_order = max(0, desired_order) + + source_date = itinerary_item.date + target_items = list( + CollectionItineraryItem.objects.filter( + collection=collection, + date=target_date, + is_global=False, ) - response.raise_for_status() - data = response.json() - except requests.RequestException: - continue - except ValueError: - continue + .exclude(id=itinerary_item.id) + .order_by("order") + ) - daily = data.get("daily") or {} - max_values = daily.get("temperature_2m_max") or [] - min_values = daily.get("temperature_2m_min") or [] - if not max_values or not min_values: - continue + insert_at = len(target_items) + if desired_order is not None: + insert_at = min(desired_order, len(target_items)) - try: - avg = (float(max_values[0]) + float(min_values[0])) / 2 - except (TypeError, ValueError, IndexError): - continue + updates = [] + for idx, item in enumerate(target_items): + if idx == insert_at: + updates.append( + { + "id": str(itinerary_item.id), + "date": target_date, + "order": insert_at, + "is_global": False, + } + ) + updates.append( + { + "id": str(item.id), + "date": target_date, + "order": idx + (1 if idx >= insert_at else 0), + "is_global": False, + } + ) + + if insert_at == len(target_items): + updates.append( + { + "id": str(itinerary_item.id), + "date": target_date, + "order": insert_at, + "is_global": False, + } + ) + + if source_date and source_date != target_date: + remaining_source = CollectionItineraryItem.objects.filter( + collection=collection, + date=source_date, + is_global=False, + ).exclude(id=itinerary_item.id) + for idx, item in enumerate(remaining_source.order_by("order")): + updates.append( + { + "id": str(item.id), + "date": source_date, + "order": idx, + "is_global": False, + } + ) + + updated_items = reorder_itinerary_items(user, updates) + moved_item = next( + (item for item in updated_items if str(item.id) == str(itinerary_item.id)), + itinerary_item, + ) + moved_date = _normalize_date_input(getattr(moved_item, "date", None)) + return { + "success": True, + "itinerary_item": { + "id": str(moved_item.id), + "date": moved_date.isoformat() if moved_date else None, + "order": moved_item.order, + }, + "source_date": source_date.isoformat() if source_date else None, + "target_date": target_date.isoformat(), + } + except Collection.DoesNotExist: + return {"error": "Trip not found"} + except Exception: + logger.exception("move_itinerary_item failed") + return {"error": "An unexpected error occurred while moving itinerary item"} + + +@agent_tool( + name="remove_itinerary_item", + description="Remove an itinerary item from a trip day", + parameters={ + "collection_id": { + "type": "string", + "description": "UUID of the collection/trip", + "required": True, + }, + "itinerary_item_id": { + "type": "string", + "description": "UUID of the itinerary item to remove", + "required": True, + }, + }, +) +def remove_itinerary_item( + user, + collection_id: str | None = None, + itinerary_item_id: str | None = None, +): + try: + if not collection_id or not itinerary_item_id: + return {"error": "collection_id and itinerary_item_id are required"} + + collection = _get_accessible_collection(user, collection_id) + itinerary_item = CollectionItineraryItem.objects.filter( + collection=collection, + id=itinerary_item_id, + ).first() + if itinerary_item is None: + return {"error": "Itinerary item not found"} + + object_type = itinerary_item.content_type.model + deleted_visit_count = 0 + + if object_type == "location" and itinerary_item.date: + location = Location.objects.filter(id=itinerary_item.object_id).first() + if location: + visits = Visit.objects.filter( + location=location, + start_date__date=itinerary_item.date, + ) + deleted_visit_count = visits.count() + visits.delete() + + itinerary_item.delete() return { - "date": date_value, - "available": True, - "temperature_c": round(avg, 1), + "success": True, + "removed_itinerary_item_id": itinerary_item_id, + "removed_object_type": object_type, + "deleted_visit_count": deleted_visit_count, } + except Collection.DoesNotExist: + return {"error": "Trip not found"} + except Exception: + logger.exception("remove_itinerary_item failed") + return {"error": "An unexpected error occurred while removing itinerary item"} - return { - "date": date_value, - "available": False, - "temperature_c": None, - } + +@agent_tool( + name="update_location_details", + description="Update itinerary-relevant details for a location in a trip", + parameters={ + "collection_id": { + "type": "string", + "description": "UUID of the collection/trip", + "required": True, + }, + "location_id": { + "type": "string", + "description": "UUID of the location", + "required": True, + }, + "name": {"type": "string", "description": "Updated location name"}, + "description": { + "type": "string", + "description": "Updated location description", + }, + "location": {"type": "string", "description": "Updated address/location text"}, + "latitude": {"type": "number", "description": "Updated latitude"}, + "longitude": {"type": "number", "description": "Updated longitude"}, + }, +) +def update_location_details( + user, + collection_id: str | None = None, + location_id: str | None = None, + name: str | None = None, + description: str | None = None, + location: str | None = None, + latitude: float | None = None, + longitude: float | None = None, +): + try: + if not collection_id or not location_id: + return {"error": "collection_id and location_id are required"} + + collection = _get_accessible_collection(user, collection_id) + location_obj = collection.locations.filter(id=location_id).first() + if location_obj is None: + return {"error": "Location not found in this trip"} + + updated_fields = [] + if isinstance(name, str) and name.strip(): + location_obj.name = name.strip() + updated_fields.append("name") + if description is not None: + location_obj.description = str(description) + updated_fields.append("description") + if location is not None: + location_obj.location = str(location) + updated_fields.append("location") + + parsed_lat = _parse_float(latitude) + parsed_lon = _parse_float(longitude) + if latitude is not None and parsed_lat is None: + return {"error": "latitude must be numeric"} + if longitude is not None and parsed_lon is None: + return {"error": "longitude must be numeric"} + if latitude is not None: + location_obj.latitude = parsed_lat + updated_fields.append("latitude") + if longitude is not None: + location_obj.longitude = parsed_lon + updated_fields.append("longitude") + + if not updated_fields: + return {"error": "At least one field to update is required"} + + location_obj.save(update_fields=updated_fields) + + return { + "success": True, + "location": { + "id": str(location_obj.id), + "name": location_obj.name, + "description": location_obj.description or "", + "location": location_obj.location or "", + "latitude": float(location_obj.latitude) + if location_obj.latitude is not None + else None, + "longitude": float(location_obj.longitude) + if location_obj.longitude is not None + else None, + }, + } + except Collection.DoesNotExist: + return {"error": "Trip not found"} + except Exception: + logger.exception("update_location_details failed") + return {"error": "An unexpected error occurred while updating location"} + + +@agent_tool( + name="add_lodging", + description="Add a lodging stay to a trip and optionally add it to itinerary day", + parameters={ + "collection_id": { + "type": "string", + "description": "UUID of the collection/trip", + "required": True, + }, + "name": {"type": "string", "description": "Lodging name", "required": True}, + "type": { + "type": "string", + "description": "Lodging type (hotel, hostel, resort, bnb, campground, cabin, apartment, house, villa, motel, other)", + }, + "location": {"type": "string", "description": "Address or location text"}, + "check_in": { + "type": "string", + "description": "Check-in datetime or date (ISO format)", + }, + "check_out": { + "type": "string", + "description": "Check-out datetime or date (ISO format)", + }, + "latitude": {"type": "number", "description": "Latitude"}, + "longitude": {"type": "number", "description": "Longitude"}, + "itinerary_date": { + "type": "string", + "description": "Optional day in YYYY-MM-DD to add this lodging to itinerary", + }, + }, +) +def add_lodging( + user, + collection_id: str | None = None, + name: str | None = None, + type: str | None = None, + location: str | None = None, + check_in: str | None = None, + check_out: str | None = None, + latitude: float | None = None, + longitude: float | None = None, + itinerary_date: str | None = None, +): + try: + if not collection_id or not name: + return {"error": "collection_id and name are required"} + + collection = _get_accessible_collection(user, collection_id) + + parsed_check_in = _normalize_datetime_input(check_in) + parsed_check_out = _normalize_datetime_input(check_out) + if check_in and parsed_check_in is None: + return {"error": "check_in must be a valid ISO date or datetime"} + if check_out and parsed_check_out is None: + return {"error": "check_out must be a valid ISO date or datetime"} + + parsed_lat = _parse_float(latitude) + parsed_lon = _parse_float(longitude) + if latitude is not None and parsed_lat is None: + return {"error": "latitude must be numeric"} + if longitude is not None and parsed_lon is None: + return {"error": "longitude must be numeric"} + + lodging = Lodging.objects.create( + user=collection.user, + collection=collection, + name=name, + type=(type or "other"), + location=location or "", + check_in=parsed_check_in, + check_out=parsed_check_out, + latitude=parsed_lat, + longitude=parsed_lon, + ) + + itinerary_item = None + if itinerary_date: + itinerary_day = _normalize_date_input(itinerary_date) + if itinerary_day is None: + return {"error": "itinerary_date must be in YYYY-MM-DD format"} + + max_order = ( + CollectionItineraryItem.objects.filter( + collection=collection, + date=itinerary_day, + is_global=False, + ).aggregate(models.Max("order"))["order__max"] + or 0 + ) + itinerary_item = CollectionItineraryItem.objects.create( + collection=collection, + content_type=ContentType.objects.get_for_model(Lodging), + object_id=lodging.id, + date=itinerary_day, + order=max_order + 1, + ) + + return { + "success": True, + "lodging": _serialize_lodging(lodging), + "itinerary_item": { + "id": str(itinerary_item.id), + "date": itinerary_item.date.isoformat() if itinerary_item else None, + "order": itinerary_item.order if itinerary_item else None, + } + if itinerary_item + else None, + } + except Collection.DoesNotExist: + return {"error": "Trip not found"} + except Exception: + logger.exception("add_lodging failed") + return {"error": "An unexpected error occurred while adding lodging"} + + +@agent_tool( + name="update_lodging", + description="Update lodging details for an existing trip lodging item", + parameters={ + "collection_id": { + "type": "string", + "description": "UUID of the collection/trip", + "required": True, + }, + "lodging_id": { + "type": "string", + "description": "UUID of the lodging", + "required": True, + }, + "name": {"type": "string", "description": "Updated lodging name"}, + "type": {"type": "string", "description": "Updated lodging type"}, + "location": {"type": "string", "description": "Updated location text"}, + "check_in": { + "type": "string", + "description": "Updated check-in datetime/date (ISO)", + }, + "check_out": { + "type": "string", + "description": "Updated check-out datetime/date (ISO)", + }, + "latitude": {"type": "number", "description": "Updated latitude"}, + "longitude": {"type": "number", "description": "Updated longitude"}, + }, +) +def update_lodging( + user, + collection_id: str | None = None, + lodging_id: str | None = None, + name: str | None = None, + type: str | None = None, + location: str | None = None, + check_in: str | None = None, + check_out: str | None = None, + latitude: float | None = None, + longitude: float | None = None, +): + try: + if not collection_id or not lodging_id: + return {"error": "collection_id and lodging_id are required"} + + collection = _get_accessible_collection(user, collection_id) + lodging = Lodging.objects.filter(id=lodging_id, collection=collection).first() + if lodging is None: + return {"error": "Lodging not found"} + + updates = [] + if isinstance(name, str) and name.strip(): + lodging.name = name.strip() + updates.append("name") + if isinstance(type, str) and type.strip(): + lodging.type = type.strip() + updates.append("type") + if location is not None: + lodging.location = str(location) + updates.append("location") + + parsed_check_in = _normalize_datetime_input(check_in) + parsed_check_out = _normalize_datetime_input(check_out) + if check_in is not None and parsed_check_in is None: + return {"error": "check_in must be a valid ISO date or datetime"} + if check_out is not None and parsed_check_out is None: + return {"error": "check_out must be a valid ISO date or datetime"} + if check_in is not None: + lodging.check_in = parsed_check_in + updates.append("check_in") + if check_out is not None: + lodging.check_out = parsed_check_out + updates.append("check_out") + + parsed_lat = _parse_float(latitude) + parsed_lon = _parse_float(longitude) + if latitude is not None and parsed_lat is None: + return {"error": "latitude must be numeric"} + if longitude is not None and parsed_lon is None: + return {"error": "longitude must be numeric"} + if latitude is not None: + lodging.latitude = parsed_lat + updates.append("latitude") + if longitude is not None: + lodging.longitude = parsed_lon + updates.append("longitude") + + if not updates: + return {"error": "At least one field to update is required"} + + lodging.save(update_fields=updates) + return {"success": True, "lodging": _serialize_lodging(lodging)} + except Collection.DoesNotExist: + return {"error": "Trip not found"} + except Exception: + logger.exception("update_lodging failed") + return {"error": "An unexpected error occurred while updating lodging"} + + +@agent_tool( + name="remove_lodging", + description="Remove a lodging record from a trip", + parameters={ + "collection_id": { + "type": "string", + "description": "UUID of the collection/trip", + "required": True, + }, + "lodging_id": { + "type": "string", + "description": "UUID of the lodging", + "required": True, + }, + }, +) +def remove_lodging( + user, + collection_id: str | None = None, + lodging_id: str | None = None, +): + try: + if not collection_id or not lodging_id: + return {"error": "collection_id and lodging_id are required"} + + collection = _get_accessible_collection(user, collection_id) + lodging = Lodging.objects.filter(id=lodging_id, collection=collection).first() + if lodging is None: + return {"error": "Lodging not found"} + + itinerary_deleted = CollectionItineraryItem.objects.filter( + collection=collection, + content_type=ContentType.objects.get_for_model(Lodging), + object_id=lodging.id, + ).delete()[0] + lodging.delete() + return { + "success": True, + "removed_lodging_id": lodging_id, + "removed_itinerary_items": itinerary_deleted, + } + except Collection.DoesNotExist: + return {"error": "Trip not found"} + except Exception: + logger.exception("remove_lodging failed") + return {"error": "An unexpected error occurred while removing lodging"} + + +@agent_tool( + name="add_transportation", + description="Add transportation to a trip and optionally add it to itinerary day", + parameters={ + "collection_id": { + "type": "string", + "description": "UUID of the collection/trip", + "required": True, + }, + "name": { + "type": "string", + "description": "Transportation name", + "required": True, + }, + "type": { + "type": "string", + "description": "Transportation type (car, plane, train, bus, boat, bike, walking, other)", + "required": True, + }, + "date": { + "type": "string", + "description": "Departure datetime/date (ISO)", + }, + "end_date": { + "type": "string", + "description": "Arrival datetime/date (ISO)", + }, + "from_location": {"type": "string", "description": "Origin location text"}, + "to_location": {"type": "string", "description": "Destination location text"}, + "origin_latitude": {"type": "number", "description": "Origin latitude"}, + "origin_longitude": {"type": "number", "description": "Origin longitude"}, + "destination_latitude": { + "type": "number", + "description": "Destination latitude", + }, + "destination_longitude": { + "type": "number", + "description": "Destination longitude", + }, + "itinerary_date": { + "type": "string", + "description": "Optional day in YYYY-MM-DD to add this transportation to itinerary", + }, + }, +) +def add_transportation( + user, + collection_id: str | None = None, + name: str | None = None, + type: str | None = None, + date: str | None = None, + end_date: str | None = None, + from_location: str | None = None, + to_location: str | None = None, + origin_latitude: float | None = None, + origin_longitude: float | None = None, + destination_latitude: float | None = None, + destination_longitude: float | None = None, + itinerary_date: str | None = None, +): + try: + if not collection_id or not name or not type: + return {"error": "collection_id, name, and type are required"} + + collection = _get_accessible_collection(user, collection_id) + + parsed_date = _normalize_datetime_input(date) + parsed_end_date = _normalize_datetime_input(end_date) + if date and parsed_date is None: + return {"error": "date must be a valid ISO date or datetime"} + if end_date and parsed_end_date is None: + return {"error": "end_date must be a valid ISO date or datetime"} + + parsed_origin_lat = _parse_float(origin_latitude) + parsed_origin_lon = _parse_float(origin_longitude) + parsed_destination_lat = _parse_float(destination_latitude) + parsed_destination_lon = _parse_float(destination_longitude) + if origin_latitude is not None and parsed_origin_lat is None: + return {"error": "origin_latitude must be numeric"} + if origin_longitude is not None and parsed_origin_lon is None: + return {"error": "origin_longitude must be numeric"} + if destination_latitude is not None and parsed_destination_lat is None: + return {"error": "destination_latitude must be numeric"} + if destination_longitude is not None and parsed_destination_lon is None: + return {"error": "destination_longitude must be numeric"} + + transportation = Transportation.objects.create( + user=collection.user, + collection=collection, + name=name, + type=type, + date=parsed_date, + end_date=parsed_end_date, + from_location=from_location or "", + to_location=to_location or "", + origin_latitude=parsed_origin_lat, + origin_longitude=parsed_origin_lon, + destination_latitude=parsed_destination_lat, + destination_longitude=parsed_destination_lon, + ) + + itinerary_item = None + if itinerary_date: + itinerary_day = _normalize_date_input(itinerary_date) + if itinerary_day is None: + return {"error": "itinerary_date must be in YYYY-MM-DD format"} + + max_order = ( + CollectionItineraryItem.objects.filter( + collection=collection, + date=itinerary_day, + is_global=False, + ).aggregate(models.Max("order"))["order__max"] + or 0 + ) + itinerary_item = CollectionItineraryItem.objects.create( + collection=collection, + content_type=ContentType.objects.get_for_model(Transportation), + object_id=transportation.id, + date=itinerary_day, + order=max_order + 1, + ) + + return { + "success": True, + "transportation": _serialize_transportation(transportation), + "itinerary_item": { + "id": str(itinerary_item.id), + "date": itinerary_item.date.isoformat() if itinerary_item else None, + "order": itinerary_item.order if itinerary_item else None, + } + if itinerary_item + else None, + } + except Collection.DoesNotExist: + return {"error": "Trip not found"} + except Exception: + logger.exception("add_transportation failed") + return {"error": "An unexpected error occurred while adding transportation"} + + +@agent_tool( + name="update_transportation", + description="Update details for an existing transportation item", + parameters={ + "collection_id": { + "type": "string", + "description": "UUID of the collection/trip", + "required": True, + }, + "transportation_id": { + "type": "string", + "description": "UUID of the transportation", + "required": True, + }, + "name": {"type": "string", "description": "Updated transportation name"}, + "type": {"type": "string", "description": "Updated transportation type"}, + "date": {"type": "string", "description": "Updated departure datetime/date"}, + "end_date": { + "type": "string", + "description": "Updated arrival datetime/date", + }, + "from_location": { + "type": "string", + "description": "Updated origin location text", + }, + "to_location": { + "type": "string", + "description": "Updated destination location text", + }, + "origin_latitude": {"type": "number", "description": "Updated origin latitude"}, + "origin_longitude": { + "type": "number", + "description": "Updated origin longitude", + }, + "destination_latitude": { + "type": "number", + "description": "Updated destination latitude", + }, + "destination_longitude": { + "type": "number", + "description": "Updated destination longitude", + }, + }, +) +def update_transportation( + user, + collection_id: str | None = None, + transportation_id: str | None = None, + name: str | None = None, + type: str | None = None, + date: str | None = None, + end_date: str | None = None, + from_location: str | None = None, + to_location: str | None = None, + origin_latitude: float | None = None, + origin_longitude: float | None = None, + destination_latitude: float | None = None, + destination_longitude: float | None = None, +): + try: + if not collection_id or not transportation_id: + return {"error": "collection_id and transportation_id are required"} + + collection = _get_accessible_collection(user, collection_id) + transportation = Transportation.objects.filter( + id=transportation_id, + collection=collection, + ).first() + if transportation is None: + return {"error": "Transportation not found"} + + updates = [] + if isinstance(name, str) and name.strip(): + transportation.name = name.strip() + updates.append("name") + if isinstance(type, str) and type.strip(): + transportation.type = type.strip() + updates.append("type") + if from_location is not None: + transportation.from_location = str(from_location) + updates.append("from_location") + if to_location is not None: + transportation.to_location = str(to_location) + updates.append("to_location") + + parsed_date = _normalize_datetime_input(date) + parsed_end_date = _normalize_datetime_input(end_date) + if date is not None and parsed_date is None: + return {"error": "date must be a valid ISO date or datetime"} + if end_date is not None and parsed_end_date is None: + return {"error": "end_date must be a valid ISO date or datetime"} + if date is not None: + transportation.date = parsed_date + updates.append("date") + if end_date is not None: + transportation.end_date = parsed_end_date + updates.append("end_date") + + parsed_origin_lat = _parse_float(origin_latitude) + parsed_origin_lon = _parse_float(origin_longitude) + parsed_destination_lat = _parse_float(destination_latitude) + parsed_destination_lon = _parse_float(destination_longitude) + if origin_latitude is not None and parsed_origin_lat is None: + return {"error": "origin_latitude must be numeric"} + if origin_longitude is not None and parsed_origin_lon is None: + return {"error": "origin_longitude must be numeric"} + if destination_latitude is not None and parsed_destination_lat is None: + return {"error": "destination_latitude must be numeric"} + if destination_longitude is not None and parsed_destination_lon is None: + return {"error": "destination_longitude must be numeric"} + if origin_latitude is not None: + transportation.origin_latitude = parsed_origin_lat + updates.append("origin_latitude") + if origin_longitude is not None: + transportation.origin_longitude = parsed_origin_lon + updates.append("origin_longitude") + if destination_latitude is not None: + transportation.destination_latitude = parsed_destination_lat + updates.append("destination_latitude") + if destination_longitude is not None: + transportation.destination_longitude = parsed_destination_lon + updates.append("destination_longitude") + + if not updates: + return {"error": "At least one field to update is required"} + + transportation.save(update_fields=updates) + return { + "success": True, + "transportation": _serialize_transportation(transportation), + } + except Collection.DoesNotExist: + return {"error": "Trip not found"} + except Exception: + logger.exception("update_transportation failed") + return {"error": "An unexpected error occurred while updating transportation"} + + +@agent_tool( + name="remove_transportation", + description="Remove transportation from a trip", + parameters={ + "collection_id": { + "type": "string", + "description": "UUID of the collection/trip", + "required": True, + }, + "transportation_id": { + "type": "string", + "description": "UUID of the transportation", + "required": True, + }, + }, +) +def remove_transportation( + user, + collection_id: str | None = None, + transportation_id: str | None = None, +): + try: + if not collection_id or not transportation_id: + return {"error": "collection_id and transportation_id are required"} + + collection = _get_accessible_collection(user, collection_id) + transportation = Transportation.objects.filter( + id=transportation_id, + collection=collection, + ).first() + if transportation is None: + return {"error": "Transportation not found"} + + itinerary_deleted = CollectionItineraryItem.objects.filter( + collection=collection, + content_type=ContentType.objects.get_for_model(Transportation), + object_id=transportation.id, + ).delete()[0] + transportation.delete() + return { + "success": True, + "removed_transportation_id": transportation_id, + "removed_itinerary_items": itinerary_deleted, + } + except Collection.DoesNotExist: + return {"error": "Trip not found"} + except Exception: + logger.exception("remove_transportation failed") + return {"error": "An unexpected error occurred while removing transportation"} @agent_tool( @@ -660,7 +1613,9 @@ def get_weather(user, latitude=None, longitude=None, dates=None): return {"error": "dates is required"} results = [ - _fetch_temperature_for_date(latitude, longitude, date_value) + fetch_daily_temperature( + date=date_value, latitude=latitude, longitude=longitude + ) for date_value in dates ] return { diff --git a/backend/server/chat/llm_client.py b/backend/server/chat/llm_client.py index 02b26471..09002664 100644 --- a/backend/server/chat/llm_client.py +++ b/backend/server/chat/llm_client.py @@ -14,7 +14,9 @@ DEFAULT_SYSTEM_PROMPT = """You are a helpful travel planning assistant for the V Your capabilities: - Search for interesting places (restaurants, tourist attractions, hotels) near any location - View and manage the user's trip collections and itineraries -- Add new locations to trip itineraries +- Add, move, and remove itinerary items +- Update itinerary location details +- Add/manage lodging and transportation entries in the trip - Check weather/temperature data for travel dates When suggesting places: @@ -23,8 +25,8 @@ When suggesting places: - Group suggestions logically (by area, by type, by day) When modifying itineraries: -- Confirm with the user before the first add_to_itinerary action in a conversation -- After the user clearly approves adding items (for example: "yes", "go ahead", "add them", "just add things there"), stop re-confirming and call add_to_itinerary directly for subsequent additions in that conversation +- Confirm with the user before the first mutating itinerary action in a conversation (add, move, remove, or update). +- After the user clearly approves itinerary changes (for example: "yes", "go ahead", "add them", "just add things there"), stop re-confirming and proceed directly for subsequent itinerary changes in that conversation. - Suggest logical ordering based on geography - Consider travel time between locations diff --git a/backend/server/chat/tests.py b/backend/server/chat/tests.py index 98c438c3..018cbe82 100644 --- a/backend/server/chat/tests.py +++ b/backend/server/chat/tests.py @@ -1,22 +1,34 @@ import json +from datetime import date from unittest import mock from unittest.mock import MagicMock, patch import requests as _requests from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType from django.test import TestCase from rest_framework.test import APITransactionTestCase -from adventures.models import Collection, CollectionItineraryItem, Location +from adventures.models import Collection, CollectionItineraryItem, Location, Visit from chat.agent_tools import ( add_to_itinerary, execute_tool, + add_lodging, + add_transportation, get_trip_details, + move_itinerary_item, + remove_itinerary_item, + remove_lodging, + remove_transportation, search_places, + update_location_details, + update_lodging, + update_transportation, web_search, ) from chat.views import ChatViewSet +from chat.views.day_suggestions import DaySuggestionsView User = get_user_model() @@ -245,6 +257,209 @@ class ChatAgentToolSharedTripAccessTests(TestCase): self.assertEqual(itinerary_result, {"error": "Trip not found"}) +class ChatAgentToolItineraryManagementTests(TestCase): + def setUp(self): + self.owner = User.objects.create_user( + username="chat-itinerary-owner", + email="chat-itinerary-owner@example.com", + password="password123", + ) + self.shared_user = User.objects.create_user( + username="chat-itinerary-shared", + email="chat-itinerary-shared@example.com", + password="password123", + ) + self.non_member = User.objects.create_user( + username="chat-itinerary-non-member", + email="chat-itinerary-non-member@example.com", + password="password123", + ) + + self.collection = Collection.objects.create( + user=self.owner, + name="Assistant Managed Trip", + ) + self.collection.shared_with.add(self.shared_user) + + self.location = Location.objects.create( + user=self.owner, + name="Existing Stop", + latitude=48.8566, + longitude=2.3522, + ) + self.collection.locations.add(self.location) + + self.location_content_type = ContentType.objects.get_for_model(Location) + self.day1_item = CollectionItineraryItem.objects.create( + collection=self.collection, + content_type=self.location_content_type, + object_id=self.location.id, + date="2026-06-01", + order=0, + is_global=False, + ) + + def test_move_itinerary_item_allows_shared_user(self): + result = move_itinerary_item( + self.shared_user, + collection_id=str(self.collection.id), + itinerary_item_id=str(self.day1_item.id), + date="2026-06-02", + order=0, + ) + + self.assertTrue(result.get("success")) + self.day1_item.refresh_from_db() + self.assertEqual(self.day1_item.date.isoformat(), "2026-06-02") + self.assertEqual(self.day1_item.order, 0) + + def test_remove_itinerary_item_removes_matching_visit_for_locations(self): + Visit.objects.create( + location=self.location, + start_date="2026-06-01T10:00:00Z", + end_date="2026-06-01T12:00:00Z", + ) + + result = remove_itinerary_item( + self.owner, + collection_id=str(self.collection.id), + itinerary_item_id=str(self.day1_item.id), + ) + + self.assertTrue(result.get("success")) + self.assertEqual(result.get("deleted_visit_count"), 1) + self.assertFalse( + CollectionItineraryItem.objects.filter(id=self.day1_item.id).exists() + ) + self.assertEqual(Visit.objects.filter(location=self.location).count(), 0) + + def test_update_location_details_scoped_to_collection(self): + outsider_location = Location.objects.create( + user=self.owner, + name="Outside", + latitude=1.0, + longitude=1.0, + ) + + denied = update_location_details( + self.shared_user, + collection_id=str(self.collection.id), + location_id=str(outsider_location.id), + name="Should fail", + ) + self.assertEqual(denied, {"error": "Location not found in this trip"}) + + allowed = update_location_details( + self.shared_user, + collection_id=str(self.collection.id), + location_id=str(self.location.id), + name="Updated Stop", + latitude=40.7128, + longitude=-74.0060, + ) + self.assertTrue(allowed.get("success")) + self.location.refresh_from_db() + self.assertEqual(self.location.name, "Updated Stop") + self.assertAlmostEqual(float(self.location.latitude), 40.7128, places=4) + self.assertAlmostEqual(float(self.location.longitude), -74.0060, places=4) + + def test_lodging_management_tools_allow_shared_user(self): + created = add_lodging( + self.shared_user, + collection_id=str(self.collection.id), + name="River Hotel", + type="hotel", + location="Paris", + check_in="2026-06-02T15:00:00Z", + check_out="2026-06-04T11:00:00Z", + latitude=48.85, + longitude=2.35, + itinerary_date="2026-06-02", + ) + + self.assertTrue(created.get("success")) + lodging_id = created["lodging"]["id"] + self.assertIsNotNone(created.get("itinerary_item")) + + updated = update_lodging( + self.shared_user, + collection_id=str(self.collection.id), + lodging_id=lodging_id, + name="River Hotel Updated", + location="Paris Center", + ) + self.assertTrue(updated.get("success")) + self.assertEqual(updated["lodging"]["name"], "River Hotel Updated") + + removed = remove_lodging( + self.shared_user, + collection_id=str(self.collection.id), + lodging_id=lodging_id, + ) + self.assertTrue(removed.get("success")) + self.assertGreaterEqual(removed.get("removed_itinerary_items", 0), 1) + + def test_transportation_management_tools_allow_shared_user(self): + created = add_transportation( + self.shared_user, + collection_id=str(self.collection.id), + name="Train to Lyon", + type="train", + date="2026-06-03T09:00:00Z", + end_date="2026-06-03T11:00:00Z", + from_location="Paris", + to_location="Lyon", + origin_latitude=48.8566, + origin_longitude=2.3522, + destination_latitude=45.7640, + destination_longitude=4.8357, + itinerary_date="2026-06-03", + ) + + self.assertTrue(created.get("success")) + transportation_id = created["transportation"]["id"] + + updated = update_transportation( + self.shared_user, + collection_id=str(self.collection.id), + transportation_id=transportation_id, + to_location="Lyon Part-Dieu", + ) + self.assertTrue(updated.get("success")) + self.assertEqual(updated["transportation"]["to_location"], "Lyon Part-Dieu") + + removed = remove_transportation( + self.shared_user, + collection_id=str(self.collection.id), + transportation_id=transportation_id, + ) + self.assertTrue(removed.get("success")) + self.assertGreaterEqual(removed.get("removed_itinerary_items", 0), 1) + + def test_management_tools_deny_non_member(self): + move_result = move_itinerary_item( + self.non_member, + collection_id=str(self.collection.id), + itinerary_item_id=str(self.day1_item.id), + date="2026-06-02", + ) + remove_result = remove_itinerary_item( + self.non_member, + collection_id=str(self.collection.id), + itinerary_item_id=str(self.day1_item.id), + ) + update_result = update_location_details( + self.non_member, + collection_id=str(self.collection.id), + location_id=str(self.location.id), + name="No access", + ) + + self.assertEqual(move_result, {"error": "Trip not found"}) + self.assertEqual(remove_result, {"error": "Trip not found"}) + self.assertEqual(update_result, {"error": "Trip not found"}) + + class ChatViewSetToolValidationBoundaryTests(TestCase): def test_trip_context_destination_summary_normalizes_to_first_segment(self): self.assertEqual( @@ -1286,3 +1501,337 @@ class GetWeatherCoordFallbackTests(APITransactionTestCase): ), "Should emit tool_validation_error when no collection coords available", ) + + @patch("chat.views.execute_tool") + @patch("chat.views.stream_chat_completion") + @patch("integrations.utils.auto_profile.update_auto_preference_profile") + def test_get_weather_missing_coords_and_dates_retries_with_collection_dates( + self, + _mock_auto_profile, + mock_stream_chat_completion, + mock_execute_tool, + ): + user = User.objects.create_user( + username="weather-coord-dates-user", + email="weather-coord-dates-user@example.com", + password="password123", + ) + self.client.force_authenticate(user=user) + + collection = Collection.objects.create( + user_id=user.id, + name="Vienna Trip", + start_date=date(2026, 6, 10), + end_date=date(2026, 6, 12), + ) + vienna_location = Location.objects.create( + user_id=user.id, + name="Vienna", + latitude=48.2082, + longitude=16.3738, + ) + collection.locations.add(vienna_location) + + conversation_response = self.client.post( + "/api/chat/conversations/", + {"title": "Weather Coord+Dates Fallback Test"}, + format="json", + ) + self.assertEqual(conversation_response.status_code, 201) + conversation_id = conversation_response.json()["id"] + + async def weather_stream(*args, **kwargs): + yield 'data: {"tool_calls": [{"index": 0, "id": "call_w3", "type": "function", "function": {"name": "get_weather", "arguments": "{}"}}]}\n\n' + yield "data: [DONE]\n\n" + + async def success_stream(*args, **kwargs): + yield 'data: {"content": "Vienna forecast loaded."}\n\n' + yield "data: [DONE]\n\n" + + mock_stream_chat_completion.side_effect = [weather_stream(), success_stream()] + mock_execute_tool.side_effect = [ + {"error": "latitude and longitude are required"}, + { + "location": "Vienna", + "forecast": [ + {"date": "2026-06-10", "temperature": 24, "condition": "sunny"} + ], + }, + ] + + response = self.client.post( + f"/api/chat/conversations/{conversation_id}/send_message/", + { + "message": "What's the weather for my trip?", + "collection_id": str(collection.id), + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + chunks = [ + chunk.decode("utf-8") + if isinstance(chunk, (bytes, bytearray)) + else str(chunk) + for chunk in response.streaming_content + ] + payload_lines = [ + chunk.strip()[len("data: ") :] + for chunk in chunks + if chunk.strip().startswith("data: ") + ] + json_payloads = [json.loads(p) for p in payload_lines if p != "[DONE]"] + + self.assertEqual(mock_execute_tool.call_count, 2) + retry_kwargs = mock_execute_tool.call_args_list[1][1] + self.assertAlmostEqual(retry_kwargs.get("latitude"), 48.2082, places=3) + self.assertAlmostEqual(retry_kwargs.get("longitude"), 16.3738, places=3) + self.assertEqual( + retry_kwargs.get("dates"), + ["2026-06-10", "2026-06-11", "2026-06-12"], + ) + self.assertFalse( + any( + payload.get("error_category") == "tool_execution_error" + for payload in json_payloads + ) + ) + + @patch("chat.views.execute_tool") + @patch("chat.views.stream_chat_completion") + @patch("integrations.utils.auto_profile.update_auto_preference_profile") + def test_get_weather_missing_dates_only_remains_validation_error( + self, + _mock_auto_profile, + mock_stream_chat_completion, + mock_execute_tool, + ): + user = User.objects.create_user( + username="weather-missing-dates-user", + email="weather-missing-dates-user@example.com", + password="password123", + ) + self.client.force_authenticate(user=user) + + collection = Collection.objects.create( + user_id=user.id, + name="Berlin Trip", + ) + berlin_location = Location.objects.create( + user_id=user.id, + name="Berlin", + latitude=52.52, + longitude=13.405, + ) + collection.locations.add(berlin_location) + + conversation_response = self.client.post( + "/api/chat/conversations/", + {"title": "Weather Missing Dates Validation Test"}, + format="json", + ) + self.assertEqual(conversation_response.status_code, 201) + conversation_id = conversation_response.json()["id"] + + async def weather_stream(*args, **kwargs): + yield ( + 'data: {"tool_calls": [{"index": 0, "id": "call_w4", "type": ' + '"function", "function": {"name": "get_weather", "arguments": ' + '"{\\"latitude\\":52.52,\\"longitude\\":13.405}"}}]}\n\n' + ) + yield "data: [DONE]\n\n" + + mock_stream_chat_completion.side_effect = weather_stream + mock_execute_tool.return_value = {"error": "dates is required"} + + response = self.client.post( + f"/api/chat/conversations/{conversation_id}/send_message/", + { + "message": "What's the weather there?", + "collection_id": str(collection.id), + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + payload_lines = [ + ( + chunk.decode("utf-8") + if isinstance(chunk, (bytes, bytearray)) + else str(chunk) + ).strip()[len("data: ") :] + for chunk in response.streaming_content + if ( + ( + chunk.decode("utf-8") + if isinstance(chunk, (bytes, bytearray)) + else str(chunk) + ) + .strip() + .startswith("data: ") + ) + ] + json_payloads = [json.loads(p) for p in payload_lines if p != "[DONE]"] + + self.assertEqual(mock_execute_tool.call_count, 1) + self.assertTrue( + any( + payload.get("error_category") == "tool_validation_error" + for payload in json_payloads + ) + ) + self.assertFalse( + any( + payload.get("error_category") == "tool_execution_error" + for payload in json_payloads + ) + ) + self.assertFalse( + any( + "Could not fetch weather for the collection locations" + in payload.get("error", "") + for payload in json_payloads + ) + ) + + +class DaySuggestionsCoordinateEnrichmentTests(TestCase): + def setUp(self): + self.view = DaySuggestionsView() + + def test_enriches_suggestion_with_coordinates_from_place_context(self): + suggestions = [ + { + "name": "Roscioli", + "location": "Via dei Giubbonari, Rome", + "description": "Classic Roman spot", + } + ] + place_candidates = [ + { + "name": "Roscioli", + "address": "Via dei Giubbonari, Rome", + "latitude": 41.8933, + "longitude": 12.4722, + } + ] + + 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) + + def test_preserves_existing_suggestion_coordinates(self): + suggestions = [ + { + "name": "Known Place", + "location": "Somewhere", + "latitude": 10.5, + "longitude": 20.5, + } + ] + place_candidates = [ + { + "name": "Known Place", + "address": "Somewhere", + "latitude": 1.0, + "longitude": 2.0, + } + ] + + enriched = self.view._enrich_suggestions_with_coordinates( + suggestions, + place_candidates, + ) + + self.assertEqual(enriched[0]["latitude"], 10.5) + self.assertEqual(enriched[0]["longitude"], 20.5) + + def test_enriches_coordinates_with_token_based_name_matching(self): + suggestions = [ + { + "name": "Borough food market", + "location": "South Bank", + "description": "Popular food destination", + } + ] + place_candidates = [ + { + "name": "Borough Market", + "address": "8 Southwark St, London SE1 1TL", + "latitude": 51.5055, + "longitude": -0.0904, + } + ] + + 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) + + def test_falls_back_to_coordinate_match_when_best_text_match_has_no_coordinates( + self, + ): + suggestions = [ + { + "name": "Sunset Bar", + "location": "Pier 7", + "description": "Cocktail spot", + } + ] + place_candidates = [ + { + "name": "Sunset Bar", + "address": "Pier 7", + "latitude": None, + "longitude": None, + }, + { + "name": "Harbor Walk", + "address": "Pier 7, Lisbon", + "latitude": 38.7072, + "longitude": -9.1366, + }, + ] + + enriched = self.view._enrich_suggestions_with_coordinates( + suggestions, + place_candidates, + ) + + self.assertEqual(len(enriched), 1) + self.assertEqual(enriched[0]["latitude"], 38.7072) + self.assertEqual(enriched[0]["longitude"], -9.1366) + + def test_does_not_inject_null_coordinates_when_no_coordinate_match_exists(self): + suggestions = [ + { + "name": "Skyline View", + "location": "Hilltop", + } + ] + place_candidates = [ + { + "name": "Skyline View", + "address": "Hilltop", + "latitude": None, + "longitude": None, + } + ] + + enriched = self.view._enrich_suggestions_with_coordinates( + suggestions, + place_candidates, + ) + + self.assertEqual(len(enriched), 1) + self.assertNotIn("latitude", enriched[0]) + self.assertNotIn("longitude", enriched[0]) diff --git a/backend/server/chat/views/__init__.py b/backend/server/chat/views/__init__.py index a8cfaf0c..fed0718a 100644 --- a/backend/server/chat/views/__init__.py +++ b/backend/server/chat/views/__init__.py @@ -2,10 +2,12 @@ import asyncio import json import logging import re +from datetime import timedelta from asgiref.sync import sync_to_async from adventures.models import Collection from django.http import StreamingHttpResponse +from django.utils import timezone from integrations.models import UserAISettings from rest_framework import status, viewsets from rest_framework.decorators import action @@ -276,6 +278,33 @@ class ChatViewSet(viewsets.ModelViewSet): continue return None + @staticmethod + def _derive_weather_dates_from_collection(collection, max_days=7): + """Derive a bounded weather date list from collection dates, or fallback to today.""" + today = timezone.localdate() + if collection is None: + return [today.isoformat()] + + start_date = getattr(collection, "start_date", None) + end_date = getattr(collection, "end_date", None) + + if start_date and end_date: + range_start = min(start_date, end_date) + range_end = max(start_date, end_date) + day_count = min((range_end - range_start).days + 1, max_days) + return [ + (range_start + timedelta(days=offset)).isoformat() + for offset in range(day_count) + ] + + if start_date: + return [start_date.isoformat()] + + if end_date: + return [end_date.isoformat()] + + return [today.isoformat()] + @staticmethod def _build_search_places_location_clarification_message(): return ( @@ -744,6 +773,12 @@ class ChatViewSet(viewsets.ModelViewSet): retry_arguments = dict(prepared_arguments) retry_arguments["latitude"] = retry_lat retry_arguments["longitude"] = retry_lon + if not retry_arguments.get("dates"): + retry_arguments["dates"] = ( + self._derive_weather_dates_from_collection( + collection + ) + ) attempted_weather_coord_retry = True retry_result = await sync_to_async( execute_tool, @@ -774,6 +809,10 @@ class ChatViewSet(viewsets.ModelViewSet): if ( attempted_weather_coord_retry and self._is_required_param_tool_error(result) + and self._is_get_weather_missing_latlong_error( + function_name, + result, + ) ): result = { "error": "Could not fetch weather for the collection locations" diff --git a/backend/server/chat/views/day_suggestions.py b/backend/server/chat/views/day_suggestions.py index c0c6d119..ceebb9e1 100644 --- a/backend/server/chat/views/day_suggestions.py +++ b/backend/server/chat/views/day_suggestions.py @@ -72,7 +72,12 @@ class DaySuggestionsView(APIView): ) try: - places_context = self._get_places_context(request.user, category, location) + place_candidates = self._fetch_place_candidates( + request.user, + category, + location, + ) + places_context = self._build_places_context(place_candidates) prompt = self._build_prompt( category=category, filters=filters, @@ -89,17 +94,30 @@ class DaySuggestionsView(APIView): provider=provider, model=model, ) + suggestions = self._enrich_suggestions_with_coordinates( + suggestions, + place_candidates, + ) return Response({"suggestions": suggestions}, status=status.HTTP_200_OK) except Exception as exc: logger.exception("Failed to generate day suggestions") payload = _safe_error_payload(exc) - status_code = { + error_category = ( + payload.get("error_category") if isinstance(payload, dict) else None + ) + status_code_map = { "model_not_found": status.HTTP_400_BAD_REQUEST, "authentication_failed": status.HTTP_401_UNAUTHORIZED, "rate_limited": status.HTTP_429_TOO_MANY_REQUESTS, "invalid_request": status.HTTP_400_BAD_REQUEST, "provider_unreachable": status.HTTP_503_SERVICE_UNAVAILABLE, - }.get(payload.get("error_category"), status.HTTP_500_INTERNAL_SERVER_ERROR) + } + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + if isinstance(error_category, str): + status_code = status_code_map.get( + error_category, + status.HTTP_500_INTERNAL_SERVER_ERROR, + ) return Response( payload, status=status_code, @@ -176,11 +194,12 @@ class DaySuggestionsView(APIView): prompt += ( " Return 3-5 specific suggestions as a JSON array." " Each suggestion should have: name, description, why_fits, category, location, rating, price_level." + " Include latitude and longitude when known from nearby-place context." " Return ONLY valid JSON, no markdown, no surrounding text." ) return prompt - def _get_places_context(self, user, category, location): + def _fetch_place_candidates(self, user, category, location): tool_category_map = { "restaurant": "food", "activity": "tourism", @@ -194,24 +213,190 @@ class DaySuggestionsView(APIView): radius=8, ) if not isinstance(result, dict): - return "" + return [] if result.get("error"): - return "" + return [] raw_results = result.get("results") if not isinstance(raw_results, list): + return [] + + return [entry for entry in raw_results if isinstance(entry, dict)] + + def _build_places_context(self, place_candidates): + if not isinstance(place_candidates, list): return "" entries = [] - for place in raw_results[:5]: - if not isinstance(place, dict): - continue + for place in place_candidates[:5]: name = place.get("name") address = place.get("address") or "" - if name: - entries.append(f"{name} ({address})" if address else name) + latitude = place.get("latitude") + longitude = place.get("longitude") + if not name: + continue + + details = [name] + if address: + details.append(address) + if latitude is not None and longitude is not None: + details.append(f"lat={latitude}") + details.append(f"lon={longitude}") + entries.append(" | ".join(details)) return "; ".join(entries) + def _tokenize_text(self, value): + normalized = self._normalize_text(value) + if not normalized: + return set() + return set(re.findall(r"[a-z0-9]+", normalized)) + + def _normalize_text(self, value): + if not isinstance(value, str): + return "" + return value.strip().lower() + + def _extract_suggestion_identity(self, suggestion): + if not isinstance(suggestion, dict): + return "", "" + + name = self._normalize_text( + suggestion.get("name") + or suggestion.get("title") + or suggestion.get("place_name") + or suggestion.get("venue") + ) + location_text = self._normalize_text( + suggestion.get("location") + or suggestion.get("address") + or suggestion.get("neighborhood") + ) + return name, location_text + + def _best_place_match(self, suggestion, place_candidates): + suggestion_name, suggestion_location = self._extract_suggestion_identity( + suggestion + ) + if not suggestion_name and not suggestion_location: + return None + + suggestion_name_tokens = self._tokenize_text(suggestion_name) + suggestion_location_tokens = self._tokenize_text(suggestion_location) + + def has_coordinates(candidate): + return ( + candidate.get("latitude") is not None + and candidate.get("longitude") is not None + ) + + best_candidate = None + best_score = -1 + best_coordinate_candidate = None + best_coordinate_score = -1 + for candidate in place_candidates: + candidate_name = self._normalize_text(candidate.get("name")) + candidate_address = self._normalize_text(candidate.get("address")) + candidate_name_tokens = self._tokenize_text(candidate_name) + candidate_address_tokens = self._tokenize_text(candidate_address) + score = 0 + + if suggestion_name and candidate_name: + if suggestion_name == candidate_name: + score += 4 + elif ( + suggestion_name in candidate_name + or candidate_name in suggestion_name + ): + score += 2 + + shared_name_tokens = suggestion_name_tokens & candidate_name_tokens + if len(shared_name_tokens) >= 2: + score += 3 + elif len(shared_name_tokens) == 1: + score += 1 + + if suggestion_location and candidate_address: + if suggestion_location == candidate_address: + score += 2 + elif ( + suggestion_location in candidate_address + or candidate_address in suggestion_location + ): + score += 1 + + shared_location_tokens = ( + suggestion_location_tokens & candidate_address_tokens + ) + if len(shared_location_tokens) >= 2: + score += 2 + elif len(shared_location_tokens) == 1: + score += 1 + + if score > best_score: + best_score = score + best_candidate = candidate + elif ( + score == best_score + and best_candidate is not None + and not has_coordinates(best_candidate) + and has_coordinates(candidate) + ): + best_candidate = candidate + + if has_coordinates(candidate) and score > best_coordinate_score: + best_coordinate_score = score + best_coordinate_candidate = candidate + + if best_score <= 0: + return None + + if has_coordinates(best_candidate): + return best_candidate + + # Bounded fallback: if the strongest text match has no coordinates, + # accept the best coordinate-bearing candidate only with a + # reasonably strong lexical overlap score. + if best_coordinate_score >= 2: + return best_coordinate_candidate + + return best_candidate + + def _enrich_suggestions_with_coordinates(self, suggestions, place_candidates): + if not isinstance(suggestions, list) or not isinstance(place_candidates, list): + return suggestions + + enriched = [] + for suggestion in suggestions: + 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") + merged["location"] = merged.get("location") or matched_place.get("address") + enriched.append(merged) + + return enriched + def _resolve_provider_and_model(self, request): request_provider = (request.data.get("provider") or "").strip().lower() or None request_model = (request.data.get("model") or "").strip() or None @@ -262,7 +447,7 @@ class DaySuggestionsView(APIView): if not api_key: raise ValueError("No API key available") - provider_config = CHAT_PROVIDER_CONFIG.get(provider, {}) + provider_config = CHAT_PROVIDER_CONFIG.get(provider or "", {}) resolved_model = normalize_gateway_model( provider, model or provider_config.get("default_model"), diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index d1fe4d37..ee105e59 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -18,7 +18,7 @@ services: - ./frontend:/app - web_node_modules:/app/node_modules - bun_cache:/bun-cache - command: sh -c "mkdir -p /bun-cache /app/node_modules && chown -R node:node /bun-cache /app/node_modules && su node -c 'BUN_INSTALL_CACHE_DIR=/bun-cache bun install --frozen-lockfile && bun run vite dev --host 0.0.0.0 --port 3000 --strictPort'" + command: sh -c "mkdir -p /bun-cache /app/node_modules && chown -R bun:bun /bun-cache /app/node_modules && su bun -c 'BUN_INSTALL_CACHE_DIR=/bun-cache bun install --frozen-lockfile && bun run vite dev --host 0.0.0.0 --port 3000 --strictPort'" db: image: postgis/postgis:16-3.5 diff --git a/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte b/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte index 5e982536..0f4ca784 100644 --- a/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte +++ b/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte @@ -65,7 +65,10 @@ type DayTemperature = { available: boolean; + temperature_low_c: number | null; + temperature_high_c: number | null; temperature_c: number | null; + is_estimate: boolean; }; $: days = groupItemsByDay(collection); @@ -653,7 +656,12 @@ if (!result?.date) continue; nextMap[result.date] = { available: !!result.available, - temperature_c: typeof result.temperature_c === 'number' ? result.temperature_c : null + temperature_low_c: + typeof result.temperature_low_c === 'number' ? result.temperature_low_c : null, + temperature_high_c: + typeof result.temperature_high_c === 'number' ? result.temperature_high_c : null, + temperature_c: typeof result.temperature_c === 'number' ? result.temperature_c : null, + is_estimate: result.is_estimate === true }; } @@ -993,12 +1001,22 @@ function formatDayTemperature(day: DayGroup, temps: Record): string { const temperature = temps[day.date]; - if (!temperature?.available || temperature.temperature_c === null) { + if ( + !temperature?.available || + temperature.temperature_low_c === null || + temperature.temperature_high_c === null + ) { return getI18nText('itinerary.temperature_unavailable', 'Temperature unavailable'); } - const rounded = Math.round(temperature.temperature_c); - return `${rounded}°C`; + const low = Math.round(temperature.temperature_low_c); + const high = Math.round(temperature.temperature_high_c); + const rangeText = `${low}°–${high}°C`; + if (temperature.is_estimate) { + return `${rangeText} ${getI18nText('itinerary.temperature_estimated_marker', 'est.')}`; + } + + return rangeText; } type HardAnchorTiming = { @@ -2624,6 +2642,18 @@ displayDate={suggestionModalDisplayDate} on:close={() => (isSuggestionModalOpen = false)} on:addItem={(e) => { + if (e.detail.type === 'location' && e.detail.location?.id) { + const createdLocation = e.detail.location; + const existingLocations = collection.locations || []; + collection = { + ...collection, + locations: [ + createdLocation, + ...existingLocations.filter((loc) => loc.id !== createdLocation.id) + ] + }; + } + addItineraryItemForObject( e.detail.type, e.detail.itemId, @@ -2917,8 +2947,6 @@
-

{day.displayDate}

- {#if canModify} {#if day.dayMetadata?.name} diff --git a/frontend/src/lib/components/collections/ItinerarySuggestionModal.svelte b/frontend/src/lib/components/collections/ItinerarySuggestionModal.svelte index 10770d1d..7b078d4d 100644 --- a/frontend/src/lib/components/collections/ItinerarySuggestionModal.svelte +++ b/frontend/src/lib/components/collections/ItinerarySuggestionModal.svelte @@ -18,6 +18,8 @@ location?: string; rating?: number | string | null; price_level?: string | null; + latitude?: number | string | null; + longitude?: number | string | null; }; const dispatch = createEventDispatcher(); @@ -46,6 +48,7 @@ ]; const supportedApiCategories = ['restaurant', 'activity', 'event', 'lodging']; + const LOCATION_MODEL_TEXT_MAX_LENGTH = 200; const activityTypes = ['outdoor', 'cultural', 'entertainment', 'other']; const durations = ['few hours', 'half-day', 'full-day']; @@ -131,6 +134,10 @@ return value.trim(); } + function truncateToModelSafeLength(value: string): string { + return value.slice(0, LOCATION_MODEL_TEXT_MAX_LENGTH); + } + function normalizeRating(value: unknown): number | null { if (typeof value === 'number' && Number.isFinite(value)) { return value; @@ -146,6 +153,19 @@ return null; } + function normalizeCoordinate(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string') { + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? parsed : null; + } + + return null; + } + function normalizeSuggestionItem(value: unknown): SuggestionItem | null { const item = asRecord(value); if (!item) return null; @@ -169,6 +189,8 @@ normalizeText(item.priceLevel) || normalizeText(item.price); const rating = normalizeRating(item.rating ?? item.score); + const latitude = normalizeCoordinate(item.latitude ?? item.lat); + const longitude = normalizeCoordinate(item.longitude ?? item.lon ?? item.lng); const finalName = name || location; if (!finalName) return null; @@ -180,30 +202,39 @@ category: category || undefined, location: location || undefined, rating, - price_level: priceLevel || null + price_level: priceLevel || null, + latitude, + longitude }; } function buildLocationPayload(suggestion: SuggestionItem) { - const name = + const resolvedName = normalizeText(suggestion.name) || normalizeText(suggestion.location) || 'Suggestion'; - const locationText = + const name = truncateToModelSafeLength(resolvedName); + const resolvedLocation = normalizeText(suggestion.location) || getCollectionLocation() || - normalizeText(suggestion.name); + normalizeText(suggestion.name) || + name; + const locationText = truncateToModelSafeLength(resolvedLocation || name); const description = normalizeText(suggestion.description) || normalizeText(suggestion.why_fits) || (suggestion.category ? `${suggestion.category} suggestion` : ''); const rating = normalizeRating(suggestion.rating); + const latitude = normalizeCoordinate(suggestion.latitude); + const longitude = normalizeCoordinate(suggestion.longitude); return { name, description, location: locationText || name, rating, + latitude, + longitude, collections: [collection.id], - is_public: false + is_public: Boolean(collection?.is_public) }; } @@ -293,7 +324,8 @@ dispatch('addItem', { type: 'location', itemId: location.id, - updateDate: false + updateDate: false, + location }); } catch (_err) { error = $t('suggestions.error'); diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 8a7cc11d..c0431aa6 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -1193,7 +1193,9 @@ "drag_to_reorder": "Drag to reorder", "add_to_day": "Add to day", "optimize": "Optimize", - "add_place": "+ Add place" + "add_place": "+ Add place", + "temperature_unavailable": "Temperature unavailable", + "temperature_estimated_marker": "est." }, "common": { "show_less": "Hide details",