* Added missing files
This commit is contained in:
25
MealPlan.Api/DTOs/Recipe/RecalculateRecipeCaloriesDto.cs
Normal file
25
MealPlan.Api/DTOs/Recipe/RecalculateRecipeCaloriesDto.cs
Normal file
@@ -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; }
|
||||||
|
/// <summary>catalog | ai | catalog+ai</summary>
|
||||||
|
public string Method { get; set; } = string.Empty;
|
||||||
|
public int UnlinkedIngredientCount { get; set; }
|
||||||
|
public List<IngredientDto> Ingredients { get; set; } = new();
|
||||||
|
public RecipeDetailDto? Recipe { get; set; }
|
||||||
|
}
|
||||||
250
MealPlan.Api/Services/RecipeCalorieRecalculationService.cs
Normal file
250
MealPlan.Api/Services/RecipeCalorieRecalculationService.cs
Normal file
@@ -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<RecalculateRecipeCaloriesResultDto?> 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<RecalculateRecipeCaloriesResultDto?> 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<string>();
|
||||||
|
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<Ingredient> 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<MacroTotals?> ComputeTotalsAsync(IEnumerable<Ingredient> 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<IngredientDto> MapIngredients(IEnumerable<Ingredient> 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();
|
||||||
|
}
|
||||||
83
MealPlan.Api/Services/RecipeMacroCalculator.cs
Normal file
83
MealPlan.Api/Services/RecipeMacroCalculator.cs
Normal file
@@ -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<MacroTotals?> 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<Ingredient> 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<Ingredient> ingredients) =>
|
||||||
|
ingredients.Count(i => i.AmountGrams is > 0 && !IsSpiceUnit(i.Unit) && !i.CatalogItemId.HasValue);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user