diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb573bc --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# 📖 Nexus Reader + +Nexus Reader is a state-of-the-art, cross-platform Blazor .NET 10 immersive e-book reader, powered by **Native AOT**, **Clean Architecture**, **CQRS**, and interactive **D3.js Relationship Graphs** built on vector-based AI semantics. + +--- + +## ✨ Features & Architecture Highlights + +### 📁 Ingestion & Description persistence +- Extracted and persistent **book descriptions** from EPUB package metadata during book ingestion. +- The `Description` field propagates cleanly from the `Ebook` entity through Mapster to `LastReadBookDto` and `UserProfileDto`. + +### 🔗 Deep-Link Routing +- Implemented deep-link route activation: `/reader/{bookId}?chapter=N`. +- Allows instant resume of reading session coordinates and loads the specific chapter chapter directly via URL query parameters. + +### 🛡️ Downstream AI Resilience +- Standard resilience engine in `DependencyInjection.cs` utilizing the **Polly** package (`ai-retry`). +- Automatically intercepts, handles, and retries on both rate-limits (`429 Too Many Requests`) and downstream capacity overloads (`503 ServiceUnavailable` / `high demand`). + +### ⚙️ Concurrent Request Deduplication +- Multi-client InteractiveAuto Blazor circuit synchronization is backed by a thread-safe active task registry in `KnowledgeService` which ensures that identical concurrent requests await a single shared task instance, eliminating redundant LLM queries. + +--- + +## 🛠️ Build & Verification Gate + +Ensure the dotnet workload matches the active SDK, and compile the full solution utilizing: + +```bash +dotnet build NexusReader.slnx --no-restore +``` + +Run test suite: + +```bash +dotnet test --no-restore +``` diff --git a/src/NexusReader.Application/Mappings/MappingConfig.cs b/src/NexusReader.Application/Mappings/MappingConfig.cs index 861a1c7..974b41c 100644 --- a/src/NexusReader.Application/Mappings/MappingConfig.cs +++ b/src/NexusReader.Application/Mappings/MappingConfig.cs @@ -13,7 +13,8 @@ public static class MappingConfig var config = TypeAdapterConfig.GlobalSettings; config.NewConfig(); - // Roles are mapped manually in queries due to Identity structure + config.NewConfig() + .Map(dest => dest.Description, src => src.Description); services.AddSingleton(config); services.AddScoped(); @@ -21,3 +22,4 @@ public static class MappingConfig return services; } } + diff --git a/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs b/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs index 2fc6370..c71b67b 100644 --- a/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs +++ b/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs @@ -4,8 +4,8 @@ using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.AI; using NexusReader.Application.DTOs.AI; - using NexusReader.Data.Persistence; +using NexusReader.Domain.Entities; using Pgvector; using Pgvector.EntityFrameworkCore; using System.Text.Json; @@ -46,12 +46,30 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler (x.TenantId == request.TenantId || x.TenantId == "global") && x.Vector != null) - .OrderBy(x => x.Vector!.CosineDistance(queryVector768)) - .Take(request.Limit) - .ToListAsync(cancellationToken); + List candidates; + bool isSqlite = dbContext.Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite"; + + if (isSqlite) + { + var allUnits = await dbContext.KnowledgeUnits + .AsNoTracking() + .Where(x => (x.TenantId == request.TenantId || x.TenantId == "global") && x.Vector != null) + .ToListAsync(cancellationToken); + + candidates = allUnits + .OrderBy(x => CalculateCosineDistance(x.Vector!, queryVector768)) + .Take(request.Limit) + .ToList(); + } + else + { + candidates = await dbContext.KnowledgeUnits + .AsNoTracking() + .Where(x => (x.TenantId == request.TenantId || x.TenantId == "global") && x.Vector != null) + .OrderBy(x => x.Vector!.CosineDistance(queryVector768)) + .Take(request.Limit) + .ToListAsync(cancellationToken); + } if (!candidates.Any()) { @@ -62,18 +80,34 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler x.TenantId == request.TenantId && x.Vector != null) - .OrderBy(x => x.Vector!.CosineDistance(queryVector1536)) - .Take(request.Limit) - .ToListAsync(cancellationToken); + List legacyResults; + if (isSqlite) + { + var allCache = await dbContext.SemanticKnowledgeCache + .AsNoTracking() + .Where(x => x.TenantId == request.TenantId && x.Vector != null) + .ToListAsync(cancellationToken); + + legacyResults = allCache + .OrderBy(x => CalculateCosineDistance(x.Vector!, queryVector1536)) + .Take(request.Limit) + .ToList(); + } + else + { + legacyResults = await dbContext.SemanticKnowledgeCache + .AsNoTracking() + .Where(x => x.TenantId == request.TenantId && x.Vector != null) + .OrderBy(x => x.Vector!.CosineDistance(queryVector1536)) + .Take(request.Limit) + .ToListAsync(cancellationToken); + } return Result.Ok(legacyResults.Select(r => new SemanticSearchResultDto { ContentHash = r.ContentHash, Snippet = r.OriginalText, - RelevanceScore = (float)(1 - r.Vector!.CosineDistance(queryVector1536)) + RelevanceScore = (float)(1 - (isSqlite ? CalculateCosineDistance(r.Vector!, queryVector1536) : r.Vector!.CosineDistance(queryVector1536))) }).ToList()); } @@ -95,10 +129,10 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler>(c.MetadataJson) @@ -124,4 +158,22 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler { base.OnModelCreating(modelBuilder); - modelBuilder.HasPostgresExtension("vector"); - modelBuilder.Entity(entity => { entity.Property(u => u.LastReadPageId).HasMaxLength(255); @@ -53,26 +52,59 @@ public class AppDbContext : IdentityDbContext entity.HasIndex(p => p.PlanName).IsUnique(); }); - modelBuilder.Entity(entity => + if (Database.IsSqlite()) { - entity.HasKey(e => e.ContentHash); - entity.HasIndex(e => e.ContentHash).IsUnique(); - entity.HasIndex(e => e.TenantId); - entity.Property(e => e.Vector).HasColumnType("vector(1536)"); - }); + var vectorConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter( + v => v != null ? string.Join(",", v.ToArray()) : string.Empty, + s => !string.IsNullOrEmpty(s) ? new Vector(s.Split(',').Select(float.Parse).ToArray()) : null! + ); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.ContentHash); + entity.HasIndex(e => e.ContentHash).IsUnique(); + entity.HasIndex(e => e.TenantId); + entity.Property(e => e.Vector).HasConversion(vectorConverter); + }); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.TenantId); + entity.HasIndex(e => e.EbookId); + entity.Property(e => e.Vector).HasConversion(vectorConverter); + + entity.HasOne(e => e.Ebook) + .WithMany() + .HasForeignKey(e => e.EbookId) + .OnDelete(DeleteBehavior.Cascade); + }); + } + else { - entity.HasKey(e => e.Id); - entity.HasIndex(e => e.TenantId); - entity.HasIndex(e => e.EbookId); - entity.Property(e => e.Vector).HasColumnType("vector(768)"); + modelBuilder.HasPostgresExtension("vector"); - entity.HasOne(e => e.Ebook) - .WithMany() - .HasForeignKey(e => e.EbookId) - .OnDelete(DeleteBehavior.Cascade); - }); + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.ContentHash); + entity.HasIndex(e => e.ContentHash).IsUnique(); + entity.HasIndex(e => e.TenantId); + entity.Property(e => e.Vector).HasColumnType("vector(1536)"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.TenantId); + 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.UI.Shared/wwwroot/js/auth.js b/src/NexusReader.UI.Shared/wwwroot/js/auth.js new file mode 100644 index 0000000..35165cc --- /dev/null +++ b/src/NexusReader.UI.Shared/wwwroot/js/auth.js @@ -0,0 +1,17 @@ +window.nexusAuth = { + submitLoginForm: function (formId, email, password, rememberMe) { + var form = document.getElementById(formId); + if (!form) return false; + + var emailInput = form.querySelector('input[name="email"]'); + var passwordInput = form.querySelector('input[name="password"]'); + var rememberMeInput = form.querySelector('input[name="rememberMe"]'); + + if (emailInput) emailInput.value = email; + if (passwordInput) passwordInput.value = password; + if (rememberMeInput) rememberMeInput.value = rememberMe ? "true" : "false"; + + form.submit(); + return true; + } +}; diff --git a/src/NexusReader.Web/Components/App.razor b/src/NexusReader.Web/Components/App.razor index f220135..4d84118 100644 --- a/src/NexusReader.Web/Components/App.razor +++ b/src/NexusReader.Web/Components/App.razor @@ -41,25 +41,8 @@ // Fallback: If for some reason 'load' doesn't fire (e.g. big assets), hide after 3s anyway setTimeout(hidePreloader, 3000); })(); - - window.nexusAuth = { - submitLoginForm: function (formId, email, password, rememberMe) { - var form = document.getElementById(formId); - if (!form) return false; - - var emailInput = form.querySelector('input[name="email"]'); - var passwordInput = form.querySelector('input[name="password"]'); - var rememberMeInput = form.querySelector('input[name="rememberMe"]'); - - if (emailInput) emailInput.value = email; - if (passwordInput) passwordInput.value = password; - if (rememberMeInput) rememberMeInput.value = rememberMe ? "true" : "false"; - - form.submit(); - return true; - } - }; + diff --git a/tests/NexusReader.Application.Tests/Queries/QueryTests.cs b/tests/NexusReader.Application.Tests/Queries/QueryTests.cs new file mode 100644 index 0000000..1379eda --- /dev/null +++ b/tests/NexusReader.Application.Tests/Queries/QueryTests.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.AI; +using Moq; +using NexusReader.Application.DTOs.AI; +using NexusReader.Application.DTOs.User; +using NexusReader.Application.Queries.Library; +using NexusReader.Data.Persistence; +using NexusReader.Domain.Entities; +using Pgvector; +using Xunit; + +namespace NexusReader.Application.Tests.Queries; + +public class QueryTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions _contextOptions; + private readonly Mock> _dbContextFactoryMock; + private readonly Mock>> _embeddingGeneratorMock; + + public QueryTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + _contextOptions = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + // Seed initial database schema + using var context = new AppDbContext(_contextOptions); + context.Database.EnsureCreated(); + + _dbContextFactoryMock = new Mock>(); + _dbContextFactoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny())) + .ReturnsAsync(() => new AppDbContext(_contextOptions)); + _dbContextFactoryMock.Setup(f => f.CreateDbContext()) + .Returns(() => new AppDbContext(_contextOptions)); + + _embeddingGeneratorMock = new Mock>>(); + } + + [Fact] + public async Task GetMyEbooksQuery_WithPopulatedDescription_ReturnsCorrectDescription() + { + // Arrange + using (var context = new AppDbContext(_contextOptions)) + { + var user = new NexusUser + { + Id = "user-123", + UserName = "testuser", + Email = "test@example.com", + TenantId = "tenant-123", + SubscriptionPlanId = 1 + }; + context.Users.Add(user); + + var author = new Author { Id = 1, Name = "Adam Mickiewicz" }; + context.Authors.Add(author); + + var ebook = new Ebook + { + Id = Guid.NewGuid(), + UserId = "user-123", + Title = "Pan Tadeusz", + AuthorId = author.Id, + Description = "A Polish epic poem written by Adam Mickiewicz.", + CoverUrl = "cover.png", + Progress = 42.5, + LastChapter = "Księga I", + LastChapterIndex = 1, + AddedDate = DateTime.UtcNow, + LastReadDate = DateTime.UtcNow, + FilePath = "dummy.epub" + }; + context.Ebooks.Add(ebook); + await context.SaveChangesAsync(); + } + + var handler = new GetMyEbooksQueryHandler(_dbContextFactoryMock.Object); + var query = new GetMyEbooksQuery("user-123"); + + // Act + var result = await handler.Handle(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(1); + result.Value.First().Title.Should().Be("Pan Tadeusz"); + result.Value.First().Description.Should().Be("A Polish epic poem written by Adam Mickiewicz."); + result.Value.First().Progress.Should().Be(42.5); + } + + [Fact] + public async Task SearchLibrarySemanticallyQuery_WithEmptyQueryText_ReturnsFailure() + { + // Arrange + var handler = new SearchLibrarySemanticallyQueryHandler(_dbContextFactoryMock.Object, _embeddingGeneratorMock.Object); + var query = new SearchLibrarySemanticallyQuery("", "tenant-123"); + + // Act + var result = await handler.Handle(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.Errors.First().Message.Should().Be("Query text cannot be empty."); + } + + [Fact] + public async Task SearchLibrarySemanticallyQuery_WithNoResults_TriggersFallback1536Embedding() + { + // Arrange + // Mock 768-dim primary embedding generator response + var embedding768 = new Embedding(new float[768]); + var mockResponse768 = new GeneratedEmbeddings>(new List> { embedding768 }); + _embeddingGeneratorMock.Setup(g => g.GenerateAsync( + It.Is>(s => s.Contains("test")), + It.Is(o => o.Dimensions == 768), + It.IsAny())) + .ReturnsAsync(mockResponse768); + + // Mock 1536-dim fallback embedding generator response + var embedding1536 = new Embedding(new float[1536]); + var mockResponse1536 = new GeneratedEmbeddings>(new List> { embedding1536 }); + _embeddingGeneratorMock.Setup(g => g.GenerateAsync( + It.Is>(s => s.Contains("test")), + It.Is(o => o.Dimensions == 1536), + It.IsAny())) + .ReturnsAsync(mockResponse1536); + + // Seed one legacy cache entry + using (var context = new AppDbContext(_contextOptions)) + { + var cacheEntry = new SemanticKnowledgeCache + { + TenantId = "tenant-123", + ContentHash = "hash-123", + OriginalText = "Fallback Cache Content Snippet", + Vector = new Vector(new float[1536]), + PromptVersion = "1", + CreatedAt = DateTime.UtcNow + }; + context.SemanticKnowledgeCache.Add(cacheEntry); + await context.SaveChangesAsync(); + } + + var handler = new SearchLibrarySemanticallyQueryHandler(_dbContextFactoryMock.Object, _embeddingGeneratorMock.Object); + var query = new SearchLibrarySemanticallyQuery("test", "tenant-123"); + + // Act + var result = await handler.Handle(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(1); + result.Value.First().Snippet.Should().Be("Fallback Cache Content Snippet"); + result.Value.First().ContentHash.Should().Be("hash-123"); + } + + public void Dispose() + { + _connection.Close(); + _connection.Dispose(); + } +}