Coverage for apps / ai / services / scaling.py: 94%

97 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-12 10:49 +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) -> 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 service = OpenRouterService() 

130 response = service.complete( 

131 system_prompt=prompt.system_prompt, 

132 user_prompt=user_prompt, 

133 model=prompt.model, 

134 json_response=True, 

135 ) 

136 

137 validator = AIResponseValidator() 

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

139 

140 # Tidy ingredient quantities (convert decimals to fractions) - QA-029 

141 return { 

142 "ingredients": tidy_quantities(validated["ingredients"]), 

143 "instructions": validated.get("instructions", []), 

144 "notes": validated.get("notes", []), 

145 "prep_time_adjusted": _parse_time(validated.get("prep_time")), 

146 "cook_time_adjusted": _parse_time(validated.get("cook_time")), 

147 "total_time_adjusted": _parse_time(validated.get("total_time")), 

148 } 

149 

150 

151def scale_recipe( 

152 recipe_id: int, 

153 target_servings: int, 

154 profile: Profile, 

155 unit_system: str = "metric", 

156) -> dict: 

157 """Scale a recipe to a different number of servings. 

158 

159 Args: 

160 recipe_id: The ID of the recipe to scale. 

161 target_servings: The desired number of servings. 

162 profile: The profile requesting the adjustment. 

163 unit_system: 'metric' or 'imperial'. 

164 

165 Returns: 

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

167 

168 Raises: 

169 Recipe.DoesNotExist: If recipe not found. 

170 ValueError: If recipe has no servings or target is invalid. 

171 AIUnavailableError: If AI service is not available. 

172 AIResponseError: If AI returns invalid response. 

173 ValidationError: If response doesn't match expected schema. 

174 """ 

175 recipe = Recipe.objects.get(id=recipe_id) 

176 

177 if not recipe.servings: 

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

179 

180 if target_servings < 1: 

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

182 

183 # Return cached result if available 

184 cached = _get_cached(recipe, profile, target_servings, unit_system) 

185 if cached is not None: 

186 logger.info(f"Returning cached adjustment for recipe {recipe_id}") 

187 return _build_result(recipe, target_servings, cached, cached=True) 

188 

189 # Generate new adjustment via AI 

190 ingredients_str, instructions_str = _format_recipe_data(recipe) 

191 adjustment = _call_ai_and_validate(recipe, target_servings, ingredients_str, instructions_str) 

192 

193 # Cache the result 

194 ServingAdjustment.objects.create( 

195 recipe=recipe, 

196 profile=profile, 

197 target_servings=target_servings, 

198 unit_system=unit_system, 

199 **adjustment, 

200 ) 

201 

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

203 

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

205 

206 

207def calculate_nutrition( 

208 recipe: Recipe, 

209 original_servings: int, 

210 target_servings: int, 

211) -> dict: 

212 """Calculate scaled nutrition values. 

213 

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

215 

216 Args: 

217 recipe: The recipe with nutrition data. 

218 original_servings: Original number of servings. 

219 target_servings: Target number of servings. 

220 

221 Returns: 

222 Dict with per_serving and total nutrition values. 

223 """ 

224 if not recipe.nutrition: 

225 return { 

226 "per_serving": {}, 

227 "total": {}, 

228 } 

229 

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

231 per_serving = recipe.nutrition.copy() 

232 

233 # Calculate total by multiplying by target servings 

234 total = {} 

235 for key, value in recipe.nutrition.items(): 

236 if isinstance(value, str): 

237 # Try to extract numeric value and unit 

238 import re 

239 

240 match = re.match(r"([\d.]+)\s*(.+)", value) 

241 if match: 

242 num = float(match.group(1)) 

243 unit = match.group(2) 

244 total_num = num * target_servings 

245 # Format nicely 

246 if total_num == int(total_num): 

247 total[key] = f"{int(total_num)} {unit}" 

248 else: 

249 total[key] = f"{total_num:.1f} {unit}" 

250 else: 

251 total[key] = value 

252 elif isinstance(value, (int, float)): 

253 total[key] = value * target_servings 

254 else: 

255 total[key] = value 

256 

257 return { 

258 "per_serving": per_serving, 

259 "total": total, 

260 } 

← Back to Dashboard