Coverage for apps / ai / services / scaling.py: 94%
97 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"""Serving adjustment (scaling) service using AI."""
3import logging
4import re
6from apps.recipes.models import Recipe, ServingAdjustment
7from apps.recipes.utils import tidy_quantities
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 _parse_time(time_str: str | None) -> int | None:
18 """Parse a time string like '30 minutes' into minutes.
20 Copied from remix.py for consistency.
21 """
22 if not time_str:
23 return None
25 time_str = time_str.lower().strip()
27 # Try to extract numbers
28 numbers = re.findall(r"\d+", time_str)
29 if not numbers:
30 return None
32 minutes = int(numbers[0])
34 # Convert hours to minutes if needed
35 if "hour" in time_str:
36 minutes *= 60
37 if len(numbers) > 1:
38 minutes += int(numbers[1])
40 return minutes
43def _format_time(minutes: int | None) -> str:
44 """Format minutes as a readable time string for the prompt."""
45 if not minutes:
46 return "Not specified"
47 if minutes >= 60:
48 hours = minutes // 60
49 mins = minutes % 60
50 if mins:
51 return f"{hours} hour{'s' if hours > 1 else ''} {mins} minutes"
52 return f"{hours} hour{'s' if hours > 1 else ''}"
53 return f"{minutes} minutes"
56def _build_result(recipe, target_servings, adjustment, cached: bool) -> dict:
57 """Build the standard result dict from a ServingAdjustment-like object."""
58 return {
59 "target_servings": target_servings,
60 "original_servings": recipe.servings,
61 "ingredients": adjustment["ingredients"],
62 "instructions": adjustment["instructions"],
63 "notes": adjustment["notes"],
64 "prep_time_adjusted": adjustment["prep_time_adjusted"],
65 "cook_time_adjusted": adjustment["cook_time_adjusted"],
66 "total_time_adjusted": adjustment["total_time_adjusted"],
67 "cached": cached,
68 }
71def _get_cached(recipe, profile, target_servings, unit_system) -> dict | None:
72 """Return cached adjustment as a dict, or None if not cached."""
73 try:
74 cached = ServingAdjustment.objects.get(
75 recipe=recipe,
76 profile=profile,
77 target_servings=target_servings,
78 unit_system=unit_system,
79 )
80 return {
81 "ingredients": cached.ingredients,
82 "instructions": cached.instructions,
83 "notes": cached.notes,
84 "prep_time_adjusted": cached.prep_time_adjusted,
85 "cook_time_adjusted": cached.cook_time_adjusted,
86 "total_time_adjusted": cached.total_time_adjusted,
87 }
88 except ServingAdjustment.DoesNotExist:
89 return None
92def _get_instructions_list(recipe) -> list[str]:
93 """Extract instructions as a list of steps from recipe fields (QA-031)."""
94 if recipe.instructions:
95 return recipe.instructions
96 if recipe.instructions_text:
97 return [s.strip() for s in recipe.instructions_text.split("\n") if s.strip()]
98 return []
101def _format_recipe_data(recipe) -> tuple[str, str]:
102 """Format recipe ingredients and instructions as prompt strings."""
103 ingredients_str = "\n".join(f"- {ing}" for ing in recipe.ingredients)
105 steps = _get_instructions_list(recipe)
106 instructions_str = "\n".join(f"{i + 1}. {step}" for i, step in enumerate(steps))
107 if not instructions_str:
108 instructions_str = "No instructions available"
110 return ingredients_str, instructions_str
113def _call_ai_and_validate(recipe, target_servings, ingredients_str, instructions_str) -> dict:
114 """Call AI service, validate response, and return parsed adjustment data."""
115 prompt = AIPrompt.get_prompt("serving_adjustment")
117 # Format the user prompt (QA-031 + QA-032)
118 user_prompt = prompt.format_user_prompt(
119 title=recipe.title,
120 original_servings=recipe.servings,
121 ingredients=ingredients_str,
122 instructions=instructions_str,
123 prep_time=_format_time(recipe.prep_time),
124 cook_time=_format_time(recipe.cook_time),
125 total_time=_format_time(recipe.total_time),
126 new_servings=target_servings,
127 )
129 service = OpenRouterService()
130 response = service.complete(
131 system_prompt=prompt.system_prompt,
132 user_prompt=user_prompt,
133 model=prompt.model,
134 json_response=True,
135 )
137 validator = AIResponseValidator()
138 validated = validator.validate("serving_adjustment", response)
140 # Tidy ingredient quantities (convert decimals to fractions) - QA-029
141 return {
142 "ingredients": tidy_quantities(validated["ingredients"]),
143 "instructions": validated.get("instructions", []),
144 "notes": validated.get("notes", []),
145 "prep_time_adjusted": _parse_time(validated.get("prep_time")),
146 "cook_time_adjusted": _parse_time(validated.get("cook_time")),
147 "total_time_adjusted": _parse_time(validated.get("total_time")),
148 }
151def scale_recipe(
152 recipe_id: int,
153 target_servings: int,
154 profile: Profile,
155 unit_system: str = "metric",
156) -> dict:
157 """Scale a recipe to a different number of servings.
159 Args:
160 recipe_id: The ID of the recipe to scale.
161 target_servings: The desired number of servings.
162 profile: The profile requesting the adjustment.
163 unit_system: 'metric' or 'imperial'.
165 Returns:
166 Dict with scaled ingredients, notes, and cache status.
168 Raises:
169 Recipe.DoesNotExist: If recipe not found.
170 ValueError: If recipe has no servings or target is invalid.
171 AIUnavailableError: If AI service is not available.
172 AIResponseError: If AI returns invalid response.
173 ValidationError: If response doesn't match expected schema.
174 """
175 recipe = Recipe.objects.get(id=recipe_id)
177 if not recipe.servings:
178 raise ValueError("Recipe does not have serving information")
180 if target_servings < 1:
181 raise ValueError("Target servings must be at least 1")
183 # Return cached result if available
184 cached = _get_cached(recipe, profile, target_servings, unit_system)
185 if cached is not None:
186 logger.info(f"Returning cached adjustment for recipe {recipe_id}")
187 return _build_result(recipe, target_servings, cached, cached=True)
189 # Generate new adjustment via AI
190 ingredients_str, instructions_str = _format_recipe_data(recipe)
191 adjustment = _call_ai_and_validate(recipe, target_servings, ingredients_str, instructions_str)
193 # Cache the result
194 ServingAdjustment.objects.create(
195 recipe=recipe,
196 profile=profile,
197 target_servings=target_servings,
198 unit_system=unit_system,
199 **adjustment,
200 )
202 logger.info(f"Created serving adjustment for recipe {recipe_id} to {target_servings} servings")
204 return _build_result(recipe, target_servings, adjustment, cached=False)
207def calculate_nutrition(
208 recipe: Recipe,
209 original_servings: int,
210 target_servings: int,
211) -> dict:
212 """Calculate scaled nutrition values.
214 Uses simple multiplication since nutrition is typically per-serving.
216 Args:
217 recipe: The recipe with nutrition data.
218 original_servings: Original number of servings.
219 target_servings: Target number of servings.
221 Returns:
222 Dict with per_serving and total nutrition values.
223 """
224 if not recipe.nutrition:
225 return {
226 "per_serving": {},
227 "total": {},
228 }
230 # Nutrition is per-serving, so per_serving stays the same
231 per_serving = recipe.nutrition.copy()
233 # Calculate total by multiplying by target servings
234 total = {}
235 for key, value in recipe.nutrition.items():
236 if isinstance(value, str):
237 # Try to extract numeric value and unit
238 import re
240 match = re.match(r"([\d.]+)\s*(.+)", value)
241 if match:
242 num = float(match.group(1))
243 unit = match.group(2)
244 total_num = num * target_servings
245 # Format nicely
246 if total_num == int(total_num):
247 total[key] = f"{int(total_num)} {unit}"
248 else:
249 total[key] = f"{total_num:.1f} {unit}"
250 else:
251 total[key] = value
252 elif isinstance(value, (int, float)):
253 total[key] = value * target_servings
254 else:
255 total[key] = value
257 return {
258 "per_serving": per_serving,
259 "total": total,
260 }