Coverage for apps / ai / services / scaling.py: 15%
87 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 00:40 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 00:40 +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
221 match = re.match(r'([\d.]+)\s*(.+)', value)
222 if match:
223 num = float(match.group(1))
224 unit = match.group(2)
225 total_num = num * target_servings
226 # Format nicely
227 if total_num == int(total_num):
228 total[key] = f'{int(total_num)} {unit}'
229 else:
230 total[key] = f'{total_num:.1f} {unit}'
231 else:
232 total[key] = value
233 elif isinstance(value, (int, float)):
234 total[key] = value * target_servings
235 else:
236 total[key] = value
238 return {
239 'per_serving': per_serving,
240 'total': total,
241 }