1d6862016d
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>
211 lines
8.5 KiB
C#
211 lines
8.5 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|