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

103 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-14 19:13 +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 .cache import cache_ai_response, CACHE_TIMEOUT_MEDIUM 

12from .openrouter import OpenRouterService, AIUnavailableError, AIResponseError 

13from .validator import AIResponseValidator, ValidationError 

14 

15logger = logging.getLogger(__name__) 

16 

17 

18@cache_ai_response("remix_suggestions", timeout=CACHE_TIMEOUT_MEDIUM) 

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

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

21 

22 Args: 

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

24 

25 Returns: 

26 List of 6 suggestion strings. 

27 

28 Raises: 

29 Recipe.DoesNotExist: If recipe not found. 

30 AIUnavailableError: If AI service is not available. 

31 AIResponseError: If AI returns invalid response. 

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

33 """ 

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

35 

36 # Get the remix_suggestions prompt 

37 prompt = AIPrompt.get_prompt("remix_suggestions") 

38 

39 # Format ingredients as a string 

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

41 

42 # Format the user prompt 

43 user_prompt = prompt.format_user_prompt( 

44 title=recipe.title, 

45 cuisine=recipe.cuisine or "Not specified", 

46 category=recipe.category or "Not specified", 

47 ingredients=ingredients_str, 

48 ) 

49 

50 # Call AI service 

51 service = OpenRouterService() 

52 response = service.complete( 

53 system_prompt=prompt.system_prompt, 

54 user_prompt=user_prompt, 

55 model=prompt.model, 

56 json_response=True, 

57 ) 

58 

59 # Validate response 

60 validator = AIResponseValidator() 

61 validated = validator.validate("remix_suggestions", response) 

62 

63 return validated 

64 

65 

66def create_remix( 

67 recipe_id: int, 

68 modification: str, 

69 profile: Profile, 

70) -> Recipe: 

71 """Create a remixed recipe using AI. 

72 

73 Args: 

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

75 modification: The user's requested modification. 

76 profile: The profile creating the remix. 

77 

78 Returns: 

79 The newly created Recipe object. 

80 

81 Raises: 

82 Recipe.DoesNotExist: If original recipe not found. 

83 AIUnavailableError: If AI service is not available. 

84 AIResponseError: If AI returns invalid response. 

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

86 """ 

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

88 

89 # Get the recipe_remix prompt 

90 prompt = AIPrompt.get_prompt("recipe_remix") 

91 

92 # Format ingredients and instructions 

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

94 

95 if isinstance(original.instructions, list): 

96 # Handle structured instructions 

97 instructions_str = "\n".join( 

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

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

100 ) 

101 else: 

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

103 

104 # Format the user prompt 

105 user_prompt = prompt.format_user_prompt( 

106 title=original.title, 

107 description=original.description or "No description", 

108 ingredients=ingredients_str, 

109 instructions=instructions_str, 

110 modification=modification, 

111 ) 

112 

113 # Call AI service 

114 service = OpenRouterService() 

115 response = service.complete( 

116 system_prompt=prompt.system_prompt, 

117 user_prompt=user_prompt, 

118 model=prompt.model, 

119 json_response=True, 

120 ) 

121 

122 # Validate response 

123 validator = AIResponseValidator() 

124 validated = validator.validate("recipe_remix", response) 

125 

126 # Parse timing values if provided 

127 prep_time = _parse_time(validated.get("prep_time")) 

128 cook_time = _parse_time(validated.get("cook_time")) 

129 total_time = _parse_time(validated.get("total_time")) 

130 

131 # Parse yields to servings 

132 yields_str = validated.get("yields", "") 

133 servings = _parse_servings(yields_str) 

134 

135 # Create the remixed recipe 

136 remix = Recipe.objects.create( 

137 profile=profile, # Owner of the remix 

138 title=validated["title"], 

139 description=validated["description"], 

140 ingredients=validated["ingredients"], 

141 instructions=validated["instructions"], 

142 instructions_text="\n".join(validated["instructions"]), 

143 host="user-generated", 

144 site_name="User Generated", 

145 source_url=None, 

146 is_remix=True, 

147 remix_profile=profile, 

148 remixed_from=original, # Link to original recipe 

149 prep_time=prep_time, 

150 cook_time=cook_time, 

151 total_time=total_time, 

152 yields=yields_str, 

153 servings=servings, 

154 # Carry over some fields from original 

155 cuisine=original.cuisine, 

156 category=original.category, 

157 image_url=original.image_url, 

158 image=original.image, 

159 ) 

160 

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

162 

163 # Estimate nutrition if original has nutrition data 

164 if original.nutrition: 

165 try: 

166 nutrition = estimate_nutrition( 

167 original=original, 

168 new_ingredients=validated["ingredients"], 

169 new_servings=servings or 1, 

170 modification=modification, 

171 ) 

172 remix.nutrition = nutrition 

173 remix.save(update_fields=["nutrition"]) 

174 logger.info(f"Added nutrition estimate to remix {remix.id}") 

175 except Exception as e: 

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

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

178 

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

180 thread = threading.Thread(target=_generate_tips_background, args=(remix.id,), daemon=True) 

181 thread.start() 

182 

183 return remix 

184 

185 

186def estimate_nutrition( 

187 original: Recipe, 

188 new_ingredients: list[str], 

189 new_servings: int, 

190 modification: str, 

191) -> dict: 

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

193 

194 Args: 

195 original: The original recipe with nutrition data. 

196 new_ingredients: List of ingredients for the remix. 

197 new_servings: Number of servings in the remix. 

198 modification: The modification description. 

199 

200 Returns: 

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

202 

203 Raises: 

204 AIUnavailableError: If AI service is not available. 

205 AIResponseError: If AI returns invalid response. 

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

207 """ 

