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

103 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-12 10:49 +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 timeout=60, 

121 ) 

122 

123 # Validate response 

124 validator = AIResponseValidator() 

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

126 

127 # Parse timing values if provided 

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

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

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

131 

132 # Parse yields to servings 

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

134 servings = _parse_servings(yields_str) 

135 

136 # Create the remixed recipe 

137 remix = Recipe.objects.create( 

138 profile=profile, # Owner of the remix 

139 title=validated["title"], 

140 description=validated["description"], 

141 ingredients=validated["ingredients"], 

142 instructions=validated["instructions"], 

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

144 host="user-generated", 

145 site_name="User Generated", 

146 source_url=None, 

147 is_remix=True, 

148 remix_profile=profile, 

149 remixed_from=original, # Link to original recipe 

150 prep_time=prep_time, 

151 cook_time=cook_time, 

152 total_time=total_time, 

153 yields=yields_str, 

154 servings=servings, 

155 # Carry over some fields from original 

156 cuisine=original.cuisine, 

157 category=original.category, 

158 image_url=original.image_url, 

159 image=original.image, 

160 ) 

161 

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

163 

164 # Estimate nutrition if original has nutrition data 

165 if original.nutrition: 

166 try: 

167 nutrition = estimate_nutrition( 

168 original=original, 

169 new_ingredients=validated["ingredients"], 

170 new_servings=servings or 1, 

171 modification=modification, 

172 ) 

173 remix.nutrition = nutrition 

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

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

176 except Exception as e: 

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

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

179 

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

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

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(" " + c if c.isupper() else c for c in readable_key).strip().title() 

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

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

220 

221 # Format ingredients 

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

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

224 

225 # Format the user prompt 

226 user_prompt = prompt.format_user_prompt( 

227 original_nutrition=original_nutrition_str, 

228 original_ingredients=original_ingredients_str, 

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

230 new_ingredients=new_ingredients_str, 

231 new_servings=new_servings, 

232 modification=modification, 

233 ) 

234 

235 # Call AI service 

236 service = OpenRouterService() 

237 response = service.complete( 

238 system_prompt=prompt.system_prompt, 

239 user_prompt=user_prompt, 

240 model=prompt.model, 

241 json_response=True, 

242 ) 

243 

244 # Validate response 

245 validator = AIResponseValidator() 

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

247 

248 return validated 

249 

250 

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

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

253 if not time_str: 

254 return None 

255 

256 time_str = time_str.lower().strip() 

257 

258 # Try to extract numbers 

259 import re 

260 

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

262 if not numbers: 

263 return None 

264 

265 minutes = int(numbers[0]) 

266 

267 # Convert hours to minutes if needed 

268 if "hour" in time_str: 

269 minutes *= 60 

270 if len(numbers) > 1: 

271 minutes += int(numbers[1]) 

272 

273 return minutes 

274 

275 

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

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

278 if not yields_str: 

279 return None 

280 

281 import re 

282 

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 

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

296 

297 from apps.core.models import AppSettings 

298 from apps.ai.services.tips import generate_tips 

299 

300 # Check if AI is available 

301 settings_obj = AppSettings.get() 

302 if not settings_obj.openrouter_api_key: 

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

304 return 

305 

306 # Generate tips 

307 generate_tips(recipe_id) 

308 logger.info(f"Auto-generated tips for remix {recipe_id}") 

309 

310 except Exception as e: 

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

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

← Back to Dashboard