diff --git a/src/NexusReader.Application/Abstractions/Persistence/IUserReadingStateStore.cs b/src/NexusReader.Application/Abstractions/Persistence/IUserReadingStateStore.cs new file mode 100644 index 0000000..8a574b6 --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Persistence/IUserReadingStateStore.cs @@ -0,0 +1,21 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace NexusReader.Application.Abstractions.Persistence; + +/// +/// Decoupled database store to retrieve active user reading states and chapter content. +/// +public interface IUserReadingStateStore +{ + /// + /// Retrieves the user's active reading state: last read ebook ID, last opened chapter/page ID, and tenant ID. + /// + Task<(Guid? EbookId, string? ChapterId, string? TenantId)> GetActiveReadingStateAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// Retrieves the text content of a specific chapter/page by its ID. + /// + Task GetChapterContentAsync(string chapterId, CancellationToken cancellationToken = default); +} diff --git a/src/NexusReader.Application/Abstractions/Persistence/IVectorSearchStore.cs b/src/NexusReader.Application/Abstractions/Persistence/IVectorSearchStore.cs index 052f620..0a1fecb 100644 --- a/src/NexusReader.Application/Abstractions/Persistence/IVectorSearchStore.cs +++ b/src/NexusReader.Application/Abstractions/Persistence/IVectorSearchStore.cs @@ -8,7 +8,7 @@ namespace NexusReader.Application.Abstractions.Persistence; /// /// Represents a chunk of text retrieved from the semantic vector database. /// -public record VectorChunk(string Content, string EbookId, double Score, string MetadataJson = ""); +public record VectorChunk(string Content, string EbookId, double Score, string MetadataJson = "", string BookTitle = "", string ChapterTitle = ""); /// /// Abstraction for performing semantic vector searches, isolating Qdrant gRPC dependencies from the Application layer. @@ -24,4 +24,9 @@ public interface IVectorSearchStore /// Searches within a whitelist of owned book IDs for the best semantic matches. /// Task> SearchLocalAsync(string queryText, string tenantId, List whitelistedBookIds, int limit, CancellationToken cancellationToken = default); + + /// + /// Searches the entire global catalog (filtered by tenant) for the best semantic matches, excluding a specific book ID. + /// + Task> SearchGlobalExcludeAsync(string queryText, string tenantId, Guid excludeBookId, int limit, CancellationToken cancellationToken = default); } diff --git a/src/NexusReader.Application/Common/AppJsonContext.cs b/src/NexusReader.Application/Common/AppJsonContext.cs index 341a70b..2265dc2 100644 --- a/src/NexusReader.Application/Common/AppJsonContext.cs +++ b/src/NexusReader.Application/Common/AppJsonContext.cs @@ -13,6 +13,9 @@ namespace NexusReader.Application.Common; [JsonSerializable(typeof(List))] [JsonSerializable(typeof(GetGlobalIntelligenceRequest))] [JsonSerializable(typeof(IntelligenceResponse))] +[JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.ContextualRecommendationResponse))] +[JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.RecommendationDto))] +[JsonSerializable(typeof(List))] public partial class AppJsonContext : JsonSerializerContext { } diff --git a/src/NexusReader.Application/Queries/Recommendations/GetContextualRecommendationsQuery.cs b/src/NexusReader.Application/Queries/Recommendations/GetContextualRecommendationsQuery.cs new file mode 100644 index 0000000..0b93945 --- /dev/null +++ b/src/NexusReader.Application/Queries/Recommendations/GetContextualRecommendationsQuery.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using FluentResults; +using MediatR; + +namespace NexusReader.Application.Queries.Recommendations; + +/// +/// MediatR query to fetch contextual recommendations based on the user's active reading state. +/// +public record GetContextualRecommendationsQuery(string UserId) + : IRequest>; + +/// +/// Response DTO containing contextual recommendations. +/// +public record ContextualRecommendationResponse(List Recommendations); + +/// +/// Individual contextual recommendation details. +/// +public record RecommendationDto( + string BookTitle, + string ChapterTitle, + int MatchPercentage, + bool IsPremiumUpsell, + Guid TargetBookId +); diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index 3f790fe..13801ad 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -130,6 +130,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); // Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper) diff --git a/src/NexusReader.Infrastructure/Persistence/UserReadingStateStore.cs b/src/NexusReader.Infrastructure/Persistence/UserReadingStateStore.cs new file mode 100644 index 0000000..8ce394b --- /dev/null +++ b/src/NexusReader.Infrastructure/Persistence/UserReadingStateStore.cs @@ -0,0 +1,56 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using NexusReader.Application.Abstractions.Persistence; +using NexusReader.Data.Persistence; + +namespace NexusReader.Infrastructure.Persistence; + +/// +/// EF Core implementation of . +/// +internal sealed class UserReadingStateStore : IUserReadingStateStore +{ + private readonly IDbContextFactory _dbContextFactory; + + public UserReadingStateStore(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + + /// + public async Task<(Guid? EbookId, string? ChapterId, string? TenantId)> GetActiveReadingStateAsync(string userId, CancellationToken cancellationToken = default) + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var userState = await dbContext.Users + .Where(u => u.Id == userId) + .Select(u => new + { + u.TenantId, + u.LastReadPageId, + LastReadBookId = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => (Guid?)e.Id).FirstOrDefault() + }) + .FirstOrDefaultAsync(cancellationToken); + + if (userState == null) + { + return (null, null, null); + } + + return (userState.LastReadBookId, userState.LastReadPageId, userState.TenantId); + } + + /// + public async Task GetChapterContentAsync(string chapterId, CancellationToken cancellationToken = default) + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + return await dbContext.KnowledgeUnits + .Where(ku => ku.Id == chapterId) + .Select(ku => ku.Content) + .FirstOrDefaultAsync(cancellationToken); + } +} diff --git a/src/NexusReader.Infrastructure/Persistence/VectorSearchStore.cs b/src/NexusReader.Infrastructure/Persistence/VectorSearchStore.cs index 2e0b2c1..0ab48d8 100644 --- a/src/NexusReader.Infrastructure/Persistence/VectorSearchStore.cs +++ b/src/NexusReader.Infrastructure/Persistence/VectorSearchStore.cs @@ -73,6 +73,25 @@ internal sealed class VectorSearchStore : IVectorSearchStore return await ExecuteSearchAsync(queryVector, filter, limit, cancellationToken); } + /// + public async Task> SearchGlobalExcludeAsync(string queryText, string tenantId, Guid excludeBookId, int limit, CancellationToken cancellationToken = default) + { + var queryVector = await GenerateEmbeddingAsync(queryText, cancellationToken); + var filter = BuildTenantFilter(tenantId); + + // Exclude current book + filter.MustNot.Add(new Qdrant.Client.Grpc.Condition + { + Field = new Qdrant.Client.Grpc.FieldCondition + { + Key = "ebookId", + Match = new Qdrant.Client.Grpc.Match { Text = excludeBookId.ToString() } + } + }); + + return await ExecuteSearchAsync(queryVector, filter, limit, cancellationToken); + } + private async Task GenerateEmbeddingAsync(string text, CancellationToken cancellationToken) { var response = await _retryPipeline.ExecuteAsync(async ct => @@ -130,8 +149,10 @@ internal sealed class VectorSearchStore : IVectorSearchStore var content = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty; var ebookId = point.Payload.TryGetValue("ebookId", out var ev) ? ev.StringValue : string.Empty; var metadataJson = point.Payload.TryGetValue("metadataJson", out var mv) ? mv.StringValue : string.Empty; + var bookTitle = point.Payload.TryGetValue("bookTitle", out var btv) ? btv.StringValue : string.Empty; + var chapterTitle = point.Payload.TryGetValue("chapterTitle", out var ctv) ? ctv.StringValue : string.Empty; - return new VectorChunk(content, ebookId, point.Score, metadataJson); + return new VectorChunk(content, ebookId, point.Score, metadataJson, bookTitle, chapterTitle); }).ToList(); } catch (Exception ex) diff --git a/src/NexusReader.Infrastructure/Queries/GetContextualRecommendationsQueryHandler.cs b/src/NexusReader.Infrastructure/Queries/GetContextualRecommendationsQueryHandler.cs new file mode 100644 index 0000000..709c3c4 --- /dev/null +++ b/src/NexusReader.Infrastructure/Queries/GetContextualRecommendationsQueryHandler.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentResults; +using MediatR; +using NexusReader.Application.Abstractions.Persistence; +using NexusReader.Application.Queries.Recommendations; + +namespace NexusReader.Infrastructure.Queries; + +/// +/// Handles by discovering the active reading state, +/// performing semantic search using IVectorSearchStore with book exclusion, and mapping upsells. +/// +public class GetContextualRecommendationsQueryHandler : IRequestHandler> +{ + private readonly IUserReadingStateStore _readingStateStore; + private readonly IUserLibraryStore _libraryStore; + private readonly IVectorSearchStore _vectorSearchStore; + + public GetContextualRecommendationsQueryHandler( + IUserReadingStateStore readingStateStore, + IUserLibraryStore libraryStore, + IVectorSearchStore vectorSearchStore) + { + _readingStateStore = readingStateStore; + _libraryStore = libraryStore; + _vectorSearchStore = vectorSearchStore; + } + + public async Task> Handle(GetContextualRecommendationsQuery request, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(request.UserId)) + { + return Result.Fail("UserId cannot be empty."); + } + + try + { + // Step 1: Discover active reading state + var (ebookId, chapterId, tenantId) = await _readingStateStore.GetActiveReadingStateAsync(request.UserId, cancellationToken); + if (ebookId == null) + { + // Fallback: brand-new user with no reading history, return empty recommendations list safely + return Result.Ok(new ContextualRecommendationResponse(new List())); + } + + // Step 2: Fetch specific content associated with active ChapterId + string? chapterContent = null; + if (!string.IsNullOrEmpty(chapterId)) + { + chapterContent = await _readingStateStore.GetChapterContentAsync(chapterId, cancellationToken); + } + + // Fallback: if no active chapter or content, try retrieving any chapter content from this book + if (string.IsNullOrEmpty(chapterContent)) + { + return Result.Ok(new ContextualRecommendationResponse(new List())); + } + + // Step 3: Perform similarity search using IVectorSearchStore + var resolvedTenantId = tenantId ?? "global"; + var searchResults = await _vectorSearchStore.SearchGlobalExcludeAsync( + chapterContent, + resolvedTenantId, + ebookId.Value, + limit: 2, + cancellationToken: cancellationToken + ); + + // Step 4: Process recommendations and cross-reference owned books + var ownedBookIds = await _libraryStore.GetOwnedBookIdsAsync(request.UserId, cancellationToken); + var recommendations = new List(); + + foreach (var point in searchResults) + { + var targetEbookIdStr = point.EbookId; + if (!Guid.TryParse(targetEbookIdStr, out var targetEbookId)) + continue; + + // Load bookTitle from point + var bookTitle = point.BookTitle; + if (string.IsNullOrEmpty(bookTitle)) + { + bookTitle = "Nieznana książka"; + } + + // Load chapterTitle from point or metadataJson + var chapterTitle = point.ChapterTitle; + if (string.IsNullOrEmpty(chapterTitle)) + { + chapterTitle = "Wiedza z rozdziału"; + if (!string.IsNullOrEmpty(point.MetadataJson)) + { + try + { + using var doc = JsonDocument.Parse(point.MetadataJson); + if (doc.RootElement.TryGetProperty("label", out var labelProp)) + { + chapterTitle = labelProp.GetString() ?? chapterTitle; + } + } + catch { } + } + } + + var isPremiumUpsell = !ownedBookIds.Contains(targetEbookId); + var matchPercentage = (int)Math.Round(point.Score * 100); + + recommendations.Add(new RecommendationDto( + BookTitle: bookTitle, + ChapterTitle: chapterTitle, + MatchPercentage: matchPercentage, + IsPremiumUpsell: isPremiumUpsell, + TargetBookId: targetEbookId + )); + } + + return Result.Ok(new ContextualRecommendationResponse(recommendations)); + } + catch (Exception ex) + { + return Result.Fail(new Error("Downstream vector database or state query failed.").CausedBy(ex)); + } + } +} diff --git a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs index 004371c..7baa8c9 100644 --- a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs +++ b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs @@ -339,6 +339,17 @@ public class KnowledgeService : IKnowledgeService { try { + // Retrieve the book's title from the database using EF Core + string bookTitle = "Nieznana książka"; + if (ebookId.HasValue) + { + var ebook = await dbContext.Ebooks.FindAsync(new object[] { ebookId.Value }, cancellationToken); + if (ebook != null) + { + bookTitle = ebook.Title; + } + } + var contents = unitsToEmbed.Select(u => u.Content).ToList(); var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct => @@ -355,6 +366,12 @@ public class KnowledgeService : IKnowledgeService var unitDto = unitsToEmbed[i]; var vector = embeddings[i].Vector.ToArray(); + string chapterTitle = "Wiedza z rozdziału"; + if (unitDto.Metadata != null && unitDto.Metadata.TryGetValue("label", out var labelVal) && labelVal is string labelStr) + { + chapterTitle = labelStr; + } + var point = new PointStruct { Id = GetDeterministicGuid(unitDto.Id), @@ -365,6 +382,8 @@ public class KnowledgeService : IKnowledgeService ["type"] = unitDto.Type ?? string.Empty, ["tenantId"] = tenantId, ["ebookId"] = ebookId?.ToString() ?? string.Empty, + ["bookTitle"] = bookTitle, + ["chapterTitle"] = chapterTitle, ["metadataJson"] = JsonSerializer.Serialize(unitDto.Metadata) } }; diff --git a/src/NexusReader.Web/Program.cs b/src/NexusReader.Web/Program.cs index e1bb6f8..e812942 100644 --- a/src/NexusReader.Web/Program.cs +++ b/src/NexusReader.Web/Program.cs @@ -437,6 +437,20 @@ app.MapPost("/api/intelligence", async ( return Results.BadRequest(errorMsg); }).RequireAuthorization(); +app.MapGet("/api/recommendations", async ( + ClaimsPrincipal user, + IMediator mediator) => +{ + var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(userId)) return Results.Unauthorized(); + + var result = await mediator.Send(new NexusReader.Application.Queries.Recommendations.GetContextualRecommendationsQuery(userId)); + if (result.IsSuccess) return Results.Ok(result.Value); + + var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Failed to fetch contextual recommendations"; + return Results.BadRequest(errorMsg); +}).RequireAuthorization(); + app.MapPost("/api/library/ingest", async ([FromBody] IngestEbookRequest request, ClaimsPrincipal user, IMediator mediator) => { var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); diff --git a/tests/NexusReader.Application.Tests/Queries/GetContextualRecommendationsQueryTests.cs b/tests/NexusReader.Application.Tests/Queries/GetContextualRecommendationsQueryTests.cs new file mode 100644 index 0000000..c30d6a1 --- /dev/null +++ b/tests/NexusReader.Application.Tests/Queries/GetContextualRecommendationsQueryTests.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NexusReader.Application.Abstractions.Persistence; +using NexusReader.Application.Queries.Recommendations; +using NexusReader.Infrastructure.Queries; +using Xunit; + +namespace NexusReader.Application.Tests.Queries; + +public class GetContextualRecommendationsQueryTests +{ + private readonly Mock _readingStateStoreMock; + private readonly Mock _libraryStoreMock; + private readonly Mock _vectorSearchStoreMock; + private readonly GetContextualRecommendationsQueryHandler _handler; + + public GetContextualRecommendationsQueryTests() + { + _readingStateStoreMock = new Mock(); + _libraryStoreMock = new Mock(); + _vectorSearchStoreMock = new Mock(); + + _handler = new GetContextualRecommendationsQueryHandler( + _readingStateStoreMock.Object, + _libraryStoreMock.Object, + _vectorSearchStoreMock.Object + ); + } + + [Fact] + public async Task Handle_WithNoActiveReadingState_ReturnsEmptyRecommendations() + { + // Arrange + var userId = "user-123"; + _readingStateStoreMock.Setup(s => s.GetActiveReadingStateAsync(userId, It.IsAny())) + .ReturnsAsync((null, null, null)); + + var query = new GetContextualRecommendationsQuery(userId); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Recommendations.Should().BeEmpty(); + } + + [Fact] + public async Task Handle_WithActiveReadingState_PerformsSimilaritySearchAndReturnsRecommendations() + { + // Arrange + var userId = "user-123"; + var activeEbookId = Guid.NewGuid(); + var activeChapterId = "chapter-1"; + var tenantId = "tenant-abc"; + var chapterContent = "Active chapter content description"; + + _readingStateStoreMock.Setup(s => s.GetActiveReadingStateAsync(userId, It.IsAny())) + .ReturnsAsync((activeEbookId, activeChapterId, tenantId)); + + _readingStateStoreMock.Setup(s => s.GetChapterContentAsync(activeChapterId, It.IsAny())) + .ReturnsAsync(chapterContent); + + // Mock vector search results using clean VectorChunk list + var targetEbookId1 = Guid.NewGuid(); + var targetEbookId2 = Guid.NewGuid(); + + var mockChunks = new List + { + new VectorChunk( + Content: "Result pattern details", + EbookId: targetEbookId1.ToString(), + Score: 0.88, + MetadataJson: "", + BookTitle: "Clean Architecture deep dive", + ChapterTitle: "Chapter 3: Result Pattern" + ), + new VectorChunk( + Content: "Performance optimizations", + EbookId: targetEbookId2.ToString(), + Score: 0.72, + MetadataJson: "", + BookTitle: "Advanced C# 14", + ChapterTitle: "Chapter 5: Span and Performance" + ) + }; + + _vectorSearchStoreMock.Setup(v => v.SearchGlobalExcludeAsync( + chapterContent, + tenantId, + activeEbookId, + 2, + It.IsAny())) + .ReturnsAsync(mockChunks); + + // User owns the second book but not the first one + _libraryStoreMock.Setup(l => l.GetOwnedBookIdsAsync(userId, It.IsAny())) + .ReturnsAsync(new List { targetEbookId2 }); + + var query = new GetContextualRecommendationsQuery(userId); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Recommendations.Should().HaveCount(2); + + var firstRec = result.Value.Recommendations.First(); + firstRec.BookTitle.Should().Be("Clean Architecture deep dive"); + firstRec.ChapterTitle.Should().Be("Chapter 3: Result Pattern"); + firstRec.MatchPercentage.Should().Be(88); + firstRec.IsPremiumUpsell.Should().BeTrue(); // User does not own book 1 + firstRec.TargetBookId.Should().Be(targetEbookId1); + + var secondRec = result.Value.Recommendations.Last(); + secondRec.BookTitle.Should().Be("Advanced C# 14"); + secondRec.ChapterTitle.Should().Be("Chapter 5: Span and Performance"); + secondRec.MatchPercentage.Should().Be(72); + secondRec.IsPremiumUpsell.Should().BeFalse(); // User owns book 2 + secondRec.TargetBookId.Should().Be(targetEbookId2); + } +}