From 1c6ee82d01aeb8baa6eabd71c6eba9aecac183f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Mon, 25 May 2026 08:51:21 +0200 Subject: [PATCH 1/5] feat: implement background ebook indexing with progress tracking and real-time UI updates --- .../Abstractions/Services/IEpubExtractor.cs | 17 ++ .../Library/IngestEbookCommandHandler.cs | 21 +- .../Commands/Library/ProcessEbookCommand.cs | 177 ++++++++++++++++ .../DTOs/User/UserProfileDto.cs | 1 + .../Queries/Graph/GraphViewModels.cs | 23 ++- .../Queries/Library/GetMyEbooksQuery.cs | 3 +- .../User/GetUserProfileQueryHandler.cs | 3 +- .../DependencyInjection.cs | 1 + .../Services/EpubExtractor.cs | 85 ++++++++ .../Services/KnowledgeService.cs | 2 +- .../Services/PromptRegistry.cs | 30 +-- .../Organisms/BookIngestionModal.razor | 88 +++++++- .../Organisms/BookIngestionModal.razor.css | 66 ++++++ .../Components/Organisms/ReaderCanvas.razor | 1 + .../Services/ISyncService.cs | 1 + .../Services/KnowledgeCoordinator.cs | 4 + .../Services/SyncService.cs | 6 + .../wwwroot/js/knowledgeGraph.js | 189 +++++++++++++++--- src/NexusReader.Web.Client/Program.cs | 7 + src/NexusReader.Web/Program.cs | 50 ++++- 20 files changed, 718 insertions(+), 57 deletions(-) create mode 100644 src/NexusReader.Application/Abstractions/Services/IEpubExtractor.cs create mode 100644 src/NexusReader.Application/Commands/Library/ProcessEbookCommand.cs create mode 100644 src/NexusReader.Infrastructure/Services/EpubExtractor.cs diff --git a/src/NexusReader.Application/Abstractions/Services/IEpubExtractor.cs b/src/NexusReader.Application/Abstractions/Services/IEpubExtractor.cs new file mode 100644 index 0000000..e5199d4 --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Services/IEpubExtractor.cs @@ -0,0 +1,17 @@ +using FluentResults; + +namespace NexusReader.Application.Abstractions.Services; + +/// +/// Service abstraction to extract raw text content from EPUB chapters. +/// +public interface IEpubExtractor +{ + /// + /// Extracts the sanitized, plain-text content of each chapter in the EPUB file. + /// + /// The relative storage path of the EPUB file. + /// Cancellation token. + /// A list of plain-text chapters, or a failure result. + Task>> ExtractChaptersTextAsync(string relativePath, CancellationToken cancellationToken = default); +} diff --git a/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs b/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs index 0ae9e21..ca53adc 100644 --- a/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs +++ b/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs @@ -1,5 +1,6 @@ using FluentResults; using MediatR; +using Microsoft.Extensions.DependencyInjection; using NexusReader.Application.Abstractions.Messaging; using NexusReader.Application.Abstractions.Persistence; using NexusReader.Application.Abstractions.Services; @@ -11,13 +12,16 @@ public class IngestEbookCommandHandler : IRequestHandler> Handle(IngestEbookCommand request, CancellationToken cancellationToken) @@ -72,6 +76,21 @@ public class IngestEbookCommandHandler : IRequestHandler + { + try + { + using var scope = _scopeFactory.CreateScope(); + var mediator = scope.ServiceProvider.GetRequiredService(); + await mediator.Send(new ProcessEbookCommand(ebook.Id, request.UserId, request.TenantId)); + } + catch (Exception) + { + // Swallowed to prevent ThreadPool crashes + } + }); + return Result.Ok(ebook.Id); } catch (Exception ex) diff --git a/src/NexusReader.Application/Commands/Library/ProcessEbookCommand.cs b/src/NexusReader.Application/Commands/Library/ProcessEbookCommand.cs new file mode 100644 index 0000000..5a1e9c4 --- /dev/null +++ b/src/NexusReader.Application/Commands/Library/ProcessEbookCommand.cs @@ -0,0 +1,177 @@ +using System.Text.RegularExpressions; +using FluentResults; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NexusReader.Application.Abstractions.Messaging; +using NexusReader.Application.Abstractions.Services; +using NexusReader.Data.Persistence; + +namespace NexusReader.Application.Commands.Library; + +public record ProcessEbookCommand( + Guid EbookId, + string UserId, + string TenantId +) : ICommand; + +public class ProcessEbookCommandHandler : IRequestHandler> +{ + private readonly IDbContextFactory _dbContextFactory; + private readonly IKnowledgeService _knowledgeService; + private readonly IEpubExtractor _epubExtractor; + private readonly ISyncBroadcaster _broadcaster; + private readonly ILogger _logger; + + public ProcessEbookCommandHandler( + IDbContextFactory dbContextFactory, + IKnowledgeService knowledgeService, + IEpubExtractor epubExtractor, + ISyncBroadcaster broadcaster, + ILogger logger) + { + _dbContextFactory = dbContextFactory; + _knowledgeService = knowledgeService; + _epubExtractor = epubExtractor; + _broadcaster = broadcaster; + _logger = logger; + } + + public async Task> Handle(ProcessEbookCommand request, CancellationToken cancellationToken) + { + _logger.LogInformation("[ProcessEbook] Starting background processing for Ebook: {EbookId}", request.EbookId); + + try + { + await _broadcaster.BroadcastIngestionProgressAsync(request.UserId, "Wyszukiwanie e-booka w bazie danych...", 0.05, cancellationToken); + + using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + var ebook = await dbContext.Ebooks.FindAsync(new object[] { request.EbookId }, cancellationToken); + if (ebook == null) + { + _logger.LogError("[ProcessEbook] Ebook not found in database: {EbookId}", request.EbookId); + return Result.Fail($"Ebook nie znaleziony w bazie danych: {request.EbookId}"); + } + + _logger.LogInformation("[ProcessEbook] Extracting chapters text for Ebook: {Title} ({FilePath})", ebook.Title, ebook.FilePath); + await _broadcaster.BroadcastIngestionProgressAsync(request.UserId, "Otwieranie i parsowanie pliku EPUB...", 0.1, cancellationToken); + + var extractionResult = await _epubExtractor.ExtractChaptersTextAsync(ebook.FilePath, cancellationToken); + if (extractionResult.IsFailed) + { + var errorMsg = extractionResult.Errors.FirstOrDefault()?.Message ?? "Failed to extract text chapters."; + _logger.LogError("[ProcessEbook] Extraction failed: {Error}", errorMsg); + return Result.Fail(extractionResult.Errors); + } + + var chapters = extractionResult.Value; + if (chapters == null || !chapters.Any()) + { + _logger.LogWarning("[ProcessEbook] EPUB has no readable content files: {EbookId}", request.EbookId); + return Result.Fail("EPUB nie zawiera czytelnych rozdziałów."); + } + + int totalChapters = chapters.Count; + _logger.LogInformation("[ProcessEbook] Processing {Count} chapters for Ebook: {Title}", totalChapters, ebook.Title); + + await _broadcaster.BroadcastIngestionProgressAsync(request.UserId, $"Analizowanie struktury ({totalChapters} rozdziałów)...", 0.15, cancellationToken); + + int processedChapters = 0; + + for (int i = 0; i < totalChapters; i++) + { + var cleanText = chapters[i]; + + if (cleanText.Length < 100) + { + _logger.LogInformation("[ProcessEbook] Skipping chapter {Index} (text too short: {Length} chars)", i, cleanText.Length); + processedChapters++; + continue; + } + + // Chunk the text to maintain granular Knowledge Units + var chunks = ChunkText(cleanText, 3000); + _logger.LogInformation("[ProcessEbook] Chapter {Index} split into {ChunkCount} chunk(s)", i, chunks.Count); + + foreach (var chunk in chunks) + { + try + { + // Invoke GetKnowledgeMapAsync to extract, embed, and upsert knowledge units + var result = await _knowledgeService.GetKnowledgeMapAsync(chunk, request.TenantId, request.EbookId, cancellationToken); + if (result.IsFailed) + { + _logger.LogWarning("[ProcessEbook] Failed to generate knowledge map for a chunk of chapter {Index}: {Error}", i, result.Errors.FirstOrDefault()?.Message); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[ProcessEbook] Exception during AI vectorization of chapter {Index} chunk", i); + } + } + + processedChapters++; + double progress = 0.15 + (0.75 * processedChapters / totalChapters); + await _broadcaster.BroadcastIngestionProgressAsync( + request.UserId, + $"Przetwarzanie rozdziału {processedChapters} z {totalChapters} przez AI...", + progress, + cancellationToken); + } + + // Mark the ebook as ready + ebook.IsReadyForReading = true; + await dbContext.SaveChangesAsync(cancellationToken); + + _logger.LogInformation("[ProcessEbook] Ingestion and vector indexing completed for: {Title}", ebook.Title); + + await _broadcaster.BroadcastIngestionProgressAsync( + request.UserId, + "Indeksowanie wektorowe e-booka przez Nexus AI zakończone pomyślnie!", + 1.0, + cancellationToken); + + return Result.Ok(true); + } + catch (Exception ex) + { + _logger.LogError(ex, "[ProcessEbook] Critical error during background EPUB vectorization of ebook {EbookId}", request.EbookId); + await _broadcaster.BroadcastIngestionProgressAsync( + request.UserId, + $"Błąd indeksowania: {ex.Message}", + 1.0, + cancellationToken); + return Result.Fail(new Error("Wystąpił błąd podczas indeksowania e-booka przez AI").CausedBy(ex)); + } + } + + private static List ChunkText(string text, int maxWords = 3000) + { + var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var chunks = new List(); + if (words.Length <= maxWords) + { + chunks.Add(text); + return chunks; + } + var currentChunk = new List(); + int count = 0; + foreach (var word in words) + { + currentChunk.Add(word); + count++; + if (count >= maxWords) + { + chunks.Add(string.Join(" ", currentChunk)); + currentChunk.Clear(); + count = 0; + } + } + if (currentChunk.Any()) + { + chunks.Add(string.Join(" ", currentChunk)); + } + return chunks; + } +} diff --git a/src/NexusReader.Application/DTOs/User/UserProfileDto.cs b/src/NexusReader.Application/DTOs/User/UserProfileDto.cs index 27a0850..212a591 100644 --- a/src/NexusReader.Application/DTOs/User/UserProfileDto.cs +++ b/src/NexusReader.Application/DTOs/User/UserProfileDto.cs @@ -38,4 +38,5 @@ public record LastReadBookDto public string? LastChapter { get; init; } public int LastChapterIndex { get; init; } public string? Description { get; init; } + public bool IsReadyForReading { get; init; } } diff --git a/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs b/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs index 19d81e4..27b9ed6 100644 --- a/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs +++ b/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs @@ -1,9 +1,24 @@ +using System.Text.Json.Serialization; + namespace NexusReader.Application.Queries.Graph; -public record GraphNodeDto(string Id, string Label, string Group, string? Type = null); -public record GraphLinkDto(string Source, string Target, string RelationType, int Value = 1); +public record GraphNodeDto( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("label")] string Label, + [property: JsonPropertyName("group")] string Group, + [property: JsonPropertyName("description")] string? Description = null, + [property: JsonPropertyName("type")] string? Type = null +); + +public record GraphLinkDto( + [property: JsonPropertyName("source")] string Source, + [property: JsonPropertyName("target")] string Target, + [property: JsonPropertyName("type")] string RelationType, + [property: JsonPropertyName("value")] int Value = 1 +); + public record GraphDataDto { - public List Nodes { get; init; } = new(); - public List Links { get; init; } = new(); + [JsonPropertyName("nodes")] public List Nodes { get; init; } = new(); + [JsonPropertyName("links")] public List Links { get; init; } = new(); } diff --git a/src/NexusReader.Application/Queries/Library/GetMyEbooksQuery.cs b/src/NexusReader.Application/Queries/Library/GetMyEbooksQuery.cs index d3eef7e..712d8a9 100644 --- a/src/NexusReader.Application/Queries/Library/GetMyEbooksQuery.cs +++ b/src/NexusReader.Application/Queries/Library/GetMyEbooksQuery.cs @@ -37,7 +37,8 @@ public class GetMyEbooksQueryHandler : IRequestHandler ur.UserId == u.Id) diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index 93ebd7f..6bc61ab 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -112,6 +112,7 @@ public static class DependencyInjection services.AddScoped(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Fix #4: Scoped instead of Singleton — BookStorageService uses path resolution // that is environment-specific and incompatible with Singleton lifetime in MAUI. diff --git a/src/NexusReader.Infrastructure/Services/EpubExtractor.cs b/src/NexusReader.Infrastructure/Services/EpubExtractor.cs new file mode 100644 index 0000000..81f0d42 --- /dev/null +++ b/src/NexusReader.Infrastructure/Services/EpubExtractor.cs @@ -0,0 +1,85 @@ +using System.Text.RegularExpressions; +using FluentResults; +using Microsoft.Extensions.Logging; +using NexusReader.Application.Abstractions.Services; +using VersOne.Epub; + +namespace NexusReader.Infrastructure.Services; + +public class EpubExtractor : IEpubExtractor +{ + private readonly ILogger _logger; + + public EpubExtractor(ILogger logger) + { + _logger = logger; + } + + public async Task>> ExtractChaptersTextAsync(string relativePath, CancellationToken cancellationToken = default) + { + try + { + var fullPath = ResolvePath(relativePath); + if (string.IsNullOrEmpty(fullPath) || !File.Exists(fullPath)) + { + _logger.LogError("[EpubExtractor] EPUB file not found at path: {FilePath}", relativePath); + return Result.Fail>($"Plik EPUB nie został znaleziony na dysku: {relativePath}"); + } + + using var bookRef = await EpubReader.OpenBookAsync(fullPath); + var readingOrder = bookRef.GetReadingOrder(); + + if (readingOrder == null || !readingOrder.Any()) + { + return Result.Fail>("EPUB nie zawiera czytelnych rozdziałów."); + } + + var chapters = new List(); + foreach (var chapterRef in readingOrder) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + var rawContent = await chapterRef.ReadContentAsTextAsync(); + var cleanText = StripHtml(rawContent); + chapters.Add(cleanText); + } + + return Result.Ok(chapters); + } + catch (Exception ex) + { + _logger.LogError(ex, "[EpubExtractor] Error extracting chapters from EPUB: {FilePath}", relativePath); + return Result.Fail>(new Error("Failed to parse and extract text from EPUB").CausedBy(ex)); + } + } + + private static string? ResolvePath(string relativePath) + { + var normalized = relativePath.Replace('/', Path.DirectorySeparatorChar); + var currentDir = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory); + while (currentDir != null) + { + var candidate = Path.Combine(currentDir.FullName, "wwwroot", normalized); + if (File.Exists(candidate)) return candidate; + + var devCandidate = Path.Combine(currentDir.FullName, "src", "NexusReader.Web", "wwwroot", normalized); + if (File.Exists(devCandidate)) return devCandidate; + + currentDir = currentDir.Parent; + } + return null; + } + + private static string StripHtml(string html) + { + if (string.IsNullOrEmpty(html)) return string.Empty; + var clean = Regex.Replace(html, @"<(style|script)\b[^>]*>.*?", "", RegexOptions.IgnoreCase | RegexOptions.Singleline); + clean = Regex.Replace(clean, @"<[^>]*>", " "); + clean = System.Net.WebUtility.HtmlDecode(clean); + clean = Regex.Replace(clean, @"\s+", " ").Trim(); + return clean; + } +} diff --git a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs index 5868f7b..4f84f5c 100644 --- a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs +++ b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs @@ -33,7 +33,7 @@ public class KnowledgeService : IKnowledgeService private readonly ILogger _logger; private readonly QdrantClient _qdrantClient; private readonly IDriver _neo4jDriver; - private const string PromptVersion = "1.3"; + private const string PromptVersion = "1.6"; private static readonly ConcurrentDictionary>>> _activeRequests = new(); public KnowledgeService( diff --git a/src/NexusReader.Infrastructure/Services/PromptRegistry.cs b/src/NexusReader.Infrastructure/Services/PromptRegistry.cs index f456e61..bb64433 100644 --- a/src/NexusReader.Infrastructure/Services/PromptRegistry.cs +++ b/src/NexusReader.Infrastructure/Services/PromptRegistry.cs @@ -4,9 +4,10 @@ public static class PromptRegistry { public const string KnowledgeExtractionSystemPrompt = "You are an expert educator. Analyze the provided text to extract key concepts, generate relevant quizzes, and construct a knowledge graph. " + - "CRITICAL: Restrict 'concept.label' to a maximum of 3 words (e.g., 'Dependency Injection' instead of full sentences). " + + "**LANGUAGE CRITICAL**: Detect the language of the provided text. You MUST generate all human-readable fields ('title', 'description', 'question', 'options', 'label') in the EXACT SAME LANGUAGE as the source text. Do NOT translate them to English unless the source text is in English. " + + "CRITICAL: Restrict 'concept.label' to a maximum of 3 words (e.g., 'Dependency Injection' or its exact foreign equivalent, never full sentences). " + "CRITICAL: Extract a MAXIMUM of 15 key concepts/plot points from the text. " + - "CRITICAL: Code blocks (e.g., markdown code snippets) must be excluded from the relationship graph, or summarized as a single node (e.g., 'Code Example'). Do NOT create nodes for variables, functions, namespaces, or individual lines of code. " + + "CRITICAL: Code blocks (e.g., markdown code snippets) must be excluded from the relationship graph, or summarized as a single node with the label 'Code Example' translated to the detected language. Do NOT create nodes for variables, functions, namespaces, or individual lines of code. " + "CRITICAL: Return ONLY a minified JSON object. Do NOT include markdown formatting like ```json or ```. Do NOT include explanations. " + "Schema: { " + "\"concepts\": [ { \"title\": \"string\", \"description\": \"string\" } ], " + @@ -15,26 +16,31 @@ public static class PromptRegistry "}."; public const string GraphExtractionPrompt = - "You are an expert at information architecture. Extract key concepts and paragraph mappings from the text to build a unified knowledge graph. " + + "You are an expert at information architecture. Extract a highly strategic, clean, and educational knowledge graph from the provided technical text to act as a clear structural roadmap, avoiding clutter or hyper-connected noise hubs. " + + "**LANGUAGE CRITICAL**: Detect the language of the provided text. The 'label' and 'description' fields MUST be generated in the EXACT SAME LANGUAGE as the source text. Do NOT translate them to English. " + "The input text consists of several paragraphs, each starting with its unique block ID in the format '[ID: seg-X]'. " + - "Extract two types of nodes: " + - "1. Concept Nodes (group: 'concept'): Extract the main technical concepts discussed (e.g., ID: 'dependency-injection', label: 'Dependency Injection'). Max 10 concepts. Labels must be at most 3 words. " + - "2. Block Nodes (group: 'current'): For each paragraph in the input, create a node representing that paragraph where 'id' is the exact block ID (e.g., 'seg-1'), and 'label' is a brief summary of that paragraph's content (max 3 words). " + - "CRITICAL: If a paragraph is a code block, represent it as a single block node with label 'Code Example' (group: 'current'). Do NOT extract low-level code elements (like variables, classes, methods, or namespaces) as separate concept nodes. " + - "CRITICAL: Connect related concept nodes together, and connect each concept node to the block nodes ('seg-X') where it is discussed. " + - "Limit connections to a MAXIMUM of 15 most relevant links. " + - "Return ONLY minified JSON. Schema: { \"graph\": { \"nodes\": [ { \"id\": \"string\", \"label\": \"string\", \"group\": \"concept|current\" } ], \"links\": [ { \"source\": \"string\", \"target\": \"string\", \"value\": 1 } ] } }"; - + "Extract three distinct types of nodes based on strict hierarchical validation: " + + "1. Concept Nodes (group: 'concept'): Extract major global architectural pillars discussed. Max 6 per segment. Labels must be 1-3 words max. " + + "2. Bridge Nodes (group: 'bridge'): If the text directly compares a legacy paradigm (e.g., Desktop/WPF) to a modern framework alternative (.NET 10/Blazor), extract them as paired concepts to visually bridge the structural evolution. " + + "3. Block Nodes (group: 'current'): Create a node ONLY for significant structural landmarks in the text (e.g., major headings). Do NOT connect every concept to every individual paragraph wrapper. Connect concepts only to the main section block where they are anchored. " + + "CRITICAL NOISE SUPPRESSION: Absolutely forbid creating separate nodes for individual configuration files, files names, simple classes, servers, or methods (e.g., 'appsettings.json', 'Kestrel', 'Thread.Sleep', 'OnInitializedAsync'). These low-level details MUST be collapsed and described only within the 'description' field of their parent concept node. " + + "CRITICAL: Code blocks must be completely ignored as separate nodes; represent them only as contextual attributes within descriptions. " + + "Limit topology connections to a MAXIMUM of 10 highly relevant links total per segment. " + + "System keys configuration: 'group' must be strictly 'concept', 'bridge', 'current', 'rule', 'definition', 'table', or 'section'. " + + "Return ONLY minified JSON. Schema: { \"graph\": { \"nodes\": [ { \"id\": \"string\", \"label\": \"string\", \"group\": \"concept|bridge|current\", \"description\": \"string\" } ], \"links\": [ { \"source\": \"string\", \"target\": \"string\", \"type\": \"maps_to|contains|relates_to\" } ] } }"; public const string SummaryAndQuizPrompt = "You are an expert educator. Provide a concise summary of the text and generate a challenging quiz (3-5 questions). " + + "**LANGUAGE CRITICAL**: Detect the language of the provided text. The generated 'summary', 'question', and 'options' MUST be in the EXACT SAME LANGUAGE as the source text. " + "Return ONLY minified JSON. Schema: { \"summary\": \"string\", \"quizzes\": [ { \"question\": \"string\", \"options\": [ \"string\" ], \"correct_index\": 0 } ] }"; public const string KM_ExtractionPrompt = "You are an expert at Knowledge Engineering. Segment the provided text into discrete Knowledge Units. " + + "**LANGUAGE CRITICAL**: Detect the language of the provided text. The 'content' field MUST be in the EXACT SAME LANGUAGE as the source text. " + "Identify 'units' (sections, tables, definitions, rules) and 'links' (how they relate). " + "CRITICAL: Units must be granular. " + - "CRITICAL: Code blocks must be summarized under the parent unit or represented as a single 'Code Example' unit. Do NOT segment code blocks into granular low-level code details (e.g., classes, variables, parameters). " + + "CRITICAL: Code blocks must be summarized under the parent unit or represented as a single 'Code Example' unit (translate the name to the detected language). Do NOT segment code blocks into granular low-level code details (e.g., classes, variables, parameters). " + + "CRITICAL SYSTEM VALUES: The fields 'type' (strictly: 'Section', 'Table', 'Definition', or 'Rule') and 'relation' (strictly: 'Next', 'Defines', 'Contains', or 'References') are system keys and MUST remain in English as specified. " + "Schema: { " + "\"units\": [ { \"id\": \"string\", \"type\": \"Section|Table|Definition|Rule\", \"content\": \"string\", \"metadata\": { \"page\": 0 } } ], " + "\"links\": [ { \"source\": \"string\", \"target\": \"string\", \"relation\": \"Next|Defines|Contains|References\" } ] " + diff --git a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor index 950da9a..4af21f7 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor @@ -2,12 +2,14 @@ @using NexusReader.Application.Abstractions.Services @using NexusReader.Application.Queries.Reader @using NexusReader.Application.Commands.Library +@using NexusReader.UI.Shared.Services @using System.Net.Http.Json @inject IEpubMetadataExtractor MetadataExtractor @inject ILogger Logger @inject HttpClient Http @inject IReaderNavigationService ReaderNavigation @inject IJSRuntime JSRuntime +@inject ISyncService SyncService @implements IAsyncDisposable @if (IsOpen) @@ -16,20 +18,23 @@ -

