Files
Nexus.Reader/src/NexusReader.Infrastructure/Services/KnowledgeService.cs
T
Antigravity a0bf6c15f4 feat(search/rag): implement NexusSearchBox, dynamic Qdrant collection auto-provisioning, batch vector ingestion, mobile Serilog logging, and resolve 401 auth handler error (#51)
Resolves #52

This Pull Request introduces the **NexusSearchBox** search feature with premium unified styling, implements a robust **dynamic Qdrant collection auto-provisioning and batch-vector ingestion pipeline**, integrates a unified **Serilog logging infrastructure** for the Blazor Hybrid environment (MAUI), and resolves the **401 Unauthorized API header propagation error** inside mobile builds.

### 🚀 Key Implementations

#### 1. Premium `NexusSearchBox` & Semantic Search UI
* **NexusSearchBox Component:** Created an elegant search-as-you-type search box with smooth key navigation, quick-clearing, and seamless dynamic styling.
* **Unified Aesthetics:** Refactored the search box isolated styling to align perfectly with the dashboard's design system using glassmorphism, `--nexus-neon` token gradients, and smooth pulse/fade animations.
* **Semantic Search Integration:** Integrated semantic search query dispatching (`SearchLibrarySemanticallyQuery`) and wired up navigation seamlessly through the updated `ReaderNavigationService`.
* **Tests Hardening:** Added/adapted query assertions in `QueryTests.cs` to guarantee safe parameterization and error boundary mapping.

#### 2. Qdrant Collection Provisioning & Vector Ingestion
* **Dynamic Auto-Provisioning:** Implemented dynamic checking and lazy-creation of the `knowledge_units` collection using 768 dimensions and Cosine distance.
* **High-Performance Ingestion:** Optimized `ProcessKnowledgeUnitsAsync` with high-performance batch embedding generation using `_embeddingGenerator` and deterministic MD5 GUIDs for stable, duplicate-free upsertion.
* **Database Cache Clear Sync:** Integrated Qdrant collection deletion in `ClearCacheAsync` to ensure absolute consistency between the PostgreSQL database cache and vector database indices.

#### 3. Cross-Platform MAUI Logging (Serilog Infrastructure)
* **Serilog Integration:** Configured cross-platform Serilog routing in `SerilogConfiguration.cs`, streaming diagnostic logs safely across native platforms and the Blazor Webview container.
* **Interop Bridge:** Built `BlazorLoggingBridge.cs` to capture web console messages and pipe them directly to the native host logger.
* **Demo Interface:** Added an interactive `SerilogDemo.razor` sandbox under Pages.

#### 4. Resolving 401 Load Errors (Authentication Handler Flow)
* **Authentication Header Handler:** Implemented the `MobileAuthenticationHeaderHandler` to correctly extract, validate, and inject bearer JWT tokens into outbound API requests.
* **Configuration-based API Host:** Structured standard API URI routing to use clean configuration bindings in `appsettings.json`.

---

### 🧪 Verification & Build Status
* Run `dotnet build` from the solution root: Successfully compiled the full multi-targeted solution (`Liczba błędów: 0`).
* All unit and integration tests successfully executed and verified (`dotnet test`).

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Co-authored-by: Marek Jaisński <jasins.marek@gmail.com>
Reviewed-on: #51
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-05-26 12:15:28 +00:00

1196 lines
52 KiB
C#

using System.Text.Json;
using System.Collections.Concurrent;
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.ML.Tokenizers;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.DTOs.AI;
using NexusReader.Domain.Entities;
using NexusReader.Infrastructure.Helpers;
using NexusReader.Data.Persistence;
using Polly;
using Polly.Registry;
using Microsoft.Extensions.Options;
using NexusReader.Infrastructure.Configuration;
using Qdrant.Client;
using Qdrant.Client.Grpc;
using Neo4j.Driver;
namespace NexusReader.Infrastructure.Services;
public class KnowledgeService : IKnowledgeService
{
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
private readonly IChatClient _chatClient;
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;
private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
private readonly ResiliencePipeline _retryPipeline;
private readonly AiSettings _settings;
private readonly Tokenizer _tokenizer;
private readonly ILogger<KnowledgeService> _logger;
private readonly QdrantClient _qdrantClient;
private readonly IDriver _neo4jDriver;
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);
public KnowledgeService(
IChatClient chatClient,
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator,
IDbContextFactory<AppDbContext> dbContextFactory,
ResiliencePipelineProvider<string> pipelineProvider,
IOptions<AiSettings> settings,
ILogger<KnowledgeService> logger,
QdrantClient qdrantClient,
IDriver neo4jDriver)
{
_chatClient = chatClient;
_embeddingGenerator = embeddingGenerator;
_dbContextFactory = dbContextFactory;
_retryPipeline = pipelineProvider.GetPipeline("ai-retry");
_settings = settings.Value;
_logger = logger;
_qdrantClient = qdrantClient;
_neo4jDriver = neo4jDriver;
// 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");
}
public async Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default)
{
return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.KnowledgeExtractionSystemPrompt, "full", ebookId, cancellationToken);
}
public async Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default)
{
return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.GraphExtractionPrompt, "graph", ebookId, cancellationToken);
}
public async Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default)
{
return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.SummaryAndQuizPrompt, "summary_quiz", ebookId, cancellationToken);
}
public async Task<Result<KnowledgePacket>> GetKnowledgeMapAsync(string text, string tenantId, Guid? ebookId = null, CancellationToken cancellationToken = default)
{
return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.KM_ExtractionPrompt, "km_map", ebookId, cancellationToken);
}
private async Task<Result<KnowledgePacket>> GetKnowledgeInternalAsync(string text, string tenantId, string systemPrompt, string traceType, Guid? ebookId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(text)) return Result.Fail("Input text is empty.");
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var normalizedText = text.Trim();
var hashInput = $"{normalizedText}:{traceType}:{PromptVersion}";
var hash = ContentHasher.ComputeHash(hashInput);
// 1. Check Cache
var cached = await dbContext.SemanticKnowledgeCache
.FirstOrDefaultAsync(c => c.ContentHash == hash, cancellationToken);
if (cached != null && cached.PromptVersion == PromptVersion)
{
_logger.LogDebug("[KnowledgeService] Cache Hit for {TraceType} ({Hash})", traceType, hash);
try
{
var packet = JsonSerializer.Deserialize<KnowledgePacket>(cached.JsonData, JsonOptions);
if (packet != null)
{
await ProcessKnowledgeUnitsAsync(packet, tenantId, ebookId, dbContext, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return Result.Ok(packet);
}
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "[KnowledgeService] Cached JSON for {Hash} was invalid; regenerating.", hash);
}
}
// Deduplicate concurrent active requests for the exact same hash
var requestKey = $"{hash}:{traceType}";
var lazyTask = _activeRequests.GetOrAdd(requestKey, k =>
new Lazy<Task<Result<KnowledgePacket>>>(
() => ExecuteAiRequestAndCacheAsync(normalizedText, tenantId, systemPrompt, traceType, ebookId, hash),
System.Threading.LazyThreadSafetyMode.ExecutionAndPublication
));
try
{
var result = await lazyTask.Value;
// If the AI call returned a failure, remove it from the active dictionary
// so subsequent retries have a chance to request the AI again.
if (result.IsFailed)
{
_activeRequests.TryRemove(requestKey, out _);
}
return result;
}
catch (Exception)
{
_activeRequests.TryRemove(requestKey, out _);
throw;
}
finally
{
_activeRequests.TryRemove(requestKey, out _);
}
}
private async Task<Result<KnowledgePacket>> ExecuteAiRequestAndCacheAsync(
string normalizedText,
string tenantId,
string systemPrompt,
string traceType,
Guid? ebookId,
string hash)
{
_logger.LogInformation("[KnowledgeService] Cache Miss for {TraceType} ({Hash}). Requesting AI...", traceType, hash);
try
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync();
var options = new ChatOptions
{
Temperature = (float)_settings.Temperature,
MaxOutputTokens = _settings.MaxOutputTokens
};
var response = await _retryPipeline.ExecuteAsync(async ct =>
await _chatClient.GetResponseAsync(new List<ChatMessage>
{
new ChatMessage(ChatRole.System, systemPrompt),
new ChatMessage(ChatRole.User, normalizedText)
}, options, cancellationToken: ct));
var rawResponse = response.Text?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(rawResponse)) return Result.Fail("AI returned an empty response.");
// Cleanup markdown code blocks and repair truncation
var jsonResponse = rawResponse.Replace("```json", "").Replace("```", "").Trim();
jsonResponse = JsonRepairHelper.Repair(jsonResponse);
try
{
var knowledgePacket = JsonSerializer.Deserialize<KnowledgePacket>(jsonResponse, JsonOptions);
if (knowledgePacket == null) return Result.Fail("Failed to deserialize AI response.");
// 4. Save to Cache
var cached = await dbContext.SemanticKnowledgeCache
.FirstOrDefaultAsync(c => c.ContentHash == hash);
var cacheEntry = new SemanticKnowledgeCache
{
ContentHash = hash,
JsonData = jsonResponse,
OriginalText = normalizedText,
ModelId = _settings.Model,
PromptVersion = PromptVersion,
TenantId = tenantId,
CreatedAt = DateTime.UtcNow
};
if (cached == null) dbContext.SemanticKnowledgeCache.Add(cacheEntry);
else
{
cached.JsonData = jsonResponse;
cached.OriginalText = normalizedText;
cached.CreatedAt = DateTime.UtcNow;
}
// 5. Process structured KnowledgeUnits (Graph Expansion)
await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, ebookId, dbContext, default);
try
{
await dbContext.SaveChangesAsync();
}
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pgEx && pgEx.SqlState == "23505")
{
_logger.LogWarning("[KnowledgeService] Concurrency collision on SemanticKnowledgeCache for {Hash}; another process saved it first. Swallowing.", hash);
}
return Result.Ok(knowledgePacket);
}
catch (JsonException ex)
{
_logger.LogError(ex, "[KnowledgeService] JSON deserialization error. Raw response length: {Length}", rawResponse.Length);
return Result.Fail($"Failed to deserialize AI response: {ex.Message}");
}
}
catch (Exception ex)
{
return Result.Fail(new Error("Failed to extract knowledge from AI").CausedBy(ex));
}
finally
{
var requestKey = $"{tenantId}:{hash}:{traceType}";
_activeRequests.TryRemove(requestKey, out _);
}
}
private async Task ProcessKnowledgeUnitsAsync(KnowledgePacket packet, string tenantId, Guid? ebookId, AppDbContext dbContext, CancellationToken cancellationToken)
{
if (packet.Graph != null && (packet.Units == null || !packet.Units.Any()))
{
var graphUnits = packet.Graph.Nodes.Select(node => new KnowledgeUnitDto(
node.Id,
node.Type ?? "concept",
node.Description ?? node.Label,
new Dictionary<string, object>
{
["label"] = node.Label,
["group"] = node.Group,
["summary"] = node.Summary ?? "",
["key_terms"] = node.KeyTerms ?? new List<string>()
}
)).ToList();
var graphLinks = packet.Graph.Links.Select(link => new KnowledgeLinkDto(
link.Source,
link.Target,
link.RelationType
)).ToList();
packet = packet with { Units = graphUnits, Links = graphLinks };
}
var unitIds = packet.Units.Select(u => u.Id).ToList();
var linkSourceIds = packet.Links.Select(l => l.Source).ToList();
var linkTargetIds = packet.Links.Select(l => l.Target).ToList();
var allCandidateIds = unitIds.Concat(linkSourceIds).Concat(linkTargetIds).Distinct().ToList();
// Single batch query to find existing units
var existingUnits = await dbContext.KnowledgeUnits
.Where(u => allCandidateIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, cancellationToken);
var processedUnitIds = new HashSet<string>();
foreach (var unitDto in packet.Units)
{
var unitId = unitDto.Id;
existingUnits.TryGetValue(unitId, out var unit);
if (unit == null)
{
unit = new KnowledgeUnit { Id = unitId, TenantId = tenantId };
dbContext.KnowledgeUnits.Add(unit);
existingUnits[unitId] = unit;
}
unit.Type = Enum.TryParse<NexusReader.Domain.Enums.KnowledgeUnitType>(unitDto.Type, true, out var type) ? type : NexusReader.Domain.Enums.KnowledgeUnitType.Snippet;
unit.Content = unitDto.Content;
// Link to the specific ebook if provided.
// Link to ebook if provided
unit.EbookId = ebookId;
unit.MetadataJson = JsonSerializer.Serialize(unitDto.Metadata);
// Embeddings and vector storage are handled via Qdrant in the new pipeline.
processedUnitIds.Add(unit.Id);
}
foreach (var linkDto in packet.Links)
{
var sourceExists = processedUnitIds.Contains(linkDto.Source) || existingUnits.ContainsKey(linkDto.Source);
var targetExists = processedUnitIds.Contains(linkDto.Target) || existingUnits.ContainsKey(linkDto.Target);
if (sourceExists && targetExists)
{
// Check if link already exists to avoid duplicates if necessary
// For now, assume we can add them or they are new in this session
var link = new KnowledgeUnitLink
{
SourceUnitId = linkDto.Source,
TargetUnitId = linkDto.Target,
RelationType = linkDto.Relation
};
dbContext.KnowledgeUnitLinks.Add(link);
}
else
{
_logger.LogWarning("[KnowledgeService] Skipping invalid link {Source} -> {Target}: one or both units are missing.", linkDto.Source, linkDto.Target);
}
}
// Generate and upsert vectors to Qdrant in batch
var unitsToEmbed = packet.Units
.Where(u => !string.IsNullOrEmpty(u.Content))
.ToList();
if (unitsToEmbed.Any())
{
try
{
var contents = unitsToEmbed.Select(u => u.Content).ToList();
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct =>
await _embeddingGenerator.GenerateAsync(
contents,
new EmbeddingGenerationOptions { Dimensions = 768 },
cancellationToken: ct), cancellationToken);
var embeddings = embeddingResponse.ToList();
var points = new List<PointStruct>();
for (int i = 0; i < unitsToEmbed.Count; i++)
{
var unitDto = unitsToEmbed[i];
var vector = embeddings[i].Vector.ToArray();
var point = new PointStruct
{
Id = GetDeterministicGuid(unitDto.Id),
Vectors = vector,
Payload =
{
["content"] = unitDto.Content,
["type"] = unitDto.Type ?? string.Empty,
["tenantId"] = tenantId,
["ebookId"] = ebookId?.ToString() ?? string.Empty,
["metadataJson"] = JsonSerializer.Serialize(unitDto.Metadata)
}
};
points.Add(point);
}
if (points.Any())
{
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
await _qdrantClient.UpsertAsync("knowledge_units", points, cancellationToken: cancellationToken);
_logger.LogInformation("[KnowledgeService] Successfully upserted {Count} points to Qdrant collection 'knowledge_units'.", points.Count);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[KnowledgeService] Failed to generate and upsert embeddings for knowledge units to Qdrant.");
}
}
// 6. Synchronize to Neo4j graph database
await SyncToNeo4jAsync(packet, cancellationToken);
}
private async Task SyncToNeo4jAsync(KnowledgePacket packet, CancellationToken cancellationToken)
{
if (packet.Units == null || !packet.Units.Any()) return;
try
{
await using var session = _neo4jDriver.AsyncSession();
// 1. Merge nodes in a transaction
await session.ExecuteWriteAsync(async tx =>
{
foreach (var unit in packet.Units)
{
var cypher = @"
MERGE (u:KnowledgeUnit {id: $id})
ON CREATE SET u.content = $content, u.type = $type
ON MATCH SET u.content = $content, u.type = $type";
var guidStr = GetDeterministicGuid(unit.Id).ToString();
await tx.RunAsync(cypher, new
{
id = guidStr,
content = unit.Content ?? string.Empty,
type = unit.Type ?? "concept"
});
}
});
// 2. Merge links in a transaction
if (packet.Links != null && packet.Links.Any())
{
await session.ExecuteWriteAsync(async tx =>
{
foreach (var link in packet.Links)
{
if (string.IsNullOrWhiteSpace(link.Source) || string.IsNullOrWhiteSpace(link.Target))
continue;
var relationType = string.IsNullOrWhiteSpace(link.Relation) ? "RELATED_TO" : link.Relation.Trim().ToUpperInvariant();
relationType = System.Text.RegularExpressions.Regex.Replace(relationType, @"[^A-Z0-9_]", "_");
if (string.IsNullOrEmpty(relationType) || relationType == "_")
{
relationType = "RELATED_TO";
}
var cypher = $@"
MATCH (source:KnowledgeUnit {{id: $sourceId}})
MATCH (target:KnowledgeUnit {{id: $targetId}})
MERGE (source)-[r:{relationType}]->(target)";
var sourceGuidStr = GetDeterministicGuid(link.Source).ToString();
var targetGuidStr = GetDeterministicGuid(link.Target).ToString();
await tx.RunAsync(cypher, new
{
sourceId = sourceGuidStr,
targetId = targetGuidStr
});
}
});
}
_logger.LogInformation("[KnowledgeService] Successfully synchronized {NodeCount} nodes and {LinkCount} links to Neo4j.", packet.Units.Count, packet.Links?.Count ?? 0);
}
catch (Exception ex)
{
_logger.LogError(ex, "[KnowledgeService] Failed to synchronize knowledge graph to Neo4j.");
}
}
private async Task EnsureCollectionExistsAsync(string collectionName, CancellationToken cancellationToken = default)
{
await _collectionSemaphore.WaitAsync(cancellationToken);
try
{
var exists = await _qdrantClient.CollectionExistsAsync(collectionName, cancellationToken);
if (!exists)
{
_logger.LogInformation("[KnowledgeService] Creating Qdrant collection '{CollectionName}'...", collectionName);
await _qdrantClient.CreateCollectionAsync(
collectionName: collectionName,
vectorsConfig: new VectorParams
{
Size = 768,
Distance = Distance.Cosine
},
cancellationToken: cancellationToken
);
_logger.LogInformation("[KnowledgeService] Qdrant collection '{CollectionName}' created successfully.", collectionName);
}
}
catch (Exception ex)
{
if (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase) ||
(ex.InnerException != null && ex.InnerException.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase)))
{
_logger.LogInformation("[KnowledgeService] Qdrant collection '{CollectionName}' was already created by another thread.", collectionName);
}
else
{
_logger.LogError(ex, "[KnowledgeService] Error ensuring Qdrant collection '{CollectionName}' exists.", collectionName);
}
}
finally
{
_collectionSemaphore.Release();
}
}
private static Guid GetDeterministicGuid(string input)
{
if (Guid.TryParse(input, out var guid))
{
return guid;
}
using var md5 = System.Security.Cryptography.MD5.Create();
byte[] hash = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input));
return new Guid(hash);
}
private static string GetPointIdString(PointId pointId)
{
if (pointId == null) return string.Empty;
return pointId.PointIdOptionsCase == PointId.PointIdOptionsOneofCase.Uuid
? pointId.Uuid
: pointId.Num.ToString();
}
public async Task<Result<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default)
{
var systemPrompt = @"
You are a Fact-Checking AI. Evaluate if the 'Answer' is supported by the 'Context'.
Rate the groundedness from 0.0 to 1.0.
Return ONLY a JSON object: { ""score"": 0.9, ""rationale"": ""string"", ""isGrounded"": true }
";
var userPrompt = $"Context: {context}\n\nAnswer: {answer}";
try
{
var options = new ChatOptions
{
Temperature = 0.0f, // Low temperature for factual checks
MaxOutputTokens = 500
};
var response = await _retryPipeline.ExecuteAsync(async ct =>
await _chatClient.GetResponseAsync(new List<ChatMessage>
{
new ChatMessage(ChatRole.System, systemPrompt),
new ChatMessage(ChatRole.User, userPrompt)
}, options, cancellationToken: ct), cancellationToken);
var rawJson = response.Text?.Trim() ?? "{}";
rawJson = rawJson.Replace("```json", "").Replace("```", "").Trim();
var result = JsonSerializer.Deserialize<GroundednessResult>(rawJson, JsonOptions);
return result != null ? Result.Ok(result) : Result.Fail("Failed to parse groundedness result");
}
catch (Exception ex)
{
return Result.Fail(new Error("Failed to verify groundedness").CausedBy(ex));
}
}
public async Task<Result<List<RelevantContext>>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default)
{
try
{
var queryEmbedding = await _retryPipeline.ExecuteAsync(async ct =>
await _embeddingGenerator.GenerateAsync(new[] { query }, cancellationToken: ct), cancellationToken);
var queryVector = queryEmbedding.First().Vector.ToArray();
var filter = new Qdrant.Client.Grpc.Filter();
filter.Should.Add(new Qdrant.Client.Grpc.Condition
{
Field = new Qdrant.Client.Grpc.FieldCondition
{
Key = "tenantId",
Match = new Qdrant.Client.Grpc.Match { Text = tenantId }
}
});
filter.Should.Add(new Qdrant.Client.Grpc.Condition
{
Field = new Qdrant.Client.Grpc.FieldCondition
{
Key = "tenantId",
Match = new Qdrant.Client.Grpc.Match { Text = "global" }
}
});
List<Qdrant.Client.Grpc.ScoredPoint> searchResult;
try
{
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
var response = await _qdrantClient.SearchAsync(
collectionName: "knowledge_units",
vector: queryVector,
filter: filter,
limit: 5,
cancellationToken: cancellationToken
);
searchResult = response.ToList();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[KnowledgeService] Qdrant search failed during GetRelevantContextAsync. Returning empty search results.");
searchResult = new List<Qdrant.Client.Grpc.ScoredPoint>();
}
var contexts = searchResult.Select(point =>
{
var content = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty;
var summary = string.Empty;
if (point.Payload.TryGetValue("metadataJson", out var metaVal) && !string.IsNullOrEmpty(metaVal.StringValue))
{
try
{
var meta = JsonSerializer.Deserialize<Dictionary<string, object>>(metaVal.StringValue);
if (meta != null && meta.TryGetValue("summary", out var sumObj))
{
summary = sumObj?.ToString();
}
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "[KnowledgeService] Failed to deserialize metadata JSON in RelevantContext mapping.");
}
}
var text = string.IsNullOrEmpty(summary) ? content : $"{content}: {summary}";
return new RelevantContext
{
Text = text,
Confidence = point.Score
};
}).ToList();
return Result.Ok(contexts);
}
catch (Exception ex)
{
return Result.Fail(new Error("Failed to retrieve relevant context").CausedBy(ex));
}
}
public async Task<Result<List<SemanticSearchResultDto>>> SearchLibrarySemanticallyAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default)
{
try
{
// 1. Generate 768-dimensional embedding
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct =>
await _embeddingGenerator.GenerateAsync(
new[] { queryText },
new EmbeddingGenerationOptions { Dimensions = 768 },
cancellationToken: ct), cancellationToken);
var queryVector = embeddingResponse.First().Vector.ToArray();
// 2. Query Qdrant
var filter = new Qdrant.Client.Grpc.Filter();
filter.Should.Add(new Qdrant.Client.Grpc.Condition
{
Field = new Qdrant.Client.Grpc.FieldCondition
{
Key = "tenantId",
Match = new Qdrant.Client.Grpc.Match { Text = tenantId }
}
});
filter.Should.Add(new Qdrant.Client.Grpc.Condition
{
Field = new Qdrant.Client.Grpc.FieldCondition
{
Key = "tenantId",
Match = new Qdrant.Client.Grpc.Match { Text = "global" }
}
});
List<Qdrant.Client.Grpc.ScoredPoint> searchResult;
try
{
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
var response = await _qdrantClient.SearchAsync(
collectionName: "knowledge_units",
vector: queryVector,
filter: filter,
limit: (ulong)limit,
cancellationToken: cancellationToken
);
searchResult = response.ToList();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[KnowledgeService] Failed to search in Qdrant; collection might not exist yet.");
searchResult = new List<Qdrant.Client.Grpc.ScoredPoint>();
}
if (!searchResult.Any())
{
return Result.Ok(new List<SemanticSearchResultDto>());
}
// 3. Graph Expansion via Neo4j
var candidateIds = searchResult.Select(r => GetPointIdString(r.Id)).ToList();
var definitions = new Dictionary<string, List<string>>();
if (candidateIds.Any())
{
try
{
await using var session = _neo4jDriver.AsyncSession();
var cypher = @"
MATCH (source:KnowledgeUnit)-[r]->(target:KnowledgeUnit)
WHERE source.id IN $candidateIds
RETURN source.id AS sourceId, target.content AS targetContent";
var neoResult = await session.ExecuteReadAsync(async tx =>
{
var cursor = await tx.RunAsync(cypher, new { candidateIds });
return await cursor.ToListAsync();
});
foreach (var record in neoResult)
{
var sourceId = record["sourceId"].As<string>();
var targetContent = record["targetContent"].As<string>();
if (!definitions.ContainsKey(sourceId))
{
definitions[sourceId] = new List<string>();
}
definitions[sourceId].Add(targetContent);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[KnowledgeService] Neo4j graph expansion query failed.");
}
}
// 4. Retrieve Ebook Titles from PostgreSQL
var ebookIds = searchResult
.Where(r => r.Payload.TryGetValue("ebookId", out var ev) && Guid.TryParse(ev.StringValue, out _))
.Select(r => Guid.Parse(r.Payload["ebookId"].StringValue))
.Distinct()
.ToList();
var ebookTitles = new Dictionary<Guid, string>();
if (ebookIds.Any())
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
ebookTitles = await dbContext.Ebooks
.Where(e => ebookIds.Contains(e.Id))
.ToDictionaryAsync(e => e.Id, e => e.Title, cancellationToken);
}
// 5. Map results to DTOs
var dtos = searchResult.Select(point =>
{
var content = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty;
var type = point.Payload.TryGetValue("type", out var tv) ? tv.StringValue : string.Empty;
var ebookIdStr = point.Payload.TryGetValue("ebookId", out var ev) ? ev.StringValue : null;
Guid? ebookId = null;
if (Guid.TryParse(ebookIdStr, out var parsedId))
{
ebookId = parsedId;
}
string? bookTitle = null;
if (ebookId.HasValue)
{
ebookTitles.TryGetValue(ebookId.Value, out bookTitle);
}
Dictionary<string, object>? metadata = null;
if (point.Payload.TryGetValue("metadataJson", out var metaVal) && !string.IsNullOrEmpty(metaVal.StringValue))
{
try
{
metadata = JsonSerializer.Deserialize<Dictionary<string, object>>(metaVal.StringValue);
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "[KnowledgeService] Failed to deserialize metadata JSON in search library mapping.");
}
}
var dto = new SemanticSearchResultDto
{
ContentHash = GetPointIdString(point.Id),
Snippet = content,
UnitType = type,
RelevanceScore = point.Score,
SourceBookTitle = bookTitle,
Metadata = metadata
};
var pointIdStr = GetPointIdString(point.Id);
if (definitions.TryGetValue(pointIdStr, out var pointDefs) && pointDefs.Any())
{
dto.Snippet = $"[Context: {string.Join("; ", pointDefs)}]\n{dto.Snippet}";
}
return dto;
}).ToList();
return Result.Ok(dtos);
}
catch (Exception ex)
{
return Result.Fail(new Error("Failed to search library semantically").CausedBy(ex));
}
}
public async Task<Result<GroundedResponseDto>> AskQuestionAsync(
string question,
string tenantId,
Guid? ebookId = null,
int limit = 5,
CancellationToken cancellationToken = default)
{
try
{
// 1. Generate 768-dimensional embedding for the question
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct =>
await _embeddingGenerator.GenerateAsync(
new[] { question },
new EmbeddingGenerationOptions { Dimensions = 768 },
cancellationToken: ct), cancellationToken);
var queryVector = embeddingResponse.First().Vector.ToArray();
// 2. Query Qdrant with filters
var filter = new Qdrant.Client.Grpc.Filter();
// Tenant filter (must match tenantId OR "global")
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 });
if (ebookId.HasValue)
{
filter.Must.Add(new Qdrant.Client.Grpc.Condition
{
Field = new Qdrant.Client.Grpc.FieldCondition
{
Key = "ebookId",
Match = new Qdrant.Client.Grpc.Match { Text = ebookId.Value.ToString() }
}
});
}
List<Qdrant.Client.Grpc.ScoredPoint> searchResult;
try
{
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
var response = await _qdrantClient.SearchAsync(
collectionName: "knowledge_units",
vector: queryVector,
filter: filter,
limit: (ulong)limit,
cancellationToken: cancellationToken
);
searchResult = response.ToList();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[KnowledgeService] Qdrant search failed during RAG retrieval.");
searchResult = new List<Qdrant.Client.Grpc.ScoredPoint>();
}
if (!searchResult.Any())
{
return Result.Ok(new GroundedResponseDto
{
Answer = "I cannot answer this based on the provided book context.",
Citations = new List<CitationDto>()
});
}
// 3. Graph Expansion via Neo4j
var candidateIds = searchResult.Select(r => GetPointIdString(r.Id)).ToList();
var relatedContexts = new List<string>();
// Keep map of point ID -> payload data for fast mapping later
var pointMap = searchResult.ToDictionary(r => GetPointIdString(r.Id), r => r);
// Fetch knowledge units from PostgreSQL to map Guids back to rich metadata summaries
var guidMap = new Dictionary<string, KnowledgeUnit>();
try
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var units = await dbContext.KnowledgeUnits
.Include(u => u.Ebook)
.ThenInclude(e => e.Author)
.Where(u => u.TenantId == tenantId && (ebookId == null || u.EbookId == ebookId))
.ToListAsync(cancellationToken);
guidMap = units.ToDictionary(u => GetDeterministicGuid(u.Id).ToString(), u => u);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[KnowledgeService] Failed to load KnowledgeUnits from PostgreSQL for Guid mapping.");
}
if (candidateIds.Any())
{
try
{
await using var session = _neo4jDriver.AsyncSession();
var cypher = @"
MATCH (source:KnowledgeUnit)
WHERE source.id IN $candidateIds
OPTIONAL MATCH (source)-[r]->(target:KnowledgeUnit)
RETURN source.id AS sourceId, source.content AS sourceContent,
collect({ targetId: target.id, targetContent: target.content, relation: type(r) }) AS relations";
var neoResult = await session.ExecuteReadAsync(async tx =>
{
var cursor = await tx.RunAsync(cypher, new { candidateIds });
return await cursor.ToListAsync();
});
foreach (var record in neoResult)
{
var sourceId = record["sourceId"].As<string>();
var sourceText = string.Empty;
if (guidMap.TryGetValue(sourceId, out var sourceUnit))
{
var summary = string.Empty;
if (!string.IsNullOrEmpty(sourceUnit.MetadataJson))
{
try
{
var meta = JsonSerializer.Deserialize<Dictionary<string, object>>(sourceUnit.MetadataJson);
if (meta != null && meta.TryGetValue("summary", out var sumObj))
{
summary = sumObj?.ToString();
}
}
catch (JsonException jsonEx)
{
_logger.LogWarning(jsonEx, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in AskQuestionAsync source hydration.", sourceUnit.Id);
}
}
sourceText = string.IsNullOrEmpty(summary) ? sourceUnit.Content : $"{sourceUnit.Content}: {summary}";
}
else
{
sourceText = record["sourceContent"].As<string>();
}
relatedContexts.Add($"[Source ID: {sourceId}] {sourceText}");
var relations = record["relations"].As<List<object>>();
if (relations != null)
{
foreach (var relObj in relations)
{
if (relObj is System.Collections.IDictionary relDict)
{
var targetId = relDict["targetId"]?.ToString();
var targetContent = relDict["targetContent"]?.ToString();
var relation = relDict["relation"]?.ToString();
if (!string.IsNullOrEmpty(targetContent) && !string.IsNullOrEmpty(relation))
{
var targetText = targetContent;
if (!string.IsNullOrEmpty(targetId) && guidMap.TryGetValue(targetId, out var targetUnit))
{
var summary = string.Empty;
if (!string.IsNullOrEmpty(targetUnit.MetadataJson))
{
try
{
var meta = JsonSerializer.Deserialize<Dictionary<string, object>>(targetUnit.MetadataJson);
if (meta != null && meta.TryGetValue("summary", out var sumObj))
{
summary = sumObj?.ToString();
}
}
catch (JsonException jsonEx)
{
_logger.LogWarning(jsonEx, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in AskQuestionAsync target hydration.", targetUnit.Id);
}
}
targetText = string.IsNullOrEmpty(summary) ? targetUnit.Content : $"{targetUnit.Content}: {summary}";
}
relatedContexts.Add($"[Related Context ({relation}) to {sourceId}] {targetText}");
}
}
}
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[KnowledgeService] Neo4j graph expansion failed. Falling back to direct Qdrant points.");
foreach (var point in searchResult)
{
var sourceId = GetPointIdString(point.Id);
var sourceText = string.Empty;
if (guidMap.TryGetValue(sourceId, out var sourceUnit))
{
var summary = string.Empty;
if (!string.IsNullOrEmpty(sourceUnit.MetadataJson))
{
try
{
var meta = JsonSerializer.Deserialize<Dictionary<string, object>>(sourceUnit.MetadataJson);
if (meta != null && meta.TryGetValue("summary", out var sumObj))
{
summary = sumObj?.ToString();
}
}
catch (JsonException jsonEx)
{
_logger.LogWarning(jsonEx, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in fallback AskQuestionAsync.", sourceUnit.Id);
}
}
sourceText = string.IsNullOrEmpty(summary) ? sourceUnit.Content : $"{sourceUnit.Content}: {summary}";
}
else
{
sourceText = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty;
}
relatedContexts.Add($"[Source ID: {sourceId}] {sourceText}");
}
}
}
// 4. Retrieve Book Titles from PostgreSQL to populate citations
var ebookIds = searchResult
.Where(r => r.Payload.TryGetValue("ebookId", out var ev) && Guid.TryParse(ev.StringValue, out _))
.Select(r => Guid.Parse(r.Payload["ebookId"].StringValue))
.Distinct()
.ToList();
var ebookTitles = new Dictionary<Guid, string>();
if (ebookIds.Any())
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
ebookTitles = await dbContext.Ebooks
.Where(e => ebookIds.Contains(e.Id))
.ToDictionaryAsync(e => e.Id, e => e.Title, cancellationToken);
}
// 5. Build prompt and invoke Gemini with structured JSON formatting
var contextBlocksText = string.Join("\n\n", relatedContexts);
var systemPrompt = PromptRegistry.GroundedRAGSystemPrompt;
var userPrompt = $"Context:\n{contextBlocksText}\n\nQuestion: {question}";
var options = new ChatOptions
{
Temperature = 0.0f,
MaxOutputTokens = 1500
};
var chatResponse = await _retryPipeline.ExecuteAsync(async ct =>
await _chatClient.GetResponseAsync(new List<ChatMessage>
{
new ChatMessage(ChatRole.System, systemPrompt),
new ChatMessage(ChatRole.User, userPrompt)
}, options, cancellationToken: ct), cancellationToken);
var rawJson = chatResponse.Text?.Trim() ?? string.Empty;
rawJson = rawJson.Replace("```json", "").Replace("```", "").Trim();
// Handle direct text fallback when model bypasses JSON format
if (!rawJson.StartsWith("{") &&
(rawJson.Contains("cannot answer", StringComparison.OrdinalIgnoreCase) ||
rawJson.Contains("context does not contain", StringComparison.OrdinalIgnoreCase) ||
rawJson.Contains("provided book context", StringComparison.OrdinalIgnoreCase)))
{
return Result.Ok(new GroundedResponseDto
{
Answer = "I cannot answer this based on the provided book context.",
Citations = new List<CitationDto>()
});
}
rawJson = JsonRepairHelper.Repair(rawJson);
try
{
var groundedResult = JsonSerializer.Deserialize<GroundedResponseDto>(rawJson, JsonOptions);
if (groundedResult == null || string.IsNullOrWhiteSpace(groundedResult.Answer))
{
return Result.Fail("Failed to deserialize grounded RAG response.");
}
// Hydrate book titles, author, and page number for citations if unknown
foreach (var citation in groundedResult.Citations)
{
if (pointMap.TryGetValue(citation.CitationId, out var point) &&
point.Payload.TryGetValue("ebookId", out var ev) &&
Guid.TryParse(ev.StringValue, out var ebId))
{
if (ebookTitles.TryGetValue(ebId, out var title))
{
citation.SourceBook = title;
}
}
// Look up from guidMap to get exact page number and author
if (guidMap.TryGetValue(citation.CitationId, out var unit))
{
if (unit.Ebook?.Author != null)
{
citation.Author = unit.Ebook.Author.Name;
}
if (!string.IsNullOrEmpty(unit.MetadataJson))
{
try
{
var meta = JsonSerializer.Deserialize<Dictionary<string, object>>(unit.MetadataJson);
if (meta != null && meta.TryGetValue("page", out var pageObj) && int.TryParse(pageObj?.ToString(), out var pageVal))
{
citation.PageNumber = pageVal;
}
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "[KnowledgeService] Failed to deserialize metadata JSON for unit {UnitId} in AskQuestionAsync citation mapping.", unit.Id);
}
}
}
}
return Result.Ok(groundedResult);
}
catch (JsonException ex)
{
_logger.LogError(ex, "[KnowledgeService] JSON deserialization failed for grounding response. Raw text: {Text}", rawJson);
return Result.Fail($"Failed to parse AI grounded response: {ex.Message}");
}
}
catch (Exception ex)
{
return Result.Fail(new Error("Failed to execute RAG retrieval flow").CausedBy(ex));
}
}
public async Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default)
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
try
{
await dbContext.SemanticKnowledgeCache.ExecuteDeleteAsync(cancellationToken);
await dbContext.KnowledgeUnits.ExecuteDeleteAsync(cancellationToken);
await dbContext.KnowledgeUnitLinks.ExecuteDeleteAsync(cancellationToken);
try
{
await _qdrantClient.DeleteCollectionAsync("knowledge_units", cancellationToken: cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[KnowledgeService] Failed to drop Qdrant collection 'knowledge_units' during cache clear.");
}
try
{
await using var session = _neo4jDriver.AsyncSession();
await session.ExecuteWriteAsync(async tx =>
{
await tx.RunAsync("MATCH (n:KnowledgeUnit) DETACH DELETE n");
});
_logger.LogInformation("[KnowledgeService] Successfully wiped Neo4j 'KnowledgeUnit' nodes.");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[KnowledgeService] Failed to wipe Neo4j graph during cache clear.");
}
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(new Error("Failed to clear knowledge cache").CausedBy(ex));
}
}
private int EstimateTokenCount(string text)
{
if (string.IsNullOrEmpty(text)) return 0;
return _tokenizer.CountTokens(text);
}
}