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
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-12 10:49 +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 timeout=60,
121 )
123 # Validate response
124 validator = AIResponseValidator()
125 validated = validator.validate("recipe_remix", response)
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"))
132 # Parse yields to servings
133 yields_str = validated.get("yields", "")
134 servings = _parse_servings(yields_str)
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 )
162 logger.info(f"Created remix {remix.id} from recipe {original.id} for profile {profile.id}")
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}")
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()
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(" " + 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"
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)
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 )
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 )
244 # Validate response
245 validator = AIResponseValidator()
246 validated = validator.validate("nutrition_estimate", response)
248 return validated
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
256 time_str = time_str.lower().strip()
258 # Try to extract numbers
259 import re
261 numbers = re.findall(r"\d+", time_str)
262 if not numbers:
263 return None
265 minutes = int(numbers[0])
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])
273 return minutes
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
281 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
295 django.setup() # Ensure Django is configured in thread
297 from apps.core.models import AppSettings
298 from apps.ai.services.tips import generate_tips
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
306 # Generate tips
307 generate_tips(recipe_id)
308 logger.info(f"Auto-generated tips for remix {recipe_id}")
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}")