feat: Release v1.2.0 - Concepts Map, RAG Search & Core Consolidations #59

Open
Antigravity wants to merge 33 commits from develop into main
10 changed files with 839 additions and 1 deletions
Showing only changes of commit cb4b7d0052 - Show all commits
+1 -1
View File
@@ -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
@@ -12,6 +12,7 @@ public interface IKnowledgeService
Task<Result<List<RelevantContext>>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default);
Task<Result<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default);
Task<Result<List<SemanticSearchResultDto>>> SearchLibrarySemanticallyAsync(string queryText, string tenantId, int limit, CancellationToken cancellationToken = default);
Task<Result<GroundedResponseDto>> AskQuestionAsync(string question, string tenantId, Guid? ebookId = null, int limit = 5, CancellationToken cancellationToken = default);
Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default);
}
@@ -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<CitationDto> 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
}
@@ -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<Result<GroundedResponseDto>>;
public class AskLibraryQuestionQueryHandler : IRequestHandler<AskLibraryQuestionQuery, Result<GroundedResponseDto>>
{
private readonly IKnowledgeService _knowledgeService;
public AskLibraryQuestionQueryHandler(IKnowledgeService knowledgeService)
{
_knowledgeService = knowledgeService;
}
public async Task<Result<GroundedResponseDto>> 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);
}
}
@@ -546,6 +546,242 @@ public class KnowledgeService : IKnowledgeService
}
}
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
{
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 => r.Id.ToString()).ToList();
var relatedContexts = new List<string>();
// 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<string>();
var sourceContent = record["sourceContent"].As<string>();
relatedContexts.Add($"[Source ID: {sourceId}] {sourceContent}");
var relations = record["relations"].As<List<object>>();
if (relations != null)
{
foreach (var relObj in relations)
{
if (relObj is Dictionary<string, object> 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<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 = @"
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<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();
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 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<Result> ClearCacheAsync(CancellationToken cancellationToken = default)
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
@@ -34,6 +34,12 @@
</div>
<span class="nav-text">Concepts Map</span>
</NavLink>
<NavLink class="nav-item" href="/intelligence">
<div class="nav-icon">
<NexusIcon Name="cpu" Size="18" />
</div>
<span class="nav-text">Global AI Q&A</span>
</NavLink>
<NavLink class="nav-item" href="/profile">
<div class="nav-icon">
<NexusIcon Name="message-square" Size="18" />
@@ -0,0 +1,453 @@
@page "/intelligence"
@attribute [Authorize]
@using NexusReader.Application.DTOs.AI
@using NexusReader.Application.Abstractions.Services
@using NexusReader.Application.DTOs.User
@using System.Net.Http.Json
@inject HttpClient Http
@inject IKnowledgeService KnowledgeService
<div class="intelligence-page">
<header class="intelligence-header">
<div class="header-title-section">
<h1>Global AI Q&A</h1>
<p class="subtitle">Search, interrogate, and extract grounded facts from your library using Polyglot KM-RAG</p>
</div>
</header>
<div class="intelligence-layout glass-panel">
<div class="search-scope-bar">
<div class="input-group search-input-group">
<input class="nexus-input"
placeholder="Ask a question about your books..."
@bind="_question"
@bind:event="oninput"
@onkeyup="HandleKeyUp" />
<button class="btn-nexus primary search-btn"
disabled="@(string.IsNullOrWhiteSpace(_question) || _isLoading)"
@onclick="AskQuestionAsync">
@if (_isLoading)
{
<div class="spinner-glow small btn-spinner"></div>
}
else
{
<span>Ask AI</span>
}
</button>
</div>
<div class="scope-selector">
<label for="book-select">Scope:</label>
<select id="book-select" class="nexus-select" @bind="_selectedBookId">
<option value="">All Books (Global Search)</option>
@if (_books != null)
{
@foreach (var book in _books)
{
<option value="@book.Id">@book.Title</option>
}
}
</select>
</div>
</div>
<div class="results-area">
@if (_isLoading)
{
<div class="loading-state">
<div class="nexus-spinner"></div>
<span>Analyzing conceptual graph and synthesizing response...</span>
</div>
}
else if (_response != null)
{
<div class="response-container">
<div class="response-section">
<h4><i class="bi bi-robot"></i> Answer</h4>
<div class="answer-text">
@_response.Answer
</div>
</div>
@if (_response.Citations != null && _response.Citations.Any())
{
<div class="citations-section">
<h4><i class="bi bi-journal-check"></i> Grounded Citations</h4>
<div class="citations-grid">
@foreach (var citation in _response.Citations)
{
<div class="citation-card">
<div class="citation-header">
<span class="source-badge">@citation.SourceBook</span>
@if (!string.IsNullOrEmpty(citation.CitationId) && citation.CitationId.Length > 8)
{
<span class="id-badge">ID: @citation.CitationId.Substring(0, Math.Min(8, citation.CitationId.Length))</span>
}
</div>
<div class="citation-body">
"@citation.Snippet"
</div>
</div>
}
</div>
</div>
}
</div>
}
else if (_hasSearched)
{
<div class="empty-state">
<i class="bi bi-info-circle"></i>
<p>No answers generated. Try adjusting your question.</p>
</div>
}
else
{
<div class="welcome-state">
<div class="welcome-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
</div>
<h3>Start Interrogating Your Library</h3>
<p>Ask complex questions across all your books. The system will search vectors, pull concept graph relations, and formulate a grounded answer with precise citations.</p>
</div>
}
</div>
</div>
</div>
<style>
.intelligence-page {
padding: 3rem 2rem;
max-width: 1100px;
margin: 0 auto;
animation: fadeIn 0.5s ease-out;
}
.intelligence-header {
margin-bottom: 2rem;
}
.header-title-section h1 {
font-family: var(--nexus-font-serif, 'Outfit', 'Georgia', serif);
font-size: 2.8rem;
font-weight: 700;
margin: 0 0 0.5rem 0;
background: linear-gradient(135deg, var(--nexus-text, #ffffff) 0%, rgba(255, 255, 255, 0.7) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
font-size: 1rem;
color: rgba(255, 255, 255, 0.6);
margin: 0;
}
.intelligence-layout {
padding: 2.5rem;
border-radius: var(--nexus-radius-lg, 16px);
background: rgba(15, 23, 42, 0.4);
border: 1px solid rgba(255, 255, 255, 0.08);
backdrop-filter: blur(20px);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
}
.search-scope-bar {
display: flex;
gap: 1.5rem;
align-items: center;
margin-bottom: 2.5rem;
flex-wrap: wrap;
}
.search-input-group {
flex-grow: 1;
display: flex;
background: rgba(15, 23, 42, 0.5);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 30px;
padding: 0.25rem 0.25rem 0.25rem 1.25rem;
transition: all 0.3s ease;
}
.search-input-group:focus-within {
border-color: var(--nexus-primary, #6366f1);
box-shadow: 0 0 10px rgba(99, 102, 241, 0.2);
}
.nexus-input {
flex-grow: 1;
background: transparent;
border: none;
color: #ffffff;
font-size: 1rem;
outline: none;
padding: 0.5rem 0;
}
.nexus-input::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.search-btn {
border-radius: 25px !important;
padding: 0.5rem 1.5rem !important;
font-size: 0.95rem !important;
display: flex;
align-items: center;
justify-content: center;
}
.scope-selector {
display: flex;
align-items: center;
gap: 0.75rem;
color: rgba(255, 255, 255, 0.7);
}
.nexus-select {
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #ffffff;
padding: 0.5rem 1.5rem 0.5rem 1rem;
border-radius: 20px;
outline: none;
cursor: pointer;
transition: all 0.3s ease;
}
.nexus-select:focus {
border-color: var(--nexus-primary, #6366f1);
}
.results-area {
min-height: 250px;
display: flex;
flex-direction: column;
justify-content: center;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
color: rgba(255, 255, 255, 0.7);
}
.nexus-spinner {
width: 50px;
height: 50px;
border: 3px solid rgba(99, 102, 241, 0.1);
border-radius: 50%;
border-top-color: var(--nexus-primary, #6366f1);
animation: spin 1s linear infinite;
}
.welcome-state, .empty-state {
text-align: center;
color: rgba(255, 255, 255, 0.5);
padding: 3rem 1rem;
display: flex;
flex-direction: column;
align-items: center;
}
.welcome-icon {
color: rgba(255, 255, 255, 0.25);
margin-bottom: 1.5rem;
animation: pulse 2s infinite alternate;
}
.welcome-state h3 {
color: #ffffff;
font-family: var(--nexus-font-sans);
margin: 0 0 0.5rem 0;
}
.welcome-state p, .empty-state p {
max-width: 500px;
margin: 0;
font-size: 0.95rem;
line-height: 1.6;
}
.response-container {
display: flex;
flex-direction: column;
gap: 2.5rem;
animation: slideUp 0.4s ease-out;
}
.response-section h4, .citations-section h4 {
font-size: 1.1rem;
text-transform: uppercase;
letter-spacing: 1px;
color: rgba(255, 255, 255, 0.5);
margin: 0 0 1rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.answer-text {
font-size: 1.15rem;
line-height: 1.7;
color: #ffffff;
background: rgba(255, 255, 255, 0.03);
padding: 1.5rem;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.citations-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1.25rem;
}
.citation-card {
background: rgba(15, 23, 42, 0.4);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 1.25rem;
transition: all 0.3s ease;
}
.citation-card:hover {
border-color: rgba(99, 102, 241, 0.3);
transform: translateY(-2px);
}
.citation-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.source-badge {
font-size: 0.8rem;
font-weight: 600;
color: var(--nexus-primary, #6366f1);
background: rgba(99, 102, 241, 0.1);
padding: 0.25rem 0.75rem;
border-radius: 20px;
}
.id-badge {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.4);
font-family: monospace;
}
.citation-body {
font-size: 0.95rem;
line-height: 1.5;
color: rgba(255, 255, 255, 0.8);
font-style: italic;
}
.btn-spinner {
margin: 0;
}
/* Animations */
@@keyframes fadeIn {
from { opacity: 0; transform: translateY(15px); }
to { opacity: 1; transform: translateY(0); }
}
@@keyframes slideUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@@keyframes pulse {
0% { transform: scale(0.95); opacity: 0.7; }
100% { transform: scale(1.05); opacity: 1; }
}
</style>
@code {
private string _question = string.Empty;
private string _selectedBookId = string.Empty;
private bool _isLoading;
private bool _hasSearched;
private GroundedResponseDto? _response;
private List<LastReadBookDto>? _books;
protected override async Task OnInitializedAsync()
{
try
{
_books = await Http.GetFromJsonAsync<List<LastReadBookDto>>("api/library/books");
}
catch (Exception ex)
{
Console.WriteLine($"[Intelligence] Failed to load books for scope selector: {ex.Message}");
}
}
private async Task HandleKeyUp(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_question) && !_isLoading)
{
await AskQuestionAsync();
}
}
private async Task AskQuestionAsync()
{
if (string.IsNullOrWhiteSpace(_question) || _isLoading) return;
_isLoading = true;
_hasSearched = true;
_response = null;
StateHasChanged();
try
{
Guid? ebookId = null;
if (!string.IsNullOrEmpty(_selectedBookId) && Guid.TryParse(_selectedBookId, out var parsedId))
{
ebookId = parsedId;
}
var result = await KnowledgeService.AskQuestionAsync(_question, "tenantId", ebookId);
if (result.IsSuccess)
{
_response = result.Value;
}
else
{
_response = new GroundedResponseDto
{
Answer = $"Error: {result.Errors.FirstOrDefault()?.Message ?? "An error occurred."}",
Citations = new List<CitationDto>()
};
}
}
catch (Exception ex)
{
_response = new GroundedResponseDto
{
Answer = $"Network/API Error: {ex.Message}",
Citations = new List<CitationDto>()
};
}
finally
{
_isLoading = false;
StateHasChanged();
}
}
}
@@ -93,6 +93,26 @@ public class WasmKnowledgeService : IKnowledgeService
}
}
public async Task<Result<GroundedResponseDto>> AskQuestionAsync(string question, string tenantId, Guid? ebookId = null, int limit = 5, CancellationToken cancellationToken = default)
{
try
{
var response = await _httpClient.PostAsJsonAsync("/api/knowledge/ask", new { question, tenantId, ebookId, limit }, cancellationToken);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<GroundedResponseDto>(cancellationToken: cancellationToken);
return result != null ? Result.Ok(result) : Result.Fail("Failed to deserialize grounded response.");
}
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
return Result.Fail($"Server error ({response.StatusCode}): {errorBody}");
}
catch (Exception ex)
{
return Result.Fail(new Error($"Network error: {ex.Message}").CausedBy(ex));
}
}
private async Task<Result<KnowledgePacket>> CallKnowledgeApiAsync(string endpoint, string text, Guid? ebookId, CancellationToken cancellationToken)
{
try
+18
View File
@@ -302,6 +302,22 @@ knowledgeApi.MapPost("/verify-groundedness", async (GroundednessRequest request,
return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error");
});
knowledgeApi.MapPost("/search", async (SemanticSearchRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) =>
{
var tenantId = user.FindFirstValue("TenantId") ?? "global";
var result = await knowledgeService.SearchLibrarySemanticallyAsync(request.QueryText, tenantId, request.Limit);
if (result.IsSuccess) return Results.Ok(result.Value);
return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error");
});
knowledgeApi.MapPost("/ask", async (AskQuestionRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) =>
{
var tenantId = user.FindFirstValue("TenantId") ?? "global";
var result = await knowledgeService.AskQuestionAsync(request.Question, tenantId, request.EbookId, request.Limit);
if (result.IsSuccess) return Results.Ok(result.Value);
return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error");
});
knowledgeApi.MapDelete("/", async (IKnowledgeService knowledgeService) =>
{
var result = await knowledgeService.ClearCacheAsync();
@@ -549,3 +565,5 @@ app.Run();
public record KnowledgeRequest(string Text, Guid? EbookId = null);
public record GroundednessRequest(string Answer, string Context);
public record SemanticSearchRequest(string QueryText, int Limit = 5);
public record AskQuestionRequest(string Question, Guid? EbookId = null, int Limit = 5);
@@ -148,6 +148,57 @@ public class QueryTests : IDisposable
result.Value.First().ContentHash.Should().Be("hash-123");
}
[Fact]
public async Task AskLibraryQuestionQuery_WithEmptyQuestion_ReturnsFailure()
{
// Arrange
var knowledgeServiceMock = new Mock<IKnowledgeService>();
var handler = new AskLibraryQuestionQueryHandler(knowledgeServiceMock.Object);
var query = new AskLibraryQuestionQuery("", "tenant-123");
// Act
var result = await handler.Handle(query, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeFalse();
result.Errors.First().Message.Should().Be("Question cannot be empty.");
}
[Fact]
public async Task AskLibraryQuestionQuery_WithValidQuestion_CallsKnowledgeService()
{
// Arrange
var knowledgeServiceMock = new Mock<IKnowledgeService>();
var expectedResponse = new GroundedResponseDto
{
Answer = "Based on the book, water boils at 100 degrees Celsius.",
Citations = new List<CitationDto>
{
new CitationDto
{
CitationId = "chunk-1",
Snippet = "Water boils at 100 degrees Celsius.",
SourceBook = "Physics 101"
}
}
};
knowledgeServiceMock.Setup(s => s.AskQuestionAsync("what temp does water boil?", "tenant-123", null, 5, It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Ok(expectedResponse));
var handler = new AskLibraryQuestionQueryHandler(knowledgeServiceMock.Object);
var query = new AskLibraryQuestionQuery("what temp does water boil?", "tenant-123");
// Act
var result = await handler.Handle(query, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Answer.Should().Be("Based on the book, water boils at 100 degrees Celsius.");
result.Value.Citations.Should().HaveCount(1);
result.Value.Citations.First().CitationId.Should().Be("chunk-1");
}
public void Dispose()
{
_connection.Close();