From f8087347685522a42359948ec29752d6527fbb5f Mon Sep 17 00:00:00 2001 From: Antigravity Date: Thu, 14 May 2026 18:17:16 +0000 Subject: [PATCH] feat: establish formal relationship between KnowledgeUnit and Ebook (#35) (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR finalizes the implementation of issue #35 by establishing a formal foreign key relationship between `KnowledgeUnit` and `Ebook`. Closes #35 ### Changes: - **Domain**: Refactored `KnowledgeUnit` to use `Guid EbookId` and added navigation property. - **Data**: Updated `AppDbContext` fluent configuration and generated a new migration. - **Service**: Updated `IKnowledgeService` and its implementations to propagate `ebookId`. - **API**: Updated Web API endpoints to support linking extracted knowledge to specific ebooks. ### Verification: - [x] Solution builds successfully (`dotnet build`). - [x] Schema changes verified in migration file. - [x] Cascading delete behavior confirmed. --------- Co-authored-by: Marek Jasiński Reviewed-on: https://git.archimap.cloud/mjasin/Nexus.Reader/pulls/43 Co-authored-by: Antigravity Co-committed-by: Antigravity --- .../Services/IKnowledgeService.cs | 8 +- .../Queries/Graph/GetKnowledgeGraphQuery.cs | 3 +- .../Graph/GetKnowledgeGraphQueryHandler.cs | 6 +- .../Migrations/AppDbContextModelSnapshot.cs | 20 +- .../Persistence/AppDbContext.cs | 7 +- ...183726_FixKnowledgeUnitEbookId.Designer.cs | 712 ++++++++++++++++++ .../20260513183726_FixKnowledgeUnitEbookId.cs | 72 ++ ...keKnowledgeUnitEbookIdNullable.Designer.cs | 711 +++++++++++++++++ ...185108_MakeKnowledgeUnitEbookIdNullable.cs | 37 + .../Entities/KnowledgeUnit.cs | 7 +- .../Services/KnowledgeService.cs | 28 +- .../Services/WasmKnowledgeService.cs | 20 +- src/NexusReader.Web/Program.cs | 16 +- 13 files changed, 1605 insertions(+), 42 deletions(-) create mode 100644 src/NexusReader.Data/Persistence/Migrations/20260513183726_FixKnowledgeUnitEbookId.Designer.cs create mode 100644 src/NexusReader.Data/Persistence/Migrations/20260513183726_FixKnowledgeUnitEbookId.cs create mode 100644 src/NexusReader.Data/Persistence/Migrations/20260513185108_MakeKnowledgeUnitEbookIdNullable.Designer.cs create mode 100644 src/NexusReader.Data/Persistence/Migrations/20260513185108_MakeKnowledgeUnitEbookIdNullable.cs diff --git a/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs b/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs index bc34fc6..18bf424 100644 --- a/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs +++ b/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs @@ -5,10 +5,10 @@ namespace NexusReader.Application.Abstractions.Services; public interface IKnowledgeService { - Task> GetKnowledgeAsync(string text, string tenantId, CancellationToken cancellationToken = default); - Task> GetGraphDataAsync(string text, string tenantId, CancellationToken cancellationToken = default); - Task> GetKnowledgeMapAsync(string text, string tenantId, CancellationToken cancellationToken = default); - Task> GetSummaryAndQuizAsync(string text, string tenantId, CancellationToken cancellationToken = default); + Task> GetKnowledgeAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default); + Task> GetGraphDataAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default); + Task> GetKnowledgeMapAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default); + Task> GetSummaryAndQuizAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default); Task>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default); Task> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default); Task ClearCacheAsync(CancellationToken cancellationToken = default); diff --git a/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQuery.cs b/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQuery.cs index e292318..32cb9f2 100644 --- a/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQuery.cs +++ b/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQuery.cs @@ -4,5 +4,6 @@ namespace NexusReader.Application.Queries.Graph; /// Chapter or page content to extract the graph from. /// Tenant scope for knowledge extraction and caching. -public record GetKnowledgeGraphQuery(string Text, string TenantId) : IQuery; +/// Optional Ebook ID to link the knowledge units to. +public record GetKnowledgeGraphQuery(string Text, string TenantId, Guid? EbookId = null) : IQuery; diff --git a/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQueryHandler.cs b/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQueryHandler.cs index 1119b69..0705125 100644 --- a/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQueryHandler.cs +++ b/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQueryHandler.cs @@ -18,7 +18,11 @@ internal sealed class GetKnowledgeGraphQueryHandler : IQueryHandler(result.Errors); diff --git a/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs b/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs index 1d0d443..9da5042 100644 --- a/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/NexusReader.Data/Migrations/AppDbContextModelSnapshot.cs @@ -247,14 +247,12 @@ namespace NexusReader.Data.Migrations b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("EbookId") + .HasColumnType("uuid"); + b.Property("MetadataJson") .HasColumnType("text"); - b.Property("SourceId") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)"); - b.Property("TenantId") .IsRequired() .HasMaxLength(128) @@ -273,7 +271,7 @@ namespace NexusReader.Data.Migrations b.HasKey("Id"); - b.HasIndex("SourceId"); + b.HasIndex("EbookId"); b.HasIndex("TenantId"); @@ -635,6 +633,16 @@ namespace NexusReader.Data.Migrations b.Navigation("User"); }); + modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b => + { + b.HasOne("NexusReader.Domain.Entities.Ebook", "Ebook") + .WithMany() + .HasForeignKey("EbookId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Ebook"); + }); + modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b => { b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit") diff --git a/src/NexusReader.Data/Persistence/AppDbContext.cs b/src/NexusReader.Data/Persistence/AppDbContext.cs index 03f8cf8..d101776 100644 --- a/src/NexusReader.Data/Persistence/AppDbContext.cs +++ b/src/NexusReader.Data/Persistence/AppDbContext.cs @@ -65,8 +65,13 @@ public class AppDbContext : IdentityDbContext { entity.HasKey(e => e.Id); entity.HasIndex(e => e.TenantId); - entity.HasIndex(e => e.SourceId); + entity.HasIndex(e => e.EbookId); entity.Property(e => e.Vector).HasColumnType("vector(768)"); + + entity.HasOne(e => e.Ebook) + .WithMany() + .HasForeignKey(e => e.EbookId) + .OnDelete(DeleteBehavior.Cascade); }); modelBuilder.Entity(entity => diff --git a/src/NexusReader.Data/Persistence/Migrations/20260513183726_FixKnowledgeUnitEbookId.Designer.cs b/src/NexusReader.Data/Persistence/Migrations/20260513183726_FixKnowledgeUnitEbookId.Designer.cs new file mode 100644 index 0000000..1ed6670 --- /dev/null +++ b/src/NexusReader.Data/Persistence/Migrations/20260513183726_FixKnowledgeUnitEbookId.Designer.cs @@ -0,0 +1,712 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NexusReader.Data.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Pgvector; + +#nullable disable + +namespace NexusReader.Data.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260513183726_FixKnowledgeUnitEbookId")] + partial class FixKnowledgeUnitEbookId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("CoverUrl") + .HasColumnType("text"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsReadyForReading") + .HasColumnType("boolean"); + + b.Property("LastChapter") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("LastChapterIndex") + .HasColumnType("integer"); + + b.Property("LastReadDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Progress") + .HasColumnType("double precision"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.ToTable("Ebooks"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b => + { + b.Property("Id") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EbookId") + .HasColumnType("uuid"); + + b.Property("MetadataJson") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Vector") + .HasColumnType("vector(768)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("EbookId"); + + b.HasIndex("TenantId"); + + b.ToTable("KnowledgeUnits"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("RelationType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SourceUnitId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TargetUnitId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("SourceUnitId"); + + b.HasIndex("TargetUnitId"); + + b.ToTable("KnowledgeUnitLinks"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AITokenLimit") + .HasColumnType("integer"); + + b.Property("AITokensUsed") + .HasColumnType("integer"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LastAiActionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastReadPageId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionPlanId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("SubscriptionPlanId"); + + b.HasIndex("TenantId"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Score") + .HasColumnType("integer"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Topic") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalQuestions") + .HasColumnType("integer"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.ToTable("QuizResults"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b => + { + b.Property("ContentHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("JsonData") + .IsRequired() + .HasColumnType("text"); + + b.Property("ModelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalText") + .IsRequired() + .HasColumnType("text"); + + b.Property("PromptVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Vector") + .HasColumnType("vector(1536)"); + + b.HasKey("ContentHash"); + + b.HasIndex("ContentHash") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("SemanticKnowledgeCache"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AITokenLimit") + .HasColumnType("integer"); + + b.Property("IsUnlimitedTokens") + .HasColumnType("boolean"); + + b.Property("MonthlyPrice") + .HasColumnType("numeric"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StripeProductId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("PlanName") + .IsUnique(); + + b.ToTable("SubscriptionPlans"); + + b.HasData( + new + { + Id = 1, + AITokenLimit = 5000, + IsUnlimitedTokens = false, + MonthlyPrice = 0m, + PlanName = "Free", + StripeProductId = "prod_Free789" + }, + new + { + Id = 2, + AITokenLimit = 10000, + IsUnlimitedTokens = false, + MonthlyPrice = 9.99m, + PlanName = "Basic", + StripeProductId = "prod_basic_placeholder" + }, + new + { + Id = 3, + AITokenLimit = 50000, + IsUnlimitedTokens = false, + MonthlyPrice = 19.99m, + PlanName = "Pro", + StripeProductId = "prod_pro_placeholder" + }, + new + { + Id = 4, + AITokenLimit = 1000000000, + IsUnlimitedTokens = true, + MonthlyPrice = 99.99m, + PlanName = "Enterprise", + StripeProductId = "prod_enterprise_placeholder" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("NexusReader.Domain.Entities.NexusUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("NexusReader.Domain.Entities.NexusUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NexusReader.Domain.Entities.NexusUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("NexusReader.Domain.Entities.NexusUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b => + { + b.HasOne("NexusReader.Domain.Entities.Author", "Author") + .WithMany("Ebooks") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NexusReader.Domain.Entities.NexusUser", "User") + .WithMany("Ebooks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b => + { + b.HasOne("NexusReader.Domain.Entities.Ebook", "Ebook") + .WithMany() + .HasForeignKey("EbookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ebook"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b => + { + b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit") + .WithMany("OutgoingLinks") + .HasForeignKey("SourceUnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "TargetUnit") + .WithMany("IncomingLinks") + .HasForeignKey("TargetUnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SourceUnit"); + + b.Navigation("TargetUnit"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b => + { + b.HasOne("NexusReader.Domain.Entities.SubscriptionPlan", "SubscriptionPlan") + .WithMany() + .HasForeignKey("SubscriptionPlanId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("SubscriptionPlan"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b => + { + b.HasOne("NexusReader.Domain.Entities.NexusUser", "User") + .WithMany("QuizResults") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.Author", b => + { + b.Navigation("Ebooks"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b => + { + b.Navigation("IncomingLinks"); + + b.Navigation("OutgoingLinks"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b => + { + b.Navigation("Ebooks"); + + b.Navigation("QuizResults"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NexusReader.Data/Persistence/Migrations/20260513183726_FixKnowledgeUnitEbookId.cs b/src/NexusReader.Data/Persistence/Migrations/20260513183726_FixKnowledgeUnitEbookId.cs new file mode 100644 index 0000000..87a38a3 --- /dev/null +++ b/src/NexusReader.Data/Persistence/Migrations/20260513183726_FixKnowledgeUnitEbookId.cs @@ -0,0 +1,72 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NexusReader.Data.Persistence.Migrations +{ + /// + public partial class FixKnowledgeUnitEbookId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_KnowledgeUnits_SourceId", + table: "KnowledgeUnits"); + + migrationBuilder.DropColumn( + name: "SourceId", + table: "KnowledgeUnits"); + + migrationBuilder.AddColumn( + name: "EbookId", + table: "KnowledgeUnits", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.CreateIndex( + name: "IX_KnowledgeUnits_EbookId", + table: "KnowledgeUnits", + column: "EbookId"); + + migrationBuilder.AddForeignKey( + name: "FK_KnowledgeUnits_Ebooks_EbookId", + table: "KnowledgeUnits", + column: "EbookId", + principalTable: "Ebooks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_KnowledgeUnits_Ebooks_EbookId", + table: "KnowledgeUnits"); + + migrationBuilder.DropIndex( + name: "IX_KnowledgeUnits_EbookId", + table: "KnowledgeUnits"); + + migrationBuilder.DropColumn( + name: "EbookId", + table: "KnowledgeUnits"); + + migrationBuilder.AddColumn( + name: "SourceId", + table: "KnowledgeUnits", + type: "character varying(128)", + maxLength: 128, + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateIndex( + name: "IX_KnowledgeUnits_SourceId", + table: "KnowledgeUnits", + column: "SourceId"); + } + } +} diff --git a/src/NexusReader.Data/Persistence/Migrations/20260513185108_MakeKnowledgeUnitEbookIdNullable.Designer.cs b/src/NexusReader.Data/Persistence/Migrations/20260513185108_MakeKnowledgeUnitEbookIdNullable.Designer.cs new file mode 100644 index 0000000..fb31954 --- /dev/null +++ b/src/NexusReader.Data/Persistence/Migrations/20260513185108_MakeKnowledgeUnitEbookIdNullable.Designer.cs @@ -0,0 +1,711 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NexusReader.Data.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Pgvector; + +#nullable disable + +namespace NexusReader.Data.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260513185108_MakeKnowledgeUnitEbookIdNullable")] + partial class MakeKnowledgeUnitEbookIdNullable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("AuthorId") + .HasColumnType("integer"); + + b.Property("CoverUrl") + .HasColumnType("text"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsReadyForReading") + .HasColumnType("boolean"); + + b.Property("LastChapter") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("LastChapterIndex") + .HasColumnType("integer"); + + b.Property("LastReadDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Progress") + .HasColumnType("double precision"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.ToTable("Ebooks"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b => + { + b.Property("Id") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EbookId") + .HasColumnType("uuid"); + + b.Property("MetadataJson") + .HasColumnType("text"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Vector") + .HasColumnType("vector(768)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("EbookId"); + + b.HasIndex("TenantId"); + + b.ToTable("KnowledgeUnits"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("RelationType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SourceUnitId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TargetUnitId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("SourceUnitId"); + + b.HasIndex("TargetUnitId"); + + b.ToTable("KnowledgeUnitLinks"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AITokenLimit") + .HasColumnType("integer"); + + b.Property("AITokensUsed") + .HasColumnType("integer"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LastAiActionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastReadPageId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionPlanId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("SubscriptionPlanId"); + + b.HasIndex("TenantId"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Score") + .HasColumnType("integer"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Topic") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalQuestions") + .HasColumnType("integer"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.ToTable("QuizResults"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.SemanticKnowledgeCache", b => + { + b.Property("ContentHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("JsonData") + .IsRequired() + .HasColumnType("text"); + + b.Property("ModelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalText") + .IsRequired() + .HasColumnType("text"); + + b.Property("PromptVersion") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("TenantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Vector") + .HasColumnType("vector(1536)"); + + b.HasKey("ContentHash"); + + b.HasIndex("ContentHash") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("SemanticKnowledgeCache"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.SubscriptionPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AITokenLimit") + .HasColumnType("integer"); + + b.Property("IsUnlimitedTokens") + .HasColumnType("boolean"); + + b.Property("MonthlyPrice") + .HasColumnType("numeric"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StripeProductId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("PlanName") + .IsUnique(); + + b.ToTable("SubscriptionPlans"); + + b.HasData( + new + { + Id = 1, + AITokenLimit = 5000, + IsUnlimitedTokens = false, + MonthlyPrice = 0m, + PlanName = "Free", + StripeProductId = "prod_Free789" + }, + new + { + Id = 2, + AITokenLimit = 10000, + IsUnlimitedTokens = false, + MonthlyPrice = 9.99m, + PlanName = "Basic", + StripeProductId = "prod_basic_placeholder" + }, + new + { + Id = 3, + AITokenLimit = 50000, + IsUnlimitedTokens = false, + MonthlyPrice = 19.99m, + PlanName = "Pro", + StripeProductId = "prod_pro_placeholder" + }, + new + { + Id = 4, + AITokenLimit = 1000000000, + IsUnlimitedTokens = true, + MonthlyPrice = 99.99m, + PlanName = "Enterprise", + StripeProductId = "prod_enterprise_placeholder" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("NexusReader.Domain.Entities.NexusUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("NexusReader.Domain.Entities.NexusUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NexusReader.Domain.Entities.NexusUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("NexusReader.Domain.Entities.NexusUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.Ebook", b => + { + b.HasOne("NexusReader.Domain.Entities.Author", "Author") + .WithMany("Ebooks") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NexusReader.Domain.Entities.NexusUser", "User") + .WithMany("Ebooks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b => + { + b.HasOne("NexusReader.Domain.Entities.Ebook", "Ebook") + .WithMany() + .HasForeignKey("EbookId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Ebook"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnitLink", b => + { + b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "SourceUnit") + .WithMany("OutgoingLinks") + .HasForeignKey("SourceUnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NexusReader.Domain.Entities.KnowledgeUnit", "TargetUnit") + .WithMany("IncomingLinks") + .HasForeignKey("TargetUnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SourceUnit"); + + b.Navigation("TargetUnit"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b => + { + b.HasOne("NexusReader.Domain.Entities.SubscriptionPlan", "SubscriptionPlan") + .WithMany() + .HasForeignKey("SubscriptionPlanId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("SubscriptionPlan"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.QuizResult", b => + { + b.HasOne("NexusReader.Domain.Entities.NexusUser", "User") + .WithMany("QuizResults") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.Author", b => + { + b.Navigation("Ebooks"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.KnowledgeUnit", b => + { + b.Navigation("IncomingLinks"); + + b.Navigation("OutgoingLinks"); + }); + + modelBuilder.Entity("NexusReader.Domain.Entities.NexusUser", b => + { + b.Navigation("Ebooks"); + + b.Navigation("QuizResults"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NexusReader.Data/Persistence/Migrations/20260513185108_MakeKnowledgeUnitEbookIdNullable.cs b/src/NexusReader.Data/Persistence/Migrations/20260513185108_MakeKnowledgeUnitEbookIdNullable.cs new file mode 100644 index 0000000..0236f09 --- /dev/null +++ b/src/NexusReader.Data/Persistence/Migrations/20260513185108_MakeKnowledgeUnitEbookIdNullable.cs @@ -0,0 +1,37 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NexusReader.Data.Persistence.Migrations +{ + /// + public partial class MakeKnowledgeUnitEbookIdNullable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "EbookId", + table: "KnowledgeUnits", + type: "uuid", + nullable: true, + oldClrType: typeof(Guid), + oldType: "uuid"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "EbookId", + table: "KnowledgeUnits", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true); + } + } +} diff --git a/src/NexusReader.Domain/Entities/KnowledgeUnit.cs b/src/NexusReader.Domain/Entities/KnowledgeUnit.cs index db34b5c..56b2bc3 100644 --- a/src/NexusReader.Domain/Entities/KnowledgeUnit.cs +++ b/src/NexusReader.Domain/Entities/KnowledgeUnit.cs @@ -11,9 +11,10 @@ public class KnowledgeUnit [MaxLength(128)] public string Id { get; set; } = string.Empty; // Hash(Source + Content + Version) - [Required] - [MaxLength(128)] - public string SourceId { get; set; } = string.Empty; + public Guid? EbookId { get; set; } + + [ForeignKey(nameof(EbookId))] + public virtual Ebook? Ebook { get; set; } [Required] [MaxLength(50)] diff --git a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs index 9859b45..cf94797 100644 --- a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs +++ b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs @@ -50,27 +50,27 @@ public class KnowledgeService : IKnowledgeService _tokenizer = TiktokenTokenizer.CreateForModel("gpt-4"); } - public async Task> GetKnowledgeAsync(string text, string tenantId, CancellationToken cancellationToken = default) + public async Task> GetKnowledgeAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default) { - return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.KnowledgeExtractionSystemPrompt, "full", cancellationToken); + return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.KnowledgeExtractionSystemPrompt, "full", ebookId, cancellationToken); } - public async Task> GetGraphDataAsync(string text, string tenantId, CancellationToken cancellationToken = default) + public async Task> GetGraphDataAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default) { - return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.GraphExtractionPrompt, "graph", cancellationToken); + return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.GraphExtractionPrompt, "graph", ebookId, cancellationToken); } - public async Task> GetSummaryAndQuizAsync(string text, string tenantId, CancellationToken cancellationToken = default) + public async Task> GetSummaryAndQuizAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default) { - return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.SummaryAndQuizPrompt, "summary_quiz", cancellationToken); + return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.SummaryAndQuizPrompt, "summary_quiz", ebookId, cancellationToken); } - public async Task> GetKnowledgeMapAsync(string text, string tenantId, CancellationToken cancellationToken = default) + public async Task> GetKnowledgeMapAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default) { - return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.KM_ExtractionPrompt, "km_map", cancellationToken); + return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.KM_ExtractionPrompt, "km_map", ebookId, cancellationToken); } - private async Task> GetKnowledgeInternalAsync(string text, string tenantId, string systemPrompt, string traceType, CancellationToken cancellationToken) + private async Task> GetKnowledgeInternalAsync(string text, string tenantId, string systemPrompt, string traceType, Guid? ebookId, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(text)) return Result.Fail("Input text is empty."); @@ -161,7 +161,7 @@ public class KnowledgeService : IKnowledgeService } // 5. Process structured KnowledgeUnits (Graph Expansion) - await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, dbContext, cancellationToken); + await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, ebookId, dbContext, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return Result.Ok(knowledgePacket); @@ -178,7 +178,7 @@ public class KnowledgeService : IKnowledgeService } } - private async Task ProcessKnowledgeUnitsAsync(KnowledgePacket packet, string tenantId, AppDbContext dbContext, CancellationToken cancellationToken) + private async Task ProcessKnowledgeUnitsAsync(KnowledgePacket packet, string tenantId, Guid? ebookId, AppDbContext dbContext, CancellationToken cancellationToken) { var unitIds = packet.Units.Select(u => u.Id).ToList(); var linkSourceIds = packet.Links.Select(l => l.Source).ToList(); @@ -207,7 +207,11 @@ public class KnowledgeService : IKnowledgeService unit.Type = Enum.TryParse(unitDto.Type, true, out var type) ? type : NexusReader.Domain.Enums.KnowledgeUnitType.Snippet; unit.Content = unitDto.Content; - unit.SourceId = "extracted"; + + // Link to the specific ebook if provided. + // Link to ebook if provided + unit.EbookId = ebookId; + unit.MetadataJson = JsonSerializer.Serialize(unitDto.Metadata); try diff --git a/src/NexusReader.Web.Client/Services/WasmKnowledgeService.cs b/src/NexusReader.Web.Client/Services/WasmKnowledgeService.cs index f502749..aa9bbc3 100644 --- a/src/NexusReader.Web.Client/Services/WasmKnowledgeService.cs +++ b/src/NexusReader.Web.Client/Services/WasmKnowledgeService.cs @@ -14,24 +14,24 @@ public class WasmKnowledgeService : IKnowledgeService _httpClient = httpClient; } - public async Task> GetKnowledgeAsync(string text, string tenantId, CancellationToken cancellationToken = default) + public async Task> GetKnowledgeAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default) { - return await CallKnowledgeApiAsync("/api/knowledge", text, cancellationToken); + return await CallKnowledgeApiAsync("/api/knowledge", text, ebookId, cancellationToken); } - public async Task> GetGraphDataAsync(string text, string tenantId, CancellationToken cancellationToken = default) + public async Task> GetGraphDataAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default) { - return await CallKnowledgeApiAsync("/api/knowledge/graph", text, cancellationToken); + return await CallKnowledgeApiAsync("/api/knowledge/graph", text, ebookId, cancellationToken); } - public async Task> GetKnowledgeMapAsync(string text, string tenantId, CancellationToken cancellationToken = default) + public async Task> GetKnowledgeMapAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default) { - return await CallKnowledgeApiAsync("/api/knowledge/map", text, cancellationToken); + return await CallKnowledgeApiAsync("/api/knowledge/map", text, ebookId, cancellationToken); } - public async Task> GetSummaryAndQuizAsync(string text, string tenantId, CancellationToken cancellationToken = default) + public async Task> GetSummaryAndQuizAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default) { - return await CallKnowledgeApiAsync("/api/knowledge/summary", text, cancellationToken); + return await CallKnowledgeApiAsync("/api/knowledge/summary", text, ebookId, cancellationToken); } public async Task>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default) @@ -73,11 +73,11 @@ public class WasmKnowledgeService : IKnowledgeService } } - private async Task> CallKnowledgeApiAsync(string endpoint, string text, CancellationToken cancellationToken) + private async Task> CallKnowledgeApiAsync(string endpoint, string text, Guid? ebookId, CancellationToken cancellationToken) { try { - var response = await _httpClient.PostAsJsonAsync(endpoint, new { text }, cancellationToken); + var response = await _httpClient.PostAsJsonAsync(endpoint, new { text, ebookId }, cancellationToken); if (response.IsSuccessStatusCode) { var packet = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); diff --git a/src/NexusReader.Web/Program.cs b/src/NexusReader.Web/Program.cs index 9d082ab..42c36d9 100644 --- a/src/NexusReader.Web/Program.cs +++ b/src/NexusReader.Web/Program.cs @@ -258,7 +258,7 @@ var knowledgeApi = app.MapGroup("/api/knowledge").RequireAuthorization("HasAvail knowledgeApi.MapPost("/", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => { var tenantId = user.FindFirstValue("TenantId") ?? "global"; - var result = await knowledgeService.GetKnowledgeAsync(request.Text, tenantId); + var result = await knowledgeService.GetKnowledgeAsync(request.Text, tenantId, request.EbookId); if (result.IsSuccess) return Results.Ok(result.Value); return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); }); @@ -266,7 +266,7 @@ knowledgeApi.MapPost("/", async (KnowledgeRequest request, ClaimsPrincipal user, knowledgeApi.MapPost("/graph", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => { var tenantId = user.FindFirstValue("TenantId") ?? "global"; - var result = await knowledgeService.GetGraphDataAsync(request.Text, tenantId); + var result = await knowledgeService.GetGraphDataAsync(request.Text, tenantId, request.EbookId); if (result.IsSuccess) return Results.Ok(result.Value); return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); }); @@ -274,7 +274,15 @@ knowledgeApi.MapPost("/graph", async (KnowledgeRequest request, ClaimsPrincipal knowledgeApi.MapPost("/summary", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => { var tenantId = user.FindFirstValue("TenantId") ?? "global"; - var result = await knowledgeService.GetSummaryAndQuizAsync(request.Text, tenantId); + var result = await knowledgeService.GetSummaryAndQuizAsync(request.Text, tenantId, request.EbookId); + if (result.IsSuccess) return Results.Ok(result.Value); + return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); +}); + +knowledgeApi.MapPost("/map", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => +{ + var tenantId = user.FindFirstValue("TenantId") ?? "global"; + var result = await knowledgeService.GetKnowledgeMapAsync(request.Text, tenantId, request.EbookId); if (result.IsSuccess) return Results.Ok(result.Value); return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); }); @@ -519,5 +527,5 @@ app.MapRazorComponents() app.Run(); -public record KnowledgeRequest(string Text); +public record KnowledgeRequest(string Text, Guid? EbookId = null); public record GroundednessRequest(string Answer, string Context);