Coverage for apps / ai / services / scaling.py: 15%
87 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"""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 scale_recipe(
57 recipe_id: int,
58 target_servings: int,
59 profile: Profile,
60 unit_system: str = "metric",
61) -> dict:
62 """Scale a recipe to a different number of servings.
64 Args:
65 recipe_id: The ID of the recipe to scale.
66 target_servings: The desired number of servings.
67 profile: The profile requesting the adjustment.
68 unit_system: 'metric' or 'imperial'.
70 Returns:
71 Dict with scaled ingredients, notes, and cache status.
73 Raises:
74 Recipe.DoesNotExist: If recipe not found.
75 ValueError: If recipe has no servings or target is invalid.
76 AIUnavailableError: If AI service is not available.
77 AIResponseError: If AI returns invalid response.
78 ValidationError: If response doesn't match expected schema.
79 """
80 recipe = Recipe.objects.get(id=recipe_id)
82 if not recipe.servings:
83 raise ValueError("Recipe does not have serving information")
85 if target_servings < 1:
86 raise ValueError("Target servings must be at least 1")
88 # Check for cached adjustment
89 try:
90 cached = ServingAdjustment.objects.get(
91 recipe=recipe,
92 profile=profile,
93 target_servings=target_servings,
94 unit_system=unit_system,
95 )
96 logger.info(f"Returning cached adjustment for recipe {recipe_id}")
97 return {
98 "target_servings": target_servings,
99 "original_servings": recipe.servings,
100 "ingredients": cached.ingredients,
101 "instructions": cached.instructions, # QA-031
102 "notes": cached.notes,
103 "prep_time_adjusted": cached.prep_time_adjusted, # QA-032
104 "cook_time_adjusted": cached.cook_time_adjusted, # QA-032
105 "total_time_adjusted": cached.total_time_adjusted, # QA-032
106 "cached": True,
107 }
108 except ServingAdjustment.DoesNotExist:
109 pass
111 # Get the serving_adjustment prompt
112 prompt = AIPrompt.get_prompt("serving_adjustment")
114 # Format ingredients as a string
115 ingredients_str = "\n".join(f"- {ing}" for ing in recipe.ingredients)
117 # Format instructions as a string (QA-031)
118 instructions = recipe.instructions or []
119 if not instructions and recipe.instructions_text:
120 instructions = [s.strip() for s in recipe.instructions_text.split("\n") if s.strip()]
121 instructions_str = "\n".join(f"{i + 1}. {step}" for i, step in enumerate(instructions))
122 if not instructions_str:
123 instructions_str = "No instructions available"
125 # Format the user prompt with new fields (QA-031 + QA-032)
126 user_prompt = prompt.format_user_prompt(
127 title=recipe.title,
128 original_servings=recipe.servings,
129 ingredients=ingredients_str,
130 instructions=instructions_str,
131 prep_time=_format_time(recipe.prep_time),
132 cook_time=_format_time(recipe.cook_time),
133 total_time=_format_time(recipe.total_time),
134 new_servings=target_servings,
135 )
137 # Call AI service
138 service = OpenRouterService()
139 response = service.complete(
140 system_prompt=prompt.system_prompt,
141 user_prompt=user_prompt,
142 model=prompt.model,
143 json_response=True,
144 )
146 # Validate response
147 validator = AIResponseValidator()
148 validated = validator.validate("serving_adjustment", response)
150 # Tidy ingredient quantities (convert decimals to fractions) - QA-029
151 ingredients = tidy_quantities(validated["ingredients"])
152 scaled_instructions = validated.get("instructions", []) # QA-031
153 notes = validated.get("notes", [])
155 # Parse time adjustments (QA-032)
156 prep_time_adjusted = _parse_time(validated.get("prep_time"))
157 cook_time_adjusted = _parse_time(validated.get("cook_time"))
158 total_time_adjusted = _parse_time(validated.get("total_time"))
160 # Cache the result
161 ServingAdjustment.objects.create(
162 recipe=recipe,
163 profile=profile,
164 target_servings=target_servings,
165 unit_system=unit_system,
166 ingredients=ingredients,
167 instructions=scaled_instructions, # QA-031
168 notes=notes,
169 prep_time_adjusted=prep_time_adjusted, # QA-032
170 cook_time_adjusted=cook_time_adjusted, # QA-032
171 total_time_adjusted=total_time_adjusted, # QA-032
172 )
174 logger.info(f"Created serving adjustment for recipe {recipe_id} to {target_servings} servings")
176 return {
177 "target_servings": target_servings,
178 "original_servings": recipe.servings,
179 "ingredients": ingredients,
180 "instructions": scaled_instructions, # QA-031
181 "notes": notes,
182 "prep_time_adjusted": prep_time_adjusted, # QA-032
183 "cook_time_adjusted": cook_time_adjusted, # QA-032
184 "total_time_adjusted": total_time_adjusted, # QA-032
185 "cached": False,
186 }
189def calculate_nutrition(
190 recipe: Recipe,
191 original_servings: int,
192 target_servings: int,
193) -> dict:
194 """Calculate scaled nutrition values.
196 Uses simple multiplication since nutrition is typically per-serving.
198 Args:
199 recipe: The recipe with nutrition data.
200 original_servings: Original number of servings.
201 target_servings: Target number of servings.
203 Returns:
204 Dict with per_serving and total nutrition values.
205 """
206 if not recipe.nutrition:
207 return {
208 "per_serving": {},
209 "total": {},
210 }
212 # Nutrition is per-serving, so per_serving stays the same
213 per_serving = recipe.nutrition.copy()
215 # Calculate total by multiplying by target servings
216 total = {}
217 for key, value in recipe.nutrition.items():
218 if isinstance(value, str):
219 # Try to extract numeric value and unit
220 import re
222 match = re.match(r"([\d.]+)\s*(.+)", value)
223 if match:
224 num = float(match.group(1))
225 unit = match.group(2)
226 total_num = num * target_servings
227 # Format nicely
228 if total_num == int(total_num):
229 total[key] = f"{int(total_num)} {unit}"
230 else:
231 total[key] = f"{total_num:.1f} {unit}"
232 else:
233 total[key] = value
234 elif isinstance(value, (int, float)):
235 total[key] = value * target_servings
236 else:
237 total[key] = value
239 return {
240 "per_serving": per_serving,
241 "total": total,
242 }