feat: implement semantic search, knowledge unit extraction, and visualization components

This commit is contained in:
2026-05-03 15:59:30 +02:00
parent 94ecc7a404
commit 1f187b5125
24 changed files with 844 additions and 21 deletions
+14 -8
View File
@@ -1,16 +1,22 @@
---
type: agent-definitions
version: 1.0
---
# Agent Personas # Agent Personas
## NexusArchitect ## NexusArchitect
- **Role:** Lead Architect & Creative Technologist (.NET 10 & Blazor) - **Role:** Lead Architect & Creative Technologist (.NET 10 & Blazor)
- **Persona:** Professional, precise, Senior Full-Stack Engineer focused on performance and "invisible UI". - **Persona:** Professional, precise, Senior Full-Stack Engineer focused on performance and "invisible UI".
- **Architecture Role:** Lead Clean Architecture Specialist. - **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] - **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:** - **Technical Constraints:**
- **Directory Structure:** Strict separation: `/src` (app code) and `/tests` (testing code) at solution root level. - **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. - **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<Task>` or async-compatible patterns. - **Async:** Strict zero-tolerance for `async void`. All async operations must return `Task` or `ValueTask`. Event handlers must use `Func<Task>` or async-compatible patterns.
- **Error Handling:** All handlers must return `Result<T>` via `FluentResult`. - **Error Handling:** All handlers must return `Result<T>` via `FluentResult`.
- **Mapping:** Use `Mapster` exclusively. Zero-tolerance for AutoMapper. - **Mapping:** Use `Mapster` exclusively. Zero-tolerance for AutoMapper.
- **Platform:** Target .NET 10 with Native AOT compatibility in mind for mobile performance. - **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. - **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). - **UI Framework:** Use Blazor Component Model. NEVER generate raw HTML/CSS; always use isolated Razor Components (.razor + .razor.css).
@@ -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>> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default);
Task<Result<KnowledgePacket>> GetGraphDataAsync(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<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); 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 [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 public record KnowledgePacket
{ {
[JsonPropertyName("concepts")] public List<KeyConcept> Concepts { get; init; } = new(); [JsonPropertyName("concepts")] public List<KeyConcept> Concepts { get; init; } = new();
[JsonPropertyName("quizzes")] public List<QuizQuestion> Quizzes { 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("graph")] public NexusReader.Application.Queries.Graph.GraphDataDto? Graph { get; init; }
[JsonPropertyName("summary")] public string? Summary { 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="Mapster.DependencyInjection" Version="10.0.7" />
<PackageReference Include="MediatR" Version="12.1.1" /> <PackageReference Include="MediatR" Version="12.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" /> <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="Microsoft.Extensions.Identity.Core" Version="10.0.7" />
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.2.1" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
@@ -1,7 +1,7 @@
namespace NexusReader.Application.Queries.Graph; namespace NexusReader.Application.Queries.Graph;
public record GraphNodeDto(string Id, string Label, string Group); public record GraphNodeDto(string Id, string Label, string Group, string? Type = null);
public record GraphLinkDto(string Source, string Target, int Value); public record GraphLinkDto(string Source, string Target, string RelationType, int Value = 1);
public record GraphDataDto public record GraphDataDto
{ {
public List<GraphNodeDto> Nodes { get; init; } = new(); 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;
using System.ComponentModel.DataAnnotations.Schema;
namespace NexusReader.Domain.Entities; namespace NexusReader.Domain.Entities;
@@ -11,6 +12,9 @@ public class SemanticKnowledgeCache
[Required] [Required]
public string JsonData { get; set; } = string.Empty; public string JsonData { get; set; } = string.Empty;
[Required]
public string OriginalText { get; set; } = string.Empty;
[Required] [Required]
[MaxLength(50)] [MaxLength(50)]
public string ModelId { get; set; } = "gemini-1.5-flash"; public string ModelId { get; set; } = "gemini-1.5-flash";
@@ -19,5 +23,12 @@ public class SemanticKnowledgeCache
[MaxLength(10)] [MaxLength(10)]
public string PromptVersion { get; set; } = "1.0"; 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; 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.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NexusReader.Domain.Entities; using NexusReader.Domain.Entities;
using NexusReader.Application.Abstractions.Persistence;
namespace NexusReader.Infrastructure.Persistence; namespace NexusReader.Infrastructure.Persistence;
public class AppDbContext : IdentityDbContext<NexusUser> public class AppDbContext : IdentityDbContext<NexusUser>, IApplicationDbContext
{ {
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{ {
} }
public DbSet<SemanticKnowledgeCache> SemanticKnowledgeCache => Set<SemanticKnowledgeCache>(); 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<Ebook> Ebooks => Set<Ebook>();
public DbSet<QuizResult> QuizResults => Set<QuizResult>(); public DbSet<QuizResult> QuizResults => Set<QuizResult>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.HasPostgresExtension("pgvector");
modelBuilder.Entity<NexusUser>(entity => modelBuilder.Entity<NexusUser>(entity =>
{ {
entity.Property(u => u.LastReadPageId).HasMaxLength(255); entity.Property(u => u.LastReadPageId).HasMaxLength(255);
@@ -28,6 +33,30 @@ public class AppDbContext : IdentityDbContext<NexusUser>
{ {
entity.HasKey(e => e.ContentHash); entity.HasKey(e => e.ContentHash);
entity.HasIndex(e => e.ContentHash).IsUnique(); 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 => modelBuilder.Entity<Ebook>(entity =>
@@ -12,12 +12,14 @@ using Polly;
using Polly.Registry; using Polly.Registry;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using NexusReader.Infrastructure.Configuration; using NexusReader.Infrastructure.Configuration;
using Pgvector.EntityFrameworkCore;
namespace NexusReader.Infrastructure.Services; namespace NexusReader.Infrastructure.Services;
public class KnowledgeService : IKnowledgeService public class KnowledgeService : IKnowledgeService
{ {
private readonly IChatClient _chatClient; private readonly IChatClient _chatClient;
private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;
private readonly AppDbContext _dbContext; private readonly AppDbContext _dbContext;
private readonly ResiliencePipeline _retryPipeline; private readonly ResiliencePipeline _retryPipeline;
private readonly AiSettings _settings; private readonly AiSettings _settings;
@@ -26,11 +28,13 @@ public class KnowledgeService : IKnowledgeService
public KnowledgeService( public KnowledgeService(
IChatClient chatClient, IChatClient chatClient,
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator,
AppDbContext dbContext, AppDbContext dbContext,
ResiliencePipelineProvider<string> pipelineProvider, ResiliencePipelineProvider<string> pipelineProvider,
IOptions<AiSettings> settings) IOptions<AiSettings> settings)
{ {
_chatClient = chatClient; _chatClient = chatClient;
_embeddingGenerator = embeddingGenerator;
_dbContext = dbContext; _dbContext = dbContext;
_retryPipeline = pipelineProvider.GetPipeline("ai-retry"); _retryPipeline = pipelineProvider.GetPipeline("ai-retry");
_settings = settings.Value; _settings = settings.Value;
@@ -54,6 +58,11 @@ public class KnowledgeService : IKnowledgeService
return await GetKnowledgeInternalAsync(text, PromptRegistry.SummaryAndQuizPrompt, "summary_quiz", cancellationToken); 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) private async Task<Result<KnowledgePacket>> GetKnowledgeInternalAsync(string text, string systemPrompt, string cacheSuffix, CancellationToken cancellationToken)
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
@@ -115,18 +124,47 @@ public class KnowledgeService : IKnowledgeService
var knowledgePacket = JsonSerializer.Deserialize<KnowledgePacket>(jsonResponse, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); var knowledgePacket = JsonSerializer.Deserialize<KnowledgePacket>(jsonResponse, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (knowledgePacket == null) return Result.Fail("Failed to deserialize AI response."); 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 var cacheEntry = new SemanticKnowledgeCache
{ {
ContentHash = hash, ContentHash = hash,
JsonData = jsonResponse, JsonData = jsonResponse,
OriginalText = normalizedText,
ModelId = _settings.Model, ModelId = _settings.Model,
PromptVersion = PromptVersion, PromptVersion = PromptVersion,
TenantId = "global", // Default for shared cache, should be overridden by caller context if possible
Vector = vector,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
if (cached == null) _dbContext.SemanticKnowledgeCache.Add(cacheEntry); 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); await _dbContext.SaveChangesAsync(cancellationToken);
return Result.Ok(knowledgePacket); 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) public async Task<Result> ClearCacheAsync(CancellationToken cancellationToken = default)
{ {
try try
@@ -21,4 +21,13 @@ public static class PromptRegistry
public const string SummaryAndQuizPrompt = public const string SummaryAndQuizPrompt =
"You are an expert educator. Provide a concise summary of the text and generate a challenging quiz (3-5 questions). " + "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 } ] }"; "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 // Update Links
link = rootGroup.select(".links-layer") link = rootGroup.select(".links-layer")
.selectAll("path") .selectAll("path")
.data(data.links, d => d.source + "-" + d.target) .data(data.links, d => d.source + "-" + d.target + "-" + d.relationType)
.join( .join(
enter => enter.append("path") 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("fill", "none")
.attr("stroke-width", 1.5) .attr("stroke-width", d => d.relationType === 'Defines' ? 2 : 1)
.call(e => e.transition().duration(500).attr("stroke", "rgba(255,255,255,0.1)")), .attr("stroke-dasharray", d => d.relationType === 'References' ? "5,5" : "0")
.call(e => e.transition().duration(500).attr("opacity", 1)),
update => update, update => update,
exit => exit.remove() exit => exit.remove()
); );
@@ -150,7 +156,12 @@ export function updateData(data) {
g.append("circle") g.append("circle")
.attr("r", 30) .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) .attr("opacity", 0)
.transition().duration(1000).attr("opacity", d => d.group === 'current' ? 0.6 : 0.2); .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("height", 24)
.attr("rx", 12) .attr("rx", 12)
.attr("fill", "rgba(20, 20, 20, 0.9)") .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); .attr("stroke-width", 1);
g.append("text") g.append("text")
.text(d => d.label) .text(d => d.label)
.attr("text-anchor", "middle") .attr("text-anchor", "middle")
.attr("y", 4) .attr("y", 4)
.attr("fill", "#ccc") .attr("fill", d => d.type === 'Definition' ? 'var(--nexus-accent)' : '#ccc')
.attr("font-size", "0.8rem"); .attr("font-size", "0.8rem");
return g; return g;
@@ -29,6 +29,26 @@ public class WasmKnowledgeService : IKnowledgeService
return await CallKnowledgeApiAsync("/api/knowledge/summary", text, cancellationToken); 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) private async Task<Result<KnowledgePacket>> CallKnowledgeApiAsync(string endpoint, string text, CancellationToken cancellationToken)
{ {
try try