251 lines
9.4 KiB
C#
251 lines
9.4 KiB
C#
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();
|
|
}
|