feat(ai): implement agent-redesign plan with enhanced AI travel features

Phase 1 - Configuration Infrastructure (WS1):
- Add instance-level AI env vars (VOYAGE_AI_PROVIDER, VOYAGE_AI_MODEL, VOYAGE_AI_API_KEY)
- Implement fallback chain: user key → instance key → error
- Add UserAISettings model for per-user provider/model preferences
- Enhance provider catalog with instance_configured and user_configured flags
- Optimize provider catalog to avoid N+1 queries

Phase 1 - User Preference Learning (WS2):
- Add Travel Preferences tab to Settings page
- Improve preference formatting in system prompt with emoji headers
- Add multi-user preference aggregation for shared collections

Phase 2 - Day-Level Suggestions Modal (WS3):
- Create ItinerarySuggestionModal with 3-step flow (category → filters → results)
- Add AI suggestions button to itinerary Add dropdown
- Support restaurant, activity, event, and lodging categories
- Backend endpoint POST /api/chat/suggestions/day/ with context-aware prompts

Phase 3 - Collection-Level Chat Improvements (WS4):
- Inject collection context (destination, dates) into chat system prompt
- Add quick action buttons for common queries
- Add 'Add to itinerary' button on search_places results
- Update chat UI with travel-themed branding and improved tool result cards

Phase 3 - Web Search Capability (WS5):
- Add web_search agent tool using DuckDuckGo
- Support location_context parameter for biased results
- Handle rate limiting gracefully

Phase 4 - Extensibility Architecture (WS6):
- Implement decorator-based @agent_tool registry
- Convert existing tools to use decorators
- Add GET /api/chat/capabilities/ endpoint for tool discovery
- Refactor execute_tool() to use registry pattern
This commit is contained in:
2026-03-08 23:53:14 +00:00
parent 246b081d97
commit 9d5681b1ef
22 changed files with 2358 additions and 255 deletions

View File

@@ -0,0 +1,52 @@
# Generated by Django 5.2.12 on 2026-03-08
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("integrations", "0007_userapikey_userrecommendationpreferenceprofile"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="UserAISettings",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"preferred_provider",
models.CharField(blank=True, max_length=100, null=True),
),
(
"preferred_model",
models.CharField(blank=True, max_length=100, null=True),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="ai_settings",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "User AI Settings",
"verbose_name_plural": "User AI Settings",
},
),
]

View File

@@ -124,3 +124,23 @@ class UserRecommendationPreferenceProfile(models.Model):
notes = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class UserAISettings(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="ai_settings",
)
preferred_provider = models.CharField(max_length=100, blank=True, null=True)
preferred_model = models.CharField(max_length=100, blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "User AI Settings"
verbose_name_plural = "User AI Settings"
def __str__(self):
return f"AI Settings for {self.user.username}"

View File

@@ -3,6 +3,7 @@ from django.db import IntegrityError
from .models import (
EncryptionConfigurationError,
ImmichIntegration,
UserAISettings,
UserAPIKey,
UserRecommendationPreferenceProfile,
)
@@ -98,3 +99,16 @@ class UserRecommendationPreferenceProfileSerializer(serializers.ModelSerializer)
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at"]
class UserAISettingsSerializer(serializers.ModelSerializer):
class Meta:
model = UserAISettings
fields = [
"id",
"preferred_provider",
"preferred_model",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at"]

View File

@@ -6,6 +6,7 @@ from integrations.views import (
StravaIntegrationView,
WandererIntegrationViewSet,
UserAPIKeyViewSet,
UserAISettingsViewSet,
UserRecommendationPreferenceProfileViewSet,
)
@@ -22,6 +23,7 @@ router.register(
UserRecommendationPreferenceProfileViewSet,
basename="user-recommendation-preferences",
)
router.register(r"ai-settings", UserAISettingsViewSet, basename="user-ai-settings")
# Include the router URLs
urlpatterns = [

View File

@@ -4,3 +4,4 @@ from .strava_view import StravaIntegrationView
from .wanderer_view import WandererIntegrationViewSet
from .user_api_key_view import UserAPIKeyViewSet
from .recommendation_profile_view import UserRecommendationPreferenceProfileViewSet
from .ai_settings_view import UserAISettingsViewSet

View File

@@ -0,0 +1,39 @@
from rest_framework import status, viewsets
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from integrations.models import UserAISettings
from integrations.serializers import UserAISettingsSerializer
class UserAISettingsViewSet(viewsets.ModelViewSet):
serializer_class = UserAISettingsSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return UserAISettings.objects.filter(user=self.request.user)
def list(self, request, *args, **kwargs):
instance = self.get_queryset().first()
if not instance:
return Response([], status=status.HTTP_200_OK)
serializer = self.get_serializer(instance)
return Response([serializer.data], status=status.HTTP_200_OK)
def perform_create(self, serializer):
existing = UserAISettings.objects.filter(user=self.request.user).first()
if existing:
for field, value in serializer.validated_data.items():
setattr(existing, field, value)
existing.save()
self._upserted_instance = existing
return
self._upserted_instance = serializer.save(user=self.request.user)
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
output = self.get_serializer(self._upserted_instance)
return Response(output.data, status=status.HTTP_200_OK)