208 # Get the nutrition_estimate prompt 

209 prompt = AIPrompt.get_prompt("nutrition_estimate") 

210 

211 # Format original nutrition as readable string 

212 nutrition_lines = [] 

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

214 # Convert camelCase to readable format 

215 readable_key = key.replace("Content", "").replace("Fat", " Fat") 

216 readable_key = "".join(" " + c if c.isupper() else c for c in readable_key).strip().title() 

217 nutrition_lines.append(f"- {readable_key}: {value}") 

218 original_nutrition_str = "\n".join(nutrition_lines) if nutrition_lines else "No nutrition data" 

219 

220 # Format ingredients 

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

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

223 

224 # Format the user prompt 

225 user_prompt = prompt.format_user_prompt( 

226 original_nutrition=original_nutrition_str, 

227 original_ingredients=original_ingredients_str, 

228 original_servings=original.servings or original.yields or "unknown", 

229 new_ingredients=new_ingredients_str, 

230 new_servings=new_servings, 

231 modification=modification, 

232 ) 

233 

234 # Call AI service 

235 service = OpenRouterService() 

236 response = service.complete( 

237 system_prompt=prompt.system_prompt, 

238 user_prompt=user_prompt, 

239 model=prompt.model, 

240 json_response=True, 

241 ) 

242 

243 # Validate response 

244 validator = AIResponseValidator() 

245 validated = validator.validate("nutrition_estimate", response) 

246 

247 return validated 

248 

249 

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

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

252 if not time_str: 

253 return None 

254 

255 time_str = time_str.lower().strip() 

256 

257 # Try to extract numbers 

258 import re 

259 

260 numbers = re.findall(r"\d+", time_str) 

261 if not numbers: 

262 return None 

263 

264 minutes = int(numbers[0]) 

265 

266 # Convert hours to minutes if needed 

267 if "hour" in time_str: 

268 minutes *= 60 

269 if len(numbers) > 1: 

270 minutes += int(numbers[1]) 

271 

272 return minutes 

273 

274 

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

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

277 if not yields_str: 

278 return None 

279 

280 import re 

281 

282 numbers = re.findall(r"\d+", yields_str) 

283 if numbers: 

284 return int(numbers[0]) 

285 

286 return None 

287 

288 

289def _generate_tips_background(recipe_id: int): 

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

291 try: 

292 import django 

293 

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