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:
@@ -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 */ }
|
||||
|
||||
Reference in New Issue
Block a user