From a0bf6c15f433fd6ff5936c90387bbc3ba5bd8597 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Tue, 26 May 2026 12:15:28 +0000 Subject: [PATCH] feat(search/rag): implement NexusSearchBox, dynamic Qdrant collection auto-provisioning, batch vector ingestion, mobile Serilog logging, and resolve 401 auth handler error (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #52 This Pull Request introduces the **NexusSearchBox** search feature with premium unified styling, implements a robust **dynamic Qdrant collection auto-provisioning and batch-vector ingestion pipeline**, integrates a unified **Serilog logging infrastructure** for the Blazor Hybrid environment (MAUI), and resolves the **401 Unauthorized API header propagation error** inside mobile builds. ### 🚀 Key Implementations #### 1. Premium `NexusSearchBox` & Semantic Search UI * **NexusSearchBox Component:** Created an elegant search-as-you-type search box with smooth key navigation, quick-clearing, and seamless dynamic styling. * **Unified Aesthetics:** Refactored the search box isolated styling to align perfectly with the dashboard's design system using glassmorphism, `--nexus-neon` token gradients, and smooth pulse/fade animations. * **Semantic Search Integration:** Integrated semantic search query dispatching (`SearchLibrarySemanticallyQuery`) and wired up navigation seamlessly through the updated `ReaderNavigationService`. * **Tests Hardening:** Added/adapted query assertions in `QueryTests.cs` to guarantee safe parameterization and error boundary mapping. #### 2. Qdrant Collection Provisioning & Vector Ingestion * **Dynamic Auto-Provisioning:** Implemented dynamic checking and lazy-creation of the `knowledge_units` collection using 768 dimensions and Cosine distance. * **High-Performance Ingestion:** Optimized `ProcessKnowledgeUnitsAsync` with high-performance batch embedding generation using `_embeddingGenerator` and deterministic MD5 GUIDs for stable, duplicate-free upsertion. * **Database Cache Clear Sync:** Integrated Qdrant collection deletion in `ClearCacheAsync` to ensure absolute consistency between the PostgreSQL database cache and vector database indices. #### 3. Cross-Platform MAUI Logging (Serilog Infrastructure) * **Serilog Integration:** Configured cross-platform Serilog routing in `SerilogConfiguration.cs`, streaming diagnostic logs safely across native platforms and the Blazor Webview container. * **Interop Bridge:** Built `BlazorLoggingBridge.cs` to capture web console messages and pipe them directly to the native host logger. * **Demo Interface:** Added an interactive `SerilogDemo.razor` sandbox under Pages. #### 4. Resolving 401 Load Errors (Authentication Handler Flow) * **Authentication Header Handler:** Implemented the `MobileAuthenticationHeaderHandler` to correctly extract, validate, and inject bearer JWT tokens into outbound API requests. * **Configuration-based API Host:** Structured standard API URI routing to use clean configuration bindings in `appsettings.json`. --- ### 🧪 Verification & Build Status * Run `dotnet build` from the solution root: Successfully compiled the full multi-targeted solution (`Liczba błędów: 0`). * All unit and integration tests successfully executed and verified (`dotnet test`). --------- Co-authored-by: Marek Jasiński Co-authored-by: Marek Jaisński Reviewed-on: https://git.archimap.cloud/mjasin/Nexus.Reader/pulls/51 Co-authored-by: Antigravity Co-committed-by: Antigravity --- .../Persistence/IEbookRepository.cs | 5 + .../Persistence/IQuizResultRepository.cs | 25 + .../Abstractions/Services/IEpubExtractor.cs | 17 + .../Abstractions/Services/IIdentityService.cs | 1 + .../Library/IngestEbookCommandHandler.cs | 45 +- .../Commands/Library/ProcessEbookCommand.cs | 176 ++++ .../Commands/Quiz/SubmitQuizResultCommand.cs | 10 + .../Quiz/SubmitQuizResultCommandHandler.cs | 41 + .../DTOs/AI/GroundedResponseDto.cs | 2 + .../DTOs/User/UserProfileDto.cs | 29 +- .../Queries/Graph/GraphViewModels.cs | 26 +- .../Queries/Library/GetMyEbooksQuery.cs | 3 +- .../Library/SearchLibrarySemanticallyQuery.cs | 46 +- .../User/GetUserProfileQueryHandler.cs | 75 +- .../Persistence/AppDbContext.cs | 11 +- .../Persistence/AppDbContextFactory.cs | 3 +- .../DependencyInjection.cs | 2 + .../Persistence/EbookRepository.cs | 6 + .../Persistence/QuizResultRepository.cs | 37 + .../Services/EpubExtractor.cs | 85 ++ .../Services/KnowledgeService.cs | 499 +++++++++-- .../Services/PromptRegistry.cs | 65 +- src/NexusReader.Maui/App.xaml.cs | 18 + .../MobileAuthenticationHeaderHandler.cs | 144 +++ .../Logging/BlazorLoggingBridge.cs | 53 ++ .../Logging/SerilogConfiguration.cs | 106 +++ src/NexusReader.Maui/Main.razor | 21 + src/NexusReader.Maui/MauiProgram.cs | 33 +- src/NexusReader.Maui/NexusReader.Maui.csproj | 12 + src/NexusReader.Maui/appsettings.json | 48 + src/NexusReader.Maui/wwwroot/index.html | 46 + .../Atoms/NexusCitationMarker.razor | 76 ++ .../Atoms/NexusCitationMarker.razor.css | 148 +++ .../Components/Atoms/NexusSearchBox.razor | 340 ++++++- .../Components/Atoms/NexusSearchBox.razor.css | 298 ++++++- .../Components/Molecules/KnowledgeCheck.razor | 141 ++- .../Molecules/KnowledgeCheck.razor.css | 214 +++++ .../Organisms/BookIngestionModal.razor | 123 ++- .../Organisms/BookIngestionModal.razor.css | 66 ++ .../Components/Organisms/ReaderCanvas.razor | 23 + .../Layout/ReaderLayout.razor | 140 ++- .../Layout/ReaderLayout.razor.css | 315 +++++++ .../Pages/Dashboard.razor | 153 +++- .../Pages/Dashboard.razor.css | 128 ++- .../Pages/Intelligence.razor | 843 ++++++++++++------ .../Pages/SerilogDemo.razor | 370 ++++++++ .../Pages/Settings.razor | 53 +- .../Services/IReaderNavigationService.cs | 1 + .../Services/ISyncService.cs | 1 + .../Services/IdentityService.cs | 19 + .../Services/KnowledgeCoordinator.cs | 8 + .../Services/ReaderNavigationService.cs | 1 + .../Services/SyncService.cs | 17 +- .../wwwroot/js/knowledgeGraph.js | 189 +++- src/NexusReader.Web.Client/Program.cs | 18 + src/NexusReader.Web/Program.cs | 53 +- .../CustomUserClaimsPrincipalFactory.cs | 28 + .../Services/ServerIdentityService.cs | 18 + .../SubmitQuizResultCommandHandlerTests.cs | 99 ++ .../Queries/CheckDatabaseTest.cs | 58 ++ .../Queries/QueryTests.cs | 50 +- 61 files changed, 5111 insertions(+), 570 deletions(-) create mode 100644 src/NexusReader.Application/Abstractions/Persistence/IQuizResultRepository.cs 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.Application/Commands/Quiz/SubmitQuizResultCommand.cs create mode 100644 src/NexusReader.Application/Commands/Quiz/SubmitQuizResultCommandHandler.cs create mode 100644 src/NexusReader.Infrastructure/Persistence/QuizResultRepository.cs create mode 100644 src/NexusReader.Infrastructure/Services/EpubExtractor.cs create mode 100644 src/NexusReader.Maui/Infrastructure/Identity/MobileAuthenticationHeaderHandler.cs create mode 100644 src/NexusReader.Maui/Infrastructure/Logging/BlazorLoggingBridge.cs create mode 100644 src/NexusReader.Maui/Infrastructure/Logging/SerilogConfiguration.cs create mode 100644 src/NexusReader.Maui/appsettings.json create mode 100644 src/NexusReader.UI.Shared/Components/Atoms/NexusCitationMarker.razor create mode 100644 src/NexusReader.UI.Shared/Components/Atoms/NexusCitationMarker.razor.css create mode 100644 src/NexusReader.UI.Shared/Pages/SerilogDemo.razor create mode 100644 src/NexusReader.Web/Services/CustomUserClaimsPrincipalFactory.cs create mode 100644 tests/NexusReader.Application.Tests/Commands/SubmitQuizResultCommandHandlerTests.cs create mode 100644 tests/NexusReader.Application.Tests/Queries/CheckDatabaseTest.cs diff --git a/src/NexusReader.Application/Abstractions/Persistence/IEbookRepository.cs b/src/NexusReader.Application/Abstractions/Persistence/IEbookRepository.cs index 021d0d5..c0a560a 100644 --- a/src/NexusReader.Application/Abstractions/Persistence/IEbookRepository.cs +++ b/src/NexusReader.Application/Abstractions/Persistence/IEbookRepository.cs @@ -23,6 +23,11 @@ public interface IEbookRepository /// void AddEbook(Ebook ebook); + /// + /// Finds an ebook by its unique identifier. + /// + Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default); + /// /// Persists all staged changes to the underlying store. /// diff --git a/src/NexusReader.Application/Abstractions/Persistence/IQuizResultRepository.cs b/src/NexusReader.Application/Abstractions/Persistence/IQuizResultRepository.cs new file mode 100644 index 0000000..87b2cd7 --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Persistence/IQuizResultRepository.cs @@ -0,0 +1,25 @@ +using NexusReader.Domain.Entities; + +namespace NexusReader.Application.Abstractions.Persistence; + +/// +/// Abstraction for QuizResult and related User entity lookup. +/// Defined in the Application layer to maintain Clean Architecture isolation. +/// +public interface IQuizResultRepository +{ + /// + /// Finds a user by ID to extract tenant context. + /// + Task FindUserByIdAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// Adds a new quiz result to the database. + /// + void AddQuizResult(QuizResult quizResult); + + /// + /// Persists all staged changes to the repository. + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} 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/Abstractions/Services/IIdentityService.cs b/src/NexusReader.Application/Abstractions/Services/IIdentityService.cs index 93b9c9b..8a154ab 100644 --- a/src/NexusReader.Application/Abstractions/Services/IIdentityService.cs +++ b/src/NexusReader.Application/Abstractions/Services/IIdentityService.cs @@ -11,4 +11,5 @@ public interface IIdentityService Task LogoutAsync(); Task> GetProfileAsync(); Task RefreshTokenAsync(); + void ClearCache(); } diff --git a/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs b/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs index 0ae9e21..e611d4b 100644 --- a/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs +++ b/src/NexusReader.Application/Commands/Library/IngestEbookCommandHandler.cs @@ -1,5 +1,8 @@ using FluentResults; +using System.Linq; using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using NexusReader.Application.Abstractions.Messaging; using NexusReader.Application.Abstractions.Persistence; using NexusReader.Application.Abstractions.Services; @@ -11,13 +14,16 @@ public class IngestEbookCommandHandler : IRequestHandler> Handle(IngestEbookCommand request, CancellationToken cancellationToken) @@ -72,6 +78,43 @@ public class IngestEbookCommandHandler : IRequestHandler + { + using var scope = _scopeFactory.CreateScope(); + var logger = scope.ServiceProvider.GetRequiredService>(); + var broadcaster = scope.ServiceProvider.GetRequiredService(); + try + { + var mediator = scope.ServiceProvider.GetRequiredService(); + var result = await mediator.Send(new ProcessEbookCommand(ebook.Id, request.UserId, request.TenantId)); + if (result.IsFailed) + { + var errorMsg = string.Join("; ", result.Errors.Select(e => e.Message)); + logger.LogError("[IngestEbook] Background ebook processing failed for Ebook {EbookId}: {Error}", ebook.Id, errorMsg); + await broadcaster.BroadcastIngestionProgressAsync( + request.UserId, + $"Błąd indeksowania: {errorMsg}", + 1.0); + } + } + catch (Exception ex) + { + logger.LogError(ex, "[IngestEbook] Exception during background ebook processing for Ebook {EbookId}", ebook.Id); + try + { + await broadcaster.BroadcastIngestionProgressAsync( + request.UserId, + $"Błąd krytyczny podczas przetwarzania e-booka: {ex.Message}", + 1.0); + } + catch + { + // Ignore broadcast failures to prevent 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..a7f6698 --- /dev/null +++ b/src/NexusReader.Application/Commands/Library/ProcessEbookCommand.cs @@ -0,0 +1,176 @@ +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.Persistence; +using NexusReader.Application.Abstractions.Services; + +namespace NexusReader.Application.Commands.Library; + +public record ProcessEbookCommand( + Guid EbookId, + string UserId, + string TenantId +) : ICommand; + +public class ProcessEbookCommandHandler : IRequestHandler> +{ + private readonly IEbookRepository _ebookRepository; + private readonly IKnowledgeService _knowledgeService; + private readonly IEpubExtractor _epubExtractor; + private readonly ISyncBroadcaster _broadcaster; + private readonly ILogger _logger; + + public ProcessEbookCommandHandler( + IEbookRepository ebookRepository, + IKnowledgeService knowledgeService, + IEpubExtractor epubExtractor, + ISyncBroadcaster broadcaster, + ILogger logger) + { + _ebookRepository = ebookRepository; + _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); + + var ebook = await _ebookRepository.FindByIdAsync(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 _ebookRepository.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/Commands/Quiz/SubmitQuizResultCommand.cs b/src/NexusReader.Application/Commands/Quiz/SubmitQuizResultCommand.cs new file mode 100644 index 0000000..15ea066 --- /dev/null +++ b/src/NexusReader.Application/Commands/Quiz/SubmitQuizResultCommand.cs @@ -0,0 +1,10 @@ +using FluentResults; +using NexusReader.Application.Abstractions.Messaging; + +namespace NexusReader.Application.Commands.Quiz; + +public record SubmitQuizResultCommand( + string UserId, + string Topic, + int Score, + int TotalQuestions) : ICommand; diff --git a/src/NexusReader.Application/Commands/Quiz/SubmitQuizResultCommandHandler.cs b/src/NexusReader.Application/Commands/Quiz/SubmitQuizResultCommandHandler.cs new file mode 100644 index 0000000..c8ed9fd --- /dev/null +++ b/src/NexusReader.Application/Commands/Quiz/SubmitQuizResultCommandHandler.cs @@ -0,0 +1,41 @@ +using FluentResults; +using NexusReader.Application.Abstractions.Messaging; +using NexusReader.Application.Abstractions.Persistence; +using NexusReader.Domain.Entities; + +namespace NexusReader.Application.Commands.Quiz; + +public sealed class SubmitQuizResultCommandHandler : ICommandHandler +{ + private readonly IQuizResultRepository _quizResultRepository; + + public SubmitQuizResultCommandHandler(IQuizResultRepository quizResultRepository) + { + _quizResultRepository = quizResultRepository; + } + + public async Task Handle(SubmitQuizResultCommand request, CancellationToken cancellationToken) + { + var user = await _quizResultRepository.FindUserByIdAsync(request.UserId, cancellationToken); + if (user == null) + { + return Result.Fail("User not found."); + } + + var quizResult = new QuizResult + { + Id = Guid.NewGuid(), + UserId = request.UserId, + TenantId = string.IsNullOrEmpty(user.TenantId) ? "global" : user.TenantId, + Topic = request.Topic, + Score = request.Score, + TotalQuestions = request.TotalQuestions, + CompletedDate = DateTime.UtcNow + }; + + _quizResultRepository.AddQuizResult(quizResult); + await _quizResultRepository.SaveChangesAsync(cancellationToken); + + return Result.Ok(); + } +} 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.Application/DTOs/User/UserProfileDto.cs b/src/NexusReader.Application/DTOs/User/UserProfileDto.cs index 27a0850..31dd1d3 100644 --- a/src/NexusReader.Application/DTOs/User/UserProfileDto.cs +++ b/src/NexusReader.Application/DTOs/User/UserProfileDto.cs @@ -5,6 +5,7 @@ namespace NexusReader.Application.DTOs.User; public record UserProfileDto { public string Email { get; init; } = string.Empty; + public string UserId { get; init; } = string.Empty; public int AITokensUsed { get; init; } public Guid TenantId { get; init; } @@ -15,11 +16,12 @@ public record UserProfileDto public int AverageQuizScore { get; init; } - /// - /// Summary of the last read book. - /// + public string? DisplayName { get; init; } + public int BooksReadCount { get; init; } + 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 @@ -28,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; } @@ -38,4 +48,15 @@ public record LastReadBookDto public string? LastChapter { get; init; } public int LastChapterIndex { get; init; } public string? Description { get; init; } + public bool IsReadyForReading { get; init; } +} + +public record QuizResultDto +{ + public Guid Id { get; init; } + public string Topic { get; init; } = string.Empty; + public int Score { get; init; } + public int TotalQuestions { get; init; } + public double Percentage { get; init; } + public DateTime CompletedDate { get; init; } } diff --git a/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs b/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs index 19d81e4..c7a9762 100644 --- a/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs +++ b/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs @@ -1,9 +1,27 @@ +using System.Collections.Generic; +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, + [property: JsonPropertyName("summary")] string? Summary = null, + [property: JsonPropertyName("key_terms")] List? KeyTerms = 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>> { - private readonly IEmbeddingGenerator> _embeddingGenerator; - private readonly IDbContextFactory _dbContextFactory; - private readonly ResiliencePipeline _retryPipeline; - private readonly IMapper _mapper; + private readonly IKnowledgeService _knowledgeService; - public SearchLibrarySemanticallyQueryHandler( - IEmbeddingGenerator> embeddingGenerator, - IDbContextFactory dbContextFactory, - ResiliencePipelineProvider pipelineProvider, - IMapper mapper) + public SearchLibrarySemanticallyQueryHandler(IKnowledgeService knowledgeService) { - _embeddingGenerator = embeddingGenerator; - _dbContextFactory = dbContextFactory; - _retryPipeline = pipelineProvider.GetPipeline("ai-retry"); - _mapper = mapper; + _knowledgeService = knowledgeService; } public async Task>> Handle(SearchLibrarySemanticallyQuery request, CancellationToken cancellationToken) @@ -45,19 +24,10 @@ public class SearchLibrarySemanticallyQueryHandler : IRequestHandler - await _embeddingGenerator.GenerateAsync(new[] { request.QueryText }, cancellationToken: ct), cancellationToken); - var queryVector = new Vector(embeddingResponse.First().Vector.ToArray()); - - await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - var cacheEntries = await dbContext.SemanticKnowledgeCache - .Where(c => c.TenantId == request.TenantId && c.Embedding != null) - .OrderBy(c => c.Embedding!.CosineDistance(queryVector)) - .Take(request.Limit) - .ToListAsync(cancellationToken); - - var dtos = _mapper.Map>(cacheEntries); - return Result.Ok(dtos); + return await _knowledgeService.SearchLibrarySemanticallyAsync( + request.QueryText, + request.TenantId, + request.Limit, + cancellationToken); } } diff --git a/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs b/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs index c98698b..82b2150 100644 --- a/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs +++ b/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs @@ -18,13 +18,15 @@ public class GetUserProfileQueryHandler : IRequestHandler> Handle(GetUserProfileQuery request, CancellationToken cancellationToken) { using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - var profile = await dbContext.Users + + var userRaw = await dbContext.Users .Where(u => u.Id == request.UserId) - .Select(u => new UserProfileDto + .Select(u => new { Email = u.Email ?? string.Empty, + UserId = u.Id, AITokensUsed = u.AITokensUsed, - TenantId = u.TenantId != null && u.TenantId.Length == 36 ? new Guid(u.TenantId) : Guid.Empty, + TenantIdString = u.TenantId, Plan = u.SubscriptionPlan != null ? new SubscriptionPlanDto { Id = u.SubscriptionPlan.Id, @@ -32,9 +34,17 @@ public class GetUserProfileQueryHandler : IRequestHandler q.TotalQuestions > 0) - ? (int)u.QuizResults.Where(q => q.TotalQuestions > 0).Average(q => (double)q.Score / q.TotalQuestions * 100) - : 0, + QuizResults = u.QuizResults.Select(q => new + { + q.Score, + q.TotalQuestions, + q.Id, + q.Topic, + q.Percentage, + q.CompletedDate + }).ToList(), + DisplayName = u.DisplayName, + BooksReadCount = u.Ebooks.Count(), LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto { Id = e.Id, @@ -48,7 +58,8 @@ public class GetUserProfileQueryHandler : IRequestHandler ur.UserId == u.Id) @@ -57,11 +68,59 @@ public class GetUserProfileQueryHandler : IRequestHandler k.TenantId == 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 + }) + .ToListAsync(cancellationToken); + + var conceptsMappedCount = await dbContext.KnowledgeUnits + .CountAsync(k => k.TenantId == tenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId), cancellationToken); + + int averageQuizScore = 0; + var validQuizzes = userRaw.QuizResults.Where(q => q.TotalQuestions > 0).ToList(); + if (validQuizzes.Count > 0) + { + averageQuizScore = (int)(validQuizzes.Average(q => (double)q.Score / q.TotalQuestions) * 100); + } + + var profile = new UserProfileDto + { + Email = userRaw.Email, + UserId = userRaw.UserId, + AITokensUsed = userRaw.AITokensUsed, + TenantId = userRaw.TenantIdString != null && userRaw.TenantIdString.Length == 36 ? new Guid(userRaw.TenantIdString) : Guid.Empty, + Plan = userRaw.Plan, + AverageQuizScore = averageQuizScore, + DisplayName = userRaw.DisplayName, + BooksReadCount = userRaw.BooksReadCount, + ConceptsMappedCount = conceptsMappedCount, + LastReadBook = userRaw.LastReadBook, + RecentQuizzes = userRaw.QuizResults.OrderByDescending(q => q.CompletedDate).Take(5).Select(q => new QuizResultDto + { + Id = q.Id, + Topic = q.Topic, + Score = q.Score, + TotalQuestions = q.TotalQuestions, + Percentage = q.Percentage, + CompletedDate = q.CompletedDate + }).ToList(), + MappedConcepts = mappedConcepts, + Roles = userRaw.Roles + }; + return Result.Ok(profile); } } diff --git a/src/NexusReader.Data/Persistence/AppDbContext.cs b/src/NexusReader.Data/Persistence/AppDbContext.cs index 4cd1505..57d80d5 100644 --- a/src/NexusReader.Data/Persistence/AppDbContext.cs +++ b/src/NexusReader.Data/Persistence/AppDbContext.cs @@ -55,16 +55,7 @@ public class AppDbContext : IdentityDbContext entity.HasKey(e => e.ContentHash); entity.HasIndex(e => e.ContentHash).IsUnique(); entity.HasIndex(e => e.TenantId); - if (Database.IsNpgsql()) - { - // Configure vector column (768 dims) and HNSW index for cosine similarity - entity.Property(e => e.Embedding).HasColumnType("vector(768)"); - entity.HasIndex(e => e.Embedding).HasMethod("hnsw").HasOperators("vector_cosine_ops"); - } - else - { - entity.Ignore(e => e.Embedding); - } + entity.Ignore(e => e.Embedding); }); modelBuilder.Entity(entity => diff --git a/src/NexusReader.Data/Persistence/AppDbContextFactory.cs b/src/NexusReader.Data/Persistence/AppDbContextFactory.cs index 6454c8c..d1e954e 100644 --- a/src/NexusReader.Data/Persistence/AppDbContextFactory.cs +++ b/src/NexusReader.Data/Persistence/AppDbContextFactory.cs @@ -1,7 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.Extensions.Configuration; -using Pgvector.EntityFrameworkCore; namespace NexusReader.Data.Persistence; @@ -38,7 +37,7 @@ public class AppDbContextFactory : IDesignTimeDbContextFactory connectionString = "Host=localhost;Database=nexus_reader;Username=postgres;Password=postgres"; } - optionsBuilder.UseNpgsql(connectionString, x => x.UseVector()); + optionsBuilder.UseNpgsql(connectionString); return new AppDbContext(optionsBuilder.Options); } diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index 93ebd7f..58ea65f 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. @@ -119,6 +120,7 @@ public static class DependencyInjection // Fix #1: Ebook repository (scoped, matches AppDbContext lifetime) services.AddScoped(); + services.AddScoped(); // Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper) services.AddScoped(); diff --git a/src/NexusReader.Infrastructure/Persistence/EbookRepository.cs b/src/NexusReader.Infrastructure/Persistence/EbookRepository.cs index 5e23e09..f6d964c 100644 --- a/src/NexusReader.Infrastructure/Persistence/EbookRepository.cs +++ b/src/NexusReader.Infrastructure/Persistence/EbookRepository.cs @@ -46,6 +46,12 @@ internal sealed class EbookRepository : IEbookRepository _context.Ebooks.Add(ebook); } + /// + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Ebooks.FindAsync(new object[] { id }, cancellationToken); + } + /// public Task SaveChangesAsync(CancellationToken cancellationToken = default) => _context.SaveChangesAsync(cancellationToken); diff --git a/src/NexusReader.Infrastructure/Persistence/QuizResultRepository.cs b/src/NexusReader.Infrastructure/Persistence/QuizResultRepository.cs new file mode 100644 index 0000000..3403bb2 --- /dev/null +++ b/src/NexusReader.Infrastructure/Persistence/QuizResultRepository.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore; +using NexusReader.Application.Abstractions.Persistence; +using NexusReader.Data.Persistence; +using NexusReader.Domain.Entities; + +namespace NexusReader.Infrastructure.Persistence; + +/// +/// EF Core implementation of . +/// +internal sealed class QuizResultRepository : IQuizResultRepository +{ + private readonly AppDbContext _context; + + public QuizResultRepository(AppDbContext context) + { + _context = context; + } + + /// + public async Task FindUserByIdAsync(string userId, CancellationToken cancellationToken = default) + { + return await _context.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); + } + + /// + public void AddQuizResult(QuizResult quizResult) + { + _context.QuizResults.Add(quizResult); + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return _context.SaveChangesAsync(cancellationToken); + } +} 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 9379aa8..0c0097c 100644 --- a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs +++ b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs @@ -15,6 +15,7 @@ using Polly.Registry; using Microsoft.Extensions.Options; using NexusReader.Infrastructure.Configuration; using Qdrant.Client; +using Qdrant.Client.Grpc; using Neo4j.Driver; namespace NexusReader.Infrastructure.Services; @@ -32,8 +33,9 @@ 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.7"; private static readonly ConcurrentDictionary>>> _activeRequests = new(); + private static readonly SemaphoreSlim _collectionSemaphore = new(1, 1); public KnowledgeService( IChatClient chatClient, @@ -84,11 +86,12 @@ 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 - .FirstOrDefaultAsync(c => c.ContentHash == hash && c.TenantId == tenantId, cancellationToken); + .FirstOrDefaultAsync(c => c.ContentHash == hash, cancellationToken); if (cached != null && cached.PromptVersion == PromptVersion) { @@ -96,7 +99,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) { @@ -105,7 +113,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>>( @@ -177,7 +185,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 { @@ -201,7 +209,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) @@ -224,6 +239,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(); @@ -285,6 +324,192 @@ public class KnowledgeService : IKnowledgeService _logger.LogWarning("[KnowledgeService] Skipping invalid link {Source} -> {Target}: one or both units are missing.", linkDto.Source, linkDto.Target); } } + + // Generate and upsert vectors to Qdrant in batch + var unitsToEmbed = packet.Units + .Where(u => !string.IsNullOrEmpty(u.Content)) + .ToList(); + + if (unitsToEmbed.Any()) + { + try + { + var contents = unitsToEmbed.Select(u => u.Content).ToList(); + + var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct => + await _embeddingGenerator.GenerateAsync( + contents, + new EmbeddingGenerationOptions { Dimensions = 768 }, + cancellationToken: ct), cancellationToken); + + var embeddings = embeddingResponse.ToList(); + var points = new List(); + + for (int i = 0; i < unitsToEmbed.Count; i++) + { + var unitDto = unitsToEmbed[i]; + var vector = embeddings[i].Vector.ToArray(); + + var point = new PointStruct + { + Id = GetDeterministicGuid(unitDto.Id), + Vectors = vector, + Payload = + { + ["content"] = unitDto.Content, + ["type"] = unitDto.Type ?? string.Empty, + ["tenantId"] = tenantId, + ["ebookId"] = ebookId?.ToString() ?? string.Empty, + ["metadataJson"] = JsonSerializer.Serialize(unitDto.Metadata) + } + }; + points.Add(point); + } + + if (points.Any()) + { + await EnsureCollectionExistsAsync("knowledge_units", cancellationToken); + await _qdrantClient.UpsertAsync("knowledge_units", points, cancellationToken: cancellationToken); + _logger.LogInformation("[KnowledgeService] Successfully upserted {Count} points to Qdrant collection 'knowledge_units'.", points.Count); + } + } + catch (Exception ex) + { + _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) + { + await _collectionSemaphore.WaitAsync(cancellationToken); + try + { + var exists = await _qdrantClient.CollectionExistsAsync(collectionName, cancellationToken); + if (!exists) + { + _logger.LogInformation("[KnowledgeService] Creating Qdrant collection '{CollectionName}'...", collectionName); + await _qdrantClient.CreateCollectionAsync( + collectionName: collectionName, + vectorsConfig: new VectorParams + { + Size = 768, + Distance = Distance.Cosine + }, + cancellationToken: cancellationToken + ); + _logger.LogInformation("[KnowledgeService] Qdrant collection '{CollectionName}' created successfully.", collectionName); + } + } + catch (Exception ex) + { + if (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase) || + (ex.InnerException != null && ex.InnerException.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase))) + { + _logger.LogInformation("[KnowledgeService] Qdrant collection '{CollectionName}' was already created by another thread.", collectionName); + } + else + { + _logger.LogError(ex, "[KnowledgeService] Error ensuring Qdrant collection '{CollectionName}' exists.", collectionName); + } + } + finally + { + _collectionSemaphore.Release(); + } + } + + private static Guid GetDeterministicGuid(string input) + { + if (Guid.TryParse(input, out var guid)) + { + return guid; + } + + using var md5 = System.Security.Cryptography.MD5.Create(); + byte[] hash = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input)); + 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) @@ -354,6 +579,7 @@ public class KnowledgeService : IKnowledgeService List searchResult; try { + await EnsureCollectionExistsAsync("knowledge_units", cancellationToken); var response = await _qdrantClient.SearchAsync( collectionName: "knowledge_units", vector: queryVector, @@ -363,15 +589,37 @@ public class KnowledgeService : IKnowledgeService ); searchResult = response.ToList(); } - catch (Exception) + catch (Exception ex) { + _logger.LogWarning(ex, "[KnowledgeService] Qdrant search failed during GetRelevantContextAsync. Returning empty search results."); 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 (JsonException ex) + { + _logger.LogWarning(ex, "[KnowledgeService] Failed to deserialize metadata JSON in RelevantContext mapping."); + } + } + var text = string.IsNullOrEmpty(summary) ? content : $"{content}: {summary}"; + return new RelevantContext + { + Text = text, + Confidence = point.Score + }; }).ToList(); return Result.Ok(contexts); @@ -417,6 +665,7 @@ public class KnowledgeService : IKnowledgeService List searchResult; try { + await EnsureCollectionExistsAsync("knowledge_units", cancellationToken); var response = await _qdrantClient.SearchAsync( collectionName: "knowledge_units", vector: queryVector, @@ -438,7 +687,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()) @@ -447,7 +696,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"; @@ -516,12 +765,15 @@ public class KnowledgeService : IKnowledgeService { metadata = JsonSerializer.Deserialize>(metaVal.StringValue); } - catch {} + catch (JsonException ex) + { + _logger.LogWarning(ex, "[KnowledgeService] Failed to deserialize metadata JSON in search library mapping."); + } } var dto = new SemanticSearchResultDto { - ContentHash = point.Id.ToString(), + ContentHash = GetPointIdString(point.Id), Snippet = content, UnitType = type, RelevanceScore = point.Score, @@ -529,7 +781,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}"; @@ -602,6 +854,7 @@ public class KnowledgeService : IKnowledgeService List searchResult; try { + await EnsureCollectionExistsAsync("knowledge_units", cancellationToken); var response = await _qdrantClient.SearchAsync( collectionName: "knowledge_units", vector: queryVector, @@ -627,11 +880,28 @@ 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 + .Include(u => u.Ebook) + .ThenInclude(e => e.Author) + .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()) { @@ -641,7 +911,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"; @@ -654,23 +924,70 @@ 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 (JsonException jsonEx) + { + _logger.LogWarning(jsonEx, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in AskQuestionAsync source hydration.", sourceUnit.Id); + } + } + 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 (JsonException jsonEx) + { + _logger.LogWarning(jsonEx, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in AskQuestionAsync target hydration.", targetUnit.Id); + } + } + targetText = string.IsNullOrEmpty(summary) ? targetUnit.Content : $"{targetUnit.Content}: {summary}"; + } + relatedContexts.Add($"[Related Context ({relation}) to {sourceId}] {targetText}"); } } } @@ -682,9 +999,35 @@ 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 (JsonException jsonEx) + { + _logger.LogWarning(jsonEx, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in fallback AskQuestionAsync.", sourceUnit.Id); + } + } + 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}"); } } } @@ -708,33 +1051,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 => @@ -746,6 +1070,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 @@ -756,15 +1094,42 @@ Strict Grounding Rules: 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; + } + + 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 (JsonException ex) + { + _logger.LogWarning(ex, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in AskQuestionAsync citation mapping.", unit.Id); + } + } } } @@ -790,6 +1155,30 @@ Strict Grounding Rules: await dbContext.SemanticKnowledgeCache.ExecuteDeleteAsync(cancellationToken); await dbContext.KnowledgeUnits.ExecuteDeleteAsync(cancellationToken); await dbContext.KnowledgeUnitLinks.ExecuteDeleteAsync(cancellationToken); + + try + { + await _qdrantClient.DeleteCollectionAsync("knowledge_units", cancellationToken: cancellationToken); + } + catch (Exception ex) + { + _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 f456e61..776b3bc 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,28 +16,66 @@ 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. " + - "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 } ] } }"; - + "You are a strict Minimalist Information Architect. Your sole job is to build a high-level, sparse linear backbone for a textbook chapter. " + + "**LANGUAGE CRITICAL**: Detect the language of the provided text. The 'label', 'summary', and 'key_terms' fields MUST be in the EXACT SAME LANGUAGE as the source text. " + + "The input text consists of sections starting with block IDs (e.g., '[ID: seg-4]'). " + + "CRITICAL TOPOLOGY RULES (ZERO TOLERANCE FOR CLUTTER): " + + "1. HARD NODE LIMIT: You are strictly forbidden from extracting more than 4 to 5 nodes IN TOTAL for the entire text. If there are more sections, select ONLY the 4-5 absolute most critical, high-level structural pillars. " + + "2. NO CONCEPT CLOUDS: Do NOT create nodes for individual technologies, files, terms, or phrases (e.g., 'Kestrel', 'appsettings.json', 'DI', 'Blazor Server' must NEVER be nodes). They must ONLY exist as text strings inside the 'key_terms' array of a major node. " + + "3. LINEAR SPINE PATTERN: Nodes must form a clear, clean path or simple tree representing the chronological reading journey (e.g., Node 1 -> Node 2 -> Node 3). Do NOT create complex web loops or interconnect every node. Limit total links in the entire JSON to maximum 4 or 5 links. " + + "4. NODE DATA STRUCTURE: " + + " - 'id': must be the exact block ID (e.g., 'seg-16'). " + + " - 'label': clear technical title (Max 3 words, e.g., 'Blazor Hosting Models'). " + + " - 'group': strictly either 'bridge' (if it compares legacy vs modern) or 'concept' (for standalone core pillars). " + + " - 'summary': exact 2-sentence distillation for the Contextual Panel. " + + " - 'key_terms': array of max 5 short strings representing the micro-concepts hidden inside this section. " + + "System keys configuration: All JSON keys ('nodes', 'links', 'id', 'label', 'group', 'summary', 'key_terms', 'source', 'target', 'type') must remain strictly in English. " + + "Return ONLY minified JSON. Schema: " + + "{ " + + " \"graph\": { " + + " \"nodes\": [ " + + " { \"id\": \"seg-X\", \"label\": \"string\", \"group\": \"concept|bridge\", \"summary\": \"string\", \"key_terms\": [ \"string\" ] } " + + " ], " + + " \"links\": [ " + + " { \"source\": \"seg-X\", \"target\": \"seg-Y\", \"type\": \"maps_to|contains\" } " + + " ] " + + " } " + + "}"; 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\" } ] " + "}."; + + 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.Maui/App.xaml.cs b/src/NexusReader.Maui/App.xaml.cs index 28db06e..365ccf5 100644 --- a/src/NexusReader.Maui/App.xaml.cs +++ b/src/NexusReader.Maui/App.xaml.cs @@ -8,4 +8,22 @@ public partial class App : Microsoft.Maui.Controls.Application MainPage = new MainPage(); } + + protected override Window CreateWindow(IActivationState? activationState) + { + var window = base.CreateWindow(activationState); + + // Hook into native MAUI lifecycle events to cleanly flush and close Serilog buffers + window.Stopped += (s, e) => + { + Serilog.Log.CloseAndFlush(); + }; + + window.Destroying += (s, e) => + { + Serilog.Log.CloseAndFlush(); + }; + + return window; + } } diff --git a/src/NexusReader.Maui/Infrastructure/Identity/MobileAuthenticationHeaderHandler.cs b/src/NexusReader.Maui/Infrastructure/Identity/MobileAuthenticationHeaderHandler.cs new file mode 100644 index 0000000..871dbd6 --- /dev/null +++ b/src/NexusReader.Maui/Infrastructure/Identity/MobileAuthenticationHeaderHandler.cs @@ -0,0 +1,144 @@ +using System.Net.Http.Headers; +using System.Threading; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using NexusReader.Application.Abstractions.Services; + +namespace NexusReader.Maui.Infrastructure.Identity; + +/// +/// A secure HTTP message delegating handler for MAUI that automatically appends JWT tokens +/// to trusted origin requests and transparently refreshes expired tokens in a thread-safe manner. +/// +public class MobileAuthenticationHeaderHandler : DelegatingHandler +{ + private readonly INativeStorageService _storageService; + private readonly IServiceProvider _serviceProvider; + private const string TokenKey = "nexus_auth_token"; + private static readonly SemaphoreSlim _refreshSemaphore = new(1, 1); + + public MobileAuthenticationHeaderHandler(INativeStorageService storageService, IServiceProvider serviceProvider) + { + _storageService = storageService; + _serviceProvider = serviceProvider; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri?.AbsolutePath ?? ""; + bool isAuthEndpoint = path.Contains("identity/login") || + path.Contains("identity/register") || + path.Contains("identity/refresh"); + + // Resolve configured API host dynamically to avoid hardcoded IP addresses + var config = _serviceProvider.GetRequiredService(); + var apiBaseUrlString = config["ApiSettings:BaseUrl"]; + string? apiHost = null; + if (!string.IsNullOrEmpty(apiBaseUrlString) && Uri.TryCreate(apiBaseUrlString, UriKind.Absolute, out var apiUri)) + { + apiHost = apiUri.Host; + } + + // In MAUI, since we only call our own local or staging APIs, we trust local IP/localhost/configured API host. + // We ensure we don't accidentally leak tokens to third-party endpoints. + bool isTrustedHost = request.RequestUri != null && + (request.RequestUri.Host == "localhost" || + request.RequestUri.Host == "127.0.0.1" || + (apiHost != null && request.RequestUri.Host == apiHost) || + request.RequestUri.Host.EndsWith("nexusreader.com")); // Or staging domains + + string? originalToken = null; + + if (!isAuthEndpoint && isTrustedHost) + { + var tokenResult = await _storageService.GetSecureString(TokenKey); + if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value)) + { + originalToken = tokenResult.Value; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", originalToken); + } + } + + var response = await base.SendAsync(request, cancellationToken); + + // Transparent JWT Auto-Refresh on 401 Unauthorized + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized && !isAuthEndpoint) + { + await _refreshSemaphore.WaitAsync(cancellationToken); + try + { + // Re-read token to verify if another concurrent request already refreshed it + var tokenResult = await _storageService.GetSecureString(TokenKey); + var currentToken = tokenResult.IsSuccess ? tokenResult.Value : null; + + bool refreshed = false; + + if (!string.IsNullOrEmpty(currentToken) && currentToken != originalToken) + { + refreshed = true; + } + else + { + using var scope = _serviceProvider.CreateScope(); + var identityService = scope.ServiceProvider.GetRequiredService(); + var refreshResult = await identityService.RefreshTokenAsync(); + if (refreshResult.IsSuccess) + { + var newTokenResult = await _storageService.GetSecureString(TokenKey); + currentToken = newTokenResult.IsSuccess ? newTokenResult.Value : null; + refreshed = !string.IsNullOrEmpty(currentToken); + } + else + { + await identityService.LogoutAsync(); + } + } + + if (refreshed && !string.IsNullOrEmpty(currentToken)) + { + var newRequest = await CloneHttpRequestMessageAsync(request); + newRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", currentToken); + return await base.SendAsync(newRequest, cancellationToken); + } + } + catch (Exception ex) + { + Serilog.Log.Error(ex, "[MobileAuthHandler] Automated token renewal failed"); + } + finally + { + _refreshSemaphore.Release(); + } + } + + return response; + } + + private async Task CloneHttpRequestMessageAsync(HttpRequestMessage req) + { + var clone = new HttpRequestMessage(req.Method, req.RequestUri) + { + Version = req.Version + }; + + if (req.Content != null) + { + var ms = new System.IO.MemoryStream(); + await req.Content.CopyToAsync(ms); + ms.Position = 0; + clone.Content = new StreamContent(ms); + + foreach (var h in req.Content.Headers) + { + clone.Content.Headers.TryAddWithoutValidation(h.Key, h.Value); + } + } + + foreach (var h in req.Headers) + { + clone.Headers.TryAddWithoutValidation(h.Key, h.Value); + } + + return clone; + } +} diff --git a/src/NexusReader.Maui/Infrastructure/Logging/BlazorLoggingBridge.cs b/src/NexusReader.Maui/Infrastructure/Logging/BlazorLoggingBridge.cs new file mode 100644 index 0000000..b667064 --- /dev/null +++ b/src/NexusReader.Maui/Infrastructure/Logging/BlazorLoggingBridge.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.Logging; +using Microsoft.JSInterop; + +namespace NexusReader.Maui.Infrastructure.Logging; + +/// +/// Lightweight bridge service that intercepts logs, console outputs, and uncaught exceptions +/// from the Blazor WebView/JS side, and routes them directly to Serilog under "BlazorWebView" context. +/// +public sealed class BlazorLoggingBridge +{ + private readonly ILogger _logger; + + public BlazorLoggingBridge(ILoggerFactory loggerFactory) + { + _logger = loggerFactory.CreateLogger("BlazorWebView"); + } + + [JSInvokable("LogJsMessage")] + public void LogJsMessage(string level, string message, string? stackTrace = null) + { + if (string.IsNullOrWhiteSpace(message)) + { + return; + } + + switch (level.ToLowerInvariant()) + { + case "error": + case "exception": + if (!string.IsNullOrWhiteSpace(stackTrace)) + { + _logger.LogError("JS Unhandled Exception: {Message}\nStack Trace:\n{StackTrace}", message, stackTrace); + } + else + { + _logger.LogError("JS Error: {Message}", message); + } + break; + + case "warning": + case "warn": + _logger.LogWarning("JS Warning: {Message}", message); + break; + + case "info": + case "log": + default: + _logger.LogInformation("JS Log: {Message}", message); + break; + } + } +} diff --git a/src/NexusReader.Maui/Infrastructure/Logging/SerilogConfiguration.cs b/src/NexusReader.Maui/Infrastructure/Logging/SerilogConfiguration.cs new file mode 100644 index 0000000..7a94779 --- /dev/null +++ b/src/NexusReader.Maui/Infrastructure/Logging/SerilogConfiguration.cs @@ -0,0 +1,106 @@ +using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Core; +using Serilog.Events; +using Serilog.Formatting; +using Serilog.Formatting.Display; + +namespace NexusReader.Maui.Infrastructure.Logging; + +public static class SerilogConfiguration +{ + private const string OutputTemplate = + "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [ThreadId: {ThreadId}] [{SourceContext}] {Message:lj}{NewLine}{Exception}"; + + public static MauiAppBuilder RegisterLogging(this MauiAppBuilder builder) + { + // 1. Ensure logs directory exists in secure sandbox + var logDir = Path.Combine(Microsoft.Maui.Storage.FileSystem.AppDataDirectory, "logs"); + if (!Directory.Exists(logDir)) + { + Directory.CreateDirectory(logDir); + } + var logPath = Path.Combine(logDir, "log-.txt"); + + // 2. Inject sandboxed log path dynamically into configuration provider + builder.Configuration["Serilog:WriteTo:0:Args:configure:0:Args:path"] = logPath; + + // 3. Configure Serilog Logger Configuration using App Configuration settings + var loggerConfig = new LoggerConfiguration() + .ReadFrom.Configuration(builder.Configuration) + .Enrich.With(new ThreadIdEnricher()); + + // 4. Platform-specific and environment-specific sinks +#if ANDROID + // Direct Native Android Logcat Sink (JNI bindings for native diagnostics) + loggerConfig.WriteTo.Sink( + new AndroidLogcatSink(new MessageTemplateTextFormatter(OutputTemplate, null)), + restrictedToMinimumLevel: LogEventLevel.Debug); +#endif + + // 5. Initialize the static Serilog Log + Log.Logger = loggerConfig.CreateLogger(); + + // 6. Connect Serilog to Microsoft.Extensions.Logging + builder.Logging.ClearProviders(); + builder.Logging.AddSerilog(dispose: true); + + return builder; + } +} + +/// +/// A custom self-contained thread enricher to avoid unnecessary NuGet packages. +/// +internal sealed class ThreadIdEnricher : ILogEventEnricher +{ + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("ThreadId", Environment.CurrentManagedThreadId)); + } +} + +#if ANDROID +/// +/// A high-performance, direct Android Logcat Sink utilizing native Android APIs. +/// +internal sealed class AndroidLogcatSink : ILogEventSink +{ + private readonly ITextFormatter _formatter; + private const string Tag = "NexusReader"; + + public AndroidLogcatSink(ITextFormatter formatter) + { + _formatter = formatter ?? throw new ArgumentNullException(nameof(formatter)); + } + + public void Emit(LogEvent logEvent) + { + using var writer = new StringWriter(); + _formatter.Format(logEvent, writer); + var message = writer.ToString().Trim(); + + switch (logEvent.Level) + { + case LogEventLevel.Verbose: + Android.Util.Log.Verbose(Tag, message); + break; + case LogEventLevel.Debug: + Android.Util.Log.Debug(Tag, message); + break; + case LogEventLevel.Information: + Android.Util.Log.Info(Tag, message); + break; + case LogEventLevel.Warning: + Android.Util.Log.Warn(Tag, message); + break; + case LogEventLevel.Error: + Android.Util.Log.Error(Tag, message); + break; + case LogEventLevel.Fatal: + Android.Util.Log.Wtf(Tag, message); + break; + } + } +} +#endif diff --git a/src/NexusReader.Maui/Main.razor b/src/NexusReader.Maui/Main.razor index 29c775f..95b82eb 100644 --- a/src/NexusReader.Maui/Main.razor +++ b/src/NexusReader.Maui/Main.razor @@ -1,5 +1,8 @@ @using Microsoft.AspNetCore.Components.Routing @using NexusReader.UI.Shared +@using NexusReader.Maui.Infrastructure.Logging +@inject IJSRuntime JSRuntime +@inject BlazorLoggingBridge LoggingBridge @@ -16,3 +19,21 @@ + +@code { + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + try + { + var dotNetRef = DotNetObjectReference.Create(LoggingBridge); + await JSRuntime.InvokeVoidAsync("NexusLogging.initializeBridge", dotNetRef); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[SerilogBridge] Failed to initialize Blazor/JS Bridge: {ex}"); + } + } + } +} diff --git a/src/NexusReader.Maui/MauiProgram.cs b/src/NexusReader.Maui/MauiProgram.cs index 8768784..9f12805 100644 --- a/src/NexusReader.Maui/MauiProgram.cs +++ b/src/NexusReader.Maui/MauiProgram.cs @@ -1,9 +1,13 @@ using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using NexusReader.Application.Abstractions.Services; using NexusReader.Infrastructure.Mobile.Services; using NexusReader.UI.Shared.Services; using NexusReader.Application; using MediatR; +using NexusReader.Maui.Infrastructure.Logging; +using NexusReader.Maui.Infrastructure.Identity; namespace NexusReader.Maui; @@ -14,16 +18,30 @@ public static class MauiProgram try { var builder = MauiApp.CreateBuilder(); + + // Load embedded appsettings.json configuration + var assembly = typeof(App).Assembly; + using (var stream = assembly.GetManifestResourceStream("NexusReader.Maui.appsettings.json")) + { + if (stream != null) + { + ((IConfigurationBuilder)builder.Configuration).AddJsonStream(stream); + } + } + builder - .UseMauiApp(); + .UseMauiApp() + .RegisterLogging(); builder.Services.AddMauiBlazorWebView(); #if DEBUG builder.Services.AddBlazorWebViewDeveloperTools(); - builder.Logging.AddDebug(); #endif + // Interception bridge for JS/Blazor WebView logs + builder.Services.AddSingleton(); + // Minimal Infrastructure builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -34,8 +52,15 @@ public static class MauiProgram sp.GetRequiredService()); builder.Services.AddAuthorizationCore(); - // Basic Network - builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri("http://10.0.2.2:5000") }); + // Basic Network with Secure Token Handler + builder.Services.AddTransient(); + builder.Services.AddHttpClient("NexusAPI", client => + { + var apiBaseUrl = builder.Configuration["ApiSettings:BaseUrl"] ?? "http://localhost:5000"; + client.BaseAddress = new Uri(apiBaseUrl); + }).AddHttpMessageHandler(); + + builder.Services.AddScoped(sp => sp.GetRequiredService().CreateClient("NexusAPI")); // UI State builder.Services.AddScoped(); diff --git a/src/NexusReader.Maui/NexusReader.Maui.csproj b/src/NexusReader.Maui/NexusReader.Maui.csproj index b92e219..38f7413 100644 --- a/src/NexusReader.Maui/NexusReader.Maui.csproj +++ b/src/NexusReader.Maui/NexusReader.Maui.csproj @@ -27,6 +27,14 @@ + + + + + + + + @@ -34,4 +42,8 @@ + + + + diff --git a/src/NexusReader.Maui/appsettings.json b/src/NexusReader.Maui/appsettings.json new file mode 100644 index 0000000..4d3ef31 --- /dev/null +++ b/src/NexusReader.Maui/appsettings.json @@ -0,0 +1,48 @@ +{ + "ApiSettings": { + "BaseUrl": "https://localhost:5000" + }, + "Serilog": { + "Using": [ + "Serilog.Sinks.File", + "Serilog.Sinks.Debug", + "Serilog.Sinks.Async" + ], + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft": "Warning", + "Microsoft.AspNetCore": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Async", + "Args": { + "configure": [ + { + "Name": "File", + "Args": { + "path": "LOG_PATH_PLACEHOLDER", + "rollingInterval": "Day", + "retainedFileCountLimit": 7, + "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [ThreadId: {ThreadId}] [{SourceContext}] {Message:lj}{NewLine}{Exception}", + "shared": true + } + } + ] + } + }, + { + "Name": "Debug", + "Args": { + "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [ThreadId: {ThreadId}] [{SourceContext}] {Message:lj}{NewLine}{Exception}" + } + } + ], + "Enrich": [ + "FromLogContext" + ] + } +} \ No newline at end of file diff --git a/src/NexusReader.Maui/wwwroot/index.html b/src/NexusReader.Maui/wwwroot/index.html index e0a77ce..8570c5a 100644 --- a/src/NexusReader.Maui/wwwroot/index.html +++ b/src/NexusReader.Maui/wwwroot/index.html @@ -26,7 +26,53 @@ + 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/Atoms/NexusSearchBox.razor b/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor index 3331c17..cc81e71 100644 --- a/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor +++ b/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor @@ -1,39 +1,347 @@ @namespace NexusReader.UI.Shared.Components.Atoms +@using System.Text.RegularExpressions +@using MediatR +@using NexusReader.Application.DTOs.AI +@using NexusReader.Application.Queries.Library +@inject IMediator Mediator +@inject IReaderNavigationService NavService +@inject IReaderInteractionService InteractionService +@inject NavigationManager NavManager +@inject AuthenticationStateProvider AuthStateProvider +@inject ILogger Logger +@implements IDisposable -
+
- - + @if (_isLoading) + { +
+ } + else + { + + } +
+ + + +
+ +
+ @if (!string.IsNullOrEmpty(SearchValue)) { - + }
+ + @if (_isDropdownOpen && (!string.IsNullOrEmpty(SearchValue) || _isLoading || _results.Any() || _searchError != null)) + { +
+ @if (_isLoading) + { + + } + else if (_searchError != null) + { + + } + else if (_results.Any()) + { + + } + else if (!string.IsNullOrEmpty(SearchValue)) + { + + } +
+ }
@code { - [Parameter] public string Placeholder { get; set; } = "Search your library..."; - [Parameter] public string IconClass { get; set; } = "bi bi-search"; + [Parameter] public string Placeholder { get; set; } = "Zapytaj swoją bibliotekę AI..."; [Parameter] public EventCallback OnSearch { get; set; } - - private string SearchValue { get; set; } = string.Empty; - private bool IsActive => !string.IsNullOrEmpty(SearchValue); + [Parameter] public int Limit { get; set; } = 5; - private async Task HandleKeyPress(KeyboardEventArgs e) + private string SearchValue { get; set; } = string.Empty; + private bool IsFocused { get; set; } + private bool HasResults => _results.Any() && _isDropdownOpen; + + private List _results = new(); + private bool _isLoading; + private string? _searchError; + private bool _isDropdownOpen; + private bool _disposed; + + private CancellationTokenSource? _searchCts; + + private async Task HandleInput(ChangeEventArgs e) { - if (e.Key == "Enter") + SearchValue = e.Value?.ToString() ?? string.Empty; + _searchError = null; + + if (string.IsNullOrWhiteSpace(SearchValue)) { - await OnSearch.InvokeAsync(SearchValue); + _results.Clear(); + _isDropdownOpen = false; + return; } + + _isDropdownOpen = true; + + // Cancel previous search in-flight + _searchCts?.Cancel(); + _searchCts?.Dispose(); + _searchCts = new CancellationTokenSource(); + + var token = _searchCts.Token; + + try + { + // Debounce for 300ms + await Task.Delay(300, token); + await PerformSearchAsync(token); + } + catch (TaskCanceledException) + { + // Typing continued, search cancelled + } + } + + private async Task PerformSearchAsync(CancellationToken token) + { + _isLoading = true; + _searchError = null; + if (!_disposed) + { + await InvokeAsync(StateHasChanged); + } + + try + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global"; + + var result = await Mediator.Send(new SearchLibrarySemanticallyQuery(SearchValue, tenantId, Limit), token); + if (token.IsCancellationRequested || _disposed) return; + + if (result.IsSuccess) + { + _results = result.Value ?? new List(); + _searchError = null; + } + else + { + _results.Clear(); + _searchError = result.Errors.FirstOrDefault()?.Message ?? "Nie udało się wykonać wyszukiwania."; + Logger.LogWarning("Semantic search returned errors: {Errors}", string.Join(", ", result.Errors.Select(e => e.Message))); + } + } + catch (Exception ex) + { + if (!token.IsCancellationRequested && !_disposed) + { + _results.Clear(); + _searchError = "Wystąpił nieoczekiwany błąd podczas wyszukiwania."; + Logger.LogError(ex, "Unexpected error during semantic search for query: {Query}", SearchValue); + } + } + finally + { + if (!token.IsCancellationRequested && !_disposed) + { + _isLoading = false; + await InvokeAsync(StateHasChanged); + } + } + } + + private async Task HandleResultClick(SemanticSearchResultDto result) + { + _isDropdownOpen = false; + + // 1. Resolve Ebook ID + Guid? ebookId = null; + if (result.Metadata != null) + { + foreach (var key in new[] { "ebookId", "ebook_id", "EbookId", "Ebook_Id" }) + { + if (result.Metadata.TryGetValue(key, out var val) && val != null) + { + if (Guid.TryParse(val.ToString(), out var g)) + { + ebookId = g; + break; + } + } + } + } + + if (ebookId == null || ebookId == Guid.Empty) + { + ebookId = NavService.CurrentEbookId; + } + + if (ebookId == null || ebookId == Guid.Empty) + { + Logger.LogWarning("Could not resolve ebook ID from search result metadata."); + return; + } + + // 2. Resolve Chapter Index + int chapterIndex = 0; + if (result.Metadata != null) + { + foreach (var key in new[] { "chapterIndex", "chapter_index", "ChapterIndex", "chapter" }) + { + if (result.Metadata.TryGetValue(key, out var val) && val != null) + { + if (int.TryParse(val.ToString(), out var parsedInt)) + { + chapterIndex = parsedInt; + break; + } + } + } + } + + // 3. Resolve Block ID + string? blockId = null; + if (result.Metadata != null) + { + foreach (var key in new[] { "blockId", "block_id", "BlockId", "nodeId", "node_id", "NodeId", "id" }) + { + if (result.Metadata.TryGetValue(key, out var val) && val != null) + { + blockId = val.ToString(); + break; + } + } + } + + if (string.IsNullOrEmpty(blockId)) + { + blockId = result.ContentHash; + } + + // 4. Set pending scroll and navigate + NavService.PendingScrollBlockId = blockId; + + if (NavService.CurrentEbookId == ebookId.Value && NavService.CurrentChapterIndex == chapterIndex) + { + // Same chapter - scroll and highlight immediately + if (!string.IsNullOrEmpty(blockId)) + { + await InteractionService.RequestScrollToBlock(blockId); + await InteractionService.RequestHighlightBlock(blockId); + } + } + else + { + // Different chapter or book - perform routing + NavService.SetBook(ebookId.Value, chapterIndex); + NavManager.NavigateTo($"/reader/{ebookId.Value}?chapter={chapterIndex}"); + } + + // Invoke the optional callback for parent components + await OnSearch.InvokeAsync(SearchValue); + } + + private void HandleKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Escape") + { + _isDropdownOpen = false; + } + } + + private void HandleFocusIn() + { + IsFocused = true; + _isDropdownOpen = true; + } + + private async Task HandleFocusOut() + { + IsFocused = false; + // Delay slightly to allow click handlers on result cards to execute + await Task.Delay(200); + if (_disposed) return; + _isDropdownOpen = false; + StateHasChanged(); } private void ClearSearch() { SearchValue = string.Empty; + _results.Clear(); + _searchError = null; + _isDropdownOpen = false; + } + + private string HighlightQueryWords(string text, string query) + { + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; + + var escapedText = System.Net.WebUtility.HtmlEncode(text); + + if (string.IsNullOrWhiteSpace(query)) + return escapedText; + + var words = query.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries) + .Where(w => w.Length > 2) + .Select(Regex.Escape); + + if (!words.Any()) + return escapedText; + + var pattern = "(" + string.Join("|", words) + ")"; + try + { + return Regex.Replace(escapedText, pattern, "$1", RegexOptions.IgnoreCase); + } + catch + { + return escapedText; + } + } + + public void Dispose() + { + _disposed = true; + _searchCts?.Cancel(); + _searchCts?.Dispose(); } } diff --git a/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor.css b/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor.css index a850668..4ff84ea 100644 --- a/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor.css +++ b/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor.css @@ -1,57 +1,309 @@ .nexus-search-container { + position: relative; width: 100%; - max-width: 500px; - margin: 1rem auto; - transition: all 0.3s ease; + max-width: 600px; + margin: 1.5rem auto; + font-family: var(--nexus-font-sans), 'Inter', sans-serif; + z-index: 1000; } .search-wrapper { position: relative; display: flex; align-items: center; - background: var(--nexus-card, #141414); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 12px; - padding: 0.5rem 1rem; - transition: border-color 0.3s ease, box-shadow 0.3s ease; + background: rgba(255, 255, 255, 0.03); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + padding: 0.65rem 1.1rem; + transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); } -.nexus-search-container.active .search-wrapper, -.search-wrapper:focus-within { - border-color: var(--nexus-neon, #00ff99); - box-shadow: 0 0 15px rgba(0, 255, 153, 0.2); +/* Focused state: glowing neon border matching other dashboard components */ +.nexus-search-container.focused .search-wrapper { + background: rgba(255, 255, 255, 0.05); + border-color: var(--nexus-neon); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 15px rgba(0, 255, 153, 0.25); +} + +.search-icon-container { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + margin-right: 0.85rem; } .nexus-icon { - color: rgba(255, 255, 255, 0.5); - margin-right: 0.75rem; - font-size: 1.1rem; + color: rgba(255, 255, 255, 0.45); + font-size: 1.25rem; + transition: color 0.3s ease; +} + +.nexus-search-container.focused .nexus-icon { + color: var(--nexus-neon); } .nexus-search-input { flex: 1; background: transparent; border: none; - color: white; - font-family: 'Inter', sans-serif; - font-size: 0.95rem; + color: #ffffff; + font-size: 1rem; + font-weight: 400; outline: none; + padding: 0; + width: 100%; } .nexus-search-input::placeholder { - color: rgba(255, 255, 255, 0.3); + color: rgba(255, 255, 255, 0.35); + font-style: italic; + transition: color 0.3s ease; +} + +.nexus-search-container.focused .nexus-search-input::placeholder { + color: rgba(255, 255, 255, 0.5); +} + +/* Pulsing neon-green AI status indicator */ +.ai-status-indicator { + display: flex; + align-items: center; + margin: 0 0.75rem; +} + +.ai-pulse-dot { + width: 8px; + height: 8px; + background-color: var(--nexus-neon); + border-radius: 50%; + display: inline-block; + position: relative; + box-shadow: 0 0 8px var(--nexus-neon); +} + +.ai-pulse-dot::after { + content: ''; + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + background-color: var(--nexus-neon); + border-radius: 50%; + z-index: -1; + animation: pulse 2s infinite ease-in-out; } .clear-btn { background: transparent; border: none; color: rgba(255, 255, 255, 0.4); - font-size: 1.2rem; + font-size: 1.5rem; + line-height: 1; cursor: pointer; - padding: 0 0.5rem; - transition: color 0.2s ease; + padding: 0 0.25rem; + margin-left: 0.5rem; + transition: color 0.2s ease, transform 0.2s ease; } .clear-btn:hover { - color: var(--nexus-neon, #00ff99); + color: #ff3b30; + transform: scale(1.1); +} + +/* Frosted glass results container */ +.search-dropdown { + position: absolute; + top: calc(100% + 8px); + left: 0; + right: 0; + background: rgba(18, 18, 18, 0.9); + backdrop-filter: blur(20px) saturate(160%); + -webkit-backdrop-filter: blur(20px) saturate(160%); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.6), 0 0 20px rgba(0, 255, 153, 0.05); + max-height: 420px; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.15) transparent; + transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); + animation: slideDown 0.25s cubic-bezier(0.16, 1, 0.3, 1); +} + +.search-dropdown::-webkit-scrollbar { + width: 6px; +} + +.search-dropdown::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + border-radius: 3px; +} + +.search-dropdown::-webkit-scrollbar-thumb:hover { + background: var(--nexus-neon); +} + +/* In-flight spinners */ +.neon-spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(0, 255, 153, 0.15); + border-top: 2px solid var(--nexus-neon); + border-radius: 50%; + animation: spin 0.75s linear infinite; +} + +.neon-spinner-large { + width: 32px; + height: 32px; + border: 3px solid rgba(255, 255, 255, 0.05); + border-top: 3px solid var(--nexus-neon); + border-radius: 50%; + animation: spin 0.8s cubic-bezier(0.5, 0.1, 0.5, 0.9) infinite; + margin-bottom: 1rem; +} + +.dropdown-state-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2.5rem 1.5rem; + text-align: center; +} + +.state-text { + font-size: 0.95rem; + color: rgba(255, 255, 255, 0.65); + font-weight: 300; +} + +.error-icon, .empty-icon { + font-size: 2rem; + margin-bottom: 0.75rem; +} + +.error-icon { + color: #ff3b30; + text-shadow: 0 0 10px rgba(255, 59, 48, 0.4); +} + +.empty-icon { + color: rgba(255, 255, 255, 0.2); +} + +/* Results Cards list */ +.dropdown-results-list { + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.result-card { + padding: 0.95rem 1.1rem; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.04); + border-radius: 10px; + cursor: pointer; + transition: all 0.2s ease; +} + +.result-card:hover { + background: rgba(0, 255, 153, 0.05); + border-color: rgba(0, 255, 153, 0.2); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.result-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; + font-size: 0.8rem; +} + +.relevance-badge { + background: rgba(0, 255, 153, 0.1); + color: var(--nexus-neon); + border: 1px solid rgba(0, 255, 153, 0.25); + border-radius: 6px; + padding: 0.15rem 0.45rem; + font-weight: 600; + letter-spacing: 0.02em; + text-shadow: 0 0 4px rgba(0, 255, 153, 0.25); +} + +.source-title { + color: rgba(255, 255, 255, 0.5); + max-width: 60%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.source-title strong { + color: rgba(255, 255, 255, 0.85); +} + +.result-snippet { + font-size: 0.88rem; + line-height: 1.45; + color: rgba(255, 255, 255, 0.78); + font-weight: 300; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Markup highlights */ +::deep mark.search-highlight { + background: rgba(0, 255, 153, 0.2); + color: var(--nexus-neon); + border-bottom: 1px solid var(--nexus-neon); + padding: 0.05rem 0.15rem; + border-radius: 3px; + font-weight: 500; +} + +/* Animations */ +@keyframes pulse { + 0% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(0, 255, 153, 0.7); + } + 70% { + transform: scale(1.6); + box-shadow: 0 0 0 6px rgba(0, 255, 153, 0); + } + 100% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(0, 255, 153, 0); + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } } diff --git a/src/NexusReader.UI.Shared/Components/Molecules/KnowledgeCheck.razor b/src/NexusReader.UI.Shared/Components/Molecules/KnowledgeCheck.razor index 184b464..945af38 100644 --- a/src/NexusReader.UI.Shared/Components/Molecules/KnowledgeCheck.razor +++ b/src/NexusReader.UI.Shared/Components/Molecules/KnowledgeCheck.razor @@ -2,9 +2,14 @@ @using NexusReader.Application.Queries.Quiz @using NexusReader.Application.Commands.Quiz @using NexusReader.Application.Abstractions.Services +@using NexusReader.UI.Shared.Components.Atoms +@using NexusReader.UI.Shared.Services @inject IMediator Mediator @inject IPlatformService PlatformService @inject IQuizStateService QuizService +@inject IIdentityService IdentityService +@inject IKnowledgeGraphService GraphService +@inject KnowledgeCoordinator Coordinator
@@ -12,10 +17,33 @@
- @if (QuizService.IsHydrating) + @if (QuizService.IsHydrating || _isGenerating) {
Skanowanie wiedzy przez AI...
} + else if (_isSubmitted) + { + + } else if (QuizService.CurrentQuiz != null) {
@@ -41,17 +69,45 @@ }
} + else + { +
+
+ +
+

Brak Aktywnego Quizu

+

Generuj spersonalizowany sprawdzian wiedzy na podstawie bieżącego rozdziału książki.

+ + +
+ }
- @code { [Parameter] public string ContextBlockId { get; set; } = string.Empty; private Dictionary _states = new(); + private bool _isSubmitting = false; + private bool _isSubmitted = false; + private bool _isGenerating = false; + private int _score = 0; + private int _totalQuestions = 0; + private double _percentage = 0.0; protected override void OnInitialized() { @@ -65,6 +121,24 @@ QuizService.OnQuizUpdated -= HandleUpdate; } + private async Task GenerateChapterQuizAsync() + { + if (_isGenerating || string.IsNullOrWhiteSpace(Coordinator.CurrentFullPageContent)) return; + + _isGenerating = true; + StateHasChanged(); + + try + { + await Coordinator.RequestSummaryAndQuizAsync(Coordinator.CurrentFullPageContent); + } + finally + { + _isGenerating = false; + StateHasChanged(); + } + } + private async Task SelectOptionAsync(QuizQuestionDto question, int index) { if (_states.ContainsKey(question)) return; @@ -90,6 +164,67 @@ return QuizService.CurrentQuiz != null && _states.Count == QuizService.CurrentQuiz.Questions.Count; } + private async Task SubmitQuizAsync() + { + if (QuizService.CurrentQuiz == null || !AllQuestionsAnswered() || _isSubmitting) return; + + _isSubmitting = true; + StateHasChanged(); + + try + { + _score = _states.Values.Count(s => s.IsCorrect); + _totalQuestions = QuizService.CurrentQuiz.Questions.Count; + _percentage = _totalQuestions > 0 ? ((double)_score / _totalQuestions) * 100 : 0.0; + + string topic = "Quiz wiedzy"; + var graph = GraphService.CurrentGraphData; + if (graph != null && !string.IsNullOrEmpty(QuizService.CurrentQuizBlockId)) + { + var node = graph.Nodes.FirstOrDefault(n => n.Id == QuizService.CurrentQuizBlockId); + if (node != null && !string.IsNullOrEmpty(node.Label)) + { + topic = $"Test: {node.Label}"; + } + } + + var profileResult = await IdentityService.GetProfileAsync(); + if (profileResult.IsSuccess && profileResult.Value != null) + { + var userId = profileResult.Value.UserId; + + var cmd = new SubmitQuizResultCommand(userId, topic, _score, _totalQuestions); + var result = await Mediator.Send(cmd); + + if (result.IsSuccess) + { + IdentityService.ClearCache(); + _isSubmitted = true; + await PlatformService.VibrateSuccessAsync(); + } + else + { + await PlatformService.VibrateErrorAsync(); + } + } + } + catch + { + await PlatformService.VibrateErrorAsync(); + } + finally + { + _isSubmitting = false; + StateHasChanged(); + } + } + + private void CloseQuiz() + { + _isSubmitted = false; + _states.Clear(); + QuizService.SetQuiz(null, null); + } private string GetBlockClass(QuizQuestionDto question) { diff --git a/src/NexusReader.UI.Shared/Components/Molecules/KnowledgeCheck.razor.css b/src/NexusReader.UI.Shared/Components/Molecules/KnowledgeCheck.razor.css index 887cf2b..1194c53 100644 --- a/src/NexusReader.UI.Shared/Components/Molecules/KnowledgeCheck.razor.css +++ b/src/NexusReader.UI.Shared/Components/Molecules/KnowledgeCheck.razor.css @@ -121,3 +121,217 @@ 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } + +.option-revealed-correct { + border-color: #00ff99 !important; + background: rgba(0, 255, 153, 0.08) !important; + box-shadow: 0 0 8px rgba(0, 255, 153, 0.15); +} + +.option-faded { + opacity: 0.45; +} + +.submitted-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 2rem 1rem; + animation: fadeIn 0.4s ease-out; +} + +.success-icon-wrapper { + background: rgba(0, 255, 153, 0.1); + border: 1px solid rgba(0, 255, 153, 0.3); + border-radius: 50%; + width: 80px; + height: 80px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1.5rem; + box-shadow: 0 0 20px rgba(0, 255, 153, 0.15); +} + +.success-glow { + color: var(--nexus-neon, #00ff99); + filter: drop-shadow(0 0 8px var(--nexus-neon, #00ff99)); +} + +.submitted-title { + font-size: 1.5rem; + font-weight: 700; + color: #fff; + margin-bottom: 0.5rem; + letter-spacing: -0.5px; +} + +.submitted-text { + font-size: 0.9rem; + color: #888; + margin-bottom: 2rem; + line-height: 1.5; +} + +.score-card { + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 16px; + padding: 1.5rem 2.5rem; + margin-bottom: 2rem; + display: flex; + flex-direction: column; + align-items: center; + backdrop-filter: blur(10px); +} + +.score-main { + display: flex; + align-items: baseline; + gap: 0.2rem; + margin-bottom: 0.5rem; +} + +.score-num { + font-size: 3rem; + font-weight: 800; + color: var(--nexus-neon, #00ff99); + line-height: 1; + text-shadow: 0 0 15px rgba(0, 255, 153, 0.3); +} + +.score-divider { + font-size: 1.8rem; + color: #444; +} + +.score-total { + font-size: 1.8rem; + font-weight: 600; + color: #fff; +} + +.score-percent { + font-size: 0.85rem; + color: #666; + text-transform: uppercase; + letter-spacing: 1px; +} + +.reset-quiz-btn { + padding: 0.8rem 3rem; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 30px; + color: #fff; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + letter-spacing: 0.5px; +} + +.reset-quiz-btn:hover { + background: rgba(255, 255, 255, 0.05); + border-color: #fff; + box-shadow: 0 0 15px rgba(255, 255, 255, 0.1); +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.empty-quiz-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 2.5rem 1rem; + animation: fadeIn 0.4s ease-out; +} + +.empty-icon-wrapper { + background: rgba(0, 255, 153, 0.03); + border: 1px solid rgba(0, 255, 153, 0.15); + border-radius: 50%; + width: 80px; + height: 80px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1.5rem; + box-shadow: 0 0 30px rgba(0, 255, 153, 0.05); + transition: all 0.3s ease; +} + +.empty-quiz-state:hover .empty-icon-wrapper { + background: rgba(0, 255, 153, 0.08); + border-color: rgba(0, 255, 153, 0.4); + box-shadow: 0 0 35px rgba(0, 255, 153, 0.15); + transform: scale(1.05); +} + +.neon-glow { + color: var(--nexus-neon, #00ff99); + filter: drop-shadow(0 0 6px var(--nexus-neon, #00ff99)); +} + +.empty-title { + font-size: 1.3rem; + font-weight: 700; + color: #fff; + margin-bottom: 0.5rem; + letter-spacing: -0.3px; +} + +.empty-text { + font-size: 0.9rem; + color: #888; + margin-bottom: 2rem; + line-height: 1.5; + max-width: 280px; +} + +.generate-quiz-btn { + padding: 0.85rem 2rem; + background: rgba(0, 255, 153, 0.08); + border: 1px solid var(--nexus-neon, #00ff99); + border-radius: 30px; + color: var(--nexus-neon, #00ff99); + font-size: 0.85rem; + font-weight: 700; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + letter-spacing: 0.8px; + text-shadow: 0 0 10px rgba(0, 255, 153, 0.3); + box-shadow: 0 0 15px rgba(0, 255, 153, 0.1); +} + +.generate-quiz-btn:not(:disabled):hover { + background: var(--nexus-neon, #00ff99); + color: #000; + box-shadow: 0 0 25px rgba(0, 255, 153, 0.4); + transform: translateY(-2px); + text-shadow: none; +} + +.generate-quiz-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + border-color: rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.02); + color: #666; + text-shadow: none; + box-shadow: none; +} + diff --git a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor index 950da9a..bb04130 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 +42,7 @@
-

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

+

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

@@ -49,34 +52,88 @@
-

Knowledge Integration Progress

+

Integracja Wiedzy

-
-
-
-
-
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.

+
+ }
-

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.

-
+ }
@@ -86,13 +143,65 @@ @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() { var result = await IdentityService.GetProfileAsync(); if (result.IsSuccess) { _profile = result.Value; } + else + { + _profile = null; + } + StateHasChanged(); + } + + private async Task HandleStateInvalidatedAsync() + { + await InvokeAsync(async () => + { + await LoadProfileAsync(); + }); + } + + 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 eaf4d28..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 { @@ -404,3 +414,117 @@ 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; +} + +/* --- 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/Pages/Intelligence.razor b/src/NexusReader.UI.Shared/Pages/Intelligence.razor index 9440b90..c8ea621 100644 --- a/src/NexusReader.UI.Shared/Pages/Intelligence.razor +++ b/src/NexusReader.UI.Shared/Pages/Intelligence.razor @@ -3,179 +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() { @@ -409,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 @@ -422,27 +614,41 @@ ebookId = parsedId; } - var result = await KnowledgeService.AskQuestionAsync(_question, "tenantId", ebookId); + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global"; + + 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 { @@ -450,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/Pages/SerilogDemo.razor b/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor new file mode 100644 index 0000000..08c8fde --- /dev/null +++ b/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor @@ -0,0 +1,370 @@ +@page "/serilog-demo" +@inject ILogger Logger +@inject IJSRuntime JSRuntime + +
+
+
+ +
+

Serilog Logging Infrastructure

+

Production-grade diagnostic pipeline for unified native & web logs

+
+
+
+ + Pipeline Active +
+
+ +
+ +
+
+ +

Native .NET Logs (C#)

+
+

Trigger structured C# logs using Dependency Injected ILogger.

+
+ + + +
+
+ + +
+
+ +

Blazor / JS WebView Logs

+
+

Trigger logs from JavaScript to verify the interop error capture bridge.

+
+ + +
+
+
+ + +
+
+ +

Pipeline Diagnostics

+
+
+
+ Rolling Daily File Sandbox Path + AppDataDirectory/logs/log-*.txt +
+
+ Active Configuration Provider + Serilog.Settings.Configuration (appsettings.json) +
+
+ Native Apple Console Sink + Serilog.Sinks.Debug (conditional compilation) +
+
+ Native Android Logcat Sink + AndroidLogcatSink (direct JNI bindings) +
+
+
+
+ + + +@code { + private void LogInfo() + { + Logger.LogInformation("Structured native log triggered by user from SerilogDemo. Button: LogInfo"); + } + + private void LogWarning() + { + Logger.LogWarning("Potential warning log triggered from Blazor razor component at {Time}", DateTime.UtcNow); + } + + private void LogError() + { + try + { + throw new InvalidOperationException("Simulated native C# operation exception triggered in Diagnostic dashboard."); + } + catch (Exception ex) + { + Logger.LogError(ex, "Captured exception successfully in native Serilog pipeline!"); + } + } + + private async Task TriggerJsLog() + { + await JSRuntime.InvokeVoidAsync("console.log", "Intercepted JS console statement from Blazor WebView interop trigger!"); + } + + private async Task TriggerJsException() + { + await JSRuntime.InvokeVoidAsync("eval", "throw new Error('Simulated runtime JS Exception triggered from Blazor UI button click!');"); + } +} diff --git a/src/NexusReader.UI.Shared/Pages/Settings.razor b/src/NexusReader.UI.Shared/Pages/Settings.razor index 227a733..f19734b 100644 --- a/src/NexusReader.UI.Shared/Pages/Settings.razor +++ b/src/NexusReader.UI.Shared/Pages/Settings.razor @@ -2,16 +2,63 @@ @attribute [Authorize]
-

Ustawienia

-

Konfiguracja Twojego konta i preferencji czytania.

+

Settings

+

Configure your account and application preferences.

+ +
+

Diagnostics & System Logs

+

Inspect native logging infrastructure, trigger custom logs, and trace WebView errors.

+ + + Open Serilog Diagnostics Dashboard + +
diff --git a/src/NexusReader.UI.Shared/Services/IReaderNavigationService.cs b/src/NexusReader.UI.Shared/Services/IReaderNavigationService.cs index 59482ed..824443f 100644 --- a/src/NexusReader.UI.Shared/Services/IReaderNavigationService.cs +++ b/src/NexusReader.UI.Shared/Services/IReaderNavigationService.cs @@ -6,6 +6,7 @@ public interface IReaderNavigationService int CurrentChapterIndex { get; } int TotalChapters { get; } string ChapterTitle { get; } + string? PendingScrollBlockId { get; set; } event Func? OnNavigationChanged; diff --git a/src/NexusReader.UI.Shared/Services/ISyncService.cs b/src/NexusReader.UI.Shared/Services/ISyncService.cs index adcec95..8fce9aa 100644 --- a/src/NexusReader.UI.Shared/Services/ISyncService.cs +++ b/src/NexusReader.UI.Shared/Services/ISyncService.cs @@ -7,5 +7,6 @@ public interface ISyncService Task InitializeAsync(); Task UpdateProgressAsync(string pageId, Guid ebookId, double progress, string? chapterTitle, int chapterIndex); event Func OnProgressReceived; + event Func? OnIngestionProgressReceived; Task DisposeAsync(); } 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 7121ba5..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(); @@ -94,11 +97,15 @@ public sealed partial class KnowledgeCoordinator : IDisposable if (OnGraphUpdated != null) await OnGraphUpdated.Invoke(packet.Graph); await _platformService.VibrateSuccessAsync(); + return; } } + + await _graphService.SetLoading(false); } catch (Exception ex) { + await _graphService.SetLoading(false); LogGraphError(ex, tenantId); } } @@ -144,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.UI.Shared/Services/ReaderNavigationService.cs b/src/NexusReader.UI.Shared/Services/ReaderNavigationService.cs index 4a64a23..e55dcc2 100644 --- a/src/NexusReader.UI.Shared/Services/ReaderNavigationService.cs +++ b/src/NexusReader.UI.Shared/Services/ReaderNavigationService.cs @@ -15,6 +15,7 @@ public class ReaderNavigationService : IReaderNavigationService public int CurrentChapterIndex { get; private set; } = 0; public int TotalChapters { get; private set; } = 1; public string ChapterTitle { get; private set; } = "Loading..."; + public string? PendingScrollBlockId { get; set; } public event Func? OnNavigationChanged; diff --git a/src/NexusReader.UI.Shared/Services/SyncService.cs b/src/NexusReader.UI.Shared/Services/SyncService.cs index 16c986f..1494f2d 100644 --- a/src/NexusReader.UI.Shared/Services/SyncService.cs +++ b/src/NexusReader.UI.Shared/Services/SyncService.cs @@ -16,6 +16,7 @@ public class SyncService : ISyncService, IAsyncDisposable private CancellationTokenSource? _debounceCts; public event Func? OnProgressReceived; + public event Func? OnIngestionProgressReceived; public SyncService( HttpClient httpClient, @@ -50,9 +51,20 @@ 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); }); + _hubConnection.On("IngestionProgress", async (message, progress) => + { + if (OnIngestionProgressReceived != null) await OnIngestionProgressReceived(message, progress); + }); + try { await _hubConnection.StartAsync(); @@ -71,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(); @@ -86,8 +100,7 @@ public class SyncService : ISyncService, IAsyncDisposable if (_hubConnection?.State == HubConnectionState.Connected) { - await _hubConnection.SendAsync("UpdateProgress", pageId, ebookId, progress, chapterTitle, token); - _lastSentPageId = pageId; + await _hubConnection.SendAsync("UpdateProgress", pageId, ebookId, progress, chapterTitle, chapterIndex); } } catch (TaskCanceledException) { /* Ignored, user kept scrolling */ } diff --git a/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js index f83f487..7356915 100644 --- a/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js +++ b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js @@ -3,6 +3,110 @@ import * as d3 from 'https://esm.sh/d3@7'; const getDisplayLabel = d => d.label.length > 20 ? d.label.substring(0, 17) + "..." : d.label; const getPillWidth = d => getDisplayLabel(d).length * 8 + 30; +const getNodeType = d => { + if (d) { + if (d.type) { + const t = d.type.toLowerCase(); + if (t === 'definition') return 'definition'; + if (t === 'table') return 'table'; + if (t === 'rule') return 'rule'; + if (t === 'section') return 'section'; + } + if (d.group) { + const g = d.group.toLowerCase(); + if (g === 'definition') return 'definition'; + if (g === 'table') return 'table'; + if (g === 'rule') return 'rule'; + if (g === 'section') return 'section'; + } + } + return null; +}; + +const getNodeGroup = d => { + if (d && d.group) { + const g = d.group.toLowerCase(); + if (g === 'bridge') return 'bridge'; + if (g === 'current') return 'current'; + if (g === 'concept') return 'concept'; + } + return 'concept'; // fallback +}; + +const getCategoryStyle = d => { + const type = getNodeType(d); + const group = getNodeGroup(d); + + // 1. Rule (red/coral) + if (type === 'rule') { + return { + color: '#ff4646', + fill: 'rgba(255, 70, 70, 0.1)', + opacity: 0.8, + glowKey: 'rule', + textColor: '#ff8b8b' + }; + } + // 2. Definition (gold/amber) + if (type === 'definition') { + return { + color: '#ffb03a', + fill: 'rgba(255, 176, 58, 0.1)', + opacity: 0.8, + glowKey: 'definition', + textColor: '#ffd18c' + }; + } + // 3. Table (purple/magenta) + if (type === 'table') { + return { + color: '#d946ef', + fill: 'rgba(217, 70, 239, 0.1)', + opacity: 0.8, + glowKey: 'table', + textColor: '#f5d0fe' + }; + } + // 4. Section (blue/indigo) + if (type === 'section') { + return { + color: '#3b82f6', + fill: 'rgba(59, 130, 246, 0.1)', + opacity: 0.8, + glowKey: 'section', + textColor: '#93c5fd' + }; + } + // 5. Bridge (cyan/comparison) + if (group === 'bridge') { + return { + color: '#06b6d4', + fill: 'rgba(6, 182, 212, 0.1)', + opacity: 0.7, + glowKey: 'bridge', + textColor: '#67e8f9' + }; + } + // 6. Current (active/focus landmark - neon green) + if (group === 'current') { + return { + color: 'var(--nexus-neon)', + fill: 'rgba(0, 255, 153, 0.15)', + opacity: 0.9, + glowKey: 'current', + textColor: '#ffffff' + }; + } + // 7. Concept / Default (subtle cool steel blue/teal) + return { + color: '#00d2c4', + fill: 'rgba(0, 210, 196, 0.05)', + opacity: 0.4, + glowKey: 'concept', + textColor: '#e0e0e0' + }; +}; + let simulation; let zoomBehavior; let svgElement; @@ -24,8 +128,10 @@ export function mount(containerId, data, dotNetHelper) { .attr("height", "100%") .style("background", "radial-gradient(circle, #1a1a1a 0%, #121212 100%)"); - // Radial gradient for Nebula effect + // Radial gradients for Nebula effects const defs = svgElement.append("defs"); + + // Fallback radial gradient for legacy nebulaGlow const radialGradient = defs.append("radialGradient") .attr("id", "nebulaGlow") .attr("cx", "50%") @@ -34,6 +140,26 @@ export function mount(containerId, data, dotNetHelper) { radialGradient.append("stop").attr("offset", "0%").attr("stop-color", "var(--nexus-neon)").attr("stop-opacity", 1); radialGradient.append("stop").attr("offset", "100%").attr("stop-color", "var(--nexus-neon)").attr("stop-opacity", 0); + const colors = { + 'rule': '#ff4646', + 'definition': '#ffb03a', + 'table': '#d946ef', + 'section': '#3b82f6', + 'bridge': '#06b6d4', + 'current': 'var(--nexus-neon)', + 'concept': '#00d2c4' + }; + + Object.entries(colors).forEach(([key, color]) => { + const radGrad = defs.append("radialGradient") + .attr("id", `nebulaGlow-${key}`) + .attr("cx", "50%") + .attr("cy", "50%") + .attr("r", "50%"); + radGrad.append("stop").attr("offset", "0%").attr("stop-color", color).attr("stop-opacity", 1); + radGrad.append("stop").attr("offset", "100%").attr("stop-color", color).attr("stop-opacity", 0); + }); + // Root Group for Zoom rootGroup = svgElement.append("g").attr("class", "zoom-containment"); @@ -135,21 +261,33 @@ export function updateData(data) { } }); + // Sanitize links to filter out any references to non-existent nodes + const nodeIds = new Set(data.nodes.map(n => n.id)); + const validLinks = (data.links || []).filter(l => { + const srcId = typeof l.source === 'object' ? l.source.id : l.source; + const tgtId = typeof l.target === 'object' ? l.target.id : l.target; + return nodeIds.has(srcId) && nodeIds.has(tgtId); + }); + // Update Links link = rootGroup.select(".links-layer") .selectAll("path") - .data(data.links, d => d.source + "-" + d.target + "-" + d.relationType) + .data(validLinks, d => { + const srcId = typeof d.source === 'object' ? d.source.id : d.source; + const tgtId = typeof d.target === 'object' ? d.target.id : d.target; + return srcId + "-" + tgtId + "-" + d.type; + }) .join( enter => enter.append("path") .attr("stroke", d => { - if (d.relationType === 'Defines') return 'var(--nexus-accent)'; - if (d.relationType === 'Next') return 'rgba(255,255,255,0.2)'; - if (d.relationType === 'Contains') return 'var(--nexus-neon)'; + if (d.type === 'Defines' || d.type === 'maps_to') return 'var(--nexus-accent, #00ffaa)'; + if (d.type === 'Next' || d.type === 'relates_to') return 'rgba(255,255,255,0.2)'; + if (d.type === 'Contains' || d.type === 'contains') return 'var(--nexus-neon)'; return 'rgba(255,255,255,0.1)'; }) .attr("fill", "none") - .attr("stroke-width", d => d.relationType === 'Defines' ? 2 : 1) - .attr("stroke-dasharray", d => d.relationType === 'References' ? "5,5" : "0") + .attr("stroke-width", d => (d.type === 'Defines' || d.type === 'maps_to') ? 2 : 1) + .attr("stroke-dasharray", d => d.type === 'References' ? "5,5" : "0") .style("opacity", 0) .call(enter => enter.transition().duration(500).style("opacity", 1)), update => update, @@ -174,13 +312,8 @@ export function updateData(data) { g.append("circle") .attr("r", 30) - .attr("fill", d => { - if (d.type === 'Definition') return 'var(--nexus-accent)'; - if (d.type === 'Table') return 'var(--nexus-neon)'; - if (d.type === 'Rule') return '#ff4444'; - return "url(#nebulaGlow)"; - }) - .attr("opacity", d => d.group === 'current' ? 0.6 : 0.2); + .attr("fill", d => `url(#nebulaGlow-${getCategoryStyle(d).glowKey})`) + .attr("opacity", d => getCategoryStyle(d).opacity); g.append("rect") .attr("class", "node-pill") @@ -189,23 +322,20 @@ export function updateData(data) { .attr("width", d => getPillWidth(d)) .attr("height", 30) .attr("rx", 15) - .attr("fill", "rgba(20, 20, 20, 0.9)") - .attr("stroke", d => { - if (d.type === 'Definition') return 'var(--nexus-accent)'; - if (d.type === 'Rule') return '#ff4444'; - return "rgba(255, 255, 255, 0.1)"; - }) - .attr("stroke-width", 1); + .attr("fill", "rgba(20, 20, 20, 0.95)") + .attr("stroke", d => getCategoryStyle(d).color) + .attr("stroke-width", d => getNodeGroup(d) === 'current' ? 2 : 1.2); g.append("text") .text(d => getDisplayLabel(d)) .attr("text-anchor", "middle") .attr("y", 5) - .attr("fill", d => d.type === 'Definition' ? 'var(--nexus-accent)' : '#ccc') - .attr("font-size", "0.8rem"); + .attr("fill", d => getCategoryStyle(d).textColor) + .attr("font-size", "0.8rem") + .attr("font-weight", d => getNodeGroup(d) === 'current' ? '600' : 'normal'); g.append("title") - .text(d => d.label); + .text(d => d.description ? `${d.label}\n\n${d.description}` : d.label); g.transition().duration(500).style("opacity", 1); @@ -216,7 +346,7 @@ export function updateData(data) { ); simulation.nodes(data.nodes); - simulation.force("link").links(data.links); + simulation.force("link").links(validLinks); simulation.alpha(0.5).restart(); // Trigger zoom to fit after a short delay to allow simulation to settle @@ -398,6 +528,15 @@ export function clear() { } simulation.nodes([]); } + + // Reset selections + link = null; + node = null; + + // Reset D3 zoom transform to clean identity state + if (svgElement && zoomBehavior) { + svgElement.call(zoomBehavior.transform, d3.zoomIdentity); + } } catch (e) { console.warn("Failed to clear force simulation safely:", e); } diff --git a/src/NexusReader.Web.Client/Program.cs b/src/NexusReader.Web.Client/Program.cs index dcc3569..9eed57e 100644 --- a/src/NexusReader.Web.Client/Program.cs +++ b/src/NexusReader.Web.Client/Program.cs @@ -50,7 +50,9 @@ builder.Services.AddSingleton>(new ThrowingDbCon builder.Services.AddSingleton>>(new ThrowingEmbeddingGenerator()); builder.Services.AddSingleton(new ThrowingBookStorageService()); builder.Services.AddSingleton(new ThrowingEbookRepository()); +builder.Services.AddSingleton(new ThrowingQuizResultRepository()); builder.Services.AddSingleton(new ThrowingSyncBroadcaster()); +builder.Services.AddSingleton(new ThrowingEpubExtractor()); builder.Services.AddApplication(); builder.Services.AddScoped(); @@ -88,6 +90,16 @@ public class ThrowingEbookRepository : IEbookRepository public Task FindAuthorByNameAsync(string name, CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage); public void AddAuthor(Author author) => throw new NotSupportedException(ErrorMessage); public void AddEbook(Ebook ebook) => throw new NotSupportedException(ErrorMessage); + public Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage); + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage); +} + +public class ThrowingQuizResultRepository : IQuizResultRepository +{ + private const string ErrorMessage = "QuizResult repository operations are not supported in the WASM client. Use the API endpoint for data access."; + + public Task FindUserByIdAsync(string userId, CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage); + public void AddQuizResult(QuizResult quizResult) => throw new NotSupportedException(ErrorMessage); public Task SaveChangesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage); } @@ -99,3 +111,9 @@ public class ThrowingSyncBroadcaster : ISyncBroadcaster public Task BroadcastIngestionProgressAsync(string userId, string message, double progress, CancellationToken cancellationToken = default) => throw new NotSupportedException("Real-time broadcasting can only be performed by the server."); } + +public class ThrowingEpubExtractor : IEpubExtractor +{ + public Task>> ExtractChaptersTextAsync(string relativePath, CancellationToken cancellationToken = default) + => throw new NotSupportedException("EPUB text extraction is not supported in the WASM client."); +} diff --git a/src/NexusReader.Web/Program.cs b/src/NexusReader.Web/Program.cs index 8123f92..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 => { @@ -194,6 +195,7 @@ using (var scope = app.Services.CreateScope()) await dbContext.Database.MigrateAsync(); await DbInitializer.SeedAsync(services); + await TriggerBackgroundProcessingForUnindexedBooksAsync(services); if (logger.IsEnabled(LogLevel.Information)) { @@ -337,13 +339,16 @@ app.MapPost("/api/library/ingest", async ([FromBody] IngestEbookRequest request, ? Convert.FromBase64String(request.CoverImageBase64) : null; + var tenantId = user.FindFirst("TenantId")?.Value ?? "global"; + var command = new IngestEbookCommand( request.Title, request.AuthorName, coverData, epubData, request.Description, - userId + userId, + tenantId ); var result = await mediator.Send(command); @@ -563,6 +568,50 @@ app.MapRazorComponents() app.Run(); +async Task TriggerBackgroundProcessingForUnindexedBooksAsync(IServiceProvider services) +{ + var logger = services.GetRequiredService>(); + try + { + var dbContextFactory = services.GetRequiredService>(); + using var dbContext = await dbContextFactory.CreateDbContextAsync(); + + var unindexedEbooks = await dbContext.Ebooks + .Where(e => !e.IsReadyForReading) + .ToListAsync(); + + if (unindexedEbooks.Any()) + { + logger.LogInformation("[Startup] Found {Count} un-indexed ebooks. Triggering background processing...", unindexedEbooks.Count); + + foreach (var ebook in unindexedEbooks) + { + logger.LogInformation("[Startup] Queuing background processing for ebook: '{Title}' ({Id})", ebook.Title, ebook.Id); + + _ = Task.Run(async () => + { + try + { + using var scope = services.CreateScope(); + var scopedMediator = scope.ServiceProvider.GetRequiredService(); + await scopedMediator.Send(new ProcessEbookCommand(ebook.Id, ebook.UserId, ebook.TenantId)); + } + catch (Exception ex) + { + using var scope = services.CreateScope(); + var scopedLogger = scope.ServiceProvider.GetRequiredService>(); + scopedLogger.LogError(ex, "Failed to run background processing for ebook {EbookId} on startup", ebook.Id); + } + }); + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking or triggering background processing for unindexed books on startup."); + } +} + public record KnowledgeRequest(string Text, Guid? EbookId = null); public record GroundednessRequest(string Answer, string Context); public record SemanticSearchRequest(string QueryText, int Limit = 5); 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/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..d683d46 --- /dev/null +++ b/tests/NexusReader.Application.Tests/Commands/SubmitQuizResultCommandHandlerTests.cs @@ -0,0 +1,99 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Moq; +using NexusReader.Application.Abstractions.Persistence; +using NexusReader.Application.Commands.Quiz; +using NexusReader.Data.Persistence; +using NexusReader.Domain.Entities; +using NexusReader.Infrastructure.Persistence; +using Xunit; + +namespace NexusReader.Application.Tests.Commands; + +public class SubmitQuizResultCommandHandlerTests +{ + private readonly Mock _repositoryMock; + + public SubmitQuizResultCommandHandlerTests() + { + _repositoryMock = new Mock(); + } + + [Fact] + public async Task Handle_WithValidRequest_PersistsQuizResultToDatabase() + { + // Arrange + var user = new NexusUser + { + Id = "user-abc", + UserName = "testuser", + Email = "test@example.com", + TenantId = "tenant-xyz", + SubscriptionPlanId = 1 + }; + + _repositoryMock.Setup(r => r.FindUserByIdAsync("user-abc", It.IsAny())) + .ReturnsAsync(user); + + QuizResult? capturedQuizResult = null; + _repositoryMock.Setup(r => r.AddQuizResult(It.IsAny())) + .Callback(q => capturedQuizResult = q); + + _repositoryMock.Setup(r => r.SaveChangesAsync(It.IsAny())) + .ReturnsAsync(1); + + var command = new SubmitQuizResultCommand( + UserId: "user-abc", + Topic: "Sprawdzian: .NET 10", + Score: 4, + TotalQuestions: 5 + ); + + var handler = new SubmitQuizResultCommandHandler(_repositoryMock.Object); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + capturedQuizResult.Should().NotBeNull(); + capturedQuizResult!.Topic.Should().Be("Sprawdzian: .NET 10"); + capturedQuizResult.Score.Should().Be(4); + capturedQuizResult.TotalQuestions.Should().Be(5); + capturedQuizResult.TenantId.Should().Be("tenant-xyz"); + + _repositoryMock.Verify(r => r.AddQuizResult(It.IsAny()), Times.Once); + _repositoryMock.Verify(r => r.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithNonExistentUser_ReturnsFailureResult() + { + // Arrange + _repositoryMock.Setup(r => r.FindUserByIdAsync("non-existent", It.IsAny())) + .ReturnsAsync((NexusUser?)null); + + var command = new SubmitQuizResultCommand( + UserId: "non-existent", + Topic: "Sprawdzian: .NET 10", + Score: 4, + TotalQuestions: 5 + ); + + var handler = new SubmitQuizResultCommandHandler(_repositoryMock.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."); + + _repositoryMock.Verify(r => r.AddQuizResult(It.IsAny()), Times.Never); + _repositoryMock.Verify(r => r.SaveChangesAsync(It.IsAny()), Times.Never); + } +} 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); + } +} diff --git a/tests/NexusReader.Application.Tests/Queries/QueryTests.cs b/tests/NexusReader.Application.Tests/Queries/QueryTests.cs index dff732d..6fa6bfb 100644 --- a/tests/NexusReader.Application.Tests/Queries/QueryTests.cs +++ b/tests/NexusReader.Application.Tests/Queries/QueryTests.cs @@ -116,11 +116,8 @@ public class QueryTests : IDisposable public async Task SearchLibrarySemanticallyQuery_WithEmptyQueryText_ReturnsFailure() { // Arrange - var handler = new SearchLibrarySemanticallyQueryHandler( - _embeddingGeneratorMock.Object, - _dbContextFactoryMock.Object, - _pipelineProviderMock.Object, - _mapperMock.Object); + var knowledgeServiceMock = new Mock(); + var handler = new SearchLibrarySemanticallyQueryHandler(knowledgeServiceMock.Object); var query = new SearchLibrarySemanticallyQuery("", "tenant-123"); // Act @@ -132,39 +129,38 @@ public class QueryTests : IDisposable } [Fact] - public async Task SearchLibrarySemanticallyQuery_WithValidQuery_GeneratesEmbeddingAndQueriesDatabase() + public async Task SearchLibrarySemanticallyQuery_WithValidQuery_CallsKnowledgeService() { // Arrange var queryText = "test query"; var tenantId = "tenant-123"; + var expectedResponse = new List + { + new SemanticSearchResultDto + { + Snippet = "Matched content", + RelevanceScore = 0.95f, + SourceBookTitle = "Test Book" + } + }; - var mockEmbedding = new Embedding(new float[768]); - var mockResponse = new GeneratedEmbeddings>(new[] { mockEmbedding }); - _embeddingGeneratorMock.Setup(g => g.GenerateAsync( - It.Is>(s => s.Contains(queryText)), - It.IsAny(), - It.IsAny())) - .ReturnsAsync(mockResponse); - - var handler = new SearchLibrarySemanticallyQueryHandler( - _embeddingGeneratorMock.Object, - _dbContextFactoryMock.Object, - _pipelineProviderMock.Object, - _mapperMock.Object); + var knowledgeServiceMock = new Mock(); + knowledgeServiceMock.Setup(s => s.SearchLibrarySemanticallyAsync(queryText, tenantId, 5, It.IsAny())) + .ReturnsAsync(Result.Ok(expectedResponse)); + var handler = new SearchLibrarySemanticallyQueryHandler(knowledgeServiceMock.Object); var query = new SearchLibrarySemanticallyQuery(queryText, tenantId); // Act - Func act = async () => await handler.Handle(query, CancellationToken.None); + var result = await handler.Handle(query, CancellationToken.None); - // Assert (SQLite provider will throw an execution/translation exception since CosineDistance is not supported, - // which confirms that the query built successfully and attempted execution!) - await act.Should().ThrowAsync(); + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(1); + result.Value.First().Snippet.Should().Be("Matched content"); + result.Value.First().SourceBookTitle.Should().Be("Test Book"); - _embeddingGeneratorMock.Verify(g => g.GenerateAsync( - It.Is>(s => s.Contains(queryText)), - It.IsAny(), - It.IsAny()), Times.Once); + knowledgeServiceMock.Verify(s => s.SearchLibrarySemanticallyAsync(queryText, tenantId, 5, It.IsAny()), Times.Once); } [Fact]