781 lines
32 KiB
C#
781 lines
32 KiB
C#
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,
|
|
};
|
|
}
|