684 lines
27 KiB
C#
684 lines
27 KiB
C#
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,
|
|
};
|
|
}
|