diff --git a/MealPlan.Api/DTOs/Recipe/RecalculateRecipeCaloriesDto.cs b/MealPlan.Api/DTOs/Recipe/RecalculateRecipeCaloriesDto.cs new file mode 100644 index 0000000..e4ed9c9 --- /dev/null +++ b/MealPlan.Api/DTOs/Recipe/RecalculateRecipeCaloriesDto.cs @@ -0,0 +1,25 @@ +namespace MealPlan.Api.DTOs.Recipe; + +public class RecalculateRecipeCaloriesRequestDto +{ + public int TargetCalories { get; set; } + public int CalorieToleranceKcal { get; set; } = 10; + public bool Apply { get; set; } +} + +public class RecalculateRecipeCaloriesResultDto +{ + public int RecipeId { get; set; } + public int? PreviousCalories { get; set; } + public int TargetCalories { get; set; } + public int? NewCalories { get; set; } + public decimal? NewProtein { get; set; } + public decimal? NewFat { get; set; } + public decimal? NewCarbs { get; set; } + public bool Applied { get; set; } + /// catalog | ai | catalog+ai + public string Method { get; set; } = string.Empty; + public int UnlinkedIngredientCount { get; set; } + public List Ingredients { get; set; } = new(); + public RecipeDetailDto? Recipe { get; set; } +} diff --git a/MealPlan.Api/Services/RecipeCalorieRecalculationService.cs b/MealPlan.Api/Services/RecipeCalorieRecalculationService.cs new file mode 100644 index 0000000..cd47817 --- /dev/null +++ b/MealPlan.Api/Services/RecipeCalorieRecalculationService.cs @@ -0,0 +1,250 @@ +using System.Text.Json; +using MealPlan.Api.Data; +using MealPlan.Api.DTOs.Recipe; +using MealPlan.Api.Models; +using MealPlan.Api.Services.AI; +using MealPlan.Api.Services.Generators; +using Microsoft.EntityFrameworkCore; + +namespace MealPlan.Api.Services; + +public interface IRecipeCalorieRecalculationService +{ + Task RecalculateAsync( + int recipeId, + RecalculateRecipeCaloriesRequestDto request, + CancellationToken ct = default); +} + +public class RecipeCalorieRecalculationService : IRecipeCalorieRecalculationService +{ + private readonly AppDbContext _db; + private readonly IOpenAiChatService _openAi; + private readonly IRecipeManagementService _recipeManagement; + + public RecipeCalorieRecalculationService( + AppDbContext db, + IOpenAiChatService openAi, + IRecipeManagementService recipeManagement) + { + _db = db; + _openAi = openAi; + _recipeManagement = recipeManagement; + } + + public async Task RecalculateAsync( + int recipeId, + RecalculateRecipeCaloriesRequestDto request, + CancellationToken ct = default) + { + if (request.TargetCalories is < 50 or > 10000) + { + throw new InvalidOperationException("Target calories must be between 50 and 10000."); + } + + var recipe = await _db.Recipes.AsNoTracking() + .Include(r => r.Ingredients) + .Include(r => r.Steps) + .FirstOrDefaultAsync(r => r.Id == recipeId && r.IsActive, ct); + if (recipe is null) return null; + + var tolerance = request.CalorieToleranceKcal > 0 ? request.CalorieToleranceKcal : 10; + var workingIngredients = recipe.Ingredients + .OrderBy(i => i.SortOrder) + .Select(CloneIngredient) + .ToList(); + + var methods = new List(); + var currentTotals = await ComputeTotalsAsync(workingIngredients, ct); + var scaleFactor = RecipeMacroCalculator.ResolveScaleFactor( + currentTotals?.Calories ?? recipe.Calories ?? 0, + request.TargetCalories, + tolerance); + + if (scaleFactor != 1m) + { + RecipeMacroCalculator.ScaleIngredientAmounts( + workingIngredients, + scaleFactor, + tolerance, + request.TargetCalories, + currentTotals); + methods.Add("catalog"); + } + + currentTotals = await ComputeTotalsAsync(workingIngredients, ct); + var needsAi = _openAi.IsConfigured && + (currentTotals is null || + Math.Abs(currentTotals.Value.Calories - request.TargetCalories) > tolerance || + RecipeMacroCalculator.CountUnlinkedMacroIngredients(workingIngredients) > 0); + + if (needsAi) + { + await ApplyAiAdjustedAmountsAsync(recipe, workingIngredients, request.TargetCalories, ct); + methods.Add("ai"); + currentTotals = await ComputeTotalsAsync(workingIngredients, ct); + } + + if (currentTotals is null && recipe.Calories is > 0) + { + var fallbackScale = RecipeMacroCalculator.ResolveScaleFactor( + recipe.Calories.Value, + request.TargetCalories, + tolerance); + if (fallbackScale != 1m) + { + foreach (var ing in workingIngredients) + { + if (ing.AmountGrams is not > 0 || RecipeMacroCalculator.IsSpiceUnit(ing.Unit)) continue; + ing.AmountGrams = Math.Round(ing.AmountGrams.Value * fallbackScale, 1); + } + + if (!methods.Contains("catalog")) methods.Add("catalog"); + } + } + + currentTotals = await ComputeTotalsAsync(workingIngredients, ct); + var ingredientDtos = MapIngredients(workingIngredients); + var unlinked = RecipeMacroCalculator.CountUnlinkedMacroIngredients(workingIngredients); + + RecipeDetailDto? savedRecipe = null; + if (request.Apply) + { + var updateDto = new CreateRecipeDto + { + Name = recipe.Name, + MealCategory = recipe.MealCategory, + Calories = currentTotals?.Calories ?? request.TargetCalories, + Protein = currentTotals?.Protein, + Fat = currentTotals?.Fat, + Carbs = currentTotals?.Carbs, + PrepTimeMinutes = recipe.PrepTimeMinutes, + Ingredients = workingIngredients.Select((i, index) => new CreateIngredientDto + { + Name = i.Name, + AmountGrams = i.AmountGrams, + Unit = i.Unit, + SortOrder = index, + CatalogItemId = i.CatalogItemId, + }).ToList(), + Steps = recipe.Steps + .OrderBy(s => s.StepNumber) + .Select(s => new CreateRecipeStepDto + { + StepNumber = s.StepNumber, + Description = s.Description, + }).ToList(), + }; + + savedRecipe = await _recipeManagement.UpdateRecipeAsync(recipeId, updateDto, ct); + if (savedRecipe is null) return null; + } + + return new RecalculateRecipeCaloriesResultDto + { + RecipeId = recipeId, + PreviousCalories = recipe.Calories, + TargetCalories = request.TargetCalories, + NewCalories = currentTotals?.Calories ?? savedRecipe?.Calories, + NewProtein = currentTotals?.Protein ?? savedRecipe?.Protein, + NewFat = currentTotals?.Fat ?? savedRecipe?.Fat, + NewCarbs = currentTotals?.Carbs ?? savedRecipe?.Carbs, + Applied = request.Apply && savedRecipe is not null, + Method = methods.Count == 0 ? "none" : string.Join('+', methods), + UnlinkedIngredientCount = unlinked, + Ingredients = savedRecipe?.Ingredients ?? ingredientDtos, + Recipe = savedRecipe, + }; + } + + private async Task ApplyAiAdjustedAmountsAsync( + Recipe source, + IList ingredients, + int targetCalories, + CancellationToken ct) + { + var system = """ + You adjust recipe ingredient amounts to match a calorie target for a meal planning app. + Respond ONLY JSON: + {"ingredients":[{"name":"exact ingredient name","amountGrams":100,"unit":null}]} + Rules: + - Keep every ingredient from the input list (same names). + - Change amountGrams for macro ingredients so the recipe totals about targetCalories kcal. + - Use realistic gram amounts. Round to one decimal. + - Spices with unit "do smaku" / "to taste" keep null amountGrams. + - Do not add or remove ingredients. + """; + + var user = JsonSerializer.Serialize(new + { + recipeName = source.Name, + targetCalories, + currentCalories = source.Calories, + ingredients = ingredients.Select(i => new + { + i.Name, + i.AmountGrams, + i.Unit, + i.CatalogItemId, + }), + }); + + var json = await _openAi.CompleteJsonAsync(system, user, OpenAiUseCase.Recipe, ct); + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("ingredients", out var ingredientsEl) || + ingredientsEl.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException("AI did not return adjusted ingredients. Try again."); + } + + var byName = ingredients.ToDictionary(i => i.Name.Trim(), StringComparer.OrdinalIgnoreCase); + foreach (var item in ingredientsEl.EnumerateArray()) + { + var name = item.TryGetProperty("name", out var nameEl) + ? AiRecipeJsonParser.GetString(nameEl) + : null; + if (string.IsNullOrWhiteSpace(name) || !byName.TryGetValue(name.Trim(), out var target)) continue; + + if (item.TryGetProperty("amountGrams", out var gramsEl)) + { + target.AmountGrams = AiRecipeJsonParser.GetNullableDecimal(gramsEl); + } + + if (item.TryGetProperty("unit", out var unitEl) && unitEl.ValueKind != JsonValueKind.Null) + { + target.Unit = AiRecipeJsonParser.GetString(unitEl); + } + } + } + + private async Task ComputeTotalsAsync(IEnumerable ingredients, CancellationToken ct) + { + return await RecipeMacroCalculator.ComputeFromCatalogAsync( + _db, + ingredients.Select(i => (i.AmountGrams, i.Unit, i.CatalogItemId)), + ct); + } + + private static Ingredient CloneIngredient(Ingredient source) => new() + { + Id = source.Id, + Name = source.Name, + AmountGrams = source.AmountGrams, + Unit = source.Unit, + SortOrder = source.SortOrder, + CatalogItemId = source.CatalogItemId, + }; + + private static List MapIngredients(IEnumerable ingredients) => + ingredients + .OrderBy(i => i.SortOrder) + .Select(i => new IngredientDto + { + Id = i.Id, + Name = i.Name, + AmountGrams = i.AmountGrams, + Unit = i.Unit, + SortOrder = i.SortOrder, + CatalogItemId = i.CatalogItemId, + }).ToList(); +} diff --git a/MealPlan.Api/Services/RecipeMacroCalculator.cs b/MealPlan.Api/Services/RecipeMacroCalculator.cs new file mode 100644 index 0000000..faa6fd0 --- /dev/null +++ b/MealPlan.Api/Services/RecipeMacroCalculator.cs @@ -0,0 +1,83 @@ +using MealPlan.Api.Data; +using MealPlan.Api.Models; +using Microsoft.EntityFrameworkCore; + +namespace MealPlan.Api.Services; + +public readonly record struct MacroTotals(int Calories, decimal Protein, decimal Fat, decimal Carbs); + +public static class RecipeMacroCalculator +{ + public static bool IsSpiceUnit(string? unit) + { + if (string.IsNullOrWhiteSpace(unit)) return false; + var u = unit.Trim().ToLowerInvariant(); + return u is "do smaku" or "optional" or "opcjonalnie" or "szczypta" or "to taste"; + } + + public static bool IsQuantifiedMacroIngredient(decimal? amountGrams, string? unit, int? catalogItemId) => + catalogItemId.HasValue && amountGrams is > 0 && !IsSpiceUnit(unit); + + public static async Task ComputeFromCatalogAsync( + AppDbContext db, + IEnumerable<(decimal? AmountGrams, string? Unit, int? CatalogItemId)> ingredients, + CancellationToken ct = default) + { + var rows = ingredients.ToList(); + var ids = rows + .Where(i => IsQuantifiedMacroIngredient(i.AmountGrams, i.Unit, i.CatalogItemId)) + .Select(i => i.CatalogItemId!.Value) + .Distinct() + .ToList(); + if (ids.Count == 0) return null; + + var catalog = await db.IngredientNutritionCatalog.AsNoTracking() + .Where(i => ids.Contains(i.Id) && i.IsActive) + .ToDictionaryAsync(i => i.Id, ct); + + int calories = 0; + decimal protein = 0, fat = 0, carbs = 0; + foreach (var ing in rows) + { + if (!IsQuantifiedMacroIngredient(ing.AmountGrams, ing.Unit, ing.CatalogItemId)) continue; + if (!catalog.TryGetValue(ing.CatalogItemId!.Value, out var cat)) continue; + + var scale = ing.AmountGrams!.Value / 100m; + calories += (int)Math.Round(cat.CaloriesPer100G * scale); + protein += cat.ProteinGPer100G * scale; + fat += cat.FatGPer100G * scale; + carbs += cat.CarbsGPer100G * scale; + } + + if (calories <= 0) return null; + return new MacroTotals(calories, Math.Round(protein, 2), Math.Round(fat, 2), Math.Round(carbs, 2)); + } + + public static decimal ResolveScaleFactor(int currentCalories, int targetCalories, int toleranceKcal) + { + if (currentCalories <= 0 || targetCalories <= 0) return 1m; + if (Math.Abs(currentCalories - targetCalories) <= toleranceKcal) return 1m; + return Math.Clamp((decimal)targetCalories / currentCalories, 0.25m, 4m); + } + + public static void ScaleIngredientAmounts( + IList ingredients, + decimal scaleFactor, + int toleranceKcal, + int targetCalories, + MacroTotals? currentTotals) + { + if (scaleFactor == 1m) return; + if (currentTotals is null || currentTotals.Value.Calories <= 0) return; + if (Math.Abs(currentTotals.Value.Calories - targetCalories) <= toleranceKcal) return; + + foreach (var ing in ingredients) + { + if (!IsQuantifiedMacroIngredient(ing.AmountGrams, ing.Unit, ing.CatalogItemId)) continue; + ing.AmountGrams = Math.Round(ing.AmountGrams!.Value * scaleFactor, 1); + } + } + + public static int CountUnlinkedMacroIngredients(IEnumerable ingredients) => + ingredients.Count(i => i.AmountGrams is > 0 && !IsSpiceUnit(i.Unit) && !i.CatalogItemId.HasValue); +}