feat(recommendations): implement contextual recommendation engine (#76)
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 <jasins.marek@gmail.com> Reviewed-on: #76 Co-authored-by: Antigravity <antigravity@google.com> Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #76.
This commit is contained in:
@@ -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<AiSettings>(configuration.GetSection(AiSettings.SectionName));
|
||||
services.Configure<StripeSettings>(configuration.GetSection(StripeSettings.SectionName));
|
||||
services.Configure<RagMonetizationOptions>(configuration.GetSection(RagMonetizationOptions.SectionName));
|
||||
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER")
|
||||
@@ -127,6 +129,9 @@ public static class DependencyInjection
|
||||
services.AddScoped<IEbookRepository, EbookRepository>();
|
||||
services.AddScoped<IQuizResultRepository, QuizResultRepository>();
|
||||
services.AddScoped<IConceptsMapReadRepository, ConceptsMapReadRepository>();
|
||||
services.AddScoped<IUserLibraryStore, UserLibraryStore>();
|
||||
services.AddScoped<IUserReadingStateStore, UserReadingStateStore>();
|
||||
services.AddScoped<IVectorSearchStore, VectorSearchStore>();
|
||||
|
||||
// Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper)
|
||||
services.AddScoped<ISyncBroadcaster, SignalRSyncBroadcaster>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core implementation of <see cref="IUserLibraryStore"/> using <see cref="AppDbContext"/>.
|
||||
/// </summary>
|
||||
internal sealed class UserLibraryStore : IUserLibraryStore
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
public UserLibraryStore(AppDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<Guid>> GetOwnedBookIdsAsync(string userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Ebooks
|
||||
.Where(e => e.UserId == userId)
|
||||
.Select(e => e.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<Guid, string>> GetBookTitlesAsync(List<Guid> bookIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (bookIds == null || !bookIds.Any())
|
||||
{
|
||||
return new Dictionary<Guid, string>();
|
||||
}
|
||||
|
||||
return await _context.Ebooks
|
||||
.Where(e => bookIds.Contains(e.Id))
|
||||
.ToDictionaryAsync(e => e.Id, e => e.Title, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core implementation of <see cref="IUserReadingStateStore"/>.
|
||||
/// </summary>
|
||||
internal sealed class UserReadingStateStore : IUserReadingStateStore
|
||||
{
|
||||
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
|
||||
|
||||
public UserReadingStateStore(IDbContextFactory<AppDbContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string?> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Infrastructure implementation of <see cref="IVectorSearchStore"/> utilizing <see cref="QdrantClient"/>
|
||||
/// and <see cref="IEmbeddingGenerator{TInput, TEmbedding}"/> to execute semantic vector queries.
|
||||
/// </summary>
|
||||
internal sealed class VectorSearchStore : IVectorSearchStore
|
||||
{
|
||||
private readonly QdrantClient _qdrantClient;
|
||||
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;
|
||||
private readonly ResiliencePipeline _retryPipeline;
|
||||
private readonly ILogger<VectorSearchStore> _logger;
|
||||
|
||||
public VectorSearchStore(
|
||||
QdrantClient qdrantClient,
|
||||
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator,
|
||||
ResiliencePipelineProvider<string> pipelineProvider,
|
||||
ILogger<VectorSearchStore> logger)
|
||||
{
|
||||
_qdrantClient = qdrantClient;
|
||||
_embeddingGenerator = embeddingGenerator;
|
||||
_retryPipeline = pipelineProvider.GetPipeline("ai-retry");
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<VectorChunk>> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<VectorChunk>> SearchLocalAsync(string queryText, string tenantId, List<Guid> whitelistedBookIds, int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (whitelistedBookIds == null || !whitelistedBookIds.Any())
|
||||
{
|
||||
return new List<VectorChunk>();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<VectorChunk>> 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<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();
|
||||
}
|
||||
|
||||
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<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,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="GetContextualRecommendationsQuery"/> by discovering the active reading state,
|
||||
/// 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,
|
||||
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))
|
||||
{
|
||||
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<RecommendationDto>()));
|
||||
}
|
||||
|
||||
// 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<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,
|
||||
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<RecommendationDto>();
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<KnowledgeService> _logger;
|
||||
private readonly QdrantClient _qdrantClient;
|
||||
private readonly IDriver _neo4jDriver;
|
||||
private readonly IMediator _mediator;
|
||||
private const string PromptVersion = "1.7";
|
||||
private static readonly ConcurrentDictionary<string, Lazy<Task<Result<KnowledgePacket>>>> _activeRequests = new();
|
||||
private static readonly SemaphoreSlim _collectionSemaphore = new(1, 1);
|
||||
@@ -45,7 +48,8 @@ public class KnowledgeService : IKnowledgeService
|
||||
IOptions<AiSettings> settings,
|
||||
ILogger<KnowledgeService> 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
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<IntelligenceResponse>> 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;
|
||||
|
||||
Reference in New Issue
Block a user