Coverage for apps / ai / services / remix.py: 15%

101 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 00:40 +0000

1"""Recipe remix service using AI.""" 

2 

3import logging 

4import threading 

5from typing import Any 

6 

7from apps.recipes.models import Recipe 

8from apps.profiles.models import Profile 

9 

10from ..models import AIPrompt 

11from .openrouter import OpenRouterService, AIUnavailableError, AIResponseError 

12from .validator import AIResponseValidator, ValidationError 

13 

14logger = logging.getLogger(__name__) 

15 

16 

17def get_remix_suggestions(recipe_id: int) -> list[str]: 

18 """Get 6 AI-generated remix suggestions for a recipe. 

19 

20 Args: 

21 recipe_id: The ID of the recipe to get suggestions for. 

22 

23 Returns: 

24 List of 6 suggestion strings. 

25 

26 Raises: 

27 Recipe.DoesNotExist: If recipe not found. 

28 AIUnavailableError: If AI service is not available. 

29 AIResponseError: If AI returns invalid response. 

30 ValidationError: If response doesn't match expected schema. 

31 """ 

32 recipe = Recipe.objects.get(id=recipe_id) 

33 

34 # Get the remix_suggestions prompt 

35 prompt = AIPrompt.get_prompt('remix_suggestions') 

36 

37 # Format ingredients as a string 

38 ingredients_str = '\n'.join(f'- {ing}' for ing in recipe.ingredients) 

39 

40 # Format the user prompt 

41 user_prompt = prompt.format_user_prompt( 

42 title=recipe.title, 

43 cuisine=recipe.cuisine or 'Not specified', 

44 category=recipe.category or 'Not specified', 

45 ingredients=ingredients_str, 

46 ) 

47 

48 # Call AI service 

49 service = OpenRouterService() 

50 response = service.complete( 

51 system_prompt=prompt.system_prompt, 

52 user_prompt=user_prompt, 

53 model=prompt.model, 

54 json_response=True, 

55 ) 

56 

57 # Validate response 

58 validator = AIResponseValidator() 

59 validated = validator.validate('remix_suggestions', response) 

60 

61 return validated 

62 

63 

64def create_remix( 

65 recipe_id: int, 

66 modification: str, 

67 profile: Profile, 

68) -> Recipe: 

69 """Create a remixed recipe using AI. 

70 

71 Args: 

72 recipe_id: The ID of the original recipe to remix. 

73 modification: The user's requested modification. 

74 profile: The profile creating the remix. 

75 

76 Returns: 

77 The newly created Recipe object. 

78 

79 Raises: 

80 Recipe.DoesNotExist: If original recipe not found. 

81 AIUnavailableError: If AI service is not available. 

82 AIResponseError: If AI returns invalid response. 

83 ValidationError: If response doesn't match expected schema. 

84 """ 

85 original = Recipe.objects.get(id=recipe_id) 

86 

87 # Get the recipe_remix prompt 

88 prompt = AIPrompt.get_prompt('recipe_remix') 

89 

90 # Format ingredients and instructions 

91 ingredients_str = '\n'.join(f'- {ing}' for ing in original.ingredients) 

92 

93 if isinstance(original.instructions, list): 

94 # Handle structured instructions 

95 instructions_str = '\n'.join( 

96 f'{i+1}. {step.get("text", step) if isinstance(step, dict) else step}' 

97 for i, step in enumerate(original.instructions) 

98 ) 

99 else: 

100 instructions_str = original.instructions_text or str(original.instructions) 

101 

102 # Format the user prompt 

103 user_prompt = prompt.format_user_prompt( 

104 title=original.title, 

105 description=original.description or 'No description', 

106 ingredients=ingredients_str, 

107 instructions=instructions_str, 

108 modification=modification, 

109 ) 

110 

111 # Call AI service 

112 service = OpenRouterService() 

113 response = service.complete( 

114 system_prompt=prompt.system_prompt, 

115 user_prompt=user_prompt, 

116 model=prompt.model, 

117 json_response=True, 

118 ) 

119 

120 # Validate response 

121 validator = AIResponseValidator() 

122 validated = validator.validate('recipe_remix', response) 

123 

124 # Parse timing values if provided 

125 prep_time = _parse_time(validated.get('prep_time')) 

126 cook_time = _parse_time(validated.get('cook_time')) 

127 total_time = _parse_time(validated.get('total_time')) 

128 

129 # Parse yields to servings 

130 yields_str = validated.get('yields', '') 

131 servings = _parse_servings(yields_str) 

132 

133 # Create the remixed recipe 

134 remix = Recipe.objects.create( 

135 profile=profile, # Owner of the remix 

136 title=validated['title'], 

137 description=validated['description'], 

138 ingredients=validated['ingredients'], 

139 instructions=validated['instructions'], 

140 instructions_text='\n'.join(validated['instructions']), 

141 host='user-generated', 

142 site_name='User Generated', 

143 source_url=None, 

144 is_remix=True, 

145 remix_profile=profile, 

146 prep_time=prep_time, 

147 cook_time=cook_time, 

148 total_time=total_time, 

149 yields=yields_str, 

150 servings=servings, 

151 # Carry over some fields from original 

152 cuisine=original.cuisine, 

153 category=original.category, 

154 image_url=original.image_url, 

155 image=original.image, 

156 ) 

157 

