From 2bbbbc746f1693860e1616ad630671633c7de8bf Mon Sep 17 00:00:00 2001 From: chloe Date: Thu, 5 Mar 2026 04:52:15 +0000 Subject: [PATCH] Project Update --- Dockerfile | 28 ++++ .../Controllers/RecipeController.cs | 3 +- Seasoned.Backend/Data/ApplicationDbContext.cs | 17 +++ .../20260305044721_SeasonedInit.Designer.cs | 64 ++++++++ .../Migrations/20260305044721_SeasonedInit.cs | 44 ++++++ .../ApplicationDbContextModelSnapshot.cs | 61 ++++++++ Seasoned.Backend/Models/Recipe.cs | 11 ++ Seasoned.Backend/Program.cs | 24 ++- Seasoned.Backend/Seasoned.Backend.csproj | 7 + Seasoned.Backend/Services/RecipeService.cs | 57 +++++-- Seasoned.Frontend/app/app.vue | 140 +++++++++--------- .../app/assets/css/app-theme.css | 116 +++++++++++++++ Seasoned.Frontend/nuxt.config.ts | 8 +- Seasoned.sln | 30 ++++ compose.yaml | 34 +++++ 15 files changed, 551 insertions(+), 93 deletions(-) create mode 100644 Dockerfile create mode 100644 Seasoned.Backend/Data/ApplicationDbContext.cs create mode 100644 Seasoned.Backend/Data/Migrations/20260305044721_SeasonedInit.Designer.cs create mode 100644 Seasoned.Backend/Data/Migrations/20260305044721_SeasonedInit.cs create mode 100644 Seasoned.Backend/Data/Migrations/ApplicationDbContextModelSnapshot.cs create mode 100644 Seasoned.Backend/Models/Recipe.cs create mode 100644 Seasoned.Frontend/app/assets/css/app-theme.css create mode 100644 Seasoned.sln create mode 100644 compose.yaml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..38eba8b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# Stage 1: Build the Nuxt 4 frontend (Debian Slim) +FROM node:20-bookworm-slim AS frontend-build +WORKDIR /src/frontend +COPY Seasoned.Frontend/package*.json ./ +RUN npm install +COPY Seasoned.Frontend/ . +RUN npx nuxi generate + +# Stage 2: Build the .NET 9 backend (Debian Slim) +FROM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS backend-build +WORKDIR /src +COPY Seasoned.Backend/*.csproj ./Seasoned.Backend/ +RUN dotnet restore ./Seasoned.Backend/Seasoned.Backend.csproj +COPY . . + +# Copy Nuxt static files into .NET wwwroot +COPY --from=frontend-build /src/frontend/.output/public ./Seasoned.Backend/wwwroot + +RUN dotnet publish ./Seasoned.Backend/Seasoned.Backend.csproj -c Release -o /app + +# Stage 3: Final Runtime (Debian Slim) +FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim +WORKDIR /app +COPY --from=backend-build /app . + +# Essential for PGVector and Gemini logic to run smoothly +ENV ASPNETCORE_URLS=http://+:5000 +ENTRYPOINT ["dotnet", "Seasoned.Backend.dll"] \ No newline at end of file diff --git a/Seasoned.Backend/Controllers/RecipeController.cs b/Seasoned.Backend/Controllers/RecipeController.cs index f55cb34..47bd6da 100644 --- a/Seasoned.Backend/Controllers/RecipeController.cs +++ b/Seasoned.Backend/Controllers/RecipeController.cs @@ -5,12 +5,11 @@ using Seasoned.Backend.DTOs; namespace Seasoned.Backend.Controllers; [ApiController] -[Route("api/[controller]")] +[Route("api/recipe")] public class RecipeController : ControllerBase { private readonly IRecipeService _recipeService; - // Dependency Injection: The service is "injected" here public RecipeController(IRecipeService recipeService) { _recipeService = recipeService; diff --git a/Seasoned.Backend/Data/ApplicationDbContext.cs b/Seasoned.Backend/Data/ApplicationDbContext.cs new file mode 100644 index 0000000..f90aa29 --- /dev/null +++ b/Seasoned.Backend/Data/ApplicationDbContext.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using Seasoned.Backend.Models; + +namespace Seasoned.Backend.Data; + +public class ApplicationDbContext : DbContext +{ + public ApplicationDbContext(DbContextOptions options) + : base(options) { } + + public DbSet Recipes { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasPostgresExtension("vector"); + } +} \ No newline at end of file diff --git a/Seasoned.Backend/Data/Migrations/20260305044721_SeasonedInit.Designer.cs b/Seasoned.Backend/Data/Migrations/20260305044721_SeasonedInit.Designer.cs new file mode 100644 index 0000000..7e3828e --- /dev/null +++ b/Seasoned.Backend/Data/Migrations/20260305044721_SeasonedInit.Designer.cs @@ -0,0 +1,64 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Seasoned.Backend.Data; + +#nullable disable + +namespace Seasoned.Backend.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260305044721_SeasonedInit")] + partial class SeasonedInit + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Seasoned.Backend.Models.Recipe", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.PrimitiveCollection>("Ingredients") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection>("Instructions") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Recipes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Seasoned.Backend/Data/Migrations/20260305044721_SeasonedInit.cs b/Seasoned.Backend/Data/Migrations/20260305044721_SeasonedInit.cs new file mode 100644 index 0000000..9fe118a --- /dev/null +++ b/Seasoned.Backend/Data/Migrations/20260305044721_SeasonedInit.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Seasoned.Backend.Data.Migrations +{ + /// + public partial class SeasonedInit : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:PostgresExtension:vector", ",,"); + + migrationBuilder.CreateTable( + name: "Recipes", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Title = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: false), + Ingredients = table.Column>(type: "text[]", nullable: false), + Instructions = table.Column>(type: "text[]", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Recipes", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Recipes"); + } + } +} diff --git a/Seasoned.Backend/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/Seasoned.Backend/Data/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..31b2b69 --- /dev/null +++ b/Seasoned.Backend/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,61 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Seasoned.Backend.Data; + +#nullable disable + +namespace Seasoned.Backend.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Seasoned.Backend.Models.Recipe", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.PrimitiveCollection>("Ingredients") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection>("Instructions") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Recipes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Seasoned.Backend/Models/Recipe.cs b/Seasoned.Backend/Models/Recipe.cs new file mode 100644 index 0000000..3192815 --- /dev/null +++ b/Seasoned.Backend/Models/Recipe.cs @@ -0,0 +1,11 @@ +namespace Seasoned.Backend.Models; + +public class Recipe +{ + public int Id { get; set; } + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public List Ingredients { get; set; } = new(); + public List Instructions { get; set; } = new(); + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/Seasoned.Backend/Program.cs b/Seasoned.Backend/Program.cs index 2e88a6c..cc9c99b 100644 --- a/Seasoned.Backend/Program.cs +++ b/Seasoned.Backend/Program.cs @@ -1,10 +1,18 @@ using Seasoned.Backend.Services; +using Microsoft.AspNetCore.HttpOverrides; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Seasoned.Backend.Data; var builder = WebApplication.CreateBuilder(args); builder.Services.AddScoped(); -builder.Services.AddControllers(); +builder.Services.AddControllers() + .AddJsonOptions(options => { + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + }); + builder.Services.AddOpenApi(); builder.Services.AddCors(options => @@ -17,7 +25,20 @@ builder.Services.AddCors(options => }); }); +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"), + o => o.UseVector())); + var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); +} + +app.UseDefaultFiles(); +app.UseStaticFiles(); app.UseCors("AllowAll"); if (app.Environment.IsDevelopment()) @@ -26,4 +47,5 @@ if (app.Environment.IsDevelopment()) } app.MapControllers(); +app.MapFallbackToFile("index.html"); app.Run(); \ No newline at end of file diff --git a/Seasoned.Backend/Seasoned.Backend.csproj b/Seasoned.Backend/Seasoned.Backend.csproj index ebc0318..31c878a 100644 --- a/Seasoned.Backend/Seasoned.Backend.csproj +++ b/Seasoned.Backend/Seasoned.Backend.csproj @@ -10,6 +10,13 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/Seasoned.Backend/Services/RecipeService.cs b/Seasoned.Backend/Services/RecipeService.cs index 86e4766..f306be1 100644 --- a/Seasoned.Backend/Services/RecipeService.cs +++ b/Seasoned.Backend/Services/RecipeService.cs @@ -2,7 +2,8 @@ using Seasoned.Backend.DTOs; using Mscc.GenerativeAI; using Microsoft.AspNetCore.Http; using System.IO; - +using System.Text.Json; +using System.Text.Json.Serialization; namespace Seasoned.Backend.Services; public class RecipeService : IRecipeService @@ -11,7 +12,7 @@ public class RecipeService : IRecipeService public RecipeService(IConfiguration config) { - _apiKey = config["GeminiApiKey"] ?? throw new ArgumentNullException("API Key missing"); + _apiKey = config["GEMINI_API_KEY"] ?? throw new ArgumentNullException("API Key missing"); } public async Task ParseRecipeImageAsync(IFormFile image) @@ -23,27 +24,55 @@ public class RecipeService : IRecipeService await image.CopyToAsync(ms); var base64Image = Convert.ToBase64String(ms.ToArray()); - // 1. Better Prompt: Tell Gemini exactly what the JSON should look like - var prompt = @"Extract the recipe from this image. - Return a JSON object with exactly these fields: + var prompt = @"Extract the recipe details from this image. + IMPORTANT: Return ONLY a raw JSON string. + DO NOT include markdown formatting (no ```json). + DO NOT include any text before or after the JSON. + All property names and string values MUST be enclosed in double quotes. + JSON structure: { ""title"": ""string"", ""description"": ""string"", - ""ingredients"": [""string""], - ""instructions"": [""string""] + ""ingredients"": [""string"", ""string""], + ""instructions"": [""string"", ""string""] }"; - // 2. Set the Response MIME Type to application/json - var config = new GenerationConfig { ResponseMimeType = "application/json" }; + var config = new GenerationConfig { + ResponseMimeType = "application/json", + Temperature = 0.1f + }; + var request = new GenerateContentRequest(prompt, config); - request.AddMedia(base64Image, "image/png"); + await Task.Run(() => request.AddMedia(base64Image, "image/png")); var response = await model.GenerateContent(request); + string rawText = response.Text?.Trim() ?? ""; - // 3. Use System.Text.Json to turn that string back into our DTO - var options = new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - var result = System.Text.Json.JsonSerializer.Deserialize(response.Text, options); + int start = rawText.IndexOf('{'); + int end = rawText.LastIndexOf('}'); - return result ?? new RecipeResponseDto { Title = "Error parsing JSON" }; + if (start == -1 || end == -1) + { + return new RecipeResponseDto { Title = "Error", Description = "AI failed to generate a valid JSON block." }; + } + + string cleanJson = rawText.Substring(start, (end - start) + 1); + + try + { + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var result = JsonSerializer.Deserialize(cleanJson, options); + return result ?? new RecipeResponseDto { Title = "Empty Response" }; + } + catch (JsonException ex) + { + Console.WriteLine($"Raw AI Output: {rawText}"); + Console.WriteLine($"Failed to parse JSON: {ex.Message}"); + + return new RecipeResponseDto { + Title = "Parsing Error", + Description = "The AI response was malformed. Check logs." + }; + } } } \ No newline at end of file diff --git a/Seasoned.Frontend/app/app.vue b/Seasoned.Frontend/app/app.vue index f5ec172..5002feb 100644 --- a/Seasoned.Frontend/app/app.vue +++ b/Seasoned.Frontend/app/app.vue @@ -1,62 +1,71 @@