2 Commits

2 changed files with 144 additions and 38 deletions
@@ -15,6 +15,7 @@ using Polly.Registry;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using NexusReader.Infrastructure.Configuration; using NexusReader.Infrastructure.Configuration;
using Qdrant.Client; using Qdrant.Client;
using Qdrant.Client.Grpc;
using Neo4j.Driver; using Neo4j.Driver;
namespace NexusReader.Infrastructure.Services; namespace NexusReader.Infrastructure.Services;
@@ -285,6 +286,98 @@ public class KnowledgeService : IKnowledgeService
_logger.LogWarning("[KnowledgeService] Skipping invalid link {Source} -> {Target}: one or both units are missing.", linkDto.Source, linkDto.Target); _logger.LogWarning("[KnowledgeService] Skipping invalid link {Source} -> {Target}: one or both units are missing.", linkDto.Source, linkDto.Target);
} }
} }
// Generate and upsert vectors to Qdrant in batch
var unitsToEmbed = packet.Units
.Where(u => !string.IsNullOrEmpty(u.Content))
.ToList();
if (unitsToEmbed.Any())
{
try
{
var contents = unitsToEmbed.Select(u => u.Content).ToList();
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct =>
await _embeddingGenerator.GenerateAsync(
contents,
new EmbeddingGenerationOptions { Dimensions = 768 },
cancellationToken: ct), cancellationToken);
var embeddings = embeddingResponse.ToList();
var points = new List<PointStruct>();
for (int i = 0; i < unitsToEmbed.Count; i++)
{
var unitDto = unitsToEmbed[i];
var vector = embeddings[i].Vector.ToArray();
var point = new PointStruct
{
Id = GetDeterministicGuid(unitDto.Id),
Vectors = vector,
Payload =
{
["content"] = unitDto.Content,
["type"] = unitDto.Type ?? string.Empty,
["tenantId"] = tenantId,
["ebookId"] = ebookId?.ToString() ?? string.Empty,
["metadataJson"] = JsonSerializer.Serialize(unitDto.Metadata)
}
};
points.Add(point);
}
if (points.Any())
{
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
await _qdrantClient.UpsertAsync("knowledge_units", points, cancellationToken: cancellationToken);
_logger.LogInformation("[KnowledgeService] Successfully upserted {Count} points to Qdrant collection 'knowledge_units'.", points.Count);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[KnowledgeService] Failed to generate and upsert embeddings for knowledge units to Qdrant.");
}
}
}
private async Task EnsureCollectionExistsAsync(string collectionName, CancellationToken cancellationToken = default)
{
try
{
var exists = await _qdrantClient.CollectionExistsAsync(collectionName, cancellationToken);
if (!exists)
{
_logger.LogInformation("[KnowledgeService] Creating Qdrant collection '{CollectionName}'...", collectionName);
await _qdrantClient.CreateCollectionAsync(
collectionName: collectionName,
vectorsConfig: new VectorParams
{
Size = 768,
Distance = Distance.Cosine
},
cancellationToken: cancellationToken
);
_logger.LogInformation("[KnowledgeService] Qdrant collection '{CollectionName}' created successfully.", collectionName);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[KnowledgeService] Error ensuring Qdrant collection '{CollectionName}' exists.", collectionName);
}
}
private static Guid GetDeterministicGuid(string input)
{
if (Guid.TryParse(input, out var guid))
{
return guid;
}
using var md5 = System.Security.Cryptography.MD5.Create();
byte[] hash = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input));
return new Guid(hash);
} }
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)
@@ -354,6 +447,7 @@ public class KnowledgeService : IKnowledgeService
List<Qdrant.Client.Grpc.ScoredPoint> searchResult; List<Qdrant.Client.Grpc.ScoredPoint> searchResult;
try try
{ {
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
var response = await _qdrantClient.SearchAsync( var response = await _qdrantClient.SearchAsync(
collectionName: "knowledge_units", collectionName: "knowledge_units",
vector: queryVector, vector: queryVector,
@@ -417,6 +511,7 @@ public class KnowledgeService : IKnowledgeService
List<Qdrant.Client.Grpc.ScoredPoint> searchResult; List<Qdrant.Client.Grpc.ScoredPoint> searchResult;
try try
{ {
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
var response = await _qdrantClient.SearchAsync( var response = await _qdrantClient.SearchAsync(
collectionName: "knowledge_units", collectionName: "knowledge_units",
vector: queryVector, vector: queryVector,
@@ -602,6 +697,7 @@ public class KnowledgeService : IKnowledgeService
List<Qdrant.Client.Grpc.ScoredPoint> searchResult; List<Qdrant.Client.Grpc.ScoredPoint> searchResult;
try try
{ {
await EnsureCollectionExistsAsync("knowledge_units", cancellationToken);
var response = await _qdrantClient.SearchAsync( var response = await _qdrantClient.SearchAsync(
collectionName: "knowledge_units", collectionName: "knowledge_units",
vector: queryVector, vector: queryVector,
@@ -790,6 +886,16 @@ Strict Grounding Rules:
await dbContext.SemanticKnowledgeCache.ExecuteDeleteAsync(cancellationToken); await dbContext.SemanticKnowledgeCache.ExecuteDeleteAsync(cancellationToken);
await dbContext.KnowledgeUnits.ExecuteDeleteAsync(cancellationToken); await dbContext.KnowledgeUnits.ExecuteDeleteAsync(cancellationToken);
await dbContext.KnowledgeUnitLinks.ExecuteDeleteAsync(cancellationToken); await dbContext.KnowledgeUnitLinks.ExecuteDeleteAsync(cancellationToken);
try
{
await _qdrantClient.DeleteCollectionAsync("knowledge_units", cancellationToken: cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[KnowledgeService] Failed to drop Qdrant collection 'knowledge_units' during cache clear.");
}
return Result.Ok(); return Result.Ok();
} }
catch (Exception ex) catch (Exception ex)
@@ -3,7 +3,7 @@
width: 100%; width: 100%;
max-width: 600px; max-width: 600px;
margin: 1.5rem auto; margin: 1.5rem auto;
font-family: 'Inter', sans-serif; font-family: var(--nexus-font-sans), 'Inter', sans-serif;
z-index: 1000; z-index: 1000;
} }
@@ -11,19 +11,21 @@
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
background-color: #121212; background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(138, 43, 226, 0.4); /* Neon fiolet border on blur */ backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px; border-radius: 14px;
padding: 0.65rem 1.1rem; padding: 0.65rem 1.1rem;
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5), 0 0 8px rgba(138, 43, 226, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
} }
/* Focused state: glowing neon green border */ /* Focused state: glowing neon border matching other dashboard components */
.nexus-search-container.focused .search-wrapper { .nexus-search-container.focused .search-wrapper {
background-color: #181818; background: rgba(255, 255, 255, 0.05);
border-color: #00ff7f; /* Neon green focus */ border-color: var(--nexus-neon);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6), 0 0 18px rgba(0, 255, 127, 0.35); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 15px rgba(0, 255, 153, 0.25);
} }
.search-icon-container { .search-icon-container {
@@ -42,7 +44,7 @@
} }
.nexus-search-container.focused .nexus-icon { .nexus-search-container.focused .nexus-icon {
color: #00ff7f; color: var(--nexus-neon);
} }
.nexus-search-input { .nexus-search-input {
@@ -77,11 +79,11 @@
.ai-pulse-dot { .ai-pulse-dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
background-color: #00ff7f; background-color: var(--nexus-neon);
border-radius: 50%; border-radius: 50%;
display: inline-block; display: inline-block;
position: relative; position: relative;
box-shadow: 0 0 8px #00ff7f; box-shadow: 0 0 8px var(--nexus-neon);
} }
.ai-pulse-dot::after { .ai-pulse-dot::after {
@@ -91,7 +93,7 @@
height: 100%; height: 100%;
top: 0; top: 0;
left: 0; left: 0;
background-color: #00ff7f; background-color: var(--nexus-neon);
border-radius: 50%; border-radius: 50%;
z-index: -1; z-index: -1;
animation: pulse 2s infinite ease-in-out; animation: pulse 2s infinite ease-in-out;
@@ -120,17 +122,17 @@
top: calc(100% + 8px); top: calc(100% + 8px);
left: 0; left: 0;
right: 0; right: 0;
background: rgba(18, 18, 18, 0.82); background: rgba(18, 18, 18, 0.9);
backdrop-filter: blur(18px) saturate(160%); backdrop-filter: blur(20px) saturate(160%);
-webkit-backdrop-filter: blur(18px) saturate(160%); -webkit-backdrop-filter: blur(20px) saturate(160%);
border: 1px solid rgba(138, 43, 226, 0.35); border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px; border-radius: 14px;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.7), 0 0 20px rgba(138, 43, 226, 0.15); box-shadow: 0 12px 36px rgba(0, 0, 0, 0.6), 0 0 20px rgba(0, 255, 153, 0.05);
max-height: 420px; max-height: 420px;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(138, 43, 226, 0.4) transparent; scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
animation: slideDown 0.25s cubic-bezier(0.16, 1, 0.3, 1); animation: slideDown 0.25s cubic-bezier(0.16, 1, 0.3, 1);
} }
@@ -140,21 +142,20 @@
} }
.search-dropdown::-webkit-scrollbar-thumb { .search-dropdown::-webkit-scrollbar-thumb {
background: rgba(138, 43, 226, 0.4); background: rgba(255, 255, 255, 0.15);
border-radius: 3px; border-radius: 3px;
} }
.search-dropdown::-webkit-scrollbar-thumb:hover { .search-dropdown::-webkit-scrollbar-thumb:hover {
background: rgba(0, 255, 127, 0.5); background: var(--nexus-neon);
} }
/* In-flight spinners */ /* In-flight spinners */
.neon-spinner { .neon-spinner {
width: 16px; width: 16px;
height: 16px; height: 16px;
border: 2px solid rgba(0, 255, 127, 0.15); border: 2px solid rgba(0, 255, 153, 0.15);
border-top: 2px solid #00ff7f; border-top: 2px solid var(--nexus-neon);
border-right: 2px solid #8a2be2;
border-radius: 50%; border-radius: 50%;
animation: spin 0.75s linear infinite; animation: spin 0.75s linear infinite;
} }
@@ -162,9 +163,8 @@
.neon-spinner-large { .neon-spinner-large {
width: 32px; width: 32px;
height: 32px; height: 32px;
border: 3px solid rgba(138, 43, 226, 0.15); border: 3px solid rgba(255, 255, 255, 0.05);
border-top: 3px solid #8a2be2; border-top: 3px solid var(--nexus-neon);
border-right: 3px solid #00ff7f;
border-radius: 50%; border-radius: 50%;
animation: spin 0.8s cubic-bezier(0.5, 0.1, 0.5, 0.9) infinite; animation: spin 0.8s cubic-bezier(0.5, 0.1, 0.5, 0.9) infinite;
margin-bottom: 1rem; margin-bottom: 1rem;
@@ -217,8 +217,8 @@
} }
.result-card:hover { .result-card:hover {
background: rgba(138, 43, 226, 0.08); background: rgba(0, 255, 153, 0.05);
border-color: rgba(138, 43, 226, 0.35); border-color: rgba(0, 255, 153, 0.2);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
} }
@@ -232,14 +232,14 @@
} }
.relevance-badge { .relevance-badge {
background: rgba(0, 255, 127, 0.15); background: rgba(0, 255, 153, 0.1);
color: #00ff7f; color: var(--nexus-neon);
border: 1px solid rgba(0, 255, 127, 0.3); border: 1px solid rgba(0, 255, 153, 0.25);
border-radius: 6px; border-radius: 6px;
padding: 0.15rem 0.45rem; padding: 0.15rem 0.45rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; letter-spacing: 0.02em;
text-shadow: 0 0 4px rgba(0, 255, 127, 0.3); text-shadow: 0 0 4px rgba(0, 255, 153, 0.25);
} }
.source-title { .source-title {
@@ -267,9 +267,9 @@
/* Markup highlights */ /* Markup highlights */
::deep mark.search-highlight { ::deep mark.search-highlight {
background: rgba(0, 255, 127, 0.22); background: rgba(0, 255, 153, 0.2);
color: #00ff7f; color: var(--nexus-neon);
border-bottom: 1px solid rgba(0, 255, 127, 0.55); border-bottom: 1px solid var(--nexus-neon);
padding: 0.05rem 0.15rem; padding: 0.05rem 0.15rem;
border-radius: 3px; border-radius: 3px;
font-weight: 500; font-weight: 500;
@@ -279,15 +279,15 @@
@keyframes pulse { @keyframes pulse {
0% { 0% {
transform: scale(1); transform: scale(1);
box-shadow: 0 0 0 0 rgba(0, 255, 127, 0.7); box-shadow: 0 0 0 0 rgba(0, 255, 153, 0.7);
} }
70% { 70% {
transform: scale(1.6); transform: scale(1.6);
box-shadow: 0 0 0 6px rgba(0, 255, 127, 0); box-shadow: 0 0 0 6px rgba(0, 255, 153, 0);
} }
100% { 100% {
transform: scale(1); transform: scale(1);
box-shadow: 0 0 0 0 rgba(0, 255, 127, 0); box-shadow: 0 0 0 0 rgba(0, 255, 153, 0);
} }
} }