* Added missing files

This commit is contained in:
2026-06-24 22:58:18 +02:00
parent beb08f2e6f
commit 2207bdde50
3 changed files with 358 additions and 0 deletions

View 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();
}