fix: resolve PR #76 review recommendations
- Add ILogger<GetContextualRecommendationsQueryHandler> with structured logging - Guard empty embedding text in VectorSearchStore (return empty vector, skip search) - Benchmark vector search and embedding latency with Stopwatch (LogDebug/LogInfo) - Refine EnsureCollectionExistsAsync: log creation events and non-fatal errors - Replace all Console.WriteLine with ILogger in UI components (AiAssistantBubble, AiResponseRenderer, IntelligenceToolbar, SelectionAiPanel, Catalog, Intelligence, MyBooks) - Create IRecommendationService abstraction + RecommendationService WASM implementation - Register IRecommendationService in Web.Client DI - Add ContextualRecommendationsWidget component with loading spinner and design tokens - Add ContextualRecommendationsWidget to Dashboard.razor - Update test constructor with ILogger mock for GetContextualRecommendationsQueryHandler Closes review items: 2, 3, 4, 5, 6, 7, 8, 9, 10 Item 1 (unit tests) was already completed in previous session
This commit is contained in:
@@ -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<float[]> 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<float>();
|
||||
}
|
||||
|
||||
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<List<VectorChunk>> 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<VectorChunk>();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="GetContextualRecommendationsQuery"/> by discovering the active reading state,
|
||||
/// performing semantic search using IVectorSearchStore with book exclusion, and mapping upsells.
|
||||
/// performing semantic search using <see cref="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;
|
||||
private readonly ILogger<GetContextualRecommendationsQueryHandler> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="GetContextualRecommendationsQueryHandler"/>.
|
||||
/// </summary>
|
||||
public GetContextualRecommendationsQueryHandler(
|
||||
IUserReadingStateStore readingStateStore,
|
||||
IUserLibraryStore libraryStore,
|
||||
IVectorSearchStore vectorSearchStore)
|
||||
IVectorSearchStore vectorSearchStore,
|
||||
ILogger<GetContextualRecommendationsQueryHandler> logger)
|
||||
{
|
||||
_readingStateStore = readingStateStore;
|
||||
_libraryStore = libraryStore;
|
||||
_vectorSearchStore = vectorSearchStore;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<ContextualRecommendationResponse>> Handle(GetContextualRecommendationsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.UserId))
|
||||
@@ -44,7 +52,7 @@ public class GetContextualRecommendationsQueryHandler : IRequestHandler<GetConte
|
||||
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
|
||||
_logger.LogInformation("[Recommendations] No active reading state for user {UserId}. Returning empty list.", request.UserId);
|
||||
return Result.Ok(new ContextualRecommendationResponse(new List<RecommendationDto>()));
|
||||
}
|
||||
|
||||
@@ -55,14 +63,17 @@ public class GetContextualRecommendationsQueryHandler : IRequestHandler<GetConte
|
||||
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))
|
||||
// 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<RecommendationDto>()));
|
||||
}
|
||||
|
||||
// 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<GetConte
|
||||
chapterTitle = labelProp.GetString() ?? chapterTitle;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
catch (JsonException jsonEx)
|
||||
{
|
||||
_logger.LogWarning(jsonEx, "[Recommendations] Failed to parse metadataJson for chunk with ebookId={EbookId}.", targetEbookIdStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,10 +133,12 @@ public class GetContextualRecommendationsQueryHandler : IRequestHandler<GetConte
|
||||
));
|
||||
}
|
||||
|
||||
_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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using NexusReader.Application.DTOs.AI
|
||||
@using Microsoft.Extensions.Logging
|
||||
@inject IQuizStateService QuizState
|
||||
@inject KnowledgeCoordinator Coordinator
|
||||
@inject ILogger<AiAssistantBubble> Logger
|
||||
@implements IDisposable
|
||||
|
||||
<div class="ai-bubble-container">
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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<AiResponseRenderer> Logger
|
||||
|
||||
<div class="message-row @(Message.Sender == "User" ? "user-row" : "ai-row")">
|
||||
<div class="message-avatar" aria-hidden="true">
|
||||
@@ -200,12 +202,12 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("[AiResponseRenderer] Purchase failed on server.");
|
||||
Logger.LogWarning("[AiResponseRenderer] Purchase failed on server for book {BookId}.", _lockedBookId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[AiResponseRenderer] Error processing purchase: {ex.Message}");
|
||||
Logger.LogError(ex, "[AiResponseRenderer] Error processing purchase for book {BookId}.", _lockedBookId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using NexusReader.Application.Abstractions.Services
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using System.Linq
|
||||
@inject IFocusModeService FocusMode
|
||||
@inject IIdentityService IdentityService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IThemeService ThemeService
|
||||
@inject IKnowledgeService KnowledgeService
|
||||
@inject ILogger<IntelligenceToolbar> Logger
|
||||
@implements IDisposable
|
||||
|
||||
<aside class="intelligence-toolbar">
|
||||
@@ -51,11 +54,15 @@
|
||||
|
||||
private async Task HandleClearCache()
|
||||
{
|
||||
Console.WriteLine("[IntelligenceToolbar] Requesting cache clear...");
|
||||
Logger.LogInformation("[IntelligenceToolbar] Requesting cache clear...");
|
||||
var result = await KnowledgeService.ClearCacheAsync();
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
Console.WriteLine("[IntelligenceToolbar] Cache cleared successfully!");
|
||||
Logger.LogInformation("[IntelligenceToolbar] Cache cleared successfully.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("[IntelligenceToolbar] Cache clear failed: {Errors}", string.Join("; ", result.Errors.Select(e => e.Message)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using NexusReader.UI.Shared.Models
|
||||
@using NexusReader.Application.DTOs.AI
|
||||
@using Microsoft.Extensions.Logging
|
||||
@inject KnowledgeCoordinator Coordinator
|
||||
@inject IReaderInteractionService InteractionService
|
||||
@inject IQuizStateService QuizService
|
||||
@inject IJSRuntime JS
|
||||
@inject ILogger<SelectionAiPanel> Logger
|
||||
|
||||
@if (IsVisible)
|
||||
{
|
||||
@@ -64,7 +66,7 @@
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
Console.WriteLine($"[SelectionAiPanel] Parameters set. SelectedText: {SelectedText.Length} chars, Coordinates: {Coordinates?.Top}");
|
||||
Logger.LogDebug("[SelectionAiPanel] Parameters set. SelectedText: {Length} chars, Coordinates: {Top}", SelectedText.Length, Coordinates?.Top);
|
||||
|
||||
if (Coordinates != _lastCoordinates)
|
||||
{
|
||||
@@ -100,7 +102,7 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[SelectionAiPanel] Error positioning toolbar: {ex.Message}");
|
||||
Logger.LogWarning(ex, "[SelectionAiPanel] Error positioning toolbar.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,7 +135,7 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[SelectionAiPanel] Error requesting summary: {ex.Message}");
|
||||
Logger.LogError(ex, "[SelectionAiPanel] Error requesting summary for block {BlockId}.", BlockId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -173,7 +175,7 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[SelectionAiPanel] Error generating quiz: {ex.Message}");
|
||||
Logger.LogError(ex, "[SelectionAiPanel] Error generating quiz for block {BlockId}.", BlockId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
@using NexusReader.UI.Shared.Components.Atoms
|
||||
@using Microsoft.Extensions.Logging
|
||||
@inject IRecommendationService RecommendationService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<ContextualRecommendationsWidget> Logger
|
||||
|
||||
<section class="recommendations-panel glass-panel" aria-label="Kontekstowe rekomendacje">
|
||||
<div class="panel-header">
|
||||
<div class="header-left">
|
||||
<NexusIcon Name="sparkles" Size="18" />
|
||||
<h4>Odkryj Więcej</h4>
|
||||
</div>
|
||||
<span class="panel-badge">AI</span>
|
||||
</div>
|
||||
|
||||
@if (_isLoading)
|
||||
{
|
||||
<div class="loading-state" role="status" aria-label="Ładowanie rekomendacji">
|
||||
<div class="spinner-ring">
|
||||
<div class="spinner-track"></div>
|
||||
<div class="spinner-head"></div>
|
||||
</div>
|
||||
<span class="loading-label">Analizowanie kontekstu lektury…</span>
|
||||
</div>
|
||||
}
|
||||
else if (_hasError)
|
||||
{
|
||||
<div class="empty-state">
|
||||
<NexusIcon Name="alert-circle" Size="32" />
|
||||
<p>Nie udało się załadować rekomendacji.</p>
|
||||
</div>
|
||||
}
|
||||
else if (_recommendations is null || _recommendations.Count == 0)
|
||||
{
|
||||
<div class="empty-state">
|
||||
<NexusIcon Name="book-open" Size="32" />
|
||||
<p>Zacznij czytać, aby odkryć powiązane tytuły.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="recommendations-list" role="list">
|
||||
@foreach (var rec in _recommendations)
|
||||
{
|
||||
<li class="recommendation-item @(rec.IsPremiumUpsell ? "premium" : "owned")"
|
||||
role="listitem">
|
||||
<div class="rec-content">
|
||||
<div class="rec-meta">
|
||||
<span class="match-badge" title="Dopasowanie semantyczne @rec.MatchPercentage%">
|
||||
@rec.MatchPercentage<span class="match-unit">%</span>
|
||||
</span>
|
||||
@if (rec.IsPremiumUpsell)
|
||||
{
|
||||
<span class="upsell-tag" aria-label="Książka premium">Premium</span>
|
||||
}
|
||||
</div>
|
||||
<p class="rec-book-title">@rec.BookTitle</p>
|
||||
<p class="rec-chapter-title">@rec.ChapterTitle</p>
|
||||
</div>
|
||||
<button class="rec-action-btn"
|
||||
@onclick="() => HandleRecommendationClick(rec)"
|
||||
aria-label="@(rec.IsPremiumUpsell ? "Kup " + rec.BookTitle : "Przejdź do " + rec.BookTitle)">
|
||||
<NexusIcon Name="@(rec.IsPremiumUpsell ? "shopping-cart" : "arrow-right")" Size="16" />
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
|
||||
@code {
|
||||
private List<RecommendationDto>? _recommendations;
|
||||
private bool _isLoading = true;
|
||||
private bool _hasError;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadRecommendationsAsync();
|
||||
}
|
||||
|
||||
private async Task LoadRecommendationsAsync()
|
||||
{
|
||||
_isLoading = true;
|
||||
_hasError = false;
|
||||
|
||||
try
|
||||
{
|
||||
_recommendations = await RecommendationService.GetRecommendationsAsync();
|
||||
if (_recommendations is null)
|
||||
{
|
||||
_hasError = true;
|
||||
Logger.LogWarning("[ContextualRecommendationsWidget] RecommendationService returned null; displaying error state.");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleRecommendationClick(RecommendationDto rec)
|
||||
{
|
||||
if (rec.IsPremiumUpsell)
|
||||
{
|
||||
NavigationManager.NavigateTo($"/catalog?highlight={rec.TargetBookId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
NavigationManager.NavigateTo($"/reader/{rec.TargetBookId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
/* ContextualRecommendationsWidget.razor.css
|
||||
Uses Nexus Design System tokens (--nexus-*) for consistency.
|
||||
*/
|
||||
|
||||
.recommendations-panel {
|
||||
width: 100%;
|
||||
padding: 1.75rem;
|
||||
background: var(--nexus-surface, #1a1a1e);
|
||||
border: 1px solid var(--nexus-border, rgba(255, 255, 255, 0.05));
|
||||
border-radius: 12px;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.recommendations-panel:hover {
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* ── Panel Header ── */
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--nexus-accent, #10b981);
|
||||
}
|
||||
|
||||
.header-left h4 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--nexus-text-primary, #ffffff);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.panel-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 100px;
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(59, 130, 246, 0.1));
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
color: var(--nexus-accent, #10b981);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ── Loading State ── */
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.spinner-ring {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.spinner-track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.spinner-head {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 3px solid transparent;
|
||||
border-top-color: var(--nexus-accent, #10b981);
|
||||
animation: nexus-spin 0.8s linear infinite;
|
||||
box-shadow: 0 0 12px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
@keyframes nexus-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--nexus-text-secondary, #a1a1aa);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Empty / Error State ── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--nexus-text-secondary, #a1a1aa);
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* ── Recommendations List ── */
|
||||
.recommendations-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.recommendation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.recommendation-item:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.recommendation-item.premium {
|
||||
border-color: rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
.recommendation-item.premium:hover {
|
||||
border-color: rgba(245, 158, 11, 0.4);
|
||||
background: rgba(245, 158, 11, 0.04);
|
||||
}
|
||||
|
||||
.recommendation-item.owned {
|
||||
border-color: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.recommendation-item.owned:hover {
|
||||
border-color: rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
|
||||
/* ── Rec Content ── */
|
||||
.rec-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.rec-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.match-badge {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--nexus-accent, #10b981);
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border-radius: 4px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
}
|
||||
|
||||
.match-unit {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.upsell-tag {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
border-radius: 4px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rec-book-title {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--nexus-text-primary, #ffffff);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rec-chapter-title {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--nexus-text-secondary, #a1a1aa);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Action Button ── */
|
||||
.rec-action-btn {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: transparent;
|
||||
color: var(--nexus-text-secondary, #a1a1aa);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.rec-action-btn:hover {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
color: var(--nexus-accent, #10b981);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.premium .rec-action-btn:hover {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.recommendations-panel {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,13 @@
|
||||
@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 NavigationManager NavigationManager
|
||||
@inject ILibraryStateService LibraryStateService
|
||||
@inject ILogger<Catalog> Logger
|
||||
|
||||
<div class="catalog-page">
|
||||
<header class="catalog-header">
|
||||
@@ -221,7 +223,7 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Catalog] Failed to load books: {ex.Message}");
|
||||
Logger.LogError(ex, "[Catalog] Failed to load books.");
|
||||
if (OperatingSystem.IsBrowser())
|
||||
{
|
||||
_isLoading = false;
|
||||
|
||||
@@ -140,6 +140,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contextual AI Recommendations -->
|
||||
<ContextualRecommendationsWidget />
|
||||
|
||||
<!-- Detailed Content Block Showcase -->
|
||||
<section class="architecture-guide-panel glass-panel">
|
||||
<div class="panel-header">
|
||||
|
||||
@@ -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<Intelligence> Logger
|
||||
|
||||
<div class="intelligence-page">
|
||||
<div class="intelligence-layout">
|
||||
@@ -130,7 +132,7 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Intelligence] Failed to load books: {ex.Message}");
|
||||
Logger.LogError(ex, "[Intelligence] Failed to load books.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<MyBooks> Logger
|
||||
|
||||
<div class="my-books-page">
|
||||
<header class="my-books-header">
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Provides contextual book recommendations based on the user's active reading state.
|
||||
/// Abstracts the HTTP transport layer from Blazor UI components.
|
||||
/// </summary>
|
||||
public interface IRecommendationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches contextual recommendations for the authenticated user.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns>
|
||||
/// A list of <see cref="RecommendationDto"/> on success, or an empty list when none are available.
|
||||
/// Returns <c>null</c> if the request fails due to a transport or server error.
|
||||
/// </returns>
|
||||
Task<List<RecommendationDto>?> GetRecommendationsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -20,3 +20,4 @@
|
||||
@using NexusReader.Application.Abstractions.Services
|
||||
@using NexusReader.Application.DTOs.User
|
||||
@using NexusReader.Application.Queries.Reader
|
||||
@using NexusReader.Application.Queries.Recommendations
|
||||
|
||||
@@ -42,6 +42,7 @@ builder.Services.AddCascadingAuthenticationState();
|
||||
// AI & Content Services
|
||||
builder.Services.AddScoped<IKnowledgeService, WasmKnowledgeService>();
|
||||
builder.Services.AddScoped<IConceptsMapService, WasmConceptsMapService>();
|
||||
builder.Services.AddScoped<IRecommendationService, RecommendationService>();
|
||||
|
||||
builder.Services.AddTransient<NexusReader.Web.Client.Handlers.AuthenticationHeaderHandler>();
|
||||
builder.Services.AddHttpClient("NexusAPI", client =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// WASM implementation of <see cref="IRecommendationService"/> that fetches contextual recommendations
|
||||
/// from the <c>/api/recommendations</c> server endpoint via a named <c>NexusAPI</c> HTTP client.
|
||||
/// </summary>
|
||||
internal sealed class RecommendationService : IRecommendationService
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<RecommendationService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="RecommendationService"/>.
|
||||
/// </summary>
|
||||
public RecommendationService(HttpClient http, ILogger<RecommendationService> logger)
|
||||
{
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<RecommendationDto>?> 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<RecommendationDto>();
|
||||
}
|
||||
|
||||
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<RecommendationDto>();
|
||||
}
|
||||
|
||||
_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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -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<IUserReadingStateStore> _readingStateStoreMock;
|
||||
private readonly Mock<IUserLibraryStore> _libraryStoreMock;
|
||||
private readonly Mock<IVectorSearchStore> _vectorSearchStoreMock;
|
||||
private readonly Mock<ILogger<GetContextualRecommendationsQueryHandler>> _loggerMock;
|
||||
private readonly GetContextualRecommendationsQueryHandler _handler;
|
||||
|
||||
public GetContextualRecommendationsQueryTests()
|
||||
@@ -24,11 +26,13 @@ public class GetContextualRecommendationsQueryTests
|
||||
_readingStateStoreMock = new Mock<IUserReadingStateStore>();
|
||||
_libraryStoreMock = new Mock<IUserLibraryStore>();
|
||||
_vectorSearchStoreMock = new Mock<IVectorSearchStore>();
|
||||
_loggerMock = new Mock<ILogger<GetContextualRecommendationsQueryHandler>>();
|
||||
|
||||
_handler = new GetContextualRecommendationsQueryHandler(
|
||||
_readingStateStoreMock.Object,
|
||||
_libraryStoreMock.Object,
|
||||
_vectorSearchStoreMock.Object
|
||||
_vectorSearchStoreMock.Object,
|
||||
_loggerMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user