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:
2026-05-18 17:53:36 +00:00
committed by Marek Jaisński
parent f808734768
commit 541e9e1fb5
42 changed files with 2351 additions and 155 deletions
@@ -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)));
}
}