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 CreateSessionAsync(Guid userId, RecipeGeneratorConstraintsDto dto, CancellationToken ct = default); Task GetSessionAsync(Guid userId, int sessionId, CancellationToken ct = default); Task GenerateAsync(Guid userId, int sessionId, GenerateRecipesRequestDto? request, CancellationToken ct = default); Task RegeneratePartAsync(Guid userId, int sessionId, RegenerateRecipePartDto dto, CancellationToken ct = default); Task UpdateDraftAsync(Guid userId, int sessionId, GeneratedRecipeDraftDto dto, CancellationToken ct = default); Task RemoveDraftAsync(Guid userId, int sessionId, string draftId, CancellationToken ct = default); Task 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 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 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 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(); 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 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 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 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 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(); 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 EnrichDraftAsync( GeneratedRecipeDraftDto draft, RecipeGeneratorConstraintsDto constraints, CancellationToken ct) { var ingredients = new List(); 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 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> GenerateDistinctDraftsAsync( RecipeGeneratorConstraintsDto constraints, object catalogSample, int count, CancellationToken ct) { const int maxAttempts = 4; var drafts = new List(); 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 RequestRecipeBatchJsonAsync( RecipeGeneratorConstraintsDto constraints, object catalogSample, int recipeCount, IReadOnlyList 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 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 ParseDrafts(string json, int defaultCategory) { using var doc = JsonDocument.Parse(json); var root = doc.RootElement; var list = new List(); 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 ParseIngredientsSync(JsonElement ingEl) { var list = new List(); 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> 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 ParseSteps(JsonElement stepsEl) { var list = new List(); 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(json) ?? new RecipeGeneratorConstraintsDto(); private static List DeserializeDrafts(string? json) { if (string.IsNullOrWhiteSpace(json)) return []; var trimmed = json.TrimStart(); if (trimmed.StartsWith('[')) { return (JsonSerializer.Deserialize>(json) ?? []) .Select(EnsureDraftId) .ToList(); } var single = JsonSerializer.Deserialize(json); return single is null ? [] : [EnsureDraftId(single)]; } private static string SerializeDrafts(IReadOnlyList 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 drafts) => new() { Id = session.Id, Status = session.Status, Constraints = constraints, Draft = drafts.FirstOrDefault(), Drafts = drafts, SavedRecipeId = session.SavedRecipeId, GenerationVersion = session.GenerationVersion, }; }