feat(ai-ux): deduplicate AI queries, handle ServiceUnavailable retries, and optimize reader canvas graph prerendering #44
@@ -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
|
||||
```
|
||||
@@ -13,7 +13,8 @@ public static class MappingConfig
|
||||
var config = TypeAdapterConfig.GlobalSettings;
|
||||
|
||||
config.NewConfig<NexusUser, UserProfileDto>();
|
||||
// Roles are mapped manually in queries due to Identity structure
|
||||
config.NewConfig<Ebook, LastReadBookDto>()
|
||||
.Map(dest => dest.Description, src => src.Description);
|
||||
|
||||
services.AddSingleton(config);
|
||||
services.AddScoped<IMapper, ServiceMapper>();
|
||||
@@ -21,3 +22,4 @@ public static class MappingConfig
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SearchLibra
|
||||
var queryVector768 = new Vector(embeddingResponse768.First().Vector.ToArray());
|
||||
|
||||
// 2. Perform Cosine Similarity Search on Knowledge Units
|
||||
var 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);
|
||||
List<KnowledgeUnit> 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<SearchLibra
|
||||
cancellationToken: cancellationToken);
|
||||
var queryVector1536 = new Vector(embeddingResponse1536.First().Vector.ToArray());
|
||||
|
||||
var 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);
|
||||
List<SemanticKnowledgeCache> 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<SearchLibra
|
||||
{
|
||||
var dto = new SemanticSearchResultDto
|
||||
{
|
||||
ContentHash = c.Id,
|
||||
ContentHash = c.Id.ToString(),
|
||||
Snippet = c.Content,
|
||||
UnitType = c.Type.ToString(),
|
||||
RelevanceScore = (float)(1 - c.Vector!.CosineDistance(queryVector768)),
|
||||
RelevanceScore = (float)(1 - (isSqlite ? CalculateCosineDistance(c.Vector!, queryVector768) : c.Vector!.CosineDistance(queryVector768))),
|
||||
Metadata = string.IsNullOrEmpty(c.MetadataJson)
|
||||
? null
|
||||
: JsonSerializer.Deserialize<Dictionary<string, object>>(c.MetadataJson)
|
||||
@@ -124,4 +158,22 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibra
|
||||
return Result.Fail(new Error("Failed to perform semantic search").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
private static double CalculateCosineDistance(Vector v1, Vector v2)
|
||||
{
|
||||
var a = v1.ToArray();
|
||||
var b = v2.ToArray();
|
||||
if (a.Length != b.Length) return 1.0;
|
||||
double dotProduct = 0;
|
||||
double l1 = 0;
|
||||
double l2 = 0;
|
||||
for (int i = 0; i < a.Length; i++)
|
||||
{
|
||||
dotProduct += a[i] * b[i];
|
||||
l1 += a[i] * a[i];
|
||||
l2 += b[i] * b[i];
|
||||
}
|
||||
if (l1 == 0 || l2 == 0) return 1.0;
|
||||
return 1.0 - (dotProduct / (Math.Sqrt(l1) * Math.Sqrt(l2)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Domain.Entities;
|
||||
using Pgvector;
|
||||
|
||||
|
||||
namespace NexusReader.Data.Persistence;
|
||||
@@ -30,8 +31,6 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.HasPostgresExtension("vector");
|
||||
|
||||
modelBuilder.Entity<NexusUser>(entity =>
|
||||
{
|
||||
entity.Property(u => u.LastReadPageId).HasMaxLength(255);
|
||||
@@ -53,26 +52,59 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
||||
entity.HasIndex(p => p.PlanName).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SemanticKnowledgeCache>(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<Vector, string>(
|
||||
v => v != null ? string.Join(",", v.ToArray()) : string.Empty,
|
||||
s => !string.IsNullOrEmpty(s) ? new Vector(s.Split(',').Select(float.Parse).ToArray()) : null!
|
||||
);
|
||||
|
||||
modelBuilder.Entity<SemanticKnowledgeCache>(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<KnowledgeUnit>(entity =>
|
||||
modelBuilder.Entity<KnowledgeUnit>(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<SemanticKnowledgeCache>(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<KnowledgeUnit>(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<KnowledgeUnitLink>(entity =>
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<script src="_content/NexusReader.UI.Shared/js/auth.js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
|
||||
@@ -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<AppDbContext> _contextOptions;
|
||||
private readonly Mock<IDbContextFactory<AppDbContext>> _dbContextFactoryMock;
|
||||
private readonly Mock<IEmbeddingGenerator<string, Embedding<float>>> _embeddingGeneratorMock;
|
||||
|
||||
public QueryTests()
|
||||
{
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
_contextOptions = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
// Seed initial database schema
|
||||
using var context = new AppDbContext(_contextOptions);
|
||||
context.Database.EnsureCreated();
|
||||
|
||||
_dbContextFactoryMock = new Mock<IDbContextFactory<AppDbContext>>();
|
||||
_dbContextFactoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() => new AppDbContext(_contextOptions));
|
||||
_dbContextFactoryMock.Setup(f => f.CreateDbContext())
|
||||
.Returns(() => new AppDbContext(_contextOptions));
|
||||
|
||||
_embeddingGeneratorMock = new Mock<IEmbeddingGenerator<string, Embedding<float>>>();
|
||||
}
|
||||
|
||||
[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<float>(new float[768]);
|
||||
var mockResponse768 = new GeneratedEmbeddings<Embedding<float>>(new List<Embedding<float>> { embedding768 });
|
||||
_embeddingGeneratorMock.Setup(g => g.GenerateAsync(
|
||||
It.Is<IEnumerable<string>>(s => s.Contains("test")),
|
||||
It.Is<EmbeddingGenerationOptions>(o => o.Dimensions == 768),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(mockResponse768);
|
||||
|
||||
// Mock 1536-dim fallback embedding generator response
|
||||
var embedding1536 = new Embedding<float>(new float[1536]);
|
||||
var mockResponse1536 = new GeneratedEmbeddings<Embedding<float>>(new List<Embedding<float>> { embedding1536 });
|
||||
_embeddingGeneratorMock.Setup(g => g.GenerateAsync(
|
||||
It.Is<IEnumerable<string>>(s => s.Contains("test")),
|
||||
It.Is<EmbeddingGenerationOptions>(o => o.Dimensions == 1536),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user