Coverage for apps / ai / services / discover.py: 14%
123 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 00:40 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 00:40 +0000
1"""AI discovery suggestions service."""
3import logging
4from datetime import datetime, timedelta
5from typing import Optional, List
7from django.utils import timezone
9from apps.profiles.models import Profile
10from apps.recipes.models import RecipeFavorite, RecipeViewHistory
12from ..models import AIPrompt, AIDiscoverySuggestion
13from .openrouter import OpenRouterService, AIUnavailableError, AIResponseError
14from .validator import AIResponseValidator, ValidationError
16logger = logging.getLogger(__name__)
18# Suggestions are cached for 24 hours
19CACHE_DURATION_HOURS = 24
22def get_discover_suggestions(profile_id: int) -> dict:
23 """Get AI discovery suggestions for a profile.
25 Returns cached suggestions if still valid (within 24 hours),
26 otherwise generates new suggestions via AI.
28 For new users (no favorites), only seasonal suggestions are returned.
30 Args:
31 profile_id: The ID of the profile to get suggestions for.
33 Returns:
34 Dict with suggestions array and refresh timestamp.
36 Raises:
37 Profile.DoesNotExist: If profile not found.
38 AIUnavailableError: If AI service is not available.
39 """
40 profile = Profile.objects.get(id=profile_id)
42 # Check for cached suggestions (within 24 hours)
43 cache_cutoff = timezone.now() - timedelta(hours=CACHE_DURATION_HOURS)
44 cached = AIDiscoverySuggestion.objects.filter(
45 profile=profile,
46 created_at__gte=cache_cutoff
47 )
49 if cached.exists():
50 logger.info(f'Returning cached discover suggestions for profile {profile_id}')
51 return _format_suggestions(cached)
53 # Clear old suggestions
54 AIDiscoverySuggestion.objects.filter(profile=profile).delete()
56 # Check if user has viewing history
57 has_history = RecipeViewHistory.objects.filter(profile=profile).exists()
59 # Generate new suggestions
60 suggestions = []
62 # Always generate seasonal suggestions
63 seasonal_suggestions = _generate_seasonal_suggestions(profile)
64 suggestions.extend(seasonal_suggestions)
66 # Only generate personalized suggestions if user has history
67 if has_history:
68 # Recommended based on what they've viewed
69 recommended_suggestions = _generate_recommended_suggestions(profile)
70 suggestions.extend(recommended_suggestions)
72 # Try something new/different
73 new_suggestions = _generate_new_suggestions(profile)
74 suggestions.extend(new_suggestions)
76 if not suggestions:
77 # If no suggestions generated, return empty
78 return {
79 'suggestions': [],
80 'refreshed_at': timezone.now().isoformat(),
81 }
83 return _format_suggestions(suggestions)
86def _format_suggestions(suggestions) -> dict:
87 """Format suggestions for API response."""
88 result = []
90 for suggestion in suggestions:
91 result.append({
92 'type': suggestion.suggestion_type,
93 'title': suggestion.title,
94 'description': suggestion.description,
95 'search_query': suggestion.search_query,
96 })
98 # Get the most recent created_at for refreshed_at
99 if hasattr(suggestions, 'first'):
100 # QuerySet
101 first = suggestions.first()
102 refreshed_at = first.created_at.isoformat() if first else timezone.now().isoformat()
103 else:
104 # List
105 refreshed_at = suggestions[0].created_at.isoformat() if suggestions else timezone.now().isoformat()
107 return {
108 'suggestions': result,
109 'refreshed_at': refreshed_at,
110 }
113def _generate_seasonal_suggestions(profile: Profile) -> List[AIDiscoverySuggestion]:
114 """Generate seasonal/holiday suggestions."""
115 try:
116 prompt = AIPrompt.get_prompt('discover_seasonal')
117 except AIPrompt.DoesNotExist:
118 logger.warning('discover_seasonal prompt not found')
119 return []
121 # Get current date info
122 now = datetime.now()
123 date_str = now.strftime('%B %d, %Y') # e.g., "January 15, 2024"
124 season = _get_season(now)
126 user_prompt = prompt.format_user_prompt(
127 date=date_str,
128 season=season,
129 )
131 try:
132 service = OpenRouterService()
133 response = service.complete(
134 system_prompt=prompt.system_prompt,
135 user_prompt=user_prompt,
136 model=prompt.model,
137 json_response=True,
138 )
140 validator = AIResponseValidator()
141 validated = validator.validate('discover_seasonal', response)
143 # Create and save suggestions
144 suggestions = []
145 for item in validated:
146 suggestion = AIDiscoverySuggestion.objects.create(
147 profile=profile,
148 suggestion_type='seasonal',
149 search_query=item['search_query'],
150 title=item['title'],
151 description=item['description'],
152 )
153 suggestions.append(suggestion)
155 logger.info(f'Generated {len(suggestions)} seasonal suggestions for profile {profile.id}')
156 return suggestions
158 except (AIUnavailableError, AIResponseError, ValidationError) as e:
159 logger.warning(f'Failed to generate seasonal suggestions: {e}')
160 return []
163def _generate_recommended_suggestions(profile: Profile) -> List[AIDiscoverySuggestion]:
164 """Generate suggestions based on user's viewing history."""
165 try:
166 prompt = AIPrompt.get_prompt('discover_favorites')
167 except AIPrompt.DoesNotExist:
168 logger.warning('discover_favorites prompt not found')
169 return []
171 # Get user's recently viewed recipes
172 history = RecipeViewHistory.objects.filter(profile=profile).select_related('recipe').order_by('-viewed_at')[:15]
173 if not history:
174 return []
176 # Format history list
177 history_list = '\n'.join(
178 f'- {item.recipe.title} ({item.recipe.cuisine or item.recipe.category or "uncategorized"})'
179 for item in history
180 )
182 user_prompt = prompt.format_user_prompt(favorites=history_list)
184 try:
185 service = OpenRouterService()
186 response = service.complete(
187 system_prompt=prompt.system_prompt,
188 user_prompt=user_prompt,
189 model=prompt.model,
190 json_response=True,
191 )
193 validator = AIResponseValidator()
194 validated = validator.validate('discover_favorites', response)
196 # Create and save suggestions
197 suggestions = []
198 for item in validated:
199 suggestion = AIDiscoverySuggestion.objects.create(
200 profile=profile,
201 suggestion_type='favorites', # Keep type for frontend compatibility
202 search_query=item['search_query'],
203 title=item['title'],
204 description=item['description'],
205 )
206 suggestions.append(suggestion)
208 logger.info(f'Generated {len(suggestions)} recommended suggestions for profile {profile.id}')
209 return suggestions
211 except (AIUnavailableError, AIResponseError, ValidationError) as e:
212 logger.warning(f'Failed to generate recommended suggestions: {e}')
213 return []
216def _generate_new_suggestions(profile: Profile) -> List[AIDiscoverySuggestion]:
217 """Generate suggestions for trying something new."""
218 try:
219 prompt = AIPrompt.get_prompt('discover_new')
220 except AIPrompt.DoesNotExist:
221 logger.warning('discover_new prompt not found')
222 return []
224 # Get user's recent recipes from history to understand their preferences
225 history = RecipeViewHistory.objects.filter(profile=profile).select_related('recipe').order_by('-viewed_at')[:15]
226 if not history:
227 return []
229 recent_recipes = '\n'.join(
230 f'- {item.recipe.title} ({item.recipe.cuisine or "unknown cuisine"}, {item.recipe.category or "unknown category"})'
231 for item in history
232 )
234 user_prompt = prompt.format_user_prompt(history=recent_recipes)
236 try:
237 service = OpenRouterService()
238 response = service.complete(
239 system_prompt=prompt.system_prompt,
240 user_prompt=user_prompt,
241 model=prompt.model,
242 json_response=True,
243 )
245 validator = AIResponseValidator()
246 validated = validator.validate('discover_new', response)
248 # Create and save suggestions
249 suggestions = []
250 for item in validated:
251 suggestion = AIDiscoverySuggestion.objects.create(
252 profile=profile,
253 suggestion_type='new',
254 search_query=item['search_query'],
255 title=item['title'],
256 description=item['description'],
257 )
258 suggestions.append(suggestion)
260 logger.info(f'Generated {len(suggestions)} try-new suggestions for profile {profile.id}')
261 return suggestions
263 except (AIUnavailableError, AIResponseError, ValidationError) as e:
264 logger.warning(f'Failed to generate try-new suggestions: {e}')
265 return []
268def _get_season(dt: datetime) -> str:
269 """Get the season for a given date (Northern Hemisphere)."""
270 month = dt.month
271 if month in (12, 1, 2):
272 return 'winter'
273 elif month in (3, 4, 5):
274 return 'spring'
275 elif month in (6, 7, 8):
276 return 'summer'
277 else:
278 return 'autumn'