diff --git a/src/NexusReader.Infrastructure/Persistence/VectorSearchStore.cs b/src/NexusReader.Infrastructure/Persistence/VectorSearchStore.cs index 0ab48d8..857e298 100644 --- a/src/NexusReader.Infrastructure/Persistence/VectorSearchStore.cs +++ b/src/NexusReader.Infrastructure/Persistence/VectorSearchStore.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -94,12 +95,21 @@ internal sealed class VectorSearchStore : IVectorSearchStore private async Task GenerateEmbeddingAsync(string text, CancellationToken cancellationToken) { + if (string.IsNullOrWhiteSpace(text)) + { + _logger.LogWarning("[VectorSearchStore] Attempted to generate embedding from empty text. Returning zero vector."); + return Array.Empty(); + } + + var sw = Stopwatch.StartNew(); var response = await _retryPipeline.ExecuteAsync(async ct => await _embeddingGenerator.GenerateAsync( new[] { text }, new EmbeddingGenerationOptions { Dimensions = 768 }, cancellationToken: ct), cancellationToken); + sw.Stop(); + _logger.LogDebug("[VectorSearchStore] Embedding generated in {ElapsedMs}ms for text of {Length} chars.", sw.ElapsedMilliseconds, text.Length); return response.First().Vector.ToArray(); } @@ -132,10 +142,17 @@ internal sealed class VectorSearchStore : IVectorSearchStore private async Task> ExecuteSearchAsync(float[] queryVector, Qdrant.Client.Grpc.Filter filter, int limit, CancellationToken cancellationToken) { + if (queryVector.Length == 0) + { + _logger.LogWarning("[VectorSearchStore] Empty query vector — skipping Qdrant search."); + return new List(); + } + try { await EnsureCollectionExistsAsync("knowledge_units", cancellationToken); + var sw = Stopwatch.StartNew(); var response = await _qdrantClient.SearchAsync( collectionName: "knowledge_units", vector: queryVector, @@ -143,6 +160,8 @@ internal sealed class VectorSearchStore : IVectorSearchStore limit: (ulong)limit, cancellationToken: cancellationToken ); + sw.Stop(); + _logger.LogInformation("[VectorSearchStore] Qdrant search returned {Count} results in {ElapsedMs}ms.", response.Count, sw.ElapsedMilliseconds); return response.Select(point => { @@ -169,6 +188,7 @@ internal sealed class VectorSearchStore : IVectorSearchStore var exists = await _qdrantClient.CollectionExistsAsync(collectionName, cancellationToken); if (!exists) { + _logger.LogInformation("[VectorSearchStore] Collection '{CollectionName}' does not exist — creating.", collectionName); await _qdrantClient.CreateCollectionAsync( collectionName: collectionName, vectorsConfig: new Qdrant.Client.Grpc.VectorParams @@ -178,11 +198,13 @@ internal sealed class VectorSearchStore : IVectorSearchStore }, cancellationToken: cancellationToken ); + _logger.LogInformation("[VectorSearchStore] Collection '{CollectionName}' created successfully.", collectionName); } } - catch (Exception) + catch (Exception ex) { - // Ignore concurrent creation conflicts in multi-threaded/concurrent flows + // Log concurrent creation conflicts (e.g., AlreadyExists gRPC status) but do not propagate. + _logger.LogWarning(ex, "[VectorSearchStore] Non-fatal error while ensuring collection '{CollectionName}' exists. Possible concurrent creation.", collectionName); } } } diff --git a/src/NexusReader.Infrastructure/Queries/GetContextualRecommendationsQueryHandler.cs b/src/NexusReader.Infrastructure/Queries/GetContextualRecommendationsQueryHandler.cs index 709c3c4..2f8a33a 100644 --- a/src/NexusReader.Infrastructure/Queries/GetContextualRecommendationsQueryHandler.cs +++ b/src/NexusReader.Infrastructure/Queries/GetContextualRecommendationsQueryHandler.cs @@ -6,6 +6,7 @@ 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; @@ -13,24 +14,31 @@ namespace NexusReader.Infrastructure.Queries; /// /// Handles by discovering the active reading state, -/// performing semantic search using IVectorSearchStore with book exclusion, and mapping upsells. +/// 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) + 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)) @@ -44,7 +52,7 @@ public class GetContextualRecommendationsQueryHandler : IRequestHandler())); } @@ -55,14 +63,17 @@ public class GetContextualRecommendationsQueryHandler : IRequestHandler())); } // 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, @@ -103,7 +114,10 @@ public class GetContextualRecommendationsQueryHandler : IRequestHandler Logger @implements IDisposable
@@ -134,7 +136,7 @@ catch (Exception ex) { _displayedText = string.IsNullOrEmpty(Dialogue) ? "Błąd analizy." : Dialogue; - Console.WriteLine($"[AiAssistantBubble] Error fetching summary: {ex.Message}"); + Logger.LogError(ex, "[AiAssistantBubble] Error fetching summary for block {BlockId}.", ContextBlockId); } finally { diff --git a/src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor b/src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor index d971f2f..b5d2251 100644 --- a/src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor +++ b/src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor @@ -2,10 +2,12 @@ @using NexusReader.UI.Shared.Services @using NexusReader.Application.DTOs.AI @using NexusReader.Application.DTOs.User +@using Microsoft.Extensions.Logging @using System.Net.Http.Json @inject HttpClient Http @inject ILibraryStateService LibraryStateService @inject NavigationManager NavigationManager +@inject ILogger Logger
+ + +
diff --git a/src/NexusReader.UI.Shared/Pages/Intelligence.razor b/src/NexusReader.UI.Shared/Pages/Intelligence.razor index c637ea3..41c03e2 100644 --- a/src/NexusReader.UI.Shared/Pages/Intelligence.razor +++ b/src/NexusReader.UI.Shared/Pages/Intelligence.razor @@ -7,11 +7,13 @@ @using NexusReader.UI.Shared.Components.Molecules @using NexusReader.UI.Shared.Components.Atoms @using NexusReader.UI.Shared.Models +@using Microsoft.Extensions.Logging @using System.Net.Http.Json @inject HttpClient Http @inject IKnowledgeService KnowledgeService @inject AuthenticationStateProvider AuthStateProvider @inject ILibraryStateService LibraryStateService +@inject ILogger Logger
@@ -130,7 +132,7 @@ } catch (Exception ex) { - Console.WriteLine($"[Intelligence] Failed to load books: {ex.Message}"); + Logger.LogError(ex, "[Intelligence] Failed to load books."); } } diff --git a/src/NexusReader.UI.Shared/Pages/MyBooks.razor b/src/NexusReader.UI.Shared/Pages/MyBooks.razor index 99fe8ae..d655e8c 100644 --- a/src/NexusReader.UI.Shared/Pages/MyBooks.razor +++ b/src/NexusReader.UI.Shared/Pages/MyBooks.razor @@ -4,10 +4,12 @@ @using NexusReader.UI.Shared.Components.Organisms @using NexusReader.Application.DTOs.User @using NexusReader.UI.Shared.Services +@using Microsoft.Extensions.Logging @using System.Net.Http.Json @inject HttpClient Http @inject IReaderNavigationService ReaderNavigation @inject ILibraryStateService LibraryStateService +@inject ILogger Logger
@@ -140,7 +142,7 @@ } catch (Exception ex) { - Console.WriteLine($"[MyBooks] Failed to load books: {ex.Message}"); + Logger.LogError(ex, "[MyBooks] Failed to load books."); if (OperatingSystem.IsBrowser()) { _isLoading = false; diff --git a/src/NexusReader.UI.Shared/Services/IRecommendationService.cs b/src/NexusReader.UI.Shared/Services/IRecommendationService.cs new file mode 100644 index 0000000..28637b8 --- /dev/null +++ b/src/NexusReader.UI.Shared/Services/IRecommendationService.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using NexusReader.Application.Queries.Recommendations; + +namespace NexusReader.UI.Shared.Services; + +/// +/// Provides contextual book recommendations based on the user's active reading state. +/// Abstracts the HTTP transport layer from Blazor UI components. +/// +public interface IRecommendationService +{ + /// + /// Fetches contextual recommendations for the authenticated user. + /// + /// A token to cancel the operation. + /// + /// A list of on success, or an empty list when none are available. + /// Returns null if the request fails due to a transport or server error. + /// + Task?> GetRecommendationsAsync(CancellationToken cancellationToken = default); +} diff --git a/src/NexusReader.UI.Shared/_Imports.razor b/src/NexusReader.UI.Shared/_Imports.razor index 3624a48..7f509a6 100644 --- a/src/NexusReader.UI.Shared/_Imports.razor +++ b/src/NexusReader.UI.Shared/_Imports.razor @@ -20,3 +20,4 @@ @using NexusReader.Application.Abstractions.Services @using NexusReader.Application.DTOs.User @using NexusReader.Application.Queries.Reader +@using NexusReader.Application.Queries.Recommendations diff --git a/src/NexusReader.Web.Client/Program.cs b/src/NexusReader.Web.Client/Program.cs index b8d02cb..dec9980 100644 --- a/src/NexusReader.Web.Client/Program.cs +++ b/src/NexusReader.Web.Client/Program.cs @@ -42,6 +42,7 @@ builder.Services.AddCascadingAuthenticationState(); // AI & Content Services builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddTransient(); builder.Services.AddHttpClient("NexusAPI", client => diff --git a/src/NexusReader.Web.Client/Services/RecommendationService.cs b/src/NexusReader.Web.Client/Services/RecommendationService.cs new file mode 100644 index 0000000..1ebe54f --- /dev/null +++ b/src/NexusReader.Web.Client/Services/RecommendationService.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NexusReader.Application.Common; +using NexusReader.Application.Queries.Recommendations; +using NexusReader.UI.Shared.Services; + +namespace NexusReader.Web.Client.Services; + +/// +/// WASM implementation of that fetches contextual recommendations +/// from the /api/recommendations server endpoint via a named NexusAPI HTTP client. +/// +internal sealed class RecommendationService : IRecommendationService +{ + private readonly HttpClient _http; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of . + /// + public RecommendationService(HttpClient http, ILogger logger) + { + _http = http; + _logger = logger; + } + + /// + public async Task?> GetRecommendationsAsync(CancellationToken cancellationToken = default) + { + try + { + var response = await _http.GetAsync("/api/recommendations", cancellationToken); + + if (response.StatusCode == HttpStatusCode.NoContent || response.StatusCode == HttpStatusCode.NotFound) + { + _logger.LogInformation("[RecommendationService] No recommendations available (status {StatusCode}).", response.StatusCode); + return new List(); + } + + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync( + AppJsonContext.Default.ContextualRecommendationResponse, + cancellationToken); + + if (result is null) + { + _logger.LogWarning("[RecommendationService] Deserialised response was null."); + return new List(); + } + + _logger.LogInformation("[RecommendationService] Received {Count} recommendations.", result.Recommendations.Count); + return result.Recommendations; + } + catch (HttpRequestException httpEx) + { + _logger.LogError(httpEx, "[RecommendationService] HTTP error fetching recommendations: {Message}", httpEx.Message); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "[RecommendationService] Unexpected error fetching recommendations."); + return null; + } + } +} diff --git a/tests/NexusReader.Application.Tests/Queries/GetContextualRecommendationsQueryTests.cs b/tests/NexusReader.Application.Tests/Queries/GetContextualRecommendationsQueryTests.cs index c30d6a1..753412d 100644 --- a/tests/NexusReader.Application.Tests/Queries/GetContextualRecommendationsQueryTests.cs +++ b/tests/NexusReader.Application.Tests/Queries/GetContextualRecommendationsQueryTests.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using FluentAssertions; +using Microsoft.Extensions.Logging; using Moq; using NexusReader.Application.Abstractions.Persistence; using NexusReader.Application.Queries.Recommendations; @@ -17,6 +18,7 @@ public class GetContextualRecommendationsQueryTests private readonly Mock _readingStateStoreMock; private readonly Mock _libraryStoreMock; private readonly Mock _vectorSearchStoreMock; + private readonly Mock> _loggerMock; private readonly GetContextualRecommendationsQueryHandler _handler; public GetContextualRecommendationsQueryTests() @@ -24,11 +26,13 @@ public class GetContextualRecommendationsQueryTests _readingStateStoreMock = new Mock(); _libraryStoreMock = new Mock(); _vectorSearchStoreMock = new Mock(); + _loggerMock = new Mock>(); _handler = new GetContextualRecommendationsQueryHandler( _readingStateStoreMock.Object, _libraryStoreMock.Object, - _vectorSearchStoreMock.Object + _vectorSearchStoreMock.Object, + _loggerMock.Object ); }