diff --git a/docker-compose.yml b/docker-compose.yml index aa7cfb4..8414ce2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,7 +48,7 @@ services: volumes: - qdrant_data:/qdrant/storage healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:6333/health"] + test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/6333'"] interval: 5s timeout: 5s retries: 5 diff --git a/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs b/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs index 4344895..a40ae18 100644 --- a/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs +++ b/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs @@ -12,6 +12,7 @@ public interface IKnowledgeService Task>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default); Task> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default); Task>> SearchLibrarySemanticallyAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default); + Task> AskQuestionAsync(string question, string tenantId, Guid? ebookId = null, int limit = 5, CancellationToken cancellationToken = default); Task ClearCacheAsync(CancellationToken cancellationToken = default); } diff --git a/src/NexusReader.Application/DTOs/AI/GroundedResponseDto.cs b/src/NexusReader.Application/DTOs/AI/GroundedResponseDto.cs new file mode 100644 index 0000000..7bb7229 --- /dev/null +++ b/src/NexusReader.Application/DTOs/AI/GroundedResponseDto.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace NexusReader.Application.DTOs.AI; + +public class GroundedResponseDto +{ + public string Answer { get; set; } = string.Empty; + public List Citations { get; set; } = new(); +} + +public class CitationDto +{ + public string CitationId { get; set; } = string.Empty; // e.g., chunk hash/ID + public string Snippet { get; set; } = string.Empty; // Verified text snippet from context + public string SourceBook { get; set; } = string.Empty; // Book title or description +} diff --git a/src/NexusReader.Application/Queries/Library/AskLibraryQuestionQuery.cs b/src/NexusReader.Application/Queries/Library/AskLibraryQuestionQuery.cs new file mode 100644 index 0000000..8b98a8b --- /dev/null +++ b/src/NexusReader.Application/Queries/Library/AskLibraryQuestionQuery.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentResults; +using MediatR; +using NexusReader.Application.Abstractions.Services; +using NexusReader.Application.DTOs.AI; + +namespace NexusReader.Application.Queries.Library; + +public record AskLibraryQuestionQuery(string Question, string TenantId, Guid? EbookId = null, int Limit = 5) + : IRequest>; + +public class AskLibraryQuestionQueryHandler : IRequestHandler> +{ + private readonly IKnowledgeService _knowledgeService; + + public AskLibraryQuestionQueryHandler(IKnowledgeService knowledgeService) + { + _knowledgeService = knowledgeService; + } + + public async Task> Handle(AskLibraryQuestionQuery request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Question)) + { + return Result.Fail("Question cannot be empty."); + } + + return await _knowledgeService.AskQuestionAsync( + request.Question, + request.TenantId, + request.EbookId, + request.Limit, + cancellationToken); + } +} diff --git a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs index 9af15b2..9379aa8 100644 --- a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs +++ b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs @@ -546,6 +546,242 @@ public class KnowledgeService : IKnowledgeService } } + public async Task> 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 searchResult; + try + { + 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(); + } + + if (!searchResult.Any()) + { + return Result.Ok(new GroundedResponseDto + { + Answer = "I cannot answer this based on the provided book context.", + Citations = new List() + }); + } + + // 3. Graph Expansion via Neo4j + var candidateIds = searchResult.Select(r => r.Id.ToString()).ToList(); + var relatedContexts = new List(); + + // Keep map of point ID -> payload data for fast mapping later + var pointMap = searchResult.ToDictionary(r => r.Id.ToString(), r => r); + + if (candidateIds.Any()) + { + try + { + await using var session = _neo4jDriver.AsyncSession(); + var cypher = @" + MATCH (source:KnowledgeUnit) + WHERE source.id IN $candidateIds + OPTIONAL MATCH (source)-[r:DEFINES|RELATED_TO]->(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(); + var sourceContent = record["sourceContent"].As(); + + relatedContexts.Add($"[Source ID: {sourceId}] {sourceContent}"); + + var relations = record["relations"].As>(); + if (relations != null) + { + foreach (var relObj in relations) + { + if (relObj is Dictionary relDict && + relDict.TryGetValue("targetId", out var targetIdVal) && targetIdVal is string targetId && + relDict.TryGetValue("targetContent", out var targetContentVal) && targetContentVal is string targetContent && + relDict.TryGetValue("relation", out var relationVal) && relationVal is string relation) + { + if (!string.IsNullOrEmpty(targetContent)) + { + relatedContexts.Add($"[Related Context ({relation}) to {sourceId}] {targetContent}"); + } + } + } + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[KnowledgeService] Neo4j graph expansion failed. Falling back to direct Qdrant points."); + foreach (var point in searchResult) + { + var sourceId = point.Id.ToString(); + var content = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty; + relatedContexts.Add($"[Source ID: {sourceId}] {content}"); + } + } + } + + // 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(); + 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 = @" +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. + +Strict Grounding Rules: +1. Rely EXCLUSIVELY on the provided context. Do NOT use any pre-existing external knowledge, facts, or assumptions. +2. If the context does not contain the answer, you must state exactly: 'I cannot answer this based on the provided book context.' +3. For every statement or claim you make in your answer, you must cite the specific source IDs (e.g., source chunk ID or hash) from the context. +4. You must format your response ONLY as a JSON object matching the following structure: +{ + ""answer"": ""The answer text goes here, referencing [Source ID] as citations."", + ""citations"": [ + { + ""citationId"": ""The exact source ID cited (e.g., chunk hash/ID)"", + ""snippet"": ""The precise sentence or phrase from the context that supports this statement."", + ""sourceBook"": ""The book title or 'Unknown'"" + } + ] +} +"; + + var userPrompt = $"Context:\n{contextBlocksText}\n\nQuestion: {question}"; + + var options = new ChatOptions + { + Temperature = 0.0f, + MaxOutputTokens = 1500, + ResponseFormat = ChatResponseFormat.Json + }; + + var chatResponse = await _retryPipeline.ExecuteAsync(async ct => + await _chatClient.GetResponseAsync(new List + { + 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(); + rawJson = JsonRepairHelper.Repair(rawJson); + + try + { + var groundedResult = JsonSerializer.Deserialize(rawJson, JsonOptions); + if (groundedResult == null || string.IsNullOrWhiteSpace(groundedResult.Answer)) + { + return Result.Fail("Failed to deserialize grounded RAG response."); + } + + // Hydrate book titles 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) && + ebookTitles.TryGetValue(ebId, out var title)) + { + citation.SourceBook = title; + } + } + + 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 ClearCacheAsync(CancellationToken cancellationToken = default) { using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); diff --git a/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor b/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor index 7ba1639..70d012c 100644 --- a/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor +++ b/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor @@ -34,6 +34,12 @@ Concepts Map + + + Global AI Q&A +