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
+80
View File
@@ -36,6 +36,11 @@ builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents();
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, NexusReader.Application.Common.AppJsonContext.Default);
});
// Enable detailed circuit errors for ServerSide Blazor components
builder.Services.AddServerSideBlazor()
.AddCircuitOptions(options =>
@@ -57,6 +62,7 @@ builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
builder.Services.AddScoped<IReaderStateService, ReaderStateService>();
builder.Services.AddScoped<ILibraryStateService, LibraryStateService>();
builder.Services.AddScoped<KnowledgeCoordinator>();
builder.Services.AddScoped<ISyncService, SyncService>();
@@ -414,6 +420,37 @@ knowledgeApi.MapDelete("/", async (IKnowledgeService knowledgeService) =>
return Results.BadRequest(errorMsg);
});
app.MapPost("/api/intelligence", async (
[FromBody] NexusReader.Application.Queries.Intelligence.GetGlobalIntelligenceRequest request,
ClaimsPrincipal user,
IMediator mediator) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
var tenantId = user.FindFirstValue("TenantId") ?? "global";
var result = await mediator.Send(new NexusReader.Application.Queries.Intelligence.GetGlobalIntelligenceQuery(request.QueryText, userId, tenantId));
if (result.IsSuccess) return Results.Ok(result.Value);
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Failed to execute global intelligence query";
return Results.BadRequest(errorMsg);
}).RequireAuthorization();
app.MapGet("/api/recommendations", async (
ClaimsPrincipal user,
IMediator mediator) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
var result = await mediator.Send(new NexusReader.Application.Queries.Recommendations.GetContextualRecommendationsQuery(userId));
if (result.IsSuccess) return Results.Ok(result.Value);
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Failed to fetch contextual recommendations";
return Results.BadRequest(errorMsg);
}).RequireAuthorization();
app.MapPost("/api/library/ingest", async ([FromBody] IngestEbookRequest request, ClaimsPrincipal user, IMediator mediator) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
@@ -454,6 +491,48 @@ app.MapGet("/api/library/books", async (ClaimsPrincipal user, IMediator mediator
return Results.BadRequest(errorMsg);
}).RequireAuthorization();
app.MapPost("/api/library/purchase", async (
ClaimsPrincipal user,
[FromBody] PurchaseBookRequest request,
IDbContextFactory<AppDbContext> dbContextFactory) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
using var dbContext = await dbContextFactory.CreateDbContextAsync();
// Find or create author
var authorName = "Nexus Architect";
var author = await dbContext.Authors.FirstOrDefaultAsync(a => a.Name == authorName);
if (author == null)
{
author = new Author { Name = authorName };
dbContext.Authors.Add(author);
await dbContext.SaveChangesAsync();
}
// Check if the book already exists for the user
var bookExists = await dbContext.Ebooks.AnyAsync(e => e.UserId == userId && e.Title == request.Title);
if (!bookExists)
{
var newBook = new Ebook
{
Title = request.Title,
AuthorId = author.Id,
UserId = userId,
FilePath = "wwwroot/assets/book.epub",
AddedDate = DateTime.UtcNow,
Progress = 0,
Description = "Zaawansowany kurs budowania skalowalnych SaaS z Native AOT, CQRS, MediatR, FluentResults i izolowanym systemem stylów Blazor CSS.",
IsReadyForReading = true
};
dbContext.Ebooks.Add(newBook);
await dbContext.SaveChangesAsync();
}
return Results.Ok();
}).RequireAuthorization();
app.MapGet("/api/book/{bookId:guid}/concepts-map", async (
Guid bookId,
ClaimsPrincipal user,
@@ -729,3 +808,4 @@ public record KnowledgeRequest(string Text, Guid? EbookId = null);
public record GroundednessRequest(string Answer, string Context);
public record SemanticSearchRequest(string QueryText, int Limit = 5);
public record AskQuestionRequest(string Question, Guid? EbookId = null, int Limit = 5);
public record PurchaseBookRequest(string Title);
+6 -1
View File
@@ -9,5 +9,10 @@
"AllowRegistration": false,
"AllowPasswordReset": false
},
"ApiBaseUrl": "http://localhost:5000"
"RagMonetization": {
"BaselineThreshold": 0.45,
"DeltaThreshold": 0.15,
"UpgradeThreshold": 0.70
},
"ApiBaseUrl": "http://localhost:5104"
}
+6 -1
View File
@@ -31,5 +31,10 @@
"MaxOutputTokens": 8192
}
},
"ApiBaseUrl": "http://localhost:5000"
"RagMonetization": {
"BaselineThreshold": 0.45,
"DeltaThreshold": 0.15,
"UpgradeThreshold": 0.70
},
"ApiBaseUrl": "http://localhost:5104"
}