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
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-14 19:13 +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 .cache import cache_ai_response, CACHE_TIMEOUT_MEDIUM
12from .openrouter import OpenRouterService, AIUnavailableError, AIResponseError
13from .validator import AIResponseValidator, ValidationError
15logger = logging.getLogger(__name__)
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.
22 Args:
23 recipe_id: The ID of the recipe to get suggestions for.
25 Returns:
26 List of 6 suggestion strings.
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)
36 # Get the remix_suggestions prompt
37 prompt = AIPrompt.get_prompt("remix_suggestions")
39 # Format ingredients as a string
40 ingredients_str = "\n".join(f"- {ing}" for ing in recipe.ingredients)
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 )
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 )
59 # Validate response
60 validator = AIResponseValidator()
61 validated = validator.validate("remix_suggestions", response)
63 return validated
66def create_remix(
67 recipe_id: int,
68 modification: str,
69 profile: Profile,
70) -> Recipe:
71 """Create a remixed recipe using AI.
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.
78 Returns:
79 The newly created Recipe object.
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)
89 # Get the recipe_remix prompt
90 prompt = AIPrompt.get_prompt("recipe_remix")
92 # Format ingredients and instructions
93 ingredients_str = "\n".join(f"- {ing}" for ing in original.ingredients)
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)
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 )
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 )
122 # Validate response
123 validator = AIResponseValidator()
124 validated = validator.validate("recipe_remix", response)
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"))
131 # Parse yields to servings
132 yields_str = validated.get("yields", "")
133 servings = _parse_servings(yields_str)
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 )
161 logger.info(f"Created remix {remix.id} from recipe {original.id} for profile {profile.id}")
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}")
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()
183 return remix
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.
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.
200 Returns:
201 Dictionary of nutrition values in the same format as scraped nutrition.
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")
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"
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)
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 )
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 )
243 # Validate response
244 validator = AIResponseValidator()
245 validated = validator.validate("nutrition_estimate", response)
247 return validated
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
255 time_str = time_str.lower().strip()
257 # Try to extract numbers
258 import re
260 numbers = re.findall(r"\d+", time_str)
261 if not numbers:
262 return None
264 minutes = int(numbers[0])
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])
272 return minutes
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
280 import re
282 numbers = re.findall(r"\d+", yields_str)
283 if numbers:
284 return int(numbers[0])
286 return None
289def _generate_tips_background(recipe_id: int):
290 """Generate AI tips for a remix recipe in background thread."""
291 try:
292 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}")