feat: implement semantic search, knowledge unit extraction, and visualization components
This commit is contained in:
@@ -1,6 +1,12 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user