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

1"""AI discovery suggestions service.""" 

2 

3import logging 

4from concurrent.futures import ThreadPoolExecutor 

5from datetime import datetime, timedelta 

6from typing import List 

7 

8from django.utils import timezone 

9 

10from apps.profiles.models import Profile 

11from apps.recipes.models import RecipeFavorite, RecipeViewHistory 

12 

13from ..models import AIPrompt, AIDiscoverySuggestion 

14from .openrouter import OpenRouterService, AIUnavailableError, AIResponseError 

15from .validator import AIResponseValidator, ValidationError 

16 

17logger = logging.getLogger(__name__) 

18 

19# Suggestions are cached for 24 hours 

20CACHE_DURATION_HOURS = 24 

21 

22 

23def get_discover_suggestions(profile_id: int, force_refresh: bool = False) -> dict: 

24 """Get AI discovery suggestions for a profile. 

25 

26 Returns cached suggestions if still valid (within 24 hours), 

27 otherwise generates new suggestions via AI. 

28 

29 For new users (no favorites), only seasonal suggestions are returned. 

30 

31 Args: 

32 profile_id: The ID of the profile to get suggestions for. 

33 force_refresh: If True, bypass cache and regenerate suggestions. 

34 

35 Returns: 

36 Dict with suggestions array and refresh timestamp. 

37 

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) 

43 

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) 

48 

49 if cached.exists(): 

50 logger.info(f"Returning cached discover suggestions for profile {profile_id}") 

51 return _format_suggestions(cached) 

52 

53 # Clear old suggestions 

54 AIDiscoverySuggestion.objects.filter(profile=profile).delete() 

55 

56 # Check if user has viewing history 

57 has_history = RecipeViewHistory.objects.filter(profile=profile).exists() 

58 

59 # Generate new suggestions — parallelize LLM calls to avoid sequential latency 

60 suggestions = [] 

61 

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) 

68 

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)) 

75 

76 if not suggestions: 

77 # If no suggestions generated, return empty 

78 return { 

79 "suggestions": [], 

80 "refreshed_at": timezone.now().isoformat(), 

81 } 

82 

83 return _format_suggestions(suggestions) 

84 

85 

86def _format_suggestions(suggestions) -> dict: 

87 """Format suggestions for API response.""" 

88 result = [] 

89 

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 ) 

99 

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() 

108 

109 return { 

110 "suggestions": result, 

111 "refreshed_at": refreshed_at, 

112 } 

113 

114 

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 

118 

119 close_old_connections() 

120 try: 

121 return func(profile) 

122 finally: 

123 close_old_connections() 

124 

125 

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 [] 

133 

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) 

138 

139 user_prompt = prompt.format_user_prompt( 

140 date=date_str, 

141 season=season, 

142 ) 

143 

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 ) 

152 

153 validator = AIResponseValidator() 

154 validated = validator.validate("discover_seasonal", response) 

155 

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) 

167 

168 logger.info(f"Generated {len(suggestions)} seasonal suggestions for profile {profile.id}") 

169 return suggestions 

170 

171 except (AIUnavailableError, AIResponseError, ValidationError) as e: 

172 logger.warning(f"Failed to generate seasonal suggestions: {e}") 

173 return [] 

174 

175 

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 [] 

183 

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 [] 

188 

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 ) 

193 

194 user_prompt = prompt.format_user_prompt(favorites=history_list) 

195 

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 ) 

204 

205 validator = AIResponseValidator() 

206 validated = validator.validate("discover_favorites", response) 

207 

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) 

219 

220 logger.info(f"Generated {len(suggestions)} recommended suggestions for profile {profile.id}") 

221 return suggestions 

222 

223 except (AIUnavailableError, AIResponseError, ValidationError) as e: 

224 logger.warning(f"Failed to generate recommended suggestions: {e}") 

225 return [] 

226 

227 

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 [] 

235 

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 [] 

240 

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 ) 

245 

246 user_prompt = prompt.format_user_prompt(history=recent_recipes) 

247 

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 ) 

256 

257 validator = AIResponseValidator() 

258 validated = validator.validate("discover_new", response) 

259 

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) 

271 

272 logger.info(f"Generated {len(suggestions)} try-new suggestions for profile {profile.id}") 

273 return suggestions 

274 

275 except (AIUnavailableError, AIResponseError, ValidationError) as e: 

276 logger.warning(f"Failed to generate try-new suggestions: {e}") 

277 return [] 

278 

279 

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" 

← Back to Dashboard