feat: implement Neo4j knowledge graph synchronization and integrate global cache support with custom tenant claims.

This commit is contained in:
2026-05-26 12:54:41 +02:00
parent 3cbbb6df6b
commit 824b4366e0
10 changed files with 510 additions and 54 deletions
@@ -21,6 +21,7 @@ public record UserProfileDto
public int ConceptsMappedCount { get; init; }
public LastReadBookDto? LastReadBook { get; init; }
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>();
// 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; }
@@ -38,7 +38,7 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
: 0,
DisplayName = u.DisplayName,
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
{
Id = e.Id,
@@ -64,6 +64,17 @@ public class GetUserProfileQueryHandler : IRequestHandler<GetUserProfileQuery, R
Percentage = q.Percentage,
CompletedDate = q.CompletedDate
}).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
.Where(ur => ur.UserId == u.Id)
.Join(dbContext.Roles, ur => ur.RoleId, r => r.Id, (ur, r) => r.Name!)
@@ -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<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)
{
@@ -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<string, object>
{
["label"] = node.Label,
["group"] = node.Group,
["summary"] = node.Summary ?? "",
["key_terms"] = node.KeyTerms ?? new List<string>()
}
)).ToList();
var graphLinks = packet.Graph.Links.Select(link => new KnowledgeLinkDto(
link.Source,
link.Target,
link.RelationType
)).ToList();
packet = packet with { Units = graphUnits, Links = graphLinks };
}
var unitIds = packet.Units.Select(u => u.Id).ToList();
var linkSourceIds = packet.Links.Select(l => l.Source).ToList();
var linkTargetIds = packet.Links.Select(l => l.Target).ToList();
@@ -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<Result<GroundednessResult>> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default)
{
var systemPrompt = @"
@@ -463,10 +573,28 @@ public class KnowledgeService : IKnowledgeService
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
};
}).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<string, List<string>>();
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<string>();
// 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())
{
@@ -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<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>>();
if (relations != null)
{
foreach (var relObj in relations)
{
if (relObj is Dictionary<string, object> relDict &&
relDict.TryGetValue("targetId", out var targetIdVal) && targetIdVal is string targetId &&
relDict.TryGetValue("targetContent", out var targetContentVal) && targetContentVal is string targetContent &&
relDict.TryGetValue("relation", out var relationVal) && relationVal is string relation)
if (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<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}");
}
}
}
@@ -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<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}");
}
}
}
@@ -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<CitationDto>()
});
}
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)
@@ -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'"
}
]
}
""";
}
@@ -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 @@
<NexusIcon Name="arrow-right" Size="16" />
</div>
<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: 120deg; --dist: 50px;"></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>
@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>
<!-- Quiz Summary -->
@@ -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;
}
}
@@ -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;
}
@@ -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;
}
}
+2 -1
View File
@@ -106,7 +106,8 @@ builder.Services.AddAuthentication(options =>
builder.Services.AddIdentityApiEndpoints<NexusUser>()
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<AppDbContext>();
.AddEntityFrameworkStores<AppDbContext>()
.AddClaimsPrincipalFactory<NexusReader.Web.Services.CustomUserClaimsPrincipalFactory>();
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);
}
}