Files
Nexus.Reader/src/NexusReader.Application/Queries/Intelligence/GetGlobalIntelligenceQuery.cs
T
mjasin 93133a49b6 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
2026-06-06 10:55:58 +02:00

223 lines
9.4 KiB
C#

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));
}
}
}