* Added missing files
This commit is contained in:
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();
|
||||
}
|
||||
Reference in New Issue
Block a user