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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 00:40 +0000
1"""Recipe remix service using AI."""
3import logging
4import threading
5from typing import Any
7from apps.recipes.models import Recipe
8from apps.profiles.models import Profile
10from ..models import AIPrompt
11from .openrouter import OpenRouterService, AIUnavailableError, AIResponseError
12from .validator import AIResponseValidator, ValidationError
14logger = logging.getLogger(__name__)
17def get_remix_suggestions(recipe_id: int) -> list[str]:
18 """Get 6 AI-generated remix suggestions for a recipe.
20 Args:
21 recipe_id: The ID of the recipe to get suggestions for.
23 Returns:
24 List of 6 suggestion strings.
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)
34 # Get the remix_suggestions prompt
35 prompt = AIPrompt.get_prompt('remix_suggestions')
37 # Format ingredients as a string
38 ingredients_str = '\n'.join(f'- {ing}' for ing in recipe.ingredients)
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 )
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 )
57 # Validate response
58 validator = AIResponseValidator()
59 validated = validator.validate('remix_suggestions', response)
61 return validated
64def create_remix(
65 recipe_id: int,
66 modification: str,
67 profile: Profile,
68) -> Recipe:
69 """Create a remixed recipe using AI.
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.
76 Returns:
77 The newly created Recipe object.
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)
87 # Get the recipe_remix prompt
88 prompt = AIPrompt.get_prompt('recipe_remix')
90 # Format ingredients and instructions
91 ingredients_str = '\n'.join(f'- {ing}' for ing in original.ingredients)
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)
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 )
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 )
120 # Validate response
121 validator = AIResponseValidator()
122 validated = validator.validate('recipe_remix', response)
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'))
129 # Parse yields to servings
130 yields_str = validated.get('yields', '')
131 servings = _parse_servings(yields_str)
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 )
158 logger.info(f'Created remix {remix.id} from recipe {original.id} for profile {profile.id}')
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}')
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()
184 return remix
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.
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.
201 Returns:
202 Dictionary of nutrition values in the same format as scraped nutrition.
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')
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'
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)
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 )
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 )
246 # Validate response
247 validator = AIResponseValidator()
248 validated = validator.validate('nutrition_estimate', response)
250 return validated
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
258 time_str = time_str.lower().strip()
260 # Try to extract numbers
261 import re
262 numbers = re.findall(r'\d+', time_str)
263 if not numbers:
264 return None
266 minutes = int(numbers[0])
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])
274 return minutes
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
282 import re
283 numbers = re.findall(r'\d+', yields_str)
284 if numbers:
285 return int(numbers[0])
287 return None
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
296 from apps.core.models import AppSettings
297 from apps.ai.services.tips import generate_tips
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
305 # Generate tips
306 generate_tips(recipe_id)
307 logger.info(f'Auto-generated tips for remix {recipe_id}')
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}')