* 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,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; }
}

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

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