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

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 _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 } 

69 

70 

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 

90 

91 

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

99 

100 

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) 

104 

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" 

109 

110 return ingredients_str, instructions_str 

111 

112 

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

116 

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 ) 

128 

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

132 

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 ) 

140 

141 validator = AIResponseValidator() 

142 validated = validator.validate("serving_adjustment", response) 

143 

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 } 

153 

154 

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. 

162 

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

168 

169 Returns: 

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

171 

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) 

180 

181 if not recipe.servings: 

182 raise ValueError("Recipe does not have serving information") 

183 

184 if target_servings < 1: 

185 raise ValueError("Target servings must be at least 1") 

186 

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) 

192 

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) 

196 

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 ) 

205 

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

207 

208 return _build_result(recipe, target_servings, adjustment, cached=False) 

209 

210 

211def calculate_nutrition( 

212 recipe: Recipe, 

213 original_servings: int, 

214 target_servings: int, 

215) -> dict: 

216 """Calculate scaled nutrition values. 

217 

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

219 

220 Args: 

221 recipe: The recipe with nutrition data. 

222 original_servings: Original number of servings. 

223 target_servings: Target number of servings. 

224 

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 } 

233 

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

235 per_serving = recipe.nutrition.copy() 

236 

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 

243 

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 

260 

261 return { 

262 "per_serving": per_serving, 

263 "total": total, 

264 } 

← Back to Dashboard