feat(intelligence): implement global hybrid search engine and monetization logic

- Created IUserLibraryStore and IVectorSearchStore abstractions to decouple relational DB and Qdrant gRPC logic from Application Layer
- Implemented MediatR GetGlobalIntelligenceQuery with value-first teaser RAG monetization logic
- Registered new request and response DTOs in AppJsonContext for Native AOT source-generated serialization
- Bound RagMonetizationOptions via IOptions pattern in appsettings.json configuration
- Added POST /api/intelligence endpoint on server and implemented GetGlobalIntelligenceAsync in WASM client service
- Refactored Intelligence.razor to consume the backend-driven global hybrid search Q&A engine
This commit is contained in:
2026-06-06 10:55:58 +02:00
parent faf6ec826e
commit 93133a49b6
15 changed files with 692 additions and 43 deletions
@@ -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 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,8 @@ namespace NexusReader.Application.Common;
[JsonSerializable(typeof(GraphDataDto))]
[JsonSerializable(typeof(List<GraphNodeDto>))]
[JsonSerializable(typeof(List<GraphLinkDto>))]
[JsonSerializable(typeof(GetGlobalIntelligenceRequest))]
[JsonSerializable(typeof(IntelligenceResponse))]
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.EntityFrameworkCore;
using Microsoft.Extensions.AI;
using NexusReader.Application.Common;
using GeminiDotnet;
using GeminiDotnet.Extensions.AI;
using NexusReader.Data.Persistence;
@@ -76,6 +77,7 @@ public static class DependencyInjection
services.Configure<AiSettings>(configuration.GetSection(AiSettings.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();
if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER")
@@ -127,6 +129,8 @@ public static class DependencyInjection
services.AddScoped<IEbookRepository, EbookRepository>();
services.AddScoped<IQuizResultRepository, QuizResultRepository>();
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)
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.Extensions.AI;
using Microsoft.Extensions.Logging;
using MediatR;
using NexusReader.Application.Queries.Intelligence;
using Microsoft.ML.Tokenizers;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.DTOs.AI;
@@ -33,6 +35,7 @@ public class KnowledgeService : IKnowledgeService
private readonly ILogger<KnowledgeService> _logger;
private readonly QdrantClient _qdrantClient;
private readonly IDriver _neo4jDriver;
private readonly IMediator _mediator;
private const string PromptVersion = "1.7";
private static readonly ConcurrentDictionary<string, Lazy<Task<Result<KnowledgePacket>>>> _activeRequests = new();
private static readonly SemaphoreSlim _collectionSemaphore = new(1, 1);
@@ -45,7 +48,8 @@ public class KnowledgeService : IKnowledgeService
IOptions<AiSettings> settings,
ILogger<KnowledgeService> logger,
QdrantClient qdrantClient,
IDriver neo4jDriver)
IDriver neo4jDriver,
IMediator mediator)
{
_chatClient = chatClient;
_embeddingGenerator = embeddingGenerator;
@@ -55,6 +59,7 @@ public class KnowledgeService : IKnowledgeService
_logger = logger;
_qdrantClient = qdrantClient;
_neo4jDriver = neo4jDriver;
_mediator = mediator;
// Use Tiktoken (cl100k_base) which is a standard for modern LLMs and provides
// a very reliable estimation for token usage in Gemini-based workloads.
_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)
{
if (string.IsNullOrEmpty(text)) return 0;
@@ -197,7 +197,30 @@
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global";
var userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? string.Empty;
if (ebookId == null)
{
var result = await KnowledgeService.GetGlobalIntelligenceAsync(userQuestion, userId, tenantId);
if (result.IsSuccess)
{
var response = result.Value;
var chatMsg = CreateGlobalAiChatMessage(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 } }
});
}
}
else
{
var result = await KnowledgeService.AskQuestionAsync(userQuestion, tenantId, ebookId);
if (result.IsSuccess)
{
@@ -249,6 +272,7 @@
});
}
}
}
catch (Exception ex)
{
var errMsg = $"Network/API Error: {ex.Message}";
@@ -301,6 +325,36 @@
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)
{
if (string.IsNullOrEmpty(text)) return (string.Empty, string.Empty);
@@ -2,6 +2,8 @@ using System.Net.Http.Json;
using FluentResults;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.DTOs.AI;
using NexusReader.Application.Common;
using NexusReader.Application.Queries.Intelligence;
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)
{
try
+22
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 =>
@@ -415,6 +420,23 @@ 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.MapPost("/api/library/ingest", async ([FromBody] IngestEbookRequest request, ClaimsPrincipal user, IMediator mediator) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
@@ -9,5 +9,10 @@
"AllowRegistration": false,
"AllowPasswordReset": false
},
"RagMonetization": {
"BaselineThreshold": 0.45,
"DeltaThreshold": 0.15,
"UpgradeThreshold": 0.70
},
"ApiBaseUrl": "http://localhost:5104"
}
+5
View File
@@ -31,5 +31,10 @@
"MaxOutputTokens": 8192
}
},
"RagMonetization": {
"BaselineThreshold": 0.45,
"DeltaThreshold": 0.15,
"UpgradeThreshold": 0.70
},
"ApiBaseUrl": "http://localhost:5104"
}