feat(recommendations): implement contextual recommendation engine #76
@@ -0,0 +1,21 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decoupled database store to retrieve active user reading states and chapter content.
|
||||||
|
/// </summary>
|
||||||
|
public interface IUserReadingStateStore
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves the user's active reading state: last read ebook ID, last opened chapter/page ID, and tenant ID.
|
||||||
|
/// </summary>
|
||||||
|
Task<(Guid? EbookId, string? ChapterId, string? TenantId)> GetActiveReadingStateAsync(string userId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves the text content of a specific chapter/page by its ID.
|
||||||
|
/// </summary>
|
||||||
|
Task<string?> GetChapterContentAsync(string chapterId, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ namespace NexusReader.Application.Abstractions.Persistence;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents a chunk of text retrieved from the semantic vector database.
|
/// Represents a chunk of text retrieved from the semantic vector database.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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 = "");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Abstraction for performing semantic vector searches, isolating Qdrant gRPC dependencies from the Application layer.
|
/// 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.
|
/// Searches within a whitelist of owned book IDs for the best semantic matches.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<VectorChunk>> SearchLocalAsync(string queryText, string tenantId, List<Guid> whitelistedBookIds, int limit, CancellationToken cancellationToken = default);
|
Task<List<VectorChunk>> SearchLocalAsync(string queryText, string tenantId, List<Guid> whitelistedBookIds, int limit, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches the entire global catalog (filtered by tenant) for the best semantic matches, excluding a specific book ID.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<VectorChunk>> SearchGlobalExcludeAsync(string queryText, string tenantId, Guid excludeBookId, int limit, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ namespace NexusReader.Application.Common;
|
|||||||
[JsonSerializable(typeof(List<GraphLinkDto>))]
|
[JsonSerializable(typeof(List<GraphLinkDto>))]
|
||||||
[JsonSerializable(typeof(GetGlobalIntelligenceRequest))]
|
[JsonSerializable(typeof(GetGlobalIntelligenceRequest))]
|
||||||
[JsonSerializable(typeof(IntelligenceResponse))]
|
[JsonSerializable(typeof(IntelligenceResponse))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.ContextualRecommendationResponse))]
|
||||||
|
[JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.RecommendationDto))]
|
||||||
|
[JsonSerializable(typeof(List<NexusReader.Application.Queries.Recommendations.RecommendationDto>))]
|
||||||
public partial class AppJsonContext : JsonSerializerContext
|
public partial class AppJsonContext : JsonSerializerContext
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using FluentResults;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Queries.Recommendations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MediatR query to fetch contextual recommendations based on the user's active reading state.
|
||||||
|
/// </summary>
|
||||||
|
public record GetContextualRecommendationsQuery(string UserId)
|
||||||
|
: IRequest<Result<ContextualRecommendationResponse>>;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response DTO containing contextual recommendations.
|
||||||
|
/// </summary>
|
||||||
|
public record ContextualRecommendationResponse(List<RecommendationDto> Recommendations);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Individual contextual recommendation details.
|
||||||
|
/// </summary>
|
||||||
|
public record RecommendationDto(
|
||||||
|
string BookTitle,
|
||||||
|
string ChapterTitle,
|
||||||
|
int MatchPercentage,
|
||||||
|
bool IsPremiumUpsell,
|
||||||
|
Guid TargetBookId
|
||||||
|
);
|
||||||
@@ -130,6 +130,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IQuizResultRepository, QuizResultRepository>();
|
services.AddScoped<IQuizResultRepository, QuizResultRepository>();
|
||||||
services.AddScoped<IConceptsMapReadRepository, ConceptsMapReadRepository>();
|
services.AddScoped<IConceptsMapReadRepository, ConceptsMapReadRepository>();
|
||||||
services.AddScoped<IUserLibraryStore, UserLibraryStore>();
|
services.AddScoped<IUserLibraryStore, UserLibraryStore>();
|
||||||
|
services.AddScoped<IUserReadingStateStore, UserReadingStateStore>();
|
||||||
services.AddScoped<IVectorSearchStore, VectorSearchStore>();
|
services.AddScoped<IVectorSearchStore, VectorSearchStore>();
|
||||||
|
|
||||||
// Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper)
|
// Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EF Core implementation of <see cref="IUserReadingStateStore"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class UserReadingStateStore : IUserReadingStateStore
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||||
|
|
||||||
|
public UserReadingStateStore(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||||
|
{
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<string?> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,25 @@ internal sealed class VectorSearchStore : IVectorSearchStore
|
|||||||
return await ExecuteSearchAsync(queryVector, filter, limit, cancellationToken);
|
return await ExecuteSearchAsync(queryVector, filter, limit, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<List<VectorChunk>> 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<float[]> GenerateEmbeddingAsync(string text, CancellationToken cancellationToken)
|
private async Task<float[]> GenerateEmbeddingAsync(string text, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var response = await _retryPipeline.ExecuteAsync(async ct =>
|
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 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 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 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();
|
}).ToList();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles <see cref="GetContextualRecommendationsQuery"/> by discovering the active reading state,
|
||||||
|
/// performing semantic search using IVectorSearchStore with book exclusion, and mapping upsells.
|
||||||
|
/// </summary>
|
||||||
|
public class GetContextualRecommendationsQueryHandler : IRequestHandler<GetContextualRecommendationsQuery, Result<ContextualRecommendationResponse>>
|
||||||
|
{
|
||||||
|
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<Result<ContextualRecommendationResponse>> 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<RecommendationDto>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<RecommendationDto>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<RecommendationDto>();
|
||||||
|
|
||||||
|
foreach (var point in searchResults)
|
||||||
|
{
|
||||||
|
var targetEbookIdStr = point.EbookId;
|
||||||
|
if (!Guid.TryParse(targetEbookIdStr, out var targetEbookId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Load bookTitle from point
|
||||||
|
Antigravity
commented
Add registration for IVectorSearchStore in DI container (Program.cs). Add registration for IVectorSearchStore in DI container (Program.cs).
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -339,6 +339,17 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
{
|
{
|
||||||
try
|
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 contents = unitsToEmbed.Select(u => u.Content).ToList();
|
||||||
|
|
||||||
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct =>
|
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct =>
|
||||||
@@ -355,6 +366,12 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
var unitDto = unitsToEmbed[i];
|
var unitDto = unitsToEmbed[i];
|
||||||
var vector = embeddings[i].Vector.ToArray();
|
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
|
var point = new PointStruct
|
||||||
{
|
{
|
||||||
Id = GetDeterministicGuid(unitDto.Id),
|
Id = GetDeterministicGuid(unitDto.Id),
|
||||||
@@ -365,6 +382,8 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
["type"] = unitDto.Type ?? string.Empty,
|
["type"] = unitDto.Type ?? string.Empty,
|
||||||
["tenantId"] = tenantId,
|
["tenantId"] = tenantId,
|
||||||
["ebookId"] = ebookId?.ToString() ?? string.Empty,
|
["ebookId"] = ebookId?.ToString() ?? string.Empty,
|
||||||
|
["bookTitle"] = bookTitle,
|
||||||
|
["chapterTitle"] = chapterTitle,
|
||||||
["metadataJson"] = JsonSerializer.Serialize(unitDto.Metadata)
|
["metadataJson"] = JsonSerializer.Serialize(unitDto.Metadata)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -437,6 +437,20 @@ app.MapPost("/api/intelligence", async (
|
|||||||
return Results.BadRequest(errorMsg);
|
return Results.BadRequest(errorMsg);
|
||||||
}).RequireAuthorization();
|
}).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) =>
|
app.MapPost("/api/library/ingest", async ([FromBody] IngestEbookRequest request, ClaimsPrincipal user, IMediator mediator) =>
|
||||||
{
|
{
|
||||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
|||||||
+128
@@ -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<IUserReadingStateStore> _readingStateStoreMock;
|
||||||
|
private readonly Mock<IUserLibraryStore> _libraryStoreMock;
|
||||||
|
private readonly Mock<IVectorSearchStore> _vectorSearchStoreMock;
|
||||||
|
private readonly GetContextualRecommendationsQueryHandler _handler;
|
||||||
|
|
||||||
|
public GetContextualRecommendationsQueryTests()
|
||||||
|
{
|
||||||
|
_readingStateStoreMock = new Mock<IUserReadingStateStore>();
|
||||||
|
_libraryStoreMock = new Mock<IUserLibraryStore>();
|
||||||
|
_vectorSearchStoreMock = new Mock<IVectorSearchStore>();
|
||||||
|
|
||||||
|
_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<CancellationToken>()))
|
||||||
|
.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<CancellationToken>()))
|
||||||
|
.ReturnsAsync((activeEbookId, activeChapterId, tenantId));
|
||||||
|
|
||||||
|
_readingStateStoreMock.Setup(s => s.GetChapterContentAsync(activeChapterId, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(chapterContent);
|
||||||
|
|
||||||
|
// Mock vector search results using clean VectorChunk list
|
||||||
|
var targetEbookId1 = Guid.NewGuid();
|
||||||
|
var targetEbookId2 = Guid.NewGuid();
|
||||||
|
|
||||||
|
var mockChunks = new List<VectorChunk>
|
||||||
|
{
|
||||||
|
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<CancellationToken>()))
|
||||||
|
.ReturnsAsync(mockChunks);
|
||||||
|
|
||||||
|
// User owns the second book but not the first one
|
||||||
|
_libraryStoreMock.Setup(l => l.GetOwnedBookIdsAsync(userId, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Guid> { 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user
Consider injecting IHttpClientFactory if future HttpClient usage is added; currently no HttpClient present.