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:
@@ -0,0 +1,23 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to user library ownership details, decoupling the relational database
|
||||
/// structures from vector search and intelligence query operations.
|
||||
/// </summary>
|
||||
public interface IUserLibraryStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves a list of book IDs that are owned by or uploaded for the specified user.
|
||||
/// </summary>
|
||||
Task<List<Guid>> GetOwnedBookIdsAsync(string userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a dictionary mapping book IDs to their titles.
|
||||
/// </summary>
|
||||
Task<Dictionary<Guid, string>> GetBookTitlesAsync(List<Guid> bookIds, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Decoupled database store to retrieve active user reading states and chapter content.
|
||||
/// </summary>
|
||||
public interface IUserReadingStateStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves the user's active reading state: last read ebook ID, last opened chapter/page ID, and tenant ID.
|
||||
/// </summary>
|
||||
Task<(Guid? EbookId, string? ChapterId, string? TenantId)> GetActiveReadingStateAsync(string userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the text content of a specific chapter/page by its ID.
|
||||
/// </summary>
|
||||
Task<string?> GetChapterContentAsync(string chapterId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a chunk of text retrieved from the semantic vector database.
|
||||
/// </summary>
|
||||
public record VectorChunk(string Content, string EbookId, double Score, string MetadataJson = "", string BookTitle = "", string ChapterTitle = "");
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for performing semantic vector searches, isolating Qdrant gRPC dependencies from the Application layer.
|
||||
/// </summary>
|
||||
public interface IVectorSearchStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Searches the entire global catalog (filtered by tenant) for the best semantic matches.
|
||||
/// </summary>
|
||||
Task<List<VectorChunk>> SearchGlobalAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Searches within a whitelist of owned book IDs for the best semantic matches.
|
||||
/// </summary>
|
||||
Task<List<VectorChunk>> SearchLocalAsync(string queryText, string tenantId, List<Guid> whitelistedBookIds, int limit, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Searches the entire global catalog (filtered by tenant) for the best semantic matches, excluding a specific book ID.
|
||||
/// </summary>
|
||||
Task<List<VectorChunk>> SearchGlobalExcludeAsync(string queryText, string tenantId, Guid excludeBookId, int limit, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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<Result<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default);
|
||||
Task<Result<List<SemanticSearchResultDto>>> SearchLibrarySemanticallyAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default);
|
||||
Task<Result<GroundedResponseDto>> AskQuestionAsync(string question, string tenantId, Guid? ebookId = null, int limit = 5, CancellationToken cancellationToken = default);
|
||||
Task<Result<IntelligenceResponse>> GetGlobalIntelligenceAsync(string queryText, string userId, string tenantId, CancellationToken cancellationToken = default);
|
||||
Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<GraphNodeDto>))]
|
||||
[JsonSerializable(typeof(List<GraphLinkDto>))]
|
||||
[JsonSerializable(typeof(GetGlobalIntelligenceRequest))]
|
||||
[JsonSerializable(typeof(IntelligenceResponse))]
|
||||
[JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.ContextualRecommendationResponse))]
|
||||
[JsonSerializable(typeof(NexusReader.Application.Queries.Recommendations.RecommendationDto))]
|
||||
[JsonSerializable(typeof(List<NexusReader.Application.Queries.Recommendations.RecommendationDto>))]
|
||||
public partial class AppJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace NexusReader.Application.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Configurations for the monetization engine, controlling the thresholds at which
|
||||
/// search queries trigger paywalls.
|
||||
/// </summary>
|
||||
public class RagMonetizationOptions
|
||||
{
|
||||
public const string SectionName = "RagMonetization";
|
||||
|
||||
/// <summary>
|
||||
/// The baseline score threshold above which global content might trigger a paywall if there is no local content.
|
||||
/// Default: 0.45.
|
||||
/// </summary>
|
||||
public double BaselineThreshold { get; set; } = 0.45;
|
||||
|
||||
/// <summary>
|
||||
/// The similarity gap (Delta) required between global and local content to trigger an upgrade paywall.
|
||||
/// Default: 0.15.
|
||||
/// </summary>
|
||||
public double DeltaThreshold { get; set; } = 0.15;
|
||||
|
||||
/// <summary>
|
||||
/// The absolute score required from global content to trigger an upgrade paywall.
|
||||
/// Default: 0.70.
|
||||
/// </summary>
|
||||
public double UpgradeThreshold { get; set; } = 0.70;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// MediatR query to request global intelligence hybrid Q&A context.
|
||||
/// </summary>
|
||||
public record GetGlobalIntelligenceQuery(string QueryText, string UserId, string TenantId = "global")
|
||||
: IRequest<Result<IntelligenceResponse>>;
|
||||
|
||||
/// <summary>
|
||||
/// Request schema for global hybrid search queries.
|
||||
/// </summary>
|
||||
public record GetGlobalIntelligenceRequest(string QueryText);
|
||||
|
||||
/// <summary>
|
||||
/// Response schema returning generated AI text, paywall status, and locked publishing details.
|
||||
/// </summary>
|
||||
public record IntelligenceResponse(
|
||||
string ResponseText,
|
||||
bool HasPaywall,
|
||||
Guid? LockedBookId,
|
||||
string? LockedBookTitle,
|
||||
List<CitationDto>? Citations = null);
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="GetGlobalIntelligenceQuery"/> by performing local/global dual searches,
|
||||
/// executing monetization rules, and invoking Chat AI with appropriate gating logic.
|
||||
/// </summary>
|
||||
public class GetGlobalIntelligenceQueryHandler : IRequestHandler<GetGlobalIntelligenceQuery, Result<IntelligenceResponse>>
|
||||
{
|
||||
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<RagMonetizationOptions> options)
|
||||
{
|
||||
_userLibraryStore = userLibraryStore;
|
||||
_vectorSearchStore = vectorSearchStore;
|
||||
_chatClient = chatClient;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<Result<IntelligenceResponse>> 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<VectorChunk> globalChunks = new();
|
||||
List<VectorChunk> 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<string>();
|
||||
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<Microsoft.Extensions.AI.ChatMessage>
|
||||
{
|
||||
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<CitationDto>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
|
||||
namespace NexusReader.Application.Queries.Recommendations;
|
||||
|
||||
/// <summary>
|
||||
/// MediatR query to fetch contextual recommendations based on the user's active reading state.
|
||||
/// </summary>
|
||||
public record GetContextualRecommendationsQuery(string UserId)
|
||||
: IRequest<Result<ContextualRecommendationResponse>>;
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO containing contextual recommendations.
|
||||
/// </summary>
|
||||
public record ContextualRecommendationResponse(List<RecommendationDto> Recommendations);
|
||||
|
||||
/// <summary>
|
||||
/// Individual contextual recommendation details.
|
||||
/// </summary>
|
||||
public record RecommendationDto(
|
||||
string BookTitle,
|
||||
string ChapterTitle,
|
||||
int MatchPercentage,
|
||||
bool IsPremiumUpsell,
|
||||
Guid TargetBookId
|
||||
);
|
||||
Reference in New Issue
Block a user