feat: implement Neo4j knowledge graph synchronization and integrate global cache support with custom tenant claims.
This commit is contained in:
@@ -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!)
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
|
|
||||||
// 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 && (c.TenantId == tenantId || c.TenantId == "global"), cancellationToken);
|
||||||
|
|
||||||
if (cached != null && cached.PromptVersion == PromptVersion)
|
if (cached != null && cached.PromptVersion == PromptVersion)
|
||||||
{
|
{
|
||||||
@@ -98,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)
|
||||||
{
|
{
|
||||||
@@ -226,6 +231,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();
|
||||||
@@ -341,6 +370,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)
|
||||||
@@ -381,6 +483,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 = @"
|
||||||
@@ -463,10 +573,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;
|
||||||
Confidence = point.Score
|
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();
|
}).ToList();
|
||||||
|
|
||||||
return Result.Ok(contexts);
|
return Result.Ok(contexts);
|
||||||
@@ -534,7 +662,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())
|
||||||
@@ -543,7 +671,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";
|
||||||
|
|
||||||
@@ -617,7 +745,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,
|
||||||
@@ -625,7 +753,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}";
|
||||||
@@ -724,11 +852,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())
|
||||||
{
|
{
|
||||||
@@ -738,7 +881,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";
|
||||||
|
|
||||||
@@ -751,23 +894,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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -779,9 +963,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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -805,33 +1012,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 =>
|
||||||
@@ -843,6 +1031,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
|
||||||
@@ -897,6 +1099,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'"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
<div class="graph-node satellite" style="--angle: 0deg; --dist: 60px;"></div>
|
|
||||||
<div class="graph-node satellite" style="--angle: 120deg; --dist: 50px;"></div>
|
@if (_profile?.MappedConcepts != null && _profile.MappedConcepts.Any())
|
||||||
<div class="graph-node satellite" style="--angle: 240deg; --dist: 70px;"></div>
|
{
|
||||||
<div class="active-node-label">TU JESTEŚ</div>
|
@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>
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,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;
|
_lastSentPageId = pageId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user