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

1"""Serving adjustment (scaling) service using AI.""" 

2 

3import logging 

4import re 

5 

6from apps.recipes.models import Recipe, ServingAdjustment 

7from apps.recipes.utils import tidy_quantities 

8from apps.profiles.models import Profile 

9 

10from ..models import AIPrompt 

11from .openrouter import OpenRouterService, AIUnavailableError, AIResponseError 

12from .validator import AIResponseValidator, ValidationError 

13 

14logger = logging.getLogger(__name__) 

15 

16 

17def _parse_time(time_str: str | None) -> int | None: 

18 """Parse a time string like '30 minutes' into minutes. 

19 

20 Copied from remix.py for consistency. 

21 """ 

22 if not time_str: 

23 return None 

24 

25 time_str = time_str.lower().strip() 

26 

27 # Try to extract numbers 

28 numbers = re.findall(r'\d+', time_str) 

29 if not numbers: 

30 return None 

31 

32 minutes = int(numbers[0]) 

33 

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]) 

39 

40 return minutes 

41 

42 

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' 

54 

55 

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. 

63 

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'. 

69 

70 Returns: 

71 Dict with scaled ingredients, notes, and cache status. 

72 

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) 

81 

82 if not recipe.servings: 

83 raise ValueError('Recipe does not have serving information') 

84 

85 if target_servings < 1: 

86 raise ValueError('Target servings must be at least 1') 

87 

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 

110 

111 # Get the serving_adjustment prompt 

112 prompt = AIPrompt.get_prompt('serving_adjustment') 

113 

114 # Format ingredients as a string 

115 ingredients_str = '\n'.join(f'- {ing}' for ing in recipe.ingredients) 

116 

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' 

124 

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 ) 

136 

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 ) 

145 

146 # Validate response 

147 validator = AIResponseValidator() 

148 validated = validator.validate('serving_adjustment', response) 

149 

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', []) 

154 

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')) 

159 

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 ) 

173 

174 logger.info(f'Created serving adjustment for recipe {recipe_id} to {target_servings} servings') 

175 

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 } 

187 

188 

189def calculate_nutrition( 

190 recipe: Recipe, 

191 original_servings: int, 

192 target_servings: int, 

193) -> dict: 

194 """Calculate scaled nutrition values. 

195 

196 Uses simple multiplication since nutrition is typically per-serving. 

197 

198 Args: 

199 recipe: The recipe with nutrition data. 

200 original_servings: Original number of servings. 

201 target_servings: Target number of servings. 

202 

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 } 

211 

212 # Nutrition is per-serving, so per_serving stays the same 

213 per_serving = recipe.nutrition.copy() 

214 

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 

237 

238 return { 

239 'per_serving': per_serving, 

240 'total': total, 

241 } 

← Back to Dashboard