3 Commits

15 changed files with 1341 additions and 368 deletions
@@ -13,4 +13,6 @@ public class CitationDto
public string CitationId { get; set; } = string.Empty; // e.g., chunk hash/ID 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 Snippet { get; set; } = string.Empty; // Verified text snippet from context
public string SourceBook { get; set; } = string.Empty; // Book title or description public string SourceBook { get; set; } = string.Empty; // Book title or description
public string? Author { get; set; }
public int? PageNumber { get; set; }
} }
@@ -21,6 +21,7 @@ public record UserProfileDto
public int ConceptsMappedCount { get; init; } public int ConceptsMappedCount { get; init; }
public LastReadBookDto? LastReadBook { get; init; } public LastReadBookDto? LastReadBook { get; init; }
public IReadOnlyList<QuizResultDto> RecentQuizzes { get; init; } = Array.Empty<QuizResultDto>(); public IReadOnlyList<QuizResultDto> RecentQuizzes { get; init; } = Array.Empty<QuizResultDto>();
public IReadOnlyList<MappedConceptDto> MappedConcepts { get; init; } = Array.Empty<MappedConceptDto>();
public string[] Roles { get; init; } = Array.Empty<string>(); public string[] Roles { get; init; } = Array.Empty<string>();
// Helper properties for UI compatibility // Helper properties for UI compatibility
@@ -29,6 +30,14 @@ public record UserProfileDto
public string LastReadBookTitle => LastReadBook?.Title ?? PlanConstants.DefaultActivityLabel; public string LastReadBookTitle => LastReadBook?.Title ?? PlanConstants.DefaultActivityLabel;
} }
public record MappedConceptDto
{
public string Id { get; init; } = string.Empty;
public string Type { get; init; } = string.Empty;
public string Content { get; init; } = string.Empty;
public string DisplayLabel => Content.Length > 25 ? Content.Substring(0, 22) + "..." : Content;
}
public record LastReadBookDto public record LastReadBookDto
{ {
public Guid Id { get; init; } public Guid Id { get; init; }
@@ -38,7 +38,7 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
: 0, : 0,
DisplayName = u.DisplayName, DisplayName = u.DisplayName,
BooksReadCount = u.Ebooks.Count(), BooksReadCount = u.Ebooks.Count(),
ConceptsMappedCount = dbContext.KnowledgeUnits.Count(k => k.TenantId == u.TenantId), ConceptsMappedCount = dbContext.KnowledgeUnits.Count(k => k.TenantId == u.TenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId)),
LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto LastReadBook = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => new LastReadBookDto
{ {
Id = e.Id, Id = e.Id,
@@ -64,6 +64,17 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
Percentage = q.Percentage, Percentage = q.Percentage,
CompletedDate = q.CompletedDate CompletedDate = q.CompletedDate
}).ToList(), }).ToList(),
MappedConcepts = dbContext.KnowledgeUnits
.Where(k => k.TenantId == u.TenantId || k.TenantId == "global" || string.IsNullOrEmpty(k.TenantId))
.OrderByDescending(k => k.CreatedAt)
.Take(6)
.Select(k => new MappedConceptDto
{
Id = k.Id,
Type = k.Type.ToString(),
Content = k.Content
})
.ToList(),
Roles = dbContext.UserRoles Roles = dbContext.UserRoles
.Where(ur => ur.UserId == u.Id) .Where(ur => ur.UserId == u.Id)
.Join(dbContext.Roles, ur => ur.RoleId, r => r.Id, (ur, r) => r.Name!) .Join(dbContext.Roles, ur => ur.RoleId, r => r.Id, (ur, r) => r.Name!)
@@ -85,11 +85,12 @@ public class KnowledgeService : IKnowledgeService
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var normalizedText = text.Trim(); var normalizedText = text.Trim();
var hash = ContentHasher.ComputeHash(normalizedText); var hashInput = $"{normalizedText}:{traceType}:{PromptVersion}";
var hash = ContentHasher.ComputeHash(hashInput);
// 1. Check Cache // 1. Check Cache
var cached = await dbContext.SemanticKnowledgeCache var cached = await dbContext.SemanticKnowledgeCache
.FirstOrDefaultAsync(c => c.ContentHash == hash && c.TenantId == tenantId, cancellationToken); .FirstOrDefaultAsync(c => c.ContentHash == hash, cancellationToken);
if (cached != null && cached.PromptVersion == PromptVersion) if (cached != null && cached.PromptVersion == PromptVersion)
{ {
@@ -97,7 +98,12 @@ public class KnowledgeService : IKnowledgeService
try try
{ {
var packet = JsonSerializer.Deserialize<KnowledgePacket>(cached.JsonData, JsonOptions); var packet = JsonSerializer.Deserialize<KnowledgePacket>(cached.JsonData, JsonOptions);
if (packet != null) return Result.Ok(packet); if (packet != null)
{
await ProcessKnowledgeUnitsAsync(packet, tenantId, ebookId, dbContext, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return Result.Ok(packet);
}
} }
catch (JsonException ex) catch (JsonException ex)
{ {
@@ -106,7 +112,7 @@ public class KnowledgeService : IKnowledgeService
} }
// Deduplicate concurrent active requests for the exact same hash // Deduplicate concurrent active requests for the exact same hash
var requestKey = $"{tenantId}:{hash}:{traceType}"; var requestKey = $"{hash}:{traceType}";
var lazyTask = _activeRequests.GetOrAdd(requestKey, k => var lazyTask = _activeRequests.GetOrAdd(requestKey, k =>
new Lazy<Task<Result<KnowledgePacket>>>( new Lazy<Task<Result<KnowledgePacket>>>(
@@ -178,7 +184,7 @@ public class KnowledgeService : IKnowledgeService
// 4. Save to Cache // 4. Save to Cache
var cached = await dbContext.SemanticKnowledgeCache var cached = await dbContext.SemanticKnowledgeCache
.FirstOrDefaultAsync(c => c.ContentHash == hash && c.TenantId == tenantId); .FirstOrDefaultAsync(c => c.ContentHash == hash);
var cacheEntry = new SemanticKnowledgeCache var cacheEntry = new SemanticKnowledgeCache
{ {
@@ -202,7 +208,14 @@ public class KnowledgeService : IKnowledgeService
// 5. Process structured KnowledgeUnits (Graph Expansion) // 5. Process structured KnowledgeUnits (Graph Expansion)
await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, ebookId, dbContext, default); await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, ebookId, dbContext, default);
try
{
await dbContext.SaveChangesAsync(); 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); return Result.Ok(knowledgePacket);
} }
catch (JsonException ex) catch (JsonException ex)
@@ -225,6 +238,30 @@ public class KnowledgeService : IKnowledgeService
private async Task ProcessKnowledgeUnitsAsync(KnowledgePacket packet, string tenantId, Guid? ebookId, AppDbContext dbContext, CancellationToken cancellationToken) 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 unitIds = packet.Units.Select(u => u.Id).ToList();
var linkSourceIds = packet.Links.Select(l => l.Source).ToList(); var linkSourceIds = packet.Links.Select(l => l.Source).ToList();
var linkTargetIds = packet.Links.Select(l => l.Target).ToList(); var linkTargetIds = packet.Links.Select(l => l.Target).ToList();
@@ -340,6 +377,79 @@ public class KnowledgeService : IKnowledgeService
_logger.LogError(ex, "[KnowledgeService] Failed to generate and upsert embeddings for knowledge units to Qdrant."); _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) private async Task EnsureCollectionExistsAsync(string collectionName, CancellationToken cancellationToken = default)
@@ -380,6 +490,14 @@ public class KnowledgeService : IKnowledgeService
return new Guid(hash); 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) public async Task<Result<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default)
{ {
var systemPrompt = @" var systemPrompt = @"
@@ -462,10 +580,28 @@ public class KnowledgeService : IKnowledgeService
searchResult = new List<Qdrant.Client.Grpc.ScoredPoint>(); searchResult = new List<Qdrant.Client.Grpc.ScoredPoint>();
} }
var contexts = searchResult.Select(point => new RelevantContext var contexts = searchResult.Select(point =>
{ {
Text = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty, 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 {}
}
var text = string.IsNullOrEmpty(summary) ? content : $"{content}: {summary}";
return new RelevantContext
{
Text = text,
Confidence = point.Score Confidence = point.Score
};
}).ToList(); }).ToList();
return Result.Ok(contexts); return Result.Ok(contexts);
@@ -533,7 +669,7 @@ public class KnowledgeService : IKnowledgeService
} }
// 3. Graph Expansion via Neo4j // 3. Graph Expansion via Neo4j
var candidateIds = searchResult.Select(r => r.Id.ToString()).ToList(); var candidateIds = searchResult.Select(r => GetPointIdString(r.Id)).ToList();
var definitions = new Dictionary<string, List<string>>(); var definitions = new Dictionary<string, List<string>>();
if (candidateIds.Any()) if (candidateIds.Any())
@@ -542,7 +678,7 @@ public class KnowledgeService : IKnowledgeService
{ {
await using var session = _neo4jDriver.AsyncSession(); await using var session = _neo4jDriver.AsyncSession();
var cypher = @" var cypher = @"
MATCH (source:KnowledgeUnit)-[r:DEFINES]->(target:KnowledgeUnit) MATCH (source:KnowledgeUnit)-[r]->(target:KnowledgeUnit)
WHERE source.id IN $candidateIds WHERE source.id IN $candidateIds
RETURN source.id AS sourceId, target.content AS targetContent"; RETURN source.id AS sourceId, target.content AS targetContent";
@@ -616,7 +752,7 @@ public class KnowledgeService : IKnowledgeService
var dto = new SemanticSearchResultDto var dto = new SemanticSearchResultDto
{ {
ContentHash = point.Id.ToString(), ContentHash = GetPointIdString(point.Id),
Snippet = content, Snippet = content,
UnitType = type, UnitType = type,
RelevanceScore = point.Score, RelevanceScore = point.Score,
@@ -624,7 +760,7 @@ public class KnowledgeService : IKnowledgeService
Metadata = metadata Metadata = metadata
}; };
var pointIdStr = point.Id.ToString(); var pointIdStr = GetPointIdString(point.Id);
if (definitions.TryGetValue(pointIdStr, out var pointDefs) && pointDefs.Any()) if (definitions.TryGetValue(pointIdStr, out var pointDefs) && pointDefs.Any())
{ {
dto.Snippet = $"[Context: {string.Join("; ", pointDefs)}]\n{dto.Snippet}"; dto.Snippet = $"[Context: {string.Join("; ", pointDefs)}]\n{dto.Snippet}";
@@ -723,11 +859,26 @@ public class KnowledgeService : IKnowledgeService
} }
// 3. Graph Expansion via Neo4j // 3. Graph Expansion via Neo4j
var candidateIds = searchResult.Select(r => r.Id.ToString()).ToList(); var candidateIds = searchResult.Select(r => GetPointIdString(r.Id)).ToList();
var relatedContexts = new List<string>(); var relatedContexts = new List<string>();
// Keep map of point ID -> payload data for fast mapping later // Keep map of point ID -> payload data for fast mapping later
var pointMap = searchResult.ToDictionary(r => r.Id.ToString(), r => r); 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
.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()) if (candidateIds.Any())
{ {
@@ -737,7 +888,7 @@ public class KnowledgeService : IKnowledgeService
var cypher = @" var cypher = @"
MATCH (source:KnowledgeUnit) MATCH (source:KnowledgeUnit)
WHERE source.id IN $candidateIds WHERE source.id IN $candidateIds
OPTIONAL MATCH (source)-[r:DEFINES|RELATED_TO]->(target:KnowledgeUnit) OPTIONAL MATCH (source)-[r]->(target:KnowledgeUnit)
RETURN source.id AS sourceId, source.content AS sourceContent, RETURN source.id AS sourceId, source.content AS sourceContent,
collect({ targetId: target.id, targetContent: target.content, relation: type(r) }) AS relations"; collect({ targetId: target.id, targetContent: target.content, relation: type(r) }) AS relations";
@@ -750,23 +901,64 @@ public class KnowledgeService : IKnowledgeService
foreach (var record in neoResult) foreach (var record in neoResult)
{ {
var sourceId = record["sourceId"].As<string>(); var sourceId = record["sourceId"].As<string>();
var sourceContent = record["sourceContent"].As<string>();
relatedContexts.Add($"[Source ID: {sourceId}] {sourceContent}"); 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 { }
}
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>>(); var relations = record["relations"].As<List<object>>();
if (relations != null) if (relations != null)
{ {
foreach (var relObj in relations) foreach (var relObj in relations)
{ {
if (relObj is Dictionary<string, object> relDict && if (relObj is System.Collections.IDictionary 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)) var targetId = relDict["targetId"]?.ToString();
var targetContent = relDict["targetContent"]?.ToString();
var relation = relDict["relation"]?.ToString();
if (!string.IsNullOrEmpty(targetContent) && !string.IsNullOrEmpty(relation))
{ {
relatedContexts.Add($"[Related Context ({relation}) to {sourceId}] {targetContent}"); 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 { }
}
targetText = string.IsNullOrEmpty(summary) ? targetUnit.Content : $"{targetUnit.Content}: {summary}";
}
relatedContexts.Add($"[Related Context ({relation}) to {sourceId}] {targetText}");
} }
} }
} }
@@ -778,9 +970,32 @@ public class KnowledgeService : IKnowledgeService
_logger.LogWarning(ex, "[KnowledgeService] Neo4j graph expansion failed. Falling back to direct Qdrant points."); _logger.LogWarning(ex, "[KnowledgeService] Neo4j graph expansion failed. Falling back to direct Qdrant points.");
foreach (var point in searchResult) foreach (var point in searchResult)
{ {
var sourceId = point.Id.ToString(); var sourceId = GetPointIdString(point.Id);
var content = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty;
relatedContexts.Add($"[Source ID: {sourceId}] {content}"); 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 { }
}
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}");
} }
} }
} }
@@ -804,33 +1019,14 @@ public class KnowledgeService : IKnowledgeService
// 5. Build prompt and invoke Gemini with structured JSON formatting // 5. Build prompt and invoke Gemini with structured JSON formatting
var contextBlocksText = string.Join("\n\n", relatedContexts); var contextBlocksText = string.Join("\n\n", relatedContexts);
var systemPrompt = @" var systemPrompt = PromptRegistry.GroundedRAGSystemPrompt;
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 userPrompt = $"Context:\n{contextBlocksText}\n\nQuestion: {question}";
var options = new ChatOptions var options = new ChatOptions
{ {
Temperature = 0.0f, Temperature = 0.0f,
MaxOutputTokens = 1500, MaxOutputTokens = 1500
ResponseFormat = ChatResponseFormat.Json
}; };
var chatResponse = await _retryPipeline.ExecuteAsync(async ct => var chatResponse = await _retryPipeline.ExecuteAsync(async ct =>
@@ -842,6 +1038,20 @@ Strict Grounding Rules:
var rawJson = chatResponse.Text?.Trim() ?? string.Empty; var rawJson = chatResponse.Text?.Trim() ?? string.Empty;
rawJson = rawJson.Replace("```json", "").Replace("```", "").Trim(); 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); rawJson = JsonRepairHelper.Repair(rawJson);
try try
@@ -852,18 +1062,55 @@ Strict Grounding Rules:
return Result.Fail("Failed to deserialize grounded RAG response."); return Result.Fail("Failed to deserialize grounded RAG response.");
} }
// Hydrate book titles for citations if unknown // Hydrate book titles, author, and page number for citations if unknown
foreach (var citation in groundedResult.Citations) foreach (var citation in groundedResult.Citations)
{ {
if (pointMap.TryGetValue(citation.CitationId, out var point) && if (pointMap.TryGetValue(citation.CitationId, out var point) &&
point.Payload.TryGetValue("ebookId", out var ev) && point.Payload.TryGetValue("ebookId", out var ev) &&
Guid.TryParse(ev.StringValue, out var ebId) && Guid.TryParse(ev.StringValue, out var ebId))
ebookTitles.TryGetValue(ebId, out var title)) {
if (ebookTitles.TryGetValue(ebId, out var title))
{ {
citation.SourceBook = 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;
}
else if (unit.EbookId.HasValue)
{
try
{
using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var eb = await dbContext.Ebooks.Include(e => e.Author).FirstOrDefaultAsync(e => e.Id == unit.EbookId.Value, cancellationToken);
if (eb?.Author != null)
{
citation.Author = eb.Author.Name;
}
}
catch { }
}
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 { }
}
}
}
return Result.Ok(groundedResult); return Result.Ok(groundedResult);
} }
catch (JsonException ex) catch (JsonException ex)
@@ -896,6 +1143,20 @@ Strict Grounding Rules:
_logger.LogWarning(ex, "[KnowledgeService] Failed to drop Qdrant collection 'knowledge_units' during cache clear."); _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(); return Result.Ok();
} }
catch (Exception ex) catch (Exception ex)
@@ -58,4 +58,24 @@ public static class PromptRegistry
"\"units\": [ { \"id\": \"string\", \"type\": \"Section|Table|Definition|Rule\", \"content\": \"string\", \"metadata\": { \"page\": 0 } } ], " + "\"units\": [ { \"id\": \"string\", \"type\": \"Section|Table|Definition|Rule\", \"content\": \"string\", \"metadata\": { \"page\": 0 } } ], " +
"\"links\": [ { \"source\": \"string\", \"target\": \"string\", \"relation\": \"Next|Defines|Contains|References\" } ] " + "\"links\": [ { \"source\": \"string\", \"target\": \"string\", \"relation\": \"Next|Defines|Contains|References\" } ] " +
"}."; "}.";
public const string GroundedRAGSystemPrompt = """
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 set the "answer" property in the JSON object exactly to: 'I cannot answer this based on the provided book context.' and the "citations" array must be empty.
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'"
}
]
}
""";
} }
@@ -0,0 +1,76 @@
@using NexusReader.Application.DTOs.AI
<div class="nexus-citation-container" @onmouseenter="ShowPopup" @onmouseleave="HidePopup">
<button type="button" class="nexus-citation-trigger" aria-label="Citation source">
<!-- Circular Neon SVG Radar Ping / Stylized Book Icon -->
<svg class="neon-radar-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="6"></circle>
<circle cx="12" cy="12" r="2"></circle>
</svg>
<span class="pulse-ring"></span>
</button>
@if (_isHovered && _citation != null)
{
<div class="nexus-citation-popup">
<div class="popup-header">
<span class="book-title"><i class="bi bi-book-half"></i> @_citation.SourceBook</span>
@if (!string.IsNullOrEmpty(_citation.Author))
{
<span class="separator">•</span>
<span class="book-author">@_citation.Author</span>
}
@if (_citation.PageNumber.HasValue)
{
<span class="separator">•</span>
<span class="page-number">Page @_citation.PageNumber.Value</span>
}
</div>
<div class="popup-body">
<p class="citation-quote">"@_citation.Snippet"</p>
</div>
<div class="popup-footer">
<span class="id-badge">ID: @SourceId.Substring(0, Math.Min(8, SourceId.Length))</span>
</div>
</div>
}
</div>
@code {
[Parameter]
[EditorRequired]
public string SourceId { get; set; } = string.Empty;
[Parameter]
public List<CitationDto>? Citations { get; set; }
private bool _isHovered;
private CitationDto? _citation;
protected override void OnParametersSet()
{
_citation = Citations?.FirstOrDefault(c => c.CitationId.Equals(SourceId, System.StringComparison.OrdinalIgnoreCase));
// If not found in the thread citations, provide a clean fallback so the UI never displays an empty error
if (_citation == null)
{
_citation = new CitationDto
{
CitationId = SourceId,
SourceBook = "Grounded Document Chunk",
Snippet = "Context snippet retrieved from vector search node."
};
}
}
private void ShowPopup()
{
_isHovered = true;
}
private void HidePopup()
{
_isHovered = false;
}
}
@@ -0,0 +1,148 @@
.nexus-citation-container {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
margin: 0 4px;
}
.nexus-citation-trigger {
background: transparent;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #06b6d4; /* Glowing Cyan */
width: 20px;
height: 20px;
position: relative;
outline: none;
transition: all 0.3s ease;
}
.nexus-citation-trigger:hover {
color: #00ff99; /* Neon Green on hover */
transform: scale(1.2);
}
.neon-radar-svg {
width: 100%;
height: 100%;
filter: drop-shadow(0 0 4px currentColor);
animation: radar-spin 8s linear infinite;
}
.pulse-ring {
position: absolute;
width: 100%;
height: 100%;
border: 1px solid currentColor;
border-radius: 50%;
animation: radar-ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite;
opacity: 0;
pointer-events: none;
}
.nexus-citation-popup {
position: absolute;
bottom: calc(100% + 10px);
left: 50%;
transform: translateX(-50%) translateY(5px);
width: 320px;
padding: 1rem;
border-radius: 12px;
background: rgba(10, 16, 26, 0.9); /* Premium dark background */
border: 1px solid rgba(6, 182, 212, 0.25); /* Cyan border */
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5), 0 0 12px rgba(6, 182, 212, 0.15);
z-index: 1000;
pointer-events: none;
opacity: 0;
animation: popup-fade-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
transform-origin: bottom center;
}
.popup-header {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
font-size: 0.75rem;
font-weight: 700;
color: #00ff99; /* Emerald/Neon Green micro-header */
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
padding-bottom: 0.35rem;
}
.separator {
color: rgba(255, 255, 255, 0.3);
}
.book-title {
display: flex;
align-items: center;
gap: 4px;
}
.book-author, .page-number {
color: rgba(255, 255, 255, 0.6);
}
.popup-body {
margin-bottom: 0.5rem;
}
.citation-quote {
font-size: 0.85rem;
line-height: 1.4;
color: rgba(255, 255, 255, 0.95);
font-style: italic;
margin: 0;
}
.popup-footer {
display: flex;
justify-content: flex-end;
}
.id-badge {
font-size: 0.65rem;
color: rgba(255, 255, 255, 0.3);
font-family: monospace;
}
/* Animations */
@keyframes radar-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes radar-ping {
0% {
transform: scale(1);
opacity: 0.8;
}
100% {
transform: scale(2.2);
opacity: 0;
}
}
@keyframes popup-fade-in {
0% {
opacity: 0;
transform: translateX(-50%) translateY(8px) scale(0.95);
}
100% {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
}
@@ -67,6 +67,7 @@
private bool _isJsInitialized; private bool _isJsInitialized;
private ElementReference _containerRef; private ElementReference _containerRef;
private bool _isInteractive; private bool _isInteractive;
private string? _currentActiveBlockId;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -143,6 +144,7 @@
[JSInvokable] [JSInvokable]
public async Task HandleBlockReached(string blockId, string content) public async Task HandleBlockReached(string blockId, string content)
{ {
_currentActiveBlockId = blockId;
await Coordinator.OnBlockReachedAsync(blockId, content); await Coordinator.OnBlockReachedAsync(blockId, content);
if (ViewModel != null) if (ViewModel != null)
@@ -160,8 +162,15 @@
private async Task HandleSyncProgressReceived(string blockId, DateTime timestamp) private async Task HandleSyncProgressReceived(string blockId, DateTime timestamp)
{ {
if (string.IsNullOrEmpty(blockId) || blockId == _currentActiveBlockId)
{
Logger.LogDebug("[Sync] Received progress {BlockId} is empty or matches active block. Ignoring scroll.", blockId);
return;
}
Logger.LogInformation("[Sync] Received progress from another device: block {BlockId} at {Timestamp}", blockId, timestamp); Logger.LogInformation("[Sync] Received progress from another device: block {BlockId} at {Timestamp}", blockId, timestamp);
_currentActiveBlockId = blockId;
await ScrollToNodeAsync(blockId); await ScrollToNodeAsync(blockId);
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
@@ -212,6 +221,7 @@
private async Task LoadChapterAsync(int index) private async Task LoadChapterAsync(int index)
{ {
await Coordinator.ClearAsync(); await Coordinator.ClearAsync();
_isJsInitialized = false; // Reset JS initialization to re-bind the scroll observer to new DOM elements!
_isLoadingChapter = true; _isLoadingChapter = true;
StatusMessage = "Wczytywanie treści..."; StatusMessage = "Wczytywanie treści...";
StateHasChanged(); StateHasChanged();
@@ -253,6 +263,7 @@
{ {
var targetBlockId = NavigationService.PendingScrollBlockId; var targetBlockId = NavigationService.PendingScrollBlockId;
NavigationService.PendingScrollBlockId = null; // Clear it to prevent multiple scrolls NavigationService.PendingScrollBlockId = null; // Clear it to prevent multiple scrolls
_currentActiveBlockId = targetBlockId;
// Give the browser slightly more than one frame to render the loaded blocks // Give the browser slightly more than one frame to render the loaded blocks
await Task.Delay(150); await Task.Delay(150);
@@ -5,6 +5,7 @@
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@inject IIdentityService IdentityService @inject IIdentityService IdentityService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject ISyncService SyncService
@attribute [Authorize] @attribute [Authorize]
@implements IDisposable @implements IDisposable
@@ -55,12 +56,49 @@
<NexusIcon Name="arrow-right" Size="16" /> <NexusIcon Name="arrow-right" Size="16" />
</div> </div>
<div class="graph-placeholder"> <div class="graph-placeholder">
<div class="graph-node central"></div> <div class="graph-node central" title="Ośrodek Wiedzy Nexus Reader"></div>
@if (_profile?.MappedConcepts != null && _profile.MappedConcepts.Any())
{
@for (int i = 0; i < _profile.MappedConcepts.Count; i++)
{
var concept = _profile.MappedConcepts[i];
var angle = i * (360.0 / _profile.MappedConcepts.Count);
var dist = 65;
<div class="graph-node satellite"
style="--angle: @(angle)deg; --dist: @(dist)px;"
title="[@concept.Type] @concept.Content"
@onmouseover="() => SetHoveredConcept(concept)"
@onmouseout="ClearHoveredConcept">
</div>
}
}
else
{
<div class="graph-node satellite" style="--angle: 0deg; --dist: 60px;"></div> <div class="graph-node satellite" style="--angle: 0deg; --dist: 60px;"></div>
<div class="graph-node satellite" style="--angle: 120deg; --dist: 50px;"></div> <div class="graph-node satellite" style="--angle: 120deg; --dist: 50px;"></div>
<div class="graph-node satellite" style="--angle: 240deg; --dist: 70px;"></div> <div class="graph-node satellite" style="--angle: 240deg; --dist: 70px;"></div>
<div class="active-node-label">TU JESTEŚ</div> }
<div class="active-node-label">
@(string.IsNullOrEmpty(_hoveredConceptLabel) ? "TU JESTEŚ" : _hoveredConceptLabel)
</div> </div>
</div>
@if (_hoveredConcept != null)
{
<div class="concept-detail-toast">
<span class="concept-type">@_hoveredConcept.Type</span>
<p class="concept-content">@_hoveredConcept.Content</p>
</div>
}
else
{
<div class="concept-detail-toast placeholder">
<span class="concept-type">Mapowanie AI</span>
<p class="concept-content">Najedź na węzeł, aby zbadać pojęcie wydobyte przez Nexus AI.</p>
</div>
}
</section> </section>
<!-- Quiz Summary --> <!-- Quiz Summary -->
@@ -105,11 +143,28 @@
@code { @code {
private UserProfileDto? _profile; private UserProfileDto? _profile;
private MappedConceptDto? _hoveredConcept;
private string _hoveredConceptLabel = string.Empty;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
IdentityService.OnStateInvalidated += HandleStateInvalidatedAsync; IdentityService.OnStateInvalidated += HandleStateInvalidatedAsync;
await LoadProfileAsync(); await LoadProfileAsync();
await SyncService.InitializeAsync();
SyncService.OnProgressReceived += HandleProgressReceivedAsync;
}
private void SetHoveredConcept(MappedConceptDto concept)
{
_hoveredConcept = concept;
_hoveredConceptLabel = concept.DisplayLabel;
}
private void ClearHoveredConcept()
{
_hoveredConcept = null;
_hoveredConceptLabel = string.Empty;
} }
private async Task LoadProfileAsync() private async Task LoadProfileAsync()
@@ -134,9 +189,19 @@
}); });
} }
private async Task HandleProgressReceivedAsync(string pageId, DateTime timestamp)
{
await InvokeAsync(async () =>
{
IdentityService.ClearCache();
await LoadProfileAsync();
});
}
public void Dispose() public void Dispose()
{ {
IdentityService.OnStateInvalidated -= HandleStateInvalidatedAsync; IdentityService.OnStateInvalidated -= HandleStateInvalidatedAsync;
SyncService.OnProgressReceived -= HandleProgressReceivedAsync;
} }
} }
@@ -294,9 +294,19 @@
} }
.graph-node.satellite { .graph-node.satellite {
width: 20px; width: 16px;
height: 20px; height: 16px;
transform: rotate(var(--angle)) translateY(var(--dist)); transform: rotate(var(--angle)) translateY(var(--dist));
background: rgba(0, 255, 153, 0.4);
border: 1px solid var(--nexus-neon);
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.graph-node.satellite:hover {
background: var(--nexus-neon);
box-shadow: 0 0 15px var(--nexus-neon);
transform: rotate(var(--angle)) translateY(var(--dist)) scale(1.3);
} }
.active-node-label { .active-node-label {
@@ -480,3 +490,41 @@
color: #666666; color: #666666;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
/* --- Concept Detail Toast for Dashboard --- */
.concept-detail-toast {
margin-top: 1rem;
padding: 0.75rem 1rem;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
min-height: 80px;
display: flex;
flex-direction: column;
gap: 0.25rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.concept-detail-toast.placeholder {
opacity: 0.5;
}
.concept-type {
font-size: 0.75rem;
font-weight: 700;
color: var(--nexus-neon);
text-transform: uppercase;
letter-spacing: 1px;
}
.concept-content {
font-size: 0.85rem;
line-height: 1.4;
color: #E0E0E0;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
+520 -292
View File
@@ -3,44 +3,103 @@
@using NexusReader.Application.DTOs.AI @using NexusReader.Application.DTOs.AI
@using NexusReader.Application.Abstractions.Services @using NexusReader.Application.Abstractions.Services
@using NexusReader.Application.DTOs.User @using NexusReader.Application.DTOs.User
@using NexusReader.UI.Shared.Components.Atoms
@using System.Net.Http.Json @using System.Net.Http.Json
@inject HttpClient Http @inject HttpClient Http
@inject IKnowledgeService KnowledgeService @inject IKnowledgeService KnowledgeService
@inject AuthenticationStateProvider AuthStateProvider @inject AuthenticationStateProvider AuthStateProvider
<div class="intelligence-page"> <div class="intelligence-page">
<header class="intelligence-header"> <header class="intelligence-header">
<div class="header-title-section"> <div class="header-title-section">
<h1>Global AI Q&A</h1> <h1 class="neon-glow-text">Global Intelligence</h1>
<p class="subtitle">Search, interrogate, and extract grounded facts from your library using Polyglot KM-RAG</p> <p class="subtitle">Interrogate, explore, and synthesize grounded knowledge from your library using Polyglot KM-RAG</p>
</div> </div>
</header> </header>
<div class="intelligence-layout glass-panel"> <div class="intelligence-layout glass-panel">
<div class="search-scope-bar"> <div class="chat-thread-container">
<div class="input-group search-input-group"> @if (_chatMessages.Count == 0)
<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> <div class="welcome-state">
<div class="welcome-icon">
<svg width="64" height="64" 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 your entire ebook collection. The KM-RAG engine dynamically builds semantic maps, resolves dependencies, and formulates high-fidelity, grounded answers with interactive popover citations.</p>
</div>
} }
else else
{ {
<span>Ask AI</span> <div class="chat-bubbles-scroll">
@foreach (var message in _chatMessages)
{
<div class="message-row @(message.Sender == "User" ? "user-row" : "ai-row")" key="@message.Id">
<div class="message-avatar">
@if (message.Sender == "User")
{
<i class="bi bi-person-fill"></i>
}
else
{
<i class="bi bi-robot"></i>
}
</div>
<div class="message-bubble @(message.Sender == "User" ? "user-bubble" : "ai-bubble")">
<div class="message-header">
<span class="sender-name">@message.Sender</span>
<span class="message-time">@message.Timestamp.ToString("HH:mm")</span>
</div>
<div class="message-content">
@foreach (var segment in message.Segments)
{
@if (segment.IsCitation)
{
<NexusCitationMarker SourceId="@segment.CitationId" Citations="@message.Citations" />
}
else
{
@RenderMarkdown(segment.Text)
}
}
</div>
</div>
</div>
}
@if (_isLoading)
{
<div class="message-row ai-row">
<div class="message-avatar">
<i class="bi bi-robot"></i>
</div>
<div class="message-bubble ai-bubble pending-bubble">
<div class="message-header">
<span class="sender-name">AI</span>
<span class="message-time">Thinking...</span>
</div>
<div class="message-content">
<div class="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
<span class="loading-label">Analyzing conceptual graphs and synthesizing response...</span>
</div>
</div>
</div>
}
</div>
} }
</button>
</div> </div>
<div class="chat-input-controls">
<div class="input-panel-wrapper">
<div class="scope-bar">
<div class="scope-selector"> <div class="scope-selector">
<label for="book-select">Scope:</label> <label for="book-select"><i class="bi bi-compass"></i> Scope:</label>
<select id="book-select" class="nexus-select" @bind="_selectedBookId"> <select id="book-select" class="nexus-select" @bind="_selectedBookId">
<option value="">All Books (Global Search)</option> <option value="">All Books (Global Search)</option>
@if (_books != null) @if (_books != null)
@@ -54,133 +113,320 @@
</div> </div>
</div> </div>
<div class="results-area"> <div class="input-field-group">
<input class="nexus-input"
placeholder="Ask a question about your books..."
@bind="_question"
@bind:event="oninput"
@onkeyup="HandleKeyUp"
disabled="@_isLoading" />
<button class="btn-nexus primary search-btn"
disabled="@(string.IsNullOrWhiteSpace(_question) || _isLoading)"
@onclick="AskQuestionAsync">
@if (_isLoading) @if (_isLoading)
{ {
<div class="loading-state"> <div class="btn-spinner"></div>
<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 else
{ {
<div class="welcome-state"> <span><i class="bi bi-send-fill"></i></span>
<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>
} }
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<style> <style>
.intelligence-page { .intelligence-page {
padding: 3rem 2rem; padding: 2rem;
max-width: 1100px; max-width: 1100px;
margin: 0 auto; margin: 0 auto;
height: calc(100vh - 100px);
display: flex;
flex-direction: column;
animation: fadeIn 0.5s ease-out; animation: fadeIn 0.5s ease-out;
} }
.intelligence-header { .intelligence-header {
margin-bottom: 2rem; margin-bottom: 1.5rem;
flex-shrink: 0;
} }
.header-title-section h1 { .neon-glow-text {
font-family: var(--nexus-font-serif); font-family: var(--nexus-font-sans, 'Outfit', sans-serif);
font-size: 2.8rem; font-size: 2.5rem;
font-weight: 700; font-weight: 800;
margin: 0 0 0.5rem 0; margin: 0 0 0.25rem 0;
background: linear-gradient(135deg, var(--nexus-text, #ffffff) 0%, rgba(255, 255, 255, 0.7) 100%); background: linear-gradient(135deg, #00ff99 0%, #06b6d4 100%);
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
filter: drop-shadow(0 0 8px rgba(6, 182, 212, 0.2));
} }
.subtitle { .subtitle {
font-size: 1rem; font-size: 0.95rem;
color: rgba(255, 255, 255, 0.6); color: rgba(255, 255, 255, 0.6);
margin: 0; margin: 0;
} }
.intelligence-layout { .intelligence-layout {
padding: 2.5rem;
border-radius: 20px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.search-scope-bar {
display: flex;
gap: 1.5rem;
align-items: center;
margin-bottom: 2.5rem;
flex-wrap: wrap;
}
.search-input-group {
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
flex-direction: column;
border-radius: 20px;
background: rgba(10, 16, 26, 0.45);
border: 1px solid rgba(6, 182, 212, 0.15);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.4), 0 0 20px rgba(6, 182, 212, 0.05);
overflow: hidden;
}
.chat-thread-container {
flex-grow: 1;
overflow-y: auto;
padding: 2rem;
display: flex;
flex-direction: column;
}
/* Custom Scrollbars */
.chat-thread-container::-webkit-scrollbar {
width: 6px;
}
.chat-thread-container::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.01);
}
.chat-thread-container::-webkit-scrollbar-thumb {
background: rgba(6, 182, 212, 0.2);
border-radius: 4px;
}
.chat-thread-container::-webkit-scrollbar-thumb:hover {
background: rgba(6, 182, 212, 0.4);
}
.chat-bubbles-scroll {
display: flex;
flex-direction: column;
gap: 1.5rem;
width: 100%;
}
.message-row {
display: flex;
gap: 1rem;
width: 100%;
max-width: 85%;
animation: bubble-fade-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.user-row {
align-self: flex-end;
flex-direction: row-reverse;
}
.ai-row {
align-self: flex-start;
}
.message-avatar {
width: 38px;
height: 38px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
flex-shrink: 0;
}
.user-row .message-avatar {
background: linear-gradient(135deg, #7c3aed 0%, #4c1d95 100%);
color: #f5f3ff;
border: 1px solid rgba(139, 92, 246, 0.4);
box-shadow: 0 0 10px rgba(139, 92, 246, 0.25);
}
.ai-row .message-avatar {
background: linear-gradient(135deg, #0f766e 0%, #115e59 100%);
color: #ccfbf1;
border: 1px solid rgba(13, 148, 136, 0.4);
box-shadow: 0 0 10px rgba(13, 148, 136, 0.25);
}
.message-bubble {
padding: 1.25rem 1.5rem;
border-radius: 16px;
position: relative;
line-height: 1.6;
font-size: 0.975rem;
}
.user-bubble {
background: rgba(43, 24, 80, 0.35);
border: 1px solid rgba(139, 92, 246, 0.25);
color: #f3e8ff;
border-top-right-radius: 4px;
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.05);
}
.ai-bubble {
background: rgba(10, 20, 30, 0.55);
border: 1px solid rgba(6, 182, 212, 0.2);
color: #e2e8f0;
border-top-left-radius: 4px;
box-shadow: 0 4px 15px rgba(6, 182, 212, 0.05);
flex-grow: 1;
}
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
font-size: 0.75rem;
opacity: 0.6;
}
.sender-name {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.message-time {
font-family: monospace;
}
.message-content {
word-break: break-word;
}
/* Paragraph Spacing & Markdown */
.message-content p {
margin: 0 0 1rem 0;
}
.message-content p:last-child {
margin-bottom: 0;
}
.nexus-code-block {
background: rgba(0, 0, 0, 0.4) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
border-radius: 8px;
padding: 1rem;
margin: 1rem 0;
overflow-x: auto;
font-family: 'Fira Code', monospace;
font-size: 0.85rem;
color: #a7f3d0;
}
.nexus-inline-code {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
padding: 0.15rem 0.35rem;
font-family: monospace;
font-size: 0.9em;
color: #f472b6; /* Light pink for inline code */
}
/* Pending State Bubble */
.pending-bubble {
border-color: rgba(6, 182, 212, 0.4);
box-shadow: 0 0 15px rgba(6, 182, 212, 0.1);
}
.typing-indicator {
display: flex;
gap: 4px;
align-items: center;
margin-bottom: 0.5rem;
}
.typing-indicator span {
width: 8px;
height: 8px;
background: #06b6d4;
border-radius: 50%;
display: inline-block;
animation: typing-bounce 1.4s infinite ease-in-out both;
}
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
.loading-label {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.5);
font-style: italic;
}
/* Input Controls */
.chat-input-controls {
padding: 1.5rem 2rem 2rem 2rem;
background: rgba(0, 0, 0, 0.2);
border-top: 1px solid rgba(255, 255, 255, 0.05);
flex-shrink: 0;
}
.input-panel-wrapper {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.scope-bar {
display: flex;
align-items: center;
}
.scope-selector {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.5);
}
.nexus-select {
background: rgba(255, 255, 255, 0.02); background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px; color: #ffffff;
padding: 0.25rem 0.25rem 0.25rem 1.25rem; padding: 0.35rem 2rem 0.35rem 0.75rem;
border-radius: 8px;
outline: none;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.3s ease;
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 0.85em;
}
.nexus-select:focus {
border-color: #06b6d4;
box-shadow: 0 0 8px rgba(6, 182, 212, 0.2);
}
.input-field-group {
display: flex;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 0.35rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
} }
.search-input-group:focus-within { .input-field-group:focus-within {
border-color: var(--nexus-neon); border-color: #06b6d4;
background: rgba(0, 255, 153, 0.02); background: rgba(6, 182, 212, 0.01);
box-shadow: 0 0 15px rgba(0, 255, 153, 0.15); box-shadow: 0 0 15px rgba(6, 182, 212, 0.15);
} }
.nexus-input { .nexus-input {
@@ -189,249 +435,138 @@
border: none; border: none;
color: #ffffff; color: #ffffff;
font-size: 1rem; font-size: 1rem;
font-family: var(--nexus-font-sans);
outline: none; outline: none;
padding: 0.5rem 0; padding: 0.5rem 1rem;
} }
.nexus-input::placeholder { .nexus-input::placeholder {
color: rgba(255, 255, 255, 0.4); color: rgba(255, 255, 255, 0.35);
} }
.btn-nexus { .btn-nexus {
padding: 0.75rem 1.25rem; padding: 0.75rem 1.25rem;
border-radius: 10px; border-radius: 10px;
font-size: 0.9rem; font-size: 0.95rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
border: none; border: none;
font-family: var(--nexus-font-sans);
} }
.btn-nexus.primary { .btn-nexus.primary {
background: var(--nexus-neon); background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%);
color: #000000; color: #ffffff;
box-shadow: 0 4px 10px rgba(6, 182, 212, 0.25);
} }
.btn-nexus:hover:not(:disabled) { .btn-nexus:hover:not(:disabled) {
transform: translateY(-2px); transform: translateY(-1px);
filter: brightness(1.1); filter: brightness(1.1);
box-shadow: 0 4px 12px rgba(0, 255, 153, 0.3); box-shadow: 0 4px 15px rgba(6, 182, 212, 0.4);
} }
.btn-nexus:disabled { .btn-nexus:disabled {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.3); color: rgba(255, 255, 255, 0.25);
box-shadow: none;
cursor: not-allowed; cursor: not-allowed;
} }
.search-btn { .search-btn {
padding: 0.5rem 1.5rem !important; width: 46px;
font-size: 0.95rem !important; height: 46px;
padding: 0 !important;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
}
.scope-selector {
display: flex;
align-items: center;
gap: 0.75rem;
color: #A0A0A0;
font-family: var(--nexus-font-sans);
}
.nexus-select {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
color: #ffffff;
padding: 0.5rem 2.5rem 0.5rem 1rem;
border-radius: 10px; border-radius: 10px;
outline: none;
cursor: pointer;
font-family: var(--nexus-font-sans);
font-size: 0.9rem;
transition: all 0.3s ease;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 1rem center;
background-size: 1em;
} }
.nexus-select:focus { .welcome-state {
border-color: var(--nexus-neon);
background-color: rgba(0, 255, 153, 0.02);
box-shadow: 0 0 10px rgba(0, 255, 153, 0.15);
}
.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(0, 255, 153, 0.1);
border-radius: 50%;
border-top-color: var(--nexus-neon);
animation: spin 1s linear infinite;
filter: drop-shadow(0 0 8px var(--nexus-neon));
}
.welcome-state, .empty-state {
text-align: center; text-align: center;
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.5);
padding: 3rem 1rem; padding: 4rem 2rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center;
height: 100%;
} }
.welcome-icon { .welcome-icon {
color: rgba(255, 255, 255, 0.25); color: rgba(6, 182, 212, 0.4);
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
animation: pulse 2s infinite alternate; filter: drop-shadow(0 0 10px rgba(6, 182, 212, 0.2));
animation: pulse 2.5s infinite alternate;
} }
.welcome-state h3 { .welcome-state h3 {
color: #ffffff; color: #ffffff;
font-family: var(--nexus-font-sans); font-size: 1.5rem;
margin: 0 0 0.5rem 0; margin: 0 0 0.75rem 0;
} }
.welcome-state p, .empty-state p { .welcome-state p {
max-width: 500px; max-width: 550px;
margin: 0; margin: 0;
font-size: 0.95rem; font-size: 0.95rem;
line-height: 1.6; 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.02);
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(255, 255, 255, 0.01);
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(0, 255, 153, 0.3);
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.05);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.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-neon);
background: rgba(0, 255, 153, 0.05);
border: 1px solid rgba(0, 255, 153, 0.2);
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 { .btn-spinner {
margin: 0; width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
border-top-color: #ffffff;
animation: spin 0.8s linear infinite;
} }
/* Animations */ /* Keyframe Animations */
@@keyframes fadeIn { @@keyframes bubble-fade-in {
from { opacity: 0; transform: translateY(15px); } 0% { opacity: 0; transform: translateY(10px) scale(0.98); }
to { opacity: 1; transform: translateY(0); } 100% { opacity: 1; transform: translateY(0) scale(1); }
} }
@@keyframes slideUp { @@keyframes typing-bounce {
from { opacity: 0; transform: translateY(10px); } 0%, 100% { transform: translateY(0); }
to { opacity: 1; transform: translateY(0); } 50% { transform: translateY(-4px); }
}
@@keyframes pulse {
0% { transform: scale(0.96); opacity: 0.8; }
100% { transform: scale(1.04); opacity: 1; }
} }
@@keyframes spin { @@keyframes spin {
0% { transform: rotate(0deg); } 0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); } 100% { transform: rotate(360deg); }
} }
@@keyframes pulse {
0% { transform: scale(0.95); opacity: 0.7; }
100% { transform: scale(1.05); opacity: 1; }
}
</style> </style>
@code { @code {
private string _question = string.Empty; private string _question = string.Empty;
private string _selectedBookId = string.Empty; private string _selectedBookId = string.Empty;
private bool _isLoading; private bool _isLoading;
private bool _hasSearched;
private GroundedResponseDto? _response;
private List<LastReadBookDto>? _books; private List<LastReadBookDto>? _books;
private List<ChatMessage> _chatMessages = new();
public class ChatMessage
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public string Sender { get; set; } = string.Empty; // "User" or "AI"
public string Text { get; set; } = string.Empty;
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public List<ResponseSegment> Segments { get; set; } = new();
public List<CitationDto> Citations { get; set; } = new();
}
public class ResponseSegment
{
public string Text { get; set; } = string.Empty;
public bool IsCitation { get; set; }
public string CitationId { get; set; } = string.Empty;
}
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -457,9 +592,18 @@
{ {
if (string.IsNullOrWhiteSpace(_question) || _isLoading) return; if (string.IsNullOrWhiteSpace(_question) || _isLoading) return;
var userQuestion = _question;
_question = string.Empty; // Clear input field immediately
_isLoading = true; _isLoading = true;
_hasSearched = true;
_response = null; // Add user query message
_chatMessages.Add(new ChatMessage
{
Sender = "User",
Text = userQuestion,
Segments = new List<ResponseSegment> { new ResponseSegment { Text = userQuestion, IsCitation = false } }
});
StateHasChanged(); StateHasChanged();
try try
@@ -473,27 +617,38 @@
var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global"; var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global";
var result = await KnowledgeService.AskQuestionAsync(_question, tenantId, ebookId); var result = await KnowledgeService.AskQuestionAsync(userQuestion, tenantId, ebookId);
if (result.IsSuccess) if (result.IsSuccess)
{ {
_response = result.Value; var response = result.Value;
_chatMessages.Add(new ChatMessage
{
Sender = "AI",
Text = response.Answer,
Segments = ParseSegments(response.Answer),
Citations = response.Citations
});
} }
else else
{ {
_response = new GroundedResponseDto var errMsg = $"Error: {result.Errors.FirstOrDefault()?.Message ?? "An error occurred."}";
_chatMessages.Add(new ChatMessage
{ {
Answer = $"Error: {result.Errors.FirstOrDefault()?.Message ?? "An error occurred."}", Sender = "AI",
Citations = new List<CitationDto>() Text = errMsg,
}; Segments = new List<ResponseSegment> { new ResponseSegment { Text = errMsg, IsCitation = false } }
});
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_response = new GroundedResponseDto var errMsg = $"Network/API Error: {ex.Message}";
_chatMessages.Add(new ChatMessage
{ {
Answer = $"Network/API Error: {ex.Message}", Sender = "AI",
Citations = new List<CitationDto>() Text = errMsg,
}; Segments = new List<ResponseSegment> { new ResponseSegment { Text = errMsg, IsCitation = false } }
});
} }
finally finally
{ {
@@ -501,4 +656,77 @@
StateHasChanged(); StateHasChanged();
} }
} }
private List<ResponseSegment> ParseSegments(string text)
{
var segments = new List<ResponseSegment>();
if (string.IsNullOrEmpty(text)) return segments;
// Matches [Source ID: some-id] OR raw GUIDs in brackets [e225e58f-7539-cd51-e0ab-82741ec7e65c]
var regex = new System.Text.RegularExpressions.Regex(
@"\[Source ID:\s*([^\]]+)\]|\[([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})\]",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
var matches = regex.Matches(text);
int lastIndex = 0;
foreach (System.Text.RegularExpressions.Match match in matches)
{
if (match.Index > lastIndex)
{
segments.Add(new ResponseSegment
{
Text = text.Substring(lastIndex, match.Index - lastIndex),
IsCitation = false
});
}
var citationId = match.Groups[1].Success
? match.Groups[1].Value.Trim()
: match.Groups[2].Value.Trim();
segments.Add(new ResponseSegment
{
IsCitation = true,
CitationId = citationId
});
lastIndex = match.Index + match.Length;
}
if (lastIndex < text.Length)
{
segments.Add(new ResponseSegment
{
Text = text.Substring(lastIndex),
IsCitation = false
});
}
return segments;
}
private MarkupString RenderMarkdown(string text)
{
if (string.IsNullOrEmpty(text)) return new MarkupString(string.Empty);
// 1. HTML Encode to prevent XSS
var html = System.Net.WebUtility.HtmlEncode(text);
// 2. Bold: **text** -> <strong>text</strong>
html = System.Text.RegularExpressions.Regex.Replace(html, @"\*\*(.*?)\*\*", "<strong>$1</strong>");
// 3. Italic: *text* -> <em>text</em>
html = System.Text.RegularExpressions.Regex.Replace(html, @"\*(.*?)\*", "<em>$1</em>");
// 4. Code blocks: ```language ... ``` -> <pre class="nexus-code-block"><code>...</code></pre>
html = System.Text.RegularExpressions.Regex.Replace(html, @"```(?:[a-zA-Z0-9+#]+)?\s*([\s\S]*?)\s*```", "<pre class=\"nexus-code-block\"><code>$1</code></pre>");
// 5. Inline Code: `code` -> <code class="nexus-inline-code">code</code>
html = System.Text.RegularExpressions.Regex.Replace(html, @"`(.*?)`", "<code class=\"nexus-inline-code\">$1</code>");
// 6. Newlines: \n -> <br />
html = html.Replace("\n", "<br />");
return new MarkupString(html);
}
} }
@@ -51,6 +51,12 @@ public class SyncService : ISyncService, IAsyncDisposable
_hubConnection.On<string, DateTime>("ProgressUpdated", async (pageId, timestamp) => _hubConnection.On<string, DateTime>("ProgressUpdated", async (pageId, timestamp) =>
{ {
// Note: In the future we might want to receive ebookId and progress here too // Note: In the future we might want to receive ebookId and progress here too
if (pageId == _lastSentPageId)
{
_logger.LogDebug("[Sync] Ignoring self progress update for page {PageId}.", pageId);
return;
}
_lastSentPageId = pageId; // Prevent echoing back duplicate progress updates
if (OnProgressReceived != null) await OnProgressReceived(pageId, timestamp); if (OnProgressReceived != null) await OnProgressReceived(pageId, timestamp);
}); });
@@ -77,6 +83,8 @@ public class SyncService : ISyncService, IAsyncDisposable
{ {
if (pageId == _lastSentPageId) return Result.Ok(); if (pageId == _lastSentPageId) return Result.Ok();
_lastSentPageId = pageId;
// Proper trailing-edge debounce // Proper trailing-edge debounce
_debounceCts?.Cancel(); _debounceCts?.Cancel();
_debounceCts = new CancellationTokenSource(); _debounceCts = new CancellationTokenSource();
@@ -92,8 +100,7 @@ public class SyncService : ISyncService, IAsyncDisposable
if (_hubConnection?.State == HubConnectionState.Connected) if (_hubConnection?.State == HubConnectionState.Connected)
{ {
await _hubConnection.SendAsync("UpdateProgress", pageId, ebookId, progress, chapterTitle, token); await _hubConnection.SendAsync("UpdateProgress", pageId, ebookId, progress, chapterTitle, chapterIndex);
_lastSentPageId = pageId;
} }
} }
catch (TaskCanceledException) { /* Ignored, user kept scrolling */ } catch (TaskCanceledException) { /* Ignored, user kept scrolling */ }
+2 -1
View File
@@ -106,7 +106,8 @@ builder.Services.AddAuthentication(options =>
builder.Services.AddIdentityApiEndpoints<NexusUser>() builder.Services.AddIdentityApiEndpoints<NexusUser>()
.AddRoles<IdentityRole>() .AddRoles<IdentityRole>()
.AddEntityFrameworkStores<AppDbContext>(); .AddEntityFrameworkStores<AppDbContext>()
.AddClaimsPrincipalFactory<NexusReader.Web.Services.CustomUserClaimsPrincipalFactory>();
builder.Services.ConfigureApplicationCookie(options => builder.Services.ConfigureApplicationCookie(options =>
{ {
@@ -0,0 +1,28 @@
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using NexusReader.Domain.Entities;
namespace NexusReader.Web.Services;
public class CustomUserClaimsPrincipalFactory : UserClaimsPrincipalFactory<NexusUser, IdentityRole>
{
public CustomUserClaimsPrincipalFactory(
UserManager<NexusUser> userManager,
RoleManager<IdentityRole> roleManager,
IOptions<IdentityOptions> optionsAccessor)
: base(userManager, roleManager, optionsAccessor)
{
}
protected override async Task<ClaimsIdentity> GenerateClaimsAsync(NexusUser user)
{
var identity = await base.GenerateClaimsAsync(user);
if (!string.IsNullOrEmpty(user.TenantId))
{
identity.AddClaim(new Claim("TenantId", user.TenantId));
}
return identity;
}
}
@@ -0,0 +1,58 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using NexusReader.Data.Persistence;
using Xunit;
namespace NexusReader.Application.Tests.Queries;
public class CheckDatabaseTest
{
[Fact]
public async Task PrintDatabaseStats()
{
var configJson = await File.ReadAllTextAsync("../../../../../src/NexusReader.Web/appsettings.json");
var doc = JsonDocument.Parse(configJson);
var pgConn = doc.RootElement.GetProperty("ConnectionStrings").GetProperty("PostgresConnection").GetString();
Console.WriteLine($"Postgres Connection: {pgConn}");
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
optionsBuilder.UseNpgsql(pgConn);
using var context = new AppDbContext(optionsBuilder.Options);
var usersCount = await context.Users.CountAsync();
var ebooksCount = await context.Ebooks.CountAsync();
var unitsCount = await context.KnowledgeUnits.CountAsync();
var cacheCount = await context.SemanticKnowledgeCache.CountAsync();
Console.WriteLine($"=== DATABASE STATS ===");
Console.WriteLine($"Users: {usersCount}");
Console.WriteLine($"Ebooks: {ebooksCount}");
Console.WriteLine($"KnowledgeUnits: {unitsCount}");
Console.WriteLine($"SemanticKnowledgeCache: {cacheCount}");
var users = await context.Users.ToListAsync();
foreach (var u in users)
{
Console.WriteLine($"User: {u.Email}, TenantId: '{u.TenantId}'");
}
var ebooks = await context.Ebooks.ToListAsync();
foreach (var eb in ebooks)
{
Console.WriteLine($"Ebook Id: {eb.Id}, Title: '{eb.Title}', FilePath: '{eb.FilePath}', Ready: {eb.IsReadyForReading}");
}
var cache = await context.SemanticKnowledgeCache.ToListAsync();
foreach (var c in cache)
{
Console.WriteLine($"Cache Hash: {c.ContentHash}, TenantId: '{c.TenantId}', PromptVersion: {c.PromptVersion}, JsonData Preview: {c.JsonData.Substring(0, Math.Min(c.JsonData.Length, 150))}");
}
Assert.True(true);
}
}