feat: implement semantic search, knowledge unit extraction, and visualization components
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Domain.Entities;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Persistence;
|
||||
|
||||
public interface IApplicationDbContext
|
||||
{
|
||||
DbSet<SemanticKnowledgeCache> SemanticKnowledgeCache { get; }
|
||||
DbSet<KnowledgeUnit> KnowledgeUnits { get; }
|
||||
DbSet<KnowledgeUnitLink> KnowledgeUnitLinks { get; }
|
||||
DbSet<Ebook> Ebooks { get; }
|
||||
DbSet<QuizResult> QuizResults { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ public interface IKnowledgeService
|
||||
{
|
||||
Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default);
|
||||
Task<Result<KnowledgePacket>> GetGraphDataAsync(string text, CancellationToken cancellationToken = default);
|
||||
Task<Result<KnowledgePacket>> GetKnowledgeMapAsync(string text, CancellationToken cancellationToken = default);
|
||||
Task<Result<KnowledgePacket>> GetSummaryAndQuizAsync(string text, CancellationToken cancellationToken = default);
|
||||
Task<Result<List<RelevantContext>>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default);
|
||||
Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.AI;
|
||||
using NexusReader.Infrastructure.Services; // For PromptRegistry
|
||||
|
||||
namespace NexusReader.Application.Commands.AI;
|
||||
|
||||
public record VerifyGroundednessCommand(string Answer, string Context) : IRequest<Result<GroundednessResult>>;
|
||||
|
||||
public record GroundednessResult(float Score, string Rationale, bool IsGrounded);
|
||||
|
||||
public class VerifyGroundednessCommandHandler : IRequestHandler<VerifyGroundednessCommand, Result<GroundednessResult>>
|
||||
{
|
||||
private readonly IChatClient _chatClient;
|
||||
|
||||
public VerifyGroundednessCommandHandler(IChatClient chatClient)
|
||||
{
|
||||
_chatClient = chatClient;
|
||||
}
|
||||
|
||||
public async Task<Result<GroundednessResult>> Handle(VerifyGroundednessCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var systemPrompt = @"
|
||||
You are a Fact-Checking AI. Evaluate if the 'Answer' is supported by the 'Context'.
|
||||
Rate the groundedness from 0.0 to 1.0.
|
||||
Return ONLY a JSON object: { ""score"": 0.9, ""rationale"": ""string"", ""isGrounded"": true }
|
||||
";
|
||||
|
||||
var userPrompt = $"Context: {request.Context}\n\nAnswer: {request.Answer}";
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _chatClient.GetResponseAsync(new List<ChatMessage>
|
||||
{
|
||||
new ChatMessage(ChatRole.System, systemPrompt),
|
||||
new ChatMessage(ChatRole.User, userPrompt)
|
||||
}, cancellationToken: cancellationToken);
|
||||
|
||||
var rawJson = response.Text?.Trim() ?? "{}";
|
||||
// Simple cleanup if needed
|
||||
rawJson = rawJson.Replace("```json", "").Replace("```", "").Trim();
|
||||
|
||||
var result = System.Text.Json.JsonSerializer.Deserialize<GroundednessResult>(rawJson, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
return result != null ? Result.Ok(result) : Result.Fail("Failed to parse groundedness result");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error("Failed to verify groundedness").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,15 @@ public record QuizQuestion(
|
||||
[property: JsonPropertyName("correct_index")] int CorrectIndex
|
||||
);
|
||||
|
||||
public record KnowledgeUnitDto(string Id, string Type, string Content, Dictionary<string, object>? Metadata = null);
|
||||
public record KnowledgeLinkDto(string Source, string Target, string Relation);
|
||||
|
||||
public record KnowledgePacket
|
||||
{
|
||||
[JsonPropertyName("concepts")] public List<KeyConcept> Concepts { get; init; } = new();
|
||||
[JsonPropertyName("quizzes")] public List<QuizQuestion> Quizzes { get; init; } = new();
|
||||
[JsonPropertyName("units")] public List<KnowledgeUnitDto> Units { get; init; } = new();
|
||||
[JsonPropertyName("links")] public List<KnowledgeLinkDto> Links { get; init; } = new();
|
||||
[JsonPropertyName("graph")] public NexusReader.Application.Queries.Graph.GraphDataDto? Graph { get; init; }
|
||||
[JsonPropertyName("summary")] public string? Summary { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace NexusReader.Application.DTOs.AI;
|
||||
|
||||
public class RelevantContext
|
||||
{
|
||||
public string Text { get; set; } = string.Empty;
|
||||
public string SourceId { get; set; } = string.Empty; // ContentHash or EbookTitle
|
||||
public double Confidence { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace NexusReader.Application.DTOs.AI;
|
||||
|
||||
public class SemanticSearchResultDto
|
||||
{
|
||||
public string ContentHash { get; set; } = string.Empty;
|
||||
public string Snippet { get; set; } = string.Empty;
|
||||
public string? UnitType { get; set; }
|
||||
public float RelevanceScore { get; set; }
|
||||
public string? SourceBookTitle { get; set; }
|
||||
public Dictionary<string, object>? Metadata { get; set; } // Bonus context
|
||||
}
|
||||
@@ -10,7 +10,10 @@
|
||||
<PackageReference Include="Mapster.DependencyInjection" Version="10.0.7" />
|
||||
<PackageReference Include="MediatR" Version="12.1.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" Version="10.5.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="10.0.7" />
|
||||
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace NexusReader.Application.Queries.Graph;
|
||||
|
||||
public record GraphNodeDto(string Id, string Label, string Group);
|
||||
public record GraphLinkDto(string Source, string Target, int Value);
|
||||
public record GraphNodeDto(string Id, string Label, string Group, string? Type = null);
|
||||
public record GraphLinkDto(string Source, string Target, string RelationType, int Value = 1);
|
||||
public record GraphDataDto
|
||||
{
|
||||
public List<GraphNodeDto> Nodes { get; init; } = new();
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
using FluentResults;
|
||||
using Mapster;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using NexusReader.Application.DTOs.AI;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
using Pgvector.EntityFrameworkCore;
|
||||
|
||||
namespace NexusReader.Application.Queries.Library;
|
||||
|
||||
public record SearchLibrarySemanticallyQuery(string QueryText, string TenantId, int Limit = 5)
|
||||
: IRequest<Result<List<SemanticSearchResultDto>>>;
|
||||
|
||||
public class SearchLibrarySemanticallyQueryHandler : IRequestHandler<SearchLibrarySemanticallyQuery, Result<List<SemanticSearchResultDto>>>
|
||||
{
|
||||
private readonly IApplicationDbContext _dbContext;
|
||||
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;
|
||||
|
||||
public SearchLibrarySemanticallyQueryHandler(
|
||||
IApplicationDbContext dbContext,
|
||||
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_embeddingGenerator = embeddingGenerator;
|
||||
}
|
||||
|
||||
public async Task<Result<List<SemanticSearchResultDto>>> Handle(SearchLibrarySemanticallyQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.QueryText))
|
||||
{
|
||||
return Result.Fail("Query text cannot be empty.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Generate embedding for user query
|
||||
var embeddingResponse = await _embeddingGenerator.GenerateAsync(new[] { request.QueryText }, cancellationToken: cancellationToken);
|
||||
var queryVector = embeddingResponse.First().Vector.ToArray();
|
||||
|
||||
// 2. Perform Cosine Similarity Search on Knowledge Units
|
||||
var candidates = await _dbContext.KnowledgeUnits
|
||||
.AsNoTracking()
|
||||
.Where(x => (x.TenantId == request.TenantId || x.TenantId == "global") && x.Vector != null)
|
||||
.OrderBy(x => x.Vector!.CosineDistance(queryVector))
|
||||
.Take(request.Limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (!candidates.Any())
|
||||
{
|
||||
// Fallback to legacy cache if no granular units found
|
||||
var legacyResults = await _dbContext.SemanticKnowledgeCache
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == request.TenantId && x.Vector != null)
|
||||
.OrderBy(x => x.Vector!.CosineDistance(queryVector))
|
||||
.Take(request.Limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(legacyResults.Select(r => new SemanticSearchResultDto
|
||||
{
|
||||
ContentHash = r.ContentHash,
|
||||
Snippet = r.OriginalText,
|
||||
RelevanceScore = (float)(1 - r.Vector!.CosineDistance(queryVector))
|
||||
}).ToList());
|
||||
}
|
||||
|
||||
// 3. Graph Expansion: Pull related units (e.g. Definitions, Next steps)
|
||||
var candidateIds = candidates.Select(c => c.Id).ToList();
|
||||
var links = await _dbContext.KnowledgeUnitLinks
|
||||
.AsNoTracking()
|
||||
.Where(l => candidateIds.Contains(l.SourceUnitId) && (l.RelationType == "Defines" || l.RelationType == "Next"))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var relatedIds = links.Select(l => l.TargetUnitId).Distinct().ToList();
|
||||
var relatedUnits = await _dbContext.KnowledgeUnits
|
||||
.AsNoTracking()
|
||||
.Where(u => relatedIds.Contains(u.Id))
|
||||
.ToDictionaryAsync(u => u.Id, cancellationToken);
|
||||
|
||||
// 4. Mapping with Context Enrichment
|
||||
var dtos = candidates.Select(c =>
|
||||
{
|
||||
var dto = new SemanticSearchResultDto
|
||||
{
|
||||
ContentHash = c.Id,
|
||||
Snippet = c.Content,
|
||||
UnitType = c.Type.ToString(),
|
||||
RelevanceScore = (float)(1 - c.Vector!.CosineDistance(queryVector)),
|
||||
Metadata = string.IsNullOrEmpty(c.MetadataJson)
|
||||
? null
|
||||
: System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(c.MetadataJson)
|
||||
};
|
||||
|
||||
// Enrich snippet with definitions if present
|
||||
var unitLinks = links.Where(l => l.SourceUnitId == c.Id && l.RelationType == "Defines").ToList();
|
||||
if (unitLinks.Any())
|
||||
{
|
||||
var definitions = unitLinks
|
||||
.Where(l => relatedUnits.ContainsKey(l.TargetUnitId))
|
||||
.Select(l => relatedUnits[l.TargetUnitId].Content);
|
||||
dto.Snippet = $"[Context: {string.Join("; ", definitions)}]\n{dto.Snippet}";
|
||||
}
|
||||
|
||||
return dto;
|
||||
}).ToList();
|
||||
|
||||
return Result.Ok(dtos);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error("Failed to perform semantic search").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using NexusReader.Domain.Enums;
|
||||
|
||||
namespace NexusReader.Domain.Entities;
|
||||
|
||||
public class KnowledgeUnit
|
||||
{
|
||||
[Key]
|
||||
[MaxLength(128)]
|
||||
public string Id { get; set; } = string.Empty; // Hash(Source + Content + Version)
|
||||
|
||||
[Required]
|
||||
[MaxLength(128)]
|
||||
public string SourceId { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(50)]
|
||||
public string Version { get; set; } = "1.0";
|
||||
|
||||
[Required]
|
||||
public KnowledgeUnitType Type { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
public string? MetadataJson { get; set; } // e.g. { "page": 1, "path": "Chapter 1 > Intro" }
|
||||
|
||||
[Required]
|
||||
[MaxLength(128)]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
[Column(TypeName = "vector(768)")] // Default for text-embedding-004
|
||||
public float[]? Vector { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Relationships
|
||||
public ICollection<KnowledgeUnitLink> OutgoingLinks { get; set; } = new List<KnowledgeUnitLink>();
|
||||
public ICollection<KnowledgeUnitLink> IncomingLinks { get; set; } = new List<KnowledgeUnitLink>();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace NexusReader.Domain.Entities;
|
||||
|
||||
public class KnowledgeUnitLink
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(128)]
|
||||
public string SourceUnitId { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(128)]
|
||||
public string TargetUnitId { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(50)]
|
||||
public string RelationType { get; set; } = "References"; // e.g., "Next", "Defines", "Contains"
|
||||
|
||||
[ForeignKey(nameof(SourceUnitId))]
|
||||
public KnowledgeUnit SourceUnit { get; set; } = null!;
|
||||
|
||||
[ForeignKey(nameof(TargetUnitId))]
|
||||
public KnowledgeUnit TargetUnit { get; set; } = null!;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace NexusReader.Domain.Entities;
|
||||
|
||||
@@ -11,6 +12,9 @@ public class SemanticKnowledgeCache
|
||||
[Required]
|
||||
public string JsonData { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string OriginalText { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(50)]
|
||||
public string ModelId { get; set; } = "gemini-1.5-flash";
|
||||
@@ -19,5 +23,12 @@ public class SemanticKnowledgeCache
|
||||
[MaxLength(10)]
|
||||
public string PromptVersion { get; set; } = "1.0";
|
||||
|
||||
[Required]
|
||||
[MaxLength(128)]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
[Column(TypeName = "vector(1536)")] // text-embedding-004 has 768 or 1536 dims, assuming 1536 for high-fidelity
|
||||
public float[]? Vector { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace NexusReader.Domain.Enums;
|
||||
|
||||
public enum KnowledgeUnitType
|
||||
{
|
||||
Section,
|
||||
Table,
|
||||
Definition,
|
||||
ProcedureStep,
|
||||
PolicyRule,
|
||||
KeyConcept,
|
||||
Snippet
|
||||
}
|
||||
@@ -1,21 +1,26 @@
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Application.Abstractions.Persistence;
|
||||
|
||||
namespace NexusReader.Infrastructure.Persistence;
|
||||
|
||||
public class AppDbContext : IdentityDbContext<NexusUser>
|
||||
public class AppDbContext : IdentityDbContext<NexusUser>, IApplicationDbContext
|
||||
{
|
||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<SemanticKnowledgeCache> SemanticKnowledgeCache => Set<SemanticKnowledgeCache>();
|
||||
public DbSet<KnowledgeUnit> KnowledgeUnits => Set<KnowledgeUnit>();
|
||||
public DbSet<KnowledgeUnitLink> KnowledgeUnitLinks => Set<KnowledgeUnitLink>();
|
||||
public DbSet<Ebook> Ebooks => Set<Ebook>();
|
||||
public DbSet<QuizResult> QuizResults => Set<QuizResult>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasPostgresExtension("pgvector");
|
||||
|
||||
modelBuilder.Entity<NexusUser>(entity =>
|
||||
{
|
||||
entity.Property(u => u.LastReadPageId).HasMaxLength(255);
|
||||
@@ -28,6 +33,30 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
||||
{
|
||||
entity.HasKey(e => e.ContentHash);
|
||||
entity.HasIndex(e => e.ContentHash).IsUnique();
|
||||
entity.HasIndex(e => e.TenantId);
|
||||
entity.Property(e => e.Vector).HasColumnType("vector(1536)"); // Standard for many models
|
||||
});
|
||||
|
||||
modelBuilder.Entity<KnowledgeUnit>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => e.TenantId);
|
||||
entity.HasIndex(e => e.SourceId);
|
||||
entity.Property(e => e.Vector).HasColumnType("vector(768)"); // text-embedding-004
|
||||
});
|
||||
|
||||
modelBuilder.Entity<KnowledgeUnitLink>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasOne(e => e.SourceUnit)
|
||||
.WithMany(u => u.OutgoingLinks)
|
||||
.HasForeignKey(e => e.SourceUnitId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(e => e.TargetUnit)
|
||||
.WithMany(u => u.IncomingLinks)
|
||||
.HasForeignKey(e => e.TargetUnitId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Ebook>(entity =>
|
||||
|
||||
@@ -12,12 +12,14 @@ using Polly;
|
||||
using Polly.Registry;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NexusReader.Infrastructure.Configuration;
|
||||
using Pgvector.EntityFrameworkCore;
|
||||
|
||||
namespace NexusReader.Infrastructure.Services;
|
||||
|
||||
public class KnowledgeService : IKnowledgeService
|
||||
{
|
||||
private readonly IChatClient _chatClient;
|
||||
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;
|
||||
private readonly AppDbContext _dbContext;
|
||||
private readonly ResiliencePipeline _retryPipeline;
|
||||
private readonly AiSettings _settings;
|
||||
@@ -25,12 +27,14 @@ public class KnowledgeService : IKnowledgeService
|
||||
private const string PromptVersion = "1.0";
|
||||
|
||||
public KnowledgeService(
|
||||
IChatClient chatClient,
|
||||
IChatClient chatClient,
|
||||
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator,
|
||||
AppDbContext dbContext,
|
||||
ResiliencePipelineProvider<string> pipelineProvider,
|
||||
IOptions<AiSettings> settings)
|
||||
{
|
||||
_chatClient = chatClient;
|
||||
_embeddingGenerator = embeddingGenerator;
|
||||
_dbContext = dbContext;
|
||||
_retryPipeline = pipelineProvider.GetPipeline("ai-retry");
|
||||
_settings = settings.Value;
|
||||
@@ -54,6 +58,11 @@ public class KnowledgeService : IKnowledgeService
|
||||
return await GetKnowledgeInternalAsync(text, PromptRegistry.SummaryAndQuizPrompt, "summary_quiz", cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Result<KnowledgePacket>> GetKnowledgeMapAsync(string text, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await GetKnowledgeInternalAsync(text, PromptRegistry.KM_ExtractionPrompt, "km_map", cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Result<KnowledgePacket>> GetKnowledgeInternalAsync(string text, string systemPrompt, string cacheSuffix, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
@@ -115,18 +124,47 @@ public class KnowledgeService : IKnowledgeService
|
||||
var knowledgePacket = JsonSerializer.Deserialize<KnowledgePacket>(jsonResponse, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
if (knowledgePacket == null) return Result.Fail("Failed to deserialize AI response.");
|
||||
|
||||
// 3. Save to Cache
|
||||
// 3. Generate Embedding if not present
|
||||
float[]? vector = null;
|
||||
try
|
||||
{
|
||||
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct =>
|
||||
await _embeddingGenerator.GenerateAsync(new[] { normalizedText }, cancellationToken: ct), cancellationToken);
|
||||
vector = embeddingResponse.First().Vector.ToArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[KnowledgeService] Embedding Error: {ex.Message}");
|
||||
// We continue even if embedding fails, as the primary goal was knowledge extraction
|
||||
}
|
||||
|
||||
// 4. Save to Cache
|
||||
var cacheEntry = new SemanticKnowledgeCache
|
||||
{
|
||||
ContentHash = hash,
|
||||
JsonData = jsonResponse,
|
||||
OriginalText = normalizedText,
|
||||
ModelId = _settings.Model,
|
||||
PromptVersion = PromptVersion,
|
||||
TenantId = "global", // Default for shared cache, should be overridden by caller context if possible
|
||||
Vector = vector,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
if (cached == null) _dbContext.SemanticKnowledgeCache.Add(cacheEntry);
|
||||
else { cached.JsonData = jsonResponse; cached.CreatedAt = DateTime.UtcNow; }
|
||||
else
|
||||
{
|
||||
cached.JsonData = jsonResponse;
|
||||
cached.OriginalText = normalizedText;
|
||||
cached.Vector = vector;
|
||||
cached.CreatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// 5. Process KM-RAG Units and Links if present
|
||||
if (knowledgePacket.Units.Any())
|
||||
{
|
||||
await ProcessKnowledgeUnitsAsync(knowledgePacket, "global", cancellationToken);
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
return Result.Ok(knowledgePacket);
|
||||
@@ -143,6 +181,75 @@ public class KnowledgeService : IKnowledgeService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessKnowledgeUnitsAsync(KnowledgePacket packet, string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var unitDto in packet.Units)
|
||||
{
|
||||
var unitId = unitDto.Id;
|
||||
var existing = await _dbContext.KnowledgeUnits.FindAsync(new object[] { unitId }, cancellationToken);
|
||||
|
||||
var unit = existing ?? new KnowledgeUnit { Id = unitId, TenantId = tenantId };
|
||||
unit.Type = Enum.TryParse<NexusReader.Domain.Enums.KnowledgeUnitType>(unitDto.Type, true, out var type) ? type : NexusReader.Domain.Enums.KnowledgeUnitType.Snippet;
|
||||
unit.Content = unitDto.Content;
|
||||
unit.SourceId = "extracted"; // Should be passed from context
|
||||
unit.MetadataJson = JsonSerializer.Serialize(unitDto.Metadata);
|
||||
|
||||
// Generate unit-specific embedding for granular retrieval
|
||||
try
|
||||
{
|
||||
var emb = await _embeddingGenerator.GenerateAsync(new[] { unit.Content }, cancellationToken: cancellationToken);
|
||||
unit.Vector = emb.First().Vector.ToArray();
|
||||
}
|
||||
catch { /* Ignore embedding errors for now */ }
|
||||
|
||||
if (existing == null) _dbContext.KnowledgeUnits.Add(unit);
|
||||
}
|
||||
|
||||
foreach (var linkDto in packet.Links)
|
||||
{
|
||||
var link = new KnowledgeUnitLink
|
||||
{
|
||||
SourceUnitId = linkDto.Source,
|
||||
TargetUnitId = linkDto.Target,
|
||||
RelationType = linkDto.Relation
|
||||
};
|
||||
_dbContext.KnowledgeUnitLinks.Add(link);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<List<RelevantContext>>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query)) return Result.Fail("Query is empty.");
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Generate embedding for query
|
||||
var embeddingResponse = await _retryPipeline.ExecuteAsync(async ct =>
|
||||
await _embeddingGenerator.GenerateAsync(new[] { query }, cancellationToken: ct), cancellationToken);
|
||||
var queryVector = embeddingResponse.First().Vector.ToArray();
|
||||
|
||||
// 2. Search using pgvector
|
||||
var results = await _dbContext.SemanticKnowledgeCache
|
||||
.AsNoTracking()
|
||||
.Where(x => (x.TenantId == tenantId || x.TenantId == "global") && x.Vector != null)
|
||||
.OrderBy(x => x.Vector!.CosineDistance(queryVector))
|
||||
.Take(5)
|
||||
.Select(x => new RelevantContext
|
||||
{
|
||||
Text = x.OriginalText,
|
||||
SourceId = x.ContentHash,
|
||||
Confidence = 1 - x.Vector!.CosineDistance(queryVector)
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(results);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error("Failed to retrieve relevant context").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -21,4 +21,13 @@ public static class PromptRegistry
|
||||
public const string SummaryAndQuizPrompt =
|
||||
"You are an expert educator. Provide a concise summary of the text and generate a challenging quiz (3-5 questions). " +
|
||||
"Return ONLY minified JSON. Schema: { \"summary\": \"string\", \"quizzes\": [ { \"question\": \"string\", \"options\": [ \"string\" ], \"correct_index\": 0 } ] }";
|
||||
|
||||
public const string KM_ExtractionPrompt =
|
||||
"You are an expert at Knowledge Engineering. Segment the provided text into discrete Knowledge Units. " +
|
||||
"Identify 'units' (sections, tables, definitions, rules) and 'links' (how they relate). " +
|
||||
"CRITICAL: Units must be granular. " +
|
||||
"Schema: { " +
|
||||
"\"units\": [ { \"id\": \"string\", \"type\": \"Section|Table|Definition|Rule\", \"content\": \"string\", \"metadata\": { \"page\": 0 } } ], " +
|
||||
"\"links\": [ { \"source\": \"string\", \"target\": \"string\", \"relation\": \"Next|Defines|Contains|References\" } ] " +
|
||||
"}.";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
@namespace NexusReader.UI.Shared.Components.Atoms
|
||||
|
||||
<div class="nexus-search-container @(IsActive ? "active" : "")">
|
||||
<div class="search-wrapper">
|
||||
<i class="nexus-icon @IconClass"></i>
|
||||
<input type="text"
|
||||
@bind="SearchValue"
|
||||
@bind:event="oninput"
|
||||
@onkeypress="HandleKeyPress"
|
||||
placeholder="@Placeholder"
|
||||
class="nexus-search-input" />
|
||||
@if (!string.IsNullOrEmpty(SearchValue))
|
||||
{
|
||||
<button class="clear-btn" @onclick="ClearSearch">×</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Placeholder { get; set; } = "Search your library...";
|
||||
[Parameter] public string IconClass { get; set; } = "bi bi-search";
|
||||
[Parameter] public EventCallback<string> OnSearch { get; set; }
|
||||
|
||||
private string SearchValue { get; set; } = string.Empty;
|
||||
private bool IsActive => !string.IsNullOrEmpty(SearchValue);
|
||||
|
||||
private async Task HandleKeyPress(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter")
|
||||
{
|
||||
await OnSearch.InvokeAsync(SearchValue);
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearSearch()
|
||||
{
|
||||
SearchValue = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
.nexus-search-container {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
margin: 1rem auto;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--nexus-card, #141414);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 0.5rem 1rem;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.nexus-search-container.active .search-wrapper,
|
||||
.search-wrapper:focus-within {
|
||||
border-color: var(--nexus-neon, #00ff99);
|
||||
box-shadow: 0 0 15px rgba(0, 255, 153, 0.2);
|
||||
}
|
||||
|
||||
.nexus-icon {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-right: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.nexus-search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nexus-search-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 0 0.5rem;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
color: var(--nexus-neon, #00ff99);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
@using MediatR
|
||||
@using NexusReader.Application.Commands.AI
|
||||
@using NexusReader.UI.Shared.Components.Atoms
|
||||
@inject IMediator Mediator
|
||||
|
||||
<div class="groundedness-badge @GetStatusClass()" title="@_result?.Rationale">
|
||||
@if (_isChecking)
|
||||
{
|
||||
<span class="shimmer">Weryfikacja...</span>
|
||||
}
|
||||
else if (_result != null)
|
||||
{
|
||||
<NexusIcon Name="@(GetIcon())" Size="14" />
|
||||
<span>@((_result.Score * 100).ToString("0"))% Grounded</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.groundedness-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.groundedness-badge.status-high { color: var(--nexus-neon); border-color: var(--nexus-neon); }
|
||||
.groundedness-badge.status-medium { color: #ffaa00; border-color: #ffaa00; }
|
||||
.groundedness-badge.status-low { color: #ff4444; border-color: #ff4444; }
|
||||
|
||||
.shimmer { opacity: 0.6; }
|
||||
</style>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Answer { get; set; } = string.Empty;
|
||||
[Parameter] public string Context { get; set; } = string.Empty;
|
||||
|
||||
private GroundednessResult? _result;
|
||||
private bool _isChecking;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(Answer) && !string.IsNullOrEmpty(Context) && _result == null)
|
||||
{
|
||||
await RunCheck();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunCheck()
|
||||
{
|
||||
_isChecking = true;
|
||||
StateHasChanged();
|
||||
|
||||
var res = await Mediator.Send(new VerifyGroundednessCommand(Answer, Context));
|
||||
if (res.IsSuccess)
|
||||
{
|
||||
_result = res.Value;
|
||||
}
|
||||
|
||||
_isChecking = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private string GetStatusClass()
|
||||
{
|
||||
if (_result == null) return "";
|
||||
if (_result.Score >= 0.8) return "status-high";
|
||||
if (_result.Score >= 0.5) return "status-medium";
|
||||
return "status-low";
|
||||
}
|
||||
|
||||
private string GetIcon()
|
||||
{
|
||||
if (_result == null) return "help";
|
||||
if (_result.Score >= 0.8) return "check-circle";
|
||||
if (_result.Score >= 0.5) return "info-circle";
|
||||
return "alert-triangle";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
@using NexusReader.Application.DTOs.AI
|
||||
@using NexusReader.UI.Shared.Components.Atoms
|
||||
@namespace NexusReader.UI.Shared.Components.Organisms
|
||||
|
||||
<div class="global-intelligence-panel">
|
||||
<div class="panel-header">
|
||||
<h3><i class="bi bi-cpu"></i> Global Intelligence</h3>
|
||||
<p>Semantic search across your library</p>
|
||||
</div>
|
||||
|
||||
<NexusSearchBox Placeholder="Ask a question about your books..." OnSearch="HandleSearch" />
|
||||
|
||||
<div class="results-container">
|
||||
@if (IsLoading)
|
||||
{
|
||||
<div class="loading-state">
|
||||
<div class="nexus-spinner"></div>
|
||||
<span>Analyzing your library...</span>
|
||||
</div>
|
||||
}
|
||||
else if (Results != null && Results.Any())
|
||||
{
|
||||
@foreach (var result in Results)
|
||||
{
|
||||
<div class="search-result-item">
|
||||
<div class="result-meta">
|
||||
<span class="relevance">@(Math.Round(result.RelevanceScore * 100))% Relevant</span>
|
||||
@if (!string.IsNullOrEmpty(result.SourceBookTitle))
|
||||
{
|
||||
<span class="source">in <strong>@result.SourceBookTitle</strong></span>
|
||||
}
|
||||
</div>
|
||||
<div class="result-snippet">
|
||||
@result.Snippet
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else if (HasSearched)
|
||||
{
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-search"></i>
|
||||
<p>No semantic matches found.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public List<SemanticSearchResultDto>? Results { get; set; }
|
||||
[Parameter] public bool IsLoading { get; set; }
|
||||
[Parameter] public EventCallback<string> OnPerformSearch { get; set; }
|
||||
|
||||
private bool HasSearched { get; set; }
|
||||
|
||||
private async Task HandleSearch(string query)
|
||||
{
|
||||
HasSearched = true;
|
||||
await OnPerformSearch.InvokeAsync(query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
.global-intelligence-panel {
|
||||
background: var(--nexus-bg, #0a0a0a);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.05);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
margin: 0;
|
||||
color: var(--nexus-neon, #00ff99);
|
||||
font-size: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-header p {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.85rem;
|
||||
margin: 0.25rem 0 1.5rem 0;
|
||||
}
|
||||
|
||||
.results-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin-top: 1rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
background: var(--nexus-card, #141414);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.search-result-item:hover {
|
||||
transform: translateX(4px);
|
||||
border-color: rgba(0, 255, 153, 0.3);
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.relevance {
|
||||
color: var(--nexus-neon, #00ff99);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.source {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.result-snippet {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading-state, .empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nexus-spinner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 2px solid rgba(0, 255, 153, 0.1);
|
||||
border-top-color: var(--nexus-neon, #00ff99);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -122,13 +122,19 @@ export function updateData(data) {
|
||||
// Update Links
|
||||
link = rootGroup.select(".links-layer")
|
||||
.selectAll("path")
|
||||
.data(data.links, d => d.source + "-" + d.target)
|
||||
.data(data.links, d => d.source + "-" + d.target + "-" + d.relationType)
|
||||
.join(
|
||||
enter => enter.append("path")
|
||||
.attr("stroke", "rgba(255,255,255,0.05)")
|
||||
.attr("stroke", d => {
|
||||
if (d.relationType === 'Defines') return 'var(--nexus-accent)';
|
||||
if (d.relationType === 'Next') return 'rgba(255,255,255,0.2)';
|
||||
if (d.relationType === 'Contains') return 'var(--nexus-neon)';
|
||||
return 'rgba(255,255,255,0.1)';
|
||||
})
|
||||
.attr("fill", "none")
|
||||
.attr("stroke-width", 1.5)
|
||||
.call(e => e.transition().duration(500).attr("stroke", "rgba(255,255,255,0.1)")),
|
||||
.attr("stroke-width", d => d.relationType === 'Defines' ? 2 : 1)
|
||||
.attr("stroke-dasharray", d => d.relationType === 'References' ? "5,5" : "0")
|
||||
.call(e => e.transition().duration(500).attr("opacity", 1)),
|
||||
update => update,
|
||||
exit => exit.remove()
|
||||
);
|
||||
@@ -150,7 +156,12 @@ export function updateData(data) {
|
||||
|
||||
g.append("circle")
|
||||
.attr("r", 30)
|
||||
.attr("fill", "url(#nebulaGlow)")
|
||||
.attr("fill", d => {
|
||||
if (d.type === 'Definition') return 'var(--nexus-accent)';
|
||||
if (d.type === 'Table') return 'var(--nexus-neon)';
|
||||
if (d.type === 'Rule') return '#ff4444';
|
||||
return "url(#nebulaGlow)";
|
||||
})
|
||||
.attr("opacity", 0)
|
||||
.transition().duration(1000).attr("opacity", d => d.group === 'current' ? 0.6 : 0.2);
|
||||
|
||||
@@ -162,14 +173,18 @@ export function updateData(data) {
|
||||
.attr("height", 24)
|
||||
.attr("rx", 12)
|
||||
.attr("fill", "rgba(20, 20, 20, 0.9)")
|
||||
.attr("stroke", "rgba(255, 255, 255, 0.1)")
|
||||
.attr("stroke", d => {
|
||||
if (d.type === 'Definition') return 'var(--nexus-accent)';
|
||||
if (d.type === 'Rule') return '#ff4444';
|
||||
return "rgba(255, 255, 255, 0.1)";
|
||||
})
|
||||
.attr("stroke-width", 1);
|
||||
|
||||
g.append("text")
|
||||
.text(d => d.label)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("y", 4)
|
||||
.attr("fill", "#ccc")
|
||||
.attr("fill", d => d.type === 'Definition' ? 'var(--nexus-accent)' : '#ccc')
|
||||
.attr("font-size", "0.8rem");
|
||||
|
||||
return g;
|
||||
|
||||
@@ -29,6 +29,26 @@ public class WasmKnowledgeService : IKnowledgeService
|
||||
return await CallKnowledgeApiAsync("/api/knowledge/summary", text, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Result<List<RelevantContext>>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync("/api/knowledge/relevant", new { query, tenantId }, cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var context = await response.Content.ReadFromJsonAsync<List<RelevantContext>>(cancellationToken: cancellationToken);
|
||||
return context != null ? Result.Ok(context) : Result.Fail("Failed to deserialize relevant context.");
|
||||
}
|
||||
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
return Result.Fail($"Server error ({response.StatusCode}): {errorBody}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error($"Network error: {ex.Message}").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Result<KnowledgePacket>> CallKnowledgeApiAsync(string endpoint, string text, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
|
||||
Reference in New Issue
Block a user