From e21c24b66d1199e0229483e964307f9ab0632e38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Sun, 3 May 2026 17:52:12 +0200 Subject: [PATCH] feat: implement multi-tenancy support across knowledge services and normalize TenantId to string type. --- GEMINI.md | 5 + backlog-code-review.md | 156 ++++++++++++++++++ .../Services/IKnowledgeService.cs | 11 +- .../Commands/AI/VerifyGroundednessCommand.cs | 41 +---- .../DependencyInjection.cs | 7 +- .../Queries/Graph/GetKnowledgeGraphQuery.cs | 3 +- .../Graph/GetKnowledgeGraphQueryHandler.cs | 11 +- src/NexusReader.Domain/Entities/NexusUser.cs | 5 +- .../DependencyInjection.cs | 10 +- .../Persistence/DbInitializer.cs | 2 +- .../Services/KnowledgeService.cs | 64 +++++-- src/NexusReader.Maui/MauiProgram.cs | 4 + .../Molecules/GroundednessBadge.razor | 33 +++- .../Services/KnowledgeCoordinator.cs | 8 +- .../Services/WasmKnowledgeService.cs | 27 ++- src/NexusReader.Web.New/Program.cs | 41 ++++- 16 files changed, 334 insertions(+), 94 deletions(-) create mode 100644 backlog-code-review.md diff --git a/GEMINI.md b/GEMINI.md index 48faa63..fd9c6ab 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -34,4 +34,9 @@ version: 1.0 1. **Verification-Led:** Plan and define tests/verification steps *before* writing feature code. 2. **Step-by-Step Execution:** Break complex tasks into manageable, verifiable chunks. 3. **Layer Integrity:** Always check for illegal cross-layer dependencies (e.g., Application depending on Infrastructure). +4. **Mandatory Build Gate:** After **every** code change, run `dotnet build` on the full solution. The agent MUST NOT proceed or report completion if there are any `error CS*` compiler errors. All build errors must be resolved before moving to the next step. + +> [!IMPORTANT] +> **Build command:** `dotnet build NexusReader.slnx --no-restore` +> Run from the solution root `/home/mjasin/Projekty/ejajBook`. Build warnings are acceptable; errors are not. diff --git a/backlog-code-review.md b/backlog-code-review.md new file mode 100644 index 0000000..1a5f5fe --- /dev/null +++ b/backlog-code-review.md @@ -0,0 +1,156 @@ +# ๐Ÿ” NexusReader Code Review Backlog + +## ๐Ÿ”ด CRITICAL โ€” Fix Before Next Release + +- **Status:** โœ… Resolved (2026-05-03) +- **Implementation:** Removed `AddMediatR` from `AddApplication()` and `AddInfrastructure()`. Unified registration in Host (`Program.cs`, `MauiProgram.cs`). Added `IInfrastructureMarker` and a startup validation check in `Web.New` that throws `InvalidOperationException` if `AddInfrastructure` is missing. +- **DoD:** Application fails fast with a clear error if `AddInfrastructure()` is omitted. + +--- + +- **Status:** โœ… Resolved (2026-05-03) +- **Implementation:** Added `VerifyGroundednessAsync` to `IKnowledgeService` and implemented it in `KnowledgeService` (Infrastructure). Updated `VerifyGroundednessCommandHandler` in Application to inject `IKnowledgeService` instead of `IChatClient`. +- **DoD:** No `IChatClient` or `IEmbeddingGenerator` references remain in `NexusReader.Application`. + +--- + +- **Status:** โœ… Resolved (2026-05-03) +- **Implementation:** Threaded `tenantId` through all `IKnowledgeService` methods and `ProcessKnowledgeUnitsAsync`. Scoped `SemanticKnowledgeCache` and `KnowledgeUnit` lookups/writes to the provided `tenantId`. Updated API endpoints in `Program.cs` and `WasmKnowledgeService` to pass the authenticated user's `TenantId`. +- **DoD:** No hardcoded `"global"` TenantId in write paths. Extracted units are always scoped to the caller's tenant. + +--- + +- **Status:** โœ… Resolved (2026-05-03) +- **Implementation:** Changed `NexusUser.TenantId` from `Guid` to `string`. All entities now use `string` for `TenantId`, allowing the use of `"global"` as a sentinel value. +- **DoD:** All entities use the same `TenantId` type. All query filters are consistent. + +--- + +## ๐ŸŸ  MAJOR โ€” High Priority Fixes + +### [MJ-01] Missing Exception Handling in `EpubService` + +- **File:** `Infrastructure/Services/EpubService.cs:45` +- **Problem:** The service uses raw `ZipArchive` operations without try-catch blocks. Corrupt EPUB files will crash the circuit instead of returning a `Result.Fail`. +- **Action:** Wrap the extraction logic in a try-catch and return `Result.Fail(ex.Message)`. +- **DoD:** Uploading a renamed `.txt` as `.epub` returns a user-friendly error instead of a 500 error. + +--- + +### [MJ-02] Hardcoded Pricing & Limits in Stripe Logic + +- **File:** `Web.New/Program.cs:298` +- **Problem:** Subscription limits (50k tokens for Pro) are hardcoded in the webhook handler. Changing prices or limits requires a code redeploy. +- **Action:** Move limits to `appsettings.json` or a `SubscriptionPlan` domain entity. Use `IOptions` in the handler. +- **DoD:** Limits can be changed via configuration without rebuilding the app. + +--- + +### [MJ-03] Knowledge Graph: Circular Dependency Potential + +- **File:** `UI.Shared/Services/KnowledgeGraphService.cs` +- **Problem:** The service manages its own state but is injected as `Scoped`. If multiple components use it, they share the same graph state, which might lead to race conditions during navigation. +- **Action:** Ensure the service is either stateless (returning data) or implement a `Clear()` method called on `OnInitialized`. +- **DoD:** Navigating between two different books correctly clears the graph. + +--- + +### [MJ-04] Insecure `Profile` Endpoint Exposes Internal IDs + +- **File:** `Web.New/Program.cs:366` +- **Problem:** The `/identity/profile` endpoint returns the raw `TenantId` and internal database IDs in the JSON response. +- **Action:** Create a `UserProfileDto` and use Mapster to exclude internal metadata. +- **DoD:** Sensitive internal GUIDs/IDs are not visible in the browser's Network tab. + +--- + +### [MJ-05] Missing Database Index for Multi-Tenancy + +- **Problem:** `TenantId` is used in almost every query (KnowledgeUnits, Cache, Users) but lacks a database index. As data grows, retrieval will slow down significantly (O(N) vs O(log N)). +- **Action:** Add `HasIndex(x => x.TenantId)` to the `AppDbContext` configuration for all relevant entities. +- **DoD:** EF Migration generated with `CREATE INDEX` for `TenantId`. + +--- + +### [MJ-06] KM-RAG: Link Integrity is Not Validated + +- **File:** `Infrastructure/Services/KnowledgeService.cs:208` +- **Problem:** When processing `KnowledgeUnitLink`, the service assumes both `Source` and `Target` units exist in the DB. If AI returns a link to a non-existent node, the DB insert will fail (foreign key violation). +- **Action:** Add a check to verify both units exist or are being created in the same batch before adding the link. +- **DoD:** Broken links from AI are logged as warnings and skipped, not causing a total failure. + +--- + +### [MJ-07] Ebook Entity Missing Tenant Isolation + +- **File:** `Domain/Entities/Ebook.cs` +- **Problem:** The `Ebook` entity lacks a `TenantId` property. All uploaded books are visible to all users if the ID is guessed. +- **Action:** Add `TenantId` to `Ebook` and filter all queries in `EpubService`. +- **DoD:** User A cannot see User B's books. + +--- + +### [MJ-08] QuizResults Missing Tenant Isolation + +- **File:** `Domain/Entities/QuizResult.cs` +- **Problem:** Similar to ebooks, quiz results are not scoped to a tenant. +- **Action:** Add `TenantId` to `QuizResult`. +- **DoD:** Results are correctly partitioned. + +--- + +## ๐ŸŸก MINOR โ€” Technical Debt & UX + +### [MN-01] Missing Logging in `KnowledgeCoordinator` +- **Action:** Add `ILogger` and log successful/failed extraction steps. + +### [MN-02] Hardcoded "Gemini-1.5-Flash" in Domain +- **File:** `Domain/Entities/SemanticKnowledgeCache.cs:20` +- **Action:** Move the default model ID to a constant in `AiSettings`. + +### [MN-03] UI: Shimmer Effect Lack Animation +- **File:** `UI.Shared/Components/Molecules/GroundednessBadge.razor` +- **Action:** Add `@keyframes` for the shimmer effect in CSS. + +### [MN-04] Identity: Google Callback Lack Error Handling +- **File:** `Web.New/Program.cs:340` +- **Action:** Better UI feedback when `ExternalLoginInfo` is null. + +### [MN-05] Tokenizer Initialization is Expensive +- **File:** `Infrastructure/Services/KnowledgeService.cs:43` +- **Action:** Make `_tokenizer` static or Singleton to avoid recreating it per request. + +### [MN-06] Mapster: Global Configuration Check +- **Action:** Ensure `TypeAdapterConfig.GlobalSettings.Scan(...)` is only called once. + +### [MN-07] SignalR: Missing Reconnection Logic +- **Action:** Implement `hubConnection.OnReconnected` in `SyncService.cs`. + +### [MN-08] CSS: Z-Index Consistency +- **Action:** Define a `z-index` scale in `index.css`. + +### [MN-09] SEO: Missing Meta Descriptions +- **Action:** Update `App.razor` with dynamic meta tags. + +### [MN-10] Performance: Large EPUB Parsing +- **Action:** Implement streaming extraction for EPUBs over 10MB. + +--- + +## ๐Ÿงช TESTING โ€” Coverage Gaps + +### [TEST-01] Integration Tests for KM-RAG Retrieval +- **Action:** Create `tests/NexusReader.IntegrationTests`. +- **Scenario:** Ingest a document, then verify that `GetRelevantContext` returns the correct snippets with tenant isolation active. + +--- + +## ๐Ÿ“Š Summary Table + +| Severity | Count | Status | +|---|---|---| +| ๐Ÿ”ด Critical | 4 | 4 resolved | +| ๐ŸŸ  Major | 8 | Unresolved | +| ๐ŸŸก Minor | 10 | Unresolved | +| ๐Ÿงช Tests | 1 | Unresolved | +| **Total** | **23** | **4 resolved** | diff --git a/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs b/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs index 68e90c6..bc34fc6 100644 --- a/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs +++ b/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs @@ -5,10 +5,13 @@ namespace NexusReader.Application.Abstractions.Services; public interface IKnowledgeService { - Task> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default); - Task> GetGraphDataAsync(string text, CancellationToken cancellationToken = default); - Task> GetKnowledgeMapAsync(string text, CancellationToken cancellationToken = default); - Task> GetSummaryAndQuizAsync(string text, CancellationToken cancellationToken = default); + Task> GetKnowledgeAsync(string text, string tenantId, CancellationToken cancellationToken = default); + Task> GetGraphDataAsync(string text, string tenantId, CancellationToken cancellationToken = default); + Task> GetKnowledgeMapAsync(string text, string tenantId, CancellationToken cancellationToken = default); + Task> GetSummaryAndQuizAsync(string text, string tenantId, CancellationToken cancellationToken = default); Task>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default); + Task> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default); Task ClearCacheAsync(CancellationToken cancellationToken = default); } + +public record GroundednessResult(float Score, string Rationale, bool IsGrounded); diff --git a/src/NexusReader.Application/Commands/AI/VerifyGroundednessCommand.cs b/src/NexusReader.Application/Commands/AI/VerifyGroundednessCommand.cs index e1a738f..0b084ba 100644 --- a/src/NexusReader.Application/Commands/AI/VerifyGroundednessCommand.cs +++ b/src/NexusReader.Application/Commands/AI/VerifyGroundednessCommand.cs @@ -1,51 +1,22 @@ using FluentResults; using MediatR; -using Microsoft.Extensions.AI; +using NexusReader.Application.Abstractions.Services; namespace NexusReader.Application.Commands.AI; -public record VerifyGroundednessCommand(string Answer, string Context) : IRequest>; - -public record GroundednessResult(float Score, string Rationale, bool IsGrounded); +public record VerifyGroundednessCommand(string Answer, string Context, string TenantId) : IRequest>; public class VerifyGroundednessCommandHandler : IRequestHandler> { - private readonly IChatClient _chatClient; + private readonly IKnowledgeService _knowledgeService; - public VerifyGroundednessCommandHandler(IChatClient chatClient) + public VerifyGroundednessCommandHandler(IKnowledgeService knowledgeService) { - _chatClient = chatClient; + _knowledgeService = knowledgeService; } public async Task> Handle(VerifyGroundednessCommand request, CancellationToken cancellationToken) { - var systemPrompt = @" - You are a Fact-Checking AI. Evaluate if the 'Answer' is supported by the 'Context'. - Rate the groundedness from 0.0 to 1.0. - Return ONLY a JSON object: { ""score"": 0.9, ""rationale"": ""string"", ""isGrounded"": true } - "; - - var userPrompt = $"Context: {request.Context}\n\nAnswer: {request.Answer}"; - - try - { - var response = await _chatClient.GetResponseAsync(new List - { - new ChatMessage(ChatRole.System, systemPrompt), - new ChatMessage(ChatRole.User, userPrompt) - }, cancellationToken: cancellationToken); - - var rawJson = response.Text?.Trim() ?? "{}"; - // Simple cleanup if needed - rawJson = rawJson.Replace("```json", "").Replace("```", "").Trim(); - - var result = System.Text.Json.JsonSerializer.Deserialize(rawJson, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - - return result != null ? Result.Ok(result) : Result.Fail("Failed to parse groundedness result"); - } - catch (Exception ex) - { - return Result.Fail(new Error("Failed to verify groundedness").CausedBy(ex)); - } + return await _knowledgeService.VerifyGroundednessAsync(request.Answer, request.Context, request.TenantId, cancellationToken); } } diff --git a/src/NexusReader.Application/DependencyInjection.cs b/src/NexusReader.Application/DependencyInjection.cs index f897249..071c8e4 100644 --- a/src/NexusReader.Application/DependencyInjection.cs +++ b/src/NexusReader.Application/DependencyInjection.cs @@ -9,11 +9,8 @@ public static class DependencyInjection { services.AddMapsterConfiguration(); - services.AddMediatR(config => - { - config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly); - }); - return services; } + + public static System.Reflection.Assembly Assembly => typeof(DependencyInjection).Assembly; } diff --git a/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQuery.cs b/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQuery.cs index 0ef50f0..e292318 100644 --- a/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQuery.cs +++ b/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQuery.cs @@ -3,5 +3,6 @@ using NexusReader.Application.Abstractions.Messaging; namespace NexusReader.Application.Queries.Graph; /// Chapter or page content to extract the graph from. -public record GetKnowledgeGraphQuery(string Text) : IQuery; +/// Tenant scope for knowledge extraction and caching. +public record GetKnowledgeGraphQuery(string Text, string TenantId) : IQuery; diff --git a/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQueryHandler.cs b/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQueryHandler.cs index c693761..1119b69 100644 --- a/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQueryHandler.cs +++ b/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQueryHandler.cs @@ -18,20 +18,13 @@ internal sealed class GetKnowledgeGraphQueryHandler : IQueryHandler(result.Errors); var graph = result.Value.Graph; - if (graph is null) - return Result.Ok(new GraphDataDto()); - - if (graph is null) - return Result.Ok(new GraphDataDto()); - - return Result.Ok(graph); + return graph is null ? Result.Ok(new GraphDataDto()) : Result.Ok(graph); } } - diff --git a/src/NexusReader.Domain/Entities/NexusUser.cs b/src/NexusReader.Domain/Entities/NexusUser.cs index 107a6d1..f5428a0 100644 --- a/src/NexusReader.Domain/Entities/NexusUser.cs +++ b/src/NexusReader.Domain/Entities/NexusUser.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Identity; +using System.ComponentModel.DataAnnotations; namespace NexusReader.Domain.Entities; @@ -20,7 +21,9 @@ public class NexusUser : IdentityUser /// /// Unique identifier for the tenant (SaaS multi-tenancy support). /// - public Guid TenantId { get; set; } + [Required] + [MaxLength(128)] + public string TenantId { get; set; } = "global"; /// /// Current subscription plan (e.g., "Free", "Pro", "Enterprise"). diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index c4735d5..b6b1c76 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -74,11 +74,13 @@ public static class DependencyInjection services.AddScoped(); - services.AddMediatR(config => - { - config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly); - }); + services.AddScoped(); return services; } + + public static System.Reflection.Assembly Assembly => typeof(DependencyInjection).Assembly; } + +public interface IInfrastructureMarker { } +internal class InfrastructureMarker : IInfrastructureMarker { } diff --git a/src/NexusReader.Infrastructure/Persistence/DbInitializer.cs b/src/NexusReader.Infrastructure/Persistence/DbInitializer.cs index 1170bf3..fc0270b 100644 --- a/src/NexusReader.Infrastructure/Persistence/DbInitializer.cs +++ b/src/NexusReader.Infrastructure/Persistence/DbInitializer.cs @@ -44,7 +44,7 @@ public static class DbInitializer EmailConfirmed = true, CurrentPlan = "Enterprise", AITokenLimit = 1000000, - TenantId = Guid.NewGuid() + TenantId = Guid.NewGuid().ToString() }; var createPowerUser = await userManager.CreateAsync(adminUser, "Admin123!"); diff --git a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs index c54dd2d..0b803a8 100644 --- a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs +++ b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs @@ -43,27 +43,27 @@ public class KnowledgeService : IKnowledgeService _tokenizer = TiktokenTokenizer.CreateForModel("gpt-4"); } - public async Task> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default) + public async Task> GetKnowledgeAsync(string text, string tenantId, CancellationToken cancellationToken = default) { - return await GetKnowledgeInternalAsync(text, PromptRegistry.KnowledgeExtractionSystemPrompt, "full", cancellationToken); + return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.KnowledgeExtractionSystemPrompt, "full", cancellationToken); } - public async Task> GetGraphDataAsync(string text, CancellationToken cancellationToken = default) + public async Task> GetGraphDataAsync(string text, string tenantId, CancellationToken cancellationToken = default) { - return await GetKnowledgeInternalAsync(text, PromptRegistry.GraphExtractionPrompt, "graph", cancellationToken); + return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.GraphExtractionPrompt, "graph", cancellationToken); } - public async Task> GetSummaryAndQuizAsync(string text, CancellationToken cancellationToken = default) + public async Task> GetSummaryAndQuizAsync(string text, string tenantId, CancellationToken cancellationToken = default) { - return await GetKnowledgeInternalAsync(text, PromptRegistry.SummaryAndQuizPrompt, "summary_quiz", cancellationToken); + return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.SummaryAndQuizPrompt, "summary_quiz", cancellationToken); } - public async Task> GetKnowledgeMapAsync(string text, CancellationToken cancellationToken = default) + public async Task> GetKnowledgeMapAsync(string text, string tenantId, CancellationToken cancellationToken = default) { - return await GetKnowledgeInternalAsync(text, PromptRegistry.KM_ExtractionPrompt, "km_map", cancellationToken); + return await GetKnowledgeInternalAsync(text, tenantId, PromptRegistry.KM_ExtractionPrompt, "km_map", cancellationToken); } - private async Task> GetKnowledgeInternalAsync(string text, string systemPrompt, string cacheSuffix, CancellationToken cancellationToken) + private async Task> GetKnowledgeInternalAsync(string text, string tenantId, string systemPrompt, string cacheSuffix, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(text)) { @@ -84,7 +84,7 @@ public class KnowledgeService : IKnowledgeService // 1. Check Cache var cached = await _dbContext.SemanticKnowledgeCache - .FirstOrDefaultAsync(c => c.ContentHash == hash && c.PromptVersion == PromptVersion, cancellationToken); + .FirstOrDefaultAsync(c => c.ContentHash == hash && c.TenantId == tenantId && c.PromptVersion == PromptVersion, cancellationToken); if (cached != null) { @@ -146,7 +146,7 @@ public class KnowledgeService : IKnowledgeService OriginalText = normalizedText, ModelId = _settings.Model, PromptVersion = PromptVersion, - TenantId = "global", // Default for shared cache, should be overridden by caller context if possible + TenantId = tenantId, Vector = vector, CreatedAt = DateTime.UtcNow }; @@ -163,7 +163,7 @@ public class KnowledgeService : IKnowledgeService // 5. Process KM-RAG Units and Links if present if (knowledgePacket.Units.Any()) { - await ProcessKnowledgeUnitsAsync(knowledgePacket, "global", cancellationToken); + await ProcessKnowledgeUnitsAsync(knowledgePacket, tenantId, cancellationToken); } await _dbContext.SaveChangesAsync(cancellationToken); @@ -191,7 +191,7 @@ public class KnowledgeService : IKnowledgeService var unit = existing ?? new KnowledgeUnit { Id = unitId, TenantId = tenantId }; unit.Type = Enum.TryParse(unitDto.Type, true, out var type) ? type : NexusReader.Domain.Enums.KnowledgeUnitType.Snippet; unit.Content = unitDto.Content; - unit.SourceId = "extracted"; // Should be passed from context + unit.SourceId = "extracted"; unit.MetadataJson = JsonSerializer.Serialize(unitDto.Metadata); // Generate unit-specific embedding for granular retrieval @@ -217,6 +217,44 @@ public class KnowledgeService : IKnowledgeService } } + public async Task> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default) + { + 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: {context}\n\nAnswer: {answer}"; + + try + { + var options = new ChatOptions + { + Temperature = 0.0f, // Low temperature for factual checks + MaxOutputTokens = 500 + }; + + var response = await _retryPipeline.ExecuteAsync(async ct => + await _chatClient.GetResponseAsync(new List + { + new ChatMessage(ChatRole.System, systemPrompt), + new ChatMessage(ChatRole.User, userPrompt) + }, options, cancellationToken: ct), cancellationToken); + + var rawJson = response.Text?.Trim() ?? "{}"; + rawJson = rawJson.Replace("```json", "").Replace("```", "").Trim(); + + var result = JsonSerializer.Deserialize(rawJson, new 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)); + } + } + public async Task>> GetRelevantContextAsync(string query, string tenantId, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(query)) return Result.Fail("Query is empty."); diff --git a/src/NexusReader.Maui/MauiProgram.cs b/src/NexusReader.Maui/MauiProgram.cs index b58658d..8768784 100644 --- a/src/NexusReader.Maui/MauiProgram.cs +++ b/src/NexusReader.Maui/MauiProgram.cs @@ -49,6 +49,10 @@ public static class MauiProgram builder.Services.AddScoped(); builder.Services.AddApplication(); + + builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies( + NexusReader.Application.DependencyInjection.Assembly + )); return builder.Build(); } diff --git a/src/NexusReader.UI.Shared/Components/Molecules/GroundednessBadge.razor b/src/NexusReader.UI.Shared/Components/Molecules/GroundednessBadge.razor index fa991f9..182c5b5 100644 --- a/src/NexusReader.UI.Shared/Components/Molecules/GroundednessBadge.razor +++ b/src/NexusReader.UI.Shared/Components/Molecules/GroundednessBadge.razor @@ -1,7 +1,10 @@ @using MediatR @using NexusReader.Application.Commands.AI +@using NexusReader.Application.Abstractions.Services @using NexusReader.UI.Shared.Components.Atoms +@using Microsoft.AspNetCore.Components.Authorization @inject IMediator Mediator +@inject AuthenticationStateProvider AuthProvider
@if (_isChecking) @@ -24,16 +27,29 @@ 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); + 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; } + .groundedness-badge.status-high { + color: var(--nexus-neon); + border-color: var(--nexus-neon); + } - .shimmer { opacity: 0.6; } + .groundedness-badge.status-medium { + color: #ffaa00; + border-color: #ffaa00; + } + + .groundedness-badge.status-low { + color: #ff4444; + border-color: #ff4444; + } + + .shimmer { + opacity: 0.6; + } @code { @@ -56,7 +72,10 @@ _isChecking = true; StateHasChanged(); - var res = await Mediator.Send(new VerifyGroundednessCommand(Answer, Context)); + var authState = await AuthProvider.GetAuthenticationStateAsync(); + var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global"; + + var res = await Mediator.Send(new VerifyGroundednessCommand(Answer, Context, tenantId)); if (res.IsSuccess) { _result = res.Value; diff --git a/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs b/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs index 60c4737..08c3616 100644 --- a/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs +++ b/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs @@ -38,7 +38,7 @@ public sealed class KnowledgeCoordinator : IDisposable _interactionService.RequestHighlightBlock(nodeId); } - public async Task ProcessFullPageAsync(string fullContent) + public async Task ProcessFullPageAsync(string fullContent, string tenantId = "global") { if (string.IsNullOrWhiteSpace(fullContent)) return; @@ -49,7 +49,7 @@ public sealed class KnowledgeCoordinator : IDisposable try { - var result = await _knowledgeService.GetGraphDataAsync(fullContent); + var result = await _knowledgeService.GetGraphDataAsync(fullContent, tenantId); if (result.IsSuccess) { var packet = result.Value; @@ -73,12 +73,12 @@ public sealed class KnowledgeCoordinator : IDisposable _graphService.SetActiveNode(blockId); } - public async Task RequestSummaryAndQuizAsync(string content) + public async Task RequestSummaryAndQuizAsync(string content, string tenantId = "global") { _quizService.SetHydrating(true); try { - var result = await _knowledgeService.GetSummaryAndQuizAsync(content); + var result = await _knowledgeService.GetSummaryAndQuizAsync(content, tenantId); if (result.IsSuccess) { var packet = result.Value; diff --git a/src/NexusReader.Web.Client/Services/WasmKnowledgeService.cs b/src/NexusReader.Web.Client/Services/WasmKnowledgeService.cs index ba8f4e1..f502749 100644 --- a/src/NexusReader.Web.Client/Services/WasmKnowledgeService.cs +++ b/src/NexusReader.Web.Client/Services/WasmKnowledgeService.cs @@ -14,22 +14,22 @@ public class WasmKnowledgeService : IKnowledgeService _httpClient = httpClient; } - public async Task> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default) + public async Task> GetKnowledgeAsync(string text, string tenantId, CancellationToken cancellationToken = default) { return await CallKnowledgeApiAsync("/api/knowledge", text, cancellationToken); } - public async Task> GetGraphDataAsync(string text, CancellationToken cancellationToken = default) + public async Task> GetGraphDataAsync(string text, string tenantId, CancellationToken cancellationToken = default) { return await CallKnowledgeApiAsync("/api/knowledge/graph", text, cancellationToken); } - public async Task> GetKnowledgeMapAsync(string text, CancellationToken cancellationToken = default) + public async Task> GetKnowledgeMapAsync(string text, string tenantId, CancellationToken cancellationToken = default) { return await CallKnowledgeApiAsync("/api/knowledge/map", text, cancellationToken); } - public async Task> GetSummaryAndQuizAsync(string text, CancellationToken cancellationToken = default) + public async Task> GetSummaryAndQuizAsync(string text, string tenantId, CancellationToken cancellationToken = default) { return await CallKnowledgeApiAsync("/api/knowledge/summary", text, cancellationToken); } @@ -53,6 +53,25 @@ public class WasmKnowledgeService : IKnowledgeService return Result.Fail(new Error($"Network error: {ex.Message}").CausedBy(ex)); } } + public async Task> VerifyGroundednessAsync(string answer, string context, string tenantId, CancellationToken cancellationToken = default) + { + try + { + var response = await _httpClient.PostAsJsonAsync("/api/knowledge/verify-groundedness", new { answer, context, tenantId }, cancellationToken); + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + return result != null ? Result.Ok(result) : Result.Fail("Failed to deserialize groundedness result."); + } + + var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); + return Result.Fail($"Server error ({response.StatusCode}): {errorBody}"); + } + catch (Exception ex) + { + return Result.Fail(new Error($"Network error: {ex.Message}").CausedBy(ex)); + } + } private async Task> CallKnowledgeApiAsync(string endpoint, string text, CancellationToken cancellationToken) { diff --git a/src/NexusReader.Web.New/Program.cs b/src/NexusReader.Web.New/Program.cs index 710db09..a73d9cc 100644 --- a/src/NexusReader.Web.New/Program.cs +++ b/src/NexusReader.Web.New/Program.cs @@ -59,6 +59,11 @@ builder.Services.AddCascadingAuthenticationState(); builder.Services.AddApplication(); builder.Services.AddInfrastructure(builder.Configuration); +builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies( + NexusReader.Application.DependencyInjection.Assembly, + NexusReader.Infrastructure.DependencyInjection.Assembly +)); + // Authorization Policies builder.Services.AddScoped(); builder.Services.AddAuthorizationBuilder() @@ -114,6 +119,16 @@ builder.Services.Configure(options => var app = builder.Build(); +// Startup Validation +using (var scope = app.Services.CreateScope()) +{ + var marker = scope.ServiceProvider.GetService(); + if (marker == null) + { + throw new InvalidOperationException("CRITICAL: Infrastructure layer was not registered. Ensure AddInfrastructure() is called in Program.cs."); + } +} + // Ensure Database is initialized and seeded using (var scope = app.Services.CreateScope()) { @@ -198,27 +213,40 @@ app.MapGet("/api/epub/{index}", async (int index, IEpubService epubService) => var knowledgeApi = app.MapGroup("/api/knowledge").RequireAuthorization("HasAvailableTokens"); -knowledgeApi.MapPost("/", async (KnowledgeRequest request, IKnowledgeService knowledgeService) => +knowledgeApi.MapPost("/", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => { - var result = await knowledgeService.GetKnowledgeAsync(request.Text); + var tenantId = user.FindFirstValue("TenantId") ?? "global"; + var result = await knowledgeService.GetKnowledgeAsync(request.Text, tenantId); if (result.IsSuccess) return Results.Ok(result.Value); return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); }); -knowledgeApi.MapPost("/graph", async (KnowledgeRequest request, IKnowledgeService knowledgeService) => +knowledgeApi.MapPost("/graph", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => { - var result = await knowledgeService.GetGraphDataAsync(request.Text); + var tenantId = user.FindFirstValue("TenantId") ?? "global"; + var result = await knowledgeService.GetGraphDataAsync(request.Text, tenantId); if (result.IsSuccess) return Results.Ok(result.Value); return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); }); -knowledgeApi.MapPost("/summary", async (KnowledgeRequest request, IKnowledgeService knowledgeService) => +knowledgeApi.MapPost("/summary", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => { - var result = await knowledgeService.GetSummaryAndQuizAsync(request.Text); + var tenantId = user.FindFirstValue("TenantId") ?? "global"; + var result = await knowledgeService.GetSummaryAndQuizAsync(request.Text, tenantId); if (result.IsSuccess) return Results.Ok(result.Value); return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); }); +knowledgeApi.MapPost("/verify-groundedness", async (GroundednessRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => +{ + var tenantId = user.FindFirstValue("TenantId") ?? "global"; + var result = await knowledgeService.VerifyGroundednessAsync(request.Answer, request.Context, tenantId); + if (result.IsSuccess) return Results.Ok(result.Value); + return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); +}); + + + knowledgeApi.MapDelete("/", async (IKnowledgeService knowledgeService) => { var result = await knowledgeService.ClearCacheAsync(); @@ -377,3 +405,4 @@ app.MapRazorComponents() app.Run(); public record KnowledgeRequest(string Text); +public record GroundednessRequest(string Answer, string Context);