From 75c7b2f279f9b18b53d4fe5cef8c24c0fc7b04dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Tue, 26 May 2026 13:43:05 +0200 Subject: [PATCH] feat: implement interactive citation markers with metadata and optimize knowledge caching with concurrent collision handling --- .../DTOs/AI/GroundedResponseDto.cs | 2 + .../Services/KnowledgeService.cs | 60 +- .../Atoms/NexusCitationMarker.razor | 76 ++ .../Atoms/NexusCitationMarker.razor.css | 148 ++++ .../Components/Organisms/ReaderCanvas.razor | 11 + .../Pages/Intelligence.razor | 838 +++++++++++------- .../Services/SyncService.cs | 9 +- 7 files changed, 830 insertions(+), 314 deletions(-) create mode 100644 src/NexusReader.UI.Shared/Components/Atoms/NexusCitationMarker.razor create mode 100644 src/NexusReader.UI.Shared/Components/Atoms/NexusCitationMarker.razor.css diff --git a/src/NexusReader.Application/DTOs/AI/GroundedResponseDto.cs b/src/NexusReader.Application/DTOs/AI/GroundedResponseDto.cs index 7bb7229..216fb2a 100644 --- a/src/NexusReader.Application/DTOs/AI/GroundedResponseDto.cs +++ b/src/NexusReader.Application/DTOs/AI/GroundedResponseDto.cs @@ -13,4 +13,6 @@ public class CitationDto public string CitationId { get; set; } = string.Empty; // e.g., chunk hash/ID public string Snippet { get; set; } = string.Empty; // Verified text snippet from context public string SourceBook { get; set; } = string.Empty; // Book title or description + public string? Author { get; set; } + public int? PageNumber { get; set; } } diff --git a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs index b0b7278..c52d40d 100644 --- a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs +++ b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs @@ -90,7 +90,7 @@ public class KnowledgeService : IKnowledgeService // 1. Check Cache var cached = await dbContext.SemanticKnowledgeCache - .FirstOrDefaultAsync(c => c.ContentHash == hash && (c.TenantId == tenantId || c.TenantId == "global"), cancellationToken); + .FirstOrDefaultAsync(c => c.ContentHash == hash, cancellationToken); if (cached != null && cached.PromptVersion == PromptVersion) { @@ -112,7 +112,7 @@ public class KnowledgeService : IKnowledgeService } // Deduplicate concurrent active requests for the exact same hash - var requestKey = $"{tenantId}:{hash}:{traceType}"; + var requestKey = $"{hash}:{traceType}"; var lazyTask = _activeRequests.GetOrAdd(requestKey, k => new Lazy>>( @@ -184,7 +184,7 @@ public class KnowledgeService : IKnowledgeService // 4. Save to Cache var cached = await dbContext.SemanticKnowledgeCache - .FirstOrDefaultAsync(c => c.ContentHash == hash && c.TenantId == tenantId); + .FirstOrDefaultAsync(c => c.ContentHash == hash); var cacheEntry = new SemanticKnowledgeCache { @@ -208,7 +208,14 @@ public class KnowledgeService : IKnowledgeService // 5. Process structured KnowledgeUnits (Graph Expansion) await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, ebookId, dbContext, default); - await dbContext.SaveChangesAsync(); + try + { + await dbContext.SaveChangesAsync(); + } + catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pgEx && pgEx.SqlState == "23505") + { + _logger.LogWarning("[KnowledgeService] Concurrency collision on SemanticKnowledgeCache for {Hash}; another process saved it first. Swallowing.", hash); + } return Result.Ok(knowledgePacket); } catch (JsonException ex) @@ -1055,15 +1062,52 @@ public class KnowledgeService : IKnowledgeService return Result.Fail("Failed to deserialize grounded RAG response."); } - // Hydrate book titles for citations if unknown + // Hydrate book titles, author, and page number for citations if unknown foreach (var citation in groundedResult.Citations) { if (pointMap.TryGetValue(citation.CitationId, out var point) && point.Payload.TryGetValue("ebookId", out var ev) && - Guid.TryParse(ev.StringValue, out var ebId) && - ebookTitles.TryGetValue(ebId, out var title)) + Guid.TryParse(ev.StringValue, out var ebId)) { - citation.SourceBook = title; + if (ebookTitles.TryGetValue(ebId, out var title)) + { + citation.SourceBook = title; + } + } + + // Look up from guidMap to get exact page number and author + if (guidMap.TryGetValue(citation.CitationId, out var unit)) + { + if (unit.Ebook?.Author != null) + { + citation.Author = unit.Ebook.Author.Name; + } + else if (unit.EbookId.HasValue) + { + try + { + using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + var eb = await dbContext.Ebooks.Include(e => e.Author).FirstOrDefaultAsync(e => e.Id == unit.EbookId.Value, cancellationToken); + if (eb?.Author != null) + { + citation.Author = eb.Author.Name; + } + } + catch { } + } + + if (!string.IsNullOrEmpty(unit.MetadataJson)) + { + try + { + var meta = JsonSerializer.Deserialize>(unit.MetadataJson); + if (meta != null && meta.TryGetValue("page", out var pageObj) && int.TryParse(pageObj?.ToString(), out var pageVal)) + { + citation.PageNumber = pageVal; + } + } + catch { } + } } } diff --git a/src/NexusReader.UI.Shared/Components/Atoms/NexusCitationMarker.razor b/src/NexusReader.UI.Shared/Components/Atoms/NexusCitationMarker.razor new file mode 100644 index 0000000..59e81d7 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Atoms/NexusCitationMarker.razor @@ -0,0 +1,76 @@ +@using NexusReader.Application.DTOs.AI + +
+ + + @if (_isHovered && _citation != null) + { +
+ + + +
+ } +
+ +@code { + [Parameter] + [EditorRequired] + public string SourceId { get; set; } = string.Empty; + + [Parameter] + public List? Citations { get; set; } + + private bool _isHovered; + private CitationDto? _citation; + + protected override void OnParametersSet() + { + _citation = Citations?.FirstOrDefault(c => c.CitationId.Equals(SourceId, System.StringComparison.OrdinalIgnoreCase)); + + // If not found in the thread citations, provide a clean fallback so the UI never displays an empty error + if (_citation == null) + { + _citation = new CitationDto + { + CitationId = SourceId, + SourceBook = "Grounded Document Chunk", + Snippet = "Context snippet retrieved from vector search node." + }; + } + } + + private void ShowPopup() + { + _isHovered = true; + } + + private void HidePopup() + { + _isHovered = false; + } +} diff --git a/src/NexusReader.UI.Shared/Components/Atoms/NexusCitationMarker.razor.css b/src/NexusReader.UI.Shared/Components/Atoms/NexusCitationMarker.razor.css new file mode 100644 index 0000000..f6bd4ef --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Atoms/NexusCitationMarker.razor.css @@ -0,0 +1,148 @@ +.nexus-citation-container { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + vertical-align: middle; + margin: 0 4px; +} + +.nexus-citation-trigger { + background: transparent; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: #06b6d4; /* Glowing Cyan */ + width: 20px; + height: 20px; + position: relative; + outline: none; + transition: all 0.3s ease; +} + +.nexus-citation-trigger:hover { + color: #00ff99; /* Neon Green on hover */ + transform: scale(1.2); +} + +.neon-radar-svg { + width: 100%; + height: 100%; + filter: drop-shadow(0 0 4px currentColor); + animation: radar-spin 8s linear infinite; +} + +.pulse-ring { + position: absolute; + width: 100%; + height: 100%; + border: 1px solid currentColor; + border-radius: 50%; + animation: radar-ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite; + opacity: 0; + pointer-events: none; +} + +.nexus-citation-popup { + position: absolute; + bottom: calc(100% + 10px); + left: 50%; + transform: translateX(-50%) translateY(5px); + width: 320px; + padding: 1rem; + border-radius: 12px; + background: rgba(10, 16, 26, 0.9); /* Premium dark background */ + border: 1px solid rgba(6, 182, 212, 0.25); /* Cyan border */ + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5), 0 0 12px rgba(6, 182, 212, 0.15); + z-index: 1000; + pointer-events: none; + opacity: 0; + animation: popup-fade-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards; + transform-origin: bottom center; +} + +.popup-header { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; + font-size: 0.75rem; + font-weight: 700; + color: #00ff99; /* Emerald/Neon Green micro-header */ + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 0.5rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + padding-bottom: 0.35rem; +} + +.separator { + color: rgba(255, 255, 255, 0.3); +} + +.book-title { + display: flex; + align-items: center; + gap: 4px; +} + +.book-author, .page-number { + color: rgba(255, 255, 255, 0.6); +} + +.popup-body { + margin-bottom: 0.5rem; +} + +.citation-quote { + font-size: 0.85rem; + line-height: 1.4; + color: rgba(255, 255, 255, 0.95); + font-style: italic; + margin: 0; +} + +.popup-footer { + display: flex; + justify-content: flex-end; +} + +.id-badge { + font-size: 0.65rem; + color: rgba(255, 255, 255, 0.3); + font-family: monospace; +} + +/* Animations */ +@keyframes radar-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +@keyframes radar-ping { + 0% { + transform: scale(1); + opacity: 0.8; + } + 100% { + transform: scale(2.2); + opacity: 0; + } +} + +@keyframes popup-fade-in { + 0% { + opacity: 0; + transform: translateX(-50%) translateY(8px) scale(0.95); + } + 100% { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + } +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor index c34e6fd..1646a98 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor @@ -67,6 +67,7 @@ private bool _isJsInitialized; private ElementReference _containerRef; private bool _isInteractive; + private string? _currentActiveBlockId; protected override async Task OnInitializedAsync() { @@ -143,6 +144,7 @@ [JSInvokable] public async Task HandleBlockReached(string blockId, string content) { + _currentActiveBlockId = blockId; await Coordinator.OnBlockReachedAsync(blockId, content); if (ViewModel != null) @@ -160,8 +162,15 @@ private async Task HandleSyncProgressReceived(string blockId, DateTime timestamp) { + if (string.IsNullOrEmpty(blockId) || blockId == _currentActiveBlockId) + { + Logger.LogDebug("[Sync] Received progress {BlockId} is empty or matches active block. Ignoring scroll.", blockId); + return; + } + Logger.LogInformation("[Sync] Received progress from another device: block {BlockId} at {Timestamp}", blockId, timestamp); + _currentActiveBlockId = blockId; await ScrollToNodeAsync(blockId); await InvokeAsync(StateHasChanged); } @@ -212,6 +221,7 @@ private async Task LoadChapterAsync(int index) { await Coordinator.ClearAsync(); + _isJsInitialized = false; // Reset JS initialization to re-bind the scroll observer to new DOM elements! _isLoadingChapter = true; StatusMessage = "Wczytywanie treści..."; StateHasChanged(); @@ -253,6 +263,7 @@ { var targetBlockId = NavigationService.PendingScrollBlockId; NavigationService.PendingScrollBlockId = null; // Clear it to prevent multiple scrolls + _currentActiveBlockId = targetBlockId; // Give the browser slightly more than one frame to render the loaded blocks await Task.Delay(150); diff --git a/src/NexusReader.UI.Shared/Pages/Intelligence.razor b/src/NexusReader.UI.Shared/Pages/Intelligence.razor index ef4e545..c8ea621 100644 --- a/src/NexusReader.UI.Shared/Pages/Intelligence.razor +++ b/src/NexusReader.UI.Shared/Pages/Intelligence.razor @@ -3,184 +3,430 @@ @using NexusReader.Application.DTOs.AI @using NexusReader.Application.Abstractions.Services @using NexusReader.Application.DTOs.User +@using NexusReader.UI.Shared.Components.Atoms @using System.Net.Http.Json @inject HttpClient Http @inject IKnowledgeService KnowledgeService @inject AuthenticationStateProvider AuthStateProvider -
-

Global AI Q&A

-

Search, interrogate, and extract grounded facts from your library using Polyglot KM-RAG

+

Global Intelligence

+

Interrogate, explore, and synthesize grounded knowledge from your library using Polyglot KM-RAG

-
-
- - -
- -
- - -
-
- -
- @if (_isLoading) +
+ @if (_chatMessages.Count == 0) { -
-
- Analyzing conceptual graph and synthesizing response... +
+
+ + + +
+

Start Interrogating Your Library

+

Ask complex questions across your entire ebook collection. The KM-RAG engine dynamically builds semantic maps, resolves dependencies, and formulates high-fidelity, grounded answers with interactive popover citations.

} - else if (_response != null) + else { -
-
-

Answer

-
- @_response.Answer -
-
- - @if (_response.Citations != null && _response.Citations.Any()) +
+ @foreach (var message in _chatMessages) { -
-

Grounded Citations

-
- @foreach (var citation in _response.Citations) +
+
+ @if (message.Sender == "User") { -
-
- @citation.SourceBook - @if (!string.IsNullOrEmpty(citation.CitationId) && citation.CitationId.Length > 8) - { - ID: @citation.CitationId.Substring(0, Math.Min(8, citation.CitationId.Length)) - } -
-
- "@citation.Snippet" -
-
+ } + else + { + + } +
+
+
+ @message.Sender + @message.Timestamp.ToString("HH:mm") +
+
+ @foreach (var segment in message.Segments) + { + @if (segment.IsCitation) + { + + } + else + { + @RenderMarkdown(segment.Text) + } + } +
+
+
+ } + + @if (_isLoading) + { +
+
+ +
+
+
+ AI + Thinking... +
+
+
+ + + +
+ Analyzing conceptual graphs and synthesizing response... +
}
} - else if (_hasSearched) - { -
- -

No answers generated. Try adjusting your question.

-
- } - else - { -
-
- - - +
+ +
+
+
+
+ +
-

Start Interrogating Your Library

-

Ask complex questions across all your books. The system will search vectors, pull concept graph relations, and formulate a grounded answer with precise citations.

- } + +
+ + +
+
@code { private string _question = string.Empty; private string _selectedBookId = string.Empty; private bool _isLoading; - private bool _hasSearched; - private GroundedResponseDto? _response; private List? _books; + private List _chatMessages = new(); + + public class ChatMessage + { + public string Id { get; set; } = Guid.NewGuid().ToString(); + public string Sender { get; set; } = string.Empty; // "User" or "AI" + public string Text { get; set; } = string.Empty; + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + public List Segments { get; set; } = new(); + public List Citations { get; set; } = new(); + } + + public class ResponseSegment + { + public string Text { get; set; } = string.Empty; + public bool IsCitation { get; set; } + public string CitationId { get; set; } = string.Empty; + } protected override async Task OnInitializedAsync() { @@ -457,9 +592,18 @@ { if (string.IsNullOrWhiteSpace(_question) || _isLoading) return; + var userQuestion = _question; + _question = string.Empty; // Clear input field immediately _isLoading = true; - _hasSearched = true; - _response = null; + + // Add user query message + _chatMessages.Add(new ChatMessage + { + Sender = "User", + Text = userQuestion, + Segments = new List { new ResponseSegment { Text = userQuestion, IsCitation = false } } + }); + StateHasChanged(); try @@ -473,27 +617,38 @@ var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global"; - var result = await KnowledgeService.AskQuestionAsync(_question, tenantId, ebookId); + var result = await KnowledgeService.AskQuestionAsync(userQuestion, tenantId, ebookId); if (result.IsSuccess) { - _response = result.Value; + var response = result.Value; + _chatMessages.Add(new ChatMessage + { + Sender = "AI", + Text = response.Answer, + Segments = ParseSegments(response.Answer), + Citations = response.Citations + }); } else { - _response = new GroundedResponseDto + var errMsg = $"Error: {result.Errors.FirstOrDefault()?.Message ?? "An error occurred."}"; + _chatMessages.Add(new ChatMessage { - Answer = $"Error: {result.Errors.FirstOrDefault()?.Message ?? "An error occurred."}", - Citations = new List() - }; + Sender = "AI", + Text = errMsg, + Segments = new List { new ResponseSegment { Text = errMsg, IsCitation = false } } + }); } } catch (Exception ex) { - _response = new GroundedResponseDto + var errMsg = $"Network/API Error: {ex.Message}"; + _chatMessages.Add(new ChatMessage { - Answer = $"Network/API Error: {ex.Message}", - Citations = new List() - }; + Sender = "AI", + Text = errMsg, + Segments = new List { new ResponseSegment { Text = errMsg, IsCitation = false } } + }); } finally { @@ -501,4 +656,77 @@ StateHasChanged(); } } + + private List ParseSegments(string text) + { + var segments = new List(); + if (string.IsNullOrEmpty(text)) return segments; + + // Matches [Source ID: some-id] OR raw GUIDs in brackets [e225e58f-7539-cd51-e0ab-82741ec7e65c] + var regex = new System.Text.RegularExpressions.Regex( + @"\[Source ID:\s*([^\]]+)\]|\[([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})\]", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + var matches = regex.Matches(text); + + int lastIndex = 0; + foreach (System.Text.RegularExpressions.Match match in matches) + { + if (match.Index > lastIndex) + { + segments.Add(new ResponseSegment + { + Text = text.Substring(lastIndex, match.Index - lastIndex), + IsCitation = false + }); + } + + var citationId = match.Groups[1].Success + ? match.Groups[1].Value.Trim() + : match.Groups[2].Value.Trim(); + + segments.Add(new ResponseSegment + { + IsCitation = true, + CitationId = citationId + }); + + lastIndex = match.Index + match.Length; + } + + if (lastIndex < text.Length) + { + segments.Add(new ResponseSegment + { + Text = text.Substring(lastIndex), + IsCitation = false + }); + } + + return segments; + } + + private MarkupString RenderMarkdown(string text) + { + if (string.IsNullOrEmpty(text)) return new MarkupString(string.Empty); + + // 1. HTML Encode to prevent XSS + var html = System.Net.WebUtility.HtmlEncode(text); + + // 2. Bold: **text** -> text + html = System.Text.RegularExpressions.Regex.Replace(html, @"\*\*(.*?)\*\*", "$1"); + + // 3. Italic: *text* -> text + html = System.Text.RegularExpressions.Regex.Replace(html, @"\*(.*?)\*", "$1"); + + // 4. Code blocks: ```language ... ``` ->
...
+ html = System.Text.RegularExpressions.Regex.Replace(html, @"```(?:[a-zA-Z0-9+#]+)?\s*([\s\S]*?)\s*```", "
$1
"); + + // 5. Inline Code: `code` -> code + html = System.Text.RegularExpressions.Regex.Replace(html, @"`(.*?)`", "$1"); + + // 6. Newlines: \n ->
+ html = html.Replace("\n", "
"); + + return new MarkupString(html); + } } diff --git a/src/NexusReader.UI.Shared/Services/SyncService.cs b/src/NexusReader.UI.Shared/Services/SyncService.cs index 8e0227d..1494f2d 100644 --- a/src/NexusReader.UI.Shared/Services/SyncService.cs +++ b/src/NexusReader.UI.Shared/Services/SyncService.cs @@ -51,6 +51,12 @@ public class SyncService : ISyncService, IAsyncDisposable _hubConnection.On("ProgressUpdated", async (pageId, timestamp) => { // Note: In the future we might want to receive ebookId and progress here too + if (pageId == _lastSentPageId) + { + _logger.LogDebug("[Sync] Ignoring self progress update for page {PageId}.", pageId); + return; + } + _lastSentPageId = pageId; // Prevent echoing back duplicate progress updates if (OnProgressReceived != null) await OnProgressReceived(pageId, timestamp); }); @@ -77,6 +83,8 @@ public class SyncService : ISyncService, IAsyncDisposable { if (pageId == _lastSentPageId) return Result.Ok(); + _lastSentPageId = pageId; + // Proper trailing-edge debounce _debounceCts?.Cancel(); _debounceCts = new CancellationTokenSource(); @@ -93,7 +101,6 @@ public class SyncService : ISyncService, IAsyncDisposable if (_hubConnection?.State == HubConnectionState.Connected) { await _hubConnection.SendAsync("UpdateProgress", pageId, ebookId, progress, chapterTitle, chapterIndex); - _lastSentPageId = pageId; } } catch (TaskCanceledException) { /* Ignored, user kept scrolling */ }