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