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
147 lines
5.0 KiB
Python
147 lines
5.0 KiB
Python
from django.db import models
|
|
from django.contrib.auth import get_user_model
|
|
import uuid
|
|
from django.conf import settings
|
|
from cryptography.fernet import Fernet, InvalidToken
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
class EncryptionConfigurationError(Exception):
|
|
pass
|
|
|
|
|
|
def get_field_fernet() -> Fernet:
|
|
key = getattr(settings, "FIELD_ENCRYPTION_KEY", None)
|
|
if not key:
|
|
raise EncryptionConfigurationError(
|
|
"FIELD_ENCRYPTION_KEY is not configured. API key storage is unavailable."
|
|
)
|
|
|
|
key_bytes = key.encode() if isinstance(key, str) else key
|
|
try:
|
|
return Fernet(key_bytes)
|
|
except (TypeError, ValueError) as exc:
|
|
raise EncryptionConfigurationError(
|
|
"FIELD_ENCRYPTION_KEY is invalid. Provide a valid Fernet key."
|
|
) from exc
|
|
|
|
|
|
class ImmichIntegration(models.Model):
|
|
server_url = models.CharField(max_length=255)
|
|
api_key = models.CharField(max_length=255)
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
|
copy_locally = models.BooleanField(
|
|
default=True,
|
|
help_text="Copy image to local storage, instead of just linking to the remote URL.",
|
|
)
|
|
id = models.UUIDField(
|
|
default=uuid.uuid4, editable=False, unique=True, primary_key=True
|
|
)
|
|
|
|
def __str__(self):
|
|
return self.user.username + " - " + self.server_url
|
|
|
|
|
|
class StravaToken(models.Model):
|
|
user = models.ForeignKey(
|
|
User, on_delete=models.CASCADE, related_name="strava_tokens"
|
|
)
|
|
access_token = models.CharField(max_length=255)
|
|
refresh_token = models.CharField(max_length=255)
|
|
expires_at = models.BigIntegerField() # Unix timestamp
|
|
athlete_id = models.BigIntegerField(null=True, blank=True)
|
|
scope = models.CharField(max_length=255, null=True, blank=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
|
|
class WandererIntegration(models.Model):
|
|
server_url = models.CharField(max_length=255)
|
|
username = models.CharField(max_length=255)
|
|
user = models.ForeignKey(
|
|
User, on_delete=models.CASCADE, related_name="wanderer_integrations"
|
|
)
|
|
token = models.CharField(null=True, blank=True)
|
|
token_expiry = models.DateTimeField(null=True, blank=True)
|
|
id = models.UUIDField(
|
|
default=uuid.uuid4, editable=False, unique=True, primary_key=True
|
|
)
|
|
|
|
def __str__(self):
|
|
return self.user.username + " - " + self.server_url
|
|
|
|
class Meta:
|
|
verbose_name = "Wanderer Integration"
|
|
verbose_name_plural = "Wanderer Integrations"
|
|
|
|
|
|
class UserAPIKey(models.Model):
|
|
id = models.UUIDField(
|
|
default=uuid.uuid4, editable=False, unique=True, primary_key=True
|
|
)
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="api_keys")
|
|
provider = models.CharField(max_length=100)
|
|
encrypted_api_key = models.TextField()
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
unique_together = ("user", "provider")
|
|
|
|
def set_api_key(self, value: str) -> None:
|
|
if value is None:
|
|
raise ValueError("API key cannot be None")
|
|
fernet = get_field_fernet()
|
|
self.encrypted_api_key = fernet.encrypt(value.encode()).decode()
|
|
|
|
def get_api_key(self) -> str | None:
|
|
if not self.encrypted_api_key:
|
|
return None
|
|
fernet = get_field_fernet()
|
|
try:
|
|
return fernet.decrypt(self.encrypted_api_key.encode()).decode()
|
|
except (InvalidToken, ValueError):
|
|
return None
|
|
|
|
@property
|
|
def masked_api_key(self) -> str:
|
|
plain = self.get_api_key() or ""
|
|
if len(plain) <= 6:
|
|
return "*" * len(plain)
|
|
return f"{plain[:3]}{'*' * (len(plain) - 6)}{plain[-3:]}"
|
|
|
|
|
|
class UserRecommendationPreferenceProfile(models.Model):
|
|
id = models.UUIDField(
|
|
default=uuid.uuid4, editable=False, unique=True, primary_key=True
|
|
)
|
|
user = models.OneToOneField(
|
|
User, on_delete=models.CASCADE, related_name="recommendation_profile"
|
|
)
|
|
cuisines = models.TextField(blank=True, null=True)
|
|
interests = models.JSONField(default=list, blank=True)
|
|
trip_style = models.CharField(max_length=120, blank=True, null=True)
|
|
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}"
|