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
@@ -0,0 +1,39 @@
@namespace NexusReader.UI.Shared.Components.Atoms
<div class="nexus-search-container @(IsActive ? "active" : "")">
<div class="search-wrapper">
<i class="nexus-icon @IconClass"></i>
<input type="text"
@bind="SearchValue"
@bind:event="oninput"
@onkeypress="HandleKeyPress"
placeholder="@Placeholder"
class="nexus-search-input" />
@if (!string.IsNullOrEmpty(SearchValue))
{
<button class="clear-btn" @onclick="ClearSearch">×</button>
}
</div>
</div>
@code {
[Parameter] public string Placeholder { get; set; } = "Search your library...";
[Parameter] public string IconClass { get; set; } = "bi bi-search";
[Parameter] public EventCallback<string> OnSearch { get; set; }
private string SearchValue { get; set; } = string.Empty;
private bool IsActive => !string.IsNullOrEmpty(SearchValue);
private async Task HandleKeyPress(KeyboardEventArgs e)
{
if (e.Key == "Enter")
{
await OnSearch.InvokeAsync(SearchValue);
}
}
private void ClearSearch()
{
SearchValue = string.Empty;
}
}
@@ -0,0 +1,57 @@
.nexus-search-container {
width: 100%;
max-width: 500px;
margin: 1rem auto;
transition: all 0.3s ease;
}
.search-wrapper {
position: relative;
display: flex;
align-items: center;
background: var(--nexus-card, #141414);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 0.5rem 1rem;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.nexus-search-container.active .search-wrapper,
.search-wrapper:focus-within {
border-color: var(--nexus-neon, #00ff99);
box-shadow: 0 0 15px rgba(0, 255, 153, 0.2);
}
.nexus-icon {
color: rgba(255, 255, 255, 0.5);
margin-right: 0.75rem;
font-size: 1.1rem;
}
.nexus-search-input {
flex: 1;
background: transparent;
border: none;
color: white;
font-family: 'Inter', sans-serif;
font-size: 0.95rem;
outline: none;
}
.nexus-search-input::placeholder {
color: rgba(255, 255, 255, 0.3);
}
.clear-btn {
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.4);
font-size: 1.2rem;
cursor: pointer;
padding: 0 0.5rem;
transition: color 0.2s ease;
}
.clear-btn:hover {
color: var(--nexus-neon, #00ff99);
}
@@ -0,0 +1,84 @@
@using MediatR
@using NexusReader.Application.Commands.AI
@using NexusReader.UI.Shared.Components.Atoms
@inject IMediator Mediator
<div class="groundedness-badge @GetStatusClass()" title="@_result?.Rationale">
@if (_isChecking)
{
<span class="shimmer">Weryfikacja...</span>
}
else if (_result != null)
{
<NexusIcon Name="@(GetIcon())" Size="14" />
<span>@((_result.Score * 100).ToString("0"))% Grounded</span>
}
</div>
<style>
.groundedness-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
transition: all 0.3s ease;
}
.groundedness-badge.status-high { color: var(--nexus-neon); border-color: var(--nexus-neon); }
.groundedness-badge.status-medium { color: #ffaa00; border-color: #ffaa00; }
.groundedness-badge.status-low { color: #ff4444; border-color: #ff4444; }
.shimmer { opacity: 0.6; }
</style>
@code {
[Parameter] public string Answer { get; set; } = string.Empty;
[Parameter] public string Context { get; set; } = string.Empty;
private GroundednessResult? _result;
private bool _isChecking;
protected override async Task OnParametersSetAsync()
{
if (!string.IsNullOrEmpty(Answer) && !string.IsNullOrEmpty(Context) && _result == null)
{
await RunCheck();
}
}
private async Task RunCheck()
{
_isChecking = true;
StateHasChanged();
var res = await Mediator.Send(new VerifyGroundednessCommand(Answer, Context));
if (res.IsSuccess)
{
_result = res.Value;
}
_isChecking = false;
StateHasChanged();
}
private string GetStatusClass()
{
if (_result == null) return "";
if (_result.Score >= 0.8) return "status-high";
if (_result.Score >= 0.5) return "status-medium";
return "status-low";
}
private string GetIcon()
{
if (_result == null) return "help";
if (_result.Score >= 0.8) return "check-circle";
if (_result.Score >= 0.5) return "info-circle";
return "alert-triangle";
}
}
@@ -0,0 +1,61 @@
@using NexusReader.Application.DTOs.AI
@using NexusReader.UI.Shared.Components.Atoms
@namespace NexusReader.UI.Shared.Components.Organisms
<div class="global-intelligence-panel">
<div class="panel-header">
<h3><i class="bi bi-cpu"></i> Global Intelligence</h3>
<p>Semantic search across your library</p>
</div>
<NexusSearchBox Placeholder="Ask a question about your books..." OnSearch="HandleSearch" />
<div class="results-container">
@if (IsLoading)
{
<div class="loading-state">
<div class="nexus-spinner"></div>
<span>Analyzing your library...</span>
</div>
}
else if (Results != null && Results.Any())
{
@foreach (var result in Results)
{
<div class="search-result-item">
<div class="result-meta">
<span class="relevance">@(Math.Round(result.RelevanceScore * 100))% Relevant</span>
@if (!string.IsNullOrEmpty(result.SourceBookTitle))
{
<span class="source">in <strong>@result.SourceBookTitle</strong></span>
}
</div>
<div class="result-snippet">
@result.Snippet
</div>
</div>
}
}
else if (HasSearched)
{
<div class="empty-state">
<i class="bi bi-search"></i>
<p>No semantic matches found.</p>
</div>
}
</div>
</div>
@code {
[Parameter] public List<SemanticSearchResultDto>? Results { get; set; }
[Parameter] public bool IsLoading { get; set; }
[Parameter] public EventCallback<string> OnPerformSearch { get; set; }
private bool HasSearched { get; set; }
private async Task HandleSearch(string query)
{
HasSearched = true;
await OnPerformSearch.InvokeAsync(query);
}
}
@@ -0,0 +1,94 @@
.global-intelligence-panel {
background: var(--nexus-bg, #0a0a0a);
border-left: 1px solid rgba(255, 255, 255, 0.05);
height: 100%;
display: flex;
flex-direction: column;
padding: 1.5rem;
}
.panel-header h3 {
margin: 0;
color: var(--nexus-neon, #00ff99);
font-size: 1.25rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.panel-header p {
color: rgba(255, 255, 255, 0.5);
font-size: 0.85rem;
margin: 0.25rem 0 1.5rem 0;
}
.results-container {
flex: 1;
overflow-y: auto;
margin-top: 1rem;
padding-right: 0.5rem;
}
.search-result-item {
background: var(--nexus-card, #141414);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
border: 1px solid rgba(255, 255, 255, 0.05);
transition: transform 0.2s ease, border-color 0.2s ease;
}
.search-result-item:hover {
transform: translateX(4px);
border-color: rgba(0, 255, 153, 0.3);
}
.result-meta {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
margin-bottom: 0.5rem;
}
.relevance {
color: var(--nexus-neon, #00ff99);
font-weight: 600;
}
.source {
color: rgba(255, 255, 255, 0.4);
}
.result-snippet {
color: rgba(255, 255, 255, 0.8);
font-size: 0.9rem;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.loading-state, .empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: rgba(255, 255, 255, 0.3);
text-align: center;
}
.nexus-spinner {
width: 30px;
height: 30px;
border: 2px solid rgba(0, 255, 153, 0.1);
border-top-color: var(--nexus-neon, #00ff99);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@@ -122,13 +122,19 @@ export function updateData(data) {
// Update Links
link = rootGroup.select(".links-layer")
.selectAll("path")
.data(data.links, d => d.source + "-" + d.target)
.data(data.links, d => d.source + "-" + d.target + "-" + d.relationType)
.join(
enter => enter.append("path")
.attr("stroke", "rgba(255,255,255,0.05)")
.attr("stroke", d => {
if (d.relationType === 'Defines') return 'var(--nexus-accent)';
if (d.relationType === 'Next') return 'rgba(255,255,255,0.2)';
if (d.relationType === 'Contains') return 'var(--nexus-neon)';
return 'rgba(255,255,255,0.1)';
})
.attr("fill", "none")
.attr("stroke-width", 1.5)
.call(e => e.transition().duration(500).attr("stroke", "rgba(255,255,255,0.1)")),
.attr("stroke-width", d => d.relationType === 'Defines' ? 2 : 1)
.attr("stroke-dasharray", d => d.relationType === 'References' ? "5,5" : "0")
.call(e => e.transition().duration(500).attr("opacity", 1)),
update => update,
exit => exit.remove()
);
@@ -150,7 +156,12 @@ export function updateData(data) {
g.append("circle")
.attr("r", 30)
.attr("fill", "url(#nebulaGlow)")
.attr("fill", d => {
if (d.type === 'Definition') return 'var(--nexus-accent)';
if (d.type === 'Table') return 'var(--nexus-neon)';
if (d.type === 'Rule') return '#ff4444';
return "url(#nebulaGlow)";
})
.attr("opacity", 0)
.transition().duration(1000).attr("opacity", d => d.group === 'current' ? 0.6 : 0.2);
@@ -162,14 +173,18 @@ export function updateData(data) {
.attr("height", 24)
.attr("rx", 12)
.attr("fill", "rgba(20, 20, 20, 0.9)")
.attr("stroke", "rgba(255, 255, 255, 0.1)")
.attr("stroke", d => {
if (d.type === 'Definition') return 'var(--nexus-accent)';
if (d.type === 'Rule') return '#ff4444';
return "rgba(255, 255, 255, 0.1)";
})
.attr("stroke-width", 1);
g.append("text")
.text(d => d.label)
.attr("text-anchor", "middle")
.attr("y", 4)
.attr("fill", "#ccc")
.attr("fill", d => d.type === 'Definition' ? 'var(--nexus-accent)' : '#ccc')
.attr("font-size", "0.8rem");
return g;