From 1f187b512559efc9e137480b048f5bc9a0e21070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Sun, 3 May 2026 15:59:30 +0200 Subject: [PATCH] feat: implement semantic search, knowledge unit extraction, and visualization components --- .agents/agents.md | 22 ++-- .../Persistence/IApplicationDbContext.cs | 15 +++ .../Services/IKnowledgeService.cs | 2 + .../Commands/AI/VerifyGroundednessCommand.cs | 52 ++++++++ .../DTOs/AI/KnowledgePacket.cs | 5 + .../DTOs/AI/RelevantContext.cs | 8 ++ .../DTOs/AI/SemanticSearchResultDto.cs | 11 ++ .../NexusReader.Application.csproj | 3 + .../Queries/Graph/GraphViewModels.cs | 4 +- .../Library/SearchLibrarySemanticallyQuery.cs | 114 ++++++++++++++++++ .../Entities/KnowledgeUnit.cs | 41 +++++++ .../Entities/KnowledgeUnitLink.cs | 28 +++++ .../Entities/SemanticKnowledgeCache.cs | 11 ++ .../Enums/KnowledgeUnitType.cs | 12 ++ .../Persistence/AppDbContext.cs | 31 ++++- .../Services/KnowledgeService.cs | 113 ++++++++++++++++- .../Services/PromptRegistry.cs | 9 ++ .../Components/Atoms/NexusSearchBox.razor | 39 ++++++ .../Components/Atoms/NexusSearchBox.razor.css | 57 +++++++++ .../Molecules/GroundednessBadge.razor | 84 +++++++++++++ .../Organisms/GlobalIntelligencePanel.razor | 61 ++++++++++ .../GlobalIntelligencePanel.razor.css | 94 +++++++++++++++ .../wwwroot/js/knowledgeGraph.js | 29 +++-- .../Services/WasmKnowledgeService.cs | 20 +++ 24 files changed, 844 insertions(+), 21 deletions(-) create mode 100644 src/NexusReader.Application/Abstractions/Persistence/IApplicationDbContext.cs create mode 100644 src/NexusReader.Application/Commands/AI/VerifyGroundednessCommand.cs create mode 100644 src/NexusReader.Application/DTOs/AI/RelevantContext.cs create mode 100644 src/NexusReader.Application/DTOs/AI/SemanticSearchResultDto.cs create mode 100644 src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs create mode 100644 src/NexusReader.Domain/Entities/KnowledgeUnit.cs create mode 100644 src/NexusReader.Domain/Entities/KnowledgeUnitLink.cs create mode 100644 src/NexusReader.Domain/Enums/KnowledgeUnitType.cs create mode 100644 src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor create mode 100644 src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor.css create mode 100644 src/NexusReader.UI.Shared/Components/Molecules/GroundednessBadge.razor create mode 100644 src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligencePanel.razor create mode 100644 src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligencePanel.razor.css diff --git a/.agents/agents.md b/.agents/agents.md index 60be4a6..34cf70f 100644 --- a/.agents/agents.md +++ b/.agents/agents.md @@ -1,16 +1,22 @@ +--- +type: agent-definitions +version: 1.0 +--- + # Agent Personas ## NexusArchitect + - **Role:** Lead Architect & Creative Technologist (.NET 10 & Blazor) - **Persona:** Professional, precise, Senior Full-Stack Engineer focused on performance and "invisible UI". - **Architecture Role:** Lead Clean Architecture Specialist. - **Skills:** [nexus-clean-architecture, nexus-ui-engine, nexus-graph-d3, blazor-state-performance, blazor-hybrid-bridge, semantic-kernel-orchestrator, nexus-identity-saas, dotnet-async-void] - **Technical Constraints:** - - **Directory Structure:** Strict separation: `/src` (app code) and `/tests` (testing code) at solution root level. - - **Patterns:** Mandatory CQRS via `MediatR` (LuckyPennySoftware implementation). No business logic in UI components. - - **Async:** Strict zero-tolerance for `async void`. All async operations must return `Task` or `ValueTask`. Event handlers must use `Func` or async-compatible patterns. - - **Error Handling:** All handlers must return `Result` via `FluentResult`. - - **Mapping:** Use `Mapster` exclusively. Zero-tolerance for AutoMapper. - - **Platform:** Target .NET 10 with Native AOT compatibility in mind for mobile performance. - - **Verification:** Follow "Verification-led development" — the agent must plan the test before writing the feature code. - - **UI Framework:** Use Blazor Component Model. NEVER generate raw HTML/CSS; always use isolated Razor Components (.razor + .razor.css). \ No newline at end of file + - **Directory Structure:** Strict separation: `/src` (app code) and `/tests` (testing code) at solution root level. + - **Patterns:** Mandatory CQRS via `MediatR` (LuckyPennySoftware implementation). No business logic in UI components. + - **Async:** Strict zero-tolerance for `async void`. All async operations must return `Task` or `ValueTask`. Event handlers must use `Func` or async-compatible patterns. + - **Error Handling:** All handlers must return `Result` via `FluentResult`. + - **Mapping:** Use `Mapster` exclusively. Zero-tolerance for AutoMapper. + - **Platform:** Target .NET 10 with Native AOT compatibility in mind for mobile performance. + - **Verification:** Follow "Verification-led development" — the agent must plan the test before writing the feature code. + - **UI Framework:** Use Blazor Component Model. NEVER generate raw HTML/CSS; always use isolated Razor Components (.razor + .razor.css). diff --git a/src/NexusReader.Application/Abstractions/Persistence/IApplicationDbContext.cs b/src/NexusReader.Application/Abstractions/Persistence/IApplicationDbContext.cs new file mode 100644 index 0000000..92c2ccb --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Persistence/IApplicationDbContext.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; +using NexusReader.Domain.Entities; + +namespace NexusReader.Application.Abstractions.Persistence; + +public interface IApplicationDbContext +{ + DbSet SemanticKnowledgeCache { get; } + DbSet KnowledgeUnits { get; } + DbSet KnowledgeUnitLinks { get; } + DbSet Ebooks { get; } + DbSet QuizResults { get; } + + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs b/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs index b6f603f..68e90c6 100644 --- a/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs +++ b/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs @@ -7,6 +7,8 @@ public interface IKnowledgeService { Task> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default); Task> GetGraphDataAsync(string text, CancellationToken cancellationToken = default); + Task> GetKnowledgeMapAsync(string text, CancellationToken cancellationToken = default); Task> GetSummaryAndQuizAsync(string text, CancellationToken cancellationToken = default); + Task>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default); Task ClearCacheAsync(CancellationToken cancellationToken = default); } diff --git a/src/NexusReader.Application/Commands/AI/VerifyGroundednessCommand.cs b/src/NexusReader.Application/Commands/AI/VerifyGroundednessCommand.cs new file mode 100644 index 0000000..cba3a73 --- /dev/null +++ b/src/NexusReader.Application/Commands/AI/VerifyGroundednessCommand.cs @@ -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>; + +public record GroundednessResult(float Score, string Rationale, bool IsGrounded); + +public class VerifyGroundednessCommandHandler : IRequestHandler> +{ + private readonly IChatClient _chatClient; + + public VerifyGroundednessCommandHandler(IChatClient chatClient) + { + _chatClient = chatClient; + } + + public async Task> 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 + { + 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(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)); + } + } +} diff --git a/src/NexusReader.Application/DTOs/AI/KnowledgePacket.cs b/src/NexusReader.Application/DTOs/AI/KnowledgePacket.cs index d0b1f8b..ba91e13 100644 --- a/src/NexusReader.Application/DTOs/AI/KnowledgePacket.cs +++ b/src/NexusReader.Application/DTOs/AI/KnowledgePacket.cs @@ -13,10 +13,15 @@ public record QuizQuestion( [property: JsonPropertyName("correct_index")] int CorrectIndex ); +public record KnowledgeUnitDto(string Id, string Type, string Content, Dictionary? Metadata = null); +public record KnowledgeLinkDto(string Source, string Target, string Relation); + public record KnowledgePacket { [JsonPropertyName("concepts")] public List Concepts { get; init; } = new(); [JsonPropertyName("quizzes")] public List Quizzes { get; init; } = new(); + [JsonPropertyName("units")] public List Units { get; init; } = new(); + [JsonPropertyName("links")] public List Links { get; init; } = new(); [JsonPropertyName("graph")] public NexusReader.Application.Queries.Graph.GraphDataDto? Graph { get; init; } [JsonPropertyName("summary")] public string? Summary { get; init; } } diff --git a/src/NexusReader.Application/DTOs/AI/RelevantContext.cs b/src/NexusReader.Application/DTOs/AI/RelevantContext.cs new file mode 100644 index 0000000..f11a5ff --- /dev/null +++ b/src/NexusReader.Application/DTOs/AI/RelevantContext.cs @@ -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; } +} diff --git a/src/NexusReader.Application/DTOs/AI/SemanticSearchResultDto.cs b/src/NexusReader.Application/DTOs/AI/SemanticSearchResultDto.cs new file mode 100644 index 0000000..5240aa8 --- /dev/null +++ b/src/NexusReader.Application/DTOs/AI/SemanticSearchResultDto.cs @@ -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? Metadata { get; set; } // Bonus context +} diff --git a/src/NexusReader.Application/NexusReader.Application.csproj b/src/NexusReader.Application/NexusReader.Application.csproj index d59adf5..f375251 100644 --- a/src/NexusReader.Application/NexusReader.Application.csproj +++ b/src/NexusReader.Application/NexusReader.Application.csproj @@ -10,7 +10,10 @@ + + + diff --git a/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs b/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs index f9b4255..19d81e4 100644 --- a/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs +++ b/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs @@ -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 Nodes { get; init; } = new(); diff --git a/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs b/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs new file mode 100644 index 0000000..2eeff2d --- /dev/null +++ b/src/NexusReader.Application/Queries/Library/SearchLibrarySemanticallyQuery.cs @@ -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>>; + +public class SearchLibrarySemanticallyQueryHandler : IRequestHandler>> +{ + private readonly IApplicationDbContext _dbContext; + private readonly IEmbeddingGenerator> _embeddingGenerator; + + public SearchLibrarySemanticallyQueryHandler( + IApplicationDbContext dbContext, + IEmbeddingGenerator> embeddingGenerator) + { + _dbContext = dbContext; + _embeddingGenerator = embeddingGenerator; + } + + public async Task>> 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>(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)); + } + } +} diff --git a/src/NexusReader.Domain/Entities/KnowledgeUnit.cs b/src/NexusReader.Domain/Entities/KnowledgeUnit.cs new file mode 100644 index 0000000..cd6703c --- /dev/null +++ b/src/NexusReader.Domain/Entities/KnowledgeUnit.cs @@ -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 OutgoingLinks { get; set; } = new List(); + public ICollection IncomingLinks { get; set; } = new List(); +} diff --git a/src/NexusReader.Domain/Entities/KnowledgeUnitLink.cs b/src/NexusReader.Domain/Entities/KnowledgeUnitLink.cs new file mode 100644 index 0000000..3d1ec9a --- /dev/null +++ b/src/NexusReader.Domain/Entities/KnowledgeUnitLink.cs @@ -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!; +} diff --git a/src/NexusReader.Domain/Entities/SemanticKnowledgeCache.cs b/src/NexusReader.Domain/Entities/SemanticKnowledgeCache.cs index f4758b0..f7b0ae8 100644 --- a/src/NexusReader.Domain/Entities/SemanticKnowledgeCache.cs +++ b/src/NexusReader.Domain/Entities/SemanticKnowledgeCache.cs @@ -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; } diff --git a/src/NexusReader.Domain/Enums/KnowledgeUnitType.cs b/src/NexusReader.Domain/Enums/KnowledgeUnitType.cs new file mode 100644 index 0000000..88d314d --- /dev/null +++ b/src/NexusReader.Domain/Enums/KnowledgeUnitType.cs @@ -0,0 +1,12 @@ +namespace NexusReader.Domain.Enums; + +public enum KnowledgeUnitType +{ + Section, + Table, + Definition, + ProcedureStep, + PolicyRule, + KeyConcept, + Snippet +} diff --git a/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs b/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs index 2f278a0..71af973 100644 --- a/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs +++ b/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs @@ -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 +public class AppDbContext : IdentityDbContext, IApplicationDbContext { public AppDbContext(DbContextOptions options) : base(options) { } public DbSet SemanticKnowledgeCache => Set(); + public DbSet KnowledgeUnits => Set(); + public DbSet KnowledgeUnitLinks => Set(); public DbSet Ebooks => Set(); public DbSet QuizResults => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.HasPostgresExtension("pgvector"); + modelBuilder.Entity(entity => { entity.Property(u => u.LastReadPageId).HasMaxLength(255); @@ -28,6 +33,30 @@ public class AppDbContext : IdentityDbContext { 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(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(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(entity => diff --git a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs index bf2fda9..c54dd2d 100644 --- a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs +++ b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs @@ -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> _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> embeddingGenerator, AppDbContext dbContext, ResiliencePipelineProvider pipelineProvider, IOptions 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> GetKnowledgeMapAsync(string text, CancellationToken cancellationToken = default) + { + return await GetKnowledgeInternalAsync(text, PromptRegistry.KM_ExtractionPrompt, "km_map", cancellationToken); + } + private async Task> 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(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(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>> 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 ClearCacheAsync(CancellationToken cancellationToken = default) { try diff --git a/src/NexusReader.Infrastructure/Services/PromptRegistry.cs b/src/NexusReader.Infrastructure/Services/PromptRegistry.cs index 7b283ad..2a3de7d 100644 --- a/src/NexusReader.Infrastructure/Services/PromptRegistry.cs +++ b/src/NexusReader.Infrastructure/Services/PromptRegistry.cs @@ -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\" } ] " + + "}."; } diff --git a/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor b/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor new file mode 100644 index 0000000..3331c17 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor @@ -0,0 +1,39 @@ +@namespace NexusReader.UI.Shared.Components.Atoms + +
+
+ + + @if (!string.IsNullOrEmpty(SearchValue)) + { + + } +
+
+ +@code { + [Parameter] public string Placeholder { get; set; } = "Search your library..."; + [Parameter] public string IconClass { get; set; } = "bi bi-search"; + [Parameter] public EventCallback 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; + } +} diff --git a/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor.css b/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor.css new file mode 100644 index 0000000..a850668 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor.css @@ -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); +} diff --git a/src/NexusReader.UI.Shared/Components/Molecules/GroundednessBadge.razor b/src/NexusReader.UI.Shared/Components/Molecules/GroundednessBadge.razor new file mode 100644 index 0000000..fa991f9 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Molecules/GroundednessBadge.razor @@ -0,0 +1,84 @@ +@using MediatR +@using NexusReader.Application.Commands.AI +@using NexusReader.UI.Shared.Components.Atoms +@inject IMediator Mediator + +
+ @if (_isChecking) + { + Weryfikacja... + } + else if (_result != null) + { + + @((_result.Score * 100).ToString("0"))% Grounded + } +
+ + + +@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"; + } +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligencePanel.razor b/src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligencePanel.razor new file mode 100644 index 0000000..4f4f2e6 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligencePanel.razor @@ -0,0 +1,61 @@ +@using NexusReader.Application.DTOs.AI +@using NexusReader.UI.Shared.Components.Atoms +@namespace NexusReader.UI.Shared.Components.Organisms + +
+
+

