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[^>]*>.*?\1>", "", 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 @@
+