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
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Collections.Concurrent;
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.AI;
@@ -29,7 +30,8 @@ public class KnowledgeService : IKnowledgeService
private readonly AiSettings _settings;
private readonly Tokenizer _tokenizer;
private readonly ILogger<KnowledgeService> _logger;
private const string PromptVersion = "1.0";
private const string PromptVersion = "1.3";
private static readonly ConcurrentDictionary<string, Task<Result<KnowledgePacket>>> _activeRequests = new();
public KnowledgeService(
IChatClient chatClient,
@@ -96,9 +98,27 @@ public class KnowledgeService : IKnowledgeService
}
}
// Deduplicate concurrent active requests for the exact same hash
var requestKey = $"{tenantId}:{hash}:{traceType}";
var task = _activeRequests.GetOrAdd(requestKey, _ =>
ExecuteAiRequestAndCacheAsync(normalizedText, tenantId, systemPrompt, traceType, ebookId, hash));
return await task;
}
private async Task<Result<KnowledgePacket>> ExecuteAiRequestAndCacheAsync(
string normalizedText,
string tenantId,
string systemPrompt,
string traceType,
Guid? ebookId,
string hash)
{
_logger.LogInformation("[KnowledgeService] Cache Miss for {TraceType} ({Hash}). Requesting AI...", traceType, hash);
try
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
var options = new ChatOptions
{
Temperature = (float)_settings.Temperature,
@@ -110,7 +130,7 @@ public class KnowledgeService : IKnowledgeService
{
new ChatMessage(ChatRole.System, systemPrompt),
new ChatMessage(ChatRole.User, normalizedText)
}, options, cancellationToken: ct), cancellationToken);
}, options, cancellationToken: ct));
var rawResponse = response.Text?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(rawResponse)) return Result.Fail("AI returned an empty response.");
@@ -129,16 +149,18 @@ public class KnowledgeService : IKnowledgeService
try
{
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct =>
await _embeddingGenerator.GenerateAsync(new[] { normalizedText }, cancellationToken: ct), cancellationToken);
await _embeddingGenerator.GenerateAsync(new[] { normalizedText }, new EmbeddingGenerationOptions { Dimensions = 1536 }, cancellationToken: ct));
vector = embeddingResponse.First().Vector.ToArray();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[KnowledgeService] Embedding generation failed; proceeding without vector.");
// We continue even if embedding fails, as the primary goal was knowledge extraction
}
// 4. Save to Cache
var cached = await dbContext.SemanticKnowledgeCache
.FirstOrDefaultAsync(c => c.ContentHash == hash && c.TenantId == tenantId);
var cacheEntry = new SemanticKnowledgeCache
{
ContentHash = hash,
@@ -161,9 +183,9 @@ public class KnowledgeService : IKnowledgeService
}
// 5. Process structured KnowledgeUnits (Graph Expansion)
await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, ebookId, dbContext, cancellationToken);
await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, ebookId, dbContext, default);
await dbContext.SaveChangesAsync(cancellationToken);
await dbContext.SaveChangesAsync();
return Result.Ok(knowledgePacket);
}
catch (JsonException ex)
@@ -176,8 +198,14 @@ public class KnowledgeService : IKnowledgeService
{
return Result.Fail(new Error("Failed to extract knowledge from AI").CausedBy(ex));
}
finally
{
var requestKey = $"{tenantId}:{hash}:{traceType}";
_activeRequests.TryRemove(requestKey, out _);
}
}
private async Task ProcessKnowledgeUnitsAsync(KnowledgePacket packet, string tenantId, Guid? ebookId, AppDbContext dbContext, CancellationToken cancellationToken)
{
var unitIds = packet.Units.Select(u => u.Id).ToList();
@@ -217,7 +245,7 @@ public class KnowledgeService : IKnowledgeService
try
{
var emb = await _retryPipeline.ExecuteAsync(async ct =>
await _embeddingGenerator.GenerateAsync(new[] { unit.Content }, cancellationToken: ct), cancellationToken);
await _embeddingGenerator.GenerateAsync(new[] { unit.Content }, new EmbeddingGenerationOptions { Dimensions = 768 }, cancellationToken: ct), cancellationToken);
unit.Vector = new Vector(emb.First().Vector.ToArray());
}
catch { /* Ignore embedding errors for now */ }