* Extended functionalities
* Added different UIs
This commit is contained in:
39
MealPlan.Api/Services/Generators/AiRecipeJsonParser.cs
Normal file
39
MealPlan.Api/Services/Generators/AiRecipeJsonParser.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MealPlan.Api.Services.Generators;
|
||||
|
||||
internal static class AiRecipeJsonParser
|
||||
{
|
||||
public static int GetInt32(JsonElement element, int fallback = 0)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number when element.TryGetInt32(out var value) => value,
|
||||
JsonValueKind.Number => (int)element.GetDecimal(),
|
||||
JsonValueKind.String when int.TryParse(element.GetString(), out var parsed) => parsed,
|
||||
JsonValueKind.True => 1,
|
||||
JsonValueKind.False => 0,
|
||||
_ => fallback,
|
||||
};
|
||||
}
|
||||
|
||||
public static int? GetNullableInt32(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) return null;
|
||||
return GetInt32(element);
|
||||
}
|
||||
|
||||
public static decimal? GetNullableDecimal(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Null or JsonValueKind.Undefined => null,
|
||||
JsonValueKind.Number => element.GetDecimal(),
|
||||
JsonValueKind.String when decimal.TryParse(element.GetString(), out var parsed) => parsed,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
public static string? GetString(JsonElement element) =>
|
||||
element.ValueKind == JsonValueKind.String ? element.GetString() : element.ToString();
|
||||
}
|
||||
60
MealPlan.Api/Services/Generators/CatalogMatchingService.cs
Normal file
60
MealPlan.Api/Services/Generators/CatalogMatchingService.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using MealPlan.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MealPlan.Api.Services.Generators;
|
||||
|
||||
public interface ICatalogMatchingService
|
||||
{
|
||||
Task<int?> ResolveCatalogItemIdAsync(string nameOrCatalogName, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class CatalogMatchingService : ICatalogMatchingService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private List<CatalogEntry>? _cache;
|
||||
|
||||
public CatalogMatchingService(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<int?> ResolveCatalogItemIdAsync(string nameOrCatalogName, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(nameOrCatalogName)) return null;
|
||||
|
||||
var cache = await GetCacheAsync(ct);
|
||||
var key = Normalize(nameOrCatalogName);
|
||||
|
||||
var exact = cache.FirstOrDefault(c =>
|
||||
c.NameKey == key || c.NamePlKey == key || c.CatalogNameKey == key);
|
||||
if (exact is not null) return exact.Id;
|
||||
|
||||
return cache
|
||||
.Where(c => key.Contains(c.NameKey, StringComparison.Ordinal) ||
|
||||
c.NameKey.Contains(key, StringComparison.Ordinal) ||
|
||||
(!string.IsNullOrEmpty(c.NamePlKey) &&
|
||||
(key.Contains(c.NamePlKey, StringComparison.Ordinal) ||
|
||||
c.NamePlKey.Contains(key, StringComparison.Ordinal))))
|
||||
.OrderBy(c => Math.Abs(c.NameKey.Length - key.Length))
|
||||
.FirstOrDefault()
|
||||
?.Id;
|
||||
}
|
||||
|
||||
private async Task<List<CatalogEntry>> GetCacheAsync(CancellationToken ct)
|
||||
{
|
||||
if (_cache is not null) return _cache;
|
||||
|
||||
_cache = await _db.IngredientNutritionCatalog.AsNoTracking()
|
||||
.Where(i => i.IsActive)
|
||||
.Select(i => new CatalogEntry(i.Id, i.Name, i.NamePl))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return _cache;
|
||||
}
|
||||
|
||||
private static string Normalize(string value) => value.Trim().ToLowerInvariant();
|
||||
|
||||
private sealed record CatalogEntry(int Id, string Name, string? NamePl)
|
||||
{
|
||||
public string NameKey { get; } = Name.Trim().ToLowerInvariant();
|
||||
public string NamePlKey { get; } = NamePl?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||
public string CatalogNameKey { get; } = Name.Trim().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
780
MealPlan.Api/Services/Generators/DietGeneratorService.cs
Normal file
780
MealPlan.Api/Services/Generators/DietGeneratorService.cs
Normal file
@@ -0,0 +1,780 @@
|
||||
using System.Text.Json;
|
||||
using MealPlan.Api.Data;
|
||||
using MealPlan.Api.DTOs.Diet;
|
||||
using MealPlan.Api.DTOs.Generators;
|
||||
using MealPlan.Api.Models;
|
||||
using MealPlan.Api.Services.AI;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MealPlan.Api.Services.Generators;
|
||||
|
||||
public interface IDietGeneratorService
|
||||
{
|
||||
Task<DietUserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<DietUserProfileDto> UpsertProfileAsync(Guid userId, DietUserProfileDto dto, CancellationToken ct = default);
|
||||
Task<DietGeneratorSessionDto> CreateSessionAsync(Guid userId, CreateDietGeneratorSessionDto dto, CancellationToken ct = default);
|
||||
Task<DietGeneratorSessionDto?> GetSessionAsync(Guid userId, int sessionId, CancellationToken ct = default);
|
||||
Task<DietGeneratorSessionDto?> ProposeMacrosAsync(Guid userId, int sessionId, CancellationToken ct = default);
|
||||
Task<DietGeneratorSessionDto?> AcceptMacrosAsync(Guid userId, int sessionId, AcceptMacrosDto dto, CancellationToken ct = default);
|
||||
Task<DietGeneratorSessionDto?> GeneratePlanAsync(Guid userId, int sessionId, CancellationToken ct = default);
|
||||
Task<DietGeneratorSessionDto?> RegenerateMealAsync(Guid userId, int sessionId, RegenerateDietMealDto dto, CancellationToken ct = default);
|
||||
Task<DietGeneratorSessionDto?> UpdateDraftMealAsync(Guid userId, int sessionId, int mealId, UpdateDraftMealDto dto, CancellationToken ct = default);
|
||||
Task<CommitDietPlanResultDto?> CommitAsync(Guid userId, int sessionId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class DietGeneratorService : IDietGeneratorService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IOpenAiChatService _openAi;
|
||||
|
||||
public DietGeneratorService(AppDbContext db, IOpenAiChatService openAi)
|
||||
{
|
||||
_db = db;
|
||||
_openAi = openAi;
|
||||
}
|
||||
|
||||
public async Task<DietUserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var profile = await _db.DietUserProfiles.AsNoTracking().FirstOrDefaultAsync(p => p.UserId == userId, ct);
|
||||
return profile is null ? null : MapProfile(profile);
|
||||
}
|
||||
|
||||
public async Task<DietUserProfileDto> UpsertProfileAsync(Guid userId, DietUserProfileDto dto, CancellationToken ct = default)
|
||||
{
|
||||
var profile = await _db.DietUserProfiles.FirstOrDefaultAsync(p => p.UserId == userId, ct);
|
||||
if (profile is null)
|
||||
{
|
||||
profile = new DietUserProfile { UserId = userId, CreatedAt = DateTime.UtcNow };
|
||||
_db.DietUserProfiles.Add(profile);
|
||||
}
|
||||
|
||||
profile.HeightCm = dto.HeightCm;
|
||||
profile.WeightKg = dto.WeightKg;
|
||||
profile.Age = dto.Age;
|
||||
profile.Sex = dto.Sex;
|
||||
profile.ActivityLevel = dto.ActivityLevel;
|
||||
profile.Goal = dto.Goal;
|
||||
profile.AllergiesNotes = dto.AllergiesNotes;
|
||||
profile.DislikedFoods = dto.DislikedFoods;
|
||||
profile.MealsPerDayMask = dto.MealsPerDayMask;
|
||||
profile.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return MapProfile(profile);
|
||||
}
|
||||
|
||||
public async Task<DietGeneratorSessionDto> CreateSessionAsync(
|
||||
Guid userId,
|
||||
CreateDietGeneratorSessionDto dto,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var profile = await _db.DietUserProfiles.AsNoTracking().FirstOrDefaultAsync(p => p.UserId == userId, ct)
|
||||
?? throw new InvalidOperationException("Save your diet profile before starting a generator session.");
|
||||
|
||||
var session = new DietGeneratorSession
|
||||
{
|
||||
UserId = userId,
|
||||
Status = DietGeneratorStatuses.MacrosPending,
|
||||
PlanStartDate = dto.PlanStartDate ?? DateOnly.FromDateTime(DateTime.UtcNow),
|
||||
PlanDayCount = dto.PlanDayCount,
|
||||
CalorieToleranceKcal = dto.CalorieToleranceKcal,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
_db.DietGeneratorSessions.Add(session);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
var calculated = TdeeCalculator.Calculate(
|
||||
profile.HeightCm, profile.WeightKg, profile.Age, profile.Sex, profile.ActivityLevel, profile.Goal);
|
||||
|
||||
session.ProposedCalories = calculated.Calories;
|
||||
session.ProposedProteinG = calculated.ProteinG;
|
||||
session.ProposedFatG = calculated.FatG;
|
||||
session.ProposedCarbsG = calculated.CarbsG;
|
||||
session.MacroRationale = calculated.Rationale;
|
||||
session.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
return await MapSessionAsync(session.Id, ct) ?? throw new InvalidOperationException("Session not found.");
|
||||
}
|
||||
|
||||
public async Task<DietGeneratorSessionDto?> GetSessionAsync(Guid userId, int sessionId, CancellationToken ct = default)
|
||||
{
|
||||
await BackfillMissingDraftIngredientsAsync(userId, sessionId, ct);
|
||||
return await MapSessionAsync(sessionId, userId, ct);
|
||||
}
|
||||
|
||||
public async Task<DietGeneratorSessionDto?> ProposeMacrosAsync(Guid userId, int sessionId, CancellationToken ct = default)
|
||||
{
|
||||
var session = await GetEditableSessionAsync(userId, sessionId, ct);
|
||||
if (session is null) return null;
|
||||
|
||||
var profile = await _db.DietUserProfiles.AsNoTracking().FirstAsync(p => p.UserId == userId, ct);
|
||||
var calculated = TdeeCalculator.Calculate(
|
||||
profile.HeightCm, profile.WeightKg, profile.Age, profile.Sex, profile.ActivityLevel, profile.Goal);
|
||||
|
||||
if (_openAi.IsConfigured)
|
||||
{
|
||||
try
|
||||
{
|
||||
var system = """
|
||||
You are a sports nutrition assistant. Respond ONLY with JSON:
|
||||
{"calories":number,"proteinG":number,"fatG":number,"carbsG":number,"rationale":"string"}
|
||||
Keep calories within 10% of the provided baseline. Use realistic macro splits.
|
||||
""";
|
||||
var user = JsonSerializer.Serialize(new
|
||||
{
|
||||
baseline = calculated,
|
||||
profile = new
|
||||
{
|
||||
profile.HeightCm,
|
||||
profile.WeightKg,
|
||||
profile.Age,
|
||||
profile.Sex,
|
||||
profile.ActivityLevel,
|
||||
profile.Goal,
|
||||
profile.AllergiesNotes,
|
||||
},
|
||||
});
|
||||
|
||||
var json = await _openAi.CompleteJsonAsync(system, user, OpenAiUseCase.Macros, ct);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
session.ProposedCalories = root.TryGetProperty("calories", out var c)
|
||||
? c.GetInt32()
|
||||
: calculated.Calories;
|
||||
session.ProposedProteinG = root.TryGetProperty("proteinG", out var p)
|
||||
? p.GetDecimal()
|
||||
: calculated.ProteinG;
|
||||
session.ProposedFatG = root.TryGetProperty("fatG", out var f)
|
||||
? f.GetDecimal()
|
||||
: calculated.FatG;
|
||||
session.ProposedCarbsG = root.TryGetProperty("carbsG", out var cb)
|
||||
? cb.GetDecimal()
|
||||
: calculated.CarbsG;
|
||||
session.MacroRationale = root.TryGetProperty("rationale", out var r)
|
||||
? r.GetString()
|
||||
: calculated.Rationale;
|
||||
}
|
||||
catch
|
||||
{
|
||||
ApplyCalculatedMacros(session, calculated);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyCalculatedMacros(session, calculated);
|
||||
}
|
||||
|
||||
session.Status = DietGeneratorStatuses.MacrosPending;
|
||||
session.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return await MapSessionAsync(sessionId, ct);
|
||||
}
|
||||
|
||||
public async Task<DietGeneratorSessionDto?> AcceptMacrosAsync(
|
||||
Guid userId,
|
||||
int sessionId,
|
||||
AcceptMacrosDto dto,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var session = await GetEditableSessionAsync(userId, sessionId, ct);
|
||||
if (session is null) return null;
|
||||
|
||||
if (dto.Calories.HasValue) session.ProposedCalories = dto.Calories;
|
||||
if (dto.ProteinG.HasValue) session.ProposedProteinG = dto.ProteinG;
|
||||
if (dto.FatG.HasValue) session.ProposedFatG = dto.FatG;
|
||||
if (dto.CarbsG.HasValue) session.ProposedCarbsG = dto.CarbsG;
|
||||
|
||||
session.Status = DietGeneratorStatuses.MacrosAccepted;
|
||||
session.MacrosAcceptedAt = DateTime.UtcNow;
|
||||
session.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return await MapSessionAsync(sessionId, ct);
|
||||
}
|
||||
|
||||
public async Task<DietGeneratorSessionDto?> GeneratePlanAsync(Guid userId, int sessionId, CancellationToken ct = default)
|
||||
{
|
||||
var session = await GetEditableSessionAsync(userId, sessionId, ct);
|
||||
if (session is null || !session.ProposedCalories.HasValue) return null;
|
||||
|
||||
var profile = await _db.DietUserProfiles.AsNoTracking().FirstAsync(p => p.UserId == userId, ct);
|
||||
var pool = await LoadRecipePoolAsync(userId, ct);
|
||||
if (pool.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No recipes with complete macros are available for planning.");
|
||||
}
|
||||
|
||||
var mealCategories = MealPlanOptimizer.MealCategoriesFromMask(profile.MealsPerDayMask);
|
||||
var requiredUnique = MealPlanOptimizer.RequiredUniqueRecipes(session.PlanDayCount, mealCategories);
|
||||
if (pool.Count < requiredUnique)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Need at least {requiredUnique} unique recipes for a {session.PlanDayCount}-day plan " +
|
||||
$"({mealCategories.Count} meals/day), but only {pool.Count} are available.");
|
||||
}
|
||||
|
||||
var start = session.PlanStartDate ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var usedRecipes = new HashSet<int>();
|
||||
var allSlots = new List<DraftMealSlot>();
|
||||
|
||||
if (_openAi.IsConfigured)
|
||||
{
|
||||
try
|
||||
{
|
||||
allSlots = await GeneratePlanWithAiAsync(session, pool, mealCategories, start, usedRecipes, ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
allSlots = BuildGreedyPlan(session, pool, mealCategories, start, usedRecipes);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
allSlots = BuildGreedyPlan(session, pool, mealCategories, start, usedRecipes);
|
||||
}
|
||||
|
||||
MealPlanOptimizer.EnsureWeeklyUniqueRecipes(
|
||||
allSlots,
|
||||
pool,
|
||||
mealCategories,
|
||||
session.ProposedCalories!.Value,
|
||||
session.CalorieToleranceKcal);
|
||||
|
||||
_db.DietGeneratorDraftMeals.RemoveRange(
|
||||
await _db.DietGeneratorDraftMeals.Where(m => m.SessionId == sessionId).ToListAsync(ct));
|
||||
|
||||
var recipes = pool.ToDictionary(r => r.Id);
|
||||
foreach (var slot in allSlots)
|
||||
{
|
||||
if (!recipes.TryGetValue(slot.RecipeId, out var recipe)) continue;
|
||||
_db.DietGeneratorDraftMeals.Add(new DietGeneratorDraftMeal
|
||||
{
|
||||
SessionId = sessionId,
|
||||
PlanDate = slot.PlanDate,
|
||||
MealCategory = slot.MealCategory,
|
||||
RecipeId = slot.RecipeId,
|
||||
Portions = slot.Portions,
|
||||
Calories = slot.TotalCalories,
|
||||
ProteinG = Math.Round(slot.TotalProtein, 2),
|
||||
FatG = Math.Round(slot.TotalFat, 2),
|
||||
CarbsG = Math.Round(slot.TotalCarbs, 2),
|
||||
GenerationVersion = 1,
|
||||
});
|
||||
}
|
||||
|
||||
session.Status = DietGeneratorStatuses.PlanDraft;
|
||||
session.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
var draftMeals = await _db.DietGeneratorDraftMeals
|
||||
.Where(m => m.SessionId == sessionId)
|
||||
.ToListAsync(ct);
|
||||
foreach (var draftMeal in draftMeals)
|
||||
{
|
||||
await SyncDraftMealIngredientsAsync(draftMeal.Id, draftMeal.RecipeId, draftMeal.Portions, ct);
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return await MapSessionAsync(sessionId, ct);
|
||||
}
|
||||
|
||||
public async Task<DietGeneratorSessionDto?> RegenerateMealAsync(
|
||||
Guid userId,
|
||||
int sessionId,
|
||||
RegenerateDietMealDto dto,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var session = await GetEditableSessionAsync(userId, sessionId, ct);
|
||||
if (session is null || !session.ProposedCalories.HasValue) return null;
|
||||
|
||||
var draftMeals = await _db.DietGeneratorDraftMeals
|
||||
.Where(m => m.SessionId == sessionId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var dayMeals = draftMeals.Where(m => m.PlanDate == dto.PlanDate).ToList();
|
||||
var target = session.ProposedCalories.Value;
|
||||
var lockedCalories = dayMeals.Where(m => m.IsLocked && m.PlanDate == dto.PlanDate).Sum(m => m.Calories ?? 0);
|
||||
var remaining = Math.Max(200, target - lockedCalories);
|
||||
var unlockedSlots = dayMeals.Count(m => !m.IsLocked);
|
||||
var slotTarget = unlockedSlots > 0 ? remaining / unlockedSlots : remaining;
|
||||
|
||||
var pool = await LoadRecipePoolAsync(userId, ct);
|
||||
|
||||
var existing = dayMeals.FirstOrDefault(m => m.MealCategory == dto.MealCategory);
|
||||
if (existing is null) return null;
|
||||
if (existing.IsLocked) return await MapSessionAsync(sessionId, ct);
|
||||
|
||||
var excluded = draftMeals
|
||||
.Where(m => m.Id != existing.Id)
|
||||
.Select(m => m.RecipeId)
|
||||
.ToHashSet();
|
||||
excluded.Add(existing.RecipeId);
|
||||
|
||||
if (!MealPlanOptimizer.TryPickGreedyMeal(pool, dto.MealCategory, slotTarget, excluded, out var picked))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"No other recipes are available for this meal. All suitable options are already used in the plan.");
|
||||
}
|
||||
|
||||
var recipe = pool.First(r => r.Id == picked.RecipeId);
|
||||
|
||||
existing.RecipeId = picked.RecipeId;
|
||||
existing.Portions = picked.Portions;
|
||||
existing.Calories = picked.TotalCalories;
|
||||
existing.ProteinG = Math.Round(picked.TotalProtein, 2);
|
||||
existing.FatG = Math.Round(picked.TotalFat, 2);
|
||||
existing.CarbsG = Math.Round(picked.TotalCarbs, 2);
|
||||
existing.GenerationVersion++;
|
||||
|
||||
var slots = dayMeals.Select(m => new DraftMealSlot
|
||||
{
|
||||
PlanDate = m.PlanDate,
|
||||
MealCategory = m.MealCategory,
|
||||
RecipeId = m.RecipeId,
|
||||
Portions = m.Portions,
|
||||
CaloriesPerPortion = recipe.Calories,
|
||||
FatPerPortion = recipe.Fat,
|
||||
ProteinPerPortion = recipe.Protein,
|
||||
CarbsPerPortion = recipe.Carbs,
|
||||
IsLocked = m.IsLocked,
|
||||
}).ToList();
|
||||
|
||||
foreach (var slot in slots)
|
||||
{
|
||||
var r = pool.First(x => x.Id == slot.RecipeId);
|
||||
slot.CaloriesPerPortion = r.Calories;
|
||||
slot.ProteinPerPortion = r.Protein;
|
||||
slot.FatPerPortion = r.Fat;
|
||||
slot.CarbsPerPortion = r.Carbs;
|
||||
}
|
||||
|
||||
MealPlanOptimizer.BalanceDay(slots, target, session.CalorieToleranceKcal);
|
||||
foreach (var slot in slots)
|
||||
{
|
||||
var entity = dayMeals.First(m => m.MealCategory == slot.MealCategory);
|
||||
var r = pool.First(x => x.Id == slot.RecipeId);
|
||||
entity.Portions = slot.Portions;
|
||||
entity.Calories = slot.TotalCalories;
|
||||
entity.ProteinG = Math.Round(slot.TotalProtein, 2);
|
||||
entity.FatG = Math.Round(slot.TotalFat, 2);
|
||||
entity.CarbsG = Math.Round(slot.TotalCarbs, 2);
|
||||
}
|
||||
|
||||
session.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
await SyncDraftMealIngredientsAsync(existing.Id, existing.RecipeId, existing.Portions, ct);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return await MapSessionAsync(sessionId, ct);
|
||||
}
|
||||
|
||||
public async Task<DietGeneratorSessionDto?> UpdateDraftMealAsync(
|
||||
Guid userId,
|
||||
int sessionId,
|
||||
int mealId,
|
||||
UpdateDraftMealDto dto,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var session = await GetEditableSessionAsync(userId, sessionId, ct);
|
||||
if (session is null) return null;
|
||||
|
||||
var meal = await _db.DietGeneratorDraftMeals
|
||||
.FirstOrDefaultAsync(m => m.Id == mealId && m.SessionId == sessionId, ct);
|
||||
if (meal is null) return null;
|
||||
|
||||
if (dto.IsLocked.HasValue) meal.IsLocked = dto.IsLocked.Value;
|
||||
if (dto.RecipeId.HasValue)
|
||||
{
|
||||
var recipe = await _db.Recipes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.Id == dto.RecipeId && r.IsActive && !r.IsDraft, ct);
|
||||
if (recipe is null || !recipe.Calories.HasValue) return null;
|
||||
meal.RecipeId = recipe.Id;
|
||||
meal.Calories = (int)Math.Round(recipe.Calories.Value * meal.Portions);
|
||||
meal.ProteinG = recipe.Protein.HasValue ? Math.Round(recipe.Protein.Value * meal.Portions, 2) : null;
|
||||
meal.FatG = recipe.Fat.HasValue ? Math.Round(recipe.Fat.Value * meal.Portions, 2) : null;
|
||||
meal.CarbsG = recipe.Carbs.HasValue ? Math.Round(recipe.Carbs.Value * meal.Portions, 2) : null;
|
||||
}
|
||||
|
||||
if (dto.Portions.HasValue)
|
||||
{
|
||||
meal.Portions = dto.Portions.Value;
|
||||
var recipe = await _db.Recipes.AsNoTracking().FirstAsync(r => r.Id == meal.RecipeId, ct);
|
||||
meal.Calories = recipe.Calories.HasValue
|
||||
? (int)Math.Round(recipe.Calories.Value * meal.Portions)
|
||||
: null;
|
||||
meal.ProteinG = recipe.Protein.HasValue ? Math.Round(recipe.Protein.Value * meal.Portions, 2) : null;
|
||||
meal.FatG = recipe.Fat.HasValue ? Math.Round(recipe.Fat.Value * meal.Portions, 2) : null;
|
||||
meal.CarbsG = recipe.Carbs.HasValue ? Math.Round(recipe.Carbs.Value * meal.Portions, 2) : null;
|
||||
}
|
||||
|
||||
session.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
await SyncDraftMealIngredientsAsync(meal.Id, meal.RecipeId, meal.Portions, ct);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return await MapSessionAsync(sessionId, ct);
|
||||
}
|
||||
|
||||
public async Task<CommitDietPlanResultDto?> CommitAsync(Guid userId, int sessionId, CancellationToken ct = default)
|
||||
{
|
||||
var session = await _db.DietGeneratorSessions
|
||||
.Include(s => s.DraftMeals)
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId, ct);
|
||||
|
||||
if (session is null || session.Status != DietGeneratorStatuses.PlanDraft) return null;
|
||||
if (!session.ProposedCalories.HasValue || session.DraftMeals.Count == 0) return null;
|
||||
|
||||
var start = session.PlanStartDate ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var end = start.AddDays(session.PlanDayCount - 1);
|
||||
|
||||
var goals = await _db.UserDailyGoals.FirstOrDefaultAsync(g => g.UserId == userId, ct);
|
||||
if (goals is null)
|
||||
{
|
||||
goals = new UserDailyGoal { UserId = userId, CreatedAt = DateTime.UtcNow };
|
||||
_db.UserDailyGoals.Add(goals);
|
||||
}
|
||||
|
||||
goals.CalorieGoal = session.ProposedCalories;
|
||||
goals.ProteinGoalG = session.ProposedProteinG;
|
||||
goals.FatGoalG = session.ProposedFatG;
|
||||
goals.CarbsGoalG = session.ProposedCarbsG;
|
||||
goals.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
var existingEntries = await _db.MealPlanEntries
|
||||
.Where(e => e.UserId == userId && e.PlanDate >= start && e.PlanDate <= end)
|
||||
.ToListAsync(ct);
|
||||
_db.MealPlanEntries.RemoveRange(existingEntries);
|
||||
|
||||
foreach (var draft in session.DraftMeals)
|
||||
{
|
||||
_db.MealPlanEntries.Add(new MealPlanEntry
|
||||
{
|
||||
UserId = userId,
|
||||
PlanDate = draft.PlanDate,
|
||||
MealCategory = draft.MealCategory,
|
||||
RecipeId = draft.RecipeId,
|
||||
Portions = draft.Portions,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
session.Status = DietGeneratorStatuses.Committed;
|
||||
session.CommittedAt = DateTime.UtcNow;
|
||||
session.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
return new CommitDietPlanResultDto
|
||||
{
|
||||
SessionId = sessionId,
|
||||
PlanStartDate = start,
|
||||
PlanEndDate = end,
|
||||
MealsWritten = session.DraftMeals.Count,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task BackfillMissingDraftIngredientsAsync(Guid userId, int sessionId, CancellationToken ct)
|
||||
{
|
||||
var session = await _db.DietGeneratorSessions.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId, ct);
|
||||
if (session?.Status != DietGeneratorStatuses.PlanDraft) return;
|
||||
|
||||
var meals = await _db.DietGeneratorDraftMeals
|
||||
.Where(m => m.SessionId == sessionId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (meals.Count == 0) return;
|
||||
|
||||
foreach (var meal in meals)
|
||||
{
|
||||
await SyncDraftMealIngredientsAsync(meal.Id, meal.RecipeId, meal.Portions, ct);
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task SyncDraftMealIngredientsAsync(
|
||||
int draftMealId,
|
||||
int recipeId,
|
||||
decimal portions,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var existing = await _db.DietGeneratorDraftMealIngredients
|
||||
.Where(i => i.DraftMealId == draftMealId)
|
||||
.ToListAsync(ct);
|
||||
_db.DietGeneratorDraftMealIngredients.RemoveRange(existing);
|
||||
|
||||
var recipeIngredients = await _db.Ingredients.AsNoTracking()
|
||||
.Where(i => i.RecipeId == recipeId)
|
||||
.OrderBy(i => i.SortOrder)
|
||||
.ThenBy(i => i.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var ingredient in recipeIngredients)
|
||||
{
|
||||
var (scaledGrams, scaledUnit) = IngredientScalingHelper.ScaleForDietPlan(
|
||||
ingredient.AmountGrams,
|
||||
ingredient.Unit,
|
||||
portions);
|
||||
|
||||
_db.DietGeneratorDraftMealIngredients.Add(new DietGeneratorDraftMealIngredient
|
||||
{
|
||||
DraftMealId = draftMealId,
|
||||
SourceIngredientId = ingredient.Id,
|
||||
Name = ingredient.Name,
|
||||
AmountGrams = scaledGrams,
|
||||
Unit = scaledUnit,
|
||||
SortOrder = ingredient.SortOrder,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyCalculatedMacros(DietGeneratorSession session, TdeeCalculator.MacroTargets calculated)
|
||||
{
|
||||
session.ProposedCalories = calculated.Calories;
|
||||
session.ProposedProteinG = calculated.ProteinG;
|
||||
session.ProposedFatG = calculated.FatG;
|
||||
session.ProposedCarbsG = calculated.CarbsG;
|
||||
session.MacroRationale = calculated.Rationale;
|
||||
}
|
||||
|
||||
private List<DraftMealSlot> BuildGreedyPlan(
|
||||
DietGeneratorSession session,
|
||||
IReadOnlyList<RecipeCandidate> pool,
|
||||
IReadOnlyList<int> mealCategories,
|
||||
DateOnly start,
|
||||
HashSet<int> usedRecipes)
|
||||
{
|
||||
var allSlots = new List<DraftMealSlot>();
|
||||
for (var day = 0; day < session.PlanDayCount; day++)
|
||||
{
|
||||
var date = start.AddDays(day);
|
||||
var daySlots = MealPlanOptimizer.BuildGreedyDay(
|
||||
date,
|
||||
pool,
|
||||
mealCategories,
|
||||
session.ProposedCalories!.Value,
|
||||
session.CalorieToleranceKcal,
|
||||
usedRecipes);
|
||||
allSlots.AddRange(daySlots);
|
||||
}
|
||||
|
||||
return allSlots;
|
||||
}
|
||||
|
||||
private async Task<List<DraftMealSlot>> GeneratePlanWithAiAsync(
|
||||
DietGeneratorSession session,
|
||||
IReadOnlyList<RecipeCandidate> pool,
|
||||
IReadOnlyList<int> mealCategories,
|
||||
DateOnly start,
|
||||
HashSet<int> usedRecipes,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var compact = pool.Take(120).Select(r => new
|
||||
{
|
||||
r.Id,
|
||||
r.Name,
|
||||
r.MealCategory,
|
||||
r.Calories,
|
||||
r.Protein,
|
||||
r.Fat,
|
||||
r.Carbs,
|
||||
});
|
||||
|
||||
var system = """
|
||||
You are a meal planner. Pick ONLY recipe ids from the provided catalog.
|
||||
Respond JSON: {"days":[{"date":"YYYY-MM-DD","meals":[{"mealCategory":0,"recipeId":1,"portions":1.0}]}]}
|
||||
Each day must use allowed meal categories only.
|
||||
Never reuse the same recipeId anywhere in the plan — each recipe may appear at most once across all days.
|
||||
""";
|
||||
var user = JsonSerializer.Serialize(new
|
||||
{
|
||||
targetCaloriesPerDay = session.ProposedCalories,
|
||||
toleranceKcal = session.CalorieToleranceKcal,
|
||||
mealCategories,
|
||||
planStartDate = start.ToString("yyyy-MM-dd"),
|
||||
planDayCount = session.PlanDayCount,
|
||||
recipes = compact,
|
||||
});
|
||||
|
||||
var json = await _openAi.CompleteJsonAsync(system, user, OpenAiUseCase.Plan, ct);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var poolMap = pool.ToDictionary(r => r.Id);
|
||||
var allSlots = new List<DraftMealSlot>();
|
||||
|
||||
if (!doc.RootElement.TryGetProperty("days", out var daysEl)) return BuildGreedyPlan(session, pool, mealCategories, start, usedRecipes);
|
||||
|
||||
foreach (var dayEl in daysEl.EnumerateArray())
|
||||
{
|
||||
var dateStr = dayEl.GetProperty("date").GetString();
|
||||
if (!DateOnly.TryParse(dateStr, out var date)) continue;
|
||||
|
||||
var daySlots = new List<DraftMealSlot>();
|
||||
var perSlot = mealCategories.Count > 0
|
||||
? session.ProposedCalories!.Value / mealCategories.Count
|
||||
: session.ProposedCalories!.Value;
|
||||
foreach (var mealEl in dayEl.GetProperty("meals").EnumerateArray())
|
||||
{
|
||||
var category = mealEl.GetProperty("mealCategory").GetInt32();
|
||||
var recipeId = mealEl.GetProperty("recipeId").GetInt32();
|
||||
DraftMealSlot slot;
|
||||
|
||||
if (!poolMap.TryGetValue(recipeId, out var recipe) || usedRecipes.Contains(recipeId))
|
||||
{
|
||||
slot = MealPlanOptimizer.PickGreedyMeal(pool, category, perSlot, usedRecipes);
|
||||
slot.PlanDate = date;
|
||||
}
|
||||
else
|
||||
{
|
||||
var portions = mealEl.TryGetProperty("portions", out var p) ? p.GetDecimal() : 1m;
|
||||
slot = new DraftMealSlot
|
||||
{
|
||||
PlanDate = date,
|
||||
MealCategory = category,
|
||||
RecipeId = recipeId,
|
||||
Portions = Math.Clamp(portions, 0.5m, 2.5m),
|
||||
CaloriesPerPortion = recipe.Calories,
|
||||
ProteinPerPortion = recipe.Protein,
|
||||
FatPerPortion = recipe.Fat,
|
||||
CarbsPerPortion = recipe.Carbs,
|
||||
};
|
||||
}
|
||||
|
||||
daySlots.Add(slot);
|
||||
usedRecipes.Add(slot.RecipeId);
|
||||
}
|
||||
|
||||
if (daySlots.Count == 0) continue;
|
||||
MealPlanOptimizer.BalanceDay(daySlots, session.ProposedCalories!.Value, session.CalorieToleranceKcal);
|
||||
allSlots.AddRange(daySlots);
|
||||
}
|
||||
|
||||
if (allSlots.Count == 0) return BuildGreedyPlan(session, pool, mealCategories, start, usedRecipes);
|
||||
return allSlots;
|
||||
}
|
||||
|
||||
private async Task<List<RecipeCandidate>> LoadRecipePoolAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
return await _db.Recipes.AsNoTracking()
|
||||
.Where(r => r.IsActive && !r.IsDraft && r.Calories.HasValue)
|
||||
.Where(r => r.CreatedByUserId == null || r.CreatedByUserId == userId)
|
||||
.Select(r => new RecipeCandidate(
|
||||
r.Id,
|
||||
r.Name,
|
||||
r.MealCategory,
|
||||
r.Calories!.Value,
|
||||
r.Protein ?? 0,
|
||||
r.Fat ?? 0,
|
||||
r.Carbs ?? 0))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
private async Task<DietGeneratorSession?> GetEditableSessionAsync(Guid userId, int sessionId, CancellationToken ct) =>
|
||||
await _db.DietGeneratorSessions.FirstOrDefaultAsync(
|
||||
s => s.Id == sessionId && s.UserId == userId && s.Status != DietGeneratorStatuses.Committed, ct);
|
||||
|
||||
private Task<DietGeneratorSessionDto?> MapSessionAsync(int sessionId, CancellationToken ct) =>
|
||||
MapSessionAsync(sessionId, null, ct);
|
||||
|
||||
private async Task<DietGeneratorSessionDto?> MapSessionAsync(int sessionId, Guid? userId, CancellationToken ct)
|
||||
{
|
||||
var query = _db.DietGeneratorSessions.AsNoTracking()
|
||||
.Include(s => s.DraftMeals)
|
||||
.ThenInclude(m => m.Recipe)
|
||||
.ThenInclude(r => r!.Steps)
|
||||
.Include(s => s.DraftMeals)
|
||||
.ThenInclude(m => m.PlannedIngredients)
|
||||
.Where(s => s.Id == sessionId);
|
||||
|
||||
if (userId.HasValue) query = query.Where(s => s.UserId == userId.Value);
|
||||
|
||||
var session = await query.FirstOrDefaultAsync(ct);
|
||||
if (session is null) return null;
|
||||
|
||||
var meals = session.DraftMeals
|
||||
.OrderBy(m => m.PlanDate)
|
||||
.ThenBy(m => m.MealCategory)
|
||||
.Select(m => new DietGeneratorDraftMealDto
|
||||
{
|
||||
Id = m.Id,
|
||||
PlanDate = m.PlanDate,
|
||||
MealCategory = m.MealCategory,
|
||||
RecipeId = m.RecipeId,
|
||||
RecipeName = m.Recipe?.Name ?? string.Empty,
|
||||
Portions = m.Portions,
|
||||
Calories = m.Calories,
|
||||
ProteinG = m.ProteinG,
|
||||
FatG = m.FatG,
|
||||
CarbsG = m.CarbsG,
|
||||
IsLocked = m.IsLocked,
|
||||
PrepTimeMinutes = m.Recipe?.PrepTimeMinutes,
|
||||
Ingredients = m.PlannedIngredients
|
||||
.OrderBy(i => i.SortOrder)
|
||||
.ThenBy(i => i.Id)
|
||||
.Select(i => new DietGeneratorDraftMealIngredientDto
|
||||
{
|
||||
Id = i.Id,
|
||||
Name = i.Name,
|
||||
AmountGrams = i.AmountGrams,
|
||||
Unit = i.Unit,
|
||||
SortOrder = i.SortOrder,
|
||||
SourceIngredientId = i.SourceIngredientId,
|
||||
})
|
||||
.ToList(),
|
||||
Steps = (m.Recipe?.Steps ?? [])
|
||||
.OrderBy(s => s.StepNumber)
|
||||
.Select(s => new DietGeneratorDraftMealStepDto
|
||||
{
|
||||
Id = s.Id,
|
||||
StepNumber = s.StepNumber,
|
||||
Description = s.Description,
|
||||
})
|
||||
.ToList(),
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var summaries = meals
|
||||
.GroupBy(m => m.PlanDate)
|
||||
.Select(g => new DietGeneratorDaySummaryDto
|
||||
{
|
||||
PlanDate = g.Key,
|
||||
TotalCalories = g.Sum(x => x.Calories ?? 0),
|
||||
TargetCalories = session.ProposedCalories ?? 0,
|
||||
WithinTolerance = session.ProposedCalories.HasValue &&
|
||||
Math.Abs(g.Sum(x => x.Calories ?? 0) - session.ProposedCalories.Value) <= session.CalorieToleranceKcal,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new DietGeneratorSessionDto
|
||||
{
|
||||
Id = session.Id,
|
||||
Status = session.Status,
|
||||
ProposedMacros = session.ProposedCalories.HasValue
|
||||
? new MacroProposalDto
|
||||
{
|
||||
Calories = session.ProposedCalories.Value,
|
||||
ProteinG = session.ProposedProteinG ?? 0,
|
||||
FatG = session.ProposedFatG ?? 0,
|
||||
CarbsG = session.ProposedCarbsG ?? 0,
|
||||
Rationale = session.MacroRationale,
|
||||
}
|
||||
: null,
|
||||
PlanStartDate = session.PlanStartDate,
|
||||
PlanDayCount = session.PlanDayCount,
|
||||
CalorieToleranceKcal = session.CalorieToleranceKcal,
|
||||
DraftMeals = meals,
|
||||
DaySummaries = summaries,
|
||||
};
|
||||
}
|
||||
|
||||
private static DietUserProfileDto MapProfile(DietUserProfile profile) => new()
|
||||
{
|
||||
HeightCm = profile.HeightCm,
|
||||
WeightKg = profile.WeightKg,
|
||||
Age = profile.Age,
|
||||
Sex = profile.Sex,
|
||||
ActivityLevel = profile.ActivityLevel,
|
||||
Goal = profile.Goal,
|
||||
AllergiesNotes = profile.AllergiesNotes,
|
||||
DislikedFoods = profile.DislikedFoods,
|
||||
MealsPerDayMask = profile.MealsPerDayMask,
|
||||
};
|
||||
}
|
||||
64
MealPlan.Api/Services/Generators/IngredientScalingHelper.cs
Normal file
64
MealPlan.Api/Services/Generators/IngredientScalingHelper.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
namespace MealPlan.Api.Services.Generators;
|
||||
|
||||
public static class IngredientScalingHelper
|
||||
{
|
||||
private const decimal CountAmountThreshold = 10m;
|
||||
|
||||
public static (decimal? AmountGrams, string? Unit) ScaleForDietPlan(
|
||||
decimal? amountGrams,
|
||||
string? unit,
|
||||
decimal portions)
|
||||
{
|
||||
if (IsNonQuantifiedUnit(unit))
|
||||
{
|
||||
return (amountGrams, unit);
|
||||
}
|
||||
|
||||
if (amountGrams is not > 0)
|
||||
{
|
||||
return (null, unit);
|
||||
}
|
||||
|
||||
if (IsCountUnit(unit) && amountGrams.Value <= CountAmountThreshold)
|
||||
{
|
||||
var gramsPerUnit = GramsPerCountUnit(unit!);
|
||||
var totalGrams = amountGrams.Value * gramsPerUnit * portions;
|
||||
return (Math.Round(totalGrams, 1), null);
|
||||
}
|
||||
|
||||
// AmountGrams stores weight in grams (even when the display unit says szt./ząbek).
|
||||
return (Math.Round(amountGrams.Value * portions, 1), null);
|
||||
}
|
||||
|
||||
public static bool IsNonQuantifiedUnit(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";
|
||||
}
|
||||
|
||||
private static bool IsCountUnit(string? unit)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(unit)) return false;
|
||||
var u = NormalizeUnit(unit);
|
||||
return u is "szt" or "sztuka" or "sztuki"
|
||||
or "ząbek" or "zabek" or "zabki" or "clove" or "cloves"
|
||||
or "łyżka" or "lyzka" or "łyżeczka" or "lyzeczka"
|
||||
or "jajko" or "jajka" or "egg" or "eggs";
|
||||
}
|
||||
|
||||
private static decimal GramsPerCountUnit(string unit)
|
||||
{
|
||||
var u = NormalizeUnit(unit);
|
||||
return u switch
|
||||
{
|
||||
"ząbek" or "zabek" or "zabki" or "clove" or "cloves" => 4m,
|
||||
"łyżka" or "lyzka" => 15m,
|
||||
"łyżeczka" or "lyzeczka" => 5m,
|
||||
_ => 60m, // szt., jajko — typical single egg / piece default
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeUnit(string unit) =>
|
||||
unit.Trim().ToLowerInvariant().TrimEnd('.');
|
||||
}
|
||||
221
MealPlan.Api/Services/Generators/MealPlanOptimizer.cs
Normal file
221
MealPlan.Api/Services/Generators/MealPlanOptimizer.cs
Normal file
@@ -0,0 +1,221 @@
|
||||
namespace MealPlan.Api.Services.Generators;
|
||||
|
||||
public record RecipeCandidate(
|
||||
int Id,
|
||||
string Name,
|
||||
int MealCategory,
|
||||
int Calories,
|
||||
decimal Protein,
|
||||
decimal Fat,
|
||||
decimal Carbs);
|
||||
|
||||
public class DraftMealSlot
|
||||
{
|
||||
public DateOnly PlanDate { get; set; }
|
||||
public int MealCategory { get; set; }
|
||||
public int RecipeId { get; set; }
|
||||
public decimal Portions { get; set; } = 1m;
|
||||
public int CaloriesPerPortion { get; set; }
|
||||
public decimal ProteinPerPortion { get; set; }
|
||||
public decimal FatPerPortion { get; set; }
|
||||
public decimal CarbsPerPortion { get; set; }
|
||||
public bool IsLocked { get; set; }
|
||||
|
||||
public int TotalCalories => (int)Math.Round(CaloriesPerPortion * Portions);
|
||||
public decimal TotalProtein => ProteinPerPortion * Portions;
|
||||
public decimal TotalFat => FatPerPortion * Portions;
|
||||
public decimal TotalCarbs => CarbsPerPortion * Portions;
|
||||
|
||||
public DraftMealSlot CloneForDate(DateOnly planDate) =>
|
||||
new()
|
||||
{
|
||||
PlanDate = planDate,
|
||||
MealCategory = MealCategory,
|
||||
RecipeId = RecipeId,
|
||||
Portions = Portions,
|
||||
CaloriesPerPortion = CaloriesPerPortion,
|
||||
ProteinPerPortion = ProteinPerPortion,
|
||||
FatPerPortion = FatPerPortion,
|
||||
CarbsPerPortion = CarbsPerPortion,
|
||||
IsLocked = IsLocked,
|
||||
};
|
||||
}
|
||||
|
||||
public static class MealPlanOptimizer
|
||||
{
|
||||
public static IReadOnlyList<int> MealCategoriesFromMask(int mask) =>
|
||||
Enumerable.Range(0, 5).Where(i => (mask & (1 << i)) != 0).ToList();
|
||||
|
||||
public static void BalanceDay(IList<DraftMealSlot> meals, int targetCalories, int tolerance)
|
||||
{
|
||||
if (meals.Count == 0) return;
|
||||
|
||||
var unlocked = meals.Where(m => !m.IsLocked).ToList();
|
||||
if (unlocked.Count == 0) return;
|
||||
|
||||
var current = meals.Sum(m => m.TotalCalories);
|
||||
if (Math.Abs(current - targetCalories) <= tolerance) return;
|
||||
|
||||
if (current <= 0)
|
||||
{
|
||||
foreach (var meal in unlocked)
|
||||
{
|
||||
meal.Portions = 1m;
|
||||
}
|
||||
|
||||
current = meals.Sum(m => m.TotalCalories);
|
||||
}
|
||||
|
||||
if (current <= 0) return;
|
||||
|
||||
var scale = (decimal)targetCalories / current;
|
||||
foreach (var meal in unlocked)
|
||||
{
|
||||
meal.Portions = Math.Clamp(Math.Round(meal.Portions * scale, 2), 0.5m, 2.5m);
|
||||
}
|
||||
|
||||
current = meals.Sum(m => m.TotalCalories);
|
||||
if (Math.Abs(current - targetCalories) <= tolerance) return;
|
||||
|
||||
var diff = targetCalories - current;
|
||||
var adjustable = unlocked.OrderByDescending(m => m.CaloriesPerPortion).FirstOrDefault();
|
||||
if (adjustable is null || adjustable.CaloriesPerPortion <= 0) return;
|
||||
|
||||
var portionDelta = diff / (decimal)adjustable.CaloriesPerPortion;
|
||||
adjustable.Portions = Math.Clamp(Math.Round(adjustable.Portions + portionDelta, 2), 0.5m, 2.5m);
|
||||
}
|
||||
|
||||
public static bool IsDayWithinTolerance(IEnumerable<DraftMealSlot> meals, int targetCalories, int tolerance) =>
|
||||
Math.Abs(meals.Sum(m => m.TotalCalories) - targetCalories) <= tolerance;
|
||||
|
||||
private static RecipeCandidate? FindGreedyCandidate(
|
||||
IReadOnlyList<RecipeCandidate> pool,
|
||||
int mealCategory,
|
||||
int targetCaloriesForSlot,
|
||||
HashSet<int> excludedRecipeIds)
|
||||
{
|
||||
var candidates = pool
|
||||
.Where(r => r.MealCategory == mealCategory && !excludedRecipeIds.Contains(r.Id))
|
||||
.OrderBy(r => Math.Abs(r.Calories - targetCaloriesForSlot))
|
||||
.ToList();
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
candidates = pool
|
||||
.Where(r => !excludedRecipeIds.Contains(r.Id))
|
||||
.OrderBy(r => Math.Abs(r.Calories - targetCaloriesForSlot))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return candidates.FirstOrDefault();
|
||||
}
|
||||
|
||||
public static bool TryPickGreedyMeal(
|
||||
IReadOnlyList<RecipeCandidate> pool,
|
||||
int mealCategory,
|
||||
int targetCaloriesForSlot,
|
||||
HashSet<int> excludedRecipeIds,
|
||||
out DraftMealSlot slot)
|
||||
{
|
||||
var recipe = FindGreedyCandidate(pool, mealCategory, targetCaloriesForSlot, excludedRecipeIds);
|
||||
if (recipe is null)
|
||||
{
|
||||
slot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
var portions = recipe.Calories > 0
|
||||
? Math.Clamp(Math.Round((decimal)targetCaloriesForSlot / recipe.Calories, 2), 0.5m, 2.5m)
|
||||
: 1m;
|
||||
|
||||
slot = new DraftMealSlot
|
||||
{
|
||||
MealCategory = mealCategory,
|
||||
RecipeId = recipe.Id,
|
||||
Portions = portions,
|
||||
CaloriesPerPortion = recipe.Calories,
|
||||
ProteinPerPortion = recipe.Protein,
|
||||
FatPerPortion = recipe.Fat,
|
||||
CarbsPerPortion = recipe.Carbs,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
public static DraftMealSlot PickGreedyMeal(
|
||||
IReadOnlyList<RecipeCandidate> pool,
|
||||
int mealCategory,
|
||||
int targetCaloriesForSlot,
|
||||
HashSet<int> excludedRecipeIds)
|
||||
{
|
||||
if (TryPickGreedyMeal(pool, mealCategory, targetCaloriesForSlot, excludedRecipeIds, out var slot))
|
||||
{
|
||||
return slot;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("No recipes available for meal planning.");
|
||||
}
|
||||
|
||||
public static IReadOnlyList<DraftMealSlot> BuildGreedyDay(
|
||||
DateOnly planDate,
|
||||
IReadOnlyList<RecipeCandidate> pool,
|
||||
IReadOnlyList<int> mealCategories,
|
||||
int targetCalories,
|
||||
int tolerance,
|
||||
HashSet<int> excludedRecipeIds)
|
||||
{
|
||||
var perSlot = mealCategories.Count > 0 ? targetCalories / mealCategories.Count : targetCalories;
|
||||
var meals = new List<DraftMealSlot>();
|
||||
|
||||
foreach (var category in mealCategories)
|
||||
{
|
||||
var slot = PickGreedyMeal(pool, category, perSlot, excludedRecipeIds);
|
||||
slot.PlanDate = planDate;
|
||||
meals.Add(slot);
|
||||
excludedRecipeIds.Add(slot.RecipeId);
|
||||
}
|
||||
|
||||
BalanceDay(meals, targetCalories, tolerance);
|
||||
return meals;
|
||||
}
|
||||
|
||||
public static int RequiredUniqueRecipes(int planDayCount, IReadOnlyList<int> mealCategories) =>
|
||||
planDayCount * mealCategories.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Replaces duplicate recipe picks so each recipe appears at most once in the plan.
|
||||
/// </summary>
|
||||
public static void EnsureWeeklyUniqueRecipes(
|
||||
IList<DraftMealSlot> slots,
|
||||
IReadOnlyList<RecipeCandidate> pool,
|
||||
IReadOnlyList<int> mealCategories,
|
||||
int targetCalories,
|
||||
int tolerance)
|
||||
{
|
||||
if (slots.Count == 0) return;
|
||||
|
||||
var used = new HashSet<int>();
|
||||
var perSlot = mealCategories.Count > 0 ? targetCalories / mealCategories.Count : targetCalories;
|
||||
|
||||
foreach (var slot in slots.OrderBy(s => s.PlanDate).ThenBy(s => s.MealCategory))
|
||||
{
|
||||
if (used.Add(slot.RecipeId)) continue;
|
||||
|
||||
var replacement = PickGreedyMeal(pool, slot.MealCategory, perSlot, used);
|
||||
CopyRecipeIntoSlot(slot, replacement);
|
||||
used.Add(slot.RecipeId);
|
||||
|
||||
var daySlots = slots.Where(s => s.PlanDate == slot.PlanDate).ToList();
|
||||
BalanceDay(daySlots, targetCalories, tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyRecipeIntoSlot(DraftMealSlot target, DraftMealSlot source)
|
||||
{
|
||||
target.RecipeId = source.RecipeId;
|
||||
target.Portions = source.Portions;
|
||||
target.CaloriesPerPortion = source.CaloriesPerPortion;
|
||||
target.ProteinPerPortion = source.ProteinPerPortion;
|
||||
target.FatPerPortion = source.FatPerPortion;
|
||||
target.CarbsPerPortion = source.CarbsPerPortion;
|
||||
}
|
||||
}
|
||||
683
MealPlan.Api/Services/Generators/RecipeGeneratorService.cs
Normal file
683
MealPlan.Api/Services/Generators/RecipeGeneratorService.cs
Normal file
@@ -0,0 +1,683 @@
|
||||
using System.Text.Json;
|
||||
using MealPlan.Api.Data;
|
||||
using MealPlan.Api.DTOs.Generators;
|
||||
using MealPlan.Api.DTOs.Recipe;
|
||||
using MealPlan.Api.Models;
|
||||
using MealPlan.Api.Services.AI;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
|
||||
namespace MealPlan.Api.Services.Generators;
|
||||
|
||||
public interface IRecipeGeneratorService
|
||||
{
|
||||
Task<RecipeGeneratorSessionDto> CreateSessionAsync(Guid userId, RecipeGeneratorConstraintsDto dto, CancellationToken ct = default);
|
||||
Task<RecipeGeneratorSessionDto?> GetSessionAsync(Guid userId, int sessionId, CancellationToken ct = default);
|
||||
Task<RecipeGeneratorSessionDto?> GenerateAsync(Guid userId, int sessionId, GenerateRecipesRequestDto? request, CancellationToken ct = default);
|
||||
Task<RecipeGeneratorSessionDto?> RegeneratePartAsync(Guid userId, int sessionId, RegenerateRecipePartDto dto, CancellationToken ct = default);
|
||||
Task<RecipeGeneratorSessionDto?> UpdateDraftAsync(Guid userId, int sessionId, GeneratedRecipeDraftDto dto, CancellationToken ct = default);
|
||||
Task<RecipeGeneratorSessionDto?> RemoveDraftAsync(Guid userId, int sessionId, string draftId, CancellationToken ct = default);
|
||||
Task<CommitRecipesResultDto?> CommitAsync(Guid userId, int sessionId, CommitRecipesRequestDto? request, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class RecipeGeneratorService : IRecipeGeneratorService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IOpenAiChatService _openAi;
|
||||
private readonly ICatalogMatchingService _catalogMatching;
|
||||
private readonly IRecipeManagementService _recipeManagement;
|
||||
|
||||
public RecipeGeneratorService(
|
||||
AppDbContext db,
|
||||
IOpenAiChatService openAi,
|
||||
ICatalogMatchingService catalogMatching,
|
||||
IRecipeManagementService recipeManagement)
|
||||
{
|
||||
_db = db;
|
||||
_openAi = openAi;
|
||||
_catalogMatching = catalogMatching;
|
||||
_recipeManagement = recipeManagement;
|
||||
}
|
||||
|
||||
public async Task<RecipeGeneratorSessionDto> CreateSessionAsync(
|
||||
Guid userId,
|
||||
RecipeGeneratorConstraintsDto dto,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var session = new RecipeGeneratorSession
|
||||
{
|
||||
UserId = userId,
|
||||
Status = RecipeGeneratorStatuses.Draft,
|
||||
ConstraintsJson = JsonSerializer.Serialize(dto),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
_db.RecipeGeneratorSessions.Add(session);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return MapSession(session, dto, []);
|
||||
}
|
||||
|
||||
public async Task<RecipeGeneratorSessionDto?> GetSessionAsync(Guid userId, int sessionId, CancellationToken ct = default)
|
||||
{
|
||||
var session = await _db.RecipeGeneratorSessions.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId, ct);
|
||||
if (session is null) return null;
|
||||
var constraints = DeserializeConstraints(session.ConstraintsJson);
|
||||
var drafts = DeserializeDrafts(session.DraftJson);
|
||||
return MapSession(session, constraints, drafts);
|
||||
}
|
||||
|
||||
public async Task<RecipeGeneratorSessionDto?> GenerateAsync(
|
||||
Guid userId,
|
||||
int sessionId,
|
||||
GenerateRecipesRequestDto? request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var session = await _db.RecipeGeneratorSessions
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId && s.Status == RecipeGeneratorStatuses.Draft, ct);
|
||||
if (session is null) return null;
|
||||
|
||||
if (!_openAi.IsConfigured)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"OpenAI is not configured. Set OpenAI__ApiKey in appsettings.Local.json.");
|
||||
}
|
||||
|
||||
var count = Math.Clamp(request?.Count ?? 3, 1, 8);
|
||||
var constraints = DeserializeConstraints(session.ConstraintsJson);
|
||||
if (string.IsNullOrWhiteSpace(constraints.Prompt))
|
||||
{
|
||||
throw new InvalidOperationException("Recipe prompt is required.");
|
||||
}
|
||||
|
||||
var catalogSample = await _db.IngredientNutritionCatalog.AsNoTracking()
|
||||
.Where(i => i.IsActive)
|
||||
.OrderBy(i => i.Name)
|
||||
.Take(80)
|
||||
.Select(i => new { i.Name, i.NamePl, i.CaloriesPer100G, i.ProteinGPer100G, i.FatGPer100G, i.CarbsGPer100G })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var drafts = await GenerateDistinctDraftsAsync(constraints, catalogSample, count, ct);
|
||||
if (drafts.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("AI did not return any valid recipes. Try again.");
|
||||
}
|
||||
|
||||
var enriched = new List<GeneratedRecipeDraftDto>();
|
||||
foreach (var draft in drafts)
|
||||
{
|
||||
enriched.Add(await EnrichDraftAsync(EnsureDraftId(draft), constraints, ct));
|
||||
}
|
||||
|
||||
session.DraftJson = SerializeDrafts(enriched);
|
||||
session.GenerationVersion++;
|
||||
session.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return MapSession(session, constraints, enriched);
|
||||
}
|
||||
|
||||
public async Task<RecipeGeneratorSessionDto?> RegeneratePartAsync(
|
||||
Guid userId,
|
||||
int sessionId,
|
||||
RegenerateRecipePartDto dto,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var session = await _db.RecipeGeneratorSessions
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId && s.Status == RecipeGeneratorStatuses.Draft, ct);
|
||||
if (session is null) return null;
|
||||
|
||||
var constraints = DeserializeConstraints(session.ConstraintsJson);
|
||||
var drafts = DeserializeDrafts(session.DraftJson);
|
||||
if (string.IsNullOrWhiteSpace(dto.DraftId))
|
||||
return MapSession(session, constraints, drafts);
|
||||
|
||||
var index = drafts.FindIndex(d => d.DraftId == dto.DraftId);
|
||||
if (index < 0) return null;
|
||||
|
||||
var current = drafts[index];
|
||||
if (!_openAi.IsConfigured)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"OpenAI is not configured. Set OpenAI__ApiKey in appsettings.Local.json.");
|
||||
}
|
||||
|
||||
var mode = dto.Mode.ToLowerInvariant();
|
||||
var system = mode switch
|
||||
{
|
||||
"steps" => """
|
||||
Rewrite ONLY cooking steps for the recipe. JSON: {"steps":[{"stepNumber":1,"description":"..."}]}
|
||||
Keep ingredients unchanged.
|
||||
""",
|
||||
"ingredients" => """
|
||||
Rewrite ONLY ingredients list. JSON: {"ingredients":[{"name":"","amountGrams":0,"unit":null,"catalogName":""}]}
|
||||
Keep name and steps unchanged.
|
||||
""",
|
||||
_ => """
|
||||
Improve the full recipe. Respond ONLY JSON with name, mealCategory, prepTimeMinutes, ingredients, steps.
|
||||
Use JSON numbers (not strings) for mealCategory, prepTimeMinutes, amountGrams, stepNumber.
|
||||
""",
|
||||
};
|
||||
|
||||
var user = JsonSerializer.Serialize(new
|
||||
{
|
||||
instruction = dto.Instruction ?? constraints.Prompt,
|
||||
current,
|
||||
constraints,
|
||||
});
|
||||
|
||||
var json = await _openAi.CompleteJsonAsync(system, user, OpenAiUseCase.Recipe, ct);
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (mode == "steps" && root.TryGetProperty("steps", out var stepsEl))
|
||||
{
|
||||
current.Steps = ParseSteps(stepsEl);
|
||||
}
|
||||
else if (mode == "ingredients" && root.TryGetProperty("ingredients", out var ingEl))
|
||||
{
|
||||
current.Ingredients = await ParseIngredientsAsync(ingEl, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
var parsed = ParseDraft(json, constraints.MealCategory);
|
||||
parsed.DraftId = current.DraftId;
|
||||
current = parsed;
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException("Could not parse AI recipe response. Try again.", ex);
|
||||
}
|
||||
|
||||
current = await EnrichDraftAsync(EnsureDraftId(current), constraints, ct);
|
||||
drafts[index] = current;
|
||||
session.DraftJson = SerializeDrafts(drafts);
|
||||
session.GenerationVersion++;
|
||||
session.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return MapSession(session, constraints, drafts);
|
||||
}
|
||||
|
||||
public async Task<RecipeGeneratorSessionDto?> UpdateDraftAsync(
|
||||
Guid userId,
|
||||
int sessionId,
|
||||
GeneratedRecipeDraftDto dto,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var session = await _db.RecipeGeneratorSessions
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId && s.Status == RecipeGeneratorStatuses.Draft, ct);
|
||||
if (session is null) return null;
|
||||
|
||||
var drafts = DeserializeDrafts(session.DraftJson);
|
||||
var constraints = DeserializeConstraints(session.ConstraintsJson);
|
||||
var enriched = await EnrichDraftAsync(EnsureDraftId(dto), constraints, ct);
|
||||
var index = drafts.FindIndex(d => d.DraftId == enriched.DraftId);
|
||||
if (index < 0)
|
||||
{
|
||||
drafts.Add(enriched);
|
||||
}
|
||||
else
|
||||
{
|
||||
drafts[index] = enriched;
|
||||
}
|
||||
|
||||
session.DraftJson = SerializeDrafts(drafts);
|
||||
session.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return MapSession(session, constraints, drafts);
|
||||
}
|
||||
|
||||
public async Task<RecipeGeneratorSessionDto?> RemoveDraftAsync(
|
||||
Guid userId,
|
||||
int sessionId,
|
||||
string draftId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var session = await _db.RecipeGeneratorSessions
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId && s.Status == RecipeGeneratorStatuses.Draft, ct);
|
||||
if (session is null) return null;
|
||||
|
||||
var drafts = DeserializeDrafts(session.DraftJson);
|
||||
var removed = drafts.RemoveAll(d => d.DraftId == draftId);
|
||||
if (removed == 0) return null;
|
||||
|
||||
session.DraftJson = SerializeDrafts(drafts);
|
||||
session.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
var constraints = DeserializeConstraints(session.ConstraintsJson);
|
||||
return MapSession(session, constraints, drafts);
|
||||
}
|
||||
|
||||
public async Task<CommitRecipesResultDto?> CommitAsync(
|
||||
Guid userId,
|
||||
int sessionId,
|
||||
CommitRecipesRequestDto? request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var session = await _db.RecipeGeneratorSessions
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId && s.Status == RecipeGeneratorStatuses.Draft, ct);
|
||||
if (session is null || string.IsNullOrWhiteSpace(session.DraftJson)) return null;
|
||||
|
||||
var drafts = DeserializeDrafts(session.DraftJson);
|
||||
if (drafts.Count == 0) return null;
|
||||
|
||||
var targetIds = request?.DraftIds?.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList();
|
||||
var toSave = targetIds is { Count: > 0 }
|
||||
? drafts.Where(d => targetIds.Contains(d.DraftId)).ToList()
|
||||
: drafts.ToList();
|
||||
|
||||
if (toSave.Count == 0) return null;
|
||||
|
||||
var saved = new List<CommitRecipeResultDto>();
|
||||
foreach (var draft in toSave)
|
||||
{
|
||||
if (draft.Ingredients.Count == 0 || draft.Steps.Count == 0) continue;
|
||||
|
||||
var input = new CreateRecipeDto
|
||||
{
|
||||
Name = draft.Name,
|
||||
MealCategory = draft.MealCategory,
|
||||
Calories = draft.Calories,
|
||||
Protein = draft.Protein,
|
||||
Fat = draft.Fat,
|
||||
Carbs = draft.Carbs,
|
||||
PrepTimeMinutes = draft.PrepTimeMinutes,
|
||||
Ingredients = draft.Ingredients.Select((i, index) => new CreateIngredientDto
|
||||
{
|
||||
Name = i.Name,
|
||||
AmountGrams = i.AmountGrams,
|
||||
Unit = i.Unit,
|
||||
SortOrder = index,
|
||||
CatalogItemId = i.CatalogItemId,
|
||||
}).ToList(),
|
||||
Steps = draft.Steps.Select(s => new CreateRecipeStepDto
|
||||
{
|
||||
StepNumber = s.StepNumber,
|
||||
Description = s.Description,
|
||||
}).ToList(),
|
||||
};
|
||||
|
||||
CommitRecipeResultDto savedEntry;
|
||||
try
|
||||
{
|
||||
var created = await _recipeManagement.CreateRecipeAsync(input, ct);
|
||||
var entity = await _db.Recipes.FirstAsync(r => r.Id == created.Id, ct);
|
||||
entity.Source = "ai_generated";
|
||||
entity.CreatedByUserId = userId;
|
||||
entity.IsDraft = false;
|
||||
|
||||
savedEntry = new CommitRecipeResultDto
|
||||
{
|
||||
SessionId = sessionId,
|
||||
RecipeId = created.Id,
|
||||
RecipeName = created.Name,
|
||||
DraftId = draft.DraftId,
|
||||
};
|
||||
}
|
||||
catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" })
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Could not save the recipe because the database recipe ID sequence is out of sync. " +
|
||||
"Run: dotnet run --project MealPlan.Api -- --fix-sequences",
|
||||
ex);
|
||||
}
|
||||
|
||||
saved.Add(savedEntry);
|
||||
}
|
||||
|
||||
if (saved.Count == 0) return null;
|
||||
|
||||
var savedIds = saved.Select(s => s.DraftId).ToHashSet();
|
||||
drafts.RemoveAll(d => savedIds.Contains(d.DraftId));
|
||||
session.DraftJson = SerializeDrafts(drafts);
|
||||
session.SavedRecipeId = saved[^1].RecipeId;
|
||||
session.Status = drafts.Count == 0 ? RecipeGeneratorStatuses.Saved : RecipeGeneratorStatuses.Draft;
|
||||
session.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
return new CommitRecipesResultDto
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Saved = saved,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<GeneratedRecipeDraftDto> EnrichDraftAsync(
|
||||
GeneratedRecipeDraftDto draft,
|
||||
RecipeGeneratorConstraintsDto constraints,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var ingredients = new List<GeneratedIngredientDto>();
|
||||
foreach (var ing in draft.Ingredients)
|
||||
{
|
||||
var catalogId = ing.CatalogItemId;
|
||||
if (!catalogId.HasValue)
|
||||
{
|
||||
catalogId = await _catalogMatching.ResolveCatalogItemIdAsync(
|
||||
ing.CatalogName ?? ing.Name, ct);
|
||||
}
|
||||
|
||||
ingredients.Add(new GeneratedIngredientDto
|
||||
{
|
||||
Name = ing.Name,
|
||||
AmountGrams = ing.AmountGrams,
|
||||
Unit = ing.Unit,
|
||||
CatalogItemId = catalogId,
|
||||
CatalogName = ing.CatalogName,
|
||||
IsLinked = catalogId.HasValue,
|
||||
});
|
||||
}
|
||||
|
||||
draft.Ingredients = ingredients;
|
||||
draft.UnlinkedIngredientCount = ingredients.Count(i =>
|
||||
i.AmountGrams is > 0 &&
|
||||
!IsSpiceUnit(i.Unit) &&
|
||||
!i.IsLinked);
|
||||
|
||||
await ScaleDraftToTargetCaloriesAsync(draft, constraints, ct);
|
||||
await ApplyComputedMacrosAsync(draft, ct);
|
||||
return draft;
|
||||
}
|
||||
|
||||
private async Task ApplyComputedMacrosAsync(GeneratedRecipeDraftDto draft, CancellationToken ct)
|
||||
{
|
||||
var macros = await ComputeMacrosAsync(draft.Ingredients, ct);
|
||||
if (!macros.HasValue) return;
|
||||
|
||||
draft.Calories = macros.Value.Calories;
|
||||
draft.Protein = macros.Value.Protein;
|
||||
draft.Fat = macros.Value.Fat;
|
||||
draft.Carbs = macros.Value.Carbs;
|
||||
}
|
||||
|
||||
private async Task ScaleDraftToTargetCaloriesAsync(
|
||||
GeneratedRecipeDraftDto draft,
|
||||
RecipeGeneratorConstraintsDto constraints,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (constraints.TargetCalories is not > 0) return;
|
||||
|
||||
var target = constraints.TargetCalories.Value;
|
||||
var tolerance = constraints.CalorieToleranceKcal > 0 ? constraints.CalorieToleranceKcal : 10;
|
||||
var currentMacros = await ComputeMacrosAsync(draft.Ingredients, ct);
|
||||
if (currentMacros is null || currentMacros.Value.Calories <= 0) return;
|
||||
if (Math.Abs(currentMacros.Value.Calories - target) <= tolerance) return;
|
||||
|
||||
var scale = Math.Clamp((decimal)target / currentMacros.Value.Calories, 0.25m, 4m);
|
||||
foreach (var ing in draft.Ingredients)
|
||||
{
|
||||
if (ing.CatalogItemId is null || ing.AmountGrams is not > 0 || IsSpiceUnit(ing.Unit)) continue;
|
||||
ing.AmountGrams = Math.Round(ing.AmountGrams.Value * scale, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(int Calories, decimal Protein, decimal Fat, decimal Carbs)?> ComputeMacrosAsync(
|
||||
IReadOnlyList<GeneratedIngredientDto> ingredients,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var ids = ingredients.Where(i => i.CatalogItemId.HasValue && i.AmountGrams is > 0 && !IsSpiceUnit(i.Unit))
|
||||
.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))
|
||||
.ToDictionaryAsync(i => i.Id, ct);
|
||||
|
||||
int calories = 0;
|
||||
decimal protein = 0, fat = 0, carbs = 0;
|
||||
foreach (var ing in ingredients)
|
||||
{
|
||||
if (ing.CatalogItemId is null || ing.AmountGrams is not > 0 || IsSpiceUnit(ing.Unit)) 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;
|
||||
}
|
||||
|
||||
return (calories, Math.Round(protein, 2), Math.Round(fat, 2), Math.Round(carbs, 2));
|
||||
}
|
||||
|
||||
private async Task<List<GeneratedRecipeDraftDto>> GenerateDistinctDraftsAsync(
|
||||
RecipeGeneratorConstraintsDto constraints,
|
||||
object catalogSample,
|
||||
int count,
|
||||
CancellationToken ct)
|
||||
{
|
||||
const int maxAttempts = 4;
|
||||
var drafts = new List<GeneratedRecipeDraftDto>();
|
||||
|
||||
for (var attempt = 0; attempt < maxAttempts && drafts.Count < count; attempt++)
|
||||
{
|
||||
var need = count - drafts.Count;
|
||||
var json = await RequestRecipeBatchJsonAsync(
|
||||
constraints,
|
||||
catalogSample,
|
||||
need,
|
||||
drafts.Select(d => d.Name).ToList(),
|
||||
count,
|
||||
ct);
|
||||
var parsed = ParseDrafts(json, constraints.MealCategory);
|
||||
|
||||
foreach (var draft in parsed)
|
||||
{
|
||||
if (IsDuplicateDraft(draft, drafts)) continue;
|
||||
drafts.Add(draft);
|
||||
if (drafts.Count >= count) break;
|
||||
}
|
||||
}
|
||||
|
||||
return drafts.Take(count).ToList();
|
||||
}
|
||||
|
||||
private async Task<string> RequestRecipeBatchJsonAsync(
|
||||
RecipeGeneratorConstraintsDto constraints,
|
||||
object catalogSample,
|
||||
int recipeCount,
|
||||
IReadOnlyList<string> excludeNames,
|
||||
int totalRequested,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var system = """
|
||||
You create realistic Polish home-cooking recipes for a meal planning app.
|
||||
Respond ONLY JSON:
|
||||
{"recipes":[{"name":"string","mealCategory":0,"prepTimeMinutes":20,"ingredients":[{"name":"Polish name","amountGrams":100,"unit":null,"catalogName":"English catalog name"}],"steps":[{"stepNumber":1,"description":"..."}]}]}
|
||||
Rules:
|
||||
- Follow constraints.prompt strictly for every recipe (dish type, main ingredients, style).
|
||||
- If constraints.targetCalories is set, each recipe MUST total about that many kcal using realistic ingredient grams.
|
||||
Aim within ±constraints.calorieToleranceKcal kcal. Do NOT put the calorie target only in the name — scale amounts.
|
||||
- Use catalogName from the provided catalog when possible; grams required for macro ingredients.
|
||||
- Spices may use unit "do smaku" with null amountGrams.
|
||||
- mealCategory: 0=Breakfast, 1=SecondBreakfast, 2=Lunch, 3=Dinner, 4=Snack.
|
||||
- Return exactly recipeCount recipes in the recipes array.
|
||||
- Each recipe MUST be clearly different (unique name, different ingredient mix, different preparation).
|
||||
- Do NOT repeat the same recipe with only a number suffix.
|
||||
- Do NOT copy any name from excludeExistingNames.
|
||||
""";
|
||||
var user = JsonSerializer.Serialize(new
|
||||
{
|
||||
constraints,
|
||||
catalog = catalogSample,
|
||||
recipeCount,
|
||||
totalRequested,
|
||||
excludeExistingNames = excludeNames,
|
||||
});
|
||||
return await _openAi.CompleteJsonAsync(system, user, OpenAiUseCase.Recipe, ct);
|
||||
}
|
||||
|
||||
private static bool IsDuplicateDraft(GeneratedRecipeDraftDto candidate, IReadOnlyList<GeneratedRecipeDraftDto> existing) =>
|
||||
existing.Any(existingDraft => DraftFingerprint(existingDraft) == DraftFingerprint(candidate));
|
||||
|
||||
private static string DraftFingerprint(GeneratedRecipeDraftDto draft)
|
||||
{
|
||||
var ingredients = string.Join(
|
||||
"|",
|
||||
draft.Ingredients
|
||||
.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(i => $"{i.Name.Trim().ToLowerInvariant()}:{i.AmountGrams}:{i.Unit}"));
|
||||
return $"{NormalizeRecipeName(draft.Name)}::{ingredients}";
|
||||
}
|
||||
|
||||
private static string NormalizeRecipeName(string name) =>
|
||||
name.Trim().ToLowerInvariant();
|
||||
|
||||
private GeneratedRecipeDraftDto ParseDraft(string json, int defaultCategory)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
return EnsureDraftId(new GeneratedRecipeDraftDto
|
||||
{
|
||||
Name = root.TryGetProperty("name", out var n) ? AiRecipeJsonParser.GetString(n) ?? "Nowy przepis" : "Nowy przepis",
|
||||
MealCategory = root.TryGetProperty("mealCategory", out var mc)
|
||||
? AiRecipeJsonParser.GetInt32(mc, defaultCategory)
|
||||
: defaultCategory,
|
||||
PrepTimeMinutes = root.TryGetProperty("prepTimeMinutes", out var pt)
|
||||
? AiRecipeJsonParser.GetNullableInt32(pt)
|
||||
: null,
|
||||
Ingredients = root.TryGetProperty("ingredients", out var ing) ? ParseIngredientsSync(ing) : [],
|
||||
Steps = root.TryGetProperty("steps", out var st) ? ParseSteps(st) : [],
|
||||
});
|
||||
}
|
||||
|
||||
private List<GeneratedRecipeDraftDto> ParseDrafts(string json, int defaultCategory)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
var list = new List<GeneratedRecipeDraftDto>();
|
||||
|
||||
if (root.TryGetProperty("recipes", out var recipesEl) && recipesEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in recipesEl.EnumerateArray())
|
||||
{
|
||||
list.Add(EnsureDraftId(ParseDraft(item.GetRawText(), defaultCategory)));
|
||||
}
|
||||
}
|
||||
else if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in root.EnumerateArray())
|
||||
{
|
||||
list.Add(EnsureDraftId(ParseDraft(item.GetRawText(), defaultCategory)));
|
||||
}
|
||||
}
|
||||
else if (root.TryGetProperty("name", out _))
|
||||
{
|
||||
list.Add(ParseDraft(json, defaultCategory));
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private List<GeneratedIngredientDto> ParseIngredientsSync(JsonElement ingEl)
|
||||
{
|
||||
var list = new List<GeneratedIngredientDto>();
|
||||
foreach (var item in ingEl.EnumerateArray())
|
||||
{
|
||||
list.Add(new GeneratedIngredientDto
|
||||
{
|
||||
Name = item.TryGetProperty("name", out var nameEl)
|
||||
? AiRecipeJsonParser.GetString(nameEl) ?? ""
|
||||
: "",
|
||||
AmountGrams = item.TryGetProperty("amountGrams", out var g)
|
||||
? AiRecipeJsonParser.GetNullableDecimal(g)
|
||||
: null,
|
||||
Unit = item.TryGetProperty("unit", out var u) && u.ValueKind != JsonValueKind.Null
|
||||
? AiRecipeJsonParser.GetString(u)
|
||||
: null,
|
||||
CatalogName = item.TryGetProperty("catalogName", out var c)
|
||||
? AiRecipeJsonParser.GetString(c)
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private async Task<List<GeneratedIngredientDto>> ParseIngredientsAsync(JsonElement ingEl, CancellationToken ct)
|
||||
{
|
||||
var list = ParseIngredientsSync(ingEl);
|
||||
foreach (var item in list)
|
||||
{
|
||||
item.CatalogItemId = await _catalogMatching.ResolveCatalogItemIdAsync(item.CatalogName ?? item.Name, ct);
|
||||
item.IsLinked = item.CatalogItemId.HasValue;
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static List<GeneratedStepDto> ParseSteps(JsonElement stepsEl)
|
||||
{
|
||||
var list = new List<GeneratedStepDto>();
|
||||
foreach (var item in stepsEl.EnumerateArray())
|
||||
{
|
||||
var stepNumber = item.TryGetProperty("stepNumber", out var stepEl)
|
||||
? AiRecipeJsonParser.GetInt32(stepEl, list.Count + 1)
|
||||
: list.Count + 1;
|
||||
list.Add(new GeneratedStepDto
|
||||
{
|
||||
StepNumber = stepNumber,
|
||||
Description = item.TryGetProperty("description", out var descEl)
|
||||
? AiRecipeJsonParser.GetString(descEl) ?? ""
|
||||
: "",
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private 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";
|
||||
}
|
||||
|
||||
private static RecipeGeneratorConstraintsDto DeserializeConstraints(string json) =>
|
||||
JsonSerializer.Deserialize<RecipeGeneratorConstraintsDto>(json) ?? new RecipeGeneratorConstraintsDto();
|
||||
|
||||
private static List<GeneratedRecipeDraftDto> DeserializeDrafts(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return [];
|
||||
|
||||
var trimmed = json.TrimStart();
|
||||
if (trimmed.StartsWith('['))
|
||||
{
|
||||
return (JsonSerializer.Deserialize<List<GeneratedRecipeDraftDto>>(json) ?? [])
|
||||
.Select(EnsureDraftId)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var single = JsonSerializer.Deserialize<GeneratedRecipeDraftDto>(json);
|
||||
return single is null ? [] : [EnsureDraftId(single)];
|
||||
}
|
||||
|
||||
private static string SerializeDrafts(IReadOnlyList<GeneratedRecipeDraftDto> drafts) =>
|
||||
JsonSerializer.Serialize(drafts);
|
||||
|
||||
private static GeneratedRecipeDraftDto EnsureDraftId(GeneratedRecipeDraftDto draft)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(draft.DraftId))
|
||||
{
|
||||
draft.DraftId = Guid.NewGuid().ToString();
|
||||
}
|
||||
|
||||
return draft;
|
||||
}
|
||||
|
||||
private static RecipeGeneratorSessionDto MapSession(
|
||||
RecipeGeneratorSession session,
|
||||
RecipeGeneratorConstraintsDto constraints,
|
||||
IReadOnlyList<GeneratedRecipeDraftDto> drafts) => new()
|
||||
{
|
||||
Id = session.Id,
|
||||
Status = session.Status,
|
||||
Constraints = constraints,
|
||||
Draft = drafts.FirstOrDefault(),
|
||||
Drafts = drafts,
|
||||
SavedRecipeId = session.SavedRecipeId,
|
||||
GenerationVersion = session.GenerationVersion,
|
||||
};
|
||||
}
|
||||
59
MealPlan.Api/Services/Generators/TdeeCalculator.cs
Normal file
59
MealPlan.Api/Services/Generators/TdeeCalculator.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
namespace MealPlan.Api.Services.Generators;
|
||||
|
||||
public static class TdeeCalculator
|
||||
{
|
||||
public record MacroTargets(int Calories, decimal ProteinG, decimal FatG, decimal CarbsG, string Rationale);
|
||||
|
||||
public static MacroTargets Calculate(
|
||||
decimal heightCm,
|
||||
decimal weightKg,
|
||||
int age,
|
||||
string sex,
|
||||
string activityLevel,
|
||||
string goal)
|
||||
{
|
||||
var bmr = sex.ToLowerInvariant() switch
|
||||
{
|
||||
"female" => 10m * weightKg + 6.25m * heightCm - 5m * age - 161m,
|
||||
_ => 10m * weightKg + 6.25m * heightCm - 5m * age + 5m,
|
||||
};
|
||||
|
||||
var multiplier = activityLevel.ToLowerInvariant() switch
|
||||
{
|
||||
"sedentary" => 1.2m,
|
||||
"light" => 1.375m,
|
||||
"moderate" => 1.55m,
|
||||
"active" => 1.725m,
|
||||
"very_active" => 1.9m,
|
||||
_ => 1.55m,
|
||||
};
|
||||
|
||||
var tdee = bmr * multiplier;
|
||||
var adjustment = goal.ToLowerInvariant() switch
|
||||
{
|
||||
"lose_weight" => -500,
|
||||
"gain_muscle" => 300,
|
||||
"recomp" => -200,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
var calories = Math.Max(1200, (int)Math.Round(tdee + adjustment));
|
||||
|
||||
var (proteinPct, fatPct, carbsPct) = goal.ToLowerInvariant() switch
|
||||
{
|
||||
"gain_muscle" => (0.30m, 0.25m, 0.45m),
|
||||
"lose_weight" => (0.35m, 0.25m, 0.40m),
|
||||
"recomp" => (0.32m, 0.28m, 0.40m),
|
||||
_ => (0.25m, 0.30m, 0.45m),
|
||||
};
|
||||
|
||||
var proteinG = Math.Round(calories * proteinPct / 4m, 1);
|
||||
var fatG = Math.Round(calories * fatPct / 9m, 1);
|
||||
var carbsG = Math.Round(calories * carbsPct / 4m, 1);
|
||||
|
||||
var rationale =
|
||||
$"TDEE ≈ {(int)Math.Round(tdee)} kcal ({activityLevel}, {goal}). Target {calories} kcal after goal adjustment.";
|
||||
|
||||
return new MacroTargets(calories, proteinG, fatG, carbsG, rationale);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user