158 logger.info(f'Created remix {remix.id} from recipe {original.id} for profile {profile.id}') 

159 

160 # Estimate nutrition if original has nutrition data 

161 if original.nutrition: 

162 try: 

163 nutrition = estimate_nutrition( 

164 original=original, 

165 new_ingredients=validated['ingredients'], 

166 new_servings=servings or 1, 

167 modification=modification, 

168 ) 

169 remix.nutrition = nutrition 

170 remix.save(update_fields=['nutrition']) 

171 logger.info(f'Added nutrition estimate to remix {remix.id}') 

172 except Exception as e: 

173 # Nutrition estimation is non-critical, log and continue 

174 logger.warning(f'Failed to estimate nutrition for remix {remix.id}: {e}') 

175 

176 # Fire-and-forget: Generate AI tips in background thread (non-blocking) 

177 thread = threading.Thread( 

178 target=_generate_tips_background, 

179 args=(remix.id,), 

180 daemon=True 

181 ) 

182 thread.start() 

183 

184 return remix 

185 

186 

187def estimate_nutrition( 

188 original: Recipe, 

189 new_ingredients: list[str], 

190 new_servings: int, 

191 modification: str, 

192) -> dict: 

193 """Estimate nutrition values for a remixed recipe. 

194 

195 Args: 

196 original: The original recipe with nutrition data. 

197 new_ingredients: List of ingredients for the remix. 

198 new_servings: Number of servings in the remix. 

199 modification: The modification description. 

200 

201 Returns: 

202 Dictionary of nutrition values in the same format as scraped nutrition. 

203 

204 Raises: 

205 AIUnavailableError: If AI service is not available. 

206 AIResponseError: If AI returns invalid response. 

207 ValidationError: If response doesn't match expected schema. 

208 """ 

209 # Get the nutrition_estimate prompt 

210 prompt = AIPrompt.get_prompt('nutrition_estimate') 

211 

212 # Format original nutrition as readable string 

213 nutrition_lines = [] 

214 for key, value in original.nutrition.items(): 

215 # Convert camelCase to readable format 

216 readable_key = key.replace('Content', '').replace('Fat', ' Fat') 

217 readable_key = ''.join( 

218 ' ' + c if c.isupper() else c for c in readable_key 

219 ).strip().title() 

220 nutrition_lines.append(f'- {readable_key}: {value}') 

221 original_nutrition_str = '\n'.join(nutrition_lines) if nutrition_lines else 'No nutrition data' 

222 

223 # Format ingredients 

224 original_ingredients_str = '\n'.join(f'- {ing}' for ing in original.ingredients) 

225 new_ingredients_str = '\n'.join(f'- {ing}' for ing in new_ingredients) 

226 

227 # Format the user prompt 

228 user_prompt = prompt.format_user_prompt( 

229 original_nutrition=original_nutrition_str, 

230 original_ingredients=original_ingredients_str, 

231 original_servings=original.servings or original.yields or 'unknown', 

232 new_ingredients=new_ingredients_str, 

233 new_servings=new_servings, 

234 modification=modification, 

235 ) 

236 

237 # Call AI service 

238 service = OpenRouterService() 

239 response = service.complete( 

240 system_prompt=prompt.system_prompt, 

241 user_prompt=user_prompt, 

242 model=prompt.model, 

243 json_response=True, 

244 ) 

245 

246 # Validate response 

247 validator = AIResponseValidator() 

248 validated = validator.validate('nutrition_estimate', response) 

249 

250 return validated 

251 

252 

253def _parse_time(time_str: str | None) -> int | None: 

254 """Parse a time string like '30 minutes' into minutes.""" 

255 if not time_str: 

256 return None 

257 

258 time_str = time_str.lower().strip() 

259 

260 # Try to extract numbers 

261 import re 

262 numbers = re.findall(r'\d+', time_str) 

263 if not numbers: 

264 return None 

265 

266 minutes = int(numbers[0]) 

267 

268 # Convert hours to minutes if needed 

269 if 'hour' in time_str: 

270 minutes *= 60 

271 if len(numbers) > 1: 

272 minutes += int(numbers[1]) 

273 

274 return minutes 

275 

276 

277def _parse_servings(yields_str: str) -> int | None: 

278 """Parse a yields string like '4 servings' into an integer.""" 

279 if not yields_str: 

280 return None 

281 

282 import re 

283 numbers = re.findall(r'\d+', yields_str) 

284 if numbers: 

285 return int(numbers[0]) 

286 

287 return None 

288 

289 

290def _generate_tips_background(recipe_id: int): 

291 """Generate AI tips for a remix recipe in background thread.""" 

292 try: 

293 import django 

294 django.setup() # Ensure Django is configured in thread 

295 

296 from apps.core.models import AppSettings 

297 from apps.ai.services.tips import generate_tips 

298 

299 # Check if AI is available 

300 settings_obj = AppSettings.get() 

301 if not settings_obj.openrouter_api_key: 

302 logger.debug(f'Skipping tips generation for remix {recipe_id}: No API key') 

303 return 

304 

305 # Generate tips 

306 generate_tips(recipe_id) 

307 logger.info(f'Auto-generated tips for remix {recipe_id}') 

308 

309 except Exception as e: 

310 # Log but don't fail - tips generation is optional 

311 logger.warning(f'Failed to auto-generate tips for remix {recipe_id}: {e}') 

← Back to Dashboard