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:
2026-06-06 13:38:48 +00:00
committed by Marek Jaisński
parent bcd5daa3a0
commit 1d6862016d
42 changed files with 2737 additions and 337 deletions
@@ -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));
}
}
}