a0bf6c15f4
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>
1196 lines
52 KiB
C#
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);
|
|
}
|
|
}
|