feat(search/rag): implement NexusSearchBox, dynamic Qdrant collection auto-provisioning, batch vector ingestion, mobile Serilog logging, and resolve 401 auth handler error (#51)

Resolves #52

This Pull Request introduces the **NexusSearchBox** search feature with premium unified styling, implements a robust **dynamic Qdrant collection auto-provisioning and batch-vector ingestion pipeline**, integrates a unified **Serilog logging infrastructure** for the Blazor Hybrid environment (MAUI), and resolves the **401 Unauthorized API header propagation error** inside mobile builds.

### 🚀 Key Implementations

#### 1. Premium `NexusSearchBox` & Semantic Search UI
* **NexusSearchBox Component:** Created an elegant search-as-you-type search box with smooth key navigation, quick-clearing, and seamless dynamic styling.
* **Unified Aesthetics:** Refactored the search box isolated styling to align perfectly with the dashboard's design system using glassmorphism, `--nexus-neon` token gradients, and smooth pulse/fade animations.
* **Semantic Search Integration:** Integrated semantic search query dispatching (`SearchLibrarySemanticallyQuery`) and wired up navigation seamlessly through the updated `ReaderNavigationService`.
* **Tests Hardening:** Added/adapted query assertions in `QueryTests.cs` to guarantee safe parameterization and error boundary mapping.

#### 2. Qdrant Collection Provisioning & Vector Ingestion
* **Dynamic Auto-Provisioning:** Implemented dynamic checking and lazy-creation of the `knowledge_units` collection using 768 dimensions and Cosine distance.
* **High-Performance Ingestion:** Optimized `ProcessKnowledgeUnitsAsync` with high-performance batch embedding generation using `_embeddingGenerator` and deterministic MD5 GUIDs for stable, duplicate-free upsertion.
* **Database Cache Clear Sync:** Integrated Qdrant collection deletion in `ClearCacheAsync` to ensure absolute consistency between the PostgreSQL database cache and vector database indices.

#### 3. Cross-Platform MAUI Logging (Serilog Infrastructure)
* **Serilog Integration:** Configured cross-platform Serilog routing in `SerilogConfiguration.cs`, streaming diagnostic logs safely across native platforms and the Blazor Webview container.
* **Interop Bridge:** Built `BlazorLoggingBridge.cs` to capture web console messages and pipe them directly to the native host logger.
* **Demo Interface:** Added an interactive `SerilogDemo.razor` sandbox under Pages.

#### 4. Resolving 401 Load Errors (Authentication Handler Flow)
* **Authentication Header Handler:** Implemented the `MobileAuthenticationHeaderHandler` to correctly extract, validate, and inject bearer JWT tokens into outbound API requests.
* **Configuration-based API Host:** Structured standard API URI routing to use clean configuration bindings in `appsettings.json`.

---

### 🧪 Verification & Build Status
* Run `dotnet build` from the solution root: Successfully compiled the full multi-targeted solution (`Liczba błędów: 0`).
* All unit and integration tests successfully executed and verified (`dotnet test`).

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Co-authored-by: Marek Jaisński <jasins.marek@gmail.com>
Reviewed-on: #51
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #51.
This commit is contained in:
2026-05-26 12:15:28 +00:00
committed by Marek Jaisński
parent 758b152a0c
commit a0bf6c15f4
61 changed files with 5111 additions and 570 deletions
@@ -2,9 +2,14 @@
@using NexusReader.Application.Queries.Quiz
@using NexusReader.Application.Commands.Quiz
@using NexusReader.Application.Abstractions.Services
@using NexusReader.UI.Shared.Components.Atoms
@using NexusReader.UI.Shared.Services
@inject IMediator Mediator
@inject IPlatformService PlatformService
@inject IQuizStateService QuizService
@inject IIdentityService IdentityService
@inject IKnowledgeGraphService GraphService
@inject KnowledgeCoordinator Coordinator
<div class="knowledge-check">
<div class="quiz-header">
@@ -12,10 +17,33 @@
<button class="expand-btn">⌵</button>
</div>
@if (QuizService.IsHydrating)
@if (QuizService.IsHydrating || _isGenerating)
{
<div class="loading-state shimmer">Skanowanie wiedzy przez AI...</div>
}
else if (_isSubmitted)
{
<div class="submitted-container">
<div class="success-icon-wrapper">
<NexusIcon Name="check" Size="48" Class="success-glow" />
</div>
<h3 class="submitted-title">Gratulacje!</h3>
<p class="submitted-text">Sprawdzian zakończony pomyślnie. Twój wynik został zapisany w bazie danych.</p>
<div class="score-card">
<div class="score-main">
<span class="score-num">@_score</span>
<span class="score-divider">/</span>
<span class="score-total">@_totalQuestions</span>
</div>
<div class="score-percent">@((int)_percentage)% poprawnych odpowiedzi</div>
</div>
<button class="reset-quiz-btn" @onclick="CloseQuiz">
<span>ZAKOŃCZ</span>
</button>
</div>
}
else if (QuizService.CurrentQuiz != null)
{
<div class="quiz-body">
@@ -41,17 +69,45 @@
}
<div class="quiz-footer">
<button class="submit-btn" disabled="@(!AllQuestionsAnswered())">Wyślij</button>
<button class="submit-btn" disabled="@(!AllQuestionsAnswered() || _isSubmitting)" @onclick="SubmitQuizAsync">
@if (_isSubmitting)
{
<span>Zapisywanie...</span>
}
else
{
<span>Wyślij</span>
}
</button>
</div>
</div>
}
else
{
<div class="empty-quiz-state">
<div class="empty-icon-wrapper">
<NexusIcon Name="robot" Size="48" Class="neon-glow" />
</div>
<h3 class="empty-title">Brak Aktywnego Quizu</h3>
<p class="empty-text">Generuj spersonalizowany sprawdzian wiedzy na podstawie bieżącego rozdziału książki.</p>
<button class="generate-quiz-btn" @onclick="GenerateChapterQuizAsync" disabled="@(string.IsNullOrWhiteSpace(Coordinator.CurrentFullPageContent))">
<span>GENERUJ QUIZ DLA ROZDZIAŁU</span>
</button>
</div>
}
</div>
@code {
[Parameter] public string ContextBlockId { get; set; } = string.Empty;
private Dictionary<QuizQuestionDto, (int SelectedIndex, bool IsCorrect)> _states = new();
private bool _isSubmitting = false;
private bool _isSubmitted = false;
private bool _isGenerating = false;
private int _score = 0;
private int _totalQuestions = 0;
private double _percentage = 0.0;
protected override void OnInitialized()
{
@@ -65,6 +121,24 @@
QuizService.OnQuizUpdated -= HandleUpdate;
}
private async Task GenerateChapterQuizAsync()
{
if (_isGenerating || string.IsNullOrWhiteSpace(Coordinator.CurrentFullPageContent)) return;
_isGenerating = true;
StateHasChanged();
try
{
await Coordinator.RequestSummaryAndQuizAsync(Coordinator.CurrentFullPageContent);
}
finally
{
_isGenerating = false;
StateHasChanged();
}
}
private async Task SelectOptionAsync(QuizQuestionDto question, int index)
{
if (_states.ContainsKey(question)) return;
@@ -90,6 +164,67 @@
return QuizService.CurrentQuiz != null && _states.Count == QuizService.CurrentQuiz.Questions.Count;
}
private async Task SubmitQuizAsync()
{
if (QuizService.CurrentQuiz == null || !AllQuestionsAnswered() || _isSubmitting) return;
_isSubmitting = true;
StateHasChanged();
try
{
_score = _states.Values.Count(s => s.IsCorrect);
_totalQuestions = QuizService.CurrentQuiz.Questions.Count;
_percentage = _totalQuestions > 0 ? ((double)_score / _totalQuestions) * 100 : 0.0;
string topic = "Quiz wiedzy";
var graph = GraphService.CurrentGraphData;
if (graph != null && !string.IsNullOrEmpty(QuizService.CurrentQuizBlockId))
{
var node = graph.Nodes.FirstOrDefault(n => n.Id == QuizService.CurrentQuizBlockId);
if (node != null && !string.IsNullOrEmpty(node.Label))
{
topic = $"Test: {node.Label}";
}
}
var profileResult = await IdentityService.GetProfileAsync();
if (profileResult.IsSuccess && profileResult.Value != null)
{
var userId = profileResult.Value.UserId;
var cmd = new SubmitQuizResultCommand(userId, topic, _score, _totalQuestions);
var result = await Mediator.Send(cmd);
if (result.IsSuccess)
{
IdentityService.ClearCache();
_isSubmitted = true;
await PlatformService.VibrateSuccessAsync();
}
else
{
await PlatformService.VibrateErrorAsync();
}
}
}
catch
{
await PlatformService.VibrateErrorAsync();
}
finally
{
_isSubmitting = false;
StateHasChanged();
}
}
private void CloseQuiz()
{
_isSubmitted = false;
_states.Clear();
QuizService.SetQuiz(null, null);
}
private string GetBlockClass(QuizQuestionDto question)
{