Coverage for apps / ai / services / scaling.py: 94%
99 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-02 13:22 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-02 13:22 +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, unit_system: str = "metric") -> 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 # Tell the AI which unit system to use for the scaled output
130 unit_label = "metric (grams, ml, °C)" if unit_system == "metric" else "imperial (oz, cups, °F)"
131 user_prompt += f"\n\nPlease express all quantities using {unit_label} units."
133 service = OpenRouterService()
134 response = service.complete(
135 system_prompt=prompt.system_prompt,
136 user_prompt=user_prompt,
137 model=prompt.model,
138 json_response=True,
139 )
141 validator = AIResponseValidator()
142 validated = validator.validate("serving_adjustment", response)
144 # Tidy ingredient quantities (convert decimals to fractions) - QA-029
145 return {
146 "ingredients": tidy_quantities(validated["ingredients"]),
147 "instructions": validated.get("instructions", []),
148 "notes": validated.get("notes", []),
149 "prep_time_adjusted": _parse_time(validated.get("prep_time")),
150 "cook_time_adjusted": _parse_time(validated.get("cook_time")),
151 "total_time_adjusted": _parse_time(validated.get("total_time")),
152 }
155def scale_recipe(
156 recipe_id: int,
157 target_servings: int,
158 profile: Profile,
159 unit_system: str = "metric",
160) -> dict:
161 """Scale a recipe to a different number of servings.
163 Args:
164 recipe_id: The ID of the recipe to scale.
165 target_servings: The desired number of servings.
166 profile: The profile requesting the adjustment.
167 unit_system: 'metric' or 'imperial'.
169 Returns:
170 Dict with scaled ingredients, notes, and cache status.
172 Raises:
173 Recipe.DoesNotExist: If recipe not found.
174 ValueError: If recipe has no servings or target is invalid.
175 AIUnavailableError: If AI service is not available.
176 AIResponseError: If AI returns invalid response.
177 ValidationError: If response doesn't match expected schema.
178 """
179 recipe = Recipe.objects.get(id=recipe_id)
181 if not recipe.servings:
182 raise ValueError("Recipe does not have serving information")
184 if target_servings < 1:
185 raise ValueError("Target servings must be at least 1")
187 # Return cached result if available
188 cached = _get_cached(recipe, profile, target_servings, unit_system)
189 if cached is not None:
190 logger.info(f"Returning cached adjustment for recipe {recipe_id}")
191 return _build_result(recipe, target_servings, cached, cached=True)
193 # Generate new adjustment via AI
194 ingredients_str, instructions_str = _format_recipe_data(recipe)
195 adjustment = _call_ai_and_validate(recipe, target_servings, ingredients_str, instructions_str, unit_system)
197 # Cache the result
198 ServingAdjustment.objects.create(
199 recipe=recipe,
200 profile=profile,
201 target_servings=target_servings,
202 unit_system=unit_system,
203 **adjustment,
204 )
206 logger.info(f"Created serving adjustment for recipe {recipe_id} to {target_servings} servings")
208 return _build_result(recipe, target_servings, adjustment, cached=False)
211def calculate_nutrition(
212 recipe: Recipe,
213 original_servings: int,
214 target_servings: int,
215) -> dict:
216 """Calculate scaled nutrition values.
218 Uses simple multiplication since nutrition is typically per-serving.
220 Args:
221 recipe: The recipe with nutrition data.
222 original_servings: Original number of servings.
223 target_servings: Target number of servings.
225 Returns:
226 Dict with per_serving and total nutrition values.
227 """
228 if not recipe.nutrition:
229 return {
230 "per_serving": {},
231 "total": {},
232 }
234 # Nutrition is per-serving, so per_serving stays the same
235 per_serving = recipe.nutrition.copy()
237 # Calculate total by multiplying by target servings
238 total = {}
239 for key, value in recipe.nutrition.items():
240 if isinstance(value, str):
241 # Try to extract numeric value and unit
242 import re
244 match = re.match(r"([\d.]+)\s*(.+)", value)
245 if match:
246 num = float(match.group(1))
247 unit = match.group(2)
248 total_num = num * target_servings
249 # Format nicely
250 if total_num == int(total_num):
251 total[key] = f"{int(total_num)} {unit}"
252 else:
253 total[key] = f"{total_num:.1f} {unit}"
254 else:
255 total[key] = value
256 elif isinstance(value, (int, float)):
257 total[key] = value * target_servings
258 else:
259 total[key] = value
261 return {
262 "per_serving": per_serving,
263 "total": total,
264 }