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:
@@ -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 Server‑Side 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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -31,5 +31,10 @@
|
||||
"MaxOutputTokens": 8192
|
||||
}
|
||||
},
|
||||
"ApiBaseUrl": "http://localhost:5000"
|
||||
"RagMonetization": {
|
||||
"BaselineThreshold": 0.45,
|
||||
"DeltaThreshold": 0.15,
|
||||
"UpgradeThreshold": 0.70
|
||||
},
|
||||
"ApiBaseUrl": "http://localhost:5104"
|
||||
}
|
||||
Reference in New Issue
Block a user