[User_Explorer1988]

+

@(string.IsNullOrEmpty(_profile?.DisplayName) ? (_profile?.Email.Split('@')[0] ?? "Użytkownik") : _profile.DisplayName)

- Books Read: - 12 + Książki: + @(_profile?.BooksReadCount ?? 0)
- Concepts Mapped: - 450 + Pojęcia: + @(_profile?.ConceptsMappedCount ?? 0)
- Quiz Mastery: - 88% + Średni Wynik: + @(_profile?.AverageQuizScore ?? 0)%
@@ -39,7 +41,7 @@
-

Witaj, @(_profile?.Email.Split('@')[0] ?? "Użytkowniku")

+

Witaj, @(string.IsNullOrEmpty(_profile?.DisplayName) ? (_profile?.Email.Split('@')[0] ?? "Użytkowniku") : _profile.DisplayName)

@@ -49,7 +51,7 @@
-

Knowledge Integration Progress

+

Integracja Wiedzy

@@ -64,19 +66,36 @@
-

Quiz Summary: Key Thinkers

+

Rozwiązane Quizy

-

Który artysta namalował 'Ostatnią Wieczerzę'?

-
-
- A) Michal Anioł + @if (_profile?.RecentQuizzes != null && _profile.RecentQuizzes.Any()) + { +
+ @foreach (var quiz in _profile.RecentQuizzes) + { +
+
+ @quiz.Topic + = 50 ? "badge-warning" : "badge-danger")"> + @quiz.Score / @quiz.TotalQuestions (@((int)quiz.Percentage)%) + +
+
+ @quiz.CompletedDate.ToString("g") +
+
+ }
-
- B) Leonardo da Vinci + } + else + { +
+

Brak rozwiązanych quizów

+

Rozwiązuj quizy w trakcie czytania książek, aby śledzić swoje postępy.

-
+ }
@@ -88,11 +107,36 @@ private UserProfileDto? _profile; protected override async Task OnInitializedAsync() + { + IdentityService.OnStateInvalidated += HandleStateInvalidatedAsync; + await LoadProfileAsync(); + } + + private async Task LoadProfileAsync() { var result = await IdentityService.GetProfileAsync(); if (result.IsSuccess) { _profile = result.Value; } + else + { + _profile = null; + } + StateHasChanged(); + } + + private async Task HandleStateInvalidatedAsync() + { + await InvokeAsync(async () => + { + await LoadProfileAsync(); + }); + } + + public void Dispose() + { + IdentityService.OnStateInvalidated -= HandleStateInvalidatedAsync; } } + diff --git a/src/NexusReader.UI.Shared/Pages/Dashboard.razor.css b/src/NexusReader.UI.Shared/Pages/Dashboard.razor.css index eaf4d28..ba334ce 100644 --- a/src/NexusReader.UI.Shared/Pages/Dashboard.razor.css +++ b/src/NexusReader.UI.Shared/Pages/Dashboard.razor.css @@ -404,3 +404,79 @@ grid-template-columns: 1fr; } } + +/* --- Quiz History Styling --- */ +.quiz-history-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.quiz-history-item { + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 12px; + padding: 1rem; + transition: all 0.2s ease; +} + +.quiz-history-item:hover { + background: rgba(255, 255, 255, 0.04); + border-color: rgba(255, 255, 255, 0.1); +} + +.quiz-item-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 0.5rem; +} + +.quiz-topic { + font-size: 0.95rem; + font-weight: 500; + color: #ffffff; +} + +.quiz-item-meta { + display: flex; + font-size: 0.75rem; + color: #666666; +} + +.badge { + padding: 0.25rem 0.5rem; + border-radius: 6px; + font-size: 0.75rem; + font-weight: 600; +} + +.badge-success { + background: rgba(0, 255, 153, 0.1); + color: var(--nexus-neon); + border: 1px solid rgba(0, 255, 153, 0.3); +} + +.badge-warning { + background: rgba(255, 170, 0, 0.1); + color: #ffa800; + border: 1px solid rgba(255, 170, 0, 0.3); +} + +.badge-danger { + background: rgba(255, 50, 50, 0.1); + color: #ff3232; + border: 1px solid rgba(255, 50, 50, 0.3); +} + +.empty-quiz-state { + text-align: center; + padding: 2rem 1rem; +} + +.empty-quiz-state .sub-text { + font-size: 0.8rem; + color: #666666; + margin-top: 0.5rem; +} diff --git a/src/NexusReader.UI.Shared/Services/IdentityService.cs b/src/NexusReader.UI.Shared/Services/IdentityService.cs index 441ea88..386dbe6 100644 --- a/src/NexusReader.UI.Shared/Services/IdentityService.cs +++ b/src/NexusReader.UI.Shared/Services/IdentityService.cs @@ -249,6 +249,25 @@ public class IdentityService : IIdentityService } } + public void ClearCache() + { + _cachedProfile = null; + if (OnStateInvalidated != null) + { + _ = Task.Run(async () => + { + try + { + await OnStateInvalidated.Invoke(); + } + catch + { + // Ignore exceptions from event handlers + } + }); + } + } + private class LoginResponse { public string TokenType { get; set; } = string.Empty; diff --git a/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs b/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs index 619cd4e..9489616 100644 --- a/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs +++ b/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs @@ -17,6 +17,8 @@ public sealed partial class KnowledgeCoordinator : IDisposable private readonly IReaderInteractionService _interactionService; private readonly ILogger _logger; + public string CurrentFullPageContent { get; private set; } = string.Empty; + /// /// Raised when the knowledge graph has been updated with new data. /// Subscribers must return a Task to enable proper async handling. @@ -77,6 +79,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable { if (string.IsNullOrWhiteSpace(fullContent)) return; + CurrentFullPageContent = fullContent; LogGeneratingGraph(tenantId); await _graphService.Clear(); @@ -148,6 +151,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable public async Task ClearAsync() { + CurrentFullPageContent = string.Empty; await _graphService.Clear(); await _quizService.SetQuiz(null, null); } diff --git a/src/NexusReader.Web/Services/ServerIdentityService.cs b/src/NexusReader.Web/Services/ServerIdentityService.cs index 164aaac..2a1aaff 100644 --- a/src/NexusReader.Web/Services/ServerIdentityService.cs +++ b/src/NexusReader.Web/Services/ServerIdentityService.cs @@ -118,4 +118,22 @@ public class ServerIdentityService : IIdentityService return Result.Ok(result.Value); } + + public void ClearCache() + { + if (OnStateInvalidated != null) + { + _ = Task.Run(async () => + { + try + { + await OnStateInvalidated.Invoke(); + } + catch + { + // Ignore + } + }); + } + } } diff --git a/tests/NexusReader.Application.Tests/Commands/SubmitQuizResultCommandHandlerTests.cs b/tests/NexusReader.Application.Tests/Commands/SubmitQuizResultCommandHandlerTests.cs new file mode 100644 index 0000000..4c9902a --- /dev/null +++ b/tests/NexusReader.Application.Tests/Commands/SubmitQuizResultCommandHandlerTests.cs @@ -0,0 +1,107 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Moq; +using NexusReader.Application.Commands.Quiz; +using NexusReader.Data.Persistence; +using NexusReader.Domain.Entities; +using Xunit; + +namespace NexusReader.Application.Tests.Commands; + +public class SubmitQuizResultCommandHandlerTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions _contextOptions; + private readonly Mock> _dbContextFactoryMock; + + public SubmitQuizResultCommandHandlerTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + _contextOptions = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + using var context = new AppDbContext(_contextOptions); + context.Database.EnsureCreated(); + + _dbContextFactoryMock = new Mock>(); + _dbContextFactoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny())) + .ReturnsAsync(() => new AppDbContext(_contextOptions)); + } + + [Fact] + public async Task Handle_WithValidRequest_PersistsQuizResultToDatabase() + { + // Arrange + using (var context = new AppDbContext(_contextOptions)) + { + var user = new NexusUser + { + Id = "user-abc", + UserName = "testuser", + Email = "test@example.com", + TenantId = "tenant-xyz", + SubscriptionPlanId = 1 + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + } + + var command = new SubmitQuizResultCommand( + UserId: "user-abc", + Topic: "Sprawdzian: .NET 10", + Score: 4, + TotalQuestions: 5 + ); + + var handler = new SubmitQuizResultCommandHandler(_dbContextFactoryMock.Object); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + + using (var context = new AppDbContext(_contextOptions)) + { + var quizResult = await context.QuizResults.FirstOrDefaultAsync(q => q.UserId == "user-abc"); + quizResult.Should().NotBeNull(); + quizResult!.Topic.Should().Be("Sprawdzian: .NET 10"); + quizResult.Score.Should().Be(4); + quizResult.TotalQuestions.Should().Be(5); + quizResult.TenantId.Should().Be("tenant-xyz"); + } + } + + [Fact] + public async Task Handle_WithNonExistentUser_ReturnsFailureResult() + { + // Arrange + var command = new SubmitQuizResultCommand( + UserId: "non-existent", + Topic: "Sprawdzian: .NET 10", + Score: 4, + TotalQuestions: 5 + ); + + var handler = new SubmitQuizResultCommandHandler(_dbContextFactoryMock.Object); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsFailed.Should().BeTrue(); + result.Errors.Should().ContainSingle(e => e.Message == "User not found."); + } + + public void Dispose() + { + _connection.Dispose(); + } +} -- 2.52.0 From 3cbbb6df6b28820c5da927b1ce3c18cb5be484e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Mon, 25 May 2026 11:31:19 +0200 Subject: [PATCH 3/5] fix(knowledge-service): resolve semantic cache collision by partitioning content hash by traceType and PromptVersion --- src/NexusReader.Infrastructure/Services/KnowledgeService.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs index 6c63bfb..9b5ca3e 100644 --- a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs +++ b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs @@ -85,7 +85,8 @@ public class KnowledgeService : IKnowledgeService using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); var normalizedText = text.Trim(); - var hash = ContentHasher.ComputeHash(normalizedText); + var hashInput = $"{normalizedText}:{traceType}:{PromptVersion}"; + var hash = ContentHasher.ComputeHash(hashInput); // 1. Check Cache var cached = await dbContext.SemanticKnowledgeCache -- 2.52.0 From 824b4366e0bd9c294922bebb3868824a0dafd286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Tue, 26 May 2026 12:54:41 +0200 Subject: [PATCH 4/5] feat: implement Neo4j knowledge graph synchronization and integrate global cache support with custom tenant claims. --- .../DTOs/User/UserProfileDto.cs | 9 + .../User/GetUserProfileQueryHandler.cs | 13 +- .../Services/KnowledgeService.cs | 304 +++++++++++++++--- .../Services/PromptRegistry.cs | 20 ++ .../Pages/Dashboard.razor | 75 ++++- .../Pages/Dashboard.razor.css | 52 ++- .../Services/SyncService.cs | 2 +- src/NexusReader.Web/Program.cs | 3 +- .../CustomUserClaimsPrincipalFactory.cs | 28 ++ .../Queries/CheckDatabaseTest.cs | 58 ++++ 10 files changed, 510 insertions(+), 54 deletions(-) create mode 100644 src/NexusReader.Web/Services/CustomUserClaimsPrincipalFactory.cs create mode 100644 tests/NexusReader.Application.Tests/Queries/CheckDatabaseTest.cs diff --git a/src/NexusReader.Application/DTOs/User/UserProfileDto.cs b/src/NexusReader.Application/DTOs/User/UserProfileDto.cs index 61cf06e..31dd1d3 100644 --- a/src/NexusReader.Application/DTOs/User/UserProfileDto.cs +++ b/src/NexusReader.Application/DTOs/User/UserProfileDto.cs @@ -21,6 +21,7 @@ public record UserProfileDto public int ConceptsMappedCount { get; init; } public LastReadBookDto? LastReadBook { get; init; } public IReadOnlyList RecentQuizzes { get; init; } = Array.Empty(); + public IReadOnlyList MappedConcepts { get; init; } = Array.Empty(); public string[] Roles { get; init; } = Array.Empty(); // Helper properties for UI compatibility @@ -29,6 +30,14 @@ public record UserProfileDto public string LastReadBookTitle => LastReadBook?.Title ?? PlanConstants.DefaultActivityLabel; } +public record MappedConceptDto +{ + public string Id { get; init; } = string.Empty; + public string Type { get; init; } = string.Empty; + public string Content { get; init; } = string.Empty; + public string DisplayLabel => Content.Length > 25 ? Content.Substring(0, 22) + "..." : Content; +} + public record LastReadBookDto { public Guid Id { get; init; } diff --git a/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs b/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs index a536dbf..dba5d2f 100644 --- a/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs +++ b/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs @@ -38,7 +38,7 @@ public class GetUserProfileQueryHandler : IRequestHandler k.TenantId == u.TenantId), + ConceptsMappedCount = dbContext.KnowledgeUnits.Count(k => k.TenantId == u.TenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId)), LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto { Id = e.Id, @@ -64,6 +64,17 @@ public class GetUserProfileQueryHandler : IRequestHandler k.TenantId == u.TenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId)) + .OrderByDescending(k => k.CreatedAt) + .Take(6) + .Select(k => new MappedConceptDto + { + Id = k.Id, + Type = k.Type.ToString(), + Content = k.Content + }) + .ToList(), Roles = dbContext.UserRoles .Where(ur => ur.UserId == u.Id) .Join(dbContext.Roles, ur => ur.RoleId, r => r.Id, (ur, r) => r.Name!) diff --git a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs index 9b5ca3e..b0b7278 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, cancellationToken); + .FirstOrDefaultAsync(c => c.ContentHash == hash && (c.TenantId == tenantId || c.TenantId == "global"), cancellationToken); if (cached != null && cached.PromptVersion == PromptVersion) { @@ -98,7 +98,12 @@ public class KnowledgeService : IKnowledgeService try { var packet = JsonSerializer.Deserialize(cached.JsonData, JsonOptions); - if (packet != null) return Result.Ok(packet); + if (packet != null) + { + await ProcessKnowledgeUnitsAsync(packet, tenantId, ebookId, dbContext, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return Result.Ok(packet); + } } catch (JsonException ex) { @@ -226,6 +231,30 @@ public class KnowledgeService : IKnowledgeService private async Task ProcessKnowledgeUnitsAsync(KnowledgePacket packet, string tenantId, Guid? ebookId, AppDbContext dbContext, CancellationToken cancellationToken) { + if (packet.Graph != null && (packet.Units == null || !packet.Units.Any())) + { + var graphUnits = packet.Graph.Nodes.Select(node => new KnowledgeUnitDto( + node.Id, + node.Type ?? "concept", + node.Description ?? node.Label, + new Dictionary + { + ["label"] = node.Label, + ["group"] = node.Group, + ["summary"] = node.Summary ?? "", + ["key_terms"] = node.KeyTerms ?? new List() + } + )).ToList(); + + var graphLinks = packet.Graph.Links.Select(link => new KnowledgeLinkDto( + link.Source, + link.Target, + link.RelationType + )).ToList(); + + packet = packet with { Units = graphUnits, Links = graphLinks }; + } + var unitIds = packet.Units.Select(u => u.Id).ToList(); var linkSourceIds = packet.Links.Select(l => l.Source).ToList(); var linkTargetIds = packet.Links.Select(l => l.Target).ToList(); @@ -341,6 +370,79 @@ public class KnowledgeService : IKnowledgeService _logger.LogError(ex, "[KnowledgeService] Failed to generate and upsert embeddings for knowledge units to Qdrant."); } } + + // 6. Synchronize to Neo4j graph database + await SyncToNeo4jAsync(packet, cancellationToken); + } + + private async Task SyncToNeo4jAsync(KnowledgePacket packet, CancellationToken cancellationToken) + { + if (packet.Units == null || !packet.Units.Any()) return; + + try + { + await using var session = _neo4jDriver.AsyncSession(); + + // 1. Merge nodes in a transaction + await session.ExecuteWriteAsync(async tx => + { + foreach (var unit in packet.Units) + { + var cypher = @" + MERGE (u:KnowledgeUnit {id: $id}) + ON CREATE SET u.content = $content, u.type = $type + ON MATCH SET u.content = $content, u.type = $type"; + + var guidStr = GetDeterministicGuid(unit.Id).ToString(); + await tx.RunAsync(cypher, new + { + id = guidStr, + content = unit.Content ?? string.Empty, + type = unit.Type ?? "concept" + }); + } + }); + + // 2. Merge links in a transaction + if (packet.Links != null && packet.Links.Any()) + { + await session.ExecuteWriteAsync(async tx => + { + foreach (var link in packet.Links) + { + if (string.IsNullOrWhiteSpace(link.Source) || string.IsNullOrWhiteSpace(link.Target)) + continue; + + var relationType = string.IsNullOrWhiteSpace(link.Relation) ? "RELATED_TO" : link.Relation.Trim().ToUpperInvariant(); + relationType = System.Text.RegularExpressions.Regex.Replace(relationType, @"[^A-Z0-9_]", "_"); + if (string.IsNullOrEmpty(relationType) || relationType == "_") + { + relationType = "RELATED_TO"; + } + + var cypher = $@" + MATCH (source:KnowledgeUnit {{id: $sourceId}}) + MATCH (target:KnowledgeUnit {{id: $targetId}}) + MERGE (source)-[r:{relationType}]->(target)"; + + var sourceGuidStr = GetDeterministicGuid(link.Source).ToString(); + var targetGuidStr = GetDeterministicGuid(link.Target).ToString(); + + await tx.RunAsync(cypher, new + { + sourceId = sourceGuidStr, + targetId = targetGuidStr + }); + } + }); + } + + _logger.LogInformation("[KnowledgeService] Successfully synchronized {NodeCount} nodes and {LinkCount} links to Neo4j.", packet.Units.Count, packet.Links?.Count ?? 0); + } + catch (Exception ex) + { + _logger.LogError(ex, "[KnowledgeService] Failed to synchronize knowledge graph to Neo4j."); + } } private async Task EnsureCollectionExistsAsync(string collectionName, CancellationToken cancellationToken = default) @@ -381,6 +483,14 @@ public class KnowledgeService : IKnowledgeService return new Guid(hash); } + private static string GetPointIdString(PointId pointId) + { + if (pointId == null) return string.Empty; + return pointId.PointIdOptionsCase == PointId.PointIdOptionsOneofCase.Uuid + ? pointId.Uuid + : pointId.Num.ToString(); + } + public async Task> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default) { var systemPrompt = @" @@ -463,10 +573,28 @@ public class KnowledgeService : IKnowledgeService searchResult = new List(); } - var contexts = searchResult.Select(point => new RelevantContext + var contexts = searchResult.Select(point => { - Text = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty, - Confidence = point.Score + var content = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty; + var summary = string.Empty; + if (point.Payload.TryGetValue("metadataJson", out var metaVal) && !string.IsNullOrEmpty(metaVal.StringValue)) + { + try + { + var meta = JsonSerializer.Deserialize>(metaVal.StringValue); + if (meta != null && meta.TryGetValue("summary", out var sumObj)) + { + summary = sumObj?.ToString(); + } + } + catch {} + } + var text = string.IsNullOrEmpty(summary) ? content : $"{content}: {summary}"; + return new RelevantContext + { + Text = text, + Confidence = point.Score + }; }).ToList(); return Result.Ok(contexts); @@ -534,7 +662,7 @@ public class KnowledgeService : IKnowledgeService } // 3. Graph Expansion via Neo4j - var candidateIds = searchResult.Select(r => r.Id.ToString()).ToList(); + var candidateIds = searchResult.Select(r => GetPointIdString(r.Id)).ToList(); var definitions = new Dictionary>(); if (candidateIds.Any()) @@ -543,7 +671,7 @@ public class KnowledgeService : IKnowledgeService { await using var session = _neo4jDriver.AsyncSession(); var cypher = @" - MATCH (source:KnowledgeUnit)-[r:DEFINES]->(target:KnowledgeUnit) + MATCH (source:KnowledgeUnit)-[r]->(target:KnowledgeUnit) WHERE source.id IN $candidateIds RETURN source.id AS sourceId, target.content AS targetContent"; @@ -617,7 +745,7 @@ public class KnowledgeService : IKnowledgeService var dto = new SemanticSearchResultDto { - ContentHash = point.Id.ToString(), + ContentHash = GetPointIdString(point.Id), Snippet = content, UnitType = type, RelevanceScore = point.Score, @@ -625,7 +753,7 @@ public class KnowledgeService : IKnowledgeService Metadata = metadata }; - var pointIdStr = point.Id.ToString(); + var pointIdStr = GetPointIdString(point.Id); if (definitions.TryGetValue(pointIdStr, out var pointDefs) && pointDefs.Any()) { dto.Snippet = $"[Context: {string.Join("; ", pointDefs)}]\n{dto.Snippet}"; @@ -724,11 +852,26 @@ public class KnowledgeService : IKnowledgeService } // 3. Graph Expansion via Neo4j - var candidateIds = searchResult.Select(r => r.Id.ToString()).ToList(); + var candidateIds = searchResult.Select(r => GetPointIdString(r.Id)).ToList(); var relatedContexts = new List(); // Keep map of point ID -> payload data for fast mapping later - var pointMap = searchResult.ToDictionary(r => r.Id.ToString(), r => r); + var pointMap = searchResult.ToDictionary(r => GetPointIdString(r.Id), r => r); + + // Fetch knowledge units from PostgreSQL to map Guids back to rich metadata summaries + var guidMap = new Dictionary(); + try + { + using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + var units = await dbContext.KnowledgeUnits + .Where(u => u.TenantId == tenantId && (ebookId == null || u.EbookId == ebookId)) + .ToListAsync(cancellationToken); + guidMap = units.ToDictionary(u => GetDeterministicGuid(u.Id).ToString(), u => u); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[KnowledgeService] Failed to load KnowledgeUnits from PostgreSQL for Guid mapping."); + } if (candidateIds.Any()) { @@ -738,7 +881,7 @@ public class KnowledgeService : IKnowledgeService var cypher = @" MATCH (source:KnowledgeUnit) WHERE source.id IN $candidateIds - OPTIONAL MATCH (source)-[r:DEFINES|RELATED_TO]->(target:KnowledgeUnit) + OPTIONAL MATCH (source)-[r]->(target:KnowledgeUnit) RETURN source.id AS sourceId, source.content AS sourceContent, collect({ targetId: target.id, targetContent: target.content, relation: type(r) }) AS relations"; @@ -751,23 +894,64 @@ public class KnowledgeService : IKnowledgeService foreach (var record in neoResult) { var sourceId = record["sourceId"].As(); - var sourceContent = record["sourceContent"].As(); - relatedContexts.Add($"[Source ID: {sourceId}] {sourceContent}"); + var sourceText = string.Empty; + if (guidMap.TryGetValue(sourceId, out var sourceUnit)) + { + var summary = string.Empty; + if (!string.IsNullOrEmpty(sourceUnit.MetadataJson)) + { + try + { + var meta = JsonSerializer.Deserialize>(sourceUnit.MetadataJson); + if (meta != null && meta.TryGetValue("summary", out var sumObj)) + { + summary = sumObj?.ToString(); + } + } + catch { } + } + sourceText = string.IsNullOrEmpty(summary) ? sourceUnit.Content : $"{sourceUnit.Content}: {summary}"; + } + else + { + sourceText = record["sourceContent"].As(); + } + + relatedContexts.Add($"[Source ID: {sourceId}] {sourceText}"); var relations = record["relations"].As>(); if (relations != null) { foreach (var relObj in relations) { - if (relObj is Dictionary relDict && - relDict.TryGetValue("targetId", out var targetIdVal) && targetIdVal is string targetId && - relDict.TryGetValue("targetContent", out var targetContentVal) && targetContentVal is string targetContent && - relDict.TryGetValue("relation", out var relationVal) && relationVal is string relation) + if (relObj is System.Collections.IDictionary relDict) { - if (!string.IsNullOrEmpty(targetContent)) + var targetId = relDict["targetId"]?.ToString(); + var targetContent = relDict["targetContent"]?.ToString(); + var relation = relDict["relation"]?.ToString(); + + if (!string.IsNullOrEmpty(targetContent) && !string.IsNullOrEmpty(relation)) { - relatedContexts.Add($"[Related Context ({relation}) to {sourceId}] {targetContent}"); + var targetText = targetContent; + if (!string.IsNullOrEmpty(targetId) && guidMap.TryGetValue(targetId, out var targetUnit)) + { + var summary = string.Empty; + if (!string.IsNullOrEmpty(targetUnit.MetadataJson)) + { + try + { + var meta = JsonSerializer.Deserialize>(targetUnit.MetadataJson); + if (meta != null && meta.TryGetValue("summary", out var sumObj)) + { + summary = sumObj?.ToString(); + } + } + catch { } + } + targetText = string.IsNullOrEmpty(summary) ? targetUnit.Content : $"{targetUnit.Content}: {summary}"; + } + relatedContexts.Add($"[Related Context ({relation}) to {sourceId}] {targetText}"); } } } @@ -779,9 +963,32 @@ public class KnowledgeService : IKnowledgeService _logger.LogWarning(ex, "[KnowledgeService] Neo4j graph expansion failed. Falling back to direct Qdrant points."); foreach (var point in searchResult) { - var sourceId = point.Id.ToString(); - var content = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty; - relatedContexts.Add($"[Source ID: {sourceId}] {content}"); + var sourceId = GetPointIdString(point.Id); + + var sourceText = string.Empty; + if (guidMap.TryGetValue(sourceId, out var sourceUnit)) + { + var summary = string.Empty; + if (!string.IsNullOrEmpty(sourceUnit.MetadataJson)) + { + try + { + var meta = JsonSerializer.Deserialize>(sourceUnit.MetadataJson); + if (meta != null && meta.TryGetValue("summary", out var sumObj)) + { + summary = sumObj?.ToString(); + } + } + catch { } + } + sourceText = string.IsNullOrEmpty(summary) ? sourceUnit.Content : $"{sourceUnit.Content}: {summary}"; + } + else + { + sourceText = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty; + } + + relatedContexts.Add($"[Source ID: {sourceId}] {sourceText}"); } } } @@ -805,33 +1012,14 @@ public class KnowledgeService : IKnowledgeService // 5. Build prompt and invoke Gemini with structured JSON formatting var contextBlocksText = string.Join("\n\n", relatedContexts); - var systemPrompt = @" -You are an advanced, extremely precise Fact-Checking AI assistant. Your task is to answer the user's question using ONLY the provided context blocks. - -Strict Grounding Rules: -1. Rely EXCLUSIVELY on the provided context. Do NOT use any pre-existing external knowledge, facts, or assumptions. -2. If the context does not contain the answer, you must state exactly: 'I cannot answer this based on the provided book context.' -3. For every statement or claim you make in your answer, you must cite the specific source IDs (e.g., source chunk ID or hash) from the context. -4. You must format your response ONLY as a JSON object matching the following structure: -{ - ""answer"": ""The answer text goes here, referencing [Source ID] as citations."", - ""citations"": [ - { - ""citationId"": ""The exact source ID cited (e.g., chunk hash/ID)"", - ""snippet"": ""The precise sentence or phrase from the context that supports this statement."", - ""sourceBook"": ""The book title or 'Unknown'"" - } - ] -} -"; + var systemPrompt = PromptRegistry.GroundedRAGSystemPrompt; var userPrompt = $"Context:\n{contextBlocksText}\n\nQuestion: {question}"; var options = new ChatOptions { Temperature = 0.0f, - MaxOutputTokens = 1500, - ResponseFormat = ChatResponseFormat.Json + MaxOutputTokens = 1500 }; var chatResponse = await _retryPipeline.ExecuteAsync(async ct => @@ -843,6 +1031,20 @@ Strict Grounding Rules: var rawJson = chatResponse.Text?.Trim() ?? string.Empty; rawJson = rawJson.Replace("```json", "").Replace("```", "").Trim(); + + // Handle direct text fallback when model bypasses JSON format + if (!rawJson.StartsWith("{") && + (rawJson.Contains("cannot answer", StringComparison.OrdinalIgnoreCase) || + rawJson.Contains("context does not contain", StringComparison.OrdinalIgnoreCase) || + rawJson.Contains("provided book context", StringComparison.OrdinalIgnoreCase))) + { + return Result.Ok(new GroundedResponseDto + { + Answer = "I cannot answer this based on the provided book context.", + Citations = new List() + }); + } + rawJson = JsonRepairHelper.Repair(rawJson); try @@ -897,6 +1099,20 @@ Strict Grounding Rules: _logger.LogWarning(ex, "[KnowledgeService] Failed to drop Qdrant collection 'knowledge_units' during cache clear."); } + try + { + await using var session = _neo4jDriver.AsyncSession(); + await session.ExecuteWriteAsync(async tx => + { + await tx.RunAsync("MATCH (n:KnowledgeUnit) DETACH DELETE n"); + }); + _logger.LogInformation("[KnowledgeService] Successfully wiped Neo4j 'KnowledgeUnit' nodes."); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[KnowledgeService] Failed to wipe Neo4j graph during cache clear."); + } + return Result.Ok(); } catch (Exception ex) diff --git a/src/NexusReader.Infrastructure/Services/PromptRegistry.cs b/src/NexusReader.Infrastructure/Services/PromptRegistry.cs index c84df21..776b3bc 100644 --- a/src/NexusReader.Infrastructure/Services/PromptRegistry.cs +++ b/src/NexusReader.Infrastructure/Services/PromptRegistry.cs @@ -58,4 +58,24 @@ public static class PromptRegistry "\"units\": [ { \"id\": \"string\", \"type\": \"Section|Table|Definition|Rule\", \"content\": \"string\", \"metadata\": { \"page\": 0 } } ], " + "\"links\": [ { \"source\": \"string\", \"target\": \"string\", \"relation\": \"Next|Defines|Contains|References\" } ] " + "}."; + + public const string GroundedRAGSystemPrompt = """ + You are an advanced, extremely precise Fact-Checking AI assistant. Your task is to answer the user's question using ONLY the provided context blocks. + + Strict Grounding Rules: + 1. Rely EXCLUSIVELY on the provided context. Do NOT use any pre-existing external knowledge, facts, or assumptions. + 2. If the context does not contain the answer, you must set the "answer" property in the JSON object exactly to: 'I cannot answer this based on the provided book context.' and the "citations" array must be empty. + 3. For every statement or claim you make in your answer, you must cite the specific source IDs (e.g., source chunk ID or hash) from the context. + 4. You must format your response ONLY as a JSON object matching the following structure: + { + "answer": "The answer text goes here, referencing [Source ID] as citations.", + "citations": [ + { + "citationId": "The exact source ID cited (e.g., chunk hash/ID)", + "snippet": "The precise sentence or phrase from the context that supports this statement.", + "sourceBook": "The book title or 'Unknown'" + } + ] + } + """; } diff --git a/src/NexusReader.UI.Shared/Pages/Dashboard.razor b/src/NexusReader.UI.Shared/Pages/Dashboard.razor index beba01c..dcfed70 100644 --- a/src/NexusReader.UI.Shared/Pages/Dashboard.razor +++ b/src/NexusReader.UI.Shared/Pages/Dashboard.razor @@ -5,6 +5,7 @@ @using NexusReader.UI.Shared.Services @inject IIdentityService IdentityService @inject NavigationManager NavigationManager +@inject ISyncService SyncService @attribute [Authorize] @implements IDisposable @@ -55,12 +56,49 @@
-
-
-
-
-
TU JESTEŚ
+
+ + @if (_profile?.MappedConcepts != null && _profile.MappedConcepts.Any()) + { + @for (int i = 0; i < _profile.MappedConcepts.Count; i++) + { + var concept = _profile.MappedConcepts[i]; + var angle = i * (360.0 / _profile.MappedConcepts.Count); + var dist = 65; +
+
+ } + } + else + { +
+
+
+ } + +
+ @(string.IsNullOrEmpty(_hoveredConceptLabel) ? "TU JESTEŚ" : _hoveredConceptLabel) +
+ + @if (_hoveredConcept != null) + { +
+ @_hoveredConcept.Type +

@_hoveredConcept.Content

+
+ } + else + { +
+ Mapowanie AI +

Najedź na węzeł, aby zbadać pojęcie wydobyte przez Nexus AI.

+
+ } @@ -105,11 +143,28 @@ @code { private UserProfileDto? _profile; + private MappedConceptDto? _hoveredConcept; + private string _hoveredConceptLabel = string.Empty; protected override async Task OnInitializedAsync() { IdentityService.OnStateInvalidated += HandleStateInvalidatedAsync; await LoadProfileAsync(); + + await SyncService.InitializeAsync(); + SyncService.OnProgressReceived += HandleProgressReceivedAsync; + } + + private void SetHoveredConcept(MappedConceptDto concept) + { + _hoveredConcept = concept; + _hoveredConceptLabel = concept.DisplayLabel; + } + + private void ClearHoveredConcept() + { + _hoveredConcept = null; + _hoveredConceptLabel = string.Empty; } private async Task LoadProfileAsync() @@ -134,9 +189,19 @@ }); } + private async Task HandleProgressReceivedAsync(string pageId, DateTime timestamp) + { + await InvokeAsync(async () => + { + IdentityService.ClearCache(); + await LoadProfileAsync(); + }); + } + public void Dispose() { IdentityService.OnStateInvalidated -= HandleStateInvalidatedAsync; + SyncService.OnProgressReceived -= HandleProgressReceivedAsync; } } diff --git a/src/NexusReader.UI.Shared/Pages/Dashboard.razor.css b/src/NexusReader.UI.Shared/Pages/Dashboard.razor.css index ba334ce..7c7f009 100644 --- a/src/NexusReader.UI.Shared/Pages/Dashboard.razor.css +++ b/src/NexusReader.UI.Shared/Pages/Dashboard.razor.css @@ -294,9 +294,19 @@ } .graph-node.satellite { - width: 20px; - height: 20px; + width: 16px; + height: 16px; transform: rotate(var(--angle)) translateY(var(--dist)); + background: rgba(0, 255, 153, 0.4); + border: 1px solid var(--nexus-neon); + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.graph-node.satellite:hover { + background: var(--nexus-neon); + box-shadow: 0 0 15px var(--nexus-neon); + transform: rotate(var(--angle)) translateY(var(--dist)) scale(1.3); } .active-node-label { @@ -480,3 +490,41 @@ color: #666666; margin-top: 0.5rem; } + +/* --- Concept Detail Toast for Dashboard --- */ +.concept-detail-toast { + margin-top: 1rem; + padding: 0.75rem 1rem; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 12px; + min-height: 80px; + display: flex; + flex-direction: column; + gap: 0.25rem; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.concept-detail-toast.placeholder { + opacity: 0.5; +} + +.concept-type { + font-size: 0.75rem; + font-weight: 700; + color: var(--nexus-neon); + text-transform: uppercase; + letter-spacing: 1px; +} + +.concept-content { + font-size: 0.85rem; + line-height: 1.4; + color: #E0E0E0; + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + diff --git a/src/NexusReader.UI.Shared/Services/SyncService.cs b/src/NexusReader.UI.Shared/Services/SyncService.cs index 214d7ef..8e0227d 100644 --- a/src/NexusReader.UI.Shared/Services/SyncService.cs +++ b/src/NexusReader.UI.Shared/Services/SyncService.cs @@ -92,7 +92,7 @@ public class SyncService : ISyncService, IAsyncDisposable if (_hubConnection?.State == HubConnectionState.Connected) { - await _hubConnection.SendAsync("UpdateProgress", pageId, ebookId, progress, chapterTitle, token); + await _hubConnection.SendAsync("UpdateProgress", pageId, ebookId, progress, chapterTitle, chapterIndex); _lastSentPageId = pageId; } } diff --git a/src/NexusReader.Web/Program.cs b/src/NexusReader.Web/Program.cs index 56230f7..9e10a59 100644 --- a/src/NexusReader.Web/Program.cs +++ b/src/NexusReader.Web/Program.cs @@ -106,7 +106,8 @@ builder.Services.AddAuthentication(options => builder.Services.AddIdentityApiEndpoints() .AddRoles() - .AddEntityFrameworkStores(); + .AddEntityFrameworkStores() + .AddClaimsPrincipalFactory(); builder.Services.ConfigureApplicationCookie(options => { diff --git a/src/NexusReader.Web/Services/CustomUserClaimsPrincipalFactory.cs b/src/NexusReader.Web/Services/CustomUserClaimsPrincipalFactory.cs new file mode 100644 index 0000000..c06ec78 --- /dev/null +++ b/src/NexusReader.Web/Services/CustomUserClaimsPrincipalFactory.cs @@ -0,0 +1,28 @@ +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using NexusReader.Domain.Entities; + +namespace NexusReader.Web.Services; + +public class CustomUserClaimsPrincipalFactory : UserClaimsPrincipalFactory +{ + public CustomUserClaimsPrincipalFactory( + UserManager userManager, + RoleManager roleManager, + IOptions optionsAccessor) + : base(userManager, roleManager, optionsAccessor) + { + } + + protected override async Task GenerateClaimsAsync(NexusUser user) + { + var identity = await base.GenerateClaimsAsync(user); + if (!string.IsNullOrEmpty(user.TenantId)) + { + identity.AddClaim(new Claim("TenantId", user.TenantId)); + } + return identity; + } +} diff --git a/tests/NexusReader.Application.Tests/Queries/CheckDatabaseTest.cs b/tests/NexusReader.Application.Tests/Queries/CheckDatabaseTest.cs new file mode 100644 index 0000000..e8afdf8 --- /dev/null +++ b/tests/NexusReader.Application.Tests/Queries/CheckDatabaseTest.cs @@ -0,0 +1,58 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using NexusReader.Data.Persistence; +using Xunit; + +namespace NexusReader.Application.Tests.Queries; + +public class CheckDatabaseTest +{ + [Fact] + public async Task PrintDatabaseStats() + { + var configJson = await File.ReadAllTextAsync("../../../../../src/NexusReader.Web/appsettings.json"); + var doc = JsonDocument.Parse(configJson); + var pgConn = doc.RootElement.GetProperty("ConnectionStrings").GetProperty("PostgresConnection").GetString(); + + Console.WriteLine($"Postgres Connection: {pgConn}"); + + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql(pgConn); + + using var context = new AppDbContext(optionsBuilder.Options); + + var usersCount = await context.Users.CountAsync(); + var ebooksCount = await context.Ebooks.CountAsync(); + var unitsCount = await context.KnowledgeUnits.CountAsync(); + var cacheCount = await context.SemanticKnowledgeCache.CountAsync(); + + Console.WriteLine($"=== DATABASE STATS ==="); + Console.WriteLine($"Users: {usersCount}"); + Console.WriteLine($"Ebooks: {ebooksCount}"); + Console.WriteLine($"KnowledgeUnits: {unitsCount}"); + Console.WriteLine($"SemanticKnowledgeCache: {cacheCount}"); + + var users = await context.Users.ToListAsync(); + foreach (var u in users) + { + Console.WriteLine($"User: {u.Email}, TenantId: '{u.TenantId}'"); + } + + var ebooks = await context.Ebooks.ToListAsync(); + foreach (var eb in ebooks) + { + Console.WriteLine($"Ebook Id: {eb.Id}, Title: '{eb.Title}', FilePath: '{eb.FilePath}', Ready: {eb.IsReadyForReading}"); + } + + var cache = await context.SemanticKnowledgeCache.ToListAsync(); + foreach (var c in cache) + { + Console.WriteLine($"Cache Hash: {c.ContentHash}, TenantId: '{c.TenantId}', PromptVersion: {c.PromptVersion}, JsonData Preview: {c.JsonData.Substring(0, Math.Min(c.JsonData.Length, 150))}"); + } + + Assert.True(true); + } +} -- 2.52.0 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 5/5] 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 */ } -- 2.52.0