Coverage for apps / ai / services / discover.py: 87%
133 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-12 10:49 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-12 10:49 +0000
1"""AI discovery suggestions service."""
3import logging
4from concurrent.futures import ThreadPoolExecutor
5from datetime import datetime, timedelta
6from typing import List
8from django.utils import timezone
10from apps.profiles.models import Profile
11from apps.recipes.models import RecipeFavorite, RecipeViewHistory
13from ..models import AIPrompt, AIDiscoverySuggestion
14from .openrouter import OpenRouterService, AIUnavailableError, AIResponseError
15from .validator import AIResponseValidator, ValidationError
17logger = logging.getLogger(__name__)
19# Suggestions are cached for 24 hours
20CACHE_DURATION_HOURS = 24
23def get_discover_suggestions(profile_id: int, force_refresh: bool = False) -> dict:
24 """Get AI discovery suggestions for a profile.
26 Returns cached suggestions if still valid (within 24 hours),
27 otherwise generates new suggestions via AI.
29 For new users (no favorites), only seasonal suggestions are returned.
31 Args:
32 profile_id: The ID of the profile to get suggestions for.
33 force_refresh: If True, bypass cache and regenerate suggestions.
35 Returns:
36 Dict with suggestions array and refresh timestamp.
38 Raises:
39 Profile.DoesNotExist: If profile not found.
40 AIUnavailableError: If AI service is not available.
41 """
42 profile = Profile.objects.get(id=profile_id)
44 # Check for cached suggestions (within 24 hours) unless force refresh
45 if not force_refresh:
46 cache_cutoff = timezone.now() - timedelta(hours=CACHE_DURATION_HOURS)
47 cached = AIDiscoverySuggestion.objects.filter(profile=profile, created_at__gte=cache_cutoff)
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 — parallelize LLM calls to avoid sequential latency
60 suggestions = []
62 if has_history:
63 # Run all 3 suggestion types in parallel to reduce wall-clock time (~16s → ~5s)
64 with ThreadPoolExecutor(max_workers=3) as executor:
65 seasonal_future = executor.submit(_generate_in_thread, _generate_seasonal_suggestions, profile)
66 recommended_future = executor.submit(_generate_in_thread, _generate_recommended_suggestions, profile)
67 new_future = executor.submit(_generate_in_thread, _generate_new_suggestions, profile)
69 suggestions.extend(seasonal_future.result())
70 suggestions.extend(recommended_future.result())
71 suggestions.extend(new_future.result())
72 else:
73 # New user — only seasonal, no need for threads
74 suggestions.extend(_generate_seasonal_suggestions(profile))
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 {
93 "type": suggestion.suggestion_type,
94 "title": suggestion.title,
95 "description": suggestion.description,
96 "search_query": suggestion.search_query,
97 }
98 )
100 # Get the most recent created_at for refreshed_at
101 if hasattr(suggestions, "first"):
102 # QuerySet
103 first = suggestions.first()
104 refreshed_at = first.created_at.isoformat() if first else timezone.now().isoformat()
105 else:
106 # List
107 refreshed_at = suggestions[0].created_at.isoformat() if suggestions else timezone.now().isoformat()
109 return {
110 "suggestions": result,
111 "refreshed_at": refreshed_at,
112 }
115def _generate_in_thread(func, profile):
116 """Run a generation function in a thread with proper DB connection handling."""
117 from django.db import close_old_connections
119 close_old_connections()
120 try:
121 return func(profile)
122 finally:
123 close_old_connections()
126def _generate_seasonal_suggestions(profile: Profile) -> List[AIDiscoverySuggestion]:
127 """Generate seasonal/holiday suggestions."""
128 try:
129 prompt = AIPrompt.get_prompt("discover_seasonal")
130 except AIPrompt.DoesNotExist:
131 logger.warning("discover_seasonal prompt not found")
132 return []
134 # Get current date info
135 now = datetime.now()
136 date_str = now.strftime("%B %d, %Y") # e.g., "January 15, 2024"
137 season = _get_season(now)
139 user_prompt = prompt.format_user_prompt(
140 date=date_str,
141 season=season,
142 )
144 try:
145 service = OpenRouterService()
146 response = service.complete(
147 system_prompt=prompt.system_prompt,
148 user_prompt=user_prompt,
149 model=prompt.model,
150 json_response=True,
151 )
153 validator = AIResponseValidator()
154 validated = validator.validate("discover_seasonal", response)
156 # Create and save suggestions
157 suggestions = []
158 for item in validated:
159 suggestion = AIDiscoverySuggestion.objects.create(
160 profile=profile,
161 suggestion_type="seasonal",
162 search_query=item["search_query"],
163 title=item["title"],
164 description=item["description"],
165 )
166 suggestions.append(suggestion)
168 logger.info(f"Generated {len(suggestions)} seasonal suggestions for profile {profile.id}")
169 return suggestions
171 except (AIUnavailableError, AIResponseError, ValidationError) as e:
172 logger.warning(f"Failed to generate seasonal suggestions: {e}")
173 return []
176def _generate_recommended_suggestions(profile: Profile) -> List[AIDiscoverySuggestion]:
177 """Generate suggestions based on user's viewing history."""
178 try:
179 prompt = AIPrompt.get_prompt("discover_favorites")
180 except AIPrompt.DoesNotExist:
181 logger.warning("discover_favorites prompt not found")
182 return []
184 # Get user's recently viewed recipes
185 history = RecipeViewHistory.objects.filter(profile=profile).select_related("recipe").order_by("-viewed_at")[:15]
186 if not history:
187 return []
189 # Format history list
190 history_list = "\n".join(
191 f"- {item.recipe.title} ({item.recipe.cuisine or item.recipe.category or 'uncategorized'})" for item in history
192 )
194 user_prompt = prompt.format_user_prompt(favorites=history_list)
196 try:
197 service = OpenRouterService()
198 response = service.complete(
199 system_prompt=prompt.system_prompt,
200 user_prompt=user_prompt,
201 model=prompt.model,
202 json_response=True,
203 )
205 validator = AIResponseValidator()
206 validated = validator.validate("discover_favorites", response)
208 # Create and save suggestions
209 suggestions = []
210 for item in validated:
211 suggestion = AIDiscoverySuggestion.objects.create(
212 profile=profile,
213 suggestion_type="favorites", # Keep type for frontend compatibility
214 search_query=item["search_query"],
215 title=item["title"],
216 description=item["description"],
217 )
218 suggestions.append(suggestion)
220 logger.info(f"Generated {len(suggestions)} recommended suggestions for profile {profile.id}")
221 return suggestions
223 except (AIUnavailableError, AIResponseError, ValidationError) as e:
224 logger.warning(f"Failed to generate recommended suggestions: {e}")
225 return []
228def _generate_new_suggestions(profile: Profile) -> List[AIDiscoverySuggestion]:
229 """Generate suggestions for trying something new."""
230 try:
231 prompt = AIPrompt.get_prompt("discover_new")
232 except AIPrompt.DoesNotExist:
233 logger.warning("discover_new prompt not found")
234 return []
236 # Get user's recent recipes from history to understand their preferences
237 history = RecipeViewHistory.objects.filter(profile=profile).select_related("recipe").order_by("-viewed_at")[:15]
238 if not history:
239 return []
241 recent_recipes = "\n".join(
242 f"- {item.recipe.title} ({item.recipe.cuisine or 'unknown cuisine'}, {item.recipe.category or 'unknown category'})"
243 for item in history
244 )
246 user_prompt = prompt.format_user_prompt(history=recent_recipes)
248 try:
249 service = OpenRouterService()
250 response = service.complete(
251 system_prompt=prompt.system_prompt,
252 user_prompt=user_prompt,
253 model=prompt.model,
254 json_response=True,
255 )
257 validator = AIResponseValidator()
258 validated = validator.validate("discover_new", response)
260 # Create and save suggestions
261 suggestions = []
262 for item in validated:
263 suggestion = AIDiscoverySuggestion.objects.create(
264 profile=profile,
265 suggestion_type="new",
266 search_query=item["search_query"],
267 title=item["title"],
268 description=item["description"],
269 )
270 suggestions.append(suggestion)
272 logger.info(f"Generated {len(suggestions)} try-new suggestions for profile {profile.id}")
273 return suggestions
275 except (AIUnavailableError, AIResponseError, ValidationError) as e:
276 logger.warning(f"Failed to generate try-new suggestions: {e}")
277 return []
280def _get_season(dt: datetime) -> str:
281 """Get the season for a given date (Northern Hemisphere)."""
282 month = dt.month
283 if month in (12, 1, 2):
284 return "winter"
285 elif month in (3, 4, 5):
286 return "spring"
287 elif month in (6, 7, 8):
288 return "summer"
289 else:
290 return "autumn"