feat(recommendations): implement contextual recommendation engine #76
@@ -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,27 @@
|
|||||||
|
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 = "");
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using FluentResults;
|
using FluentResults;
|
||||||
using NexusReader.Application.DTOs.AI;
|
using NexusReader.Application.DTOs.AI;
|
||||||
|
using NexusReader.Application.Queries.Intelligence;
|
||||||
|
|
||||||
namespace NexusReader.Application.Abstractions.Services;
|
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<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<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<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);
|
Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Collections.Generic;
|
||||||
using NexusReader.Application.Queries.Graph;
|
using NexusReader.Application.Queries.Graph;
|
||||||
|
using NexusReader.Application.Queries.Intelligence;
|
||||||
|
|
||||||
namespace NexusReader.Application.Common;
|
namespace NexusReader.Application.Common;
|
||||||
|
|
||||||
@@ -9,6 +11,8 @@ namespace NexusReader.Application.Common;
|
|||||||
[JsonSerializable(typeof(GraphDataDto))]
|
[JsonSerializable(typeof(GraphDataDto))]
|
||||||
[JsonSerializable(typeof(List<GraphNodeDto>))]
|
[JsonSerializable(typeof(List<GraphNodeDto>))]
|
||||||
[JsonSerializable(typeof(List<GraphLinkDto>))]
|
[JsonSerializable(typeof(List<GraphLinkDto>))]
|
||||||
|
[JsonSerializable(typeof(GetGlobalIntelligenceRequest))]
|
||||||
|
[JsonSerializable(typeof(IntelligenceResponse))]
|
||||||
public partial class AppJsonContext : JsonSerializerContext
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
|
using NexusReader.Application.Common;
|
||||||
using GeminiDotnet;
|
using GeminiDotnet;
|
||||||
using GeminiDotnet.Extensions.AI;
|
using GeminiDotnet.Extensions.AI;
|
||||||
using NexusReader.Data.Persistence;
|
using NexusReader.Data.Persistence;
|
||||||
@@ -76,6 +77,7 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
services.Configure<AiSettings>(configuration.GetSection(AiSettings.SectionName));
|
services.Configure<AiSettings>(configuration.GetSection(AiSettings.SectionName));
|
||||||
services.Configure<StripeSettings>(configuration.GetSection(StripeSettings.SectionName));
|
services.Configure<StripeSettings>(configuration.GetSection(StripeSettings.SectionName));
|
||||||
|
services.Configure<RagMonetizationOptions>(configuration.GetSection(RagMonetizationOptions.SectionName));
|
||||||
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
|
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER")
|
if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER")
|
||||||
@@ -127,6 +129,8 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IEbookRepository, EbookRepository>();
|
services.AddScoped<IEbookRepository, EbookRepository>();
|
||||||
services.AddScoped<IQuizResultRepository, QuizResultRepository>();
|
services.AddScoped<IQuizResultRepository, QuizResultRepository>();
|
||||||
services.AddScoped<IConceptsMapReadRepository, ConceptsMapReadRepository>();
|
services.AddScoped<IConceptsMapReadRepository, ConceptsMapReadRepository>();
|
||||||
|
services.AddScoped<IUserLibraryStore, UserLibraryStore>();
|
||||||
|
services.AddScoped<IVectorSearchStore, VectorSearchStore>();
|
||||||
|
|
||||||
// Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper)
|
// Fix #2: SignalR broadcaster (scoped, wraps IHubContext which is itself a singleton wrapper)
|
||||||
services.AddScoped<ISyncBroadcaster, SignalRSyncBroadcaster>();
|
services.AddScoped<ISyncBroadcaster, SignalRSyncBroadcaster>();
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NexusReader.Application.Abstractions.Persistence;
|
||||||
|
using NexusReader.Data.Persistence;
|
||||||
|
|
||||||
|
namespace NexusReader.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EF Core implementation of <see cref="IUserLibraryStore"/> using <see cref="AppDbContext"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class UserLibraryStore : IUserLibraryStore
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _context;
|
||||||
|
|
||||||
|
public UserLibraryStore(AppDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<List<Guid>> GetOwnedBookIdsAsync(string userId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.Ebooks
|
||||||
|
.Where(e => e.UserId == userId)
|
||||||
|
.Select(e => e.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Dictionary<Guid, string>> GetBookTitlesAsync(List<Guid> bookIds, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (bookIds == null || !bookIds.Any())
|
||||||
|
{
|
||||||
|
return new Dictionary<Guid, string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _context.Ebooks
|
||||||
|
.Where(e => bookIds.Contains(e.Id))
|
||||||
|
.ToDictionaryAsync(e => e.Id, e => e.Title, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<float[]> GenerateEmbeddingAsync(string text, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var response = await _retryPipeline.ExecuteAsync(async ct =>
|
||||||
|
await _embeddingGenerator.GenerateAsync(
|
||||||
|
new[] { text },
|
||||||
|
new EmbeddingGenerationOptions { Dimensions = 768 },
|
||||||
|
cancellationToken: ct), cancellationToken);
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
|
||||||
|
|
||||||
|
var response = await _qdrantClient.SearchAsync(
|
||||||
|
collectionName: "knowledge_units",
|
||||||
|
vector: queryVector,
|
||||||
|
filter: filter,
|
||||||
|
limit: (ulong)limit,
|
||||||
|
cancellationToken: cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
return new VectorChunk(content, ebookId, point.Score, metadataJson);
|
||||||
|
}).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)
|
||||||
|
{
|
||||||
|
await _qdrantClient.CreateCollectionAsync(
|
||||||
|
collectionName: collectionName,
|
||||||
|
vectorsConfig: new Qdrant.Client.Grpc.VectorParams
|
||||||
|
{
|
||||||
|
Size = 768,
|
||||||
|
Distance = Distance.Cosine
|
||||||
|
},
|
||||||
|
cancellationToken: cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Ignore concurrent creation conflicts in multi-threaded/concurrent flows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ using FluentResults;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using MediatR;
|
||||||
|
using NexusReader.Application.Queries.Intelligence;
|
||||||
using Microsoft.ML.Tokenizers;
|
using Microsoft.ML.Tokenizers;
|
||||||
using NexusReader.Application.Abstractions.Services;
|
using NexusReader.Application.Abstractions.Services;
|
||||||
using NexusReader.Application.DTOs.AI;
|
using NexusReader.Application.DTOs.AI;
|
||||||
@@ -33,6 +35,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
private readonly ILogger<KnowledgeService> _logger;
|
private readonly ILogger<KnowledgeService> _logger;
|
||||||
private readonly QdrantClient _qdrantClient;
|
private readonly QdrantClient _qdrantClient;
|
||||||
private readonly IDriver _neo4jDriver;
|
private readonly IDriver _neo4jDriver;
|
||||||
|
private readonly IMediator _mediator;
|
||||||
private const string PromptVersion = "1.7";
|
private const string PromptVersion = "1.7";
|
||||||
private static readonly ConcurrentDictionary<string, Lazy<Task<Result<KnowledgePacket>>>> _activeRequests = new();
|
private static readonly ConcurrentDictionary<string, Lazy<Task<Result<KnowledgePacket>>>> _activeRequests = new();
|
||||||
private static readonly SemaphoreSlim _collectionSemaphore = new(1, 1);
|
private static readonly SemaphoreSlim _collectionSemaphore = new(1, 1);
|
||||||
@@ -45,7 +48,8 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
IOptions<AiSettings> settings,
|
IOptions<AiSettings> settings,
|
||||||
ILogger<KnowledgeService> logger,
|
ILogger<KnowledgeService> logger,
|
||||||
QdrantClient qdrantClient,
|
QdrantClient qdrantClient,
|
||||||
IDriver neo4jDriver)
|
IDriver neo4jDriver,
|
||||||
|
IMediator mediator)
|
||||||
{
|
{
|
||||||
_chatClient = chatClient;
|
_chatClient = chatClient;
|
||||||
_embeddingGenerator = embeddingGenerator;
|
_embeddingGenerator = embeddingGenerator;
|
||||||
@@ -55,6 +59,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
_qdrantClient = qdrantClient;
|
_qdrantClient = qdrantClient;
|
||||||
_neo4jDriver = neo4jDriver;
|
_neo4jDriver = neo4jDriver;
|
||||||
|
_mediator = mediator;
|
||||||
// Use Tiktoken (cl100k_base) which is a standard for modern LLMs and provides
|
// Use Tiktoken (cl100k_base) which is a standard for modern LLMs and provides
|
||||||
// a very reliable estimation for token usage in Gemini-based workloads.
|
// a very reliable estimation for token usage in Gemini-based workloads.
|
||||||
_tokenizer = TiktokenTokenizer.CreateForModel("gpt-4");
|
_tokenizer = TiktokenTokenizer.CreateForModel("gpt-4");
|
||||||
@@ -1187,6 +1192,12 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<IntelligenceResponse>> GetGlobalIntelligenceAsync(string queryText, string userId, string tenantId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _mediator.Send(new GetGlobalIntelligenceQuery(queryText, userId, tenantId), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
private int EstimateTokenCount(string text)
|
private int EstimateTokenCount(string text)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(text)) return 0;
|
if (string.IsNullOrEmpty(text)) return 0;
|
||||||
|
|||||||
@@ -197,56 +197,80 @@
|
|||||||
|
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global";
|
var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global";
|
||||||
|
var userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? string.Empty;
|
||||||
|
|
||||||
var result = await KnowledgeService.AskQuestionAsync(userQuestion, tenantId, ebookId);
|
if (ebookId == null)
|
||||||
if (result.IsSuccess)
|
|
||||||
{
|
{
|
||||||
var response = result.Value;
|
var result = await KnowledgeService.GetGlobalIntelligenceAsync(userQuestion, userId, tenantId);
|
||||||
|
if (result.IsSuccess)
|
||||||
// --- Paywall Simulation Logic ---
|
|
||||||
// If the user does not own "Architektura .NET 10 i Ekosystem Blazor"
|
|
||||||
// and the question refers to Blazor/architecture/C#/etc,
|
|
||||||
// we simulate that the RAG search pulled a citation from that unowned book.
|
|
||||||
var hasBlazorBook = _books != null && _books.Any(b => b.Title.Contains("Architektura .NET 10", StringComparison.OrdinalIgnoreCase));
|
|
||||||
var isSimulatingPaywall = !hasBlazorBook &&
|
|
||||||
(userQuestion.Contains("blazor", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
userQuestion.Contains("net", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
userQuestion.Contains("architektura", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
userQuestion.Contains("c#", StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
if (isSimulatingPaywall)
|
|
||||||
{
|
{
|
||||||
var mockCitationId = Guid.NewGuid().ToString();
|
var response = result.Value;
|
||||||
var mockCitation = new CitationDto
|
var chatMsg = CreateGlobalAiChatMessage(response, _books);
|
||||||
{
|
_chatMessages.Add(chatMsg);
|
||||||
CitationId = mockCitationId,
|
}
|
||||||
SourceBook = "Architektura .NET 10 i Ekosystem Blazor",
|
else
|
||||||
Author = "Nexus Architect",
|
{
|
||||||
Snippet = "Konfiguracja kontenera dependency injection w standardzie .NET 10 Blazor przy użyciu Native AOT wymaga wyeliminowania dynamicznej refleksji.",
|
var errMsg = $"Error: {result.Errors.FirstOrDefault()?.Message ?? "An error occurred."}";
|
||||||
PageNumber = 42
|
_chatMessages.Add(new ChatMessage
|
||||||
};
|
{
|
||||||
|
Sender = "AI",
|
||||||
if (response.Citations == null)
|
Text = errMsg,
|
||||||
{
|
Segments = new List<ResponseSegment> { new ResponseSegment { Text = errMsg, IsCitation = false } }
|
||||||
response.Citations = new List<CitationDto>();
|
});
|
||||||
}
|
|
||||||
response.Citations.Add(mockCitation);
|
|
||||||
|
|
||||||
response.Answer = "Aby poprawnie skonfigurować architekturę .NET 10 Blazor pod Native AOT, należy unikać dynamicznego ładowania typów. Konieczne jest używanie generatorów kodu źródłowego (Source Generators) do rejestracji zależności w kontenerze DI. W ten sposób kompilator AOT może przeanalizować graf zależności podczas kompilacji. [Source ID: " + mockCitationId + "]\n\nPełny przykładowy kod konfiguracji Program.cs wygląda następująco:\n```csharp\nvar builder = WebApplication.CreateBuilder(args);\nbuilder.Services.AddSingleton<IService, AotService>();\n```";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var chatMsg = CreateAiChatMessage(response, _books);
|
|
||||||
_chatMessages.Add(chatMsg);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var errMsg = $"Error: {result.Errors.FirstOrDefault()?.Message ?? "An error occurred."}";
|
var result = await KnowledgeService.AskQuestionAsync(userQuestion, tenantId, ebookId);
|
||||||
_chatMessages.Add(new ChatMessage
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
Sender = "AI",
|
var response = result.Value;
|
||||||
Text = errMsg,
|
|
||||||
Segments = new List<ResponseSegment> { new ResponseSegment { Text = errMsg, IsCitation = false } }
|
// --- Paywall Simulation Logic ---
|
||||||
});
|
// If the user does not own "Architektura .NET 10 i Ekosystem Blazor"
|
||||||
|
// and the question refers to Blazor/architecture/C#/etc,
|
||||||
|
// we simulate that the RAG search pulled a citation from that unowned book.
|
||||||
|
var hasBlazorBook = _books != null && _books.Any(b => b.Title.Contains("Architektura .NET 10", StringComparison.OrdinalIgnoreCase));
|
||||||
|
var isSimulatingPaywall = !hasBlazorBook &&
|
||||||
|
(userQuestion.Contains("blazor", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
userQuestion.Contains("net", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
userQuestion.Contains("architektura", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
userQuestion.Contains("c#", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (isSimulatingPaywall)
|
||||||
|
{
|
||||||
|
var mockCitationId = Guid.NewGuid().ToString();
|
||||||
|
var mockCitation = new CitationDto
|
||||||
|
{
|
||||||
|
CitationId = mockCitationId,
|
||||||
|
SourceBook = "Architektura .NET 10 i Ekosystem Blazor",
|
||||||
|
Author = "Nexus Architect",
|
||||||
|
Snippet = "Konfiguracja kontenera dependency injection w standardzie .NET 10 Blazor przy użyciu Native AOT wymaga wyeliminowania dynamicznej refleksji.",
|
||||||
|
PageNumber = 42
|
||||||
|
};
|
||||||
|
|
||||||
|
if (response.Citations == null)
|
||||||
|
{
|
||||||
|
response.Citations = new List<CitationDto>();
|
||||||
|
}
|
||||||
|
response.Citations.Add(mockCitation);
|
||||||
|
|
||||||
|
response.Answer = "Aby poprawnie skonfigurować architekturę .NET 10 Blazor pod Native AOT, należy unikać dynamicznego ładowania typów. Konieczne jest używanie generatorów kodu źródłowego (Source Generators) do rejestracji zależności w kontenerze DI. W ten sposób kompilator AOT może przeanalizować graf zależności podczas kompilacji. [Source ID: " + mockCitationId + "]\n\nPełny przykładowy kod konfiguracji Program.cs wygląda następująco:\n```csharp\nvar builder = WebApplication.CreateBuilder(args);\nbuilder.Services.AddSingleton<IService, AotService>();\n```";
|
||||||
|
}
|
||||||
|
|
||||||
|
var chatMsg = CreateAiChatMessage(response, _books);
|
||||||
|
_chatMessages.Add(chatMsg);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var errMsg = $"Error: {result.Errors.FirstOrDefault()?.Message ?? "An error occurred."}";
|
||||||
|
_chatMessages.Add(new ChatMessage
|
||||||
|
{
|
||||||
|
Sender = "AI",
|
||||||
|
Text = errMsg,
|
||||||
|
Segments = new List<ResponseSegment> { new ResponseSegment { Text = errMsg, IsCitation = false } }
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -301,6 +325,36 @@
|
|||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ChatMessage CreateGlobalAiChatMessage(NexusReader.Application.Queries.Intelligence.IntelligenceResponse response, List<LastReadBookDto>? ownedBooks)
|
||||||
|
{
|
||||||
|
var msg = new ChatMessage
|
||||||
|
{
|
||||||
|
Sender = "AI",
|
||||||
|
Text = response.ResponseText,
|
||||||
|
Segments = ParseSegments(response.ResponseText),
|
||||||
|
Citations = response.Citations ?? new List<CitationDto>()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (response.HasPaywall)
|
||||||
|
{
|
||||||
|
msg.IsPaywalled = true;
|
||||||
|
msg.SourceBookTitle = response.LockedBookTitle ?? string.Empty;
|
||||||
|
|
||||||
|
// Split sentences *once* during creation for Native AOT rendering performance
|
||||||
|
var (clear, _) = SplitSentences(response.ResponseText);
|
||||||
|
msg.ClearText = clear;
|
||||||
|
msg.BlurredTeaserText = "\n\n// [Blokada Paywall] Pełna treść oraz kody źródłowe C# zostały zablokowane.\npublic class ArchitekturaProcessor {\n public async Task ProcessAsync() {\n // Zaimplementuj wzorzec CQRS...\n throw new PaywallException(\"Kup publikację w katalogu\");\n }\n}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
msg.IsPaywalled = false;
|
||||||
|
msg.ClearText = response.ResponseText;
|
||||||
|
msg.BlurredTeaserText = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
private (string ClearText, string BlurredText) SplitSentences(string text)
|
private (string ClearText, string BlurredText) SplitSentences(string text)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(text)) return (string.Empty, string.Empty);
|
if (string.IsNullOrEmpty(text)) return (string.Empty, string.Empty);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ using System.Net.Http.Json;
|
|||||||
using FluentResults;
|
using FluentResults;
|
||||||
using NexusReader.Application.Abstractions.Services;
|
using NexusReader.Application.Abstractions.Services;
|
||||||
using NexusReader.Application.DTOs.AI;
|
using NexusReader.Application.DTOs.AI;
|
||||||
|
using NexusReader.Application.Common;
|
||||||
|
using NexusReader.Application.Queries.Intelligence;
|
||||||
|
|
||||||
namespace NexusReader.Web.Client.Services;
|
namespace NexusReader.Web.Client.Services;
|
||||||
|
|
||||||
@@ -113,6 +115,34 @@ public class WasmKnowledgeService : IKnowledgeService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Result<IntelligenceResponse>> GetGlobalIntelligenceAsync(string queryText, string userId, string tenantId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _httpClient.PostAsJsonAsync(
|
||||||
|
"/api/intelligence",
|
||||||
|
new GetGlobalIntelligenceRequest(queryText),
|
||||||
|
AppJsonContext.Default.GetGlobalIntelligenceRequest,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<IntelligenceResponse>(
|
||||||
|
AppJsonContext.Default.IntelligenceResponse,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
return result != null ? Result.Ok(result) : Result.Fail("Failed to deserialize global intelligence response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
return Result.Fail($"Server error ({response.StatusCode}): {errorBody}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Fail(new Error($"Network error: {ex.Message}").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<Result<KnowledgePacket>> CallKnowledgeApiAsync(string endpoint, string text, Guid? ebookId, CancellationToken cancellationToken)
|
private async Task<Result<KnowledgePacket>> CallKnowledgeApiAsync(string endpoint, string text, Guid? ebookId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ builder.Services.AddRazorComponents()
|
|||||||
.AddInteractiveServerComponents()
|
.AddInteractiveServerComponents()
|
||||||
.AddInteractiveWebAssemblyComponents();
|
.AddInteractiveWebAssemblyComponents();
|
||||||
|
|
||||||
|
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||||
|
{
|
||||||
|
options.SerializerOptions.TypeInfoResolverChain.Insert(0, NexusReader.Application.Common.AppJsonContext.Default);
|
||||||
|
});
|
||||||
|
|
||||||
// Enable detailed circuit errors for Server‑Side Blazor components
|
// Enable detailed circuit errors for Server‑Side Blazor components
|
||||||
builder.Services.AddServerSideBlazor()
|
builder.Services.AddServerSideBlazor()
|
||||||
.AddCircuitOptions(options =>
|
.AddCircuitOptions(options =>
|
||||||
@@ -415,6 +420,23 @@ knowledgeApi.MapDelete("/", async (IKnowledgeService knowledgeService) =>
|
|||||||
return Results.BadRequest(errorMsg);
|
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.MapPost("/api/library/ingest", async ([FromBody] IngestEbookRequest request, ClaimsPrincipal user, IMediator mediator) =>
|
app.MapPost("/api/library/ingest", async ([FromBody] IngestEbookRequest request, ClaimsPrincipal user, IMediator mediator) =>
|
||||||
{
|
{
|
||||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
|||||||
@@ -9,5 +9,10 @@
|
|||||||
"AllowRegistration": false,
|
"AllowRegistration": false,
|
||||||
"AllowPasswordReset": false
|
"AllowPasswordReset": false
|
||||||
},
|
},
|
||||||
|
"RagMonetization": {
|
||||||
|
"BaselineThreshold": 0.45,
|
||||||
|
"DeltaThreshold": 0.15,
|
||||||
|
"UpgradeThreshold": 0.70
|
||||||
|
},
|
||||||
"ApiBaseUrl": "http://localhost:5104"
|
"ApiBaseUrl": "http://localhost:5104"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,5 +31,10 @@
|
|||||||
"MaxOutputTokens": 8192
|
"MaxOutputTokens": 8192
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"RagMonetization": {
|
||||||
|
"BaselineThreshold": 0.45,
|
||||||
|
"DeltaThreshold": 0.15,
|
||||||
|
"UpgradeThreshold": 0.70
|
||||||
|
},
|
||||||
"ApiBaseUrl": "http://localhost:5104"
|
"ApiBaseUrl": "http://localhost:5104"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user