feat(ai-ux): deduplicate AI queries, handle ServiceUnavailable retries, and optimize reader canvas graph prerendering (#44)
This Pull Request encapsulates all outstanding AI, Blazor InteractiveAuto lifecycle, pgvector, and Firefox authorization/session compatibility fixes. ### Key Accomplishments: 1. **Concurrent Request Deduplication (Option B):** Implemented a thread-safe active task registry in `KnowledgeService` that groups concurrent graph extraction queries for the same content, preventing duplicate AI calls completely. 2. **Resilience Strategy for Downstream Demands:** Extended the `ai-retry` resilience pipeline to automatically intercept and retry on temporary Google API `503 ServiceUnavailable` / `high demand` spikes. 3. **Interactive Graph Generation Guard (Option A):** Prevented server-side prerender-phase graph requests in the reader canvas component. 4. **Firefox Compatibility & Cookie Handler:** Implemented an authentication endpoint and hybrid hidden-form submission flow to solve login, registration, and logout redirections and cookies securely. 5. **Autoscrolling & Graph Exclusions:** Added concept-to-block smooth scrolling, active block badging, and filtered out markdown code blocks from being extracted as nodes. All unit tests compiled and passed 100% cleanly. --------- Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Reviewed-on: #44 Co-authored-by: Antigravity <antigravity@google.com> Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #44.
This commit is contained in:
@@ -9,6 +9,7 @@ namespace NexusReader.Application.Commands.Library;
|
||||
/// <param name="AuthorName">The name of the author.</param>
|
||||
/// <param name="CoverImage">The raw bytes of the cover image (optional).</param>
|
||||
/// <param name="EpubData">The raw bytes of the EPUB file.</param>
|
||||
/// <param name="Description">The description or summary of the book (optional).</param>
|
||||
/// <param name="UserId">The ID of the user owning the book.</param>
|
||||
/// <param name="TenantId">The tenant ID for multi-tenant isolation. Defaults to "global" for single-tenant or default usage.</param>
|
||||
public record IngestEbookCommand(
|
||||
@@ -16,6 +17,7 @@ public record IngestEbookCommand(
|
||||
string AuthorName,
|
||||
byte[]? CoverImage,
|
||||
byte[] EpubData,
|
||||
string? Description,
|
||||
string UserId,
|
||||
string TenantId = "global"
|
||||
) : ICommand<Guid>;
|
||||
|
||||
@@ -63,6 +63,7 @@ public class IngestEbookCommandHandler : IRequestHandler<IngestEbookCommand, Res
|
||||
Author = author,
|
||||
FilePath = epubPath,
|
||||
CoverUrl = coverUrl,
|
||||
Description = request.Description,
|
||||
UserId = request.UserId,
|
||||
TenantId = request.TenantId,
|
||||
AddedDate = DateTime.UtcNow
|
||||
|
||||
@@ -4,5 +4,6 @@ public record IngestEbookRequest(
|
||||
string Title,
|
||||
string AuthorName,
|
||||
string? CoverImageBase64,
|
||||
string EpubDataBase64
|
||||
string EpubDataBase64,
|
||||
string? Description = null
|
||||
);
|
||||
|
||||
@@ -37,4 +37,5 @@ public record LastReadBookDto
|
||||
public double Progress { get; init; }
|
||||
public string? LastChapter { get; init; }
|
||||
public int LastChapterIndex { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Application.DTOs.User;
|
||||
using NexusReader.Data.Persistence;
|
||||
|
||||
namespace NexusReader.Application.Queries.Library;
|
||||
|
||||
public record GetMyEbooksQuery(string UserId) : IRequest<Result<List<LastReadBookDto>>>;
|
||||
|
||||
public class GetMyEbooksQueryHandler : IRequestHandler<GetMyEbooksQuery, Result<List<LastReadBookDto>>>
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
|
||||
public GetMyEbooksQueryHandler(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Result<List<LastReadBookDto>>> Handle(GetMyEbooksQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var ebooks = await dbContext.Ebooks
|
||||
.Where(e => e.UserId == request.UserId)
|
||||
.OrderByDescending(e => e.LastReadDate ?? e.AddedDate)
|
||||
.Select(e => new LastReadBookDto
|
||||
{
|
||||
Id = e.Id,
|
||||
Title = e.Title,
|
||||
Author = new AuthorDto
|
||||
{
|
||||
Id = e.Author.Id,
|
||||
Name = e.Author.Name
|
||||
},
|
||||
CoverUrl = e.CoverUrl,
|
||||
Progress = e.Progress,
|
||||
LastChapter = e.LastChapter ?? "Rozpoczynanie...",
|
||||
LastChapterIndex = e.LastChapterIndex,
|
||||
Description = e.Description
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(ebooks);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -38,33 +38,76 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibra
|
||||
using var dbContext = _dbContextFactory.CreateDbContext();
|
||||
try
|
||||
{
|
||||
// 1. Generate embedding for user query
|
||||
var embeddingResponse = await _embeddingGenerator.GenerateAsync(new[] { request.QueryText }, cancellationToken: cancellationToken);
|
||||
var queryVector = new Vector(embeddingResponse.First().Vector.ToArray());
|
||||
// 1. Generate 768-dimensional embedding for primary Knowledge Unit search
|
||||
var embeddingResponse768 = await _embeddingGenerator.GenerateAsync(
|
||||
new[] { request.QueryText },
|
||||
new EmbeddingGenerationOptions { Dimensions = 768 },
|
||||
cancellationToken: cancellationToken);
|
||||
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(queryVector))
|
||||
.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())
|
||||
{
|
||||
// Fallback to legacy cache if no granular units found
|
||||
var legacyResults = await dbContext.SemanticKnowledgeCache
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == request.TenantId && x.Vector != null)
|
||||
.OrderBy(x => x.Vector!.CosineDistance(queryVector))
|
||||
.Take(request.Limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
// 3. Fallback to 1536-dimensional embedding for legacy cache search
|
||||
var embeddingResponse1536 = await _embeddingGenerator.GenerateAsync(
|
||||
new[] { request.QueryText },
|
||||
new EmbeddingGenerationOptions { Dimensions = 1536 },
|
||||
cancellationToken: cancellationToken);
|
||||
var queryVector1536 = new Vector(embeddingResponse1536.First().Vector.ToArray());
|
||||
|
||||
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(queryVector))
|
||||
RelevanceScore = (float)(1 - (isSqlite ? CalculateCosineDistance(r.Vector!, queryVector1536) : r.Vector!.CosineDistance(queryVector1536)))
|
||||
}).ToList());
|
||||
}
|
||||
|
||||
@@ -86,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(queryVector)),
|
||||
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)
|
||||
@@ -115,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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,4 +19,9 @@ public record LocalEpubMetadata
|
||||
/// The raw bytes of the cover image, if available.
|
||||
/// </summary>
|
||||
public byte[]? CoverImage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The description or summary of the book.
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
|
||||
CoverUrl = e.CoverUrl,
|
||||
Progress = e.Progress,
|
||||
LastChapter = e.LastChapter ?? "Rozpoczynanie...",
|
||||
LastChapterIndex = e.LastChapterIndex
|
||||
LastChapterIndex = e.LastChapterIndex,
|
||||
Description = e.Description
|
||||
}).FirstOrDefault(),
|
||||
Roles = dbContext.UserRoles
|
||||
.Where(ur => ur.UserId == u.Id)
|
||||
|
||||
Reference in New Issue
Block a user