feat(ui/quiz): implement real-time global chapter quiz generation, submit results to database, and display dynamic statistics on dashboard #53
@@ -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,
|
||||
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<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 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="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">
|
||||
@(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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user