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 Microsoft.Extensions.Logging;
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 with book exclusion, and mapping upsells.
///
public class GetContextualRecommendationsQueryHandler : IRequestHandler>
{
private readonly IUserReadingStateStore _readingStateStore;
private readonly IUserLibraryStore _libraryStore;
private readonly IVectorSearchStore _vectorSearchStore;
private readonly ILogger _logger;
///
/// Initializes a new instance of .
///
public GetContextualRecommendationsQueryHandler(
IUserReadingStateStore readingStateStore,
IUserLibraryStore libraryStore,
IVectorSearchStore vectorSearchStore,
ILogger logger)
{
_readingStateStore = readingStateStore;
_libraryStore = libraryStore;
_vectorSearchStore = vectorSearchStore;
_logger = logger;
}
///
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)
{
_logger.LogInformation("[Recommendations] No active reading state for user {UserId}. Returning empty list.", request.UserId);
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);
}
// Guard: empty chapter content cannot produce a meaningful embedding
if (string.IsNullOrWhiteSpace(chapterContent))
{
_logger.LogWarning("[Recommendations] Chapter content is empty for chapterId={ChapterId}. Returning empty list.", chapterId);
return Result.Ok(new ContextualRecommendationResponse(new List()));
}
// Step 3: Perform similarity search using IVectorSearchStore
var resolvedTenantId = tenantId ?? "global";
_logger.LogDebug("[Recommendations] Performing vector search for user {UserId}, book {EbookId}, tenant {TenantId}.", request.UserId, ebookId, resolvedTenantId);
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 (JsonException jsonEx)
{
_logger.LogWarning(jsonEx, "[Recommendations] Failed to parse metadataJson for chunk with ebookId={EbookId}.", targetEbookIdStr);
}
}
}
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
));
}
_logger.LogInformation("[Recommendations] Returning {Count} recommendations for user {UserId}.", recommendations.Count, request.UserId);
return Result.Ok(new ContextualRecommendationResponse(recommendations));
}
catch (Exception ex)
{
_logger.LogError(ex, "[Recommendations] Downstream vector database or state query failed for user {UserId}.", request.UserId);
return Result.Fail(new Error("Downstream vector database or state query failed.").CausedBy(ex));
}
}
}