diff --git a/src/NexusReader.Application/DTOs/User/UserProfileDto.cs b/src/NexusReader.Application/DTOs/User/UserProfileDto.cs index 61cf06e..31dd1d3 100644 --- a/src/NexusReader.Application/DTOs/User/UserProfileDto.cs +++ b/src/NexusReader.Application/DTOs/User/UserProfileDto.cs @@ -21,6 +21,7 @@ public record UserProfileDto public int ConceptsMappedCount { get; init; } public LastReadBookDto? LastReadBook { get; init; } public IReadOnlyList RecentQuizzes { get; init; } = Array.Empty(); + public IReadOnlyList MappedConcepts { get; init; } = Array.Empty(); public string[] Roles { get; init; } = Array.Empty(); // Helper properties for UI compatibility @@ -29,6 +30,14 @@ public record UserProfileDto 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 Guid Id { get; init; } diff --git a/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs b/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs index a536dbf..dba5d2f 100644 --- a/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs +++ b/src/NexusReader.Application/Queries/User/GetUserProfileQueryHandler.cs @@ -38,7 +38,7 @@ public class GetUserProfileQueryHandler : IRequestHandler 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 { Id = e.Id, @@ -64,6 +64,17 @@ public class GetUserProfileQueryHandler : IRequestHandler 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 .Where(ur => ur.UserId == u.Id) .Join(dbContext.Roles, ur => ur.RoleId, r => r.Id, (ur, r) => r.Name!) diff --git a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs index 9b5ca3e..b0b7278 100644 --- a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs +++ b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs @@ -90,7 +90,7 @@ public class KnowledgeService : IKnowledgeService // 1. Check Cache var cached = await dbContext.SemanticKnowledgeCache - .FirstOrDefaultAsync(c => c.ContentHash == hash && c.TenantId == tenantId, cancellationToken); + .FirstOrDefaultAsync(c => c.ContentHash == hash && (c.TenantId == tenantId || c.TenantId == "global"), cancellationToken); if (cached != null && cached.PromptVersion == PromptVersion) { @@ -98,7 +98,12 @@ public class KnowledgeService : IKnowledgeService try { var packet = JsonSerializer.Deserialize(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) { @@ -226,6 +231,30 @@ public class KnowledgeService : IKnowledgeService 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 + { + ["label"] = node.Label, + ["group"] = node.Group, + ["summary"] = node.Summary ?? "", + ["key_terms"] = node.KeyTerms ?? new List() + } + )).ToList(); + + var graphLinks = packet.Graph.Links.Select(link => new KnowledgeLinkDto( + link.Source, + link.Target, + link.RelationType + )).ToList(); + + packet = packet with { Units = graphUnits, Links = graphLinks }; + } + var unitIds = packet.Units.Select(u => u.Id).ToList(); var linkSourceIds = packet.Links.Select(l => l.Source).ToList(); var linkTargetIds = packet.Links.Select(l => l.Target).ToList(); @@ -341,6 +370,79 @@ public class KnowledgeService : IKnowledgeService _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) @@ -381,6 +483,14 @@ public class KnowledgeService : IKnowledgeService 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> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default) { var systemPrompt = @" @@ -463,10 +573,28 @@ public class KnowledgeService : IKnowledgeService searchResult = new List(); } - var contexts = searchResult.Select(point => new RelevantContext + var contexts = searchResult.Select(point => { - Text = point.Payload.TryGetValue("content", out var cv) ? cv.StringValue : string.Empty, - Confidence = point.Score + 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>(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 + }; }).ToList(); return Result.Ok(contexts); @@ -534,7 +662,7 @@ public class KnowledgeService : IKnowledgeService } // 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>(); if (candidateIds.Any()) @@ -543,7 +671,7 @@ public class KnowledgeService : IKnowledgeService { await using var session = _neo4jDriver.AsyncSession(); var cypher = @" - MATCH (source:KnowledgeUnit)-[r:DEFINES]->(target:KnowledgeUnit) + MATCH (source:KnowledgeUnit)-[r]->(target:KnowledgeUnit) WHERE source.id IN $candidateIds RETURN source.id AS sourceId, target.content AS targetContent"; @@ -617,7 +745,7 @@ public class KnowledgeService : IKnowledgeService var dto = new SemanticSearchResultDto { - ContentHash = point.Id.ToString(), + ContentHash = GetPointIdString(point.Id), Snippet = content, UnitType = type, RelevanceScore = point.Score, @@ -625,7 +753,7 @@ public class KnowledgeService : IKnowledgeService Metadata = metadata }; - var pointIdStr = point.Id.ToString(); + var pointIdStr = GetPointIdString(point.Id); if (definitions.TryGetValue(pointIdStr, out var pointDefs) && pointDefs.Any()) { dto.Snippet = $"[Context: {string.Join("; ", pointDefs)}]\n{dto.Snippet}"; @@ -724,11 +852,26 @@ public class KnowledgeService : IKnowledgeService } // 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(); // 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(); + 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()) { @@ -738,7 +881,7 @@ public class KnowledgeService : IKnowledgeService var cypher = @" MATCH (source:KnowledgeUnit) 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, collect({ targetId: target.id, targetContent: target.content, relation: type(r) }) AS relations"; @@ -751,23 +894,64 @@ public class KnowledgeService : IKnowledgeService foreach (var record in neoResult) { var sourceId = record["sourceId"].As(); - var sourceContent = record["sourceContent"].As(); - 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>(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(); + } + + relatedContexts.Add($"[Source ID: {sourceId}] {sourceText}"); var relations = record["relations"].As>(); if (relations != null) { foreach (var relObj in relations) { - if (relObj is Dictionary relDict && - relDict.TryGetValue("targetId", out var targetIdVal) && targetIdVal is string targetId && - relDict.TryGetValue("targetContent", out var targetContentVal) && targetContentVal is string targetContent && - relDict.TryGetValue("relation", out var relationVal) && relationVal is string relation) + if (relObj is System.Collections.IDictionary relDict) { - 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>(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}"); } } } @@ -779,9 +963,32 @@ public class KnowledgeService : IKnowledgeService _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}"); + var sourceId = GetPointIdString(point.Id); + + var sourceText = string.Empty; + if (guidMap.TryGetValue(sourceId, out var sourceUnit)) + { + var summary = string.Empty; + if (!string.IsNullOrEmpty(sourceUnit.MetadataJson)) + { + try + { + var meta = JsonSerializer.Deserialize>(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}"); } } } @@ -805,33 +1012,14 @@ public class KnowledgeService : IKnowledgeService // 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 systemPrompt = PromptRegistry.GroundedRAGSystemPrompt; var userPrompt = $"Context:\n{contextBlocksText}\n\nQuestion: {question}"; var options = new ChatOptions { Temperature = 0.0f, - MaxOutputTokens = 1500, - ResponseFormat = ChatResponseFormat.Json + MaxOutputTokens = 1500 }; var chatResponse = await _retryPipeline.ExecuteAsync(async ct => @@ -843,6 +1031,20 @@ Strict Grounding Rules: var rawJson = chatResponse.Text?.Trim() ?? string.Empty; rawJson = rawJson.Replace("```json", "").Replace("```", "").Trim(); + + // Handle direct text fallback when model bypasses JSON format + if (!rawJson.StartsWith("{") && + (rawJson.Contains("cannot answer", StringComparison.OrdinalIgnoreCase) || + rawJson.Contains("context does not contain", StringComparison.OrdinalIgnoreCase) || + rawJson.Contains("provided book context", StringComparison.OrdinalIgnoreCase))) + { + return Result.Ok(new GroundedResponseDto + { + Answer = "I cannot answer this based on the provided book context.", + Citations = new List() + }); + } + rawJson = JsonRepairHelper.Repair(rawJson); try @@ -897,6 +1099,20 @@ Strict Grounding Rules: _logger.LogWarning(ex, "[KnowledgeService] Failed to drop Qdrant collection 'knowledge_units' during cache clear."); } + try + { + await using var session = _neo4jDriver.AsyncSession(); + await session.ExecuteWriteAsync(async tx => + { + await tx.RunAsync("MATCH (n:KnowledgeUnit) DETACH DELETE n"); + }); + _logger.LogInformation("[KnowledgeService] Successfully wiped Neo4j 'KnowledgeUnit' nodes."); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[KnowledgeService] Failed to wipe Neo4j graph during cache clear."); + } + return Result.Ok(); } catch (Exception ex) diff --git a/src/NexusReader.Infrastructure/Services/PromptRegistry.cs b/src/NexusReader.Infrastructure/Services/PromptRegistry.cs index c84df21..776b3bc 100644 --- a/src/NexusReader.Infrastructure/Services/PromptRegistry.cs +++ b/src/NexusReader.Infrastructure/Services/PromptRegistry.cs @@ -58,4 +58,24 @@ public static class PromptRegistry "\"units\": [ { \"id\": \"string\", \"type\": \"Section|Table|Definition|Rule\", \"content\": \"string\", \"metadata\": { \"page\": 0 } } ], " + "\"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'" + } + ] + } + """; } diff --git a/src/NexusReader.UI.Shared/Pages/Dashboard.razor b/src/NexusReader.UI.Shared/Pages/Dashboard.razor index beba01c..dcfed70 100644 --- a/src/NexusReader.UI.Shared/Pages/Dashboard.razor +++ b/src/NexusReader.UI.Shared/Pages/Dashboard.razor @@ -5,6 +5,7 @@ @using NexusReader.UI.Shared.Services @inject IIdentityService IdentityService @inject NavigationManager NavigationManager +@inject ISyncService SyncService @attribute [Authorize] @implements IDisposable @@ -55,12 +56,49 @@
-
-
-
-
-
TU JESTEŚ
+
+ + @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; +
+
+ } + } + else + { +
+
+
+ } + +
+ @(string.IsNullOrEmpty(_hoveredConceptLabel) ? "TU JESTEŚ" : _hoveredConceptLabel) +
+ + @if (_hoveredConcept != null) + { +
+ @_hoveredConcept.Type +

@_hoveredConcept.Content

+
+ } + else + { +
+ Mapowanie AI +

Najedź na węzeł, aby zbadać pojęcie wydobyte przez Nexus AI.

+
+ } @@ -105,11 +143,28 @@ @code { private UserProfileDto? _profile; + private MappedConceptDto? _hoveredConcept; + private string _hoveredConceptLabel = string.Empty; protected override async Task OnInitializedAsync() { IdentityService.OnStateInvalidated += HandleStateInvalidatedAsync; 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() @@ -134,9 +189,19 @@ }); } + private async Task HandleProgressReceivedAsync(string pageId, DateTime timestamp) + { + await InvokeAsync(async () => + { + IdentityService.ClearCache(); + await LoadProfileAsync(); + }); + } + public void Dispose() { IdentityService.OnStateInvalidated -= HandleStateInvalidatedAsync; + SyncService.OnProgressReceived -= HandleProgressReceivedAsync; } } diff --git a/src/NexusReader.UI.Shared/Pages/Dashboard.razor.css b/src/NexusReader.UI.Shared/Pages/Dashboard.razor.css index ba334ce..7c7f009 100644 --- a/src/NexusReader.UI.Shared/Pages/Dashboard.razor.css +++ b/src/NexusReader.UI.Shared/Pages/Dashboard.razor.css @@ -294,9 +294,19 @@ } .graph-node.satellite { - width: 20px; - height: 20px; + width: 16px; + height: 16px; 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 { @@ -480,3 +490,41 @@ color: #666666; 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; +} + diff --git a/src/NexusReader.UI.Shared/Services/SyncService.cs b/src/NexusReader.UI.Shared/Services/SyncService.cs index 214d7ef..8e0227d 100644 --- a/src/NexusReader.UI.Shared/Services/SyncService.cs +++ b/src/NexusReader.UI.Shared/Services/SyncService.cs @@ -92,7 +92,7 @@ public class SyncService : ISyncService, IAsyncDisposable 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; } } diff --git a/src/NexusReader.Web/Program.cs b/src/NexusReader.Web/Program.cs index 56230f7..9e10a59 100644 --- a/src/NexusReader.Web/Program.cs +++ b/src/NexusReader.Web/Program.cs @@ -106,7 +106,8 @@ builder.Services.AddAuthentication(options => builder.Services.AddIdentityApiEndpoints() .AddRoles() - .AddEntityFrameworkStores(); + .AddEntityFrameworkStores() + .AddClaimsPrincipalFactory(); builder.Services.ConfigureApplicationCookie(options => { diff --git a/src/NexusReader.Web/Services/CustomUserClaimsPrincipalFactory.cs b/src/NexusReader.Web/Services/CustomUserClaimsPrincipalFactory.cs new file mode 100644 index 0000000..c06ec78 --- /dev/null +++ b/src/NexusReader.Web/Services/CustomUserClaimsPrincipalFactory.cs @@ -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 +{ + public CustomUserClaimsPrincipalFactory( + UserManager userManager, + RoleManager roleManager, + IOptions optionsAccessor) + : base(userManager, roleManager, optionsAccessor) + { + } + + protected override async Task GenerateClaimsAsync(NexusUser user) + { + var identity = await base.GenerateClaimsAsync(user); + if (!string.IsNullOrEmpty(user.TenantId)) + { + identity.AddClaim(new Claim("TenantId", user.TenantId)); + } + return identity; + } +} diff --git a/tests/NexusReader.Application.Tests/Queries/CheckDatabaseTest.cs b/tests/NexusReader.Application.Tests/Queries/CheckDatabaseTest.cs new file mode 100644 index 0000000..e8afdf8 --- /dev/null +++ b/tests/NexusReader.Application.Tests/Queries/CheckDatabaseTest.cs @@ -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(); + 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); + } +}