feat(ai-ux): deduplicate AI queries, handle ServiceUnavailable retries, and optimize reader canvas graph prerendering #44

Merged
mjasin merged 2 commits from fix/firefox-login-error into develop 2026-05-18 17:53:36 +00:00
7 changed files with 350 additions and 53 deletions
Showing only changes of commit e6b4fcffd7 - Show all commits
+38
View File
@@ -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;
}
};
+1 -18
View File
@@ -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();
}
}