From 1d6862016dbf6a592a6178cbead1a6a9f58c8a3b Mon Sep 17 00:00:00 2001 From: Antigravity Date: Sat, 6 Jun 2026 13:38:48 +0000 Subject: [PATCH] feat(recommendations): implement contextual recommendation engine (#76) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #75 ### Description This pull request implements a smart, Native AOT-compliant contextual recommendation engine for the desktop dashboard to drive user retention and cross-book monetization. ### Key Changes 1. **Application Layer**: - Declared `IUserReadingStateStore` interface to decouple reading state discovery. - Added `IVectorSearchStore.SearchGlobalExcludeAsync(...)` to abstract semantic similarity searches with exclusions. - Defined `GetContextualRecommendationsQuery` and response DTOs (`ContextualRecommendationResponse`, `RecommendationDto`). 2. **Infrastructure Layer**: - Implemented `UserReadingStateStore` using EF Core with DbContext pooling. - Implemented `SearchGlobalExcludeAsync` in `VectorSearchStore` to construct gRPC Qdrant filters (excluding the active book ID) and fetch `bookTitle` and `chapterTitle` from point payloads. - Implemented `GetContextualRecommendationsQueryHandler` using clean abstractions. 3. **Web & Serialization Layer**: - Mapped `GET /api/recommendations` endpoint. - Registered types in `AppJsonContext.cs` for AOT-compliant JSON serialization. 4. **Verification**: - Added complete unit test coverage in `GetContextualRecommendationsQueryTests.cs`. All 30 unit tests pass. --------- Co-authored-by: Marek Jasiński Reviewed-on: https://git.archimap.cloud/mjasin/Nexus.Reader/pulls/76 Co-authored-by: Antigravity Co-committed-by: Antigravity --- .../Persistence/IUserLibraryStore.cs | 23 ++ .../Persistence/IUserReadingStateStore.cs | 21 + .../Persistence/IVectorSearchStore.cs | 32 ++ .../Services/IKnowledgeService.cs | 2 + .../Common/AppJsonContext.cs | 7 + .../Common/RagMonetizationOptions.cs | 28 ++ .../GetGlobalIntelligenceQuery.cs | 222 +++++++++++ .../GetContextualRecommendationsQuery.cs | 28 ++ .../DependencyInjection.cs | 5 + .../Persistence/UserLibraryStore.cs | 45 +++ .../Persistence/UserReadingStateStore.cs | 56 +++ .../Persistence/VectorSearchStore.cs | 210 ++++++++++ ...etContextualRecommendationsQueryHandler.cs | 145 +++++++ .../Services/KnowledgeService.cs | 32 +- src/NexusReader.Maui/MauiProgram.cs | 3 +- src/NexusReader.Maui/appsettings.json | 2 +- .../Molecules/AiAssistantBubble.razor | 4 +- .../Molecules/AiResponseRenderer.razor | 279 ++++++++++++++ .../Molecules/AiResponseRenderer.razor.css | 267 +++++++++++++ .../Molecules/IntelligenceToolbar.razor | 11 +- .../Molecules/SelectionAiPanel.razor | 10 +- .../ContextualRecommendationsWidget.razor | 112 ++++++ .../ContextualRecommendationsWidget.razor.css | 253 ++++++++++++ .../Models/ReaderModels.cs | 6 + src/NexusReader.UI.Shared/Pages/Catalog.razor | 21 +- .../Pages/Dashboard.razor | 3 + .../Pages/Intelligence.razor | 314 ++++++++++----- .../Pages/Intelligence.razor.css | 364 +++++++----------- src/NexusReader.UI.Shared/Pages/MyBooks.razor | 21 +- .../Services/ILibraryStateService.cs | 12 + .../Services/IRecommendationService.cs | 23 ++ .../Services/LibraryStateService.cs | 27 ++ .../Services/PaywallParser.cs | 72 ++++ src/NexusReader.UI.Shared/_Imports.razor | 1 + src/NexusReader.Web.Client/Program.cs | 2 + .../Services/RecommendationService.cs | 74 ++++ .../Services/WasmKnowledgeService.cs | 30 ++ src/NexusReader.Web/Program.cs | 80 ++++ src/NexusReader.Web/appsettings.Test.json | 7 +- src/NexusReader.Web/appsettings.json | 7 +- .../GetContextualRecommendationsQueryTests.cs | 132 +++++++ .../Services/PaywallParserTests.cs | 81 ++++ 42 files changed, 2737 insertions(+), 337 deletions(-) create mode 100644 src/NexusReader.Application/Abstractions/Persistence/IUserLibraryStore.cs create mode 100644 src/NexusReader.Application/Abstractions/Persistence/IUserReadingStateStore.cs create mode 100644 src/NexusReader.Application/Abstractions/Persistence/IVectorSearchStore.cs create mode 100644 src/NexusReader.Application/Common/RagMonetizationOptions.cs create mode 100644 src/NexusReader.Application/Queries/Intelligence/GetGlobalIntelligenceQuery.cs create mode 100644 src/NexusReader.Application/Queries/Recommendations/GetContextualRecommendationsQuery.cs create mode 100644 src/NexusReader.Infrastructure/Persistence/UserLibraryStore.cs create mode 100644 src/NexusReader.Infrastructure/Persistence/UserReadingStateStore.cs create mode 100644 src/NexusReader.Infrastructure/Persistence/VectorSearchStore.cs create mode 100644 src/NexusReader.Infrastructure/Queries/GetContextualRecommendationsQueryHandler.cs create mode 100644 src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor create mode 100644 src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor.css create mode 100644 src/NexusReader.UI.Shared/Components/Organisms/ContextualRecommendationsWidget.razor create mode 100644 src/NexusReader.UI.Shared/Components/Organisms/ContextualRecommendationsWidget.razor.css create mode 100644 src/NexusReader.UI.Shared/Services/ILibraryStateService.cs create mode 100644 src/NexusReader.UI.Shared/Services/IRecommendationService.cs create mode 100644 src/NexusReader.UI.Shared/Services/LibraryStateService.cs create mode 100644 src/NexusReader.UI.Shared/Services/PaywallParser.cs create mode 100644 src/NexusReader.Web.Client/Services/RecommendationService.cs create mode 100644 tests/NexusReader.Application.Tests/Queries/GetContextualRecommendationsQueryTests.cs create mode 100644 tests/NexusReader.Application.Tests/Services/PaywallParserTests.cs diff --git a/src/NexusReader.Application/Abstractions/Persistence/IUserLibraryStore.cs b/src/NexusReader.Application/Abstractions/Persistence/IUserLibraryStore.cs new file mode 100644 index 0000000..cd3fe8e --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Persistence/IUserLibraryStore.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace NexusReader.Application.Abstractions.Persistence; + +/// +/// Provides access to user library ownership details, decoupling the relational database +/// structures from vector search and intelligence query operations. +/// +public interface IUserLibraryStore +{ + /// + /// Retrieves a list of book IDs that are owned by or uploaded for the specified user. + /// + Task> GetOwnedBookIdsAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// Retrieves a dictionary mapping book IDs to their titles. + /// + Task> GetBookTitlesAsync(List bookIds, CancellationToken cancellationToken = default); +} diff --git a/src/NexusReader.Application/Abstractions/Persistence/IUserReadingStateStore.cs b/src/NexusReader.Application/Abstractions/Persistence/IUserReadingStateStore.cs new file mode 100644 index 0000000..8a574b6 --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Persistence/IUserReadingStateStore.cs @@ -0,0 +1,21 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace NexusReader.Application.Abstractions.Persistence; + +/// +/// Decoupled database store to retrieve active user reading states and chapter content. +/// +public interface IUserReadingStateStore +{ + /// + /// Retrieves the user's active reading state: last read ebook ID, last opened chapter/page ID, and tenant ID. + /// + Task<(Guid? EbookId, string? ChapterId, string? TenantId)> GetActiveReadingStateAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// Retrieves the text content of a specific chapter/page by its ID. + /// + Task GetChapterContentAsync(string chapterId, CancellationToken cancellationToken = default); +} diff --git a/src/NexusReader.Application/Abstractions/Persistence/IVectorSearchStore.cs b/src/NexusReader.Application/Abstractions/Persistence/IVectorSearchStore.cs new file mode 100644 index 0000000..0a1fecb --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Persistence/IVectorSearchStore.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace NexusReader.Application.Abstractions.Persistence; + +/// +/// Represents a chunk of text retrieved from the semantic vector database. +/// +public record VectorChunk(string Content, string EbookId, double Score, string MetadataJson = "", string BookTitle = "", string ChapterTitle = ""); + +/// +/// Abstraction for performing semantic vector searches, isolating Qdrant gRPC dependencies from the Application layer. +/// +public interface IVectorSearchStore +{ + /// + /// Searches the entire global catalog (filtered by tenant) for the best semantic matches. + /// + Task> SearchGlobalAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default); + + /// + /// Searches within a whitelist of owned book IDs for the best semantic matches. + /// + Task> SearchLocalAsync(string queryText, string tenantId, List whitelistedBookIds, int limit, CancellationToken cancellationToken = default); + + /// + /// Searches the entire global catalog (filtered by tenant) for the best semantic matches, excluding a specific book ID. + /// + Task> SearchGlobalExcludeAsync(string queryText, string tenantId, Guid excludeBookId, int limit, CancellationToken cancellationToken = default); +} diff --git a/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs b/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs index a40ae18..1edfa4f 100644 --- a/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs +++ b/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs @@ -1,5 +1,6 @@ using FluentResults; using NexusReader.Application.DTOs.AI; +using NexusReader.Application.Queries.Intelligence; namespace NexusReader.Application.Abstractions.Services; @@ -13,6 +14,7 @@ public interface IKnowledgeService Task> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default); Task>> SearchLibrarySemanticallyAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default); Task> AskQuestionAsync(string question, string tenantId, Guid? ebookId = null, int limit = 5, CancellationToken cancellationToken = default); + Task> GetGlobalIntelligenceAsync(string queryText, string userId, string tenantId, CancellationToken cancellationToken = default); Task ClearCacheAsync(CancellationToken cancellationToken = default); } diff --git a/src/NexusReader.Application/Common/AppJsonContext.cs b/src/NexusReader.Application/Common/AppJsonContext.cs index 99d0896..2265dc2 100644 --- a/src/NexusReader.Application/Common/AppJsonContext.cs +++ b/src/NexusReader.Application/Common/AppJsonContext.cs @@ -1,5 +1,7 @@ using System.Text.Json.Serialization; +using System.Collections.Generic; using NexusReader.Application.Queries.Graph; +using NexusReader.Application.Queries.Intelligence; namespace NexusReader.Application.Common; @@ -9,6 +11,11 @@ namespace NexusReader.Application.Common; [JsonSerializable(typeof(GraphDataDto))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(GetGlobalIntelligenceRequest))] +[JsonSerializable(typeof(IntelligenceResponse))] +[JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.ContextualRecommendationResponse))] +[JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.RecommendationDto))] +[JsonSerializable(typeof(List))] public partial class AppJsonContext : JsonSerializerContext { } diff --git a/src/NexusReader.Application/Common/RagMonetizationOptions.cs b/src/NexusReader.Application/Common/RagMonetizationOptions.cs new file mode 100644 index 0000000..1aa6c64 --- /dev/null +++ b/src/NexusReader.Application/Common/RagMonetizationOptions.cs @@ -0,0 +1,28 @@ +namespace NexusReader.Application.Common; + +/// +/// Configurations for the monetization engine, controlling the thresholds at which +/// search queries trigger paywalls. +/// +public class RagMonetizationOptions +{ + public const string SectionName = "RagMonetization"; + + /// + /// The baseline score threshold above which global content might trigger a paywall if there is no local content. + /// Default: 0.45. + /// + public double BaselineThreshold { get; set; } = 0.45; + + /// + /// The similarity gap (Delta) required between global and local content to trigger an upgrade paywall. + /// Default: 0.15. + /// + public double DeltaThreshold { get; set; } = 0.15; + + /// + /// The absolute score required from global content to trigger an upgrade paywall. + /// Default: 0.70. + /// + public double UpgradeThreshold { get; set; } = 0.70; +} diff --git a/src/NexusReader.Application/Queries/Intelligence/GetGlobalIntelligenceQuery.cs b/src/NexusReader.Application/Queries/Intelligence/GetGlobalIntelligenceQuery.cs new file mode 100644 index 0000000..78d0cf8 --- /dev/null +++ b/src/NexusReader.Application/Queries/Intelligence/GetGlobalIntelligenceQuery.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentResults; +using MediatR; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Options; +using NexusReader.Application.Abstractions.Persistence; +using NexusReader.Application.Common; +using NexusReader.Application.DTOs.AI; + +namespace NexusReader.Application.Queries.Intelligence; + +/// +/// MediatR query to request global intelligence hybrid Q&A context. +/// +public record GetGlobalIntelligenceQuery(string QueryText, string UserId, string TenantId = "global") + : IRequest>; + +/// +/// Request schema for global hybrid search queries. +/// +public record GetGlobalIntelligenceRequest(string QueryText); + +/// +/// Response schema returning generated AI text, paywall status, and locked publishing details. +/// +public record IntelligenceResponse( + string ResponseText, + bool HasPaywall, + Guid? LockedBookId, + string? LockedBookTitle, + List? Citations = null); + +/// +/// Handles by performing local/global dual searches, +/// executing monetization rules, and invoking Chat AI with appropriate gating logic. +/// +public class GetGlobalIntelligenceQueryHandler : IRequestHandler> +{ + private readonly IUserLibraryStore _userLibraryStore; + private readonly IVectorSearchStore _vectorSearchStore; + private readonly IChatClient _chatClient; + private readonly RagMonetizationOptions _options; + + public GetGlobalIntelligenceQueryHandler( + IUserLibraryStore userLibraryStore, + IVectorSearchStore vectorSearchStore, + IChatClient chatClient, + IOptions options) + { + _userLibraryStore = userLibraryStore; + _vectorSearchStore = vectorSearchStore; + _chatClient = chatClient; + _options = options.Value; + } + + public async Task> Handle(GetGlobalIntelligenceQuery request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.QueryText)) + { + return Result.Fail("Question cannot be empty."); + } + + try + { + // Step A: Fetch whitelisted BookIds + var whitelistedBookIds = await _userLibraryStore.GetOwnedBookIdsAsync(request.UserId, cancellationToken); + + // Step B & C: Vector Dual-Search with Resilient Trapping + List globalChunks = new(); + List localChunks = new(); + double globalScore = 0.0; + double localScore = 0.0; + + try + { + // Execute searches + globalChunks = await _vectorSearchStore.SearchGlobalAsync(request.QueryText, request.TenantId, limit: 3, cancellationToken); + globalScore = globalChunks.Any() ? Math.Max(0.0, globalChunks.Max(c => c.Score)) : 0.0; + + if (whitelistedBookIds.Any()) + { + localChunks = await _vectorSearchStore.SearchLocalAsync(request.QueryText, request.TenantId, whitelistedBookIds, limit: 3, cancellationToken); + localScore = localChunks.Any() ? Math.Max(0.0, localChunks.Max(c => c.Score)) : 0.0; + } + } + catch (Exception ex) + { + // Resilient Error Trapping: transform connectivity anomalies into domain-friendly errors + return Result.Fail(new Error("Serwer wyszukiwania semantycznego jest tymczasowo niedostępny. Spróbuj ponownie później.").CausedBy(ex)); + } + + // Step D: Evaluate Monetization Thresholds + bool triggerPaywall = false; + + if (localScore == 0.0 && globalScore > _options.BaselineThreshold) + { + triggerPaywall = true; + } + else if ((globalScore - localScore) > _options.DeltaThreshold && globalScore > _options.UpgradeThreshold) + { + triggerPaywall = true; + } + + var chosenChunks = triggerPaywall ? globalChunks : localChunks; + + // Fetch book titles for citations/paywall metadata + var chunkEbookIds = chosenChunks + .Where(c => Guid.TryParse(c.EbookId, out _)) + .Select(c => Guid.Parse(c.EbookId)) + .Distinct() + .ToList(); + + var bookTitles = await _userLibraryStore.GetBookTitlesAsync(chunkEbookIds, cancellationToken); + + // Step E: Identify locked book if paywall triggered + Guid? lockedBookId = null; + string? lockedBookTitle = null; + + if (triggerPaywall && globalChunks.Any()) + { + var topGlobalChunk = globalChunks.OrderByDescending(c => c.Score).First(); + if (Guid.TryParse(topGlobalChunk.EbookId, out var parsedLockedId)) + { + lockedBookId = parsedLockedId; + bookTitles.TryGetValue(parsedLockedId, out lockedBookTitle); + if (string.IsNullOrEmpty(lockedBookTitle)) + { + lockedBookTitle = "Nieznana książka"; + } + } + } + + // Format context blocks for LLM + var relatedContexts = new List(); + foreach (var chunk in chosenChunks) + { + var sourceId = chunk.EbookId; + relatedContexts.Add($"[Source ID: {sourceId}] {chunk.Content}"); + } + var contextBlocksText = string.Join("\n\n", relatedContexts); + + // Build LLM prompts + var systemPrompt = "You are an advanced, extremely precise Fact-Checking AI assistant. Your task is to answer the user's question using ONLY the provided context blocks.\n" + + "Rely EXCLUSIVELY on the provided context. Do NOT use any pre-existing external knowledge, facts, or assumptions.\n" + + "If the context does not contain the answer, say: 'I cannot answer this based on the provided book context.'"; + + if (triggerPaywall) + { + var localScorePercent = (int)Math.Round(localScore * 100); + var globalScorePercent = (int)Math.Round(globalScore * 100); + var resolvedTitle = lockedBookTitle ?? "Nieznana książka"; + + systemPrompt += $"\n\nCRITICAL: You are operating in TEASER mode. The user does not own the source document named '{resolvedTitle}'. You are strictly allowed to provide only a 1-sentence foundational definition or answer based on the context to prove the system knows the solution. DO NOT output code blocks, implementation details, or bullet points. You must immediately terminate your response with this exact token format: [PAYWALL_TRIGGER:{lockedBookId}:{resolvedTitle}:{localScorePercent}:{globalScorePercent}]."; + } + + var messages = new List + { + new(Microsoft.Extensions.AI.ChatRole.System, systemPrompt), + new(Microsoft.Extensions.AI.ChatRole.User, $"Context:\n{contextBlocksText}\n\nQuestion: {request.QueryText}") + }; + + var chatOptions = new ChatOptions + { + Temperature = 0.0f, + MaxOutputTokens = 1000 + }; + + var chatResponse = await _chatClient.GetResponseAsync(messages, chatOptions, cancellationToken); + var responseText = chatResponse.Text?.Trim() ?? string.Empty; + + // Ensure the paywall token is appended if LLM misses it in teaser mode + if (triggerPaywall) + { + var localScorePercent = (int)Math.Round(localScore * 100); + var globalScorePercent = (int)Math.Round(globalScore * 100); + var resolvedTitle = lockedBookTitle ?? "Nieznana książka"; + var paywallToken = $"[PAYWALL_TRIGGER:{lockedBookId}:{resolvedTitle}:{localScorePercent}:{globalScorePercent}]"; + + if (!responseText.Contains("[PAYWALL_TRIGGER:")) + { + responseText = responseText.Trim() + " " + paywallToken; + } + } + + // Build citations list + var citations = new List(); + foreach (var chunk in chosenChunks) + { + var sourceBookName = "Unknown"; + if (Guid.TryParse(chunk.EbookId, out var parsedId) && bookTitles.TryGetValue(parsedId, out var title)) + { + sourceBookName = title; + } + + citations.Add(new CitationDto + { + CitationId = chunk.EbookId, + Snippet = chunk.Content, + SourceBook = sourceBookName, + Author = null, + PageNumber = null + }); + } + + return Result.Ok(new IntelligenceResponse( + ResponseText: responseText, + HasPaywall: triggerPaywall, + LockedBookId: lockedBookId, + LockedBookTitle: lockedBookTitle, + Citations: citations + )); + } + catch (Exception ex) + { + return Result.Fail(new Error("Nieoczekiwany błąd serwera podczas przetwarzania zapytania.").CausedBy(ex)); + } + } +} diff --git a/src/NexusReader.Application/Queries/Recommendations/GetContextualRecommendationsQuery.cs b/src/NexusReader.Application/Queries/Recommendations/GetContextualRecommendationsQuery.cs new file mode 100644 index 0000000..0b93945 --- /dev/null +++ b/src/NexusReader.Application/Queries/Recommendations/GetContextualRecommendationsQuery.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using FluentResults; +using MediatR; + +namespace NexusReader.Application.Queries.Recommendations; + +/// +/// MediatR query to fetch contextual recommendations based on the user's active reading state. +/// +public record GetContextualRecommendationsQuery(string UserId) + : IRequest>; + +/// +/// Response DTO containing contextual recommendations. +/// +public record ContextualRecommendationResponse(List Recommendations); + +/// +/// Individual contextual recommendation details. +/// +public record RecommendationDto( + string BookTitle, + string ChapterTitle, + int MatchPercentage, + bool IsPremiumUpsell, + Guid TargetBookId +); diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index a72162c..13801ad 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Configuration; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.AI; +using NexusReader.Application.Common; using GeminiDotnet; using GeminiDotnet.Extensions.AI; using NexusReader.Data.Persistence; @@ -76,6 +77,7 @@ public static class DependencyInjection services.Configure(configuration.GetSection(AiSettings.SectionName)); services.Configure(configuration.GetSection(StripeSettings.SectionName)); + services.Configure(configuration.GetSection(RagMonetizationOptions.SectionName)); var aiSettings = configuration.GetSection(AiSettings.SectionName).Get() ?? new AiSettings(); if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER") @@ -127,6 +129,9 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper) services.AddScoped(); diff --git a/src/NexusReader.Infrastructure/Persistence/UserLibraryStore.cs b/src/NexusReader.Infrastructure/Persistence/UserLibraryStore.cs new file mode 100644 index 0000000..23a6754 --- /dev/null +++ b/src/NexusReader.Infrastructure/Persistence/UserLibraryStore.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +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; + +/// +/// EF Core implementation of using . +/// +internal sealed class UserLibraryStore : IUserLibraryStore +{ + private readonly AppDbContext _context; + + public UserLibraryStore(AppDbContext context) + { + _context = context; + } + + /// + public async Task> GetOwnedBookIdsAsync(string userId, CancellationToken cancellationToken = default) + { + return await _context.Ebooks + .Where(e => e.UserId == userId) + .Select(e => e.Id) + .ToListAsync(cancellationToken); + } + + /// + public async Task> GetBookTitlesAsync(List bookIds, CancellationToken cancellationToken = default) + { + if (bookIds == null || !bookIds.Any()) + { + return new Dictionary(); + } + + return await _context.Ebooks + .Where(e => bookIds.Contains(e.Id)) + .ToDictionaryAsync(e => e.Id, e => e.Title, cancellationToken); + } +} diff --git a/src/NexusReader.Infrastructure/Persistence/UserReadingStateStore.cs b/src/NexusReader.Infrastructure/Persistence/UserReadingStateStore.cs new file mode 100644 index 0000000..8ce394b --- /dev/null +++ b/src/NexusReader.Infrastructure/Persistence/UserReadingStateStore.cs @@ -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; + +/// +/// EF Core implementation of . +/// +internal sealed class UserReadingStateStore : IUserReadingStateStore +{ + private readonly IDbContextFactory _dbContextFactory; + + public UserReadingStateStore(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + + /// + 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); + } + + /// + public async Task 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); + } +} diff --git a/src/NexusReader.Infrastructure/Persistence/VectorSearchStore.cs b/src/NexusReader.Infrastructure/Persistence/VectorSearchStore.cs new file mode 100644 index 0000000..857e298 --- /dev/null +++ b/src/NexusReader.Infrastructure/Persistence/VectorSearchStore.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Qdrant.Client; +using Qdrant.Client.Grpc; +using Polly; +using Polly.Registry; +using NexusReader.Application.Abstractions.Persistence; + +namespace NexusReader.Infrastructure.Persistence; + +/// +/// Infrastructure implementation of utilizing +/// and to execute semantic vector queries. +/// +internal sealed class VectorSearchStore : IVectorSearchStore +{ + private readonly QdrantClient _qdrantClient; + private readonly IEmbeddingGenerator> _embeddingGenerator; + private readonly ResiliencePipeline _retryPipeline; + private readonly ILogger _logger; + + public VectorSearchStore( + QdrantClient qdrantClient, + IEmbeddingGenerator> embeddingGenerator, + ResiliencePipelineProvider pipelineProvider, + ILogger logger) + { + _qdrantClient = qdrantClient; + _embeddingGenerator = embeddingGenerator; + _retryPipeline = pipelineProvider.GetPipeline("ai-retry"); + _logger = logger; + } + + /// + public async Task> SearchGlobalAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default) + { + var queryVector = await GenerateEmbeddingAsync(queryText, cancellationToken); + var filter = BuildTenantFilter(tenantId); + + return await ExecuteSearchAsync(queryVector, filter, limit, cancellationToken); + } + + /// + public async Task> SearchLocalAsync(string queryText, string tenantId, List whitelistedBookIds, int limit, CancellationToken cancellationToken = default) + { + if (whitelistedBookIds == null || !whitelistedBookIds.Any()) + { + return new List(); + } + + var queryVector = await GenerateEmbeddingAsync(queryText, cancellationToken); + var filter = BuildTenantFilter(tenantId); + + var whitelistFilter = new Qdrant.Client.Grpc.Filter(); + foreach (var bookId in whitelistedBookIds) + { + whitelistFilter.Should.Add(new Qdrant.Client.Grpc.Condition + { + Field = new Qdrant.Client.Grpc.FieldCondition + { + Key = "ebookId", + Match = new Qdrant.Client.Grpc.Match { Text = bookId.ToString() } + } + }); + } + filter.Must.Add(new Qdrant.Client.Grpc.Condition { Filter = whitelistFilter }); + + return await ExecuteSearchAsync(queryVector, filter, limit, cancellationToken); + } + + /// + public async Task> 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 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(); + } + + private Qdrant.Client.Grpc.Filter BuildTenantFilter(string tenantId) + { + var filter = new Qdrant.Client.Grpc.Filter(); + var tenantFilter = new Qdrant.Client.Grpc.Filter(); + + tenantFilter.Should.Add(new Qdrant.Client.Grpc.Condition + { + Field = new Qdrant.Client.Grpc.FieldCondition + { + Key = "tenantId", + Match = new Qdrant.Client.Grpc.Match { Text = tenantId } + } + }); + + tenantFilter.Should.Add(new Qdrant.Client.Grpc.Condition + { + Field = new Qdrant.Client.Grpc.FieldCondition + { + Key = "tenantId", + Match = new Qdrant.Client.Grpc.Match { Text = "global" } + } + }); + + filter.Must.Add(new Qdrant.Client.Grpc.Condition { Filter = tenantFilter }); + return filter; + } + + 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, + filter: filter, + 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 => + { + 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 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, bookTitle, chapterTitle); + }).ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "[VectorSearchStore] Qdrant search execution failed."); + throw; + } + } + + private async Task EnsureCollectionExistsAsync(string collectionName, CancellationToken cancellationToken) + { + try + { + 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 + { + Size = 768, + Distance = Distance.Cosine + }, + cancellationToken: cancellationToken + ); + _logger.LogInformation("[VectorSearchStore] Collection '{CollectionName}' created successfully.", collectionName); + } + } + catch (Exception ex) + { + // 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 new file mode 100644 index 0000000..2f8a33a --- /dev/null +++ b/src/NexusReader.Infrastructure/Queries/GetContextualRecommendationsQueryHandler.cs @@ -0,0 +1,145 @@ +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)); + } + } +} diff --git a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs index 0c0097c..7baa8c9 100644 --- a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs +++ b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs @@ -4,6 +4,8 @@ using FluentResults; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; +using MediatR; +using NexusReader.Application.Queries.Intelligence; using Microsoft.ML.Tokenizers; using NexusReader.Application.Abstractions.Services; using NexusReader.Application.DTOs.AI; @@ -33,6 +35,7 @@ public class KnowledgeService : IKnowledgeService private readonly ILogger _logger; private readonly QdrantClient _qdrantClient; private readonly IDriver _neo4jDriver; + private readonly IMediator _mediator; private const string PromptVersion = "1.7"; private static readonly ConcurrentDictionary>>> _activeRequests = new(); private static readonly SemaphoreSlim _collectionSemaphore = new(1, 1); @@ -45,7 +48,8 @@ public class KnowledgeService : IKnowledgeService IOptions settings, ILogger logger, QdrantClient qdrantClient, - IDriver neo4jDriver) + IDriver neo4jDriver, + IMediator mediator) { _chatClient = chatClient; _embeddingGenerator = embeddingGenerator; @@ -55,6 +59,7 @@ public class KnowledgeService : IKnowledgeService _logger = logger; _qdrantClient = qdrantClient; _neo4jDriver = neo4jDriver; + _mediator = mediator; // Use Tiktoken (cl100k_base) which is a standard for modern LLMs and provides // a very reliable estimation for token usage in Gemini-based workloads. _tokenizer = TiktokenTokenizer.CreateForModel("gpt-4"); @@ -334,6 +339,17 @@ public class KnowledgeService : IKnowledgeService { 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 embeddingResponse = await _retryPipeline.ExecuteAsync(async ct => @@ -350,6 +366,12 @@ public class KnowledgeService : IKnowledgeService var unitDto = unitsToEmbed[i]; 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 { Id = GetDeterministicGuid(unitDto.Id), @@ -360,6 +382,8 @@ public class KnowledgeService : IKnowledgeService ["type"] = unitDto.Type ?? string.Empty, ["tenantId"] = tenantId, ["ebookId"] = ebookId?.ToString() ?? string.Empty, + ["bookTitle"] = bookTitle, + ["chapterTitle"] = chapterTitle, ["metadataJson"] = JsonSerializer.Serialize(unitDto.Metadata) } }; @@ -1187,6 +1211,12 @@ public class KnowledgeService : IKnowledgeService } } + /// + public async Task> GetGlobalIntelligenceAsync(string queryText, string userId, string tenantId, CancellationToken cancellationToken = default) + { + return await _mediator.Send(new GetGlobalIntelligenceQuery(queryText, userId, tenantId), cancellationToken); + } + private int EstimateTokenCount(string text) { if (string.IsNullOrEmpty(text)) return 0; diff --git a/src/NexusReader.Maui/MauiProgram.cs b/src/NexusReader.Maui/MauiProgram.cs index a4b2c6c..4a5fca4 100644 --- a/src/NexusReader.Maui/MauiProgram.cs +++ b/src/NexusReader.Maui/MauiProgram.cs @@ -56,7 +56,7 @@ public static class MauiProgram builder.Services.AddTransient(); builder.Services.AddHttpClient("NexusAPI", client => { - var apiBaseUrl = builder.Configuration["ApiSettings:BaseUrl"] ?? "http://localhost:5000"; + var apiBaseUrl = builder.Configuration["ApiSettings:BaseUrl"] ?? "http://localhost:5104"; client.BaseAddress = new Uri(apiBaseUrl); }).AddHttpMessageHandler(); @@ -74,6 +74,7 @@ public static class MauiProgram builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/NexusReader.Maui/appsettings.json b/src/NexusReader.Maui/appsettings.json index 4d3ef31..20260df 100644 --- a/src/NexusReader.Maui/appsettings.json +++ b/src/NexusReader.Maui/appsettings.json @@ -1,6 +1,6 @@ { "ApiSettings": { - "BaseUrl": "https://localhost:5000" + "BaseUrl": "http://localhost:5104" }, "Serilog": { "Using": [ diff --git a/src/NexusReader.UI.Shared/Components/Molecules/AiAssistantBubble.razor b/src/NexusReader.UI.Shared/Components/Molecules/AiAssistantBubble.razor index ba4a703..192f520 100644 --- a/src/NexusReader.UI.Shared/Components/Molecules/AiAssistantBubble.razor +++ b/src/NexusReader.UI.Shared/Components/Molecules/AiAssistantBubble.razor @@ -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 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 new file mode 100644 index 0000000..b5d2251 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor @@ -0,0 +1,279 @@ +@using NexusReader.UI.Shared.Models +@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 + +
+ + +
+
+ @Message.Sender + @Message.Timestamp.ToString("HH:mm") +
+ +
+ @if (Message.Sender == "User") + { +

@Message.Text

+ } + else + { + @if (_hasPaywall) + { + + + + } + else + { +
+ @foreach (var segment in ParseSegments(GetCleanText())) + { + @if (segment.IsCitation) + { + + } + else + { + @RenderMarkdown(segment.Text) + } + } +
+ + @if (_showSuccessBanner) + { +
+ + Odblokowano pełną odpowiedź! Książka została dodana do Twojej biblioteki. +
+ } + } + } +
+
+
+ +@code { + [Parameter] public ChatMessage Message { get; set; } = default!; + [Parameter] public List? OwnedBooks { get; set; } + [Parameter] public EventCallback OnUnlockRequested { get; set; } + + private bool _hasPaywall; + private string _displayTeaserText = string.Empty; + private Guid _lockedBookId; + private string _lockedBookTitle = string.Empty; + private int _localScore; + private int _globalScore; + + private bool _isUnlocked = false; + private bool _isSimulatingPayment = false; + private bool _showSuccessBanner = false; + + protected override void OnParametersSet() + { + base.OnParametersSet(); + + if (Message != null && Message.Sender != "User" && !_isUnlocked) + { + _hasPaywall = PaywallParser.TryParsePaywallTrigger(Message.Text, out _displayTeaserText, out _lockedBookId, out _lockedBookTitle, out _localScore, out _globalScore); + + // Additional check: if user already owns the book, don't show the paywall + if (_hasPaywall && OwnedBooks != null) + { + var isOwned = OwnedBooks.Any(b => + b.Id == _lockedBookId || + (!string.IsNullOrEmpty(b.Title) && b.Title.Equals(_lockedBookTitle, StringComparison.OrdinalIgnoreCase))); + if (isOwned) + { + _hasPaywall = false; + } + } + } + else + { + _hasPaywall = false; + } + } + + private string GetCleanText() + { + if (Message == null) return string.Empty; + if (PaywallParser.TryParsePaywallTrigger(Message.Text, out var cleanText, out _, out _, out _, out _)) + { + return cleanText; + } + return Message.Text; + } + + private string GetBubbleClass() + { + if (Message.Sender == "User") return "user-bubble"; + return _hasPaywall ? "ai-bubble paywalled-bubble" : "ai-bubble"; + } + + private async Task HandlePurchase() + { + if (_isSimulatingPayment) return; + + _isSimulatingPayment = true; + StateHasChanged(); + + // Simulate payment gateway delay (1.5 seconds) + await Task.Delay(1500); + + try + { + var bookTitle = string.IsNullOrEmpty(_lockedBookTitle) + ? "Architektura .NET 10 i Ekosystem Blazor" + : _lockedBookTitle; + + // Call POST endpoint to persist the purchase + var response = await Http.PostAsJsonAsync("api/library/purchase", new { Title = bookTitle }); + if (response.IsSuccessStatusCode) + { + _isUnlocked = true; + _hasPaywall = false; + _showSuccessBanner = true; + + // Fetch updated library list and update state manager + var updatedBooks = await Http.GetFromJsonAsync>("api/library/books"); + LibraryStateService.OwnedBooks = updatedBooks; + + if (OnUnlockRequested.HasDelegate) + { + await OnUnlockRequested.InvokeAsync(_lockedBookId); + } + } + else + { + Logger.LogWarning("[AiResponseRenderer] Purchase failed on server for book {BookId}.", _lockedBookId); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "[AiResponseRenderer] Error processing purchase for book {BookId}.", _lockedBookId); + } + finally + { + _isSimulatingPayment = false; + StateHasChanged(); + } + } + + private List ParseSegments(string text) + { + var segments = new List(); + if (string.IsNullOrEmpty(text)) return segments; + + var regex = new System.Text.RegularExpressions.Regex( + @"\[Source ID:\s*([^\]]+)\]|\[([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})\]", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + var matches = regex.Matches(text); + + int lastIndex = 0; + foreach (System.Text.RegularExpressions.Match match in matches) + { + if (match.Index > lastIndex) + { + segments.Add(new ResponseSegment + { + Text = text.Substring(lastIndex, match.Index - lastIndex), + IsCitation = false + }); + } + + var citationId = match.Groups[1].Success + ? match.Groups[1].Value.Trim() + : match.Groups[2].Value.Trim(); + + segments.Add(new ResponseSegment + { + IsCitation = true, + CitationId = citationId + }); + + lastIndex = match.Index + match.Length; + } + + if (lastIndex < text.Length) + { + segments.Add(new ResponseSegment + { + Text = text.Substring(lastIndex), + IsCitation = false + }); + } + + return segments; + } + + private MarkupString RenderMarkdown(string text) + { + if (string.IsNullOrEmpty(text)) return new MarkupString(string.Empty); + + var html = System.Net.WebUtility.HtmlEncode(text); + html = System.Text.RegularExpressions.Regex.Replace(html, @"\*\*(.*?)\*\*", "$1"); + html = System.Text.RegularExpressions.Regex.Replace(html, @"\*(.*?)\*", "$1"); + html = System.Text.RegularExpressions.Regex.Replace(html, @"```(?:[a-zA-Z0-9+#]+)?\s*([\s\S]*?)\s*```", "
$1
"); + html = System.Text.RegularExpressions.Regex.Replace(html, @"`(.*?)`", "$1"); + html = html.Replace("\n", "
"); + + return new MarkupString(html); + } +} diff --git a/src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor.css b/src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor.css new file mode 100644 index 0000000..b8d96f1 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor.css @@ -0,0 +1,267 @@ +.message-row { + display: flex; + gap: 1rem; + width: 100%; + max-width: 90%; + margin-bottom: 1.5rem; + animation: bubble-fade-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +.user-row { + align-self: flex-end; + margin-left: auto; + flex-direction: row-reverse; +} + +.ai-row { + align-self: flex-start; + margin-right: auto; +} + +.message-avatar { + width: 38px; + height: 38px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; + flex-shrink: 0; +} + +.user-row .message-avatar { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.05) 100%); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: 0 0 10px rgba(255, 255, 255, 0.1); +} + +.ai-row .message-avatar { + background: linear-gradient(135deg, #005f38 0%, #004024 100%); + color: #e6fffa; + border: 1px solid rgba(0, 255, 153, 0.4); + box-shadow: 0 0 10px rgba(0, 255, 153, 0.25); +} + +.message-bubble { + padding: 1.25rem 1.5rem; + border-radius: 16px; + position: relative; + line-height: 1.6; + font-size: 0.975rem; + display: flex; + flex-direction: column; + width: 100%; +} + +.user-bubble { + background: #1a1a1e; + border: 1px solid rgba(255, 255, 255, 0.05); + color: #e4e4e7; + border-top-right-radius: 4px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); +} + +.ai-bubble { + background: rgba(26, 26, 30, 0.6); + border: 1px solid rgba(255, 255, 255, 0.05); + color: #e2e8f0; + border-top-left-radius: 4px; + box-shadow: 0 4px 25px rgba(0, 0, 0, 0.2); +} + +.paywalled-bubble { + border-color: rgba(16, 185, 129, 0.15); +} + +.message-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; + font-size: 0.75rem; + opacity: 0.6; +} + +.sender-name { + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.message-time { + font-family: monospace; +} + +.message-content { + word-break: break-word; +} + +/* Paragraph spacing */ +.message-content p { + margin: 0 0 1rem 0; +} +.message-content p:last-child { + margin-bottom: 0; +} + +/* Paywall Blur Styles */ +.paywall-teaser { + position: relative; + margin-bottom: 1.5rem; + -webkit-mask-image: linear-gradient(to bottom, black 30%, transparent 100%); + mask-image: linear-gradient(to bottom, black 30%, transparent 100%); + filter: blur(2px); + pointer-events: none; + -webkit-user-select: none; + user-select: none; +} + +/* Upsell Card */ +.upsell-card { + background: #1a1a1e; + border-radius: 12px; + border: 1px solid rgba(16, 185, 129, 0.25); + padding: 1.5rem; + margin-top: 1rem; + box-shadow: 0 8px 32px rgba(16, 185, 129, 0.08), 0 4px 12px rgba(0, 0, 0, 0.4); + animation: card-slide-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +.upsell-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.upsell-icon { + font-size: 1.25rem; +} + +.upsell-header h4 { + margin: 0; + color: #10b981; + font-size: 1.1rem; + font-weight: 700; + letter-spacing: 0.5px; +} + +.upsell-text { + color: rgba(255, 255, 255, 0.75); + font-size: 0.9rem; + line-height: 1.55; + margin: 0 0 1.25rem 0; +} + +.upsell-actions { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +.btn-upsell { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.75rem 1.5rem; + font-size: 0.85rem; + font-weight: 700; + text-transform: uppercase; + border-radius: 8px; + cursor: pointer; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + text-decoration: none; + letter-spacing: 0.5px; + min-height: 44px; +} + +.btn-primary { + background: #10b981; + border: none; + color: #121214; +} + +.btn-primary:hover:not(:disabled) { + background: #0d9668; + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(16, 185, 129, 0.3); +} + +.btn-primary:active:not(:disabled) { + transform: translateY(0); +} + +.btn-primary:disabled { + background: rgba(16, 185, 129, 0.5); + color: rgba(18, 18, 20, 0.6); + cursor: not-allowed; +} + +.btn-secondary { + background: transparent; + border: 1px solid #10b981; + color: #10b981; +} + +.btn-secondary:hover { + background: rgba(16, 185, 129, 0.05); + transform: translateY(-2px); +} + +.btn-secondary:active { + transform: translateY(0); +} + +/* Success Banner */ +.success-unlock-banner { + display: flex; + align-items: center; + gap: 0.75rem; + background: rgba(16, 185, 129, 0.1); + border: 1px solid rgba(16, 185, 129, 0.3); + color: #10b981; + padding: 1rem; + border-radius: 8px; + margin-top: 1.25rem; + font-size: 0.9rem; + font-weight: 600; + animation: fade-in 0.5s ease-out; +} + +.success-icon { + font-weight: bold; + font-size: 1.1rem; +} + +/* Payment Spinner */ +.payment-spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(18, 18, 20, 0.2); + border-top-color: #121214; + border-radius: 50%; + margin-right: 0.75rem; + animation: spin 0.8s linear infinite; +} + +/* Keyframes */ +@keyframes bubble-fade-in { + 0% { opacity: 0; transform: translateY(12px) scale(0.98); } + 100% { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes card-slide-in { + 0% { opacity: 0; transform: translateY(10px); } + 100% { opacity: 1; transform: translateY(0); } +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} diff --git a/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor b/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor index 840acdc..3026d26 100644 --- a/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor +++ b/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor @@ -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 Logger @implements IDisposable
+ + +
diff --git a/src/NexusReader.UI.Shared/Pages/Intelligence.razor b/src/NexusReader.UI.Shared/Pages/Intelligence.razor index 8e7e432..41c03e2 100644 --- a/src/NexusReader.UI.Shared/Pages/Intelligence.razor +++ b/src/NexusReader.UI.Shared/Pages/Intelligence.razor @@ -1,35 +1,36 @@ @page "/intelligence" @attribute [Authorize] +@implements IDisposable @using NexusReader.Application.DTOs.AI @using NexusReader.Application.Abstractions.Services @using NexusReader.Application.DTOs.User +@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
-
-
-

Global Intelligence

-

Interrogate, explore, and synthesize grounded knowledge from your library using Polyglot KM-RAG

-
-
- -
+
@if (_chatMessages.Count == 0) {
- - + + + + + +
-

Start Interrogating Your Library

-

Ask complex questions across your entire ebook collection. The KM-RAG engine dynamically builds semantic maps, resolves dependencies, and formulates high-fidelity, grounded answers with interactive popover citations.

+
Zadaj pytanie globalne do całej biblioteki...
} else @@ -37,37 +38,7 @@
@foreach (var message in _chatMessages) { -
-
- @if (message.Sender == "User") - { - - } - else - { - - } -
-
-
- @message.Sender - @message.Timestamp.ToString("HH:mm") -
-
- @foreach (var segment in message.Segments) - { - @if (segment.IsCitation) - { - - } - else - { - @RenderMarkdown(segment.Text) - } - } -
-
-
+ } @if (_isLoading) @@ -100,9 +71,9 @@
- +