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);
+ }
+}