Global Intelligence

+

Semantic search across your library

+
+ + + +
+ @if (IsLoading) + { +
+
+ Analyzing your library... +
+ } + else if (Results != null && Results.Any()) + { + @foreach (var result in Results) + { +
+
+ @(Math.Round(result.RelevanceScore * 100))% Relevant + @if (!string.IsNullOrEmpty(result.SourceBookTitle)) + { + in @result.SourceBookTitle + } +
+
+ @result.Snippet +
+
+ } + } + else if (HasSearched) + { +
+ +

No semantic matches found.

+
+ } +
+
+ +@code { + [Parameter] public List? Results { get; set; } + [Parameter] public bool IsLoading { get; set; } + [Parameter] public EventCallback OnPerformSearch { get; set; } + + private bool HasSearched { get; set; } + + private async Task HandleSearch(string query) + { + HasSearched = true; + await OnPerformSearch.InvokeAsync(query); + } +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligencePanel.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligencePanel.razor.css new file mode 100644 index 0000000..39114c3 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligencePanel.razor.css @@ -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); } +} diff --git a/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js index 4b9046b..362490d 100644 --- a/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js +++ b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js @@ -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; diff --git a/src/NexusReader.Web.Client/Services/WasmKnowledgeService.cs b/src/NexusReader.Web.Client/Services/WasmKnowledgeService.cs index afadcc8..58de739 100644 --- a/src/NexusReader.Web.Client/Services/WasmKnowledgeService.cs +++ b/src/NexusReader.Web.Client/Services/WasmKnowledgeService.cs @@ -29,6 +29,26 @@ public class WasmKnowledgeService : IKnowledgeService return await CallKnowledgeApiAsync("/api/knowledge/summary", text, cancellationToken); } + public async Task>> 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>(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> CallKnowledgeApiAsync(string endpoint, string text, CancellationToken cancellationToken) { try